@digithings-ai/design 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,344 @@
1
- # Temporary Holding Version
1
+ # digithings Design System
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared visual language for every digithings public surface:
4
+ `digithings.ai`, `digiquant.io`, and `digithings.ai/chat`. A simple,
5
+ utilitarian, dark-first aesthetic with per-module accent colors.
6
+
7
+ This document describes the token package. `docs/design/CURRENT_UI_LANGUAGE.md` is the current description of the shipped language; where the two disagree, `tokens.css` wins. The canonical implementation
8
+ lives in `packages/design/tokens.css` and `packages/design/site/site.css`,
9
+ packaged as the `@digithings-ai/design` npm workspace. Consumers
10
+ import the package (or reference the files via relative paths for the
11
+ static sites).
12
+
13
+ **Strategy:** [`EVOLUTION.md`](EVOLUTION.md) is the historical ledger. The competitor
14
+ scans, design mine, landing demos, and the static spec/smoke pages were removed
15
+ so the next design reference can be built in digiweb (`apps/digiweb`) instead
16
+ of extended here. **Copy & IA:** [`COPY_GUIDE.md`](COPY_GUIDE.md).
17
+
18
+ See [ADR-0009 — Frontend umbrella](../../docs/adr/0009-frontend-umbrella.md)
19
+ for the layout rationale.
20
+
21
+ ## Consumers
22
+
23
+ - `apps/digithings-web/` — digithings.ai (Next.js; imports `tokens.css` + `site/site.css` directly)
24
+ - `apps/digiquant-web/` — digiquant.io (Next.js; imports `tokens.css` + `site/site.css` directly)
25
+ - `apps/digichat/` — digithings.ai/chat (Next.js; workspace dep, token adoption tracked by #240)
26
+ - `apps/dashboard/` — token adopter: imports `tokens.css`, the `@digithings-ai/ui` `web-theme.css` bridge and ~15 kit sheets
27
+
28
+ ---
29
+
30
+ ## Tokens
31
+
32
+ All tokens are declared as CSS custom properties on `:root` in
33
+ `packages/design/tokens.css`.
34
+
35
+ ### Base palette (dark)
36
+
37
+ | Token | Value | Role |
38
+ | -------------------- | ----------- | ------------------------------------- |
39
+ | `--bg-primary` | `#121212` | Page background |
40
+ | `--bg-secondary` | `#0a0a0a` | Panels, cards, nav |
41
+ | `--text-primary` | `#e6e6e6` | Default body text |
42
+ | `--text-secondary` | `#a3a3a3` | Muted text, captions |
43
+ | `--border-color` | `#2a2a2a` | Dividers, card borders |
44
+ | `--fg` | `#ffffff` | Emphasis / highlight foreground |
45
+
46
+ These match the dark-mode values in `digichat/src/app/globals.css`, so
47
+ digichat and the marketing site render at identical contrast.
48
+
49
+ ### Typography
50
+
51
+ **Canonical fonts (all surfaces), utilitarian v0.1 — see [`BLEND.md`](BLEND.md):**
52
+
53
+ | Role | Font | Weight | Token(s) |
54
+ | ------------------------ | -------------------------------- | ------- | ------------------------------- |
55
+ | Display / claims | Geist Mono | 400–500 | `--font-display` |
56
+ | Body | Geist Mono | 400 | `--font-sans` |
57
+ | Chrome / labels | Geist Mono, uppercase, tracked | 400 | `--font-mono` |
58
+ | Data / metrics | Geist Mono, tabular nums | 400–500 | `--font-mono` + `qn-metric` |
59
+ | Code | Geist Mono | 400 | `--font-mono` |
60
+
61
+ `--font-sans`, `--font-mono`, `--font-display` are declared in the
62
+ `[data-theme]` redesign layer of `tokens.css` and all default to the Geist
63
+ Mono stack. **Rule:** one mono voice; hierarchy is size and tracking, not a
64
+ second face. Serif (`serif-legacy` / Instrument Serif or Fraunces) is an
65
+ escape hatch for rare editorial moments — quotes, legal names — never the
66
+ default marketing H1. Product apps that still override with Fraunces are
67
+ Phase 2/3 debt ([`ROLLOUT.md`](ROLLOUT.md)).
68
+
69
+ **Deprecated:** `--font-family` and `--font-family-mono` are the legacy
70
+ `:root` tokens. Unthemed pages still resolve `--font-family` to Inter
71
+ (a sans escape hatch). Under `[data-theme]` they follow `--font-sans` /
72
+ `--font-mono` (Geist Mono). New components should reference
73
+ `--font-sans` / `--font-mono` / `--font-display`.
74
+
75
+ | Token (legacy, deprecated) | Unthemed `:root` | Themed `[data-theme]` |
76
+ | --------------------------- | ------------------------------------- | ------------------------- |
77
+ | `--font-family` | `var(--font-stack-sans)` | `var(--font-sans)` |
78
+ | `--font-family-mono` | `var(--font-stack-mono)` | `var(--font-mono)` |
79
+
80
+ ### Fonts per surface
81
+
82
+ `tokens.css` builds the **stacks**, each app supplies the **faces**. One file
83
+ per app calls `next/font`; nothing else in a surface may load a face or name a
84
+ font family.
85
+
86
+ ```
87
+ apps/<app>/app/fonts.ts → --font-sans-face, --font-mono-face (next/font)
88
+ tokens.css → --font-stack-sans / -mono / -display (face + fallbacks)
89
+ --font-sans / --font-mono / --font-display (read by CSS)
90
+ ```
91
+
92
+ `--font-stack-mono` is `var(--font-mono-face, "Geist Mono"), ui-monospace,
93
+ "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", "Segoe UI Symbol",
94
+ monospace`. The fallback chain is required, not decoration: Geist Mono has no
95
+ U+25B8 / U+25BE (nav triangles) and no U+2318 (the command key), so those
96
+ glyphs come from a later family in the row.
97
+
98
+ **Swapping a surface's mono is one line.** Change the `mono` loader in that
99
+ surface's config:
100
+
101
+ | Surface | Config | Line to edit |
102
+ | --- | --- | --- |
103
+ | dashboard (`apps/dashboard`) | `app/fonts.ts:17` | the only `Geist_Mono({…})` call (mono only; this surface has no sans loader) |
104
+ | digiquant.io (`apps/digiquant-web`) | `app/fonts.ts:24` | the `Geist_Mono({… variable: "--font-mono-face" …})` call |
105
+ | digithings.ai (`apps/digithings-web`) | no `fonts.ts` yet — faces load inline in the layout (pending port, same as DigiChat below) | — |
106
+ | DigiChat (`apps/digichat`), both `(digichat)` and `(baseline)` layouts | no `fonts.ts` yet — faces load inline in the layouts (pending port) | — |
107
+ | tearsheet (client-facing HTML) | `digiquant/src/digiquant/fonts.py:21` | `MONO_FONT_FAMILY` — Python, not `next/font`: `MONO_FONT_STACK` and `GOOGLE_FONTS_HREF` follow it, and the page CSS, both Plotly layouts and `charts/trades.py` read the stack |
108
+
109
+ To try another face, change one loader (and its import) in one `fonts.ts`,
110
+ screenshot it, then revert:
111
+
112
+ ```diff
113
+ -import { Geist_Mono } from "next/font/google";
114
+ +import { <Other_Mono_Face> } from "next/font/google";
115
+ -export const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono-face", display: "swap" });
116
+ +export const mono = <Other_Mono_Face>({ subsets: ["latin"], variable: "--font-mono-face", display: "swap" });
117
+ ```
118
+
119
+ Nothing else changes: the variable name is generic, so every consumer of
120
+ `--font-mono` follows. No surface *config* loads Inter anymore (DIG-2695);
121
+ the pending-port surfaces (`apps/digichat`, `apps/digithings-web`) still load
122
+ their faces inline in their layouts until their port is redone.
123
+
124
+ Out of scope for this system: `apps/digichat/reference/**` is a type-specimen
125
+ gallery that deliberately names alternatives. `apps/digiweb` is the live
126
+ reference and reads these tokens rather than restating them.
127
+
128
+ | Token | Value |
129
+ | -------------------------- | ------------------------------------- |
130
+ | `--font-size-h1` | `clamp(3rem, 6vw, 4.5rem)` |
131
+ | `--font-size-h2` | `2.5rem` |
132
+ | `--font-size-h3` | `1.5rem` |
133
+ | `--font-size-section-title`| `3.5rem` |
134
+ | `--font-size-body` | `1.25rem` |
135
+ | `--font-size-small` | `0.95rem` |
136
+ | `--font-size-xs` | `0.85rem` |
137
+
138
+ ### Spacing
139
+
140
+ Tokens `--space-1` … `--space-9` (0.25rem, 0.5rem, 1rem, 1.5rem, 2rem,
141
+ 3rem, 4rem, 6rem, 8rem). `--spacing-base` (1.5rem) remains as the
142
+ container gutter.
143
+
144
+ **Redesign layer (`[data-theme]`, EVOLUTION.md §5):** `--wrap: 1180px`
145
+ (default container), `--wrap-wide: 1280px` (marketing/bento sections),
146
+ `--product-frame-w: 800px` (Graphite/Cursor-style CQ-scaled UI embed),
147
+ `--section-y: clamp(4rem, 8vw, 7rem)` / `--section-y-tight:
148
+ clamp(2.5rem, 5vw, 4rem)` (section vertical rhythm).
149
+
150
+ ### Radius
151
+
152
+ `--radius-sm`, `--radius-md`, and `--radius-lg` are `0` (`tokens.css`). True circles
153
+ may still use `999px` locally. That is geometry, not chrome.
154
+
155
+ ### Motion
156
+
157
+ `--transition-speed: 0.8s`, `--transition-ease: cubic-bezier(0.2, 0.8, 0.2, 1)`.
158
+
159
+ **Redesign layer (`[data-theme]`, EVOLUTION.md §5):** `--ease-glide:
160
+ cubic-bezier(0.22, 1, 0.36, 1)` (same curve as `--ease`, named
161
+ separately for reveal-on-scroll consumers per the primitives in Phase
162
+ B), `--duration-reveal: 0.6s`, `--duration-hover: 0.18s`.
163
+
164
+ ---
165
+
166
+ ## Accent palette
167
+
168
+ Each module gets one accent color. Surfaces scope `--accent` to a module's
169
+ value; component rules always reference `var(--accent)`.
170
+
171
+ | Module | Token | Swatch | Hex |
172
+ | ----------- | ----------------------- | --------------------------------------------------------------------------------------------- | --------- |
173
+ | digigraph | `--accent-digigraph` | ![](https://readme-swatches.vercel.app/e5b765?style=round) | `#e5b765` |
174
+ | digiquant | `--accent-digiquant` | ![](https://readme-swatches.vercel.app/3dd6c4?style=round) | `#3dd6c4` |
175
+ | Research | `--accent-research` | ![](https://readme-swatches.vercel.app/6fbf94?style=round) | `#6fbf94` |
176
+ | Portfolio | `--accent-portfolio` | ![](https://readme-swatches.vercel.app/4a8f7b?style=round) | `#4a8f7b` |
177
+ | Execution | `--accent-execution` | ![](https://readme-swatches.vercel.app/2f7a65?style=round) | `#2f7a65` |
178
+ | digisearch | `--accent-digisearch` | ![](https://readme-swatches.vercel.app/5aa3c4?style=round) | `#5aa3c4` |
179
+ | digichat | `--accent-digichat` | ![](https://readme-swatches.vercel.app/e2708a?style=round) | `#e2708a` |
180
+ | digikey | `--accent-digikey` | ![](https://readme-swatches.vercel.app/d97a5a?style=round) | `#d97a5a` |
181
+ | digitrace | `--accent-digitrace` | ![](https://readme-swatches.vercel.app/6fa3a3?style=round) | `#6fa3a3` |
182
+ | digibot | `--accent-digibot` | ![](https://readme-swatches.vercel.app/b87840?style=round) | `#b87840` |
183
+ | digibase | `--accent-digibase` | ![](https://readme-swatches.vercel.app/9ea0a5?style=round) | `#9ea0a5` |
184
+ | digistore | `--accent-digistore` | ![](https://readme-swatches.vercel.app/7b7fc7?style=round) | `#7b7fc7` |
185
+ | digilink | `--accent-digilink` | ![](https://readme-swatches.vercel.app/4fa39b?style=round) | `#4fa39b` |
186
+ | digivault | `--accent-digivault` | ![](https://readme-swatches.vercel.app/9d8fc9?style=round) | `#9d8fc9` |
187
+
188
+ ### Scoped override pattern
189
+
190
+ ```html
191
+ <section class="accent-digiquant">
192
+ <!-- any `.module-card`, `.flow-node.highlight`, etc. inside resolves
193
+ var(--accent) to #3dd6c4 -->
194
+ </section>
195
+ ```
196
+
197
+ Or directly in CSS:
198
+
199
+ ```css
200
+ .my-section { --accent: var(--accent-digisearch); }
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Component primitives
206
+
207
+ The legacy `components.css` sheet (global reset plus `.nav`, `.hero`,
208
+ `.module-card`, `.terminal`, and the `--scroll` direction rules) had no
209
+ importer. It was removed. Live primitives are `site/site.css`, consumed by
210
+ digithings.ai and digiquant.io, and the React kit in `@digithings-ai/ui`.
211
+
212
+ The vanilla `starfield.js` and `typewriter.js` modules had no importer either
213
+ (`apps/dashboard` only declared the starfield module). They were removed.
214
+ `scroll-trigger.js` left in #5692. Do not revive them; the next design
215
+ reference is `apps/digiweb`.
216
+
217
+ ---
218
+ ## Non-goals
219
+
220
+ - Light and dark both ship under `[data-theme]` in `tokens.css`. The legacy
221
+ `:root` block above that layer stays dark-first for pages that have not migrated.
222
+ - No component JS framework here. React components live in `@digithings-ai/ui`;
223
+ this package keeps tokens and the few vanilla-JS modules documented below.
224
+ - Confidential internal projects are out of scope. This design system
225
+ documents only public-facing surfaces.
226
+
227
+ ---
228
+
229
+ ## Extended primitives
230
+
231
+ Two primitives that live surfaces still import sit alongside the tokens.
232
+ Each ships a CSS file and an ESM JS module. The static smoke page
233
+ and the HTML spec were removed with the old design reference. Live specimens
234
+ belong in `apps/digiweb`.
235
+
236
+ ### `quant-native/` — blueprint grid, ticker, metric + chart utilities
237
+
238
+ ```js
239
+ import { initTicker } from '@digithings-ai/design/quant-native';
240
+ import '@digithings-ai/design/quant-native/styles.css';
241
+
242
+ initTicker({
243
+ elementId: 'ticker',
244
+ symbols: [
245
+ { sym: 'RESEARCH', price: '184.22', delta: '+0.42%' },
246
+ { sym: 'EXECUTION', price: '342.07', delta: '-0.18%' },
247
+ ],
248
+ cadence: 60, // px/sec
249
+ });
250
+ ```
251
+
252
+ | Class / API | Purpose |
253
+ | --------------------- | ---------------------------------------------------------- |
254
+ | `.qn-blueprint-bg` | Horizontal-rule pattern at ~2% opacity; apply to any block. |
255
+ | `.qn-ticker` | Ribbon container (JS-driven `translateX`, pause on hover). |
256
+ | `.qn-metric` | `font-feature-settings: "tnum"`, mono, right-aligned. |
257
+ | `.qn-chart-frame` | Hairline border + `--bg-primary` fill; wraps an SVG chart. |
258
+ | `.qn-up` / `.qn-down` | Muted emerald (digiquant accent) / muted copper (digibot accent). |
259
+
260
+ Directional color is intentionally muted — never Bloomberg-bright red/green.
261
+
262
+ **Reduced motion**: ticker track is pinned at `translateX(0)` with left
263
+ padding; up/down colors unchanged.
264
+
265
+ ### `app-shell-terminal/` — Claude-Code-style app chrome
266
+
267
+ ```js
268
+ import { initAppShell, SlashCommandRegistry }
269
+ from '@digithings-ai/design/app-shell-terminal';
270
+ import '@digithings-ai/design/app-shell-terminal/styles.css';
271
+
272
+ const registry = new SlashCommandRegistry();
273
+ registry.register('route', {
274
+ description: 'Route a prompt to a specialist',
275
+ handler: (args) => console.log('route:', args.join(' ')),
276
+ });
277
+
278
+ const shell = initAppShell({
279
+ hostId: 'app',
280
+ title: 'digichat',
281
+ sidebarSlot: '<div>history · settings · /commands</div>',
282
+ mainSlot: '<div id="chat"></div>',
283
+ slashCommands: registry,
284
+ onSubmit: (text) => { /* non-slash input */ },
285
+ });
286
+ ```
287
+
288
+ | Feature | Behavior |
289
+ | -------------------------- | --------------------------------------------------------------- |
290
+ | Sidebar | `aria-expanded` collapsible; `Cmd+/` toggles. |
291
+ | Top bar | Mono title + metadata strip. |
292
+ | Input bar | Terminal-style `>` marker; auto-grows to 200px; `Enter` submits, `Shift+Enter` newline. |
293
+ | Slash commands | `registry.register/parse/dispatch`. Built-ins: `/help`, `/clear`. |
294
+ | `Cmd+K` | Opens a `role="dialog"` palette with focus-trap + filter. |
295
+ | Slash-command reference | Use `<span class="shell-cmd-ref">/name</span>` inside the sidebar. |
296
+
297
+ **Reduced motion**: no sidebar / palette transitions; everything resolves
298
+ instantly.
299
+
300
+ ## Documents
301
+
302
+ Canon-styled, print-ready document templates (HTML → PDF, self-contained, no
303
+ build step).
304
+
305
+ ### `invoice/` — invoice template
306
+
307
+ A utilitarian **monochrome** single-page invoice — Geist Mono for claim, body,
308
+ and chrome, hairline rules, zero radius, no glass. Line items read like an
309
+ AI-services invoice (deliverable + compact scope), with an optional second-page
310
+ delivery breakdown. Fill the `[bracketed]` placeholders, then
311
+ `chromium --headless --print-to-pdf=invoice.pdf index.html`. See
312
+ `invoice/README.md`.
313
+
314
+ ### `quote/` — quote / statement-of-work template
315
+
316
+ The forward-looking companion to `invoice/`, same utilitarian mono chrome. One page:
317
+ priced scope rows (optional items carry an "optional" chip and total
318
+ separately), headline figure, timeline, payment terms, and a running-costs /
319
+ assumptions block for third-party services the client pays directly. See
320
+ `quote/README.md`.
321
+
322
+ ## Publishing
323
+
324
+ This package is published to npm as part of the digithings R0 release. Its
325
+ `exports` point at the CSS, vanilla-JS and TypeScript files in this package —
326
+ there is no build output, so the published tarball is the same code the
327
+ workspace consumes.
328
+
329
+ Consumers import by subpath:
330
+
331
+ ```ts
332
+ import "@digithings-ai/design/tokens.css";
333
+ import { designTokens } from "@digithings-ai/design/tokens";
334
+ ```
335
+
336
+ A CSS or plain-JS import needs no bundler configuration. A Next.js app that
337
+ imports the TypeScript entry (`@digithings-ai/design/tokens`) must list the package
338
+ in `transpilePackages`:
339
+
340
+ ```js
341
+ const nextConfig = {
342
+ transpilePackages: ["@digithings-ai/design"],
343
+ };
344
+ ```
@@ -0,0 +1,300 @@
1
+ /* ==========================================================================
2
+ @digithings-ai/design — app-shell-terminal
3
+ --------------------------------------------------------------------------
4
+ Claude-Code-style app chrome: collapsible sidebar, main slot, top bar,
5
+ terminal-like input bar, Cmd+K palette, slash command registry.
6
+
7
+ Usage:
8
+ import { initAppShell } from '@digithings-ai/design/app-shell-terminal';
9
+ import '@digithings-ai/design/app-shell-terminal/styles.css';
10
+
11
+ const shell = initAppShell({
12
+ hostId: 'app',
13
+ title: 'digichat',
14
+ sidebarSlot: '<div>history / settings</div>',
15
+ mainSlot: '<div>main content</div>',
16
+ slashCommands: registry, // optional SlashCommandRegistry instance
17
+ onSubmit: (text) => { ... },
18
+ });
19
+ Integrator slots (sidebarSlot, mainSlot):
20
+ - Prefer static HTML from your bundler, or build with DOM APIs.
21
+ - For dynamic text, escape with escapeHtml() before concatenating into a slot string.
22
+ - Never pass unsanitized user/network input into mountTrustedHtml.
23
+
24
+ export { escapeHtml, mountTrustedHtml } from '@digithings-ai/design/html-escape.js';
25
+ ========================================================================== */
26
+
27
+ import { SlashCommandRegistry } from './slash-commands.js';
28
+ import { escapeHtml, mountTrustedHtml } from '../html-escape.js';
29
+
30
+ export { escapeHtml, mountTrustedHtml };
31
+
32
+ const FOCUSABLE = 'a,button,input,textarea,select,[tabindex]:not([tabindex="-1"])';
33
+
34
+ function createEl(tag, className) {
35
+ const e = document.createElement(tag);
36
+ if (className) e.className = className;
37
+ return e;
38
+ }
39
+
40
+ /**
41
+ * @param {object} opts
42
+ * @param {string} [opts.sidebarSlot] Static or escapeHtml()-safe HTML for mountTrustedHtml.
43
+ * @param {string} [opts.mainSlot] Static or escapeHtml()-safe HTML for mountTrustedHtml.
44
+ */
45
+ export function initAppShell({
46
+ hostId,
47
+ title = 'digithings',
48
+ sidebarSlot = '',
49
+ mainSlot = '',
50
+ slashCommands,
51
+ onSubmit,
52
+ } = {}) {
53
+ const host = hostId ? document.getElementById(hostId) : null;
54
+ if (!host) throw new Error(`initAppShell: no host element for "${hostId}"`);
55
+
56
+ const prefersReduced = typeof window !== 'undefined'
57
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
58
+
59
+ const registry = slashCommands instanceof SlashCommandRegistry
60
+ ? slashCommands
61
+ : new SlashCommandRegistry();
62
+
63
+ // Built-ins — idempotent; won't overwrite if already registered.
64
+ if (!registry.list().some((c) => c.name === 'help')) {
65
+ registry.register('help', {
66
+ description: 'List available slash commands.',
67
+ handler: () => registry.list(),
68
+ });
69
+ }
70
+ if (!registry.list().some((c) => c.name === 'clear')) {
71
+ registry.register('clear', {
72
+ description: 'Clear the input.',
73
+ handler: () => { input.value = ''; },
74
+ });
75
+ }
76
+
77
+ host.classList.add('app-shell');
78
+ if (prefersReduced) host.classList.add('app-shell-reduced-motion');
79
+ host.replaceChildren();
80
+
81
+ // ----- Sidebar ---------------------------------------------------------
82
+ const sidebar = createEl('aside', 'app-sidebar');
83
+ sidebar.setAttribute('aria-label', 'App sidebar');
84
+ sidebar.setAttribute('aria-expanded', 'true');
85
+ const sidebarBody = createEl('div', 'app-sidebar-body');
86
+ mountTrustedHtml(sidebarBody, sidebarSlot);
87
+ sidebar.appendChild(sidebarBody);
88
+
89
+ // ----- Main column -----------------------------------------------------
90
+ const mainCol = createEl('div', 'app-shell-main-col');
91
+
92
+ // Top bar
93
+ const topbar = createEl('header', 'app-topbar');
94
+ const titleEl = createEl('span', 'app-topbar-title');
95
+ titleEl.textContent = title;
96
+ const meta = createEl('span', 'app-topbar-meta');
97
+ meta.textContent = 'ready';
98
+ topbar.appendChild(titleEl);
99
+ topbar.appendChild(meta);
100
+
101
+ // Main slot
102
+ const main = createEl('main', 'app-main');
103
+ mountTrustedHtml(main, mainSlot);
104
+
105
+ // Input bar
106
+ const inputBar = createEl('form', 'app-input');
107
+ inputBar.setAttribute('role', 'search');
108
+ const marker = createEl('span', 'app-input-marker');
109
+ marker.textContent = '>';
110
+ const input = document.createElement('textarea');
111
+ input.className = 'app-input-field';
112
+ input.rows = 1;
113
+ input.setAttribute('aria-label', 'Command input');
114
+ input.placeholder = 'Type a message, or / for commands';
115
+ const hint = createEl('span', 'slash-hint');
116
+ const hintKbd = document.createElement('kbd');
117
+ hintKbd.textContent = '⌘K';
118
+ hint.appendChild(hintKbd);
119
+ inputBar.appendChild(marker);
120
+ inputBar.appendChild(input);
121
+ inputBar.appendChild(hint);
122
+
123
+ input.addEventListener('input', () => {
124
+ // Auto-grow a few rows.
125
+ input.style.height = 'auto';
126
+ input.style.height = `${Math.min(200, input.scrollHeight)}px`;
127
+ });
128
+
129
+ inputBar.addEventListener('submit', (e) => {
130
+ e.preventDefault();
131
+ const value = input.value.trim();
132
+ if (!value) return;
133
+ if (registry.parse(value)) {
134
+ registry.dispatch(value);
135
+ } else if (typeof onSubmit === 'function') {
136
+ onSubmit(value);
137
+ }
138
+ input.value = '';
139
+ input.style.height = 'auto';
140
+ });
141
+
142
+ input.addEventListener('keydown', (e) => {
143
+ if (e.key === 'Enter' && !e.shiftKey) {
144
+ e.preventDefault();
145
+ inputBar.requestSubmit();
146
+ }
147
+ });
148
+
149
+ mainCol.appendChild(topbar);
150
+ mainCol.appendChild(main);
151
+ mainCol.appendChild(inputBar);
152
+
153
+ host.appendChild(sidebar);
154
+ host.appendChild(mainCol);
155
+
156
+ // ----- Sidebar collapse ------------------------------------------------
157
+ let collapsed = false;
158
+ function setCollapsed(v) {
159
+ collapsed = !!v;
160
+ host.classList.toggle('app-shell-sidebar-collapsed', collapsed);
161
+ sidebar.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
162
+ }
163
+
164
+ // ----- Command palette (Cmd+K) -----------------------------------------
165
+ const palette = createEl('div', 'app-shell-palette');
166
+ palette.setAttribute('role', 'dialog');
167
+ palette.setAttribute('aria-modal', 'true');
168
+ palette.setAttribute('aria-label', 'Command palette');
169
+ palette.hidden = true;
170
+ const paletteInner = createEl('div', 'app-shell-palette-inner');
171
+ const paletteInput = document.createElement('input');
172
+ paletteInput.type = 'text';
173
+ paletteInput.className = 'app-shell-palette-input';
174
+ paletteInput.placeholder = 'Type a command…';
175
+ paletteInput.setAttribute('aria-label', 'Command palette search');
176
+ const paletteList = createEl('ul', 'app-shell-palette-list');
177
+ paletteInner.appendChild(paletteInput);
178
+ paletteInner.appendChild(paletteList);
179
+ palette.appendChild(paletteInner);
180
+ host.appendChild(palette);
181
+
182
+ let lastFocused = null;
183
+
184
+ function renderPalette(filter = '') {
185
+ const q = filter.trim().toLowerCase();
186
+ const items = registry.list().filter((c) =>
187
+ !q || c.name.toLowerCase().includes(q) || (c.description || '').toLowerCase().includes(q)
188
+ );
189
+ paletteList.replaceChildren();
190
+ items.forEach((c, i) => {
191
+ const li = createEl('li', 'app-shell-palette-item');
192
+ li.tabIndex = 0;
193
+ li.dataset.name = c.name;
194
+ const nameSpan = createEl('span', 'shell-cmd-ref');
195
+ nameSpan.textContent = `/${c.name}`;
196
+ const descSpan = createEl('span', 'app-shell-palette-desc');
197
+ descSpan.textContent = c.description || '';
198
+ li.appendChild(nameSpan);
199
+ li.appendChild(descSpan);
200
+ if (i === 0) li.classList.add('is-active');
201
+ li.addEventListener('click', () => {
202
+ closePalette();
203
+ registry.dispatch(`/${c.name}`);
204
+ });
205
+ li.addEventListener('keydown', (e) => {
206
+ if (e.key === 'Enter') {
207
+ closePalette();
208
+ registry.dispatch(`/${c.name}`);
209
+ }
210
+ });
211
+ paletteList.appendChild(li);
212
+ });
213
+ }
214
+
215
+ function openPalette() {
216
+ lastFocused = document.activeElement;
217
+ palette.hidden = false;
218
+ host.classList.add('app-shell-palette-open');
219
+ paletteInput.value = '';
220
+ renderPalette('');
221
+ paletteInput.focus();
222
+ }
223
+ function closePalette() {
224
+ palette.hidden = true;
225
+ host.classList.remove('app-shell-palette-open');
226
+ if (lastFocused && typeof lastFocused.focus === 'function') lastFocused.focus();
227
+ }
228
+
229
+ paletteInput.addEventListener('input', () => renderPalette(paletteInput.value));
230
+ paletteInput.addEventListener('keydown', (e) => {
231
+ if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; }
232
+ if (e.key === 'Enter') {
233
+ e.preventDefault();
234
+ const first = paletteList.querySelector('.app-shell-palette-item');
235
+ if (first) {
236
+ closePalette();
237
+ registry.dispatch(`/${first.dataset.name}`);
238
+ }
239
+ return;
240
+ }
241
+ // Simple focus trap: forward Tab into list.
242
+ if (e.key === 'Tab' && !e.shiftKey) {
243
+ const first = paletteList.querySelector('.app-shell-palette-item');
244
+ if (first) { e.preventDefault(); first.focus(); }
245
+ }
246
+ });
247
+ palette.addEventListener('keydown', (e) => {
248
+ if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
249
+ // Trap focus inside palette
250
+ if (e.key === 'Tab') {
251
+ const focusables = palette.querySelectorAll(FOCUSABLE);
252
+ if (focusables.length === 0) return;
253
+ const first = focusables[0];
254
+ const last = focusables[focusables.length - 1];
255
+ if (e.shiftKey && document.activeElement === first) {
256
+ e.preventDefault(); last.focus();
257
+ } else if (!e.shiftKey && document.activeElement === last) {
258
+ e.preventDefault(); first.focus();
259
+ }
260
+ }
261
+ });
262
+
263
+ // ----- Global shortcuts ------------------------------------------------
264
+ function onKey(e) {
265
+ const meta = e.metaKey || e.ctrlKey;
266
+ if (meta && e.key.toLowerCase() === 'k') {
267
+ e.preventDefault();
268
+ if (palette.hidden) openPalette(); else closePalette();
269
+ return;
270
+ }
271
+ if (meta && e.key === '/') {
272
+ e.preventDefault();
273
+ setCollapsed(!collapsed);
274
+ }
275
+ }
276
+ document.addEventListener('keydown', onKey);
277
+
278
+ return {
279
+ sidebar,
280
+ main,
281
+ input,
282
+ registry,
283
+ setCollapsed,
284
+ toggleSidebar: () => setCollapsed(!collapsed),
285
+ openPalette,
286
+ closePalette,
287
+ destroy() {
288
+ document.removeEventListener('keydown', onKey);
289
+ host.replaceChildren();
290
+ host.classList.remove(
291
+ 'app-shell',
292
+ 'app-shell-reduced-motion',
293
+ 'app-shell-sidebar-collapsed',
294
+ 'app-shell-palette-open',
295
+ );
296
+ },
297
+ };
298
+ }
299
+
300
+ export { SlashCommandRegistry };