# HG changeset patch # User MrJuneJune # Date 1785860097 25200 # Node ID 117c4d53c9a4e94d4482fd27dd4775d0df6d87a9 # Parent 745fd127b2a1afca074f2742b540a3bfbb19d38a [ui] Add HTML-first Web Component system Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff -r 745fd127b2a1 -r 117c4d53c9a4 .claude/skills/zenbu-bazel-c/SKILL.md --- a/.claude/skills/zenbu-bazel-c/SKILL.md Tue Aug 04 06:23:37 2026 -0700 +++ b/.claude/skills/zenbu-bazel-c/SKILL.md Tue Aug 04 09:14:57 2026 -0700 @@ -21,6 +21,8 @@ - `deita`: SQLite wrapper library. - `s3`: S3 presigned upload helper. - `gui_ze`: Bazel macros for bundling/copying web assets. +- `design_system`: light-DOM Web Components, shared CSS tokens, and a Seobeo + component catalog. Avoid broad searches in `third_party/`, Bazel output directories, virtualenvs, `node_modules`, and generated bundles unless the task specifically requires them. diff -r 745fd127b2a1 -r 117c4d53c9a4 .claude/skills/zenbu-design-system/SKILL.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,46 @@ +--- +name: zenbu-design-system +description: Use this skill when changing Zenbu UI Web Components, design tokens, component stories, catalog routes, or cross-project design-system assets. +--- + +# Zenbu UI design system + +## Structure + +- `design_system/src/components/`: dependency-free ES module Web Components. +- `design_system/src/styles/tokens.css`: shared `--zen-*` design tokens. +- `design_system/src/styles/components.css`: component presentation. +- `design_system/src/index.html`: declarative component stories. +- `design_system/src/storybook.js` and `storybook.css`: catalog shell. +- `design_system/main.c`: Seobeo catalog server. + +## Component conventions + +- Stay close to native HTML and use light DOM. +- Wrap native interactive elements instead of recreating form/link semantics. +- Do not add framework or runtime dependencies. +- Keep application state outside components. +- Preserve keyboard, form, validity, label, and ARIA behavior. +- Prefix custom elements and tokens with `zen-` / `--zen-`. +- Keep selectors low-specificity so applications can override tokens. +- Support light, dark, reduced-motion, desktop, and mobile behavior. + +## Bazel targets + +```bash +bazel run //design_system:dev +bazel test //design_system/test:storybook_test +bazel build //design_system:design_system_server_bundle +``` + +Reusable targets: + +- `//design_system:components` +- `//design_system:styles` +- `//design_system:web_assets` + +## Story convention + +Write stories declaratively with `zen-story` and a direct `template`. Every +component state should have a rendered example and visible source. Browser tests +must cover native semantics and interactions rather than only element presence. diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/BUILD --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/BUILD Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,47 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("//gui_ze:gui_ze.bzl", "bundle") + +filegroup( + name = "styles", + srcs = glob(["src/styles/*.css"]), + visibility = ["//visibility:public"], +) + +js_library( + name = "components", + srcs = glob(["src/components/*.js"]), + visibility = ["//visibility:public"], +) + +filegroup( + name = "web_assets", + srcs = [ + ":components", + ":styles", + ], + visibility = ["//visibility:public"], +) + +filegroup( + name = "catalog_assets", + srcs = glob(["src/**"]), +) + +cc_binary( + name = "design_system_server", + srcs = ["main.c"], + data = [":catalog_assets"], + deps = ["//seobeo:seobeo"], + visibility = ["//design_system:__subpackages__"], +) + +alias( + name = "dev", + actual = ":design_system_server", +) + +bundle( + name = "design_system_server_bundle", + binary = ":design_system_server", +) diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/README.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/README.md Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,74 @@ +# Zenbu UI design system + +Zenbu UI is a dependency-free set of light-DOM Web Components and CSS tokens. +It stays close to ordinary HTML: components wrap native controls and sectioning +elements instead of replacing their semantics. + +## Catalog + +Run the Seobeo-backed component catalog: + +```bash +bazel run //design_system:dev +``` + +Open `http://127.0.0.1:6980`. Override the port with +`DESIGN_SYSTEM_PORT`. + +The catalog is intentionally Storybook-like without depending on Storybook. Its +stories are declared as HTML templates: + +```html + + + +``` + +The story component renders the example and its source side by side. + +## Reuse + +Reusable assets are public Bazel targets: + +```text +//design_system:components +//design_system:styles +//design_system:web_assets +``` + +Applications should copy the assets while preserving `components/` and +`styles/`, then load: + +```html + + + +``` + +Every visual token is a `--zen-*` custom property and can be overridden by the +application. + +## Native HTML contract + +| Component | Native content | Attributes | Events | +| --- | --- | --- | --- | +| `zen-button` | direct `button` or `a` | `variant`, `loading`, `disabled` | native click/form events | +| `zen-card` | direct `article` or `section` | `interactive` | native descendant events | +| `zen-alert` | message HTML | `tone`, `dismissible` | `zen-dismiss` | +| `zen-field` | direct `label`, form control, help text | native control attributes | native input/change/invalid | +| `zen-stack` | any HTML | `direction`, `gap` | native descendant events | + +Do not put business state into the design-system components. They provide +presentation, small accessibility wiring, and interaction affordances while +applications retain data and workflow ownership. + +## Tests + +```bash +bazel test //design_system/test:storybook_test +bazel build //design_system:design_system_server_bundle +``` diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/main.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/main.c Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,98 @@ +#include "seobeo/seobeo.h" + +#include +#include + +static Seobeo_Request_Entry *Get_Component_Catalog( + Seobeo_Request_Entry *request, + Dowa_Arena *arena) +{ + (void)request; + size_t file_size = 0; + char *source = Seobeo_Web_LoadFile("/index.html", &file_size); + if (!source) + { + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "500", arena); + Dowa_HashMap_Push_Arena( + response, + "content-type", + "text/plain; charset=utf-8", + arena); + Dowa_HashMap_Push_Arena( + response, + "body", + "Component catalog is unavailable.", + arena); + return response; + } + + char *body = Dowa_Arena_Allocate(arena, file_size + 1); + if (!body) + { + free(source); + return NULL; + } + memcpy(body, source, file_size + 1); + free(source); + + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf(content_length, 32, "%zu", file_size); + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena( + response, + "content-type", + "text/html; charset=utf-8", + arena); + Dowa_HashMap_Push_Arena( + response, + "content-length", + content_length, + arena); + Dowa_HashMap_Push_Arena(response, "body", body, arena); + return response; +} + +static boolean Port_Is_Valid(const char *port) +{ + if (!port || port[0] == '\0') + return FALSE; + char *end = NULL; + long value = strtol(port, &end, 10); + return end != port && + *end == '\0' && + value > 0 && + value <= 65535; +} + +int main(void) +{ + Seobeo_Router_Init(); + Seobeo_Router_Register("GET", "/", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/tokens", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/button", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/card", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/alert", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/field", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/components/stack", Get_Component_Catalog); + + const char *port = getenv("DESIGN_SYSTEM_PORT"); + if (!port || port[0] == '\0') + port = "6980"; + if (!Port_Is_Valid(port)) + { + Seobeo_Log(SEOBEO_ERROR, "Invalid design-system port: %s\n", port); + Seobeo_Router_Destroy(); + return 1; + } + int result = Seobeo_Web_Server_Start_On( + "127.0.0.1", + "design_system/src", + port, + SEOBEO_MODE_EDGE, + 2); + Seobeo_Router_Destroy(); + return result; +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/alert.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/alert.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,73 @@ +export class ZenAlert extends HTMLElement { + static get observedAttributes() { + return ["dismissible", "tone"]; + } + + connectedCallback() { + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.unbindDismissControl(); + } + + attributeChangedCallback() { + this.sync(); + } + + unbindDismissControl() { + if (this._dismissControl && this._dismissHandler) { + this._dismissControl.removeEventListener( + "click", + this._dismissHandler, + ); + } + this._dismissControl = null; + this._dismissHandler = null; + } + + sync() { + this.setAttribute( + "role", + this.getAttribute("tone") === "danger" ? "alert" : "status", + ); + + let dismiss = this.querySelector(":scope > [data-zen-dismiss]"); + if (!this.hasAttribute("dismissible")) { + this.unbindDismissControl(); + if (dismiss?.dataset.zenGenerated === "true") dismiss.remove(); + return; + } + + if (!dismiss) { + dismiss = document.createElement("button"); + dismiss.type = "button"; + dismiss.dataset.zenDismiss = ""; + dismiss.dataset.zenGenerated = "true"; + dismiss.setAttribute("aria-label", "Dismiss message"); + dismiss.textContent = "×"; + this.append(dismiss); + } + if (dismiss === this._dismissControl) return; + + this.unbindDismissControl(); + this._dismissControl = dismiss; + this._dismissHandler = () => { + this.dispatchEvent(new CustomEvent("zen-dismiss", { + bubbles: true, + composed: true, + })); + this.remove(); + }; + dismiss.addEventListener("click", this._dismissHandler); + } +} + +if (!customElements.get("zen-alert")) { + customElements.define("zen-alert", ZenAlert); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/button.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/button.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,116 @@ +export class ZenButton extends HTMLElement { + static get observedAttributes() { + return ["disabled", "loading"]; + } + + connectedCallback() { + if (!this._blockInteraction) { + this._blockInteraction = event => { + if (!this.hasAttribute("disabled") && + !this.hasAttribute("loading")) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }; + } + this.addEventListener("click", this._blockInteraction, true); + if (!this._observer) { + this._observer = new MutationObserver(records => { + for (const record of records) { + for (const node of record.removedNodes) { + if (node instanceof Element && + node.matches("button, a")) { + this.restoreManagedState(node); + } + } + } + this.sync(); + }); + } + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._blockInteraction, true); + } + + attributeChangedCallback() { + this.sync(); + } + + sync() { + const control = this.querySelector(":scope > button, :scope > a"); + if (!control) return; + + const disabled = + this.hasAttribute("disabled") || + this.hasAttribute("loading"); + + if (disabled) { + if (control.dataset.zenManagedAriaDisabled !== "true") { + control.dataset.zenOriginalAriaDisabled = + control.getAttribute("aria-disabled") ?? "__zen_missing__"; + control.dataset.zenManagedAriaDisabled = "true"; + } + control.setAttribute("aria-disabled", "true"); + } else { + this.restoreDisabledState(control); + } + + if (this.hasAttribute("loading")) { + if (control.dataset.zenManagedAriaBusy !== "true") { + control.dataset.zenOriginalAriaBusy = + control.getAttribute("aria-busy") ?? "__zen_missing__"; + control.dataset.zenManagedAriaBusy = "true"; + } + control.setAttribute("aria-busy", "true"); + } else if (control.dataset.zenManagedAriaBusy === "true") { + if (control.dataset.zenOriginalAriaBusy === "__zen_missing__") { + control.removeAttribute("aria-busy"); + } else { + control.setAttribute( + "aria-busy", + control.dataset.zenOriginalAriaBusy, + ); + } + delete control.dataset.zenManagedAriaBusy; + delete control.dataset.zenOriginalAriaBusy; + } + } + + restoreDisabledState(control) { + if (control.dataset.zenManagedAriaDisabled === "true") { + if (control.dataset.zenOriginalAriaDisabled === "__zen_missing__") { + control.removeAttribute("aria-disabled"); + } else { + control.setAttribute( + "aria-disabled", + control.dataset.zenOriginalAriaDisabled, + ); + } + delete control.dataset.zenManagedAriaDisabled; + delete control.dataset.zenOriginalAriaDisabled; + } + } + + restoreManagedState(control) { + this.restoreDisabledState(control); + if (control.dataset.zenManagedAriaBusy === "true") { + if (control.dataset.zenOriginalAriaBusy === "__zen_missing__") { + control.removeAttribute("aria-busy"); + } else { + control.setAttribute( + "aria-busy", + control.dataset.zenOriginalAriaBusy, + ); + } + delete control.dataset.zenManagedAriaBusy; + delete control.dataset.zenOriginalAriaBusy; + } + } +} + +if (!customElements.get("zen-button")) { + customElements.define("zen-button", ZenButton); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/card.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/card.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,5 @@ +export class ZenCard extends HTMLElement {} + +if (!customElements.get("zen-card")) { + customElements.define("zen-card", ZenCard); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/field.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/field.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,126 @@ +let nextFieldId = 1; + +function uniqueId(base) { + let candidate = base; + let suffix = 2; + while (document.getElementById(candidate)) { + candidate = `${base}-${suffix++}`; + } + return candidate; +} + +export class ZenField extends HTMLElement { + connectedCallback() { + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { + attributes: true, + attributeFilter: ["id"], + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.disconnectControl(); + } + + disconnectControl() { + if (!this._control || !this._updateValidity) return; + this._control.removeEventListener("invalid", this._updateValidity); + this._control.removeEventListener("input", this._updateValidity); + this._control.removeEventListener("change", this._updateValidity); + if (this._addedDescription) { + const describedBy = (this._control.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(value => value && value !== this._addedDescription); + if (describedBy.length) { + this._control.setAttribute( + "aria-describedby", + describedBy.join(" "), + ); + } else { + this._control.removeAttribute("aria-describedby"); + } + } + if (this._label) { + if (this._originalLabelFor === null) { + this._label.removeAttribute("for"); + } else { + this._label.setAttribute("for", this._originalLabelFor); + } + } + this._control = null; + this._label = null; + this._help = null; + this._controlId = null; + this._helpId = null; + this._addedDescription = null; + this._originalLabelFor = null; + this._updateValidity = null; + this.removeAttribute("data-invalid"); + } + + sync() { + const label = this.querySelector(":scope > label"); + const control = this.querySelector( + ":scope > input, :scope > select, :scope > textarea", + ); + if (!label || !control) { + this.disconnectControl(); + return; + } + const help = this.querySelector(":scope > small, :scope > [data-help]"); + if (control === this._control && + label === this._label && + help === this._help && + control.id === this._controlId && + (help?.id || null) === this._helpId) return; + + this.disconnectControl(); + this._control = control; + this._label = label; + this._help = help; + if (!control.id) { + control.id = uniqueId(`zen-field-${nextFieldId++}`); + } + this._originalLabelFor = label.getAttribute("for"); + label.htmlFor = control.id; + + if (help) { + if (!help.id) help.id = uniqueId(`${control.id}-help`); + const describedBy = new Set( + (control.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(Boolean), + ); + if (!describedBy.has(help.id)) { + this._addedDescription = help.id; + } + describedBy.add(help.id); + control.setAttribute( + "aria-describedby", + [...describedBy].join(" "), + ); + } + this._controlId = control.id; + this._helpId = help?.id || null; + + this._updateValidity = () => { + this.toggleAttribute( + "data-invalid", + !control.validity.valid, + ); + }; + control.addEventListener("invalid", this._updateValidity); + control.addEventListener("input", this._updateValidity); + control.addEventListener("change", this._updateValidity); + } +} + +if (!customElements.get("zen-field")) { + customElements.define("zen-field", ZenField); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/index.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/index.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,6 @@ +export { ZenAlert } from "./alert.js"; +export { ZenButton } from "./button.js"; +export { ZenCard } from "./card.js"; +export { ZenField } from "./field.js"; +export { ZenStack } from "./stack.js"; +export { ZenStory } from "./story.js"; diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/stack.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/stack.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,5 @@ +export class ZenStack extends HTMLElement {} + +if (!customElements.get("zen-stack")) { + customElements.define("zen-stack", ZenStack); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/components/story.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/story.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,57 @@ +function normalizeSource(source) { + const lines = source.replace(/^\n|\n\s*$/g, "").split("\n"); + const indentation = lines + .filter(line => line.trim()) + .map(line => line.match(/^\s*/)[0].length); + const minimum = indentation.length + ? Math.min(...indentation) + : 0; + return lines.map(line => line.slice(minimum)).join("\n"); +} + +export class ZenStory extends HTMLElement { + connectedCallback() { + if (this.dataset.ready === "true") return; + const template = this.querySelector(":scope > template"); + if (!template) return; + this.dataset.ready = "true"; + + const heading = document.createElement("header"); + const title = document.createElement("h3"); + title.textContent = this.getAttribute("name") || "Example"; + heading.append(title); + + const copy = document.createElement("button"); + copy.type = "button"; + copy.textContent = "Copy HTML"; + copy.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText( + normalizeSource(template.innerHTML), + ); + copy.textContent = "Copied"; + } catch { + copy.textContent = "Copy unavailable"; + } + setTimeout(() => { + copy.textContent = "Copy HTML"; + }, 1200); + }); + heading.append(copy); + + const canvas = document.createElement("div"); + canvas.className = "story-canvas"; + canvas.append(template.content.cloneNode(true)); + + const source = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = normalizeSource(template.innerHTML); + source.append(code); + + this.prepend(heading, canvas, source); + } +} + +if (!customElements.get("zen-story")) { + customElements.define("zen-story", ZenStory); +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/index.html Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,245 @@ + + + + + + + Zenbu UI + + + + + + +
+ + +
+
+
+

