amberfork-layout 1.0.0

The semantic view-model between amberfork's DiffResult and its painters (CLI, web).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
      /* DESIGN.md tokens — dark is the primary/default theme (DD5); light follows the OS via
         prefers-color-scheme (#31), no manual toggle. Fonts are the metrics-compatible
         fallbacks; the latin-subset Monaspace/General Sans woff2 land in a later slice with
         no layout shift. */
      :root {
        color-scheme: dark;
        --bg: #0a0a0b;
        --surface: #121214;
        --raised: #17171b;
        --hair: #26262b;
        --text: #e6e6e9;
        --muted: #8a8a93;
        --faint: #55555c;
        --amber: #ff7a1a;
        --warning: #f5a623;
        --error: #ff5c5c;
        --error-bg: #ff5c5c14;
        --success: #46d39a;
        --success-bg: #46d39a14;
        --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
        --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
      }

      /* Light palette (DESIGN.md "Light mode"). --raised coincides with --surface (both read
         as the elevated/white layer against the --bg canvas — the dark scale's bg<surface<raised
         progression has no headroom above white). --faint has no explicit light spec; it's
         decorative-only (DD4), so it's picked for a low, ~faint-in-dark contrast against --bg
         rather than lifted from the doc. */
      @media (prefers-color-scheme: light) {
        :root {
          color-scheme: light;
          --bg: #f7f7f5;
          --surface: #ffffff;
          --raised: #ffffff;
          --hair: #e2e2dd;
          --text: #16161a;
          --muted: #6b6b72;
          --faint: #a3a39c;
          --amber: #e0570b;
          --error: #c7382f;
          --error-bg: #c7382f14;
          --success: #1e9e6a;
          --success-bg: #1e9e6a14;
        }
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        height: 100vh; /* exact viewport height so the CANVAS scrolls, never the whole page */
        display: flex;
        flex-direction: column;
        overflow: hidden;
        background: var(--bg);
        color: var(--text);
        font-family: var(--sans);
        font-size: 0.875rem;
        line-height: 1.5;
        -webkit-font-smoothing: antialiased;
      }

      /* The two-pane body: canvas flexes, attribution holds a fixed 320px on the right. Fills
         the height below the header so the pane's border-left runs the full column. */
      .body {
        display: flex;
        flex: 1;
        min-height: 0;
      }

      /* --- The app shell: never a blank page before wasm mounts (D20). The boot node is
             removed by the csr entry once the app is alive; if it survives, boot failed and
             the error state below is revealed in its place. --- */
      .boot {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 0.5rem;
        min-height: 100vh;
        padding: 2rem;
        text-align: center;
        color: var(--muted);
      }

      .boot-glyph {
        font-family: var(--mono);
        font-size: 1.5rem;
        color: var(--faint);
      }

      .boot p {
        max-width: 34rem;
      }

      .boot-hint {
        color: var(--muted);
        font-size: 0.8125rem;
      }

      .boot code {
        font-family: var(--mono);
        color: var(--text);
      }

      .boot--error .boot-glyph {
        color: var(--warning);
      }

      [hidden] {
        display: none !important;
      }

      @media (prefers-reduced-motion: no-preference) {
        .boot-glyph {
          animation: boot-pulse 1.6s ease-in-out infinite;
        }
      }

      @keyframes boot-pulse {
        0%,
        100% {
          opacity: 0.55;
        }
        50% {
          opacity: 1;
        }
      }

      /* --- Header bar. Mono, compact, quiet: the verdict is the protagonist through the
             `text` token and its position, not color. Zero amber in the header frame. --- */
      .hdr {
        display: flex;
        align-items: baseline;
        gap: 1rem;
        padding: 0.5rem 0.875rem;
        border-bottom: 1px solid var(--hair);
        background: var(--surface);
        font-family: var(--mono);
        font-size: 0.75rem;
      }

      .logo {
        color: var(--text);
        font-weight: 700;
        letter-spacing: -0.01em;
      }

      .logo-glyph {
        color: var(--muted); /* never amber — amber is spent only in the canvas */
      }

      .pair {
        color: var(--text);
      }

      .pair b {
        font-weight: 600;
      }

      .role {
        color: var(--muted);
      }

      .vs {
        color: var(--faint);
      }

      .verdict {
        color: var(--text);
        font-variant-numeric: tabular-nums;
        text-decoration: none;
      }

      .verdict--fork:hover {
        text-decoration: underline;
        text-underline-offset: 3px;
      }

      .verdict:focus-visible {
        outline: 1px solid var(--hair);
        outline-offset: 2px;
        border-radius: 2px;
      }

      .meta {
        margin-left: auto;
        color: var(--muted);
        font-variant-numeric: tabular-nums;
      }

      /* --- The alignment canvas: side-by-side runs on the shared spine. Sameness recedes
             (muted), divergence glows (amber on the fork + every downstream row). Amber is
             spent here and nowhere else. DOM rows carry selectable text; the SVG overlay
             draws the timeline rail, the amber divergent-path segment, and the fork node,
             both keyed to the 30px row pitch so they line up. --- */
      .canvas {
        flex: 1;
        min-width: 0; /* let the flex child shrink so its own overflow engages */
        min-height: 12rem;
        overflow: auto; /* the canvas is the scroll container — header + pane stay fixed */
      }

      /* Fills the canvas viewport (dotted field), with the bounded rows left-anchored on top.
         When the rows are wider than the canvas the canvas alone scrolls (DD4 canvas-only
         horizontal scroll) — the header and attribution pane never move. */
      .track {
        position: relative;
        min-height: 100%;
        background-image: radial-gradient(circle, var(--hair) 1px, transparent 1px);
        background-size: 22px 22px;
      }

      .spine {
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
      }
      .spine-rail {
        stroke: var(--faint); /* the quiet shared timeline */
        stroke-width: 1;
      }
      .spine-path {
        stroke: var(--amber); /* the divergent path made literal; the ignition beat animates it */
        stroke-width: 2;
      }
      .fork-node {
        fill: var(--amber);
      }

      .rows {
        list-style: none;
        width: fit-content; /* hug the two bounded runs; the fork band never spans dead space */
        margin-left: 28px; /* clear the 28px SVG spine strip */
        padding: 18px 0; /* 18px top pad matches the geometry's TOP_PAD */
      }

      /* Fixed side columns, not 1fr: the two runs sit adjacent as one comparison instead of
         stretching to opposite edges, and the layout anchors left — leaving the right open for
         the attribution pane (slice 2). Same template on every row keeps the columns aligned. */
      .row {
        display: grid;
        grid-template-columns: 104px 300px 300px auto;
        align-items: center;
        column-gap: 18px;
        height: 30px; /* == ROW_H; the SVG overlay is computed from the same pitch */
        padding: 0 14px;
        font-family: var(--mono);
        font-size: 0.75rem;
        color: var(--muted);
        font-variant-numeric: tabular-nums;
        cursor: pointer;
        scroll-margin-top: 96px; /* auto-scroll lands the fork in the upper third, not the edge */
      }
      /* Hover tint (never on the selected row) and a clear focus-visible ring. The ring is a
         box-shadow, not an outline, so it never overrides the fork's dashed amber cue. */
      .row:hover:not(.row--selected) {
        background: var(--surface);
      }
      .row:focus-visible {
        outline: none;
        box-shadow: inset 0 0 0 2px var(--muted);
      }

      .gutter {
        display: flex;
        align-items: baseline;
        gap: 8px;
      }
      .cue {
        width: 0.9em;
        display: inline-block;
        text-align: center;
        color: var(--faint);
      }
      .idx {
        color: var(--muted);
      }

      .cell {
        display: flex;
        align-items: baseline;
        gap: 8px;
        min-width: 0;
      }
      .kind {
        width: 40px;
        color: var(--faint);
      }
      .name {
        width: 104px;
        color: var(--muted);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .sum {
        flex: 1;
        min-width: 0;
        color: var(--muted);
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      /* The honest cut mark (issue #17) and, when the slot carries a resolvable address, a real
         click-to-expand affordance (issue #30) — same element, same class, so idle appearance
         never changes: only interaction reveals whether there's anything behind it to fetch.
         Never amber (DESIGN.md) — this is not divergence, and must not compete with the fork's
         one scarce accent. */
      .slot-trunc {
        border: none;
        background: none;
        padding: 0;
        margin: 0;
        font: inherit;
        color: var(--muted);
      }
      button.slot-trunc {
        cursor: pointer;
        transition: color 150ms ease-out;
      }
      button.slot-trunc:hover {
        color: var(--text);
        text-decoration: underline dotted;
      }
      button.slot-trunc:focus-visible {
        color: var(--text);
        text-decoration: underline dotted;
        outline: 2px solid var(--muted);
        outline-offset: 1px;
      }
      button.slot-trunc:disabled {
        cursor: default;
        opacity: 0.6;
        text-decoration: none;
      }
      @media (prefers-reduced-motion: reduce) {
        button.slot-trunc {
          transition: none;
        }
      }

      /* Divergence glows: the fork and every downstream row in one uniform amber (no intensity
         grading — one scarce accent). The dashed outline is the fork's redundant non-color cue;
         `outline` never shifts layout, so the row stays 30px and the SVG stays aligned. */
      .row--fork {
        color: var(--amber);
        background: rgba(255, 122, 26, 0.05);
        border-radius: 4px;
        outline: 1px dashed var(--amber);
        outline-offset: -1px;
      }
      .row--down {
        color: var(--amber);
      }
      .row--fork .idx,
      .row--fork .kind,
      .row--fork .name,
      .row--fork .sum,
      .row--fork .cue,
      .row--down .idx,
      .row--down .kind,
      .row--down .name,
      .row--down .sum,
      .row--down .cue {
        color: var(--amber);
      }
      .tag {
        justify-self: end;
        white-space: nowrap; /* the [FORK · conf] tag stays on the fork's single 30px row */
        color: var(--amber);
        font-variant-numeric: tabular-nums;
      }

      /* Selection is the neutral frame: raised surface + a hairline ring (inset box-shadow, so
         no layout shift). Never amber (DD2) — the default-selected fork keeps its amber role via
         `row--fork` and only gains the raised surface + hairline on top. Placed after the role
         rules so its raised background wins over the fork's amber tint. */
      .row--selected {
        background: var(--raised);
        box-shadow: inset 0 0 0 1px var(--hair);
      }

      /* --- Motion: the ONE expressive beat (DESIGN.md §Motion). On load the amber ignites at
             the fork node and flows down the divergent path — the fork node pops, the amber
             spine segment draws downward from the fork, and the divergent rows kindle to full
             amber. Medium 250–400ms, ease-out (enter easing); the beat spans 0–380ms. Each
             animation's `to` state IS the static markup, so with `prefers-reduced-motion:
             reduce` this whole block is off and the lit end state simply shows with no motion.
             The glow is the saturated stroke — no blur halo. Fires on load; slice 3a already
             scrolls the fork into view first, so the beat plays where the eye is. --- */
      @media (prefers-reduced-motion: no-preference) {
        .fork-node {
          transform-box: fill-box;
          transform-origin: center;
          animation: fork-ignite 200ms ease-out both;
        }
        .spine-path {
          transform-box: fill-box;
          transform-origin: top; /* the fork end: the segment runs fork_y → y_last */
          animation: path-flow 300ms ease-out 80ms both;
        }
        .row--fork,
        .row--down {
          animation: row-kindle 300ms ease-out 80ms both;
        }
      }

      @keyframes fork-ignite {
        from {
          opacity: 0;
          transform: scale(0.2);
        }
        to {
          opacity: 1;
          transform: scale(1);
        }
      }
      @keyframes path-flow {
        from {
          transform: scaleY(0);
        }
        to {
          transform: scaleY(1);
        }
      }
      @keyframes row-kindle {
        from {
          opacity: 0.4;
        }
        to {
          opacity: 1;
        }
      }

      /* --- Attribution pane: the diff's one answer in DR5 order. No amber, no red/green — a
             statement ABOUT the divergence, not the divergence itself. --- */
      .attr {
        flex: 0 0 320px;
        border-left: 1px solid var(--hair);
        background: var(--surface);
        padding: 16px 18px;
        font-family: var(--mono);
        font-size: 0.75rem;
        overflow-y: auto;
      }
      .attr-title {
        margin-bottom: 14px;
        font-size: 0.625rem;
        font-weight: 600;
        text-transform: uppercase;
        letter-spacing: 0.08em;
        color: var(--muted);
      }
      .attr-list {
        display: flex;
        flex-direction: column;
        gap: 10px;
      }
      .attr-row {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: 14px;
      }
      .attr-row dt {
        color: var(--muted);
      }
      .attr-row dd {
        color: var(--text);
        text-align: right;
        font-variant-numeric: tabular-nums;
      }
      .attr-empty {
        color: var(--muted);
        line-height: 1.6;
      }

      /* A division of the SAME pane (issue #40), not a new one — a hairline divider plus a
             heading-level step-down (h3, still styled as .attr-title) carry the hierarchy, so
             this reads as "more of the same voice," never a competing header. */
      .attr-section {
        margin-top: 20px;
        padding-top: 16px;
        border-top: 1px solid var(--hair);
      }
      .attr-section .attr-title {
        margin-bottom: 10px;
      }

      /* --- Content-diff pane: the selected pair's field-level -/+ evidence (issue #27). The
             ONLY red/green in any surface (DESIGN.md containment) — removed red, added green,
             kept deliberately distinct from the amber fork. Lives inside the attribution aside,
             below the answer; a card per field, monospace, values wrapping so long payloads stay
             readable and copyable (never a canvas draw). --- */
      .content-diff,
      .content-diff-empty {
        margin-top: 18px;
        padding-top: 14px;
        border-top: 1px solid var(--hair);
      }
      .content-diff {
        display: flex;
        flex-direction: column;
        gap: 12px;
      }
      .content-diff-empty {
        color: var(--muted);
        line-height: 1.6;
      }
      .content-diff-field {
        display: flex;
        flex-direction: column;
        gap: 3px;
      }
      .content-diff-path {
        color: var(--muted);
        font-size: 0.6875rem;
      }
      .content-diff-del,
      .content-diff-add {
        display: flex;
        gap: 6px;
        padding: 2px 6px;
        border-radius: 3px;
        white-space: pre-wrap;
        overflow-wrap: anywhere;
      }
      .content-diff-del {
        color: var(--error);
        background: var(--error-bg);
      }
      .content-diff-add {
        color: var(--success);
        background: var(--success-bg);
      }
      .content-diff-sign {
        flex: 0 0 auto;
        font-variant-numeric: tabular-nums;
        opacity: 0.85;
      }
      .content-diff-val {
        flex: 1;
        min-width: 0;
      }
      /* The copy affordance (issue #27 evidence-out). Quiet neutral at rest — buttons are never
         amber (DESIGN.md), and it must not compete with the card's scarce red/green, so the
         confirmed state stays neutral too. Sits top-right of the card; hover = surface tint;
         focus-visible = hairline ring, matching the canvas rows. */
      .content-diff-copy {
        align-self: flex-end;
        margin-bottom: -4px;
        padding: 2px 8px;
        border: 1px solid var(--hair);
        border-radius: 4px;
        background: var(--surface);
        color: var(--muted);
        font-family: var(--mono);
        font-size: 0.6875rem;
        cursor: pointer;
        transition:
          color 150ms ease-out,
          background 150ms ease-out,
          border-color 150ms ease-out;
      }
      .content-diff-copy:hover {
        background: var(--raised);
        color: var(--text);
        border-color: var(--muted);
      }
      .content-diff-copy:focus-visible {
        outline: 2px solid var(--muted);
        outline-offset: 1px;
      }
      @media (prefers-reduced-motion: reduce) {
        .content-diff-copy {
          transition: none;
        }
      }

      /* --- Disconnect banner: the server that fed this view stopped (D-decision 2026-07-12).
             A slim strip docked at the bottom edge, `warning` — never amber: a system-status
             message is not a divergence. Fixed, so it annunciates without reflowing the canvas;
             no spinner — the state is terminal until the user restarts + reloads. --- */
      .banner {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        padding: 0.5rem 0.875rem;
        border-top: 1px solid var(--warning);
        background: rgba(245, 166, 35, 0.08); /* faint warning wash over the surface */
        font-family: var(--mono);
        font-size: 0.75rem;
        color: var(--warning);
      }

      .banner code {
        color: var(--text); /* the restart command reads as copy-ready, brighter than the label */
      }