From 4f1abc6321d8b3aa93bd28723d0a5ea9e05922fa Mon Sep 17 00:00:00 2001 From: gobha Date: Fri, 20 Mar 2026 23:52:55 +0000 Subject: [PATCH] Changeset 0.37.2 (#214) Co-authored-by: gobha Co-committed-by: gobha --- UI redesign.md | 2 +- docs/ROADMAP.md | 131 +++++++++-- src/css/sw-primitives.css | 285 +++++++++++++++++++++++ src/css/sw-shell.css | 109 +++++++++ src/dev.html | 359 +++++++++++++++++++++++++++++ src/js/sw/primitives/avatar.js | 35 +++ src/js/sw/primitives/banner.js | 26 +++ src/js/sw/primitives/button.js | 22 ++ src/js/sw/primitives/confirm.js | 44 ++++ src/js/sw/primitives/dialog.js | 93 ++++++++ src/js/sw/primitives/drawer.js | 47 ++++ src/js/sw/primitives/dropdown.js | 106 +++++++++ src/js/sw/primitives/form-field.js | 19 ++ src/js/sw/primitives/index.js | 18 ++ src/js/sw/primitives/menu.js | 159 +++++++++++++ src/js/sw/primitives/prompt.js | 60 +++++ src/js/sw/primitives/spinner.js | 9 + src/js/sw/primitives/tabs.js | 60 +++++ src/js/sw/primitives/toast.js | 94 ++++++++ src/js/sw/primitives/tooltip.js | 36 +++ src/js/sw/shell/app-shell.js | 81 +++++++ src/js/sw/vendor/hooks.module.js | 2 + src/js/sw/vendor/htm.module.js | 1 + src/js/sw/vendor/preact.module.js | 2 + 24 files changed, 1775 insertions(+), 25 deletions(-) create mode 100644 src/css/sw-primitives.css create mode 100644 src/css/sw-shell.css create mode 100644 src/dev.html create mode 100644 src/js/sw/primitives/avatar.js create mode 100644 src/js/sw/primitives/banner.js create mode 100644 src/js/sw/primitives/button.js create mode 100644 src/js/sw/primitives/confirm.js create mode 100644 src/js/sw/primitives/dialog.js create mode 100644 src/js/sw/primitives/drawer.js create mode 100644 src/js/sw/primitives/dropdown.js create mode 100644 src/js/sw/primitives/form-field.js create mode 100644 src/js/sw/primitives/index.js create mode 100644 src/js/sw/primitives/menu.js create mode 100644 src/js/sw/primitives/prompt.js create mode 100644 src/js/sw/primitives/spinner.js create mode 100644 src/js/sw/primitives/tabs.js create mode 100644 src/js/sw/primitives/toast.js create mode 100644 src/js/sw/primitives/tooltip.js create mode 100644 src/js/sw/shell/app-shell.js create mode 100644 src/js/sw/vendor/hooks.module.js create mode 100644 src/js/sw/vendor/htm.module.js create mode 100644 src/js/sw/vendor/preact.module.js diff --git a/UI redesign.md b/UI redesign.md index c4a4d7e..b9a67f9 100644 --- a/UI redesign.md +++ b/UI redesign.md @@ -488,7 +488,7 @@ longer generates any UI DOM. ``` 0.37.1 Permission audit + enforcement fixes + GET /profile/permissions ✅ -0.37.2 Layer 0 — UI primitives (Menu, Dialog, Toast, Drawer, etc.) +0.37.2 Layer 0 — UI primitives + Layer 1 Shell layout ✅ 0.37.3 Layer 1 — SDK (namespaced API client, auth, RBAC, events, pipe) 0.37.4 Layer 2 — Shell (no auth gate, temp bypass for visual validation) 0.37.5 Login surface + Settings surface diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 96dcd54..5cbad60 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,30 +26,35 @@ v0.9.x–v0.28.7 Foundation through Platform Polish ✅ │ v0.28.8 ICD Green Board ✅ │ - ┌───────────────┴───────────────┐ - │ │ - Extension Track Operations Track - │ │ - v0.29.0 Starlark Sandbox ✅ v0.32.0 Multi-Replica HA ✅ - v0.29.1 API Extensions ✅ v0.33.0 Observability ✅ - v0.29.2 DB Extensions ✅ v0.34.0 Data Portability ✅ - v0.29.3 Workflow Forms ✅ │ - v0.30.0 Package Lifecycle ✅ │ - v0.30.1 SDK Adoption ✅ │ - v0.30.2 Workflow Packages ✅ │ - v0.31.0 Editor + SDK ✅ │ - v0.31.1 SDK Exercise ✅ │ - v0.31.2 Team Workflows ✅ │ - │ │ - │ v0.35.0 Workflow Product - │ │ - │ v0.36.0 Full OpenAPI Spec ✅ - │ │ - ══════╪═══════════════════════════════╪══════ - │ MVP v0.50.0 │ - │ (v0.31.2 + v0.35.0 │ - │ + mobile + deployment docs) │ - ══════╪═══════════════════════════════╪══════ + ┌───────────────┼───────────────┐ + │ │ │ + Extension Track UI Rewrite Operations Track + │ Track │ + v0.29.0 ✅ │ v0.32.0 ✅ + v0.29.1 ✅ │ v0.33.0 ✅ + v0.29.2 ✅ │ v0.34.0 ✅ + v0.29.3 ✅ │ │ + v0.30.0 ✅ │ │ + v0.30.1 ✅ │ │ + v0.30.2 ✅ │ │ + v0.31.0 ✅ │ │ + v0.31.1 ✅ │ │ + v0.31.2 ✅ │ │ + │ │ v0.35.0 Workflow Product + │ │ │ + │ │ v0.36.0 Full OpenAPI ✅ + │ │ │ + │ v0.37.1 Perm Audit ✅ │ + │ v0.37.2 Primitives ✅ │ + │ v0.37.3 SDK │ + │ v0.37.4 Shell │ + │ v0.37.5–13 Surfaces │ + │ │ │ + ══════╪════════════╪═════════════════╪══════ + │ MVP v0.50.0 │ + │ (all three tracks complete │ + │ + mobile + deploy docs) │ + ══════╪════════════╪═════════════════╪══════ │ v0.50.0+ Rich Media + Beyond (image gen, code sandbox, @@ -528,6 +533,84 @@ Depends on: v0.33.0 (Swagger UI + initial spec). --- +## UI Rewrite Track + +Scorched earth rebuild of the frontend on Preact+htm. Three-layer +architecture (Primitives → SDK → Shell) replacing the imperative +DOM manipulation codebase. See `UI redesign.md` for full design doc. + +Depends on: v0.36.0 (OpenAPI spec), v0.31.2 (SDK composability). + +### v0.37.1 — Permission Audit & Enforcement ✅ + +Backend prerequisite for frontend RBAC. See `CHANGESET.md`. + +- [x] `RequirePermission` middleware on 8 unprotected routes +- [x] `GET /profile/permissions` — self-service resolved permission set +- [x] 12 permission enforcement integration tests (deny → grant → allow) +- [x] Mirror route fixes in integration test harness + +### v0.37.2 — Layer 0: UI Primitives + Shell Layout ✅ + +Preact+htm component library and application frame. + +- [x] Vendor: preact.module.js, hooks.module.js, htm.module.js (~5KB total) +- [x] 14 primitive components: Button, Spinner, Avatar, FormField, + Tooltip, Banner, Toast, Dialog, Confirm, Prompt, Menu, Drawer, + Tabs, Dropdown +- [x] AppShell layout: fixed top/bottom banners (single config), + dismissible message bar, footer, surface viewport +- [x] `sw-primitives.css` + `sw-shell.css` — `sw-` prefixed, no + conflicts with existing styles +- [x] `dev.html` — interactive primitives gallery + shell controls +- [x] ES module loading with `window.preact/hooks/html` globals + +### v0.37.3 — Layer 1: SDK + +Namespaced REST client, auth module, RBAC gate, event bus, pipe/filter +pipeline, theme control. + +Depends on: v0.37.2. + +- [ ] `sw.api.{domain}.*` — namespaced methods for all 18 ICD domains +- [ ] `sw.auth.*` — login, logout, refresh, session lifecycle +- [ ] `sw.can(perm)` — synchronous RBAC gate from cached permission set +- [ ] `sw.on/off/emit` — event bus with wildcard patterns +- [ ] `sw.pipe.*` — carry forward pre-send/stream/render pipeline +- [ ] `sw.theme.*` — theme control with FOUC prevention + +### v0.37.4 — Layer 2: Shell + +Application frame with user menu, surface viewport, banners, toast/dialog +stacks. Temp auth bypass for visual validation. + +Depends on: v0.37.3. + +- [ ] Root `` component tree +- [ ] `UserMenu` — avatar + flyout (Settings, Admin, Sign Out) +- [ ] `SurfaceViewport` — mount point for active surface +- [ ] `ToastContainer` + `DialogStack` — global overlay management +- [ ] Simplified `base.html` Go template (single `#app` mount) + +### v0.37.5–v0.37.13 — Surface Rebuilds + +Each surface rebuilt as Preact component tree. Old JS deleted per surface. + +| Version | Surface | Notes | +|---------|---------|-------| +| v0.37.5 | Login + Settings | Self-contained, no real-time | +| v0.37.6 | Admin | Largest section count, no real-time | +| v0.37.7 | Team Admin | Inherits from Admin patterns | +| v0.37.8 | ChatPane | Reusable component, NOT a surface | +| v0.37.9 | NotesPane | Reusable component, NOT a surface | +| v0.37.10 | Chat surface | Composes ChatPane + sidebar + panels | +| v0.37.11 | Notes surface | Composes NotesPane + graph | +| v0.37.12 | Extension surface container | `.pkg` mount point | +| v0.37.13 | Workflow surfaces | Form renderer, review views | +| v0.37.# | Tag | Functionality restored, old code deleted | + +--- + ## MVP v0.50.0 **Gate:** deploy for 5–10 teams, ~50 users, 100+ anonymous visitors diff --git a/src/css/sw-primitives.css b/src/css/sw-primitives.css new file mode 100644 index 0000000..89e3218 --- /dev/null +++ b/src/css/sw-primitives.css @@ -0,0 +1,285 @@ +/* ── Layer 0: New Primitives CSS (v0.37.2) ─────────── + Preact component styles. Uses variables.css tokens. + Prefixed sw- to avoid clash with old primitives.css. + ──────────────────────────────────────────────────── */ + +/* ── Button ────────────────────────────────── */ + +.sw-btn { + display: inline-flex; align-items: center; justify-content: center; + gap: 0.5em; border: 1px solid transparent; border-radius: var(--radius); + font-family: var(--font); font-weight: 500; cursor: pointer; + transition: background var(--transition), border-color var(--transition), opacity var(--transition); + white-space: nowrap; position: relative; +} +.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.sw-btn--sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; } +.sw-btn--md { padding: 0.45rem 0.9rem; font-size: 0.875rem; } +.sw-btn--lg { padding: 0.6rem 1.2rem; font-size: 1rem; } + +.sw-btn--primary { background: var(--accent); color: var(--text-on-color); } +.sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); } +.sw-btn--secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border); } +.sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); } +.sw-btn--danger { background: var(--danger); color: var(--text-on-color); } +.sw-btn--danger:hover:not(:disabled) { opacity: 0.9; } +.sw-btn--ghost { background: transparent; color: var(--text-2); } +.sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } + +.sw-btn--loading .sw-btn__content--hidden { visibility: hidden; } +.sw-btn__spinner { + position: absolute; width: 1em; height: 1em; + border: 2px solid transparent; border-top-color: currentColor; + border-radius: 50%; animation: sw-spin 0.6s linear infinite; +} + +/* ── Spinner ───────────────────────────────── */ + +.sw-spinner { + display: inline-block; border: 2px solid var(--border); + border-top-color: var(--accent); border-radius: 50%; + animation: sw-spin 0.6s linear infinite; +} +.sw-spinner--sm { width: 16px; height: 16px; } +.sw-spinner--md { width: 24px; height: 24px; } +.sw-spinner--lg { width: 40px; height: 40px; } + +@keyframes sw-spin { to { transform: rotate(360deg); } } + +/* ── Avatar ────────────────────────────────── */ + +.sw-avatar { + display: inline-flex; align-items: center; justify-content: center; + border-radius: 50%; object-fit: cover; flex-shrink: 0; + font-weight: 600; color: var(--text-on-color); background: var(--accent); +} +.sw-avatar--sm { width: 24px; height: 24px; font-size: 0.65rem; } +.sw-avatar--md { width: 32px; height: 32px; font-size: 0.75rem; } +.sw-avatar--lg { width: 48px; height: 48px; font-size: 1rem; } +.sw-avatar--fallback { user-select: none; } + +/* ── FormField ─────────────────────────────── */ + +.sw-field { display: flex; flex-direction: column; gap: 0.3rem; } +.sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); } +.sw-field__required { color: var(--danger); margin-left: 2px; } +.sw-field__error { font-size: 0.75rem; color: var(--danger); } +.sw-field__hint { font-size: 0.75rem; color: var(--text-3); } +.sw-field--error .sw-input { border-color: var(--danger); } + +/* ── Shared input ──────────────────────────── */ + +.sw-input { + width: 100%; padding: 0.45rem 0.7rem; background: var(--input-bg); + color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); + font-family: var(--font); font-size: 0.875rem; outline: none; + transition: border-color var(--transition); +} +.sw-input:focus { border-color: var(--accent); } +.sw-input::placeholder { color: var(--text-3); } + +/* ── Tooltip ───────────────────────────────── */ + +.sw-tooltip-wrap { position: relative; display: inline-flex; } +.sw-tooltip { + position: absolute; z-index: 1000; padding: 0.3rem 0.6rem; + background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); + border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap; + pointer-events: none; animation: sw-fade-in 0.15s ease; +} +.sw-tooltip--top { bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); } +.sw-tooltip--bottom { top: calc(100% + 6px); left: 50%; transform: translateX(-50%); } +.sw-tooltip--left { right: calc(100% + 6px); top: 50%; transform: translateY(-50%); } +.sw-tooltip--right { left: calc(100% + 6px); top: 50%; transform: translateY(-50%); } + +@keyframes sw-fade-in { from { opacity: 0; } to { opacity: 1; } } + +/* ── Banner ────────────────────────────────── */ + +.sw-banner { + display: flex; align-items: center; justify-content: center; + gap: 0.5rem; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 500; + width: 100%; flex-shrink: 0; +} +.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); } +.sw-banner--warn { background: var(--warning-dim); color: var(--warning-light); } +.sw-banner--error { background: var(--danger-dim); color: var(--danger-light); } +.sw-banner--success { background: var(--success-dim); color: var(--success-light); } +.sw-banner__close { + background: none; border: none; color: inherit; cursor: pointer; + font-size: 1.1rem; padding: 0 0.3rem; opacity: 0.7; +} +.sw-banner__close:hover { opacity: 1; } + +/* ── Toast ─────────────────────────────────── */ + +.sw-toast-container { + position: fixed; bottom: 1rem; right: 1rem; z-index: 10000; + display: flex; flex-direction: column-reverse; gap: 0.5rem; + max-width: 380px; pointer-events: none; +} +.sw-toast { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.75rem 1rem; border-radius: var(--radius-lg); + background: var(--bg-raised); border: 1px solid var(--border); + box-shadow: var(--shadow-lg); pointer-events: auto; + font-size: 0.875rem; +} +.sw-toast--enter { animation: sw-slide-in 0.25s ease; } +.sw-toast--exit { animation: sw-fade-out 0.2s ease forwards; } +.sw-toast--success { border-left: 3px solid var(--success); } +.sw-toast--error { border-left: 3px solid var(--danger); } +.sw-toast--warn { border-left: 3px solid var(--warning); } +.sw-toast--info { border-left: 3px solid var(--accent); } +.sw-toast__msg { flex: 1; color: var(--text); } +.sw-toast__close { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 1.1rem; padding: 0; line-height: 1; +} +.sw-toast__close:hover { color: var(--text); } + +@keyframes sw-slide-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } +@keyframes sw-fade-out { to { opacity: 0; transform: translateX(50%); } } + +/* ── Dialog ────────────────────────────────── */ + +.sw-dialog-overlay { + position: fixed; inset: 0; z-index: 5000; + background: var(--overlay); display: flex; align-items: center; justify-content: center; + animation: sw-fade-in 0.15s ease; +} +.sw-dialog { + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); + min-width: 360px; max-width: min(520px, 90vw); max-height: 85vh; + display: flex; flex-direction: column; animation: sw-scale-in 0.2s ease; +} +.sw-dialog__header { + display: flex; align-items: center; justify-content: space-between; + padding: 1rem 1.25rem 0.75rem; border-bottom: 1px solid var(--border); +} +.sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; } +.sw-dialog__close { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 1.3rem; padding: 0; line-height: 1; +} +.sw-dialog__close:hover { color: var(--text); } +.sw-dialog__body { padding: 1rem 1.25rem; overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; } +.sw-dialog__actions { + display: flex; gap: 0.5rem; justify-content: flex-end; + padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); +} + +@keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } + +/* ── Menu ──────────────────────────────────── */ + +.sw-menu { + z-index: 6000; min-width: 160px; max-width: 300px; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-lg); + padding: 4px 0; outline: none; animation: sw-fade-in 0.1s ease; +} +.sw-menu__item { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem; + color: var(--text); transition: background var(--transition); +} +.sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); } +.sw-menu__item--disabled { opacity: 0.4; cursor: not-allowed; } +.sw-menu__item--disabled:hover { background: transparent; } +.sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); } +.sw-menu__label { flex: 1; } +.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); } +.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; } + +/* ── Drawer ────────────────────────────────── */ + +.sw-drawer-overlay { + position: fixed; inset: 0; z-index: 4000; + background: transparent; transition: background 0.25s ease; + pointer-events: none; +} +.sw-drawer-overlay--open { background: var(--overlay); pointer-events: auto; } +.sw-drawer { + position: fixed; top: 0; bottom: 0; z-index: 4001; + background: var(--bg-surface); border: 1px solid var(--border); + display: flex; flex-direction: column; + transition: transform 0.25s ease; + overflow: hidden; +} +.sw-drawer--right { right: 0; transform: translateX(100%); } +.sw-drawer--right.sw-drawer--open { transform: translateX(0); } +.sw-drawer--left { left: 0; transform: translateX(-100%); } +.sw-drawer--left.sw-drawer--open { transform: translateX(0); } +.sw-drawer__header { + display: flex; align-items: center; justify-content: space-between; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; } +.sw-drawer__close { + background: none; border: none; color: var(--text-3); cursor: pointer; + font-size: 1.3rem; padding: 0; line-height: 1; +} +.sw-drawer__close:hover { color: var(--text); } +.sw-drawer__body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; } + +/* ── Tabs ──────────────────────────────────── */ + +.sw-tabs { + display: flex; align-items: center; gap: 0; + border-bottom: 1px solid var(--border); position: relative; +} +.sw-tabs__scroll { + display: flex; overflow-x: auto; scrollbar-width: none; + -ms-overflow-style: none; flex: 1; +} +.sw-tabs__scroll::-webkit-scrollbar { display: none; } +.sw-tabs__tab { + padding: 0.5rem 0.9rem; background: none; border: none; + border-bottom: 2px solid transparent; color: var(--text-2); + font-family: var(--font); font-size: 0.85rem; font-weight: 500; + cursor: pointer; white-space: nowrap; + transition: color var(--transition), border-color var(--transition); +} +.sw-tabs__tab:hover { color: var(--text); } +.sw-tabs__tab--active { color: var(--accent); border-bottom-color: var(--accent); } +.sw-tabs__tab--disabled { opacity: 0.4; cursor: not-allowed; } +.sw-tabs__icon { margin-right: 0.35rem; } +.sw-tabs__arrow { + background: none; border: none; color: var(--text-3); + cursor: pointer; padding: 0.3rem 0.5rem; font-size: 1.2rem; flex-shrink: 0; +} +.sw-tabs__arrow:hover { color: var(--text); } + +/* ── Dropdown ──────────────────────────────── */ + +.sw-dropdown { position: relative; display: inline-block; } +.sw-dropdown__trigger { + display: flex; align-items: center; justify-content: space-between; + gap: 0.5rem; width: 100%; padding: 0.45rem 0.7rem; + background: var(--input-bg); color: var(--text); + border: 1px solid var(--border); border-radius: var(--radius); + font-family: var(--font); font-size: 0.875rem; cursor: pointer; + transition: border-color var(--transition); +} +.sw-dropdown--open .sw-dropdown__trigger { border-color: var(--accent); } +.sw-dropdown__value { flex: 1; text-align: left; } +.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; } +.sw-dropdown__list { + position: absolute; top: calc(100% + 4px); left: 0; right: 0; + z-index: 6000; max-height: 240px; overflow-y: auto; + background: var(--bg-surface); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-lg); + animation: sw-fade-in 0.1s ease; +} +.sw-dropdown__search { margin: 4px; width: calc(100% - 8px); } +.sw-dropdown__option { + padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem; + color: var(--text); transition: background var(--transition); +} +.sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); } +.sw-dropdown__option--selected { color: var(--accent); font-weight: 500; } +.sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; } +.sw-dropdown__option--disabled:hover { background: transparent; } +.sw-dropdown__empty { padding: 0.75rem; text-align: center; color: var(--text-3); font-size: 0.85rem; } diff --git a/src/css/sw-shell.css b/src/css/sw-shell.css new file mode 100644 index 0000000..cac7e09 --- /dev/null +++ b/src/css/sw-shell.css @@ -0,0 +1,109 @@ +/* ── Layer 1: AppShell layout ───────────────── + Full-viewport column layout with optional + fixed banners, announcement, surface, and footer. + ──────────────────────────────────────────── */ + +.sw-shell { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + overflow: hidden; + background: var(--bg); +} + +/* ── Banner (fixed top / bottom — identical component) ── */ + +.sw-shell__banner { + position: fixed; + left: 0; + right: 0; + z-index: 900; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.45rem 1rem; + font-size: 0.8rem; + font-weight: 500; +} + +.sw-shell__banner--top { top: 0; } +.sw-shell__banner--bottom { bottom: 0; } + +.sw-shell__banner--info { background: var(--accent-dim); color: var(--accent-light); } +.sw-shell__banner--warn { background: var(--warning-dim); color: var(--warning-light); } +.sw-shell__banner--error { background: var(--danger-dim); color: var(--danger-light); } +.sw-shell__banner--success { background: var(--success-dim); color: var(--success-light); } + +.sw-shell__banner-text { + flex: 1; + text-align: center; +} + +.sw-shell__banner-close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1.1rem; + padding: 0 0.3rem; + opacity: 0.7; + line-height: 1; +} +.sw-shell__banner-close:hover { opacity: 1; } + +/* ── Shell body — insets for fixed banners ─── */ + +.sw-shell__body { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + padding-top: var(--banner-top-height, 0px); + padding-bottom: var(--banner-bottom-height, 0px); +} + +/* ── Announcement bar ─────────────────────── */ + +.sw-shell__announcement { + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} + +.sw-shell__announcement-inner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1.25rem; + font-size: 0.85rem; +} + +.sw-shell__announcement--info { background: var(--bg-surface); color: var(--text-2); } +.sw-shell__announcement--warn { background: var(--warning-dim); color: var(--warning-light); } +.sw-shell__announcement--error { background: var(--danger-dim); color: var(--danger-light); } +.sw-shell__announcement--success { background: var(--success-dim); color: var(--success-light); } + +.sw-shell__announcement-text { + flex: 1; +} + +/* ── Surface (main content area) ──────────── */ + +.sw-shell__surface { + flex: 1; + overflow: auto; + position: relative; +} + +/* ── Footer ───────────────────────────────── */ + +.sw-shell__footer { + flex-shrink: 0; + padding: 0.5rem 1rem; + border-top: 1px solid var(--border); + font-size: 0.75rem; + color: var(--text-3); + text-align: center; + background: var(--bg-surface); +} diff --git a/src/dev.html b/src/dev.html new file mode 100644 index 0000000..d9b1ffa --- /dev/null +++ b/src/dev.html @@ -0,0 +1,359 @@ + + + + + + Chat Switchboard — Dev Gallery + + + + + + +
+ + + diff --git a/src/js/sw/primitives/avatar.js b/src/js/sw/primitives/avatar.js new file mode 100644 index 0000000..8dee931 --- /dev/null +++ b/src/js/sw/primitives/avatar.js @@ -0,0 +1,35 @@ +/** + * Avatar primitive — circle avatar with letter fallback + * Sizes: sm (24px), md (32px), lg (48px) + */ +const { html } = window; +const { useState } = hooks; + +function initials(name) { + if (!name) return '?'; + const parts = name.trim().split(/\s+/); + return parts.length > 1 + ? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + : name.slice(0, 2).toUpperCase(); +} + +export function Avatar({ src, name = '', size = 'md' }) { + const [failed, setFailed] = useState(false); + + if (src && !failed) { + return html` + ${name} setFailed(true)} + /> + `; + } + return html` + + ${initials(name)} + + `; +} diff --git a/src/js/sw/primitives/banner.js b/src/js/sw/primitives/banner.js new file mode 100644 index 0000000..e34a7e1 --- /dev/null +++ b/src/js/sw/primitives/banner.js @@ -0,0 +1,26 @@ +/** + * Banner primitive — persistent top/bottom bar + * Variants: info, warn, error, success + */ +const { html } = window; +const { useState } = hooks; + +export function Banner({ text, variant = 'info', dismissible = false, position = 'top', onDismiss }) { + const [dismissed, setDismissed] = useState(false); + + if (dismissed || !text) return null; + + const handleDismiss = () => { + setDismissed(true); + if (onDismiss) onDismiss(); + }; + + return html` +
+ ${text} + ${dismissible && html` + + `} +
+ `; +} diff --git a/src/js/sw/primitives/button.js b/src/js/sw/primitives/button.js new file mode 100644 index 0000000..7e230d6 --- /dev/null +++ b/src/js/sw/primitives/button.js @@ -0,0 +1,22 @@ +/** + * Button primitive — variants: primary, secondary, danger, ghost + * Sizes: sm, md (default), lg + */ +const { html } = window; +const { useState } = hooks; + +export function Button({ variant = 'primary', size = 'md', disabled, loading, onClick, children, type = 'button', className = '' }) { + const cls = `sw-btn sw-btn--${variant} sw-btn--${size} ${loading ? 'sw-btn--loading' : ''} ${className}`.trim(); + + return html` + + `; +} diff --git a/src/js/sw/primitives/confirm.js b/src/js/sw/primitives/confirm.js new file mode 100644 index 0000000..633f739 --- /dev/null +++ b/src/js/sw/primitives/confirm.js @@ -0,0 +1,44 @@ +/** + * Confirm primitive — specialized Dialog that resolves a Promise + * Usage: const ok = await confirm('Delete this?', { destructive: true }) + */ +const { html } = window; +const { useState } = hooks; +const { h, render } = preact; + +import { Dialog } from './dialog.js'; + +let _setConfirm = null; +let _resolve = null; + +export function confirm(message, opts = {}) { + return new Promise((resolve) => { + _resolve = resolve; + if (_setConfirm) _setConfirm({ message, ...opts }); + }); +} + +function handleClose(result) { + if (_resolve) _resolve(result); + _resolve = null; + if (_setConfirm) _setConfirm(null); +} + +export function ConfirmHost() { + const [state, setState] = useState(null); + _setConfirm = setState; + + if (!state) return null; + + const { message, destructive, confirmLabel = 'Confirm', cancelLabel = 'Cancel' } = state; + const actions = [ + { label: cancelLabel, variant: 'secondary', onClick: () => handleClose(false) }, + { label: confirmLabel, variant: destructive ? 'danger' : 'primary', onClick: () => handleClose(true) }, + ]; + + return html` + <${Dialog} open title="Confirm" onClose=${() => handleClose(false)} actions=${actions}> +

${message}

+ + `; +} diff --git a/src/js/sw/primitives/dialog.js b/src/js/sw/primitives/dialog.js new file mode 100644 index 0000000..341e072 --- /dev/null +++ b/src/js/sw/primitives/dialog.js @@ -0,0 +1,93 @@ +/** + * Dialog primitive — modal with focus trapping, Escape close, backdrop click + * Actions: [{label, onClick, variant}] + */ +const { html } = window; +const { useEffect, useRef, useCallback } = hooks; + +export function Dialog({ open, title, children, onClose, actions = [], persistent = false, className = '' }) { + const dialogRef = useRef(null); + const prevFocus = useRef(null); + + // Focus trap + restore + useEffect(() => { + if (!open) return; + prevFocus.current = document.activeElement; + const el = dialogRef.current; + if (!el) return; + + // Focus first focusable element + requestAnimationFrame(() => { + const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (focusable.length) focusable[0].focus(); + }); + + // Lock body scroll + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = ''; + if (prevFocus.current) prevFocus.current.focus(); + }; + }, [open]); + + // Escape key + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (e.key === 'Escape' && onClose) onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + // Focus trap: Tab cycles within dialog + const onKeyDown = useCallback((e) => { + if (e.key !== 'Tab') return; + const el = dialogRef.current; + if (!el) return; + const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, []); + + if (!open) return null; + + const backdropClick = persistent ? undefined : onClose; + + return html` +
+ +
+ `; +} diff --git a/src/js/sw/primitives/drawer.js b/src/js/sw/primitives/drawer.js new file mode 100644 index 0000000..3b344bc --- /dev/null +++ b/src/js/sw/primitives/drawer.js @@ -0,0 +1,47 @@ +/** + * Drawer primitive — slide-in panel (left/right) + */ +const { html } = window; +const { useEffect, useRef } = hooks; + +export function Drawer({ open, side = 'right', width = '360px', title, children, onClose }) { + const drawerRef = useRef(null); + + // Escape to close + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (e.key === 'Escape' && onClose) onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + // Lock body scroll + useEffect(() => { + if (!open) return; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = ''; }; + }, [open]); + + return html` +
+
e.stopPropagation()} + role="dialog" aria-modal="true" aria-label=${title || 'Panel'}> + ${title && html` +
+

${title}

+ ${onClose && html` + + `} +
+ `} +
${children}
+
+
+ `; +} diff --git a/src/js/sw/primitives/dropdown.js b/src/js/sw/primitives/dropdown.js new file mode 100644 index 0000000..ab49c76 --- /dev/null +++ b/src/js/sw/primitives/dropdown.js @@ -0,0 +1,106 @@ +/** + * Dropdown primitive — select replacement with search/filter + * options: [{ label, value, disabled? }] + */ +const { html } = window; +const { useState, useRef, useEffect, useCallback, useMemo } = hooks; + +export function Dropdown({ options = [], value, onChange, placeholder = 'Select...', searchable = false }) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [focusIdx, setFocusIdx] = useState(-1); + const wrapRef = useRef(null); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return options; + const q = search.toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(q)); + }, [options, search]); + + const selected = options.find(o => o.value === value); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Focus search on open + useEffect(() => { + if (open && searchable && inputRef.current) inputRef.current.focus(); + if (open) setFocusIdx(-1); + if (!open) setSearch(''); + }, [open, searchable]); + + const select = useCallback((opt) => { + if (opt.disabled) return; + if (onChange) onChange(opt.value); + setOpen(false); + }, [onChange]); + + const onKeyDown = useCallback((e) => { + if (!open) { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + } + return; + } + const count = filtered.length; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusIdx(i => Math.min(i + 1, count - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusIdx(i => Math.max(i - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (focusIdx >= 0 && focusIdx < count) select(filtered[focusIdx]); + break; + case 'Escape': + setOpen(false); + break; + } + }, [open, filtered, focusIdx, select]); + + return html` +
+ + ${open && html` +
+ ${searchable && html` + { setSearch(e.target.value); setFocusIdx(0); }} /> + `} + ${filtered.map((opt, i) => html` +
setFocusIdx(i)} + onClick=${() => select(opt)}> + ${opt.label} +
+ `)} + ${filtered.length === 0 && html` +
No results
+ `} +
+ `} +
+ `; +} diff --git a/src/js/sw/primitives/form-field.js b/src/js/sw/primitives/form-field.js new file mode 100644 index 0000000..2a57081 --- /dev/null +++ b/src/js/sw/primitives/form-field.js @@ -0,0 +1,19 @@ +/** + * FormField primitive — wrapper for form inputs with label + validation + */ +const { html } = window; + +export function FormField({ label, error, hint, required, children }) { + return html` +
+ ${label && html` + + `} +
${children}
+ ${error && html`

${error}

`} + ${hint && !error && html`

${hint}

`} +
+ `; +} diff --git a/src/js/sw/primitives/index.js b/src/js/sw/primitives/index.js new file mode 100644 index 0000000..3ede1ce --- /dev/null +++ b/src/js/sw/primitives/index.js @@ -0,0 +1,18 @@ +/** + * Layer 0 — Primitives re-export + * All UI primitives available via: import { Button, Dialog, ... } from './primitives/index.js' + */ +export { Button } from './button.js'; +export { Spinner } from './spinner.js'; +export { Avatar } from './avatar.js'; +export { FormField } from './form-field.js'; +export { Tooltip } from './tooltip.js'; +export { Banner } from './banner.js'; +export { Dialog } from './dialog.js'; +export { confirm, ConfirmHost } from './confirm.js'; +export { prompt, PromptHost } from './prompt.js'; +export { toast, ToastContainer } from './toast.js'; +export { Menu } from './menu.js'; +export { Drawer } from './drawer.js'; +export { Tabs } from './tabs.js'; +export { Dropdown } from './dropdown.js'; diff --git a/src/js/sw/primitives/menu.js b/src/js/sw/primitives/menu.js new file mode 100644 index 0000000..487d1b4 --- /dev/null +++ b/src/js/sw/primitives/menu.js @@ -0,0 +1,159 @@ +/** + * Menu primitive — flyout menu with viewport clamping, keyboard nav, submenus + * + * Items: [{ label, action, icon?, disabled?, divider?, children? }] + * Directions: down-right (default), down-left, up-right, up-left + */ +const { html } = window; +const { useState, useEffect, useRef, useCallback } = hooks; + +export function Menu({ items = [], anchor, open, direction = 'down-right', onSelect, onClose }) { + const menuRef = useRef(null); + const [pos, setPos] = useState(null); + const [focusIdx, setFocusIdx] = useState(-1); + const [submenu, setSubmenu] = useState(null); + + const selectableItems = items.filter(i => !i.divider && !i.disabled); + + // Position relative to anchor + useEffect(() => { + if (!open || !anchor) return; + const rect = typeof anchor.getBoundingClientRect === 'function' + ? anchor.getBoundingClientRect() + : anchor; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top, left; + const [vDir, hDir] = direction.split('-'); + + if (vDir === 'down') top = rect.bottom + 4; + else top = rect.top - 4; + + if (hDir === 'right') left = rect.left; + else left = rect.right; + + // Defer measurement to get menu size + requestAnimationFrame(() => { + const el = menuRef.current; + if (!el) return; + const mw = el.offsetWidth; + const mh = el.offsetHeight; + + // Viewport clamp + if (vDir === 'down' && top + mh > vh) top = Math.max(4, rect.top - mh - 4); + if (vDir === 'up') { top = top - mh; if (top < 4) top = rect.bottom + 4; } + if (hDir === 'right' && left + mw > vw) left = vw - mw - 4; + if (hDir === 'left') { left = left - mw; if (left < 4) left = 4; } + + setPos({ top, left }); + }); + setPos({ top: -9999, left: -9999 }); // initial offscreen for measurement + setFocusIdx(-1); + }, [open, anchor, direction]); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + if (onClose) onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open, onClose]); + + // Keyboard navigation + const onKeyDown = useCallback((e) => { + const count = selectableItems.length; + if (!count) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusIdx(i => (i + 1) % count); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusIdx(i => (i - 1 + count) % count); + break; + case 'Home': + e.preventDefault(); + setFocusIdx(0); + break; + case 'End': + e.preventDefault(); + setFocusIdx(count - 1); + break; + case 'Enter': { + e.preventDefault(); + if (focusIdx >= 0 && focusIdx < count) { + const item = selectableItems[focusIdx]; + if (item.children) setSubmenu(item); + else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); } + } + break; + } + case 'ArrowRight': { + if (focusIdx >= 0 && selectableItems[focusIdx]?.children) { + setSubmenu(selectableItems[focusIdx]); + } + break; + } + case 'ArrowLeft': + if (submenu) setSubmenu(null); + break; + case 'Escape': + if (submenu) setSubmenu(null); + else if (onClose) onClose(); + break; + } + }, [selectableItems, focusIdx, submenu, onSelect, onClose]); + + // Focus menu on open + useEffect(() => { + if (open && menuRef.current) menuRef.current.focus(); + }, [open, pos]); + + if (!open) return null; + + let selIdx = 0; + return html` +