Skip to content

Brand the gallery app like the website, keep the scaffold neutral #1382

Description

@vivek7405

Anchors in this body are dated to HEAD 034d6a0e. Re-verify a line number before trusting it.

Problem

gallery.webjs.dev and webjs.dev read as two different products. The website carries a full brand system (a warm-orange accent, light-dark() oklch tokens, self-hosted Inter Tight / Inter / JetBrains Mono, an ambient glow treatment), while the gallery ships a cool-grey neutral shell with a monospaced body face served off the Google Fonts CDN. The favicon was reconciled in #1375 / #1377 / #1379 so the tab strip matches, but everything below the tab still does not.

The gallery must stay neutral in one specific respect, and that constraint is what makes the task non-trivial. gallery/ is the single canonical source of the feature gallery that webjs create copies into every scaffolded app. The neutral palette there is deliberate. An app-building AI agent reads the gallery for idioms, then runs npm run gallery:clear, and a vividly branded example outweighs a prose instruction telling the agent to pick its own design.

Those two goals do not conflict, because gallery/ already has a clean split between what is scaffold payload and what exists only because the gallery is also a live deployed app.

Verified against HEAD, with corrections to the previous statement of this issue

Every claim below was re-checked at 034d6a0e. Four things in the earlier draft of this issue were wrong or incomplete and are corrected here.

Correct. packages/cli/lib/gallery-shell-files.js exports GALLERY_APP_SHELL_FILES, frozen, exactly four entries, app/layout.ts, app/page.ts, components/theme-toggle.ts, lib/utils/cn.ts, plus isGalleryAppShellFile(relPath).

Correct. copyGallery() at packages/cli/lib/create.js:131 loops ['app', 'modules', 'test', 'components', 'lib'] (the loop is at L145) and passes filter: (src) => !isGalleryAppShellFile(relative(galleryDir, src)). gallery/public/ is not in that list, so it is not payload.

Correct. scripts/sync-scaffold-gallery.mjs applies the same isGalleryAppShellFile filter over ['app', 'modules', 'components', 'lib', 'test']. The two lists hold the same five members in a different order, and cp is called once per member, so order is not observable. This is NOT a latent bug. It is still worth pinning, because the two lists are duplicated literals that could drift in membership, and the new repo-health test below asserts they agree as sets.

Correction 1, the theme storage key. The earlier statement said the gallery reads localStorage.getItem('theme') while the scaffold reads 'webjs_theme', and that the two never meet so the gallery's key should be left alone. That is wrong. The mismatch is INSIDE the gallery. gallery/app/layout.ts:39 reads 'theme', while gallery/components/theme-toggle.ts writes 'webjs_theme' at L18, L29 and L30. So the gallery's own pre-paint bootstrap never sees what its own toggle stored, and a reader who picked dark gets a light first paint followed by a flash on hydration. website/lib/theme.ts:22 and packages/cli/lib/create.js:1227 both use 'webjs_theme'. The gallery is the only outlier. One of the two files must move or the gallery keeps flashing, and Decision 10 settles which.

Correction 2, the fonts are already half-wired and broken. gallery/public/input.css:76-78 already declares @font-face for Inter Tight, Inter and JetBrains Mono with src: url('/public/fonts/<file>.woff2'). gallery/public/fonts/ does not exist. Meanwhile gallery/app/layout.ts:60-62 loads Bricolage Grotesque and JetBrains Mono from the Google Fonts CDN. So the gallery ships three dead @font-face rules pointing at 404s, one of which claims the same family the CDN stylesheet also declares and wins the cascade by document order. Shipping gallery/public/fonts/ fixes a live defect rather than only adding polish.

Correction 3, #1380 is closed, not open. gh api repos/webjsdev/webjs/issues/1380 --jq .state reports closed. It changed website/test/ssr/seo-infra.test.ts only. Its surface is disjoint from this issue's (gallery/app/layout.ts, gallery/app/page.ts, gallery/components/theme-toggle.ts, gallery/public/**). No ordering constraint applies.

Correction 4, the token-name question was never posed, and it decides the whole change. See the Design section. Renaming the gallery's tokens to the website's names would require editing scaffold payload, so the tokens keep their shadcn NAMES and change only their VALUES.

Additional verified anchors.

  • website/app/layout.ts:202-264 is the :root token block. --fg L203, --fg-muted L204, --bg L206, --bg-elev L207, --border L210, --border-strong L211, --accent L212 (light-dark(oklch(0.54 0.16 52), oklch(0.78 0.18 58))), --accent-fg L214, --accent-tint L232, the shadow set L250-254, --glow-strength L256, the font stack L257-260. The earlier draft said "L203 to L260", which is right to within the closing brace.
  • website/public/input.css @theme at L30, @theme inline at L119, the explanatory comment at L94-118, the @font-face block at L214-216.
  • website/app/layout.ts:126-128 preloads the three woff2 files with BARE public/ paths, no asset(), which is the documented rule for an asset a CSS url() fetches.
  • gallery/app/layout.ts:43-57 is the header-measure script. It bails unless the header computes to position: fixed, and today the gallery's header is in normal flow, so --header-h stays 0px and the padding-top: var(--header-h) at L97 is a no-op.
  • test/repo-health/gallery-favicon.test.mjs asserts the gallery links, serves and byte-matches the brand favicon, and that app/icon.ts stays unlinked. Nothing in this plan touches metadata.icons, so it stays green.

Correction 5, the logo is not an inline SVG and its gradient tokens are dead. brandLockup() at website/lib/design/brand.ts:43 renders TWO <img> tags, at /public/brand/webjs-lockup-on-dark.svg and /public/brand/webjs-lockup-on-light.svg, and swaps them with dark:block / dark:hidden. Each file is a self-contained outlined-path SVG (2744 bytes, viewBox="0 0 722 190") with a hard-coded fill, #f7f5f2 on the dark variant and #161311 on the light one. There is no inline SVG and no gradient. --logo-from and --logo-to at website/app/layout.ts:218-219 are declared and referenced NOWHERE in the whole site (grepped across website/**), so they are dead tokens left from an earlier inline-gradient lockup. The header consumes the lockup at website/app/layout.ts:359 and the footer at website/lib/ui/site-footer.ts:61, both confirmed. The consequence is spelled out in Decision 7 below, and it is the opposite of what it looks like: shipping static files LOSES no theme response, because the website's own logo has none to lose.

Correction 6, the gallery cannot use a dark: utility today. website/public/input.css:23-28 declares @custom-variant dark pointing at [data-theme], which is what makes the website's logo swap follow the toggle. gallery/public/input.css declares no @custom-variant at all, so a bare dark: there would follow the OS preference alone and ignore the toggle. Porting the logo therefore requires porting the variant, which Decision 8 covers.

Design / approach

Restyle only the gallery-only surfaces. Change no byte of scaffold payload.

Two categories are gallery-only, and nothing else is:

  1. The four app-shell files in packages/cli/lib/gallery-shell-files.js, namely app/layout.ts, app/page.ts, components/theme-toggle.ts, lib/utils/cn.ts.
  2. All of gallery/public/, which neither copier reads.

Decision 1, keep the shadcn token NAMES and change only their VALUES

This is the decision the whole change hangs on, and it is what makes the rest cheap.

