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([]); +});