@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 +343 -2
- package/app-shell-terminal/index.js +300 -0
- package/app-shell-terminal/slash-commands.js +59 -0
- package/app-shell-terminal/styles.css +234 -0
- package/fonts.ts +15 -0
- package/html-escape.js +19 -0
- package/motion.json +6 -0
- package/package.json +48 -4
- package/palettes.css +1942 -0
- package/quant-native/styles.css +99 -0
- package/quant-native/ticker.js +100 -0
- package/releases.json +122 -0
- package/site/README.md +609 -0
- package/site/site.css +475 -0
- package/tokens.css +268 -0
- package/tokens.ts +508 -0
package/README.md
CHANGED
|
@@ -1,3 +1,344 @@
|
|
|
1
|
-
#
|
|
1
|
+
# digithings Design System
|
|
2
2
|
|
|
3
|
-
|
|
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` |  | `#e5b765` |
|
|
174
|
+
| digiquant | `--accent-digiquant` |  | `#3dd6c4` |
|
|
175
|
+
| Research | `--accent-research` |  | `#6fbf94` |
|
|
176
|
+
| Portfolio | `--accent-portfolio` |  | `#4a8f7b` |
|
|
177
|
+
| Execution | `--accent-execution` |  | `#2f7a65` |
|
|
178
|
+
| digisearch | `--accent-digisearch` |  | `#5aa3c4` |
|
|
179
|
+
| digichat | `--accent-digichat` |  | `#e2708a` |
|
|
180
|
+
| digikey | `--accent-digikey` |  | `#d97a5a` |
|
|
181
|
+
| digitrace | `--accent-digitrace` |  | `#6fa3a3` |
|
|
182
|
+
| digibot | `--accent-digibot` |  | `#b87840` |
|
|
183
|
+
| digibase | `--accent-digibase` |  | `#9ea0a5` |
|
|
184
|
+
| digistore | `--accent-digistore` |  | `#7b7fc7` |
|
|
185
|
+
| digilink | `--accent-digilink` |  | `#4fa39b` |
|
|
186
|
+
| digivault | `--accent-digivault` |  | `#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 };
|