anentrypoint-design 1.0.2 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -6,6 +6,7 @@
6
6
  // data and receives action callbacks.
7
7
  //
8
8
  // Adapter contract (all fields optional; the app degrades when one is absent):
9
+ // adapter.brandName // string shown in the topbar brand span; defaults to 'app'
9
10
  // adapter.get() -> snapshot {
10
11
  // channels, categories, servers, currentChannel, currentServerId, homeMode,
11
12
  // messages, // each message may carry reactions: [{emoji, count, users?, you?}]
@@ -60,6 +61,10 @@ export function mountCommunityApp(root, adapter = {}) {
60
61
  const get = typeof adapter.get === 'function' ? adapter.get : () => ({});
61
62
  const A = adapter.actions || {};
62
63
  const H = adapter.helpers || {};
64
+ // The topbar brand name was hardcoded 'zellous' -- a design-system
65
+ // component should not bake in one consumer's name. Any host can supply
66
+ // its own via adapter.brandName; 'app' is a neutral, non-branded fallback.
67
+ const brandName = adapter.brandName || 'app';
63
68
  const avatarColor = H.avatarColor || (() => 'var(--accent)');
64
69
  const initial = H.initial || ((n) => String(n || '?').slice(0, 1).toUpperCase());
65
70
  const formatTime = H.formatTime || ((t) => new Date(t || Date.now()).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }));