gallery/components/ui/{button,card,badge,input}.ts, gallery/lib/utils/ui.ts, every demo under gallery/app/features/** and gallery/app/examples/**, and every component under gallery/modules/** are ALL scaffold payload, and they are all written against the shadcn class vocabulary (bg-card, text-muted-foreground, border-border, bg-primary, text-primary-foreground). Renaming the gallery's tokens to the website's names (--fg, --bg, --bg-elev, --accent) would mean editing every one of those files, which is exactly the outcome this issue exists to prevent.

So gallery/app/layout.ts keeps --background, --foreground, --card, --muted-foreground, --border, --primary, --ring and the rest of the shadcn set, and only their VALUES move to the website's warm oklch palette. Every payload demo then renders branded with zero payload edits, and a scaffolded app keeps the cool-grey values packages/cli/lib/create.js writes into its own generated layout (the :root block starting at L1280). The mapping is stated file by file in the Implementation plan.

The mapping also lands the accent where the website's own comment says it belongs. website/app/layout.ts:184-192 records that colour is rationed, that the accent appears only where a click is asked for, and that it never tints a content panel or a heading. Making --primary the warm accent puts orange on exactly the primary buttons and focus rings across the demo cards, and leaves every panel neutral.

Decision 2, port the FULL visual system, background treatment included

The bar is that a visitor moving between the two origins cannot tell they changed products. So port the palette, the accent, the font stack, the shadow set, the fixed blurred header, the selection colour, the logo, the footer, AND the ambient background treatment, in BOTH themes.

That includes .glow-layer (website/app/layout.ts:312-319), --glow-a (L217), --glow-strength (L256), the dark override of --glow-strength (L270 and L272), and the relative z-1 content wrapper (L376) that puts the page above the fixed glow. All of it lives in gallery/app/layout.ts, which is gallery-only, so it costs no payload.

One thing to watch and to check by eye rather than to design around in advance. The gallery home is a grid of opaque bg-card panels, so more of the glow is occluded there than on the website's long-form scroll, and it will read mostly through the gutters and the page margins. That is acceptable and is what the website's own content panels already do to it. What is NOT acceptable is banding or a visible seam between cards, so look at the home page at a wide viewport in both themes before calling it done. If it bands, the correct fix is the --glow-strength value for that theme, which is already a per-theme token, and not deleting the layer.

Stacking order matters and there are three layers. The glow is position: fixed at z-0, the content wrapper is position: relative at z-1, and the header is position: fixed at z-20. Get that wrong and the glow paints over the page or the header slides under the content.

Decision 3, self-host the fonts in a new gallery/public/fonts/

Copy inter-tight.woff2, inter.woff2 and jetbrains-mono.woff2 from website/public/fonts/ into a new gallery/public/fonts/, and delete the three Google Fonts tags at gallery/app/layout.ts:60-62.

  • The @font-face block at gallery/public/input.css:76-78 already points at /public/fonts/, so this completes wiring that is already half-written and currently 404s.
  • Referencing the website's origin is rejected. gallery.webjs.dev is a separate Railway service on a separate origin, so a cross-origin font needs CORS on the other service, adds a DNS plus TLS handshake to the critical path, and couples one deploy to another. The website itself moved off a third-party font origin for these reasons, recorded at website/public/input.css:205-213.
  • A symlink is rejected. It would have to escape gallery/, and Dockerfile:66-69 copies gallery and website as separate COPY steps, so the relative target is not guaranteed to exist at the same relative position in every build context, and the static file server following links is not something to rely on.
  • No repo-health test asserts the website's font files are unique or unduplicated. Verified by grepping test/ and website/test/ for fonts/, which returns one unrelated hit at test/types/metadata-types.test-d.ts:68. So duplication trips nothing.
  • gallery/public/ is not payload, verified by the manifest measurement below (the generated app's public/ holds only input.css, offline.html, sw.js and favicon.svg, all written by create.js), so 133KB of woff2 never enters a scaffolded app.
  • Dockerfile:68 is COPY gallery ./gallery, so the new directory ships to the deployed service with no Dockerfile edit. test/repo-health/dockerfile-copy-paths.test.mjs asserts copied paths, and a whole-directory copy already covers a new subdirectory, so it stays green.
  • asset() must NOT wrap the preload hints. AGENTS.md states the rule outright, and website/app/layout.ts:126-128 follows it. The fonts are fetched by a CSS url(), so a hashed preload could never match the request and the file would download twice.

Decision 4, gallery/components/theme-toggle.ts does change, but not for the cn.ts reason

The gallery's copy imports only @webjsdev/core and never imports #lib/utils/cn.ts (verified, its import line is import { WebComponent, html, signal } from '@webjsdev/core'). The scaffold's generated copy does import cn, at packages/cli/lib/create.js:1456, but that is a different file the generator writes and is out of reach here. So the cn.ts provenance question raises no constraint at all.

It changes for two real reasons. Its button classes name tokens whose values move, so the hover and focus states need re-picking against the new palette. And it is a light-DOM component whose focus ring uses the Tailwind UTILITY form (focus-visible:ring-primary-tint), which is the form #1064 recorded as resolving correctly, so it must stay a utility and must not become a hand-written var(--color-ring) rule.

Decision 5, gallery/public/input.css keeps ONE plain @theme block, and gains no @theme inline

Do not copy the website's @theme versus @theme inline split.

The split exists on the website for exactly one reason, spelled out at website/public/input.css:94-118. The /ui gallery renders kit previews inside .ui-preview, a subtree that re-declares the raw token values, and only inline lets a utility resolve at the element rather than at :root. The gallery has no such subtree (grepping gallery/ for ui-preview and @theme inline returns nothing), so inline's only benefit does not apply.

Plain @theme is also load-bearing here, which makes the choice a hard constraint rather than a preference. gallery/modules/components/components/reactive-meter.ts:13 and L21 are inside a static shadow = true component's static styles, and they read var(--color-border) and var(--color-ring) directly. Tailwind cannot see a component's static styles, so those names must exist as real custom properties on :root for the shadow tree to inherit them. That file is scaffold payload and must not be edited.

Measured on tailwindcss@4.3.0, driving a fixture that uses only the border-border and outline-ring/50 UTILITIES so nothing references the theme name verbatim:

input :root emits --color-ring utility compiles to
@theme { --color-ring: var(--ring) } yes outline-color: var(--color-ring)
@theme inline { --color-ring: var(--ring) } no outline-color: var(--ring)

So flipping the gallery to inline would strip --color-ring and --color-border off :root and drop that shadow component's border and focus ring to currentColor, which is the precise symptom #1064 recorded. Keep the single plain @theme block.

Two consequences worth knowing and not acting on. A raw var(--color-x) written in the SAME stylesheet does force emission even under inline, which is why the kit theme at packages/ui/packages/registry/themes/index.css (its @theme inline at L4, --color-ring at L29, its raw @layer base rule at L149) works correctly in a scaffolded app. And the scaffold's generated public/input.css uses @theme inline for its own small app block (packages/cli/lib/create.js:1163), which is fine there because that app's shadow demo inherits --color-ring from the kit theme's forced emission. Neither is a defect and neither is in scope.

Decision 7, the LOGO ships as the two static SVG files under gallery/public/brand/, with the swap markup inline in the layout

This is the option the lead labelled (b) for the ASSETS combined with (a) for the markup, and it is right for a reason the framing did not anticipate.

brandLockup() is not an inline SVG and carries no gradient. It is two <img> tags swapped by the dark: variant, and each SVG hard-codes its own fill. So there is no CSS-token theme response anywhere in the website's logo, and shipping the same two files under gallery/public/brand/ reproduces the website's mechanism EXACTLY rather than degrading it. The theme response comes entirely from the dark: variant, which Decision 8 ports.

Concretely: copy webjs-lockup-on-dark.svg and webjs-lockup-on-light.svg from website/public/brand/ into a new gallery/public/brand/, and write the two-<img> fragment inline in gallery/app/layout.ts (and in the footer markup, which is also in the layout). Do not copy webjs-brand-assets.zip, the two webjs-mark-on-*.svg monogram files, or either webjs-monogram*.svg. The gallery uses only the lockup, and the monogram already reaches it as the favicon through gallery/public/favicon.svg.

Weighed on the three criteria the lead named:

logo stays theme-reactive scaffold output byte-identical surfaces touched
(b) + inline markup, CHOSEN yes, same mechanism as the website yes, gallery/public/ is not payload 2 new files under gallery/public/brand/, markup inside gallery/app/layout.ts, one @custom-variant in gallery/public/input.css
(a) inline the path data itself yes yes 1, but see below
(c) extend GALLERY_APP_SHELL_FILES yes only if the scaffold keeps generating an app without the file 6 or more, enumerated below

Rejected, (a) read as inlining the DRAWING. The lockup is 2744 bytes of outlined path data per variant. Inlining both means 5.5KB of path geometry in the layout template, re-serialized into the HTML of every page on every request, where the two files are instead fetched once and cached. It would also be the one place the two sites draw the mark differently, which is how a lockup drifts. Note this is NOT a rejection of inlining the two-<img> FRAGMENT, which is what the chosen option does, because that fragment is four lines and lives in a file that is already gallery-only.

Rejected, (c) extending the shell-file list. It is the only option that changes the copier CONTRACT, and the contract is the thing this issue exists to protect. It drags in, at minimum: packages/cli/lib/gallery-shell-files.js (published-package source, so the doc gate and the test gate both engage), scripts/sync-scaffold-gallery.mjs (which reads the same list, so it follows automatically but must be re-verified), gallery/AGENTS.md and framework-dev.md (both state the list is exactly four files, in prose, and both would be wrong), the new payload-boundary test (whose first assertion enumerates the list), and the scaffold itself, which must keep generating a working app that has no lib/design/brand.ts while the gallery's demos must never import it. It buys nothing the chosen option does not already give, since the asset files have to live in public/ either way (a .ts module cannot serve an SVG byte). Growing a protective list in order to carry brand through it inverts the list's purpose.

Decision 8, port the website's @custom-variant dark into gallery/public/input.css

Required by Decision 7, since dark:block / dark:hidden are what swap the lockup. Copy website/public/input.css:23-28 verbatim:

@custom-variant dark {
  @media (prefers-color-scheme: dark) {
    &:where(:root:not([data-theme='light']) *) { @slot; }
  }
  &:where(:root[data-theme='dark'] *) { @slot; }
}

Use THIS form, not the kit's @custom-variant dark (&:is(.dark *)) at packages/ui/packages/registry/themes/index.css:55. The gallery's toggle sets both [data-theme] and the .dark class, so either would work with JavaScript on, and only this one works with JavaScript OFF. With no script, no .dark class is ever added, so the class form resolves to light for every reader including one on a dark OS, and the logo would render the dark-ground variant on a light page. The [data-theme] form carries a prefers-color-scheme half that answers correctly with no script at all, which is the progressive-enhancement default WebJs is built on.

This reverses a line that would otherwise have gone in Out of scope. Nothing in the gallery uses a dark: utility today, so the variant looks like dead configuration until the logo needs it.

Decision 9, port a REDUCED footer, inline in the layout

The gallery has no footer today and the website has one on every page, so a visitor reaches the bottom of a gallery page and the product identity simply stops. Port it, but not verbatim.

website/lib/ui/site-footer.ts is a five-column nav of 24 links, and nearly all of them are website-internal routes that do not exist on the gallery origin (/docs, /blog, /articles, /compare/*, /ui, /changelog, /brand, /what-is-webjs, /why-webjs). It also imports #lib/links.ts and #lib/design/brand.ts. Copying it to gallery/lib/ui/site-footer.ts would put all of it in scaffold payload, which Decision 7 already rules out for the same reason.

So write a single-row footer inline in gallery/app/layout.ts, carrying the website's footer TREATMENT and nothing dead: the same border-t border-border rule above it, the same subtle sunken ground, the lockup at the same 26px height linking to https://webjs.dev, the one-line product description, and the "Built with WebJs" heart line the website closes with at site-footer.ts:66. Every link is an absolute https://webjs.dev/... URL, which is honest about the origin split and cannot rot into a 404 the way a copied relative path would.

Decision 10, keep the layout's theme key and move the TOGGLE to match it

Flagging a conflict in the instructions, then settling it so the work is not blocked.

The direction says to preserve gallery/app/layout.ts's localStorage.getItem('theme') and not to align it to webjs_theme, and it also requires that both themes render with no flash on load. Those two cannot both hold while gallery/components/theme-toggle.ts writes webjs_theme at L18, L29 and L30, because the bootstrap then reads a key nothing ever wrote and every reader who chose a theme gets a first paint in the other one. The mismatch is inside the gallery and predates this issue.

Both files are gallery-only, so either can move. Settled: keep 'theme' in the layout, exactly as instructed, and change the three occurrences in gallery/components/theme-toggle.ts to 'theme'. That honours the instruction literally, keeps the gallery's key deliberately distinct from the scaffold's webjs_theme (which is the stated intent), and fixes the flash. It is also the smaller diff of the two, since the layout's key is interpolated into an inline script while the toggle's is three plain string literals.

Note for completeness that localStorage is partitioned per origin, so the key never travels between webjs.dev and gallery.webjs.dev in either direction. The only requirement any key has to satisfy is that the gallery's bootstrap and the gallery's toggle agree, and after this change they do.

Decision 11, the payload boundary gets a functional guard, not a manifest

The new repo-health test runs the real copier into a temp directory and asserts the boundary functionally. Three assertions, described in the Tests section.

Rejected alternative, a committed file manifest of the whole payload. shadcn does keep an explicit manifest (~/Documents/Projects/frameworks/shadcn/apps/v4/registry.json enumerates what its CLI serves), which is real prior art for the shape. Rejected here because WebJs's payload is roughly 200 files that change whenever a demo is added, so a manifest would churn on unrelated PRs and train reviewers to accept its diffs, which is the failure mode a guard is supposed to prevent. The exclusion list is four entries and is the thing at risk, so guard that.

Prior art

The question is how a framework keeps its own branding out of its starter template while still shipping a branded showcase. The established answers are physical separation, and where separation is impossible, a directory boundary inside one app.

  • Astro keeps its starter templates neutral and its branded sites in other repositories entirely. ~/Documents/Projects/frameworks/astro/examples/basics/src/layouts/Layout.astro is a bare shell with <title>Astro Basics</title>, a plain favicon and no palette, and the withastro/astro monorepo contains no www/, docs/ or site/ directory at all.
  • Next.js does the same. ~/Documents/Projects/frameworks/next.js/packages/create-next-app/templates/app-tw/ts/app/layout.tsx sets title: "Create Next App", pulls the generic Geist faces, and declares no brand colour. The only in-repo app is apps/bundle-analyzer.
  • shadcn is the closest analogue and the one WebJs is actually shaped like, because one app is BOTH the branded docs site and the source of what the CLI copies into user projects. The boundary is a directory. ~/Documents/Projects/frameworks/shadcn/apps/v4/app/globals.css and apps/v4/app/layout.tsx carry the site's own brand, while apps/v4/registry/** carries the neutral payload, and the site's brand never appears under registry/.

WebJs's split is the shadcn one with the roles inverted (the payload is the majority of the directory and the shell is the exception), so the discipline it needs is the same: keep the brand inside the shell files, and never let it leak into the payload tree.

Implementation plan

Cut a worktree first, per the root AGENTS.md. git worktree add -b feat/brand-gallery-shell ../webjs-brand-gallery-shell origin/main, then cd in and run npm run worktree:link.

Before touching anything, capture the baseline manifest. The exact commands are in the Tests section. Do this first, because the whole acceptance bar is a byte-identical diff against it.

Step 0, the payload boundary you must not cross

Only these may be edited:

gallery/app/layout.ts
gallery/app/page.ts
gallery/components/theme-toggle.ts
gallery/public/**            (input.css, and the new fonts/ directory)

Everything else under gallery/app/, gallery/components/, gallery/lib/, gallery/modules/ and gallery/test/ ships verbatim into every generated app. The names that will tempt you, because they read like brand surfaces and are NOT:

gallery/app/icon.ts            gallery/app/apple-icon.ts
gallery/app/opengraph-image.ts gallery/app/twitter-image.ts
gallery/app/manifest.ts        gallery/app/robots.ts
gallery/app/sitemap.ts         gallery/app/sitemaps/route.ts
gallery/app/global-error.ts    gallery/app/global-not-found.ts
gallery/components/ui/{button,card,badge,input}.ts
gallery/lib/utils/ui.ts        (and ANY new gallery/lib/** file, see below)
gallery/modules/**             (including modules/components/components/reactive-meter.ts)

Two specific traps this change walks straight into. The website keeps its logo in website/lib/design/brand.ts and its footer in website/lib/ui/site-footer.ts, so the reflex is to create the same two files under gallery/lib/. copyGallery() copies lib and filters out ONLY lib/utils/cn.ts, so either one would ship the WebJs brand lockup and the WebJs footer into every app webjs create generates. The same holds for anything new under gallery/components/ other than the already-excluded theme-toggle.ts. Assertion 2 of the new repo-health test fires on exactly this.

All of those are in the baseline manifest, so a stray edit shows up as a diff. Do not add a NEW file under gallery/app/, gallery/components/, gallery/lib/, gallery/modules/ or gallery/test/ either. A brand helper such as a logo lockup module belongs INLINE in gallery/app/layout.ts, not in a new gallery/lib/design/brand.ts, because a new file there is payload unless it is also added to GALLERY_APP_SHELL_FILES, and growing that list to carry brand is the wrong trade.

Step 1, gallery/public/fonts/ and gallery/public/brand/, five new binary files

mkdir -p gallery/public/fonts
cp website/public/fonts/inter-tight.woff2 website/public/fonts/inter.woff2 \
   website/public/fonts/jetbrains-mono.woff2 gallery/public/fonts/
mkdir -p gallery/public/brand
cp website/public/brand/webjs-lockup-on-dark.svg \
   website/public/brand/webjs-lockup-on-light.svg gallery/public/brand/

Copy exactly those two. Do NOT copy webjs-brand-assets.zip, webjs-mark-on-dark.svg, webjs-mark-on-light.svg, webjs-monogram.svg or webjs-monogram-on-light.svg. The gallery renders only the lockup, and the monogram already reaches it as gallery/public/favicon.svg.

Real files, not symlinks. gallery/.gitignore ignores public/tailwind.css only, so all five land in git normally. Confirm with git status --short gallery/public that fonts and brand are staged as new files.

Step 2, gallery/public/input.css

The file needs almost nothing, which is the payoff of Decision 1. Two edits.

The @font-face block at L69-78 is already correct and now resolves. Leave the three rules byte-identical. Update only the comment at L70 so it no longer implies the files are somewhere else, and note they are copied from website/public/fonts/.

Add the dark: custom variant, required by the logo swap (Decision 8). Put it immediately after the @source line at L10, matching where the website declares its own, and copy website/public/input.css:23-28 verbatim:

/* The logo swaps two files with dark:block / dark:hidden, so `dark:` has to
   mean the PAGE theme rather than the OS preference alone. Keyed off
   [data-theme], the same signal the bootstrap script in app/layout.ts writes,
   with a prefers-color-scheme half so the right variant still shows with
   JavaScript off. Copied verbatim from website/public/input.css so the two
   sites resolve the variant identically. */
