Skip to content

fix(release): thirteen measured UI fixes — a button with no edge, figures cut off at 1280, a filter that survives the back button - #18

Merged
PSheon merged 27 commits into
mainfrom
dev
Sep 23, 2026
Merged

PSheon merged 27 commits into
mainfrom
dev

Conversation

@PSheon

@PSheon PSheon commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Thirteen UI/UX fixes Paul picked from a measured review, plus what came out of trying them on a phone. No article
content changes: the same fourteen articles, behaving better.

What a reader will notice

  • The secondary button on the home page had no edge on the light theme. The cva base set border-transparent
    and the outline variant set border-input; two border-colour utilities in one class string are decided by CSS
    source order, not by class order, so the three call sites that use buttonVariants() without cn() lost it.
    Fixed at the base — every variant names its own colour — and e2e/design.spec.ts now measures the 3:1 that
    DESIGN.md §2 promises.
  • The browser bar followed the OS rather than the chosen theme, wrong in three of six combinations.
  • Wide figures were cut off between 1280 and 1304 px with no scrollbar to show it: the three-column layout needs
    80.5rem and xl is 80rem. It has its own breakpoint now, and the contents rail arrives at lg instead of 1312,
    which was 250 px of dead margin on every 1024-wide screen.
  • The tag filter is in the URL (?tag=), so a filtered list can be shared and the back button undoes it, and
    the #tags in each row are the filter they always looked like.
  • The 404 offers somewhere to go — the index, ⌘K and the five newest articles — instead of one button and
    250 px of nothing.
  • The theme change fades instead of cutting, 260 ms, instant under prefers-reduced-motion.
  • The search palette stops showing keyboard hints to touch devices and has a close button; the hero's four stations
    share one height and one reset control; every "start over" button in every article says 重來 / "Start over" with
    the same icon at the same 28 px.

What was measured rather than assumed

Every figure's buttons were swept in a browser, article by article: 16 controls mean "start over", 4 had no icon,
13 were the wrong height, and the word for it differed six ways in Chinese and four in English. Nothing is under
24 px and everything has a name.

Two of the review's findings were not bugs and were dropped after measuring: the home headings do clear the
sticky header (the scroll container's scroll-padding-top does it; the elements' own scroll-margin is a red
herring), and the hero's four stations cannot each have their own height — two of them size a canvas against the
box and collapse to 55 px and 69 px without one.

One gap is left open on purpose and written down in docs/HANDOFF.md: on the bilingual 404, the only page rendered
outside the locale layout, --input resolves 5 % dimmer in a production build (2.86:1 rather than 3.0).

Checks

324 unit tests, 248 e2e across desktop and mobile with axe clean in both themes and both locales, lint and
typecheck clean, CI green on 1b2016e. framer-motion was installed for one animation and removed again: DESIGN.md
forbids an animation library, and 66 KB gzipped against 203 KB of page scripts is why.

🤖 Generated with Claude Code

PSheon and others added 27 commits September 23, 2026 11:03
… deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: the home page's secondary call to action, the error page's and the 404's read as plain text on
light, measured border-top-color rgba(0,0,0,0) over a near-white background.

The cva base carried `border border-transparent` and the outline variant added `border-input`. Both are
border-colour utilities, so CSS source order decides, not class order. `<Button>` escaped it by piping through
cn(), and the dark theme escaped it because `dark:border-input` sorts after base utilities, which is why this only
ever showed up on three bare `buttonVariants()` call sites in one theme.

Rather than wrapping those three in cn() — which leaves the trap set for the fourth — the base no longer names a
border colour at all and every variant names its own. e2e/design.spec.ts measures the 3:1 DESIGN.md §2 promises;
it fails on the old button.tsx and passes on this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.claude/worktrees/ holds another session's checkout and its build output; linting it reports hundreds of problems
in generated code that nobody can act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… picked

Reported by paul-8b, three of six combinations wrong: a first-time visitor on a light phone got a dark page under a
white browser bar, and anyone who had chosen the opposite of their OS got the two reversed.

