--- name: penguin-harness-frontend description: Use when changing the PenguinHarness Web App (`packages/web`) or the shared UI package — adding or restyling any UI, picking a status colour, adding an icon, laying out a row or a form field, writing user-facing copy, building a popup or a hover-revealed panel, or making anything open, fold, resize or maximize. Covers the semantic tone tokens, the icon size/stroke/gap scale, the semantic-versus-formatting rule and the hover "?", the two-dictionary i18n contract, the portal-panel pattern, motion (every disclosure folds through `Fold`, sizes move through the theme's layout motion), and the recent owner decisions on favorites, maximized surfaces, the collapsed rail and example cards. --- # Web App frontend conventions `packages/web` is React 19 + Vite + Tailwind CSS 4, with no `cn`/`clsx`, no `tailwind-merge`, and no variants library. Classes are composed with template literals, and a component's variants are a `Record` next to it (`button.tsx`'s `variantClass`, `input.tsx`'s `sizeClass`). Match that shape; do not introduce a styling dependency. This file records the decisions that already exist so they are not re-litigated per PR. Read `penguin-harness-dev` for the repo-wide contract (verification chain, changelog, two-repo layout). ## Status colour: pick a tone, never a palette class `src/lib/tone.ts` is the only place a status colour is spelled. Five tones, chosen by **meaning**: | tone | meaning | when | | --- | --- | --- | | `busy` | executing right now | spinners, live titles, running dots | | `attention` | unfinished — waiting on time, a queue, or the user | hourglass glyphs, pending-approval marks, near-limit rings, warning strips | | `success` | finished well, connected, healthy | completed badges, connected servers | | `danger` | failed, destructive, over a limit | errors, delete affordances | | `muted` | settled; the mark should recede | a done row's glyph | Three maps, by the shape of the thing being coloured: `toneInk` (a glyph or a line of status text), `toneSurface` (a tinted pill with its own text — badges), `toneDot` (the 6px state dots). A notice that owns a row is the shared UI package's `NoticeStrip`, which takes the package's tone names (`busy` → `success`, `link` → `info`, `muted` → `neutral`); toasts render through it. Rules: - **Two states may share a tone.** `busy` and `success` resolve to the same emerald on purpose. A tone says what a mark *means*, not which state it belongs to. Where two states share a tone, separate them by **shape and motion** — that is what the session list's turning hourglass and squeezing compress mark do, and it is legible to a reader who cannot separate hues. - **Never make colour the only carrier.** Every status mark also names its state in an accessible name or in adjacent text. - **Contrast is measured, not assumed.** The ratios in `tone.ts` are WCAG 2.x against the four surfaces marks actually sit on — white and gray-50 in light, and the values the default theme *overrides* for dark (`packages/ui/src/themes/github.css`: gray-950 is `#000000`, gray-900 is `#0d0d0d`, not Tailwind's stock values). Recompute if you change a tone; a graphical mark needs 3:1, and `muted` is the one tone allowed below it because its meaning is always already in text. - **What is out of scope**, and must not be folded in: categorical palettes where colour is an identity rather than a judgement (`category-colors.ts`, `token-colors.ts`, the timeline phase bars, per-skill avatar tints); the terminal's chrome, which resolves light/dark in JS because a subtree cannot opt out of the `dark:` variant (`terminal-appearance.ts` carries its own `success`/`attention`/`danger`); secondary body text, which is typography; a background-only wash on a card section; and hover-only variants, since a tone token is the resting ink. ## Explanatory text: semantics disclose, formatting stays Two kinds of prose, and the split decides *whether* it is disclosed. - **Semantics** — what a section is, what a field means, what it affects, when a change takes effect. Read once, then in the way forever. It is disclosed on request. - **Formatting** — the shape the value must take: "one `KEY=value` per line", "one argument per line", "leave empty for unlimited", allowed characters, a `k`/`m` suffix. Read *while typing*. It stays on screen, in the field's `hint`. Hiding it turns a glance into a click and raises the error rate. A string that mixes both is a string that should be split, not a judgement call. When you cannot split it, keep it visible — a visible sentence is never a bug, a hidden format rule is. One exception: a rule the control already enforces or already shows goes behind the "?". The Profile nickname's "1–32 characters, blank clears" is the case. `maxLength` stops the 33rd character, and the placeholder says what blank means, so a visible line would only repeat them (user decision, 2026-10-03). Already-disclosed text does not move: `title=` tooltips, `OptionMenu` row descriptions, confirm dialog bodies (the dialog *is* the disclosure), toasts, and empty states. ### Tooltips: when a mark gets one A hover hint shows only where its words are not on screen (`hintAllowedFor` in the UI package's tooltip.tsx): an icon-only control, a wordless mark, an input, or text that is cut off. Three glyphs are read without one — the circled "?" beside a title (`InfoPopover`, whose hover opens the explanation itself), a close or clear × beside the thing it dismisses, and a fold chevron under the rows it folds — so they carry no `data-tooltip` unless the hint says something the glyph cannot: a shortcut ("Close (Esc)"), a consequence ("Kill this terminal"), the subject when it is not beside it. That is why `DockTabs`' close × keeps its hint (the shortcut; on a terminal tab, the consequence) while the password banner's × and a browser tab's × have none. Either way the mark keeps its `aria-label`; that name is for assistive technology, not a panel. A hint on text that may truncate shows that text whole (plus an id when one helps), never the verb of the click — the underline already says it opens. ### Which disclosure — the "?" or the fold Two forms, and **a title decides between them, not taste**: > **The circled "?" may only appear beside a title. It must never stand alone on its own line. > Where it would stand alone, use the fold.** - **A title is present** → `InfoPopover` (`@prismshadow/penguin-ui`). A circled "?" immediately after the section heading, the table column header, or the field label — the last of those via `Field`/`Input`/`Textarea`/`PasswordInput`/`Select`'s `info` prop. The "?" is an *anchored* mark: it reads as help only because it modifies the title it sits against, and it borrows that title's meaning instead of restating it. - **No title on the surface** → `HelpFold` (`@prismshadow/penguin-ui`). A compact row that names itself and expands its explanation inline underneath. This is the Agent settings tabs: their name lives in the tab bar and the panel does not repeat it, so a "?" at the top of the panel would be a mark modifying nothing. A neighbouring `