@custom-variant dark {
  @media (prefers-color-scheme: dark) {
    &:where(:root:not([data-theme='light']) *) { @slot; }
  }
  &:where(:root[data-theme='dark'] *) { @slot; }
}

Add the brand-only tokens the layout will introduce to the SAME plain @theme block (do not open a second block, and do not use @theme inline, per Decision 5). After the --color-primary-tint line at L46, add:

  /* Brand tokens the gallery shell uses and the shadcn set has no name for.
     Plain @theme, like everything above it, because the shadow-DOM demo in
     modules/components/ reads var(--color-*) from inside its shadow root and
     Tailwind cannot see a component's static styles. */
  --color-accent-tint:   var(--accent-tint);
  --color-hover-surface: var(--hover-surface);
  --color-glow-a:        var(--glow-a);

The type scale at L58-66 already names --text-display / --text-h1 / --text-h2 / --text-lede and --duration-fast / --duration-slow. Leave those values alone. The gallery's scale and the website's differ slightly and neither is wrong, and changing them would reflow every payload demo's rendering without changing a payload byte, which is churn with no brand payoff.

Step 3, gallery/app/layout.ts, the head

3a. Drop the Google Fonts tags. Today L60-62 read:

      <link rel="preconnect" href="https://fonts.googleapis.com">
      <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400..800&family=JetBrains+Mono:wght@400;500;700&display=swap">