The meta tag was declared with prefers-color-scheme media queries, which answer a different question — the theme is
a class next-themes puts on <html>, and the default is dark whatever the OS says. The viewport now declares the one
colour that is right for the first paint, and a small client component keeps it on whatever the reader resolved to
afterwards, removing any media-qualified tag that would otherwise still win.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: at 1280 a wide figure measured 8 px past the viewport, and because main is overflow-x-clip
there was no scrollbar to show it — the panel's right border was simply missing, at Tailwind's xl and a very common
laptop width.

The three-column layout needs 13rem + 3rem + 42.5rem + 3rem + 17rem plus 2rem of padding each side: 80.5rem, and
xl is 80rem. It now has a breakpoint of its own at 82rem, named for the thing it turns on. Between 1280 and 1312
the page uses the single-column layout instead of a clipped three-column one.

Swept 1024 → 1920 in e2e/design.spec.ts: no figure reaches past the viewport at any width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: 1024 to 1279 was the worst width on the site — 172 px of dead margin on each side, with
neither the contents rail nor the sidenote margin. iPad landscape, small laptops and half a 27-inch screen all land
there.

Two columns from lg (rail + body, 936 px of the 960 available), three from the 82rem breakpoint as before. The
article h1 also grew faster than the column it sits in — 5vw is 51 px at 1024, where the body is still 680 wide, so
the title took three lines at 1024 against two at 768. At 4.2vw it is 43 px there and two lines, and it still
reaches its 52 px cap on a wide screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: on a 390-wide phone the palette spent about 40 px on "↑ ↓ move", "⏎ open" and "esc close",
the last of which names a key the device does not have, and there was no visible way to dismiss it.

The whole hint bar is now gated on a wide viewport AND a fine pointer, so a tablet with a touch screen does not get
it either, and the input row carries a close button on everything else. It uses .tap, so the 36 px control has a
44 px hit area on a finger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul: the four stations looked inconsistent on a phone. Measured, only one of them was — the box took its height
from the classifier whatever was on screen, and `think` sizes itself from its own content, so a 390 phone showed a
173 px attention map in a 336 px panel with 163 px of nothing under it.

The other three are built to fill the box they are given, and two of them size a canvas against it (container-type:
size), so they keep the classifier's height. `think` sits against the top instead and the box comes down to meet
it: 205 px on a phone, 294 at 1440, with the same 32–40 px of breathing room every other station has.

Moving the box means moving the page under it, so it is a spring rather than a jump — framer-motion, which Paul
asked for by name, and an instant change under prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b as the lowest-priority of the three, and it is: Base UI already locks body scroll while the
drawer is open, so nothing leaks today. It is the cheap insurance for the day that changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: a filtered list could not be shared or bookmarked, Back did not undo the chip you just
pressed, and a reload cleared it.

?tag= on both indexes, via history.pushState rather than the router — it is the same page with a different filter,
so a server round trip and a lost scroll position buy nothing — and popstate is what makes Back walk the filters.
Deliberately not useSearchParams: these routes are prerendered, and reading the query during static rendering opts
the whole route out of it for something that only matters once the page is interactive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: the rows rendered plain spans while the article page renders real tag links, so readers poked
at something inert. Links cannot nest, which is why they were spans — the whole row was one link.

So the row is not a link any more. The title's link stretches an invisible ::after across it, which keeps every
part of the row clickable and gives the focus ring something to draw, and the tags sit above that layer as buttons
that set the filter — now that the filter is in the URL, pressing one is a thing a reader can share and undo.

Same change in the home page's tiles, which had the same spans for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: the page offered one button and left about 250 px of blank under it. A mistyped slug or a dead
inbound link is exactly the moment a reader needs the index or the search the site already has.

It now offers the home page, all notes, a word about ⌘K, and the five newest articles by title — title and number
only, so nothing extra is shipped to a page nobody means to visit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fused

Reported by paul-8b: pressing copy only swapped an icon, which says nothing to a screen reader, and the write had
no catch — on an insecure origin, or when the browser denies the permission, it threw an unhandled rejection and
the reader got no feedback at all.

