Skip to content

feat(website): world-class brand and design system - #1179

Merged
vivek7405 merged 45 commits into
mainfrom
feat/brand-design-system
Jul 30, 2026
Merged

feat(website): world-class brand and design system#1179
vivek7405 merged 45 commits into
mainfrom
feat/brand-design-system

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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.ts replaces four copy-pasted BTN strings; lib/brand.ts holds the marks; lib/brand-tokens.ts holds 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 check clean. All routes 200 and reviewed in both themes at 1440px. Screenshots taken against a live dev server throughout.

Not done yet

  • /docs and /ui reviewed only at the route level, not section by section
  • public/og.png social card still shows the previous brand
  • No automated test pins the design tokens

@vivek7405

Copy link
Copy Markdown
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: 4ade2863 (code), 33e378e8 (assets and infra), plus 3194973d earlier (design pass: serif face, OG card).

Highlights

  • tokens.ts had drifted from layout.ts on three ACCENTS values, so /brand painted chips in colours the site does not use. Synced, and now pinned by test/ssr/design-tokens.test.ts (counterfactual-verified).
  • The hero stage flip never updated its label or live dot: peer-checked: reaches siblings only. Moved to group-has-[:checked]/stage:, browser-verified.
  • The wordmark is genuinely outlined now, 2.8 KB per variant (hybrid opentype.js cmap + fontkit variation instancing). The authored viewBox had been clipping the W's bottom miters; no longer.
  • generate-favicon.mjs no longer draws the retired mark, it reads the authored monogram, so re-running it can never revert the brand. favicon.ico/png regenerated.
  • Two of my own earlier seds had eaten an import line in a docs sample and left dangling sample tails as real code; both caught and fixed.

Round 2 (delta) runs next; merge waits for it to come back clean and for owner approval.

vivek7405 added 29 commits July 30, 2026 12:40
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.
vivek7405 added 16 commits July 30, 2026 12:40
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.
… 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.
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
vivek7405 force-pushed the feat/brand-design-system branch from 0ecef5c to 1c14843 Compare July 30, 2026 07:10
@vivek7405
vivek7405 marked this pull request as ready for review July 30, 2026 07:10
@vivek7405
vivek7405 merged commit 5f1c62f into main Jul 30, 2026
10 checks passed
@vivek7405
vivek7405 deleted the feat/brand-design-system branch July 30, 2026 07:16
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.
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.

Ship a world-class brand and design system for the website

1 participant