Replace all three with the preload hints, matching website/app/layout.ts:126-128 exactly, bare paths and no asset():

      <!-- Self-hosted fonts, declared via @font-face in public/input.css.
           Preloaded so the two critical faces are requested with the document
           rather than discovered late through the stylesheet. Bare paths, NOT
           asset(): the bytes are fetched by a CSS url(), so a hashed preload
           could never match the request and the file would download twice. -->
      <link rel="preload" href="/public/fonts/inter-tight.woff2" as="font" type="font/woff2" crossorigin>
      <link rel="preload" href="/public/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
      <link rel="preload" href="/public/fonts/jetbrains-mono.woff2" as="font" type="font/woff2" crossorigin>

3b. Leave the theme storage key alone. L39 stays exactly as it is:

            var t = localStorage.getItem('theme');

Per Decision 10 the layout's key is preserved and the TOGGLE moves to match it, in Step 7. Do not touch this line.

Step 4, gallery/app/layout.ts, the token block

The :root block at L65-92 keeps every NAME. Only the values move. Each colour stays a single light-dark(LIGHT, DARK) declaration, which is the rule the styling reference teaches and the block already follows.

Font stack, replacing L66-69:

          --font-sans:    'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
          --font-display: 'Inter Tight', 'Inter', system-ui, -apple-system, sans-serif;
          --font-serif:   ui-serif, 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, Cambria, serif;
          --font-mono:    'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