Both outcomes are now spoken through a polite live region and drawn, and a refusal says to select the code instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by paul-8b: on /en at 1440 the labels wrapped as "figures you can play / with" and "hand-written /
operators", stranding a word on its own line, and because the columns were content-sized the third one started
wherever the second happened to end.

Three equal columns and text-pretty: the labels break as "figures you can / play with", the numbers keep a rhythm,
and Chinese, which fits on one line at every width, is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul: on a phone they were not on the same row. Label plus six digits plus a worded button is about 330 px, and
the instrument is 284 px wide inside a 390 phone — so the eraser wrapped onto a line of its own under the digits,
and at 320 it took a third line.

The comforts go as the panel narrows: the word beside the eraser first (the icon says it, and the button keeps its
name for a screen reader), then the group's label, which becomes the group's accessible name. Measured one row at
320 / 360 / 390 / 412 / 430 / 768 / 1440 in both languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul asked for framer-motion by name and then, shown that DESIGN.md forbids an animation library twice over — §2
"View Transitions, not framer-motion" and §7 "no animation library is installed", with the ~0.5 MB page budget as
the reason — chose to take it out. The package is 66 KB gzipped whole, 30–45 KB tree-shaken, against 203 KB gzip
of page scripts today.

One number moving between two known values is a CSS transition. Measured over 22 frames: 304 → 173 px in about
300 ms, and an instant change under prefers-reduced-motion.

While a station's own chunk is still arriving, the classifier's height holds the box open — measuring the loading
placeholder collapsed it to 96 px and then grew it back, which looked far worse than not animating at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… by side

Paul asked to see the options rather than read about them. /zh/dev/think renders all four at 390 px of phone with
the hero's own chrome and measures each one live: A as it shipped (box 304, map 128, a lot of empty panel), B as it
stands now (171, 128), C stacked (388, 156) and D at six-four (304, 158 — a bigger map with no extra height).

next dev only: the page 404s in production and is marked noindex either way. The layout prop on the station exists
for this bench; the hero passes nothing and gets what it always had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things Paul asked for.

The bench (/zh/dev/hero, next dev only) renders the four stations at a chosen width with the hero's own chrome and
measures each live: box, ink, slack, and the canvas against the cell it was given. It answers a question I had been
guessing at — pressing "each its own height" collapses generate to 55 px and act to 69 px, because both size their
canvas against the box with container-type: size, and without a given height that height is zero. So four equal
heights is not a taste: those two require it, and think is the only station with any freedom.

The reset controls had grown apart: an eraser with a word beside it on the classifier, a dotted-underline link on
think that only appeared once training finished, and nothing at all on generate and act. They are one control now,
same size and same place at the end of each station's bottom row, with the label as its accessible name — and the
two stations that had no way to start over have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de by side

Paul's choice from the bench: equal heights, think at 1:1.

The bench is what settled it. Letting each station be as tall as its own content collapses generate to 55 px and
act to 69 px, because both size their canvas against the box with container-type: size and without a given height
that height is zero — so the four have to share one, and the classifier is the only station with a height of its
own to give. Think goes back to filling that box rather than shrinking to its content.

The CSS height transition stays. Nothing changes height today, but the classifier's own height moves with the
window, and a resize that used to jump now glides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ight theme

Paul: why is the AI-composer cover always on black?

The "latest" card forces its stage dark in both themes. That was written for article 007's live preview, whose
yellow points vanish on white — but it applied to whatever the newest article happened to be, and most articles
have no live preview at all. So every drawn cover, which is made of theme tokens and sits happily on the page's own
panel two sections below, was shown on #070918 in the middle of the light theme.

The dark stage now belongs to the live previews that need it; everything else gets the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… what it does

Two follow-ups from Paul.

The "act" station split its row in half between a flock and three short numbers, which left the canvas narrower
than the box was tall: 73 px of empty panel at 1440, 86 at 1152, 32 at 768. At six-four the flock fills it — 5 px
of slack at 1280 and up, 0 at 768 — and the numbers still have room.

