anentrypoint-design 1.0.10 → 1.0.12

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.
@@ -80,7 +80,23 @@ body.canvas-host { background: transparent !important; }
80
80
  .app-topbar > .brand { flex: 0 0 auto; }
81
81
  .app-topbar > .app-search { flex: 1 1 auto; }
82
82
  .app-topbar > nav { margin-left: auto; }
83
- .app-topbar > .app-topbar-theme { flex: 0 0 auto; }
83
+ /* Utility cluster grouping: the theme toggle sits after the repo-info nav
84
+ links with no visual separation between the two groups, so the row read
85
+ as one undifferentiated strip of controls. A thin inset rule plus extra
86
+ left gap reads as "these are a distinct utility group", matching the
87
+ subtle divider already used between .app-status segments. */
88
+ .app-topbar > .app-topbar-theme {
89
+ flex: 0 0 auto;
90
+ margin-left: var(--space-2);
91
+ padding-left: var(--space-3);
92
+ position: relative;
93
+ }
94
+ .app-topbar > .app-topbar-theme::before {
95
+ content: ''; position: absolute; left: 0; top: 50%;
96
+ transform: translateY(-50%);
97
+ width: 1px; height: 18px;
98
+ background: var(--rule-strong);
99
+ }
84
100
  .app-topbar nav { display: flex; gap: var(--space-1); font-size: var(--fs-sm); flex-wrap: wrap; }
85
101
  .app-topbar nav a { flex: 0 0 auto; }
86
102
  .app-topbar nav a {
@@ -171,6 +187,15 @@ body.canvas-host { background: transparent !important; }
171
187
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
172
188
  min-width: 0; flex: 0 1 auto;
173
189
  }
190
+ /* Ancestor trail segments previously had no rule of their own and simply
191
+ inherited .app-crumb's base --fg-3 — the same tone the separators sit on
192
+ top of (before their own 0.5 opacity dims them further). That leaves only
193
+ two visually distinct tiers in practice (dim separator, bold leaf) rather
194
+ than three, since the ancestor tone reads as "default text colour", not a
195
+ deliberate middle step. --fg-3 is the crumb bar's OWN base, so explicitly
196
+ restating it here is the anchor for the 3-tier read: separator (--fg-3 at
197
+ 0.5 opacity, dimmest) < ancestor (--fg-3, mid) < leaf (--fg bold, brightest). */
198
+ .app-crumb > span:not(.sep):not(.leaf):not(.crumb-right) { color: var(--fg-3); }
174
199
  .app-crumb .leaf { color: var(--fg); font-weight: 600; flex: 0 1 auto; }
175
200
  .app-crumb .crumb-right { margin-left: auto; color: var(--fg-3); flex: 0 0 auto; }
176
201
 
@@ -189,6 +214,15 @@ body.canvas-host { background: transparent !important; }
189
214
  .app-side-shell {
190
215
  background: var(--bg); overflow-y: auto; min-height: 0;
191
216
  scrollbar-width: thin; scrollbar-color: var(--rule-strong) transparent;
217
+ /* Bottom-edge fade: a rail longer than the viewport otherwise shows only
218
+ the thin scrollbar track as its sole "there is more below" cue. Mirrors
219
+ the horizontal fade already used by .os-instances (src/kits/os/theme.css)
220
+ for its own overflow edge, oriented top-to-bottom for this vertical
221
+ scroller. The top edge stays fully opaque (rows start flush under the
222
+ topbar with no need to hint upward scroll on initial load); only the
223
+ trailing 24px softens toward transparent. */
224
+ -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 24px), transparent);
225
+ mask-image: linear-gradient(to bottom, black calc(100% - 24px), transparent);
192
226
  }
193
227
  .app-side-shell::-webkit-scrollbar { width: 8px; }
194
228
  .app-side-shell::-webkit-scrollbar-thumb { background: var(--rule-strong); border-radius: var(--r-pill, 999px); }