Palette, replacing the hex values at L73-91. Each right-hand side is the website token whose value it takes, so the two sites resolve to the same colour under both schemes:

gallery token takes the value of resulting declaration
--background --bg light-dark(oklch(0.985 0.008 75), oklch(0.08 0.012 60))
--foreground --fg light-dark(oklch(0.20 0.018 60), oklch(0.96 0.01 60))
--card --bg-elev light-dark(oklch(1 0 0), oklch(0.14 0.015 60))
--card-foreground --fg same as --foreground
--popover --bg-elev same as --card
--popover-foreground --fg same as --foreground
--primary --accent light-dark(oklch(0.54 0.16 52), oklch(0.78 0.18 58))
--primary-foreground --accent-fg light-dark(oklch(1 0 0), oklch(0 0 0))
--secondary --bg-subtle light-dark(oklch(0.96 0.008 75), oklch(0.11 0.014 60))
--secondary-foreground --fg same as --foreground
--muted --bg-subtle same as --secondary
--muted-foreground --fg-muted light-dark(oklch(0.44 0.02 60), oklch(0.78 0.015 60))
--accent --bg-subtle same as --secondary (shadcn --accent is a hover SURFACE, not the brand accent, so it must stay neutral)
--accent-foreground --fg same as --foreground
--border --border light-dark(oklch(0.88 0.012 70 / 0.9), oklch(0.24 0.015 60 / 0.9))
--border-strong --border-strong light-dark(oklch(0.78 0.014 70 / 0.95), oklch(0.36 0.02 60 / 0.95))
--input --border same as --border
--ring --accent-live light-dark(oklch(0.63 0.17 50), oklch(0.78 0.18 58))

Note the trap on the fifth-from-last row. shadcn's --accent is a neutral hover surface, and the website's --accent is the brand orange. They share a name and mean opposite things. Mapping shadcn --accent to the website --accent would turn every hover surface in every payload demo orange. It stays neutral and takes --bg-subtle.

Then the derived tokens, replacing --primary-tint at L91:

          --primary-tint:   color-mix(in oklch, var(--ring) 22%, transparent);
          --accent-tint:    color-mix(in oklch, var(--ring) 14%, transparent);
          --hover-surface:  light-dark(oklch(0 0 0 / 0.055), oklch(1 0 0 / 0.09));
          --shadow-sm:      0 1px 2px light-dark(oklch(0.5 0.06 55 / 0.08), oklch(0 0 0 / 0.4));
          --shadow-cast:    light-dark(oklch(0.5 0.08 55 / 0.10), oklch(0 0 0 / 0.5));
          --shadow-ambient: light-dark(oklch(0.5 0.06 55 / 0.06), oklch(0 0 0 / 0.35));
          --shadow-spread:  0 8px 30px;
          --shadow:         var(--shadow-spread) var(--shadow-cast), 0 2px 6px var(--shadow-ambient);

--shadow-spread is not a colour, so it cannot ride light-dark(). It keeps the explicit pair the website uses at L269-272, the OS media query plus the attribute rule, added right after the existing [data-theme] rules at L93-94:

        @media (prefers-color-scheme: dark) {
          :root:not([data-theme='light']) { --glow-strength: 0.08; --shadow-spread: 0 10px 40px; }
        }
        :root[data-theme='dark'] { --glow-strength: 0.08; --shadow-spread: 0 10px 40px; }

Do NOT add a colour to those two rules. Every colour above is already one light-dark() declaration and color-scheme picks the side. --glow-strength and --shadow-spread are numbers, which is exactly why they need the pair, and they are the only two that do.

The glow needs its own colour token and its light-theme strength, added beside the derived tokens above:

          --glow-a:         light-dark(oklch(0.63 0.17 44), oklch(0.78 0.18 58));
          --glow-strength:  0.16;

Both values are the website's, at L217 and L256, so the two backgrounds resolve identically.

Finally, in the body rule at L96-103, change the font shorthand from font: 15px/1.6 var(--font-sans) to font: 400 16px/1.65 var(--font-sans) (the website's, at L285), keep padding-top: var(--header-h), and add a selection colour after the rule:

        ::selection { background: var(--accent-tint); color: var(--foreground); }

Invariant 9 applies to every line above. There is no backtick anywhere in the token block or in its comments, and there must not be one after the edit, including inside a CSS comment.

Step 5, gallery/app/layout.ts, the glow layer and the stacking order

Add the glow rules to the same <style> block, copied from website/app/layout.ts:311-319:

        /* Background glow, three soft radial gradients. Fixed and
           pointer-events:none so it never intercepts a click, and z-0 so the
           content wrapper below sits on top of it. Strength is a per-theme
           number token, since light-dark() carries colours only. */
        .glow-layer { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
        .glow-layer::before {
          content: ''; position: absolute; inset: 0;
          background:
            radial-gradient(60% 48% at 50% -5%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 100%), transparent), transparent 75%),
            radial-gradient(45% 40% at 85% 10%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 60%), transparent), transparent 70%),
            radial-gradient(50% 50% at 15% 85%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 40%), transparent), transparent 70%);
        }

Also add the prefers-reduced-motion block the website carries at L277-279, since the header and links now animate.

Three layers, and getting the order wrong is the one way to break the page. The glow is position: fixed at z-0, the content wrapper is position: relative at z-1, and the header is position: fixed at z-20. A relative element with no z-index does not reliably sit above a fixed one, which is why the wrapper carries an explicit z-1 rather than relying on source order.

Step 6, gallery/app/layout.ts, the header

The header at L107-117 is in normal flow today, which is why the measure script at L43-57 bails and --header-h stays 0px. Make it fixed, which activates the script that is already there and matches the website's chrome. Per #610 and the styling reference, position: fixed, never position: sticky.

Put position: fixed on the <header> ITSELF, not on a wrapper around it. The measure script queries document.querySelector('header') and bails unless THAT element computes to fixed, so wrapping it in a fixed div would leave the header static and the script would still bail. The website can afford its .site-top wrapper because lib/scroll-lock.ts needs an element to hang --wj-scrollbar-compensation on (website/app/layout.ts:310); the gallery has no scroll lock, so the wrapper would buy nothing and cost the measurement.

Today:

      <header class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">

After, taking the treatment from website/app/layout.ts:356:

      <header class="fixed inset-x-0 top-0 z-20 backdrop-blur-md bg-[color-mix(in_oklch,var(--color-background)_50%,transparent)] border-b border-border">
        <div class="max-w-5xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between gap-4">
          ...brand link, nav, theme toggle...
        </div>
      </header>

Replace --header-h: 0px at L70 with a real default, --header-h: 57px, so no-JS and first paint reserve the height before the script measures. The website does this at L281. Measure the real rendered height after the change and use that number.

