Imported from raineorshine/github-triage (
.github/skills/mockup/SKILL.md). Install upstream withnpx skills add raineorshine/github-triage --skill mockup. Copyright stays with the author.
Mockup (several looks → one page the user chooses from)
A change with one answer is verified; a change with several is chosen, and the branch can only ever hold one of them at a time. Installing each in turn spends the slot, the lock and a real inbox read per look, and asks the user to hold the last one in their head while the next compiles. So the alternatives go on one static page instead, and the user picks.
docs/verifying-changes.md > A look that is a choice goes to a static mockup is why; this skill is the page's shape, so it is not reinvented per session.
The page answers a look and nothing else — how something sits, or how it moves. It holds no React, so behaviour, hydration and anything reading real threads stay with the branch on the private port — build the chosen look there afterwards and verify it the ordinary way.
The shape
template.html beside this file is the page, with the chrome finished and the variants left blank.
Copy it into the session scratchpad and fill in the shouting placeholders; do not rederive the CSS.
- Numbered variants, one per tab. Labels are
1,2,3— neverA/B/C. The user says "3" out loud and in chat, and a letter makes them translate. One panel visible, the resthidden. - A real tablist.
role="tablist"ofrole="tab"buttons witharia-controls,aria-selectedand a rovingtabindex, so the strip is clickable and correct to a screen reader as well as keyed. A<div>that only answers keys is the half of this that gets forgotten. - Bare
1–Njump,←/→step, wrapping, and↑/↓with them. The legend is stated on the page, not only in chat — the user reads the page, not the transcript. - A sticky bar carrying the tabs and the theme radios, so both are reachable from any scroll position.
- Monokai by default, with every other palette the app ships selectable beside it. A dark palette is the one the user reads the app in, so it is the one a look is judged in first.
- Switching is plain CSS — radios plus
body:has(#t-mk:checked)redefining the tokens. No script is needed for the palette, and the page still switches if the script ever breaks.
What the guards owe
- A modifier means the browser's own shortcut. Return on
metaKey,ctrlKey,altKey,shiftKey, so ⌘1 still switches Brave's tabs and ⌘R still reloads. - Guard text entry, not
input.textarea,select,contenteditable, andinputtypes other than radio, checkbox and button. Guarding everyinputis the bug: the number keys die the moment the user clicks a theme radio, which is the first thing they click, and the strip reads as broken. This mirrorslib/keys.ts(isBareLetter/isBareChar) andlib/focus.ts, which the app enforces in React and the mockup reimplements in plain JS.
Tokens
Copy --bg-*, --border-* and --fg-* out of src/app/globals.css verbatim, onto a wrapper
class rather than :root — the chrome around the variant needs its own, so that a dark theme is not
judged on a light card. :root there pairs GitHub Light with Monokai Classic through light-dark(),
which cannot survive the move: the palette is picked by a radio, not by the Mac's appearance, so each
block carries one resolved side. A token a variant invents is invented in every palette block, and
checked on the surface it lands on — 4.5:1 for text, 3:1 for a state icon, per AGENTS.md > The UI.
A variant that overrides a token the radios also set has to out-rank them. The blocks above are
keyed on body:has(#t-ivory:checked), and a :has() carries its most specific argument's
specificity, so a plain .surface[data-palette='…'] loses to them and every variant renders in the
radio's palette instead of its own. Repeat the radio contexts in the variant's own selector. It is
the failure worth naming here because it corrupts the answer rather than the page: each tab looks
like a finished candidate, and the one that comes back was never on screen
(docs/solutions/conventions/a-losing-override-renders-as-a-design-choice.md).
The variants themselves
- Say at the top what to judge and what to ignore. One question, named — "which number answers how much", "where the chip sits" — and the list of what is deliberately rough: wording, spacing, exact colour. Without it every variant reads as a finished proposal and the notes come back about the placeholder text.
- Invented figures stay consistent across variants. The same counts, the same money, the same logins in all of them, stated in the lede as invented. A number that moves between tabs is read as part of the difference.
- A state every variant must be seen in is a toggle in the bar, not a tab. A filter switched
on, a row selected: a checkbox beside the theme radios, flipping the markup through
body:has(#…:checked)as the palettes do, so each look is judged in both states and the strip stays a list of looks. Made a tab, it pairs one state with one look and doubles the strip. A control that opens something is live the same way — a native popover needs no script. - Show enough surrounding furniture to judge placement. A block on its own answers what it looks like and not where it belongs, and where it belongs is usually the question. Put the real neighbours around it — the heading above, the table below — copied from the app's own markup.
- Write the rows out as plain HTML. Markup generated on load is markup that vanishes if the page is ever opened as a file, and a blank page reads as "the change is not showing".
- Each panel carries a one-line note on what that variant trades away. The tab label is a name; the note is the argument.
- A tab showing the design unchanged is labelled
current— not "as it is", "today" or "baseline". It is the one tab whose label is not a name for a look but a statement about which look is installed, and the user reads it against the thing on their own screen.currentsays that; the others invite the reading that every tab, this one included, is a proposal. - Past about six variants the strip stops fitting, read on half a screen beside the chat. That is the signal to cut, not to wrap: collapse the ones that differ in wording alone into one tab, or split the page into two questions asked in sequence. A strip that wraps to a second line puts the tab the user is on somewhere they have to look for it.
When the choice is a motion
A variant that differs only in how it moves is identical at rest. Given a page of those with a ✓ to press on each, the user asked how to tell two of them apart "if they are not interactive" — they were, but the one control that set them off was a small button inside the variant, and nothing said so. So a motion page plays itself on demand:
<body class="motion">switches the kit on. The template already carries it, hidden without the class: a ▶ Play on each panel, Space to play the variant on screen,sand a ¼ speed box in the bar, and a line in the lede saying so. Fill inreset(panel)andrun(panel, later)in the template's script; the rest is done.- Play from the first frame, and run it more than once.
resetputs the panel back — a card deck refilled, a row un-hidden — andrunpresses through the sequence, three times for a dismissal. One run is over before the eye has found where to look. - Time the script through
later, and the CSS throughvar(--slow). Every duration and delay a variant sets iscalc(<ms> * var(--slow)), so the ¼-speed box stretches the transitions and the steps between them together. Quarter speed is what shows a curve; at full speed ease-out and ease-in-out are a feeling rather than a difference. - Read a duration back with
transitionMs(el); never assume it. Anything that runs after a transition — a leaving element set to its final state — waits for the transition that is really on the element. A cleanup on a fixed timer cut short every variant slower than its number, and the slow variants read as the broken ones. - Every knob is a custom property on the variant, and the note prints the numbers, as in "360ms ease-in-out, falls 140px, 30px left". The user answers in mixes ("2 but a shallower angle", "3 but further"), and a mix is only nameable when each tab says what it is made of. Where the knob is a number, the tab label is the number rather than an adjective.
- Give the motion room. An element that falls or slides needs space on the stage to do it in,
or
overflowclips the half of the motion the user is judging. - The page honours
prefers-reduced-motion, and says so when it is on — the template's warning line — since every variant then plays instantly and they all look alike. - A motion choice comes in rounds, one knob family per page. Shape, then the handover, then
distance, direction, angle, timing: each round a new page named for its question, served beside
the earlier ones so the user can go back. The previous round's pick is tab 1, labelled as picked
and not
current, which means what is installed. An answer that moves a knob the page did not offer ("slower, ease-out") is the next page's question, not an edit to this one.
Serving it
In the session scratchpad, not the repo tree. It is scaffolding for one decision, deleted once
the choice is made — a file in public/ is a file to remember to remove, and one that ships if
nobody does.
Kept in the repo only when the user asks. Then it goes to mockups/<topic>/, one file per
round numbered in the order asked (1-deck-shape.html, 2-handover-motion.html), with a
README.md table of page, question and what was chosen, and the final answer in a sentence under
it — the pages alone say what was offered, not what won. npm run lint runs prettier . --check,
so write them through npm run prettier:fix.
Over http://localhost, never file://. The Browser pane renders a file:// document with
scripts off, so the tab strip is dead there and the check below proves nothing. A plain static server
is enough:
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])')
echo "http://localhost:$PORT/mockup.html"
Then serve the scratchpad directory on that port with python3 -m http.server "$PORT" --bind 127.0.0.1 --directory <scratchpad>, as a background task. Never start a second npm run dev — the live
slot at localhost:3100 is a launchd agent serving the main checkout, this needs no Next, and a mockup
is not a reason to take the lock.
The server dies with the session, so a user coming back much later gets nothing; re-running the one command is the fix, and it is worth saying alongside the URL.
Verify it before handing it over
A tab strip that does not answer keys is the failure this skill exists to prevent, and it looks perfect in a screenshot. So drive it, in the pass that loads it:
preview_startwith the URL. A hidden Browser pane composites no frames, so picture and pointer both go — take the screenshot in this pass.- Press a number:
{action: "key", text: "2"}.keyand nevertype— a typed character carries nokeydowna page-level handler reads. - Press
ArrowRight, andArrowLeftfrom variant 1 to prove the wrap. - Click a theme radio, then press a number again. This is the guard that regresses.
read_pageafter each, foraria-selectedand which panel ishidden— a screenshot taken straight after a press can still show the frame before it.- Screenshot each palette,
find+ click byreffor the radios.
On a motion page a screenshot proves nothing about the motion, so measure it instead, in the same visible pass — a hidden pane freezes the timeline and a working transition reads flat (docs/solutions/workflow-issues/hidden-pane-freezes-the-timeline-so-a-transition-measures-flat.md):
- Press Space after the radio click in step 4, and read
scrollY— it must not have moved, and the radio must not have toggled. - Sample the moving element with
getComputedStyleat two instants after the press —opacity, andtransformthroughnew DOMMatrix(...)— to prove it is between its two states rather than at either end. Once more with ¼ speed on, where the same instant sits nearer the start. - Where a variant claims a relationship — "along the deck's line", "the same distance" — measure
it with
getBoundingClientRectrather than trusting the arithmetic in the CSS.
Then preview_stop and close the tab.
Handing it over
Open it for them, once the check above has passed — a link they have to click is a step between them and the thing they were asked to look at:
open -a 'Brave Browser' "http://localhost:$PORT/mockup.html"
It lands in their own window, at full width, in the browser they triage in; a screenshot in the
transcript is not something they can act on, and the pane is not where a choice gets made. Print the
URL alongside it anyway, for the tab they close and want back. Say which question the page asks, and
that 1–N and the arrows move between the variants — and on a motion page, that Space plays one
and s slows it.
That hand-back is a park — the decision is the user's and nothing proceeds without it. Retitle
🚙 and stop; do not build one of the variants into the branch on a guess, and do not offer to.