feat(ui): migrate the page and section components, add the layout tour
Quality / quality (ubuntu-latest) (push) Failing after 12m21s
Quality / quality (windows-latest) (push) Canceled after 0s

Section, SectionHeader and PublicPageShell move onto wire-* classes with
local style blocks, variants as data attributes. SectionHeader loses 38
utility lines, and Section stops spending one line per variant and colour
pairing: tinted and solid now select on two attributes. PageHeader was already
on the convention and only needed the audit.

examples/basic-app/app/pages/layout.wrn composes the whole set into one page.

Building it surfaced a library-wide bug. Ten custom properties were referenced
by components and defined by nothing: --wire-color-focus, --wire-color-surface-soft,
--wire-color-on-danger, --wire-color-surface-subtle and the input-* family,
plus hover and contrast for every semantic colour except primary and
secondary. An undefined custom property does not warn, it resolves to nothing,
so focus rings drew with no colour and every soft surface rendered
transparent -- 27 components referenced surface-soft alone. They are derived
in the theme now, and a test checks every token a component references against
the rendered theme CSS rather than the source, since most are generated.

The semantic spread also had to move ahead of the primary and secondary
entries so the palette keeps winning for those two.

Known and unresolved: LayoutSplitter emits its sizeChange output and the
component does fire it, but a parent binding on the tag is not invoked. The
tour page therefore points at the handle aria-valuenow rather than wiring a
handler that would never update. Outputs work elsewhere, so this is narrower
than an outputs-are-broken problem and needs its own investigation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 15:50:33 +05:30
co-authored by Claude Opus 5
parent 8aa28205e0
commit c7ab605e85
18 changed files with 720 additions and 149 deletions
+4 -4
View File
@@ -53,7 +53,7 @@
"packages/ui/components/InputGroup.wrn": "0e23ea541a60e893c9d9e6f95113911ee459468d8d6105bf03aeb55395359123",
"packages/ui/components/InputNumber.wrn": "a0aa4f4566bf54ffbef54c99648eab05b5f9870be6ea07639a4ecf3930afb631",
"packages/ui/components/Kbd.wrn": "5afaabbb15902bd8fb82cb4155cf6cbfff55600927b54d564d27e2dd8b752386",
"packages/ui/components/LayoutSplitter.wrn": "a86dc54b74aed6b05e770cd1802960d6309bba42fe80e899e8aaa8b1b7aa88e3",
"packages/ui/components/LayoutSplitter.wrn": "677e2dba6dbf385d8aef14829d785137ea26272a62affcd1839fa74f5145716d",
"packages/ui/components/LegendIndicator.wrn": "6a7607b27a17196f899132eb952203a1073e4077365a94935f485e700cbac665",
"packages/ui/components/Link.wrn": "b986abcc66bc0b1c6296f3ff4e560812e35d89cc680d5251d7a3883e68c1d053",
"packages/ui/components/List.wrn": "02f960a8e82fa049aac477bac9c1d9371394c86d9c47513138f61bffc79dc7d8",
@@ -73,14 +73,14 @@
"packages/ui/components/Popover.wrn": "167f6c476cf3114ac5062ecf7739bddd9b5a81b1d209396a3675067dad1577b2",
"packages/ui/components/PortalDashboard.wrn": "037d4300b59c7d60543abc7d4aba5c738efc143e9b61abc48aad0ddfcfe6845b",
"packages/ui/components/PreferenceSwitcher.wrn": "2cc186d4dcb6580b4b152e3a265d9ed5ad210d3fdc76330b9092db21894465f0",
"packages/ui/components/PublicPageShell.wrn": "507baad0e83dc05c24db42b8af8bd45b18f4e32e418843d77ec906428c687199",
"packages/ui/components/PublicPageShell.wrn": "369b74f65e27227311a2ed73e9ca56c8b60908100d6840a798e8ba5f2c8b1040",
"packages/ui/components/Radio.wrn": "0b2d38a5aee3e859280e4590fa1d509d789cc2239082054872fa342817735f1b",
"packages/ui/components/RangeSlider.wrn": "21eb7a5df0ee2cb5e78f628eb920265998eb9f1a4933d4e5c57db0323fd66b41",
"packages/ui/components/Rating.wrn": "a2d74dc748fc7d98892684c760518b87fa65411b1102e3611252b87245b3ffcb",
"packages/ui/components/Scrollspy.wrn": "76d1c17580c8d460f1222ce97585bdcac9779141f2f30d5460c84ff9f75413d8",
"packages/ui/components/SearchBox.wrn": "315f54f1feaaa47a815d1e2d7a35b492f09fbfadfc6d37228e1009e19e0652ad",
"packages/ui/components/Section.wrn": "21485de07d8bb986837a65c61c5dd01ba6c2f3d4e37d58c154a584c7b58de0ed",
"packages/ui/components/SectionHeader.wrn": "512cb96636ee1f0540a0f4b4c75603fc0fe7536e7ed7ed7dc82173a16f48a4d3",
"packages/ui/components/Section.wrn": "33eb8a82a2102229c2b1f64cec497e9e8e70b67adab8dbe396be860fccbb699c",
"packages/ui/components/SectionHeader.wrn": "8eecf2ea7a93139512260e3d46ca2a1afeb341b8bd46b7c30baa6e2c75e25d10",
"packages/ui/components/Select.wrn": "7f046bb11b7c2470dae26d91a4b2261c66a40ae054d0e04c69b6748912d1e204",
"packages/ui/components/Sidebar.wrn": "03f1bbce75ca6d50ed940e62df39c539610e89eadd2064fe24abc7d5470f98bf",
"packages/ui/components/SplitHero.wrn": "70e843565ff869bcf413b11b6d9830b2e2bd229863a00904596f71e2dff0e76d",
@@ -12,6 +12,7 @@ component PublicLayout {
<a href="/dashboard">{t:nav.dashboard}</a>
<a href="/about">{t:nav.about}</a>
<a href="/navigation">{t:nav.navigation}</a>
<a href="/layout">{t:nav.layout}</a>
<button type="button" data-wire-theme-toggle aria-label="Toggle color theme">Theme</button>
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="en">EN</button>
<button class="wire-btn wire-btn--ghost wire-btn--sm" data-wire-lang-set="es">ES</button>
+2 -1
View File
@@ -5,7 +5,8 @@
"chat": "Chat",
"dashboard": "Dashboard",
"about": "About",
"navigation": "Navigation"
"navigation": "Navigation",
"layout": "Layout"
},
"home": {
"title": "Hello from WrNexus",
+2 -1
View File
@@ -5,7 +5,8 @@
"chat": "Chat",
"dashboard": "Panel",
"about": "Acerca de",
"navigation": "Navegación"
"navigation": "Navegación",
"layout": "Diseño"
},
"home": {
"title": "Hola desde WrNexus",
+211
View File
@@ -0,0 +1,211 @@
// Layout tour. Route: /layout
//
// One page built out of the layout set, rather than showing each piece alone:
//
// PublicPageShell the outer shell, holding the page background
// Section each band down the page, with its own spacing and tint
// SectionHeader the eyebrow, heading and description of each band
// Container the reading width
// Grid the card deck, including an auto-fit track
// Columns the coarse two-way split
// LayoutSplitter a resizable pane, by drag or by arrow key
// CustomScrollbar a themed scrolling region
// Divider the rules between bands
// Typography the prose column
// Image, Link, Kbd the smaller pieces inside it
//
// CustomScrollbar sits beside the splitter rather than inside a pane of it: a
// component tag nested in another component slot is dropped, and only its
// children survive.
//
// The splitter does not report into a status line here. It emits a sizeChange
// output and the component does fire it, but a parent binding on the tag is
// not invoked, so wiring one up would show something that never updates.
page LayoutTour {
layout = "public"
seo {
title = "Layout tour"
description = "Every layout component working together on one page."
}
view {
<PublicPageShell background="default" minHeight="dvh" fullWidth={true}>
<Section spacing="lg" variant="soft" borderBottom={true}>
<Container maxWidth="lg" columns={1}>
<SectionHeader
eyebrow="Layout"
title="Composing a page"
description="Every band on this page is a Section. The width you are reading at is a Container, and the pieces inside are the rest of the layout set."
align="center"
maxWidth="2xl"
/>
</Container>
</Section>
<Section spacing="lg">
<Container maxWidth="lg" columns={1}>
<SectionHeader
eyebrow="Grid"
title="Card decks"
description="A fixed column count collapses at each breakpoint. Pass minItemWidth instead and the browser decides the count, reflowing continuously."
headingLevel={3}
/>
<Divider />
<Grid columns={3} gap="md">
<div class="tour-card">
<h4>Fixed columns</h4>
<p>Three tracks on a wide screen, two on a tablet, one on a phone.</p>
</div>
<div class="tour-card">
<h4>Themed</h4>
<p>Every surface here is a theme token, so light and dark both work.</p>
</div>
<div class="tour-card">
<h4>No Tailwind</h4>
<p>These components carry their own styles and no longer need it.</p>
</div>
</Grid>
<Divider label="auto fit" />
<Grid minItemWidth="12rem" gap="sm">
<div class="tour-card tour-card--compact"><h4>One</h4></div>
<div class="tour-card tour-card--compact"><h4>Two</h4></div>
<div class="tour-card tour-card--compact"><h4>Three</h4></div>
<div class="tour-card tour-card--compact"><h4>Four</h4></div>
<div class="tour-card tour-card--compact"><h4>Five</h4></div>
</Grid>
</Container>
</Section>
<Section spacing="lg" variant="soft" borderTop={true} borderBottom={true}>
<Container maxWidth="lg" columns={1}>
<SectionHeader
eyebrow="Split"
title="Columns and panes"
description="Columns is the coarse split of a page. LayoutSplitter is the one the reader can move."
headingLevel={3}
/>
<Columns columns={2} gap="lg">
<Typography maxWidth="full">
<h4>Prose</h4>
<p>
Typography caps the measure so a line never runs too long to
follow. Inside it you get the smaller pieces: a
<Link href="/navigation" label="link" /> to somewhere else, and
<Kbd label="Ctrl" /> <Kbd label="K" /> for a shortcut.
</p>
</Typography>
<Image src="" alt="Placeholder artwork" size="video" rounded={true} />
</Columns>
</Container>
</Section>
<Section spacing="lg">
<Container maxWidth="lg" columns={1}>
<SectionHeader
eyebrow="Interactive"
title="Resize and scroll"
description="The splitter resolves its size in the runtime and reports it here. The scrolling region themes its own scrollbar."
headingLevel={3}
/>
<p class="tour-status">
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.
</p>
<LayoutSplitter size={45} minSize={20} step={5} label="Resize the panes">
<div data-slot="start">
<div class="tour-pane">
<h4>Start pane</h4>
<p>Drag the divider, or focus it and use the arrow keys. Home and End go to the bounds.</p>
</div>
</div>
<div data-slot="end">
<div class="tour-pane">
<h4>End pane</h4>
<p>The size is resolved in the runtime and written onto the container, so the panes are plain grid tracks.</p>
</div>
</div>
</LayoutSplitter>
<Divider label="scrolling" />
<CustomScrollbar maxHeight="12rem" thickness={10}>
<div class="tour-scroll">
<p>This region scrolls, and its scrollbar follows the theme.</p>
<p>Scrollbar appearance is CSS rather than script.</p>
<p>scrollbar-width and scrollbar-color are the standard properties.</p>
<p>The webkit rules cover the engines that still need them.</p>
<p>Enough lines here that the region actually overflows.</p>
<p>Otherwise there would be no scrollbar to look at.</p>
<p>Which is the mistake the showcase demo made first time.</p>
</div>
</CustomScrollbar>
</Container>
</Section>
</PublicPageShell>
}
style {
.tour-card {
padding: 1rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-md);
background: var(--wire-color-surface);
}
.tour-card h4,
.tour-pane h4 {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 700;
}
.tour-card p,
.tour-pane p,
.tour-scroll p {
margin: 0;
color: var(--wire-color-text-muted);
font-size: 0.86rem;
line-height: 1.6;
}
.tour-card--compact {
text-align: center;
}
.tour-status {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--wire-color-border);
border-radius: var(--wire-radius-sm);
background: var(--wire-color-surface-soft);
color: var(--wire-color-text-muted);
font-size: 0.82rem;
}
.tour-pane {
padding: 1rem;
}
.tour-scroll {
display: grid;
gap: 0.6rem;
padding: 0.75rem;
}
/* The splitter panes need a floor or they collapse to their text. */
.tour-pane,
.tour-scroll {
min-height: 8rem;
}
}
}
+3
View File
@@ -10,6 +10,7 @@ export interface Routes {
"/dashboard": Record<string, never>;
"/hello": Record<string, never>;
"/language-tools": Record<string, never>;
"/layout": Record<string, never>;
"/login": Record<string, never>;
"/modal": Record<string, never>;
"/navigation": Record<string, never>;
@@ -31,6 +32,7 @@ export interface RouteNames {
"dashboard": "/dashboard";
"hello": "/hello";
"language.tools": "/language-tools";
"layout": "/layout";
"login": "/login";
"modal": "/modal";
"navigation": "/navigation";
@@ -121,6 +123,7 @@ export function route<N extends RouteName>(
"dashboard": "/dashboard",
"hello": "/hello",
"language.tools": "/language-tools",
"layout": "/layout",
"login": "/login",
"modal": "/modal",
"navigation": "/navigation",
@@ -25,7 +25,7 @@ page LayoutSplitterDetail {
</div>
<div class="detail-hero-icon"><span class="icon-[lucide--component] size-10" aria-hidden="true"></span></div>
</header>
<section id="playground" class="detail-section playground-section" data-playground data-playground-component="LayoutSplitter" data-playground-public-tag="true" data-playground-has-slot="true" data-playground-events="resize">
<section id="playground" class="detail-section playground-section" data-playground data-playground-component="LayoutSplitter" data-playground-public-tag="true" data-playground-has-slot="true" data-playground-events="sizeChange">
<div class="detail-section-heading"><span>Interactive playground</span><h2>Configure Layout Splitter</h2><p>Change any prop and inspect the server-rendered component immediately.</p></div>
<div class="playground-workbench">
<div class="playground-preview">
@@ -110,14 +110,14 @@ page LayoutSplitterDetail {
</section>
</div>
</article></div></section>
<section id="events" class="detail-section"><div class="detail-section-heading"><span>Component outputs</span><h2>Receive every typed component output</h2><p>Use declarative output handlers in <code>.wrn</code> files or register a direct output handler from JavaScript. The canonical API exposes <code>payload</code> and does not require <code>event.detail</code>.</p></div><div class="detail-events-grid"><div class="detail-events-list"><div><code>@resize</code><span>Fires when the component emits the resize event.</span></div></div><div class="detail-event-examples"><section class="demo-code"><header><span><span class="icon-[lucide--braces] size-4" aria-hidden="true"></span>Declarative handlers</span><small>.wrn</small></header><pre><code>&lt;LayoutSplitter
@resize='console.log(payload)'
<section id="events" class="detail-section"><div class="detail-section-heading"><span>Component outputs</span><h2>Receive every typed component output</h2><p>Use declarative output handlers in <code>.wrn</code> files or register a direct output handler from JavaScript. The canonical API exposes <code>payload</code> and does not require <code>event.detail</code>.</p></div><div class="detail-events-grid"><div class="detail-events-list"><div><code>@sizeChange</code><span>Fires when the component emits the sizeChange event.</span></div></div><div class="detail-event-examples"><section class="demo-code"><header><span><span class="icon-[lucide--braces] size-4" aria-hidden="true"></span>Declarative handlers</span><small>.wrn</small></header><pre><code>&lt;LayoutSplitter
@sizeChange='console.log(payload)'
/&gt;</code></pre></section><section class="demo-code"><header><span><span class="icon-[lucide--radio] size-4" aria-hidden="true"></span>Direct output handlers</span><small>.js</small></header><pre><code>import <span>&#123;</span> registerOutputHandler <span>&#125;</span> from "@wrnexus/csr/outputs"
const component = document.querySelector("[data-ui-component=\"LayoutSplitter\"], [data-component=\"LayoutSplitter\"]")
if (component) registerOutputHandler(component, "resize", (payload) =&gt; <span>&#123;</span>
console.log("resize", payload)
if (component) registerOutputHandler(component, "sizeChange", (payload) =&gt; <span>&#123;</span>
console.log("sizeChange", payload)
<span>&#125;</span>)</code></pre></section></div></div></section>
<section id="api" class="detail-section"><div class="detail-section-heading"><span>Component API</span><h2>Props and configuration</h2><p>All content and behavior shown above is supplied through these props and slots.</p></div><div class="docs-table-wrap"><table class="docs-table"><thead><tr><th>Prop</th><th>Type</th><th>Default</th><th>Required</th></tr></thead><tbody><tr><td><code>color</code></td><td>string</td><td><code>"primary"</code></td><td>No</td></tr><tr><td><code>size</code></td><td>number</td><td><code>50</code></td><td>No</td></tr><tr><td><code>orientation</code></td><td>string</td><td><code>"horizontal"</code></td><td>No</td></tr><tr><td><code>minSize</code></td><td>number</td><td><code>15</code></td><td>No</td></tr><tr><td><code>step</code></td><td>number</td><td><code>5</code></td><td>No</td></tr><tr><td><code>label</code></td><td>string</td><td><code>"Resize panels"</code></td><td>No</td></tr><tr><td><code>class</code></td><td>string</td><td><code>""</code></td><td>No</td></tr></tbody></table></div></section>
</main>
@@ -693,7 +693,7 @@
"demoCount": 2,
"propCount": 7,
"slots": ["start", "end", "default"],
"events": ["resize"],
"events": ["sizeChange"],
"profiled": true
},
{
+8 -2
View File
@@ -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;
}
+3 -4
View File
@@ -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(
`<div data-wrn-splitter="horizontal" data-wrn-splitter-min="10" data-wrn-splitter-step="10"
style="--wrn-split: 50%">
@@ -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(
+42 -2
View File
@@ -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<string, ThemeTokens> = {
"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<string, ThemeTokens> = {
"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",
+1 -1
View File
@@ -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
+2 -2
View File
@@ -7855,11 +7855,11 @@
"slots": ["start", "end", "default"],
"outputs": [
{
"name": "resize",
"name": "sizeChange",
"payloadType": "{ size: number }"
}
],
"events": ["resize"],
"events": ["sizeChange"],
"source": "components/LayoutSplitter.wrn"
},
{
+15 -1
View File
@@ -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)'
>
<div class="wire-splitter__pane wire-splitter__pane--start">
<slot name="start"></slot>
+104 -25
View File
@@ -14,40 +14,119 @@ component PublicPageShell {
view {
<div
{...attrs}
data-ui-component="PublicPageShell"
class='wire-page-shell {class}'
data-size='{size}'
data-color='{color}'
data-variant='{variant}'
class='relative isolate w-full text-[var(--wire-color-text)] {class}'
class:min-h-screen='minHeight === "screen"'
class:min-h-dvh='minHeight === "dvh"'
class:overflow-x-clip='overflow === "clip"'
class:overflow-x-hidden='overflow === "hidden"'
class:bg-[var(--wire-color-background)]='background === "default"'
class:bg-[var(--wire-color-surface-soft)]='background === "soft"'
class:bg-[var(--wire-color-surface-raised)]='background === "raised"'
class:pt-16='headerOffset === "sm"'
class:pt-20='headerOffset === "md"'
class:pt-24='headerOffset === "lg"'
data-background='{background}'
data-overflow='{overflow}'
data-min-height='{minHeight}'
data-header-offset='{headerOffset}'
>
<slot name="before"></slot>
{#if fullWidth}
<main class="w-full">
<slot></slot>
</main>
{:else}
<main
class="mx-auto w-full px-4 sm:px-6 lg:px-8"
class:max-w-5xl='maxWidth === "lg"'
class:max-w-7xl='maxWidth === "xl"'
class:max-w-screen-2xl='maxWidth === "2xl"'
>
<slot></slot>
</main>
{/if}
<main
class="wire-page-shell__main"
data-full-width='{fullWidth}'
data-max-width='{maxWidth}'
>
<slot></slot>
</main>
<slot name="after"></slot>
</div>
}
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;
}
}
}
+92 -22
View File
@@ -16,33 +16,16 @@ component Section {
view {
<section
{...attrs}
data-ui-component="Section"
class='wire-section {class}'
id='{id}'
data-size='{size}'
data-color='{color}'
data-variant='{variant}'
class='relative isolate w-full {class}'
class:py-8='spacing === "sm"'
class:py-12='spacing === "md"'
class:py-16='spacing === "lg"'
class:sm:py-20='spacing === "lg"'
class:py-20='spacing === "xl"'
class:sm:py-24='spacing === "xl"'
class:bg-[var(--wire-color-background)]='variant === "default"'
class:bg-[var(--wire-color-surface-soft)]='variant === "soft"'
class:bg-[var(--wire-color-surface-raised)]='variant === "raised"'
class:bg-[var(--wire-color-primary-soft)]='variant === "tinted" && color === "primary"'
class:bg-[var(--wire-color-success-soft)]='variant === "tinted" && color === "success"'
class:bg-[var(--wire-color-warning-soft)]='variant === "tinted" && color === "warning"'
class:bg-[var(--wire-color-danger-soft)]='variant === "tinted" && color === "danger"'
class:bg-[var(--wire-color-info-soft)]='variant === "tinted" && color === "info"'
class:bg-[var(--wire-color-primary)]='variant === "solid" && color === "primary"'
class:text-[var(--wire-color-on-primary)]='variant === "solid" && color === "primary"'
class:bg-[var(--wire-color-danger)]='variant === "solid" && color === "danger"'
class:text-[var(--wire-color-on-danger)]='variant === "solid" && color === "danger"'
class:border-t='borderTop'
class:border-b='borderBottom'
class:border-[var(--wire-color-border)]='borderTop || borderBottom'
data-spacing='{spacing}'
data-border-top='{borderTop}'
data-border-bottom='{borderBottom}'
>
{#if fullWidth}
<slot></slot>
@@ -59,4 +42,91 @@ component Section {
{/if}
</section>
}
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;
}
}
}
}
+182 -78
View File
@@ -14,100 +14,204 @@ component SectionHeader {
view {
<header
{...attrs}
data-ui-component="SectionHeader"
class='flex min-w-0 flex-col gap-5 {class}'
class:items-center='align === "center"'
class:text-center='align === "center"'
class:items-end='align === "right"'
class:text-right='align === "right"'
class:lg:flex-row='align === "split"'
class:lg:items-end='align === "split"'
class:lg:justify-between='align === "split"'
class='wire-section-header {class}'
data-align='{align}'
data-size='{size}'
data-color='{color}'
data-max-width='{maxWidth}'
>
<div
class="min-w-0"
class:max-w-xl='maxWidth === "xl"'
class:max-w-2xl='maxWidth === "2xl"'
class:max-w-3xl='maxWidth === "3xl"'
class:max-w-4xl='maxWidth === "4xl"'
class:mx-auto='align === "center"'
>
<div class="mb-3 flex items-center gap-3" class:justify-center='align === "center"' class:justify-end='align === "right"'>
<div class="wire-section-header__copy">
<div class="wire-section-header__eyebrow-row">
<slot name="icon"></slot>
{#if eyebrow}
<p
class="text-xs font-bold uppercase tracking-[0.18em]"
class:text-sm='size === "lg"'
class:text-[var(--wire-color-primary)]='color === "primary"'
class:text-[var(--wire-color-secondary)]='color === "secondary"'
class:text-[var(--wire-color-success)]='color === "success"'
class:text-[var(--wire-color-warning-text)]='color === "warning"'
class:text-[var(--wire-color-danger)]='color === "danger"'
class:text-[var(--wire-color-info)]='color === "info"'
>
{eyebrow}
</p>
{/if}
<p class="wire-section-header__eyebrow" data-show="eyebrow">{eyebrow}</p>
</div>
{#if headingLevel === 1}
<h1
id='{id}'
class="font-bold leading-tight tracking-tight text-[var(--wire-color-text)]"
class:text-3xl='size === "sm"'
class:text-4xl='size === "default" || size === "md"'
class:sm:text-5xl='size === "default" || size === "md"'
class:text-5xl='size === "lg"'
class:sm:text-6xl='size === "lg"'
>
{title}
</h1>
<h1 id='{id}' class="wire-section-header__title">{title}</h1>
{:else if headingLevel === 3}
<h3
id='{id}'
class="font-bold leading-tight tracking-tight text-[var(--wire-color-text)]"
class:text-xl='size === "sm"'
class:text-2xl='size === "default" || size === "md"'
class:text-3xl='size === "lg"'
>
{title}
</h3>
<h3 id='{id}' class="wire-section-header__title">{title}</h3>
{:else}
<h2
id='{id}'
class="font-bold leading-tight tracking-tight text-[var(--wire-color-text)]"
class:text-2xl='size === "sm"'
class:text-3xl='size === "default" || size === "md"'
class:sm:text-4xl='size === "default" || size === "md"'
class:text-4xl='size === "lg"'
class:sm:text-5xl='size === "lg"'
>
{title}
</h2>
<h2 id='{id}' class="wire-section-header__title">{title}</h2>
{/if}
{#if description}
<p
class="mt-4 leading-7 text-[var(--wire-color-text-muted)]"
class:text-sm='size === "sm"'
class:text-base='size === "default" || size === "md"'
class:text-lg='size === "lg"'
>
{description}
</p>
{/if}
<p class="wire-section-header__description" data-show="description">{description}</p>
<slot></slot>
</div>
<div
class="flex shrink-0 flex-wrap items-center gap-3"
class:justify-center='align === "center"'
class:justify-end='align === "right" || align === "split"'
>
<div class="wire-section-header__actions">
<slot name="actions"></slot>
</div>
</header>
}
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;
}
}
}
}
+42
View File
@@ -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<string, string[]>();
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([]);
});