@@ -235,7 +240,7 @@ export function mountCommunityApp(root, adapter = {}) {
235
240
  h('a', { href: '#app-main', class: 'skip-link' }, 'skip to main content'),
236
241
  // top bar (sole app chrome above the chat-head)
237
242
  h('header', { class: 'app-topbar' },
238
- h('span', { class: 'brand' }, 'zellous', h('span', { class: 'slash' }, ' / '), h('span', {}, ch.name || 'general')),
243
+ h('span', { class: 'brand' }, brandName, h('span', { class: 'slash' }, ' / '), h('span', {}, ch.name || 'general')),
239
244
  h('span', {}),
240
245
  h('nav', {},
241
246
  h('a', { href: '../', title: 'Home', onclick: (e) => { if (A.goHome) { e.preventDefault(); A.goHome(); } } }, 'home'),
@@ -6,7 +6,7 @@ import * as webjsx from '../../../vendor/webjsx/index.js';
6
6
  import { Icon } from '../shell.js';
7
7
  const h = webjsx.createElement;
8
8
 
9
- // items: [n, label] or [n, label, {delta, tone: 'up'|'down', spark: number[], invert}]
9
+ // items: [n, label] or [n, label, {delta, tone: 'up'|'down', spark: number[], invert, glyph}]
10
10
  // meta is optional and additive — every existing 2-tuple call site is untouched.
11
11
  // `tone` always drives the arrow glyph (it mirrors the delta's own arithmetic
12
12
  // sign, so the arrow never contradicts the figure beside it). `invert` is a
@@ -15,12 +15,16 @@ const h = webjsx.createElement;
15
15
  // relative to tone while the arrow direction is left alone — a rising error
16
16
  // rate still shows an up-arrow (the number went up) but in the bad/danger
17
17
  // color, not the good/success color the raw arithmetic sign would imply.
18
+ // `meta.glyph` is an optional icon NAME (resolved via Icon()) giving each
19
+ // tile a small identifying glyph in its top-right corner — purely additive,
20
+ // omitted call sites render exactly as before (no icon slot in the DOM).
18
21
  export function Kpi({ items = [], emptyText = 'no metrics yet' }) {
19
22
  if (!items.length) return h('div', { class: 'empty' }, emptyText);
20
23
  return h('div', { class: 'kpi' }, ...items.map(([n, l, meta], i) => {
21
24
  const isUp = meta && meta.tone !== 'down';
22
25
  const good = meta && meta.invert ? !isUp : isUp;
23
26
  return h('div', { key: i, class: 'kpi-card' },
27
+ meta && meta.glyph ? h('div', { class: 'kpi-glyph' }, Icon(meta.glyph, { size: 16 })) : null,
24
28
  h('div', { class: 'num' }, String(n)),
25
29
  h('div', { class: 'lbl' }, l),
26
30
  meta && (meta.delta != null || meta.spark)
@@ -29,11 +29,11 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
29
29
  // component has no opinion on comparator/locale/type - it only renders the
30
30
  // control and current state). A docstudio-style dense admin table needs
31
31
  // sortable columns; Table previously had no way to express that at all.
32
- const thFor = (hd, i) => {
33
- if (!sortable || !onSort) return h('th', { key: i, scope: 'col' }, hd);
32
+ const thFor = (hd, i, isNum) => {
33
+ if (!sortable || !onSort) return h('th', { key: i, scope: 'col', class: isNum ? 'is-num' : null }, hd);
34
34
  const isActive = sortKey === i;
35
35
  const ariaSort = isActive ? (sortDir === 'desc' ? 'descending' : 'ascending') : 'none';
36
- return h('th', { key: i, scope: 'col', 'aria-sort': ariaSort },
36
+ return h('th', { key: i, scope: 'col', 'aria-sort': ariaSort, class: isNum ? 'is-num' : null },
37
37
  h('button', { type: 'button', class: 'ds-table-sort-btn' + (isActive ? ' is-active' : ''), onclick: () => onSort(i) },
38
38
  h('span', { class: 'ds-table-sort-label' }, hd),
39
39
  isActive ? Icon(sortDir === 'desc' ? 'chevron-down' : 'chevron-up', { size: 12 }) : null));
@@ -50,13 +50,23 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
50
50
  // NOTE this fires only when the table actually overflows, which is
51
51
  // viewport-dependent — it reproduces at 1024x768 but not at 1280x900,
52
52
  // which is why it surfaced only in CI's viewport.
53
+ // A column is numeric when every row's value in it is a plain
54
+ // integer/decimal (optionally signed) — checked across the whole column,
55
+ // not per-cell, so a mixed column (e.g. one row's count rendered as a
56
+ // Chip vnode) never right-aligns only some of its cells.
57
+ const NUM_RE = /^-?\d+(\.\d+)?$/;
58
+ const isNumericCol = (j) => rows.every((row) => {
59
+ const c = row[j];
60
+ return c != null && typeof c !== 'object' && NUM_RE.test(String(c).trim());
61
+ });
62
+ const numericCols = headers.map((_, j) => isNumericCol(j));
53
63
  return h('div', {
54
64
  class: wrapClass,
55
65
  tabindex: '0',
56
66
  role: 'group',
57
67
  'aria-label': 'table, scrollable',
58
68
  }, h('table', {},
59
- h('thead', {}, h('tr', {}, ...headers.map((hd, i) => thFor(hd, i)))),
69
+ h('thead', {}, h('tr', {}, ...headers.map((hd, i) => thFor(hd, i, numericCols[i])))),
60
70
  h('tbody', {}, ...rows.map((row, i) => h('tr', {
61
71
  key: i,
62
72
  class: onRowClick ? 'clickable' : '',
@@ -64,7 +74,7 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
64
74
  // Space scrolls by default — preventDefault on Space (and Enter) so
65
75
  // keyboard activation matches click without page jump.
66
76
  ...(onRowClick ? { tabindex: '0', role: 'button', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onRowClick(i); } } } : {})
67
- }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c)))))))));
77
+ }, ...row.map((c, j) => h('td', { key: j, class: numericCols[j] ? 'is-num' : null }, c == null ? '' : (typeof c === 'object' ? c : String(c)))))))));
68
78
  }
69
79
 
70
80
  // HealthTable — generic health-check table: given an arbitrary