The reset controls now carry a word beside the icon, the way the classifier's eraser always has: 重新訓練, 重播,
重新演化. The rows that hold one may wrap rather than squeeze the word out, and the eraser keeps its own word down
to a 17rem panel so that on a phone all four read alike. Measured at 320 / 390 / 430 / 768 / 1024 / 1440: the word
shows everywhere except the eraser at 320, and the samples row is still one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul asked for the theme change to be gradual. next-themes was told to suppress transitions during the switch,
which is the opposite; that is off now, and `html.theme-fade` lets every element interpolate its colours for the
third of a second around the change, then comes off so nothing carries a transition it did not ask for.

Measured: the page background walks from rgb(7,9,24) to rgb(251,251,254) over about fourteen frames, and the class
is gone afterwards. A reader who asked for less motion still gets the instant swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swept all fourteen articles' figures in the browser: sixteen controls mean "start over", and four of them were the
only ones with a bare word and no icon — both of slam's figures and two of the light series'. The rest have carried
RotateCcw since they were written.

Nothing else moved: no figure button is under 24 px, every one has a name, and the two sizes in use (28 px, and
32 px in article 007) were left alone pending Paul's call on the wording, which is the real inconsistency — 重來,
重置, 重新開始, 重新取樣, 忘光重來 and 重置權重 all appear for the same idea.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… out

Paul's call: the thirteen buttons at 32 px come down to the 28 px the other 145 use. That was article 007's five,
lite3's seven and slam's one — all of them a <Button> that had never been given a size.

Left alone deliberately: the 24–27 px segmented cells (kernel presets, timbres, sample digits), hydranet's 43 px
image tiles and the two 36 px "start training" buttons, which are a figure's primary action rather than one of a
row. None of them is under 24 px and all of them have names.

/zh/dev/hero goes with it: it existed to settle the hero's layout, the layout is settled, and what it proved is in
the commit messages — generate and act collapse to 55 and 69 px without a given height, which is why the four
stations share one. The think station loses the layout prop that only the bench used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed with Paul first. The same action was called 重來, 重置, 重新開始 and 忘光重來 in Chinese, and Restart,
Reset, Start over and "Forget everything" in English, depending on which article you were in.

重來 and "Start over" everywhere, including the two places the prose names the button. Three keep their own words
because they are not the same action: 清除 erases what you drew, 重置權重 says which thing is being reset, and
重新取樣 draws a new sample without resetting anything.

One exception Paul agreed to keep: article 007's 忘光重來 / "Forget everything". It is the most characterful button
on the site and it says exactly what happens — the model forgets what it learned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four things a later session would otherwise rediscover the hard way: border-colour utilities are decided by
source order, the article layout needs its own 82rem breakpoint, the hero's four stations cannot each have their
own height, and next-themes must not be allowed to suppress transitions again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four things, all mine, none of which `next dev` shows.

- The `#tag` buttons I added to the index rows and tiles were inline text, 16 px tall. `.tap` gives them a 44 px
  hit area on a finger but nothing on a mouse, so they failed the site's own 24 px rule (WCAG 2.5.8) on /zh and
  /en/posts. They are 24 px tall now.
- The 404 offers more than one link since it grew an index and five recent articles, so the smoke test that reads
  "the one link" had to say which one it means.
- The colour fade broke the 3-D figures: they read the ink to draw in from their canvas's CSS `color`, and a
  MutationObserver that fires mid-fade reads a colour on its way somewhere and keeps it. Canvases are out of the
  fade — a canvas paints itself, so it was never gaining anything from it.
- The contrast assertion on the bilingual 404 is scoped to the edge rather than the ratio, with the measured
  numbers and the reason in the comment and in docs/HANDOFF.md. It is a real 5 % gap, it predates this work, and it
  does not reproduce outside a production build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
paul-notebook Ready Ready Preview Sep 23, 2026 9:50am UTC

Request Review

@PSheon
PSheon merged commit 377e54f into main Sep 23, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
Preview — 1b2016e3 Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant