crosscue

Rebus Entry (G6) — Implementation Plan

Status: Historical — shipped; this is the plan of record, not edited further. Decision record: ADR-0010. Tracks #8 (G6): Rebus entry — editable multi-letter cells. Companion doc to ARCHITECTURE.md. The feature is live (Rebus key + long-press + Esc, first-letter acceptance, autoshrink rendering); see “Feature: solve” in ARCHITECTURE.md for the as-built summary.

1. Goal

Let a solver type more than one letter into a single grid cell so puzzles with rebus squares (e.g. a “THE” or “EST” cell) can actually be solved from the UI. Stay consistent with existing solve mechanics (focus advance, check/reveal, autosave, sync, completion detection) and stay discoverable for users who have never met a rebus before.

2. Current state (audit)

What already works:

What is missing:

  1. Discoverability. Entry is buried under long-press → submenu.
  2. Rendering. CrosswordGridPainter._paintLetter uses a fixed font-size factor with clamp(10, 32) and a maxWidth of one cell width. Multi-character strings render but clip / look cramped — no autoshrink, no fitting.
  3. Soft-keyboard affordance. The custom CrosswordKeyboard has no way to open the rebus dialog; soft-keyboard-only users (the iOS/ Android default) cannot use the feature at all.
  4. Backspace semantics on a multi-letter cell. Today, one backspace() call clears the entire letter field. Reasonable, but undocumented and worth confirming behavior matches user mental model.
  5. State signals. No visual distinction between a normal cell and a rebus-eligible cell, and no signal that a cell currently holds a rebus answer beyond visually-cramped text.
  6. Tests for keyboard-driven rebus flow, autoshrink rendering, and reset/clear behavior.

3. Design principles

Guiding tradeoffs, in priority order:

  1. Don’t spoil the puzzle. Standard crossword convention (NYT, Crosshare, AcrossLite) is that rebus cells are not visually marked in the grid — finding them is part of the theme. We follow this. No badges, no different border on cells whose `solution.length

    1`.

  2. Preserve the one-keystroke-per-cell mental model. Typing a letter on the soft/physical keyboard must continue to overwrite the focused cell with that single letter. Rebus entry has to be an explicit mode change, not an implicit “type a second letter into the same cell” gesture. (Implicit append would break the overwrite-and-advance behavior every user has internalized.)
  3. One canonical entry point, surfaced everywhere. A single _showRebusDialog UI, reachable from at least: long-press menu (today), a soft-keyboard “Rebus” key, and a physical-keyboard shortcut. All three call the same method, identical normalization.
  4. Stay within existing architecture layers. No new domain models. Add a notifier method only if behavior changes. Visual changes are confined to the painter and keyboard widget.
  5. Graceful fallback for users who never use rebus. Most puzzles have no rebus cells; the soft-keyboard affordance must not steal space or attention in the common case. Either (a) only show when the puzzle contains at least one rebus square, or (b) keep it present but small.

4.1 Soft-keyboard “Rebus” key (the primary surface)

The NYT pattern, as described in their own explainer:

Two takeaways for our design:

  1. The labeled “Rebus” button is the consistent surface across all three NYT platforms — not a glyph like . The users may remember on Android is the layer toggle, not the rebus button.
  2. NYT routes it through a two-tap path (More → Rebus) because their keyboard hosts two layers (letters; numbers/symbols). Our CrosswordKeyboard is single-layer (alphabet only). A two-tap path would be friction without benefit.

Decision: put a single, always-visible key labeled “Rebus” on the bottom row of our soft keyboard. One tap opens the dialog. This is strictly fewer taps than NYT, uses NYT’s actual visible label so NYT-trained users recognize it, and keeps the principle-3.1 “don’t spoil” property (the key is on every puzzle so its presence leaks nothing).

Keyboard layout change. Current bottom row:

⌫ Z X C V B N M ✓

Proposed bottom row (insert a “Rebus” _SpecialKey to the right of so the rebus key is the bottom-right-most element, matching NYT’s bottom-right convention):

⌫ Z X C V B N M ✓ Rebus

The Rebus key is wider than a letter key (matches _SpecialKey’s existing unit * 1.3 width to fit the four-letter label). (check word) stays — it’s a Crosscue-specific affordance NYT doesn’t have. The seven bottom-row letter keys narrow slightly; this is the lowest-density row so the impact is acceptable.

If user testing shows the layout is too crowded, fall back to placing “Rebus” immediately right of :

⌫ Rebus Z X C V B N M ✓

Either way, the key is present on every puzzle.

