@cardstack/boxel-cli 0.11.0-unstable.2 → 0.11.0-unstable.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.
Files changed (106) hide show
  1. package/bundled-realms/pretui/components/agent-mark.gts +98 -0
  2. package/bundled-realms/pretui/components/agent-question.gts +340 -37
  3. package/bundled-realms/pretui/components/answer-footer.gts +249 -0
  4. package/bundled-realms/pretui/components/card-embed.gts +523 -0
  5. package/bundled-realms/pretui/components/card-pills.gts +725 -0
  6. package/bundled-realms/pretui/components/code-block.gts +87 -45
  7. package/bundled-realms/pretui/components/collapsed-turn.gts +133 -12
  8. package/bundled-realms/pretui/components/compaction-chip.gts +81 -67
  9. package/bundled-realms/pretui/components/composer-cap.gts +516 -0
  10. package/bundled-realms/pretui/components/context-ring.gts +108 -0
  11. package/bundled-realms/pretui/components/diff-block.gts +1002 -31
  12. package/bundled-realms/pretui/components/diff-stat.gts +102 -0
  13. package/bundled-realms/pretui/components/jump-chip.gts +86 -53
  14. package/bundled-realms/pretui/components/markdown-stream.gts +799 -0
  15. package/bundled-realms/pretui/components/markdown-surface.gts +71 -0
  16. package/bundled-realms/pretui/components/plan-mark.gts +147 -0
  17. package/bundled-realms/pretui/components/prose.gts +185 -8
  18. package/bundled-realms/pretui/components/prose.md +6 -8
  19. package/bundled-realms/pretui/components/quick-look.gts +377 -0
  20. package/bundled-realms/pretui/components/reactions.gts +136 -0
  21. package/bundled-realms/pretui/components/recommendation.gts +67 -36
  22. package/bundled-realms/pretui/components/result-card.gts +58 -22
  23. package/bundled-realms/pretui/components/segmented-control.gts +13 -8
  24. package/bundled-realms/pretui/components/session-prep.gts +12 -11
  25. package/bundled-realms/pretui/components/sliding-highlight.gts +8 -3
  26. package/bundled-realms/pretui/components/speaker-label.gts +132 -0
  27. package/bundled-realms/pretui/components/state-glyph.gts +121 -0
  28. package/bundled-realms/pretui/components/step-list.gts +62 -60
  29. package/bundled-realms/pretui/components/stick-to-bottom.gts +1150 -0
  30. package/bundled-realms/pretui/components/system-line.gts +143 -0
  31. package/bundled-realms/pretui/components/task-checklist.gts +184 -0
  32. package/bundled-realms/pretui/components/thinking.gts +63 -94
  33. package/bundled-realms/pretui/components/tool-change-table.gts +285 -0
  34. package/bundled-realms/pretui/components/tool-gate.gts +462 -0
  35. package/bundled-realms/pretui/components/tool-group.gts +920 -0
  36. package/bundled-realms/pretui/components/tool-icon.ts +64 -0
  37. package/bundled-realms/pretui/components/tool-meta.gts +82 -0
  38. package/bundled-realms/pretui/components/tool-plan.gts +197 -0
  39. package/bundled-realms/pretui/components/tool-row.gts +2087 -0
  40. package/bundled-realms/pretui/components/turn-changes.gts +227 -0
  41. package/bundled-realms/pretui/components/user-bubble.gts +575 -0
  42. package/bundled-realms/pretui/components/work-item.gts +185 -48
  43. package/bundled-realms/pretui/docs/scroll-anchoring-protocol.md +136 -0
  44. package/bundled-realms/pretui/internal/markdown-projection.ts +438 -0
  45. package/bundled-realms/pretui/internal/mount-queue.ts +79 -0
  46. package/bundled-realms/pretui/internal/tool-phrases.ts +168 -0
  47. package/bundled-realms/pretui/package.json +2 -1
  48. package/bundled-test-harness/assets/{ai-module-creation-test-beRNwH-e.js → ai-module-creation-test-B6FQKqIl.js} +1 -1
  49. package/bundled-test-harness/assets/{app-CtezUCip.js → app-KpPqdyX3.js} +4 -4
  50. package/bundled-test-harness/assets/{binary-upload-dedupe-test-bI8T0QLl.js → binary-upload-dedupe-test-DN00y1tM.js} +1 -1
  51. package/bundled-test-harness/assets/{can-invoke-test-DVaU_yRT.js → can-invoke-test-Bl-EUFM3.js} +1 -1
  52. package/bundled-test-harness/assets/{card-chooser-paging-test-DnxMnXKR.js → card-chooser-paging-test-BNuZRHX3.js} +1 -1
  53. package/bundled-test-harness/assets/{card-chooser-test-DUSnLr-u.js → card-chooser-test-wKfPOOT4.js} +1 -1
  54. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-GBUr3tFK.js → card-def-field-def-relationships-test-CduQ6zan.js} +1 -1
  55. package/bundled-test-harness/assets/{card-delete-test-CQveO1VC.js → card-delete-test-DKxolj5o.js} +1 -1
  56. package/bundled-test-harness/assets/{check-correctness-test-D2M2p66N.js → check-correctness-test-B-kgHztp.js} +1 -1
  57. package/bundled-test-harness/assets/{codeblocks-test-AdolhdCA.js → codeblocks-test-BDD7qK_V.js} +1 -1
  58. package/bundled-test-harness/assets/{compaction-test-DvyAQq56.js → compaction-test-BweolkhG.js} +1 -1
  59. package/bundled-test-harness/assets/{container-DIF_9lZ9.css → container-B5B6u-xm.css} +29 -22
  60. package/bundled-test-harness/assets/{container-DvvqmNpg.js → container-BnJjIM6J.js} +9 -6
  61. package/bundled-test-harness/assets/{create-listing-modal-test-D95JfQab.js → create-listing-modal-test-FvcAKpIa.js} +1 -1
  62. package/bundled-test-harness/assets/{debug-message-test-BcNtj7Wr.js → debug-message-test-CJ18Evt7.js} +1 -1
  63. package/bundled-test-harness/assets/{externals-DCnB4XSS.js → externals-BWjhmCD4.js} +1 -1
  64. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-5lnN1pv2.js → fallback-banner-live-delete-test-C2H3BrT-.js} +1 -1
  65. package/bundled-test-harness/assets/{fallback-banner-test-DFb_m_Kq.js → fallback-banner-test-C10aYjkj.js} +1 -1
  66. package/bundled-test-harness/assets/{fallback-models-test-p3YIwj1L.js → fallback-models-test-BxoPiaGF.js} +1 -1
  67. package/bundled-test-harness/assets/{file-attachment-test-CmyjNWRR.js → file-attachment-test-DeKyzYaz.js} +1 -1
  68. package/bundled-test-harness/assets/{general-test-CX-TFDuX.js → general-test-BewzrpoB.js} +1 -1
  69. package/bundled-test-harness/assets/{index-query-engine-test-CY2tAkot.js → index-query-engine-test-BtI8Y2LR.js} +1 -1
  70. package/bundled-test-harness/assets/{instance-filter-matcher-test-CMBTh8xn.js → instance-filter-matcher-test-BHXHGHjE.js} +1 -1
  71. package/bundled-test-harness/assets/{main-BOZbZVem.js → main-CqAHZ2kK.js} +1 -1
  72. package/bundled-test-harness/assets/{message-tool-test-CfjRSJvt.js → message-tool-test-NMaPJFsd.js} +1 -1
  73. package/bundled-test-harness/assets/{model-cost-tier-test-mZG84Uso.js → model-cost-tier-test-BgbfN2LH.js} +1 -1
  74. package/bundled-test-harness/assets/{operation-lowering-test-CHnjKSJb.js → operation-lowering-test-C4IaOjsA.js} +1 -1
  75. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-KWS8lq16.js → operator-mode-card-chooser-test-Bwe47Rn7.js} +1 -1
  76. package/bundled-test-harness/assets/{operator-mode-links-test-CViFt0Ws.js → operator-mode-links-test-BU4jkC-B.js} +1 -1
  77. package/bundled-test-harness/assets/{operator-mode-test-D0gZM2yw.js → operator-mode-test-Bh0rGM9z.js} +1 -1
  78. package/bundled-test-harness/assets/{operator-mode-ui-test-CEvHJ9DS.js → operator-mode-ui-test-whgUw0ju.js} +1 -1
  79. package/bundled-test-harness/assets/{overlay-menu-items-test-Pw80RXsr.js → overlay-menu-items-test-Cwn1vr8q.js} +1 -1
  80. package/bundled-test-harness/assets/{past-sessions-test-CW0-Bjav.js → past-sessions-test-Bh24N-Kz.js} +1 -1
  81. package/bundled-test-harness/assets/{permission-checking-realm-test-D5uXQypS.js → permission-checking-realm-test-DKnnAy0f.js} +1 -1
  82. package/bundled-test-harness/assets/{realm-config-policy-standing-test-Btatheni.js → realm-config-policy-standing-test-DngdYypq.js} +1 -1
  83. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-Dh5zW13o.js → realm-config-routing-rule-edit-test-_rkFU8SN.js} +1 -1
  84. package/bundled-test-harness/assets/{realm-config-settings-test-BJa8gv_C.js → realm-config-settings-test-CR9NH0Zn.js} +1 -1
  85. package/bundled-test-harness/assets/{reasoning-test-2Zp37cQB.js → reasoning-test-0lH10kRl.js} +1 -1
  86. package/bundled-test-harness/assets/{room-message-test-BsrON13e.js → room-message-test-C-PddE4Y.js} +1 -1
  87. package/bundled-test-harness/assets/{run-realm-code-BxktQf6R.js → run-realm-code-C8N7DtN-.js} +1 -1
  88. package/bundled-test-harness/assets/{run-realm-code-test-7grpTos-.js → run-realm-code-test-DBdp147q.js} +1 -1
  89. package/bundled-test-harness/assets/{schema-generation-test-CM9ggQji.js → schema-generation-test-CB1Hb7GQ.js} +1 -1
  90. package/bundled-test-harness/assets/{scrolling-test-BER00htB.js → scrolling-test-3PhVin8h.js} +1 -1
  91. package/bundled-test-harness/assets/{searchable-option-test-Ctf8r3HZ.js → searchable-option-test-BAdOiL4U.js} +1 -1
  92. package/bundled-test-harness/assets/{sending-test-BJGobs2E.js → sending-test-KSmu1tSJ.js} +1 -1
  93. package/bundled-test-harness/assets/{skills-test-GPOsHRN6.js → skills-test-D-dlcFpd.js} +1 -1
  94. package/bundled-test-harness/assets/{store-test-QvvHimzH.js → store-test-CEW7xQjZ.js} +1 -1
  95. package/bundled-test-harness/assets/{tests-DoTEpvcF.js → tests-QYqqa6nv.js} +47 -47
  96. package/bundled-test-harness/assets/{text-input-validator-test-CYYH2N0L.js → text-input-validator-test-D8iNOWep.js} +1 -1
  97. package/bundled-test-harness/assets/{to-device-streaming-test-Ctqa0bVp.js → to-device-streaming-test-trCX-505.js} +1 -1
  98. package/bundled-test-harness/assets/{token-usage-test-BQVbK3X0.js → token-usage-test-C6sULooH.js} +1 -1
  99. package/bundled-test-harness/assets/{tools-DU8Rc15s.js → tools-UJfD-_al.js} +1 -1
  100. package/bundled-test-harness/assets/{tools-test-B13yvEKE.js → tools-test-Brav31AV.js} +1 -1
  101. package/bundled-test-harness/assets/{worker-DHBEBihX.js → worker-9ph9_Lpv.js} +31 -26
  102. package/bundled-test-harness/index.html +8 -8
  103. package/bundled-test-harness/tests/index.html +8 -8
  104. package/bundled-types/host-app/lib/realm-runner/worker.ts +8 -51
  105. package/bundled-types/runtime-common/realm-runner-script.d.ts +3 -0
  106. package/package.json +1 -1