@@ -44,6 +44,16 @@ export function makeCorePages(ctx) {
44
44
  const tools = h0.pi.tools.size;
45
45
  const skills = h0.pi.skills.size;
46
46
  const health = (typeof h0.pi.health === 'function') ? h0.pi.health() : { ok: true };
47
+ // Epoch-ms fields (ts, or any *At/*Time-suffixed key) render as raw
48
+ // 13-digit numbers otherwise -- unreadable and gives no sense of
49
+ // recency. Format as a locale timestamp; anything that doesn't
50
+ // parse as a plausible epoch-ms value falls through to String(v)
51
+ // unchanged so this never mangles a genuine small integer.
52
+ const isEpochMsKey = (k) => k === 'ts' || /(At|Time)$/.test(k);
53
+ const fmtHealthValue = (k, v) => {
54
+ if (isEpochMsKey(k) && typeof v === 'number' && v > 1e12) return new Date(v).toLocaleString();
55
+ return String(v);
56
+ };
47
57
  return [
48
58
  Hero({ title: 'assistant', body: 'open js agent harness — in-page agent runtime.', accent: h0.version || 'web' }),
49
59
  Kpi({ items: [[sessions.length, 'sessions'], [tools, 'tools'], [skills, 'skills']] }),
@@ -55,7 +65,7 @@ export function makeCorePages(ctx) {
55
65
  ['set api key', 'keys tab -> click chip to set value'],
56
66
  ['add cron', 'cron tab -> form'],
57
67
  ] }) }),
58
- Panel({ title: 'host', children: Receipt({ rows: Object.entries(health).map(([k, v]) => [k, String(v)]) }) }),
68
+ Panel({ title: 'host', children: Receipt({ rows: Object.entries(health).map(([k, v]) => [k, fmtHealthValue(k, v)]) }) }),
59
69
  ];
60
70
  },
61
71
  async sessions(h0) {
@@ -79,7 +89,7 @@ export function makeCorePages(ctx) {
79
89
  Kpi({ items: [[list.length, 'sessions']] }),
80
90
  Panel({ title: 'recent sessions', count: list.length, children: list.length === 0
81
91
  ? EmptyState({ text: 'no sessions yet — open chat and send a message', glyph: Icon('thread') })
82
- : Table({ headers: ['id', 'title', 'platform', 'model', 'cwd', 'skill', ''], rows }) }),
92
+ : Table({ headers: ['id', 'title', 'platform', 'model', 'cwd', 'skill', ''], striped: true, rows }) }),
83
93
  ];
84
94
  },
