diff --git a/website/.gitignore b/website/.gitignore index 139c68baa..c22b30b61 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -7,3 +7,10 @@ /modules/ui/components/ /lib/utils/cn.ts /lib/utils/dom.ts + +# components/ui/ belongs to `webjs ui add`, exactly as it does in a scaffolded +# app, so this site keeps it empty and tracks nothing there. It is ignored +# rather than merely left alone because it has already been committed by +# accident once: eleven byte-identical copies of the generated mirror above +# landed here and sat unimported until they were noticed. +/components/ui/ diff --git a/website/AGENTS.md b/website/AGENTS.md index 66bc93fd2..7d09674cd 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -56,8 +56,12 @@ website/ preview-tabs.ts Preview / Code toggle around a gallery demo (components/ui/ is intentionally EMPTY here, left free for `webjs ui add` to own, exactly as the scaffold - expects. The gallery's preview copies live in - modules/ui/components/ instead, see below.) + expects, and gitignored so it stays that way. The + gallery's preview copies live in modules/ui/components/ + instead, see below. Enforced by + test/ssr/kit-surfaces.test.ts, because eleven copies of + that mirror were once committed here by accident and + sat unimported until someone read the directory.) lib/ design/ the design system, one subsystem in one folder recipes.ts class recipes + the scale (BTN_*, EYEBROW, layout widths) @@ -163,14 +167,17 @@ is split by editorial intent, which is what decides where a piece goes: live content queries, so a new article, comparison, or post needs no edit to those files. -## Announcement banner +## Header -The layout (`app/layout.ts`) renders a top-of-page announcement strip -just above the sticky header: a small utility-class `
` with a "New" -badge and a link (currently the `UI_PATH` link, "Introducing the AI-first -component library"). To swap the announcement, edit that `
` (its copy -and the link `href`). The banner shows on every page. Remove the `
` -to hide it. +`app/layout.ts` renders the site header on every page. It is +`position: fixed`, NOT sticky: a sticky header flickers on iOS WebKit during +a client-router navigation (#610), so the height is reserved on the content +through a `--header-h` offset, measured by the inline script in the layout +head and defaulted on `:root` for no-JS and first paint. + +There is no announcement banner. One used to sit above the header, and +`--header-h` is why its removal is not free: re-adding a strip means the +measurement has to cover it too. ## How to update headline / hero copy @@ -197,6 +204,16 @@ calling an action), re-enable it and delete the assertion in - Light DOM, Tailwind utilities, `@theme` tokens from the root layout (same palette / type scale as the blog and docs). +- **Each per-theme colour is declared ONCE, as `light-dark(LIGHT, DARK)`**, + and the three `color-scheme` declarations in `app/layout.ts` pick the side + (the default follows the OS; the toggle's `[data-theme]` forces one). This + is the rule the framework teaches its own users, in the skill at + `.agents/skills/webjs/references/styling.md`. A token that is NOT a colour + (`--glow-strength`, `--cta-mix`, `--shadow-spread`) cannot ride + `light-dark()`, so it keeps an explicit override pair: the OS media query + plus the attribute rule. Nothing else may. The same rule governs the + `.ui-preview` kit palette in `public/input.css`. Both are pinned by + `test/ssr/design-tokens.test.ts` and `test/ssr/kit-surfaces.test.ts`. - Each section in `page.ts` is a `
` wrapper for predictable scroll anchors. - **Code blocks follow three accessibility rules.** `test/ssr/pre-block-a11y.test.ts` diff --git a/website/app/docs/components/page.ts b/website/app/docs/components/page.ts index 8e9fe8758..deece1865 100644 --- a/website/app/docs/components/page.ts +++ b/website/app/docs/components/page.ts @@ -97,7 +97,7 @@ interface Student { name: string; gpa: number; } class UserCard extends WebComponent({ count: prop(Number, { reflect: true }), // reflect to the attribute label: prop(String, { attribute: 'aria-label' }), // renamed attribute - open: prop({ state: true }), // internal, no attribute + open: prop({ state: true }), // internal, no attribute (lit parity; prefer a signal) student: prop<Student>(Object), // narrowed object type tags: prop<string[]>(Array), // array-typed: pass Array, not Object size: prop<'sm' | 'lg'>(String), // narrowed enum type diff --git a/website/app/layout.ts b/website/app/layout.ts index a7d19048a..88369ae03 100644 --- a/website/app/layout.ts +++ b/website/app/layout.ts @@ -244,83 +244,89 @@ export default function RootLayout({ children }: LayoutProps) { 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. */ + /* Every per-theme COLOUR is declared ONCE, as light-dark(LIGHT, DARK), + and the color-scheme declarations below pick the side. This is the + rule the framework teaches its own users (the skill's + references/styling.md, and the palette webjs create generates), and + the site had been the counter-example: the dark half was written + twice, once under the OS media query and once under the toggle's + attribute, so an edit to either block silently drifted the two paths + apart. There is no cascade trick here, just the three color-scheme + declarations at the bottom of this block feeding the one function. */ :root { - color-scheme: light dark; - --fg: oklch(0.20 0.018 60); - --fg-muted: oklch(0.44 0.02 60); - --fg-subtle: oklch(0.50 0.02 65); - --bg: oklch(0.985 0.008 75); - --bg-elev: oklch(1 0 0); - --bg-subtle: oklch(0.96 0.008 75); - --bg-sunken: oklch(0.93 0.01 70); - --border: oklch(0.88 0.012 70 / 0.9); - --border-strong: oklch(0.78 0.014 70 / 0.95); - --accent: oklch(0.54 0.16 52); - --accent-hover: oklch(0.5 0.16 52); - --accent-fg: oklch(1 0 0); - --heart: oklch(0.64 0.22 6); - --accent-live: oklch(0.63 0.17 50); - --glow-a: oklch(0.63 0.17 44); - --accent-tint: color-mix(in oklch, var(--accent-live) 14%, transparent); - --logo-from: oklch(0.63 0.17 50); - --logo-to: oklch(0.44 0.11 52); + --fg: light-dark(oklch(0.20 0.018 60), oklch(0.96 0.01 60)); + --fg-muted: light-dark(oklch(0.44 0.02 60), oklch(0.78 0.015 60)); + --fg-subtle: light-dark(oklch(0.50 0.02 65), oklch(0.66 0.02 65)); + --bg: light-dark(oklch(0.985 0.008 75), oklch(0.08 0.012 60)); + --bg-elev: light-dark(oklch(1 0 0), oklch(0.14 0.015 60)); + --bg-subtle: light-dark(oklch(0.96 0.008 75), oklch(0.11 0.014 60)); + --bg-sunken: light-dark(oklch(0.93 0.01 70), oklch(0.06 0.01 60)); + --border: light-dark(oklch(0.88 0.012 70 / 0.9), oklch(0.24 0.015 60 / 0.9)); + --border-strong: light-dark(oklch(0.78 0.014 70 / 0.95), oklch(0.36 0.02 60 / 0.95)); + --accent: light-dark(oklch(0.54 0.16 52), oklch(0.78 0.18 58)); + --accent-hover: light-dark(oklch(0.5 0.16 52), oklch(0.83 0.17 58)); + --accent-fg: light-dark(oklch(1 0 0), oklch(0 0 0)); + --heart: light-dark(oklch(0.64 0.22 6), oklch(0.74 0.18 6)); + --accent-live: light-dark(oklch(0.63 0.17 50), oklch(0.78 0.18 58)); + --glow-a: light-dark(oklch(0.63 0.17 44), oklch(0.78 0.18 58)); + --logo-from: light-dark(oklch(0.63 0.17 50), oklch(0.82 0.17 58)); + --logo-to: light-dark(oklch(0.44 0.11 52), oklch(0.64 0.18 44)); + /* 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: light-dark(oklch(0 0 0 / 0.055), oklch(1 0 0 / 0.09)); + + /* Derived tokens. Each one reads a light-dark() token above, so it + tracks BOTH themes with no override of its own. */ --accent-text: var(--accent); + --accent-tint: color-mix(in oklch, var(--accent-live) 14%, transparent); --accent-surface: color-mix(in oklch, var(--accent-live) 12%, transparent); --accent-border: color-mix(in oklch, var(--accent-live) 28%, transparent); + --shadow-glow: 0 0 0 1px var(--accent-tint), 0 14px 50px color-mix(in oklch, var(--accent-live) 18%, transparent); + /* The closing CTA's fill. Light keeps a faint accent tint, which + separates the panel from the page. Dark pulls the tint back toward + the plain elevated surface: over black the same amount went muddy + rather than warm, so the glow does more of the work there. The two + sides differ by the MIX RATIO, which is a number rather than a + colour, so the ratio is the token that varies. */ + --cta-mix: 7%; + --cta-surface: color-mix(in oklch, var(--accent-live) var(--cta-mix), var(--bg-elev)); + + /* Shadows. --shadow-sm keeps the same geometry in both themes, so its + colour is the only per-theme part and rides light-dark() inline. + --shadow's SPREAD also changes (a wider, softer cast reads as depth + over black, where the light spread disappears), and light-dark() is + colour-only, so the spread is its own token in the block below. */ + --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); + --glow-strength: 0.16; --font-display: 'Inter Tight', 'Inter', system-ui, -apple-system, sans-serif; --font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 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; - --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; + + color-scheme: light dark; /* the default: follow the OS */ } + /* The three tokens above that are NOT colours, so light-dark() cannot + carry them. Per the styling reference these keep an explicit pair: + the media query for the OS default, the attribute rule for the + toggle. Colours must never be added here. */ @media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) { - --heart: oklch(0.74 0.18 6); - --fg: oklch(0.96 0.01 60); --fg-muted: oklch(0.78 0.015 60); --fg-subtle: oklch(0.66 0.02 65); - --bg: oklch(0.08 0.012 60); --bg-elev: oklch(0.14 0.015 60); --bg-subtle: oklch(0.11 0.014 60); --bg-sunken: oklch(0.06 0.01 60); - --border: oklch(0.24 0.015 60 / 0.9); --border-strong: oklch(0.36 0.02 60 / 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: oklch(0.78 0.18 58); - --glow-strength: 0.08; - --cta-surface: color-mix(in oklch, var(--accent-live) 6%, var(--bg-elev)); - --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); - } - } - :root[data-theme='dark'] { - color-scheme: dark; - --heart: oklch(0.74 0.18 6); - --fg: oklch(0.96 0.01 60); --fg-muted: oklch(0.78 0.015 60); --fg-subtle: oklch(0.66 0.02 65); - --bg: oklch(0.08 0.012 60); --bg-elev: oklch(0.14 0.015 60); --bg-subtle: oklch(0.11 0.014 60); --bg-sunken: oklch(0.06 0.01 60); - --border: oklch(0.24 0.015 60 / 0.9); --border-strong: oklch(0.36 0.02 60 / 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: oklch(0.78 0.18 58); - --glow-strength: 0.08; - --cta-surface: color-mix(in oklch, var(--accent-live) 6%, var(--bg-elev)); - --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); + :root:not([data-theme='light']) { --glow-strength: 0.08; --cta-mix: 6%; --shadow-spread: 0 10px 40px; } } + :root[data-theme='dark'] { --glow-strength: 0.08; --cta-mix: 6%; --shadow-spread: 0 10px 40px; } + /* The toggle forces a scheme, which is what re-points every + light-dark() above. Nothing else needs to change per theme. */ + :root[data-theme='dark'] { color-scheme: dark; } :root[data-theme='light'] { color-scheme: light; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; } diff --git a/website/app/llms.txt/route.ts b/website/app/llms.txt/route.ts index 3685a4294..f0c68daa6 100644 --- a/website/app/llms.txt/route.ts +++ b/website/app/llms.txt/route.ts @@ -5,6 +5,7 @@ import { renderDocsIndexSection } from '#lib/docs-llms.server.ts'; import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; import { splitByTier } from '#modules/ui/utils/tier.ts'; import { UI_PATH, GH_URL } from '#lib/links.ts'; +import { siteUrl } from '#lib/env.ts'; /** * GET /llms.txt @@ -30,7 +31,7 @@ import { UI_PATH, GH_URL } from '#lib/links.ts'; * enumerated inline (every page, with its description) rather than reduced * to a single link at another host. This is the one llms.txt for the site. */ -const SITE_URL = ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); +const SITE_URL = siteUrl(); /** Render a `## ` link section, or nothing when it has no items. */ function section(title: string, items: string[]): string[] { diff --git a/website/app/page.ts b/website/app/page.ts index 08e40643f..77da4b6ea 100644 --- a/website/app/page.ts +++ b/website/app/page.ts @@ -125,18 +125,12 @@ export default function LandingPage() { --editor-fg: var(--fg); --editor-gutter-fg: var(--fg-subtle); --editor-gutter-border: var(--border); - --code-tag: oklch(0.55 0.13 250); - --code-attr: oklch(0.52 0.16 150); - --code-str: oklch(0.55 0.13 145); + --code-tag: light-dark(oklch(0.55 0.13 250), oklch(0.78 0.13 250)); + --code-attr: light-dark(oklch(0.52 0.16 150), oklch(0.66 0.16 150)); + --code-str: light-dark(oklch(0.55 0.13 145), oklch(0.80 0.15 145)); --code-text: var(--fg); --code-punc: var(--fg-muted); } - :root[data-theme='dark'] { - --code-tag: oklch(0.78 0.13 250); --code-attr: oklch(0.66 0.16 150); --code-str: oklch(0.80 0.15 145); - } - @media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) { --code-tag: oklch(0.78 0.13 250); --code-attr: oklch(0.66 0.16 150); --code-str: oklch(0.80 0.15 145); } - } /* Syntax-highlight token colors (.t-kw / .t-str / ...) are defined globally in public/input.css so every surface (this page and the blog code fences) shares one palette. */ @@ -288,14 +282,20 @@ export default function LandingPage() { <div class="${CARD}"> <div class="mb-6"> <h3 class="font-display font-bold text-base leading-[1.3] tracking-[-0.02em] mt-0 mb-2">Light DOM web components</h3> - <p class="m-0 text-sm leading-[1.6] text-fg-muted">Web components that render to light DOM, so Tailwind and global CSS just work, no shadow plumbing.</p> + <p class="m-0 text-sm leading-[1.6] text-fg-muted">Components render to light DOM by default, so Tailwind and global CSS just work, no shadow plumbing. Scoped styles are one line away when you want them.</p> </div> - <div class="bg-[var(--editor-sidebar-bg)] border border-[var(--editor-border)] rounded-xl p-3.5 font-mono text-xs leading-[1.5] select-none text-[var(--editor-fg)]"> - <div class="text-[var(--code-punc)]"><<span class="text-[var(--code-tag)]">like-button</span>></div> - <div class="pl-4 text-[var(--code-punc)]"><<span class="text-[var(--code-tag)]">button</span> <span class="text-[var(--code-attr)]">class</span>=<span class="text-[var(--code-str)]">"px-3 rounded bg-accent"</span>></div> - <div class="pl-8 text-[var(--code-text)]">♥ Like</div> - <div class="pl-4 text-[var(--code-punc)]"></<span class="text-[var(--code-tag)]">button</span>></div> - <div class="text-[var(--code-punc)]"></<span class="text-[var(--code-tag)]">like-button</span>></div> + <!-- Two rows rather than a markup snippet: the point of this card + is which mode is the DEFAULT and how you leave it, and a tag + with Tailwind classes on it shows neither. Same row idiom as + the Built-in essentials card, with the accent border marking + the default and the opt-in stating the real one-line API. --> + <div class="bg-[var(--editor-sidebar-bg)] border border-[var(--editor-border)] rounded-xl p-2.5 flex flex-col gap-1.5 font-mono text-xs text-[var(--editor-fg)] select-none"> + <div class="flex justify-between items-center gap-2 px-2 py-1 bg-[var(--editor-bg)] border border-[var(--accent-border)] rounded"> + <span>Light DOM</span> <span class="text-[var(--accent-text)] whitespace-nowrap">default</span> + </div> + <div class="flex justify-between items-center gap-2 px-2 py-1 bg-[var(--editor-bg)] border border-[var(--editor-border)] rounded text-fg-subtle"> + <span>Shadow DOM</span> <span class="whitespace-nowrap">static shadow = true</span> + </div> </div> </div> diff --git a/website/app/robots.ts b/website/app/robots.ts index ff507b815..eccc6dca3 100644 --- a/website/app/robots.ts +++ b/website/app/robots.ts @@ -1,3 +1,4 @@ +import { siteUrl } from '#lib/env.ts'; /** * /robots.txt * @@ -28,7 +29,7 @@ * * `SITE_URL` mirrors app/sitemap.ts so the two agree on the origin. */ -const SITE_URL = ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); +const SITE_URL = siteUrl(); // Answer-engine and AI crawlers we explicitly welcome. These are the agents // that put a citation in front of a developer who asks an assistant what diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts index d54f3855e..857045f00 100644 --- a/website/app/sitemap.ts +++ b/website/app/sitemap.ts @@ -4,6 +4,7 @@ import { listArticles } from '#modules/articles/queries/list-articles.server.ts' import { listPosts } from '#modules/blog/queries/list-posts.server.ts'; import { getDocPages } from '#lib/docs-llms.server.ts'; import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; +import { siteUrl } from '#lib/env.ts'; /** * /sitemap.xml @@ -21,7 +22,7 @@ import { loadRegistryIndex } from '#modules/ui/queries/registry.server.ts'; * `SITE_URL` falls back to the production origin; override it per * deployment the same way the header/footer links are configured. */ -const SITE_URL = ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); +const SITE_URL = siteUrl(); export default async function Sitemap() { const [comparisons, articles, posts, docPages, registry] = await Promise.all([ diff --git a/website/components/copy-cmd.ts b/website/components/copy-cmd.ts index 7d6ed6b55..629704c6c 100644 --- a/website/components/copy-cmd.ts +++ b/website/components/copy-cmd.ts @@ -1,4 +1,15 @@ -import { WebComponent, html, signal } from '@webjsdev/core'; +import { WebComponent, html, signal, createRef, ref } from '@webjsdev/core'; + +/** + * gtag is installed by the root layout's Google tag snippet. Declaring it + * here gives the call below a real signature instead of a cast through + * unknown, and the optional marker keeps a blocked or absent tag safe. + */ +declare global { + interface Window { + gtag?: (command: 'event', name: string, params?: Record<string, string>) => void; + } +} /** * `<copy-cmd>` wraps a shell-command line with a copy-to-clipboard @@ -38,7 +49,14 @@ export class CopyCmd extends WebComponent { // (an aria-live region only announces on a content CHANGE), re-announcing // "Copied" even though `copied` is already true. private _copies = signal(0); - private _resetTimer: number | undefined; + private _resetTimer: ReturnType<typeof setTimeout> | undefined; + /** + * The command line, bound through the ref directive rather than looked up + * with querySelector. render() already owns this element, so a ref keeps the + * reference flowing out of the template instead of re-finding it by selector + * on every copy, and it cannot silently return null if the markup moves. + */ + private _textRef = createRef<HTMLElement>(); disconnectedCallback() { if (this._resetTimer) clearTimeout(this._resetTimer); @@ -46,8 +64,7 @@ export class CopyCmd extends WebComponent { } _copy = async () => { - const textEl = this.querySelector('[data-copy-text]'); - const text = (textEl?.textContent || '').trim(); + const text = (this._textRef.value?.textContent || '').trim(); if (!text) return; try { await navigator.clipboard.writeText(text); @@ -59,7 +76,7 @@ export class CopyCmd extends WebComponent { this.copied.set(true); this._copies.set(this._copies.get() + 1); if (this._resetTimer) clearTimeout(this._resetTimer); - this._resetTimer = (setTimeout(() => this.copied.set(false), 1500) as unknown as number); + this._resetTimer = setTimeout(() => this.copied.set(false), 1500); // Record install-intent. A copied command (almost always the // `npm create webjs@latest` line) is the cleanest human-adoption // signal we have, far more trustworthy than npm download counts. @@ -67,11 +84,7 @@ export class CopyCmd extends WebComponent { // or absent tag is a silent no-op. This sits OUTSIDE the clipboard // try so it is not the catch that swallows a write failure, and so an // absent-gtag access genuinely depends on the `?.` to stay safe. - (window as unknown as { gtag?: (...a: unknown[]) => void }).gtag?.( - 'event', - 'copy_command', - { command: text }, - ); + window.gtag?.('event', 'copy_command', { command: text }); }; _onKey = (e: KeyboardEvent) => { @@ -92,6 +105,7 @@ export class CopyCmd extends WebComponent { <span class="scroll-thin flex-1 min-w-0 overflow-x-auto whitespace-nowrap cursor-copy pr-9 rounded-md outline-none focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2" data-copy-text + ${ref(this._textRef)} role="button" tabindex="0" title="Copy command to clipboard" diff --git a/website/components/doc-search.ts b/website/components/doc-search.ts index bbe767e3b..e56af5c44 100644 --- a/website/components/doc-search.ts +++ b/website/components/doc-search.ts @@ -10,7 +10,10 @@ export class DocSearch extends WebComponent { results = signal<Result[]>([]); loading = signal(false); open = signal(false); - _timer: any = null; + // undefined rather than null for the unset state: clearTimeout accepts + // undefined (a no-op) but not null, so this is the one that needs no cast + // at the call site. + _timer: ReturnType<typeof setTimeout> | undefined; onInput(e: InputEvent) { const val = (e.target as HTMLInputElement).value; diff --git a/website/components/preview-tabs.ts b/website/components/preview-tabs.ts index c273714eb..b2c34039e 100644 --- a/website/components/preview-tabs.ts +++ b/website/components/preview-tabs.ts @@ -1,4 +1,4 @@ -import { WebComponent, html, css, signal } from '@webjsdev/core'; +import { WebComponent, html, css, signal, createRef, ref } from '@webjsdev/core'; /** * `<preview-tabs>`: a Preview / Code segmented toggle wrapping a live @@ -25,6 +25,18 @@ export class PreviewTabs extends WebComponent { /** Which pane is visible. Instance signal, so each toggle is component-local. */ mode = signal<'preview' | 'code'>('preview'); + /** + * The two tab buttons, bound through the ref directive. Keyboard selection + * has to move focus to the newly-selected tab (the roving-tabindex half of + * the APG pattern), and a ref is how render() hands that element back, in + * place of reaching into the shadow root with a selector built from the + * mode string. + */ + private _tabRefs = { + preview: createRef<HTMLButtonElement>(), + code: createRef<HTMLButtonElement>(), + }; + static styles = css` :host { display: block; } .bar { @@ -79,7 +91,14 @@ export class PreviewTabs extends WebComponent { this.mode.set(next); // Follow-focus selection, the APG default for a tablist whose panels are // already in the DOM (both slots stay mounted here). - this.shadowRoot?.querySelector<HTMLElement>(`#tab-${next}`)?.focus(); + // + // Deferred behind updateComplete so focus lands after the roving tabindex + // has been committed rather than while the target still reads + // tabindex="-1". Ordering only: focusing a tabindex="-1" element + // programmatically is legal, so both orders currently pass, and the + // browser test cannot tell them apart. Kept because the committed order is + // the one the ARIA state actually describes. + this.updateComplete.then(() => this._tabRefs[next].value?.focus()); } render() { @@ -92,6 +111,7 @@ export class PreviewTabs extends WebComponent { <button type="button" id="tab-preview" + ${ref(this._tabRefs.preview)} class="tab" role="tab" aria-controls="panel-preview" @@ -103,6 +123,7 @@ export class PreviewTabs extends WebComponent { <button type="button" id="tab-code" + ${ref(this._tabRefs.code)} class="tab" role="tab" aria-controls="panel-code" diff --git a/website/components/ui/accordion.ts b/website/components/ui/accordion.ts deleted file mode 100644 index 968bc1ea4..000000000 --- a/website/components/ui/accordion.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Accordion: vertical collapsible list built on native <details>/<summary>. - * - * Tier-1 (no custom element). Exclusive open behaviour (Radix's - * `type="single"`) comes from giving every <details> the same - * `name="..."` attribute. Independent open (`type="multiple"`) is the - * default when `name` is omitted. Both modes give `collapsible` for - * free: clicking the open <summary> closes it. - * - * shadcn parity: - * <Accordion type="single" collapsible> → <div class=${accordionClass()}> wrapping - * <details name="..."> items - * <Accordion type="multiple"> → same, omit `name` - * <AccordionItem> → <details class=${accordionItemClass()}> - * <AccordionTrigger> → <summary class=${accordionTriggerClass()}> - * <AccordionContent> → <div class=${accordionContentClass()}> - * - * Initial state: add `open` on the <details> that should render expanded - * on first paint. Programmatic toggling: `el.open = true | false`. - * - * `<details name="X">` is the platform's exclusive-accordion primitive: - * Chrome 120+, Safari 17.2+, Firefox 130+. Migrated from the prior - * <ui-accordion> custom element set. - * - * Design tokens used: --border, --ring, --foreground. - * - * @example - * ```html - * <div class=${accordionClass()}> - * <details name="faq" class=${accordionItemClass()} open> - * <summary class=${accordionTriggerClass()}> - * <span>Is it accessible?</span> - * <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg> - * </summary> - * <div class=${accordionContentClass()}>Yes, it uses a native disclosure widget.</div> - * </details> - * <details name="faq" class=${accordionItemClass()}> - * <summary class=${accordionTriggerClass()}> - * <span>Is it styled?</span> - * <svg class="size-4 shrink-0 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg> - * </summary> - * <div class=${accordionContentClass()}>Yes, with the shadcn design tokens.</div> - * </details> - * </div> - * ``` - */ - -/** Root wrapper. Holds the column-of-items rhythm; no display: rules. */ -export const accordionClass = (): string => 'w-full'; - -/** - * Item: each <details>. The `group` utility lets the trigger's chevron - * rotate on open via `group-open:rotate-180`. `last:border-b-0` cleans - * the trailing edge. - */ -export const accordionItemClass = (): string => 'group border-b last:border-b-0'; - -/** - * Trigger: applied to <summary>. Hides the native disclosure triangle so - * authors can compose their own chevron icon (typical pattern: trailing - * lucide chevron with `group-open:rotate-180`). - * - * `disabled: true` returns the visual disabled state (greyed out, - * not-allowed cursor, no pointer events). For true keyboard prevention - *, the native disabled-disclosure-widget gap, add the standard - * `inert` attribute to the <details> element. shadcn's React `disabled` - * prop combines both; native HTML has no `disabled` on <details>. - */ -export const accordionTriggerClass = (opts: { disabled?: boolean } = {}): string => { - const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-4 py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden'; - if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`; - return base; -}; - -/** - * Content: <details> hides this entirely when not [open], so all we add - * is the typography rhythm matching shadcn (bottom padding, small text). - */ -export const accordionContentClass = (): string => 'pb-4 text-sm'; diff --git a/website/components/ui/aspect-ratio.ts b/website/components/ui/aspect-ratio.ts deleted file mode 100644 index 122f80d4c..000000000 --- a/website/components/ui/aspect-ratio.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * AspectRatio: preserve a width:height ratio for its child. Tier-1 - * class helper over the modern CSS `aspect-ratio` property (Baseline - * 2022). No JS, no custom element. - * - * shadcn parity: - * AspectRatio (Radix primitive) → aspectRatioClass() + inline `aspect-ratio` style - * - * Design tokens used: none (layout only). - * - * @example - * ```html - * <div style="aspect-ratio: 16/9;" class="${aspectRatioClass()}"> - * <img src="/hero.jpg" alt="Team offsite" class="h-full w-full object-cover rounded-md"> - * </div> - * - * <!-- Or with Tailwind's arbitrary aspect-ratio (no helper needed): --> - * <div class="aspect-[16/9]"> - * <img src="/hero.jpg" alt="Team offsite" class="h-full w-full object-cover rounded-md"> - * </div> - * ``` - */ - -export const aspectRatioClass = (): string => 'relative w-full'; diff --git a/website/components/ui/breadcrumb.ts b/website/components/ui/breadcrumb.ts deleted file mode 100644 index 3f5dc4422..000000000 --- a/website/components/ui/breadcrumb.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Breadcrumb: semantic nav with breadcrumb list. Tier-1 class helpers; - * compose with native `<nav>` + `<ol>` + `<li>` + `<a>` / `<span>`. - * - * shadcn parity: - * Breadcrumb → <nav aria-label="breadcrumb" data-slot="breadcrumb"> - * BreadcrumbList → breadcrumbListClass() - * BreadcrumbItem → breadcrumbItemClass() - * BreadcrumbLink → breadcrumbLinkClass() - * BreadcrumbPage → breadcrumbPageClass() (with aria-current="page") - * BreadcrumbSeparator → breadcrumbSeparatorClass() (aria-hidden + role="presentation") - * BreadcrumbEllipsis → breadcrumbEllipsisClass() - * - * A11y (required for accessible output): wrap the list in <nav - * aria-label="breadcrumb">, set aria-current="page" on the current-page - * element, and mark each separator role="presentation" aria-hidden="true". - * The class helpers emit none of these. - * - * Design tokens used: --muted-foreground, --foreground. - * - * @example - * ```html - * <nav aria-label="breadcrumb" data-slot="breadcrumb"> - * <ol class=${breadcrumbListClass()}> - * <li class=${breadcrumbItemClass()}> - * <a class=${breadcrumbLinkClass()} href="/">Home</a> - * </li> - * <li class=${breadcrumbSeparatorClass()} role="presentation" aria-hidden="true"> - * <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg> - * </li> - * <li class=${breadcrumbItemClass()}> - * <span class=${breadcrumbPageClass()} aria-current="page">Posts</span> - * </li> - * </ol> - * </nav> - * ``` - */ - -export const breadcrumbListClass = (): string => - 'flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5'; - -export const breadcrumbItemClass = (): string => 'inline-flex items-center gap-1.5'; - -export const breadcrumbLinkClass = (): string => 'transition-colors hover:text-foreground'; - -export const breadcrumbPageClass = (): string => 'font-normal text-foreground'; - -export const breadcrumbSeparatorClass = (): string => '[&>svg]:size-3.5'; - -export const breadcrumbEllipsisClass = (): string => 'flex size-9 items-center justify-center'; diff --git a/website/components/ui/card.ts b/website/components/ui/card.ts deleted file mode 100644 index b63e38a20..000000000 --- a/website/components/ui/card.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Card: visual container. Tier-1 class helpers; compose with `<div>` - * (or any element) for each subpart. - * - * shadcn parity: - * Card (size: default | sm) → cardClass({ size }) - * CardHeader → cardHeaderClass() - * CardTitle → cardTitleClass() - * CardDescription → cardDescriptionClass() - * CardAction → cardActionClass() - * CardContent → cardContentClass() - * CardFooter → cardFooterClass() - * - * Design tokens used: --card, --card-foreground, --muted-foreground, --border. - * - * @example - * ```html - * <div class=${cardClass()}> - * <div class=${cardHeaderClass()}> - * <div class=${cardTitleClass()}>Notifications</div> - * <div class=${cardDescriptionClass()}>You have 3 unread messages.</div> - * <div class=${cardActionClass()}> - * <button class=${buttonClass({ variant: 'ghost', size: 'sm' })}>Mark all read</button> - * </div> - * </div> - * <div class=${cardContentClass()}> - * <p class="text-sm">Your weekly digest is ready to review.</p> - * </div> - * <div class=${cardFooterClass()}> - * <button class=${buttonClass()}>Save</button> - * </div> - * </div> - * ``` - */ - -export type CardSize = 'default' | 'sm'; - -/** - * Card root. shadcn ships `size?: "default" | "sm"` on Card across - * 14/15 style families (only new-york-v4 omits it). The class string - * uses `group/card` so the header / title / content / footer helpers - * can read the parent card's data-size and adjust their own padding - * + gap. - * - * USAGE: pass size to cardClass AND set data-size="<size>" on the - * same host element so the group-data-[size=...]/card child rules - * fire. Set `data-slot="card"` for shadcn parity. - * - * <div class=${cardClass({ size: 'sm' })} data-slot="card" data-size="sm"> - * <div class=${cardHeaderClass()}>...</div> - * ... - * </div> - * - * Sizes: - * default: gap-6 / py-6 (shadcn new-york-v4 default) - * sm: gap-3 / py-3 (shadcn radix-nova + base-* defaults) - */ -export const cardClass = (opts: { size?: CardSize } = {}): string => { - const size = opts.size ?? 'default'; - const base = - 'group/card flex flex-col rounded-xl border bg-card text-card-foreground shadow-sm'; - return size === 'sm' ? base + ' gap-3 py-3' : base + ' gap-6 py-6'; -}; - -/** - * Card header: supports an optional `CardAction` slot via grid layout. - * group-data-[size=sm]/card rules pick up the compact layout when the - * root card carries data-size="sm". - */ -export const cardHeaderClass = (): string => - '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 group-data-[size=sm]/card:px-4 group-data-[size=sm]/card:gap-1 group-data-[size=sm]/card:[.border-b]:pb-3'; - -/** Card title: heading text within the header. Smaller when card is data-size="sm". */ -export const cardTitleClass = (): string => - 'leading-none font-semibold group-data-[size=sm]/card:text-sm'; - -/** Card description: subdued caption beneath the title. */ -export const cardDescriptionClass = (): string => 'text-sm text-muted-foreground'; - -/** Card action: right-aligned controls inside the header (matches shadcn CardAction). */ -export const cardActionClass = (): string => - 'col-start-2 row-span-2 row-start-1 self-start justify-self-end'; - -/** Card content: the main body region. Tighter padding when card is data-size="sm". */ -export const cardContentClass = (): string => - 'px-6 group-data-[size=sm]/card:px-4'; - -/** Card footer: trailing controls or actions. Tighter padding when card is data-size="sm". */ -export const cardFooterClass = (): string => - 'flex items-center px-6 [.border-t]:pt-6 group-data-[size=sm]/card:px-4 group-data-[size=sm]/card:[.border-t]:pt-3'; diff --git a/website/components/ui/collapsible.ts b/website/components/ui/collapsible.ts deleted file mode 100644 index 5cba4fdaa..000000000 --- a/website/components/ui/collapsible.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Collapsible: togglable content panel built on native <details>/<summary>. - * - * Tier-1 (no custom element). The browser handles open/close state, - * keyboard activation (Enter / Space on <summary>), focus management, - * and disclosure-widget accessibility, nothing to ship in JS. - * - * shadcn parity: - * Collapsible → <details class=${collapsibleClass()}> - * CollapsibleTrigger → <summary class=${collapsibleTriggerClass()}> - * CollapsibleContent → <div class=${collapsibleContentClass()}> - * - * Initial state: add `open` on <details> to render expanded on first - * paint. Programmatic toggling: `el.open = true | false`. Migrated from - * the prior <ui-collapsible> custom element; the trigger class hides - * the native disclosure marker so callers can render their own chevron. - * - * Design tokens used: --border, --ring, --foreground. - * - * @example - * ```html - * <details class=${collapsibleClass()}> - * <summary class=${collapsibleTriggerClass()}> - * Show details - * <svg class="size-4 transition-transform group-open:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg> - * </summary> - * <div class=${collapsibleContentClass()}> - * Hidden until <summary> is clicked, Enter / Space pressed, or the - * <details> element's `open` property is set via JS. - * </div> - * </details> - * ``` - */ - -/** - * Root: marks the disclosure widget as a `group` so descendants can use - * Tailwind's `group-open:` variant to react to the `[open]` attribute - * (which `<details>` sets natively). No visual styling of its own. - */ -export const collapsibleClass = (): string => 'group'; - -/** - * Trigger: hides the native ::marker (and the WebKit -details-marker shim) - * so the disclosure triangle does not appear; callers wrap their own - * chevron icon and rotate it on open via `group-open:rotate-180`. - * - * `disabled: true` returns the visual disabled state. Native <details> - * has no `disabled` attribute, so for full keyboard prevention add the - * standard `inert` attribute on the <details> element. shadcn's React - * `disabled` prop combines both visual and behavior; we split them. - */ -export const collapsibleTriggerClass = (opts: { disabled?: boolean } = {}): string => { - const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-2 rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden'; - if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`; - return base; -}; - -/** - * Content: <details> already hides children other than <summary> when - * not [open], so this is purely typographic spacing. No display rules. - */ -export const collapsibleContentClass = (): string => 'text-sm'; diff --git a/website/components/ui/kbd.ts b/website/components/ui/kbd.ts deleted file mode 100644 index c3965a78d..000000000 --- a/website/components/ui/kbd.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Kbd: keyboard chord display. Tier-1 class helpers; compose with the - * native `<kbd>` element for correct semantics. - * - * shadcn parity: - * Kbd → kbdClass() - * KbdGroup → kbdGroupClass() - * - * Design tokens used: --muted, --muted-foreground, --background. - * - * @example - * ```html - * <kbd class=${kbdClass()}>⌘</kbd> - * <kbd class=${kbdClass()}>K</kbd> - * - * <div class=${kbdGroupClass()}> - * <kbd class=${kbdClass()}>⌘</kbd> - * <kbd class=${kbdClass()}>Shift</kbd> - * <kbd class=${kbdClass()}>P</kbd> - * </div> - * ``` - */ - -export const kbdClass = (): string => - "pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none [&_svg:not([class*='size-'])]:size-3 [[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10"; - -export const kbdGroupClass = (): string => 'inline-flex items-center gap-1'; diff --git a/website/components/ui/label.ts b/website/components/ui/label.ts deleted file mode 100644 index cf4c3f76e..000000000 --- a/website/components/ui/label.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Label: styled native `<label>`. Tier-1 class helper. Compose with a - * real `<label for="...">` so click-to-focus, `htmlFor` / `for` linking, - * and screen-reader association all work natively (no Radix Label needed). - * - * shadcn parity: - * Label → labelClass() - * - * Disabled-state inheritance: when the label is inside a container with - * `data-disabled="true"` (the "field" pattern), or next to a peer-disabled - * control, it dims automatically. - * - * Design tokens used: none (typography only). - * - * @example - * ```html - * <label class=${labelClass()} for="email">Email</label> - * <input class=${inputClass()} id="email" name="email" type="email"> - * ``` - */ - -/** Compose Tailwind classes for a native `<label>`. */ -export function labelClass(): string { - return 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50'; -} diff --git a/website/components/ui/popover.ts b/website/components/ui/popover.ts deleted file mode 100644 index 662441010..000000000 --- a/website/components/ui/popover.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Popover: floating panel anchored to a trigger button, built on the - * native HTML Popover API (`popover` attribute + `popovertarget`). - * - * Tier-1 (no custom element). The browser handles open / close state, - * top-layer rendering, light-dismiss on outside click (with - * `popover="auto"`), Escape-to-close, focus restoration to the invoker, - * and CSS Anchor Positioning's implicit anchor (a popover invoked via - * `popovertarget` is automatically anchored to its invoker, no - * `anchor-name` / `position-anchor` inline style needed for the common - * case). CSS Anchor Positioning ships in Chrome 125+, Safari 26+, - * Firefox 134+. - * - * shadcn parity: - * Popover → <button popovertarget="id"> + <div id="id" popover> - * PopoverContent → popoverContentClass({ side, align, sideOffset, alignOffset }) - * PopoverHeader → popoverHeaderClass() - * PopoverTitle → popoverTitleClass() - * PopoverDescription → popoverDescriptionClass() - * - * The `positionFloating` helper is also exported (used by the Tier-2 - * tooltip / hover-card / dropdown-menu components for imperative - * positioning where CSS Anchor Positioning isn't enough). Migrated from - * the prior <ui-popover> custom element set. - * - * Design tokens used: --popover, --popover-foreground, --border. - * - * @example - * ```html - * <!-- Single invoker, implicit anchor, zero inline style. --> - * <button popovertarget="filter" class=${buttonClass({ variant: 'outline' })}>Filter</button> - * <div id="filter" popover - * class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}> - * <div class=${popoverHeaderClass()}> - * <h3 class=${popoverTitleClass()}>Filter posts</h3> - * <p class=${popoverDescriptionClass()}>By tag and status.</p> - * </div> - * </div> - * - * <!-- Explicit anchor, for multiple invokers or anchoring to a different element. --> - * <span style="anchor-name: --picker">@vivek</span> - * <button popovertarget="profile">Show</button> - * <div id="profile" popover style="position-anchor: --picker" - * class=${popoverContentClass({ side: 'bottom' })}> - * <div class=${popoverHeaderClass()}> - * <h3 class=${popoverTitleClass()}>Vivek Khandelwal</h3> - * <p class=${popoverDescriptionClass()}>@vivek</p> - * </div> - * </div> - * ``` - */ - -// -------------------------------------------------------------------------- -// Class helpers -// -------------------------------------------------------------------------- - -/** - * Popover content options, mirror shadcn's `<PopoverContent>` props. - * `side` and `align` map to CSS Anchor Positioning's `position-area`; - * `sideOffset` maps to a directional margin so the popover sits a few - * pixels off the anchor's edge. - * - * `sideOffset` is restricted to a discrete set so Tailwind 4's static - * scanner sees each emitted class literal. For arbitrary values, - * override the margin via inline style on the popover element. - */ -export type PopoverSideOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24; -export type PopoverAlignOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24; - -export interface PopoverContentOptions { - /** Which side of the trigger the popover appears on. Default 'bottom'. */ - side?: PopoverSide; - /** Alignment along the chosen side. Default 'center'. */ - align?: PopoverAlign; - /** Pixels between the trigger and the popover. Default 4 (shadcn default). */ - sideOffset?: PopoverSideOffset; - /** - * Pixels offset along the align axis. Positive values push away from - * the aligned edge toward the opposite edge. No-op for align='center'. - * Default 0 (matches shadcn). - */ - alignOffset?: PopoverAlignOffset; -} - -// position-area combinations baked as literal class strings so Tailwind 4's -// scanner generates CSS for every (side, align) pair the helper can return. -// Underscores become spaces in arbitrary CSS values. -const POSITION_AREA_CLASS: Record<string, string> = { - 'top-start': '[position-area:top_span-right]', - 'top-center': '[position-area:top]', - 'top-end': '[position-area:top_span-left]', - 'bottom-start': '[position-area:bottom_span-right]', - 'bottom-center': '[position-area:bottom]', - 'bottom-end': '[position-area:bottom_span-left]', - 'left-start': '[position-area:left_span-bottom]', - 'left-center': '[position-area:left]', - 'left-end': '[position-area:left_span-top]', - 'right-start': '[position-area:right_span-bottom]', - 'right-center': '[position-area:right]', - 'right-end': '[position-area:right_span-top]', -}; - -// Align-axis translate classes. For align='start', positive offset moves -// the popover AWAY from the start edge (toward the end edge); align='end' -// reverses. align='center' is a no-op. The axis is perpendicular to the -// side: top/bottom sides translate X; left/right sides translate Y. -// All 36 (4 axis-direction combos × 9 offset values) appear literally so -// Tailwind 4 generates each class. -const ALIGN_OFFSET_CLASS: Record<string, Record<PopoverAlignOffset, string>> = { - 'horizontal-start': { - 0: 'translate-x-[0px]', 2: 'translate-x-[2px]', 4: 'translate-x-[4px]', - 6: 'translate-x-[6px]', 8: 'translate-x-[8px]', 12: 'translate-x-[12px]', - 16: 'translate-x-[16px]', 20: 'translate-x-[20px]', 24: 'translate-x-[24px]', - }, - 'horizontal-end': { - 0: 'translate-x-[0px]', 2: 'translate-x-[-2px]', 4: 'translate-x-[-4px]', - 6: 'translate-x-[-6px]', 8: 'translate-x-[-8px]', 12: 'translate-x-[-12px]', - 16: 'translate-x-[-16px]', 20: 'translate-x-[-20px]', 24: 'translate-x-[-24px]', - }, - 'vertical-start': { - 0: 'translate-y-[0px]', 2: 'translate-y-[2px]', 4: 'translate-y-[4px]', - 6: 'translate-y-[6px]', 8: 'translate-y-[8px]', 12: 'translate-y-[12px]', - 16: 'translate-y-[16px]', 20: 'translate-y-[20px]', 24: 'translate-y-[24px]', - }, - 'vertical-end': { - 0: 'translate-y-[0px]', 2: 'translate-y-[-2px]', 4: 'translate-y-[-4px]', - 6: 'translate-y-[-6px]', 8: 'translate-y-[-8px]', 12: 'translate-y-[-12px]', - 16: 'translate-y-[-16px]', 20: 'translate-y-[-20px]', 24: 'translate-y-[-24px]', - }, -}; - -// Per-side offset margin classes. Side 'bottom' wants margin-top, etc. -// Each value of PopoverSideOffset appears literally for Tailwind's scanner. -const MARGIN_OFFSET_CLASS: Record<PopoverSide, Record<PopoverSideOffset, string>> = { - top: { - 0: '[margin-bottom:0px]', 2: '[margin-bottom:2px]', 4: '[margin-bottom:4px]', - 6: '[margin-bottom:6px]', 8: '[margin-bottom:8px]', 12: '[margin-bottom:12px]', - 16: '[margin-bottom:16px]', 20: '[margin-bottom:20px]', 24: '[margin-bottom:24px]', - }, - bottom: { - 0: '[margin-top:0px]', 2: '[margin-top:2px]', 4: '[margin-top:4px]', - 6: '[margin-top:6px]', 8: '[margin-top:8px]', 12: '[margin-top:12px]', - 16: '[margin-top:16px]', 20: '[margin-top:20px]', 24: '[margin-top:24px]', - }, - left: { - 0: '[margin-right:0px]', 2: '[margin-right:2px]', 4: '[margin-right:4px]', - 6: '[margin-right:6px]', 8: '[margin-right:8px]', 12: '[margin-right:12px]', - 16: '[margin-right:16px]', 20: '[margin-right:20px]', 24: '[margin-right:24px]', - }, - right: { - 0: '[margin-left:0px]', 2: '[margin-left:2px]', 4: '[margin-left:4px]', - 6: '[margin-left:6px]', 8: '[margin-left:8px]', 12: '[margin-left:12px]', - 16: '[margin-left:16px]', 20: '[margin-left:20px]', 24: '[margin-left:24px]', - }, -}; - -/** - * Popover content class. `side` and `align` cover the shadcn - * `<PopoverContent>` placement props; `sideOffset` sets the gap to - * the anchor. The visual layer (border, bg, padding, shadow) is - * fixed to match shadcn's default; width is opinionated at `w-72` - * (override at the call site). - * - * `m-0` clears the UA `margin: auto` so anchor positioning isn't - * fighting auto-centering, then a single directional margin sets the - * sideOffset gap. - */ -export function popoverContentClass(opts: PopoverContentOptions = {}): string { - const side = opts.side ?? 'bottom'; - const align = opts.align ?? 'center'; - const sideOffset = opts.sideOffset ?? 4; - const alignOffset = opts.alignOffset ?? 0; - // align='center' has no align axis to offset along, skip the translate. - let alignClass = ''; - if (align !== 'center' && alignOffset !== 0) { - const axis = side === 'top' || side === 'bottom' ? 'horizontal' : 'vertical'; - alignClass = ALIGN_OFFSET_CLASS[`${axis}-${align}`][alignOffset]; - } - return [ - 'w-72 m-0 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden', - POSITION_AREA_CLASS[`${side}-${align}`], - MARGIN_OFFSET_CLASS[side][sideOffset], - alignClass, - ].filter(Boolean).join(' '); -} - -export const popoverHeaderClass = (): string => 'flex flex-col gap-1 text-sm'; -export const popoverTitleClass = (): string => 'font-medium'; -export const popoverDescriptionClass = (): string => 'text-muted-foreground'; - -// -------------------------------------------------------------------------- -// Imperative positioning helper. Still exported for the tier-2 tooltip / -// hover-card / dropdown-menu components, which need exact placement before -// CSS anchor positioning is universally available. -// -------------------------------------------------------------------------- - -export type PopoverSide = 'top' | 'bottom' | 'left' | 'right'; -export type PopoverAlign = 'start' | 'center' | 'end'; - -export function positionFloating( - trigger: HTMLElement, - content: HTMLElement, - opts: { - side?: PopoverSide; - align?: PopoverAlign; - sideOffset?: number; - alignOffset?: number; - } = {}, -): void { - const side = opts.side ?? 'bottom'; - const align = opts.align ?? 'center'; - const sideOffset = opts.sideOffset ?? 4; - const alignOffset = opts.alignOffset ?? 0; - const tr = trigger.getBoundingClientRect(); - const cr = content.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - let top = 0; - let left = 0; - let actualSide = side; - - const fitsBottom = tr.bottom + sideOffset + cr.height <= vh; - const fitsTop = tr.top - sideOffset - cr.height >= 0; - const fitsRight = tr.right + sideOffset + cr.width <= vw; - const fitsLeft = tr.left - sideOffset - cr.width >= 0; - - if (side === 'bottom' && !fitsBottom && fitsTop) actualSide = 'top'; - else if (side === 'top' && !fitsTop && fitsBottom) actualSide = 'bottom'; - else if (side === 'right' && !fitsRight && fitsLeft) actualSide = 'left'; - else if (side === 'left' && !fitsLeft && fitsRight) actualSide = 'right'; - - if (actualSide === 'bottom') top = tr.bottom + sideOffset; - else if (actualSide === 'top') top = tr.top - sideOffset - cr.height; - else if (actualSide === 'right' || actualSide === 'left') { - if (align === 'start') top = tr.top; - else if (align === 'end') top = tr.bottom - cr.height; - else top = tr.top + (tr.height - cr.height) / 2; - } - - if (actualSide === 'right') left = tr.right + sideOffset; - else if (actualSide === 'left') left = tr.left - sideOffset - cr.width; - else { - if (align === 'start') left = tr.left; - else if (align === 'end') left = tr.right - cr.width; - else left = tr.left + (tr.width - cr.width) / 2; - } - - // alignOffset shifts the popover along the align axis. For align='start', - // positive shifts AWAY from the start edge; align='end' reverses; center - // is a no-op. Axis is perpendicular to the chosen side. - if (align !== 'center' && alignOffset !== 0) { - const dir = align === 'start' ? 1 : -1; - if (actualSide === 'top' || actualSide === 'bottom') { - left += dir * alignOffset; - } else { - top += dir * alignOffset; - } - } - - left = Math.max(8, Math.min(left, vw - cr.width - 8)); - top = Math.max(8, Math.min(top, vh - cr.height - 8)); - - content.style.top = `${top}px`; - content.style.left = `${left}px`; - content.setAttribute('data-side', actualSide); - content.setAttribute('data-align', align); -} diff --git a/website/components/ui/progress.ts b/website/components/ui/progress.ts deleted file mode 100644 index c871051d3..000000000 --- a/website/components/ui/progress.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Progress: progress bar. Tier-1 class helper over the native - * `<progress>` element. The native element supplies the `progressbar` - * role and `aria-valuenow` automatically from its `value` / `max` - * attributes, so no JS, no custom element. - * - * shadcn parity: - * Progress → progressClass() (visual: 2px track + animated fill, - * styled via `::-webkit-progress-bar`, - * `::-webkit-progress-value`, and - * `::-moz-progress-bar`) - * - * A11y (required for accessible output): give the <progress> an accessible - * name with aria-label (or a <label for>), e.g. aria-label="Upload - * progress". The native element supplies the role and value; only the name - * is the author's responsibility. - * - * Design tokens used: --primary. - * - * @example - * ```html - * <progress value="42" max="100" class=${progressClass()}></progress> - * - * <!-- Indeterminate state, with no value attribute. --> - * <progress class=${progressClass()}></progress> - * ``` - */ - -/** - * Class for the native `<progress>` element. The track + fill colours - * come from Tailwind utilities. The browser handles the actual bar - * rendering through the `::-webkit-progress-value` and - * `::-moz-progress-bar` pseudo-elements. We expose them via the - * `[&::-webkit-progress-value]:bg-primary` and - * `[&::-moz-progress-bar]:bg-primary` Tailwind variants. - * - * Indeterminate state (no `value` attribute on the element) gets a - * pulse animation via `:indeterminate { animate-pulse }`. - */ -export const progressClass = (): string => - [ - // Reset native styling. WebKit / Blink draw a default 3D bar that we - // strip via appearance-none + classic clear of border/bg. - 'block h-2 w-full overflow-hidden rounded-full', - 'appearance-none border-0 bg-primary/20 [&::-webkit-progress-bar]:bg-primary/20', - // Bar fill: blink/webkit + firefox both via Tailwind 4's arbitrary- - // pseudo variant. Smooth animation on width change matches shadcn. - "[&::-webkit-progress-value]:bg-primary [&::-webkit-progress-value]:transition-all", - "[&::-moz-progress-bar]:bg-primary", - // Indeterminate state animates the track itself (no value -> no bar - // to color, so we pulse the bg). - 'indeterminate:animate-pulse', - ].join(' '); diff --git a/website/components/ui/skeleton.ts b/website/components/ui/skeleton.ts deleted file mode 100644 index e9f48d0b0..000000000 --- a/website/components/ui/skeleton.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Skeleton: loading placeholder. Tier-1 class helper. Sizing comes from - * caller-supplied utilities; `skeletonClass()` only provides the - * animation + base look. - * - * shadcn parity: - * Skeleton → skeletonClass() (visual: animated rounded muted block) - * - * A11y (required for accessible output): a skeleton is a decorative - * placeholder, so hide it from assistive tech with aria-hidden="true" (or - * mark the loading region aria-busy="true"). Announce the real content - * once it replaces the skeleton. - * - * Design tokens used: --accent. - * - * @example - * ```html - * <div class=${cn(skeletonClass(), 'h-4 w-32')}></div> - * <div class=${cn(skeletonClass(), 'h-12 w-12 rounded-full')}></div> - * ``` - */ - -export const skeletonClass = (): string => 'animate-pulse rounded-md bg-accent'; diff --git a/website/components/ui/table.ts b/website/components/ui/table.ts deleted file mode 100644 index 2ecc25d92..000000000 --- a/website/components/ui/table.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Table: semantic data table with shadcn styling. Tier-1 class helpers; - * compose with native `<table>`, `<thead>`, `<tbody>`, `<tfoot>`, `<tr>`, - * `<th>`, `<td>`, `<caption>`. Native semantics + accessibility tree - * work out of the box. - * - * shadcn parity: - * Table container (scroll wrapper) → tableContainerClass() - * Table → tableClass() - * TableHeader / TableBody / TableFooter - * → tableHeaderClass() / tableBodyClass() / tableFooterClass() - * TableRow → tableRowClass() - * TableHead / TableCell / TableCaption - * → tableHeadClass() / tableCellClass() / tableCaptionClass() - * - * A11y (required for accessible output): every header cell needs a scope - * (scope="col" on a column header, scope="row" on a row header) so screen - * readers map cells to their headers. Add a <caption> naming the table's - * purpose (it can be visually hidden if a heading already names it). - * - * Design tokens used: --muted, --muted-foreground, --foreground. - * - * @example - * ```html - * <div class=${tableContainerClass()}> - * <table class=${tableClass()}> - * <thead class=${tableHeaderClass()}> - * <tr class=${tableRowClass()}> - * <th scope="col" class=${tableHeadClass()}>Name</th> - * <th scope="col" class=${tableHeadClass()}>Status</th> - * </tr> - * </thead> - * <tbody class=${tableBodyClass()}> - * <tr class=${tableRowClass()}> - * <td class=${tableCellClass()}>Vivek</td> - * <td class=${tableCellClass()}>Active</td> - * </tr> - * </tbody> - * <caption class=${tableCaptionClass()}>Users</caption> - * </table> - * </div> - * ``` - */ - -export const tableContainerClass = (): string => 'relative w-full overflow-x-auto'; - -export const tableClass = (): string => 'w-full caption-bottom text-sm'; - -export const tableHeaderClass = (): string => '[&_tr]:border-b'; - -export const tableBodyClass = (): string => '[&_tr:last-child]:border-0'; - -export const tableFooterClass = (): string => - 'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0'; - -export const tableRowClass = (): string => - 'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted'; - -export const tableHeadClass = (): string => - 'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]'; - -export const tableCellClass = (): string => - 'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]'; - -export const tableCaptionClass = (): string => 'mt-4 text-sm text-muted-foreground'; diff --git a/website/lib/docs-llms.server.ts b/website/lib/docs-llms.server.ts index 698e23037..47fa0968d 100644 --- a/website/lib/docs-llms.server.ts +++ b/website/lib/docs-llms.server.ts @@ -20,6 +20,7 @@ import { readFile, readdir } from 'node:fs/promises'; import { join, basename, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { siteUrl } from '#lib/env.ts'; // Resolve the app root from THIS module's location, not process.cwd(). // This module lives at `website/lib/docs-llms.server.ts`, so the app root @@ -93,7 +94,7 @@ export function originFor(req?: Request): string { /* fall through */ } } - return ((globalThis as any).process?.env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); + return siteUrl(); } /** diff --git a/website/lib/env.ts b/website/lib/env.ts new file mode 100644 index 000000000..5cd0ddc36 --- /dev/null +++ b/website/lib/env.ts @@ -0,0 +1,27 @@ +/** + * The site's canonical origin, read once from the environment. + * + * Four modules (app/sitemap.ts, app/robots.ts, app/llms.txt/route.ts, and + * lib/docs-llms.server.ts) each wrote their own + * `(globalThis as any).process?.env?.SITE_URL` for this, which is one missing + * type worked around four times, and four places for the fallback origin to + * drift apart. + * + * The `globalThis` hop rather than a bare `process.env`: every caller today is + * server-only (two metadata routes, a route handler, and a `.server.ts`), so + * `process` does exist for all of them. It is written this way so the module + * stays importable from a browser-loading module too, since the origin is + * public information already present in the rendered HTML and there is nothing + * here that needs the server boundary. A bare `process.env` would throw at + * module load the moment such an importer appeared. + */ +type SiteEnv = { SITE_URL?: string }; + +/** + * The canonical origin, with any trailing slash removed so callers can + * concatenate a path onto it without doubling the separator. + */ +export function siteUrl(): string { + const env = (globalThis as { process?: { env?: SiteEnv } }).process?.env; + return (env?.SITE_URL || 'https://webjs.dev').replace(/\/$/, ''); +} diff --git a/website/lib/links.ts b/website/lib/links.ts index f348000e5..88c946599 100644 --- a/website/lib/links.ts +++ b/website/lib/links.ts @@ -2,15 +2,14 @@ import { html } from '@webjsdev/core'; /** * Shared, browser-safe link config for the site chrome (header + footer), - * imported by both app/layout.ts and app/page.ts so the cross-app URLs and the - * new-tab cue are declared once instead of duplicated across the two files. + * imported by both app/layout.ts and app/page.ts so the paths and the new-tab + * cue are declared once instead of duplicated across the two files. * - * Sibling app URLs are read from env so the same code works across `webjs dev` - * and any deployment target, guarded against `process` being undefined since - * these modules also load on the client. Each falls back to its production - * domain, and `.env` overrides it to the localhost dev port. + * Every entry is a literal. There is no env read here any more: the docs and + * the component gallery used to be sibling apps needing a configurable URL + * each, and both moved in-app (#1098, #1099), so what is left is same-origin + * paths and a few fixed external URLs. */ -const env = (globalThis as any).process?.env ?? {}; /** * The documentation is served by THIS app under /docs, so it is a plain diff --git a/website/modules/ui/utils/examples.ts b/website/modules/ui/utils/examples.ts index 639ed90d9..8e77f25ef 100644 --- a/website/modules/ui/utils/examples.ts +++ b/website/modules/ui/utils/examples.ts @@ -136,7 +136,14 @@ import { // Registry the frozen holes are evaluated against. Listing every imported // helper here also keeps each import "used" after the freeze. -const HELPERS: Record<string, (...args: any[]) => string> = { +// +// The helpers take heterogeneous arguments (none, a size, a variant object), +// and every call goes through the `new Function` evaluator below rather than a +// typed call site, so the only contract this map can honestly state is +// "callable, returns a class string". `never[]` says exactly that: each +// concrete helper is assignable to it, and it refuses a direct call with +// invented arguments, which `any[]` would have waved through. +const HELPERS: Record<string, (...args: never[]) => string> = { accordionClass, accordionContentClass, accordionItemClass, accordionTriggerClass, alertClass, alertDescriptionClass, alertTitleClass, alertDialogContentClass, alertDialogDescriptionClass, alertDialogFooterClass, diff --git a/website/public/input.css b/website/public/input.css index cf541f2aa..a52653954 100644 --- a/website/public/input.css +++ b/website/public/input.css @@ -145,76 +145,38 @@ means the kit's neutral shadcn palette, not this site's warm editorial one. The old ui.webjs.dev declared these values on :root, which it could afford because nothing else lived on that domain. Here they must not leak, so every - raw value is declared on the preview container instead. Mirrors what - `webjs ui init` writes into a user's globals.css (packages/ui/packages/ - registry/themes/index.css), which is the point: what you see is what you get. + raw value is declared on the preview container instead. + + The VALUES are the kit's, the same ones `webjs ui init` writes into a user's + globals.css (packages/ui/packages/registry/themes/index.css), which is the + point: what you see is what you get. The SHAPE differs on two counts, both + deliberate. The kit keys its dark half off a `.dark` class, while this site + keys the whole page off `[data-theme]` (see the `dark` custom variant at the + top of this file), and each colour here is written ONCE via light-dark() + rather than as a light block plus a dark block. `color-scheme` inherits, so + the container picks up whatever the root resolved and every light-dark() + below lands on the matching side with no selector of its own. -------------------------------------------------------------------------- */ .ui-preview { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.97 0.01 17); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) .ui-preview { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.371 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.58 0.22 27); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - } -} - -:root[data-theme='dark'] .ui-preview { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.371 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.58 0.22 27); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: light-dark(oklch(1 0 0), oklch(0.145 0 0)); + --foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0)); + --card: light-dark(oklch(1 0 0), oklch(0.205 0 0)); + --card-foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0)); + --popover: light-dark(oklch(1 0 0), oklch(0.205 0 0)); + --popover-foreground: light-dark(oklch(0.145 0 0), oklch(0.985 0 0)); + --primary: light-dark(oklch(0.205 0 0), oklch(0.922 0 0)); + --primary-foreground: light-dark(oklch(0.985 0 0), oklch(0.205 0 0)); + --secondary: light-dark(oklch(0.97 0 0), oklch(0.269 0 0)); + --secondary-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0)); + --muted: light-dark(oklch(0.97 0 0), oklch(0.269 0 0)); + --muted-foreground: light-dark(oklch(0.556 0 0), oklch(0.708 0 0)); + --accent: light-dark(oklch(0.97 0 0), oklch(0.371 0 0)); + --accent-foreground: light-dark(oklch(0.205 0 0), oklch(0.985 0 0)); + --destructive: light-dark(oklch(0.577 0.245 27.325), oklch(0.704 0.191 22.216)); + --destructive-foreground: light-dark(oklch(0.97 0.01 17), oklch(0.58 0.22 27)); + --border: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%)); + --input: light-dark(oklch(0.922 0 0), oklch(1 0 0 / 15%)); + --ring: light-dark(oklch(0.708 0 0), oklch(0.556 0 0)); } @layer base { @@ -247,24 +209,14 @@ * one tokenizer, so the palette is defined once here, globally, rather than * inline per page). Class names: t-com/t-str/t-kw/t-fn/t-type/t-num/t-punc/t-id. */ +/* One light-dark() per token, same rule as the palettes above: the dark half + used to be written twice, once per theme selector. .t-com / .t-punc / .t-id + read palette tokens that already carry their own pair. */ .t-com { color: var(--fg-subtle); font-style: italic; } -.t-str { color: oklch(0.52 0.13 150); } -.t-kw { color: oklch(0.52 0.16 295); font-weight: 600; } -.t-fn { color: oklch(0.52 0.15 250); } -.t-type{ color: oklch(0.52 0.10 200); } -.t-num { color: oklch(0.55 0.12 215); } +.t-str { color: light-dark(oklch(0.52 0.13 150), oklch(0.80 0.14 150)); } +.t-kw { color: light-dark(oklch(0.52 0.16 295), oklch(0.76 0.14 295)); font-weight: 600; } +.t-fn { color: light-dark(oklch(0.52 0.15 250), oklch(0.75 0.13 250)); } +.t-type{ color: light-dark(oklch(0.52 0.10 200), oklch(0.80 0.10 200)); } +.t-num { color: light-dark(oklch(0.55 0.12 215), oklch(0.82 0.12 215)); } .t-punc{ color: var(--fg-muted); } .t-id { color: var(--fg); } - -:root[data-theme='dark'] .t-str { color: oklch(0.80 0.14 150); } -:root[data-theme='dark'] .t-kw { color: oklch(0.76 0.14 295); } -:root[data-theme='dark'] .t-fn { color: oklch(0.75 0.13 250); } -:root[data-theme='dark'] .t-type{ color: oklch(0.80 0.10 200); } -:root[data-theme='dark'] .t-num { color: oklch(0.82 0.12 215); } -@media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) .t-str { color: oklch(0.80 0.14 150); } - :root:not([data-theme='light']) .t-kw { color: oklch(0.76 0.14 295); } - :root:not([data-theme='light']) .t-fn { color: oklch(0.75 0.13 250); } - :root:not([data-theme='light']) .t-type{ color: oklch(0.80 0.10 200); } - :root:not([data-theme='light']) .t-num { color: oklch(0.82 0.12 215); } -} diff --git a/website/test/components/browser/preview-tabs.test.js b/website/test/components/browser/preview-tabs.test.js index bf1fd93d8..8c713a6d3 100644 --- a/website/test/components/browser/preview-tabs.test.js +++ b/website/test/components/browser/preview-tabs.test.js @@ -83,6 +83,31 @@ suite('preview-tabs', () => { assert.equal(selected(), 'tab-preview', 'Home selects the first tab'); }); + test('moves focus onto the newly-selected tab', async () => { + // Follow-focus is the other half of the roving tabindex: the group is one + // tab stop, so a keyboard user who arrows to a tab must LAND on it, not be + // left focused on the one they arrowed away from. + // + // Asserted separately from the tabindex test because it exercises a + // different mechanism. Selection re-renders, and the focus call is deferred + // behind updateComplete so it runs after the roving tabindex is committed; + // a focus that fired before the commit would target an element still + // carrying tabindex="-1". Focus inside a shadow root reports as the HOST at + // document level, so the inner element is read off shadowRoot.activeElement. + const host = await track(); + const bar = q(host, '[role="tablist"]'); + q(host, '#tab-preview').focus(); + assert.equal(host.shadowRoot.activeElement?.id, 'tab-preview', 'starts focused on Preview'); + + bar.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, composed: true })); + await host.updateComplete; + await tick(); + + assert.equal(host.shadowRoot.activeElement?.id, 'tab-code', 'ArrowRight moves focus to Code'); + assert.equal(host.shadowRoot.activeElement?.getAttribute('tabindex'), '0', + 'the focused tab is the one carrying the roving tabindex'); + }); + test('shows exactly one panel at a time, and keeps both slots mounted', async () => { // Both slots must stay in the tree: the projected demo contains ui-* elements // that capture their innerHTML on connect, so a rebuild would be destructive. diff --git a/website/test/ssr/design-tokens.test.ts b/website/test/ssr/design-tokens.test.ts index cce6c23e8..259292957 100644 --- a/website/test/ssr/design-tokens.test.ts +++ b/website/test/ssr/design-tokens.test.ts @@ -6,6 +6,15 @@ * colours presented as the same token. Review round 1 found exactly that * (three drifted ACCENTS values); this test is what makes the next drift a * red build instead of a silent lie. + * + * It ALSO pins the shape the layout declares them in. Each per-theme colour is + * one light-dark(LIGHT, DARK) declaration, which is the rule the framework + * teaches its own users (the skill's references/styling.md) and which the site + * itself did not follow until #1216: the dark half used to be written twice, + * once under the OS media query and once under the toggle's attribute, so an + * edit to either copy drifted the two paths apart with nothing to catch it. + * Both halves of this file matter, since a duplicated block would still let + * the value assertions below pass. */ import test from 'node:test'; import assert from 'node:assert/strict'; @@ -17,26 +26,64 @@ import { SWATCHES, ACCENTS } from '#lib/design/tokens.ts'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); const layout = readFileSync(resolve(ROOT, 'app/layout.ts'), 'utf8'); -/** Extract `--name: value;` pairs from one CSS block of layout.ts. */ -function declarations(block: string): Map<string, string> { - const map = new Map<string, string>(); - for (const m of block.matchAll(/(--[a-z-]+):\s*([^;]+);/g)) { - if (!map.has(m[1])) map.set(m[1], m[2].trim()); +/** The one `:root { ... }` block that declares the palette. */ +const rootBlock = layout.slice(layout.indexOf(':root {'), layout.indexOf('@media (prefers-color-scheme: dark)')); + +/** + * Extract `--name: light-dark(LIGHT, DARK);` pairs. The split is on the comma + * at paren depth 0, because both sides are themselves function calls carrying + * commas of their own (`oklch(...)`, `color-mix(...)`). + */ +function themePairs(block: string): Map<string, { light: string; dark: string }> { + const map = new Map<string, { light: string; dark: string }>(); + for (const m of block.matchAll(/(--[a-z-]+):\s*light-dark\(([\s\S]*?)\);/g)) { + const inner = m[2]; + let depth = 0; + let split = -1; + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '(') depth++; + else if (inner[i] === ')') depth--; + else if (inner[i] === ',' && depth === 0) { split = i; break; } + } + if (split === -1) continue; + if (!map.has(m[1])) { + map.set(m[1], { light: inner.slice(0, split).trim(), dark: inner.slice(split + 1).trim() }); + } } return map; } -// The light theme is the bare `:root { ... }` block; dark is the explicit -// `:root[data-theme='dark']` block (the media-query copy mirrors it). -const rootBlock = layout.slice(layout.indexOf(':root {'), layout.indexOf('@media (prefers-color-scheme: dark)')); -const darkStart = layout.indexOf(":root[data-theme='dark']"); -const darkBlock = layout.slice(darkStart, layout.indexOf('}', layout.indexOf('--shadow:', darkStart))); -const light = declarations(rootBlock); -const dark = declarations(darkBlock); +const pairs = themePairs(rootBlock); for (const entry of [...SWATCHES, ...ACCENTS]) { test(`${entry.token} matches app/layout.ts in both themes`, () => { - assert.equal(entry.light, light.get(entry.token), `${entry.token} (light) drifted from layout.ts`); - assert.equal(entry.dark, dark.get(entry.token), `${entry.token} (dark) drifted from layout.ts`); + const pair = pairs.get(entry.token); + assert.ok(pair, `${entry.token} is not declared as a light-dark() pair in the layout's :root block`); + assert.equal(entry.light, pair.light, `${entry.token} (light) drifted from layout.ts`); + assert.equal(entry.dark, pair.dark, `${entry.token} (dark) drifted from layout.ts`); }); } + +test('the layout declares its palette once, not as duplicated dark blocks', () => { + // Only the non-colour overrides may sit under a theme selector. Both blocks + // are single-line, so a colour creeping back in shows up here as a + // light-dark()-free `--token: <colour>` on the same line. + const overrides = [...layout.matchAll(/:root(?::not\(\[data-theme='light'\]\)|\[data-theme='dark'\])\s*\{([^}]*)\}/g)] + .map((m) => m[1]) + .join('\n'); + const declared = [...overrides.matchAll(/(--[a-z-]+):/g)].map((m) => m[1]); + assert.deepEqual( + [...new Set(declared)].sort(), + ['--cta-mix', '--glow-strength', '--shadow-spread'], + 'only NON-colour tokens may keep a per-theme override; a colour belongs in a light-dark() pair on :root', + ); +}); + +test('every theme state is reachable from color-scheme alone', () => { + // light-dark() resolves off the used value of color-scheme, so these three + // declarations are the entire theme mechanism. Losing one silently pins the + // whole palette to one side. + assert.match(rootBlock, /color-scheme:\s*light dark;/, 'the default (follow the OS) scheme is missing'); + assert.match(layout, /:root\[data-theme='dark'\]\s*\{\s*color-scheme:\s*dark;\s*\}/, "the toggle's forced-dark scheme is missing"); + assert.match(layout, /:root\[data-theme='light'\]\s*\{\s*color-scheme:\s*light;\s*\}/, "the toggle's forced-light scheme is missing"); +}); diff --git a/website/test/ssr/kit-surfaces.test.ts b/website/test/ssr/kit-surfaces.test.ts new file mode 100644 index 000000000..63a265f65 --- /dev/null +++ b/website/test/ssr/kit-surfaces.test.ts @@ -0,0 +1,118 @@ +/** + * Two surfaces this site shares with a scaffolded app, both of which drifted + * before #1216 with nothing to catch either one. + * + * 1. components/ui/ belongs to `webjs ui add`, exactly as it does in a real + * app, so this site tracks nothing there. Eleven byte-identical copies of + * the generated modules/ui/components/ mirror were committed into it by + * accident and sat unimported until someone read the directory. + * 2. The .ui-preview palette in public/input.css declares each kit colour once + * via light-dark(), rather than as a light block plus two dark blocks + * saying the same thing. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +test('components/ui/ is left empty for webjs ui add', () => { + const dir = resolve(ROOT, 'components/ui'); + const entries = existsSync(dir) ? readdirSync(dir).filter((f) => !f.startsWith('.')) : []; + assert.deepEqual( + entries, + [], + 'components/ui/ is reserved for `webjs ui add`; the gallery previews import from modules/ui/components/ instead', + ); +}); + +// Comments are stripped first, so a selector named in explanatory prose is not +// mistaken for a live rule (the comment above .ui-preview names data-theme to +// explain why the block does NOT use it). +const inputCss = readFileSync(resolve(ROOT, 'public/input.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); +const previewBlock = inputCss.slice(inputCss.indexOf('.ui-preview {'), inputCss.indexOf('}', inputCss.indexOf('.ui-preview {'))); + +test('the .ui-preview palette declares each colour once via light-dark()', () => { + const declared = [...previewBlock.matchAll(/(--[a-z-]+):/g)].map((m) => m[1]); + const paired = [...previewBlock.matchAll(/(--[a-z-]+):\s*light-dark\(/g)].map((m) => m[1]); + assert.ok(declared.length > 0, 'the .ui-preview block was not found in public/input.css'); + assert.deepEqual( + declared.filter((t) => !paired.includes(t)), + [], + 'every .ui-preview token is a per-theme colour, so each one takes a light-dark() pair', + ); +}); + +test('.ui-preview keeps no duplicate dark block', () => { + // color-scheme inherits from the root, so the container needs no theme + // selector of its own. One reintroduced here is the duplication that + // light-dark() replaced. + assert.equal( + /(?:prefers-color-scheme|data-theme)[^{]*\.ui-preview\s*\{/.test(inputCss), + false, + '.ui-preview resolves its theme through inherited color-scheme, not a per-theme selector', + ); +}); + +/** + * The general rule, swept across every stylesheet and every page/layout that + * writes CSS. The two palettes above are the ones that were duplicated worst, + * but they were not the only ones: the syntax-highlight classes in input.css + * and the home page's code-sample tokens each carried the same pair of + * verbatim dark blocks. A per-file assertion would have kept missing them, so + * this asserts the rule itself. + */ +test('no colour is declared under a per-theme selector anywhere', () => { + // modules/ is deliberately absent: its only subtree is the gitignored + // modules/ui/components/ mirror of the @webjsdev/ui registry, which is the + // kit's code rather than this site's, and modules/ui/utils holds no CSS. + // Add it here the moment a feature module starts writing styles. + const files = [ + 'public/input.css', + ...['app', 'lib', 'components'].flatMap(function walk(dir: string): string[] { + const abs = resolve(ROOT, dir); + if (!existsSync(abs)) return []; + return readdirSync(abs, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? walk(`${dir}/${e.name}`) : e.name.endsWith('.ts') ? [`${dir}/${e.name}`] : [], + ); + }), + ]; + + // Only these three NON-colour tokens may sit under a theme selector. Every + // colour belongs in a light-dark() pair instead. + const ALLOWED = new Set(['--glow-strength', '--cta-mix', '--shadow-spread']); + const offenders: string[] = []; + + for (const rel of files) { + // Comments are stripped so prose naming a selector is not read as a rule, + // and `pre` blocks in the docs pages are left alone: those are code + // SAMPLES teaching the reader, not this site's own styling. + const src = readFileSync(resolve(ROOT, rel), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/<pre[\s\S]*?<\/pre>/g, ''); + // Terminate on the first closing brace, NOT on one at the start of a line: + // a single-line rule (`:root[data-theme='dark'] .t-str { color: ... }`) is + // the exact shape the highlight classes used, and anchoring to a newline + // walked straight past it. + const re = /(?:@media\s*\(prefers-color-scheme:\s*dark\)|\[data-theme=['"]dark['"]\])([\s\S]{0,900}?)\}/g; + for (const m of src.matchAll(re)) { + for (const d of m[1].matchAll(/(--[a-z-]+):\s*([^;]+);/g)) { + if (ALLOWED.has(d[1])) continue; + offenders.push(`${rel}: ${d[1]}`); + } + // A bare colour on a class inside a theme block (the .t-* highlight + // shape) has no custom property to catch, so look for it directly. + for (const d of m[1].matchAll(/\bcolor:\s*(oklch|#|rgb|hsl)/g)) { + offenders.push(`${rel}: a bare ${d[1]} colour`); + } + } + } + + assert.deepEqual( + offenders, + [], + 'these belong in a light-dark(LIGHT, DARK) pair, not under a per-theme selector', + ); +});