From b180ea6660b780e5ef9509b8a3c3dd669e0d0964 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 20 Apr 2026 22:52:40 +0700 Subject: [PATCH] =?UTF-8?q?fix(wordle):=20drop=20column=20alignment=20?= =?UTF-8?q?=E2=80=94=20use=20NYT=20share=20format=20(word=20above=20colors?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-client column alignment between the marker row and a letter row is unreliable in Telegram: -
 monospace doesn't enforce equal width for emoji
- fullwidth Latin (U+FF21..FF3A) falls back to base Latin on mobile fonts
- squared-letter emoji (U+1F130/1F170..) render at different intrinsic
  widths than color-square emoji (U+1F7E8/1F7E9/2B1C) on many clients

Instead, render each guess as the word on one line followed by the
colored marker row โ€” the standard NYT Wordle share format:

  CRANE
  ๐ŸŸฉ๐ŸŸจโฌœ๐ŸŸฉ๐ŸŸฉ

The association between a letter and its color is visually unambiguous
without depending on character-column alignment. Also drops the HTML
parse_mode requirement โ€” replies are plain text again.
---
 src/modules/wordle/handlers.js | 19 ++++---------
 src/modules/wordle/render.js   | 51 +++++++++++-----------------------
 2 files changed, 21 insertions(+), 49 deletions(-)

diff --git a/src/modules/wordle/handlers.js b/src/modules/wordle/handlers.js
index 1455b92..aa6d6cf 100644
--- a/src/modules/wordle/handlers.js
+++ b/src/modules/wordle/handlers.js
@@ -24,10 +24,6 @@ import wordsData from "./words-data.js";
 const words = wordsData;
 const wordSet = makeWordSet(words);
 
-// All replies use HTML parse mode so renderGuess/renderBoard 
 blocks
-// render as a monospace code box; emoji markers then align with letter columns.
-const HTML = { parse_mode: "HTML" };
-
 /**
  * @param {import("grammy").Context} ctx
  * @returns {number|null}
@@ -90,8 +86,8 @@ export async function handleWordle(ctx, db) {
       ? `๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}. /wordle_new for another.`
       : game.giveup
         ? `๐Ÿณ๏ธ Gave up. Answer was ${game.target.toUpperCase()}. /wordle_new for another.`
-        : `Guess ${game.guesses.length}/${MAX_GUESSES}. Use /wordle <word>.`;
-    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, HTML);
+        : `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/wordle \`.`;
+    return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
   }
 
   if (isFinished(game)) {
@@ -109,22 +105,20 @@ export async function handleWordle(ctx, db) {
   if (won) game.solved = true;
   await saveGame(db, subject, game);
 
-  const reply = renderGuess(results);
+  const reply = renderGuess(validated.word, results);
   if (won) {
     const s = await recordResult(db, subject, true);
     return ctx.reply(
       `${reply}\n\n๐ŸŽ‰ Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /wordle_new for another.`,
-      HTML,
     );
   }
   if (game.guesses.length >= MAX_GUESSES) {
     await recordResult(db, subject, false);
     return ctx.reply(
       `${reply}\n\nโŒ Out of guesses. Answer was ${game.target.toUpperCase()}. /wordle_new to retry.`,
-      HTML,
     );
   }
-  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, HTML);
+  return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
 }
 
 /**
@@ -143,10 +137,7 @@ export async function handleNew(ctx, db) {
   }
 
   await startFreshGame(db, subject);
-  return ctx.reply(
-    `${prelude}๐Ÿ†• New round started. Use /wordle <word> to guess.`,
-    HTML,
-  );
+  return ctx.reply(`${prelude}๐Ÿ†• New round started. Use \`/wordle \` to guess.`);
 }
 
 /**
diff --git a/src/modules/wordle/render.js b/src/modules/wordle/render.js
index a35b1e6..2ff9d77 100644
--- a/src/modules/wordle/render.js
+++ b/src/modules/wordle/render.js
@@ -1,57 +1,38 @@
 /**
- * @file Render wordle comparison results as a Telegram monospace grid.
+ * @file Render wordle comparison results for Telegram.
  *
- * Each guess renders as two lines โ€” colored markers over the upper-case
- * letters โ€” wrapped in an HTML 
 block so Telegram renders it in
- * monospace and the emoji column widths line up with the letter columns.
+ * Uses the NYT Wordle share format โ€” guess word on one line, colored marker
+ * row below โ€” so there's no cross-client column-alignment dependency:
+ *
+ *   CRANE
+ *   ๐ŸŸฉ๐ŸŸจโฌœ๐ŸŸฉ๐ŸŸฉ
  *
  *   ๐ŸŸฉ correct ยท ๐ŸŸจ partial ยท โฌœ wrong
  *
- * Output strings are intended to be sent with `parse_mode: "HTML"`. All
- * game content is validated `[a-z]` so no HTML escaping is needed inside
- * the grid; only callers embedding user-controlled text around the grid
- * need to escape it.
+ * Output is plain text; no HTML parse mode required.
  */
 
 const MARKER = { correct: "๐ŸŸฉ", partial: "๐ŸŸจ", wrong: "โฌœ" };
 
-// Alignment under Telegram 
: the color markers are emoji; to guarantee
-// the letter row is the same width, render letters as emoji too. Unicode
-// block "Enclosed Alphanumeric Supplement" has emoji-class capitals at
-// U+1F170..FF189 (๐Ÿ…ฐ๐Ÿ…ฑ๐Ÿ…ฒ..๐Ÿ†‰ โ€” "Negative Squared Latin Capital Letter").
-// Because both rows draw from the emoji font, column widths match 1-to-1
-// on every client, regardless of the monospace font shipped with it.
-const EMOJI_A = 0x1f170;
-const ASCII_A = 0x41;
-
-function toEmojiLetter(ch) {
-  const code = ch.toUpperCase().charCodeAt(0);
-  if (code >= ASCII_A && code <= 0x5a) {
-    return String.fromCodePoint(EMOJI_A + (code - ASCII_A));
-  }
-  return ch;
-}
-
-function rowPair(results) {
+function rowPair({ word, results }) {
   const markers = results.map((r) => MARKER[r.result] ?? "โฌœ").join("");
-  const letters = results.map((r) => toEmojiLetter(r.letter)).join("");
-  return `${markers}\n${letters}`;
+  return `${word.toUpperCase()}\n${markers}`;
 }
 
 /**
- * Render a single guess row as an HTML 
 block.
+ * Render a single guess (word over colors).
+ * @param {string} word โ€” the submitted guess (lowercase a-z)
  * @param {ReturnType} results
  */
-export function renderGuess(results) {
-  return `
${rowPair(results)}
`; +export function renderGuess(word, results) { + return rowPair({ word, results }); } /** - * Render the full board (all prior guesses, blank-line separated) as a - * single HTML
 block, so all rows share one monospace code box.
+ * Render the full board (all prior guesses, blank-line separated).
  * @param {Array<{word:string, results: any[]}>} guesses
  */
 export function renderBoard(guesses) {
-  if (guesses.length === 0) return "No guesses yet. Reply with /wordle <word>.";
-  return `
${guesses.map((g) => rowPair(g.results)).join("\n\n")}
`; + if (guesses.length === 0) return "No guesses yet. Reply with `/wordle `."; + return guesses.map((g) => rowPair(g)).join("\n\n"); }