Show the gesture, and offer the full text when the card clips

Two faults behind one report. The card clipped long descriptions, and it
never rendered the how field at all - so for SHIFT + REMAIN, where the
instruction IS a three-second hold, the most important part was missing
rather than merely shortened.

Measured rather than assumed: that entry is Live mode, how is "Hold
SHIFT, hold REMAIN 3 s", desc is 112 characters, and the pair renders
43px tall into a 29px two-line clamp. A full line was being lost.

The gesture now leads the card text in the accent colour, and a MORE
button opens a sheet with the combo, the gesture, the full description,
the modes it applies in and the manual page. Whether to offer MORE is
decided by measuring scrollHeight against clientHeight, not by guessing
a character count, so it appears exactly when text is really cut. 39 of
159 entries qualify: 24 carry a gesture, 17 have a description over 110
characters.

The card stays 84px because the panel needs that space, so a sheet is
the right place for the overflow, as in the reference.

141 tests, typecheck clean, installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:10:41 +03:00
parent 6889167fcf
commit 6ddf6f9a77
4 changed files with 119 additions and 8 deletions

View File

@@ -112,3 +112,29 @@
.sheetLabel:first-child { .sheetLabel:first-child {
margin-top: 0; margin-top: 0;
} }
/* The detail sheet: what the 84px card cannot hold. */
.detailCombo {
margin: 0;
font-family: var(--mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--accent);
}
/* The gesture is the instruction for the 24 entries that have one. */
.detailHow {
margin: 0;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.detailBody {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: var(--muted);
text-wrap: pretty;
}

View File

@@ -11,7 +11,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "r
import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill"; import { DECKS, boxDistribution, deckPool, knownCount, pickCard } from "./domain/drill";
import type { DeckId, Direction, InputMode } from "./domain/drill"; import type { DeckId, Direction, InputMode } from "./domain/drill";
import { ENTRIES, layerKeyOf } from "./domain/entries"; import { ENTRIES, layerKeyOf } from "./domain/entries";
import { controlName } from "./domain/format"; import { comboText, contextNames, controlName } from "./domain/format";
import { CTX, CTXHINT, NOLATCH } from "./domain/labels"; import { CTX, CTXHINT, NOLATCH } from "./domain/labels";
import { import {
entriesByTarget, entriesByTarget,
@@ -35,7 +35,7 @@ import styles from "./App.module.css";
const TABS = ["Explore", "Drill", "Find", "Rules"] as const; const TABS = ["Explore", "Drill", "Find", "Rules"] as const;
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
type OpenSheet = null | "mode" | "layer" | "settings"; type OpenSheet = null | "mode" | "layer" | "settings" | "detail";
/** Longer than this and the press was a hold to peek, not a tap to lock. */ /** Longer than this and the press was a hold to peek, not a tap to lock. */
const HOLD_MS = 350; const HOLD_MS = 350;
@@ -322,7 +322,13 @@ export function App() {
onPickOption={(name) => dispatch({ type: "pickOption", name })} onPickOption={(name) => dispatch({ type: "pickOption", name })}
/> />
) : ( ) : (
<InfoCard entry={selected} ctx={ctx} hint={CTXHINT[ctx]} count={onLayer.length} /> <InfoCard
entry={selected}
ctx={ctx}
hint={CTXHINT[ctx]}
count={onLayer.length}
onMore={() => setSheet("detail")}
/>
)} )}
<nav className={styles.nav}> <nav className={styles.nav}>
@@ -337,6 +343,36 @@ export function App() {
))} ))}
</nav> </nav>
<Sheet
open={sheet === "detail" && selected !== null}
title={selected ? selected.name : ""}
onClose={() => setSheet(null)}
>
{selected ? (
<>
<p className={styles.sheetLabel}>Combo</p>
<p className={styles.detailCombo}>{comboText(selected)}</p>
{selected.how ? (
<>
<p className={styles.sheetLabel}>Gesture</p>
<p className={styles.detailHow}>{selected.how}</p>
</>
) : null}
<p className={styles.sheetLabel}>What it does</p>
<p className={styles.detailBody}>
{selected.desc || "No description in the reference."}
</p>
<p className={styles.sheetLabel}>Where</p>
<p className={styles.detailBody}>
{contextNames(selected.ctx).join(", ")} &middot; manual p.{selected.page}
</p>
</>
) : null}
</Sheet>
<Sheet open={sheet === "mode"} title="Where you are" onClose={() => setSheet(null)}> <Sheet open={sheet === "mode"} title="Where you are" onClose={() => setSheet(null)}>
{(Object.keys(CTX) as ContextCode[]).map((code) => ( {(Object.keys(CTX) as ContextCode[]).map((code) => (
<SheetOption <SheetOption

View File

@@ -35,6 +35,30 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.metaRight {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
.more {
font-family: var(--font);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--ink);
background: none;
border: var(--outline);
padding: 2px 6px;
}
/* The gesture, when the table gives one: read this before the description. */
.how {
color: var(--accent);
font-weight: 700;
}
.combo { .combo {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,5 +1,6 @@
/** The card under the panel: the layer's hint, or the selected combo. */ /** The card under the panel: the layer's hint, or the selected combo. */
import { comboParts, contextNames } from "../domain/format"; import { useEffect, useRef, useState } from "react";
import { comboParts } from "../domain/format";
import type { ContextCode, Entry } from "../domain/types"; import type { ContextCode, Entry } from "../domain/types";
import styles from "./InfoCard.module.css"; import styles from "./InfoCard.module.css";
@@ -8,9 +9,21 @@ export interface InfoCardProps {
readonly ctx: ContextCode; readonly ctx: ContextCode;
readonly hint: string; readonly hint: string;
readonly count: number; readonly count: number;
/** Open the full description. Offered when the card cannot show it all. */
readonly onMore: () => void;
} }
export function InfoCard({ entry, hint, count }: InfoCardProps) { export function InfoCard({ entry, hint, count, onMore }: InfoCardProps) {
const body = useRef<HTMLParagraphElement>(null);
const [clipped, setClipped] = useState(false);
// Measured rather than guessed from a character count: the card is a fixed
// 84px, so whether the text fits depends on how it wraps.
useEffect(() => {
const el = body.current;
setClipped(el !== null && el.scrollHeight > el.clientHeight + 1);
}, [entry]);
if (!entry) { if (!entry) {
return ( return (
<div className={styles.card}> <div className={styles.card}>
@@ -27,6 +40,10 @@ export function InfoCard({ entry, hint, count }: InfoCardProps) {
); );
} }
// The gesture matters as much as the description — SHIFT + REMAIN is a
// three-second hold — so a card carrying one always offers the full text.
const showMore = clipped || entry.how !== undefined;
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.meta}> <div className={styles.meta}>
@@ -38,11 +55,19 @@ export function InfoCard({ entry, hint, count }: InfoCardProps) {
</span> </span>
))} ))}
</span> </span>
<span className={styles.metaRight}>
{showMore ? (
<button className={styles.more} onClick={onMore}>
MORE
</button>
) : null}
<span className={styles.page}>P.{entry.page}</span> <span className={styles.page}>P.{entry.page}</span>
</span>
</div> </div>
<h2 className={styles.title}>{entry.name}</h2> <h2 className={styles.title}>{entry.name}</h2>
<p className={styles.body}> <p className={styles.body} ref={body}>
{entry.desc || contextNames(entry.ctx).join(", ")} {entry.how ? <b className={styles.how}>{entry.how}. </b> : null}
{entry.desc}
</p> </p>
</div> </div>
); );