From c7ab605e858fd74758af7ee0ae7224162f214663 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Sat, 8 Aug 2026 15:50:33 +0530 Subject: [PATCH] feat(ui): migrate the page and section components, add the layout tour 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 --- docs/ui-visual-contract-0.8.json | 8 +- examples/basic-app/app/layouts/public.wrn | 1 + examples/basic-app/app/locales/en.json | 3 +- examples/basic-app/app/locales/es.json | 3 +- examples/basic-app/app/pages/layout.wrn | 211 ++++++++++++++ examples/basic-app/app/routes.gen.ts | 3 + .../app/pages/components/layout-splitter.wrn | 10 +- .../component-showcase/showcase-manifest.json | 2 +- packages/csr/src/reactive-runtime.ts | 10 +- packages/csr/test/reactive.test.ts | 7 +- packages/styles/src/theme.ts | 44 ++- packages/ui/COMPONENTS.md | 2 +- packages/ui/component-reference.json | 4 +- packages/ui/components/LayoutSplitter.wrn | 16 +- packages/ui/components/PublicPageShell.wrn | 129 +++++++-- packages/ui/components/Section.wrn | 114 ++++++-- packages/ui/components/SectionHeader.wrn | 260 ++++++++++++------ packages/ui/test/ui.test.ts | 42 +++ 18 files changed, 720 insertions(+), 149 deletions(-) create mode 100644 examples/basic-app/app/pages/layout.wrn diff --git a/docs/ui-visual-contract-0.8.json b/docs/ui-visual-contract-0.8.json index 80c52d50..18c9f663 100644 --- a/docs/ui-visual-contract-0.8.json +++ b/docs/ui-visual-contract-0.8.json @@ -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", diff --git a/examples/basic-app/app/layouts/public.wrn b/examples/basic-app/app/layouts/public.wrn index 55a1f9c3..023e82ae 100644 --- a/examples/basic-app/app/layouts/public.wrn +++ b/examples/basic-app/app/layouts/public.wrn @@ -12,6 +12,7 @@ component PublicLayout { {t:nav.dashboard} {t:nav.about} {t:nav.navigation} + {t:nav.layout} diff --git a/examples/basic-app/app/locales/en.json b/examples/basic-app/app/locales/en.json index 2886371e..568cfac6 100644 --- a/examples/basic-app/app/locales/en.json +++ b/examples/basic-app/app/locales/en.json @@ -5,7 +5,8 @@ "chat": "Chat", "dashboard": "Dashboard", "about": "About", - "navigation": "Navigation" + "navigation": "Navigation", + "layout": "Layout" }, "home": { "title": "Hello from WrNexus", diff --git a/examples/basic-app/app/locales/es.json b/examples/basic-app/app/locales/es.json index 64d20a5d..844cfa14 100644 --- a/examples/basic-app/app/locales/es.json +++ b/examples/basic-app/app/locales/es.json @@ -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", diff --git a/examples/basic-app/app/pages/layout.wrn b/examples/basic-app/app/pages/layout.wrn new file mode 100644 index 00000000..278707ae --- /dev/null +++ b/examples/basic-app/app/pages/layout.wrn @@ -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 { + +
+ + + +
+ +
+ + + + + + +
+

Fixed columns

+

Three tracks on a wide screen, two on a tablet, one on a phone.

+
+
+

Themed

+

Every surface here is a theme token, so light and dark both work.

+
+
+

No Tailwind

+

These components carry their own styles and no longer need it.

+
+
+ + + + +

One

+

Two

+

Three

+

Four

+

Five

+
+
+
+ +
+ + + + + +

Prose

+

+ 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. +

+
+ + Placeholder artwork +
+
+
+ +
+ + + +

+ 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. +

+ + +
+
+

Start pane

+

Drag the divider, or focus it and use the arrow keys. Home and End go to the bounds.

+
+
+
+
+

End pane

+

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.

+
+
+
+
+
+ } + + 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; + } + } +} diff --git a/examples/basic-app/app/routes.gen.ts b/examples/basic-app/app/routes.gen.ts index 7285cfcc..e51e67d3 100644 --- a/examples/basic-app/app/routes.gen.ts +++ b/examples/basic-app/app/routes.gen.ts @@ -10,6 +10,7 @@ export interface Routes { "/dashboard": Record; "/hello": Record; "/language-tools": Record; + "/layout": Record; "/login": Record; "/modal": Record; "/navigation": Record; @@ -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( "dashboard": "/dashboard", "hello": "/hello", "language.tools": "/language-tools", + "layout": "/layout", "login": "/login", "modal": "/modal", "navigation": "/navigation", diff --git a/examples/component-showcase/app/pages/components/layout-splitter.wrn b/examples/component-showcase/app/pages/components/layout-splitter.wrn index a55ade87..c3dbe3e1 100644 --- a/examples/component-showcase/app/pages/components/layout-splitter.wrn +++ b/examples/component-showcase/app/pages/components/layout-splitter.wrn @@ -25,7 +25,7 @@ page LayoutSplitterDetail {
-
+
Interactive playground

Configure Layout Splitter

Change any prop and inspect the server-rendered component immediately.

@@ -110,14 +110,14 @@ page LayoutSplitterDetail {
-
Component outputs

Receive 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.

@resizeFires when the component emits the resize event.
Declarative handlers.wrn
<LayoutSplitter
-  @resize='console.log(payload)'
+        
Component outputs

Receive 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 API

Props and configuration

All content and behavior shown above is supplied through these props and slots.

PropTypeDefaultRequired
colorstring"primary"No
sizenumber50No
orientationstring"horizontal"No
minSizenumber15No
stepnumber5No
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([]); +});