The brand link at L108-111 currently renders a gradient square and the text "WebJs Gallery". Replace the square with the real lockup, inline, using the same two-<img> swap website/lib/design/brand.ts:46-51 performs. Keep the wrapping <a> and its aria-label:

          <a href="/" aria-label="WebJs Gallery home" class="inline-flex items-center gap-3 no-underline text-foreground shrink-0 transition-opacity duration-150 hover:opacity-80">
            <!-- The lockup, two files swapped by the dark: variant rather than
                 one file inverted: invert() flips the paper along with the ink,
                 so the mark stops sitting on the page. Both carry explicit
                 width/height so the header composes before either fetch.
                 Intrinsic box is 722 x 190, so a 26px height is 99px wide. -->
            <img src=${asset('/public/brand/webjs-lockup-on-dark.svg')} alt="WebJs" width="99" height="26" class="block w-auto hidden dark:block" style="height:26px" />
            <img src=${asset('/public/brand/webjs-lockup-on-light.svg')} alt="WebJs" width="99" height="26" class="block w-auto dark:hidden" style="height:26px" />
            <span class="text-sm text-muted-foreground font-medium">Gallery</span>
          </a>

Two things about that markup. asset() is correct here and is NOT the case the AGENTS.md warning covers: these are <img src> holes inside the LAYOUT's render function, which is exactly where asset() belongs, and nothing fetches them from a CSS url(). And both <img> tags carry alt="WebJs", which double-announces to a screen reader alongside the aria-label; take alt="" on both and let the aria-label name the link, which is the better of the two and differs deliberately from the website's copy.

The <main> at L118 subtracts the header via the token rather than a hard-coded 3.5rem, and the whole content region gets the stacking wrapper:

      <div class="relative z-1">
        <main class="min-h-[calc(100dvh-var(--header-h))] max-w-5xl mx-auto px-4 sm:px-6 py-8">
          ${children}
        </main>
        ...footer...
      </div>

The <div class="glow-layer" aria-hidden="true"></div> goes immediately inside <body>, before that wrapper, matching website/app/layout.ts:353.

Step 7, gallery/app/layout.ts, the footer

New, inline, at the bottom of the content wrapper. Reduced per Decision 9, with the website's treatment and no dead links:

        <footer class="mt-24 border-t border-border py-12 px-4 sm:px-6 bg-secondary/30">
          <div class="max-w-5xl mx-auto flex flex-col md:flex-row md:items-center justify-between gap-6">
            <div class="flex flex-col gap-3">
              <a href="https://webjs.dev" aria-label="WebJs home" class="no-underline text-foreground inline-flex w-fit transition-opacity duration-150 hover:opacity-80">
                <img src=${asset('/public/brand/webjs-lockup-on-dark.svg')} alt="" width="99" height="26" class="block w-auto hidden dark:block" style="height:26px" />
                <img src=${asset('/public/brand/webjs-lockup-on-light.svg')} alt="" width="99" height="26" class="block w-auto dark:hidden" style="height:26px" />
              </a>
              <p class="m-0 text-xs text-muted-foreground leading-relaxed">The web framework for AI agents. Full-stack web components, SSR, zero build step.</p>
            </div>
            <nav class="flex flex-wrap items-center gap-x-5 gap-y-2 text-sm" aria-label="Footer">
              <a class="text-muted-foreground hover:text-primary no-underline transition-colors" href="https://webjs.dev/docs">Docs</a>
              <a class="text-muted-foreground hover:text-primary no-underline transition-colors" href="https://webjs.dev/ui">UI components</a>
              <a class="text-muted-foreground hover:text-primary no-underline transition-colors" href="https://webjs.dev/blog">Blog</a>
              <a class="text-muted-foreground hover:text-primary no-underline transition-colors" href="https://github.com/webjsdev/webjs" target="_blank" rel="noopener noreferrer">GitHub</a>
            </nav>
          </div>
        </footer>

Every href is absolute on purpose. The gallery is a different origin, so a relative /docs would 404 against its own router, and an absolute URL cannot rot the way a copied relative path would.

Do NOT extract this into gallery/lib/ui/site-footer.ts. That directory is scaffold payload (Decision 7 and Step 0).

Step 8, gallery/app/page.ts

Only the shell-level treatment changes. Every class here already names a token whose value moved, so most of the file is correct as written.

The hero heading at L27 uses style="font-family: var(--font-display); letter-spacing: -0.02em;" and a from-foreground to-muted-foreground gradient. Keep the display face. Change the gradient to read as the brand rather than as a grey fade:

        <h1 class="text-5xl sm:text-6xl font-bold tracking-tight leading-none m-0 break-words bg-gradient-to-b from-foreground to-[var(--ring)] bg-clip-text text-transparent" style="font-family: var(--font-display); letter-spacing: -0.02em;">

The two section headings at L38 and L69 and the card markup at L46-99 need no change: cardClass(...), badgeClass(...), border-border, hover:border-border-strong and text-muted-foreground all resolve against the new values automatically. Leave CARD_LINK built inside the render function, per the existing comment at L12-17 (a module-scope call is a side effect that ships the page).

Do not import anything new here. #components/ui/card.ts, #components/ui/badge.ts and #modules/gallery/nav.ts are payload and stay untouched.

Step 9, gallery/components/theme-toggle.ts

Two edits.

9a, the storage key, per Decision 10. Three occurrences move from webjs_theme to theme, so the toggle agrees with the layout's bootstrap at gallery/app/layout.ts:39:

    try { saved = localStorage.getItem('theme'); } catch {}          // L18
      if (next === 'system') localStorage.removeItem('theme');       // L29
      else localStorage.setItem('theme', next);                      // L30

Update the JSDoc at L6, which names webjs_theme in prose, in the same edit.

9b, the button's class bundle at L48. It currently reads:

class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-card text-muted-foreground cursor-pointer transition-all duration-150 hover:text-foreground hover:border-border active:scale-[0.94] focus-visible:outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary-tint"

hover:border-border is a no-op (it restates the base). Point it at the stronger token so the hover is visible, and keep every focus class in the UTILITY form per #1064:

class="inline-flex items-center justify-center w-9 h-9 p-0 border border-border rounded-full bg-card text-muted-foreground cursor-pointer transition-all duration-150 hover:text-foreground hover:border-border-strong active:scale-[0.94] focus-visible:outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary-tint"

Do NOT rewrite the focus ring as a hand-written outline: 2px solid var(--color-ring) rule in a raw @layer base block. #1064 recorded that form resolving to currentColor while the utility form resolved fine, and the tailwind measurement in Decision 5 explains why (a raw reference is only emitted when the theme name appears verbatim in the same stylesheet). This is a light-DOM component, so it has no static styles and invariant 7 has nothing to apply to. Leave the .dark class sync at L34-39 exactly as it is: it is what the new @custom-variant dark reads on the JavaScript path, so it is now load-bearing rather than inert.

Step 10, the payload guard

New file test/repo-health/gallery-payload-boundary.test.mjs. Shape and assertions in the Tests section.

Step 11, verify

Run the byte-identical diff, the suites, and webjs check plus webjs doctor, all listed in the Tests section. Then boot the gallery and look at it.

( cd gallery && cp -n .env.example .env && npm run db:migrate && npm run dev )   # port 5005

Check every one of these by eye, in BOTH themes and with the toggle set to each of system / light / dark, plus once with JavaScript disabled:

  • the lockup renders, and it is the DARK-ground file on the dark theme and the LIGHT-ground file on the light theme, never the wrong one against its ground
  • with JavaScript off, the lockup still matches the OS preference (this is what the prefers-color-scheme half of the custom variant buys, and the .dark-class form would fail it)
  • no flash of the wrong palette on load, which is what Step 9a fixes and which no test runner observes reliably
  • the header stays put while the page scrolls, with no content hidden behind it and no gap above it
  • the glow reads as an ambient wash and does not band between the demo cards at a wide viewport
  • the body face is Inter and the headings are Inter Tight, with no request to fonts.googleapis.com in the network tab
  • the footer renders and its links resolve

