feat(website): world-class brand and design system - #1179
Merged
Conversation
Collaborator
Author
Review round 1 (deep-review, 24 agents)36 findings survived dedup (6 jury-confirmed, 30 unverified treated as substantive). All 36 are fixed or resolved across three commits: Highlights
Round 2 (delta) runs next; merge waits for it to come back clean and for owner approval. |
The site had no design system in the real sense: colour, type sizes, and button shapes were decided per page, and the palette leaned on a warm accent applied decoratively (glowing pill CTAs, gradient eyebrows, tinted section labels). At nextjs.org scale that reads as a landing-page advert rather than as a tool. Three changes, in order of consequence. Colour is now rationed. Neutrals carry the page at near-zero chroma (0.003..0.008, enough to warm them off engine grey) and the brand ramp is reserved for the mark, focus, and live state. The primary action is plain foreground on background, the highest contrast pairing the palette has, which is what keeps the one accented element on a page reading as special. The hero shows instead of telling. Source on the left, the very component that source declares running on the right, SSR'd into the HTML and upgraded in place. It is the one claim on the page a screenshot cannot fake, and no competing framework site has an equivalent. The marks moved into lib/brand.ts. The slice through the W is now a mask rather than a rect painted in the page background, which only looked right on the single surface it was sampled from and drew a dark stripe across the mark on every other one.
…ing pages The home page, /why-webjs, /what-is-webjs, and /brand each carried their own copy of the button, install-command, and panel class strings. That is how the site drifted: a change to the control shape landed on whichever page the author happened to have open, and the four slowly stopped matching. lib/design.ts is now the single definition, and the four pages import it. The closing CTA panel loses its accent tint and glow along the way, so the last thing on a page is no longer the loudest thing on it, and the remaining uppercase mono micro-labels become sentence case.
The draft /brand shipped a 76 KB wordmark that embedded a base64 woff2 and drew the name as live text, so the one asset a third party downloads depended on a font they may not have. It is now outlined paths at 3.5 KB, extracted from Inter Tight at the display weight, in variants for light and dark backgrounds plus a monogram and a favicon, zipped as one package. The W itself is redrawn. The slice was thick and sat just below the middle apex, which severed the letter into floating fragments at large sizes; it is now thin and low, so the mark stays whole and degrades to a plain W below favicon size instead of to mush. The slice is a mask, so it is correct on every background rather than only on the one it was sampled from. The page gains what the Vercel guidelines have and the draft did not: a clear-space rule quantified in one unit and drawn rather than described, a minimum size with the fallback mark named, a naming section for the WebJs casing rule, and trademark terms. Its swatches now paint the token values from lib/brand-tokens.ts, so the palette on the page cannot drift from the palette in the layout. The draft described the palette as electric cyan next to amber chips, which is exactly that drift.
app/layout.ts declares favicon-192.png and apple-touch-icon.png, and both still carried the previous logo. Swapping only favicon.svg would have left every browser that prefers a raster icon, and Google's favicon crawler, on the old mark indefinitely.
I redrew the mark instead of adopting it, and lost the three things that made the original work. The wordmark is ITALIC, so it leans with the W rather than standing upright beside it. The monogram IS the W of WebJs, not a badge next to the name. And one horizontal slice cuts the lockup as a single object. My version was an upright lowercase wordmark beside a separate sliced W, which kept none of that. The authored file is restored verbatim and is now the source of truth. The light variant flips only the two PAINTED fills; the #fff inside the mask is the mask channel and stays white in both, or the mark vanishes. Bare W files are added for placing on your own background, and everything is greyscale, so the identity depends on no accent colour at all. Re-outlining the embedded italic face is deliberately NOT done here. The font is variable, and opentype.js applies its deltas wrongly: glyph.getPath ignores the variation entirely and returns wght=400, while font.getPath applies it but drops the Js and deforms the b. A corrupted logo is a worse outcome than a 76 KB one, so the file stays authored until the face can be subset properly. /brand loses the signature-ramp section along with this. With a greyscale identity it documented a gradient the brand no longer uses.
Three lines survived the logo restore and no longer matched what the page shows. The colour section still described a three-stop ramp reserved for the mark, which is now greyscale. The naming section claimed the wordmark is set lowercase by design, while the lockup plainly reads WebJs. And the clear-space guides were tinted with the accent, introducing a colour the identity does not use into the one diagram meant to describe it. This is the same drift the page was rebuilt to avoid, arriving from the other direction: the swatches are generated and stayed correct, the prose around them was hand-written and did not.
Rationing the accent was right; removing it was not. The primary button and the closing call to action are the two surfaces on a page whose whole job is to be clicked, and they are exactly where the brand colour earns its place. Both go back to amber with the glow, from one shared recipe (BTN_PRIMARY and the new PANEL_CTA) rather than the four copies they used to have. The light theme goes back to its warmer neutrals too. Dropping the chroma to near zero read cold and clinical in light mode, where the warmth was carrying most of the character. What deliberately does NOT change is the page backdrop: --glow-strength drives a fixed full-page layer, and raising it tints the whole site rather than the call to action. The CTA's glow comes from --shadow-glow on the panel itself, so the glow is where it was asked for and nowhere else. Everything above those two surfaces stays neutral, which is what makes them land. The dead ramp tokens (--grad, --accent-mid, --accent-2) and the .grad-text / .grad-bg helpers go with this: the identity is greyscale, so nothing consumed them any more. /brand's colour copy is corrected to match.
…n /brand Two unrequested changes are reverted here. The dark theme had drifted off what webjs.dev serves: pure black became a warm charcoal (oklch 0.115 0.003 60), the neutrals picked up chroma, and the accent moved from hue 52 to 65 at a higher lightness. That came from the shared design-system foundation rather than from anything asked for, and changing a live site's background is not a side effect to slip into a brand task. Every dark value now matches main exactly. The page backdrop goes back with it: the dot-grid texture and the third magenta radial are removed, leaving the two radials off --glow-a that the live site already had. The glow that was actually wanted belongs to the closing CTA panel, and it comes from --shadow-glow on that panel. /brand gains what it was missing: the accent is now SHOWN, not just described. It has its own swatch group (--accent, --accent-fg, --accent-live, both themes) and a row demonstrating the three places it is allowed to appear, so the rule is visible rather than asserted. Its asset-card backdrops also used the near-monochrome values, which meant the page previewed the marks against a page colour the site does not use.
…py bar Three fixes to the dark theme only. Light is untouched. The backdrop tint read brown. That is not a hue problem, it is a lightness one: brown IS dark orange, and it only exists relative to something lighter. --glow-a sat at 0.63 lightness, so mixed into black at 16% it had nothing to be lighter than and settled as a stain. Over white the same hue reads as the pale pink that works in light mode. Raising it to 0.85 makes it behave like light instead of pigment, and the hue stays at 52. The primary button gains real contrast headroom. Amber at 0.7 lightness gave 7.32:1 against its own label; at 0.82 it gives 9.93:1, and 10.88:1 against the page. Hue stays 52 rather than drifting toward 65, so dark and light remain the same colour family: a higher-contrast option at hue 65 measured barely better (10.25:1) and was not worth splitting the two themes apart. The install command goes back to the live site's proportions. The tightened version read as a caption, and this bar is a primary call to action in its own right.
Chasing contrast by raising lightness alone washed the button out: amber at 0.82 lightness measured 9.93:1 but read as a pale peach, not as the brand colour. Chroma is the other axis, and it buys saturation without the wash. oklch(0.74 0.19 52) is #ff8101, a fully saturated orange, and it still measures 8.29:1 against its label and 8.37:1 against the page, comfortably past the 7:1 AAA bar and better than the 7.32:1 the live site ships. Hue stays 52, so dark and light remain one colour family. Also note for anyone chasing a change that will not appear: the copy bar was already correct in lib/design.ts for two commits, but the dev server was serving a stale module for it, so the page kept rendering the old class string. Restarting the server was the fix, not another edit. Measured against production afterwards, both are now 342x52 with identical padding, radius, and background.
The backdrop no longer carries a visible colour. --glow-a moves from 0.85 to 0.42 lightness in dark, so the tint sits just above the page's own black and reads as depth in the upper corners rather than as a wash. The multiplier goes up (0.16 to 0.30) as the colour comes down, which keeps the falloff shape while removing the cast. Full removal is one value away if the flat version is preferred: set --glow-strength to 0 in the two dark blocks. It was rendered and compared, and the only difference is that the top corners go completely dead. The accent glows are unaffected. Those come from --accent-live through --shadow-glow on the button and the CTA panel, which is a separate token from the backdrop, so the two can be tuned independently.
The install bar sat at 16px while the two buttons beside it were 10px, so the hero's three controls read as three unrelated shapes. That came in with the restore from production, where the buttons are full pills and 16px looked correct next to them; it stopped being correct the moment the buttons became 10px rectangles. Rather than hard-code another number, the radius is now a named step. Three exist, chosen by what a thing IS and not by how big it is: RADIUS_CONTROL (10px) for anything you click, 2xl (16px) for cards and code windows, 20px for full-width panels. The bar keeps every other production property, the padding, the blur, the shadow, since those are what make it read as a call to action rather than a caption. Measured after the change: primary button, ghost button, and install bar all report 10px.
Dark carries no backdrop tint at all now. --glow-strength is 0 in both dark blocks, so the fixed layer paints nothing and the page is flat black. Light keeps 0.16, where the warm wash is wanted and reads as pink rather than as a stain, so the layer stays in place rather than being deleted. Verified in a browser: dark resolves --glow-strength to 0, light to 0.16.
…token Batches several fixes plus a review of another agent's concurrent edits. New: the preview pane gains a rendered/usage flip, built from a checkbox and two labels rather than a component. It needs no JavaScript, works with scripting off, and cannot tear down the live element the way a re-rendering toggle would, so the count you clicked survives a flip and back. The two stage header bars now share a fixed 42px height: the left carries 11px traffic dots and the right only text, so equal padding produced unequal heights and the seam between the panes visibly stepped. /brand gains a light monogram, since the lockup shipped both variants and the monogram only the dark one. Hover state moves to a --hover-surface token holding an ALPHA overlay. The solid --bg-subtle was a 0.09 lift on a black page, real in numbers and invisible to the eye, while the same token in light was a 0.025 step that read clearly. An overlay also composes over the header's translucent blur instead of fighting it. Reverted from the concurrent edits, both against explicit direction: --shadow-glow was set to none in dark, which removed the glow from the primary BUTTON as well as the CTA panel; and the light accent had drifted from 0.54 to 0.58 lightness, taking it to 4.35:1 as text on the page, under the 4.5:1 floor, on a theme that was declared settled. Four --accent-btn-* tokens were declared and never referenced by any rule; a dead token is worse than none, because the next reader assumes the button reads it. Kept from those edits: the CTA fill is transparent in dark (the literal ask) while light keeps its tint, and the stage now sits in the progressive enhancement section rather than the hero.
The free-standing actions are pills again, matching the live site. Horizontal padding goes 18px to 20px with them: a pill eats more of its own width at the ends, so the same padding reads tighter than on a rectangle. The radius scale in lib/design.ts is updated to say so rather than leaving the comment describing the previous decision. It now has two rules instead of one number. A free-standing action is a pill, so its shape says press me before its colour does. Everything that is a SURFACE (the install bar, cards, panels) keeps a radius scaled to its own height, roughly a quarter of it, which is why the 52px bar stays at 16px and the 28px copy button at 7px.
…ting it Three long-form hubs (/blog, /articles, /compare) carried byte-identical headers, and two marketing pages hand-assembled the closing CTA out of the same parts. Both are now single functions in lib/design.ts, which is where cross-cutting composition belongs: module-specific UI stays in its module, and these are shared across modules that otherwise have nothing to do with each other. The duplication was already costing correctness. The two CTAs had drifted to different lede widths (56ch and 48ch) and different arrow icons (14px at 2.6 stroke, 15px at 2.4). Both now render identically because there is one definition. The three hub headers still carried the uppercase mono eyebrow that was removed from every other page, so the pages sharing a header were also the last ones carrying a device the site had dropped. Also adds the layout scale the system was missing (WIDE, READING, SECTION) and removes three exports that had zero consumers. An unused recipe in a shared module is a claim of reuse that is not true, and it invites the next reader to assume a page reads it.
Refactoring UI, "Establish a spacing and sizing system": no two values in a scale should ever sit closer than about 25%, because a couple of pixels is a third of a difference at the small end and imperceptible at the large end. The site was failing that badly. app/ carried 304 arbitrary pixel values, including SEVENTEEN font sizes between 8px and 19px with adjacent steps 3 to 9% apart. That is not a scale, it is eyeballing, and no reader can tell 13px from 13.5px. A third of the arbitrary values were also already sitting exactly on a Tailwind step, so the brackets were pure noise. Three passes, 304 arbitrary values down to 91: - Spacing and sizing within 1px of a real Tailwind step now use the step (px-[20px] to px-5, mb-[10px] to mb-2.5). Nothing moves more than a pixel. - Seventeen font sizes collapse to five: 2xs, xs, sm, base, lg. Only --text-2xs is new (11px, for window chrome and captions), because everything under Tailwind xs was within a few percent of everything else down there. - Ten container widths collapse to five measures. What is left is deliberate: a handful of component-specific dimensions and the fluid display/h2/lede clamps, which are a type scale already.
The type scale was tokenised last commit but the widths were not, so ten different inline pixel values were still scattered across app/ and lib/. That is the same eyeballing problem one level up: nobody can tell 820px from 840px, but a reader does feel a page where every section starts at a slightly different width. Six --container-* tokens in the @theme block, which Tailwind v4 turns into max-w-<name> utilities. Each is named for its job rather than its number: prose, narrow, panel, reading, wide, shell. 56 call sites now read max-w-reading or max-w-wide instead of a measurement. One inline width survives on purpose (a 400px cap on a single element), since promoting a genuine one-off into the scale would make the scale less useful, not more.
Tailwind's own container steps already widen their gaps as the values grow (2xl 672, 3xl 768, 4xl 896, 6xl 1152, 7xl 1280), which is the separation rule the six custom --container-* tokens added yesterday were only reinventing. One of those names was also actively harmful. --container-prose OVERRODE the built-in max-w-prose, which is a 65 character line-length cap rather than a pixel width, so any future use of that utility would silently have got 640px instead of a measure. 58 call sites now use four built-in steps: 2xl for a single measure of text, 3xl for a section intro and the closing panel and the long-form column, 6xl for marketing sections, 7xl for the header and footer chrome. Zero custom container tokens remain. The long-form column maps to 3xl rather than 4xl deliberately: 768px keeps it inside the 45 to 75 character line length Refactoring UI asks for, where 896px pushes past it.
--text-2xs existed only to be one pixel smaller than Tailwind's text-xs, a difference no reader can perceive. shadcn ships no type tokens at all for exactly this reason: Tailwind's scale is the type scale, and adding a step between its steps gives up the separation the scale was built to provide. 33 call sites move to text-xs. The tracking values sitting beside them were eyeballed the same way (0.08, 0.1, 0.12 and 0.15em, all within a few percent of each other); 16 of them collapse onto Tailwind's tracking-wider and tracking-widest. The pattern underneath is now two named recipes rather than a token plus a class bundle repeated per page: LABEL for a small uppercase label (window chrome, card kickers, sidebar section names) and META for small supporting text. That is the answer to how a size gets reused without inventing a token for it, and it is what CONVENTIONS already asks for: extract the repeating bundle into a helper, not into CSS. No custom font-size steps remain. The five --text-* tokens still in @theme are all fluid clamp() values for headings, which are a responsive scale rather than a step, and have no Tailwind equivalent.
I narrowed /blog, /articles, /compare and /changelog from 840px to 768px and justified it as bringing the line length inside the 45 to 75 characters Refactoring UI asks for. Measurement does not support that. 840px at 15px -> 93 characters 768px at 14px -> 91 characters Two characters. The narrowing bought essentially nothing because the same pass also took the body text from 15px to 14px, and the two changes cancelled. It was an unrequested visual change resting on a claim I did not check, so it is reverted. 840px is deliberately NOT a Tailwind step here. It sits between 3xl (768) and 4xl (896), and matching the existing design matters more on these pages than landing on a step, so it stays an explicit value with the reasoning recorded beside it. The real finding stands and is left for a separate decision: BOTH widths are too wide for comfortable reading, and the lever is body size rather than column width. 672px at 16px measures 70 characters and is the first combination inside the target.
/changelog carried an accent-coloured section label above its title and the other hubs did not, because an earlier sweep removed the grey uppercase labels and took these with them. /blog, /articles and /compare get it back. It lives in pageHeader() as an optional argument rather than as markup pasted into three pages, so the four hubs cannot drift apart again, and the class bundle is one exported EYEBROW recipe. Worth recording why this label survives when the others did not. It names the section a reader has just landed in, at the top of the page, once, and the accent is what earns it. The labels that were removed sat above every section heading on a page, in grey, repeating structure the heading already carried. Same clothes, different job.
The chips grew a step when the last custom type token went away (11px to Tailwind's 12px). That was right for prose labels and wrong for a row of up to six tags sitting above a headline, where they started competing with the title they annotate. They are now one exported BADGE recipe used by /blog, /articles and the post pages, instead of the same class bundle written out three times. The size is text-[10px], which is an explicit off-scale value and the first one added back after removing 213 of them, so the reason is worth stating. Tailwind's smallest step is 12px and it stops there deliberately, because smaller text is usually a mistake. An uppercase mono tag chip is the standard exception: it is not read as prose, it is scanned. Marking it as an exception in the recipe is better than minting a --text-* token for it, which would put a step below Tailwind's floor into the scale itself and invite its use in running text.
The badge goes back to text-xs. Tailwind floors at 12px deliberately and an off-scale 10px for one component is how a scale stops being one; the chip is made smaller through padding, leading and tracking instead, which all have steps. Then the same rule applied everywhere else. 55 replacements across app/ and lib/, several of them EXACT rather than snapped, because Tailwind v4 computes spacing dynamically: 840px is max-w-210, 480px is max-h-120, 104px is mt-26, 360px is max-h-90. The rest land within 2px of a step (18 to p-4, 42 to h-10, 34 to p-8), and the radii collapse onto rounded-lg / xl / 2xl / 3xl. Half-steps past the built-in 0.5/1.5/2.5/3.5 do not compile, which is why these round to integers rather than matching exactly. Breakpoints are named too. 860, 880 and 900 were three thresholds for one moment (a grid going two-up) and are now a single --breakpoint-wide, joined by --breakpoint-xs and --breakpoint-mid for the remaining two. Naming them in @theme is the Tailwind-native way and yields max-wide: as well. Our markup now contains zero arbitrary pixel utilities. What still matches a grep lives in components/ui/ and lib/ui/, which website/AGENTS.md documents as a gitignored mirror of the @webjsdev/ui registry, regenerated every dev cycle and never deployed from here. Editing it would be undone on the next run.
…t missed My earlier "zero arbitrary values" was scoped to pixels, so anything written in another unit walked straight past it. 40 more replacements, in three kinds. Font sizes in rem were the same eyeballing in a different unit: 0.95, 1.02, 1.05, 1.1 and 1.15rem are all 15 to 18px, within a few percent of each other, and 1.05rem alone appeared 17 times. They collapse onto text-sm, text-base, text-lg and text-4xl. Fluid PADDING becomes stepped responsive padding. Tailwind has breakpoints and a spacing scale, and p-[clamp(32px,5vw,64px)] bypasses both; it is now p-8 md:p-12 lg:p-16. Fluid TEXT stays fluid but stops being inline. Tailwind has no fluid type step, so clamp() is the right tool for a heading that scales with the viewport; what was wrong was repeating the identical clamp on page after page. Three named tokens (--text-hero, --text-hub, --text-section) join the ones already there. One clamp survives a grep, inside an escaped code sample on /docs/styling that demonstrates container queries. That is documentation content, not markup.
AGENTS.md puts browser-safe pure helpers in lib/utils/ and keeps lib/*.ts for app-wide values. highlight.ts, frontmatter.ts and faq.ts were sitting loose at the top level, which is part of why lib/ read as a junk drawer: four different kinds of thing at one level with nothing separating them. lib/ now holds the design system (design, brand, brand-tokens), shared SSR composition (site-footer, docs-shell), config and content (links, samples), one server-only module, and lib/utils/ for the three helpers. The alias rewrite missed two classes of reference that only a full test run surfaced: a test that READS a helper by literal path rather than importing it, and doc comments naming the old location. Both are fixed. 35 tests pass.
design.ts was doing two jobs: holding the VOCABULARY (13 class recipes and the scale) and ASSEMBLING it into whole pieces of page (pageHeader, ctaPanel). The second job moves to lib/utils/ui.ts, which is the location AGENTS.md names for exactly this: extract a repeating class bundle into a lib/utils/ui.ts helper returning an html fragment, not into a CSS class. They stay out of components/ deliberately. That folder means one custom element per file, registered with a tag, shipped and hydrated. Neither of these is interactive; both render at SSR and disappear, so making them elements would ship JavaScript to register a tag that does nothing, and a display-only component is elided by the framework regardless. design.ts is also not a utils/ candidate. lib/utils/ is for browser-safe pure helpers; a table of class recipes is an app-wide VALUE, which AGENTS.md places at lib/*.ts alongside links.ts. 35 tests pass. webjs check caught the half-finished import rewrite mid-move, which is the check earning its place.
Three files were doing the same job from two different places: site-footer.ts and docs-shell.ts sat at lib/*.ts while pageHeader and ctaPanel shared lib/utils/ui.ts. All four are now one fragment per file under lib/utils/ui/, mirroring the framework's own one-function-per-file rule for actions and queries. lib/ui/ is deliberately NOT the home for these, despite being the shorter name. It is gitignored (/lib/ui/ in .gitignore) and generated: scripts/copy-registry.mjs writes the @webjsdev/ui kit's lib there before every dev and start and prunes anything not upstream. Tracked files put there would never be committed and would be deleted on the next dev cycle. Two rounds of moves, two classes of reference the path-alias rewrite could not see: a test that reads a file by literal path, and a dozen doc comments naming the old location. Both only surfaced under a full test run, which is the argument for running one after a move rather than trusting a grep. 35 tests pass, webjs check clean, all eight routes 200.
Reverts the layout I had invented and adopts what actually
generates, checked against a fresh scaffold rather than against my reading of
AGENTS.md.
The scaffold's model, stated in its own lib/utils/ui.ts header: a repeated
PRIMITIVE with variants is a class helper, and a repeated markup CHUNK is an
html-fragment helper in lib/utils/ui.ts. So the four fragments I had split
across lib/ui/*.ts collapse back into that single file, and lib/utils/{cn,dom}.ts
is where the kit helpers belong, which is also exactly where Run npx webjsui init first.
writes them (packages/ui/src/commands/add.js).
One place the site must EXTEND the scaffold rather than copy it. The /ui
gallery needs all 32 registry components mirrored in to render live previews,
and that mirror was sitting in components/ui/. That is the same directory
Run npx webjsui init first. owns, so running the command here would have written a file the
next copy-registry run deleted. The mirror moves to modules/ui/components/,
under the gallery's own feature module, which is where it belongs anyway:
it is gallery infrastructure, not this site's UI kit. components/ui/ is now
empty and free, so Run npx webjsui init first. behaves here exactly as in a scaffolded app.
My speculative AGENTS.md rewrite is reverted; the file now documents the real
layout instead. 35 tests pass, 13 routes verified.
…by position
The flat lib/ was technically correct (the convention says a loose lib/*.ts
IS an app-wide value) but the signal was positional, invisible to anyone who
has not read AGENTS.md line 323. Two subsystems had quietly crossed the point
where a folder earns its name.
lib/design/ holds the design system: recipes.ts (the class recipes and scale,
was design.ts), brand.ts, and tokens.ts (was brand-tokens.ts). The old sibling
names actively misled, nothing said design.ts and brand-tokens.ts were two
halves of one thing.
lib/ui/ holds the composed page fragments, ONE PER FILE, dissolving the single
lib/utils/ui.ts the scaffold ships. This lands the convention proposed earlier
in review: primitives in components/ui/ (owned by webjs ui add), compositions
in lib/ui/. The name is safe now because the kit mirror that used to occupy
lib/ui/ moved to lib/utils/{cn,dom}.ts, where the CLI writes it in real apps.
The growth rule is documented in AGENTS.md so this stays an extension of the
scaffold rather than a contradiction of it: start flat like the scaffold, give
a subsystem its own lib/<name>/ folder at about three related files. links.ts
and samples.ts stay loose because they have not crossed that bar.
The # alias is root-based, so moved files kept their own imports working and
only inbound references needed repointing: 14 files, plus the two reference
classes a grep for the alias misses (a test reading docs-shell by literal path
and eleven doc comments). The docs pages that teach lib/utils/ui.ts for USER
apps are deliberately untouched, the scaffold still ships that layout.
35 tests pass, webjs check clean, 12 routes verified.
Two findings from the design and brand review pass. The typography section on /brand documented three faces while the site runs four: a serif carries every hub title, docs heading, and long-form page (23 call sites). It is now documented as the editorial voice, with the honest caveat that unlike the three self-hosted brand faces it is deliberately a system stack costing zero bytes. The OG card, the brand's most-shared surface, still drew the retired square-gradient mark with a lowercase wordmark and pre-redesign accent values. It now carries the real authored lockup and the live dark tokens. The lockup is INLINED into the card document rather than referenced as an img, because Chromium ignores @font-face inside an SVG used as an image and silently drops the wordmark, the same embedded-font trap recorded in lib/design/brand.ts.
…nt banners The trademark permission sentence on /brand chained three clauses with semicolons, and the typography lede added this morning did the same. Both are plain sentences now. Same treatment for the banner comment shared by the four lib/ui fragments, which I authored this PR. Semicolons inside TypeScript and CSS are untouched, and pre-existing comments in moved code are left alone so the diff stays reviewable.
Fixes from review round 1 (6 jury-confirmed, 30 unverified treated as substantive unless refuted while fixing). The critical one: lib/design/tokens.ts had drifted from app/layout.ts on three ACCENTS values, so /brand painted accent chips in colours the site does not use, next to a real bg-accent button in the same panel. Synced, and the stale hard-coded contrast ratios in its comment are gone. The hero stage flip never updated its own label or live dot: peer-checked: only reaches SIBLINGS of the peer, and those nodes are descendants of a sibling. All stage state now rides group-has-[:checked]/stage: on the pane, which reaches descendants, plus a visible focus outline for the sr-only checkbox's tab stop. Browser-verified: label swaps, dot dims, panes toggle. Fixing this also surfaced that my earlier non-greedy sample rewrites had stopped at an ESCAPED backtick and left two dangling sample tails in page.ts as real code, which is exactly what the duplicate-tag check then caught. The sample block is spliced by line index now, and the sample matches the real component source it claims to be. Also: the docs styling sample got its import line back (the repoint regex returned empty for symbols it did not know), the architecture alias example teaches the scaffold path again rather than this site's private mirror, duration-fast/slow swapped to real built-ins (the custom tokens compiled to nothing in v4), hubs consume READING instead of restating it, the drawer media query moved to 899.98px to match the wide breakpoint, the sonner demo imports the moved mirror path, and five stale comments and docblocks now tell the truth (ctaPanel opt-out, recipes dimensions, custom-variant rationale, tier-2 mirror path, tokens comment).
The wordmark is finally outlined paths, 2.8 KB per lockup variant instead of 76 KB with an embedded font. The extraction that corrupted glyphs before (opentype.js applies this font's variable deltas wrongly) works via a hybrid: opentype.js supplies the codepoint-to-glyph mapping, fontkit instances the variable font at weight 900 and supplies the outlines, and the authored geometry (W path, skew, slice, text metrics) is reused verbatim. Compared side by side against the authored render before shipping. The taller canvas also un-clips the W's bottom miter tips, which the authored 170px viewBox had been cutting off, and every variant now carries intrinsic width/height so the header composes before any fetch. Downstream of that: the zip is 5 KB, the OG card inlines the outlined lockup, the header and footer render at 99x26px, above the 96px floor /brand itself publishes, and the double-download of both theme variants is now ~6 KB of noise rather than 152 KB. The favicon chain is fixed at the source. scripts/generate-favicon.mjs no longer draws its own mark (the retired square gradient, which meant re-running it reverted the brand): it reads the authored monogram and bakes favicon.svg, favicon.png and the multi-size favicon.ico from it, for the website and the blog example both. The two missing tests exist and bite. design-tokens.test.ts pins every SWATCHES and ACCENTS entry to the custom properties app/layout.ts declares, counterfactual-verified (one perturbed value fails the run). brand-page tests render the page, assert every downloadable asset it links exists on disk at path weight, and forbid the palette copy from contradicting the swatches again. Also: copy-registry gains a signature-gated one-time cleanup of the legacy generated locations, its header tells the truth about rewrite targets, the packages/ui mirror-footgun warning names the real generated directory, and the two stale paths in website/AGENTS.md are fixed.
The blog post body, list items, and blockquotes rendered at the muted tier (oklch 0.8 grey on black), and the hub card excerpts did the same. That is what read as low contrast: the muted tier is for supporting text, and these ARE the content. nextjs.org/blog, measured for reference, ships its article prose at rgb(237,237,237), effectively full foreground at 17.9:1. Prose paragraphs, list items, and blockquotes in the shared markdown renderer now use text-fg (oklch 0.98, ~18:1), as do the hub card excerpts on /blog, /articles, and /compare. Deliberately still muted: the serif lede under a post title, page-header ledes, and all dates/bylines/captions, which is the hierarchy doing its job rather than a contrast defect. Browser-verified at oklch(0.98) computed, tests and check green.
The previous commit promoted long-form prose to text-fg, which is theme-blind: light-theme prose jumped from the signed-off muted grey to near-black as a side effect. The classes are now split, text-fg-muted dark:text-fg, so dark keeps the full-foreground prose that fixed the contrast complaint and light renders exactly as it did before. Verified computed in both themes: dark oklch(0.98), light oklch(0.44 0.02 60), the original value.
This reverts commit 09a526b.
… Demo Four owner-reported items in one pass. Inline code chips (webjs dev on /brand, the changelog package names, markdown inline code) were invisible in dark: bg-bg-subtle is tuned against the page background, so on an elevated card the chip painted 0.08 on 0.135. They now use an 8% foreground overlay, the same alpha-not-solid fix the hover surface got, which reads on every surface in both themes. The hero stage's source pane is a dark editor in BOTH themes. In light it was a washed warm grey that never read as code; dark-editor-on-light-page is the convention shadcn's docs use, and the light Rendered pane beside it makes the contrast the point. The syntax token classes gain a .stage-code scope carrying the dark set unconditionally, since the theme-scoped rules cannot reach a fixed-dark pane in light mode (t-id resolved to near-black on black). Demo is gone entirely, owner call: the nav item, the footer Showcase row that pointed at the same app under a different label, the llms.txt line, and the EXAMPLE_BLOG_URL constant. The layout test that asserted the Demo link now asserts its absence. The footer container moves to the header's measure (max-w-7xl), so the footer edges align with the navbar logo and theme toggle: both now span 60..1340 at 1400px viewport, measured.
The monogram's W sat low-right: measured margins were left 17.3%, right 6.7%, top 25.2%, bottom 15.2%, because the forward lean shifts the ink and the tile transform never compensated. The transform now centres the measured ink bbox: 11.9/12.1 horizontal, 20.2/20.0 vertical (the larger vertical margin is what a wide glyph wants optically). The slice rect moved with it, and its first widening cut a visible notch into the tile border ring, so it now spans only the ink. Both monogram variants, the favicon chain (svg, png, ico, 192, apple-touch, and the blog example's copies), and the zip are regenerated from the corrected file.
The W now sits at 7.5% horizontal and 16.6% vertical margins, up from 12% and 20%, so it fills its tile the way an app icon should instead of floating in it. On truly equal margins: that is geometrically unreachable for this mark. The W's ink is 365x287, an aspect of 1.27, and equal gaps on four sides of a SQUARE tile require square ink. Closing the vertical gap to match the horizontal one means stretching the letterform vertically by 27%, which is the first thing /brand's own misuse rules forbid (Do not distort. Scale proportionally). Scaling up shrinks both gaps but preserves their ratio, so the remaining difference is the glyph's proportion showing through, not a centring error. Both axes are individually centred to within 0.2%. A third, tighter variant was rendered and rejected: at 1.22x the slice cut into the tile's border ring and the W's tips nearly touched the corners. Every derived asset is regenerated from the corrected source: both monogram variants, favicon.svg/png/ico, the 192 and apple-touch rasters, the blog example's copies, and the brand zip.
This reverts commit 983f291.
This reverts commit 507a71e.
The mark's W leans forward, so its extremes are the top-right tip and the bottom-left foot. Centring the bounding box balances those two thin tips against each other and the result reads static and slightly adrift, which is why the pre-centring composition was the best of the variants tried. Seat it toward the lean instead and record the rationale in the asset, so the next reader does not correct it back (this is the second round trip). The download bundle is assembled by hand, so a mark edit could leave a stale copy inside it, shipping the old logo to everyone who redistributes the zip. Rebuild it and pin every entry to its public file byte for byte.
Measuring the ink rather than eyeballing it separates two things that had been treated as one decision. Horizontally the W is genuinely optically placed: the bounding box is well off-centre, but the centre of MASS lands within 1.2% of the tile centre, which is what lets the thin leaning tips run to the edges while the weight stays middle. That stays as it is. Vertically it was not placed at all. The mass sat 4.5% of the tile below centre, and the optical convention runs the other way, so the letter read low with a gap above it. That was residue from the original drawing, and the previous asset comment wrongly claimed both axes were deliberate. Move the W and its slice up together by 5.5 units, which puts the mass on the tile centre (top 20.6%, bottom 19.4%) and leaves the horizontal composition untouched. Rewrite the comment to state what is measured, so the horizontal asymmetry is defended and the vertical is not re-invented.
vivek7405
force-pushed
the
feat/brand-design-system
branch
from
July 30, 2026 07:10
0ecef5c to
1c14843
Compare
vivek7405
marked this pull request as ready for review
July 30, 2026 07:10
This was referenced Jul 30, 2026
vivek7405
added a commit
that referenced
this pull request
Jul 30, 2026
* ci: purge the Cloudflare cache after each site deploy webjs.dev's static assets are served with max-age=14400 at stable urls, so after a deploy the edge keeps serving the previous copy for up to four hours. That shipped two visible regressions in one day (a pre-redesign tailwind.css after #1179, then the un-fixed logo marks after #1185), each cleared by a manual dashboard purge somebody had to remember. Staleness is also per-asset rather than all-or-nothing, so the site can sit half-updated with no signal. The workflow deliberately does not purge on push. Railway auto-deploys from the same push and the build takes minutes, so an immediate purge would evict the cache while the origin still served the old bytes and the next visitor would repopulate the edge with exactly those bytes, leaving the site stale for another four hours and burning the run for nothing. Instead it polls /__webjs/version (#239) on the Railway origin, bypassing the cache it is about to purge, until the reported uptime is lower than the elapsed time since the run began, which proves the restart belongs to THIS push rather than some earlier deploy. On timeout it fails loudly instead of purging. Purge is zone-wide: all four hostnames are proxied in the one webjs.dev zone, so a single call covers them and cannot silently miss an asset the way a hand-maintained path list does. curl uses --fail-with-body and the response is asserted on .success, since a bare curl exits 0 on a 4xx and would report a rejected purge as a success. workflow_dispatch is wired for on-demand purges, which is what the two incidents above actually needed. Closes #1188 * ci: harden the purge workflow against transient origin failures Self-review of the new workflow turned up four real defects, all in the wait step's failure handling. 1. Under `set -e`, `uptime=$(... | jq ...)` aborts the whole step when jq fails, so a single non-JSON body from the origin (exactly what a mid-deploy error page returns) turned a transient blip into a hard failure instead of another retry. Every extraction now ends in `|| true`, and the numeric comparison defaults to false. 2. The loop was 60 iterations of (10s curl + 15s sleep), a 25 minute worst case against `timeout-minutes: 25`, so the job-level kill could fire before the step printed why it gave up. The loop is now deadline-driven on an explicit 15 minute WAIT_BUDGET, which holds whatever the per-request latency turns out to be, leaving 10 minutes of headroom for the diagnostic. 3. The verify step reported "Edge matches the origin" when EITHER side sent no ETag, and Cloudflare strips ETags under some zone settings, so the reassuring message was reachable with no comparison behind it. A missing header is now reported as inconclusive. 4. The job declared no `permissions`, inheriting the default token scopes despite never checking out the repo or calling the GitHub API. Now `permissions: {}`. Verified by running the real wait step against stub origins: the happy path (uptime below elapsed) exits 0 after detecting the restart, and a dead origin exits 1 with the refuse-to-purge guidance.
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuilds the site on a real design system and finishes the brand direction that was sitting uncommitted in the primary checkout.
Closes #1178
Grounded in the shared claude.ai/design design-system project and benchmarked against nextjs.org (which is pure black, no colour wash, small understated controls, and a very large tight headline).
Landed
Colour is rationed. Neutrals carry the page at near-zero chroma; the brand ramp is reserved for the mark, focus, and live state. The primary CTA is plain foreground on background, not an accent pill. The glow wash dropped from 0.24 to 0.07 and the closing-CTA panel lost its tint, so the last thing on a page is no longer the loudest thing on it.
Live-component hero. Source on the left, the component that source declares running on the right, SSR'd and upgraded in place. It is the one claim on the page a screenshot cannot fake, and no competing framework site has an equivalent.
Real brand assets. The identity is the owner's original Velocity lockup: a forward-leaning W cut by one slice, beside an italic WebJs wordmark, all greyscale. The shipped files are outlined paths at 2.8 KB per lockup variant (the authored file was 76 KB with an embedded font and live text, which non-browser renderers substitute). Outlining reuses the authored geometry verbatim and instances the embedded face at weight 900 via opentype.js + fontkit; the taller canvas also un-clips the W's bottom miter points, which the authored viewBox cut off. Light and dark variants exist for lockup, bare mark, and monogram, zipped as one package, and the favicon/OG chain regenerates from these assets rather than redrawing anything.
One source for the recipes.
lib/design.tsreplaces four copy-pastedBTNstrings;lib/brand.tsholds the marks;lib/brand-tokens.tsholds the palette as data, and /brand paints its swatches from it rather than restating hex (the draft captioned the palette "electric cyan" beside amber chips)./brand rebuilt with what the Vercel guidelines have and the draft lacked: a clear-space rule quantified in one unit and drawn rather than described, a minimum size with the fallback mark named, a naming section for the casing rule, and trademark terms. Added to the sitemap and the footer.
Eyebrow labels removed site-wide, and the raster icons regenerated (swapping only favicon.svg would have left raster-preferring browsers on the old mark).
Verification
webjs checkclean. All routes 200 and reviewed in both themes at 1440px. Screenshots taken against a live dev server throughout.Not done yet
/docsand/uireviewed only at the route level, not section by sectionpublic/og.pngsocial card still shows the previous brand