+ ${pageHeader('How WebJs compares', 'Honest head-to-head write-ups: where WebJs agrees with each framework, where it genuinely differs, and who should pick which. No trashing the alternative, and each one says where the other tool is the better call.', 'Compare')}
${comparisons.length === 0
? html`No comparisons yet.
`
@@ -33,10 +29,10 @@ export default async function Compare() {
- WebJs vs ${c.competitor}
+ WebJs vs ${c.competitor}
- ${c.tagline}
- ${c.description}
+ ${c.tagline}
+ ${c.description}
`)}
diff --git a/website/app/docs/layout.ts b/website/app/docs/layout.ts
index 50aeceb0b..7b33a4d6b 100644
--- a/website/app/docs/layout.ts
+++ b/website/app/docs/layout.ts
@@ -1,5 +1,5 @@
import { html } from '@webjsdev/core';
-import { docsShell } from '#lib/docs-shell.ts';
+import { docsShell } from '#lib/ui/docs-shell.ts';
import '#components/doc-search.ts';
/**
@@ -13,7 +13,7 @@ import '#components/doc-search.ts';
* design system, one set of tokens, no parallel shell to drift.
*
* The shell itself (sidebar, mobile drawer, .prose-docs typography) lives in
- * lib/docs-shell.ts, shared with the component library at /ui, so the two
+ * lib/ui/docs-shell.ts, shared with the component library at /ui, so the two
* sections cannot drift apart. This file contributes only the docs nav tree
* and the docs-scoped metadata.
*
@@ -76,7 +76,7 @@ const NAV_SECTIONS = [
},
{
// The component library is its own section at /ui, with the same shell as
- // these pages (see lib/docs-shell.ts). This entry is a cross-link out of
+ // these pages (see lib/ui/docs-shell.ts). This entry is a cross-link out of
// the docs rather than a doc page: /docs/ui used to hold a second,
// hand-written description of the kit that had drifted badly (it
// advertised roughly 55 components against an actual 32 and showed a
diff --git a/website/app/docs/styling/page.ts b/website/app/docs/styling/page.ts
index 186fb9ece..3054e9217 100644
--- a/website/app/docs/styling/page.ts
+++ b/website/app/docs/styling/page.ts
@@ -22,7 +22,7 @@ export default function Styling() {
--color-muted-foreground: var(--muted-foreground);
--font-serif: var(--font-serif);
--text-display: clamp(2.6rem, 1.6rem + 3.2vw, 4.25rem);
- --duration-fast: 140ms;
+ --duration-150: 140ms;
}
// app/layout.ts excerpt
@@ -40,7 +40,7 @@ export default function RootLayout({ children }: { children: unknown }) {
/* …etc */
}
</style>
- <main class="max-w-[760px] mx-auto px-4 py-12">
+ <main class="max-w-3xl mx-auto px-4 py-12">
\${children}
</main>
\`;
@@ -49,7 +49,7 @@ export default function RootLayout({ children }: { children: unknown }) {
From any page or component you now write things like:
<h1 class="font-serif text-display text-foreground mb-6">Hello</h1>
<p class="text-muted-foreground font-sans">Lede copy</p>
-<a class="text-primary hover:underline duration-fast">Link</a>
+<a class="text-primary hover:underline duration-150">Link</a>
Light-DOM components
Light DOM is the default for any WebComponent. Tailwind classes apply as they would on plain HTML:
@@ -110,7 +110,7 @@ class MyCard extends WebComponent {
Two layout defects ship silently because the checker and the type-checker are static (they never render the pixels), so both only show once you render and interact. Both have a one-line fix.
Light-DOM component hosts are display: block by default. A custom element is display: inline in plain CSS, which would collapse a light-DOM component used as a block container (a board, a card, a panel) to its content size. The framework marks every light host data-wj-host and injects one rule in a low-priority cascade layer, @layer webjs-host { :where([data-wj-host]) { display: block } }, so a container fills its parent. The layer keeps it overridable by any author style, including Tailwind utilities (class="flex", grid, hidden) whose layer wins; a [hidden] carve-out keeps ?hidden working. Want an inline light component? Opt out with my-badge { display: inline }.
Shadow-DOM hosts are NOT marked. A document rule targeting the host would override the shadow tree's own :host display, so the framework leaves shadow hosts alone. A shadow-DOM component sets its host display the idiomatic way, :host { display: block } (or flex / grid) in static styles, which is fully respected. Set it for a shadow block container (an unstyled shadow host stays display: inline).
- Size the HOST, not just an inner wrapper. The host custom element is the box the parent lays out. display: block stops the inline-collapse, but a host that is a flex/grid item in a centering parent (flex justify-center, grid place-items-center) is still sized to its content unless it carries width itself. Put w-full max-w-[...] on the host, not only on an inner <div> (an inner w-full resolves against a collapsed host and the whole component shrinks). Symptom: a board or card renders tiny even though its inner grid says w-full max-w-[400px].
+ Size the HOST, not just an inner wrapper. The host custom element is the box the parent lays out. display: block stops the inline-collapse, but a host that is a flex/grid item in a centering parent (flex justify-center, grid place-items-center) is still sized to its content unless it carries width itself. Put w-full max-w-[...] on the host, not only on an inner <div> (an inner w-full resolves against a collapsed host and the whole component shrinks). Symptom: a board or card renders tiny even though its inner grid says w-full max-w-100.
An even grid uses 1fr tracks, never auto rows. The reflow bug (a cell grows when it gets content while the others shrink) comes from auto-sized rows. Put aspect-ratio on the CONTAINER, size the tracks explicitly, and cap the cells:
<!-- a 3x3 board whose cells stay equal and square as it fills -->
@@ -173,14 +173,14 @@ import { html } from '@webjsdev/core';
/** \`label\` kicker: small caps, accent colour, above headings. */
export function rubric(label: string) {
return html\`
- <span class="block font-mono text-[11px] leading-none font-semibold tracking-[0.2em] uppercase text-primary mb-4">● \${label}</span>
+ <span class="block font-mono text-xs leading-none font-semibold tracking-widest uppercase text-primary mb-4">● \${label}</span>
\`;
}
/** "← label" back link. */
export function backLink(href: string, label: string) {
return html\`
- <a href=\${href} class="inline-block mb-12 text-muted-foreground/70 no-underline font-mono text-[11px] uppercase tracking-[0.15em] duration-fast hover:text-foreground">← \${label}</a>
+ <a href=\${href} class="inline-block mb-12 text-muted-foreground/70 no-underline font-mono text-xs uppercase tracking-widest duration-150 hover:text-foreground">← \${label}</a>
\`;
}
diff --git a/website/app/error.ts b/website/app/error.ts
index cdb4f42aa..03d9aaaaa 100644
--- a/website/app/error.ts
+++ b/website/app/error.ts
@@ -22,7 +22,7 @@ export default function ErrorBoundary({ error }: { error: unknown }) {
500 · server error
Something went wrong.
- We hit an unexpected error while rendering this page. The full stack is logged on the server; only the short message is shown here.
+ We hit an unexpected error while rendering this page. The full stack is logged on the server; only the short message is shown here.
${message}
← Back home
diff --git a/website/app/layout.ts b/website/app/layout.ts
index e721f5bc4..083d161b7 100644
--- a/website/app/layout.ts
+++ b/website/app/layout.ts
@@ -1,7 +1,8 @@
import { html, cspNonce } from '@webjsdev/core';
import '#components/theme-toggle.ts';
-import { DOCS_START_PATH, UI_PATH, EXAMPLE_BLOG_URL, GH_URL, NEW_TAB } from '#lib/links.ts';
-import { siteFooter } from '#lib/site-footer.ts';
+import { DOCS_START_PATH, UI_PATH, GH_URL, NEW_TAB } from '#lib/links.ts';
+import { siteFooter } from '#lib/ui/site-footer.ts';
+import { brandLockup } from '#lib/design/brand.ts';
/**
* Root layout for the redesigned marketing site.
@@ -13,7 +14,7 @@ import { siteFooter } from '#lib/site-footer.ts';
* clamp, the fixed static glow layer, the hover-only scrollbar (`.scroll-thin`),
* and the icon swap. Everything else is Tailwind.
*
- * Shared link config (DOCS_START_PATH / UI_PATH / EXAMPLE_BLOG_URL / GH_URL / NEW_TAB) lives in
+ * Shared link config (DOCS_START_PATH / UI_PATH / GH_URL / NEW_TAB) lives in
* lib/links.ts, imported here and by app/page.ts.
*/
@@ -23,7 +24,6 @@ const DESCRIPTION = 'An AI-first full-stack web framework built on web component
const NAV = [
{ label: 'Docs', href: DOCS_START_PATH, ext: false },
{ label: 'UI', href: UI_PATH, ext: false },
- { label: 'Demo', href: EXAMPLE_BLOG_URL, ext: true },
{ label: 'Blog', href: '/blog', ext: false },
{ label: 'Compare', href: '/compare', ext: false },
{ label: 'Changelog', href: '/changelog', ext: false },
@@ -75,8 +75,8 @@ export function generateMetadata(ctx: { url: string }) {
};
}
-const navLink = 'text-fg-muted no-underline font-medium text-sm px-[11px] py-2 rounded-lg transition-colors duration-[140ms] hover:text-fg hover:bg-bg-subtle';
-const panelLink = 'text-fg-muted no-underline font-medium text-sm px-3 py-[10px] rounded-[9px] hover:text-fg hover:bg-bg-subtle';
+const navLink = 'text-fg-muted no-underline font-medium text-sm px-3 py-2 rounded-lg transition-colors duration-[140ms] hover:text-fg hover:bg-[var(--hover-surface)]';
+const panelLink = 'text-fg-muted no-underline font-medium text-sm px-3 py-2.5 rounded-lg hover:text-fg hover:bg-[var(--hover-surface)]';
export default function RootLayout({ children }: { children: unknown }) {
const nonce = cspNonce();
@@ -148,7 +148,7 @@ export default function RootLayout({ children }: { children: unknown }) {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', measure);
else measure();
})();
- // The sidebar drawer shared by /docs and /ui (lib/docs-shell.ts) is
+ // The sidebar drawer shared by /docs and /ui (lib/ui/docs-shell.ts) is
// driven entirely from here, and both halves of that are deliberate.
//
// Its open state is an attribute on , which is OUTSIDE every swap
@@ -234,6 +234,15 @@ export default function RootLayout({ children }: { children: unknown }) {
/* Foundation tokens + effects that Tailwind utilities cannot express. */
/* A single static gradient glow layer. It used to breathe (two layers
cross-faded on a 16s loop), removed so nothing animates on the page. */
+ /* Colour is rationed, not absent.
+ Neutrals carry the page, and they are WARM: a real chroma on hue
+ 60..75 rather than a trace of one. A near-zero-chroma version of this
+ palette was tried and read cold and clinical in light mode, where the
+ warmth is doing most of the work. The accent then appears in exactly
+ the places that ask for a click, the primary button and the closing
+ CTA, plus live and focus state. It never tints a content panel or a
+ heading, which is what keeps those few amber surfaces meaningful
+ instead of decorative. */
:root {
color-scheme: light dark;
--fg: oklch(0.20 0.018 60);
@@ -264,17 +273,34 @@ export default function RootLayout({ children }: { children: unknown }) {
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--shadow-sm: 0 1px 2px oklch(0.5 0.06 55 / 0.08);
--shadow: 0 8px 30px oklch(0.5 0.08 55 / 0.10), 0 2px 6px oklch(0.5 0.06 55 / 0.06);
+ /* The closing CTA's fill. Light keeps a faint accent tint, which
+ separates the panel from the page. Dark takes the plain elevated
+ surface: over black the same tint went muddy rather than warm, so
+ the glow alone carries it there. */
+ /* Hover lift for nav links and other bare targets. An ALPHA overlay,
+ not a solid colour, for two reasons. It composes over the header's
+ translucent blurred background instead of fighting it, and it gives
+ the same perceived step in both themes: the solid --bg-subtle was a
+ 0.09 lift on a black page, which is real in numbers and invisible to
+ the eye, while the same token in light was a 0.025 step that read
+ clearly because the eye is adapted to a bright field. */
+ --hover-surface: oklch(0 0 0 / 0.055);
+ --cta-surface: color-mix(in oklch, var(--accent-live) 7%, var(--bg-elev));
--shadow-glow: 0 0 0 1px var(--accent-tint), 0 14px 50px color-mix(in oklch, var(--accent-live) 18%, transparent);
--t: 240ms;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--heart: oklch(0.74 0.18 6);
- --fg: oklch(0.96 0 0); --fg-muted: oklch(0.74 0 0); --fg-subtle: oklch(0.62 0 0);
- --bg: oklch(0 0 0); --bg-elev: oklch(0.135 0 0); --bg-subtle: oklch(0.09 0 0); --bg-sunken: oklch(0 0 0);
- --border: oklch(0.32 0 0 / 0.9); --border-strong: oklch(0.44 0 0 / 0.92);
- --accent: oklch(0.7 0.16 52); --accent-hover: oklch(0.75 0.16 52); --accent-fg: oklch(0.1 0.01 52); --logo-from: oklch(0.8 0.16 58); --logo-to: oklch(0.62 0.18 44);
- --glow-strength: 0.16;
+ --fg: oklch(0.98 0 0); --fg-muted: oklch(0.80 0 0); --fg-subtle: oklch(0.68 0 0);
+ --bg: oklch(0 0 0); --bg-elev: oklch(0.12 0 0); --bg-subtle: oklch(0.08 0 0); --bg-sunken: oklch(0 0 0);
+ --border: oklch(0.26 0 0 / 0.9); --border-strong: oklch(0.38 0 0 / 0.95);
+ --accent: oklch(0.78 0.18 58); --accent-hover: oklch(0.83 0.17 58); --accent-fg: oklch(0 0 0); --logo-from: oklch(0.82 0.17 58); --logo-to: oklch(0.64 0.18 44);
+ --accent-live: oklch(0.78 0.18 58);
+ --glow-a: transparent;
+ --glow-strength: 0;
+ --cta-surface: transparent;
+ --hover-surface: oklch(1 0 0 / 0.09);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow: 0 10px 40px oklch(0 0 0 / 0.5), 0 2px 6px oklch(0 0 0 / 0.35);
}
@@ -282,11 +308,15 @@ export default function RootLayout({ children }: { children: unknown }) {
:root[data-theme='dark'] {
color-scheme: dark;
--heart: oklch(0.74 0.18 6);
- --fg: oklch(0.96 0 0); --fg-muted: oklch(0.74 0 0); --fg-subtle: oklch(0.62 0 0);
- --bg: oklch(0 0 0); --bg-elev: oklch(0.135 0 0); --bg-subtle: oklch(0.09 0 0); --bg-sunken: oklch(0 0 0);
- --border: oklch(0.32 0 0 / 0.9); --border-strong: oklch(0.44 0 0 / 0.92);
- --accent: oklch(0.7 0.16 52); --accent-hover: oklch(0.75 0.16 52); --accent-fg: oklch(0.1 0.01 52); --logo-from: oklch(0.8 0.16 58); --logo-to: oklch(0.62 0.18 44);
- --glow-strength: 0.16;
+ --fg: oklch(0.98 0 0); --fg-muted: oklch(0.80 0 0); --fg-subtle: oklch(0.68 0 0);
+ --bg: oklch(0 0 0); --bg-elev: oklch(0.12 0 0); --bg-subtle: oklch(0.08 0 0); --bg-sunken: oklch(0 0 0);
+ --border: oklch(0.26 0 0 / 0.9); --border-strong: oklch(0.38 0 0 / 0.95);
+ --accent: oklch(0.78 0.18 58); --accent-hover: oklch(0.83 0.17 58); --accent-fg: oklch(0 0 0); --logo-from: oklch(0.82 0.17 58); --logo-to: oklch(0.64 0.18 44);
+ --accent-live: oklch(0.78 0.18 58);
+ --glow-a: transparent;
+ --glow-strength: 0;
+ --cta-surface: transparent;
+ --hover-surface: oklch(1 0 0 / 0.09);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.4);
--shadow: 0 10px 40px oklch(0 0 0 / 0.5), 0 2px 6px oklch(0 0 0 / 0.35);
}
@@ -325,6 +355,12 @@ export default function RootLayout({ children }: { children: unknown }) {
selector outranks the border-* utilities that also set
border-right-color, so cascade order does not matter. */
.site-top > header { border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; }
+ /* The page backdrop is deliberately UNCHANGED from what webjs.dev
+ serves: one fixed layer, two radials off --glow-a, no animation. A
+ dot-grid texture and a third magenta radial were tried here and
+ reverted. They tinted every page, which is a site-wide change, and the
+ glow that was actually wanted belongs to the closing CTA panel, where
+ it comes from --shadow-glow on the panel itself. */
.glow-layer { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.glow-layer::before {
content: ''; position: absolute; inset: 0;
@@ -364,10 +400,9 @@ export default function RootLayout({ children }: { children: unknown }) {