Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/prism-shadow-penguin-harness-frontend/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 25% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 153% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 20% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 35% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 85% | 0% |
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).
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:
busy and success resolve to the same emerald on purpose. Atone 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.
name or in adjacent text.
tone.ts are WCAG 2.x against the foursurfaces 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.
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.
Two kinds of prose, and the split decides whether it is disclosed.
effect. Read once, then in the way forever. It is disclosed on request.
KEY=value per line", "one argument perline", "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.
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.
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.
HelpFold (components/ui/help-fold.tsx). A compact row thatnames 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 InfoPopover with no title among its preceding siblings. Extend TITLE_ELEMENTS there if you add a component whose job is to be a title.
The HTML trap this forces. A <button> is a labelable element, and a wrapping <label> names its first labelable descendant — so a "?" nested inside Field's usual <label> would silently retarget the field's title from the input to the button. Field therefore has two layouts: without info it wraps in <label>; with info it splits the title out and associates it by htmlFor, which is why the control needs an id. test/info-popover.test.ts guards this.
One renderer: components/ui/glyph-icon.tsx. A 24×24 path, strokeWidth 1.7, stroke="currentColor", fill="none" (or filled for an "on" state). Do not hand-write an <svg> for a line icon — put the path in the module that owns it (lib/stat-icons.ts, components/ui/icons.tsx, group-list.tsx, session-row-menu.tsx) and render it through GlyphIcon.
Two marks deliberately live off that grid, because a two-stroke mark aliases when its grid and its render size disagree: ChevronDown (12×12, stroke 1.5) and CloseIcon (14×14, stroke 1.5). Charts, sparklines, the topology view, the ring gauges and the login background draw their own geometry and are outside the family entirely.
Sizes come from src/lib/icon-scale.ts, named by role, not by number — inlineGlyph 13, rowLead 14, iconButton / groupHeaderGlyph 15, navRow / groupHeaderAction 16, groupHeaderAvatar / sectionMark 18, chevron 14 / chevronDense 12, caret 12 / caretDense 10. Pick the rung whose role matches; if none does, the honest move is to add a rung with a sentence saying what it is for, not to type a bare number. An avatar sits one rung above a line glyph in the same slot, because a tile fills its box and a glyph only draws inside it.
Gaps come from ICON_GAP in the same module and track text size: tight (gap-1) for a glyph welded to a number, row (gap-1.5) for a list row, menu (gap-2) for menu/nav rows and banners, card (gap-3) for a card row led by an avatar.
test/icon-scale.test.ts fails on a stroke weight outside the chosen set and on a second copy of the caret, the close cross or the collapse chevron.
Two dictionaries: src/lib/strings.ts is zh (and defines the Strings type), src/lib/strings-en.ts is en and is typed const en: Strings. Adding a key to one and not the other is a type error, not a runtime surprise — and test/placeholders-parity.test.ts checks that both sides interpolate the same placeholders. Add both, in the same shape, in the same PR.
S is a live binding swapped on locale change, so read it at render time; never hoist S.x.y into a module-level constant.
Chinese belongs only in the zh dictionary, titleZh fields, *.zh.md documents, and fixtures that exercise CJK behaviour. Comments, test names and every other string are English.
Anything that overlays — a menu, a picker, an info popover — uses usePortalPanel (components/ui/use-portal-panel.ts) and createPortal to document.body, positioned fixed against viewport coordinates. An in-place absolute panel is a DOM descendant of its trigger, so any ancestor with overflow-x-auto clips it vertically (the CSS spec forces the visible axis to auto when the other is not visible), and auditing every call site's ancestor chain is not a plan.
Three behaviours there are load-bearing:
Modal listens during the window bubble phase andregisters earlier, so without stopping propagation one Esc would close the panel and the dialog.
would go stale rather than floating out of place. Its own internal scroll is exempted.
z-[60], above the modal overlay's z-50: a portaled node sits in the root stacking contextand may be opened from inside a dialog.
Modals and Dropdown additionally register in the Esc-layer stack (modal.tsx's pushEscLayer), so Escape only acts on the topmost layer. A portal panel does not need to — capture plus stopPropagation already gets there first.
z-40; modal and drawer overlaysz-50; portaled panels z-[60]. styles.css's header states this.
styles.css, @layer base). Do not undo it: anabsolutely positioned descendant of a static scroller escapes to the initial containing block and gives the whole shell a second scrollbar. That bug shipped three times.
transform-only where possible, so the global prefers-reduced-motion ruledisables them into a correct resting state instead of hiding the element.
shpnpm --filter @prismshadow/penguin-web typecheck pnpm --filter @prismshadow/penguin-web test pnpm format && pnpm format:check
Playwright (packages/web/e2e/) only when selectors or flows move. On a shared machine, probe with ss -tln before picking ports, and never point PENGUIN_HOME at ~/.penguin.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 41,046 | 41,569 | +1% | 1 | 1 | 0% | 7,167 | 8,943 | +25% | 0 | 0 | — |
case-02 | fail→fail | 37,506 | 11,595 | -69% | 1 | 1 | 0% | 5,352 | 3,383 | -37% | 0 | 0 | — |
case-03 | fail→pass | 14,533 | 30,291 | +108% | 1 | 1 | 0% | 2,688 | 6,789 | +153% | 0 | 0 | — |
case-04 | fail→pass | 44,854 | 14,368 | -68% | 1 | 1 | 0% | 4,538 | 5,467 | +20% | 0 | 0 | — |
case-05 | fail→pass | 17,084 | 10,955 | -36% | 1 | 1 | 0% | 3,040 | 4,116 | +35% | 0 | 0 | — |
case-06 | fail→pass | 14,678 | 5,846 | -60% | 1 | 1 | 0% | 2,044 | 3,772 | +85% | 0 | 0 | — |
case-07 | fail→pass | 17,300 | 12,251 | -29% | 1 | 1 | 0% | 3,381 | 5,344 | +58% | 0 | 0 | — |
case-08 | fail→pass | 12,293 | 4,785 | -61% | 1 | 1 | 0% | 1,654 | 3,574 | +116% | 0 | 0 | — |
case-09 | fail→pass | 13,563 | 6,624 | -51% | 1 | 1 | 0% | 1,704 | 4,053 | +138% | 0 | 0 | — |
case-10 | pass→pass | 50,373 | 7,140 | -86% | 1 | 1 | 0% | 1,820 | 4,078 | +124% | 0 | 0 | — |
case-11 | fail→pass | 18,949 | 4,201 | -78% | 1 | 1 | 0% | 3,543 | 3,681 | +4% | 0 | 0 | — |
case-12 | pass→pass | 16,313 | 7,429 | -54% | 1 | 1 | 0% | 1,446 | 4,266 | +195% | 0 | 0 | — |
case-13 | fail→pass | 19,768 | 11,142 | -44% | 1 | 1 | 0% | 1,957 | 4,080 | +108% | 0 | 0 | — |
case-14 | fail→pass | 10,205 | 5,299 | -48% | 1 | 1 | 0% | 1,261 | 3,842 | +205% | 0 | 0 | — |
case-15 | pass→pass | 11,180 | 5,600 | -50% | 1 | 1 | 0% | 1,717 | 3,945 | +130% | 0 | 0 | — |
case-16 | fail→pass | 13,906 | 6,881 | -51% | 1 | 1 | 0% | 2,119 | 4,058 | +92% | 0 | 0 | — |
case-17 | fail→fail | 9,696 | 6,620 | -32% | 1 | 1 | 0% | 1,597 | 4,008 | +151% | 0 | 0 | — |
case-18 | fail→pass | 16,902 | 5,023 | -70% | 1 | 1 | 0% | 2,244 | 3,550 | +58% | 0 | 0 | — |
case-19 | pass→pass | 14,283 | 4,729 | -67% | 1 | 1 | 0% | 1,931 | 3,455 | +79% | 0 | 0 | — |
case-20 | pass→pass | 9,220 | 4,743 | -49% | 1 | 1 | 0% | 1,189 | 3,520 | +196% | 0 | 0 | — |
case-21 | fail→pass | 12,328 | 4,017 | -67% | 1 | 1 | 0% | 1,720 | 3,565 | +107% | 0 | 0 | — |
case-22 | fail→pass | 96,921 | 5,654 | -94% | 1 | 1 | 0% | 2,174 | 3,371 | +55% | 0 | 0 | — |
case-23 | fail→pass | 29,731 | 8,715 | -71% | 1 | 1 | 0% | 1,871 | 3,890 | +108% | 0 | 0 | — |
case-24 | pass→pass | 17,121 | 12,004 | -30% | 1 | 1 | 0% | 2,570 | 4,667 | +82% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted, and 23 counted toward the lift figure. The other 1 produced results that are not comparable between the two arms, so they are excluded from the headline rather than averaged into it. The headline lift of +67 percentage points is the difference between those two pass rates over the 23 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.