privateboard 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,9 +37,18 @@
37
37
 
38
38
  /* Chat page chrome (sidebar, queue, room head, etc.) runs on Human
39
39
  Sans. Agent message bubbles override to --font-agent;
40
- the logo is pinned back to Inter further down the stylesheet. */
41
- --mono: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", system-ui, sans-serif;
42
- --sans: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", system-ui, sans-serif;
40
+ the logo is pinned back to Inter further down the stylesheet.
41
+ PingFang SC is named explicitly in the CJK fallback chain so
42
+ Chinese glyphs render as PingFang on macOS instead of whatever
43
+ `system-ui` resolves to. */
44
+ --mono: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
45
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB", "Microsoft YaHei",
46
+ "Source Han Sans CN", "Noto Sans CJK SC",
47
+ system-ui, sans-serif;
48
+ --sans: "Human Sans", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
49
+ "PingFang SC", "PingFang TC", "Hiragino Sans GB", "Microsoft YaHei",
50
+ "Source Han Sans CN", "Noto Sans CJK SC",
51
+ system-ui, sans-serif;
43
52
 
44
53
  --sidebar-w: 280px;
45
54
  }
@@ -63,16 +72,71 @@
63
72
  height: 100vh;
64
73
  overflow: hidden;
65
74
  }
75
+ /* Body becomes a flex column so the optional system-notice banner
76
+ can sit above the .control shell without forcing total height to
77
+ exceed the viewport. .control keeps its existing internal grid;
78
+ it just lives inside this outer flex now. */
79
+ body {
80
+ display: flex;
81
+ flex-direction: column;
82
+ min-height: 0;
83
+ }
66
84
 
67
85
  ::-webkit-scrollbar { width: 5px; height: 5px; }
68
86
  ::-webkit-scrollbar-track { background: var(--bg); }
69
87
  ::-webkit-scrollbar-thumb { background: var(--line-bright); }
70
88
 
