crewforth 0.0.1 → 3.0.0-rc.2

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.
Files changed (169) hide show
  1. package/CHANGELOG.md +3805 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1739 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +639 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/skill-listing.awk +43 -0
  29. package/kit/eval/lib/star.sh +70 -0
  30. package/kit/eval/parser-conformance.sh +612 -0
  31. package/kit/eval/preflight.sh +186 -0
  32. package/kit/eval/routing-eval.sh +209 -0
  33. package/kit/eval/scan-skill.sh +162 -0
  34. package/kit/eval/smoke-test.sh +6736 -0
  35. package/kit/eval/update-guard.sh +81 -0
  36. package/kit/eval/utilization.sh +130 -0
  37. package/kit/hooks/board-sync.sh +106 -0
  38. package/kit/hooks/board.sh +962 -0
  39. package/kit/hooks/commit-msg +49 -0
  40. package/kit/hooks/context-usage.sh +353 -0
  41. package/kit/hooks/floor-blocklist.txt +124 -0
  42. package/kit/hooks/guard-bash.sh +1433 -0
  43. package/kit/hooks/guard-commit-scan.sh +511 -0
  44. package/kit/hooks/guard-write.sh +513 -0
  45. package/kit/hooks/pre-commit +577 -0
  46. package/kit/hooks/route-hint.sh +185 -0
  47. package/kit/hooks/secret-blocklist.txt +82 -0
  48. package/kit/hooks/session-guard.sh +149 -0
  49. package/kit/hooks/session-rehydrate.sh +41 -0
  50. package/kit/hooks/session-stats.sh +252 -0
  51. package/kit/hooks/session-update-check.sh +236 -0
  52. package/kit/hooks/skill-trust.sh +163 -0
  53. package/kit/hooks/trace-blocklist.txt +53 -0
  54. package/kit/settings.json +132 -0
  55. package/kit/skills/a11y/SKILL.md +44 -0
  56. package/kit/skills/adr/SKILL.md +74 -0
  57. package/kit/skills/api-design/SKILL.md +50 -0
  58. package/kit/skills/automode-policy/SKILL.md +91 -0
  59. package/kit/skills/automode-policy/references/policy.json +13 -0
  60. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  61. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  62. package/kit/skills/backend-architecture/SKILL.md +103 -0
  63. package/kit/skills/brainstorm/SKILL.md +47 -0
  64. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  65. package/kit/skills/commit-message/SKILL.md +82 -0
  66. package/kit/skills/commit-message/references/examples.md +8 -0
  67. package/kit/skills/confidence-check/SKILL.md +86 -0
  68. package/kit/skills/crew-board/SKILL.md +55 -0
  69. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  70. package/kit/skills/crew-code-review/SKILL.md +151 -0
  71. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  72. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  73. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  74. package/kit/skills/crew-doctor/SKILL.md +46 -0
  75. package/kit/skills/crew-gates/SKILL.md +25 -0
  76. package/kit/skills/crew-handoff/SKILL.md +14 -0
  77. package/kit/skills/crew-plan/SKILL.md +18 -0
  78. package/kit/skills/crew-review/SKILL.md +26 -0
  79. package/kit/skills/crew-ship/SKILL.md +19 -0
  80. package/kit/skills/crew-skill/SKILL.md +62 -0
  81. package/kit/skills/crew-studio/SKILL.md +129 -0
  82. package/kit/skills/crew-update/SKILL.md +43 -0
  83. package/kit/skills/db-migration/SKILL.md +120 -0
  84. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  85. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  86. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  87. package/kit/skills/dependency-audit/SKILL.md +61 -0
  88. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  89. package/kit/skills/deploy/SKILL.md +100 -0
  90. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  91. package/kit/skills/deploy/references/rollback.md +18 -0
  92. package/kit/skills/deploy/references/scripts.md +9 -0
  93. package/kit/skills/deploy/references/topology-a.md +128 -0
  94. package/kit/skills/deploy/references/topology-b.md +44 -0
  95. package/kit/skills/docs-writer/SKILL.md +53 -0
  96. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  97. package/kit/skills/eval-grader/SKILL.md +75 -0
  98. package/kit/skills/eval-grader/references/method.md +51 -0
  99. package/kit/skills/frontend/SKILL.md +56 -0
  100. package/kit/skills/frontend/references/verify-contract.md +41 -0
  101. package/kit/skills/frontend-design/SKILL.md +71 -0
  102. package/kit/skills/frontend-design/references/design-review.md +34 -0
  103. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  104. package/kit/skills/handoff/SKILL.md +71 -0
  105. package/kit/skills/handoff/references/cold-reader.md +51 -0
  106. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  107. package/kit/skills/incident-runbook/SKILL.md +40 -0
  108. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  109. package/kit/skills/iterate/SKILL.md +40 -0
  110. package/kit/skills/mcp-builder/SKILL.md +54 -0
  111. package/kit/skills/mcp-builder/references/building.md +71 -0
  112. package/kit/skills/observability/SKILL.md +50 -0
  113. package/kit/skills/performance/SKILL.md +38 -0
  114. package/kit/skills/performance/references/bottlenecks.md +10 -0
  115. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  116. package/kit/skills/red-team/SKILL.md +48 -0
  117. package/kit/skills/reflect/SKILL.md +57 -0
  118. package/kit/skills/release/SKILL.md +44 -0
  119. package/kit/skills/security-scan/SKILL.md +99 -0
  120. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  121. package/kit/skills/security-scan/references/fronts.md +99 -0
  122. package/kit/skills/security-scan/references/prompting.md +23 -0
  123. package/kit/skills/security-scan/references/reporting.md +75 -0
  124. package/kit/skills/security-scan/references/verify.md +131 -0
  125. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  126. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  127. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  128. package/kit/skills/spec-planning/SKILL.md +92 -0
  129. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  130. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  131. package/kit/skills/teamboard/SKILL.md +88 -0
  132. package/kit/skills/teamboard/references/setup.md +37 -0
  133. package/kit/skills/testing/SKILL.md +49 -0
  134. package/kit/skills/testing/references/flaky-triage.md +51 -0
  135. package/kit/skills/threat-model/SKILL.md +57 -0
  136. package/kit/skills/threat-model/references/interview.md +51 -0
  137. package/kit/skills/threat-model/references/schema.md +42 -0
  138. package/kit/skills/token-budget/SKILL.md +51 -0
  139. package/kit/skills/trace-scan/SKILL.md +43 -0
  140. package/kit/skills/worktree/SKILL.md +40 -0
  141. package/kit/studio/README.md +266 -0
  142. package/kit/studio/ensure-node.sh +471 -0
  143. package/kit/studio/package.json +14 -0
  144. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  145. package/kit/studio/server/index.js +902 -0
  146. package/kit/studio/server/lib/crew-env.js +11 -0
  147. package/kit/studio/server/lib/fleet.js +146 -0
  148. package/kit/studio/server/lib/graph.js +533 -0
  149. package/kit/studio/server/lib/instance.js +135 -0
  150. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  151. package/kit/studio/server/lib/kit.js +158 -0
  152. package/kit/studio/server/lib/palette.js +118 -0
  153. package/kit/studio/server/lib/peers.js +72 -0
  154. package/kit/studio/server/lib/permissions.js +150 -0
  155. package/kit/studio/server/lib/projects.js +294 -0
  156. package/kit/studio/server/lib/roster.js +191 -0
  157. package/kit/studio/server/lib/session.js +309 -0
  158. package/kit/studio/server/lib/terminal.js +91 -0
  159. package/kit/studio/server/lib/transcript.js +100 -0
  160. package/kit/studio/web/app.js +1098 -0
  161. package/kit/studio/web/canvas.js +1179 -0
  162. package/kit/studio/web/chat.js +543 -0
  163. package/kit/studio/web/index.html +86 -0
  164. package/kit/studio/web/md.js +110 -0
  165. package/kit/studio/web/storage-migrate.js +23 -0
  166. package/kit/studio/web/style.css +1453 -0
  167. package/package.json +46 -8
  168. package/start.sh +764 -0
  169. package/bin/crewforth.js +0 -10