Wiring. Add VoidCallback onRebus to CrosswordKeyboard (no visibility flag) and route the tap through the same shared dialog helper used by long-press and the physical-keyboard shortcut.

4.2 Long-press menu (kept)

Today’s “Enter rebus” item in the long-press popup stays exactly as-is. It is the discoverable surface for power users and the only surface on desktop where there’s no soft keyboard.

4.3 Physical keyboard shortcut

Use Esc to open the rebus dialog on the current focused cell — matching NYT’s web shortcut exactly. Pressing Esc inside the dialog cancels (also matching NYT). The dual role of Esc is internally consistent: “switch into rebus mode” on the grid, “switch out of rebus mode” in the dialog.

Implement in _onKeyEvent: branch on LogicalKeyboardKey.escape → call the shared showRebusDialogForFocus helper.

4.4 The rebus dialog itself (refinements)

Today’s AlertDialog is functional. Aligning with NYT conventions:

4.5 Rendering — autoshrink, never spill

CrosswordGridPainter._paintLetter needs to fit multi-character text in a single cell without changing cell size. Change:

Edge case: the entry animation (scale: 0.7 + 0.3 * effectValue) multiplies into the autoshrink. Use the autoshrunk font size as the base, then apply the animation scale, so multi-letter cells still animate proportionally without overflowing at peak.

4.6 First-letter acceptance (the forgiving completion rule)

Important finding from the NYT article: NYT accepts either the full rebus string or just its first letter as a valid answer. From the JACK example:

“JACK works for both the Across and Down entries, and the following rebus answers would be accepted: JACK, J (The first letter)”

This is a deliberate accessibility choice: a solver who never discovers rebus mode can still complete the puzzle. The cluing still works (“LUMBERJ” makes no sense, but the grid is accepted as solved).

Recommendation: adopt the same rule. Three reasons:

  1. Intuitiveness. A user who doesn’t know rebus exists is not punished. The “Rebus” key is a power tool for solvers who want to honor the puzzle’s full intent; it’s not a gate that locks completion behind a feature they may not have found.
  2. Consistency with NYT. Users who solve on both platforms see the same completion behavior.
  3. Composes cleanly with our existing code. The change is local to _checkCompletion in solve_notifier.dart and checkCells in grid_progress_mutator.dart. No model changes.

Implementation sketch. Replace strict equality in the two checking sites with a helper:

bool _matchesSolution(String entered, String solution) {
  if (entered.isEmpty) return false;
  final e = entered.toUpperCase();
  final s = solution.toUpperCase();
  if (e == s) return true;
  // Rebus cells: first letter alone also counts.
  if (s.length > 1 && e.length == 1 && e == s[0]) return true;
  return false;
}

Used in _checkCompletion (completion) and in checkCells (the check-letter / check-word actions). Reveal stays as-is — revealing writes the full canonical answer.

Implications:

4.7 Backspace semantics

5. Implementation phases

Phase A — Rendering (no behavior changes)

Goal: existing rebus entries (via long-press) render correctly.

Phase B — Soft keyboard affordance

Note: a hasRebusSquares derived state on SolveState is not needed under the NYT-style always-visible rule. We avoid the derived getter entirely.

Phase C — Notifier refinements + first-letter acceptance

Phase D — Physical keyboard shortcut

Phase E — Documentation & tests

6. File-by-file change list

File Change
lib/features/solve/presentation/widgets/crossword_grid_painter.dart _fitFontSize helper; use in _paintLetter.
lib/features/solve/presentation/widgets/crossword_keyboard.dart New required onRebus; insert “Rebus” _SpecialKey to the right of ; recompute bottom-row width math.
lib/features/solve/presentation/widgets/crossword_grid_input.dart Pre-fill dialog with single-char letters; route 1-char input through inputLetter; cap at 6; add Esc shortcut in _onKeyEvent.
lib/features/solve/presentation/screens/solve_screen.dart Pass onRebus into CrosswordKeyboard; lift _showRebusDialog into a shared showRebusDialogForFocus helper used by all three surfaces (long-press, keyboard Rebus key, Esc).
lib/features/solve/presentation/notifiers/solve_notifier.dart inputRebus delegates on 1-char input; clamps at 6 chars; _checkCompletion uses _matchesSolution to accept first-letter answers.
lib/features/solve/domain/services/grid_progress_mutator.dart checkCells uses the same _matchesSolution helper so check/letter feedback agrees with completion.
test/... See Phase E.
ARCHITECTURE.md One paragraph under “Feature: solve”.

No changes to:

7. Resolved decisions

8. Out of scope