89
+ /* ─────────── SYSTEM NOTICE BANNER ───────────
90
+ Dismissible single-line notice that appears above the app shell
91
+ when storage migrations have been applied. Mono micro-type, lime
92
+ left chevron, hairline border under so it reads as system chrome
93
+ not chat content. Only shown when app.js sets data-sys-notice
94
+ visible. */
95
+ .sys-notice {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 10px;
99
+ padding: 8px 14px;
100
+ background: var(--panel-2, #1A1A18);
101
+ border-bottom: 0.5px solid var(--lime-dim, #2D5532);
102
+ font-family: var(--mono, "Inter", system-ui, sans-serif);
103
+ font-size: 11.5px;
104
+ color: var(--text-soft, #8E8B83);
105
+ flex-shrink: 0;
106
+ }
107
+ .sys-notice[hidden] { display: none; }
108
+ .sys-notice-mark {
109
+ color: var(--lime, #6FB572);
110
+ font-weight: 700;
111
+ }
112
+ .sys-notice-text {
113
+ flex: 1 1 auto;
114
+ min-width: 0;
115
+ line-height: 1.4;
116
+ }
117
+ .sys-notice-text .sys-notice-strong {
118
+ color: var(--text, #C8C5BE);
119
+ font-weight: 600;
120
+ }
121
+ .sys-notice-close {
122
+ appearance: none;
123
+ background: none;
124
+ border: 0;
125
+ color: var(--text-faint, #5C5A4D);
126
+ cursor: pointer;
127
+ font-size: 13px;
128
+ line-height: 1;
129
+ padding: 4px 6px;
130
+ transition: color 0.12s;
131
+ }
132
+ .sys-notice-close:hover { color: var(--lime, #6FB572); }
133
+
71
134
  /* ─────────── ROOT ─────────── */
72
135
  .control {
73
136
  display: grid;
74
137
  grid-template-rows: auto 1fr;
75
- height: 100vh;
138
+ flex: 1 1 auto;
139
+ min-height: 0;
76
140
  gap: 8px;
77
141
  padding: 8px;
78
142
  }
@@ -141,98 +205,6 @@
141
205
  .topbar-right .val { color: var(--text-soft); }
142
206
  @keyframes pulse { 0%, 60% { opacity: 1; } 80% { opacity: 0.4; } }
143
207
 
144
- .user-menu-wrap { position: relative; flex: 1; min-width: 0; }
145
- .user-menu {
146
- position: absolute;
147
- /* Default opens DOWNWARD; sidebar-foot variant flips upward below */
148
- top: calc(100% + 6px);
149
- right: 0;
150
- min-width: 200px;
151
- background: var(--panel);
152
- border: 0.5px solid var(--line-bright);
153
- z-index: 100;
154
- opacity: 0;
155
- visibility: hidden;
156
- transform: translateY(-2px);
157
- transition: opacity 0.12s, transform 0.12s, visibility 0s linear 0.12s;
158
- }
159
- .user-menu-wrap:hover .user-menu,
160
- .user-menu-wrap:focus-within .user-menu {
161
- opacity: 1;
162
- visibility: visible;
163
- transform: translateY(0);
164
- transition: opacity 0.12s, transform 0.12s, visibility 0s;
165
- }
166
-
167
- /* Sidebar-foot variant: menu pops UPWARD from the bottom user-block,
168
- anchored to the left edge so it sits inside the sidebar column. */
169
- .sidebar-foot .user-menu-wrap { display: flex; }
170
- .sidebar-foot .user-block { flex: 1; min-width: 0; }
171
- .sidebar-foot .user-menu {
172
- top: auto;
173
- bottom: calc(100% + 6px);
174
- right: auto;
175
- left: 0;
176
- transform: translateY(2px);
177
- }
178
- .sidebar-foot .user-menu-wrap:hover .user-menu,
179
- .sidebar-foot .user-menu-wrap:focus-within .user-menu {
180
- transform: translateY(0);
181
- }
182
- .sidebar-foot .user-block .chev {
183
- margin-left: auto;
184
- color: var(--text-dim);
185
- font-size: 9px;
186
- transition: color 0.1s;
187
- }
188
- .sidebar-foot .user-menu-wrap:hover .user-block .chev,
189
- .sidebar-foot .user-menu-wrap:focus-within .user-block .chev {
190
- color: var(--lime);
191
- }
192
-
193
- .user-menu-section {
194
- padding: 8px 12px;
195
- border-bottom: 0.5px solid var(--line);
196
- background: var(--panel-2);
197
- }
198
- .user-menu-section .name {
199
- font-size: 11px;
200
- color: var(--text);
201
- font-weight: 700;
202
- margin-bottom: 1px;
203
- }
204
- .user-menu-section .meta {
205
- font-size: 9.5px;
206
- color: var(--text-dim);
207
- letter-spacing: 0.04em;
208
- }
209
- .user-menu-section .meta .lime { color: var(--lime); }
210
-
211
- .user-menu-item {
212
- display: flex;
213
- align-items: center;
214
- gap: 10px;
215
- padding: 8px 12px;
216
- font-size: 11.5px;
217
- color: var(--text-soft);
218
- text-decoration: none;
219
- border-bottom: 0.5px solid var(--line);
220
- transition: all 0.08s;
221
- }
222
- .user-menu-item:last-child { border-bottom: none; }
223
- .user-menu-item:hover {
224
- background: var(--panel-2);
225
- color: var(--lime);
226
- }
227
- .user-menu-item .menu-icon {
228
- width: 14px;
229
- color: var(--text-dim);
230
- text-align: center;
231
- }
232
- .user-menu-item:hover .menu-icon { color: var(--lime); }
233
- .user-menu-item.danger:hover { color: var(--red); }
234
- .user-menu-item.danger:hover .menu-icon { color: var(--red); }
235
-
236
208
  /* ═══════════════════════════════════════════
237
209
  MAIN GRID — sidebar + content
238
210
  ═══════════════════════════════════════════ */
@@ -242,6 +214,11 @@
242
214
  gap: 0;
243
215
  overflow: hidden;
244
216
  min-height: 0;
217
+ /* `position: relative` so the floating sidebar-expand-btn (absolute-
218
+ positioned) anchors to the body-grid's top-left edge instead of
219
+ the viewport — that puts the button below the topbar / brand
220
+ logo where the user expects it, not on top of it. */
221
+ position: relative;
245
222
  }
246
223
 
247
224
  /* Drag handles for resizable columns */
@@ -324,6 +301,134 @@
324
301
  }
325
302
  .sidebar-head-meta .lime { color: var(--lime); }
326
303
 
304
+ /* ─── Sidebar collapse toggle ───
305
+ Sits at the right edge of sidebar-head. Pure CSS glyph (no svg)
306
+ so we can flip the direction via class without re-rendering DOM.
307
+ Hover lime per the new-btn vocabulary. */
308
+ .sidebar-collapse-btn {
309
+ appearance: none;
310
+ background: var(--panel-3);
311
+ border: 0.5px solid var(--line-strong);
312
+ color: var(--text-soft);
313
+ cursor: pointer;
314
+ width: 24px;
315
+ height: 22px;
316
+ padding: 0;
317
+ line-height: 1;
318
+ font-family: var(--mono);
319
+ font-size: 16px;
320
+ font-weight: 700;
321
+ display: inline-flex;
322
+ align-items: center;
323
+ justify-content: center;
324
+ transition: color 0.12s, background 0.12s, border-color 0.12s;
325
+ flex-shrink: 0;
326
+ }
327
+ .sidebar-collapse-btn::before { content: "‹"; }
328
+ body.sidebar-collapsed .sidebar-collapse-btn::before { content: "›"; }
329
+ .sidebar-collapse-btn:hover {
330
+ color: var(--lime);
331
+ border-color: var(--lime-dim);
332
+ background: var(--panel-2);
333
+ }
334
+
335
+ /* ─── Collapsed state · sidebar fully hidden ───
336
+ Sidebar + resizer collapse out of layout via display:none. The
337
+ grid drops to a single 1fr column · auto-placement would otherwise
338
+ try to drop the .main into the now-empty first track (still
339
+ reserved by template), zero-width, and the user sees an empty
340
+ room. Single column = main fills the whole body-grid cleanly.
341
+ The user's preferred --sidebar-w stays in localStorage so
342
+ expanding restores the same width. */
343
+ body.sidebar-collapsed .body-grid {
344
+ grid-template-columns: 1fr !important;
345
+ }
346
+ body.sidebar-collapsed .sidebar,
347
+ body.sidebar-collapsed .col-resizer {
348
+ display: none;
349
+ }
350
+
351
+ /* Floating expand button · ONLY shown in the empty / no-room
352
+ state, where there's no room-head to host the in-header
353
+ expand control (rendered by renderHeader). Positioned absolute
354
+ relative to .body-grid (which carries `position: relative`),
355
+ pinned to the top-left of the body-grid area. Frosted black
356
+ glass with hairline border, lime on hover. z-index sits above
357
+ chat content but below modal overlays (which use 1500+). */
358
+ .sidebar-expand-btn {
359
+ display: none;
360
+ position: absolute;
361
+ top: 12px;
362
+ left: 12px;
363
+ width: 34px;
364
+ height: 34px;
365
+ align-items: center;
366
+ justify-content: center;
367
+ background: rgba(8, 8, 8, 0.55);
368
+ -webkit-backdrop-filter: blur(12px) saturate(1.05);
369
+ backdrop-filter: blur(12px) saturate(1.05);
370
+ border: 0.5px solid var(--line-strong);
371
+ color: var(--text-soft);
372
+ cursor: pointer;
373
+ z-index: 200;
374
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
375
+ appearance: none;
376
+ padding: 0;
377
+ }
378
+ /* Show floating button only when sidebar is collapsed AND we're
379
+ in the empty / no-room state. When a room is loaded, the
380
+ in-header `.room-head-expand` button takes over instead, so
381
+ the icon doesn't sit on top of the room title. */
382
+ html.no-room body.sidebar-collapsed .sidebar-expand-btn {
383
+ display: inline-flex;
384
+ }
385
+ .sidebar-expand-btn:hover {
386
+ color: var(--lime);
387
+ border-color: var(--lime-dim);
388
+ background: rgba(8, 8, 8, 0.72);
389
+ }
390
+ .sidebar-expand-btn svg { display: block; }
391
+
392
+ /* ─── In-header expand button ───
393
+ Lives at the leading edge of `.room-head` (rendered by
394
+ renderHeader() in app.js). Twin of `.sidebar-collapse-btn` —
395
+ naked CSS-glyph, no border, faint text, lime on hover. The
396
+ ▸ glyph mirrors the ◂ on the collapse counterpart so the
397
+ two controls read as a paired set. Hidden by default; shown
398
+ only when the sidebar is collapsed AND a room is loaded. */
399
+ .room-head-expand {
400
+ appearance: none;
401
+ background: var(--panel-3);
402
+ border: 0.5px solid var(--line-strong);
403
+ color: var(--text-soft);
404
+ cursor: pointer;
405
+ width: 24px;
406
+ height: 22px;
407
+ padding: 0;
408
+ line-height: 1;
409
+ font-family: var(--mono);
410
+ font-size: 16px;
411
+ font-weight: 700;
412
+ display: none;
413
+ align-items: center;
414
+ justify-content: center;
415
+ transition: color 0.12s, background 0.12s, border-color 0.12s;
416
+ flex-shrink: 0;
417
+ }
418
+ .room-head-expand::before { content: "›"; }
419
+ body.sidebar-collapsed .room-head-expand {
420
+ display: inline-flex;
421
+ }
422
+ .room-head-expand:hover {
423
+ color: var(--lime);
424
+ border-color: var(--lime-dim);
425
+ background: var(--panel-2);
426
+ }
427
+ .room-head-expand:hover {
428
+ color: var(--lime);
429
+ background: var(--panel-3);
430
+ }
431
+
327
432
  /* ─── Sidebar tabs (Rooms / Agents) — segmented control with icon ─── */
328
433
  .sidebar-tabs {
329
434
  display: flex;
@@ -459,26 +564,48 @@
459
564
  flex-shrink: 0;
460
565
  }
461
566
  .agent-row .agent-row-content { min-width: 0; }
567
+ /* Top-line · matches `.row-top-line` in the rooms list so the
568
+ agent's name + time read with the same baseline/gap rhythm. The
569
+ trailing pin button sits AFTER the time and is hover-revealed,
570
+ so we don't need `space-between` to push it right — `flex: 1` on
571
+ the title handles that. */
462
572
  .agent-row .agent-row-top-line {
463
573
  display: flex;
464
- justify-content: space-between;
465
- gap: 6px;
466
- font-family: var(--mono);
467
- margin-bottom: 2px;
574
+ align-items: baseline;
575
+ gap: 4px;
576
+ margin-bottom: 1px;
468
577
  }
469
578
  .agent-row .agent-row-title {
470
579
  color: var(--text);
471
580
  font-family: var(--font-human);
472
581
  font-weight: 600;
473
- font-size: 12.5px;
582
+ font-size: 14px;
474
583
  letter-spacing: -0.005em;
584
+ /* Truncate long agent names with ellipsis instead of wrapping
585
+ to a second line. Without `min-width: 0` here, flex children
586
+ default to min-width: auto = content-size and refuse to
587
+ shrink below their text — which produced the wrapping the
588
+ user saw. `flex: 1 1 0` + `min-width: 0` lets the title
589
+ shrink as needed; the trailing time tag is locked via
590
+ flex-shrink: 0 below so it never gets eaten. */
591
+ flex: 1 1 0;
592
+ min-width: 0;
593
+ white-space: nowrap;
594
+ overflow: hidden;
595
+ text-overflow: ellipsis;
475
596
  }
597
+ /* Time tag · same register as `.row-time` in the rooms list
598
+ (10.5px mono, text-dim) so both lists read as one consistent
599
+ "title · time" rhythm. Earlier this was 9px uppercase faint —
600
+ visually a different "kind of metadata" from the rooms time. */
476
601
  .agent-row .agent-row-time {
477
- color: var(--text-faint);
478
- font-size: 9px;
479
- letter-spacing: 0.06em;
602
+ color: var(--text-dim);
603
+ font-size: 10.5px;
604
+ font-family: var(--mono);
605
+ letter-spacing: 0;
606
+ text-transform: none;
480
607
  white-space: nowrap;
481
- text-transform: uppercase;
608
+ flex-shrink: 0;
482
609
  }
483
610
  .agent-row .agent-row-subtitle {
484
611
  font-family: var(--mono);
@@ -520,7 +647,17 @@
520
647
  accent · outlined (hairline border + cyan text on transparent
521
648
  bg) instead of a solid cyan fill that screamed at the user.
522
649
  · Section header reverts to the standard text-faint kicker. */
523
- .agent-row.is-chair { background: transparent; }
650
+ /* Chair row · NOT wrapped in `.agent-row-shell` (the shell provides
651
+ the 1px×6px inset + 4px border-radius for director rows). To make
652
+ the chair's hover/active background match — same inset, same
653
+ rounded corners — fold those rules onto the chair anchor itself.
654
+ Without this, director hover felt "cushioned" while chair hover
655
+ painted edge-to-edge with sharp corners. */
656
+ .agent-row.is-chair {
657
+ background: transparent;
658
+ margin: 1px 6px;
659
+ border-radius: 4px;
660
+ }
524
661
  .agent-row.is-chair:hover { background: var(--panel-2); }
525
662
  .agent-row.is-chair.active { background: var(--panel-3); }
526
663
  .agent-row.is-chair .agent-row-av.chair-av {
@@ -600,7 +737,7 @@
600
737
  color: var(--text);
601
738
  border: none;
602
739
  font-family: var(--sans);
603
- font-size: 13px;
740
+ font-size: 14px;
604
741
  font-weight: 500;
605
742
  cursor: pointer;
606
743
  text-decoration: none;
@@ -626,8 +763,8 @@
626
763
  adjacent label without per-icon overrides. */
627
764
  .new-btn::before {
628
765
  content: "";
629
- width: 14px;
630
- height: 14px;
766
+ width: 16px;
767
+ height: 16px;
631
768
  flex-shrink: 0;
632
769
  background-color: currentColor;
633
770
  /* Inherit `color` from .new-btn so the icon picks up the same
@@ -639,8 +776,8 @@
639
776
  mask-repeat: no-repeat;
640
777
  -webkit-mask-position: center;
641
778
  mask-position: center;
642
- -webkit-mask-size: 14px 14px;
643
- mask-size: 14px 14px;
779
+ -webkit-mask-size: 16px 16px;
780
+ mask-size: 16px 16px;
644
781
  transition: color 0.12s;
645
782
  }
646
783
  /* "New room" · SquarePen (Lucide) — square frame with a pen
@@ -659,11 +796,59 @@
659
796
  .new-btn.nav-reports::before {
660
797
  --icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/><path d='M14 2v4a2 2 0 0 0 2 2h4'/><path d='M10 9H8'/><path d='M16 13H8'/><path d='M16 17H8'/></svg>");
661
798
  }
799
+ /* "All Notes" · Bookmark (Lucide) — pennant-shaped bookmark glyph
800
+ mirroring the qcta save-button icon. Matches the chairman's-notes
801
+ vocabulary across the app (sidebar entry, save action, in-room
802
+ overlay all share the bookmark register). */
803
+ .new-btn.nav-notes::before {
804
+ --icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z'/></svg>");
805
+ }
806
+ /* All Reports / All Notes nav-button shape · the count badge floats
807
+ to the right of the label, so the link's flex layout grows to fill
808
+ the row. `.nav-label` flex-grows, `.nav-count` is auto-sized and
809
+ hugs the trailing edge. */
810
+ .new-btn.nav-notes,
811
+ .new-btn.nav-reports {
812
+ justify-content: flex-start;
813
+ }
814
+ .new-btn.nav-notes .nav-label,
815
+ .new-btn.nav-reports .nav-label {
816
+ flex: 1;
817
+ min-width: 0;
818
+ }
819
+ /* Count badge · subtle mono micro-tag. Uses --text-faint on its own
820
+ and inherits the link's color cascade on hover/active so the badge
821
+ tracks the label's lime shift. Hidden until at least one item
822
+ exists (the `hidden` attr is removed once count > 0). Same visual
823
+ vocabulary as the section-header .badge below — both render as
824
+ mono pills so the sidebar reads as one consistent counting
825
+ system. */
826
+ .new-btn.nav-notes .nav-count,
827
+ .new-btn.nav-reports .nav-count {
828
+ font-family: var(--mono);
829
+ font-size: 10px;
830
+ font-weight: 600;
831
+ letter-spacing: 0.04em;
832
+ color: var(--text-faint);
833
+ padding: 1px 6px;
834
+ background: var(--panel-3);
835
+ border-radius: 3px;
836
+ flex-shrink: 0;
837
+ transition: color 0.12s, background 0.12s;
838
+ }
839
+ .new-btn.nav-notes:hover .nav-count,
840
+ .new-btn.nav-notes.active .nav-count,
841
+ .new-btn.nav-reports:hover .nav-count,
842
+ .new-btn.nav-reports.active .nav-count {
843
+ color: var(--lime);
844
+ background: rgba(190, 242, 100, 0.08);
845
+ }
662
846
  .new-btn:hover {
663
847
  background: var(--panel-2);
664
- color: var(--text);
848
+ color: var(--lime);
665
849
  }
666
- .new-btn:hover::before { color: var(--lime); }
850
+ /* The ::before icon inherits via currentColor — no explicit
851
+ hover override needed; setting the parent's color cascades. */
667
852
  /* Active state · matches session-row-shell.active treatment so the
668
853
  sidebar reads consistently when the composer is the current view. */
669
854
  .new-btn.active {
@@ -682,7 +867,17 @@
682
867
  display: flex;
683
868
  align-items: center;
684
869
  gap: 6px;
685
- padding: 8px 10px 4px;
870
+ /* Trailing count badge column-aligns with the .new-btn.nav-*
871
+ badges above. Geometry:
872
+ nav badge inset · 6px margin + 10px padding = 16px from
873
+ the sidebar edge
874
+ section badge inset · 4px sessions-scroll padding + this
875
+ rule's right padding
876
+ So this rule's right padding must be 16 − 4 = 12px. The
877
+ parent's 4px padding was missed in an earlier version that
878
+ set this to 16px and made the section badges drift 4px
879
+ further left than the nav badges. */
880
+ padding: 8px 12px 4px 10px;
686
881
  font-size: 9px;
687
882
  color: var(--text-dim);
688
883
  text-transform: uppercase;
@@ -704,12 +899,27 @@
704
899
  }
705
900
  .row-status.paused { color: var(--amber); }
706
901
  .section-header .line { flex: 1; border-top: 0.5px dashed var(--line-bright); }
902
+ /* Section badge · same visual vocabulary as .new-btn.nav-* .nav-count
903
+ above so the sidebar reads as one consistent counting system.
904
+ Mono pill, panel-3 bg, faint text, rounded — overrides the
905
+ .section-header parent's sans uppercase letter-spacing because a
906
+ count is a number, not a label. The .live variant still takes its
907
+ pulsing dot via ::before. */
707
908
  .section-header .badge {
708
- font-size: 9px;
709
- color: inherit;
710
- font-weight: 700;
711
- padding: 0 4px;
712
- background: var(--panel-2);
909
+ font-family: var(--mono);
910
+ font-size: 10px;
911
+ font-weight: 600;
912
+ letter-spacing: 0.04em;
913
+ text-transform: none;
914
+ color: var(--text-faint);
915
+ padding: 1px 6px;
916
+ background: var(--panel-3);
917
+ border-radius: 3px;
918
+ flex-shrink: 0;
919
+ }
920
+ .section-header.live .badge {
921
+ color: var(--lime);
922
+ background: rgba(190, 242, 100, 0.08);
713
923
  }
714
924
  .section-header.live .badge::before {
715
925
  content: "● ";
@@ -723,9 +933,20 @@
723
933
  }
724
934
  .agents-section-header.pinned { color: var(--text-soft); }
725
935
 
726
- /* ─── Pin toggle (per-row · hover-revealed for non-pinned) ─── */
936
+ /* ─── Pin toggle (per-row · hover-revealed for non-pinned) ───
937
+ Positioned absolutely so the in-flow time tag (.agent-row-time /
938
+ .row-time) sits flush against the right edge — same as the rooms
939
+ list. Earlier the pin took 22px (18 + 4 margin) of in-flow width
940
+ even when invisible (opacity:0 still occupies space), which shoved
941
+ the agent's "7h" tag inward compared to the rooms list and made
942
+ the two columns of timestamps look mis-aligned. The row-shell
943
+ ancestor needs `position: relative` for this to anchor correctly. */
944
+ .agent-row-shell, .session-row-shell { position: relative; }
727
945
  .row-top-line .pin-toggle,
728
946
  .agent-row-top-line .pin-toggle {
947
+ position: absolute;
948
+ top: 8px;
949
+ right: 8px;
729
950
  width: 18px;
730
951
  height: 18px;
731
952
  border: none;
@@ -736,10 +957,8 @@
736
957
  align-items: center;
737
958
  justify-content: center;
738
959
  padding: 0;
739
- margin-left: 4px;
740
960
  opacity: 0;
741
961
  transition: opacity 0.12s, color 0.12s;
742
- flex-shrink: 0;
743
962
  }
744
963
  .row-top-line .pin-toggle svg,
745
964
  .agent-row-top-line .pin-toggle svg {
@@ -749,6 +968,10 @@
749
968
  }
750
969
  .session-row:hover .pin-toggle,
751
970
  .agent-row:hover .pin-toggle { opacity: 0.55; }
971
+ /* Hover · hide the in-flow time so the absolutely-positioned pin
972
+ button has the right edge to itself. Same pattern as the rooms
973
+ list (`.session-row-shell:hover .row-time { visibility: hidden }`). */
974
+ .agent-row-shell:hover .agent-row-time { visibility: hidden; }
752
975
  .pin-toggle:hover { opacity: 1 !important; color: var(--lime); }
753
976
  .session-row.pinned .pin-toggle,
754
977
  .agent-row.pinned .pin-toggle {
@@ -889,7 +1112,7 @@
889
1112
  margin-bottom: 1px;
890
1113
  }
891
1114
  .row-title {
892
- font-size: 12.5px;
1115
+ font-size: 14px;
893
1116
  font-weight: 600;
894
1117
  color: var(--text);
895
1118
  overflow: hidden;
@@ -1102,6 +1325,20 @@
1102
1325
  .main-view[data-main-view="reports"]:hover { scrollbar-width: thin; }
1103
1326
  .main-view[data-main-view="reports"]:hover::-webkit-scrollbar-thumb { background: var(--line-bright); }
1104
1327
 
1328
+ /* All Notes view · same scroll register as All Reports so the two
1329
+ cross-room aggregation destinations read as a paired set. */
1330
+ .main-view[data-main-view="notes"] {
1331
+ display: block;
1332
+ overflow-y: auto;
1333
+ overscroll-behavior: none;
1334
+ scrollbar-width: none;
1335
+ background: var(--panel);
1336
+ }
1337
+ .main-view[data-main-view="notes"]::-webkit-scrollbar-thumb { background: transparent; }
1338
+ .main-view[data-main-view="notes"]::-webkit-scrollbar-track { background: transparent; }
1339
+ .main-view[data-main-view="notes"]:hover { scrollbar-width: thin; }
1340
+ .main-view[data-main-view="notes"]:hover::-webkit-scrollbar-thumb { background: var(--line-bright); }
1341
+
1105
1342
  /* Hidden takes precedence over the per-view display rules above.
1106
1343
  Declared LAST + with !important so the variant rules (which match
1107
1344
  at the same specificity) can never override it back to block —
@@ -1159,14 +1396,19 @@
1159
1396
  bg + lime label), same 4px corner radius, same hover (panel-2 bg).
1160
1397
  One coherent vocabulary across every segmented control. The count
1161
1398
  rides inline after the label as quiet mono micro-type — no nested
1162
- chip chrome. */
1163
- .reports-filters {
1399
+ chip chrome.
1400
+ Comma-extended to `.notes-filters` / `.notes-filter-chip` so the
1401
+ All Notes page reuses the exact same chip vocabulary — both
1402
+ cross-room aggregation destinations stay visually identical. */
1403
+ .reports-filters,
1404
+ .notes-filters {
1164
1405
  display: flex;
1165
1406
  gap: 2px;
1166
1407
  margin: 22px 0 6px;
1167
1408
  flex-wrap: wrap;
1168
1409
  }
1169
- .reports-filter-chip {
1410
+ .reports-filter-chip,
1411
+ .notes-filter-chip {
1170
1412
  background: transparent;
1171
1413
  border: none;
1172
1414
  color: var(--text-dim);
@@ -1182,27 +1424,67 @@
1182
1424
  gap: 6px;
1183
1425
  transition: background 0.12s, color 0.12s;
1184
1426
  }
1185
- .reports-filter-chip:hover {
1427
+ .reports-filter-chip:hover,
1428
+ .notes-filter-chip:hover {
1186
1429
  background: var(--panel-2);
1187
1430
  color: var(--text-soft);
1188
1431
  }
1189
- .reports-filter-chip.on {
1432
+ .reports-filter-chip.on,
1433
+ .notes-filter-chip.on {
1190
1434
  background: var(--panel-3);
1191
1435
  color: var(--text);
1192
1436
  font-weight: 600;
1193
1437
  }
1194
- .reports-filter-count {
1438
+ .reports-filter-count,
1439
+ .notes-filter-count {
1195
1440
  font-family: var(--mono);
1196
1441
  font-size: 10.5px;
1197
1442
  color: var(--text-faint);
1198
1443
  font-weight: 400;
1199
1444
  letter-spacing: 0;
1200
1445
  }
1201
- .reports-filter-chip.on .reports-filter-count { color: var(--text-dim); }
1446
+ .reports-filter-chip.on .reports-filter-count,
1447
+ .notes-filter-chip.on .notes-filter-count { color: var(--text-dim); }
1202
1448
 
1203
1449
  /* ─── Reading list ─────────────────────────────────────────────── */
1204
1450
  .reports-list-wrap { margin-top: 18px; }
1205
1451
 
1452
+ /* Load-more sentinel · sits at the bottom of the list when more
1453
+ items are available beyond the current page (default 20). The
1454
+ IntersectionObserver fires when this enters the viewport (200px
1455
+ pre-roll) and bumps the visible count by 20. The button is also
1456
+ a click target for users who'd rather page explicitly. */
1457
+ .reports-load-sentinel {
1458
+ margin-top: 24px;
1459
+ padding: 16px 0 32px;
1460
+ display: flex;
1461
+ justify-content: center;
1462
+ }
1463
+ .reports-load-more {
1464
+ display: inline-flex;
1465
+ align-items: center;
1466
+ gap: 8px;
1467
+ padding: 8px 16px;
1468
+ background: transparent;
1469
+ color: var(--text-soft);
1470
+ border: 1px solid var(--line-bright);
1471
+ border-radius: 4px;
1472
+ font-family: var(--mono);
1473
+ font-size: 11px;
1474
+ letter-spacing: 0.04em;
1475
+ cursor: pointer;
1476
+ transition: color 0.12s, border-color 0.12s, background 0.12s;
1477
+ }
1478
+ .reports-load-more:hover {
1479
+ color: var(--lime);
1480
+ border-color: var(--lime);
1481
+ background: var(--panel-2);
1482
+ }
1483
+ .reports-load-more-arrow {
1484
+ font-size: 13px;
1485
+ line-height: 1;
1486
+ }
1487
+
1206
1488
  /* Date-bucket label · sits flush left, hairline rule fills the
1207
1489
  remaining width. Reads as a quiet section break. */
1208
1490
  .reports-group + .reports-group { margin-top: 32px; }
@@ -1454,8 +1736,347 @@
1454
1736
  .reports-item-room { max-width: 140px; }
1455
1737
  }
1456
1738
 
1457
- /* Room head generously padded so the title has breathing
1458
- room above and below. */
1739
+ /* ─── All Notes view · chairman's saved excerpts ──────────────
1740
+ Same column geometry + typographic register as `.reports-page`
1741
+ so the two cross-room aggregation destinations read as a paired
1742
+ set. The substantive difference is the "passage" cell — each
1743
+ note row foregrounds the saved quote with a thick dotted lime
1744
+ underline (text-decoration only, NOT bold-weight type), wrapped
1745
+ by faded sentence-context that gradient-masks toward the
1746
+ edges. The visual treatment is the same overlay the in-room
1747
+ `.note-highlight` uses, so a note seen on this page reads with
1748
+ the same identity when the user jumps back to the source. */
1749
+ .notes-page {
1750
+ max-width: 820px;
1751
+ margin: 0 auto;
1752
+ padding: 56px 48px 80px;
1753
+ }
1754
+ .notes-page-head {
1755
+ display: flex;
1756
+ align-items: baseline;
1757
+ justify-content: space-between;
1758
+ gap: 16px;
1759
+ margin-bottom: 8px;
1760
+ border-bottom: 0.5px solid var(--line);
1761
+ padding-bottom: 14px;
1762
+ }
1763
+ .notes-page-title {
1764
+ font-family: var(--font-human);
1765
+ font-size: 26px;
1766
+ font-weight: 700;
1767
+ letter-spacing: -0.01em;
1768
+ color: var(--text);
1769
+ }
1770
+ .notes-page-kicker {
1771
+ font-family: var(--mono);
1772
+ font-size: 9.5px;
1773
+ letter-spacing: 0.22em;
1774
+ text-transform: uppercase;
1775
+ color: var(--text-faint);
1776
+ font-weight: 700;
1777
+ margin-bottom: 4px;
1778
+ }
1779
+ .notes-page-meta {
1780
+ font-family: var(--mono);
1781
+ font-size: 10.5px;
1782
+ letter-spacing: 0.04em;
1783
+ color: var(--text-dim);
1784
+ }
1785
+
1786
+ /* Date-bucket sections · each opens with its own hairline-divided
1787
+ label row, matching the reports `.reports-group` rhythm. */
1788
+ .notes-group + .notes-group { margin-top: 32px; }
1789
+ .notes-group:first-of-type { margin-top: 22px; }
1790
+ .notes-group-head {
1791
+ display: flex;
1792
+ align-items: baseline;
1793
+ justify-content: space-between;
1794
+ padding-bottom: 8px;
1795
+ border-bottom: 0.5px solid var(--line);
1796
+ margin-bottom: 4px;
1797
+ }
1798
+ .notes-group-label {
1799
+ font-family: var(--mono);
1800
+ font-size: 10px;
1801
+ font-weight: 700;
1802
+ letter-spacing: 0.22em;
1803
+ text-transform: uppercase;
1804
+ color: var(--text-faint);
1805
+ }
1806
+ .notes-group-count {
1807
+ font-family: var(--mono);
1808
+ font-size: 10px;
1809
+ color: var(--text-dim);
1810
+ letter-spacing: 0.04em;
1811
+ }
1812
+
1813
+ .notes-list { list-style: none; margin: 0; padding: 0; }
1814
+ .notes-item { border-bottom: 0.5px solid var(--line); }
1815
+ .notes-item:last-child { border-bottom: none; }
1816
+ .notes-item-link {
1817
+ display: block;
1818
+ padding: 18px 4px;
1819
+ text-decoration: none;
1820
+ color: inherit;
1821
+ cursor: pointer;
1822
+ }
1823
+ /* Hover lifts ONLY the meta room-tag + bumps the quote underline
1824
+ to full-lime — the quote text itself stays at --text so the
1825
+ dotted-underline does the focal work. */
1826
+ .notes-item-link:hover .notes-item-room { color: var(--lime); }
1827
+ .notes-item-link:hover .note-quote { text-decoration-color: var(--lime); }
1828
+
1829
+ .notes-item-meta {
1830
+ display: flex;
1831
+ align-items: baseline;
1832
+ gap: 6px;
1833
+ flex-wrap: wrap;
1834
+ font-family: var(--mono);
1835
+ font-size: 9.5px;
1836
+ letter-spacing: 0.14em;
1837
+ text-transform: uppercase;
1838
+ color: var(--text-faint);
1839
+ font-weight: 700;
1840
+ margin-bottom: 8px;
1841
+ }
1842
+ .notes-item-room { color: var(--text-soft); transition: color 0.14s; }
1843
+ .notes-item-subject {
1844
+ color: var(--text-dim);
1845
+ text-transform: none;
1846
+ letter-spacing: -0.005em;
1847
+ font-family: var(--font-human);
1848
+ font-size: 11px;
1849
+ font-weight: 600;
1850
+ overflow: hidden;
1851
+ text-overflow: ellipsis;
1852
+ white-space: nowrap;
1853
+ max-width: 280px;
1854
+ }
1855
+ .notes-item-director {
1856
+ color: var(--text-soft);
1857
+ text-transform: none;
1858
+ letter-spacing: 0;
1859
+ font-family: var(--font-human);
1860
+ font-size: 11px;
1861
+ font-weight: 600;
1862
+ }
1863
+ .notes-item-time {
1864
+ margin-left: auto;
1865
+ color: var(--text-faint);
1866
+ letter-spacing: 0.06em;
1867
+ }
1868
+ .notes-item-sep { color: var(--text-faint); opacity: 0.7; }
1869
+
1870
+ /* The reading passage · context_before + quote + context_after
1871
+ in a single inline flow. Contexts use a horizontal mask-image
1872
+ fade toward the outer edges so the quote sits in a "spotlight"
1873
+ of full-strength text while the surrounding sentences taper
1874
+ visually — reads as "supporting context" without sacrificing
1875
+ readability. The mask applies to every wrapped line, which is
1876
+ what we want: each line's far edge is the "least important"
1877
+ spatially, so each fades. */
1878
+ .notes-item-passage {
1879
+ font-family: var(--font-human);
1880
+ font-size: 14px;
1881
+ line-height: 1.6;
1882
+ color: var(--text);
1883
+ margin: 0;
1884
+ word-break: normal;
1885
+ overflow-wrap: anywhere;
1886
+ }
1887
+ .note-context { color: var(--text-soft); }
1888
+ .note-context-before {
1889
+ -webkit-mask-image: linear-gradient(to right, transparent 0%, black 32%);
1890
+ mask-image: linear-gradient(to right, transparent 0%, black 32%);
1891
+ }
1892
+ .note-context-after {
1893
+ -webkit-mask-image: linear-gradient(to right, black 68%, transparent 100%);
1894
+ mask-image: linear-gradient(to right, black 68%, transparent 100%);
1895
+ }
1896
+ /* The quote span · pure typography accent. Text weight stays
1897
+ normal (do NOT bold the type — it would collide with markdown's
1898
+ own `**emphasis**`); only the underline grows thicker. Dotted
1899
+ style differentiates from inline-link's solid 1px underline.
1900
+ The lime accent identifies the note's identity across the app
1901
+ — the same treatment appears on the in-room highlight overlay. */
1902
+ .note-quote {
1903
+ color: var(--text);
1904
+ text-decoration: underline dotted;
1905
+ text-decoration-thickness: 3px;
1906
+ text-decoration-color: var(--lime-dim);
1907
+ text-underline-offset: 4px;
1908
+ transition: text-decoration-color 0.14s;
1909
+ }
1910
+
1911
+ /* In-room highlight overlay · injected into director-message
1912
+ `.msg-bubble` text by app.applyNoteHighlightsForMessage. Same
1913
+ visual register as `.note-quote` so a saved span reads with the
1914
+ same identity wherever it appears — the user can re-open a room
1915
+ and see at a glance what they've already collected. Hover
1916
+ strengthens the underline to full lime; the cursor uses `help`
1917
+ so the saved-tooltip is discoverable (the title attr renders
1918
+ "Saved to Notes"). */
1919
+ .note-highlight {
1920
+ text-decoration: underline dotted;
1921
+ text-decoration-thickness: 3px;
1922
+ text-decoration-color: var(--lime-dim);
1923
+ text-underline-offset: 4px;
1924
+ cursor: help;
1925
+ transition: text-decoration-color 0.14s, background 0.18s;
1926
+ }
1927
+ .note-highlight:hover { text-decoration-color: var(--lime); }
1928
+ /* Flash · briefly blooms a soft lime backdrop when the user jumps
1929
+ to a note from the All-Notes view, so the eye lands on the
1930
+ right span. Fades back to the resting underline after 1.6s
1931
+ (matches scrollToNote's setTimeout in app.js). */
1932
+ .note-highlight-flash {
1933
+ animation: note-highlight-flash 1.6s ease-out;
1934
+ }
1935
+ @keyframes note-highlight-flash {
1936
+ 0% { background: rgba(190, 242, 100, 0.28); }
1937
+ 60% { background: rgba(190, 242, 100, 0.14); }
1938
+ 100% { background: transparent; }
1939
+ }
1940
+
1941
+ /* Hover tooltip · custom pop replacing the native `title` attribute
1942
+ so it appears immediately, not after the browser's 1–2s delay.
1943
+ Same visual register as the qcta floating bar (panel-2 surface,
1944
+ hairline border, mono micro-type). pointer-events:none so the
1945
+ tooltip can never block the hover that triggers it. */
1946
+ .note-tip {
1947
+ position: absolute;
1948
+ z-index: 1450;
1949
+ display: none;
1950
+ align-items: center;
1951
+ gap: 6px;
1952
+ background: var(--panel-2);
1953
+ border: 0.5px solid var(--line-strong);
1954
+ font-family: var(--mono);
1955
+ font-size: 11px;
1956
+ letter-spacing: 0.04em;
1957
+ padding: 6px 10px;
1958
+ color: var(--text);
1959
+ white-space: nowrap;
1960
+ pointer-events: none;
1961
+ box-shadow: 0 4px 14px -6px rgba(0, 0, 0, 0.55);
1962
+ }
1963
+ .note-tip.open { display: inline-flex; }
1964
+ .note-tip-mark {
1965
+ color: var(--lime);
1966
+ font-weight: 700;
1967
+ font-size: 12px;
1968
+ line-height: 1;
1969
+ }
1970
+ .note-tip-label { color: var(--text); }
1971
+ .note-tip-sep { color: var(--text-faint); opacity: 0.7; }
1972
+ .note-tip-meta { color: var(--text-faint); }
1973
+
1974
+ /* Empty state · single placeholder card matching the visual
1975
+ register of `.reports-list-empty`, with a one-line hint and
1976
+ two `.kbd` pills explaining how to save the first note. */
1977
+ .notes-list-empty {
1978
+ margin-top: 28px;
1979
+ padding: 28px 24px 30px;
1980
+ background: var(--panel-2);
1981
+ border: 0.5px solid var(--line);
1982
+ border-radius: 6px;
1983
+ display: flex;
1984
+ flex-direction: column;
1985
+ gap: 10px;
1986
+ align-items: flex-start;
1987
+ }
1988
+ .notes-empty-mark {
1989
+ font-family: var(--mono);
1990
+ font-size: 18px;
1991
+ color: var(--text-faint);
1992
+ line-height: 1;
1993
+ }
1994
+ .notes-empty-title {
1995
+ font-family: var(--font-human);
1996
+ font-size: 15px;
1997
+ font-weight: 600;
1998
+ line-height: 1.3;
1999
+ color: var(--text-soft);
2000
+ }
2001
+ .notes-empty-deck {
2002
+ font-family: var(--font-human);
2003
+ font-size: 12.5px;
2004
+ line-height: 1.6;
2005
+ color: var(--text-dim);
2006
+ max-width: 540px;
2007
+ }
2008
+ .notes-list-empty .kbd {
2009
+ display: inline-flex;
2010
+ align-items: center;
2011
+ font-family: var(--mono);
2012
+ font-size: 10.5px;
2013
+ font-weight: 600;
2014
+ padding: 1px 7px;
2015
+ margin: 0 1px;
2016
+ border: 0.5px solid var(--line-bright);
2017
+ border-radius: 3px;
2018
+ color: var(--text);
2019
+ background: var(--panel-3);
2020
+ }
2021
+ /* Window-empty CTA · "← Show all notes" button shown when a
2022
+ non-All filter has zero matches. Same visual register as
2023
+ `.reports-list-empty-cta`. */
2024
+ .notes-empty-cta {
2025
+ margin-top: 6px;
2026
+ display: inline-flex;
2027
+ align-items: center;
2028
+ gap: 7px;
2029
+ padding: 6px 12px;
2030
+ background: transparent;
2031
+ border: 0.5px solid var(--line-bright);
2032
+ color: var(--text-soft);
2033
+ font-family: var(--mono);
2034
+ font-size: 10px;
2035
+ font-weight: 700;
2036
+ letter-spacing: 0.14em;
2037
+ text-transform: uppercase;
2038
+ cursor: pointer;
2039
+ border-radius: 4px;
2040
+ transition: border-color 0.12s, color 0.12s, background 0.12s;
2041
+ }
2042
+ .notes-empty-cta:hover {
2043
+ border-color: var(--lime);
2044
+ color: var(--lime);
2045
+ background: var(--panel-3);
2046
+ }
2047
+ .notes-empty-cta-arrow {
2048
+ font-family: var(--mono);
2049
+ font-size: 11px;
2050
+ line-height: 1;
2051
+ }
2052
+ /* Wrap around the date-grouped list · matches the reports list
2053
+ wrap so the chip strip → list spacing reads identically. */
2054
+ .notes-list-wrap { margin-top: 18px; }
2055
+
2056
+ /* Loading skeleton · matches `.reports-skeleton` rhythm and
2057
+ reuses its pulse keyframe. */
2058
+ .notes-skeleton {
2059
+ display: flex;
2060
+ flex-direction: column;
2061
+ gap: 18px;
2062
+ margin-top: 32px;
2063
+ }
2064
+ .notes-skeleton-card {
2065
+ height: 84px;
2066
+ background: var(--panel-2);
2067
+ border: 0.5px solid var(--line);
2068
+ border-radius: 4px;
2069
+ animation: reports-skeleton-pulse 1.6s ease-in-out infinite;
2070
+ }
2071
+
2072
+ @media (max-width: 720px) {
2073
+ .notes-page { padding: 36px 22px 60px; }
2074
+ .notes-page-title { font-size: 22px; }
2075
+ .notes-item-subject { max-width: 160px; }
2076
+ }
2077
+
2078
+ /* Room head — generously padded so the title has breathing
2079
+ room above and below. */
1459
2080
  .room-head {
1460
2081
  background: var(--panel-2);
1461
2082
  border-bottom: 0.5px solid var(--line-bright);
@@ -1465,6 +2086,15 @@
1465
2086
  gap: 14px;
1466
2087
  align-items: center;
1467
2088
  }
2089
+ /* When the sidebar is collapsed the room-head gains a leading
2090
+ auto-sized track for the in-header `.room-head-expand` button.
2091
+ When expanded the button is display:none, so a 0-width track
2092
+ would still leave a `gap` artifact — switching to a 3-track
2093
+ template only in the collapsed state keeps the header visually
2094
+ identical to before whenever the sidebar is open. */
2095
+ body.sidebar-collapsed .room-head {
2096
+ grid-template-columns: auto 1fr auto;
2097
+ }
1468
2098
  /* `overflow: visible` so the tone-tag hover tooltip (positioned via
1469
2099
  ::after below the tag) can escape this container. The room-subject
1470
2100
  has its own overflow:hidden + text-overflow ellipsis rule, so the
@@ -1838,8 +2468,10 @@
1838
2468
  .paused-bar-text { font-size: 10.5px; color: var(--text-dim); }
1839
2469
  .paused-bar-text strong { color: var(--text); font-weight: 700; }
1840
2470
  .paused-bar-text .lime { color: var(--lime); }
1841
- .paused-bar-actions { display: flex; gap: 6px; flex-wrap: wrap; }
2471
+ .paused-bar-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
1842
2472
  .resume-btn-lg {
2473
+ display: inline-flex;
2474
+ align-items: center;
1843
2475
  padding: 6px 12px;
1844
2476
  background: var(--lime);
1845
2477
  color: var(--bg);
@@ -1847,6 +2479,7 @@
1847
2479
  font-family: var(--mono);
1848
2480
  font-size: 10px;
1849
2481
  font-weight: 700;
2482
+ line-height: 1.2;
1850
2483
  cursor: pointer;
1851
2484
  text-decoration: none;
1852
2485
  text-transform: uppercase;
@@ -1891,20 +2524,30 @@
1891
2524
  letter-spacing: 0.1em;
1892
2525
  }
1893
2526
  .view-report-btn:hover { background: var(--bg); color: var(--lime); }
1894
- /* No-report variant · shown in the head when an adjourned room has no
1895
- brief filed (user opted out via skip-brief). Visually muted so it
1896
- reads as "status indicator", not an actionable button. */
1897
- .view-report-btn.no-report {
1898
- background: transparent;
1899
- color: var(--text-faint);
1900
- border-color: var(--line-bright);
1901
- cursor: default;
1902
- user-select: none;
2527
+ /* Generate-report variant · shown in the head when an adjourned
2528
+ room has no brief yet. Active CTA (lime filled, same register as
2529
+ the "View Report" button next door) clicking POSTs to
2530
+ /api/rooms/:id/brief and kicks off the post-hoc 3-stage pipeline.
2531
+ Replaces the earlier muted `[ ⊘ No Report ]` static span which
2532
+ gave users no path back to a brief once they'd skipped at adjourn. */
2533
+ .view-report-btn.generate-report {
2534
+ display: inline-flex;
2535
+ align-items: center;
2536
+ gap: 6px;
1903
2537
  }
1904
- .view-report-btn.no-report:hover {
1905
- background: transparent;
1906
- color: var(--text-faint);
1907
- border-color: var(--line-bright);
2538
+ .view-report-btn.generate-report .vr-mark {
2539
+ font-size: 10px;
2540
+ line-height: 1;
2541
+ }
2542
+ /* Pending state · applied to either the header anchor or the
2543
+ no-brief-card button while the brief pipeline is mid-flight.
2544
+ Reads as "action accepted, working on it" without disabling the
2545
+ surrounding region. */
2546
+ .view-report-btn[data-pending="1"],
2547
+ [data-generate-brief][data-pending="1"] {
2548
+ cursor: progress;
2549
+ opacity: 0.7;
2550
+ pointer-events: none;
1908
2551
  }
1909
2552
 
1910
2553
  .adjourned-bar {
@@ -1935,17 +2578,26 @@
1935
2578
  }
1936
2579
  .followup-btn:hover { background: var(--bg); color: var(--lime); }
1937
2580
  .ghost-btn {
1938
- padding: 5px 10px;
2581
+ /* inline-flex + align-items:center so text always centers vertically
2582
+ inside the box even when a flex parent stretches us to match the
2583
+ resume-btn-lg sibling's height. Padding / font-size / weight are
2584
+ lined up with .resume-btn-lg so the buttons read as the same
2585
+ size class — only the fill / border treatment distinguishes the
2586
+ primary lime CTA from a quieter ghost. */
2587
+ display: inline-flex;
2588
+ align-items: center;
2589
+ padding: 6px 12px;
1939
2590
  background: var(--bg);
1940
2591
  border: 0.5px solid var(--line-strong);
1941
2592
  font-family: var(--mono);
1942
- font-size: 9.5px;
1943
- font-weight: 600;
2593
+ font-size: 10px;
2594
+ font-weight: 700;
2595
+ line-height: 1.2;
1944
2596
  color: var(--text);
1945
2597
  cursor: pointer;
1946
2598
  text-decoration: none;
1947
2599
  text-transform: uppercase;
1948
- letter-spacing: 0.08em;
2600
+ letter-spacing: 0.1em;
1949
2601
  }
1950
2602
  .ghost-btn:hover { border-color: var(--lime); color: var(--lime); }
1951
2603
 
@@ -2076,6 +2728,21 @@
2076
2728
  text-transform: uppercase;
2077
2729
  margin: 0;
2078
2730
  }
2731
+ /* Subtle middot separator between meta items (authors · word count).
2732
+ Faint enough that the eye walks the items, not the divider. */
2733
+ .brief-meta-sep {
2734
+ color: var(--text-faint);
2735
+ font-family: var(--mono);
2736
+ font-size: 9px;
2737
+ line-height: 1;
2738
+ opacity: 0.6;
2739
+ }
2740
+ /* Word / character count · same register as `.brief-meta-line` but
2741
+ keep numerals tabular so the figure doesn't jitter while the
2742
+ parent flex-row gets re-rendered (e.g. on tab switch). */
2743
+ .brief-meta-words {
2744
+ font-variant-numeric: tabular-nums;
2745
+ }
2079
2746
 
2080
2747
  /* Signatures · folded into the meta line, no top border */
2081
2748
  .brief-signed {
@@ -2136,44 +2803,54 @@
2136
2803
  40% { opacity: 1; transform: translateY(-1px); }
2137
2804
  }
2138
2805
 
2139
- .brief-stages {
2806
+ /* ── Loading layout · active card + horizontal pip rail ────────
2807
+ Replaces the older 3-row stacked checklist. The active stage
2808
+ gets full visual weight (label + rotating italic sub-line +
2809
+ timing + bottom progress line); the pip rail underneath is the
2810
+ "where am I in the sequence" reference. Container scales from
2811
+ 3 to N pips without breaking. */
2812
+ .brief-progress {
2140
2813
  display: flex;
2141
2814
  flex-direction: column;
2142
- gap: 4px;
2143
- }
2144
- .brief-stage-row {
2145
- display: grid;
2146
- grid-template-columns: 16px 1fr;
2147
- gap: 8px;
2148
- align-items: start;
2149
- padding: 6px 0;
2150
- transition: opacity 0.2s, color 0.2s;
2815
+ gap: 14px;
2816
+ padding: 4px 0;
2151
2817
  }
2152
- .brief-stage-content {
2153
- display: flex;
2154
- flex-direction: column;
2155
- gap: 2px;
2156
- min-width: 0;
2818
+
2819
+ /* Active card · the protagonist row. Hairline frame on top + bottom
2820
+ edges only (no left/right border, per project rule against left
2821
+ callout treatments). The bottom edge is overdrawn by the lime
2822
+ progress line as the stage advances. */
2823
+ .brief-active-card {
2824
+ position: relative;
2825
+ padding: 10px 0 14px;
2826
+ border-top: 1px solid var(--line);
2827
+ border-bottom: 1px solid var(--line);
2828
+ min-height: 76px;
2157
2829
  }
2158
- .brief-stage-line {
2830
+ .brief-active-head {
2159
2831
  display: flex;
2160
2832
  align-items: baseline;
2161
- gap: 8px;
2833
+ justify-content: space-between;
2834
+ gap: 12px;
2162
2835
  flex-wrap: wrap;
2163
2836
  }
2164
- .brief-stage-substage {
2165
- font-family: var(--mono);
2166
- font-size: 9.5px;
2167
- letter-spacing: 0.04em;
2168
- color: var(--text-faint);
2169
- line-height: 1.5;
2170
- margin-top: 1px;
2171
- transition: opacity 0.2s ease;
2837
+ .brief-active-label {
2838
+ font-family: var(--font-human);
2839
+ font-size: 14px;
2840
+ font-weight: 600;
2841
+ line-height: 1.4;
2842
+ color: var(--text);
2843
+ letter-spacing: 0.005em;
2172
2844
  }
2173
- .brief-stage-row.brief-stage-active .brief-stage-substage {
2845
+ .brief-active-card.brief-active-pending .brief-active-label,
2846
+ .brief-active-card.brief-active-done .brief-active-label {
2174
2847
  color: var(--text-soft);
2848
+ font-weight: 500;
2175
2849
  }
2176
- .brief-stage-timing {
2850
+ .brief-active-meta {
2851
+ display: inline-flex;
2852
+ align-items: baseline;
2853
+ gap: 10px;
2177
2854
  font-family: var(--mono);
2178
2855
  font-size: 9.5px;
2179
2856
  letter-spacing: 0.06em;
@@ -2182,72 +2859,200 @@
2182
2859
  white-space: nowrap;
2183
2860
  margin-left: auto;
2184
2861
  }
2185
- .brief-stage-row.brief-stage-active .brief-stage-timing {
2186
- color: var(--lime);
2187
- }
2188
- .brief-stage-row.brief-stage-pending {
2862
+ .brief-active-meta .meta-detail { color: var(--text-soft); }
2863
+ .brief-active-meta .meta-timing { color: var(--lime); }
2864
+ .brief-active-card.brief-active-pending .brief-active-meta .meta-timing,
2865
+ .brief-active-card.brief-active-done .brief-active-meta .meta-timing {
2189
2866
  color: var(--text-faint);
2190
- opacity: 0.55;
2191
2867
  }
2192
- .brief-stage-row.brief-stage-active {
2193
- color: var(--text);
2194
- }
2195
- .brief-stage-row.brief-stage-done {
2868
+ /* Italic-serif rotator under the head · changes every 3s while
2869
+ active. Empty placeholder &nbsp; reserves the line so the layout
2870
+ doesn't jump on the first render before substage populates. */
2871
+ .brief-active-substage {
2872
+ font-family: var(--font-headline, "Charter", "Songti SC", Georgia, serif);
2873
+ font-style: italic;
2874
+ font-size: 13px;
2875
+ line-height: 1.55;
2196
2876
  color: var(--text-soft);
2877
+ margin-top: 6px;
2878
+ min-height: 20px;
2197
2879
  }
2198
- .brief-stage-mark {
2199
- display: inline-flex;
2200
- align-items: center;
2201
- justify-content: center;
2202
- width: 12px; height: 12px;
2203
- font-size: 11px;
2204
- line-height: 1;
2205
- margin-top: 4px;
2880
+ /* Bottom progress line · overdraws the card's bottom border in
2881
+ lime as elapsed advances. JS sets width via inline style; the
2882
+ CSS transition smooths each tick. */
2883
+ .brief-active-progressline {
2884
+ position: absolute;
2885
+ bottom: -1px;
2886
+ left: 0;
2887
+ height: 1.5px;
2888
+ background: var(--lime);
2889
+ width: 0%;
2890
+ transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1);
2891
+ pointer-events: none;
2206
2892
  }
2207
- .brief-stage-mark.done {
2208
- color: var(--lime);
2209
- font-weight: 700;
2893
+ .brief-active-card.brief-active-pending .brief-active-progressline {
2894
+ background: var(--text-faint);
2210
2895
  }
2211
- .brief-stage-mark.pending {
2212
- color: var(--text-faint);
2896
+
2897
+ /* Pip rail · horizontal sequence indicator. Each pip = dot + tiny
2898
+ mono caption underneath. Connectors are 1px lines between dots,
2899
+ coloured by the LEFT pip's status so a done→active transition
2900
+ reads as "completed crossing into the present." */
2901
+ .brief-pip-rail {
2902
+ display: flex;
2903
+ align-items: flex-start;
2904
+ padding: 0 2px;
2213
2905
  }
2214
- .brief-stage-mark.active {
2215
- position: relative;
2906
+ .brief-pip {
2907
+ display: flex;
2908
+ flex-direction: column;
2909
+ align-items: center;
2910
+ gap: 6px;
2911
+ flex: 0 0 auto;
2912
+ min-width: 0;
2216
2913
  }
2217
- .brief-stage-pulse {
2218
- width: 8px; height: 8px;
2914
+ .brief-pip-dot {
2915
+ width: 10px;
2916
+ height: 10px;
2219
2917
  border-radius: 50%;
2918
+ border: 1px solid var(--line-bright);
2919
+ background: var(--bg);
2920
+ position: relative;
2921
+ transition: background 0.4s ease, border-color 0.4s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
2922
+ }
2923
+ .brief-pip.is-done .brief-pip-dot {
2220
2924
  background: var(--lime);
2221
- box-shadow: 0 0 0 0 var(--lime);
2222
- animation: brief-stage-pulse 1.4s ease-out infinite;
2925
+ border-color: var(--lime);
2223
2926
  }
2224
- @keyframes brief-stage-pulse {
2225
- 0% { box-shadow: 0 0 0 0 var(--lime); opacity: 1; }
2226
- 70% { box-shadow: 0 0 0 6px transparent; opacity: 0.85; }
2227
- 100% { box-shadow: 0 0 0 0 transparent; opacity: 1; }
2927
+ .brief-pip.is-done .brief-pip-dot::after {
2928
+ content: "";
2929
+ position: absolute;
2930
+ inset: 2px;
2931
+ border-radius: 50%;
2932
+ background: var(--bg);
2228
2933
  }
2229
- .brief-stage-label {
2230
- font-weight: 500;
2231
- letter-spacing: 0;
2232
- text-transform: none;
2233
- font-family: var(--font-human);
2234
- font-size: 12.5px;
2235
- line-height: 1.35;
2934
+ .brief-pip.is-active .brief-pip-dot {
2935
+ background: var(--lime);
2936
+ border-color: var(--lime);
2937
+ animation: brief-pip-pulse 1.4s ease-out infinite;
2236
2938
  }
2237
- .brief-stage-row.brief-stage-active .brief-stage-label {
2238
- font-weight: 700;
2939
+ @keyframes brief-pip-pulse {
2940
+ 0% { box-shadow: 0 0 0 0 rgba(124, 184, 88, 0.55); }
2941
+ 70% { box-shadow: 0 0 0 7px rgba(124, 184, 88, 0); }
2942
+ 100% { box-shadow: 0 0 0 0 rgba(124, 184, 88, 0); }
2239
2943
  }
2240
- .brief-stage-detail {
2944
+ .brief-pip-label {
2241
2945
  font-family: var(--mono);
2242
- font-size: 9.5px;
2243
- letter-spacing: 0.06em;
2946
+ font-size: 9px;
2947
+ letter-spacing: 0.1em;
2244
2948
  text-transform: uppercase;
2245
2949
  color: var(--text-faint);
2246
2950
  white-space: nowrap;
2951
+ line-height: 1;
2952
+ transition: color 0.3s ease;
2247
2953
  }
2248
- .brief-stage-row.brief-stage-active .brief-stage-detail {
2249
- color: var(--lime);
2954
+ .brief-pip.is-active .brief-pip-label { color: var(--lime); font-weight: 700; }
2955
+ .brief-pip.is-done .brief-pip-label { color: var(--text-soft); }
2956
+
2957
+ /* Connector · stretches to fill remaining horizontal space between
2958
+ two pip columns, sits at the dot's vertical center (5px from top
2959
+ of the column). Done connectors are lime; pending stay neutral. */
2960
+ .brief-pip-line {
2961
+ flex: 1 1 0;
2962
+ height: 1px;
2963
+ background: var(--line-bright);
2964
+ margin: 5px 6px 0;
2965
+ align-self: flex-start;
2966
+ transition: background 0.4s ease;
2967
+ }
2968
+ .brief-pip-line.is-done { background: var(--lime); }
2969
+
2970
+ /* One-shot "settle" spring when a pip first transitions to done.
2971
+ The is-fresh-done class is applied by renderBriefStages ONLY on
2972
+ the first render where the pip is done (subsequent re-renders
2973
+ check the per-brief seenDone Set and skip the class). The brief
2974
+ moment of motion reads as a chip falling into place — not a
2975
+ particle, not a flash, just a small spring on the dot. */
2976
+ .brief-pip.is-fresh-done .brief-pip-dot {
2977
+ animation: brief-pip-settle 360ms cubic-bezier(0.34, 1.56, 0.64, 1);
2978
+ }
2979
+ @keyframes brief-pip-settle {
2980
+ 0% { transform: scale(0.6); }
2981
+ 55% { transform: scale(1.18); }
2982
+ 100% { transform: scale(1.0); }
2983
+ }
2984
+
2985
+ /* Stats row · accumulates harvested numbers + director chips below
2986
+ the pip rail. Empty by default — the renderer hides the wrapper
2987
+ entirely when no fragments are present so the layout doesn't show
2988
+ a phantom border on first render. */
2989
+ .brief-stats-row {
2990
+ display: flex;
2991
+ flex-wrap: wrap;
2992
+ gap: 6px 10px;
2993
+ align-items: center;
2994
+ padding-top: 10px;
2995
+ border-top: 1px solid var(--line);
2996
+ font-family: var(--mono);
2997
+ font-size: 9.5px;
2998
+ letter-spacing: 0.04em;
2999
+ color: var(--text-faint);
2250
3000
  }
3001
+ .brief-stat-roster {
3002
+ display: inline-flex;
3003
+ flex-wrap: wrap;
3004
+ gap: 4px;
3005
+ align-items: center;
3006
+ }
3007
+ .brief-stat-chip {
3008
+ display: inline-flex;
3009
+ align-items: center;
3010
+ gap: 5px;
3011
+ padding: 2px 8px;
3012
+ font-family: var(--mono);
3013
+ font-size: 9.5px;
3014
+ letter-spacing: 0.04em;
3015
+ color: var(--text-soft);
3016
+ background: transparent;
3017
+ border: 1px solid var(--line-bright);
3018
+ border-radius: 9px;
3019
+ line-height: 1.45;
3020
+ white-space: nowrap;
3021
+ }
3022
+ /* Top-kind tag · italic afterthought next to the count. The
3023
+ renderer only emits this when the dominant kind has ≥ 2 entries,
3024
+ keeping the chip uncluttered on the long tail. */
3025
+ .brief-stat-chip-tag {
3026
+ font-style: italic;
3027
+ color: var(--text-faint);
3028
+ letter-spacing: 0.02em;
3029
+ }
3030
+ /* Entrance animation runs ONLY on the render where the chip first
3031
+ appears · the renderer applies `.is-fresh` only when the
3032
+ director's id isn't yet in `_briefSeenHarvestKeys[briefId]`, then
3033
+ drops it on subsequent ticks. Without this gate, every 1-second
3034
+ re-render would re-trigger the keyframe and the chips would fade
3035
+ in over and over (the "flickering" bug). Stagger gives the row a
3036
+ roll-call feel rather than a single dump. */
3037
+ .brief-stat-chip.is-fresh {
3038
+ animation: brief-stat-chip-in 280ms ease-out both;
3039
+ }
3040
+ .brief-stat-chip.is-fresh:nth-child(1) { animation-delay: 0ms; }
3041
+ .brief-stat-chip.is-fresh:nth-child(2) { animation-delay: 60ms; }
3042
+ .brief-stat-chip.is-fresh:nth-child(3) { animation-delay: 120ms; }
3043
+ .brief-stat-chip.is-fresh:nth-child(4) { animation-delay: 180ms; }
3044
+ .brief-stat-chip.is-fresh:nth-child(5) { animation-delay: 240ms; }
3045
+ .brief-stat-chip.is-fresh:nth-child(6) { animation-delay: 300ms; }
3046
+ @keyframes brief-stat-chip-in {
3047
+ 0% { opacity: 0; transform: translateY(2px); }
3048
+ 100% { opacity: 1; transform: translateY(0); }
3049
+ }
3050
+ .brief-stat-fact {
3051
+ color: var(--text-soft);
3052
+ text-transform: uppercase;
3053
+ letter-spacing: 0.06em;
3054
+ }
3055
+ .brief-stat-fact.brief-stat-live { color: var(--lime); }
2251
3056
 
2252
3057
  /* Brief error / interrupted state · retry button matches the
2253
3058
  supplement-row treatment but uses red accent for "something went
@@ -2285,6 +3090,72 @@
2285
3090
  line-height: 1;
2286
3091
  }
2287
3092
 
3093
+ /* Salvage-path retry banner · shown when a regeneration fails but a
3094
+ prior successful brief is still available. The banner sits AT the
3095
+ top of the brief area (in flow, not overlay) so the existing
3096
+ report below stays fully visible. Keeps the retry affordance
3097
+ within reach without burying the user's last good output. */
3098
+ .brief-retry-banner {
3099
+ display: flex;
3100
+ align-items: center;
3101
+ gap: 10px;
3102
+ padding: 8px 12px;
3103
+ margin: 0 0 14px;
3104
+ background: color-mix(in srgb, var(--red) 6%, var(--panel-2));
3105
+ border: 0.5px solid color-mix(in srgb, var(--red) 40%, var(--line-bright));
3106
+ font-family: var(--mono);
3107
+ font-size: 11px;
3108
+ color: var(--text);
3109
+ }
3110
+ .brief-retry-banner .brb-mark {
3111
+ color: var(--red);
3112
+ font-size: 12px;
3113
+ line-height: 1;
3114
+ }
3115
+ .brief-retry-banner .brb-text {
3116
+ flex: 1;
3117
+ min-width: 0;
3118
+ color: var(--text-soft);
3119
+ letter-spacing: 0.02em;
3120
+ overflow: hidden;
3121
+ text-overflow: ellipsis;
3122
+ white-space: nowrap;
3123
+ }
3124
+ .brief-retry-banner .brb-retry {
3125
+ display: inline-flex;
3126
+ align-items: center;
3127
+ gap: 5px;
3128
+ padding: 4px 10px;
3129
+ background: transparent;
3130
+ border: 0.5px solid var(--lime);
3131
+ color: var(--lime);
3132
+ cursor: pointer;
3133
+ font-family: var(--mono);
3134
+ font-size: 9.5px;
3135
+ font-weight: 700;
3136
+ letter-spacing: 0.12em;
3137
+ text-transform: uppercase;
3138
+ transition: background 0.12s, color 0.12s;
3139
+ }
3140
+ .brief-retry-banner .brb-retry:hover {
3141
+ background: var(--lime);
3142
+ color: var(--bg);
3143
+ }
3144
+ .brief-retry-banner .brb-retry-mark {
3145
+ font-size: 11px;
3146
+ line-height: 1;
3147
+ }
3148
+ .brief-retry-banner .brb-dismiss {
3149
+ background: transparent;
3150
+ border: none;
3151
+ color: var(--text-faint);
3152
+ cursor: pointer;
3153
+ font-size: 13px;
3154
+ line-height: 1;
3155
+ padding: 2px 4px;
3156
+ }
3157
+ .brief-retry-banner .brb-dismiss:hover { color: var(--red); }
3158
+
2288
3159
  /* Brief version tabs · scrollable horizontal strip at the top of
2289
3160
  the brief card when ≥ 2 briefs have been filed for this room.
2290
3161
  Each tab shows a mono number + a short label (Initial / supplement
@@ -2372,6 +3243,25 @@
2372
3243
  .brief-version-tab.active .brief-version-label {
2373
3244
  color: var(--text);
2374
3245
  }
3246
+ /* Failure marker · small "!" glyph next to the version number when
3247
+ a brief is errored / interrupted / timed-out. Lets the user spot
3248
+ which tab needs retrying without entering it. The full error UI
3249
+ surfaces once the tab is clicked (bypassSalvage path). */
3250
+ .brief-version-state {
3251
+ display: inline-flex;
3252
+ align-items: center;
3253
+ justify-content: center;
3254
+ width: 12px;
3255
+ height: 12px;
3256
+ margin-left: 4px;
3257
+ border-radius: 50%;
3258
+ background: var(--red);
3259
+ color: var(--bg);
3260
+ font-family: var(--mono);
3261
+ font-size: 8.5px;
3262
+ font-weight: 700;
3263
+ line-height: 1;
3264
+ }
2375
3265
 
2376
3266
  /* Add-perspective + delete row · sits at the bottom of the brief
2377
3267
  card with a hairline divider above. The supplement button leads
@@ -2522,57 +3412,538 @@
2522
3412
  background: var(--bg);
2523
3413
  border: 0.5px solid var(--line-strong);
2524
3414
  color: var(--text);
2525
- font-family: var(--font-human);
2526
- font-size: 13.5px;
2527
- line-height: 1.55;
2528
- padding: 12px 14px;
2529
- resize: vertical;
2530
- min-height: 132px;
2531
- transition: border-color 0.12s;
3415
+ font-family: var(--font-human);
3416
+ font-size: 13.5px;
3417
+ line-height: 1.55;
3418
+ padding: 12px 14px;
3419
+ resize: vertical;
3420
+ min-height: 132px;
3421
+ transition: border-color 0.12s;
3422
+ }
3423
+ .supplement-input:focus { outline: none; border-color: var(--lime); }
3424
+ .supplement-input::placeholder {
3425
+ color: var(--text-faint);
3426
+ white-space: pre-wrap;
3427
+ }
3428
+ .supplement-hint {
3429
+ margin: 10px 0 0;
3430
+ font-size: 11.5px;
3431
+ line-height: 1.5;
3432
+ color: var(--text-faint);
3433
+ }
3434
+ .supplement-foot {
3435
+ display: flex;
3436
+ justify-content: flex-end;
3437
+ gap: 10px;
3438
+ padding: 12px 22px 16px;
3439
+ border-top: 0.5px solid var(--line);
3440
+ }
3441
+ .supplement-cancel,
3442
+ .supplement-confirm {
3443
+ font-family: var(--mono);
3444
+ font-size: 10.5px;
3445
+ font-weight: 700;
3446
+ letter-spacing: 0.14em;
3447
+ text-transform: uppercase;
3448
+ padding: 8px 14px;
3449
+ cursor: pointer;
3450
+ background: transparent;
3451
+ border: 0.5px solid var(--line-strong);
3452
+ color: var(--text-soft);
3453
+ transition: border-color 0.1s, color 0.1s, background 0.1s;
3454
+ }
3455
+ .supplement-cancel:hover { border-color: var(--text-soft); color: var(--text); }
3456
+ .supplement-confirm {
3457
+ background: var(--lime);
3458
+ border-color: var(--lime);
3459
+ color: var(--bg);
3460
+ }
3461
+ .supplement-confirm:hover { background: transparent; color: var(--lime); }
3462
+ .supplement-confirm[disabled] {
3463
+ opacity: 0.55;
3464
+ pointer-events: none;
3465
+ }
3466
+
3467
+ /* ─── Follow-up overlay · widens the supplement modal slightly so
3468
+ the multi-field form (subject + cast + tone/intensity) breathes,
3469
+ and adds shape for the parent reference card + form fields.
3470
+ Inherits supplement-overlay's classification / head / footer
3471
+ chrome — only the inside form differs. */
3472
+ .supplement-modal.followup-modal {
3473
+ max-width: 640px;
3474
+ }
3475
+ .followup-parent-card {
3476
+ background: var(--panel-2);
3477
+ border: 0.5px solid var(--line-bright);
3478
+ padding: 10px 14px;
3479
+ margin-bottom: 18px;
3480
+ font-family: var(--mono);
3481
+ }
3482
+ .followup-parent-subject {
3483
+ font-size: 13px;
3484
+ font-weight: 600;
3485
+ color: var(--text);
3486
+ line-height: 1.4;
3487
+ margin-bottom: 4px;
3488
+ }
3489
+ .followup-parent-meta {
3490
+ font-size: 10px;
3491
+ color: var(--text-soft);
3492
+ letter-spacing: 0.06em;
3493
+ text-transform: uppercase;
3494
+ }
3495
+ /* Context note · explains to the user that this room's content
3496
+ will be packaged as the follow-up's prior context. Sits inside
3497
+ the parent reference card with a hairline separator above it
3498
+ so it reads as a footnote on the card, not a separate block. */
3499
+ .followup-parent-note {
3500
+ margin-top: 8px;
3501
+ padding-top: 8px;
3502
+ border-top: 0.5px dashed var(--line-bright);
3503
+ font-family: var(--font-human);
3504
+ font-size: 11.5px;
3505
+ line-height: 1.45;
3506
+ color: var(--text-soft);
3507
+ letter-spacing: -0.003em;
3508
+ text-transform: none;
3509
+ }
3510
+ .followup-field {
3511
+ display: flex;
3512
+ flex-direction: column;
3513
+ margin-bottom: 14px;
3514
+ }
3515
+ .followup-field-label {
3516
+ display: flex;
3517
+ align-items: baseline;
3518
+ gap: 8px;
3519
+ font-family: var(--mono);
3520
+ font-size: 10px;
3521
+ font-weight: 700;
3522
+ letter-spacing: 0.16em;
3523
+ text-transform: uppercase;
3524
+ color: var(--text-soft);
3525
+ margin-bottom: 6px;
3526
+ }
3527
+ .followup-field-hint {
3528
+ font-family: var(--mono);
3529
+ font-size: 9px;
3530
+ font-weight: 400;
3531
+ letter-spacing: 0.08em;
3532
+ color: var(--text-faint);
3533
+ text-transform: uppercase;
3534
+ }
3535
+ /* Cast / tone / intensity row · cast button + tune dropdowns on
3536
+ ONE line, mirroring the new-room composer's toolbar layout
3537
+ (cast left, separator, tune dropdowns) but in a form-row
3538
+ register. The wrapper is `.cmp-tune` so the inline-flex + gap +
3539
+ wrap behaviour is inherited as-is. */
3540
+ .followup-cast-row {
3541
+ align-items: stretch;
3542
+ }
3543
+ .followup-cast-btn {
3544
+ /* Hairline visible at rest in the overlay (vs the toolbar
3545
+ variant which is borderless until hover). Makes the picker
3546
+ obviously interactive in the form context. */
3547
+ border-color: var(--line-bright);
3548
+ }
3549
+ .followup-cast-btn[disabled] {
3550
+ cursor: not-allowed;
3551
+ opacity: 0.7;
3552
+ border-style: dashed;
3553
+ }
3554
+ .followup-cast-btn[data-cast-mode="same-as-last"] {
3555
+ border-color: var(--lime-dim);
3556
+ background: var(--panel-2);
3557
+ }
3558
+ /* Visual separator between cast button and the tune group ·
3559
+ mirrors `.cmp-toolbar-sep` from the inline composer toolbar. */
3560
+ .followup-cast-row-sep {
3561
+ display: inline-block;
3562
+ width: 0.5px;
3563
+ align-self: stretch;
3564
+ background: var(--line-bright);
3565
+ margin: 0 4px;
3566
+ }
3567
+
3568
+ /* "Same cast as last session" checkbox · sits below the cast
3569
+ button. Native checkbox restyled to match the radio chrome
3570
+ used elsewhere in this overlay. */
3571
+ .followup-checkbox {
3572
+ display: flex;
3573
+ align-items: center;
3574
+ gap: 8px;
3575
+ padding: 8px 0 0;
3576
+ font-family: var(--mono);
3577
+ font-size: 12px;
3578
+ color: var(--text-soft);
3579
+ cursor: pointer;
3580
+ user-select: none;
3581
+ }
3582
+ .followup-checkbox input[type="checkbox"] {
3583
+ appearance: none;
3584
+ -webkit-appearance: none;
3585
+ width: 12px; height: 12px;
3586
+ border: 0.5px solid var(--line-strong);
3587
+ background: var(--bg);
3588
+ cursor: pointer;
3589
+ flex-shrink: 0;
3590
+ transition: border-color 0.1s, background 0.1s;
3591
+ position: relative;
3592
+ }
3593
+ .followup-checkbox input[type="checkbox"]:hover { border-color: var(--text-soft); }
3594
+ .followup-checkbox input[type="checkbox"]:checked {
3595
+ background: var(--lime);
3596
+ border-color: var(--lime);
3597
+ }
3598
+ .followup-checkbox input[type="checkbox"]:checked::after {
3599
+ content: "";
3600
+ position: absolute;
3601
+ inset: 2px;
3602
+ background: var(--bg);
3603
+ clip-path: polygon(14% 50%, 0 65%, 38% 100%, 100% 30%, 86% 16%, 38% 70%);
3604
+ }
3605
+ .followup-checkbox input[type="checkbox"]:disabled {
3606
+ opacity: 0.5;
3607
+ cursor: not-allowed;
3608
+ }
3609
+ .followup-checkbox:has(input:checked) { color: var(--text); }
3610
+
3611
+ /* ─── Follow-up tree · parent banner + child list ─────────────────
3612
+ parent banner sits at the top of the chat in a follow-up room
3613
+ (under the room head, before the first message); child list
3614
+ renders below the brief card on a parent room that's spawned
3615
+ follow-ups. Both use the hairline + mono-tag visual register. */
3616
+ .followup-parent-banner {
3617
+ /* Width-match the chat messages cap (960px, centred) so the
3618
+ banner sits flush with the column underneath instead of
3619
+ spanning the full chat scroller on wide monitors. Without
3620
+ this, the banner reads as a viewport-wide alert bar — out
3621
+ of register with everything else. */
3622
+ max-width: 960px;
3623
+ margin: 0 auto 14px;
3624
+ padding: 8px 14px;
3625
+ background: var(--panel-2);
3626
+ border: 0.5px solid var(--line-bright);
3627
+ display: flex;
3628
+ align-items: center;
3629
+ gap: 10px;
3630
+ cursor: pointer;
3631
+ text-decoration: none;
3632
+ font-family: var(--mono);
3633
+ transition: border-color 0.1s, background 0.1s;
3634
+ }
3635
+ .followup-parent-banner:hover {
3636
+ border-color: var(--lime-dim);
3637
+ background: var(--panel-3);
3638
+ }
3639
+ .followup-parent-banner .label {
3640
+ font-size: 9.5px;
3641
+ color: var(--text-soft);
3642
+ letter-spacing: 0.14em;
3643
+ text-transform: uppercase;
3644
+ flex-shrink: 0;
3645
+ }
3646
+ .followup-parent-banner .room-num {
3647
+ font-size: 11px;
3648
+ color: var(--lime);
3649
+ font-weight: 700;
3650
+ flex-shrink: 0;
3651
+ }
3652
+ .followup-parent-banner .subject {
3653
+ font-size: 12px;
3654
+ color: var(--text);
3655
+ flex: 1;
3656
+ min-width: 0;
3657
+ white-space: nowrap;
3658
+ overflow: hidden;
3659
+ text-overflow: ellipsis;
3660
+ }
3661
+ .followup-parent-banner .arrow {
3662
+ font-size: 11px;
3663
+ color: var(--text-dim);
3664
+ flex-shrink: 0;
3665
+ }
3666
+
3667
+ .followup-children {
3668
+ /* Width-match the brief card (.ending-block: max-width 760px,
3669
+ margin auto). The follow-ups list slots in directly below the
3670
+ brief, so they need to share the same gutter — otherwise the
3671
+ follow-ups span full chat width and the alignment breaks. */
3672
+ max-width: 760px;
3673
+ margin: 24px auto 0;
3674
+ padding: 14px 16px;
3675
+ background: var(--panel-2);
3676
+ border: 0.5px solid var(--line);
3677
+ font-family: var(--mono);
3678
+ }
3679
+
3680
+ /* ─── Session analytics card · post-adjourn summary ───
3681
+ Sits below the brief (and any follow-ups) when the room is
3682
+ adjourned. Editorial mood: panel-2 surface, lime banner kicker,
3683
+ hero token-count headline, model-usage stacked bar tinted by
3684
+ provider, "what you valued" highlights at the bottom. Width
3685
+ matches the brief / follow-ups column (760px max, centred). */
3686
+ /* Compact post-adjourn analytics tile · earlier iteration was 22px
3687
+ padding + 22px section gaps + 32px hero numerals, which read as
3688
+ "celebratory dashboard" rather than "summary stripe". Tightened
3689
+ across the board so the tile reads as a dense info row sitting
3690
+ above the brief, not as its own visual event. Hero numerals
3691
+ dropped to 21px, section gaps to 10px, banner padding to 5px,
3692
+ sub-tile padding cut by ~30%. */
3693
+ .session-analytics {
3694
+ max-width: 760px;
3695
+ margin: 18px auto 10px;
3696
+ background: var(--panel-2);
3697
+ border: 0.5px solid var(--line-bright);
3698
+ }
3699
+ .sa-banner {
3700
+ padding: 5px 14px;
3701
+ display: flex;
3702
+ justify-content: space-between;
3703
+ align-items: center;
3704
+ gap: 12px;
3705
+ font-family: var(--mono);
3706
+ border-bottom: 0.5px solid var(--line);
3707
+ background: var(--panel-3);
3708
+ }
3709
+ .sa-banner-tag {
3710
+ font-size: 9.5px;
3711
+ font-weight: 700;
3712
+ letter-spacing: 0.18em;
3713
+ text-transform: uppercase;
3714
+ color: var(--lime);
3715
+ }
3716
+ .sa-banner-stamp {
3717
+ font-size: 9px;
3718
+ letter-spacing: 0.18em;
3719
+ text-transform: uppercase;
3720
+ color: var(--text-faint);
3721
+ }
3722
+ .sa-body {
3723
+ padding: 12px 14px 14px;
3724
+ display: flex;
3725
+ flex-direction: column;
3726
+ gap: 12px;
3727
+ }
3728
+
3729
+ /* Headline metric grid · 4 cells. Hero (tokens) is the only one
3730
+ in accent colour; the others read as siblings at the same size.
3731
+ Compact register · all values 17px, hero 21px, label 9px. */
3732
+ .sa-headline {
3733
+ display: grid;
3734
+ grid-template-columns: 1.4fr 1fr 1fr 1fr;
3735
+ gap: 10px;
3736
+ align-items: end;
3737
+ }
3738
+ @media (max-width: 600px) {
3739
+ .sa-headline { grid-template-columns: 1fr 1fr; }
3740
+ }
3741
+ .sa-metric {
3742
+ display: flex;
3743
+ flex-direction: column;
3744
+ gap: 2px;
3745
+ min-width: 0;
3746
+ }
3747
+ .sa-metric-value {
3748
+ font-family: "SF Mono", "JetBrains Mono", "Menlo", monospace;
3749
+ font-size: 17px;
3750
+ font-weight: 700;
3751
+ color: var(--text);
3752
+ letter-spacing: -0.01em;
3753
+ font-variant-numeric: tabular-nums;
3754
+ line-height: 1;
3755
+ }
3756
+ .sa-metric-hero .sa-metric-value {
3757
+ font-size: 21px;
3758
+ color: var(--lime);
3759
+ }
3760
+ .sa-metric-label {
3761
+ font-family: var(--mono);
3762
+ font-size: 9px;
3763
+ letter-spacing: 0.14em;
3764
+ text-transform: uppercase;
3765
+ color: var(--text-faint);
3766
+ font-weight: 700;
3767
+ }
3768
+
3769
+ /* Model breakdown · stacked bar + legend. Section head sits
3770
+ inline · 9px mono kicker, 6px gap to the bar, 6px height bar
3771
+ (down from 8). Legend rows lose their padding so they read as
3772
+ a dense column not a list. */
3773
+ .sa-section { display: flex; flex-direction: column; gap: 6px; }
3774
+ .sa-section-head {
3775
+ font-family: var(--mono);
3776
+ font-size: 9px;
3777
+ letter-spacing: 0.18em;
3778
+ text-transform: uppercase;
3779
+ font-weight: 700;
3780
+ color: var(--text-soft);
3781
+ }
3782
+ .sa-bar {
3783
+ display: flex;
3784
+ height: 6px;
3785
+ overflow: hidden;
3786
+ background: var(--bg);
3787
+ border: 0.5px solid var(--line);
3788
+ }
3789
+ .sa-bar-seg {
3790
+ height: 100%;
3791
+ transition: opacity 0.15s;
3792
+ }
3793
+ .sa-bar-seg:hover { opacity: 0.85; }
3794
+ .sa-legend {
3795
+ list-style: none;
3796
+ margin: 2px 0 0;
3797
+ padding: 0;
3798
+ display: flex;
3799
+ flex-direction: column;
3800
+ gap: 1px;
3801
+ }
3802
+ .sa-legend-row {
3803
+ display: grid;
3804
+ grid-template-columns: 10px 1fr auto auto;
3805
+ align-items: baseline;
3806
+ gap: 8px;
3807
+ font-family: var(--mono);
3808
+ font-size: 10.5px;
3809
+ color: var(--text-soft);
3810
+ }
3811
+ .sa-legend-swatch {
3812
+ width: 8px;
3813
+ height: 8px;
3814
+ align-self: center;
3815
+ border-radius: 0;
3816
+ }
3817
+ .sa-legend-name {
3818
+ color: var(--text);
3819
+ letter-spacing: -0.003em;
3820
+ }
3821
+ .sa-legend-pct {
3822
+ color: var(--text-soft);
3823
+ font-variant-numeric: tabular-nums;
3824
+ text-align: right;
3825
+ }
3826
+ .sa-legend-tokens {
3827
+ color: var(--text-faint);
3828
+ font-variant-numeric: tabular-nums;
3829
+ text-align: right;
3830
+ min-width: 44px;
3831
+ }
3832
+
3833
+ /* What you valued · chips for counts + list of ▲-voted points.
3834
+ Smaller chip padding + tighter point rows so the section reads
3835
+ as a tight strip rather than a separate panel. */
3836
+ .sa-chips {
3837
+ display: flex;
3838
+ flex-wrap: wrap;
3839
+ gap: 4px;
3840
+ }
3841
+ .sa-chip {
3842
+ display: inline-flex;
3843
+ align-items: center;
3844
+ gap: 4px;
3845
+ padding: 2px 7px;
3846
+ background: var(--bg);
3847
+ border: 0.5px solid var(--line-bright);
3848
+ font-family: var(--mono);
3849
+ font-size: 9.5px;
3850
+ letter-spacing: 0.06em;
3851
+ color: var(--text-soft);
3852
+ }
3853
+ .sa-chip-mark {
3854
+ color: var(--lime);
3855
+ font-weight: 700;
3856
+ }
3857
+ .sa-points {
3858
+ list-style: none;
3859
+ margin: 2px 0 0;
3860
+ padding: 0;
3861
+ display: flex;
3862
+ flex-direction: column;
3863
+ gap: 3px;
3864
+ }
3865
+ .sa-point {
3866
+ display: grid;
3867
+ grid-template-columns: 12px 1fr;
3868
+ gap: 6px;
3869
+ align-items: baseline;
3870
+ padding: 5px 8px;
3871
+ background: var(--bg);
3872
+ border: 0.5px solid var(--line);
3873
+ }
3874
+ .sa-point-mark {
3875
+ color: var(--lime);
3876
+ font-size: 9.5px;
3877
+ font-weight: 700;
3878
+ line-height: 1.4;
3879
+ }
3880
+ .sa-point-body {
3881
+ font-family: var(--font-human);
3882
+ font-size: 12px;
3883
+ line-height: 1.42;
3884
+ color: var(--text);
3885
+ letter-spacing: -0.003em;
2532
3886
  }
2533
- .supplement-input:focus { outline: none; border-color: var(--lime); }
2534
- .supplement-input::placeholder {
3887
+ .sa-empty {
3888
+ font-family: var(--mono);
3889
+ font-size: 10px;
2535
3890
  color: var(--text-faint);
2536
- white-space: pre-wrap;
3891
+ letter-spacing: 0.04em;
2537
3892
  }
2538
- .supplement-hint {
2539
- margin: 10px 0 0;
2540
- font-size: 11.5px;
2541
- line-height: 1.5;
2542
- color: var(--text-faint);
3893
+ .followup-children-head {
3894
+ font-size: 10px;
3895
+ color: var(--text-soft);
3896
+ letter-spacing: 0.16em;
3897
+ text-transform: uppercase;
3898
+ font-weight: 700;
3899
+ margin-bottom: 10px;
2543
3900
  }
2544
- .supplement-foot {
3901
+ .followup-children-list {
2545
3902
  display: flex;
2546
- justify-content: flex-end;
3903
+ flex-direction: column;
3904
+ gap: 6px;
3905
+ }
3906
+ .followup-child-tile {
3907
+ display: grid;
3908
+ grid-template-columns: 32px 1fr auto;
2547
3909
  gap: 10px;
2548
- padding: 12px 22px 16px;
2549
- border-top: 0.5px solid var(--line);
3910
+ align-items: center;
3911
+ padding: 8px 10px;
3912
+ background: var(--bg);
3913
+ border: 0.5px solid var(--line);
3914
+ cursor: pointer;
3915
+ text-decoration: none;
3916
+ transition: border-color 0.1s, background 0.1s;
2550
3917
  }
2551
- .supplement-cancel,
2552
- .supplement-confirm {
2553
- font-family: var(--mono);
2554
- font-size: 10.5px;
3918
+ .followup-child-tile:hover {
3919
+ border-color: var(--lime-dim);
3920
+ background: var(--panel);
3921
+ }
3922
+ .followup-child-tile .num {
3923
+ font-size: 9.5px;
3924
+ color: var(--text-faint);
3925
+ letter-spacing: 0.1em;
3926
+ text-align: center;
2555
3927
  font-weight: 700;
2556
- letter-spacing: 0.14em;
2557
- text-transform: uppercase;
2558
- padding: 8px 14px;
2559
- cursor: pointer;
2560
- background: transparent;
2561
- border: 0.5px solid var(--line-strong);
2562
- color: var(--text-soft);
2563
- transition: border-color 0.1s, color 0.1s, background 0.1s;
2564
3928
  }
2565
- .supplement-cancel:hover { border-color: var(--text-soft); color: var(--text); }
2566
- .supplement-confirm {
2567
- background: var(--lime);
2568
- border-color: var(--lime);
2569
- color: var(--bg);
3929
+ .followup-child-tile .subject {
3930
+ font-size: 12px;
3931
+ color: var(--text);
3932
+ line-height: 1.3;
3933
+ white-space: nowrap;
3934
+ overflow: hidden;
3935
+ text-overflow: ellipsis;
2570
3936
  }
2571
- .supplement-confirm:hover { background: transparent; color: var(--lime); }
2572
- .supplement-confirm[disabled] {
2573
- opacity: 0.55;
2574
- pointer-events: none;
3937
+ .followup-child-tile .meta {
3938
+ font-size: 9.5px;
3939
+ color: var(--text-soft);
3940
+ letter-spacing: 0.06em;
3941
+ text-transform: uppercase;
3942
+ font-weight: 600;
2575
3943
  }
3944
+ .followup-child-tile .meta.live { color: var(--lime); }
3945
+ .followup-child-tile .meta.paused { color: var(--amber, #B59560); }
3946
+ .followup-child-tile .meta.adjourned { color: var(--text-dim); }
2576
3947
 
2577
3948
  /* Open CTA · compact pill anchored to the right */
2578
3949
  .brief-open {
@@ -2672,6 +4043,31 @@
2672
4043
  overflow-y: auto;
2673
4044
  background: var(--panel);
2674
4045
  padding: 14px 20px 18px;
4046
+ transition: opacity 0.18s ease-out;
4047
+ }
4048
+ /* Note-jump loading state · the user clicked a note in the All
4049
+ Notes view; openRoom is fetching the room body + the notes list
4050
+ before renderChat can scroll to the saved span. Keep the chat
4051
+ invisible while that work happens so the user doesn't see the
4052
+ stale-content → repaint → scroll-to-position transition as a
4053
+ flicker. The class is added at openRoom entry and removed once
4054
+ scrollToNote has landed (with a 1.2s timeout fallback in case
4055
+ the scroll never resolves). */
4056
+ body.note-jump-loading .chat { opacity: 0; }
4057
+ /* Cap reading measure on the inner messages container · pure
4058
+ percentage layout reads as a wall of text on wide monitors
4059
+ (~110+ chars / line) and tanks reading comprehension. ~820px
4060
+ keeps prose around 70-78 chars / line — Bringhurst's comfort
4061
+ band — while still leaving room for tables, tool-use rows
4062
+ (max-width 760px), and message chrome to sit inside without
4063
+ truncation. Centred so the column doesn't drift left of the
4064
+ viewport on ultra-wide displays. The .chat scroller stays
4065
+ full-width so the scrollbar lives at the viewport edge, not
4066
+ beside the prose. */
4067
+ .chat > [data-chat-messages] {
4068
+ max-width: 960px;
4069
+ margin-left: auto;
4070
+ margin-right: auto;
2675
4071
  }
2676
4072
 
2677
4073
  /* ─── Tool-use row ───────────────────────────────────────────
@@ -3487,6 +4883,53 @@
3487
4883
  .chair-intervention .ci-body strong { color: var(--text); font-weight: 600; }
3488
4884
  .chair-intervention .ci-body em { color: var(--lime); font-style: normal; font-weight: 500; }
3489
4885
 
4886
+ /* Chair-pending placeholder · transient "preparing…" card injected
4887
+ directly into [data-chat-messages] while the chair does silent
4888
+ server-side work (haiku discipline gate, pre-fetch tools, LLM
4889
+ startup). Mirrors the .chair-intervention skeleton — same lime
4890
+ accent, mono kicker, centered layout — but adds the bouncing
4891
+ thinking-dots so the user sees motion. Removed when the real chair
4892
+ bubble lands or the pipeline aborts. */
4893
+ .chair-pending {
4894
+ margin: 14px auto 18px;
4895
+ max-width: 640px;
4896
+ padding: 14px 22px 14px;
4897
+ background: transparent;
4898
+ }
4899
+ .chair-pending .cp-rule {
4900
+ width: 28px;
4901
+ height: 1px;
4902
+ background: var(--lime);
4903
+ margin: 0 auto 10px;
4904
+ opacity: 0.85;
4905
+ }
4906
+ .chair-pending .cp-kicker {
4907
+ text-align: center;
4908
+ font-family: var(--mono);
4909
+ font-size: 9.5px;
4910
+ font-weight: 600;
4911
+ letter-spacing: 0.22em;
4912
+ text-transform: uppercase;
4913
+ color: var(--lime);
4914
+ margin-bottom: 10px;
4915
+ opacity: 0.95;
4916
+ }
4917
+ .chair-pending .cp-body {
4918
+ display: flex;
4919
+ align-items: center;
4920
+ justify-content: center;
4921
+ gap: 10px;
4922
+ font-family: var(--font-agent);
4923
+ font-size: 13.5px;
4924
+ line-height: 1.65;
4925
+ color: var(--text-soft);
4926
+ letter-spacing: -0.005em;
4927
+ }
4928
+ .chair-pending .cp-text {
4929
+ font-style: italic;
4930
+ color: var(--text-faint);
4931
+ }
4932
+
3490
4933
  /* Chair billing notice · same skeleton as chair-intervention but with
3491
4934
  amber accent so it reads as a warning rather than a moderator
3492
4935
  re-frame. Posted by the orchestrator when an upstream API rejects
@@ -3604,6 +5047,9 @@
3604
5047
  justify-content: center;
3605
5048
  }
3606
5049
  .no-brief-card .nb-cta {
5050
+ display: inline-flex;
5051
+ align-items: center;
5052
+ gap: 8px;
3607
5053
  font-family: var(--mono);
3608
5054
  font-size: 10px;
3609
5055
  font-weight: 700;
@@ -3616,15 +5062,19 @@
3616
5062
  cursor: pointer;
3617
5063
  transition: color 0.12s, border-color 0.12s, background 0.12s;
3618
5064
  }
5065
+ .no-brief-card .nb-cta-mark { color: var(--lime); font-size: 11px; line-height: 1; }
5066
+ .no-brief-card .nb-cta-text { line-height: 1; }
3619
5067
  .no-brief-card .nb-cta:hover {
3620
5068
  color: var(--lime);
3621
5069
  border-color: var(--lime);
3622
5070
  background: var(--panel-2);
3623
5071
  }
5072
+ .no-brief-card .nb-cta:hover .nb-cta-mark { color: var(--lime); }
3624
5073
  .no-brief-card .nb-cta[disabled] {
3625
5074
  opacity: 0.6;
3626
5075
  cursor: not-allowed;
3627
5076
  }
5077
+ .no-brief-card .nb-cta[disabled] .nb-cta-mark { color: var(--text-faint); }
3628
5078
 
3629
5079
  .msg {
3630
5080
  display: grid;
@@ -3850,6 +5300,37 @@
3850
5300
  .msg-bubble p:last-child { margin-bottom: 0; }
3851
5301
  .msg-bubble strong { color: var(--text); font-weight: 600; }
3852
5302
  .msg-bubble em { font-style: normal; color: var(--lime); font-weight: 500; }
5303
+ /* Markdown blockquote · designed inset card, not a raw `>` line.
5304
+ Per the no-coloured-left-borders rule, the callout treatment
5305
+ uses a top mono kicker + panel-2 surface + indent (NOT a left
5306
+ border). Italic body sets it apart from the surrounding agent
5307
+ prose; the inline @director attribution at the end (rendered
5308
+ by inline()'s @-mention pass) gets its usual lime treatment.
5309
+ Used by quote-cta probe / second messages and any plain
5310
+ markdown blockquote a director / chair might emit. */
5311
+ .msg-bubble .msg-quote {
5312
+ margin: 0 0 12px;
5313
+ padding: 10px 14px;
5314
+ background: var(--panel-2);
5315
+ font-family: var(--font-agent);
5316
+ font-style: italic;
5317
+ font-size: 13px;
5318
+ line-height: 1.55;
5319
+ color: var(--text-soft);
5320
+ }
5321
+ .msg-bubble .msg-quote::before {
5322
+ content: "// quoted";
5323
+ display: block;
5324
+ font-family: var(--mono);
5325
+ font-size: 9px;
5326
+ font-weight: 700;
5327
+ letter-spacing: 0.18em;
5328
+ text-transform: uppercase;
5329
+ color: var(--text-faint);
5330
+ font-style: normal;
5331
+ margin-bottom: 6px;
5332
+ }
5333
+ .msg-bubble .msg-quote:last-child { margin-bottom: 0; }
3853
5334
  /* Markdown tables · editorial style. No outer box, no zebra, no
3854
5335
  uppercase data-grid headers — those read as "spreadsheet pasted
3855
5336
  into a conversation" and broke the bubble's literary register.
@@ -4303,6 +5784,80 @@
4303
5784
  text-align: center;
4304
5785
  }
4305
5786
 
5787
+ /* Chair's tone-shift proposal callout · sits between the key-points
5788
+ list and the CTAs when the chair appended a MODE-SHIFT block to the
5789
+ round-end output. Mono kicker matches the existing kp-eyebrow
5790
+ register; serif body mirrors a pull-quote so the reasoning reads
5791
+ differently from the procedural ping. */
5792
+ .kp-mode-shift {
5793
+ margin-top: 14px;
5794
+ padding: 10px 12px;
5795
+ border-top: 0.5px solid var(--line-bright);
5796
+ border-bottom: 0.5px solid var(--line-bright);
5797
+ background: color-mix(in srgb, var(--amber) 6%, var(--panel));
5798
+ }
5799
+ .kp-shift-eyebrow {
5800
+ font-family: var(--mono);
5801
+ font-size: 9.5px;
5802
+ color: var(--amber);
5803
+ letter-spacing: 0.16em;
5804
+ text-transform: uppercase;
5805
+ margin-bottom: 6px;
5806
+ }
5807
+ .kp-shift-eyebrow strong {
5808
+ color: var(--amber);
5809
+ font-weight: 700;
5810
+ }
5811
+ .kp-shift-because {
5812
+ font-size: 12.5px;
5813
+ color: var(--text);
5814
+ line-height: 1.45;
5815
+ }
5816
+ /* Suppress the kp-ctas top rule when the mode-shift callout sits
5817
+ directly above it · two adjacent 0.5px borders read as a doubled
5818
+ frame. The mode-shift's own border-bottom is the divider here. */
5819
+ .kp-mode-shift + .kp-ctas {
5820
+ border-top: none;
5821
+ margin-top: 0;
5822
+ padding-top: 12px;
5823
+ }
5824
+
5825
+ /* 3-button layout · only used when the chair proposed a tone shift
5826
+ (kp-ctas-shift). The primary "switch to <tone>" action takes ~50%
5827
+ of the row and each ghost button takes ~25%. Without this, three
5828
+ `flex: 1` buttons compress each to ~33% width and longer tone
5829
+ names ("constructive", "brainstorm") wrap inside the button.
5830
+ The wrap fallback (`flex-wrap: wrap` + flex-basis on each item)
5831
+ handles narrow chat viewports: when the row can't fit all three
5832
+ side-by-side, the primary spans the row and the two ghosts share
5833
+ the line below. Letter-spacing and uppercase are also relaxed for
5834
+ this row so the labels stay narrow. */
5835
+ .kp-ctas-shift { flex-wrap: wrap; }
5836
+ .kp-ctas-shift .kp-cta.primary { flex: 2 1 240px; }
5837
+ .kp-ctas-shift .kp-cta.ghost { flex: 1 1 110px; }
5838
+ .kp-ctas-shift .kp-cta {
5839
+ text-transform: none;
5840
+ letter-spacing: 0.04em;
5841
+ padding: 8px 10px;
5842
+ }
5843
+
5844
+ /* Degraded round-end card · shown when the chair finished streaming
5845
+ but the parser couldn't extract any key points from the body.
5846
+ Quieter eyebrow than the regular kp-eyebrow so the user reads it
5847
+ as "this round didn't produce a vote ballot" rather than an error.
5848
+ The continue / adjourn buttons below stay primary so the room can
5849
+ still move forward. Earlier failure mode here was an indefinite
5850
+ "drafting key points…" lock-up because the skeleton kept rendering
5851
+ after streaming ended. */
5852
+ .kp-eyebrow-degraded {
5853
+ font-family: var(--mono);
5854
+ font-size: 9.5px;
5855
+ color: var(--text-faint);
5856
+ letter-spacing: 0.16em;
5857
+ text-transform: uppercase;
5858
+ margin-bottom: 10px;
5859
+ }
5860
+
4306
5861
  /* Inline @mention / /handle pill. Two flavours so the addressee is
4307
5862
  glanceable: agent mentions get amber, user mentions get lime.
4308
5863
  Unscoped so the styling lands in any context (chat bubble, convene
@@ -5590,6 +7145,21 @@
5590
7145
  transform: rotate(180deg);
5591
7146
  }
5592
7147
 
7148
+ /* Composer-toolbar fitting for the reused .ap-skill-row-toggle
7149
+ vocabulary · same control as agent-profile's web-search row but
7150
+ in a slightly different surrounding context (cmp-toolbar is
7151
+ denser than the skill row). Just whitespace tuning — the toggle
7152
+ visuals come from agent-profile.css. */
7153
+ .cmp-toolbar .cmp-ws-toggle {
7154
+ /* Full label + state takes more horizontal room than the
7155
+ cmp-dd dropdowns; tighten letter-spacing slightly so the row
7156
+ still fits the model dropdown + manual button + Convene CTA
7157
+ without wrapping at common widths. */
7158
+ letter-spacing: 0.12em;
7159
+ padding-left: 4px;
7160
+ padding-right: 4px;
7161
+ }
7162
+
5593
7163
  /* Tune dropdown · row layout matches the director picker exactly:
5594
7164
  same padding (5px 10px), same name (12.5px sans) + tag (8.5px
5595
7165
  mono uppercase) inline-baseline pairing, same active treatment
@@ -5843,41 +7413,153 @@
5843
7413
  text-transform: uppercase;
5844
7414
  color: var(--text-soft);
5845
7415
  }
5846
- .ag-cmp-dots {
5847
- display: inline-flex;
5848
- gap: 3px;
5849
- margin-left: 6px;
5850
- vertical-align: middle;
7416
+ .ag-cmp-dots {
7417
+ display: inline-flex;
7418
+ gap: 3px;
7419
+ margin-left: 6px;
7420
+ vertical-align: middle;
7421
+ }
7422
+ .ag-cmp-dots i {
7423
+ width: 4px; height: 4px;
7424
+ border-radius: 50%;
7425
+ background: var(--lime);
7426
+ display: inline-block;
7427
+ animation: ag-cmp-bounce 1.1s ease-in-out infinite;
7428
+ }
7429
+ .ag-cmp-dots i:nth-child(2) { animation-delay: 0.18s; }
7430
+ .ag-cmp-dots i:nth-child(3) { animation-delay: 0.36s; }
7431
+ @keyframes ag-cmp-bounce {
7432
+ 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
7433
+ 40% { opacity: 1; transform: translateY(-2px); }
7434
+ }
7435
+ .cmp-input-frame.is-generating {
7436
+ opacity: 0.7;
7437
+ }
7438
+
7439
+ /* ─── Agent generation · animated stage card ───────────────────
7440
+ Replaces the single "Generating…" deck. Shows a numbered checklist
7441
+ of what the LLM is producing right now (imagining → naming → bio
7442
+ → cover quote → instruction → voice → polish). Each stage ticks
7443
+ done on a timer; the active row pulses lime. */
7444
+ .ag-gen-card {
7445
+ margin-top: 22px;
7446
+ background: var(--panel-2);
7447
+ border: 0.5px solid var(--line-bright);
7448
+ padding: 18px 22px 16px;
7449
+ position: relative;
7450
+ overflow: hidden;
7451
+ }
7452
+ /* Recovery card · shown when /generate-spec hits the 5-min hard
7453
+ timeout or returns an error. Same outer shell as ag-gen-card so
7454
+ the layout doesn't jump between generating → error. */
7455
+ .ag-gen-error-card {
7456
+ margin-top: 22px;
7457
+ background: var(--panel-2);
7458
+ border: 0.5px solid var(--amber, #B59560);
7459
+ padding: 22px 24px 20px;
7460
+ }
7461
+ .ag-gen-error-kicker {
7462
+ font-family: var(--mono);
7463
+ font-size: 9.5px;
7464
+ letter-spacing: 0.16em;
7465
+ text-transform: uppercase;
7466
+ color: var(--amber, #B59560);
7467
+ margin-bottom: 10px;
7468
+ }
7469
+ .ag-gen-error-title {
7470
+ font-family: var(--font-human);
7471
+ font-size: 18px;
7472
+ font-weight: 600;
7473
+ color: var(--text);
7474
+ line-height: 1.35;
7475
+ margin: 0 0 10px;
7476
+ }
7477
+ .ag-gen-error-hint {
7478
+ font-family: var(--font-human);
7479
+ font-size: 13.5px;
7480
+ line-height: 1.55;
7481
+ color: var(--text-soft);
7482
+ margin: 0 0 12px;
7483
+ }
7484
+ .ag-gen-error-detail {
7485
+ font-family: var(--mono);
7486
+ font-size: 10.5px;
7487
+ line-height: 1.55;
7488
+ color: var(--text-faint);
7489
+ background: var(--bg);
7490
+ border: 0.5px solid var(--line);
7491
+ padding: 8px 12px;
7492
+ margin: 0 0 14px;
7493
+ word-break: break-word;
7494
+ max-height: 120px;
7495
+ overflow-y: auto;
7496
+ }
7497
+ .ag-gen-error-desc {
7498
+ margin: 0 0 16px;
7499
+ padding-top: 12px;
7500
+ border-top: 0.5px solid var(--line);
7501
+ }
7502
+ .ag-gen-error-desc-label {
7503
+ font-family: var(--mono);
7504
+ font-size: 9.5px;
7505
+ letter-spacing: 0.12em;
7506
+ text-transform: uppercase;
7507
+ color: var(--text-faint);
7508
+ margin-bottom: 6px;
7509
+ }
7510
+ .ag-gen-error-desc-body {
7511
+ font-family: var(--font-human);
7512
+ font-size: 13px;
7513
+ line-height: 1.55;
7514
+ color: var(--text-soft);
7515
+ }
7516
+ .ag-gen-error-actions {
7517
+ display: flex;
7518
+ align-items: center;
7519
+ gap: 12px;
5851
7520
  }
5852
- .ag-cmp-dots i {
5853
- width: 4px; height: 4px;
5854
- border-radius: 50%;
7521
+ .ag-gen-error-retry {
7522
+ appearance: none;
5855
7523
  background: var(--lime);
5856
- display: inline-block;
5857
- animation: ag-cmp-bounce 1.1s ease-in-out infinite;
7524
+ color: var(--bg);
7525
+ border: 0.5px solid var(--lime);
7526
+ padding: 9px 18px;
7527
+ font-family: var(--mono);
7528
+ font-size: 11px;
7529
+ font-weight: 700;
7530
+ letter-spacing: 0.14em;
7531
+ text-transform: uppercase;
7532
+ cursor: pointer;
7533
+ display: inline-flex;
7534
+ align-items: center;
7535
+ gap: 8px;
7536
+ transition: background 0.12s, color 0.12s;
5858
7537
  }
5859
- .ag-cmp-dots i:nth-child(2) { animation-delay: 0.18s; }
5860
- .ag-cmp-dots i:nth-child(3) { animation-delay: 0.36s; }
5861
- @keyframes ag-cmp-bounce {
5862
- 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
5863
- 40% { opacity: 1; transform: translateY(-2px); }
7538
+ .ag-gen-error-retry:hover {
7539
+ background: transparent;
7540
+ color: var(--lime);
5864
7541
  }
5865
- .cmp-input-frame.is-generating {
5866
- opacity: 0.7;
7542
+ .ag-gen-error-retry-mark {
7543
+ font-size: 13px;
7544
+ line-height: 1;
5867
7545
  }
5868
-
5869
- /* ─── Agent generation · animated stage card ───────────────────
5870
- Replaces the single "Generating…" deck. Shows a numbered checklist
5871
- of what the LLM is producing right now (imagining → naming → bio
5872
- → cover quote → instruction → voice → polish). Each stage ticks
5873
- done on a timer; the active row pulses lime. */
5874
- .ag-gen-card {
5875
- margin-top: 22px;
5876
- background: var(--panel-2);
7546
+ .ag-gen-error-discard {
7547
+ appearance: none;
7548
+ background: transparent;
7549
+ color: var(--text-soft);
5877
7550
  border: 0.5px solid var(--line-bright);
5878
- padding: 18px 22px 16px;
5879
- position: relative;
5880
- overflow: hidden;
7551
+ padding: 9px 16px;
7552
+ font-family: var(--mono);
7553
+ font-size: 10.5px;
7554
+ font-weight: 600;
7555
+ letter-spacing: 0.12em;
7556
+ text-transform: uppercase;
7557
+ cursor: pointer;
7558
+ transition: color 0.12s, border-color 0.12s;
7559
+ }
7560
+ .ag-gen-error-discard:hover {
7561
+ color: var(--text);
7562
+ border-color: var(--text-soft);
5881
7563
  }
5882
7564
  /* Subtle scanline texture across the card · adds an "active system"
5883
7565
  atmosphere without being distracting. Slow upward drift. */
@@ -6656,6 +8338,119 @@
6656
8338
  color: var(--lime);
6657
8339
  }
6658
8340
 
8341
+ /* ─── Brief picker popover · the [View Report] click target on
8342
+ multi-brief rooms. Anchored under the room-head's button via
8343
+ position: fixed and right-aligned so it visually drops out of
8344
+ the trigger. Each row is a plain anchor to /report.html so
8345
+ middle-click / cmd-click work for opening multiple reports
8346
+ in tabs. ─── */
8347
+ .brief-picker-pop {
8348
+ position: fixed;
8349
+ z-index: 9001;
8350
+ width: 380px;
8351
+ max-width: calc(100vw - 32px);
8352
+ overflow-y: auto;
8353
+ background: var(--panel);
8354
+ border: 0.5px solid var(--line-strong);
8355
+ }
8356
+ .brief-picker-head {
8357
+ display: flex;
8358
+ justify-content: space-between;
8359
+ align-items: baseline;
8360
+ padding: 8px 12px;
8361
+ border-bottom: 0.5px solid var(--line);
8362
+ }
8363
+ .brief-picker-title-head {
8364
+ font-family: var(--mono);
8365
+ font-size: 14px;
8366
+ font-weight: 700;
8367
+ letter-spacing: 0.04em;
8368
+ color: var(--text);
8369
+ }
8370
+ .brief-picker-count {
8371
+ font-family: var(--mono);
8372
+ font-size: 10px;
8373
+ letter-spacing: 0.08em;
8374
+ color: var(--text-faint);
8375
+ background: var(--panel-2);
8376
+ border: 0.5px solid var(--line);
8377
+ padding: 2px 8px;
8378
+ }
8379
+ .brief-picker-list {
8380
+ padding: 2px 0;
8381
+ }
8382
+ .brief-picker-row {
8383
+ display: grid;
8384
+ grid-template-columns: 32px 1fr auto auto;
8385
+ gap: 10px;
8386
+ align-items: center;
8387
+ padding: 10px 12px;
8388
+ text-decoration: none;
8389
+ color: inherit;
8390
+ border-bottom: 0.5px solid var(--line);
8391
+ transition: background 0.1s;
8392
+ }
8393
+ .brief-picker-row:last-child { border-bottom: none; }
8394
+ .brief-picker-row:hover { background: var(--panel-2); }
8395
+ .brief-picker-num {
8396
+ font-family: var(--mono);
8397
+ font-size: 11px;
8398
+ font-weight: 700;
8399
+ color: var(--text-faint);
8400
+ letter-spacing: 0.06em;
8401
+ }
8402
+ .brief-picker-row:hover .brief-picker-num { color: var(--lime); }
8403
+ .brief-picker-main {
8404
+ display: flex;
8405
+ flex-direction: column;
8406
+ gap: 2px;
8407
+ min-width: 0;
8408
+ }
8409
+ .brief-picker-title {
8410
+ font-family: var(--font-human, system-ui, sans-serif);
8411
+ font-size: 13px;
8412
+ font-weight: 600;
8413
+ color: var(--text);
8414
+ line-height: 1.3;
8415
+ overflow: hidden;
8416
+ display: -webkit-box;
8417
+ -webkit-line-clamp: 2;
8418
+ -webkit-box-orient: vertical;
8419
+ }
8420
+ .brief-picker-sub {
8421
+ font-family: var(--mono);
8422
+ font-size: 10px;
8423
+ letter-spacing: 0.04em;
8424
+ color: var(--text-faint);
8425
+ overflow: hidden;
8426
+ text-overflow: ellipsis;
8427
+ white-space: nowrap;
8428
+ }
8429
+ .brief-picker-time {
8430
+ font-family: var(--mono);
8431
+ font-size: 9.5px;
8432
+ letter-spacing: 0.04em;
8433
+ color: var(--text-faint);
8434
+ white-space: nowrap;
8435
+ }
8436
+ .brief-picker-arrow {
8437
+ font-family: var(--mono);
8438
+ font-size: 12px;
8439
+ color: var(--text-faint);
8440
+ transition: color 0.12s, transform 0.12s;
8441
+ }
8442
+ .brief-picker-row:hover .brief-picker-arrow {
8443
+ color: var(--lime);
8444
+ transform: translate(2px, -2px);
8445
+ }
8446
+ /* The "· N" count chip rendered inline with the View Report button
8447
+ when multiple briefs exist · subtle, blends with the bracket
8448
+ monospace label, mirrors how `.session-num` reads. */
8449
+ .view-report-btn .vr-count {
8450
+ color: var(--text-faint);
8451
+ font-weight: 500;
8452
+ }
8453
+
6659
8454
  /* Convene opener · the room's seed question, distinct from chat bubbles. */
6660
8455
  .convene-opener {
6661
8456
  margin: 0 auto 24px;
@@ -6693,6 +8488,44 @@
6693
8488
  margin: 0 0 10px;
6694
8489
  }
6695
8490
  .convene-body p { margin: 0; }
8491
+
8492
+ /* Long-opener clamp · when the user wrote more than a sentence of
8493
+ context, the card would otherwise dominate the viewport. We
8494
+ clamp the body to ~2.5 lines with a fade-out overlay; a
8495
+ `<button data-convene-toggle>` below toggles `.expanded` on the
8496
+ opener parent to reveal the rest. 2.5 × line-height (1.32 ×
8497
+ 19px ≈ 25px) = 63px. */
8498
+ .convene-opener-clamped:not(.expanded) .convene-body {
8499
+ max-height: 63px;
8500
+ overflow: hidden;
8501
+ position: relative;
8502
+ }
8503
+ .convene-opener-clamped:not(.expanded) .convene-body::after {
8504
+ content: "";
8505
+ position: absolute;
8506
+ left: 0;
8507
+ right: 0;
8508
+ bottom: 0;
8509
+ height: 32px;
8510
+ background: linear-gradient(to bottom, transparent, var(--panel-2));
8511
+ pointer-events: none;
8512
+ }
8513
+ .convene-toggle {
8514
+ appearance: none;
8515
+ background: transparent;
8516
+ border: 0;
8517
+ color: var(--text-soft);
8518
+ cursor: pointer;
8519
+ font-family: var(--mono);
8520
+ font-size: 10px;
8521
+ font-weight: 700;
8522
+ letter-spacing: 0.14em;
8523
+ text-transform: uppercase;
8524
+ padding: 4px 0 6px;
8525
+ margin: 4px 0 6px;
8526
+ transition: color 0.12s;
8527
+ }
8528
+ .convene-toggle:hover { color: var(--lime); }
6696
8529
  .convene-meta {
6697
8530
  font-family: var(--mono);
6698
8531
  font-size: 10px;
@@ -6707,6 +8540,74 @@
6707
8540
  .convene-meta .convene-time { color: var(--text-faint); }
6708
8541
  .convene-meta .convene-cast { color: var(--text-dim); }
6709
8542
 
8543
+ /* Follow-up origin row · only present when this room was started as
8544
+ a continuation of a prior adjourned room. Sits between the
8545
+ eyebrow and the question body, click-navigates back to the parent
8546
+ room via hash route. Visually distinct from the eyebrow (mono +
8547
+ ↩ glyph + brighter than meta-row) but quieter than the headline. */
8548
+ .convene-origin {
8549
+ display: inline-flex;
8550
+ align-items: baseline;
8551
+ gap: 6px;
8552
+ margin: -2px 0 10px;
8553
+ padding: 4px 8px 4px 6px;
8554
+ background: var(--bg);
8555
+ border: 0.5px solid var(--line-bright);
8556
+ font-family: var(--mono);
8557
+ font-size: 10.5px;
8558
+ letter-spacing: 0.04em;
8559
+ color: var(--text-soft);
8560
+ text-decoration: none;
8561
+ transition: border-color 0.12s, color 0.12s, background 0.12s;
8562
+ max-width: 100%;
8563
+ /* Lock chrome to one line. CJK ("继续自") has no inter-character
8564
+ wrap stopper by default and would otherwise break mid-word
8565
+ when the row got tight. */
8566
+ white-space: nowrap;
8567
+ }
8568
+ .convene-origin:hover {
8569
+ border-color: var(--lime-dim);
8570
+ color: var(--text);
8571
+ background: var(--panel-3);
8572
+ }
8573
+ /* Lock the labels so flex shrinking only consumes the subject
8574
+ (which has its own ellipsis). Otherwise every child shares the
8575
+ shrink and the labels squeeze before the subject does. */
8576
+ .convene-origin-arrow,
8577
+ .convene-origin-label,
8578
+ .convene-origin-room,
8579
+ .convene-origin-sep {
8580
+ flex-shrink: 0;
8581
+ }
8582
+ .convene-origin-arrow {
8583
+ color: var(--lime);
8584
+ font-weight: 700;
8585
+ line-height: 1;
8586
+ }
8587
+ .convene-origin-label {
8588
+ color: var(--text-soft);
8589
+ text-transform: uppercase;
8590
+ letter-spacing: 0.1em;
8591
+ font-weight: 600;
8592
+ font-size: 9.5px;
8593
+ }
8594
+ .convene-origin-room {
8595
+ color: var(--lime);
8596
+ font-weight: 700;
8597
+ font-variant-numeric: tabular-nums;
8598
+ }
8599
+ .convene-origin-sep { color: var(--text-faint); }
8600
+ .convene-origin-subject {
8601
+ color: var(--text-soft);
8602
+ text-transform: none;
8603
+ letter-spacing: -0.003em;
8604
+ overflow: hidden;
8605
+ text-overflow: ellipsis;
8606
+ white-space: nowrap;
8607
+ min-width: 0;
8608
+ flex-shrink: 1;
8609
+ }
8610
+
6710
8611
  /* ─── Convening card · multi-stage placeholder while the room opens ───
6711
8612
  Lives at the tail of the chat from the moment createRoom resolves
6712
8613
  until the chair's first message lands (~10s). Three stages tied to
@@ -6991,6 +8892,8 @@
6991
8892
  <script src="auto-hide-scroll.js" defer></script>
6992
8893
  <link rel="stylesheet" href="onboarding.css">
6993
8894
  <script src="onboarding.js" defer></script>
8895
+ <link rel="stylesheet" href="quote-cta.css">
8896
+ <script src="quote-cta.js" defer></script>
6994
8897
  <script src="app.js" defer></script>
6995
8898
  <script>
6996
8899
  /* FOUC-prevention: apply saved theme synchronously before paint */
@@ -7007,6 +8910,19 @@
7007
8910
  </head>
7008
8911
  <body>
7009
8912
 
8913
+ <!-- ═══════════════ SYSTEM NOTICE BANNER ═══════════════
8914
+ Surfaces "storage upgraded" notices when the user opens a build
8915
+ that ran new schema migrations against their existing DB. Hidden
8916
+ by default; populated + un-hidden by app.js after the boot fetch
8917
+ to /api/system/migrations resolves. Dismiss writes the latest
8918
+ migration name to localStorage so it doesn't re-show until the
8919
+ next time fresh migrations actually apply. -->
8920
+ <div class="sys-notice" data-sys-notice hidden>
8921
+ <span class="sys-notice-mark">▸</span>
8922
+ <span class="sys-notice-text" data-sys-notice-text></span>
8923
+ <button type="button" class="sys-notice-close" data-sys-notice-close aria-label="Dismiss">✕</button>
8924
+ </div>
8925
+
7010
8926
  <div class="control">
7011
8927
 
7012
8928
  <!-- ═══════════════ TOP BAR (classification banner) ═══════════════ -->
@@ -7044,6 +8960,7 @@
7044
8960
  <div class="sidebar-head">
7045
8961
  <div class="sidebar-head-title">// CONTROL</div>
7046
8962
  <div class="sidebar-head-meta"><span class="lime">●</span> <span data-sidebar-summary>0 LIVE / 0 AGENTS</span></div>
8963
+ <button type="button" class="sidebar-collapse-btn" data-sidebar-collapse aria-label="Collapse sidebar" title="Collapse sidebar"></button>
7047
8964
  </div>
7048
8965
 
7049
8966
  <div class="sidebar-tabs" role="tablist">
@@ -7068,7 +8985,20 @@
7068
8985
  "New room" so it reads as a peer destination, not a list
7069
8986
  item, with a square glyph echoing the dashboard glyphs
7070
8987
  elsewhere in the sidebar. -->
7071
- <a href="#" class="new-btn nav-reports" data-reports-trigger>All Reports</a>
8988
+ <a href="#" class="new-btn nav-reports" data-reports-trigger>
8989
+ <span class="nav-label">All Reports</span>
8990
+ <span class="nav-count" data-reports-count hidden></span>
8991
+ </a>
8992
+ <!-- "All Notes" · chairman's-notes index. Sits directly below
8993
+ All Reports because both are cross-cutting aggregation views
8994
+ (read-only collections that span every room), distinct from
8995
+ the room list below. Bookmark glyph differentiates from
8996
+ Reports' FileText glyph. The data-notes-count badge updates
8997
+ live as new notes are saved or deleted. -->
8998
+ <a href="#" class="new-btn nav-notes" data-notes-trigger>
8999
+ <span class="nav-label">All Notes</span>
9000
+ <span class="nav-count" data-notes-count hidden></span>
9001
+ </a>
7072
9002
 
7073
9003
  <div class="sessions-scroll">
7074
9004
  <div data-rooms-list></div>
@@ -7089,21 +9019,13 @@
7089
9019
  </div><!-- /agents panel -->
7090
9020
 
7091
9021
  <div class="sidebar-foot">
7092
- <div class="user-menu-wrap" tabindex="0">
7093
- <a href="#" class="user-block">
7094
- <div class="user-av" data-user-avatar>K</div>
7095
- <div class="user-info">
7096
- <div class="user-name" data-user-name>—</div>
7097
- <div class="user-meta" data-user-meta>// host</div>
7098
- </div>
7099
- <span class="chev">▴</span>
7100
- </a>
7101
- <div class="user-menu">
7102
- <div class="user-menu-section">
7103
- <div class="name" data-user-menu-name>—</div>
7104
- </div>
9022
+ <a href="#" class="user-block">
9023
+ <div class="user-av" data-user-avatar>K</div>
9024
+ <div class="user-info">
9025
+ <div class="user-name" data-user-name>—</div>
9026
+ <div class="user-meta" data-user-meta>// host</div>
7105
9027
  </div>
7106
- </div>
9028
+ </a>
7107
9029
  <a href="#" class="icon-btn" title="Settings" data-user-settings-trigger>⚙</a>
7108
9030
  </div>
7109
9031
 
@@ -7171,8 +9093,8 @@
7171
9093
  <strong>// room adjourned.</strong> the brief is filed above.
7172
9094
  </div>
7173
9095
  <div class="adjourned-bar-actions">
7174
- <a href="#" class="ghost-btn">[↓] Export</a>
7175
- <a href="#" class="ghost-btn">[] Share</a>
9096
+ <a href="#" class="ghost-btn" data-room-export>[↓] Export</a>
9097
+ <a href="#" class="ghost-btn" data-room-followup>[] Convene Follow-up</a>
7176
9098
  </div>
7177
9099
  </footer>
7178
9100
  </div>
@@ -7190,8 +9112,30 @@
7190
9112
  <div class="reports-page" data-reports-page></div>
7191
9113
  </div>
7192
9114
 
9115
+ <!-- All Notes view · cross-room chairman's-notes index. Vertical
9116
+ timeline grouped by date (Today / This Week / Earlier).
9117
+ Filled by app.renderNotesPage() on demand. -->
9118
+ <div class="main-view" data-main-view="notes" hidden>
9119
+ <div class="notes-page" data-notes-page></div>
9120
+ </div>
9121
+
7193
9122
  </main>
7194
9123
 
9124
+ <!-- Floating expand affordance · only visible when the sidebar is
9125
+ collapsed (body.sidebar-collapsed). Lives INSIDE .body-grid
9126
+ (which is position: relative) so the button's absolute
9127
+ positioning anchors to the body-grid's top-left edge — i.e.
9128
+ below the topbar / brand logo, where the sidebar's own header
9129
+ sat before collapse. Frosted black glass with a 3-line
9130
+ hamburger glyph. -->
9131
+ <button type="button" class="sidebar-expand-btn" data-sidebar-expand aria-label="Expand sidebar" title="Expand sidebar">
9132
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
9133
+ <line x1="2.5" y1="4.25" x2="13.5" y2="4.25"/>
9134
+ <line x1="2.5" y1="8" x2="13.5" y2="8"/>
9135
+ <line x1="2.5" y1="11.75" x2="13.5" y2="11.75"/>
9136
+ </svg>
9137
+ </button>
9138
+
7195
9139
  </div>
7196
9140
 
7197
9141
  </div>
@@ -7261,6 +9205,45 @@
7261
9205
  }
7262
9206
 
7263
9207
  document.querySelectorAll("[data-resize]").forEach(attach);
9208
+
9209
+ // ─── Sidebar collapse / expand ───
9210
+ // Three affordances, all event-delegated on document:
9211
+ // · `data-sidebar-collapse` inside the sidebar head ·
9212
+ // visible only while the sidebar is open · collapses
9213
+ // · `data-sidebar-expand` inside `.room-head` (rendered by
9214
+ // renderHeader) · visible when collapsed AND a room is
9215
+ // loaded · sits at the leading edge of the room title bar
9216
+ // · `data-sidebar-expand` floating button at top-left of
9217
+ // `.body-grid` · visible only when collapsed AND no room
9218
+ // is loaded (empty / no-room state, where there's no
9219
+ // room-head to host the in-header button)
9220
+ // All flip `body.sidebar-collapsed` and persist to localStorage.
9221
+ const COLLAPSE_KEY = "boardroom.sidebar.collapsed";
9222
+ function applySidebarCollapsed(collapsed) {
9223
+ document.body.classList.toggle("sidebar-collapsed", collapsed);
9224
+ }
9225
+ function readSidebarCollapsed() {
9226
+ try { return localStorage.getItem(COLLAPSE_KEY) === "1"; }
9227
+ catch { return false; }
9228
+ }
9229
+ function writeSidebarCollapsed(v) {
9230
+ try { localStorage.setItem(COLLAPSE_KEY, v ? "1" : "0"); } catch {}
9231
+ }
9232
+ applySidebarCollapsed(readSidebarCollapsed());
9233
+ document.addEventListener("click", (e) => {
9234
+ if (e.target.closest("[data-sidebar-collapse]")) {
9235
+ e.preventDefault();
9236
+ applySidebarCollapsed(true);
9237
+ writeSidebarCollapsed(true);
9238
+ return;
9239
+ }
9240
+ if (e.target.closest("[data-sidebar-expand]")) {
9241
+ e.preventDefault();
9242
+ applySidebarCollapsed(false);
9243
+ writeSidebarCollapsed(false);
9244
+ return;
9245
+ }
9246
+ });
7264
9247
  })();
7265
9248
 
7266
9249
  /* ─── Speaking queue: collapse / expand persistence ───
@@ -7368,11 +9351,28 @@
7368
9351
  const TAB_KEY = "boardroom.sidebar.tab";
7369
9352
  const ROOMS_KEY = "boardroom.sidebar.rooms";
7370
9353
  const AGENTS_KEY = "boardroom.sidebar.agents";
9354
+ /* Mirror of the last actually-opened roomId. Diverges from ROOMS_KEY
9355
+ when the user lands on +New Room / All Reports / All Notes — those
9356
+ overwrite ROOMS_KEY with "new" / "reports" / "notes" but should not
9357
+ erase the memory of which ROOM the user had open. Used as a tab-
9358
+ switch fallback so re-entering the Rooms tab restores a meaningful
9359
+ selection rather than blanking it. */
9360
+ const ROOMS_LAST_KEY = "boardroom.sidebar.rooms.last";
7371
9361
  const VALID_TABS = new Set(["rooms", "agents"]);
7372
9362
 
7373
9363
  const lsGet = (k) => { try { return localStorage.getItem(k); } catch (_) { return null; } };
7374
9364
  const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (_) {} };
7375
9365
 
9366
+ /* Fall back to the first session row in the sidebar when we have
9367
+ no explicit user choice. Ordering matches DOM order (live first,
9368
+ then paused, then adjourned), which is the same ordering the user
9369
+ sees — picking [0] always means "the one at the top." Returns null
9370
+ when the sidebar has no rooms at all. */
9371
+ function firstRoomId() {
9372
+ const row = document.querySelector(".session-row-shell[data-room-id]");
9373
+ return row ? row.dataset.roomId : null;
9374
+ }
9375
+
7376
9376
  function applyRoomsSubState(sub) {
7377
9377
  // Make sure the room main view is visible (in case the agent
7378
9378
  // profile main view was up). setComposerMode also does this when
@@ -7388,6 +9388,13 @@
7388
9388
  }
7389
9389
  return;
7390
9390
  }
9391
+ // "notes" → cross-room chairman's-notes index.
9392
+ if (sub === "notes") {
9393
+ if (window.app && typeof window.app.openAllNotes === "function") {
9394
+ window.app.openAllNotes();
9395
+ }
9396
+ return;
9397
+ }
7391
9398
  if (!sub || sub === "new") {
7392
9399
  if (window.app && typeof window.app.setComposerMode === "function") {
7393
9400
  window.app.setComposerMode("room");
@@ -7395,20 +9402,40 @@
7395
9402
  return;
7396
9403
  }
7397
9404
  // Navigate to the saved room — but only if it still exists in
7398
- // the sidebar list. Stale ids fall back to the composer.
7399
- const exists = !!document.querySelector(`.session-row-shell[data-room-id="${sub}"]`);
9405
+ // the sidebar list. A stale id (deleted room, never-existed)
9406
+ // falls back to the most-recently-viewed room, then the first
9407
+ // row, before resorting to the composer. Without the fallback,
9408
+ // a deleted-room id would land the user on a blank composer
9409
+ // every tab-switch even when other rooms are available.
9410
+ let exists = !!document.querySelector(`.session-row-shell[data-room-id="${sub}"]`);
7400
9411
  if (!exists) {
7401
- lsSet(ROOMS_KEY, "new");
7402
- if (window.app && typeof window.app.setComposerMode === "function") {
7403
- window.app.setComposerMode("room");
9412
+ const last = lsGet(ROOMS_LAST_KEY);
9413
+ const candidate = (last && document.querySelector(`.session-row-shell[data-room-id="${last}"]`))
9414
+ ? last
9415
+ : firstRoomId();
9416
+ if (candidate) {
9417
+ sub = candidate;
9418
+ lsSet(ROOMS_KEY, candidate);
9419
+ exists = true;
9420
+ } else {
9421
+ lsSet(ROOMS_KEY, "new");
9422
+ if (window.app && typeof window.app.setComposerMode === "function") {
9423
+ window.app.setComposerMode("room");
9424
+ }
9425
+ return;
7404
9426
  }
7405
- return;
7406
9427
  }
7407
9428
  const want = "#/r/" + sub;
7408
9429
  if (location.hash !== want) {
7409
9430
  location.hash = want;
7410
9431
  } else if (window.app && window.app.currentRoomId !== sub && typeof window.app.openRoom === "function") {
7411
9432
  window.app.openRoom(sub);
9433
+ } else if (window.app && typeof window.app.markActiveRoom === "function") {
9434
+ // Both hash and currentRoomId already match — but the sidebar
9435
+ // session-row .active highlight may have been cleared by an
9436
+ // intervening agent-profile open (markActiveAgent wipes it).
9437
+ // Re-apply so the row reads as selected on tab return.
9438
+ window.app.markActiveRoom(sub);
7412
9439
  }
7413
9440
  }
7414
9441
 
@@ -7433,6 +9460,7 @@
7433
9460
  function activate(which, opts) {
7434
9461
  if (!VALID_TABS.has(which)) return;
7435
9462
  const persist = !opts || opts.persist !== false;
9463
+ const fromTabClick = !!(opts && opts.fromTabClick);
7436
9464
  document.querySelectorAll(".sidebar-tab[data-sidebar-tab]").forEach((t) => {
7437
9465
  const on = t.dataset.sidebarTab === which;
7438
9466
  t.classList.toggle("active", on);
@@ -7444,7 +9472,41 @@
7444
9472
  else p.setAttribute("hidden", "");
7445
9473
  });
7446
9474
 
7447
- if (which === "rooms") applyRoomsSubState(lsGet(ROOMS_KEY) || "new");
9475
+ if (which === "rooms") {
9476
+ // Resolve which sub-state to apply. The room id ROOMS_KEY
9477
+ // points to is the user's explicit choice when set; on
9478
+ // user-driven tab switches we additionally prefer the last
9479
+ // actually-opened room over a blank "new"-composer landing,
9480
+ // so re-entering the Rooms tab feels continuous (selection
9481
+ // restored) instead of resetting. Initial-load (refresh /
9482
+ // boot) keeps the legacy "lands on composer" intent so a
9483
+ // fresh user without prior history sees the new-room invite.
9484
+ const saved = lsGet(ROOMS_KEY);
9485
+ let target;
9486
+ if (saved && saved !== "new" && saved !== "reports" && saved !== "notes") {
9487
+ target = saved; // explicit room id
9488
+ } else if (saved === "reports" || saved === "notes") {
9489
+ target = saved; // cross-room destinations preserve on any nav
9490
+ } else if (fromTabClick) {
9491
+ // User-driven tab switch · prefer the last actually-opened
9492
+ // room (or first row) over the +New Room composer, so
9493
+ // re-entering the Rooms tab feels continuous instead of
9494
+ // resetting to the composer. The "new" intent only persists
9495
+ // within the rooms tab — once the user leaves and returns,
9496
+ // we surface their last room.
9497
+ const last = lsGet(ROOMS_LAST_KEY);
9498
+ if (last && document.querySelector(`.session-row-shell[data-room-id="${last}"]`)) {
9499
+ target = last;
9500
+ } else {
9501
+ target = firstRoomId() || "new";
9502
+ }
9503
+ } else if (saved === "new") {
9504
+ target = "new"; // initial load · preserve composer intent
9505
+ } else {
9506
+ target = "new"; // initial load with no history → composer
9507
+ }
9508
+ applyRoomsSubState(target);
9509
+ }
7448
9510
  else if (which === "agents") applyAgentsSubState(lsGet(AGENTS_KEY) || "new");
7449
9511
 
7450
9512
  if (persist) lsSet(TAB_KEY, which);
@@ -7476,7 +9538,7 @@
7476
9538
  const tab = e.target.closest(".sidebar-tab[data-sidebar-tab]");
7477
9539
  if (tab) {
7478
9540
  e.preventDefault();
7479
- activate(tab.dataset.sidebarTab);
9541
+ activate(tab.dataset.sidebarTab, { fromTabClick: true });
7480
9542
  return;
7481
9543
  }
7482
9544
  // Track sub-state · "+ New room" → rooms = "new"
@@ -7495,10 +9557,47 @@
7495
9557
  }
7496
9558
  return;
7497
9559
  }
7498
- // Track sub-state · clicked a session row → rooms = roomId
9560
+ // "All Notes" trigger · cross-room chairman's-notes index. Same
9561
+ // sub-state pattern as reports — special token "notes" so the
9562
+ // sidebar persists which destination was last visited.
9563
+ const notesBtn = e.target.closest("[data-notes-trigger]");
9564
+ if (notesBtn) {
9565
+ e.preventDefault();
9566
+ lsSet(ROOMS_KEY, "notes");
9567
+ if (window.app && typeof window.app.openAllNotes === "function") {
9568
+ window.app.openAllNotes();
9569
+ }
9570
+ return;
9571
+ }
9572
+ // Track sub-state · clicked a session row → rooms = roomId.
9573
+ // Also stamp ROOMS_LAST_KEY so a later +New Room / All Reports
9574
+ // / All Notes click that overwrites ROOMS_KEY doesn't erase the
9575
+ // memory of which actual room was last open.
7499
9576
  const sessRow = e.target.closest(".session-row-shell[data-room-id]");
7500
9577
  if (sessRow && sessRow.dataset.roomId) {
7501
- lsSet(ROOMS_KEY, sessRow.dataset.roomId);
9578
+ const id = sessRow.dataset.roomId;
9579
+ lsSet(ROOMS_KEY, id);
9580
+ lsSet(ROOMS_LAST_KEY, id);
9581
+ // Anchor `href="#/r/<id>"` clicks rely on `hashchange` →
9582
+ // handleRoute → openRoom. When the hash already equals the
9583
+ // target (e.g. user just left an agent profile that didn't
9584
+ // change the hash, then clicks the same room), the browser
9585
+ // suppresses hashchange and openRoom never runs — the row
9586
+ // stays unselected. Re-trigger the room load + highlight
9587
+ // manually for the same-hash case.
9588
+ const want = "#/r/" + id;
9589
+ if (location.hash === want && window.app) {
9590
+ if (window.app.currentRoomId !== id && typeof window.app.openRoom === "function") {
9591
+ window.app.openRoom(id);
9592
+ } else {
9593
+ if (typeof window.closeAgentProfile === "function") {
9594
+ try { window.closeAgentProfile(); } catch (_) {}
9595
+ }
9596
+ if (typeof window.app.markActiveRoom === "function") {
9597
+ window.app.markActiveRoom(id);
9598
+ }
9599
+ }
9600
+ }
7502
9601
  return;
7503
9602
  }
7504
9603
  // Track sub-state · "+ New agent" → agents = "new"
@@ -7509,10 +9608,15 @@
7509
9608
  });
7510
9609
 
7511
9610
  /* ─── hashchange · keep rooms sub-state synced when navigation
7512
- happens via URL bar / browser history. */
9611
+ happens via URL bar / browser history. ROOMS_LAST_KEY mirrors
9612
+ the same id so it survives later +New Room / All Reports
9613
+ overwrites of ROOMS_KEY. */
7513
9614
  window.addEventListener("hashchange", () => {
7514
9615
  const m = (location.hash || "").match(/^#\/r\/([a-z0-9]+)/i);
7515
- if (m && m[1]) lsSet(ROOMS_KEY, m[1]);
9616
+ if (m && m[1]) {
9617
+ lsSet(ROOMS_KEY, m[1]);
9618
+ lsSet(ROOMS_LAST_KEY, m[1]);
9619
+ }
7516
9620
  });
7517
9621
 
7518
9622
  /* Wrap window.openAgentProfile so EVERY open call (sidebar click,
@@ -7548,6 +9652,7 @@
7548
9652
  const m = (location.hash || "").match(/^#\/r\/([a-z0-9]+)/i);
7549
9653
  if (m && m[1]) {
7550
9654
  lsSet(ROOMS_KEY, m[1]);
9655
+ lsSet(ROOMS_LAST_KEY, m[1]);
7551
9656
  activate("rooms", { persist: false });
7552
9657
  return;
7553
9658
  }