Tests

The byte-identical verification, run FIRST and again at the end

This was run at 034d6a0e and is reproducible. scaffoldApp takes install as opt-in, so no network and no npm install happen. Write this to your scratchpad, not into the repo.

// manifest.mjs, usage: node manifest.mjs <outfile>
import { mkdtemp, rm, readdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { join, relative, sep } from 'node:path';
import { tmpdir } from 'node:os';
import { scaffoldApp } from '<ABSOLUTE-REPO-PATH>/packages/cli/lib/create.js';

async function walk(dir, root, out) {
  for (const e of await readdir(dir, { withFileTypes: true })) {
    const p = join(dir, e.name);
    if (e.isDirectory()) await walk(p, root, out);
    else if (e.isFile()) {
      const h = createHash('sha256').update(await readFile(p)).digest('hex');
      out.push(h + '  ' + relative(root, p).split(sep).join('/'));
    }
  }
  return out;
}

const cwd = await mkdtemp(join(tmpdir(), 'webjs-manifest-'));
try {
  await scaffoldApp('demo', cwd, { template: 'full-stack' });
  const rows = (await walk(join(cwd, 'demo'), join(cwd, 'demo'), [])).sort();
  await writeFile(process.argv[2], rows.join('\n') + '\n');
  console.log(rows.length + ' files');
} finally { await rm(cwd, { recursive: true, force: true }); }
node manifest.mjs before.txt     # on origin/main, BEFORE any edit
# ... make the change ...
node manifest.mjs after.txt
diff before.txt after.txt && echo BYTE-IDENTICAL

Measured baseline at 034d6a0e, 205 files, and the output is deterministic (two consecutive runs produced identical manifests, so nothing in the generator writes a timestamp or a random id). The baseline also confirms the boundary empirically. The generated public/ holds exactly input.css, offline.html, sw.js and favicon.svg, all written by create.js, and nothing from gallery/public/. The generated app/ holds 69 files including icon.ts, apple-icon.ts, opengraph-image.ts, twitter-image.ts, manifest.ts, robots.ts, sitemap.ts, sitemaps/route.ts, global-error.ts and global-not-found.ts, and the generated tree includes components/ui/{badge,button,card,input}.ts and lib/utils/ui.ts. Every one of those is payload.

diff before.txt after.txt reporting no output is the acceptance bar. Inspection is not.

New, test/repo-health/gallery-payload-boundary.test.mjs

Follows test/repo-health/gallery-favicon.test.mjs in naming, location and header style. It lives in the repo suite and NOT in gallery/test/, for the reason that file's header at L20-25 states. gallery/test/** is itself scaffold payload, so a guard placed there would be copied into every generated app, where the thing it guards does not exist, and a stray directory also defeats gallery:clear's prune of an empty test/ (asserted by test/scaffolds/scaffold-gallery.test.js).

Three assertions, each catching a different regression:

  1. The exclusion holds. Import scaffoldApp from packages/cli/lib/create.js, generate into a temp directory with { template: 'full-stack' }, and assert that for each entry of GALLERY_APP_SHELL_FILES the generated file either does not exist or is NOT byte-equal to the gallery's copy. All four exist in a generated app because the generator writes its own, so byte-inequality is the assertion that actually fires. This catches a filter that stopped filtering.
  2. No gallery-only branding reached the payload. Walk the generated tree and assert no file contains the brand markers this change introduces. Grep for the brand asset path (/public/brand/webjs-lockup), the self-hosted font paths (/public/fonts/inter), the display face name (Inter Tight), and the accent hue literal (0.16 52). All four are absent from a generated app today and are introduced only into the gallery-only surfaces, so a leak into any payload file reds this. This is the assertion that catches an implementer putting the lockup in gallery/lib/design/brand.ts, or restyling gallery/app/opengraph-image.ts or gallery/app/global-error.ts, which are the specific failures this issue exists to prevent.
  3. The two copier lists agree as sets. Read packages/cli/lib/create.js and scripts/sync-scaffold-gallery.mjs, extract each for (const sub of [...]) array literal, and assert the two sorted sets are equal. They hold the same five members in different order today and order is not observable, so compare sets rather than sequences. This catches the drift the duplicated literal invites.

The counterfactuals, which must be run and reported. Three, one per assertion. Revert only the filter, by changing copyGallery()'s filter to () => true, and assert assertion 1 fails. Copy the branded gallery/app/layout.ts over gallery/app/global-error.ts and assert assertion 2 fails. Drop 'lib' from copyGallery()'s subdirectory list and assert assertion 3 fails. Restore all three. A guard that cannot be made to fire is not a guard.

Existing suites to run and report

node --test test/repo-health/                          # incl. gallery-favicon, which must stay green
node --test test/scaffolds/                            # scaffold-gallery, scaffold-integration, gallery-coverage
( cd gallery && npm test )                             # node + browser, needs db:migrate first
( cd gallery && npm run typecheck )
( cd gallery && npx webjs check )                      # from INSIDE the app, never the repo root
( cd gallery && npx webjs doctor )

gallery/AGENTS.md:86-88 warns that test/auth/auth.test.ts SKIPS rather than fails on an unmigrated database, so run npm run db:migrate in gallery/ before trusting a green run.

webjs doctor is worth running specifically because of the new <link rel="preload"> tags. The UNMARKED_ASSET_LINKS check covers a <link rel="stylesheet"> written without asset(), not a preload, and gallery/package.json declares no webjs.doctor.gate, so no check is gated to error for this app. Confirm no new failure rather than assuming.

Browser layer

gallery/test/** is payload, so a new browser test CANNOT be added there. The gallery's existing browser tests (gallery/modules/<feature>/components/browser/*.test.js) run under npm test and must stay green, which is the coverage this layer gets. The visual result itself is verified by eye per Step 9. Note honestly that the no-flash-on-load behaviour that Step 3b fixes is a first-paint timing property that a test runner cannot observe reliably, so it is a manual check.

Layers that do NOT apply, and why

  • e2e (test/e2e/*.test.mjs, WEBJS_E2E=1). Nothing here touches routing, navigation, streaming or the network path. The change is app-level CSS, three static assets, and one localStorage key inside one app's inline bootstrap.
  • Bun parity (test/bun/*.mjs). No runtime-sensitive surface moves. Nothing under packages/*/src is edited at all, so the serializer, the listener, dispatch, streams, node:crypto, the TS stripper and auth are all untouched. The bun-parity hook keys on staged runtime source and will not trip.
  • Smoke (test/examples/*/smoke/*). Those cover examples/blog, which this change does not touch.
  • Unit tests under packages/*/test/. No package source changes, so there is nothing there to assert.

Docs

Three surfaces, all of which describe the split this change relies on. None of them describes a moving API, so none needs new API prose.

  • gallery/AGENTS.md. The "Four files here are NOT scaffold payload" section at L25-48 lists the four correctly. It does NOT mention that gallery/public/ is also gallery-only, which is the second half of the boundary and the half this change leans on hardest. Add it, and add the sentence that a new brand helper belongs inline in a shell file rather than in a new gallery/lib/ module. Also name the new repo-health test beside the existing warning at L44-46, so the next reader learns the boundary is now guarded rather than only documented.
  • framework-dev.md:162. The paragraph names the four shell files and both copiers accurately. Add gallery/public/ to the same sentence and name test/repo-health/gallery-payload-boundary.test.mjs as the guard. One sentence, no restructure.
  • .agents/skills/webjs/references/styling.md. Two touch points to check rather than assume. L110 states that an app's public/input.css maps tokens with @theme inline. That is true of the scaffold's generated file and of the kit theme, and NOT true of the gallery's hand-written one, and Decision 5 explains why the gallery must stay on plain @theme. Add one sentence recording the rule that decides it, that a token read by raw CSS Tailwind cannot see (a shadow component's static styles) must come from a plain @theme, since @theme inline emits no custom property at all. That is a genuinely useful rule for any WebJs app author with a shadow component, and it is currently written down nowhere. The focus-ring paragraph at L146 stays correct and unchanged, since this change keeps the utility form everywhere.

The docs gate (.claude/hooks/require-docs-with-src.sh) blocks a commit staging public packages/*/src source with no doc surface alongside it. This change stages no packages/*/src file, so the gate does not apply and WEBJS_NO_DOC_GATE=1 is not needed. Do not reach for it.

No other surface applies. The docs site, the marketing website, README.md, the root AGENTS.md and the scaffold templates under packages/cli/templates/ all describe framework behaviour, and no framework behaviour changes. webjs create output is byte-identical by construction, which is the whole point.

Acceptance criteria

The payload boundary

  • diff before.txt after.txt over the 205-file scaffold manifest reports NO differences, proving webjs create output is byte-identical
  • The shadcn token NAMES in gallery/app/layout.ts are unchanged, and only their values moved
  • No file under gallery/app/, gallery/components/, gallery/lib/, gallery/modules/ or gallery/test/ is modified, other than app/layout.ts, app/page.ts and components/theme-toggle.ts
  • No NEW file is added under those five directories, and GALLERY_APP_SHELL_FILES still holds exactly its four entries
  • test/repo-health/gallery-payload-boundary.test.mjs exists and its three assertions pass
  • All three counterfactuals fire, reported in the PR with the failing output

The logo

  • gallery/public/brand/ holds exactly webjs-lockup-on-dark.svg and webjs-lockup-on-light.svg, byte-equal to website/public/brand/
  • The gallery header renders the WebJs lockup, and the footer renders it again, at the website's 26px height
  • The DARK-ground file shows on the dark theme and the LIGHT-ground file on the light theme, checked at every toggle position, never the wrong file against its ground
  • With JavaScript disabled, the lockup still matches the OS preference, which is what the prefers-color-scheme half of @custom-variant dark buys
  • The lockup is NOT duplicated as path data in any source file, and no brand module was added under gallery/lib/

Both themes

  • Every colour token in gallery/app/layout.ts is ONE light-dark() declaration, and only --glow-strength and --shadow-spread carry an explicit per-theme pair
  • Light and dark both render correctly across the home page, a feature demo and the example app
  • A reader who picked dark sees no light flash on load, and a reader who picked light sees no dark flash, checked manually
  • gallery/app/layout.ts still reads localStorage.getItem('theme') and gallery/components/theme-toggle.ts now writes the same key, so the bootstrap sees the toggle's choice
  • The shadow-DOM demo at gallery/modules/components/components/reactive-meter.ts still shows a themed border and focus ring in both themes, verified at /features/components

The background treatment

  • The glow layer renders in both themes at the website's --glow-a and --glow-strength values
  • It sits BEHIND the content (z-0 glow, z-1 content wrapper, z-20 header) and intercepts no clicks
  • It reads as an ambient wash on the card grid at a wide viewport, with no banding or seam between cards

The fonts

  • gallery/public/fonts/ holds the three woff2 files as real files, byte-equal to website/public/fonts/
  • The gallery makes NO request to fonts.googleapis.com or fonts.gstatic.com, verified in the network tab
  • The three @font-face rules in gallery/public/input.css resolve, verified by a 200 on /public/fonts/inter.woff2
  • Body copy renders in Inter and headings in Inter Tight, matching webjs.dev
  • The preload hints use bare public/ paths and are NOT wrapped in asset()

Chrome and stylesheet

  • The header is position: fixed (never sticky), --header-h measures a real value, and no content hides behind it
  • The footer renders on every gallery page and all of its links resolve
  • gallery/public/input.css still has exactly one plain @theme block and no @theme inline, plus the one new @custom-variant dark

Suites and tooling

  • test/repo-health/gallery-favicon.test.mjs is still green
  • test/scaffolds/ is green
  • ( cd gallery && npm test ) and npm run typecheck are green, after db:migrate
  • ( cd gallery && npx webjs check ) and npx webjs doctor report no new failure
  • npm run gallery:clear still produces a clean buildable app
  • gallery/AGENTS.md, framework-dev.md and .agents/skills/webjs/references/styling.md are updated as described

Out of scope

  • Do not restyle any payload file. Named explicitly because they read like brand surfaces: gallery/app/{icon,apple-icon,opengraph-image,twitter-image,manifest,robots,sitemap,global-error,global-not-found}.ts, gallery/app/sitemaps/route.ts, gallery/components/ui/**, gallery/lib/utils/ui.ts, gallery/modules/**, gallery/test/**.
  • Do not change what the scaffold generates. packages/cli/lib/create.js keeps its cool-grey neutral palette, its Google Fonts links and its generated shell verbatim. The neutrality of the generated app is the requirement, not an accident to fix.
  • Do not touch packages/ui/packages/registry/themes/index.css. Its @theme inline plus raw @layer base combination is correct and is a published surface. It is discussed in Decision 5 only so the implementer does not "fix" it.
  • Do not change the type scale or the motion durations in gallery/public/input.css L58-66. Changing them reflows every payload demo's rendering for no brand gain.
  • Do not extract a brand module or a footer module into gallery/lib/design/, gallery/lib/ui/, or anywhere else under the five payload directories. Both the lockup markup and the footer markup stay inline in gallery/app/layout.ts, per Decisions 7 and 9.
  • Do not extend GALLERY_APP_SHELL_FILES. Rejected in Decision 7 with the full list of surfaces it would drag in.
  • Do not copy the rest of website/public/brand/. Only the two lockup files. The monogram, the mark variants and the assets zip are not used by the gallery.
  • Do not port --logo-from or --logo-to. They are dead tokens on the website itself, referenced nowhere (Correction 5).
  • Do not copy website/lib/ui/site-footer.ts verbatim. Most of its 24 links are website-internal routes that 404 on the gallery origin, and it imports two website-only modules.
  • Do not add a scroll lock or a .site-top wrapper. The website's wrapper exists for lib/scroll-lock.ts and its scrollbar compensation, which the gallery has no equivalent of, and a wrapper would break the header-measure script (Step 6).
  • Do not change the website. Every anchor into website/ in this plan is a READ. The two apps stay independent and the gallery copies from it.
  • Do not collapse the duplicated subdirectory list in copyGallery() and sync-scaffold-gallery.mjs into a shared export. Assertion 3 of the new test pins them instead, which costs nothing and touches no shipped code path.
  • Do not file follow-up issues. Anything genuinely separate that surfaces goes in the PR description for the owner to decide.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions