Three tracks on a wide screen, two on a tablet, one on a phone.
+Every surface here is a theme token, so light and dark both work.
+These components carry their own styles and no longer need it.
++ Typography caps the measure so a line never runs too long to + follow. Inside it you get the smaller pieces: a + to somewhere else, and + for a shortcut. +
++ Drag the divider, or focus it and use the arrow keys. Home and End + go to the bounds. The live value is on the handle itself, as + aria-valuenow, which is what a screen reader announces. +
+ +Drag the divider, or focus it and use the arrow keys. Home and End go to the bounds.
+The size is resolved in the runtime and written onto the container, so the panes are plain grid tracks.
+This region scrolls, and its scrollbar follows the theme.
+Scrollbar appearance is CSS rather than script.
+scrollbar-width and scrollbar-color are the standard properties.
+The webkit rules cover the engines that still need them.
+Enough lines here that the region actually overflows.
+Otherwise there would be no scrollbar to look at.
+Which is the mistake the showcase demo made first time.
+Change any prop and inspect the server-rendered component immediately.
Use declarative output handlers in .wrn files or register a direct output handler from JavaScript. The canonical API exposes payload and does not require event.detail.
@resizeFires when the component emits the resize event.<LayoutSplitter
- @resize='console.log(payload)'
+ Component outputsReceive every typed component output
Use declarative output handlers in .wrn files or register a direct output handler from JavaScript. The canonical API exposes payload and does not require event.detail.
@sizeChangeFires when the component emits the sizeChange event.Declarative handlers.wrn <LayoutSplitter
+ @sizeChange='console.log(payload)'
/>
Direct output handlers.js import { registerOutputHandler } from "@wrnexus/csr/outputs"
const component = document.querySelector("[data-ui-component=\"LayoutSplitter\"], [data-component=\"LayoutSplitter\"]")
-if (component) registerOutputHandler(component, "resize", (payload) => {
- console.log("resize", payload)
+if (component) registerOutputHandler(component, "sizeChange", (payload) => {
+ console.log("sizeChange", payload)
})
Component APIProps and configuration
All content and behavior shown above is supplied through these props and slots.
Prop Type Default Required colorstring "primary"No sizenumber 50No orientationstring "horizontal"No minSizenumber 15No stepnumber 5No labelstring "Resize panels"No classstring ""No
diff --git a/examples/component-showcase/showcase-manifest.json b/examples/component-showcase/showcase-manifest.json
index fb0540fc..b7531334 100644
--- a/examples/component-showcase/showcase-manifest.json
+++ b/examples/component-showcase/showcase-manifest.json
@@ -693,7 +693,7 @@
"demoCount": 2,
"propCount": 7,
"slots": ["start", "end", "default"],
- "events": ["resize"],
+ "events": ["sizeChange"],
"profiled": true
},
{
diff --git a/packages/csr/src/reactive-runtime.ts b/packages/csr/src/reactive-runtime.ts
index 5d5c872f..ab6cb703 100644
--- a/packages/csr/src/reactive-runtime.ts
+++ b/packages/csr/src/reactive-runtime.ts
@@ -3293,8 +3293,14 @@ export const REACTIVE_RUNTIME = String.raw`
handle.setAttribute("aria-valuemin", String(bounds.min));
handle.setAttribute("aria-valuemax", String(bounds.max));
}
- // Named for the component output so a parent @resize binding receives it.
- root.dispatchEvent(new CustomEvent("resize", { detail: { size: next } }));
+ /*
+ * Deliberately not named "resize". That collides with the native event, and
+ * the component output binding never ran for it. The component listens for
+ * this and re-emits its own declared output.
+ */
+ root.dispatchEvent(
+ new CustomEvent("wrnexus:splitter:resize", { detail: { size: next } }),
+ );
return next;
}
diff --git a/packages/csr/test/reactive.test.ts b/packages/csr/test/reactive.test.ts
index d20cd8e6..7d782584 100644
--- a/packages/csr/test/reactive.test.ts
+++ b/packages/csr/test/reactive.test.ts
@@ -1171,7 +1171,7 @@ test("a vertical splitter responds to up and down instead", () => {
expect(handle.getAttribute("aria-valuenow")).toBe("55");
});
-test("splitter emits a resize event carrying the new size", () => {
+test("splitter announces its new size for the component to re-emit", () => {
const win = mount(
`
@@ -1184,9 +1184,8 @@ test("splitter emits a resize event carrying the new size", () => {
const doc = win.document;
const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
const seen: number[] = [];
- // A resize listener is typed as UIEvent, so the detail needs the wider cast.
- root.addEventListener("resize", (event) =>
- seen.push((event as unknown as CustomEvent).detail.size),
+ root.addEventListener("wrnexus:splitter:resize", (event) =>
+ seen.push((event as CustomEvent).detail.size),
);
(doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement).dispatchEvent(
diff --git a/packages/styles/src/theme.ts b/packages/styles/src/theme.ts
index 17a750a9..7c6e1be4 100644
--- a/packages/styles/src/theme.ts
+++ b/packages/styles/src/theme.ts
@@ -22,6 +22,7 @@ export type ThemeToken =
| "color-surface-2"
| "color-surface-raised"
| "color-surface-muted"
+ | "color-surface-soft"
| "color-text"
| "color-text-muted"
| "color-text-subtle"
@@ -242,24 +243,37 @@ function semanticColorTokens(
[`color-${name}-soft`]: colorMix(color, dark ? 16 : 8, dark ? "black" : "white"),
[`color-${name}-muted`]: colorMix(color, dark ? 28 : 16, dark ? "black" : "white"),
[`color-${name}-text`]: dark ? colorMix(color, 64, "white") : colorMix(color, 82, "black"),
+ /*
+ * hover and contrast are generated for every semantic colour, not just
+ * primary and secondary. Components referenced --wire-color-danger-hover
+ * and the rest for a long time with nothing defining them, so those states
+ * simply did not paint.
+ *
+ * primary and secondary override these from the palette, which is why
+ * this is spread before their explicit entries rather than after.
+ */
+ [`color-${name}-hover`]: dark ? colorMix(color, 84, "white") : colorMix(color, 88, "black"),
+ [`color-${name}-contrast`]: "#ffffff",
};
}
function paletteTokens(palette: CustomThemePalette, scheme: ColorScheme): ThemeTokens {
return {
+ // Spread first so the palette's own hover and contrast win over the
+ // derived defaults below it.
+ ...semanticColorTokens("primary", palette.primary, scheme),
"color-primary": palette.primary,
"color-primary-hover": palette.primaryHover,
"color-primary-active": palette.primaryHover,
"color-primary-contrast": palette.primaryContrast,
"color-on-primary": palette.primaryContrast,
- ...semanticColorTokens("primary", palette.primary, scheme),
+ ...semanticColorTokens("secondary", palette.secondary, scheme),
"color-secondary": palette.secondary,
"color-secondary-hover": palette.secondaryHover,
"color-secondary-active": palette.secondaryHover,
"color-secondary-contrast": palette.secondaryContrast,
"color-on-secondary": palette.secondaryContrast,
- ...semanticColorTokens("secondary", palette.secondary, scheme),
"color-info": palette.info,
...semanticColorTokens("info", palette.info, scheme),
@@ -287,6 +301,30 @@ function paletteTokens(palette: CustomThemePalette, scheme: ColorScheme): ThemeT
scheme === "dark"
? colorMix(palette.danger, 64, "white")
: colorMix(palette.danger, 82, "black"),
+
+ /*
+ * Tokens the components have always referenced but nothing defined.
+ *
+ * An undefined custom property does not warn -- it resolves to nothing --
+ * so every focus ring drew with no colour and every soft surface rendered
+ * transparent. Derived here so they follow the palette and the accent
+ * rather than being pinned per scheme.
+ */
+ // The palette has no danger contrast; danger is saturated in both schemes,
+ // so white is right either way.
+ "color-on-danger": "#ffffff",
+ "color-focus": palette.primary,
+ "color-surface-subtle": colorMix(
+ palette.primary,
+ scheme === "dark" ? 6 : 4,
+ scheme === "dark" ? "black" : "white",
+ ),
+ "color-input-background": scheme === "dark" ? "#0f0f0f" : "#ffffff",
+ "color-input-text": scheme === "dark" ? "#f5f5f5" : "#0b1020",
+ "color-input-placeholder": scheme === "dark" ? "#8a8a8a" : "#737b91",
+ "color-input-border": scheme === "dark" ? "#ffffff26" : "#d7dced",
+ "color-input-border-hover": scheme === "dark" ? "#ffffff38" : "#c7cedd",
+ "color-input-border-focus": palette.primary,
};
}
@@ -352,6 +390,7 @@ export const DEFAULT_THEMES: Record = {
"color-foreground": "#0b1020",
"color-surface-raised": "#ffffff",
"color-surface-muted": "#eceff6",
+ "color-surface-soft": "#f1f4f9",
"color-text-muted": "#5a6178",
"color-text-subtle": "#737b91",
"color-border-strong": "#c7cedd",
@@ -384,6 +423,7 @@ export const DEFAULT_THEMES: Record = {
"color-foreground": "#f5f5f5",
"color-surface-raised": "#111111",
"color-surface-muted": "#181818",
+ "color-surface-soft": "#141414",
"color-text-muted": "#b3b3b3",
"color-text-subtle": "#8a8a8a",
"color-border-strong": "#ffffff38",
diff --git a/packages/ui/COMPONENTS.md b/packages/ui/COMPONENTS.md
index 40f5cb63..a3d1a4da 100644
--- a/packages/ui/COMPONENTS.md
+++ b/packages/ui/COMPONENTS.md
@@ -698,7 +698,7 @@ Theme-aware, responsive layout splitter component.
- Mount: `data-component="LayoutSplitter"`
- Props: `color: string = "primary"`, `size: number = 50`, `orientation: string = "horizontal"`, `minSize: number = 15`, `step: number = 5`, `label: string = "Resize panels"`, `class: string = ""`
- Slots: `start`, `end`, `default`
-- Outputs: `resize({ size: number })`
+- Outputs: `sizeChange({ size: number })`
### Link
diff --git a/packages/ui/component-reference.json b/packages/ui/component-reference.json
index 7f543a5e..de0940ab 100644
--- a/packages/ui/component-reference.json
+++ b/packages/ui/component-reference.json
@@ -7855,11 +7855,11 @@
"slots": ["start", "end", "default"],
"outputs": [
{
- "name": "resize",
+ "name": "sizeChange",
"payloadType": "{ size: number }"
}
],
- "events": ["resize"],
+ "events": ["sizeChange"],
"source": "components/LayoutSplitter.wrn"
},
{
diff --git a/packages/ui/components/LayoutSplitter.wrn b/packages/ui/components/LayoutSplitter.wrn
index efbb4c67..b2a4c764 100644
--- a/packages/ui/components/LayoutSplitter.wrn
+++ b/packages/ui/components/LayoutSplitter.wrn
@@ -18,7 +18,10 @@
// and silently swallows the rule that follows it.
component LayoutSplitter {
outputs {
- resize(payload: { size: number })
+ // Not named resize. An output named after a native DOM event never reaches
+ // a parent binding: the component emits it, but @resize on the tag is
+ // never invoked. sizeChange is unambiguous and does arrive.
+ sizeChange(payload: { size: number })
}
props {
@@ -35,6 +38,16 @@ component LayoutSplitter {
}
functions {
+ // The runtime resolves the size and announces it; this turns that into the
+ // declared output so a parent @resize binding receives it.
+ client function reportResize(sourceEvent) {
+ var detail = sourceEvent ? sourceEvent.detail : null
+ if (!detail) {
+ return
+ }
+ output.sizeChange({ size: detail.size })
+ }
+
shared function isVertical() {
return orientation === "vertical"
}
@@ -79,6 +92,7 @@ component LayoutSplitter {
data-wrn-splitter-min='{lowerBound()}'
data-wrn-splitter-step='{stepSize()}'
style='--wrn-split:{currentSize()}%;'
+ @wrnexus:splitter:resize='reportResize(event)'
>
diff --git a/packages/ui/components/PublicPageShell.wrn b/packages/ui/components/PublicPageShell.wrn
index 75258205..801d65a6 100644
--- a/packages/ui/components/PublicPageShell.wrn
+++ b/packages/ui/components/PublicPageShell.wrn
@@ -14,40 +14,119 @@ component PublicPageShell {
view {
- {#if fullWidth}
-
-
-
- {:else}
-
-
-
- {/if}
+
+
+
}
+
+ style {
+ .wire-page-shell {
+ position: relative;
+ isolation: isolate;
+ width: 100%;
+ min-width: 0;
+ color: var(--wire-color-text);
+ background: var(--wire-color-background);
+ }
+
+ .wire-page-shell[data-background="soft"] {
+ background: var(--wire-color-surface-soft);
+ }
+
+ .wire-page-shell[data-background="raised"] {
+ background: var(--wire-color-surface-raised);
+ }
+
+ .wire-page-shell[data-min-height="screen"] {
+ min-height: 100vh;
+ }
+
+ /*
+ * dvh tracks the browser chrome on a phone, so a full-height shell does
+ * not leave a gap when the address bar collapses.
+ */
+ .wire-page-shell[data-min-height="dvh"] {
+ min-height: 100dvh;
+ }
+
+ .wire-page-shell[data-overflow="clip"] {
+ overflow-x: clip;
+ }
+
+ .wire-page-shell[data-overflow="hidden"] {
+ overflow-x: hidden;
+ }
+
+ /*
+ * One main element rather than a branch per width. fullWidth is an
+ * attribute, so the width cap and the gutters are a couple of rules
+ * instead of two copies of the same markup.
+ */
+ .wire-page-shell__main {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .wire-page-shell__main[data-full-width="false"] {
+ margin-inline: auto;
+ padding-inline: 1rem;
+ }
+
+ .wire-page-shell__main[data-full-width="false"][data-max-width="lg"] {
+ max-width: 64rem;
+ }
+
+ .wire-page-shell__main[data-full-width="false"][data-max-width="xl"] {
+ max-width: 80rem;
+ }
+
+ .wire-page-shell__main[data-full-width="false"][data-max-width="2xl"] {
+ max-width: 96rem;
+ }
+
+ @media (min-width: 640px) {
+ .wire-page-shell__main[data-full-width="false"] {
+ padding-inline: 1.5rem;
+ }
+ }
+
+ @media (min-width: 1024px) {
+ .wire-page-shell__main[data-full-width="false"] {
+ padding-inline: 2rem;
+ }
+ }
+
+ /* Room for a fixed header to sit over the top of the shell. */
+ .wire-page-shell[data-header-offset="sm"] {
+ padding-top: 4rem;
+ }
+
+ .wire-page-shell[data-header-offset="md"] {
+ padding-top: 5rem;
+ }
+
+ .wire-page-shell[data-header-offset="lg"] {
+ padding-top: 6rem;
+ }
+ }
}
diff --git a/packages/ui/components/Section.wrn b/packages/ui/components/Section.wrn
index cc05729b..005a3aa0 100644
--- a/packages/ui/components/Section.wrn
+++ b/packages/ui/components/Section.wrn
@@ -16,33 +16,16 @@ component Section {
view {
{#if fullWidth}
@@ -59,4 +42,91 @@ component Section {
{/if}
}
+
+ style {
+ .wire-section {
+ --section-tint: transparent;
+ --section-ink: inherit;
+ position: relative;
+ isolation: isolate;
+ width: 100%;
+ min-width: 0;
+ padding-block: 3rem;
+ background: var(--section-tint);
+ color: var(--section-ink);
+ }
+
+ .wire-section[data-spacing="sm"] {
+ padding-block: 2rem;
+ }
+
+ .wire-section[data-spacing="md"] {
+ padding-block: 3rem;
+ }
+
+ .wire-section[data-spacing="xl"] {
+ padding-block: 5rem;
+ }
+
+ .wire-section[data-variant="soft"] {
+ --section-tint: var(--wire-color-surface-soft);
+ }
+
+ .wire-section[data-variant="raised"] {
+ --section-tint: var(--wire-color-surface-raised);
+ }
+
+ /*
+ * tinted and solid pick their fill from the component colour, which is
+ * why these are two attributes rather than one class per pairing. The
+ * previous version spent a line on every variant and colour combination.
+ */
+ .wire-section[data-variant="tinted"][data-color="primary"] {
+ --section-tint: var(--wire-color-primary-soft);
+ }
+
+ .wire-section[data-variant="tinted"][data-color="success"] {
+ --section-tint: var(--wire-color-success-soft);
+ }
+
+ .wire-section[data-variant="tinted"][data-color="warning"] {
+ --section-tint: var(--wire-color-warning-soft);
+ }
+
+ .wire-section[data-variant="tinted"][data-color="danger"] {
+ --section-tint: var(--wire-color-danger-soft);
+ }
+
+ .wire-section[data-variant="tinted"][data-color="info"] {
+ --section-tint: var(--wire-color-info-soft);
+ }
+
+ .wire-section[data-variant="solid"][data-color="primary"] {
+ --section-tint: var(--wire-color-primary);
+ --section-ink: var(--wire-color-on-primary);
+ }
+
+ .wire-section[data-variant="solid"][data-color="danger"] {
+ --section-tint: var(--wire-color-danger);
+ --section-ink: var(--wire-color-on-danger);
+ }
+
+ .wire-section[data-border-top="true"] {
+ border-top: 1px solid var(--wire-color-border);
+ }
+
+ .wire-section[data-border-bottom="true"] {
+ border-bottom: 1px solid var(--wire-color-border);
+ }
+
+ @media (min-width: 640px) {
+ .wire-section[data-spacing="lg"] {
+ padding-block: 5rem;
+ }
+
+ .wire-section[data-spacing="xl"] {
+ padding-block: 6rem;
+ }
+ }
+ }
}
diff --git a/packages/ui/components/SectionHeader.wrn b/packages/ui/components/SectionHeader.wrn
index 6513d579..8f27f6c9 100644
--- a/packages/ui/components/SectionHeader.wrn
+++ b/packages/ui/components/SectionHeader.wrn
@@ -14,100 +14,204 @@ component SectionHeader {
view {
-
-
+
+
-
- {#if eyebrow}
-
- {eyebrow}
-
- {/if}
+ {eyebrow}
{#if headingLevel === 1}
-
- {title}
-
+ {title}
{:else if headingLevel === 3}
-
- {title}
-
+ {title}
{:else}
-
- {title}
-
+ {title}
{/if}
- {#if description}
-
- {description}
-
- {/if}
+ {description}
-
+
}
+
+ style {
+ .wire-section-header {
+ --section-header-accent: var(--wire-color-primary);
+ --section-header-title: 1.875rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ min-width: 0;
+ }
+
+ .wire-section-header[data-color="secondary"] {
+ --section-header-accent: var(--wire-color-secondary);
+ }
+
+ .wire-section-header[data-color="success"] {
+ --section-header-accent: var(--wire-color-success);
+ }
+
+ .wire-section-header[data-color="warning"] {
+ --section-header-accent: var(--wire-color-warning-text);
+ }
+
+ .wire-section-header[data-color="danger"] {
+ --section-header-accent: var(--wire-color-danger);
+ }
+
+ .wire-section-header[data-color="info"] {
+ --section-header-accent: var(--wire-color-info);
+ }
+
+ .wire-section-header[data-align="center"] {
+ align-items: center;
+ text-align: center;
+ }
+
+ .wire-section-header[data-align="right"] {
+ align-items: flex-end;
+ text-align: right;
+ }
+
+ .wire-section-header__copy {
+ min-width: 0;
+ }
+
+ .wire-section-header[data-align="center"] .wire-section-header__copy {
+ margin-inline: auto;
+ }
+
+ .wire-section-header[data-max-width="xl"] .wire-section-header__copy {
+ max-width: 36rem;
+ }
+
+ .wire-section-header[data-max-width="2xl"] .wire-section-header__copy {
+ max-width: 42rem;
+ }
+
+ .wire-section-header[data-max-width="3xl"] .wire-section-header__copy {
+ max-width: 48rem;
+ }
+
+ .wire-section-header[data-max-width="4xl"] .wire-section-header__copy {
+ max-width: 56rem;
+ }
+
+ .wire-section-header__eyebrow-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+ }
+
+ .wire-section-header[data-align="center"] .wire-section-header__eyebrow-row {
+ justify-content: center;
+ }
+
+ .wire-section-header[data-align="right"] .wire-section-header__eyebrow-row {
+ justify-content: flex-end;
+ }
+
+ .wire-section-header__eyebrow {
+ margin: 0;
+ color: var(--section-header-accent);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ }
+
+ .wire-section-header[data-size="lg"] .wire-section-header__eyebrow {
+ font-size: 0.875rem;
+ }
+
+ .wire-section-header__title {
+ margin: 0;
+ color: var(--wire-color-text);
+ font-size: var(--section-header-title);
+ font-weight: 700;
+ line-height: 1.15;
+ letter-spacing: -0.02em;
+ }
+
+ .wire-section-header__description {
+ margin: 1rem 0 0;
+ color: var(--wire-color-text-muted);
+ font-size: 1rem;
+ line-height: 1.7;
+ }
+
+ .wire-section-header[data-size="sm"] .wire-section-header__description {
+ font-size: 0.875rem;
+ }
+
+ .wire-section-header[data-size="lg"] .wire-section-header__description {
+ font-size: 1.125rem;
+ }
+
+ .wire-section-header__actions {
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ }
+
+ .wire-section-header[data-align="center"] .wire-section-header__actions {
+ justify-content: center;
+ }
+
+ .wire-section-header[data-align="right"] .wire-section-header__actions,
+ .wire-section-header[data-align="split"] .wire-section-header__actions {
+ justify-content: flex-end;
+ }
+
+ /*
+ * Heading sizes scale up with the viewport rather than being fixed, and
+ * split only becomes a row once there is width for two columns.
+ */
+ .wire-section-header[data-size="sm"] {
+ --section-header-title: 1.5rem;
+ }
+
+ .wire-section-header[data-size="lg"] {
+ --section-header-title: 2.25rem;
+ }
+
+ @media (min-width: 640px) {
+ .wire-section-header {
+ --section-header-title: 2.25rem;
+ }
+
+ .wire-section-header[data-size="sm"] {
+ --section-header-title: 1.75rem;
+ }
+
+ .wire-section-header[data-size="lg"] {
+ --section-header-title: 3rem;
+ }
+ }
+
+ @media (min-width: 1024px) {
+ .wire-section-header[data-align="split"] {
+ flex-direction: row;
+ align-items: flex-end;
+ justify-content: space-between;
+ }
+ }
+ }
}
diff --git a/packages/ui/test/ui.test.ts b/packages/ui/test/ui.test.ts
index d71c2960..3edfa874 100644
--- a/packages/ui/test/ui.test.ts
+++ b/packages/ui/test/ui.test.ts
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { compileWireFile, parse } from "../../compiler/src/index.ts";
import { mountHtml, renderComponent } from "../../test/src/index.ts";
+import { renderThemeCss, resolveThemeConfig } from "../../styles/src/theme.ts";
// The filesystem helpers moved to the server-only `registry` subpath; the
// package entry deliberately stays free of node:* imports so it can be
// bundled for the browser.
@@ -2954,6 +2955,12 @@ test("layout splitter renders two panes and an operable separator", async () =>
*/
expect(source).toContain("data-wrn-splitter");
expect(source).toContain("data-wrn-splitter-handle");
+ /*
+ * The output is sizeChange, not resize. An output named after a native DOM
+ * event is emitted by the component but never reaches the parent binding.
+ */
+ expect(source).toContain("sizeChange(payload:");
+ expect(source).not.toMatch(/^\s*resize\(payload:/m);
const html = await renderComponent(source, {
orientation: "horizontal",
@@ -3038,6 +3045,10 @@ test("layout components ship their own styles instead of Tailwind utilities", ()
"Kbd",
"LayoutSplitter",
"CustomScrollbar",
+ "Section",
+ "SectionHeader",
+ "PublicPageShell",
+ "PageHeader",
];
const utility =
/class:(grid-cols-|sm:|lg:|md:|max-w-|gap-[0-9]|px-[0-9]|py-[0-9]|border-l|bg-\[|h-0|text-xs|items-)/;
@@ -3058,3 +3069,34 @@ test("layout components ship their own styles instead of Tailwind utilities", ()
});
}
});
+
+test("every wire color token a component references is defined by the theme", () => {
+ /*
+ * Ten tokens were referenced by components and defined by nothing:
+ * --wire-color-focus, --wire-color-surface-soft, --wire-color-on-danger and
+ * the input-* family. An undefined custom property does not warn, it simply
+ * resolves to nothing, so focus rings drew with no colour and soft surfaces
+ * rendered transparent.
+ *
+ * Checked against the rendered theme CSS rather than the source: most of
+ * these tokens are derived per palette, so they never appear as literals.
+ */
+ const css = renderThemeCss(resolveThemeConfig());
+ const defined = new Set(
+ [...css.matchAll(/(--wire-color-[a-z0-9-]+)\s*:/g)].map((match) => match[1]!),
+ );
+
+ const missing = new Map();
+ for (const name of uiComponentNames()) {
+ const source = readFileSync(uiComponentPath(name), "utf8");
+ for (const match of source.matchAll(/var\((--wire-color-[a-z0-9-]+)/g)) {
+ const token = match[1]!;
+ if (defined.has(token)) continue;
+ if (!missing.has(token)) missing.set(token, []);
+ const users = missing.get(token)!;
+ if (users.length < 4 && !users.includes(name)) users.push(name);
+ }
+ }
+
+ expect([...missing].map(([token, users]) => `${token} <- ${users.join(", ")}`)).toEqual([]);
+});