HTML first

+

A small design system that stays close to the platform.

+

+ Zenbu UI uses light-DOM Web Components to organize styling and + behavior without hiding the native button, link, label, input, + article, and section elements that already work. +

+
+ +
+
+

Native underneath

+

Interactive wrappers contain real HTML controls, preserving forms, links, keyboard behavior, and browser APIs.

+
+
+

Light DOM

+

Application code can inspect, style, test, and progressively enhance every element without crossing a shadow boundary.

+
+
+

Portable tokens

+

CSS custom properties provide the visual contract. Components remain dependency-free ES modules.

+
+
+ +

Components

+ +
+ + + + + + + + + + + + +
+
+ + diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/storybook.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/storybook.css Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,268 @@ +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: var(--zen-color-canvas); + color: var(--zen-color-text); + font-family: var(--zen-font-sans); +} + +body { + min-height: 100vh; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.catalog-shell { + display: grid; + grid-template-columns: 17rem minmax(0, 1fr); + min-height: 100vh; +} + +.catalog-sidebar { + position: sticky; + top: 0; + height: 100vh; + overflow: auto; + padding: var(--zen-space-5); + border-right: var(--zen-border-width) solid var(--zen-color-border); + background: var(--zen-color-surface); +} + +.catalog-brand { + display: inline-flex; + align-items: center; + gap: var(--zen-space-3); + color: inherit; + font-size: var(--zen-font-size-lg); + font-weight: 850; + text-decoration: none; +} + +.catalog-mark { + display: grid; + width: 2.5rem; + height: 2.5rem; + place-items: center; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: 50%; + background: var(--zen-color-accent); + color: #171a21; +} + +.catalog-sidebar h2 { + margin: var(--zen-space-6) 0 var(--zen-space-2); + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-xs); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.catalog-nav { + display: grid; + gap: var(--zen-space-1); +} + +.catalog-nav a { + padding: var(--zen-space-2) var(--zen-space-3); + border-radius: var(--zen-radius-sm); + color: inherit; + text-decoration: none; +} + +.catalog-nav a:hover, +.catalog-nav a[aria-current] { + background: var(--zen-color-accent); + color: #171a21; +} + +.theme-toggle { + width: 100%; + margin-top: var(--zen-space-6); + padding: var(--zen-space-2); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: transparent; + color: inherit; + cursor: pointer; +} + +.catalog-main { + width: min(100%, var(--zen-content-width)); + padding: var(--zen-space-7) clamp(1rem, 5vw, 5rem); + outline: none; +} + +.catalog-page[hidden] { + display: none; +} + +.page-heading { + max-width: 52rem; + margin-bottom: var(--zen-space-7); +} + +.eyebrow { + margin: 0 0 var(--zen-space-2); + color: var(--zen-color-info); + font-size: var(--zen-font-size-sm); + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.page-heading h1 { + margin: 0 0 var(--zen-space-3); + font-size: var(--zen-font-size-xl); + line-height: 1.05; +} + +.page-heading p { + color: var(--zen-color-text-muted); + font-size: var(--zen-font-size-lg); +} + +.principles, +.component-grid, +.token-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: var(--zen-space-5); +} + +.principle, +.token-group { + padding: var(--zen-space-5); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +.principle h2, +.token-group h2 { + margin-top: 0; +} + +.component-grid a { + color: inherit; + text-decoration: none; +} + +.component-grid zen-card { + height: 100%; +} + +.swatch-list { + display: grid; + gap: var(--zen-space-2); + margin: 0; +} + +.swatch-list div { + display: grid; + grid-template-columns: 2rem 1fr; + align-items: center; + gap: var(--zen-space-2); +} + +.swatch-list dt, +.swatch-list dd { + margin: 0; + font-family: var(--zen-font-mono); + font-size: var(--zen-font-size-xs); +} + +.swatch { + width: 2rem; + height: 2rem; + border: 1px solid var(--zen-color-border); + border-radius: 50%; + background: var(--swatch); +} + +zen-story { + display: block; + margin-bottom: var(--zen-space-6); + overflow: hidden; + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + background: var(--zen-color-surface); +} + +zen-story > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--zen-space-3); + padding: var(--zen-space-3) var(--zen-space-4); + border-bottom: 1px solid var(--zen-color-border); +} + +zen-story > header h3 { + margin: 0; +} + +zen-story > header button { + padding: var(--zen-space-1) var(--zen-space-2); + border: 0; + border-radius: var(--zen-radius-sm); + background: transparent; + color: var(--zen-color-info); + cursor: pointer; +} + +.story-canvas { + min-height: 8rem; + padding: clamp(1rem, 4vw, 3rem); + background: + linear-gradient(90deg, rgba(127, 127, 127, 0.08) 1px, transparent 1px), + linear-gradient(rgba(127, 127, 127, 0.08) 1px, transparent 1px); + background-size: 1rem 1rem; +} + +zen-story > pre { + max-height: 24rem; + overflow: auto; + margin: 0; + padding: var(--zen-space-4); + border-top: 1px solid var(--zen-color-border); + background: #111318; + color: #f4f4f5; + font: var(--zen-font-size-sm)/1.55 var(--zen-font-mono); +} + +.demo-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: var(--zen-space-5); +} + +@media (max-width: 760px) { + .catalog-shell { + grid-template-columns: 1fr; + } + + .catalog-sidebar { + position: static; + width: 100%; + height: auto; + border-right: 0; + border-bottom: var(--zen-border-width) solid var(--zen-color-border); + } + + .catalog-nav { + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + } + + .catalog-main { + padding: var(--zen-space-6) var(--zen-space-4); + } +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/storybook.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/storybook.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,84 @@ +import "./components/index.js"; + +const pages = new Map([ + ["/", "overview"], + ["/components", "overview"], + ["/tokens", "tokens"], + ["/components/button", "button"], + ["/components/card", "card"], + ["/components/alert", "alert"], + ["/components/field", "field"], + ["/components/stack", "stack"], +]); + +function showPage(pathname) { + const pageName = pages.get(pathname) || "overview"; + for (const page of document.querySelectorAll("[data-catalog-page]")) { + page.hidden = page.dataset.catalogPage !== pageName; + } + for (const link of document.querySelectorAll( + ".catalog-sidebar [data-catalog-link]", + )) { + const active = new URL(link.href).pathname === pathname; + if (active) link.setAttribute("aria-current", "page"); + else link.removeAttribute("aria-current"); + } + const heading = document.querySelector( + `[data-catalog-page="${pageName}"] h1`, + ); + if (heading) document.title = `${heading.textContent} · Zenbu UI`; +} + +window.addEventListener("DOMContentLoaded", () => { + const root = document.documentElement; + const systemTheme = matchMedia("(prefers-color-scheme: dark)"); + const storedTheme = localStorage.getItem("zen-theme"); + if (storedTheme) root.dataset.zenTheme = storedTheme; + + const themeToggle = document.querySelector("#themeToggle"); + const isDark = () => { + if (root.dataset.zenTheme) { + return root.dataset.zenTheme === "dark"; + } + return systemTheme.matches; + }; + const updateThemeButton = () => { + const dark = isDark(); + themeToggle?.setAttribute("aria-pressed", String(dark)); + if (themeToggle) { + themeToggle.textContent = dark + ? "Use light theme" + : "Use dark theme"; + } + }; + updateThemeButton(); + themeToggle?.addEventListener("click", () => { + const next = isDark() ? "light" : "dark"; + root.dataset.zenTheme = next; + localStorage.setItem("zen-theme", next); + updateThemeButton(); + }); + systemTheme.addEventListener("change", () => { + if (!root.dataset.zenTheme) updateThemeButton(); + }); + + document.addEventListener("click", event => { + const link = event.target.closest("a[data-catalog-link]"); + if (!link || + link.origin !== location.origin || + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + link.hasAttribute("download") || + link.target) return; + event.preventDefault(); + history.pushState({}, "", link.href); + showPage(location.pathname); + document.querySelector("#catalogMain")?.focus(); + }); + window.addEventListener("popstate", () => showPage(location.pathname)); + showPage(location.pathname); +}); diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/styles/components.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/styles/components.css Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,210 @@ +:where(zen-button, zen-card, zen-alert, zen-field, zen-stack) { + box-sizing: border-box; + font-family: var(--zen-font-sans); + color: var(--zen-color-text); +} + +zen-button { + display: inline-flex; +} + +zen-button > :where(button, a) { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--zen-space-2); + min-height: 2.75rem; + padding: var(--zen-space-2) var(--zen-space-4); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + box-shadow: var(--zen-shadow-sm); + background: var(--zen-color-accent); + color: #171a21; + font: 700 var(--zen-font-size-md)/1 var(--zen-font-sans); + text-decoration: none; + cursor: pointer; + transition: + background var(--zen-motion-fast) ease, + box-shadow var(--zen-motion-fast) ease, + transform var(--zen-motion-fast) ease; +} + +zen-button > :where(button, a):hover { + background: var(--zen-color-accent-hover); + transform: translate(-1px, -1px); + box-shadow: 5px 5px 0 rgba(23, 26, 33, 0.18); +} + +zen-button > :where(button, a):active { + transform: translate(2px, 2px); + box-shadow: none; +} + +zen-button > :where(button, a):focus-visible, +zen-alert [data-zen-dismiss]:focus-visible, +zen-field :where(input, select, textarea):focus-visible { + outline: 3px solid var(--zen-color-focus); + outline-offset: 3px; +} + +zen-button[variant="quiet"] > :where(button, a) { + background: var(--zen-color-surface); + color: var(--zen-color-text); +} + +zen-button[variant="danger"] > :where(button, a) { + background: var(--zen-color-danger); + color: var(--zen-color-on-danger); +} + +zen-button[disabled] > :where(button, a), +zen-button[loading] > :where(button, a) { + opacity: 0.55; + box-shadow: none; + cursor: not-allowed; + transform: none; +} + +zen-button[loading] > :where(button, a)::before { + width: 0.85rem; + height: 0.85rem; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + content: ""; + animation: zen-spin 700ms linear infinite; +} + +@keyframes zen-spin { + to { transform: rotate(1turn); } +} + +zen-card { + display: block; +} + +zen-card > :where(article, section) { + height: 100%; + padding: var(--zen-space-5); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-lg); + box-shadow: var(--zen-shadow-md); + background: var(--zen-color-surface-raised); +} + +zen-card[interactive] > :where(article, section) { + transition: + box-shadow var(--zen-motion-fast) ease, + transform var(--zen-motion-fast) ease; +} + +zen-card[interactive] > :where(article, section):hover { + transform: translate(-2px, -2px); + box-shadow: 10px 10px 0 rgba(23, 26, 33, 0.18); +} + +zen-card :where(h2, h3, p) { + margin-top: 0; +} + +zen-alert { + display: grid; + grid-template-columns: 0.35rem minmax(0, 1fr) auto; + align-items: start; + gap: var(--zen-space-3); + padding: var(--zen-space-4); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface-raised); +} + +zen-alert::before { + align-self: stretch; + border-radius: 999px; + background: var(--zen-color-info); + content: ""; +} + +zen-alert[tone="success"]::before { background: var(--zen-color-success); } +zen-alert[tone="warning"]::before { background: var(--zen-color-warning); } +zen-alert[tone="danger"]::before { background: var(--zen-color-danger); } + +zen-alert > :where(p, div) { + margin: 0; +} + +zen-alert [data-zen-dismiss] { + min-width: 2rem; + min-height: 2rem; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: inherit; + font: 700 1.2rem/1 var(--zen-font-sans); + cursor: pointer; +} + +zen-field { + display: grid; + gap: var(--zen-space-2); +} + +zen-field > label { + font-weight: 750; +} + +zen-field > :where(input, select, textarea) { + width: 100%; + min-height: 2.75rem; + box-sizing: border-box; + padding: var(--zen-space-3); + border: var(--zen-border-width) solid var(--zen-color-border); + border-radius: var(--zen-radius-md); + background: var(--zen-color-surface); + color: var(--zen-color-text); + font: inherit; +} + +zen-field > textarea { + min-height: 7rem; + resize: vertical; +} + +zen-field > :where(small, [data-help]) { + color: var(--zen-color-text-muted); +} + +zen-field[data-invalid] > :where(input, select, textarea) { + border-color: var(--zen-color-danger); +} + +zen-field[data-invalid] > :where(small, [data-help]) { + color: var(--zen-color-danger); +} + +zen-stack { + display: flex; + flex-direction: column; + gap: var(--zen-stack-gap, var(--zen-space-4)); +} + +zen-stack[direction="row"] { + flex-flow: row wrap; + align-items: center; +} + +zen-stack[gap="tight"] { --zen-stack-gap: var(--zen-space-2); } +zen-stack[gap="loose"] { --zen-stack-gap: var(--zen-space-6); } + +@media (prefers-reduced-motion: reduce) { + zen-button > :where(button, a), + zen-card > :where(article, section) { + transition: none; + } + + zen-button[loading] > :where(button, a)::before { + animation-duration: 1400ms; + } +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/src/styles/tokens.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/styles/tokens.css Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,84 @@ +:root { + color-scheme: light; + + --zen-color-canvas: #f7f3e8; + --zen-color-surface: #fffdf6; + --zen-color-surface-raised: #ffffff; + --zen-color-text: #171a21; + --zen-color-text-muted: #5f6470; + --zen-color-border: #272b35; + --zen-color-accent: #ffb000; + --zen-color-accent-hover: #ffca4a; + --zen-color-info: #2764d7; + --zen-color-success: #18794e; + --zen-color-warning: #9a5b00; + --zen-color-danger: #c62f3e; + --zen-color-on-danger: #ffffff; + --zen-color-focus: #7c3aed; + + --zen-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --zen-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --zen-font-size-xs: 0.75rem; + --zen-font-size-sm: 0.875rem; + --zen-font-size-md: 1rem; + --zen-font-size-lg: 1.25rem; + --zen-font-size-xl: clamp(1.75rem, 4vw, 3rem); + --zen-line-height: 1.55; + + --zen-space-1: 0.25rem; + --zen-space-2: 0.5rem; + --zen-space-3: 0.75rem; + --zen-space-4: 1rem; + --zen-space-5: 1.5rem; + --zen-space-6: 2rem; + --zen-space-7: 3rem; + + --zen-radius-sm: 0.35rem; + --zen-radius-md: 0.7rem; + --zen-radius-lg: 1rem; + --zen-border-width: 2px; + --zen-shadow-sm: 3px 3px 0 rgba(23, 26, 33, 0.16); + --zen-shadow-md: 7px 7px 0 rgba(23, 26, 33, 0.16); + --zen-motion-fast: 120ms; + --zen-content-width: 76rem; +} + +:root[data-zen-theme="dark"] { + color-scheme: dark; + --zen-color-canvas: #101218; + --zen-color-surface: #181b23; + --zen-color-surface-raised: #222631; + --zen-color-text: #f5f1e8; + --zen-color-text-muted: #b9bdc7; + --zen-color-border: #f5f1e8; + --zen-color-accent: #ffc247; + --zen-color-accent-hover: #ffd77d; + --zen-color-info: #8ab4ff; + --zen-color-success: #65d6a3; + --zen-color-warning: #ffc66d; + --zen-color-danger: #ff8792; + --zen-color-on-danger: #171a21; + --zen-color-focus: #c4a5ff; + --zen-shadow-sm: 3px 3px 0 rgba(0, 0, 0, 0.5); + --zen-shadow-md: 7px 7px 0 rgba(0, 0, 0, 0.5); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-zen-theme="light"]) { + color-scheme: dark; + --zen-color-canvas: #101218; + --zen-color-surface: #181b23; + --zen-color-surface-raised: #222631; + --zen-color-text: #f5f1e8; + --zen-color-text-muted: #b9bdc7; + --zen-color-border: #f5f1e8; + --zen-color-accent: #ffc247; + --zen-color-accent-hover: #ffd77d; + --zen-color-info: #8ab4ff; + --zen-color-success: #65d6a3; + --zen-color-warning: #ffc66d; + --zen-color-danger: #ff8792; + --zen-color-on-danger: #171a21; + --zen-color-focus: #c4a5ff; + } +} diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/test/BUILD --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/test/BUILD Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,22 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") + +js_test( + name = "storybook_test", + entry_point = "storybook_test.js", + data = [ + "//design_system:design_system_server", + "//hg-web/e2e:node_modules/playwright-core", + "@playwright_chromium_linux//:chrome", + "@playwright_chromium_linux//:chromium", + ], + env = { + "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)", + }, + no_copy_to_bin = ["@playwright_chromium_linux//:chromium"], + size = "large", + target_compatible_with = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], + timeout = "long", +) diff -r 745fd127b2a1 -r 117c4d53c9a4 design_system/test/storybook_test.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/test/storybook_test.js Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,400 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const net = require('node:net'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const RUNFILES = process.env.JS_BINARY__RUNFILES; +const WORKSPACE = process.env.JS_BINARY__WORKSPACE; +const runfilesWorkspace = path.join(RUNFILES, WORKSPACE); +const playwrightPath = path.join( + runfilesWorkspace, + 'hg-web/e2e/node_modules/playwright-core', +); +const { chromium } = require(playwrightPath); + +function findFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close(error => { + if (error) reject(error); + else resolve(String(address.port)); + }); + }); + }); +} + +async function stopProcess(child) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await new Promise(resolve => { + const timer = setTimeout(() => { + if (child.exitCode === null) child.kill('SIGKILL'); + }, 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function waitForServer(server, baseUrl, logs) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`Server exited with ${server.exitCode}\n${logs.join('')}`); + } + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // Keep waiting. + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Server startup timed out\n${logs.join('')}`); +} + +function requestRawPath(port, requestPath) { + return new Promise((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', + port, + method: 'GET', + path: requestPath, + agent: false, + }, response => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + }); + request.on('error', reject); + request.end(); + }); +} + +(async () => { + assert.ok(RUNFILES); + assert.ok(WORKSPACE); + const serverBinary = path.join( + runfilesWorkspace, + 'design_system/design_system_server', + ); + const chromiumPath = path.resolve(process.env.CHROMIUM_PATH); + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logs = []; + let server; + let browser; + + try { + const invalidServer = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + DESIGN_SYSTEM_PORT: 'not-a-port', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const invalidExit = await new Promise(resolve => { + invalidServer.once('exit', (code, signal) => resolve({ code, signal })); + }); + assert.equal(invalidExit.code, 1); + assert.equal(invalidExit.signal, null); + + server = spawn(serverBinary, [], { + cwd: runfilesWorkspace, + env: { + ...process.env, + DESIGN_SYSTEM_PORT: port, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout.on('data', chunk => logs.push(chunk.toString())); + server.stderr.on('data', chunk => logs.push(chunk.toString())); + await waitForServer(server, baseUrl, logs); + assert.equal(await requestRawPath(port, '/../../MODULE.bazel'), 400); + + for (const route of [ + '/', + '/tokens', + '/components', + '/components/button', + '/components/card', + '/components/alert', + '/components/field', + '/components/stack', + ]) { + const response = await fetch(`${baseUrl}${route}`); + assert.equal(response.status, 200, route); + assert.match( + response.headers.get('content-type') || '', + /^text\/html/, + ); + } + for (const asset of [ + '/styles/tokens.css', + '/styles/components.css', + '/components/index.js', + '/storybook.js', + ]) { + const response = await fetch(`${baseUrl}${asset}`); + assert.equal(response.status, 200, asset); + assert.ok((await response.text()).length > 100, asset); + } + + browser = await chromium.launch({ + executablePath: chromiumPath, + headless: true, + args: ['--no-sandbox'], + }); + const context = await browser.newContext({ colorScheme: 'light' }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('response', response => { + if (response.status() >= 400) { + errors.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto(`${baseUrl}/components/button`, { + waitUntil: 'networkidle', + }); + await page.waitForFunction(() => + customElements.get('zen-button') && + customElements.get('zen-story') + ); + assert.equal( + await page.locator('[data-catalog-page="button"]').isVisible(), + true, + ); + assert.equal( + await page.locator('.catalog-sidebar a[href="/components/button"]').getAttribute('aria-current'), + 'page', + ); + assert.match( + await page.locator('zen-story pre').first().textContent(), + //, + ); + assert.equal( + await page.locator('zen-button[loading]').getAttribute('inert'), + null, + ); + assert.equal( + await page.locator('zen-button[loading] button').getAttribute('aria-disabled'), + 'true', + ); + assert.equal( + await page.locator('zen-button[loading] button').getAttribute('aria-busy'), + 'true', + ); + await page.evaluate(() => { + window.__loadingClicks = 0; + document.querySelector('zen-button[loading] button') + .addEventListener('click', () => window.__loadingClicks++); + }); + await page.locator('zen-button[loading] button').evaluate( + button => button.click(), + ); + assert.equal(await page.evaluate(() => window.__loadingClicks), 0); + await page.locator('zen-button[loading] button').focus(); + assert.equal( + await page.locator('zen-button[loading] button').evaluate( + button => document.activeElement === button, + ), + true, + ); + await page.keyboard.press('Tab'); + assert.equal( + await page.locator('zen-button[loading] button').evaluate( + button => document.activeElement === button, + ), + false, + ); + const nativeDisabledPreserved = await page.evaluate(async () => { + const wrapper = document.createElement('zen-button'); + const button = document.createElement('button'); + button.disabled = true; + button.textContent = 'Native disabled'; + wrapper.append(button); + document.body.append(wrapper); + await customElements.whenDefined('zen-button'); + wrapper.setAttribute('disabled', ''); + wrapper.removeAttribute('disabled'); + return button.disabled; + }); + assert.equal(nativeDisabledPreserved, true); + const replacementButtonState = await page.evaluate(async () => { + const wrapper = document.createElement('zen-button'); + wrapper.setAttribute('disabled', ''); + const first = document.createElement('button'); + first.textContent = 'First'; + wrapper.append(first); + document.body.append(wrapper); + await new Promise(resolve => setTimeout(resolve)); + const second = document.createElement('button'); + second.textContent = 'Second'; + wrapper.replaceChildren(second); + await new Promise(resolve => setTimeout(resolve)); + return { + firstDisabled: first.disabled, + secondAriaDisabled: second.getAttribute('aria-disabled'), + wrapperInert: wrapper.hasAttribute('inert'), + }; + }); + assert.equal(replacementButtonState.firstDisabled, false); + assert.equal(replacementButtonState.secondAriaDisabled, 'true'); + assert.equal(replacementButtonState.wrapperInert, false); + const modifiedClickAllowed = await page.evaluate(() => { + const link = document.querySelector( + '.catalog-sidebar a[href="/components/card"]', + ); + return link.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + button: 0, + ctrlKey: true, + })); + }); + assert.equal(modifiedClickAllowed, true); + + await page.goto(`${baseUrl}/components/field`, { + waitUntil: 'networkidle', + }); + const fieldWiring = await page.locator('zen-field').first().evaluate(field => { + const label = field.querySelector('label'); + const input = field.querySelector('input'); + const help = field.querySelector('small'); + input.checkValidity(); + return { + describedBy: input.getAttribute('aria-describedby'), + helpId: help.id, + inputId: input.id, + invalid: field.hasAttribute('data-invalid'), + labelFor: label.htmlFor, + }; + }); + assert.ok(fieldWiring.inputId); + assert.equal(fieldWiring.labelFor, fieldWiring.inputId); + assert.equal(fieldWiring.describedBy, fieldWiring.helpId); + assert.equal(fieldWiring.invalid, true); + const replacementField = await page.locator('zen-field').first().evaluate( + async field => { + const label = field.querySelector('label'); + const oldInput = field.querySelector('input'); + const blocker = document.createElement('div'); + blocker.id = 'zen-field-3'; + document.body.append(blocker); + const nextInput = document.createElement('input'); + nextInput.required = true; + oldInput.replaceWith(nextInput); + await new Promise(resolve => setTimeout(resolve)); + oldInput.dispatchEvent(new Event('invalid')); + const help = field.querySelector('small'); + nextInput.id = 'replacement-email'; + help.id = 'replacement-help'; + await new Promise(resolve => setTimeout(resolve)); + return { + describedBy: nextInput.getAttribute('aria-describedby'), + helpId: help.id, + labelFor: label.htmlFor, + nextId: nextInput.id, + blockerId: blocker.id, + }; + }, + ); + assert.ok(replacementField.nextId); + assert.notEqual(replacementField.nextId, replacementField.blockerId); + assert.equal(replacementField.labelFor, replacementField.nextId); + assert.equal(replacementField.describedBy, replacementField.helpId); + + await page.goto(`${baseUrl}/components/alert`, { + waitUntil: 'networkidle', + }); + await page.evaluate(() => { + window.__dismissed = 0; + document.addEventListener('zen-dismiss', () => { + window.__dismissed++; + }); + }); + const dismissible = page.locator('zen-alert[dismissible]'); + assert.equal(await dismissible.getAttribute('role'), 'alert'); + await dismissible.locator('[data-zen-dismiss]').click(); + assert.equal(await dismissible.count(), 0); + assert.equal(await page.evaluate(() => window.__dismissed), 1); + const authoredDismissState = await page.evaluate(async () => { + const alert = document.createElement('zen-alert'); + alert.setAttribute('dismissible', ''); + const message = document.createElement('p'); + message.textContent = 'Authored dismiss control'; + const dismiss = document.createElement('button'); + dismiss.dataset.zenDismiss = ''; + alert.append(message, dismiss); + document.body.append(alert); + await new Promise(resolve => setTimeout(resolve)); + let events = 0; + alert.addEventListener('zen-dismiss', () => events++); + alert.removeAttribute('dismissible'); + dismiss.click(); + return { + connected: alert.isConnected, + events, + }; + }); + assert.equal(authoredDismissState.connected, true); + assert.equal(authoredDismissState.events, 0); + + const lightCanvas = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zen-color-canvas') + .trim() + ); + await page.locator('#themeToggle').click(); + const darkCanvas = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zen-color-canvas') + .trim() + ); + assert.notEqual(lightCanvas, darkCanvas); + assert.equal( + await page.evaluate(() => localStorage.getItem('zen-theme')), + 'dark', + ); + + assert.deepEqual(errors, []); + await context.close(); + + const darkContext = await browser.newContext({ colorScheme: 'dark' }); + const darkPage = await darkContext.newPage(); + await darkPage.goto(baseUrl, { waitUntil: 'networkidle' }); + assert.equal( + await darkPage.locator('#themeToggle').getAttribute('aria-pressed'), + 'true', + ); + const systemDarkCanvas = await darkPage.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zen-color-canvas') + .trim() + ); + await darkPage.locator('#themeToggle').click(); + const explicitLightCanvas = await darkPage.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue('--zen-color-canvas') + .trim() + ); + assert.notEqual(systemDarkCanvas, explicitLightCanvas); + await darkContext.close(); + } finally { + if (browser) await browser.close(); + await stopProcess(server); + } +})().catch(error => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff -r 745fd127b2a1 -r 117c4d53c9a4 seobeo/s_network.c --- a/seobeo/s_network.c Tue Aug 04 06:23:37 2026 -0700 +++ b/seobeo/s_network.c Tue Aug 04 09:14:57 2026 -0700 @@ -41,7 +41,8 @@ pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe); Seobeo_Handle *p_handle; struct addrinfo hints, *server_infos, *free_server_info; - int32 socket_fd, yes = 1; // Need this for setsockopt + int32 socket_fd = -1; + int32 yes = 1; memset(&hints, 0, sizeof hints); hints.ai_family = AF_UNSPEC; @@ -70,20 +71,39 @@ Seobeo_Socket_Disable_Sigpipe(socket_fd); if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1) - { perror("setsockopt SO_REUSEADDR"); continue; } + { + perror("setsockopt SO_REUSEADDR"); + close(socket_fd); + socket_fd = -1; + continue; + } #ifdef SO_REUSEPORT // SO_REUSEPORT allows multiple threads/processes to bind to the same port // The kernel will distribute incoming connections among them if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEPORT, &yes, sizeof(yes)) == -1) - { perror("setsockopt SO_REUSEPORT"); continue; } + { + perror("setsockopt SO_REUSEPORT"); + close(socket_fd); + socket_fd = -1; + continue; + } #endif if (bind(socket_fd, free_server_info->ai_addr, free_server_info->ai_addrlen) == -1) - { perror("v_network: Couldn't make socket non-blocking\n"); continue; } + { + perror("bind"); + close(socket_fd); + socket_fd = -1; + continue; + } break; } + freeaddrinfo(server_infos); + + if (socket_fd < 0) + return NULL; if (listen(socket_fd, 16) != 0) { @@ -92,15 +112,25 @@ } int flags = fcntl(socket_fd, F_GETFL, 0); - if(fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK) != 0) { perror("fcntl"); return NULL; } - freeaddrinfo(server_infos); + if (flags < 0 || + fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK) != 0) + { + perror("fcntl"); + close(socket_fd); + return NULL; + } - p_handle = malloc(sizeof(*p_handle)); + p_handle = calloc(1, sizeof(*p_handle)); + if (!p_handle) + { + close(socket_fd); + return NULL; + } p_handle->socket = socket_fd; p_handle->type = SEOBEO_STREAM_TYPE_SERVER; p_handle->connected = FALSE; - p_handle->host = host != NULL ? strdup(host) : "localhost"; + p_handle->host = strdup(host != NULL ? host : "localhost"); p_handle->port = strdup(port); p_handle->ssl_ctx = NULL; @@ -117,6 +147,15 @@ p_handle->destroyed = FALSE; + if (!p_handle->host || + !p_handle->port || + !p_handle->read_buffer || + !p_handle->write_buffer) + { + Seobeo_Handle_Destroy(p_handle); + return NULL; + } + return p_handle; } @@ -258,7 +297,7 @@ Seobeo_SSL_Cleanup(p_handle); - if (p_handle->socket) { + if (p_handle->socket >= 0) { Seobeo_Log(SEOBEO_DEBUG, "Closing handle socket: %d\n", p_handle->socket); close(p_handle->socket); } diff -r 745fd127b2a1 -r 117c4d53c9a4 seobeo/s_web.c --- a/seobeo/s_web.c Tue Aug 04 06:23:37 2026 -0700 +++ b/seobeo/s_web.c Tue Aug 04 09:14:57 2026 -0700 @@ -4,6 +4,27 @@ static char g_folder_path[512] = "."; +static boolean Seobeo_Static_Path_Is_Safe(const char *path) +{ + if (!path || strchr(path, '\\')) + return FALSE; + const char *segment = path; + while (*segment) + { + while (*segment == '/') + segment++; + const char *end = segment; + while (*end && *end != '/') + end++; + if ((size_t)(end - segment) == 2 && + segment[0] == '.' && + segment[1] == '.') + return FALSE; + segment = end; + } + return TRUE; +} + static char *canonical_request_header(char *header) { if (strcasecmp(header, "content-length") == 0) return "Content-Length"; @@ -241,6 +262,22 @@ // --- Static files fallback for GET (use original large arena logic) --- if (strcmp(method, "GET") == 0) { + if (!Seobeo_Static_Path_Is_Safe(path)) + { + Seobeo_Web_Header_Generate_KeepAlive( + p_response_header, + HTTP_BAD_REQUEST, + "text/plain", + 0, + should_keep_alive); + Seobeo_Handle_Queue( + p_cli_handle, + (const uint8 *)p_response_header, + (uint32)strlen(p_response_header)); + Seobeo_Handle_Flush(p_cli_handle); + goto clean_up_arenas; + } + char *file_path = Dowa_Arena_Allocate(p_response_arena, (size_t)5 * 1024); if (!path || strcmp(path, "/") == 0) @@ -261,27 +298,10 @@ strcpy(file_path, path); } - void *p_file_kv = Dowa_HashMap_Get_Ptr(p_html_cache, file_path); - const char *file_content = NULL; + char *file_content = NULL; size_t body_size = 0; - - if (p_file_kv) - { - Seobeo_Cached_File *cached = ((Seobeo_Cache_Entry*)p_file_kv)->value; - file_content = cached->content; - body_size = cached->size; - } - else - { - file_content = Seobeo_Web_LoadFile(file_path, &body_size); - if (file_content) - { - Seobeo_Cached_File *cached = malloc(sizeof(Seobeo_Cached_File)); - cached->content = (char*)file_content; - cached->size = body_size; - Dowa_HashMap_Push(p_html_cache, file_path, cached); - } - } + (void)p_html_cache; + file_content = Seobeo_Web_LoadFile(file_path, &body_size); if (!file_content) { @@ -336,6 +356,7 @@ (const uint8*)file_content, (uint32)body_size); Seobeo_Handle_Flush(p_cli_handle); + free(file_content); } else { @@ -600,14 +621,34 @@ Seobeo_ServerMode mode, int thread_count) { + return Seobeo_Web_Server_Start_On( + NULL, + folder_path, + port, + mode, + thread_count); +} + +int Seobeo_Web_Server_Start_On( + const char *host, + const char *folder_path, + const char *port, + Seobeo_ServerMode mode, + int thread_count) +{ if (folder_path) strncpy(g_folder_path, folder_path, sizeof(g_folder_path) - 1); Seobeo_Cache_Entry *p_html_cache = NULL; Seobeo_Handle *p_server_handle = - Seobeo_Stream_Handle_Server_Create(NULL, port); - if (p_server_handle->socket < 0) return 1; + Seobeo_Stream_Handle_Server_Create(host, port); + if (!p_server_handle || p_server_handle->socket < 0) + { + if (p_server_handle) + Seobeo_Handle_Destroy(p_server_handle); + return 1; + } Seobeo_Log(SEOBEO_INFO, "Listening on port %s\n", port); diff -r 745fd127b2a1 -r 117c4d53c9a4 seobeo/seobeo.h --- a/seobeo/seobeo.h Tue Aug 04 06:23:37 2026 -0700 +++ b/seobeo/seobeo.h Tue Aug 04 09:14:57 2026 -0700 @@ -72,6 +72,8 @@ extern void Seobeo_Web_Header_Generate_KeepAlive(void *buffer, int status, const char *content_type, const int content_length, boolean keep_alive); /* Start a Generic HTTP static file server with given folder. It will store folder into memory. */ extern int Seobeo_Web_Server_Start(const char *folder_path, const char *port, Seobeo_ServerMode mode, int thread_count); +/* Start a web server bound to a specific host. Pass NULL to bind all interfaces. */ +extern int Seobeo_Web_Server_Start_On(const char *host, const char *folder_path, const char *port, Seobeo_ServerMode mode, int thread_count); /* Generic HTTP GET Rquest to given host and port with path. It will mimic chrome. */ extern int Seobeo_Web_Client_Get(const char *host, const char *port, const char *path); diff -r 745fd127b2a1 -r 117c4d53c9a4 seobeo/tests/BUILD --- a/seobeo/tests/BUILD Tue Aug 04 06:23:37 2026 -0700 +++ b/seobeo/tests/BUILD Tue Aug 04 09:14:57 2026 -0700 @@ -82,3 +82,13 @@ timeout = "short", visibility = ["//visibility:public"], ) + +cc_test( + name = "seobeo_server_bind_test", + srcs = ["seobeo_server_bind_test.c"], + deps = ["//seobeo:seobeo"], + size = "small", + timeout = "short", + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:public"], +) diff -r 745fd127b2a1 -r 117c4d53c9a4 seobeo/tests/seobeo_server_bind_test.c --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/seobeo/tests/seobeo_server_bind_test.c Tue Aug 04 09:14:57 2026 -0700 @@ -0,0 +1,59 @@ +#include "seobeo/seobeo.h" + +#include +#include +#include +#include +#include + +static int open_descriptor_count(void) +{ + DIR *directory = opendir("/proc/self/fd"); + assert(directory); + int count = 0; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) + { + if (entry->d_name[0] != '.') + count++; + } + closedir(directory); + return count; +} + +int main(void) +{ + int listener = socket(AF_INET, SOCK_STREAM, 0); + assert(listener >= 0); + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_addr.s_addr = htonl(INADDR_LOOPBACK), + .sin_port = 0, + }; + assert(bind( + listener, + (struct sockaddr *)&address, + sizeof(address)) == 0); + assert(listen(listener, 1) == 0); + + socklen_t address_length = sizeof(address); + assert(getsockname( + listener, + (struct sockaddr *)&address, + &address_length) == 0); + char port[16]; + snprintf(port, sizeof(port), "%u", ntohs(address.sin_port)); + + int before = open_descriptor_count(); + Seobeo_Handle *server = + Seobeo_Stream_Handle_Server_Create("127.0.0.1", port); + assert(server == NULL); + assert(open_descriptor_count() == before); + + assert(Seobeo_Stream_Handle_Server_Create( + "127.0.0.1", + "not-a-port") == NULL); + close(listener); + return 0; +}