@tangle-network/sandbox-ui 0.124.0 → 0.125.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.
@@ -0,0 +1,1002 @@
1
+ /* Tailwind SOURCE entry: `@tangle-network/sandbox-ui/tailwind.css`.
2
+ *
3
+ * Import it from a Tailwind v4 stylesheet, after `tailwindcss`:
4
+ *
5
+ * @import "tailwindcss";
6
+ * @import "@tangle-network/sandbox-ui/tailwind.css";
7
+ *
8
+ * It carries Brand's tokens, named themes and utility registrations, this
9
+ * package's runtime CSS, and the `@source` lines that compile every utility
10
+ * sandbox-ui and @tangle-network/ui components write. The published copy in
11
+ * dist/ appends those `@source` lines (see scripts/copy-styles.mjs); Tailwind
12
+ * resolves them relative to the installed file, so a consumer lists no
13
+ * node_modules paths of its own.
14
+ *
15
+ * The same file is the source of the precompiled `./styles` bundle:
16
+ * src/styles/globals.css wraps it with `@import "tailwindcss"` and this repo's
17
+ * own source globs. Edit runtime CSS here, never in globals.css. */
18
+
19
+ /* Fonts are loaded by the consumer, not this library. See README
20
+ * "Fonts" for the list of font families sandbox-ui references
21
+ * (Geist, Geist Mono, Outfit, Manrope, Inter) and the recommended
22
+ * loading strategies (@fontsource packages or an HTML <link>).
23
+ *
24
+ * Historical note: previous versions shipped
25
+ * @import url("https://fonts.googleapis.com/css2?family=Geist...")
26
+ * at the top of this file. That pattern broke in downstream apps that
27
+ * used CSS chain `@import`s — once the dist CSS is inlined after any
28
+ * rule, the URL @import is no longer at the top of the merged
29
+ * stylesheet and the CSS spec disallows it (PostCSS rejects the
30
+ * build). Shipping fonts as a library concern is also undesirable
31
+ * for privacy (third-party Google Fonts request) and network-failure
32
+ * reasons. Consumers own font loading now. */
33
+
34
+ @import "@tangle-network/brand/styles/tokens.css";
35
+ /* Brand's named themes (`data-theme="<name>"`). They re-skin the surface ladder
36
+ * and nothing else — the Tangle accent stays put. Imported here so a consuming
37
+ * app opting into one resolves it through the single composed entry point,
38
+ * rather than reaching into brand for a second stylesheet. */
39
+ @import "@tangle-network/brand/styles/named-themes.css";
40
+
41
+ /* Canonical font, radius and semantic-color utility registrations, imported as
42
+ * DEFAULT theme values. A default registration still emits every Brand utility,
43
+ * and it still outranks Tailwind's own defaults (it is declared later), but a
44
+ * consumer's `@config` preset can override a name it also maps. That is the
45
+ * precedence a consumer had when this package shipped only a precompiled bundle:
46
+ * the app's own compile loaded last and its preset mapping won. Without
47
+ * `default`, Brand's `--color-border` and `--radius-lg` would silently replace
48
+ * the Agent App preset's `border-border` and `rounded-lg` in every app that
49
+ * compiles this entry. */
50
+ @import "@tangle-network/brand/styles/theme.css" theme(default);
51
+
52
+ /* Brand defines these aliases on :root and [data-sandbox-ui]. Rebind them on
53
+ * explicit theme scopes so nested themes resolve their own HSL colors. A host
54
+ * can still override either alias on a nearer wrapper. */
55
+ :is(.dark, .light, [data-theme], [data-sandbox-theme="vault"]) {
56
+ --foreground: var(--hsl-foreground);
57
+ --muted-foreground: var(--hsl-muted-foreground);
58
+ }
59
+
60
+ /* `--text-dim` arrives from brand and is NOT overridden here.
61
+ *
62
+ * `--text-dim` is the quietest text tier: an Input/Textarea hint, a StatCard
63
+ * subtitle, a TerminalLine timestamp. Its value is only correct when it is tuned
64
+ * against the surface ladder it lands on, so the token and the ladder must move
65
+ * together and must come from the same place. brand ships both, and
66
+ * `scripts/text-dim-contrast.test.mjs` holds the resolved pair to AA on every
67
+ * plane this package paints content on.
68
+ *
69
+ * A local override here would decouple the two: the ladder would keep arriving
70
+ * from brand while the text tier stopped tracking it, and the next ladder change
71
+ * would silently move every ratio. Correct the value in brand instead. */
72
+
73
+
74
+ /* The text ramp, emitted unconditionally.
75
+ *
76
+ * Everything above is a SCAN: Tailwind emits a utility only if it found that
77
+ * exact string in a scanned file, and the `@tangle-network/ui` glob scans
78
+ * whatever version happens to be installed HERE, at build time. A consumer
79
+ * installs its own `@tangle-network/ui` and can perfectly well resolve a newer
80
+ * one than this package was built against — the peer range permits it — and
81
+ * every utility that version introduced is then missing from the precompiled
82
+ * bundle.
83
+ *
84
+ * That is not hypothetical. `@tangle-network/ui` 11.2.4 restyled the
85
+ * Input/Textarea hint, the StatCard subtitle and the TerminalLine timestamp onto
86
+ * `text-[var(--text-dim)]`; this package resolved 11.2.0; `dist/globals.css`
87
+ * emitted no rule for it. Apps that run their own Tailwind/UnoCSS scan over
88
+ * `@tangle-network/ui` were fine. Apps on the precompiled bundle (physim,
89
+ * blueprint-agent) rendered those three at inherited body colour — 15-17:1,
90
+ * indistinguishable from body copy. A contrast audit cannot see it, because
91
+ * nothing is under-contrast; the tier is simply gone.
92
+ *
93
+ * A scan cannot fix that, because the missing string is by definition one this
94
+ * build never saw. So the four text tiers are declared inline: they are the
95
+ * brand text ramp, they are a closed set, and each one's absence degrades
96
+ * silently to the inherited colour rather than to something visibly wrong.
97
+ * Pinned by `scripts/text-dim-contrast.test.mjs`.
98
+ *
99
+ * The test for belonging here is who WRITES the class, not which token it
100
+ * names: a class a consumer writes and this package does not. Anything this
101
+ * package's own components write is already covered by the `@source` globs
102
+ * above and needs no entry — `text-[var(--accent-text)]` in the connector
103
+ * catalog, for one, which the scan sees and emits. */
104
+ @source inline("text-[var(--text-primary)]");
105
+ @source inline("text-[var(--text-secondary)]");
106
+ @source inline("text-[var(--text-muted)]");
107
+ @source inline("text-[var(--text-dim)]");
108
+
109
+ /* The run-mix ramp, emitted for the same reason the text ramp is: a consuming
110
+ * app paints a proportional status bar with these, and a scan of THIS package
111
+ * never sees the class. */
112
+ @source inline("bg-[var(--run-mix-succeeded)]");
113
+ @source inline("bg-[var(--run-mix-waiting)]");
114
+ @source inline("bg-[var(--run-mix-failed)]");
115
+ @source inline("bg-[var(--run-mix-in-flight)]");
116
+ @source inline("bg-[var(--run-mix-cancelled)]");
117
+
118
+ /* Compatibility names not registered by Brand 1.10.0. The runtime values
119
+ * still belong to Brand; do not duplicate its other @theme registrations. */
120
+ @theme inline default {
121
+ --color-surface: var(--md3-surface);
122
+ --color-surface-dim: var(--md3-surface-dim);
123
+ --color-surface-bright: var(--md3-surface-bright);
124
+ }
125
+
126
+ /* ── Motion ────────────────────────────────────────────────────────────────
127
+ *
128
+ * `@tangle-network/brand` ships exactly one motion token (`--transition-default:
129
+ * 0.16s ease`) and no curves, so every animated surface in this package picked
130
+ * its own number: `duration-150` on a rail button, `duration-200` on the rail
131
+ * itself, `0.18s ease-out` on a session row, Tailwind's implicit default
132
+ * everywhere a bare `transition-colors` was written. Nothing was wrong;
133
+ * nothing was decidable either, and none of it was reachable by a reduced-motion
134
+ * preference.
135
+ *
136
+ * These are the SAME names and the SAME values `@tangle-network/agent-app`
137
+ * declares in its own `theme/tokens.css` (0.45.46+), deliberately duplicated
138
+ * rather than imported: sandbox-ui does not depend on agent-app, and a product
139
+ * that loads both stylesheets must not get two different answers for
140
+ * `--duration-base`. Identical declarations are order-independent; divergent
141
+ * ones are a coin flip decided by import order. If agent-app retunes the
142
+ * ladder, retune it here in the same change.
143
+ *
144
+ * They live in `globals.css` and not in `dist/tokens.css` because that file is
145
+ * a byte-for-byte copy of brand's (see scripts/copy-styles.mjs) — the tokens
146
+ * that are ours to declare belong in the one stylesheet this package authors,
147
+ * next to the utilities that consume them.
148
+ *
149
+ * instant a control acknowledging a pointer (hover/active colour)
150
+ * fast a state change on a control already on screen
151
+ * base a surface arriving or leaving (popover, panel, disclosure)
152
+ * slow a full-height surface travelling (drawer, sheet, the rail)
153
+ *
154
+ * Curves: entrances decelerate hard and land softly; state changes decelerate
155
+ * normally; exits ACCELERATE away, because a user who dismissed something is
156
+ * done with it and should not wait for it to glide out. */
157
+ :root {
158
+ --duration-instant: 90ms;
159
+ --duration-fast: 150ms;
160
+ --duration-base: 240ms;
161
+ --duration-slow: 360ms;
162
+ --ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
163
+ --ease-entrance: cubic-bezier(0.22, 1, 0.36, 1);
164
+ --ease-exit: cubic-bezier(0.4, 0, 1, 1);
165
+ /* Drop-in duration+easing pairs: `transition: background-color var(--motion-control)`. */
166
+ --motion-control: var(--duration-fast) var(--ease-standard);
167
+ --motion-surface: var(--duration-base) var(--ease-entrance);
168
+ --motion-dismiss: var(--duration-instant) var(--ease-exit);
169
+
170
+ /* Entrance choreography. The durations above answer "how fast does an
171
+ * existing thing change"; these answer "how does a thing that was not there
172
+ * ARRIVE" — a nav rail unfolding, a session appearing in the list, a
173
+ * disclosure revealing its sub-items.
174
+ *
175
+ * `--motion-stream` is longer than `base` on purpose: streamed text arrives
176
+ * continuously, so each chunk overlaps its neighbours and a fast curve makes
177
+ * the paragraph flicker. `--motion-arrive` is the row/card entrance and is
178
+ * meant to be staggered by index (see `--stagger-step`). */
179
+ --duration-stream: 420ms;
180
+ --duration-arrive: 600ms;
181
+ --ease-expo: cubic-bezier(0.23, 1, 0.32, 1);
182
+ --motion-stream: var(--duration-stream) var(--ease-expo);
183
+ --motion-arrive: var(--duration-arrive) var(--ease-expo);
184
+ /* Per-item delay in a staggered group. 5 items = 200ms tail, which stays
185
+ * under the 250ms an interface can add before it reads as latency. */
186
+ --stagger-step: 50ms;
187
+ /* Position in a staggered group, overridden PER ELEMENT by the caller. It is
188
+ * declared here with a zero default rather than read through a `var()`
189
+ * fallback so the file never references a token it does not define — an
190
+ * undefined `var()` resolves to nothing and paints silently, which is the
191
+ * failure the token contract exists to catch. */
192
+ --stagger-index: 0;
193
+ /* How far an arriving surface travels, and how far a streamed chunk resolves
194
+ * from. Both are small: motion that announces itself is motion you notice
195
+ * twice. */
196
+ --arrive-distance: 8px;
197
+ --stream-blur: 4px;
198
+ /* Period of the `.agent-shimmer` sweep. Deliberately NOT named
199
+ * `--duration-*`: that prefix is the collapsible ladder, and the
200
+ * reduced-motion block sets every member of it to 1ms at `:root`. A custom
201
+ * property collapsed at the root cannot be excluded per element, so naming
202
+ * this one `--duration-shimmer` would freeze the sweep for the very users the
203
+ * `data-motion="essential"` exemption exists to keep informed. The sweep is a
204
+ * looping SIGNAL, not a transition, so it sits with the other non-collapsible
205
+ * motion constants above. */
206
+ --shimmer-period: 1400ms;
207
+ }
208
+
209
+ /* `@pierre/trees` (RichFileTree) is CSS-var driven and ships LIGHT defaults with
210
+ * no dark switching — `--trees-bg: var(--trees-bg-override, <light>)`. Without the
211
+ * `*-override` vars it paints a near-white `#f8f8f8` container even inside a dark
212
+ * subtree (the file-tree-rail-looks-light bug). Map the overrides onto the design
213
+ * tokens so the tree follows light/vault ↔ dark automatically (the surface +
214
+ * foreground vars already flip per theme). Apply via `.sandbox-tree-surface`. */
215
+ .sandbox-tree-surface {
216
+ --trees-bg-override: var(--md3-surface-container-lowest);
217
+ --trees-bg-muted-override: var(--md3-surface-container-low);
218
+ --trees-search-bg-override: var(--md3-surface-container);
219
+ --trees-input-bg-override: var(--md3-surface-container);
220
+ --trees-hover-bg-override: var(--md3-surface-container-high);
221
+ --trees-selected-bg-override: hsl(var(--primary) / 0.16);
222
+ --trees-selected-fg-override: hsl(var(--foreground));
223
+ --trees-fg-override: hsl(var(--foreground));
224
+ --trees-fg-muted-override: hsl(var(--muted-foreground));
225
+ --trees-muted-fg-override: hsl(var(--muted-foreground));
226
+ --trees-search-fg-override: hsl(var(--foreground));
227
+ --trees-border-color-override: var(--md3-outline-variant);
228
+ --trees-indent-guide-bg-override: var(--md3-outline-variant);
229
+ --trees-accent-override: hsl(var(--primary));
230
+ --trees-focus-ring-color-override: var(--focus-border);
231
+ }
232
+
233
+ .sandbox-code-view { container-type: inline-size; }
234
+ .sandbox-code-view-mobile-toolbar { display: none; }
235
+
236
+ @container (max-width: 560px) {
237
+ .sandbox-code-view-layout { flex-direction: column; }
238
+ .sandbox-code-view-mobile-toolbar {
239
+ display: flex;
240
+ gap: 0.25rem;
241
+ padding: 0.375rem 0.75rem;
242
+ border-bottom: 1px solid var(--md3-outline-variant);
243
+ background: var(--md3-surface-container-lowest);
244
+ }
245
+ .sandbox-code-view-mobile-toolbar button {
246
+ padding: 0.25rem 0.625rem;
247
+ border-radius: 0.375rem;
248
+ color: hsl(var(--muted-foreground));
249
+ font-size: 0.75rem;
250
+ }
251
+ .sandbox-code-view-mobile-toolbar button[aria-pressed="true"] {
252
+ background: var(--md3-surface-container-high);
253
+ color: hsl(var(--foreground));
254
+ }
255
+ .sandbox-code-view-tree { display: none; width: 100%; min-height: 0; flex: 1; border-right: 0; }
256
+ .sandbox-code-view-tree[data-mobile-open="true"] { display: flex; }
257
+ .sandbox-code-view-editor[data-mobile-hidden="true"] { display: none; }
258
+ }
259
+
260
+ @layer base {
261
+ :root {
262
+ --sandbox: var(--primary);
263
+ --sandbox-glow: var(--info);
264
+ }
265
+
266
+ * {
267
+ border-color: hsl(var(--border));
268
+ }
269
+
270
+ /* The default keyboard focus for any control that does not style its own,
271
+ * the same line and halo as ui's `focusRing` (mirrors brand's globals.css,
272
+ * which this bundle does not import). Base layer, so component utilities
273
+ * still win. Text fields have their own fallback below. */
274
+ :where(
275
+ a[href],
276
+ button,
277
+ summary,
278
+ input:is([type="checkbox"], [type="radio"]),
279
+ [role="button"],
280
+ [role="checkbox"],
281
+ [role="link"],
282
+ [role="menuitem"],
283
+ [role="option"],
284
+ [role="radio"],
285
+ [role="switch"],
286
+ [role="tab"],
287
+ [tabindex]:not([tabindex="-1"])
288
+ ):focus-visible {
289
+ outline: 1px solid var(--focus-border);
290
+ outline-offset: 0;
291
+ box-shadow: 0 0 0 4px var(--focus-halo);
292
+ }
293
+
294
+ /* A native text field that styles no focus: a 1px --focus-border line over
295
+ * its border, instead of the browser's blue outline (mirrors brand's
296
+ * globals.css). An outline, because a border-colour utility on the field
297
+ * outranks a base-layer border colour. No halo: a field that hands its
298
+ * focus to a container clears the line with `outline-none`. */
299
+ :where(
300
+ input:not(
301
+ [type="button"],
302
+ [type="checkbox"],
303
+ [type="color"],
304
+ [type="file"],
305
+ [type="hidden"],
306
+ [type="image"],
307
+ [type="radio"],
308
+ [type="range"],
309
+ [type="reset"],
310
+ [type="submit"]
311
+ ),
312
+ select,
313
+ textarea
314
+ ):focus-visible {
315
+ outline: 1px solid var(--focus-border);
316
+ outline-offset: -1px;
317
+ /* A field with an explicit tabindex also matches the control rule above;
318
+ * drop that rule's halo. */
319
+ box-shadow: none;
320
+ }
321
+
322
+ /* The rounded composite owns the focus line, not its inner editor. */
323
+ .focus-field-within :is(input:not([type=button],[type=submit],[type=reset],[type=image],[type=checkbox],[type=radio],[type=range],[type=color],[type=file]),textarea,select,[contenteditable]:not([contenteditable=false])):focus-visible {
324
+ outline: none;
325
+ }
326
+
327
+ body {
328
+ -webkit-font-smoothing: antialiased;
329
+ -moz-osx-font-smoothing: grayscale;
330
+ background-color: hsl(var(--background));
331
+ color: hsl(var(--foreground));
332
+ font-family: var(--font-sans);
333
+ font-feature-settings:
334
+ "rlig" 1,
335
+ "calt" 1,
336
+ "ss01" 1;
337
+ }
338
+
339
+ ::selection {
340
+ background: var(--bg-selection);
341
+ color: hsl(var(--hsl-foreground));
342
+ }
343
+
344
+ /* Thin neutral scrollbar — recedes until hovered. */
345
+ ::-webkit-scrollbar {
346
+ width: 6px;
347
+ height: 6px;
348
+ }
349
+
350
+ ::-webkit-scrollbar-track {
351
+ background: transparent;
352
+ }
353
+
354
+ ::-webkit-scrollbar-thumb {
355
+ background: hsl(var(--border) / 0.6);
356
+ border-radius: 9999px;
357
+ }
358
+
359
+ ::-webkit-scrollbar-thumb:hover {
360
+ background: hsl(var(--muted-foreground) / 0.4);
361
+ }
362
+
363
+ /* Firefox: thin neutral track. */
364
+ * {
365
+ scrollbar-width: thin;
366
+ scrollbar-color: hsl(var(--border) / 0.6) transparent;
367
+ }
368
+ }
369
+
370
+ @layer utilities {
371
+ /* Gradient text -- Tangle default accent */
372
+ .text-gradient-sandbox,
373
+ .text-gradient-brand {
374
+ background-image: var(--tangle-gradient);
375
+ background-clip: text;
376
+ -webkit-background-clip: text;
377
+ color: transparent;
378
+ }
379
+
380
+ /* Tangle Quiet has no glow chrome. Class retained as a flat no-op so any
381
+ stray consumer renders without a halo. */
382
+ .glow-sandbox {
383
+ box-shadow: none;
384
+ }
385
+
386
+ /* Resting card — L2 surface in the MD3 elevation ladder. */
387
+ .glass-card {
388
+ background: var(--md3-surface-container);
389
+ border: 1px solid var(--md3-outline-variant);
390
+ transition: border-color var(--transition-default), box-shadow var(--transition-default);
391
+ }
392
+
393
+ .glass-card:hover {
394
+ border-color: var(--md3-outline-variant);
395
+ }
396
+
397
+ /* Emphasized / nested card — one step up the ladder (L3). */
398
+ .glass-card-strong {
399
+ background: var(--md3-surface-container-high);
400
+ border: 1px solid var(--md3-outline-variant);
401
+ }
402
+
403
+ /* Flat panels — no backdrop blur, edge-light hairline, flat card shadow.
404
+ .glass-panel is the resting L2 surface; -heavy lifts one ladder step (L3). */
405
+ .glass-panel {
406
+ background: var(--md3-surface-container);
407
+ border: 1px solid var(--md3-outline-variant);
408
+ box-shadow: var(--shadow-card);
409
+ }
410
+
411
+ .glass-panel-heavy {
412
+ background: var(--md3-surface-container-high);
413
+ border: 1px solid var(--md3-outline-variant);
414
+ box-shadow: var(--shadow-card);
415
+ }
416
+
417
+ .glass-panel:hover {
418
+ border-color: var(--md3-outline-variant);
419
+ }
420
+
421
+ /* Glow utilities retained as flat no-ops — Tangle Quiet has no halos. */
422
+ .glow-primary {
423
+ box-shadow: none;
424
+ }
425
+
426
+ .glow-primary:hover {
427
+ box-shadow: none;
428
+ }
429
+
430
+ .text-glow {
431
+ text-shadow: none;
432
+ }
433
+
434
+ /* Radiant gradient text */
435
+ .gradient-text {
436
+ background: linear-gradient(135deg, hsl(var(--primary) / 0.7) 0%, hsl(var(--primary)) 100%);
437
+ -webkit-background-clip: text;
438
+ -webkit-text-fill-color: transparent;
439
+ background-clip: text;
440
+ }
441
+
442
+ .gradient-bg {
443
+ background: linear-gradient(135deg, hsl(var(--primary) / 0.1) 0%, hsl(var(--primary) / 0.05) 100%);
444
+ }
445
+
446
+ /* Retained as a flat no-op — the mesh texture belongs to the glow era. */
447
+ .neural-mesh {
448
+ background-image: none;
449
+ }
450
+
451
+ .nav-active-indicator {
452
+ position: relative;
453
+ }
454
+
455
+ .nav-active-indicator::before {
456
+ content: "";
457
+ position: absolute;
458
+ left: 0;
459
+ top: 50%;
460
+ transform: translateY(-50%);
461
+ height: 60%;
462
+ width: 3px;
463
+ background-color: hsl(var(--primary));
464
+ border-radius: 0 4px 4px 0;
465
+ }
466
+
467
+ /* Legacy alias — resting L2 surface. */
468
+ .glass {
469
+ background-color: var(--md3-surface-container);
470
+ border: 1px solid var(--md3-outline-variant);
471
+ }
472
+
473
+ /* Atmospheric mesh gradient background */
474
+ .bg-mesh {
475
+ position: relative;
476
+ }
477
+
478
+ /* Mesh + noise retained as flat no-ops — atmospheric texture is the glow era. */
479
+ .bg-mesh::before {
480
+ content: none;
481
+ }
482
+
483
+ .noise {
484
+ background-image: none;
485
+ pointer-events: none;
486
+ }
487
+
488
+ /* Status dots — operational state indicators */
489
+ .status-dot {
490
+ width: 8px;
491
+ height: 8px;
492
+ border-radius: 50%;
493
+ display: inline-block;
494
+ flex-shrink: 0;
495
+ }
496
+
497
+ .status-dot-running {
498
+ background: var(--status-running);
499
+ }
500
+
501
+ .status-dot-creating {
502
+ background: var(--status-creating);
503
+ animation: status-pulse 2s ease-in-out infinite;
504
+ }
505
+
506
+ .status-dot-stopped {
507
+ background: var(--status-stopped);
508
+ }
509
+
510
+ .status-dot-warm {
511
+ background: var(--status-warm);
512
+ }
513
+
514
+ .status-dot-cold {
515
+ background: var(--status-cold);
516
+ }
517
+
518
+ .status-dot-error {
519
+ background: var(--status-error);
520
+ }
521
+
522
+ .status-dot-deleted {
523
+ background: var(--status-deleted);
524
+ opacity: 0.6;
525
+ }
526
+
527
+ /* Terminal cursor */
528
+ .terminal-cursor {
529
+ display: inline-block;
530
+ width: 0.5rem;
531
+ height: 1.25rem;
532
+ background-color: hsl(var(--foreground));
533
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
534
+ }
535
+
536
+ /* Shimmer animation */
537
+ .shimmer {
538
+ background: linear-gradient(
539
+ 90deg,
540
+ hsl(var(--muted)) 0%,
541
+ hsl(var(--accent)) 50%,
542
+ hsl(var(--muted)) 100%
543
+ );
544
+ background-size: 200% 100%;
545
+ animation: shimmer 2s infinite;
546
+ }
547
+
548
+ .shimmer-text {
549
+ background-image: linear-gradient(
550
+ 90deg,
551
+ hsl(var(--muted-foreground) / 0.5) 0%,
552
+ hsl(var(--muted-foreground) / 0.5) 40%,
553
+ hsl(var(--primary)) 50%,
554
+ hsl(var(--muted-foreground) / 0.5) 60%,
555
+ hsl(var(--muted-foreground) / 0.5) 100%
556
+ );
557
+ background-size: 200% 100%;
558
+ -webkit-background-clip: text;
559
+ background-clip: text;
560
+ -webkit-text-fill-color: transparent;
561
+ animation: shimmer 1.1s linear infinite;
562
+ }
563
+
564
+ /* Silenced outright rather than floored, and kept that way: this is shipped
565
+ behaviour of a published class. It is also the correct default under the
566
+ rule stated with the reduced-motion block below — a bare class carries no
567
+ claim about whether the thing it labels is still working. A caller that
568
+ needs the sweep to survive the preference uses `.agent-shimmer` on an
569
+ element that declares `data-motion="essential"`, which is where that claim
570
+ can actually be made. */
571
+ @media (prefers-reduced-motion: reduce) {
572
+ .shimmer-text {
573
+ animation: none;
574
+ }
575
+ }
576
+
577
+ /* Fade in animation */
578
+ .animate-in {
579
+ animation: fade-in 0.25s ease-out;
580
+ }
581
+
582
+ /* A workflow node's content when its density swap lands mid layout-morph:
583
+ the incoming content materializes inside the moving box instead of popping
584
+ in. Opacity only — movement belongs to the layout tween — and applied to
585
+ CONTENT, never to a node's border/surface shell, so the node itself never
586
+ blinks out during the swap. */
587
+ .wf-node-body-in {
588
+ animation: wf-node-body-in 150ms ease-out both;
589
+ }
590
+
591
+ @keyframes wf-node-body-in {
592
+ from { opacity: 0; }
593
+ to { opacity: 1; }
594
+ }
595
+
596
+ @media (prefers-reduced-motion: reduce) {
597
+ .wf-node-body-in {
598
+ animation: none;
599
+ }
600
+ }
601
+
602
+ /* Quiet enter for optimistically-inserted list rows.
603
+ PUBLISHED CLASS — FROZEN TIMING. Ships in `dist/globals.css`, so consumers
604
+ time their own list insertions against these exact 180ms and this exact
605
+ 4px travel DOWNWARD from above. Retiming it to the 600ms `agent-arrive`
606
+ entrance is a 3.3x slowdown and a reversed travel direction in every app
607
+ that already uses it, delivered as a patch with no call site to change; the
608
+ motion vocabulary is not worth that. It stays as shipped.
609
+
610
+ `--motion-arrive` is not read here for the same reason: a retune of the
611
+ token ladder must not reach a class whose timing is the contract. Reduced
612
+ motion still covers it — the universal floor below collapses any animation
613
+ on an element that has not declared itself essential, token or not.
614
+
615
+ New code writes `.agent-arrive` (plus `--stagger-index` for a list), which
616
+ is the vocabulary the rail and the session list ride. */
617
+ .animate-row-in {
618
+ animation: row-in 0.18s ease-out;
619
+ }
620
+
621
+ @keyframes row-in {
622
+ from { opacity: 0; transform: translateY(-4px); }
623
+ to { opacity: 1; transform: translateY(0); }
624
+ }
625
+
626
+ /* Pulse ring animation */
627
+ .pulse-ring {
628
+ position: relative;
629
+ }
630
+
631
+ .pulse-ring::before {
632
+ content: "";
633
+ position: absolute;
634
+ inset: 0;
635
+ border-radius: 9999px;
636
+ animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
637
+ }
638
+
639
+ @keyframes shimmer {
640
+ 0% { background-position: -200% 0; }
641
+ 100% { background-position: 200% 0; }
642
+ }
643
+
644
+ @keyframes fade-in {
645
+ from { opacity: 0; transform: translateY(8px); }
646
+ to { opacity: 1; transform: translateY(0); }
647
+ }
648
+
649
+ @keyframes pulse-ring {
650
+ 0%, 100% { box-shadow: 0 0 0 0 currentColor; opacity: 0.4; }
651
+ 50% { box-shadow: 0 0 0 8px currentColor; opacity: 0; }
652
+ }
653
+
654
+ @keyframes status-pulse {
655
+ 0%, 100% { opacity: 1; }
656
+ 50% { opacity: 0.35; }
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Entrance choreography — the keyframes and the classes that ride them.
662
+ *
663
+ * Unlayered on purpose. Tailwind's own utilities live in its `utilities` layer,
664
+ * and unlayered rules beat every layer, so `.agent-disclose`'s grid rows and
665
+ * `.agent-arrive`'s animation cannot be knocked out by a utility that happens to
666
+ * sort later.
667
+ *
668
+ * Every duration below is a TOKEN, so no class here carries a media query of its
669
+ * own. They are not all reached by the SAME rule, and the difference is
670
+ * deliberate. The entrance classes read the `--duration-*` ladder (through the
671
+ * `--motion-*` pairs) and `--stagger-step`, which the reduced-motion block
672
+ * collapses at `:root`: that reaches every element, unconditionally.
673
+ * `.agent-shimmer` reads `--shimmer-period`, which is deliberately NOT in that
674
+ * ladder, because a value collapsed at the root cannot be un-inherited by the
675
+ * elements that declare `data-motion="essential"` and whose sweep is the only
676
+ * evidence their agent is still working. Everywhere else, that sweep is silenced
677
+ * by the universal floor in the same block. @see `--shimmer-period`.
678
+ *
679
+ * Same names, same keyframes, same values as `@tangle-network/agent-app`'s
680
+ * `theme/tokens.css` — @see the token block at the top of this file for why they
681
+ * are duplicated rather than imported.
682
+ */
683
+
684
+ /* Streamed text: each chunk resolves out of a small blur instead of popping in. */
685
+ @keyframes agent-stream-in {
686
+ from {
687
+ opacity: 0;
688
+ filter: blur(var(--stream-blur));
689
+ }
690
+ to {
691
+ opacity: 1;
692
+ filter: blur(0);
693
+ }
694
+ }
695
+
696
+ /* A row, card or chip that was not there a moment ago. Travels a short distance
697
+ so the eye is told WHERE it came from. */
698
+ @keyframes agent-arrive {
699
+ from {
700
+ opacity: 0;
701
+ transform: translateY(var(--arrive-distance));
702
+ }
703
+ to {
704
+ opacity: 1;
705
+ transform: none;
706
+ }
707
+ }
708
+
709
+ /* A control appearing in place — a chip, a badge, a rail tooltip, a flyout. No
710
+ travel, because it did not come from anywhere. */
711
+ @keyframes agent-pop-in {
712
+ from {
713
+ opacity: 0;
714
+ transform: scale(0.96);
715
+ }
716
+ to {
717
+ opacity: 1;
718
+ transform: none;
719
+ }
720
+ }
721
+
722
+ /* The insertion caret at the end of streaming text. A hard step blink, not a
723
+ fade: a caret that eases is a caret that reads as a loading placeholder. */
724
+ @keyframes agent-caret {
725
+ 0%,
726
+ 49% {
727
+ opacity: 1;
728
+ }
729
+ 50%,
730
+ 100% {
731
+ opacity: 0;
732
+ }
733
+ }
734
+
735
+ /* A label that is WAITING. The sweep is what separates "the agent is working"
736
+ from "the interface is stuck": a static label cannot tell you which. */
737
+ @keyframes agent-shimmer {
738
+ from {
739
+ background-position: 150% center;
740
+ }
741
+ to {
742
+ background-position: -50% center;
743
+ }
744
+ }
745
+
746
+ .agent-stream-in {
747
+ animation: agent-stream-in var(--motion-stream) both;
748
+ }
749
+
750
+ .agent-arrive {
751
+ animation: agent-arrive var(--motion-arrive) both;
752
+ }
753
+
754
+ .agent-pop-in {
755
+ animation: agent-pop-in var(--motion-control) both;
756
+ }
757
+
758
+ /* Stagger by index: set `--stagger-index` and a group arrives as a sequence
759
+ rather than as a flash. Capped at 8 so a 200-session list does not open with a
760
+ 10-second cascade — past the cap every row shares the last delay. */
761
+ .agent-arrive {
762
+ animation-delay: calc(min(var(--stagger-index), 8) * var(--stagger-step));
763
+ }
764
+
765
+ /* The waiting label. `background-clip: text` over a moving gradient, so the
766
+ shimmer runs THROUGH the glyphs rather than behind them.
767
+
768
+ The class alone is decorative and IS silenced by the reduced-motion floor.
769
+ It becomes essential only where the element also declares
770
+ `data-motion="essential"`, which is the caller's assertion that this
771
+ particular label's agent is still working. Class-level exemption would be
772
+ wrong: the same sweep is used as a plain skeleton treatment, and a rule
773
+ cannot tell the two apart. @see the reduced-motion block below. */
774
+ .agent-shimmer {
775
+ background: linear-gradient(
776
+ 90deg,
777
+ hsl(var(--muted-foreground)) 0%,
778
+ hsl(var(--foreground)) 45%,
779
+ hsl(var(--muted-foreground)) 60%
780
+ );
781
+ background-size: 250% auto;
782
+ background-clip: text;
783
+ -webkit-background-clip: text;
784
+ color: transparent;
785
+ animation: agent-shimmer var(--shimmer-period) linear infinite;
786
+ }
787
+
788
+ /* Height-auto disclosure without measuring anything in JavaScript. The row
789
+ animates between 0fr and 1fr; the single child needs `min-height: 0` and
790
+ `overflow: hidden` to be clipped while it travels — it needs no class of its
791
+ own. A max-height guess is the usual alternative and it either clips tall
792
+ content or eases against a number the content never reaches, which for a nav
793
+ disclosure holding an unknown number of sessions is both. */
794
+ .agent-disclose {
795
+ display: grid;
796
+ grid-template-rows: 0fr;
797
+ transition: grid-template-rows var(--motion-surface), opacity var(--motion-control);
798
+ opacity: 0;
799
+ }
800
+
801
+ .agent-disclose > * {
802
+ min-height: 0;
803
+ overflow: hidden;
804
+ }
805
+
806
+ .agent-disclose[data-open="true"] {
807
+ grid-template-rows: 1fr;
808
+ opacity: 1;
809
+ }
810
+
811
+ /**
812
+ * Reduced motion, at the token layer and at the floor.
813
+ *
814
+ * Two rules, because they cover two different populations. Collapsing the
815
+ * duration TOKENS is what makes motion built on this system honour the
816
+ * preference for free — a component that wrote `var(--motion-surface)` or
817
+ * `duration-[var(--duration-fast)]` needs no media query of its own, and gets no
818
+ * chance to forget one. The universal rule under it is the floor for everything
819
+ * that predates the tokens: this package still carries transitions on Tailwind's
820
+ * implicit duration, and a consuming app carries its own. Neither reads a token,
821
+ * so neither would be reached.
822
+ *
823
+ * 1ms rather than 0: a zero-duration transition fires no `transitionend`, and
824
+ * any component that awaits one to unmount or advance a state machine hangs
825
+ * forever. 1ms is imperceptible AND still fires.
826
+ *
827
+ * THE RULE, so the next element is classified the same way this one was:
828
+ *
829
+ * Motion is ESSENTIAL when it is the only evidence that something is
830
+ * happening RIGHT NOW — an operation in flight, a resource actively running.
831
+ * Freeze it and "working" becomes indistinguishable from "stuck", which is
832
+ * information removed. Everything else is DECORATIVE and is silenced.
833
+ *
834
+ * Two consequences make it decidable rather than a matter of taste:
835
+ *
836
+ * - A static label beside the motion does NOT make an in-flight indicator
837
+ * decorative. "Loading processes…" stays on screen whether the fetch is
838
+ * alive or hung; only the spinner separates those.
839
+ * - A state that is NOT in flight is carried by its label, its colour and its
840
+ * shape, so motion on it is emphasis. The `animate-ping` beside "Error
841
+ * fetching logs" says nothing the sentence does not, and it is silenced.
842
+ * Skeleton placeholders are the same case: a frozen skeleton still reads as
843
+ * a placeholder, because the shape is the message.
844
+ *
845
+ * The exemption is declared PER ELEMENT with `data-motion="essential"`, never on
846
+ * a class. `.agent-shimmer` is the reason: the identical sweep marks a session
847
+ * whose agent is mid-turn and dresses a plain skeleton, and only the caller
848
+ * knows which one it built. The exclusion covers the element and its subtree,
849
+ * and `:where()` keeps the selector at zero specificity so a consumer's own rule
850
+ * can still win. Reduced motion is not a design preference to be overridden, it
851
+ * is a request from the person using the product; the only defensible exemption
852
+ * is motion whose absence removes information.
853
+ *
854
+ * Note the asymmetry with the `:root` block: a duration collapsed there reaches
855
+ * every element, `data-motion` included, because a custom property cannot be
856
+ * un-inherited. That is why an essential animation's period is not a
857
+ * `--duration-*` token — @see `--shimmer-period`.
858
+ */
859
+ @media (prefers-reduced-motion: reduce) {
860
+ :root {
861
+ --duration-instant: 1ms;
862
+ --duration-fast: 1ms;
863
+ --duration-base: 1ms;
864
+ --duration-slow: 1ms;
865
+ --duration-stream: 1ms;
866
+ --duration-arrive: 1ms;
867
+ /* The stagger is a delay, not a duration, and a collapsed animation with a
868
+ live delay still arrives as a visible cascade. */
869
+ --stagger-step: 0ms;
870
+ }
871
+
872
+ /* `::before` and `::after` are listed because `*` matches elements only, and
873
+ an animation declared on a pseudo-element is therefore invisible to the
874
+ floor. `.pulse-ring::before` in this very file drew its 2s ring infinitely
875
+ under the preference — measured in Chromium against the built stylesheet —
876
+ while every element around it was collapsed to 1ms. The exemption still
877
+ applies: a pseudo-element of an essential element, or of a descendant of
878
+ one, is not floored. */
879
+ *:where(:not([data-motion="essential"], [data-motion="essential"] *)),
880
+ *:where(:not([data-motion="essential"], [data-motion="essential"] *))::before,
881
+ *:where(:not([data-motion="essential"], [data-motion="essential"] *))::after {
882
+ animation-duration: 1ms !important;
883
+ animation-iteration-count: 1 !important;
884
+ transition-duration: 1ms !important;
885
+ }
886
+
887
+ html {
888
+ scroll-behavior: auto !important;
889
+ }
890
+ }
891
+
892
+ /* Light theme overrides — solid surfaces, no glassmorphism */
893
+ [data-sandbox-theme="vault"] .glass-panel {
894
+ background: var(--md3-surface-container);
895
+ backdrop-filter: none;
896
+ -webkit-backdrop-filter: none;
897
+ border-color: var(--md3-outline-variant);
898
+ box-shadow: var(--shadow-card);
899
+ }
900
+
901
+ [data-sandbox-theme="vault"] .glass-panel:hover {
902
+ border-color: var(--md3-outline);
903
+ }
904
+
905
+ [data-sandbox-theme="vault"] .glass-panel-heavy {
906
+ background: var(--md3-surface-container-high);
907
+ backdrop-filter: none;
908
+ -webkit-backdrop-filter: none;
909
+ border-color: var(--md3-outline-variant);
910
+ box-shadow: var(--shadow-card);
911
+ }
912
+
913
+ [data-sandbox-theme="vault"] .glow-primary {
914
+ box-shadow: none;
915
+ }
916
+
917
+ [data-sandbox-theme="vault"] .glow-primary:hover {
918
+ box-shadow: none;
919
+ }
920
+
921
+ /* Markdown prose — styles the `tangle-prose` surface that @tangle-network/ui's
922
+ * Markdown renderer emits (re-exported from this library's /markdown, /chat and
923
+ * /run entries). ui ships no @tailwindcss/typography, so without these rules
924
+ * headings, lists, tables, and block spacing carry no styling in consumers that
925
+ * source prose CSS solely from @tangle-network/sandbox-ui/styles. Self-contained
926
+ * and token-driven so it re-skins with host, light, vault, and dark colors. */
927
+ .tangle-prose {
928
+ color: hsl(var(--foreground));
929
+ line-height: 1.7;
930
+ }
931
+ /* Zero the browser's default block margins at zero specificity (:where) so the
932
+ * owl selector below owns all vertical rhythm — a plain `.tangle-prose p` reset
933
+ * (0,1,1) would outrank the owl (0,1,0) and collapse the gap between blocks. */
934
+ .tangle-prose :where(p, ul, ol) { margin: 0; }
935
+ .tangle-prose > * + * { margin-top: 0.85em; }
936
+ .tangle-prose h1, .tangle-prose h2, .tangle-prose h3, .tangle-prose h4 {
937
+ font-weight: 600;
938
+ line-height: 1.3;
939
+ margin-top: 1.4em;
940
+ margin-bottom: 0.5em;
941
+ color: inherit;
942
+ }
943
+ .tangle-prose h1 { font-size: 1.4em; }
944
+ .tangle-prose h2 { font-size: 1.25em; }
945
+ .tangle-prose h3 { font-size: 1.1em; }
946
+ .tangle-prose ul, .tangle-prose ol { padding-left: 1.4em; }
947
+ .tangle-prose li { margin: 0.25em 0; }
948
+ .tangle-prose li > ul, .tangle-prose li > ol { margin-top: 0.25em; }
949
+ .tangle-prose a {
950
+ color: var(--accent-text);
951
+ text-decoration: underline;
952
+ text-underline-offset: 2px;
953
+ }
954
+ .tangle-prose blockquote {
955
+ border-left: 2px solid var(--border-default);
956
+ padding-left: 0.9em;
957
+ color: hsl(var(--muted-foreground));
958
+ }
959
+ .tangle-prose hr { border: 0; border-top: 1px solid var(--border-subtle); margin: 1.2em 0; }
960
+ .tangle-prose :not(pre) > code {
961
+ background: var(--md3-surface-container-high);
962
+ padding: 0.12em 0.36em;
963
+ border-radius: 4px;
964
+ font-size: 0.9em;
965
+ font-family: var(--font-mono);
966
+ }
967
+ .tangle-prose pre { margin: 0.85em 0; }
968
+
969
+ /* Tables — hairline dividers + real cell padding, so columns read as a grid
970
+ * instead of colliding. `display: block` + `overflow-x` lets wide tables scroll
971
+ * on narrow viewports; that also makes `border-collapse` inert (it only applies
972
+ * to `display: table`), so dividers come from the per-cell `border-top` /
973
+ * `border-left` and the `thead` underline below — not from border collapsing. */
974
+ .tangle-prose table {
975
+ width: 100%;
976
+ margin: 0.85em 0;
977
+ font-size: 0.95em;
978
+ display: block;
979
+ overflow-x: auto;
980
+ color: inherit;
981
+ }
982
+ .tangle-prose thead { border-bottom: 1px solid var(--border-default); }
983
+ .tangle-prose th {
984
+ text-align: left;
985
+ font-weight: 600;
986
+ padding: 0.5em 0.85em;
987
+ color: inherit;
988
+ white-space: nowrap;
989
+ }
990
+ .tangle-prose td {
991
+ padding: 0.5em 0.85em;
992
+ border-top: 1px solid var(--border-subtle);
993
+ vertical-align: top;
994
+ }
995
+ .tangle-prose th:not(:first-child), .tangle-prose td:not(:first-child) {
996
+ border-left: 1px solid var(--border-subtle);
997
+ }
998
+
999
+ /* Published sources: this package's dist and its @tangle-network/ui peer. */
1000
+ @source "./**/*.js";
1001
+ @source "../../ui/dist/**/*.js";
1002
+ @source "../node_modules/@tangle-network/ui/dist/**/*.js";