@@ -69,7 +69,7 @@ export const logs = makePage((ctx) => {
69
69
  return [
70
70
  PageHeader({
71
71
  title: 'logs', lede: 'live JSONL log tail — /api/logs/stream',
72
- right: s.connected ? Chip({ tone: 'ok', children: 'live' }) : Chip({ tone: 'miss', children: 'reconnecting…' }),
72
+ right: s.connected ? Chip({ tone: 'live', children: 'live' }) : Chip({ tone: 'miss', children: 'reconnecting…' }),
73
73
  }),
74
74
  s.wsError ? refreshError(s.wsError) : null,
75
75
  h('div', { class: 'ds-toolbar' },
@@ -154,8 +154,12 @@ export function Lede({ children }) {
154
154
 
155
155
  export function Dot({ tone = 'on' }) {
156
156
  const isOn = tone === 'on' || tone === 'live';
157
- const cls = 'ds-dot ' + (isOn ? 'ds-dot-on' : 'ds-dot-off');
158
- const statusLabel = isOn ? 'on status indicator' : 'off status indicator';
157
+ // 'live' gets its own visual modifier (ds-dot-live, sky hue) layered on
158
+ // top of ds-dot-on so a live-broadcast indicator is never visually
159
+ // identical to a plain "this thing is on" status dot — same split
160
+ // rationale as .chip.tone-live / .ds-badge.tone-live.
161
+ const cls = 'ds-dot ' + (isOn ? 'ds-dot-on' : 'ds-dot-off') + (tone === 'live' ? ' ds-dot-live' : '');
162
+ const statusLabel = tone === 'live' ? 'live status indicator' : (isOn ? 'on status indicator' : 'off status indicator');
159
163
  // Drawn as a CSS circle (.ds-dot) — no decorative text glyph.
160
164
  return h('span', { class: cls, role: 'img', 'aria-label': statusLabel });
161
165
  }
@@ -348,6 +348,12 @@ table tr.clickable:focus-visible {
348
348
  outline-offset: -2px;
349
349
  }
350
350
  table tr.clickable:focus-visible td { background: var(--bg-2); }
351
+ /* Non-clickable (static) rows still get a subtle hover cue — most dashboard
352
+ tables (freddie's sessions-by-platform/model, tool distribution, commands)
353
+ never pass onRowClick, so they had zero row-hover feedback at all. A dim
354
+ hover on ANY row (not just .clickable) reads as "you're scanning this
355
+ row" without implying it's actionable. */
356
+ table tr:not(.clickable):hover td { background: color-mix(in oklab, var(--bg-2) 60%, transparent); }
351
357
 
352
358
  /* Table() striped/compact opt-in density modifiers (webgeist g-table parity).
353
359
  Scoped under .ds-table-wrap so they never leak onto an unrelated bare
@@ -355,8 +361,15 @@ table tr.clickable:focus-visible td { background: var(--bg-2); }
355
361
  .ds-table-wrap.is-striped table tr:nth-child(even) td { background: var(--bg-2); }
356
362
  .ds-table-wrap.is-striped table tr.clickable:nth-child(even):hover td,
357
363
  .ds-table-wrap.is-striped table tr.clickable:nth-child(even):focus-visible td { background: var(--bg-3, var(--bg-2)); }
364
+ .ds-table-wrap.is-striped table tr:not(.clickable):nth-child(even):hover td { background: color-mix(in oklab, var(--bg-3, var(--bg-2)) 60%, transparent); }
358
365
  .ds-table-wrap.is-compact table th { padding: var(--space-1-5, 5px) var(--space-2, 8px); }
359
366
  .ds-table-wrap.is-compact table td { padding: var(--space-1-5, 5px) var(--space-2, 8px); }
367
+ /* Numeric-column right-alignment: Table() (content/table.js) tags a cell
368
+ td.is-num when its raw content is a plain integer/decimal, so digit
369
+ columns (session counts, tool counts, etc.) line up on the ones place
370
+ instead of ragged-left like prose columns. th.is-num mirrors it on the
371
+ matching header so the label sits over its column. */
372
+ table td.is-num, table th.is-num { text-align: right; font-variant-numeric: tabular-nums; }
360
373
 
361
374
  /* ============================================================
362
375
  Changelog
@@ -419,6 +432,9 @@ table tr.clickable:focus-visible td { background: var(--bg-2); }
419
432
  .ds-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; vertical-align: middle; }
420
433
  .ds-dot-on { color: var(--green-2); }
421
434
  .ds-dot-off { color: var(--fg-3); }
435
+ /* live is a distinct hue from a plain "on" dot — see .chip.tone-live's
436
+ comment for the success-vs-live rationale. */
437
+ .ds-dot-live { color: var(--sky); }
422
438
  .ds-dot-live { color: var(--green-2); }
423
439
  .ds-dot-idle { color: var(--fg-3); }
424
440
 
@@ -436,6 +452,26 @@ table tr.clickable:focus-visible td { background: var(--bg-2); }
436
452
  padding: var(--space-3);
437
453
  background: var(--bg-2);
438
454
  border-radius: var(--r-1);
455
+ /* A thin border + hover elevation gives the tile real edges and a "this is
456
+ a distinct object" affordance instead of a flat color patch that blends
457
+ into the surrounding panel — matches the "no border, no hover state"
458
+ gap named against the stat tiles. */
459
+ border: 1px solid var(--rule);
460
+ position: relative;
461
+ transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
462
+ }
463
+ .kpi-card:hover {
464
+ border-color: color-mix(in oklab, var(--accent) 40%, var(--rule));
465
+ transform: translateY(-1px);
466
+ box-shadow: var(--shadow-1, 0 2px 8px rgba(0,0,0,.12));
467
+ }
468
+ .kpi-glyph {
469
+ position: absolute;
470
+ top: var(--space-2);
471
+ right: var(--space-2);
472
+ color: var(--fg-3);
473
+ opacity: 0.6;
474
+ display: inline-flex;
439
475
  }
440
476
  .kpi-card .num {
441
477
  font-family: var(--ff-body); font-weight: 600;
@@ -96,9 +96,19 @@
96
96
  workspace rail item-count badge (atoms.js Badge size="sm"). */
97
97
  .ds-badge.ds-badge--sm { min-width: var(--icon-xs); height: var(--icon-xs); padding: 0 var(--space-1); font-size: var(--fs-nano); }
98
98
  .ds-badge.ds-badge--lg { min-width: 22px; height: 22px; padding: 0 var(--space-2); font-size: var(--fs-tiny); }
99
- .ds-badge.tone-green, .ds-badge.tone-live, .ds-badge.tone-success {
99
+ .ds-badge.tone-green, .ds-badge.tone-success {
100
100
  background: var(--green-tint); color: var(--green-deep);
101
101
  }
102
+ /* tone-live is its own hue (sky), split out of the success/green group:
103
+ "live/broadcasting now" and "operation succeeded" are different facts and
104
+ read identically when both borrow the brand's accent green — a LIVE badge
105
+ next to an "ok" chip must not look like the same state. --sky is already
106
+ used for informational surfaces (ds-alert-info, audio-file icons) and is
107
+ never pinned by the thebird brand's `--accent:var(--green)!important`
108
+ override, so it stays visually distinct under every accent preset. */
109
+ .ds-badge.tone-live {
110
+ background: color-mix(in oklab, var(--sky) 22%, var(--bg)); color: var(--sky);
111
+ }
102
112
  .ds-badge.tone-flame, .ds-badge.tone-error {
103
113
  background: var(--flame); color: var(--on-color);
104
114
  }
@@ -128,9 +138,36 @@
128
138
  text-transform: none; letter-spacing: 0; font-weight: 500;
129
139
  border-radius: var(--r-0); padding: var(--space-half) var(--space-2);
130
140
  }
131
- .chip.tone-green, .chip.tone-live, .chip.tone-success, .chip.tone-ok {
141
+ .chip.tone-green, .chip.tone-success, .chip.tone-ok {
132
142
  background: var(--green-tint); color: var(--green-deep);
133
143
  }
144
+ /* tone-live: its own sky hue, split from tone-ok/tone-success — see the
145
+ .ds-badge.tone-live comment above for the rationale. A small pulsing dot
146
+ precedes the label so "live" reads as an active broadcast state, not a
147
+ static status chip. */
148
+ .chip.tone-live {
149
+ background: color-mix(in oklab, var(--sky) 16%, var(--bg));
150
+ color: var(--sky);
151
+ position: relative;
152
+ padding-left: 22px;
153
+ }
154
+ .chip.tone-live::before {
155
+ content: '';
156
+ position: absolute;
157
+ left: 10px; top: 50%;
158
+ width: 6px; height: 6px;
159
+ border-radius: 50%;
160
+ background: var(--sky);
161
+ transform: translateY(-50%);
162
+ animation: chip-live-pulse 1.6s ease-in-out infinite;
163
+ }
164
+ @keyframes chip-live-pulse {
165
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in oklab, var(--sky) 50%, transparent); }
166
+ 50% { opacity: 0.7; box-shadow: 0 0 0 4px transparent; }
167
+ }
168
+ @media (prefers-reduced-motion: reduce) {
169
+ .chip.tone-live::before { animation: none; }
170
+ }
134
171
  .chip.tone-flame, .chip.tone-error, .chip.tone-miss {
135
172
  background: var(--flame); color: var(--on-color);
136
173
  }
@@ -12,18 +12,18 @@ export function makeToolsPages(ctx) {
12
12
  async analytics(h0) {
13
13
  const list = await h0.pi.sessions.list();
14
14
  const tools = [...h0.pi.tools.values()];
15
- const byPlatform = list.reduce((a, s) => { const k = s.platform || '?'; a[k] = (a[k] || 0) + 1; return a; }, {});
16
- const byModel = list.reduce((a, s) => { const k = s.model || '?'; a[k] = (a[k] || 0) + 1; return a; }, {});
15
+ const byPlatform = list.reduce((a, s) => { const k = s.platform || 'unknown'; a[k] = (a[k] || 0) + 1; return a; }, {});
16
+ const byModel = list.reduce((a, s) => { const k = s.model || 'unknown'; a[k] = (a[k] || 0) + 1; return a; }, {});
17
17
  const byToolset = tools.reduce((a, t) => { (a[t.toolset || 'core'] = a[t.toolset || 'core'] || []).push(t.name); return a; }, {});
18
18
  return [
19
19
  Kpi({ items: [[list.length, 'sessions'], [tools.length, 'tools']] }),
20
20
  Panel({ title: 'sessions by platform', children: Object.keys(byPlatform).length === 0
21
21
  ? EmptyState({ text: 'no data', glyph: Icon('activity') })
22
- : Table({ headers: ['platform', 'count'], rows: Object.entries(byPlatform).sort((a, b) => b[1] - a[1]) }) }),
22
+ : Table({ headers: ['platform', 'count'], striped: true, rows: Object.entries(byPlatform).sort((a, b) => b[1] - a[1]) }) }),
23
23
  Panel({ title: 'sessions by model', children: Object.keys(byModel).length === 0
24
24
  ? EmptyState({ text: 'no data', glyph: Icon('circle-dot') })
25
- : Table({ headers: ['model', 'count'], rows: Object.entries(byModel).sort((a, b) => b[1] - a[1]) }) }),
26
- Panel({ title: 'tool distribution', children: Table({ headers: ['toolset', 'count', 'tools'],
25
+ : Table({ headers: ['model', 'count'], striped: true, rows: Object.entries(byModel).sort((a, b) => b[1] - a[1]) }) }),
26
+ Panel({ title: 'tool distribution', children: Table({ headers: ['toolset', 'count', 'tools'], striped: true,
27
27
  rows: Object.entries(byToolset).map(([k, v]) => [k, v.length, v.slice(0, 4).join(', ') + (v.length > 4 ? '…' : '')]) }) }),
28
28
  ];
29
29
  },
@@ -65,7 +65,7 @@ export function makeToolsPages(ctx) {
65
65
  }) }),
66
66
  Panel({ title: 'scheduled jobs', count: list.length, children: list.length === 0
67
67
  ? EmptyState({ text: 'no cron jobs — add one above', glyph: Icon('circle') })
68
- : Table({ headers: ['id', 'cron', 'prompt', 'enabled'],
68
+ : Table({ headers: ['id', 'cron', 'prompt', 'enabled'], striped: true,
69
69
  rows: list.map(j => [j.id, j.cron, (j.prompt || '').slice(0, 40), j.enabled ? 'yes' : 'no']) }) }),
70
70
  ];
71
71
  },
@@ -77,7 +77,7 @@ export function makeToolsPages(ctx) {
77
77
  list.length === 0 ? EmptyState({ text: 'no skills loaded — add SKILL.md files to ~/.freddie/skills/', glyph: Icon('square') }) : null,
78
78
  ...Object.entries(byCat).map(([cat, ss]) => Panel({ title: cat, count: ss.length,
79
79
  children: ss.length === 0 ? EmptyState({ text: 'none', glyph: Icon('square') })
80
- : Table({ headers: ['name', 'description'], rows: ss.map(s => [skillLabel(s), (s.description || '').slice(0, 120)]) }) })),
80
+ : Table({ headers: ['name', 'description'], striped: true, rows: ss.map(s => [skillLabel(s), (s.description || '').slice(0, 120)]) }) })),
81
81
  ].filter(Boolean);