85
95
  async agents(h0) {
@@ -88,7 +98,7 @@ export function makeCorePages(ctx) {
88
98
  Kpi({ items: [[a.count || 0, 'active'], [a.turns || 0, 'turns']] }),
89
99
  Panel({ title: 'agent overview', children: Receipt({ rows: [
90
100
  ['total turns', String(a.turns || 0)],
91
- ['active session', a.active || '(none)'],
101
+ ['active session', a.active || '—'],
92
102
  ['last activity', a.last_activity ? new Date(a.last_activity).toLocaleString() : '—'],
93
103
  ] }) }),
94
104
  ];
@@ -14,7 +14,7 @@ export function makeOsPages(ctx) {
14
14
  Kpi({ items: [[list.length, 'instances'], [activeId || '—', 'active']] }),
15
15
  Panel({ title: 'instances', count: list.length, children: list.length === 0
16
16
  ? EmptyState({ text: 'no instances', glyph: Icon('square') })
17
- : Table({ headers: ['id', 'active', 'shells', 'windows'],
17
+ : Table({ headers: ['id', 'active', 'shells', 'windows'], striped: true,
18
18
  rows: list.map(i => [i.id, i.id === activeId ? Icon('circle') : '', String((i.shells || []).length), String((i.windows || []).length)]) }) }),
19
19
  ];
20
20
  },
@@ -25,7 +25,7 @@ export function makeOsPages(ctx) {
25
25
  Kpi({ items: [[wins.length, 'windows'], [focused ? (focused.id || focused.title || '?') : '—', 'focused']] }),
26
26
  Panel({ title: 'windows', count: wins.length, children: wins.length === 0
27
27
  ? EmptyState({ text: 'no windows open', glyph: Icon('square') })
28
- : Table({ headers: ['id', 'title', 'min', 'max', 'pos'],
28
+ : Table({ headers: ['id', 'title', 'min', 'max', 'pos'], striped: true,
29
29
  rows: wins.map(w => [w.id || '?', w.title || '', w.min ? Icon('circle') : '', w.max ? Icon('circle') : '',
30
30
  (w.el ? `${w.el.offsetLeft},${w.el.offsetTop} ${w.el.offsetWidth}×${w.el.offsetHeight}` : '')]) }) }),
31
31
  ];
@@ -19,6 +19,13 @@ export function createFreddieDashboard({ instance, bootHost, osSurfaces, loading
19
19
  const state = { active: 'home', ts: new Date().toLocaleTimeString(), body: null, error: null };
20
20
  let host = instance.host || null;
21
21
  const allRoutes = osSurfaces ? [...ROUTES, ...OS_ROUTE_DEFS] : ROUTES;
22
+ // Bumped on every setActive() and captured by loadActive() before its
23
+ // first await; a resolving page whose generation no longer matches the
24
+ // live one is a stale in-flight nav (user clicked a second page before
25
+ // the first's async page() resolved) and must not overwrite state.body —
26
+ // otherwise the crumb/side (synchronous) shows the new page while the
27
+ // main content silently keeps whichever page happened to resolve last.
28
+ let navGeneration = 0;
22
29
 
23
30
  async function ensureHost() {
24
31
  if (host) return host;
@@ -27,7 +34,7 @@ export function createFreddieDashboard({ instance, bootHost, osSurfaces, loading
27
34
  return host;
28
35
  }
29
36
 
30
- function setActive(p) { state.active = p; rerender(); }
37
+ function setActive(p) { state.active = p; navGeneration++; rerender(); }
31
38
  if (typeof window !== 'undefined') window.__fd_nav = setActive;
32
39
 
33
40
  function rerender() { webjsx.applyDiff(root, view()); loadActive(); }
@@ -72,15 +79,19 @@ export function createFreddieDashboard({ instance, bootHost, osSurfaces, loading
72
79
  }
73
80
 
74
81
  async function loadActive() {
82
+ const myGeneration = navGeneration;
83
+ let body, error = null;
75
84
  try {
76
85
  const h0 = await ensureHost();
77
86
  const page = PAGES[state.active] || PAGES.home;
78
- state.body = await page(h0, instance);
79
- state.error = null;
87
+ body = await page(h0, instance);
80
88
  } catch (e) {
81
- state.error = String(e && e.stack || e);
82
- state.body = Panel({ title: 'error', children: pre(state.error) });
89
+ error = String(e && e.stack || e);
90
+ body = Panel({ title: 'error', children: pre(error) });
83
91
  }
92
+ if (myGeneration !== navGeneration) return; // superseded by a later nav click
93
+ state.body = body;
94
+ state.error = error;
84
95
  state.ts = new Date().toLocaleTimeString();
85
96
  webjsx.applyDiff(root, view());
86
97
  }
@@ -105,12 +105,24 @@ html, body {
105
105
  * (barely above the base tone, no imagery/texture asset to fetch or theme)
106
106
  * gives the desktop depth without competing with window chrome — scoped to
107
107
  * .ds-247420 .wm-root specifically (the actual desktop viewport BEHIND
108
- * windows), not the page background used by bare/non-OS surfaces. */
108
+ * windows), not the page background used by bare/non-OS surfaces.
109
+ * A faint repeating dot-grid is layered on top of the wash: the radial wash
110
+ * alone reads as a barely-perceptible tint over a large empty area (the
111
+ * reported "huge empty dark canvas" below a single default window), while a
112
+ * grid of low-opacity dots gives the eye actual anchor points across that
113
+ * space without competing with window chrome or requiring an image asset. */
109
114
  .ds-247420 .wm-root {
110
- background:
115
+ /* Split into background-image/-color longhands, NOT the `background`
116
+ shorthand: some engines fail to parse a multi-layer gradient list
117
+ shorthand-form when any layer's color-stop uses color-mix() — the
118
+ whole declaration is dropped and the canvas silently renders flat.
119
+ The longhand form parses these same layers correctly. */
120
+ background-image:
121
+ radial-gradient(circle, color-mix(in oklab, var(--os-fg) 6%, transparent) 1px, transparent 1px),
111
122
  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);
123
+ radial-gradient(ellipse 100% 70% at 100% 100%, color-mix(in oklab, var(--os-accent) 4%, transparent), transparent 55%);
124
+ background-color: var(--os-bg-0);
125
+ background-size: 28px 28px, auto, auto;
114
126
  }
115
127
  @media (prefers-reduced-transparency: reduce) {
116
128
  .ds-247420 .wm-root { background: var(--os-bg-0); }
@@ -354,7 +366,65 @@ html, body {
354
366
  border-radius: var(--radius-lg, var(--r-3));
355
367
  box-shadow: var(--shadow-2);
356
368
  overflow: hidden;
369
+ display: flex;
370
+ flex-direction: column;
371
+ /* Open/maximize/restore transitions: transform+opacity are interpolable
372
+ * (unlike wm-min's `display:none`, which cannot transition at all — a
373
+ * real minimize/restore animation needs wm.js to defer the display swap
374
+ * until this transition ends, tracked as its own follow-up). A freshly
375
+ * mounted .wm-win starts from CSS-animation-in via @starting-style so it
376
+ * scales/fades into place instead of snapping to full size, and every
377
+ * maximize/restore toggle (a real geometry change, not a display swap)
378
+ * eases instead of jumping. */
379
+ transition: opacity 160ms ease, transform 160ms cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 160ms ease;
380
+ }
381
+ @supports (transition-behavior: allow-discrete) {
382
+ .ds-247420 .wm-win {
383
+ transition: opacity 160ms ease, transform 160ms cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 160ms ease;
384
+ @starting-style {
385
+ opacity: 0;
386
+ transform: scale(0.96);
387
+ }
388
+ }
389
+ }
390
+ @media (prefers-reduced-motion: reduce) {
391
+ .ds-247420 .wm-win { transition: none; }
392
+ }
393
+
394
+ /* Minimize/restore/close: wm.js's animateMinimize()/animateClose() (wm.js)
395
+ * apply these classes BEFORE the real wm-min toggle / el.remove(), holding
396
+ * them for exactly the duration below so the display:none / DOM-removal
397
+ * that neither CSS nor `transition` can animate happens only after the
398
+ * visual scale+fade has actually played out. Direction: minimize collapses
399
+ * toward the taskbar (down+shrink); close shrinks in place; restore is the
400
+ * minimize keyframe in reverse. Not scoped to .ds-247420 -- these classes
401
+ * are always renderWindow()-authored, own their state fully. */
402
+ .wm-win.wm-minimizing {
403
+ transition: opacity 200ms ease, transform 200ms cubic-bezier(0.4, 0, 1, 1);
404
+ opacity: 0;
405
+ transform: scale(0.85) translateY(24px);
406
+ pointer-events: none;
407
+ }
408
+ .wm-win.wm-restoring {
409
+ transition: opacity 200ms ease, transform 200ms cubic-bezier(0.2, 0.8, 0.3, 1);
410
+ animation: wm-restore-in 200ms cubic-bezier(0.2, 0.8, 0.3, 1);
411
+ }
412
+ @keyframes wm-restore-in {
413
+ from { opacity: 0; transform: scale(0.85) translateY(24px); }
414
+ to { opacity: 1; transform: scale(1) translateY(0); }
357
415
  }
416
+ .wm-win.wm-closing {
417
+ transition: opacity 200ms ease, transform 200ms cubic-bezier(0.4, 0, 1, 1);
418
+ opacity: 0;
419
+ transform: scale(0.92);
420
+ pointer-events: none;
421
+ }
422
+ @media (prefers-reduced-motion: reduce) {
423
+ .wm-win.wm-minimizing, .wm-win.wm-restoring, .wm-win.wm-closing {
424
+ transition: none; animation: none; opacity: 1; transform: none;
425
+ }
426
+ }
427
+
358
428
  .ds-247420 .wm-win.wm-focused {
359
429
  border-color: color-mix(in oklab, var(--os-fg) 28%, transparent);
360
430
  box-shadow: inset var(--space-1) 0 0 var(--os-accent), var(--shadow-3);
@@ -364,6 +434,8 @@ html, body {
364
434
  background: var(--os-bg-2);
365
435
  border: none;
366
436
  padding: var(--space-2) var(--space-2-75);
437
+ display: flex;
438
+ align-items: center;
367
439
  gap: var(--space-2-5);
368
440
  transition: background 120ms ease;
369
441
  }
@@ -380,7 +452,12 @@ html, body {
380
452
  letter-spacing: 0.005em;
381
453
  text-align: center;
382
454
  font-weight: 600;
383
- opacity: 0.65;
455
+ /* 0.72, not 0.65: measured live against the unfocused titlebar's actual
456
+ * background (rgb(77,77,82)) the opacity-blended effective contrast at
457
+ * 0.65 was 4.25:1 -- below WCAG AA's 4.5:1 floor for normal text. 0.72
458
+ * clears it with margin (~4.8:1) while staying visually dimmer than the
459
+ * focused window's full-opacity title. */
460
+ opacity: 0.72;
384
461
  transition: opacity 120ms ease;
385
462
  /* No text-transform here: the titlebar renders the window's own dynamic
386
463
  * title (renderWindow({title}) / setTitle(t) in wm.js) — a user- or
@@ -442,7 +519,12 @@ html, body {
442
519
  linear-gradient(135deg, transparent 0 45%, currentColor 45% 55%, transparent 55% 100%),
443
520
  linear-gradient(135deg, transparent 0 70%, currentColor 70% 80%, transparent 80% 100%);
444
521
  }
445
- .ds-247420 .wm-body { background: var(--os-bg-1); }
522
+ .ds-247420 .wm-body {
523
+ background: var(--os-bg-1);
524
+ flex: 1 1 auto;
525
+ min-height: 0;
526
+ overflow: auto;
527
+ }
446
528
 
447
529
  .os-side-rail {
448
530
  position: fixed;
@@ -593,6 +675,18 @@ html, body {
593
675
  .app-frame { width: 100%; height: 100%; border: 0; background: var(--os-bg-0); }
594
676
  .app-iframe { width: 100%; height: 100%; border: 0; display: block; }
595
677
  .app-iframe.web { background: #ffffff; } /* intentional: white canvas for embedded external web content that expects white */
678
+ /* Browser-pane empty state (about:blank): a bare framed iframe with no
679
+ * content is a large dead rectangle -- this placeholder fills that space
680
+ * with a real affordance until a navigation lands. Hidden the instant the
681
+ * pane's own url stops being about:blank (docs/browser-pane.js). */
682
+ .bp-empty {
683
+ width: 100%; height: 100%;
684
+ display: flex; align-items: center; justify-content: center;
685
+ color: var(--os-fg-3);
686
+ font: var(--fs-sm) var(--os-font);
687
+ background: var(--os-bg-1);
688
+ text-align: center;
689
+ }
596
690
  .app-text-cursor { }
597
691
 
598
692
  @media (prefers-reduced-motion: no-preference) {
@@ -936,7 +1030,11 @@ html.ds-247420 { touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-t
936
1030
  .ds-247420 .tb-sess-badge {
937
1031
  display: inline-flex; align-items: center; justify-content: center;
938
1032
  min-width: 16px; height: 14px; padding: 0 var(--space-1);
939
- border-radius: var(--radius-sm, var(--r-0));
1033
+ /* A neutral window-COUNT chip, not a notification/alert badge -- a small
1034
+ * corner radius (--r-0, 4px) reads as a rectangular count pill, distinct
1035
+ * from the fully-round .tb-sess-dot presence indicator elsewhere in this
1036
+ * chip, so the two don't get visually conflated. */
1037
+ border-radius: var(--r-0);
940
1038
  background: color-mix(in oklab, var(--fg) 14%, transparent);
941
1039
  font-size: var(--fs-pico); font-weight: 600; line-height: 1;
942
1040
  }
@@ -1071,6 +1169,10 @@ html.ds-247420 { touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-t
1071
1169
  .ds-247420 .tb-sessions-card-actions { display: flex; gap: var(--space-1-75); margin-top: var(--space-1); }
1072
1170
  .ds-247420 .tb-sessions-card-actions button { flex: 1; height: 24px; font-size: var(--fs-nano); padding: 0 var(--space-2); border-radius: var(--radius-sm, var(--r-0)); border: 1px solid color-mix(in oklab, var(--fg) 16%, transparent); background: transparent; color: inherit; cursor: pointer; font-family: inherit; }
1073
1171
  .ds-247420 .tb-sessions-card-actions button:hover { background: color-mix(in oklab, var(--fg) 6%, transparent); }
1172
+ /* Baseline (non-hover) danger tint so the destructive action reads as
1173
+ * distinct at rest, not only on hover -- otherwise Delete is visually
1174
+ * identical to Active/Export until the pointer happens to land on it. */
1175
+ .ds-247420 .tb-sessions-card-actions button.danger { color: var(--color-danger); border-color: color-mix(in oklab, var(--color-danger) 40%, transparent); }
1074
1176
  .ds-247420 .tb-sessions-card-actions button.danger:hover { background: var(--color-danger); color: var(--on-color); border-color: var(--color-danger); }
1075
1177
  .ds-247420 .tb-sessions-empty-mid { padding: 40px; text-align: center; opacity: 0.6; font-size: var(--fs-tiny); }
1076
1178
 
package/src/kits/os/wm.js CHANGED
@@ -226,11 +226,11 @@ export function renderWindow(opts = {}) {
226
226
  },
227
227
  setFocused(v) { applyFocused(el, v); },
228
228
  setMaximized(v) { applyMaximized(el, v); },
229
- setMinimized(v) { applyMinimized(el, v); },
229
+ setMinimized(v) { animateMinimize(el, v); },
230
230
  setInstanceId(id) { if (id) el.dataset.instanceId = id; else delete el.dataset.instanceId; },
231
231
  setZIndex(z) { el.style.zIndex = String(z); },
232
232
  getBounds() { return { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight }; },
233
- dispose() { if (closeArmTimer) clearTimeout(closeArmTimer); el.remove(); },
233
+ dispose() { if (closeArmTimer) clearTimeout(closeArmTimer); animateClose(el); },
234
234
  };
235
235
  }
236
236
 
@@ -252,3 +252,42 @@ function setBodyContent(host, body) {
252
252
  function applyFocused(el, v) { el.classList.toggle('wm-focused', !!v); }
253
253
  function applyMaximized(el, v) { el.classList.toggle('wm-max', !!v); }
254
254
  function applyMinimized(el, v) { el.classList.toggle('wm-min', !!v); }
255
+
256
+ function reducedMotion() {
257
+ return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
258
+ }
259
+
260
+ // wm.css's `.wm-win.wm-min{display:none}` cannot be CSS-transitioned (display
261
+ // has no interpolable intermediate value), so a real minimize animation needs
262
+ // the display swap deferred until a scale+fade transition actually finishes.
263
+ // `.wm-minimizing`/`.wm-restoring` (theme.css) carry the transform+opacity
264
+ // keyframes; this only sequences when `wm-min` itself flips. 220ms fallback
265
+ // timer guards against a transitionend that never fires (element removed
266
+ // mid-transition, browser tab backgrounded and rAF/transitions paused, etc.)
267
+ // so a window can never get stuck invisible-but-not-display:none.
268
+ function animateMinimize(el, v) {
269
+ if (reducedMotion()) { applyMinimized(el, v); return; }
270
+ if (v) {
271
+ el.classList.add('wm-minimizing');
272
+ const done = () => { el.classList.remove('wm-minimizing'); applyMinimized(el, true); };
273
+ el.addEventListener('transitionend', done, { once: true });
274
+ setTimeout(done, 220);
275
+ } else {
276
+ applyMinimized(el, false);
277
+ el.classList.add('wm-restoring');
278
+ setTimeout(() => el.classList.remove('wm-restoring'), 220);
279
+ }
280
+ }
281
+
282
+ // Same display-can't-transition problem as minimize, but for the terminal
283
+ // close path: el.remove() used to happen synchronously, so a window vanished
284
+ // instantly with no close animation at all (the literal gap named in the
285
+ // "no animation on open/close" request). Fade+scale out, then remove.
286
+ function animateClose(el) {
287
+ if (!el.isConnected) return;
288
+ if (reducedMotion()) { el.remove(); return; }
289
+ el.classList.add('wm-closing');
290
+ const done = () => el.remove();
291
+ el.addEventListener('transitionend', done, { once: true });
292
+ setTimeout(done, 220);
293
+ }
@@ -35,9 +35,15 @@ function heroNode(hero) {
35
35
  hero.body || hero.subheading,
36
36
  hero.accent ? h('span', { class: 'ds-hero-accent' }, ' ' + hero.accent) : null,
37
37
  ) : null,
38
+ // Every hero CTA is a plain <a href> that navigates away from this page
39
+ // (GitHub, the portfolio site, ...), never an in-page action -- so the
40
+ // trailing arrow is appended here once, at the render layer, rather than
41
+ // hand-typed per label in content yaml where it could drift out of sync
42
+ // with the "navigates away = arrow" rule used elsewhere (row ctas,
43
+ // signin's in-page submit buttons).
38
44
  Array.isArray(hero.ctas) && hero.ctas.length
39
45
  ? h('div', { class: 'ds-hero-actions' }, ...hero.ctas.map((c, i) =>
40
- h('a', { key: i, class: i === 0 ? 'btn btn-accent' : 'btn btn-ghost', href: c.href || '#' }, c.label || c.cta || 'go')))
46
+ h('a', { key: i, class: i === 0 ? 'btn btn-accent' : 'btn btn-ghost', href: c.href || '#' }, (c.label || c.cta || 'go') + ' ->')))
41
47
  : null,
42
48
  badgeRow,
43
49
  );
@@ -56,8 +62,18 @@ function showcaseNode(showcase) {
56
62
  C.Btn({ key: 'b1', variant: 'primary', children: 'Primary' }),
57
63
  C.Btn({ key: 'b2', variant: 'default', children: 'Default' }),
58
64
  C.Btn({ key: 'b3', variant: 'ghost', children: 'Ghost' }),
59
- C.Btn({ key: 'b4', variant: 'danger', children: 'Danger' }),
65
+ // .ds-showcase-btn-danger-group gives Danger its own visually separated
66
+ // cluster (extra leading gap + a hairline divider) instead of sitting in
67
+ // the same uniform-gap row as Primary -- two similarly-weighted filled
68
+ // buttons side by side otherwise compete for "the one action to take"
69
+ // with nothing marking Danger as the deliberately-set-apart one.
70
+ h('span', { key: 'b4-group', class: 'ds-showcase-btn-danger-group' },
71
+ C.Btn({ key: 'b4', variant: 'danger', children: 'Danger' })),
60
72
  );
73
+ // Hue documents STATUS, not decoration: green = shipping/live, blue = pre-
74
+ // release/in-flux, purple = newly added. Badge (not a bespoke pill) carries
75
+ // the '0 violations' count so every status indicator in this row is one of
76
+ // the two real chip-family components, never a one-off styled span.
61
77
  const chipRow = h('div', { class: 'ds-showcase-row' },
62
78
  C.Chip({ key: 'c1', tone: 'green', children: 'Live' }),
63
79
  C.Chip({ key: 'c2', tone: 'blue', children: 'Beta' }),
@@ -106,6 +106,16 @@ a.row:hover .ds-row-arrow { opacity: 1 }
106
106
  letter-spacing: 0.06em; color: var(--fg-3, #6b6b6b);
107
107
  }
108
108
  .ds-showcase-row { display: flex; flex-wrap: wrap; gap: var(--space-2, 8px); align-items: center; }
109
+ /* Sets Danger apart from Primary/Default/Ghost in the button showcase row: an
110
+ extra left margin beyond the row's own gap, plus a hairline divider, so a
111
+ danger action never reads as just one more same-weight choice beside the
112
+ primary action. */
113
+ .ds-showcase-btn-danger-group {
114
+ display: inline-flex; align-items: center;
115
+ margin-left: var(--space-3, 16px);
116
+ padding-left: var(--space-3, 16px);
117
+ border-left: 1px solid var(--rule, rgba(0,0,0,.12));
118
+ }
109
119
  /* Card-grid layout (panel.layout === 'cards'): a browsable gallery of tiles
110
120
  instead of a dense text-row list, opt-in per panel via home.yaml's
111
121
  kits.layout: cards. */
@@ -267,7 +267,7 @@ export declare function Lede(props?: LedeProps): VNode;
267
267
  */
268
268
  export interface DotProps {
269
269
  /** @default 'on' */
270
- tone?: 'on' | 'live' | (string & {});
270
+ tone?: 'on' | 'live' | 'warn' | (string & {});
271
271
  }
272
272
  export declare function Dot(props?: DotProps): VNode;
273
273