@jabworks/condux 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jabworks/condux",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -172,6 +172,24 @@ function gitRoot() {
172
172
  }
173
173
  }
174
174
 
175
+ // Write atomically. A plain writeFileSync is open(O_CREAT|O_TRUNC) then write()
176
+ // — two syscalls — so any reader polling for the file can win the gap and see a
177
+ // created, still-empty file. Writing a sibling temp and rename()-ing it into
178
+ // place is atomic on POSIX: readers see either the old file or the complete new
179
+ // one, never a truncated one. Same directory so the rename never crosses a
180
+ // filesystem. If rename is unavailable (some Windows AV/lock cases), fall back
181
+ // to the direct write rather than losing the feedback.
182
+ function writeFileAtomic(file, data) {
183
+ const tmp = file + '.' + process.pid + '.tmp';
184
+ try {
185
+ fs.writeFileSync(tmp, data);
186
+ fs.renameSync(tmp, file);
187
+ } catch (e) {
188
+ try { fs.rmSync(tmp, { force: true }); } catch (e2) { /* nothing to clean */ }
189
+ fs.writeFileSync(file, data);
190
+ }
191
+ }
192
+
175
193
  function feedbackMarkdown(decision, thread) {
176
194
  const lines = ['# Plan Review Feedback', '',
177
195
  '- **Decision:** ' + decision,
@@ -262,7 +280,7 @@ function resolveDecision(decision, thread) {
262
280
  // browser tab live-reloads when the agent revises the plan on disk. On a
263
281
  // terminal decision (Approve/Reject) the review is over, so we exit after the
264
282
  // response flushes; on "Request Revisions" we keep serving the next round.
265
- fs.writeFileSync(feedbackFile, md);
283
+ writeFileAtomic(feedbackFile, md);
266
284
  const payload = { decision: decision, feedback: md, feedbackFile: feedbackFile };
267
285
  if (decisionWaiter) {
268
286
  try { decisionWaiter.writeHead(200, { 'Content-Type': 'application/json' }); decisionWaiter.end(JSON.stringify(payload)); } catch (e) { /* client gone */ }
@@ -276,7 +294,7 @@ function resolveDecision(decision, thread) {
276
294
  setTimeout(function () { process.exit(0); }, 400);
277
295
  }
278
296
  } else {
279
- fs.writeFileSync(feedbackFile, md);
297
+ writeFileAtomic(feedbackFile, md);
280
298
  log('\n[PLAN-REVIEW FEEDBACK -> ' + feedbackFile + ']\n' + md + '\n');
281
299
  }
282
300
  }
@@ -476,12 +476,21 @@
476
476
  the smaller 0.25rem step now say so explicitly via --radius, which
477
477
  already carries that value and is already this file's convention
478
478
  (.nav .tabs, .popover, .chip, code, pre, …). */
479
+ /* Docket #48 / Q16 — three theme states, not two. These tokens are
480
+ dark-first like the core, so their light values are an override. Written
481
+ as a bare media query, clicking Dark on a light system flipped every core
482
+ token and left the highlight cream, because the media query still
483
+ matched. Written the way scripts/tokens/core.css:84-85,130 writes it:
484
+ the system-light block excludes an explicit dark stamp, and an explicit
485
+ light block beats a dark OS. */
479
486
  :root {
480
487
  --hl: rgba(255, 202, 22, 0.30); --hl-active: rgba(255, 202, 22, 0.55);
481
488
  }
482
489
  @media (prefers-color-scheme: light) {
483
- :root { --hl: rgba(234, 179, 8, 0.42); --hl-active: rgba(202, 138, 4, 0.62); }
490
+ :root:not([data-theme="dark"]) { --hl: rgba(234, 179, 8, 0.42); --hl-active: rgba(202, 138, 4, 0.62); }
484
491
  }
492
+ :root[data-theme="light"] { --hl: rgba(234, 179, 8, 0.42); --hl-active: rgba(202, 138, 4, 0.62); }
493
+ :root[data-theme="dark"] { --hl: rgba(255, 202, 22, 0.30); --hl-active: rgba(255, 202, 22, 0.55); }
485
494
  * { box-sizing: border-box; }
486
495
  html, body { height: 100%; }
487
496
  /* .kit-error sets display:flex unconditionally; an author rule beats the
@@ -493,9 +502,16 @@
493
502
  one legitimately 2-D layout in this file; everything else here is a
494
503
  one-dimensional row or column and stays flex. */
495
504
  body { margin: 0; background: var(--background); color: var(--foreground); font-family: var(--sans); font-size: var(--text-sm); line-height: var(--leading-normal); font-variant-numeric: tabular-nums; display: grid; grid-template-rows: 1fr auto; height: 100dvh; }
496
- .app { display: grid; grid-template-columns: 248px minmax(0, 1fr) 372px; min-height: 0; }
505
+ /* D10 — the document takes the width the two lists were spending. Every
506
+ column rule is scoped ABOVE the stacking breakpoint on purpose: an
507
+ unscoped `grid-template-columns` outranks the 860px and 1100px rules by
508
+ source order, and the three-column grid survives into a phone viewport.
509
+ Measured at 640px while building D10: `.main` collapsed to 88px and the
510
+ 340px popover ran off the right edge of the screen. */
511
+ .app { display: grid; grid-template-columns: 236px minmax(0, 1fr) 330px; min-height: 0; }
512
+ .app > * { min-width: 0; } /* Q14 — a bare 1fr column floors at min-content */
497
513
  @media (max-width: 1100px) {
498
- .app { grid-template-columns: 208px minmax(0, 1fr) 320px; }
514
+ .app { grid-template-columns: 208px minmax(0, 1fr) 300px; }
499
515
  }
500
516
  @media (max-width: 860px) {
501
517
  .app { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; max-height: none; }
@@ -537,8 +553,12 @@
537
553
  .badge-warning { background: var(--warning-muted); color: var(--warning); border-color: var(--warning-border); }
538
554
  .badge-soft { background: var(--primary-muted); color: var(--primary-text); }
539
555
 
540
- /* Left: navigation — Contents (docs + headings) | Files (paths touched) */
541
- .nav { border-right: 1px solid var(--border); background: var(--card); overflow-y: auto; padding: 18px 14px 40px; }
556
+ /* Left: navigation — Contents (docs + headings) | Files (paths touched).
557
+ D10 — a table of contents rather than a menu: no card ground, no active
558
+ pill, depth as padding. Note `.nav a` is display:flex, so a negative
559
+ text-indent does not produce a hanging indent here — it shifts the flex
560
+ container's first line and clips the label against the row. */
561
+ .nav { border-right: 1px solid var(--border); background: var(--background); overflow-y: auto; padding: 22px 16px 40px; }
542
562
  .nav .kit-theme { margin: 0 0 12px; }
543
563
  .nav .tabs { display: flex; margin: 0 0 12px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
544
564
  .nav .tab { flex: 1; font: 500 var(--text-2xs) var(--sans); letter-spacing: .08em; text-transform: uppercase; padding: 6px 4px;
@@ -547,16 +567,16 @@
547
567
  .nav .tab:hover { color: var(--foreground); }
548
568
  .nav .tab.active { background: var(--accent); color: var(--foreground); }
549
569
  .nav a.doc { font-weight: 600; color: var(--foreground); }
550
- .nav a.doc.active { border-left-color: var(--primary); background: var(--accent); }
570
+ .nav a.doc.active { color: var(--foreground); background: transparent; }
551
571
  .nav a.file .t { font-family: var(--mono); font-size: var(--text-xs); }
552
572
  .nav .none { color: var(--subtle); font-size: var(--text-xs); padding: 4px 6px; }
553
573
  code.mention-flash { outline: 2px solid var(--warning); outline-offset: 1px; }
554
574
  .nav .label, .chat .label { font-size: var(--text-2xs); letter-spacing: 0.08em; text-transform: uppercase; color: var(--subtle); margin: 0 0 12px; padding: 0 6px; }
555
- .nav a { display: flex; align-items: center; gap: 6px; color: var(--muted-foreground); text-decoration: none; padding: 4px 6px; border-radius: 4px; border-left: 2px solid transparent; }
575
+ .nav a { display: flex; align-items: center; gap: 6px; color: var(--muted-foreground); text-decoration: none; padding: 3px 2px; border-radius: 0; border-left: none; }
556
576
  .nav a span.t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; }
557
- .nav a:hover { background: var(--accent); color: var(--foreground); }
558
- .nav a.active { color: var(--foreground); border-left-color: var(--primary); background: var(--accent); }
559
- .nav a.lvl2 { padding-left: 16px; } .nav a.lvl3 { padding-left: 28px; font-size: var(--text-xs); color: var(--subtle); }
577
+ .nav a:hover { color: var(--foreground); }
578
+ .nav a.active { color: var(--foreground); font-weight: 600; background: transparent; }
579
+ .nav a.lvl2 { padding-left: 14px; } .nav a.lvl3 { padding-left: 26px; font-size: var(--text-xs); color: var(--subtle); }
560
580
 
561
581
  /* Directory mode: doc-site tree — folder rows fold, filter narrows, the
562
582
  active doc alone carries its headings. All of it DIRMODE-gated. */
@@ -566,14 +586,14 @@
566
586
  .nav .tree-filter input:focus { border-color: var(--ring); }
567
587
  .nav a.folder { font-weight: 600; color: var(--muted-foreground); }
568
588
  .nav a.folder .arrow { flex: none; width: 12px; color: var(--subtle); font-size: var(--text-2xs); }
569
- .crumbs { display: none; max-width: 760px; margin: 0 auto 18px; font: var(--text-xs) var(--mono); color: var(--subtle);
589
+ .crumbs { display: none; max-width: 720px; margin: 0 auto 18px; font: var(--text-xs) var(--mono); color: var(--subtle);
570
590
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
571
591
  .crumbs .seg { color: var(--muted-foreground); }
572
592
  .crumbs a.seg { color: var(--muted-foreground); text-decoration: none; cursor: pointer; }
573
593
  .crumbs a.seg:hover { color: var(--foreground); text-decoration: underline; }
574
594
  .crumbs .sep { margin: 0 6px; color: var(--subtle); }
575
595
  .crumbs .cur { color: var(--foreground); }
576
- .pnav { display: none; max-width: 760px; margin: 32px auto 0; padding-top: 14px; border-top: 1px solid var(--border);
596
+ .pnav { display: none; max-width: 720px; margin: 32px auto 0; padding-top: 14px; border-top: 1px solid var(--border);
577
597
  justify-content: space-between; gap: 12px; }
578
598
  .pnav a { display: inline-flex; align-items: center; gap: 6px; max-width: 48%; color: var(--muted-foreground);
579
599
  text-decoration: none; font-size: var(--text-sm); padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius);
@@ -581,23 +601,23 @@
581
601
  .pnav a:hover { background: var(--accent); color: var(--foreground); }
582
602
  .pnav .spacer { flex: 1; }
583
603
 
584
- /* Center: plan */
585
- .main { overflow-y: auto; padding: 28px 44px 120px; position: relative;
586
- background-image:
587
- linear-gradient(to right, color-mix(in oklab, var(--foreground) 3%, transparent) 1px, transparent 1px),
588
- linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 3%, transparent) 1px, transparent 1px);
589
- background-size: 32px 32px; }
590
- .main .hint { color: var(--subtle); margin: 0 auto 24px; font-size: var(--text-xs); max-width: 760px; }
591
- .doc { max-width: 760px; margin: 0 auto; line-height: var(--leading-relaxed); }
604
+ /* Center: plan. `position: relative` and `overflow-y: auto` are load-bearing
605
+ and must both stay: #popover is absolutely positioned against this box,
606
+ and selDoc stores coordinates relative to it plus its scrollTop. Moving
607
+ the scroll to body, or wrapping #plan in its own scroller, silently
608
+ breaks every annotation anchor. */
609
+ .main { overflow-y: auto; padding: 40px 44px 140px; position: relative; }
610
+ .main .hint { color: var(--subtle); margin: 0 auto 24px; font-size: var(--text-xs); max-width: 720px; }
611
+ .doc { max-width: 720px; margin: 0 auto; line-height: var(--leading-relaxed); counter-reset: hl; }
592
612
 
593
613
  /* Revision diff — on a revised plan, show what changed instead of a silent swap. */
594
- .revbar { display: none; align-items: center; gap: 10px; max-width: 760px; margin: 0 auto 20px;
614
+ .revbar { display: none; align-items: center; gap: 10px; max-width: 720px; margin: 0 auto 20px;
595
615
  padding: 9px 12px; border: 1px solid var(--warning-border); background: var(--warning-muted); border-radius: var(--radius); }
596
616
  .revbar.show { display: flex; }
597
617
  .revbar .rb-text { color: var(--warning); font: 600 var(--text-xs) var(--sans); }
598
618
  .revbar .rb-stat { color: var(--muted-foreground); font-size: var(--text-xs); }
599
619
  .revbar .rb-actions { margin-left: auto; display: flex; gap: 6px; }
600
- .diffwrap { display: none; max-width: 760px; margin: 0 auto; }
620
+ .diffwrap { display: none; max-width: 720px; margin: 0 auto; }
601
621
  .diffwrap.show { display: block; }
602
622
  .diffview { background: var(--muted); border: 1px solid var(--border); border-radius: var(--radius);
603
623
  padding: 8px 0; overflow-x: auto; font: var(--text-xs)/var(--leading-normal) var(--mono); }
@@ -609,11 +629,24 @@
609
629
  .diffline.del { background: var(--destructive-muted); color: var(--destructive); }
610
630
  .diffline.del .g { color: var(--destructive); }
611
631
  .diffline.fold { justify-content: center; color: var(--subtle); font-style: italic; padding: 3px 12px; }
632
+ /* D10 — a typographic spine. Hierarchy is space and rule, not boxes: h1
633
+ closes with a rule, h2 opens a section against a hairline, h3 hangs off
634
+ a short dash. This is what makes a long plan skimmable without folding
635
+ it — folds are ruled out on this surface (live Ranges and mark.hl nodes
636
+ have no client rects inside a collapsed <details>). */
612
637
  h1, h2, h3, h4, h5, h6 { line-height: var(--leading-snug); margin: 1.5em 0 0.6em; scroll-margin-top: 24px; }
613
- h1 { font-size: var(--text-xl); } h2 { font-size: var(--text-md); } h3 { font-size: var(--text-base); color: var(--muted-foreground); }
638
+ h1 { font-size: var(--text-2xl); letter-spacing: -0.015em; margin: 0 0 0.2em; padding-bottom: 0.5em; border-bottom: 1px solid var(--border); }
639
+ h2 { font-size: var(--text-lg); letter-spacing: -0.01em; margin: 2.4em 0 0.7em; padding-top: 0.9em; border-top: 1px solid var(--border); }
640
+ h3 { font-size: var(--text-base); color: var(--foreground); margin: 1.9em 0 0.5em; }
641
+ h3::before { content: ''; display: inline-block; width: 14px; height: 1px; vertical-align: middle; margin-right: 10px; background: var(--subtle); }
614
642
  h4 { font-size: var(--text-sm); } h5, h6 { font-size: var(--text-xs); color: var(--muted-foreground); }
643
+ .doc > *:first-child, .docpane > *:first-child { margin-top: 0; }
615
644
  p { margin: 0.6em 0; }
616
645
  ul, ol { margin: 0.6em 0; padding-left: 22px; } li { margin: 0.25em 0; }
646
+ /* Q15 — capping measure on <p> alone misses the worst line, and a plan is
647
+ mostly list items. Table cells keep their column. */
648
+ .doc p, .doc li { max-width: 68ch; }
649
+ .doc td p, .doc td li, .doc th p { max-width: none; }
617
650
  li.task { list-style: none; margin-left: -18px; }
618
651
  del { color: var(--subtle); }
619
652
  a { color: var(--info); }
@@ -622,12 +655,32 @@
622
655
  pre code { background: none; border: none; padding: 0; }
623
656
  blockquote { margin: 0.6em 0; padding: 2px 14px; color: var(--muted-foreground); border-left: 3px solid var(--primary); }
624
657
  hr { border: none; border-top: 1px solid var(--border); margin: 1.6em 0; }
658
+ /* D10 — h2 now opens a section against its own rule, which makes a
659
+ preceding `---` a second divider saying the same thing. draft-plan's
660
+ canonical template puts `---` immediately before every task card, so
661
+ this is the shape of nearly every plan this surface renders: measured
662
+ 9 doubled rules in one 9-task plan. Each block is wrapped in its own
663
+ .blk, so `hr + h2` cannot match — the relative selector looks forward
664
+ to the next block instead. The h2's rule is the spine and stays; the
665
+ redundant separator goes. */
666
+ .blk:has(> hr):has(+ .blk h2) { display: none; }
625
667
  table { border-collapse: collapse; margin: 0.9em 0; width: 100%; display: block; overflow-x: auto; font-size: var(--text-sm); }
626
668
  th, td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
627
669
  th { background: var(--muted); font-weight: 600; }
628
670
  tbody tr:nth-child(even) { background: color-mix(in oklch, var(--muted) 40%, transparent); }
629
671
  .blk { border-radius: 4px; padding: 1px 8px; margin: 0 -8px; }
630
- mark.hl { background: var(--hl); color: inherit; border-radius: 2px; cursor: pointer; padding: 0 1px; }
672
+ /* D10 — numbered marginalia. The counter increments per highlight in
673
+ document order and the review column repeats the same ordinal in its
674
+ gutter, so the two columns read as one artifact. paint() orders the
675
+ thread by document position to keep the two numberings in agreement —
676
+ without that they diverge the moment a note is added above an earlier
677
+ one. The counter is also the only trace of an annotation on paper, since
678
+ the review column is print-suppressed. */
679
+ mark.hl { background: var(--hl); color: inherit; border-radius: 2px; cursor: pointer; padding: 0.1em 1px;
680
+ box-decoration-break: clone; -webkit-box-decoration-break: clone; counter-increment: hl; }
681
+ mark.hl::after { content: counter(hl); font: 600 var(--text-2xs) var(--mono); color: var(--muted-foreground);
682
+ vertical-align: super; margin-left: 2px; line-height: 0; }
683
+ mark.hl:hover { background: var(--hl-active); }
631
684
  mark.hl.active { background: var(--hl-active); box-shadow: 0 0 0 1px var(--warning-border); }
632
685
 
633
686
  /* Floating "comment" button (Button surface) + comment Popover */
@@ -664,20 +717,51 @@
664
717
  .popover .ta { min-height: 132px; border: none; border-radius: 0; padding: 8px 10px; } .popover .ta:focus-visible { outline: none; box-shadow: none; }
665
718
  .composer .ta { min-height: 52px; }
666
719
 
667
- /* Right: annotations (Cards) + composer + decisions */
668
- .chat { border-left: 1px solid var(--border); background: var(--card); display: flex; flex-direction: column; }
669
- .chat .label { padding: 16px 16px 0; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; }
670
- .thread { flex: 1; overflow-y: auto; padding: 6px 14px; display: flex; flex-direction: column; gap: 8px; }
720
+ /* Right: annotations as marginalia + composer + decisions.
721
+ D10 — the review column recedes so the document can lead. Notes lose
722
+ their card chrome and are separated by rule alone, each keyed by the
723
+ same ordinal its highlight carries in the document. */
724
+ .chat { border-left: 1px solid var(--border); background: var(--background); display: flex; flex-direction: column; }
725
+ .chat .label { padding: 22px 18px 0; margin-bottom: 4px; display: flex; align-items: center; gap: 8px;
726
+ font: 600 var(--text-2xs) var(--sans); letter-spacing: .09em; text-transform: uppercase; color: var(--subtle); }
727
+ .thread { flex: 1; overflow-y: auto; padding: 10px 18px; display: flex; flex-direction: column; gap: 0; counter-reset: note; }
671
728
  .thread .empty { color: var(--subtle); padding: 8px 4px; }
672
- /* Terminus: Card */
673
- .msg { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; background: var(--card); box-shadow: var(--shadow-sm); cursor: default; }
674
- .msg.note { border-color: var(--warning-border); cursor: pointer; }
675
- .msg.note.active { box-shadow: 0 0 0 2px var(--warning); }
729
+ .msg { position: relative; background: transparent; border: none; border-top: 1px solid var(--border);
730
+ border-left: 2px solid transparent; border-radius: 0; padding: 14px 0 14px 26px; box-shadow: none; cursor: default; }
731
+ .msg:first-child { border-top: none; }
732
+ .msg::before { counter-increment: note; content: counter(note); position: absolute; left: 8px; top: 15px;
733
+ font: 600 var(--text-2xs) var(--mono); color: var(--subtle); }
734
+ /* A note on another document takes no ordinal — see paint(). The marker
735
+ says "annotated, elsewhere" without claiming a position in a sequence
736
+ the reader cannot see. */
737
+ .msg[data-other-doc]::before { counter-increment: none; content: '·'; }
738
+ .msg.note { cursor: pointer; }
739
+ .msg.note.active { background: var(--accent); }
676
740
  .msg .meta { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px; align-items: center; }
677
- .msg .who { color: var(--muted-foreground); font-size: var(--text-2xs); font-weight: 600; text-transform: uppercase; letter-spacing: .07em; } .msg.note .who { color: var(--warning); }
741
+ .msg .who { color: var(--muted-foreground); font: 600 var(--text-2xs) var(--sans); text-transform: uppercase; letter-spacing: .07em; }
678
742
  .msg .del { cursor: pointer; border: none; background: none; color: var(--subtle); font-size: var(--text-2xs); padding: 0 2px; } .msg .del:hover { color: var(--destructive); }
679
- .msg .quote { color: var(--muted-foreground); font-size: var(--text-2xs); border-left: 2px solid var(--warning); padding: 1px 8px; margin-bottom: 5px; max-height: 42px; overflow: hidden; }
680
- .msg .body { white-space: pre-wrap; word-break: break-word; color: var(--card-foreground); }
743
+ .msg .quote { color: var(--muted-foreground); font-size: var(--text-2xs); font-style: italic; border-left: 2px solid var(--hl-active); padding: 1px 9px; margin-bottom: 6px; max-height: 42px; overflow: hidden; }
744
+ .msg .body { white-space: pre-wrap; word-break: break-word; color: var(--foreground); }
745
+
746
+ /* Category as colour, carried by the two marks already in the gutter — the
747
+ ordinal and the label — rather than by a card edge. Lets you count the
748
+ issues in a full thread without reading it. `data-cat` is set in paint();
749
+ the category is otherwise only written as TEXT into .who, and CSS cannot
750
+ select on text content. */
751
+ /* The five categories are exactly the popover's chips — Comment, Issue,
752
+ Question, Suggestion, Nitpick — plus "Note", which is what a note gets
753
+ when no chip was clicked (openToolbar clears pendingCat on every open,
754
+ so the markup's initial `active` chip is not a default). Comment and
755
+ Note are deliberately quiet: they are the common case, and colouring
756
+ them would leave nothing for the categories that carry a decision. */
757
+ .msg[data-cat="Issue"] { border-left-color: var(--destructive); }
758
+ .msg[data-cat="Issue"]::before, .msg[data-cat="Issue"] .who { color: var(--destructive); }
759
+ .msg[data-cat="Question"] { border-left-color: var(--warning); }
760
+ .msg[data-cat="Question"]::before, .msg[data-cat="Question"] .who { color: var(--warning); }
761
+ .msg[data-cat="Suggestion"] { border-left-color: var(--primary); }
762
+ .msg[data-cat="Suggestion"]::before, .msg[data-cat="Suggestion"] .who { color: var(--primary-text); }
763
+ .msg[data-cat="Nitpick"] { border-left-color: var(--subtle); }
764
+ .msg[data-cat="Nitpick"]::before, .msg[data-cat="Nitpick"] .who { color: var(--subtle); }
681
765
 
682
766
  /* Terminus: Separator + composer */
683
767
  .composer { border-top: 1px solid var(--border); padding: 12px 14px; }
@@ -709,6 +793,22 @@
709
793
  .sent .again { margin-top: 10px; }
710
794
  .dot { width: 7px; height: 7px; border-radius: 9999px; background: currentColor; animation: pulse 1.3s ease-in-out infinite; }
711
795
  @keyframes pulse { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
796
+
797
+ /* Docket #50 — the shell has to COLLAPSE for print, not just hide its
798
+ chrome. The kit's print block sets [data-kit-chrome] { display: none },
799
+ which hides .nav and .chat but leaves their grid TRACKS on .app, so
800
+ .main auto-placed into the first (248px) track and the plan printed in a
801
+ ~151px column. .main is also the scroll container, so printed output was
802
+ clipped to the one screenful scrolled into view — the rest of the plan
803
+ did not print at all.
804
+ The measure cap stays: prose set to the full page width is no more
805
+ readable on paper than on screen. Only the centring margin goes, so the
806
+ column sits against the page's own margin. */
807
+ @media print {
808
+ .app { display: block; }
809
+ .main { overflow: visible; padding: 0; height: auto; }
810
+ .doc, .crumbs { margin: 0; }
811
+ }
712
812
  </style>
713
813
  </head>
714
814
  <body>
@@ -1473,12 +1573,47 @@
1473
1573
  function sendChat(){ const ta=document.getElementById('input'); const text=ta.value.trim(); if(!text)return;
1474
1574
  annotations.push({id:nextId++,kind:'chat',quote:'',text:text,section:''}); ta.value=''; paint(); }
1475
1575
 
1576
+ // The thread renders in DOCUMENT order, not insertion order, because each
1577
+ // highlight carries a CSS counter numbered by document position and the
1578
+ // gutter repeats that ordinal. Left in insertion order the two numberings
1579
+ // disagree the moment a note is added above an earlier one.
1580
+ //
1581
+ // Sort a COPY: `annotations` is the identity store that saveNote pushes to
1582
+ // and the delete handler indexes into with indexOf, so reordering it in
1583
+ // place breaks both. And never reorder the rendered DOM instead — chat
1584
+ // messages have no document position and would be shuffled to the top.
1585
+ function threadOrder(){
1586
+ // Scope the mark lookup to the ACTIVE pane. In DIRMODE every doc's pane
1587
+ // stays in the DOM hidden, so a document-wide query happily matches a
1588
+ // mark in another document's pane and sorts against it.
1589
+ const pane=docState[activeDoc]&&docState[activeDoc].pane;
1590
+ const markOf=function(a){ return pane?pane.querySelector('mark.hl[data-aid="'+a.id+'"]'):null; };
1591
+ // Array.prototype.sort is stable, so a 0 comparison keeps chat messages
1592
+ // — and any note whose mark is missing — in their existing order.
1593
+ return annotations.slice().sort(function(x,y){
1594
+ if(x.kind!=='note'||y.kind!=='note') return 0;
1595
+ const mx=markOf(x), my=markOf(y);
1596
+ if(!mx||!my) return 0;
1597
+ return (mx.compareDocumentPosition(my)&Node.DOCUMENT_POSITION_FOLLOWING)?-1:1;
1598
+ });
1599
+ }
1476
1600
  function paint(){
1477
1601
  const box=document.getElementById('thread'); box.innerHTML='';
1478
1602
  document.getElementById('count').textContent=annotations.length?'('+annotations.length+')':'';
1479
1603
  if(!annotations.length){ box.innerHTML='<div class="empty">No notes yet. Select text to comment, or send a message.</div>'; updateBadges(); updateSummary(); return; }
1480
- annotations.forEach(function(a){
1604
+ threadOrder().forEach(function(a){
1481
1605
  const m=document.createElement('div'); m.className='msg'+(a.kind==='note'?' note':''); if(a.kind==='note')m.dataset.aid=String(a.id);
1606
+ // The category is otherwise written only as TEXT into .who, and CSS
1607
+ // cannot select on text content — this is what the colour rules key on.
1608
+ m.dataset.cat=(a.kind==='note'?(a.cat||'Note'):'message');
1609
+ // DIRMODE: the thread lists notes from EVERY document, but the in-document
1610
+ // ordinal counts only the visible pane — marks inside a display:none pane
1611
+ // do not increment a CSS counter. Numbering every row would therefore
1612
+ // label a note "3" in the gutter whose highlight reads "1" in the
1613
+ // document, which is exactly the divergence the numbering exists to
1614
+ // remove. Rows belonging to another document are flagged so they take no
1615
+ // ordinal, leaving both sequences running 1..m over the same set.
1616
+ if(a.kind==='note'&&a.doc&&a.doc!==activeDoc) m.dataset.otherDoc='1';
1482
1617
  const meta=document.createElement('div'); meta.className='meta';
1483
1618
  const who=document.createElement('span'); who.className='who'; who.textContent=a.kind==='note'?(a.cat||'Note'):'message';
1484
1619
  const del=document.createElement('button'); del.className='del'; del.textContent='×';
@@ -1552,7 +1687,12 @@
1552
1687
  if(submitting) return; submitting=true;
1553
1688
  document.querySelectorAll('.dbar button').forEach(function(b){b.disabled=true;});
1554
1689
  try{
1555
- const r=await fetch('/api/feedback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:decision,thread:annotations})});
1690
+ // Send the thread in the SAME order the reviewer saw it. The rendered
1691
+ // thread is ordered by document position and each note is numbered in
1692
+ // the gutter; posting the raw insertion-order array would hand the
1693
+ // agent a different numbering than the one on screen, so "note 1" in a
1694
+ // conversation would mean two different notes.
1695
+ const r=await fetch('/api/feedback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:decision,thread:threadOrder()})});
1556
1696
  if(!r.ok) throw new Error('status '+r.status);
1557
1697
  hideApiError();
1558
1698
  decisionSent=true; // a terminal decision makes the server exit —