82
82
  },
83
83
  async config(h0) {
@@ -97,7 +97,7 @@ export function makeToolsPages(ctx) {
97
97
  },
98
98
  }) }),
99
99
  Panel({ title: 'commands', count: commands.length,
100
- children: Table({ headers: ['name', 'category', 'description'], rows: commands.map(c => [c.name, c.category || '', c.description || '']) }) }),
100
+ children: Table({ headers: ['name', 'category', 'description'], striped: true, rows: commands.map(c => [c.name, c.category || '', c.description || '']) }) }),
101
101
  Panel({ title: 'active config', children: pre(cfg) }),
102
102
  ];
103
103
  },
@@ -57,9 +57,9 @@
57
57
  --------------------------------------------------------------------- */
58
58
  .fd-chat-config { display: flex; flex-direction: column; gap: var(--space-2, 8px); padding-bottom: var(--space-3, 12px); border-bottom: 1px solid color-mix(in oklab, var(--fg) 10%, transparent); margin-bottom: var(--space-3, 12px); }
59
59
  .fd-chat-config .fd-chat-field { display: flex; flex-direction: column; gap: var(--space-1, 4px); min-width: 120px; }
60
- .fd-chat-config .fd-chat-field > label { font-family: var(--os-mono, monospace); font-size: 10px; opacity: 0.6; letter-spacing: 0.05em; text-transform: uppercase; }
60
+ .fd-chat-config .fd-chat-field > label { font-family: var(--os-mono, monospace); font-size: var(--fs-nano, 11px); opacity: 0.6; letter-spacing: 0.05em; text-transform: uppercase; }
61
61
  .fd-chat-config .fd-chat-field > input,