@@ -2,26 +2,103 @@
2
2
  import Component from '@glimmer/component';
3
3
  import { ProgressBar } from './progress-bar';
4
4
  import { Spinner } from './spinner';
5
+ import { StateGlyph } from './state-glyph';
5
6
 
6
7
  type Tone = 'idle' | 'running' | 'attention' | 'done' | 'failed';
7
8
  const TONES: Record<string, Tone> = {
8
- pending: 'idle', preparing: 'running', running: 'running', applying: 'running',
9
- handoff: 'running', reconnecting: 'running', 'host-attached': 'running',
10
- fallback: 'running', ready: 'attention', 'awaiting-approval': 'attention',
11
- ask: 'attention', input: 'attention', completed: 'done', kept: 'done',
12
- delivered: 'done', failed: 'failed', invalid: 'failed', reverted: 'idle',
9
+ pending: 'idle',
10
+ preparing: 'running',
11
+ running: 'running',
12
+ applying: 'running',
13
+ handoff: 'running',
14
+ starting: 'running',
15
+ // a reload lost the page that watched the run, not the run: grey, never red
16
+ reconnecting: 'idle',
17
+ 'host-attached': 'running',
18
+ fallback: 'running',
19
+ ready: 'attention',
20
+ 'awaiting-approval': 'attention',
21
+ ask: 'attention',
22
+ input: 'attention',
23
+ completed: 'done',
24
+ kept: 'done',
25
+ delivered: 'done',
26
+ failed: 'failed',
27
+ invalid: 'failed',
28
+ reverted: 'idle',
13
29
  canceled: 'idle',
30
+ superseded: 'idle',
31
+ stopped: 'idle',
32
+ // staged writes: the preview waits on the reader, the commit runs, a file
33
+ // that moved since the preview waits again; a partial keep is still done
34
+ previewed: 'attention',
35
+ committing: 'running',
36
+ conflict: 'attention',
37
+ partial: 'done',
38
+ resumed: 'running',
39
+ // a transient failure that will try again by itself is not red yet
40
+ retrying: 'idle',
41
+ // the run spent its budget: it stopped with what it had, and can continue
42
+ budget: 'idle',
43
+ // the decision belongs to someone else in the room
44
+ routed: 'idle',
45
+ };
46
+ const GLYPHS: Record<Tone, string> = {
47
+ idle: '·',
48
+ running: '',
49
+ attention: '●',
50
+ done: '✓',
51
+ failed: '✕',
14
52
  };
15
- const GLYPHS: Record<Tone, string> = { idle: '·', running: '', attention: '●', done: '✓', failed: '✕' };
16
53
  const DEFAULT_STATUS: Record<string, string> = {
17
- pending: 'queued', preparing: 'preparing…', running: 'running…',
18
- applying: 'applying…', handoff: 'handing off…', reconnecting: 'reconnecting…',
19
- 'host-attached': 'attached', fallback: 'fallback…', ready: '● needs you',
20
- 'awaiting-approval': '● needs you', ask: '● needs you', input: '● needs you',
21
- completed: 'done', kept: 'kept', delivered: 'delivered', failed: 'failed',
22
- invalid: 'invalid', reverted: 'reverted', canceled: 'canceled',
54
+ pending: 'queued',
55
+ preparing: 'preparing…',
56
+ running: 'running…',
57
+ applying: 'applying…',
58
+ handoff: 'handing off…',
59
+ starting: 'starting…',
60
+ reconnecting: 'reconnecting…',
61
+ 'host-attached': 'attached',
62
+ fallback: 'fallback…',
63
+ ready: 'needs you',
64
+ 'awaiting-approval': 'needs you',
65
+ ask: 'needs you',
66
+ input: 'needs you',
67
+ completed: 'done',
68
+ kept: 'kept',
69
+ delivered: 'delivered',
70
+ failed: 'failed',
71
+ invalid: 'invalid',
72
+ reverted: 'reverted',
73
+ canceled: 'canceled',
74
+ superseded: 'retried below',
75
+ stopped: 'stopped',
76
+ previewed: 'review',
77
+ committing: 'writing…',
78
+ conflict: 'conflict',
79
+ partial: 'partly kept',
80
+ resumed: 'resumed…',
81
+ retrying: 'retrying soon',
82
+ budget: 'budget reached',
83
+ routed: 'waiting on someone',
23
84
  };
24
85
 
86
+ /** The tone an agent state collapses to. Exported so components built on the
87
+ * same vocabulary (ToolRow) map states identically rather than keeping a
88
+ * second table that drifts. */
89
+ export function workTone(state: string): Tone {
90
+ return TONES[state] ?? 'idle';
91
+ }
92
+ /** The glyph for a tone; empty for `running`, which shows a Spinner. */
93
+ export function workGlyph(tone: Tone): string {
94
+ return GLYPHS[tone];
95
+ }
96
+ /** The default status phrase for a state. */
97
+ export function workStatus(state: string): string {
98
+ return DEFAULT_STATUS[state] ?? state;
99
+ }
100
+ export type WorkTone = Tone;
101
+
25
102
  export interface WorkItemSignature {
26
103
  Args: {
27
104
  tier?: 'step' | 'action' | 'run';
@@ -33,6 +110,9 @@ export interface WorkItemSignature {
33
110
  progressMax?: number;
34
111
  count?: string;
35
112
  activity?: string;
113
+ /** whether settled work recedes (default: done or idle states). Only
114
+ * shows inside a container that sets --pretui-recede-opacity */
115
+ recede?: boolean;
36
116
  };
37
117
  Blocks: { default: []; footer: [] };
38
118
  Element: HTMLDivElement;
@@ -54,6 +134,9 @@ export class WorkItem extends Component<WorkItemSignature> {
54
134
  get isAttention() {
55
135
  return this.tone === 'attention';
56
136
  }
137
+ get recedes() {
138
+ return this.args.recede ?? (this.tone === 'done' || this.tone === 'idle');
139
+ }
57
140
  get statusText() {
58
141
  return this.args.status ?? DEFAULT_STATUS[this.state] ?? this.state;
59
142
  }
@@ -78,23 +161,47 @@ export class WorkItem extends Component<WorkItemSignature> {
78
161
  data-tier={{if @tier @tier 'step'}}
79
162
  data-state={{this.state}}
80
163
  data-attention={{if this.isAttention 'true'}}
164
+ data-recede={{if this.recedes 'true'}}
81
165
  data-test-pretui-work-item
82
166
  ...attributes
83
167
  >
84
168
  <div class='pretui-workitem-row'>
85
- <span class='pretui-disc' data-tone={{this.tone}}>
86
- {{#if this.isRunning}}<Spinner @size={{9}} />{{else}}{{this.glyph}}{{/if}}
169
+ <span
170
+ class='pretui-disc'
171
+ data-tone={{this.tone}}
172
+ data-test-pretui-work-item-disc
173
+ >
174
+ {{#if this.isRunning}}<Spinner @size={{9}} />{{else}}<StateGlyph
175
+ @glyph={{this.glyph}}
176
+ />{{/if}}
87
177
  </span>
88
- {{#if @verb}}<span class='pretui-verb'>{{@verb}}</span>{{/if}}
89
- <span class='pretui-workitem-title'>{{@title}}</span>
90
- <span class='pretui-workitem-status' data-tone={{this.tone}}>{{this.statusText}}</span>
178
+ {{#if @verb}}<span
179
+ class='pretui-verb'
180
+ data-test-pretui-work-item-verb
181
+ >{{@verb}}</span>{{/if}}
182
+ <span
183
+ class='pretui-workitem-title'
184
+ data-test-pretui-work-item-title
185
+ >{{@title}}</span>
186
+ <span
187
+ class='pretui-workitem-status'
188
+ data-tone={{this.tone}}
189
+ data-test-pretui-work-item-status
190
+ >{{this.statusText}}</span>
91
191
  </div>
92
192
  {{#if this.hasBody}}
93
- <div class='pretui-workitem-body'>
193
+ <div class='pretui-workitem-body' data-test-pretui-work-item-body>
94
194
  {{#if this.hasProgress}}
95
- <ProgressBar @value={{this.progressValue}} @max={{@progressMax}} @count={{this.countText}} />
195
+ <ProgressBar
196
+ @value={{this.progressValue}}
197
+ @max={{@progressMax}}
198
+ @count={{this.countText}}
199
+ />
96
200
  {{/if}}
97
- {{#if @activity}}<div class='pretui-activity'>{{@activity}}</div>{{/if}}
201
+ {{#if @activity}}<div
202
+ class='pretui-activity'
203
+ data-test-pretui-work-item-activity
204
+ >{{@activity}}</div>{{/if}}
98
205
  {{yield}}
99
206
  </div>
100
207
  {{else}}
@@ -105,63 +212,93 @@ export class WorkItem extends Component<WorkItemSignature> {
105
212
  <style scoped>
106
213
  @layer PretComponent {
107
214
  .pretui-workitem {
215
+ /* the row's inset, disc and gap, which the body's leading edge
216
+ adds up to so its text lines up under the title */
217
+ --_workitem-inset: var(--boxel-sp-sm);
218
+ --_workitem-disc: 1rem;
219
+ --_workitem-gap: var(--boxel-sp-xs);
108
220
  display: flex;
109
221
  flex-direction: column;
110
- background: var(--card);
111
- border-radius: 10px;
222
+ background-color: var(--card);
223
+ border-radius: var(--boxel-border-radius);
112
224
  box-shadow: var(--pretui-shadow-hairline, 0 0 0 1px var(--border));
113
- font-size: var(--text-ui-md, 12.5px);
225
+ font-size: var(--boxel-font-size-xs);
114
226
  }
227
+ /* needs you: the attention hue tints the face and draws the edge;
228
+ the status words and the disc's mark take its ink (below) */
115
229
  .pretui-workitem[data-attention] {
116
- box-shadow: 0 0 0 1px color-mix(in oklch, var(--pretui-attention, var(--boxel-fuschia)) 60%, var(--border)),
117
- 0 2px 10px color-mix(in oklch, var(--pretui-attention, var(--boxel-fuschia)) 14%, transparent);
118
- background: linear-gradient(
230
+ box-shadow:
231
+ 0 0 0 1px color-mix(in oklch, var(--attention) 60%, var(--border)),
232
+ 0 0.125rem 0.625rem
233
+ color-mix(in oklch, var(--attention) 14%, transparent);
234
+ background-image: linear-gradient(
119
235
  180deg,
120
- color-mix(in oklch, var(--pretui-attention, var(--boxel-fuschia)) 7%, var(--card)),
236
+ color-mix(in oklch, var(--attention) 7%, var(--card)),
121
237
  var(--card) 55%
122
238
  );
123
239
  }
124
240
  .pretui-workitem[data-state='failed'],
125
241
  .pretui-workitem[data-state='invalid'] {
126
- box-shadow: 0 0 0 1px color-mix(in oklch, var(--destructive) 45%, var(--border));
242
+ box-shadow: 0 0 0 1px
243
+ color-mix(in oklch, var(--destructive) 45%, var(--border));
244
+ }
245
+ .pretui-workitem[data-recede] > .pretui-workitem-row {
246
+ opacity: var(--pretui-recede-opacity, 1);
247
+ transition: opacity 200ms linear;
248
+ }
249
+ .pretui-workitem[data-recede]:is(:hover, :focus-within)
250
+ > .pretui-workitem-row {
251
+ opacity: 1;
127
252
  }
128
253
  .pretui-workitem-row {
129
254
  display: flex;
130
255
  align-items: center;
131
- gap: 9px;
132
- padding: 7px 11px;
133
- min-height: 32px;
256
+ gap: var(--_workitem-gap);
257
+ padding: var(--boxel-sp-2xs) var(--_workitem-inset);
258
+ min-height: 2rem;
134
259
  }
135
260
  .pretui-workitem-body {
136
- padding: 0 11px 9px 36px;
261
+ padding: 0 var(--_workitem-inset) var(--boxel-sp-xs)
262
+ calc(
263
+ var(--_workitem-inset) + var(--_workitem-disc) +
264
+ var(--_workitem-gap)
265
+ );
137
266
  display: flex;
138
267
  flex-direction: column;
139
- gap: 8px;
268
+ gap: var(--boxel-sp-xs);
140
269
  }
141
270
  .pretui-disc {
142
- width: 16px;
143
- height: 16px;
271
+ width: var(--_workitem-disc);
272
+ height: var(--_workitem-disc);
144
273
  border-radius: 50%;
145
274
  display: grid;
146
275
  place-items: center;
147
276
  flex: none;
148
- font-size: 9px;
277
+ font-size: 0.5625rem;
149
278
  font-weight: 700;
150
- background: var(--inset, var(--boxel-100));
279
+ background-color: var(--inset);
151
280
  color: var(--muted-foreground);
152
281
  box-shadow: var(--pretui-shadow-hairline, 0 0 0 1px var(--border));
153
282
  }
154
283
  .pretui-disc[data-tone='failed'] {
155
- background: color-mix(in oklch, var(--destructive) 15%, var(--card));
156
- color: var(--pretui-destructive-ink, var(--boxel-danger));
284
+ background-color: color-mix(
285
+ in oklch,
286
+ var(--destructive) 15%,
287
+ var(--card)
288
+ );
289
+ color: var(--destructive-ink);
157
290
  }
158
291
  .pretui-disc[data-tone='attention'] {
159
- background: color-mix(in oklch, var(--pretui-attention, var(--boxel-fuschia)) 22%, var(--card));
160
- color: var(--pretui-attention-ink, var(--pretui-attention, var(--boxel-fuschia)));
292
+ background-color: color-mix(
293
+ in oklch,
294
+ var(--attention) 22%,
295
+ var(--card)
296
+ );
297
+ color: var(--attention-ink);
161
298
  }
162
299
  .pretui-verb {
163
300
  font-family: var(--font-mono);
164
- font-size: 10px;
301
+ font-size: 0.625rem;
165
302
  font-weight: 600;
166
303
  letter-spacing: 0.06em;
167
304
  color: var(--muted-foreground);
@@ -178,25 +315,25 @@ export class WorkItem extends Component<WorkItemSignature> {
178
315
  .pretui-workitem-status {
179
316
  margin-left: auto;
180
317
  flex: none;
181
- font-size: var(--text-ui-sm, 11.5px);
318
+ font-size: var(--boxel-font-size-xs);
182
319
  color: var(--muted-foreground);
183
320
  display: flex;
184
321
  align-items: center;
185
- gap: 6px;
322
+ gap: var(--boxel-sp-3xs);
186
323
  font-variant-numeric: tabular-nums;
187
324
  }
188
325
  .pretui-workitem-status[data-tone='attention'] {
189
- color: var(--pretui-attention-ink, var(--pretui-attention, var(--boxel-fuschia)));
326
+ color: var(--attention-ink);
190
327
  font-weight: 600;
191
328
  }
192
329
  .pretui-workitem-status[data-tone='failed'] {
193
- color: var(--pretui-destructive-ink, var(--boxel-danger));
330
+ color: var(--destructive-ink);
194
331
  font-weight: 500;
195
332
  }
196
333
  .pretui-activity {
197
334
  font-family: var(--font-mono);
198
- font-size: var(--text-ui-xs, 11px);
199
- color: var(--ink-3, var(--boxel-400));
335
+ font-size: var(--boxel-font-size-2xs);
336
+ color: var(--subtle-foreground);
200
337
  white-space: nowrap;
201
338
  overflow: hidden;
202
339
  text-overflow: ellipsis;
@@ -0,0 +1,136 @@
1
+ # Scroll anchoring: one protocol for where the reader's view stays
2
+
3
+ Status: built in StickToBottom (`settle()` and `navigate()` on its API), with seeded property tests in `stick-to-bottom.test.gts`. [ADR 012](../../../docs/adr/012-hold-one-scroll-anchor-per-change-chosen-by-priority-and-read-from-layout.md) states its rules. The settle phase in Choreo's render-pass barrier (below) is not built yet.
4
+
5
+ ## The problem
6
+
7
+ A transcript changes height all the time, for many causes:
8
+
9
+ - a reply streams;
10
+ - a row's steps grow;
11
+ - a turn folds or unfolds;
12
+ - a question collapses into its answer;
13
+ - an image or card loads;
14
+ - the cap above the message box grows;
15
+ - the reader opens a disclosure;
16
+ - a motion layer moves things with transforms while their layout has already moved.
17
+
18
+ Every one of these can move what the reader is looking at. A scroll owner can answer them with one rule per cause:
19
+
20
+ - following pins the end;
21
+ - a detached reader's top element is put back after a resize;
22
+ - a prepend keeps the first row still;
23
+ - a press in the content counts as the reader's for 500 ms;
24
+ - an upward scroll with no input re-pins when the heights changed;
25
+ - the app has its own scroll-into-view branches.
26
+
27
+ Each rule is right on its own, but they don't compose. When two causes land in one frame, which rule wins depends on event order: a click that collapses a question while a reply streams, or a turn that folds while the cap grows. That's the yank the reader sees.
28
+
29
+ The fix is one invariant, held by one owner, at one moment in the frame.
30
+
31
+ ## The invariant
32
+
33
+ > In every render pass, one element is the **anchor**, and its top edge keeps the same distance from the viewport's top, by layout, from before the pass to after it. The one exception: when the anchor is the **end** (following), the content's bottom edge keeps its distance from the viewport's bottom.
34
+
35
+ Everything else follows from that:
36
+
37
+ - Content that changes below the anchor moves nothing the reader sees above it.
38
+ - Content that changes above the anchor is paid for with a scroll correction of exactly the anchor's layout shift.
39
+ - What moves on screen is only what changed between the anchor and the edge the change happened on. That's what the motion layer then animates.
40
+
41
+ This is an algorithm, not a set of heuristics. The work is choosing the anchor well and correcting at the right moment.
42
+
43
+ ## Choosing the anchor
44
+
45
+ Each pass picks the first of these that holds:
46
+
47
+ | Priority | Anchor | Holds when | Why |
48
+ | -------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
49
+ | 1 | **Navigation target** | the app asked to show something: the jump chip, "go to the waiting row", `reveal(id)`, a Quick Look closing back to its source; or focus moved into the content other than by a pointer press (Tab, a skip link, the app's `focus()`), which the browser scrolls into view | the app's intent is explicit; once it arrives, the target becomes the reading anchor |
50
+ | 2 | **Pressed element** | the reader pressed something in the content (pointer, or Enter or Space; focus that code moves does not count) less than 2 s ago and hasn't scrolled since: a toggle, a choice, Undo, a disclosure | what they touched stays under their pointer, so an expand grows downward from it and a collapse shrinks toward it (macOS disclosure behaviour) |
51
+ | 3 | **End** | following: the reader is at the end and hasn't left it by their own input | new content arrives below, and the end stays in view |
52
+ | 4 | **Reading line** | otherwise | the element crossing a line about a third of the way down the viewport, with its offset from that line: where eye-tracking puts a reader's attention, steadier than the top edge, which half-hidden elements cross |
53
+
54
+ Priority 2 outranks 3 on purpose. A reader at the end who opens a fold shouldn't be pulled to the new end; they're reading what they opened. The pressed element is the anchor, not a timer that guesses.
55
+
56
+ **What counts as an element.** Anchor candidates are the elements marked `data-scroll-anchor`: messages, rows, steps, answer blocks. Picking the deepest marked element that crosses the line keeps the anchor small enough to stay still while its parent grows around it (a message that streams below the step being read). In unmarked content, the anchor is the smallest element the line crosses.
57
+
58
+ **Where the reader is.** Following stops on:
59
+
60
+ - the reader's scroll input: a wheel, a touch, a scrolling key (also when its target is the body, as after a click on text), a press on the scrollbar;
61
+ - a pressed or focused anchor (priority 1 or 2) that grows the content past the end: the reader caused it, and is reading what grew;
62
+ - `navigate()` or `reveal()`;
63
+ - a scroll the component did not write and no layout change caused (find in page, a stray `scrollIntoView`): it is taken as the reader's, because the next settle would otherwise pull it back to the end.
64
+
65
+ A layout change alone (content shrank, the viewport grew, a clamp) never stops following; the clamp is corrected. Following starts again when a scroll reaches the end, or by `scrollToEnd()`.
66
+
67
+ ## When to correct: inside the render pass, before motion measures
68
+
69
+ The correction must land after the DOM for the pass is in, and before anything measures or paints. StickToBottom settles in the render commit of a change it is told about (`@revision`, `@active`), and on resize. A host with a motion layer calls `settle()` after the pass commits and before the motion layer measures.
70
+
71
+ Choreo's render-pass barrier (`far.ts`) has that moment: `runBarrier()` calls `beforeMeasure()` (Choreo's own mount flush), then every region's `measurePass()`, then MATCH and RUN. The plan adds a **SETTLE** phase there (not built yet):
72
+
73
+ ```
74
+ render pass commits DOM
75
+ └─ Choreo barrier (afterRender)
76
+ 0. MOUNT flush pending mounts (exists)
77
+ 1. SETTLE every scroll owner holds its anchor (planned)
78
+ 2. MEASURE every region builds its changeset (exists)
79
+ 3. MATCH far matching across regions (exists)
80
+ 4. RUN every region plays its score (exists)
81
+ └─ browser paints
82
+ ```
83
+
84
+ The protocol, per scroll owner:
85
+
86
+ 1. **Record (end of every pass, and on every reader scroll):** the anchor element (by the priority table) and its offset in the viewport, by layout: `layoutTop(anchor) − scrollTop`. For the end anchor, record `scrollHeight − scrollTop − clientHeight`.
87
+ 2. **Settle (phase 1 of the next pass):** read the anchor's new layout top and correct the position as below. Then re-record. If the anchor element left the DOM, fall back to its nearest surviving marked ancestor.
88
+ - Normally the recorded offset is held: `scrollTop += layoutTop(anchor) − scrollTop − recorded`. This also undoes a clamp when content shrank. For the end anchor, pin the end.
89
+ - While the reader scrolls (their input, or a scroll that follows it such as momentum, within a short window), only what layout moved is held: `scrollTop += layoutTop(anchor) − recordedLayoutTop`. A settle can run between their scroll and its scroll event, and holding the offset would take their step back.
90
+ - While the component's own smooth scroll is in flight, nothing is written: the smooth scroll's target moves by `layoutTop(anchor) − recordedLayoutTop`, or to the new end while following, so the scroll is not cut short.
91
+ 3. **Measure (phase 2):** Choreo measures in the scroller's viewport space after the correction. An element that holds its place on screen reads as not moved and gets no animation. Only what really moved on screen gets a FLIP, from its painted position before to its new one.
92
+
93
+ **Layout, never paint.** The anchor's position is read from layout offsets (an `offsetTop` chain), which a running FLIP's transforms don't move. Choreo works from painted positions for its starts; the anchor works from layout for its correction. The two never fight: the correction is a layout fact, and the motion is drawn on top of it.
94
+
95
+ **One pass, one clock.** Every cause that lands in the same frame is settled once, together, against one anchor. A click that collapses a question while a reply streams is one correction, not two that race.
96
+
97
+ **Outside a render pass.** Some changes don't come from a render: an image decodes, fonts swap, the viewport resizes. The scroll owner's ResizeObserver runs the same settle step on its own (`onResize`), and the next pass's motion sees the result.
98
+
99
+ ## Architecture
100
+
101
+ | Piece | Where | Does |
102
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
103
+ | `ScrollCoordinator` | Pret UI, inside StickToBottom (one per scroller) | holds the mode (follow or read), the navigation target and the pressed element with their times, and the recorded anchor; runs `record()` and `settle()` |
104
+ | Anchor marks | `data-scroll-anchor` on messages, rows, steps, answer blocks | bounds anchor candidates; no runtime cost |
105
+ | Reader input | the existing wheel, touch, key and scrollbar listeners | the only path that changes the mode |
106
+ | Navigation API | `api.reveal(id)`, `api.scrollToEnd()`, `api.navigate(element, block)`, instead of the app's own `scrollIntoView` calls; focus moved into the content | sets priority 1 |
107
+ | Press tracking | `pointerdown`, and Enter or Space, in the content | sets priority 2 (element and time) |
108
+ | SETTLE phase | planned: Choreo's barrier, run after MOUNT and before MEASURE | calls each live coordinator's `settle()` |
109
+ | Fallback | the coordinator's ResizeObserver | runs `settle()` for changes outside a render pass |
110
+
111
+ Prepend is "anchor = reading line" with nothing special about it. Send is "mode = follow". The jump chip is a navigation.
112
+
113
+ **ADR 007 fit.** Scroll position is state, and it's settled before motion measures, so state never waits on motion (§1.1). One engine reacts (§1.2). ADR 012 records this as a decision.
114
+
115
+ ## Proving it
116
+
117
+ The invariant is testable without a person watching:
118
+
119
+ - **Property test** (kit, in the browser). Build a transcript of random rows. Apply random batches of operations in one pass each: insert above, below or inside the anchor; grow or shrink any row; fold or unfold; resize the viewport; press a random element first; scroll by input or by API. After every pass, assert the anchor's viewport offset is unchanged within 0.5 px, the end is pinned exactly when following, and the mode changed only on input. Run a few thousand seeded cases; a failure prints its seed and its operation list.
120
+ - **Motion check.** With Choreo regions on, the same sequences assert that no element's painted top jumps more than its own layout delta in one frame (a probe of each element's painted top, made a test helper), and that `orphanCount()` is 0 after interruptions.
121
+ - **Live probe.** A follow-gap sampler (every 100 ms: mode, gap, heights) as a dev-only overlay, so a reader reporting "it yanked" can turn it on and send the trace.
122
+
123
+ ## Build order
124
+
125
+ 1. **Coordinator in StickToBottom**, with the priority table, anchor marks and property tests. Built.
126
+ 2. **SETTLE phase in Choreo**, so the coordinator settles in it when a region is live and motion animates only what moved on screen. Not built. This is a Choreo change, so its PR title takes a conventional-commit prefix (`feat(choreo): …`).
127
+ 3. **The app's own scrolling moves onto the API** (`navigate`, `scrollToEnd`). Not in this package.
128
+ 4. **ADR**: ADR 012, proposed.
129
+
130
+ ## Open questions
131
+
132
+ - **The reading line.** One third of the way down is a guess from reading research. It should be a token, tuned by watching where readers' last presses and selections land.
133
+ - **Two scrollers.** When one scroller is inside another (a code block or table inside the transcript), the inner one settles first, then the outer. The barrier runs scroll owners innermost first.
134
+ - **Smooth navigation.** A smooth `navigate` spans several frames. The coordinator moves the smooth scroll's target by what layout moved and writes no position while it is in flight. Whether a settle should also re-aim at the target element when the target's own height changes is open.
135
+
136
+ [ADR 012](../../../docs/adr/012-hold-one-scroll-anchor-per-change-chosen-by-priority-and-read-from-layout.md) numbers the rules this protocol keeps. A change to scroll or follow behaviour updates ADR 012 in the same change.