Skip to content

Design: width-based fade truncation via <truncated-text> (plan for review)#460

Merged
KucharczykL merged 11 commits into
mainfrom
claude/truncated-text-fade-design
Jul 21, 2026
Merged

Design: width-based fade truncation via <truncated-text> (plan for review)#460
KucharczykL merged 11 commits into
mainfrom
claude/truncated-text-fade-design

Conversation

@KucharczykL

Copy link
Copy Markdown
Owner

Plan/design PR — no implementation yet. Review the design doc; once approved I'll implement it on a follow-up (or this) branch.

Adds docs/superpowers/specs/2026-07-21-truncated-text-fade-design.md.

Summary

Replace character-count name truncation (truncate_info, fixed 30 chars, reveal) with width-based truncation: a new <truncated-text> custom element clips by the cell/container width, fades the right edge with a mask-image gradient (no ellipsis), detects real overflow client-side via ResizeObserver, and gates the hover (desktop) / tap-button (mobile) reveal to appear only when actually clipped. Full text stays in the DOM, so screen readers read the whole name.

Key decisions captured

  • Self-capping element (max-w-[cap] min-w-0 + inner overflow-hidden) so the auto-layout table column caps + clips — no table-fixed. Gated behind a Step-0 empirical spike (table-auto clipping is the one real CSS risk; fallback is a max-w on the name <th>).
  • Reveal button stays mobile-only ([@media(hover:hover)]:hidden, from Touch-target size audit for small popover triggers (WCAG 2.5.8) #454) and now shows only when overflowing.
  • Shared tooltip engine: the menu/attachMenu engine is click/keyboard-menu-shaped (roving focus, role=menu, click-toggle) and unsuitable for a passive tooltip — confirmed by exploration. Instead, extract pop-over's passive state machine into a reusable tooltip-behavior.ts controller that both pop-over and <truncated-text> ride, reusing the already-shared positionAnchored + bindPopupDismiss.
  • Scope: NameWithIcon (linked + menu), LinkedPurchase single-game, sort-name column. Multi-game games-list stays an always-on info popover (not truncation). Menu items get fade-only + tap-through, resolving the Link-wrapped truncation tooltips still hover-only on touch #455 residual.

Verification plan (for the implementation PR)

Empirical spike → full make check → vitest (overflow state machine + pop-over regression) → Playwright (desktop hover, mobile-emulated overflow button+fade+tap, short-name no-op) → test_html_validity invariant → desktop/mobile/short-name screenshots.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

@KucharczykL

Copy link
Copy Markdown
Owner Author

Thanks — all 7 folded into the doc (rev 2, commit pushed). Point by point:

  1. Width policy. max_width param defaulting to _NAME_MAX_W = "max-w-[24rem]" (single tunable constant) + always min-w-0. Rationale + per-context behavior spelled out (desktop 24rem cap, 390px mobile shrink-to-fit, navbar passes max-w-full bounded by the menu's own max-w-xs). Acceptance criterion: no horizontal document scroll at 390px (documentElement.scrollWidth <= innerWidth), asserted by the spike.
  2. Controller API. attachTooltip({host, trigger, panel, content, arrow, side, tap, isActive}) — passes the content wrapper and arrow elements explicitly (not hard-coded data-pop-over-* selectors), so scroll-capping, tinting, and arrow placement work against each element's own data-*.
  3. Overflow closes an open panel. The ResizeObserver calls controller.close() on the overflowing→not-overflowing transition, not just gating future opens via isActive. Explicit vitest for the long→short transition.
  4. Table fallback plumbing. Corrected: the <th> fallback requires changing TableRow(data)TableRow(data, columns) and threading columns from StyledTable into every row, plus the Column field — not a bare field add. Flagged as an interface change, and only taken if the spike fails.
  5. ARIA identity. Unique panel id + aria-describedby wired client-side (module counter) so duplicate names on a page don't collide (server HTML carries no panel id). role="tooltip" stays server-side.
  6. Menu contract — decided: tap=False = fade + hover/focus tooltip remains (as today's hover span), no tap button, mobile taps navigate (the Link-wrapped truncation tooltips still hover-only on touch #455 tap-through). e2e checks the desktop tooltip positions correctly over the open dropdown and no <button> nests in the menu <a>.
  7. Verification now covers keyboard focus-open + Escape-close, short-name no-op, resize both into and out of overflow (incl. closing an open panel), unique ids for duplicate names, and the spike asserting no mobile horizontal overflow (not just clip scrollWidth > clientWidth).

Spike still ordered before the controller extraction, as you noted. Ready for another look.

@KucharczykL

Copy link
Copy Markdown
Owner Author

Done (rev 3, pushed f146d7c). Tooltip id + aria-describedby now render server-side in the markup, matching the _popover_html contract — the a11y relationship holds before JS initializes. To keep the earlier duplicate-name concern covered, the panel id is randomid(seed=f"{text}:{id_seed}") with a required pk-based id_seed (content-only seed would collide for identical names); one distinct id_seed per truncated cell (the name and sort-name cells share a game.pk, so sort-name uses f"{game.pk}-sort"). JS now only toggles data-overflowing + wires behavior — no id assignment.

The rev-2 commitments you called out are all in the doc: 24rem width policy (_NAME_MAX_W + min-w-0, no mobile h-scroll), explicit attachTooltip({host, trigger, panel, content, arrow, side, tap, isActive}), close-on-shrink via the ResizeObserver, and the TableRow(data, columns) fallback plumbing. Ready for a final look.

@KucharczykL

Copy link
Copy Markdown
Owner Author

Both fixed (rev 4, 2f896ad):

  1. Parent-state selector. Host is now a Tailwind group carrying data-overflowing; the clip mask and reveal button use group-data-[overflowing]:… (not a bare data-[overflowing]:, which would match the child's own attribute). So the fade and button actually react to the host state — no more "detection works but nothing changes visually."
  2. Menu = hover-only + tap-through. Corrected the wording: the menu row's <a role=menuitem> is an ancestor of <truncated-text>, so its focus can't propagate down into the host — no focus-triggered tooltip there (same limitation pop-over documents). e2e for the menu asserts the hover tooltip + absence of a nested <button>, not a focus tooltip. Focus tooltips remain for the linked table names, where the <a> is a descendant of the host and focus bubbles up.

@KucharczykL

Copy link
Copy Markdown
Owner Author

Fixed (rev 5, 080b508):

Overflow hysteresis. The reveal button is now absolute positioned over the clip's right-edge fade area (absolute inset-y-0 right-0 on the relative host), out of normal flow — so showing/hiding it doesn't change the clip's width, and the measurement basis (clip.scrollWidth vs clip.clientWidth) is independent of the button. No stuck-open feedback loop. The button overlays only the already-faded last ~1.5rem (hides no readable text) and stays 24px (WCAG). Still a sibling of the <a>, just positioned — invariant holds.

Added the button-width threshold test: overflow → button appears → grow the viewport just past where the text fits the no-button layout → data-overflowing clears exactly there (not a button-width later), fade+button disappear.

Doc cleanup: the Decisions scope bullet now says menu items get a hover-only tooltip, consistent with the Menu contract section.

@KucharczykL

Copy link
Copy Markdown
Owner Author

I would request changes on this plan before implementation. The overall architecture is sound, but the reviewed head (080b508) still has four blocking design gaps.

Findings

  1. [P1] The Step-0 mobile assertion can pass while the table is still horizontally scrollable.

    The plan checks document overflow, but the table already sits inside its own overflow-x-auto wrapper. That wrapper prevents table overflow from increasing documentElement.scrollWidth.

    A minimal Chromium probe using the proposed core rules produced:

    • Document: clientWidth=390, scrollWidth=390 — proposed assertion passes.
    • Table wrapper: clientWidth=390, scrollWidth=580 — table still scrolls.
    • Host: 384px rather than shrinking into the available first-column space.
    • Clip: clientWidth=384, scrollWidth=760.

    Adding only max-width:384px to the <th> still left the wrapper at 532px. The fallback therefore needs more than “a max-w on the name <th>”; both the cell’s table sizing and the child’s used width must be constrained.

    Revise the spike to assert:

    • The actual scroll wrapper has scrollWidth <= clientWidth.
    • The mobile clip becomes narrower than the 24rem cap.
    • The actions column remains visible and non-overlapping.
    • At least one intermediate width such as 640/768px, where all columns reappear.

    The fallback must specify and empirically verify the complete cell/child constraint combination.

  2. [P1] The proposed tooltip IDs are invalid for normal names and are not page-unique.

    The plan uses randomid(seed=f"{text}:{id_seed}"). But randomid() appends only enough hash characters to reach length=10; when the seed is longer than ten characters, it returns the seed unchanged.

    A normal result would therefore resemble A Very Long Game:1, including spaces. Since aria-describedby is a whitespace-separated ID reference list, that relationship will not resolve to the panel.

    The IDs are also guaranteed to collide in existing layouts:

    • The same recent sessions are rendered into both desktop and mobile navbar instances.
    • The same session/game can also appear in the page table.
    • A session list can contain multiple rows for the same game.

    Use a safe hash, for example randomid(content=f"truncated-text:{instance_key}:{text}"), with a DOM-instance key that includes context and slot—not merely a model PK. Navbar keys must include navbar-log-mobile versus navbar-log-desktop.

    Add a full-page test that asserts:

    • Every id is globally unique.
    • Every aria-describedby token resolves to exactly one element.
    • Duplicate names and the two navbar copies are present in the fixture.

    The current HTML validity test checks interactive nesting only, so uniqueness needs new parser logic or a separate invariant.

  3. [P1] The TruncatedText API cannot preserve the platform/emulation icons or existing link hit area.

    The proposed signature accepts only text, link, and behavior options. However:

    Following the call-site examples literally either drops the icons or moves them outside the link, shrinking the clickable area.

    Define an explicit leading/visible-content slot and specify the final DOM nesting. The link should continue to contain the icons and clipped text, while the reveal button remains its sibling. Also ensure the link’s focus outline is not placed inside an overflow-hidden element and clipped.

  4. [P1 accessibility] Unlinked truncated values lose their keyboard reveal path on desktop.

    The sort-name column is unlinked. Under the plan, its visible trigger is a plain <span>, while the only <button> is hidden under hover:hover. Nothing in that host can receive keyboard focus.

    This contradicts the general desktop hover/focus promise. The menu is intentionally hover-only, but sort-name is not a menu item and currently uses a focusable PopoverTruncated button.

    Specify one of:

    • An overflow-only tabindex="0" managed by the custom element.
    • A focusable visible-text trigger for unlinked tap=True instances.
    • Another explicit keyboard affordance.

    Add an e2e case that focuses an overflowing sort-name and opens/closes it with focus/Escape, plus a check that short names do not gain useless tab stops.

  5. [P2] Multi-game purchases have an unresolved truncation path.

    Current LinkedPurchase calls truncate_info() before deciding whether the purchase is single- or multi-game. The plan migrates only the single-game branch while retaining the multi-game information popover.

    That leaves two possibilities, neither documented:

    • Keep character-count truncation for long named bundles, contradicting the stated replacement.
    • Remove it, allowing a long bundle name to expand the table without width clipping.

    Define how the existing always-on games-list popover’s preface gets width-based clipping without creating a second competing tooltip/button. Add a long named multi-game purchase test.

  6. [P2] The fallback and regression-test contracts need completion.

    If TableRow(data, columns) becomes required, there is a production call outside StyledTable, plus direct test calls. The plan currently discusses only threading columns through StyledTable. Either keep the new argument optional or update fragment rendering through a shared purchase-column definition.

    The extracted controller also declares a required content element, while the existing “unchanged” pop-over fixture has no data-pop-over-content wrapper. Update that fixture or make the controller contract optional. Existing e2e selectors in test_touch_targets_e2e.py and test_purchase_e2e.py must migrate as well.

What is solid

The shared passive tooltip controller is a better fit than the menu engine. Closing on overflow→fit and positioning the reveal button out of flow correctly address state leakage and hysteresis. I also verified that the proposed group-data-[overflowing] mask selector compiles and that the desktop hover-media rule wins over the overflow visibility rule.

After the four P1 items are resolved and the multi-game behavior is explicit, the plan should be ready to implement. Minor cleanup: #455 is already closed, and the lockfile currently resolves Tailwind 4.3.0 rather than the 4.1.18 named in the document.

@KucharczykL

Copy link
Copy Markdown
Owner Author

Thanks — this was a genuinely deep review; all four P1s and both P2s are resolved in rev 6 (f1eeab4). I verified each claim against the code first (randomid, the overflow-x-auto wrapper, TableRow(data) + the purchase.py:466 call, the pop-over fixture).

P1 #1 — spike measures the wrong element. Corrected: the acceptance is now the table's own overflow-x-auto scroll wrapper (primitives.py:1757) having scrollWidth <= clientWidth, not documentElement (which the wrapper hides — matches your 390 vs 580 probe). The self-cap is documented as insufficient, and the fallback must constrain both cell table-sizing and the child's used width (the exact combo is the spike's deliverable). Spike now asserts at 390/640/768 incl. actions-column visibility and middle-column reappearance.

P1 #2 — invalid, non-unique ids. Fixed: randomid(content=f"truncated-text:{instance_key}:{text}") — the seed= form returns the raw seed verbatim for names ≥10 chars (spaces → invalid IDREF), the content= form hashes to 10 hex. instance_key is a context+slot key, not a bare pk (navbar renders each session in both desktop layout.py:376 and mobile :537; games repeat in table+navbar). Added a full-page invariant: every id unique, every aria-describedby token resolves to exactly one element, fixture includes duplicate names + both navbar copies.

P1 #3 — icons/hit area. Added a leading slot; explicit nesting: <a>[leading-icons, <span clip>text</span>]</a> + sibling button, with overflow-hidden on the inner clip only so the link's focus ring isn't clipped and icons stay inside the link.

P1 #4 — unlinked keyboard. The element manages an overflow-only tabindex=0 on the clip span for unlinked tap=True (sort-name): focusable+revealable when overflowing, no tab stop when it fits. e2e added.

P2 #5 — multi-game. Folded into TruncatedText with reveal="always" + tooltip_content=games_list: the bundle name width-clips+fades like any name and the games-list reveal is always available — one tooltip/button, no leftover char-truncation. Long-bundle test added.

P2 #6 — contracts. TableRow(data, columns=None) stays optional (the purchase.py:466 refund fragment + test calls keep working); controller content/arrow are optional (the pop-over fixture has no content wrapper — stays as-is); name-reveal e2e selectors migrate pop-overtruncated-text.

Minor: #455 left closed (no action); Tailwind version reference corrected to lockfile 4.3.0.

@KucharczykL

Copy link
Copy Markdown
Owner Author

Strong review — all 8 folded into rev 7 (5e13c87). Findings 1 and 2 were the important ones.

1 (blocker — the big simplification). You're right: overflow:hidden keeps the full text in the a11y tree, so the clip already exposes the whole name to a screen reader; a default tooltip whose content is that same text adds nothing via aria-describedby. So the default case now renders the panel aria-hidden="true" with no id, no aria-describedby, no instance_key — deleting the randomid/id-uniqueness apparatus revs 3–6 built. The ARIA relationship survives only where tooltip_content differs (multi-game games-list), which is now the sole call site passing instance_key. Big lever; thank you.

2 (blocker — shipping bug). Correct and subtle: the font-condensed swap changes content width but not the column-constrained border-box, so the ResizeObserver never re-fires and data-overflowing goes stale. Added a shared document.fonts.ready (+ loadingdone) re-measure of all mounted instances, with vitest (mocked) + e2e (overflowing after fonts settle, no intervening resize).

3. DEBUG render-time duplicate-id assertion in the Page() walk (not fixture-only); id-uniqueness test narrows to multi-game rows.
4. 1px epsilon: scrollWidth - clientWidth > 1; spike/vitest pin the exactly-fits and +1/+2 boundaries.
5. Icons now always outside the clip (one structure, linked + unlinked) — identical mask/focus math.
6. reveal_label default is generic "Show full text".
7. Panel reuses data-pop-over-{panel,content,arrow}; only data-truncated-{clip,reveal} are new — halves selector migration.
8. Corrected the Context claim (today's describedby panel already carries the full name for AT; the new element's story is "needs almost no ARIA", not "fixes SR data loss").

Architecture (shared passive controller, self-capping element, spike-first) unchanged.

KucharczykL and others added 6 commits July 21, 2026 13:01
Per review: generate the panel id and aria-describedby in the server markup
(matching the _popover_html contract) so the a11y relationship exists before
JS init, instead of client-side. Keep ids unique for duplicate names via a
required pk-based id_seed (content-only seed would collide); one distinct
id_seed per truncated cell (the name and sort-name cells share a row).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Per review:
- host is a Tailwind `group`; overflow-reactive children (clip mask, reveal
  button) use `group-data-[overflowing]:` — a bare `data-[overflowing]:` on a
  child matches the child's own attribute, not the host's, so fade/button
  wouldn't react while detection still "worked".
- menu (caller-wrapped `<a>` ancestor) corrected to hover-only + tap-through:
  focus on the ancestor `<a>` can't propagate down into the host, so no
  focus-triggered tooltip there (same as pop-over). Focus tooltips apply to
  linked table names where the `<a>` is a descendant of the host.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Per review: an inline-flex sibling reveal button consumes ~24px when shown,
shrinking the clip so data-overflowing stays true even after the viewport
grows past where the text fits without the button — a stuck-open feedback
loop. Position the button absolutely over the fade area (out of flow) so the
clip's measured width is independent of the button. Add the button-width
growth-threshold test. Make the Decisions/menu wording consistent (hover-only).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
- P1#1 spike measures the table's own overflow-x-auto scroll wrapper (not
  documentElement, which the wrapper hides); fallback constrains both cell
  table-sizing and child used width; assert at 390/640/768.
- P1#2 ids use randomid(content=...) (seed= returns raw seed for long names)
  with a context+slot instance_key (navbar renders each session twice) +
  full-page id-uniqueness/aria-describedby-resolves invariant.
- P1#3 add a `leading` slot: icons stay inside the link, focus ring outside
  the overflow-hidden clip; explicit linked/unlinked DOM nesting.
- P1#4 unlinked tap=True (sort-name) gets an overflow-only, element-managed
  tabindex so desktop keyboard can reveal; short names gain no tab stop.
- P2#5 multi-game bundles fold into TruncatedText (reveal="always" +
  tooltip_content=games-list); no competing popover, no char-truncation left.
- P2#6 TableRow(data, columns=None) optional (production call outside
  StyledTable); controller content/arrow optional (pop-over fixture lacks
  content); e2e name-reveal selectors migrate to truncated-text.
- Minor: #455 already closed; Tailwind lockfile is 4.3.0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Deep review (2 blockers + 2 major + nits):
- finding 1 (big simplification): the clipped full text is already in the
  a11y tree, so the default tooltip's aria-describedby duplicates the name.
  Drop panel id + instance_key + the randomid/id-uniqueness machinery for the
  common case; keep the ARIA relationship only for multi-game (differing
  content). Deletes most of what revs 3-6 built.
- finding 2 (shipping bug): the font-condensed webfont swap doesn't change the
  clip's border-box, so the ResizeObserver never re-fires and data-overflowing
  goes stale. Re-measure all instances on document.fonts.ready.
- finding 3: DEBUG render-time duplicate-id assertion (not fixture-only).
- finding 4: 1px epsilon on scrollWidth>clientWidth (subpixel false-overflow).
- finding 5: icons always outside the clip (one structure, linked + unlinked).
- finding 6: generic reveal_label default ("Show full text").
- finding 7: reuse data-pop-over-{panel,content,arrow} for the panel anatomy.
- finding 8: corrected the "SR loses characters" claim.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Remove the Rev 2-7 changelog block, all review/finding/P1/P2 tags, and
draft-relative narration; recast load-bearing rationale as present-tense
design statements. No technical decision changed. Also reconcile the panel
data-attribute wording (pop-over anatomy shared) in the controller section.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@KucharczykL
KucharczykL force-pushed the claude/truncated-text-fade-design branch from 97bb297 to 23679dc Compare July 21, 2026 11:46
@KucharczykL
KucharczykL merged commit 630d362 into main Jul 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant