Skip to content

refactor(website): declare every theme colour once via light-dark() - #1217

Merged
vivek7405 merged 7 commits into
mainfrom
feat/website-light-dark
Aug 3, 2026
Merged

refactor(website): declare every theme colour once via light-dark()#1217
vivek7405 merged 7 commits into
mainfrom
feat/website-light-dark

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #1216

Brings the marketing site in line with the styling and layout rules WebJs ships to its own users. It was the counter-example to its own guidance on three counts.

Each theme colour is declared once, via light-dark()

references/styling.md in the skill, and the palette webjs create generates, both teach one rule: write each colour token ONCE as light-dark(LIGHT, DARK) and let color-scheme pick the side. The site did the opposite everywhere it had a palette.

Four places wrote it twice instead, verbatim, once under @media (prefers-color-scheme: dark) and again under :root[data-theme='dark']:

  • app/layout.ts, the site palette (16 colours).
  • public/input.css, the .ui-preview kit palette (19 colours, said three times over).
  • public/input.css, the syntax-highlight classes (5 colours).
  • app/page.ts, the home page's code-sample tokens (3 colours).

Every pair was already byte-equal, so any future edit had to land in both copies or the OS-dark and toggle-dark paths would drift apart silently.

The prerequisite was already there: :root declares color-scheme: light dark and the toggle's two attribute rules force one. So the three-way selector logic was redundant with a cascade that already existed.

Three tokens genuinely cannot ride light-dark(), which is colour-only, so they keep the explicit override pair the styling reference prescribes:

  • --glow-strength is a bare number.
  • --cta-mix is the CTA fill's mix ratio, split out of --cta-surface because the two themes differ by the ratio, not by a colour.
  • --shadow-spread is the geometry half of --shadow, whose cast widens in dark mode. Its two colours became their own light-dark() tokens, and --shadow-sm keeps identical geometry in both themes so its colour rides light-dark() inline.

Everything derived through color-mix() (--accent-tint, --accent-surface, --accent-border, --shadow-glow) now tracks both themes with no override at all.

components/ui/ is empty again

website/AGENTS.md says the directory is intentionally empty, left free for webjs ui add to own exactly as in a scaffolded app, with the gallery's preview copies living in the generated modules/ui/components/ mirror. It was not empty: eleven files (766 lines) landed in it in #1213, every one byte-identical to its counterpart in that mirror and imported by nothing. .gitignore covered the other two generated paths but not this one, which is how they got in. Now ignored as well as removed.

Doc drift

website/AGENTS.md documented an announcement banner above a "sticky" header. The banner is gone and #610 made the header position: fixed deliberately, because a sticky one flickers on iOS WebKit during a client-router nav. Also typed the search debounce handle, which was any.

Test plan

  • Colour equivalence, in a real browser. 360 resolved readings, every palette token plus the highlight classes and code tokens, in all six theme states (OS light and dark, each with the toggle unset, forced light, forced dark), compared against a pristine main build on a second port. Zero differences. Shadows are read back through box-shadow rather than as custom properties, since a custom property's computed value keeps light-dark() unsubstituted, so reading the property compares source text rather than what the browser paints. That distinction was load-bearing: the first probe pass compared raw strings and reported eighteen false differences.
  • Unit. test/ssr/design-tokens.test.ts rewritten to parse the light-dark() pairs (its old parser read the now-deleted dark block) and extended to pin the DRY rule itself: only the three non-colour tokens may carry a per-theme override, and all three color-scheme declarations must be present. New test/ssr/kit-surfaces.test.ts pins the empty components/ui/, the .ui-preview palette's shape, and sweeps every stylesheet, page, layout, and component asserting the rule itself: no colour under a per-theme selector, only the three non-colour tokens. That sweep replaced a per-file assertion, which is what let the highlight classes and code tokens survive the first pass.
  • Counterfactuals, all eight proven to fire at 7642b099: reintroducing a colour override under a theme selector, reverting one token to a bare declaration, dropping the forced-dark color-scheme, committing a file into components/ui/, re-adding a duplicated .ui-preview dark block, and the three shapes the sweep catches (a bare colour on a class under a dark selector, a custom-property colour under a media query, a colour token in a page's own style block). Each reds the suite; restoring greens it.
  • Suites. 165 server tests and 35 browser tests pass. webjs check and webjs typecheck clean.
  • Boot. Serves 200 on /, /docs/getting-started, /ui, /ui/button, /brand in prod mode.

Doc surfaces

  • website/AGENTS.md: updated (the styling rule, the header section, the components/ui/ note).
  • The skill at .agents/skills/webjs/ and packages/cli/lib/create.js: N/A, both already teach the light-dark() rule. This PR makes the site follow it, not the other way round.
  • Docs site, marketing copy, MCP, editor plugins, scaffold templates, framework packages/*/src: N/A, no framework surface changed.
  • Bun parity: N/A, no runtime-sensitive source touched.

Deliberately not done here

The .ui-preview comment claimed to mirror packages/ui/packages/registry/themes/index.css byte for byte. It never did (the registry keys its dark half off a .dark class, this site off [data-theme]), so the comment is corrected to say which parts are shared and which differ. Converting the registry theme itself to light-dark() is a scaffold surface change, needs the webjs-scaffold-sync skill and a generate-boot-check pass, and belongs in its own issue.

components/ui/ is meant to stay empty here, left free for `webjs ui add`
to own exactly as it is in a scaffolded app, with the gallery's preview
copies living in the generated modules/ui/components/ mirror instead.
Eleven byte-identical copies of that mirror were committed into it by
accident and sat unimported ever since.

Gitignore the directory rather than only emptying it, since .gitignore
already covers the other two generated paths and missing this one is
what let the copies land in the first place.
The site was the counter-example to the rule the framework teaches its
own users. references/styling.md in the skill, and the palette webjs
create generates, both say to write each colour token once as
light-dark(LIGHT, DARK) and let color-scheme pick the side. Here the dark
half was written twice instead, verbatim, once under the OS media query
and once under the toggle's attribute selector, so an edit to either copy
drifted the two paths apart with nothing to catch it. The kit palette
scoped to .ui-preview said the same nineteen colours three times over.

The prerequisite was already in place: :root declares color-scheme light
dark, and the toggle's two attribute rules force one. So the three-way
selector logic was redundant with a cascade that already existed, and the
dark blocks collapse into the light-dark() calls with no behaviour
change.

Three tokens genuinely cannot ride light-dark(), which is colour-only, so
they keep an explicit override pair. --glow-strength is a bare number.
--cta-mix is the CTA fill's mix ratio, split out of --cta-surface because
the two themes differ by the ratio rather than by a colour.
--shadow-spread is the geometry half of --shadow, whose cast widens in
dark mode; its two colours became their own light-dark() tokens, and
--shadow-sm keeps identical geometry so its colour rides light-dark()
inline. Everything derived through color-mix() now tracks both themes
with no override at all.

Verified colour-identical against main in a real browser: 282 resolved
readings, every token in all six theme states (OS light and dark, each
with the toggle unset, forced light, and forced dark), zero differences.
Shadows are read back through box-shadow rather than as custom
properties, since a custom property's computed value keeps light-dark()
unsubstituted and comparing that compares source text, not paint.
The layout was documented as rendering an announcement strip above a
sticky header. Neither is true. The strip is gone, and #610 made the
header position: fixed on purpose, because a sticky one flickers on iOS
WebKit during a client-router navigation. Describe what is actually
there, including the --header-h offset that re-adding a strip would have
to account for.

Also type the search debounce handle, which was any. undefined rather
than null for the unset state, since clearTimeout takes undefined as a
no-op but rejects null.
@vivek7405 vivek7405 self-assigned this Aug 3, 2026
The layout and .ui-preview were not the only places writing the dark half
twice. The syntax-highlight classes in public/input.css repeated five
colours across both theme selectors, and the home page's code-sample
tokens repeated three. Same collapse onto light-dark().

Replace the per-file assertion with a sweep over every stylesheet and
every page, layout, and component that writes CSS, asserting the rule
rather than the two known sites: no colour may be declared under a
per-theme selector, only the three non-colour tokens may. A per-file
check is what let these two survive the first pass. Docs code samples
are excluded, since a pre block teaching the reader is not this site's
own styling.

Re-verified against main in a real browser after the change: 360 resolved
readings across the six theme states, now covering the highlight classes
and the code tokens as well, zero differences.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why the shadow tokens split, and why the check became a sweep

Two calls worth writing down.

The shadow split. light-dark() is colour-only, so a shadow cannot ride it whole. I had three options: leave both shadows in the override block, split their colours out into light-dark() tokens, or reconstruct the whole shadow from geometry plus colour tokens. I went with the middle one, unevenly, because the two shadows are not the same case. --shadow-sm has identical geometry in both themes, so its colour is the ONLY per-theme part and it rides light-dark() inline with nothing extra. --shadow genuinely changes spread (0 8px 30px light, 0 10px 40px dark, because a tighter cast disappears over black), so it needs a --shadow-spread token regardless. Splitting its two colours out anyway means the override block holds only the part that truly cannot be expressed, which is the line the styling reference draws.

--cta-mix is the same call in a smaller place. The CTA fill's two themes differ by the mix RATIO (7% against 6%), not by a colour, so hoisting the ratio out lets --cta-surface stay a single definition instead of being restated per theme for the sake of one digit.

Why the check is a sweep. My first version asserted the two palettes I had found by hand. That is exactly the check that would not have found the other two: the syntax-highlight classes in input.css and the home page's code tokens were both duplicated the same way, and I only caught them by grepping for theme selectors afterwards rather than by any test. So the assertion is now the rule itself, swept over every stylesheet and every page, layout, and component: no colour under a per-theme selector, only the three non-colour tokens. Docs pre blocks are excluded, since a code sample teaching the reader is not this site's styling.

Two things the sweep taught me while writing it, both of which cost a counterfactual to find. Comments have to be stripped before matching, or prose that NAMES a selector to explain why the block does not use it reads as a live rule. And the block terminator cannot anchor to a newline: :root[data-theme='dark'] .t-str { color: ... } is a one-liner, which is precisely the shape the highlight classes used, so the first version walked straight past the case it was written for.

Two related passes over the site's own code.

Types. Five modules each carried their own (globalThis as any).process
cast to read SITE_URL, which is one missing type worked around five
times, so lib/env.ts declares the shape once and returns a typed partial
record. copy-cmd cast setTimeout's handle through unknown to a number and
reached window.gtag through an unknown-shaped object literal; both now
have real types, gtag via a declare global that matches the tag the root
layout installs. The example registry in modules/ui typed its class
helpers (...args: any[]), which waved through a direct call with invented
arguments; never[] states what is actually true, since every call goes
through the Function evaluator rather than a typed call site.

lib/links.ts turned out to hold a dead env read: the docs and the gallery
used to be sibling apps needing a configurable URL each, and both moved
in-app, so every link there is now a literal.

DOM access. copy-cmd found its command line with querySelector on every
copy and preview-tabs built a selector from the mode string to focus a
tab. Both elements come out of render(), so both now bind through the ref
directive, which is the lit-style API the framework provides and cannot
silently return null if the markup moves. preview-tabs defers the focus
behind updateComplete so it lands after the roving tabindex is committed.

The browser suite already covered the clipboard write and the tabindex
roving, so both refs are load-bearing there (dropping either reds it).
The FOCUS move was not covered, which is the half that changed shape, so
add an activeElement assertion for it. Note the deferral itself is
ordering only: focusing a tabindex="-1" element programmatically is
legal, so a synchronous focus still passes and the test cannot tell the
two apart.

State needed no change: all eight pieces of component state were already
signals, with no state:true prop anywhere in the site's own components.
The "Light DOM web components" card showed a static <like-button> tag
wrapped around a Tailwind-classed button. That demonstrated neither half
of the claim: not that light DOM is the DEFAULT, nor how to leave it. It
was also the only card in that grid using a markup snippet where its
neighbours (Server actions, Built-in essentials, Streaming, Progressive
enhancement) all use a schematic.

Replace it with two rows in the same idiom the Built-in essentials card
uses: the accent border marks light DOM as the default, and the muted row
carries the real one-line opt-in, static shadow = true. Checked in both
themes and down to 480px, where the opt-in still fits on one line.

The <like-button> component keeps its live instance further down the page
in "What the browser receives", which is where an interactive demo
belongs; only this card's static copy of the markup goes.

Also mark the prop({ state: true }) entry in the components docs as lit
parity with a signal preferred, so the options reference does not read as
an equal recommendation next to the page's own "signals are the default
state primitive".
Review of my own commit. Two problems, both in the new file.

env() was exported with no consumer outside the module: only siteUrl()
called it, and the NODE_ENV member of its type was never read. Removing
dead code from lib/links.ts and adding some in the same commit is not a
good trade, so inline the read and let siteUrl() be the whole surface.

The docstring justified the globalThis guard by naming lib/links.ts as a
browser-loading consumer, and that same commit deleted the links.ts
consumer. All four remaining callers are server-only. The guard is still
worth keeping, but for the reason that is actually true: it keeps the
module importable from either side, and the origin is public information
already in the rendered HTML, so nothing here wants the server boundary.

Also record why modules/ is outside the theme-selector sweep, since its
only subtree is the gitignored kit mirror rather than this site's code.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went back over this one properly. The palette work is the strong part: collapsing four duplicated dark blocks onto light-dark() is a clear net negative in lines, the three non-colour tokens that genuinely cannot ride it are carved out for the right reason rather than left behind by accident, and the 360-reading browser comparison against main is the kind of evidence I want on a refactor that touches every colour on the site. The sweep test is the piece I would keep longest: asserting the rule instead of the two known sites is what caught the highlight classes and the code tokens, which a per-file check had already walked past once.

Two things I got wrong, both in lib/env.ts, both mine from this branch.

I also checked two things I had half-suspected and cleared both. The ref() binding leaves no artifact in the SSR output (<copy-cmd data-wj-host><!--webjs-hydrate-->), so the byte-stability the conditional-GET path depends on is intact. And updateComplete.then() with no .catch() is safe here: component.js is explicit that the promise always resolves and never rejects, so there is no unhandled rejection to guard against.

Fixed in 6664b07.

Comment thread website/lib/env.ts
Comment thread website/lib/env.ts
@vivek7405
vivek7405 marked this pull request as ready for review August 3, 2026 08:07
@vivek7405
vivek7405 merged commit b349c9c into main Aug 3, 2026
18 of 20 checks passed
@vivek7405
vivek7405 deleted the feat/website-light-dark branch August 3, 2026 08:15
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.

refactor(website): adopt light-dark() and fix scaffold-guideline drift

1 participant