penguin-harness-frontend
Use when changing the PenguinHarness Web App (`packages/web`) — adding or restyling any UI, picking a status colour, adding an icon, laying out a row or a form field, writing user-facing copy, or building a popup. Covers the semantic tone tokens, the icon size/stroke/gap scale, the semantic-versus-formatting rule for explanatory text, the two-dictionary i18n contract, and the portal-panel pattern with its Esc and scroll caveats.
git clone --depth 1 https://github.com/Prism-Shadow/penguin-harness /tmp/penguin-harness-frontend && cp -r /tmp/penguin-harness-frontend/.agents/skills/penguin-harness-frontend ~/.claude/skills/penguin-harness-frontendSKILL.md
# 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<Key, string>` 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 |
Four 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),
`toneStrip` (a bordered notice that owns a row).
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 this app *overrides*
in `styles.css` for dark (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.
Already-disclosed text does not move: `title=` tooltips, `OptionMenu` row descriptions, confirm
dialog bodies (the dialog *is* the disclosure), toasts, and empty states.
### 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` (`components/ui/info-popover.tsx`). A circled "?"
immediately after the section heading, the table column header, or the field label — the last of
those via `Field`/`Input`/`Textarea`/`PasswordInput`'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` (`components/ui/help-fold.tsx`). 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 `<Button>` does not rescue it — a
control is not a title.
The fold is **not** a popover in another shape: it is inline flow, so it takes no portal. Do not
reach for `usePortalPanel` there for symmetry — that hook exists to keep a *floating* panel clear
of an ancestor's overflow and to close it on outside click, Esc or scroll, and a fold does none of
those things. It follows the WAI-ARIA disclosure pattern instead: the panel stays in the DOM and
is `hidden` while collapsed, so its `aria-controls` always resolves.
Both are collapsed by default, both are real `<button>`s with `aria-expanded` and `aria-controls`,
and both fold the subject into the accessible name ("More info: Vault") rather than repeating it,
so a "?" inside a heading does not make that heading announce its own title twice. The fold's
visible text is a prefix of that name, so "label in name" holds.
`test/disclosure-anchor.test.ts` enforces the rule: it parses the real JSX with the TypeScript
parser and fails, naming file and line, on any `InfoPopoverUse when developing PenguinHarness itself — changing packages/{core,server,web,cli,desktop,landing,docs,skills}, the built-in model catalog, the installers or the release workflow; writing or auditing changelog entries; writing a blog post or capturing release screenshots; deciding what to do about data already on disk; or auditing prose that reads like a leaked authoring session. Covers the two-repo symlink layout, the CI-parity verification chain, the record-and-ship contract, where blog media is hosted, and the seams that are intentional.
Run one specified Test Agent on one specified Benchmark Case exactly once, privately score that execution, and return one protocol result.
Initialize an Agent's settings from a user requirement by writing AGENTS.md, setting identity metadata, and installing only needed Skills.
Improve an Agent State through versioned scores and score-linked Traces from a frozen Benchmark.