@@ -0,0 +1,1453 @@
1
+ /* The panel's palette, light and dark. */
2
+ :root {
3
+ --bg: #f7f8fa;
4
+ --surface: #ffffff;
5
+ --surface-2: #eef0f4;
6
+ --line: #d9dde5;
7
+ --ink: #1a1d24;
8
+ --ink-dim: #5c6472;
9
+ --ink-faint: #8d95a3;
10
+
11
+ --busy: #26c6e6;
12
+ --waiting: #f2a65a;
13
+ --idle: #9aa7cc;
14
+ --good: #35c874;
15
+ --bad: #ff8a4d;
16
+ --accent: #5b8cff;
17
+
18
+ /* Edge tones. A branch that did not finish on its own terms is the one
19
+ thing on this canvas that must not be read as "some other agent colour",
20
+ so it gets a hue of its own rather than the node's.
21
+
22
+ The four weights are tokens and not literals because the same stroke sits
23
+ on white here and on near-black in the dark theme: 0.3 that reads as
24
+ "finished, out of the way" on #0d1017 reads as "barely drawn" on #f7f8fa. */
25
+ --cv-fail: #d92d3f;
26
+ --cv-edge-live: 1;
27
+ --cv-edge-wake: 0.52;
28
+ --cv-edge-done: 0.42;
29
+ --cv-edge-quiet: 0.24;
30
+
31
+ --radius: 10px;
32
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
33
+ --sans: ui-sans-serif, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ }
35
+
36
+ :root:not([data-theme="light"]) {
37
+ --bg: #0d1017;
38
+ --surface: #141821;
39
+ --surface-2: #1b202b;
40
+ --line: #262c39;
41
+ --ink: #e6e9f0;
42
+ --ink-dim: #9aa7cc;
43
+ --ink-faint: #5c6472;
44
+
45
+ --cv-fail: #ff6b7a;
46
+ --cv-edge-live: 0.95;
47
+ --cv-edge-wake: 0.44;
48
+ --cv-edge-done: 0.3;
49
+ --cv-edge-quiet: 0.16;
50
+ }
51
+
52
+ @media (prefers-color-scheme: light) {
53
+ :root:not([data-theme="dark"]) {
54
+ --bg: #f7f8fa;
55
+ --surface: #ffffff;
56
+ --surface-2: #eef0f4;
57
+ --line: #d9dde5;
58
+ --ink: #1a1d24;
59
+ --ink-dim: #5c6472;
60
+ --ink-faint: #8d95a3;
61
+
62
+ --cv-fail: #d92d3f;
63
+ --cv-edge-live: 1;
64
+ --cv-edge-wake: 0.52;
65
+ --cv-edge-done: 0.42;
66
+ --cv-edge-quiet: 0.24;
67
+ }
68
+ }
69
+
70
+ * { box-sizing: border-box; }
71
+
72
+ body {
73
+ margin: 0;
74
+ background: var(--bg);
75
+ color: var(--ink);
76
+ font-family: var(--sans);
77
+ font-size: 14px;
78
+ line-height: 1.5;
79
+ /* Fixed height, not min-height: the canvas sizes itself to its box, so a
80
+ body that can grow past the window puts the bottom of the graph below the
81
+ fold while the fit maths still believes everything is visible. */
82
+ height: 100vh;
83
+ overflow: hidden;
84
+ display: flex;
85
+ flex-direction: column;
86
+ }
87
+
88
+ /* ---- top bar ---- */
89
+ .bar {
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: space-between;
93
+ gap: 16px;
94
+ padding: 12px 20px;
95
+ border-bottom: 1px solid var(--line);
96
+ background: var(--surface);
97
+ }
98
+
99
+ .brand { display: flex; align-items: center; gap: 10px; }
100
+
101
+ .brand h1 {
102
+ margin: 0;
103
+ font-size: 15px;
104
+ font-weight: 600;
105
+ letter-spacing: 0.01em;
106
+ }
107
+
108
+ /* Crewforth's own mark, from assets/mark.svg: three chevrons (›››), drawn as
109
+ strokes. Two palettes: the light ones from the logo read on the dark panel,
110
+ the darker ones from mark.svg read on the light. Same geometry either way. */
111
+ .brand-mark { width: 19px; height: 19px; flex: none; }
112
+ .brand-mark polyline { fill: none; stroke-linecap: round; stroke-linejoin: round; }
113
+ .brand-mark .bar-1 { stroke: #C7CBFB; }
114
+ .brand-mark .bar-2 { stroke: #9AA3F7; }
115
+ .brand-mark .bar-3 { stroke: #7C3AED; }
116
+
117
+ :root:not([data-theme="light"]) .brand-mark .bar-1 { stroke: #E5E7FB; }
118
+ :root:not([data-theme="light"]) .brand-mark .bar-2 { stroke: #B9BEF9; }
119
+ :root:not([data-theme="light"]) .brand-mark .bar-3 { stroke: #A78BFA; }
120
+
121
+ @media (prefers-color-scheme: dark) {
122
+ :root:not([data-theme="light"]) .brand-mark .bar-1 { stroke: #E5E7FB; }
123
+ :root:not([data-theme="light"]) .brand-mark .bar-2 { stroke: #B9BEF9; }
124
+ :root:not([data-theme="light"]) .brand-mark .bar-3 { stroke: #A78BFA; }
125
+ }
126
+ :root[data-theme="dark"] .brand-mark .bar-1 { stroke: #E5E7FB; }
127
+ :root[data-theme="dark"] .brand-mark .bar-2 { stroke: #B9BEF9; }
128
+ :root[data-theme="dark"] .brand-mark .bar-3 { stroke: #A78BFA; }
129
+
130
+ /* Two-tone, the way the wordmark does it. */
131
+ .brand-accent { color: #A78BFA; }
132
+ :root[data-theme="light"] .brand-accent { color: #7C3AED; }
133
+
134
+ .bar-right { display: flex; align-items: center; gap: 12px; }
135
+
136
+ .pulse {
137
+ font-family: var(--mono);
138
+ font-size: 11px;
139
+ color: var(--ink-faint);
140
+ letter-spacing: 0.06em;
141
+ text-transform: uppercase;
142
+ }
143
+ .pulse.on { color: var(--good); }
144
+ .pulse.off { color: var(--bad); }
145
+
146
+ .ghost {
147
+ background: transparent;
148
+ border: 1px solid var(--line);
149
+ color: var(--ink-dim);
150
+ border-radius: 8px;
151
+ padding: 4px 10px;
152
+ cursor: pointer;
153
+ font-size: 13px;
154
+ }
155
+ .ghost:hover { color: var(--ink); border-color: var(--ink-faint); }
156
+
157
+ /* ---- layout ---- */
158
+ .meta { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); }
159
+
160
+ /* ---- fleet ---- */
161
+ .fleet { display: grid; gap: 1px; background: var(--line); }
162
+
163
+ .session {
164
+ display: grid;
165
+ grid-template-columns: auto 1fr auto;
166
+ gap: 14px;
167
+ align-items: center;
168
+ padding: 14px 16px;
169
+ background: var(--surface);
170
+ }
171
+
172
+ .ring {
173
+ width: 12px; height: 12px;
174
+ border-radius: 50%;
175
+ border: 2px solid var(--idle);
176
+ flex: none;
177
+ }
178
+ .ring[data-status="busy"] {
179
+ border-color: var(--busy);
180
+ background: color-mix(in srgb, var(--busy) 30%, transparent);
181
+ animation: breathe 1.6s ease-in-out infinite;
182
+ }
183
+ .ring[data-status="waiting"] {
184
+ border-color: var(--waiting);
185
+ border-style: dashed;
186
+ }
187
+ .ring[data-status="idle"] { border-color: var(--idle); }
188
+
189
+ @keyframes breathe {
190
+ 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--busy) 45%, transparent); }
191
+ 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--busy) 0%, transparent); }
192
+ }
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .ring[data-status="busy"] { animation: none; }
195
+ }
196
+
197
+ .who { min-width: 0; }
198
+ .who .name { font-weight: 600; font-size: 14px; }
199
+ .who .path {
200
+ font-family: var(--mono);
201
+ font-size: 12px;
202
+ color: var(--ink-dim);
203
+ white-space: nowrap;
204
+ overflow: hidden;
205
+ text-overflow: ellipsis;
206
+ }
207
+ .who .waiting-for {
208
+ font-size: 12px;
209
+ color: var(--waiting);
210
+ margin-top: 3px;
211
+ }
212
+
213
+ .stat { text-align: right; font-family: var(--mono); font-size: 11px; color: var(--ink-faint); white-space: nowrap; }
214
+ .stat .status { display: block; font-size: 12px; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.05em; }
215
+
216
+ /* ---- honest empty / unmeasured states ---- */
217
+ .note {
218
+ padding: 28px 20px;
219
+ text-align: center;
220
+ color: var(--ink-dim);
221
+ background: var(--surface);
222
+ }
223
+ .note strong { display: block; color: var(--ink); margin-bottom: 4px; font-weight: 600; }
224
+ .note .why { font-family: var(--mono); font-size: 12px; color: var(--ink-faint); margin-top: 6px; }
225
+ .note.unmeasured strong { color: var(--waiting); }
226
+
227
+ .foot {
228
+ padding: 10px 20px;
229
+ border-top: 1px solid var(--line);
230
+ font-family: var(--mono);
231
+ font-size: 11px;
232
+ color: var(--ink-faint);
233
+ background: var(--surface);
234
+ }
235
+
236
+ /* ============================================================== shell === */
237
+
238
+ .shell {
239
+ position: relative;
240
+ flex: 1; min-height: 0;
241
+ display: grid;
242
+ /* Five columns: the two dividers are real columns, so dragging one moves a
243
+ boundary rather than overlaying it. The conversation sits on the right,
244
+ beside the graph rather than under it — a reply is read next to the work
245
+ it is about, not below the fold. */
246
+ grid-template-columns: var(--side-w, 260px) 5px 1fr 5px var(--chat-w, 420px);
247
+ }
248
+ /* Placed explicitly, never by auto-flow. A `display: none` child occupies no
249
+ grid cell, so hiding the sidebar slid every remaining column one to the left:
250
+ the graph was handed the 22px rail slot and the conversation took the rest. */
251
+ .shell > .side { grid-column: 1; }
252
+ .shell > #resizer { grid-column: 2; }
253
+ .shell > .stage { grid-column: 3; }
254
+ .shell > #chat-split { grid-column: 4; }
255
+ .shell > .chat { grid-column: 5; }
256
+
257
+ .shell.no-chat { grid-template-columns: var(--side-w, 260px) 5px 1fr 0 0; }
258
+ .shell.no-side { grid-template-columns: 0 0 1fr 5px var(--chat-w, 420px); }
259
+ .shell.no-side.no-chat { grid-template-columns: 0 0 1fr 0 0; }
260
+ .shell.no-side > .side, .shell.no-side > #resizer { display: none; }
261
+
262
+ .resizer {
263
+ cursor: col-resize;
264
+ background: var(--line);
265
+ position: relative;
266
+ }
267
+ .resizer::after {
268
+ /* A 5px target is hard to hit; the hit area is wider than the line. */
269
+ content: ''; position: absolute; inset: 0 -4px; z-index: 5;
270
+ }
271
+ .resizer:hover, .resizer:focus-visible, .resizer.dragging { background: var(--accent); outline: none; }
272
+ body.resizing { cursor: col-resize; user-select: none; }
273
+ body.resizing .cv-root { pointer-events: none; }
274
+
275
+ .side {
276
+ border-right: 1px solid var(--line);
277
+ background: var(--surface);
278
+ display: flex;
279
+ flex-direction: column;
280
+ min-height: 0;
281
+ overflow: hidden;
282
+ }
283
+ .side-block { border-bottom: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; }
284
+ .side-block.grow { flex: 1; border-bottom: none; }
285
+ .side-head {
286
+ display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
287
+ padding: 10px 14px; flex: none;
288
+ }
289
+ .side-head h2 {
290
+ margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
291
+ text-transform: uppercase; color: var(--ink-faint);
292
+ }
293
+ .fleet, .sessions { overflow-y: auto; min-height: 0; }
294
+ .fleet { max-height: 30vh; }
295
+
296
+ .session, .srow {
297
+ display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center;
298
+ padding: 8px 14px; border-top: 1px solid var(--line); background: var(--surface);
299
+ }
300
+ .srow { cursor: pointer; }
301
+ .srow:hover { background: var(--surface-2); }
302
+ .srow[aria-current="true"] { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--accent); }
303
+ .srow .sid { font-family: var(--mono); font-size: 12px; }
304
+ .srow .sagents { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); }
305
+ .srow .sbadge {
306
+ font-family: var(--mono); font-size: 10px; color: var(--accent);
307
+ border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
308
+ border-radius: 4px; padding: 0 5px;
309
+ }
310
+ .who .name { font-size: 13px; }
311
+ .who .path { font-size: 11px; }
312
+ .stat { font-size: 10px; }
313
+ .stat .status { font-size: 10px; }
314
+
315
+ .bar-mid { flex: 1; text-align: center; }
316
+
317
+ /* ============================================================== stage === */
318
+
319
+ .stage { position: relative; min-width: 0; display: flex; }
320
+ .canvas { flex: 1; position: relative; overflow: hidden; background: var(--bg); }
321
+
322
+ /* A dot grid reads as a workspace rather than a void, and it makes panning
323
+ legible — without it the canvas looks frozen while it moves. */
324
+ .cv-root {
325
+ background-image: radial-gradient(var(--line) 1px, transparent 1px);
326
+ background-size: 22px 22px;
327
+ cursor: grab;
328
+ touch-action: none;
329
+ user-select: none;
330
+ }
331
+ .cv-root.cv-panning { cursor: grabbing; }
332
+
333
+ .cv-viewport { position: absolute; inset: 0; transform-origin: 0 0; }
334
+ .cv-edges { position: absolute; overflow: visible; pointer-events: none; }
335
+ .cv-nodes { position: absolute; inset: 0; }
336
+
337
+ /* ------------------------------------------------------------- edge ---
338
+ Delegation drawn as motion. The dash travels out of the parent's port and
339
+ into the child's, because the question a glance has to answer is "where did
340
+ the work go", not "these two are related".
341
+
342
+ Every state is carried by at least two channels — weight, dash pattern,
343
+ tone — and motion is only ever the third. That is what makes the
344
+ reduced-motion path below a removal of speed rather than of information. */
345
+
346
+ .cv-edge {
347
+ fill: none;
348
+ stroke-width: 1.6;
349
+ stroke-linecap: round;
350
+ stroke-dasharray: none;
351
+ stroke-dashoffset: 0;
352
+ opacity: var(--cv-edge-done);
353
+ /* One dash period. The flow animation travels exactly this far per
354
+ iteration, so a state can change its dash pattern without putting a seam
355
+ in the loop. */
356
+ --dash-period: 13px;
357
+ }
358
+
359
+ /* Working. The brightest, heaviest, fastest line on the canvas. */
360
+ .cv-edge[data-state="live"] {
361
+ opacity: var(--cv-edge-live);
362
+ stroke-width: 2.2;
363
+ stroke-dasharray: 6px 7px;
364
+ --dash-period: 13px;
365
+ animation: cv-flow 0.9s linear infinite;
366
+ }
367
+
368
+ /* Spawned, nothing back yet. Same direction, half lit, and moving slowly:
369
+ something is on this wire, and it has not produced anything. */
370
+ .cv-edge[data-state="waking"] {
371
+ opacity: var(--cv-edge-wake);
372
+ stroke-width: 1.6;
373
+ stroke-dasharray: 2px 7px;
374
+ --dash-period: 9px;
375
+ animation: cv-flow 1.9s linear infinite;
376
+ }
377
+
378
+ /* Finished. Solid and pulled back, so live work stands out against it. */
379
+ .cv-edge[data-state="done"] {
380
+ opacity: var(--cv-edge-done);
381
+ stroke-width: 1.6;
382
+ stroke-dasharray: none;
383
+ }
384
+
385
+ /* Ended, stale. Still there, saying nothing. */
386
+ .cv-edge[data-state="quiet"] {
387
+ opacity: var(--cv-edge-quiet);
388
+ stroke-width: 1.4;
389
+ stroke-dasharray: 2px 6px;
390
+ }
391
+
392
+ /* Wrong, and said in colour rather than in a label somewhere else. The stroke
393
+ itself is set to var(--cv-fail) rather than the child's palette colour. */
394
+ .cv-edge[data-state="failed"] {
395
+ opacity: 1;
396
+ stroke-width: 2.4;
397
+ stroke-dasharray: 1px 5px;
398
+ }
399
+
400
+ /* A workflow's members are one dispatch. They share a weight here and a hue
401
+ family in canvas.js, so twelve of them read as a bundle rather than as
402
+ twelve independent decisions. */
403
+ .cv-edge[data-group="member"] { stroke-width: 1.4; }
404
+ .cv-edge[data-group="member"][data-state="live"] { stroke-width: 1.8; }
405
+
406
+ /* Arrival. The edge draws itself from the parent toward the node that just
407
+ appeared. Both the pattern and the offset live in the keyframes, so with
408
+ the animation switched off the edge simply renders in its final state
409
+ wearing its own status dash. */
410
+ .cv-edge.cv-drawing { animation: cv-draw 0.3s ease-out; }
411
+
412
+ @keyframes cv-flow { to { stroke-dashoffset: calc(-1 * var(--dash-period)); } }
413
+ @keyframes cv-draw {
414
+ from { stroke-dasharray: var(--edge-len) var(--edge-len); stroke-dashoffset: var(--edge-len); }
415
+ to { stroke-dasharray: var(--edge-len) var(--edge-len); stroke-dashoffset: 0; }
416
+ }
417
+
418
+ /* ------------------------------------------------------------- node --- */
419
+
420
+ .cv-node {
421
+ position: absolute;
422
+ width: 236px;
423
+ min-height: 104px;
424
+ box-sizing: border-box;
425
+ background: var(--surface);
426
+ border: 1px solid var(--line);
427
+ border-radius: 12px;
428
+ padding: 12px 14px 12px 18px;
429
+ cursor: grab;
430
+ transition: box-shadow 0.15s, border-color 0.15s, opacity 0.25s, scale 0.25s;
431
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.18), 0 8px 20px -12px rgb(0 0 0 / 0.4);
432
+ }
433
+ .cv-node:hover { border-color: color-mix(in srgb, var(--node-color) 55%, var(--line)); }
434
+ .cv-node.cv-dragging { cursor: grabbing; z-index: 10; box-shadow: 0 12px 34px -10px rgb(0 0 0 / 0.55); }
435
+ .cv-node.cv-selected {
436
+ border-color: var(--node-color);
437
+ box-shadow: 0 0 0 1px var(--node-color), 0 12px 30px -14px var(--node-color);
438
+ }
439
+ .cv-node.cv-born { opacity: 0; scale: 0.9; }
440
+
441
+ .cv-accent {
442
+ position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px;
443
+ border-radius: 0 3px 3px 0; background: var(--node-color);
444
+ }
445
+
446
+ /* A running card, legibly alive when nothing else on the canvas is moving.
447
+ One pulse per card and no more: the accent bar used to breathe as well, and
448
+ two rhythms on one card is the wall of throbbing this is trying to avoid.
449
+
450
+ The ring is drawn whether or not it breathes, so "running" survives with
451
+ motion off; breathing is the only part the animation adds. Only opacity is
452
+ animated — the shadow itself never changes, so nothing re-rasterises. */
453
+ .cv-node[data-state="live"]::after {
454
+ content: '';
455
+ position: absolute; inset: -2px;
456
+ border-radius: 14px;
457
+ pointer-events: none;
458
+ box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--node-color) 55%, transparent);
459
+ animation: cv-pulse 2.4s ease-in-out infinite;
460
+ }
461
+ @keyframes cv-pulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.28; } }
462
+
463
+ /* Failed, killed, stopped — one look, and not the look of a colour some agent
464
+ might legitimately own. */
465
+ .cv-node[data-state="failed"] { border-color: var(--cv-fail); }
466
+ .cv-node[data-state="failed"] .cv-accent { background: var(--cv-fail); }
467
+ .cv-node[data-state="failed"] .cv-chip {
468
+ color: var(--cv-fail);
469
+ border-color: color-mix(in srgb, var(--cv-fail) 50%, transparent);
470
+ }
471
+
472
+ .cv-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
473
+ .cv-type {
474
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
475
+ letter-spacing: 0.06em; text-transform: uppercase; color: var(--node-color);
476
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
477
+ }
478
+ .cv-type.cv-unknown::after {
479
+ content: '?'; margin-left: 5px; color: var(--ink-faint);
480
+ border: 1px solid var(--line); border-radius: 3px; padding: 0 3px; font-size: 9px;
481
+ }
482
+
483
+ .cv-chip {
484
+ font-family: var(--mono); font-size: 10px; letter-spacing: 0.05em;
485
+ text-transform: uppercase; padding: 1px 6px; border-radius: 999px;
486
+ border: 1px solid var(--line); color: var(--ink-faint); white-space: nowrap; flex: none;
487
+ }
488
+ .cv-chip[data-status="running"] { color: var(--busy); border-color: color-mix(in srgb, var(--busy) 45%, transparent); }
489
+ .cv-chip[data-status="done"] { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, transparent); }
490
+ .cv-chip[data-status="stale"] { color: var(--waiting); border-color: color-mix(in srgb, var(--waiting) 40%, transparent); }
491
+ .cv-chip[data-status="starting"]{ color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
492
+
493
+ .cv-desc {
494
+ margin-top: 7px; font-size: 13px; line-height: 1.35; color: var(--ink);
495
+ display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2;
496
+ -webkit-box-orient: vertical; overflow: hidden;
497
+ }
498
+ .cv-node[data-kind="session"] .cv-desc { font-family: var(--mono); font-size: 12px; color: var(--ink-dim); }
499
+
500
+ .cv-foot { margin-top: 9px; display: flex; flex-wrap: wrap; gap: 5px; }
501
+ .cv-bit {
502
+ font-family: var(--mono); font-size: 10px; color: var(--ink-faint);
503
+ background: var(--surface-2); border-radius: 4px; padding: 1px 6px;
504
+ }
505
+ .cv-bit.cv-live { color: var(--busy); background: color-mix(in srgb, var(--busy) 14%, transparent); }
506
+ .cv-bit.cv-err { color: var(--bad); background: color-mix(in srgb, var(--bad) 14%, transparent); }
507
+ .cv-bit.cv-branch { color: var(--ink-dim); }
508
+
509
+ /* Ports sit on whichever axis the flow runs along. */
510
+ .cv-port {
511
+ position: absolute; width: 9px; height: 9px; border-radius: 50%;
512
+ background: var(--surface); border: 2px solid var(--node-color);
513
+ }
514
+ [data-flow="down"] .cv-port { left: 50%; transform: translateX(-50%); }
515
+ [data-flow="down"] .cv-in { top: -5px; }
516
+ [data-flow="down"] .cv-out { bottom: -5px; }
517
+
518
+ [data-flow="right"] .cv-port { top: 50%; transform: translateY(-50%); }
519
+ [data-flow="right"] .cv-in { left: -5px; }
520
+ [data-flow="right"] .cv-out { right: -5px; }
521
+ .cv-node[data-kind="session"] .cv-in { display: none; }
522
+
523
+ /* --------------------------------------------------------------- hud --- */
524
+
525
+ .cv-hud {
526
+ position: absolute; right: 14px; bottom: 14px; z-index: 20;
527
+ display: flex; align-items: center; gap: 6px;
528
+ background: var(--surface); border: 1px solid var(--line);
529
+ border-radius: 9px; padding: 4px 8px;
530
+ }
531
+ .cv-btn {
532
+ background: transparent; border: none; color: var(--ink-dim);
533
+ cursor: pointer; font-size: 14px; line-height: 1; padding: 3px 5px; border-radius: 5px;
534
+ }
535
+ .cv-btn:hover { color: var(--ink); background: var(--surface-2); }
536
+ .cv-zoom {
537
+ font-family: var(--mono); font-size: 10px; color: var(--ink-faint);
538
+ min-width: 96px; text-align: right; white-space: nowrap;
539
+ }
540
+ /* Loud on purpose: it only appears when something went wrong. */
541
+ .cv-btn.cv-alarm { color: var(--cv-fail); font-family: var(--mono); font-size: 11px; }
542
+ .cv-btn.cv-alarm:hover { color: var(--cv-fail); background: color-mix(in srgb, var(--cv-fail) 16%, transparent); }
543
+ .cv-btn[hidden] { display: none; }
544
+
545
+ .cv-empty {
546
+ position: absolute; inset: 0; display: grid; place-content: center;
547
+ text-align: center; color: var(--ink-dim); padding: 24px; pointer-events: none;
548
+ }
549
+ .cv-empty strong { display: block; color: var(--ink); margin-bottom: 6px; font-size: 15px; }
550
+
551
+ /* --------------------------------------------------------- inspector --- */
552
+
553
+ .inspector {
554
+ width: 300px; flex: none; border-left: 1px solid var(--line);
555
+ background: var(--surface); overflow-y: auto; padding: 16px;
556
+ }
557
+ .inspector h3 {
558
+ margin: 0 0 2px; font-family: var(--mono); font-size: 12px;
559
+ letter-spacing: 0.06em; text-transform: uppercase;
560
+ }
561
+ .inspector .isub { color: var(--ink-dim); font-size: 13px; margin-bottom: 14px; }
562
+ .inspector dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
563
+ .inspector dt { font-size: 11px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.05em; }
564
+ .inspector dd { margin: 0; font-family: var(--mono); font-size: 12px; word-break: break-word; }
565
+ .inspector .tools { margin-top: 16px; display: flex; flex-wrap: wrap; gap: 5px; }
566
+
567
+ @media (max-width: 900px) {
568
+ .shell { grid-template-columns: 1fr; }
569
+ .side, .resizer { display: none; }
570
+ .inspector { display: none; }
571
+ }
572
+ /* =================================================== three readings ===
573
+ One picture, read three ways, chosen by how much of a card would actually
574
+ survive on screen. Nothing moves between them: the layout metrics are
575
+ identical in all three, so the shape of the graph is the constant and only
576
+ what is written inside a card changes.
577
+
578
+ The idea is not "hide things at low zoom" — hiding alone still leaves an
579
+ unreadable title. What survives is drawn at a constant SCREEN size: a world
580
+ font-size of 11px/k is 11px after the canvas transform, whatever k is.
581
+ canvas.js publishes k as --k and the reading as data-lod on the root, and
582
+ every card responds through the cascade, so a band change costs nothing per
583
+ node and cannot move the world that decided the band.
584
+
585
+ Registered so that a malformed write falls back to 1 rather than blanking
586
+ 250 cards. k is clamped to MIN_K = 0.06 in canvas.js, so no divisor here is
587
+ ever zero. (The selfcheck's CSS parser drops at-rules, so this registration
588
+ is invisible to the gate — it is verified in a browser.) */
589
+ @property --k { syntax: '<number>'; initial-value: 1; inherits: true; }
590
+
591
+ /* NEAR — k >= 0.70. The card exactly as it has always been drawn, so it has
592
+ no rules of its own here. */
593
+
594
+ /* Blocks a smaller reading gives up fade rather than vanish, so the eye reads
595
+ a substitution instead of a loss. */
596
+ .cv-desc, .cv-foot, .cv-chip { transition: opacity 120ms; }
597
+ /* The same element is the 14px source glyph up close and the hue disc far
598
+ out; it travels there rather than being swapped. */
599
+ .cv-mark { transition: transform 160ms ease, width 160ms ease, height 160ms ease; }
600
+
601
+ /* MID — 0.20 <= k < 0.70, under the label budget. Short type, status chip,
602
+ mark, fold caret. The description and the foot bits are dropped not because
603
+ they are small but because they are DARK: the description fills the lower
604
+ half of the card in unreadable grey, and forty of those lower halves is the
605
+ noise floor that stops the type line reading. Removing them is what creates
606
+ the contrast the type needs. */
607
+ [data-lod="mid"] .cv-desc,
608
+ [data-lod="mid"] .cv-foot { opacity: 0; }
609
+
610
+ /* The chip keeps its channel and loses its words. Its text is a 10px absolute
611
+ glyph, so at the bottom of this band it is 2px on screen — unreadable, and
612
+ measured in a browser it was eating the width the type line needed: SESSION
613
+ was clipping to SESSI and REVIEWER to REVIEWE. As a dot it still says the
614
+ status in colour and gives 45 world px back to the name. */
615
+ [data-lod="mid"] .cv-chip {
616
+ font-size: 0;
617
+ padding: 0;
618
+ width: calc(7px / var(--k)); /* 7 SCREEN px at any k */
619
+ height: calc(7px / var(--k));
620
+ border-radius: 50%;
621
+ background: currentColor;
622
+ border-color: currentColor;
623
+ }
624
+
625
+ /* The real type line is taken out of the flow and redrawn counter-scaled, as
626
+ the short name: the crew- prefix and the -expert / -agent suffix dropped. */
627
+ [data-lod="mid"] .cv-type,
628
+ [data-lod="far"] .cv-type { font-size: 0; letter-spacing: 0; }
629
+ [data-lod="mid"] .cv-type::after,
630
+ [data-lod="far"] .cv-type::after { content: none; }
631
+ [data-lod="mid"] .cv-type::before {
632
+ content: attr(data-short);
633
+ font-size: calc(11px / var(--k)); /* 11 SCREEN px at any k */
634
+ letter-spacing: 0.04em;
635
+ transition: font-size 120ms ease;
636
+ }
637
+
638
+ /* MID, THE LIVE EXEMPTION. `far` below already establishes this principle —
639
+ "with 250 strokes on screen the only bright ones must be the branches that
640
+ are alive" — and gives the live card its name, its mark and a z-index. It
641
+ was never applied to THIS band, which is the one a viewer actually sits in:
642
+ measured at 60% with fourteen agents, and there the running card is
643
+ distinguishable from thirteen finished ones only by the hue of a 7px disc.
644
+ The panel's own README promises "which agent is running, what it is doing
645
+ right now"; at mid it was keeping neither.
646
+ The description is dropped at mid because forty dark lower halves are a
647
+ noise floor that stops the type line reading. ONE is not a floor — it is
648
+ the signal, and it is the only field that says WHICH agent. It is drawn
649
+ counter-scaled like every other survivor in this block, so it clears the
650
+ same 9px screen floor that set the band rather than undercutting it, and
651
+ clamped to one line so it cannot reclaim the whole lower half. */
652
+ [data-lod="mid"] .cv-node[data-state="live"] .cv-desc {
653
+ opacity: 1;
654
+ font-size: calc(11px / var(--k)); /* 11 SCREEN px at any k */
655
+ -webkit-line-clamp: 1; line-clamp: 1;
656
+ }
657
+ /* The chip keeps its WORD here instead of collapsing to a disc. The disc
658
+ exists because 10px of chip text is 2px on screen at the bottom of this
659
+ band and was clipping the type line; the running card is the one place that
660
+ trade is wrong, so its chip is counter-scaled instead of silenced. Every
661
+ property the disc rule set has to be undone explicitly, or the word renders
662
+ inside a 7px circle. */
663
+ [data-lod="mid"] .cv-node[data-state="live"] .cv-chip {
664
+ font-size: calc(9px / var(--k));
665
+ padding: 0 calc(5px / var(--k));
666
+ width: auto; height: auto;
667
+ border-radius: calc(999px / var(--k));
668
+ background: transparent;
669
+ border-color: color-mix(in srgb, var(--busy) 45%, transparent);
670
+ }
671
+ /* Rides above its neighbours for the same reason it does at `far`: the words
672
+ it just kept must not sit under the next card. */
673
+ [data-lod="mid"] .cv-node[data-state="live"] { z-index: 5; }
674
+
675
+ /* FAR — k < 0.20, or more drawn nodes than the label budget. No text is
676
+ attempted for an ordinary node, because none would survive. Three things
677
+ still resolve at this size and each gets a channel: POSITION (which type
678
+ group, which depth — the layout already encodes it), HUE (--node-color, the
679
+ one place colour is decided) and a RING (status). */
680
+ [data-lod="far"] {
681
+ /* Screen diameter of the disc: floored so it survives the tightest node
682
+ pitch, capped so it never outgrows its cell. Divided back out of k to get
683
+ the world size that draws it. */
684
+ --dot-screen: clamp(5px, calc(88 * var(--k) * 1px), 16px);
685
+ --dots: calc(var(--dot-screen) / var(--k));
686
+ }
687
+
688
+ /* The card body stops being a rectangle, because the rectangle is the wall. */
689
+ [data-lod="far"] .cv-node {
690
+ background: transparent;
691
+ border-color: transparent;
692
+ box-shadow: none;
693
+ /* Pinned rather than left to min-height. A long description pushes the box
694
+ past 104px, and since the disc is centred on the box rather than on the
695
+ cell, cards with more text put their disc a few world px lower than their
696
+ neighbours — a lattice of dots that does not quite line up. Nothing
697
+ visible is clipped: what overflows here is already at opacity 0. */
698
+ height: 104px;
699
+ }
700
+ /* Except the one the user picked, which still has to be findable. */
701
+ [data-lod="far"] .cv-node.cv-selected {
702
+ box-shadow: 0 0 0 calc(1.5px / var(--k)) var(--node-color);
703
+ }
704
+ [data-lod="far"] .cv-desc,
705
+ [data-lod="far"] .cv-foot,
706
+ [data-lod="far"] .cv-chip { opacity: 0; }
707
+ [data-lod="far"] .cv-accent,
708
+ [data-lod="far"] .cv-port,
709
+ [data-lod="far"] .cv-fold { display: none; }
710
+ /* The card ring is a 236x104 rectangle; the disc carries "running" instead. */
711
+ [data-lod="far"] .cv-node[data-state="live"]::after { display: none; }
712
+
713
+ /* .cv-mark becomes the disc, in place. */
714
+ [data-lod="far"] .cv-mark {
715
+ position: absolute;
716
+ left: 18px; /* the padding it already sat behind */
717
+ top: 12px;
718
+ width: var(--dots);
719
+ height: var(--dots);
720
+ /* 93 and 33 carry a 14px mark from the head to the card's visual centre —
721
+ (236-14)/2 - 18 and (104-14)/2 - 12 — and the half-diameter term holds the
722
+ disc on that centre as it grows. */
723
+ transform: translate(calc(93px + 7px - var(--dots) / 2), calc(33px + 7px - var(--dots) / 2));
724
+ border-radius: 50%;
725
+ background: var(--node-color);
726
+ /* A screen-constant hairline around every agent circle. */
727
+ box-shadow: 0 0 0 calc(1.4px / var(--k)) color-mix(in srgb, var(--ink) 34%, transparent);
728
+ }
729
+ /* An 11-ray burst is mush at 7px. */
730
+ [data-lod="far"] .cv-mark > * { display: none; }
731
+ /* Source survives as SHAPE, which reads at 7px where a glyph does not. */
732
+ [data-lod="far"] .cv-node[data-source="kit"] .cv-mark { border-radius: 3px; }
733
+ /* Status survives as the ring — the chip's colour, moved. */
734
+ [data-lod="far"] .cv-node[data-state="live"] .cv-mark {
735
+ box-shadow: 0 0 0 calc(2px / var(--k)) var(--busy);
736
+ }
737
+ [data-lod="far"] .cv-node[data-state="failed"] .cv-mark {
738
+ box-shadow: 0 0 0 calc(2px / var(--k)) var(--cv-fail);
739
+ }
740
+
741
+ /* Who keeps a label out here. This is what makes the far band a picture
742
+ rather than a field of anonymous dots, and it answers the question the panel
743
+ exists for — which agent is running, and what is it doing — without a click.
744
+ Running and failed branches keep their words whatever the budget says; the
745
+ session and the workflows keep theirs because they are few and they anchor
746
+ the picture.
747
+
748
+ POSITIVE SELECTORS ONLY. The selfcheck's matcher treats a pseudo-class it
749
+ does not model as a non-match, so a :not() rule here would resolve to "no
750
+ match" in the gate and pass green for a rule the browser is running and the
751
+ test never saw. */
752
+ [data-lod="far"] .cv-node[data-state="live"] .cv-type::before,
753
+ [data-lod="far"] .cv-node[data-state="failed"] .cv-type::before,
754
+ [data-lod="far"] .cv-node[data-kind="session"] .cv-type::before,
755
+ [data-lod="far"] .cv-node[data-kind="workflow"] .cv-type::before {
756
+ content: attr(data-far);
757
+ font-size: calc(11px / var(--k));
758
+ letter-spacing: 0.04em;
759
+ transition: font-size 120ms ease;
760
+ }
761
+ /* Centred under its own disc and allowed out of the card, because at this size
762
+ the card is no longer a box — the disc is the node. */
763
+ [data-lod="far"] .cv-node[data-state="live"] .cv-type,
764
+ [data-lod="far"] .cv-node[data-state="failed"] .cv-type,
765
+ [data-lod="far"] .cv-node[data-kind="session"] .cv-type,
766
+ [data-lod="far"] .cv-node[data-kind="workflow"] .cv-type {
767
+ position: absolute;
768
+ left: 50%; /* = 118px, the disc's centre */
769
+ top: calc(52px + var(--dots) * 0.62);
770
+ transform: translateX(-50%);
771
+ overflow: visible;
772
+ text-overflow: clip;
773
+ }
774
+ /* Their words ride above the field rather than under the next card. */
775
+ [data-lod="far"] .cv-node[data-state="live"],
776
+ [data-lod="far"] .cv-node[data-state="failed"] { z-index: 5; }
777
+
778
+ /* With 250 strokes on screen the only bright ones must be the branches that
779
+ are alive and the branches that went wrong. */
780
+ [data-lod="far"] .cv-edge[data-state="done"],
781
+ [data-lod="far"] .cv-edge[data-state="quiet"] { opacity: var(--cv-edge-quiet); }
782
+
783
+ /* ------------------------------------------------------- stillness ---
784
+ Two ways the canvas stops moving, and one set of rules for both.
785
+
786
+ The first is the motion budget: canvas.js counts what would be in motion
787
+ and stamps data-motion="still" on the root past MOTION_BUDGET. A dash
788
+ travelling along a stroke is paint work, not compositor work, so its cost
789
+ scales with the number of moving strokes; 250 of them is a real session.
790
+
791
+ The second is the viewer asking for it.
792
+
793
+ Both land in the same place because every state above is already carried by
794
+ weight, dash and tone. Nothing here has a state to lose — only a speed. */
795
+
796
+ [data-motion="still"] .cv-edge,
797
+ [data-motion="still"] .cv-edge.cv-drawing,
798
+ [data-motion="still"] .cv-node::after { animation: none; }
799
+ [data-motion="still"] .cv-node.cv-born { opacity: 1; scale: 1; }
800
+ /* A reading change is motion too — the mark travels to the card's centre and
801
+ the label resizes. `.cv-node { transition: none }` does not reach a child;
802
+ that is specificity, not the media query, so each of these carries at least
803
+ the weight of the rule it cancels. */
804
+ [data-motion="still"] .cv-mark,
805
+ [data-motion="still"] .cv-type::before,
806
+ [data-motion="still"] .cv-desc,
807
+ [data-motion="still"] .cv-foot,
808
+ [data-motion="still"] .cv-chip { transition: none; }
809
+
810
+ /* A media query adds no specificity. `.cv-edge` alone loses to
811
+ `.cv-edge[data-state="live"]` and the flow keeps running, so each selector
812
+ here carries at least the weight of the rule it has to cancel. */
813
+ @media (prefers-reduced-motion: reduce) {
814
+ .cv-edge[data-state],
815
+ .cv-edge.cv-drawing,
816
+ .cv-node[data-state]::after { animation: none; }
817
+ .cv-node { transition: none; }
818
+ .cv-node .cv-mark,
819
+ .cv-node .cv-type::before,
820
+ .cv-node .cv-desc,
821
+ .cv-node .cv-foot,
822
+ .cv-node .cv-chip { transition: none; }
823
+ /* The birth class comes off a frame later, which with no transition left is
824
+ a blink from invisible to placed. Land it in its final state instead. */
825
+ .cv-node.cv-born { opacity: 1; scale: 1; }
826
+ }
827
+
828
+ /* ==================================================== inspector detail === */
829
+
830
+ .inspector { width: 340px; transition: width 0.18s; display: flex; flex-direction: column; padding: 0; }
831
+ .inspector.wide { width: min(760px, 55vw); }
832
+
833
+ .ihead { display: flex; align-items: center; gap: 8px; padding: 14px 16px 0; }
834
+ .ihead h3 { flex: 1; margin: 0; }
835
+ .iwide { padding: 2px 8px; font-size: 12px; line-height: 1.2; }
836
+ .inspector .isub { padding: 4px 16px 12px; margin: 0; }
837
+
838
+ .itabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); flex: none; }
839
+ .itab {
840
+ background: none; border: none; cursor: pointer;
841
+ font-family: var(--mono); font-size: 11px; letter-spacing: 0.05em; text-transform: uppercase;
842
+ color: var(--ink-faint); padding: 8px 10px; border-bottom: 2px solid transparent; margin-bottom: -1px;
843
+ }
844
+ .itab:hover { color: var(--ink-dim); }
845
+ .itab.on { color: var(--ink); border-bottom-color: var(--accent); }
846
+
847
+ .ibody { flex: 1; overflow-y: auto; padding: 14px 16px 24px; min-height: 0; }
848
+ .ihint { color: var(--ink-faint); font-size: 13px; line-height: 1.5; padding: 6px 0; }
849
+ .ibar { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
850
+ .ibar .ghost { font-family: var(--mono); font-size: 11px; padding: 2px 8px; }
851
+
852
+ /* ------------------------------------------------------- activity list --- */
853
+
854
+ .itimeline { display: flex; flex-direction: column; gap: 1px; }
855
+ .istep {
856
+ display: grid; grid-template-columns: 22px 62px 1fr; gap: 8px; align-items: baseline;
857
+ padding: 5px 0; border-bottom: 1px solid var(--line);
858
+ }
859
+ .istep-n { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); text-align: right; }
860
+ .istep-tool { font-family: var(--mono); font-size: 11px; color: var(--accent); }
861
+ .istep-label { font-size: 12px; color: var(--ink-dim); overflow-wrap: anywhere; }
862
+ .md-narration { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line); opacity: 0.85; }
863
+
864
+ /* ---------------------------------------------------------- markdown --- */
865
+
866
+ .md { font-size: 13px; line-height: 1.6; color: var(--ink); overflow-wrap: anywhere; }
867
+ .md-h { margin: 18px 0 6px; font-size: 13px; font-weight: 600; color: var(--ink); letter-spacing: 0.01em; }
868
+ .md-h:first-child { margin-top: 0; }
869
+ h4.md-h { font-size: 14px; }
870
+ h5.md-h, h6.md-h { font-size: 12px; color: var(--ink-dim); text-transform: uppercase; letter-spacing: 0.05em; }
871
+ .md-p { margin: 0 0 9px; }
872
+ .md-list { margin: 0 0 9px; padding-left: 20px; }
873
+ .md-list li { margin-bottom: 3px; }
874
+ .md code {
875
+ font-family: var(--mono); font-size: 0.9em;
876
+ background: var(--surface-2); border-radius: 4px; padding: 1px 4px;
877
+ }
878
+ .md-code {
879
+ background: var(--surface-2); border: 1px solid var(--line); border-radius: 8px;
880
+ padding: 10px 12px; overflow-x: auto; margin: 0 0 10px;
881
+ font-family: var(--mono); font-size: 11.5px; line-height: 1.5;
882
+ }
883
+ .md-code code { background: none; padding: 0; }
884
+ .md-quote {
885
+ margin: 0 0 10px; padding: 6px 0 6px 12px;
886
+ border-left: 2px solid var(--accent); color: var(--ink-dim);
887
+ }
888
+ .md-hr { border: none; border-top: 1px solid var(--line); margin: 16px 0; }
889
+ .md-link { color: var(--accent); }
890
+ .md-tablewrap { overflow-x: auto; margin: 0 0 12px; }
891
+ .md-table { border-collapse: collapse; font-size: 12px; min-width: 100%; }
892
+ .md-table th, .md-table td {
893
+ border: 1px solid var(--line); padding: 5px 9px; text-align: left; vertical-align: top;
894
+ }
895
+ .md-table th { background: var(--surface-2); font-weight: 600; white-space: nowrap; }
896
+
897
+ /* `display: flex` on .inspector outranks the UA's `[hidden] { display: none }`,
898
+ so the hidden panel kept its width. Restate it explicitly. */
899
+ .inspector[hidden] { display: none; }
900
+
901
+ /* `overflow-wrap: anywhere` keeps long paths inside the panel, but inside a
902
+ table it shreds every cell into one letter per line. Tables get their real
903
+ width back and scroll sideways instead. */
904
+ .md-table th, .md-table td { overflow-wrap: normal; min-width: 76px; }
905
+ .md-table td { hyphens: none; }
906
+
907
+ /* ================================================= project sidebar === */
908
+
909
+ .side-filter { padding: 0 12px 8px; flex: none; }
910
+ .side-filter input {
911
+ width: 100%; box-sizing: border-box;
912
+ background: var(--bg); border: 1px solid var(--line); border-radius: 7px;
913
+ color: var(--ink); font-family: var(--mono); font-size: 11px; padding: 5px 8px;
914
+ }
915
+ .side-filter input:focus { outline: none; border-color: var(--accent); }
916
+ .side-filter input::placeholder { color: var(--ink-faint); }
917
+
918
+ .pgroup {
919
+ display: grid; grid-template-columns: 14px 1fr auto; gap: 7px; align-items: center;
920
+ padding: 7px 14px; cursor: pointer; border-top: 1px solid var(--line);
921
+ background: var(--surface-2);
922
+ }
923
+ .pgroup:hover { background: color-mix(in srgb, var(--accent) 8%, var(--surface-2)); }
924
+ .pgroup.current { box-shadow: inset 3px 0 0 var(--accent); }
925
+ .pcaret { font-size: 9px; color: var(--ink-faint); }
926
+ .plabel {
927
+ font-size: 12px; font-weight: 600;
928
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
929
+ }
930
+ .pcount { display: flex; gap: 5px; align-items: center; font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
931
+
932
+ .srow {
933
+ display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center;
934
+ padding: 6px 14px 6px 28px; border-top: 1px solid var(--line);
935
+ background: var(--surface); cursor: pointer;
936
+ }
937
+ .srow:hover { background: var(--surface-2); }
938
+ .srow[aria-current="true"] { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--good); }
939
+ .sname {
940
+ font-size: 12px; color: var(--ink);
941
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
942
+ }
943
+ .srow[aria-current="true"] .sname { font-weight: 600; }
944
+ .sright { display: flex; gap: 5px; align-items: center; }
945
+ .sagents { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
946
+ .sbadge {
947
+ font-family: var(--mono); font-size: 9px; color: var(--accent);
948
+ border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
949
+ border-radius: 4px; padding: 0 4px;
950
+ }
951
+ .ptrunc {
952
+ padding: 5px 14px 5px 28px; font-family: var(--mono); font-size: 10px;
953
+ color: var(--ink-faint); background: var(--surface); border-top: 1px solid var(--line);
954
+ }
955
+
956
+ /* ------------------------------------------------------- kit version --- */
957
+
958
+ .kitbadge {
959
+ font-family: var(--mono); font-size: 9px; padding: 0 4px;
960
+ border-radius: 4px; border: 1px solid var(--line); color: var(--ink-faint);
961
+ white-space: nowrap;
962
+ }
963
+ .kitbadge.ok { color: var(--good); border-color: color-mix(in srgb, var(--good) 35%, transparent); }
964
+ .kitbadge.old { color: var(--waiting); border-color: color-mix(in srgb, var(--waiting) 50%, transparent);
965
+ background: color-mix(in srgb, var(--waiting) 12%, transparent); }
966
+ .kitbadge.ahead { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 35%, transparent); }
967
+ .kitbadge.unknown { color: var(--ink-faint); }
968
+
969
+ .pgroup.gone .plabel { color: var(--ink-faint); text-decoration: line-through; }
970
+
971
+ .kitline {
972
+ display: block; font-family: var(--mono); font-size: 10px;
973
+ color: var(--ink-faint); letter-spacing: 0.03em; margin-top: 1px;
974
+ }
975
+ .kitline.warn { color: var(--waiting); }
976
+
977
+ .ptrunc.pmissing { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
978
+ .ptrunc.pmissing .ghost { font-family: var(--mono); font-size: 10px; padding: 1px 6px; }
979
+
980
+ /* ------------------------------------------------------- fold control --- */
981
+
982
+ .cv-fold {
983
+ background: transparent; border: 1px solid var(--line); border-radius: 5px;
984
+ color: var(--ink-dim); cursor: pointer; flex: none;
985
+ font-family: var(--mono); font-size: 9px; line-height: 1; padding: 2px 5px;
986
+ }
987
+ .cv-fold:hover { color: var(--ink); border-color: var(--node-color); }
988
+ .cv-fold[hidden] { display: none; }
989
+
990
+ /* A folded node is a container, so it reads as one: stacked edges behind the
991
+ card stand in for the children it is holding. */
992
+ .cv-node.cv-folded { box-shadow: 0 1px 2px rgb(0 0 0 / 0.18), 6px 6px 0 -2px var(--surface-2), 6px 6px 0 -1px var(--line); }
993
+ .cv-node[data-kind="workflow"] { border-style: dashed; }
994
+ .cv-node[data-kind="workflow"] .cv-desc { font-family: var(--mono); font-size: 11px; color: var(--ink-dim); }
995
+
996
+ /* The fold control is an affordance, not decoration: when a node is holding
997
+ children back it says how many, in the node's own colour. */
998
+ .cv-node.cv-folded .cv-fold {
999
+ color: var(--node-color);
1000
+ border-color: color-mix(in srgb, var(--node-color) 45%, transparent);
1001
+ background: color-mix(in srgb, var(--node-color) 12%, transparent);
1002
+ font-weight: 600;
1003
+ }
1004
+
1005
+ /* ---------------------------------------------------------- origins --- */
1006
+
1007
+ /* Which machine a row came from, shown only once more than one is in view. */
1008
+ .origin {
1009
+ margin-left: 6px; padding: 0 4px; border-radius: 4px;
1010
+ font-family: var(--mono); font-size: 9px; font-weight: 400;
1011
+ color: var(--accent);
1012
+ border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
1013
+ vertical-align: 1px;
1014
+ }
1015
+ .meta.warn { color: var(--waiting); }
1016
+ .ptrunc.pdown { color: var(--bad); }
1017
+
1018
+ /* ================================================== conversation ====== */
1019
+
1020
+ .stage { position: relative; min-width: 0; display: block; }
1021
+ .canvas { position: absolute; inset: 0; }
1022
+
1023
+ /* The inspector floats over the canvas rather than taking a column of its own:
1024
+ with the conversation on the right there is no third column to spare, and a
1025
+ node's details are something you open and dismiss. */
1026
+ .inspector {
1027
+ position: absolute; top: 0; right: 0; bottom: 0; z-index: 30;
1028
+ border-left: 1px solid var(--line);
1029
+ box-shadow: -12px 0 24px -18px rgb(0 0 0 / 0.7);
1030
+ }
1031
+
1032
+ .hsplit { height: 5px; background: var(--line); cursor: row-resize; position: relative; }
1033
+ .hsplit::after { content: ''; position: absolute; inset: -4px 0; }
1034
+ .hsplit:hover, .hsplit:focus-visible, .hsplit.dragging { background: var(--accent); outline: none; }
1035
+ .hsplit[hidden] { display: none; }
1036
+ body.vresizing { cursor: row-resize; user-select: none; }
1037
+
1038
+ .chat {
1039
+ min-width: 0; min-height: 0;
1040
+ display: flex;
1041
+ flex-direction: column;
1042
+ background: var(--surface);
1043
+ border-left: 1px solid var(--line);
1044
+ }
1045
+ .chat[hidden] { display: none; }
1046
+
1047
+ .chat-head {
1048
+ display: flex; align-items: center; gap: 10px;
1049
+ padding: 8px 14px; border-bottom: 1px solid var(--line); flex: none;
1050
+ }
1051
+ .chat-title {
1052
+ font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
1053
+ text-transform: uppercase; color: var(--ink-faint);
1054
+ }
1055
+ .chat-state {
1056
+ font-family: var(--mono); font-size: 10px; color: var(--ink-dim);
1057
+ border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px;
1058
+ }
1059
+ .chat-state[data-state="working"] { color: var(--busy); border-color: color-mix(in srgb, var(--busy) 45%, transparent); }
1060
+ .chat-state[data-state="idle"] { color: var(--good); border-color: color-mix(in srgb, var(--good) 35%, transparent); }
1061
+ .chat-state[data-state="exited"], .chat-state[data-state="failed"] { color: var(--bad); }
1062
+ .chat-cost { flex: 1; font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
1063
+ .chat-stop { font-family: var(--mono); font-size: 11px; padding: 2px 8px; }
1064
+
1065
+ .chat-log { flex: 1; overflow-y: auto; padding: 12px 14px; min-height: 0; }
1066
+
1067
+ /* Who said it, read from which side it sits on. The role label stays, small,
1068
+ because a bubble tells you the side but not the name — and once a session
1069
+ spawns agents there is more than one voice in here. */
1070
+ .msg { display: flex; flex-direction: column; gap: 3px; margin-bottom: 14px; }
1071
+ .msg-user { align-items: flex-end; }
1072
+ .msg-assistant { align-items: flex-start; }
1073
+
1074
+ .msg-role {
1075
+ font-family: var(--mono); font-size: 9px; text-transform: uppercase;
1076
+ letter-spacing: 0.08em; color: var(--ink-faint); padding: 0 4px;
1077
+ }
1078
+ .msg-user .msg-role { color: var(--accent); }
1079
+
1080
+ .msg-body {
1081
+ max-width: 88%;
1082
+ min-width: 0;
1083
+ font-size: 13px; line-height: 1.55;
1084
+ border-radius: 12px;
1085
+ padding: 8px 12px;
1086
+ overflow-wrap: anywhere;
1087
+ }
1088
+ .msg-user .msg-body {
1089
+ background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
1090
+ border-bottom-right-radius: 3px;
1091
+ }
1092
+ .msg-assistant .msg-body {
1093
+ background: var(--surface-2);
1094
+ border-bottom-left-radius: 3px;
1095
+ }
1096
+ .msg-text { white-space: pre-wrap; }
1097
+
1098
+ /* A reply carrying a table or a code block needs the room; a sentence does not. */
1099
+ .msg-assistant .msg-body:has(.md-table, .md-code) { max-width: 100%; }
1100
+
1101
+ .msg-streaming .msg-body { color: var(--ink-dim); }
1102
+ .thinking { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); }
1103
+ .thinking::after { content: ''; animation: dots 1.2s steps(4, end) infinite; }
1104
+ @keyframes dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } }
1105
+
1106
+ /* Quick replies belong to the answer above them, so they sit on its side. */
1107
+ .msg-assistant .quick { justify-content: flex-start; }
1108
+
1109
+ .tool-card {
1110
+ display: flex; gap: 8px; align-items: baseline;
1111
+ background: var(--surface-2); border-radius: 6px; padding: 4px 8px; margin: 4px 0;
1112
+ }
1113
+ .tool-name { font-family: var(--mono); font-size: 10px; color: var(--accent); flex: none; }
1114
+ .tool-label { font-size: 12px; color: var(--ink-dim); overflow-wrap: anywhere; }
1115
+
1116
+ .chat-note { font-family: var(--mono); font-size: 11px; color: var(--ink-faint); padding: 4px 0; }
1117
+ .chat-note.bad { color: var(--bad); }
1118
+
1119
+ .chat-form { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line); flex: none; }
1120
+ .chat-form[hidden] { display: none; }
1121
+ .chat-input {
1122
+ flex: 1; resize: none; box-sizing: border-box;
1123
+ background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
1124
+ color: var(--ink); font-family: inherit; font-size: 13px; line-height: 1.45; padding: 7px 10px;
1125
+ }
1126
+ .chat-input:focus { outline: none; border-color: var(--accent); }
1127
+ .chat-input:disabled { opacity: 0.5; }
1128
+ .chat-send {
1129
+ background: var(--accent); border: none; border-radius: 8px; color: #fff;
1130
+ cursor: pointer; font-size: 12px; font-weight: 600; padding: 0 16px; align-self: stretch;
1131
+ }
1132
+ .chat-send:hover { filter: brightness(1.1); }
1133
+
1134
+ .ghost.primary {
1135
+ color: var(--accent);
1136
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1137
+ font-size: 12px;
1138
+ }
1139
+ .ghost.primary:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); }
1140
+
1141
+ .srow.owned .sname::after {
1142
+ content: 'owned'; margin-left: 6px; padding: 0 4px; border-radius: 4px;
1143
+ font-family: var(--mono); font-size: 9px; color: var(--good);
1144
+ border: 1px solid color-mix(in srgb, var(--good) 35%, transparent);
1145
+ }
1146
+
1147
+ /* `display: grid` outranks the UA's `[hidden] { display: none }`, so the empty
1148
+ note kept painting over a graph that had nodes. Same trap as .inspector. */
1149
+ .cv-empty[hidden] { display: none; }
1150
+
1151
+ /* ------------------------------------------------------- permissions --- */
1152
+
1153
+ .perm-queue { flex: none; border-top: 1px solid var(--line); padding: 10px 14px; max-height: 46vh; overflow-y: auto; }
1154
+ .perm-queue[hidden] { display: none; }
1155
+
1156
+ .perm {
1157
+ border: 1px solid color-mix(in srgb, var(--waiting) 45%, var(--line));
1158
+ background: color-mix(in srgb, var(--waiting) 7%, transparent);
1159
+ border-radius: 9px; padding: 9px 11px; margin-bottom: 8px;
1160
+ }
1161
+ .perm-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
1162
+ .perm-tool {
1163
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
1164
+ letter-spacing: 0.05em; text-transform: uppercase; color: var(--waiting);
1165
+ }
1166
+ .perm-clock { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
1167
+ .perm-detail {
1168
+ margin: 7px 0 0; padding: 7px 9px; border-radius: 6px;
1169
+ background: var(--surface-2); font-family: var(--mono); font-size: 11.5px;
1170
+ line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere;
1171
+ max-height: 140px; overflow-y: auto;
1172
+ }
1173
+ .perm-acts { display: flex; gap: 7px; margin-top: 9px; flex-wrap: wrap; }
1174
+ .perm-btn {
1175
+ background: transparent; border: 1px solid var(--line); border-radius: 7px;
1176
+ color: var(--ink-dim); cursor: pointer; font-size: 12px; padding: 4px 11px;
1177
+ }
1178
+ .perm-btn:hover:not(:disabled) { color: var(--ink); border-color: var(--ink-faint); }
1179
+ .perm-btn:disabled { opacity: 0.4; cursor: default; }
1180
+ .perm-btn.ok { color: var(--good); border-color: color-mix(in srgb, var(--good) 40%, transparent); }
1181
+ .perm-btn.bad { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
1182
+
1183
+ .chat-state.ungated { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 50%, transparent); }
1184
+
1185
+ /* ------------------------------------------------------ quick replies --- */
1186
+
1187
+ .quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 8px; }
1188
+ .quick-hint {
1189
+ font-family: var(--mono); font-size: 10px; letter-spacing: 0.05em;
1190
+ text-transform: uppercase; color: var(--ink-faint);
1191
+ }
1192
+ .quick-btn {
1193
+ background: transparent; border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent);
1194
+ border-radius: 999px; color: var(--accent); cursor: pointer;
1195
+ font-size: 12px; padding: 3px 12px; max-width: 100%;
1196
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1197
+ }
1198
+ .quick-btn:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
1199
+
1200
+ /* --------------------------------------------------------- session tabs --- */
1201
+
1202
+ .panes { flex: 1; min-height: 0; display: flex; }
1203
+ .pane { flex: 1; min-height: 0; display: flex; flex-direction: column; }
1204
+ .pane[hidden] { display: none; }
1205
+
1206
+ .tabs { display: flex; gap: 3px; flex: 1; overflow-x: auto; align-items: center; }
1207
+ .tab {
1208
+ display: flex; align-items: center; gap: 6px;
1209
+ background: transparent; border: 1px solid var(--line); border-radius: 7px;
1210
+ color: var(--ink-dim); cursor: pointer; font-family: var(--mono); font-size: 11px;
1211
+ padding: 3px 7px; white-space: nowrap; flex: none;
1212
+ }
1213
+ .tab:hover { color: var(--ink); }
1214
+ .tab.on { color: var(--ink); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
1215
+
1216
+ .tab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--idle); flex: none; }
1217
+ .tab-dot[data-state="working"] { background: var(--busy); animation: breathe 1.6s ease-in-out infinite; }
1218
+ .tab-dot[data-state="idle"] { background: var(--good); }
1219
+ .tab-dot[data-state="starting"]{ background: var(--accent); }
1220
+ .tab-dot[data-state="exited"], .tab-dot[data-state="failed"] { background: var(--bad); }
1221
+
1222
+ .tab-badge {
1223
+ background: var(--surface-2); border-radius: 999px; padding: 0 5px;
1224
+ font-size: 9px; color: var(--ink-faint);
1225
+ }
1226
+ .tab-badge.warn { background: color-mix(in srgb, var(--waiting) 22%, transparent); color: var(--waiting); }
1227
+ .tab-x { color: var(--ink-faint); font-size: 13px; line-height: 1; padding: 0 1px; }
1228
+ .tab-x:hover { color: var(--bad); }
1229
+
1230
+ @media (prefers-reduced-motion: reduce) { .tab-dot[data-state="working"] { animation: none; } }
1231
+
1232
+ /* -------------------------------------------------------- kit telemetry --- */
1233
+
1234
+ .kit-sum { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 10px; }
1235
+ .kit-sum .cv-bit[data-verdict="BLOCK"] { color: var(--bad); }
1236
+ .kit-sum .cv-bit[data-verdict="ASK"] { color: var(--waiting); }
1237
+ .kit-sum .cv-bit[data-verdict="ALLOW"] { color: var(--good); }
1238
+
1239
+ .gate-row {
1240
+ display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: baseline;
1241
+ padding: 4px 0; border-bottom: 1px solid var(--line); font-size: 12px;
1242
+ }
1243
+ .gate-when { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
1244
+ .gate-name { color: var(--ink-dim); overflow-wrap: anywhere; }
1245
+ .gate-sec { font-family: var(--mono); font-size: 10px; color: var(--ink-faint); }
1246
+ .gate-verdict {
1247
+ font-family: var(--mono); font-size: 9px; letter-spacing: 0.05em;
1248
+ border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; white-space: nowrap;
1249
+ }
1250
+ .gate-verdict[data-verdict="BLOCK"] { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
1251
+ .gate-verdict[data-verdict="ASK"] { color: var(--waiting); border-color: color-mix(in srgb, var(--waiting) 40%, transparent); }
1252
+ .gate-verdict[data-verdict="ALLOW"] { color: var(--good); border-color: color-mix(in srgb, var(--good) 35%, transparent); }
1253
+
1254
+ .inspector dd.bad { color: var(--bad); }
1255
+ .ihint strong { display: block; color: var(--waiting); margin-bottom: 3px; }
1256
+
1257
+ /* A node that holds others opens on click, so it says so before you try. */
1258
+ .cv-node.cv-container { cursor: zoom-in; }
1259
+ .cv-node.cv-container:not(.cv-folded) { cursor: zoom-out; }
1260
+ .cv-node.cv-container.cv-folded .cv-desc::after {
1261
+ content: ' — click to open';
1262
+ color: var(--ink-faint);
1263
+ font-size: 11px;
1264
+ }
1265
+
1266
+ /* --------------------------------------------------- reachable elsewhere --- */
1267
+
1268
+ .reach-head { border-top: 1px solid var(--line); padding-top: 10px; }
1269
+ .reach-head[hidden] { display: none; }
1270
+ #reach { max-height: 24vh; }
1271
+ /* Listed, not openable: the transcript is on the other machine. */
1272
+ .reach-row { cursor: default; opacity: 0.92; }
1273
+ .reach-row .name { font-size: 13px; }
1274
+
1275
+ /* ------------------------------------------------ history and read-only --- */
1276
+
1277
+ /* The seam between what was said before a fork and what is being said now. */
1278
+ .chat-seam {
1279
+ display: flex; align-items: center; gap: 10px;
1280
+ margin: 14px 0; font-family: var(--mono); font-size: 10px;
1281
+ letter-spacing: 0.05em; color: var(--ink-faint); text-transform: uppercase;
1282
+ }
1283
+ .chat-seam::before, .chat-seam::after {
1284
+ content: ''; flex: 1; height: 1px; background: var(--line);
1285
+ }
1286
+
1287
+ .chat-ro {
1288
+ flex: none; padding: 9px 14px; border-top: 1px solid var(--line);
1289
+ font-size: 12px; line-height: 1.45; color: var(--ink-dim);
1290
+ background: var(--surface-2);
1291
+ }
1292
+ .chat-ro[hidden] { display: none; }
1293
+ .chat-ro strong { color: var(--accent); font-weight: 600; }
1294
+
1295
+ .chat-state[data-state="observed"] { color: var(--ink-dim); }
1296
+
1297
+ /* --------------------------------------------------- panel controls --- */
1298
+
1299
+ .ghost.pin { padding: 1px 6px; font-size: 11px; line-height: 1.4; }
1300
+ .side-head { position: relative; }
1301
+ .side-head h2 { flex: 1; }
1302
+
1303
+ /* The chat divider only exists while there is a conversation beside it. */
1304
+ #chat-split[hidden] { display: none; }
1305
+
1306
+ .chat-wide { position: relative; }
1307
+ .chat-head .chat-grow { margin-left: 2px; }
1308
+
1309
+ @media (max-width: 1100px) {
1310
+ /* Two side panels and a graph do not fit; the conversation wins because it is
1311
+ the thing being read. */
1312
+ .shell { grid-template-columns: 0 0 1fr 5px var(--chat-w, 420px); }
1313
+ .shell > .side, .shell > #resizer { display: none; }
1314
+ }
1315
+ @media (max-width: 760px) {
1316
+ .shell, .shell.no-chat, .shell.no-side { grid-template-columns: 1fr; }
1317
+ .shell > .side, .shell > #resizer, .shell > #chat-split { display: none; }
1318
+ .inspector { display: none; }
1319
+ }
1320
+
1321
+ /* A live session is opened from the fleet the same way it is from the list. */
1322
+ .fleet-row.clickable { cursor: pointer; }
1323
+ .fleet-row.clickable:hover { background: var(--surface-2); }
1324
+ .fleet-row[aria-current="true"] { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--good); }
1325
+
1326
+ /* ============================================================ scrollbars === */
1327
+
1328
+ /* The browser default is a light, wide bar that sits on top of a dark panel
1329
+ like a seam. These are thin, dim until touched, and drawn from the theme so
1330
+ they belong to the panel rather than to the window. */
1331
+ * {
1332
+ scrollbar-width: thin; /* Firefox */
1333
+ scrollbar-color: var(--line) transparent;
1334
+ }
1335
+
1336
+ *::-webkit-scrollbar { width: 9px; height: 9px; }
1337
+ *::-webkit-scrollbar-track { background: transparent; }
1338
+ *::-webkit-scrollbar-thumb {
1339
+ background: var(--line);
1340
+ border-radius: 999px;
1341
+ /* A transparent border with background-clip leaves the thumb visually
1342
+ narrower than its hit area, so it is easy to grab and quiet to look at. */
1343
+ border: 2px solid transparent;
1344
+ background-clip: content-box;
1345
+ }
1346
+ *::-webkit-scrollbar-thumb:hover { background: var(--ink-faint); background-clip: content-box; }
1347
+ *::-webkit-scrollbar-corner { background: transparent; }
1348
+
1349
+ /* Where two scrollable panes meet, the bar should not read as a third border. */
1350
+ .side *::-webkit-scrollbar, .chat *::-webkit-scrollbar, .inspector *::-webkit-scrollbar { width: 8px; }
1351
+
1352
+ /* ------------------------------------------------------- sidebar rail --- */
1353
+
1354
+ /* The control that brings the sidebar back sits where the sidebar was. It
1355
+ started in the header beside the theme toggle — the far corner from the edge
1356
+ it acts on — and was, fairly, hard to find. */
1357
+ .side-rail {
1358
+ position: absolute; left: 0; top: 0; bottom: 0; z-index: 25;
1359
+ width: 22px;
1360
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1361
+ gap: 10px;
1362
+ background: var(--surface);
1363
+ border: none; border-right: 1px solid var(--line);
1364
+ color: var(--ink-faint);
1365
+ cursor: pointer;
1366
+ padding: 0;
1367
+ }
1368
+ .side-rail[hidden] { display: none; }
1369
+ .side-rail:hover { background: var(--surface-2); color: var(--ink); border-right-color: var(--accent); }
1370
+ .side-rail:focus-visible { outline: none; border-right-color: var(--accent); color: var(--ink); }
1371
+
1372
+ .side-rail-arrow { font-size: 15px; line-height: 1; }
1373
+ /* Turned along the rail, so the strip says what it opens rather than being an
1374
+ unlabelled sliver. */
1375
+ .side-rail-label {
1376
+ writing-mode: vertical-rl;
1377
+ font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
1378
+ text-transform: uppercase;
1379
+ }
1380
+
1381
+ /* The rail takes the space the sidebar left, so the graph does not sit under it. */
1382
+ .shell.no-side { grid-template-columns: 22px 0 1fr 5px var(--chat-w, 420px); }
1383
+ .shell.no-side.no-chat { grid-template-columns: 22px 0 1fr 0 0; }
1384
+
1385
+ @media (max-width: 760px) { .side-rail { display: none; } }
1386
+
1387
+ /* --------------------------------------------------------- full screen --- */
1388
+
1389
+ #fullscreen.on {
1390
+ color: var(--accent);
1391
+ border-color: color-mix(in srgb, var(--accent) 45%, transparent);
1392
+ }
1393
+
1394
+ /* In full screen the window chrome is gone, so the panel's own top edge is the
1395
+ only frame left; it should not look like a floating bar. */
1396
+ :fullscreen .bar { border-top: none; }
1397
+
1398
+
1399
+ /* ---------------------------------------------------------- node mark --- */
1400
+
1401
+ .cv-mark { width: 14px; height: 14px; flex: none; color: var(--node-color); }
1402
+ /* Both logos carry their own colours; only the generic glyphs take the node's. */
1403
+ .cv-node[data-source="kit"] .cv-mark,
1404
+ .cv-node[data-source="builtin"] .cv-mark { color: inherit; }
1405
+ /* A type the palette does not know keeps a neutral mark, like its colour. */
1406
+ .cv-mark.cv-mark-unknown { color: var(--ink-faint); opacity: 0.7; }
1407
+ .cv-node[data-source="kit"] .cv-mark { opacity: 1; }
1408
+ .cv-node[data-source="builtin"] .cv-mark { opacity: 0.85; }
1409
+
1410
+ /* ------------------------------------------------- tab bar that fits --- */
1411
+
1412
+ /* A tab has to give way before the close control does. Without min-width:0 a
1413
+ flex item refuses to shrink below its content, so a long session title pushed
1414
+ the × out of the panel and the only way to close a tab was to widen the
1415
+ panel first. */
1416
+ .chat-head { min-width: 0; }
1417
+ .tabs { min-width: 0; }
1418
+ .tab { min-width: 0; max-width: 190px; }
1419
+ .tab-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1420
+ .tab-dot, .tab-x, .tab-badge { flex: none; }
1421
+
1422
+ /* The status and the controls keep their size; the tabs are what scrolls. */
1423
+ .chat-state, .chat-cost, .chat-stop, .chat-grow { flex: none; }
1424
+ .chat-cost { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1425
+
1426
+ /* Below this the status text is what has to go, not the way out of a tab. */
1427
+ @media (max-width: 1400px) {
1428
+ .chat-head .chat-cost { display: none; }
1429
+ }
1430
+
1431
+ /* ------------------------------------------------ foldable side sections */
1432
+
1433
+ .twisty {
1434
+ flex: none; width: 16px; height: 16px; padding: 0; margin-right: -2px;
1435
+ display: grid; place-items: center;
1436
+ background: none; border: 0; cursor: pointer; border-radius: 4px;
1437
+ font-size: 10px; line-height: 1; color: var(--ink-faint);
1438
+ transition: background 0.12s, color 0.12s;
1439
+ }
1440
+ .twisty:hover { background: var(--surface-2); color: var(--ink); }
1441
+ .twisty:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1442
+
1443
+ .side-head [data-fold-for] { cursor: pointer; user-select: none; }
1444
+ .side-head [data-fold-for]:hover { color: var(--ink); }
1445
+
1446
+ /* Two selector classes, so this outranks the `display` these bodies get from
1447
+ .fleet / .sessions / .side-filter. A single .is-folded would tie and lose. */
1448
+ [data-fold-body].is-folded { display: none; }
1449
+
1450
+ /* A folded section stops competing for the leftover height: without this the
1451
+ sidebar keeps a tall blank gap where the project tree used to be. */
1452
+ .side-block.folded { flex: none; }
1453
+ .side-block.grow.folded { border-bottom: 1px solid var(--line); }