Menu drawer: category names must never break mid-word
📝 TLDR
A signed-in user who opens the header MENU drawer sees category names broken
mid-word in the “Wszystkie przepisy według kategorii” grid — “Ciastecz·ka”,
“Śniadani·a”, “Pieczyw·o”, “Przekąs·ki”. The 2-column tiles carry the app-wide
wrap-anywhere utility and the longest names do not fit at phone width, so the
browser splits them at an arbitrary character. Proposed: keep the compact 2-column
tiles but size them to fit the longest single word, drop to a single column on the
narrowest phones (< 360 px), break words only as a last resort, and bind Polish
single-letter words (“i”, “w”, “z”…) to the next word so they are never left
dangling at the end of a line (orphan / “sierotka” rule).
📝 Problem Statement
The drawer category grid (src/app/components/app-menu.tsx:229–253) is a
grid-cols-2 of 12 tiles — an icon + an 18 px bold label — each carrying
wrap-anywhere (overflow-wrap: anywhere), the pattern used across the app to stop
long user text forcing horizontal page scroll at 320 px (documented in
src/app/globals.css:80, guarded by pnpm test:reflow).
For a 2-column tile the text width at 320 px is ≈ 70–75 px, narrower than the longest
single-word labels, so overflow-wrap: anywhere breaks those words at an arbitrary
character. This reads as a typo and hurts scannability — against the project’s “wysoka
czytelność” rule for an audience that includes older users (CLAUDE.md, E01 design rules).
Labels (src/lib/recipe-categories.ts:52–65): longest single words — “Ciasteczka” (10),
“Śniadania”/“Przekąski” (9), “Pieczywo” (8); one multi-word — “Syropy i napary”.
Two defects, one grid:
- Mid-word break of single words that do not fit a 2-column tile.
- Polish orphan — “Syropy i napary” currently wraps as “Syropy i” / “napary”, leaving the one-letter “i” stranded at the end of a line. Polish typography requires single-letter words to be carried to the next line with the following word.
The S09 menu-drawer spec (.ai/specs/2026-10-17-menu-drawer.md:70) recorded the 2-column,
icon-left, 18 px grid, and its edge cases (line 82) accepted label wrap only at large
text scale. Mid-word breaking at the default size is an unintended regression.
📝 Proposed Solution
Keep the compact two-column tiles (owner’s choice: variant C), but make them fit and read correctly:
- Column count by width.
grid-cols-1 min-[360px]:grid-cols-2. Below 360 px the drawer shows one full-width column (ample room, no break); from 360 px up, the compact two columns. This reuses the exact breakpointCategoryGridalready uses on the home page for the same reason (src/app/components/home/category-grid.tsx:5–6). - Sizing that fits the longest word. 16 px bold label (keeps the ≥ 16 px readability
floor), 20 px icon, tighter padding (
px-2.5), so “Ciasteczka” sits on one line from 360 px up with slack. - Break only as a last resort. Replace
wrap-anywherewithbreak-words(overflow-wrap: break-word) on the label span, keepingmin-w-0. A word breaks only if it genuinely cannot fit (extreme zoom), so the reflow guard still holds but default rendering never breaks mid-word. - Orphan binding (sierotka rule). A small render-time helper inserts a non-breaking
space (U+00A0) after every single-letter Polish word, so “Syropy i napary” renders as
“Syropy i napary” and can only break as “Syropy” / “i napary”. Applied at render,
not in the data, so the enum-matching path (
toRecipeCategory→foldCategory) and SEO/markdown consumers of the raw label are untouched.
Alternatives considered. (A) Single full-width column — safest, but the owner chose the
more compact 2-column look. (B) Icon-on-top stacked tile — good, but a bigger visual change
than needed. Baking the NBSP into the label constant — rejected: the label feeds
toRecipeCategory’s matcher and the SEO title/markdown builders, where an injected U+00A0
would break LLM-category matching (foldCategory does not fold NBSP → space) and alter
plain-text output.
📝 Architecture
- Changed:
src/app/components/app-menu.tsx— the category grid’s<ul>column classes and the tile<Link>class list; the label span gains the orphan helper. - New:
src/lib/polish-typography.ts—bindOrphans(text); a pure string helper, no dependencies, reusable by any Polish-copy surface later. - Unaffected:
recipe-categories.tsdata,categoryPath, routing, the “Moje” rows, the footer, panel sizing, and every otherwrap-anywhereusage in the app. - The home-page
CategoryGridhas the identicalwrap-anywhere+ “Syropy i napary” issue; it is out of scope here (owner: drawer only) and handed to a follow-up.
📝 API / Helper contract
// Binds Polish single-letter words (a, i, o, u, w, z — any case) to the next word with// a non-breaking space, so they are never orphaned at a line end. Idempotent.export function bindOrphans(text: string): string// "Syropy i napary" -> "Syropy i napary"// "Obiady" -> "Obiady" (no-op)// "A to i owo" -> "A to i owo"Regex: match a single-letter word (start-of-string or after whitespace) followed by
whitespace, and replace that trailing whitespace with U+00A0. Letter set
[aiouwzAIOUWZ] (includes the Polish one-letter prepositions/conjunctions a, i, o, u, w, z).
📝 UI/UX
- From 360 px: two columns, 16 px bold names, 20 px
action-texticon,min-h-[56px]tiles (touch target ≥ 48 px kept),#fff7edfill with the 1.5 px#fed7aaframe unchanged; active tile keepsborder-action+text-action-text. - Below 360 px: one full-width column, same tile chrome.
- “Syropy i napary” is the only label that wraps; it wraps at the space to two lines (“Syropy” / “i napary”), never mid-word, never on the “i”.
- Focus ring,
aria-current="page"active marking, andno-underline!are unchanged.
Prototype: none (reference is the inline mockup reviewed in-session — variant C).
📝 Edge Cases & Failure Scenarios
- Extreme text zoom (S01 A++ / 200 %). A single word may exceed even a one-column
tile;
break-wordsthen breaks it as a last resort instead of forcing horizontal page scroll — accepted (matches S09’s large-scale allowance), and never happens at normal sizes. - A future long single-word category. Same mechanism: fits at 16 px from 360 px up, or
the < 360 px single column catches it;
break-wordsis the final safety net. - Screen readers. U+00A0 is announced as a normal space; the accessible name of each tile is unchanged.
- LLM category matching. Untouched — the NBSP lives only in the rendered label, not in
RECIPE_CATEGORIES[].label, sotoRecipeCategory("Syropy i napary")still resolves.
📝 Risks & Impact Review
- Readability floor. 16 px bold is at the project’s ≥ 16 px helper floor and below the 18 px the S09 tile used. This is the owner’s compact-layout trade-off; the single-column fallback below 360 px keeps the smallest screens legible. If a 320–360 px device must stay 2-column in future, 15 px is the only lever — flagged, not taken.
- Supersedes S09. The 2-column/18 px/icon-left decision in
.ai/specs/2026-10-17-menu-drawer.md:70is refined here (16 px, 1-col < 360 px, nowrap-anywhere). Owner-approved by the variant-C choice. - Divergence from home grid. Until the follow-up lands, the drawer and the home
CategoryGridhandle “Syropy i napary” differently. Low impact; follow-up filed. - Reversibility. Pure CSS-class + one small pure helper; revert is a one-commit diff.
📝 Decisions in play
No product-brief.md in this area. The only recorded decision touched is the S09 tile
layout (above), explicitly superseded with owner sign-off (variant C).
📋 Phasing
One shippable phase. The home-grid parity fix and any app-wide orphan sweep are a separate follow-up issue, not a phase of this spec.
📋 Implementation Plan
Phase 1 — Non-breaking, orphan-safe drawer category tiles
- Add
src/lib/polish-typography.ts+ unit test. ImplementbindOrphans(text)per the contract. Testsrc/lib/polish-typography.test.ts: “Syropy i napary” → “Syropy i napary”; no-op for “Obiady”; multiple orphans (“A to i owo”); idempotent (running twice adds no second NBSP). App unaffected (helper not yet wired). Gate green. - Rework the drawer grid + tile in
app-menu.tsx, wiring the helper in the same edit.<ul>:grid grid-cols-2 gap-2 px-5→grid grid-cols-1 min-[360px]:grid-cols-2 gap-2 px-5.- Tile class list:
gap-2.5→gap-2,px-3.5→px-2.5,text-[18px]→text-[16px], removewrap-anywhere, addbreak-words; iconh-6 w-6→h-5 w-5. - Label span:
{label}→{bindOrphans(label)}(import added in this same Edit, per the import+first-usage rule). - Extend the drawer unit test: tile class list no longer contains
wrap-anywhereand does containbreak-words; the rendered “Syropy i napary” label contains U+00A0; the<ul>carriesgrid-cols-1andmin-[360px]:grid-cols-2. - Run
pnpm test:reflow(320 px — no horizontal scroll, single column) and capture a 320 px + a 360 px drawer screenshot confirming no mid-word break and “i” carried over.
Validation gate: pnpm typecheck, pnpm lint, pnpm test, pnpm test:reflow.
📋 Out of scope (follow-up issue)
- Apply the same no-
wrap-anywhere+bindOrphanstreatment to the home-pageCategoryGrid(src/app/components/home/category-grid.tsx:56) so the two grids agree. - Consider an app-wide orphan sweep for Polish headings/titles where the rule should hold (CLAUDE.md “wysoka czytelność”). Evaluate separately; not every surface wants NBSP.