62
- .fd-chat-config .fd-chat-field > select { width: 100%; box-sizing: border-box; padding: var(--space-1-75) var(--space-2); background: var(--panel-1, transparent); color: var(--fg, inherit); border: 1px solid color-mix(in oklab, var(--fg) 14%, transparent); border-radius: var(--r-0); font: inherit; font-size: 12px; }
62
+ .fd-chat-config .fd-chat-field > select { width: 100%; box-sizing: border-box; padding: var(--space-1-75) var(--space-2); background: var(--panel-1, transparent); color: var(--fg, inherit); border: 1px solid color-mix(in oklab, var(--fg) 14%, transparent); border-radius: var(--r-0); font: inherit; font-size: var(--fs-tiny, 12px); }
63
63
  .fd-chat-config .fd-chat-field > input:focus-visible,
64
64
  .fd-chat-config .fd-chat-field > select:focus-visible { outline: 2px solid var(--os-accent, #247420); outline-offset: 0; border-color: color-mix(in oklab, var(--os-accent, #247420) 60%, transparent); }
65
65
  .fd-chat-config .fd-chat-row { display: flex; gap: var(--space-2, 8px); flex-wrap: wrap; }
@@ -68,5 +68,5 @@
68
68
  .app-fd ds-chat.fd-dashboard-chat { flex: 1 1 auto; min-height: 280px; display: flex; flex-direction: column; overflow: hidden; }
69
69
 
70
70
  .fd-chat-actions { display: inline-flex; gap: var(--space-1, 4px); align-items: center; }
71
- .fd-chat-actions .btn-secondary { background: transparent; color: var(--danger, #c0392b); border: 1px solid color-mix(in oklab, var(--danger, #c0392b) 40%, transparent); cursor: pointer; padding: var(--space-hair) var(--space-2); border-radius: var(--r-0); font: inherit; font-size: 12px; }
71
+ .fd-chat-actions .btn-secondary { background: transparent; color: var(--danger, #c0392b); border: 1px solid color-mix(in oklab, var(--danger, #c0392b) 40%, transparent); cursor: pointer; padding: var(--space-hair) var(--space-2); border-radius: var(--r-0); font: inherit; font-size: var(--fs-tiny, 12px); }
72
72
  .fd-chat-actions .btn-secondary:hover { background: color-mix(in oklab, var(--danger, #c0392b) 10%, transparent); }
@@ -64,7 +64,7 @@ export function createFreddieDashboard({ instance, bootHost, osSurfaces, loading
64
64
  const route = allRoutes.find(r => r.path === state.active) || ROUTES[1];
65
65
  return AppShell({
66
66
  topbar: Topbar({ brand: 'assistant', leaf: 'dashboard', items: [], active: '' }),
67
- crumb: Crumb({ trail: ['assistant', instance.id], leaf: route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'live' }) }),
67
+ crumb: Crumb({ trail: ['assistant', instance.id], leaf: route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'live', children: 'live' }) }),
68
68
  side: buildSide(),
69
69
  main: state.body || EmptyState({ text: loadingText || 'loading…', glyph: Icon('circle') }),
70
70
  status: Status({ left: ['ds-247420 · webjsx · ' + allRoutes.length + ' routes', 'instance=' + instance.id], right: [state.ts] }),
@@ -99,6 +99,23 @@ html, body {
99
99
  text-rendering: optimizeLegibility;
100
100
  }
101
101
 
102
+ /* ---- Desktop wallpaper ----
103
+ * A flat --os-bg-0 fill behind the window layer reads as an empty/unfinished
104
+ * void once windows are moved aside. A very subtle two-stop radial wash
105
+ * (barely above the base tone, no imagery/texture asset to fetch or theme)
106
+ * gives the desktop depth without competing with window chrome — scoped to
107
+ * .ds-247420 .wm-root specifically (the actual desktop viewport BEHIND
108
+ * windows), not the page background used by bare/non-OS surfaces. */
109
+ .ds-247420 .wm-root {
110
+ background:
111
+ radial-gradient(ellipse 120% 80% at 20% -10%, color-mix(in oklab, var(--os-accent) 6%, transparent), transparent 60%),
112
+ radial-gradient(ellipse 100% 70% at 100% 100%, color-mix(in oklab, var(--os-accent) 4%, transparent), transparent 55%),
113
+ var(--os-bg-0);
114
+ }
115
+ @media (prefers-reduced-transparency: reduce) {
116
+ .ds-247420 .wm-root { background: var(--os-bg-0); }
117
+ }
118
+
102
119
  /* ---- Global focus-visible ring ----
103
120
  Baseline accent-colored outline for every keyboard-focusable element
104
121
  (inputs/textareas/buttons/links/contenteditable, including the terminal's
@@ -192,6 +209,13 @@ html, body {
192
209
  .os-btn:focus-visible { background: var(--panel-hover, var(--os-bg-2)); }
193
210
  .os-btn .ic { color: var(--os-accent); display: inline-flex; width: 16px; height: 16px; }
194
211
  .os-btn .ic svg { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; }
212
+ /* Filled/duotone active-state icon treatment: an active nav icon otherwise
213
+ * looks visually IDENTICAL to its inactive sibling (only the container
214
+ * background changes) — a soft `fill:currentColor` wash behind the same
215
+ * stroke-only glyph gives active items real duotone weight, matching the
216
+ * "filled/duotone treatment for active items" ask. fill-opacity keeps the
217
+ * glyph legible over the wash rather than solid-filling the whole path. */
218
+ .os-btn.active .ic svg, .os-btn[aria-pressed="true"] .ic svg { fill: currentColor; fill-opacity: 0.18; }
195
219
 
196
220
  .os-menu {
197
221
  position: absolute;
@@ -451,6 +475,7 @@ html, body {
451
475
  .os-rail-btn:active { background: var(--panel-select, var(--os-accent-soft)); }
452
476
  .os-rail-btn .ic { color: var(--os-accent); display: inline-flex; }
453
477
  .os-rail-btn .ic svg { width: 22px; height: 22px; fill: none; stroke: currentColor; }
478
+ .os-rail-btn:active .ic svg { fill: currentColor; fill-opacity: 0.18; }
454
479
 
455
480
  .os-drawer {
456
481
  position: fixed;
@@ -518,6 +543,7 @@ html, body {
518
543
  .os-drawer-tile:active, .os-drawer-tile.active { background: var(--panel-select, var(--os-accent-soft)); }
519
544
  .os-drawer-tile .ic { color: var(--os-accent); display: inline-flex; }
520
545
  .os-drawer-tile .ic svg { width: 32px; height: 32px; fill: none; stroke: currentColor; }
546
+ .os-drawer-tile:active .ic svg, .os-drawer-tile.active .ic svg { fill: currentColor; fill-opacity: 0.18; }
521
547
  .os-drawer-tile .lbl { color: var(--os-fg); font-weight: 600; }
522
548
 
523
549
  .os-spacer { flex: 1 1 auto; }