@aparte/core 0.16.7 → 0.16.9

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 (77) hide show
  1. package/dist/client/aparte-client.d.ts +57 -2
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/lifecycle-events.d.ts +8 -1
  4. package/dist/client/lifecycle-events.d.ts.map +1 -1
  5. package/dist/client/stream-adapter.d.ts +8 -0
  6. package/dist/client/stream-adapter.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +39 -18
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/chat/aparte-chat.d.ts +32 -25
  10. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  11. package/dist/components/composer/aparte-composer-action.d.ts +1 -1
  12. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  13. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  14. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-cancel.d.ts +2 -2
  16. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-input.d.ts +6 -6
  18. package/dist/components/composer/aparte-composer-send.d.ts +3 -3
  19. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer.d.ts +9 -2
  21. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  22. package/dist/components/context/aparte-context.d.ts +0 -1
  23. package/dist/components/context/aparte-context.d.ts.map +1 -1
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts +11 -6
  25. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  26. package/dist/components/elicitation/aparte-elicitation.d.ts +20 -18
  27. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  28. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +11 -3
  29. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  30. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -2
  31. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  32. package/dist/components/split/aparte-split.d.ts +9 -4
  33. package/dist/components/split/aparte-split.d.ts.map +1 -1
  34. package/dist/components/status/aparte-chat-status.d.ts +44 -5
  35. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  36. package/dist/components/viewport/aparte-chat-viewport.d.ts +27 -4
  37. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  38. package/dist/config/icon-provider.d.ts +6 -0
  39. package/dist/config/icon-provider.d.ts.map +1 -1
  40. package/dist/config/locale.d.ts +3 -1
  41. package/dist/config/locale.d.ts.map +1 -1
  42. package/dist/custom-elements.json +13010 -12737
  43. package/dist/{dialog-triggers-Dw_DIlQ7.js → dialog-triggers-CApC7K1I.js} +119 -44
  44. package/dist/dialog-triggers-CApC7K1I.js.map +1 -0
  45. package/dist/elicitation/panel.d.ts.map +1 -1
  46. package/dist/generated/element-attributes.d.ts +13 -3
  47. package/dist/generated/element-attributes.d.ts.map +1 -1
  48. package/dist/{glyphs-DQTeOj4w.js → glyphs-C8z2oJUg.js} +36 -27
  49. package/dist/{glyphs-DQTeOj4w.js.map → glyphs-C8z2oJUg.js.map} +1 -1
  50. package/dist/icons/extended.d.ts +0 -6
  51. package/dist/icons/extended.d.ts.map +1 -1
  52. package/dist/icons/glyphs.d.ts +13 -4
  53. package/dist/icons/glyphs.d.ts.map +1 -1
  54. package/dist/icons.js +28 -31
  55. package/dist/icons.js.map +1 -1
  56. package/dist/index.css +946 -386
  57. package/dist/index.js +321 -89
  58. package/dist/index.js.map +1 -1
  59. package/dist/index.node.js +1 -1
  60. package/dist/interop/element-props.d.ts.map +1 -1
  61. package/dist/primitives/icon/aparte-icon.d.ts +1 -1
  62. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +2 -3
  63. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -1
  64. package/dist/primitives/select/aparte-optgroup.d.ts +1 -1
  65. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  66. package/dist/primitives/select/aparte-option.d.ts +4 -4
  67. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  68. package/dist/primitives/select/aparte-select.d.ts +43 -12
  69. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  70. package/dist/renderers/segments/code.d.ts.map +1 -1
  71. package/dist/renderers/segments/error.d.ts.map +1 -1
  72. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  73. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  74. package/dist/types/events.d.ts +4 -2
  75. package/dist/types/events.d.ts.map +1 -1
  76. package/package.json +2 -2
  77. package/dist/dialog-triggers-Dw_DIlQ7.js.map +0 -1
package/dist/index.css CHANGED
@@ -183,7 +183,6 @@
183
183
  below (it was a frozen plum hex that outlived every rebrand); the assistant is
184
184
  plain prose (AI-chat convention) — flip it to get a two-bubble chat-app look. */
185
185
  --aparte-message-content-bg-assistant: transparent;
186
- --aparte-message-content-radius: 14px;
187
186
 
188
187
  /* Aliases used by some segment renderers (artifact card, tool pill).
189
188
  Mapped to existing tokens so consumers that override --aparte-surface-1/2
@@ -206,8 +205,6 @@
206
205
  --aparte-thumb-remove-bg-hover: rgba(0, 0, 0, 0.85);
207
206
  --aparte-thumb-remove-color: #ffffff;
208
207
 
209
- /* Action Bar */
210
- --aparte-action-bar-btn-size: 28px;
211
208
  /* The mark (display/mark.css): how much of its intent a marked row's ground takes,
212
209
  and how wide the bar on its start edge is. One pair for the select's chosen option,
213
210
  the checked field choice, the active conversation and an answered question. */
@@ -215,12 +212,16 @@
215
212
  --aparte-mark-bar: 2px;
216
213
 
217
214
  /* Branch Picker */
218
- --aparte-branch-picker-btn-size: 20px;
219
- --aparte-branch-picker-btn-icon-size: 16px;
220
215
  --aparte-branch-picker-label-min-width: 32px;
221
216
 
222
217
  /* Avatars */
223
218
  --aparte-avatar-size: 32px;
219
+ /* Fractions of the size, computed on the element (display/avatar.css): the initials'
220
+ type, the corner and the group's overlap follow the ramp instead of sitting at one
221
+ absolute value for 20px and 56px alike. */
222
+ --aparte-avatar-initials-ratio: 0.34;
223
+ --aparte-avatar-radius-ratio: 0.28;
224
+ --aparte-avatar-overlap-ratio: 0.28;
224
225
  --aparte-avatar-image-user: none;
225
226
  --aparte-avatar-image-assistant: none;
226
227
  --aparte-avatar-image-size: 90%;
@@ -230,21 +231,18 @@
230
231
  /* Input */
231
232
  --aparte-input-editor-max-height: 200px;
232
233
 
233
- /* Icons — one knob, inherited: a container declares it, every glyph below follows. */
234
- --aparte-icon-size: 14px;
235
- --aparte-icon-size-sm: 12px;
236
- --aparte-icon-size-lg: 16px;
237
- --aparte-icon-size-xl: 20px;
234
+ /* Icons: one knob, inherited — a container declares `--aparte-icon-size` and every
235
+ glyph below follows. The steps are on the type scale, in the derived layer. */
238
236
 
239
- /* Send button + inline action buttons */
240
- --aparte-send-btn-size: 36px;
241
- --aparte-input-action-btn-size: 36px;
237
+ /* Send button + inline action buttons (their size is the lg step, in the derived layer) */
242
238
  --aparte-input-action-btn-icon-size: 20px;
243
239
  --aparte-model-select-min-width: 120px;
244
240
 
245
241
  /* Status Indicator */
246
242
  --aparte-status-bg: transparent;
247
243
  --aparte-status-dot-size: 6px;
244
+ /* The floor of every pulsing dot's opacity: above the perception threshold. */
245
+ --aparte-pulse-floor: 0.55;
248
246
 
249
247
  /* Waiting Indicator (the bubble's own dots, before the first token) */
250
248
  --aparte-waiting-height: 1.5em;
@@ -252,7 +250,6 @@
252
250
  /* Thinking Segment */
253
251
  --aparte-thinking-content-max-height: 300px;
254
252
  --aparte-thinking-content-line-height: 1.55;
255
- --aparte-thinking-toggle-size: 9px;
256
253
  --aparte-thinking-rail-width: 2px;
257
254
 
258
255
  /* Elicitation — the panel a tool's question is asked in.
@@ -282,8 +279,7 @@
282
279
  --aparte-code-copy-icon-size: 14px;
283
280
  --aparte-code-inline-font-size: 0.875em;
284
281
 
285
- /* Scroll-to-bottom button */
286
- --aparte-scroll-btn-size: 36px;
282
+ /* Scroll-to-bottom button (its size is the lg step, in the derived layer) */
287
283
  --aparte-scroll-btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
288
284
 
289
285
  /* Error segment */
@@ -291,8 +287,6 @@
291
287
  --aparte-error-border: #fecaca;
292
288
  --aparte-error-solid: #dc2626;
293
289
  --aparte-error-text: #991b1b;
294
- --aparte-error-title: #7f1d1d;
295
- --aparte-error-icon-size: 20px;
296
290
  --aparte-error-rail-width: 4px;
297
291
 
298
292
  /* Prose (aparte-segment-content) */
@@ -327,21 +321,21 @@
327
321
 
328
322
  /* Input (composer) */
329
323
 
330
- /* Conversation List */
331
- --aparte-conv-action-btn-size: 20px;
332
324
 
333
325
  /* Scroll rail — the ticks beside the transcript. Off-scale on purpose: a 2px line
334
326
  and a 14px mark are a minimap's measures, not spacing steps. */
335
- --aparte-scroll-rail-width: 20px;
336
327
  --aparte-scroll-rail-tick-size: 14px;
337
328
  --aparte-scroll-rail-tick-thickness: 2px;
338
-
339
- --aparte-scroll-rail-gap: 8px;
329
+ /* What a tick is to a POINTER, as opposed to what it is to the eye: WCAG 2.5.8's
330
+ 24px minimum. The drawn line stays 14×2 — this sizes the pseudo-element around it,
331
+ and the gap below derives from it so the pitch rises with the zone. The rail is
332
+ `display: none` under `(pointer: coarse)`, so 2.5.8's 24px is the bar here and not
333
+ 2.5.5's 44px. */
334
+ --aparte-scroll-rail-hit-size: 24px;
340
335
 
341
336
  /* Context gauge, ring variant — a diameter, and a stroke in the ring's own 36-unit
342
337
  box. Literals like the rail's: a 22px ring is a toolbar's measure, not a spacing step. */
343
338
  --aparte-context-ring-size: 22px;
344
- --aparte-context-ring-stroke: 4;
345
339
 
346
340
  /* Shell — the sidebar's column, the header's row and the split's seam. */
347
341
  --aparte-sidebar-width: 260px;
@@ -353,9 +347,15 @@
353
347
  is 12px because that is a target, and it grows to the touch target on a coarse
354
348
  pointer (responsive.css). */
355
349
  --aparte-split-position: 38%;
356
- --aparte-split-min: 20rem;
350
+ /* Never more than the container has: at 320px this floor was wider than a phone, so
351
+ the primary pane took everything and the end pane was annihilated to 0. */
352
+ --aparte-split-min: min(20rem, 100%);
357
353
  --aparte-split-max: 60%;
358
- --aparte-split-handle-size: 4px;
354
+ /* The seam's TRACK — the grab zone, and what the grid reserves. The painted line
355
+ inside it is `--aparte-split-seam-width` (derived layer): one token used to size
356
+ both, so the line could not be thinned without moving the layout, and at 4px it
357
+ was four times the kit's rule with no grip — an interaction drawn as a decoration. */
358
+ --aparte-split-handle-size: 12px;
359
359
  --aparte-split-hit-area: 12px;
360
360
  /* Sizes that are not steps of a scale. Each is off-scale on purpose — a 40px
361
361
  file tile is a tile, not fourteen spacing units — so each gets a name rather
@@ -406,6 +406,9 @@
406
406
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
407
407
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
408
408
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
409
+ /* The composer laid over the transcript (overlay-composer): a thing that floats has to
410
+ be seen floating, and its ground is the transcript's own. */
411
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
409
412
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
410
413
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
411
414
 
@@ -444,11 +447,28 @@
444
447
  --aparte-skeleton-text-last-width: 60%;
445
448
  --aparte-progress-indeterminate-width: 40%;
446
449
 
450
+ /* The control height scale — four steps, and EVERY control reads one: a text
451
+ button used to have no height (23px of padding and line-height) beside 24, 29, 32,
452
+ 36 and 44px neighbours, and the 36px family (send, the input's action button, the
453
+ scroll button) lived off the scale as literals. sm is the small control (action bar,
454
+ branch arrows, a row's ⋯), md the default button, lg the field and the composer's
455
+ controls at rest, xl the large button. */
447
456
  --aparte-btn-size-sm: 24px;
448
457
  --aparte-btn-size-md: 32px;
449
- --aparte-btn-size-lg: 40px;
458
+ --aparte-btn-size-lg: 36px;
459
+ --aparte-btn-size-xl: 40px;
460
+ /* The field family's literal sizes, beside the button's: the derived knobs
461
+ (paddings, radius) sit in the anchored layer below, like the button's own. */
462
+ --aparte-field-textarea-min-height: 80px;
463
+ --aparte-checkbox-size: 18px;
464
+ --aparte-radio-size: 18px;
465
+ --aparte-radio-dot-size: 8px;
466
+ --aparte-switch-width: 40px;
467
+ --aparte-switch-height: 22px;
468
+ --aparte-switch-thumb-inset: 2px;
469
+ --aparte-range-thumb-size: 16px;
470
+ --aparte-range-track-height: 4px;
450
471
 
451
- --aparte-focus-outline-offset: -2px;
452
472
  --aparte-tool-spinner-size: 10px;
453
473
  --aparte-model-select-chevron-room: 28px;
454
474
  }
@@ -482,6 +502,9 @@
482
502
  --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
483
503
  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
484
504
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
505
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
506
+ /* Declared once at :root it darkened a dark page by 8/255: the modal state vanished. */
507
+ --aparte-scrim: rgba(0, 0, 0, 0.6);
485
508
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
486
509
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
487
510
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
@@ -491,7 +514,6 @@
491
514
  --aparte-error-border: #7f1d1d;
492
515
  --aparte-error-solid: #f87171;
493
516
  --aparte-error-text: #fca5a5;
494
- --aparte-error-title: #fecaca;
495
517
  }
496
518
  }
497
519
 
@@ -521,6 +543,10 @@
521
543
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
522
544
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
523
545
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
546
+ /* The composer laid over the transcript (overlay-composer): a thing that floats has to
547
+ be seen floating, and its ground is the transcript's own. */
548
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
549
+ --aparte-scrim: rgba(0, 0, 0, 0.35);
524
550
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
525
551
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
526
552
  --aparte-ink-l: 0.40;
@@ -528,7 +554,6 @@
528
554
  --aparte-error-border: #fecaca;
529
555
  --aparte-error-solid: #dc2626;
530
556
  --aparte-error-text: #991b1b;
531
- --aparte-error-title: #7f1d1d;
532
557
  }
533
558
 
534
559
 
@@ -568,6 +593,8 @@
568
593
  --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
569
594
  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
570
595
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
596
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
597
+ --aparte-scrim: rgba(0, 0, 0, 0.6);
571
598
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
572
599
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
573
600
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
@@ -587,7 +614,6 @@
587
614
  --aparte-error-border: #7f1d1d;
588
615
  --aparte-error-solid: #f87171;
589
616
  --aparte-error-text: #fca5a5;
590
- --aparte-error-title: #fecaca;
591
617
  }
592
618
 
593
619
  /* =========================================
@@ -653,8 +679,14 @@ aparte-chat {
653
679
  --aparte-font-size-xs: calc(0.6875rem * var(--aparte-font-scale));
654
680
  --aparte-font-size-sm: calc(0.75rem * var(--aparte-font-scale));
655
681
  --aparte-font-size-md: calc(0.8125rem * var(--aparte-font-scale));
656
- --aparte-font-size-lg: calc(0.875rem * var(--aparte-font-scale));
657
682
  --aparte-font-size-base: calc(0.9375rem * var(--aparte-font-scale));
683
+ /* Above the body, in the order the names say. `lg` used to be 0.875rem — SMALLER than
684
+ `base` — with nothing above it, so a title and a placeholder shared a size and a
685
+ card's title was smaller than its body. A scale whose name lies is worse than a
686
+ short one. */
687
+ --aparte-font-size-lg: calc(1.0625rem * var(--aparte-font-scale));
688
+ --aparte-font-size-xl: calc(1.25rem * var(--aparte-font-scale));
689
+ --aparte-font-size-2xl: calc(1.5rem * var(--aparte-font-scale));
658
690
 
659
691
  /* Elicitation and the conversation item, on the scale.
660
692
  They were literals off every step — 0.76 / 0.78 / 0.8 / 0.82rem and a `7px 10px`
@@ -680,7 +712,6 @@ aparte-chat {
680
712
  vertical half (#56) — a shorthand cannot be read by parts. */
681
713
  --aparte-message-padding-block: var(--aparte-space-8);
682
714
  --aparte-message-padding-inline: var(--aparte-space-6);
683
- --aparte-message-padding: var(--aparte-message-padding-block) var(--aparte-message-padding-inline);
684
715
  --aparte-message-content-padding: var(--aparte-space-5) var(--aparte-space-7);
685
716
  --aparte-action-bar-gap: var(--aparte-space-2);
686
717
  --aparte-branch-picker-gap: var(--aparte-space-2);
@@ -694,17 +725,23 @@ aparte-chat {
694
725
  --aparte-viewport-padding: var(--aparte-space-8);
695
726
  --aparte-scrollbar-thumb: var(--aparte-neutral);
696
727
  --aparte-input-padding-x: var(--aparte-space-6);
697
- --aparte-input-padding-y: var(--aparte-space-5);
728
+ /* The editor at rest is the control height: half of what the control size leaves once
729
+ one line of its own text is taken out. It used to be a flat 10px, which made the editor
730
+ 44px beside 36px buttons and, the row aligning at the end, sat the send button 4px
731
+ below the editor's centre. Derived from the editor's own line (1lh), so a coarse
732
+ pointer's 44 and a changed font both keep the editor and its buttons on one centre. */
733
+ --aparte-input-padding-y: calc((var(--aparte-composer-control-size) - 1lh) / 2);
698
734
  --aparte-conv-list-gap: var(--aparte-space-1);
699
735
  --aparte-conv-item-gap: var(--aparte-space-3);
700
- --aparte-elic-option-radius: var(--aparte-radius-lg);
736
+ --aparte-elic-option-radius: var(--aparte-radius-md);
701
737
  --aparte-elic-input-radius: var(--aparte-radius-md);
702
738
  --aparte-tool-row-radius: var(--aparte-radius-md);
703
- --aparte-avatar-font-size: var(--aparte-font-size-lg);
704
- --aparte-name-font-size: var(--aparte-font-size-lg);
739
+ /* The split's painted seam: the kit's rule, inside a 12px track. */
740
+ --aparte-split-seam-width: var(--aparte-border-width);
741
+ --aparte-name-font-size: var(--aparte-font-size-md);
705
742
  --aparte-timestamp-font-size: var(--aparte-font-size-sm);
706
743
  --aparte-branch-picker-label-size: var(--aparte-font-size-sm);
707
- --aparte-input-font-size: var(--aparte-font-size-lg);
744
+ --aparte-input-font-size: var(--aparte-font-size-base);
708
745
  --aparte-content-font-size: var(--aparte-font-size-base);
709
746
  --aparte-input-editor-font-size: var(--aparte-font-size-base);
710
747
  --aparte-status-font-size: var(--aparte-font-size-md);
@@ -717,6 +754,9 @@ aparte-chat {
717
754
  moves --aparte-space-unit or --aparte-font-scale carries them along. */
718
755
  /* A tooltip is an INVERTED surface: dark on a light theme, light on a dark one.
719
756
  Reading the text pair gives exactly that, and for free — no third palette. */
757
+ /* The distance between a placed tooltip (`data-side`) and its anchor: the arrow's
758
+ half-diagonal, so the tip lands on the trigger rather than 2px short of it. */
759
+ --aparte-tooltip-gap: var(--aparte-space-3);
720
760
  --aparte-tooltip-bg: var(--aparte-text);
721
761
  --aparte-tooltip-color: var(--aparte-text-inverse);
722
762
 
@@ -752,6 +792,14 @@ aparte-chat {
752
792
  --aparte-alert-padding: var(--aparte-space-4) var(--aparte-space-5);
753
793
  --aparte-alert-radius: var(--aparte-radius-md);
754
794
  --aparte-alert-rail-width: var(--aparte-space-1);
795
+ /* The icon scale, in rem times the type factor — the one scale in the theme that
796
+ was pinned in px while every type size followed `--aparte-font-scale`, so a glyph
797
+ beside text shrank optically the moment a reader enlarged the text. The loose
798
+ default is 16 (it was 14, and its documentation already said 16). */
799
+ --aparte-icon-size: calc(1rem * var(--aparte-font-scale));
800
+ --aparte-icon-size-sm: calc(0.75rem * var(--aparte-font-scale));
801
+ --aparte-icon-size-lg: calc(1.125rem * var(--aparte-font-scale));
802
+ --aparte-icon-size-xl: calc(1.25rem * var(--aparte-font-scale));
755
803
  --aparte-alert-icon-size: var(--aparte-space-8);
756
804
  --aparte-alert-dismiss-size: var(--aparte-btn-size-sm);
757
805
  --aparte-alert-title-font-size: var(--aparte-font-size-md);
@@ -759,8 +807,7 @@ aparte-chat {
759
807
 
760
808
  --aparte-avatar-fallback-bg: var(--aparte-surface-3);
761
809
  --aparte-avatar-fallback-color: var(--aparte-text);
762
- --aparte-avatar-group-overlap: var(--aparte-space-3);
763
- --aparte-avatar-group-ring-width: var(--aparte-border-width);
810
+ --aparte-avatar-group-ring-width: calc(var(--aparte-border-width) * 2);
764
811
  --aparte-avatar-group-ring-color: var(--aparte-surface-1);
765
812
 
766
813
  --aparte-badge-font-size: var(--aparte-font-size-2xs);
@@ -781,7 +828,7 @@ aparte-chat {
781
828
  --aparte-card-header-padding: var(--aparte-space-4) var(--aparte-space-5);
782
829
  --aparte-card-body-padding: var(--aparte-space-5);
783
830
  --aparte-card-footer-padding: var(--aparte-space-4) var(--aparte-space-5);
784
- --aparte-card-header-font-size: var(--aparte-font-size-lg);
831
+ --aparte-card-header-font-size: var(--aparte-font-size-base);
785
832
 
786
833
  --aparte-divider-margin: var(--aparte-space-5);
787
834
  --aparte-divider-label-gap: var(--aparte-space-4);
@@ -797,22 +844,62 @@ aparte-chat {
797
844
  --aparte-progress-height: var(--aparte-space-2);
798
845
  --aparte-progress-fill: var(--aparte-primary);
799
846
 
800
- --aparte-skeleton-base: var(--aparte-surface-2);
847
+ /* The ground of a gauge or a placeholder — the thing a value is read against, so it
848
+ has to be seen. Derived RELATIVE to the page, not a surface level: a level sat at
849
+ two different distances from the ground in light and dark. */
850
+ --aparte-track: color-mix(in srgb, var(--aparte-text) 8%, var(--aparte-bg));
851
+ --aparte-skeleton-base: var(--aparte-track);
852
+ /* The rail is as wide as its widest tick (the current one is 1.6× the resting size);
853
+ it used to be a 20px literal clipping a 22.4px tick. The floor is one tick's
854
+ pressable zone: the rail clips, so a column narrower than the zone would cut the
855
+ target back below 24px on the very edge a reader aims at. */
856
+ --aparte-scroll-rail-width: max(var(--aparte-scroll-rail-hit-size), calc(var(--aparte-scroll-rail-tick-size) * 1.6));
857
+ /* The distance between two ticks IS the zone minus the line: gap + thickness = the
858
+ 24px pitch, so the zones tile edge to edge. Growing the zone alone would have
859
+ overlapped them by 14px and left the z-order to decide every press. */
860
+ --aparte-scroll-rail-gap: calc(var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness));
801
861
  --aparte-skeleton-shine: var(--aparte-surface-3);
802
- --aparte-skeleton-text-height: var(--aparte-space-6);
803
- --aparte-skeleton-text-gap: var(--aparte-space-3);
862
+ /* The line it stands for: as tall as the content type, and a gap that is the rest of
863
+ the content line, so the text that replaces it lands where it was (T21). It was 12px
864
+ on an 18px step for a 16px line on a 27px step — a third of a line of jump per line. */
865
+ --aparte-skeleton-text-height: var(--aparte-content-font-size);
866
+ --aparte-skeleton-text-gap: calc(var(--aparte-content-font-size) * (var(--aparte-content-line-height) - 1));
804
867
  --aparte-skeleton-circle-size: var(--aparte-avatar-size);
805
868
 
806
869
  --aparte-spinner-thickness: var(--aparte-space-1);
807
870
 
808
- --aparte-field-error-ring: 0 0 0 var(--aparte-focus-outline-width) var(--aparte-error-solid);
871
+
872
+ /* The field family's knobs, beside the button's — they used to be declared on
873
+ `.aparte-field` itself, which is why `.aparte-field-group` (their PARENT) and
874
+ `.aparte-color` (a sibling recipe) read a property that did not exist there and
875
+ rendered SQUARE: a custom property is substituted where it is declared and only
876
+ inherits downwards. Same reason they belong here rather than there: the theming
877
+ guide sends a consumer to `:root`, and every other family's knobs are here.
878
+ The *channels* stay local on purpose (`--aparte-checkbox-intent` and friends are
879
+ filled by a modifier, not set by a theme). */
880
+ --aparte-field-padding: var(--aparte-space-2) var(--aparte-space-3);
881
+ --aparte-field-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
882
+ --aparte-field-padding-lg: var(--aparte-space-3) var(--aparte-space-4);
883
+ --aparte-field-radius: var(--aparte-radius-md);
884
+ /* The field's resting height is the lg step of the control scale, one step above the
885
+ default button and level with the composer's controls; `--sm` and `--lg` sit one
886
+ step either side. It used to be padding plus line-height, ~29px, the second
887
+ tallest thing in a row of five different heights. */
888
+ --aparte-field-size: var(--aparte-btn-size-lg);
889
+ --aparte-field-size-sm: var(--aparte-btn-size-md);
890
+ --aparte-field-size-lg: var(--aparte-btn-size-xl);
891
+ /* The 36px family, on the scale: named, not literal. */
892
+ --aparte-send-btn-size: var(--aparte-btn-size-lg);
893
+ --aparte-input-action-btn-size: var(--aparte-btn-size-lg);
894
+ --aparte-scroll-btn-size: var(--aparte-btn-size-lg);
809
895
 
810
896
  --aparte-btn-gap: var(--aparte-space-2);
811
897
  --aparte-btn-padding: var(--aparte-space-2) var(--aparte-space-3);
812
898
  --aparte-btn-radius: var(--aparte-radius-md);
813
- --aparte-btn-font-size: var(--aparte-font-size-sm);
899
+ /* Control text is the md step (14px). One step lower was the origin of the "poor"
900
+ look: 13px labels in 32px boxes, beside 15px prose. */
901
+ --aparte-btn-font-size: var(--aparte-font-size-md);
814
902
  --aparte-btn-bg-hover: var(--aparte-surface-2);
815
- --aparte-btn-focus-offset: var(--aparte-space-1);
816
903
  --aparte-btn-bg-active: var(--aparte-surface-3);
817
904
  --aparte-btn-bg-toggled: var(--aparte-surface-2);
818
905
  --aparte-btn-color-toggled: var(--aparte-text);
@@ -822,11 +909,17 @@ aparte-chat {
822
909
  --aparte-btn-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
823
910
  --aparte-btn-padding-lg: var(--aparte-space-3) var(--aparte-space-5);
824
911
 
912
+ /* One knob for the composer's control set — the send button, the input's action
913
+ buttons, the editor's single-line height: the lg step at rest, the touch target
914
+ under a coarse pointer (responsive.css). It used to be undeclared, with `44px`
915
+ written as a fallback at every reader, so a desktop composer was a 62px bar around
916
+ 15px of text and the coarse-pointer block had nothing left to enlarge. */
917
+ --aparte-composer-control-size: var(--aparte-btn-size-lg);
825
918
  --aparte-input-container-min-height: var(--aparte-touch-target-size);
826
- --aparte-elic-message-size: var(--aparte-font-size-md);
827
- --aparte-elic-title-size: var(--aparte-font-size-md);
919
+ --aparte-elic-message-size: var(--aparte-font-size-base);
920
+ --aparte-elic-title-size: var(--aparte-font-size-base);
828
921
  --aparte-elic-desc-size: var(--aparte-font-size-sm);
829
- --aparte-elic-option-title-size: var(--aparte-font-size-lg);
922
+ --aparte-elic-option-title-size: var(--aparte-font-size-md);
830
923
  --aparte-elic-option-desc-size: var(--aparte-font-size-sm);
831
924
  --aparte-elic-skip-size: var(--aparte-font-size-md);
832
925
  --aparte-elic-step-size: var(--aparte-font-size-sm);
@@ -836,6 +929,17 @@ aparte-chat {
836
929
 
837
930
  /* Borders */
838
931
  --aparte-border-focus: var(--aparte-primary);
932
+ /* THE focus ring: a 2px outline in this colour, one spacing step OUTSIDE the box, on
933
+ every control. It used to be drawn two ways — this outline on seventeen recipes and a
934
+ soft box-shadow wash (30 % of the accent, 1.39:1 against the page) on five — and the
935
+ offset was −2px, inside the box, so on a bordered row the ring sat as a second
936
+ concentric line and the next row painted over its edge. */
937
+ --aparte-focus-outline-offset: var(--aparte-space-1);
938
+ /* The edge of a CONTROL — a field, a choice box, the select's trigger. `--aparte-border`
939
+ separates two regions and is right to be faint; a thing someone can touch has to be
940
+ seen, and six previews showed a control with no visible edge. Derived from the ink
941
+ and the ground so both schemes follow; the mix is a first setting. */
942
+ --aparte-border-control: color-mix(in srgb, var(--aparte-text) 30%, var(--aparte-surface-1));
839
943
 
840
944
  /* Component-specific radius (overridable per app) */
841
945
  --aparte-radius-input: var(--aparte-radius-lg);
@@ -843,19 +947,25 @@ aparte-chat {
843
947
  --aparte-radius-send-btn: var(--aparte-radius-md);
844
948
  --aparte-radius-action-btn: var(--aparte-radius-sm);
845
949
  --aparte-radius-select: var(--aparte-radius-md);
846
- --aparte-radius-avatar: var(--aparte-radius-md);
847
950
  --aparte-radius-bubble: var(--aparte-radius-lg);
951
+ /* The bubble's corner IS the bubble radius above: it was a 14px literal off the scale,
952
+ the most visible corner of the transcript, and the knob named for it drove nothing. */
953
+ --aparte-message-content-radius: var(--aparte-radius-bubble);
848
954
  --aparte-radius-code: var(--aparte-radius-lg);
849
955
  --aparte-radius-thinking: var(--aparte-radius-lg);
850
956
  --aparte-radius-error: var(--aparte-radius-md);
957
+ /* The floating surfaces. They read a step of the scale directly, so a theme that
958
+ wanted square menus and round bubbles had to move the step — and every other
959
+ reader of it. */
960
+ --aparte-radius-menu: var(--aparte-radius-lg);
961
+ --aparte-radius-popover: var(--aparte-radius-lg);
962
+ --aparte-radius-dialog: var(--aparte-radius-xl);
963
+ --aparte-radius-tooltip: var(--aparte-radius-sm);
851
964
 
852
965
  /* The error ring on an avatar. A full shadow value, like
853
966
  --aparte-scroll-btn-shadow: the whole ring is the knob, not its width. */
854
967
  --aparte-avatar-error-ring: 0 0 0 2px var(--aparte-error-solid);
855
968
 
856
- /* Focus ring — derived from the accent so it themes automatically. */
857
- --aparte-focus-ring: 0 0 0 3px color-mix(in srgb, var(--aparte-primary) 30%, transparent);
858
-
859
969
  /* Messages */
860
970
  --aparte-message-content-text-user: var(--aparte-text);
861
971
  --aparte-message-content-text-assistant: var(--aparte-text);
@@ -865,7 +975,9 @@ aparte-chat {
865
975
  in sRGB rather than derived with `oklch(from …)`, because a wash has to keep the
866
976
  SURFACE's lightness, not the accent's; 12 % is where the default palette reads as a
867
977
  tint, not a fill, on both grounds. */
868
- --aparte-message-content-bg-user: color-mix(in srgb, var(--aparte-primary) 12%, var(--aparte-surface-1));
978
+ /* The mark's own tint: at 12 % the user bubble — the one mark that says "this turn is
979
+ yours" — sat at 1.03:1 on the light page. */
980
+ --aparte-message-content-bg-user: color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), var(--aparte-surface-1));
869
981
  /* The third surface: the second, pulled 6 % toward the text. Close to what the old
870
982
  literals encoded (`#e7dfce` from `#f1ebdf` light, `#342a40` from `#2a2333` dark —
871
983
  5 to 9 % per channel, not one figure), now one rule that follows the masters. */
@@ -893,6 +1005,12 @@ aparte-chat {
893
1005
  --aparte-thumb-radius: var(--aparte-radius-lg);
894
1006
 
895
1007
  /* Action Bar */
1008
+ /* The small controls of a turn and of a conversation row are the recipe's small step
1009
+ (24px, the WCAG 2.5.8 floor). The action bar used to feed 28 with a 24 exception for
1010
+ the last user turn, the branch arrows redrew 20 over their own `--sm`, the row's
1011
+ `⋯` redrew 20: three control heights in one 14px row. */
1012
+ --aparte-action-bar-btn-size: var(--aparte-btn-size-sm);
1013
+ --aparte-conv-action-btn-size: var(--aparte-btn-size-sm);
896
1014
  --aparte-action-bar-btn-color: var(--aparte-text-muted);
897
1015
  --aparte-action-bar-btn-hover-bg: var(--aparte-surface-2);
898
1016
  --aparte-action-bar-btn-hover-color: var(--aparte-text);
@@ -903,11 +1021,10 @@ aparte-chat {
903
1021
  --aparte-branch-picker-label-color: var(--aparte-text-muted);
904
1022
 
905
1023
  /* Avatars */
906
- --aparte-avatar-radius: var(--aparte-radius-avatar);
907
1024
  --aparte-avatar-bg-user: var(--aparte-primary);
908
1025
  --aparte-avatar-text-user: var(--aparte-text-inverse);
909
1026
  --aparte-avatar-bg-assistant: var(--aparte-surface-3);
910
- --aparte-avatar-text-assistant: var(--aparte-text-inverse);
1027
+ --aparte-avatar-text-assistant: var(--aparte-text);
911
1028
 
912
1029
  /* Text Sizes */
913
1030
  --aparte-name-color: var(--aparte-text);
@@ -936,6 +1053,10 @@ aparte-chat {
936
1053
  --aparte-code-header-bg: var(--aparte-surface-2);
937
1054
  --aparte-code-border: var(--aparte-border);
938
1055
  --aparte-code-header-text: var(--aparte-text-muted);
1056
+ /* The block's own type. It used to inherit the prose's size and leading (16px on 1.7)
1057
+ under the browser's generic monospace — the largest, airiest text in the message. */
1058
+ --aparte-code-block-font-size: var(--aparte-font-size-md);
1059
+ --aparte-code-block-line-height: var(--aparte-line-height-normal);
939
1060
 
940
1061
  /* Scroll-to-bottom button */
941
1062
 
@@ -955,6 +1076,9 @@ aparte-chat {
955
1076
 
956
1077
  /* Shell */
957
1078
  --aparte-sidebar-bg: var(--aparte-surface-2);
1079
+ /* The one inline inset of the column's four regions — header, search, body, footer.
1080
+ They used to carry three (16 / 12 / 12 / 16) and the content sat on two axes. */
1081
+ --aparte-sidebar-inset: var(--aparte-space-3);
958
1082
 
959
1083
  /* Conversation List */
960
1084
  --aparte-conv-item-radius: var(--aparte-radius-md);
@@ -1034,6 +1158,28 @@ aparte-chat {
1034
1158
  animation: aparte-pulse var(--aparte-duration-pulse) infinite ease-in-out;
1035
1159
  }
1036
1160
 
1161
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
1162
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
1163
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
1164
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
1165
+ the way display/icon.css already does. */
1166
+ @media (prefers-reduced-motion: reduce) {
1167
+ .aparte-dot { animation: none; }
1168
+ }
1169
+
1170
+ /* `hidden` means hidden. 71 recipes in these sheets set `display` on their base class,
1171
+ and an author declaration beats the browser's own `[hidden] { display: none }` — so a
1172
+ hidden `.aparte-btn` was out of the accessibility tree and still painted: the copy
1173
+ button of a tool-only turn stood there with `hidden` on it. Scoped to our own classes
1174
+ (a prefix match, not a bare `[hidden]`, so a host's markup is not touched), and
1175
+ `!important` because the whole point is to outrank the recipe's `display`. The
1176
+ handful of per-element `[hidden]` rules that predate this stay; they are now
1177
+ redundant, not wrong. */
1178
+ [class^="aparte-"][hidden],
1179
+ [class*=" aparte-"][hidden] {
1180
+ display: none !important;
1181
+ }
1182
+
1037
1183
  /* =========================================
1038
1184
  aparte-conversation-list
1039
1185
  ========================================= */
@@ -1065,7 +1211,7 @@ aparte-conversation-list {
1065
1211
  .aparte-link:hover { color: var(--aparte-link-color-hover); }
1066
1212
  .aparte-link:focus-visible {
1067
1213
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1068
- outline-offset: var(--aparte-btn-focus-offset);
1214
+ outline-offset: var(--aparte-focus-outline-offset);
1069
1215
  }
1070
1216
  /* Quiet — for a link inside a dense row, where a coloured one would shout. It keeps
1071
1217
  the underline, which is the part that carries the meaning. */
@@ -1088,8 +1234,11 @@ aparte-conversation-list {
1088
1234
  same thing at slightly different rhythms; a travelling wave, where a row wants one,
1089
1235
  comes from an animation-delay on its later dots, not from a skewed curve. */
1090
1236
  @keyframes aparte-pulse {
1091
- 0%, 100% { opacity: 0.3; transform: scale(0.85); }
1092
- 50% { opacity: 1; transform: scale(1.1); }
1237
+ /* Opacity alone: the scale that used to ride along made a row of waiting dots change
1238
+ width in a loop. And a floor above the perception threshold — at 0.3 the status
1239
+ dot spent half of every cycle at 1.55:1, and a capture froze it there. */
1240
+ 0%, 100% { opacity: var(--aparte-pulse-floor); }
1241
+ 50% { opacity: 1; }
1093
1242
  }
1094
1243
 
1095
1244
  /* One rotation, for every spinner and every spinning glyph. */
@@ -1110,9 +1259,9 @@ aparte-conversation-list {
1110
1259
  * <aparte-icon name="copy"></aparte-icon>
1111
1260
  * </button>
1112
1261
  * <div class="aparte-btn-group" role="group" aria-label="Versions">
1113
- * <button class="aparte-btn aparte-btn--surface">‹</button>
1262
+ * <button class="aparte-btn aparte-btn--surface" aria-label="Previous"><aparte-icon name="prevBranch"></aparte-icon></button>
1114
1263
  * <button class="aparte-btn aparte-btn--surface">1 / 2</button>
1115
- * <button class="aparte-btn aparte-btn--surface">›</button>
1264
+ * <button class="aparte-btn aparte-btn--surface" aria-label="Next"><aparte-icon name="nextBranch"></aparte-icon></button>
1116
1265
  * </div>
1117
1266
  * <div class="aparte-btn-group" role="group" aria-label="View">
1118
1267
  * <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="true">Chat</button>
@@ -1139,6 +1288,13 @@ aparte-conversation-list {
1139
1288
  */
1140
1289
 
1141
1290
  .aparte-btn {
1291
+ /* The glyph reads this: an `<aparte-icon>` (or a raw `svg.aparte-icon`) inside a button
1292
+ takes the button's icon size, per size modifier. */
1293
+ --aparte-icon-size: var(--aparte-btn-icon-size);
1294
+ /* The box, for EVERY button: `--icon` and `--circle` used to be the only readers of
1295
+ the size, so a text button was padding plus line-height — 23px beside a 32px icon
1296
+ button in the same row. The size is declared here and read below as a minimum. */
1297
+ --aparte-btn-size: var(--aparte-btn-size-md);
1142
1298
  /* The intent, as a value. Every fill below reads THIS and nothing else, which is
1143
1299
  what keeps the two axes independent. */
1144
1300
  --aparte-btn-intent: var(--aparte-neutral);
@@ -1157,6 +1313,8 @@ aparte-conversation-list {
1157
1313
  --aparte-btn-intent-ink: var(--aparte-btn-intent);
1158
1314
 
1159
1315
  display: inline-flex;
1316
+
1317
+ vertical-align: middle;
1160
1318
  align-items: center;
1161
1319
  justify-content: center;
1162
1320
  gap: var(--aparte-btn-gap);
@@ -1167,6 +1325,7 @@ aparte-conversation-list {
1167
1325
  be set by the composer's own row rule, which is exactly the kind of thing a recipe
1168
1326
  should not need a caller for. */
1169
1327
  box-sizing: border-box;
1328
+ min-block-size: var(--aparte-btn-size);
1170
1329
  padding: var(--aparte-btn-padding);
1171
1330
  border: var(--aparte-border-width) solid transparent;
1172
1331
  border-radius: var(--aparte-btn-radius);
@@ -1321,15 +1480,21 @@ aparte-conversation-list {
1321
1480
  .aparte-btn--soft:active:not(:disabled) { background: color-mix(in srgb, var(--aparte-btn-intent) 30%, transparent); }
1322
1481
  .aparte-btn--solid:active:not(:disabled) { background: var(--aparte-btn-intent); filter: brightness(0.93); }
1323
1482
 
1483
+ /* Drawn, not faded. Opacity on the whole button faded the glyph with its ground and
1484
+ read at 2:1 on the send button; "inactive" and "disappearing" are not the same
1485
+ message. A neutral ground, the muted ink, no intent — whatever the fill was. */
1324
1486
  .aparte-btn:disabled,
1325
1487
  .aparte-btn[aria-disabled='true'] {
1326
1488
  cursor: not-allowed;
1327
- opacity: var(--aparte-disabled-opacity);
1489
+ background: var(--aparte-surface-2);
1490
+ color: var(--aparte-text-muted);
1491
+ border-color: transparent;
1492
+ filter: none;
1328
1493
  }
1329
1494
 
1330
1495
  .aparte-btn:focus-visible {
1331
- outline: var(--aparte-focus-outline-width) solid var(--aparte-btn-intent);
1332
- outline-offset: var(--aparte-btn-focus-offset);
1496
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1497
+ outline-offset: var(--aparte-focus-outline-offset);
1333
1498
  }
1334
1499
 
1335
1500
  /* Toggled — a disclosure that is open, a toggle that is on, a tab that is current. */
@@ -1362,25 +1527,39 @@ aparte-conversation-list {
1362
1527
  .aparte-btn--block { display: flex; width: 100%; }
1363
1528
 
1364
1529
  .aparte-btn--sm { --aparte-btn-size: var(--aparte-btn-size-sm); --aparte-btn-icon-size: var(--aparte-icon-size-sm); font-size: var(--aparte-font-size-sm); padding: var(--aparte-btn-padding-sm); }
1365
- .aparte-btn--lg { --aparte-btn-size: var(--aparte-btn-size-lg); --aparte-btn-icon-size: var(--aparte-icon-size-xl); font-size: var(--aparte-font-size-lg); padding: var(--aparte-btn-padding-lg); }
1530
+ .aparte-btn--lg { --aparte-btn-size: var(--aparte-btn-size-lg); --aparte-btn-icon-size: var(--aparte-icon-size-xl); font-size: var(--aparte-font-size-base); padding: var(--aparte-btn-padding-lg); }
1531
+ .aparte-btn--xl { --aparte-btn-size: var(--aparte-btn-size-xl); --aparte-btn-icon-size: var(--aparte-icon-size-xl); font-size: var(--aparte-font-size-base); padding: var(--aparte-btn-padding-lg); }
1366
1532
  .aparte-btn--sm.aparte-btn--icon,
1367
1533
  .aparte-btn--sm.aparte-btn--circle,
1368
1534
  .aparte-btn--lg.aparte-btn--icon,
1369
- .aparte-btn--lg.aparte-btn--circle { padding: 0; }
1535
+ .aparte-btn--lg.aparte-btn--circle,
1536
+ .aparte-btn--xl.aparte-btn--icon,
1537
+ .aparte-btn--xl.aparte-btn--circle { padding: 0; }
1370
1538
 
1371
1539
  /* An icon inside a button never takes the pointer: the click belongs to the button,
1372
- and an SVG target makes `event.target` lie to a delegated listener. It is sized with
1373
- the button so the two axes stay in step. */
1540
+ and an SVG target makes `event.target` lie to a delegated listener. */
1374
1541
  .aparte-btn > svg,
1542
+ .aparte-btn > aparte-icon,
1375
1543
  .aparte-btn > .aparte-btn__icon {
1376
1544
  pointer-events: none;
1377
1545
  flex-shrink: 0;
1546
+ }
1547
+
1548
+ /* A raw SVG or a consumer's own icon box is sized with the button so the two axes stay
1549
+ in step. An `<aparte-icon>` is NOT sized here: it renders its `<svg>` inside itself, a
1550
+ grandchild this child combinator never reached, so the documented markup kept the
1551
+ glyph at its loose 14px default in a button that asked for 16. The recipe feeds the
1552
+ icon's own knob instead — `--aparte-icon-size`, declared on `.aparte-btn` above — and
1553
+ `display/icon.css` draws it, at `--sm`'s 12 and `--lg`'s 20 like everything else. */
1554
+ .aparte-btn > svg:not(.aparte-icon),
1555
+ .aparte-btn > .aparte-btn__icon {
1378
1556
  width: var(--aparte-btn-icon-size);
1379
1557
  height: var(--aparte-btn-icon-size);
1380
1558
  }
1381
1559
 
1382
1560
  /* ── Group — buttons that read as one control. ───────────────────────────────── */
1383
- .aparte-btn-group { display: inline-flex; }
1561
+ .aparte-btn-group { display: inline-flex;
1562
+ vertical-align: middle; }
1384
1563
  .aparte-btn-group > .aparte-btn { border-radius: 0; }
1385
1564
  /* Logical properties, so the joined edge follows the writing direction instead of
1386
1565
  assuming left-to-right. */
@@ -1464,24 +1643,24 @@ aparte-conversation-list {
1464
1643
  /* ── The shared text-entry recipe ────────────────────────────────────────────── */
1465
1644
 
1466
1645
  .aparte-field {
1467
- /* Padding recipe, declared here: nothing in the shared token file defines a field
1468
- padding yet, so this file owns its own default and size variants. */
1469
- --aparte-field-padding: var(--aparte-space-2) var(--aparte-space-3);
1470
- /* Same reason, and a gap the recipe had: every other recipe names its own radius
1471
- (`--aparte-btn-radius`, `--aparte-tag-radius`, `--aparte-field-choice-radius`) and
1472
- this one hardcoded the scale step, so a field could not be re-cornered from its
1473
- own element the way every sibling can. */
1474
- --aparte-field-radius: var(--aparte-radius-md);
1475
-
1646
+ /* Every knob this family reads lives in `theme.css` on `:root` — including the
1647
+ radius and the paddings, which used to be declared right here. That was the one
1648
+ construction bug of the kit: `.aparte-field-group` is this element's PARENT and
1649
+ `.aparte-color` a sibling recipe, both read `--aparte-field-radius`, and a custom
1650
+ property declared here is invisible to both, so they computed `border-radius: 0`
1651
+ and every field group in the library rendered square. */
1476
1652
  box-sizing: border-box;
1477
1653
  display: block;
1478
1654
  width: 100%;
1655
+ /* A resting height from the control scale, so a field and the button beside it stop
1656
+ disagreeing by 6px; the size modifiers move it a step either way. */
1657
+ min-block-size: var(--aparte-field-size);
1479
1658
  font: inherit;
1480
- font-size: var(--aparte-font-size-sm);
1659
+ font-size: var(--aparte-font-size-md);
1481
1660
  line-height: var(--aparte-line-height-normal);
1482
1661
  color: var(--aparte-text);
1483
1662
  background: var(--aparte-surface-1);
1484
- border: var(--aparte-border-width) solid var(--aparte-border);
1663
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1485
1664
  border-radius: var(--aparte-field-radius);
1486
1665
  padding: var(--aparte-field-padding);
1487
1666
  transition:
@@ -1516,10 +1695,13 @@ aparte-conversation-list {
1516
1695
  border-color: var(--aparte-border-focus);
1517
1696
  }
1518
1697
 
1698
+ /* Drawn, not faded: the muted ink on the quieter ground, and the edge back to the region
1699
+ colour. Opacity faded the text with its box and read under 3:1. */
1519
1700
  .aparte-field:disabled {
1520
1701
  cursor: not-allowed;
1521
- opacity: var(--aparte-disabled-opacity);
1522
1702
  background: var(--aparte-surface-2);
1703
+ color: var(--aparte-text-muted);
1704
+ border-color: var(--aparte-border);
1523
1705
  }
1524
1706
 
1525
1707
  .aparte-field[readonly] {
@@ -1533,37 +1715,35 @@ aparte-conversation-list {
1533
1715
  border-color: var(--aparte-error);
1534
1716
  }
1535
1717
 
1718
+ /* The one ring (see --aparte-focus-outline-offset in theme.css): an outline outside the
1719
+ box, and the border left alone — a highlighted border under an offset ring read as two
1720
+ concentric lines. An invalid field's ring takes the error colour. */
1536
1721
  .aparte-field:focus-visible {
1537
- outline: none;
1538
- border-color: var(--aparte-border-focus);
1539
- box-shadow: var(--aparte-focus-ring);
1722
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1723
+ outline-offset: var(--aparte-focus-outline-offset);
1540
1724
  }
1541
1725
 
1542
1726
  .aparte-field[aria-invalid='true']:focus-visible {
1543
- border-color: var(--aparte-error);
1544
- box-shadow: var(--aparte-field-error-ring);
1727
+ outline-color: var(--aparte-error-solid);
1545
1728
  }
1546
1729
 
1547
1730
  /* ── Sizes ───────────────────────────────────────────────────────────────────── */
1548
1731
 
1549
1732
  .aparte-field--sm {
1550
- --aparte-field-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
1551
-
1733
+ --aparte-field-size: var(--aparte-field-size-sm);
1552
1734
  padding: var(--aparte-field-padding-sm);
1553
- font-size: var(--aparte-font-size-xs);
1735
+ font-size: var(--aparte-font-size-sm);
1554
1736
  }
1555
1737
 
1556
1738
  .aparte-field--lg {
1557
- --aparte-field-padding-lg: var(--aparte-space-3) var(--aparte-space-4);
1558
-
1739
+ --aparte-field-size: var(--aparte-field-size-lg);
1559
1740
  padding: var(--aparte-field-padding-lg);
1560
- font-size: var(--aparte-font-size-md);
1741
+ font-size: var(--aparte-font-size-base);
1561
1742
  }
1562
1743
 
1563
1744
  /* ── Textarea ────────────────────────────────────────────────────────────────── */
1564
1745
 
1565
1746
  .aparte-field--textarea {
1566
- --aparte-field-textarea-min-height: 80px;
1567
1747
 
1568
1748
  display: block;
1569
1749
  min-block-size: var(--aparte-field-textarea-min-height);
@@ -1578,7 +1758,7 @@ aparte-conversation-list {
1578
1758
  display: flex;
1579
1759
  align-items: stretch;
1580
1760
  background: var(--aparte-surface-1);
1581
- border: var(--aparte-border-width) solid var(--aparte-border);
1761
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1582
1762
  border-radius: var(--aparte-field-radius);
1583
1763
  /* The addons' ground follows the group's corners; the focus ring is a shadow on the
1584
1764
  group itself, outside this box, so it is not clipped. */
@@ -1600,8 +1780,15 @@ aparte-conversation-list {
1600
1780
  border-radius: 0;
1601
1781
  }
1602
1782
 
1783
+ /* The GROUP draws the ring (below): the field inside it draws none. */
1603
1784
  .aparte-field-group > .aparte-field:focus-visible {
1604
- box-shadow: none;
1785
+ outline: none;
1786
+ }
1787
+
1788
+ /* A button in the group is as tall as the field it sits beside — the one row where the
1789
+ two recipes meet, and the one where a 4px difference shows. */
1790
+ .aparte-field-group > .aparte-btn {
1791
+ --aparte-btn-size: var(--aparte-field-size);
1605
1792
  }
1606
1793
 
1607
1794
  /* An addon has a ground of its own and a rule against the field: on the field's own
@@ -1628,8 +1815,8 @@ aparte-conversation-list {
1628
1815
  }
1629
1816
 
1630
1817
  .aparte-field-group:focus-within {
1631
- border-color: var(--aparte-border-focus);
1632
- box-shadow: var(--aparte-focus-ring);
1818
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1819
+ outline-offset: var(--aparte-focus-outline-offset);
1633
1820
  }
1634
1821
 
1635
1822
  /* `aria-invalid` lives on the field itself, not the wrapper the app didn't write a
@@ -1640,7 +1827,7 @@ aparte-conversation-list {
1640
1827
  }
1641
1828
 
1642
1829
  .aparte-field-group:has(.aparte-field[aria-invalid='true']):focus-within {
1643
- box-shadow: var(--aparte-field-error-ring);
1830
+ outline-color: var(--aparte-error-solid);
1644
1831
  }
1645
1832
 
1646
1833
  /* ── Checkbox ────────────────────────────────────────────────────────────────── */
@@ -1664,7 +1851,6 @@ aparte-conversation-list {
1664
1851
  --aparte-derived-ink: var(--aparte-on-intent);
1665
1852
  /* No shared control-size token covers a checkbox yet (the button's own sizes are
1666
1853
  button-specific), so its box is declared here. */
1667
- --aparte-checkbox-size: 18px;
1668
1854
 
1669
1855
  box-sizing: border-box;
1670
1856
  appearance: none;
@@ -1675,7 +1861,7 @@ aparte-conversation-list {
1675
1861
  place-content: center;
1676
1862
  width: var(--aparte-checkbox-size);
1677
1863
  height: var(--aparte-checkbox-size);
1678
- border: var(--aparte-border-width) solid var(--aparte-border);
1864
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1679
1865
  border-radius: var(--aparte-radius-sm);
1680
1866
  background: var(--aparte-surface-1);
1681
1867
  cursor: pointer;
@@ -1760,7 +1946,7 @@ aparte-conversation-list {
1760
1946
  }
1761
1947
 
1762
1948
  .aparte-checkbox:focus-visible {
1763
- outline: var(--aparte-focus-outline-width) solid var(--aparte-checkbox-intent);
1949
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1764
1950
  outline-offset: var(--aparte-focus-outline-offset);
1765
1951
  }
1766
1952
 
@@ -1790,8 +1976,6 @@ aparte-conversation-list {
1790
1976
  used. `middle` and not `text-bottom`: the label may wrap. */
1791
1977
  vertical-align: middle;
1792
1978
  --aparte-radio-intent: var(--aparte-primary);
1793
- --aparte-radio-size: 18px;
1794
- --aparte-radio-dot-size: 8px;
1795
1979
 
1796
1980
  box-sizing: border-box;
1797
1981
  appearance: none;
@@ -1802,7 +1986,7 @@ aparte-conversation-list {
1802
1986
  place-content: center;
1803
1987
  width: var(--aparte-radio-size);
1804
1988
  height: var(--aparte-radio-size);
1805
- border: var(--aparte-border-width) solid var(--aparte-border);
1989
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1806
1990
  border-radius: var(--aparte-radius-full);
1807
1991
  background: var(--aparte-surface-1);
1808
1992
  cursor: pointer;
@@ -1844,7 +2028,7 @@ aparte-conversation-list {
1844
2028
  }
1845
2029
 
1846
2030
  .aparte-radio:focus-visible {
1847
- outline: var(--aparte-focus-outline-width) solid var(--aparte-radio-intent);
2031
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1848
2032
  outline-offset: var(--aparte-focus-outline-offset);
1849
2033
  }
1850
2034
 
@@ -1875,9 +2059,6 @@ aparte-conversation-list {
1875
2059
  has to live here where `:checked` can override it — a central default could never
1876
2060
  react to the class + state combo. */
1877
2061
  --aparte-switch-track: var(--aparte-surface-3);
1878
- --aparte-switch-width: 40px;
1879
- --aparte-switch-height: 22px;
1880
- --aparte-switch-thumb-inset: 2px;
1881
2062
  /* DERIVED from the track, not a fourth literal to keep in step with the other three:
1882
2063
  the thumb used to be 16px in a 20px track with a 1px border and a 2px top inset —
1883
2064
  2px of air above it and none below, visibly low ("le cercle pas centré"). The
@@ -1896,7 +2077,7 @@ aparte-conversation-list {
1896
2077
  flex-shrink: 0;
1897
2078
  inline-size: var(--aparte-switch-width);
1898
2079
  block-size: var(--aparte-switch-height);
1899
- border: var(--aparte-border-width) solid var(--aparte-border);
2080
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1900
2081
  border-radius: var(--aparte-radius-full);
1901
2082
  background: var(--aparte-switch-track);
1902
2083
  cursor: pointer;
@@ -1944,7 +2125,7 @@ aparte-conversation-list {
1944
2125
  }
1945
2126
 
1946
2127
  .aparte-switch:focus-visible {
1947
- outline: var(--aparte-focus-outline-width) solid var(--aparte-switch-intent);
2128
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1948
2129
  outline-offset: var(--aparte-focus-outline-offset);
1949
2130
  }
1950
2131
 
@@ -1968,8 +2149,6 @@ aparte-conversation-list {
1968
2149
 
1969
2150
  .aparte-range {
1970
2151
  --aparte-range-intent: var(--aparte-primary);
1971
- --aparte-range-thumb-size: 16px;
1972
- --aparte-range-track-height: 4px;
1973
2152
 
1974
2153
  appearance: none;
1975
2154
  -webkit-appearance: none;
@@ -2039,11 +2218,11 @@ aparte-conversation-list {
2039
2218
  .aparte-range:active::-moz-range-thumb { transform: scale(1.15); }
2040
2219
 
2041
2220
  .aparte-range:focus-visible::-webkit-slider-thumb {
2042
- outline: var(--aparte-focus-outline-width) solid var(--aparte-range-intent);
2221
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2043
2222
  outline-offset: var(--aparte-focus-outline-offset);
2044
2223
  }
2045
2224
  .aparte-range:focus-visible::-moz-range-thumb {
2046
- outline: var(--aparte-focus-outline-width) solid var(--aparte-range-intent);
2225
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2047
2226
  outline-offset: var(--aparte-focus-outline-offset);
2048
2227
  }
2049
2228
 
@@ -2064,6 +2243,7 @@ aparte-conversation-list {
2064
2243
 
2065
2244
  .aparte-field-label {
2066
2245
  display: inline-block;
2246
+ vertical-align: middle;
2067
2247
  margin-block-end: var(--aparte-space-1);
2068
2248
  font-size: var(--aparte-font-size-sm);
2069
2249
  font-weight: var(--aparte-font-weight-medium);
@@ -2151,7 +2331,7 @@ aparte-conversation-list {
2151
2331
  inline-size: var(--aparte-color-size);
2152
2332
  block-size: var(--aparte-color-size);
2153
2333
  padding: 0;
2154
- border: var(--aparte-border-width) solid var(--aparte-border);
2334
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
2155
2335
  border-radius: var(--aparte-field-radius);
2156
2336
  background: none;
2157
2337
  cursor: pointer;
@@ -2161,7 +2341,7 @@ aparte-conversation-list {
2161
2341
  .aparte-color::-moz-color-swatch { border: none; border-radius: var(--aparte-radius-sm); }
2162
2342
  .aparte-color:focus-visible {
2163
2343
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2164
- outline-offset: var(--aparte-btn-focus-offset);
2344
+ outline-offset: var(--aparte-focus-outline-offset);
2165
2345
  }
2166
2346
  .aparte-color:disabled { cursor: not-allowed; opacity: var(--aparte-disabled-opacity); }
2167
2347
 
@@ -2217,6 +2397,7 @@ aparte-conversation-list {
2217
2397
  would invite a click. */
2218
2398
  .aparte-output {
2219
2399
  display: inline-block;
2400
+ vertical-align: middle;
2220
2401
  padding: var(--aparte-output-padding);
2221
2402
  border-radius: var(--aparte-radius-sm);
2222
2403
  background: var(--aparte-output-bg);
@@ -2299,19 +2480,22 @@ aparte-conversation-list {
2299
2480
  }
2300
2481
  .aparte-field-choice:has(:focus-visible) {
2301
2482
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2302
- outline-offset: var(--aparte-btn-focus-offset);
2483
+ outline-offset: var(--aparte-focus-outline-offset);
2303
2484
  }
2304
2485
 
2305
2486
  .aparte-field-choice__body { flex: 1 1 auto; min-width: 0; }
2306
2487
  /* =========================================
2307
2488
  aparte-avatar — sizes, shape, image + initials fallback
2308
2489
  =========================================
2309
- --aparte-avatar-size, --aparte-avatar-radius and --aparte-avatar-font-size are
2310
- NOT invented here: they already exist centrally for the message avatar
2311
- (bubble.css), and this component deliberately reads the same three rather than
2312
- declare its own — a generic avatar and a chat-message avatar are the same
2313
- object, and a size or shape modifier applied here resizes a role-coloured
2314
- message avatar too, for free, instead of only a second, disconnected one.
2490
+ `--aparte-avatar-size` is NOT invented here: it already exists centrally for the
2491
+ message avatar (bubble.css), and this component reads the same one — a generic
2492
+ avatar and a chat-message avatar are the same object, and a size modifier applied
2493
+ here resizes a role-coloured message avatar too. The corner, the initials and the
2494
+ group's overlap are FRACTIONS of that size (`--aparte-avatar-radius-ratio`,
2495
+ `--aparte-avatar-initials-ratio`, `--aparte-avatar-overlap-ratio`), computed on the
2496
+ element so a size modifier moves them: as absolute tokens the 40px avatar drew the
2497
+ same 11px initials as the 32px one, the corner drifted from squircle to square up
2498
+ the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one.
2315
2499
 
2316
2500
  <span class="aparte-avatar aparte-avatar--sm">AP</span>
2317
2501
  <span class="aparte-avatar">JD</span>
@@ -2326,16 +2510,17 @@ aparte-conversation-list {
2326
2510
  .aparte-avatar {
2327
2511
  position: relative;
2328
2512
  display: inline-flex;
2513
+ vertical-align: middle;
2329
2514
  flex-shrink: 0;
2330
2515
  align-items: center;
2331
2516
  justify-content: center;
2332
2517
  overflow: hidden;
2333
2518
  width: var(--aparte-avatar-size);
2334
2519
  height: var(--aparte-avatar-size);
2335
- border-radius: var(--aparte-avatar-radius);
2520
+ border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio));
2336
2521
  background: var(--aparte-avatar-fallback-bg);
2337
2522
  color: var(--aparte-avatar-fallback-color);
2338
- font-size: var(--aparte-avatar-font-size);
2523
+ font-size: calc(var(--aparte-avatar-size) * var(--aparte-avatar-initials-ratio));
2339
2524
  font-weight: var(--aparte-font-weight-semibold);
2340
2525
  line-height: var(--aparte-line-height-none);
2341
2526
  text-transform: uppercase;
@@ -2359,13 +2544,13 @@ aparte-conversation-list {
2359
2544
 
2360
2545
  /* Square is the default shape and needs no class (it is what the message avatar
2361
2546
  already renders); named anyway so a square avatar can say so rather than omit. */
2362
- .aparte-avatar--square { border-radius: var(--aparte-avatar-radius); }
2547
+ .aparte-avatar--square { border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio)); }
2363
2548
  .aparte-avatar--circle { border-radius: var(--aparte-radius-full); }
2364
2549
 
2365
- .aparte-avatar--xs { --aparte-avatar-size: var(--aparte-avatar-size-xs); --aparte-avatar-font-size: var(--aparte-font-size-2xs); }
2366
- .aparte-avatar--sm { --aparte-avatar-size: var(--aparte-avatar-size-sm); --aparte-avatar-font-size: var(--aparte-font-size-xs); }
2367
- .aparte-avatar--lg { --aparte-avatar-size: var(--aparte-avatar-size-lg); --aparte-avatar-font-size: var(--aparte-font-size-lg); }
2368
- .aparte-avatar--xl { --aparte-avatar-size: var(--aparte-avatar-size-xl); --aparte-avatar-font-size: var(--aparte-font-size-lg); }
2550
+ .aparte-avatar--xs { --aparte-avatar-size: var(--aparte-avatar-size-xs); }
2551
+ .aparte-avatar--sm { --aparte-avatar-size: var(--aparte-avatar-size-sm); }
2552
+ .aparte-avatar--lg { --aparte-avatar-size: var(--aparte-avatar-size-lg); }
2553
+ .aparte-avatar--xl { --aparte-avatar-size: var(--aparte-avatar-size-xl); }
2369
2554
 
2370
2555
  /* ── Group — a fan of avatars that read as one party. ─────────────────────────
2371
2556
  No z-index is set: later siblings simply paint over earlier ones in normal
@@ -2373,13 +2558,14 @@ aparte-conversation-list {
2373
2558
  the one thing z-index would otherwise be needed for. */
2374
2559
  .aparte-avatar-group {
2375
2560
  display: inline-flex;
2561
+ vertical-align: middle;
2376
2562
  align-items: center;
2377
2563
  }
2378
2564
  .aparte-avatar-group > .aparte-avatar {
2379
2565
  box-shadow: 0 0 0 var(--aparte-avatar-group-ring-width) var(--aparte-avatar-group-ring-color);
2380
2566
  }
2381
2567
  .aparte-avatar-group > .aparte-avatar:not(:first-child) {
2382
- margin-inline-start: calc(var(--aparte-avatar-group-overlap) * -1);
2568
+ margin-inline-start: calc(var(--aparte-avatar-size) * var(--aparte-avatar-overlap-ratio) * -1);
2383
2569
  }
2384
2570
  .aparte-avatar-group > .aparte-avatar:hover {
2385
2571
  z-index: var(--aparte-z-raised);
@@ -2498,6 +2684,8 @@ aparte-icon {
2498
2684
  --aparte-derived-ink: var(--aparte-on-intent);
2499
2685
 
2500
2686
  display: inline-flex;
2687
+
2688
+ vertical-align: middle;
2501
2689
  align-items: center;
2502
2690
  justify-content: center;
2503
2691
  gap: var(--aparte-badge-gap);
@@ -2595,6 +2783,7 @@ aparte-icon {
2595
2783
  .aparte-tag {
2596
2784
  box-sizing: border-box;
2597
2785
  display: inline-flex;
2786
+ vertical-align: middle;
2598
2787
  align-items: center;
2599
2788
  gap: var(--aparte-tag-gap);
2600
2789
  max-width: 100%;
@@ -2657,8 +2846,8 @@ aparte-icon {
2657
2846
  * <span class="aparte-thumbnail aparte-thumbnail--lg"><img class="aparte-thumbnail__image"
2658
2847
  * src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 3'%3E%3Crect width='2' height='3' fill='%23b07d33'/%3E%3C/svg%3E"
2659
2848
  * alt="A screenshot"></span>
2660
- * <span class="aparte-thumbnail"><span class="aparte-thumbnail__ext">PDF</span></span>
2661
- * <span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__ext">MD</span></span>
2849
+ * <span class="aparte-thumbnail"><span class="aparte-thumbnail__label">PDF</span></span>
2850
+ * <span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__label">MD</span></span>
2662
2851
  *
2663
2852
  * Distinct from `.aparte-avatar`, which is the same shape for a different reason: an
2664
2853
  * avatar stands for a PERSON and falls back to initials, so it carries type styling and
@@ -2758,21 +2947,30 @@ aparte-icon {
2758
2947
  --aparte-thumbnail-border: var(--aparte-attachment-chip-border);
2759
2948
  }
2760
2949
 
2761
- /* Only an image tile the app declared it can preview is a button — see
2762
- aparteGlobalConfig.setHostHandlers(). Undeclared, it is a picture, not a control. */
2763
- .aparte-thumb--image[role='button'] {
2950
+ /* Only an image the app declared it can preview is a button — see
2951
+ aparteGlobalConfig.setHostHandlers(). Undeclared, it is a picture, not a control.
2952
+
2953
+ Two selectors because the two strips carry the role in two places, and both are
2954
+ right. The sent-message strip has no ✕, so its TILE is the whole control. The
2955
+ composer's tile wraps the remove button, so the control there is the IMAGE — a
2956
+ button inside a button is what the role on the tile used to make. */
2957
+ .aparte-thumb--image[role='button'],
2958
+ .aparte-thumbnail__image[role='button'] {
2764
2959
  cursor: pointer;
2765
2960
  }
2766
2961
 
2767
2962
  .aparte-thumb--image[role='button']:focus-visible {
2768
- box-shadow: var(--aparte-focus-ring);
2963
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2964
+ outline-offset: var(--aparte-focus-outline-offset);
2769
2965
  }
2770
2966
 
2771
- .aparte-thumb__img {
2772
- width: 100%;
2773
- height: 100%;
2774
- object-fit: cover;
2775
- display: block;
2967
+ /* Inset, unlike the tile's. The image fills the tile edge to edge and the tile is the
2968
+ frame (`overflow: hidden` above), so an outline drawn outward is cropped away — the
2969
+ focus ring would simply not exist. Pulled in by its own width, it lands just inside
2970
+ the border and stays whole. */
2971
+ .aparte-thumbnail__image[role='button']:focus-visible {
2972
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2973
+ outline-offset: calc(-1 * var(--aparte-focus-outline-width));
2776
2974
  }
2777
2975
 
2778
2976
  .aparte-thumb--file {
@@ -2781,13 +2979,6 @@ aparte-icon {
2781
2979
  justify-content: center;
2782
2980
  }
2783
2981
 
2784
- .aparte-thumb__ext {
2785
- font-size: var(--aparte-font-size-xs);
2786
- font-weight: var(--aparte-font-weight-bold);
2787
- letter-spacing: 0.03em;
2788
- color: var(--aparte-text-muted);
2789
- }
2790
-
2791
2982
  .aparte-thumb__name {
2792
2983
  position: absolute;
2793
2984
  left: 0;
@@ -2818,9 +3009,13 @@ aparte-icon {
2818
3009
  */
2819
3010
 
2820
3011
  .aparte-spinner {
2821
-
2822
3012
  display: inline-block;
3013
+ vertical-align: middle;
2823
3014
  flex-shrink: 0;
3015
+ /* The token is the ring's outer size, border included: without this the ring painted
3016
+ 16/20/28 for tokens of 12/16/24 (the 2px stroke added on each side), and painted
3017
+ the token's value only on a host page with its own border-box reset. */
3018
+ box-sizing: border-box;
2824
3019
  width: var(--aparte-spinner-size);
2825
3020
  height: var(--aparte-spinner-size);
2826
3021
  border: var(--aparte-spinner-thickness) solid currentColor;
@@ -2835,6 +3030,15 @@ aparte-icon {
2835
3030
 
2836
3031
  .aparte-spinner--sm { --aparte-spinner-size: var(--aparte-spinner-size-sm); }
2837
3032
  .aparte-spinner--lg { --aparte-spinner-size: var(--aparte-spinner-size-lg); }
3033
+
3034
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3035
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3036
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3037
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3038
+ the way display/icon.css already does. */
3039
+ @media (prefers-reduced-motion: reduce) {
3040
+ .aparte-spinner { animation: none; }
3041
+ }
2838
3042
  /* =========================================
2839
3043
  aparte-progress — determinate bar + indeterminate variant
2840
3044
  =========================================
@@ -2859,7 +3063,9 @@ aparte-icon {
2859
3063
  width: 100%;
2860
3064
  height: var(--aparte-progress-height);
2861
3065
  border-radius: var(--aparte-radius-full);
2862
- background: var(--aparte-surface-3);
3066
+ /* The track: a reference the value is read against, so it has to be seen. A surface
3067
+ level put it at 1.06:1 in light and 1.20:1 in dark — one token, two distances. */
3068
+ background: var(--aparte-track);
2863
3069
  }
2864
3070
 
2865
3071
  .aparte-progress__bar {
@@ -2877,6 +3083,21 @@ aparte-icon {
2877
3083
  animation: aparte-progress-slide var(--aparte-duration-pulse) var(--aparte-ease) infinite;
2878
3084
  }
2879
3085
 
3086
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3087
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3088
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3089
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3090
+ the way display/icon.css already does.
3091
+ Stopped, the sliding segment would sit at one spot and read as a value; the whole
3092
+ track filled says "busy" without naming an amount. */
3093
+ @media (prefers-reduced-motion: reduce) {
3094
+ .aparte-progress--indeterminate .aparte-progress__bar {
3095
+ animation: none;
3096
+ width: 100%;
3097
+ inset-inline-start: 0;
3098
+ }
3099
+ }
3100
+
2880
3101
  /* inset-inline-start, not transform: translateX — a transform has no logical
2881
3102
  axis, so an RTL page would slide the indeterminate segment backwards. Animating
2882
3103
  a logical inset instead makes the sweep follow reading direction automatically. */
@@ -2909,10 +3130,15 @@ aparte-icon {
2909
3130
  <div>
2910
3131
  <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
2911
3132
  <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
2912
- <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
3133
+ <span class="aparte-skeleton aparte-skeleton--text" style="max-inline-size: 60%" aria-hidden="true"></span>
2913
3134
  </div>
2914
- <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 1 / -1; block-size: 64px" aria-hidden="true"></span>
3135
+ <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 2" aria-hidden="true"></span>
2915
3136
  </div>
3137
+
3138
+ The last line is shorter because the last line of a paragraph is, and the block sits
3139
+ in the text column, under the lines it stands for — not across the avatar's. The
3140
+ rect's height is the family's token (`--aparte-skeleton-rect-height`), not an inline
3141
+ value that contradicts it.
2916
3142
  */
2917
3143
 
2918
3144
  .aparte-skeleton {
@@ -2929,6 +3155,19 @@ aparte-icon {
2929
3155
  animation: aparte-skeleton-shimmer var(--aparte-duration-pulse) linear infinite;
2930
3156
  }
2931
3157
 
3158
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3159
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3160
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3161
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3162
+ the way display/icon.css already does.
3163
+ The shine goes with the shimmer: a stopped gradient would sit as a pale band. */
3164
+ @media (prefers-reduced-motion: reduce) {
3165
+ .aparte-skeleton {
3166
+ animation: none;
3167
+ background-image: none;
3168
+ }
3169
+ }
3170
+
2932
3171
  .aparte-skeleton--text {
2933
3172
  width: 100%;
2934
3173
  height: var(--aparte-skeleton-text-height);
@@ -2936,7 +3175,7 @@ aparte-icon {
2936
3175
  }
2937
3176
  .aparte-skeleton--text + .aparte-skeleton--text { margin-block-start: var(--aparte-skeleton-text-gap); }
2938
3177
  /* The ragged last line reads as "text", where a full-width block reads as a bar. */
2939
- .aparte-skeleton--text:last-child { width: var(--aparte-skeleton-text-last-width); }
3178
+ .aparte-skeleton--text:last-child:not(:first-child) { width: var(--aparte-skeleton-text-last-width); }
2940
3179
 
2941
3180
  .aparte-skeleton--circle {
2942
3181
  width: var(--aparte-skeleton-circle-size);
@@ -3024,6 +3263,7 @@ aparte-icon {
3024
3263
  <button class="aparte-alert__dismiss" aria-label="Dismiss"><aparte-icon name="close"></aparte-icon></button>
3025
3264
  </div>
3026
3265
  <div class="aparte-alert aparte-alert--danger" role="alert">
3266
+ <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span>
3027
3267
  <div class="aparte-alert__body">
3028
3268
  <p class="aparte-alert__message">The request was refused: no API key is set.</p>
3029
3269
  </div>
@@ -3114,7 +3354,7 @@ aparte-icon {
3114
3354
  }
3115
3355
  .aparte-alert__dismiss:active:not(:disabled) { filter: brightness(0.93); }
3116
3356
  .aparte-alert__dismiss:focus-visible {
3117
- outline: var(--aparte-focus-outline-width) solid var(--aparte-alert-intent);
3357
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3118
3358
  outline-offset: var(--aparte-focus-outline-offset);
3119
3359
  }
3120
3360
  .aparte-alert__dismiss:disabled { opacity: var(--aparte-disabled-opacity); cursor: not-allowed; pointer-events: none; }
@@ -3153,6 +3393,12 @@ aparte-icon {
3153
3393
  padding: var(--aparte-card-body-padding);
3154
3394
  }
3155
3395
 
3396
+ /* The body owns its edges: a paragraph's block margins fold into the padding instead of
3397
+ stacking on it. The sheet's own example puts a <p> here, and the body band measured
3398
+ twice the height of the header and footer beside it. */
3399
+ .aparte-card__body > :first-child { margin-block-start: 0; }
3400
+ .aparte-card__body > :last-child { margin-block-end: 0; }
3401
+
3156
3402
  .aparte-card__footer {
3157
3403
  display: flex;
3158
3404
  align-items: center;
@@ -3291,11 +3537,12 @@ aparte-icon {
3291
3537
  * script the caller owns: a <details open> toggling itself, a positioning library placing
3292
3538
  * a menu next to its trigger. Nothing here reaches for position:fixed on your behalf.
3293
3539
  *
3294
- * <div class="aparte-tabs" role="tablist">
3295
- * <button class="aparte-tabs__tab" role="tab" aria-selected="true">Preview</button>
3296
- * <button class="aparte-tabs__tab" role="tab" aria-selected="false">Code</button>
3540
+ * <div class="aparte-tabs" role="tablist" aria-label="Result view">
3541
+ * <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-preview" aria-controls="tabs-a-preview-panel" aria-selected="true" tabindex="0">Preview</button>
3542
+ * <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-code" aria-controls="tabs-a-code-panel" aria-selected="false" tabindex="-1">Code</button>
3297
3543
  * </div>
3298
- * <div class="aparte-tabs__panel" role="tabpanel">The panel for the selected tab.</div>
3544
+ * <div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-preview-panel" aria-labelledby="tabs-a-preview">The panel for the selected tab.</div>
3545
+ * <div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-code-panel" aria-labelledby="tabs-a-code" hidden>The panel the other tab opens.</div>
3299
3546
  *
3300
3547
  * <div class="aparte-accordion">
3301
3548
  * <details class="aparte-accordion__item" open>
@@ -3342,17 +3589,29 @@ aparte-icon {
3342
3589
  the current tab, which reads as a control rather than as navigation. Neither moves
3343
3590
  anything: `aria-selected` is what says which tab is current, and the app owns it.
3344
3591
 
3345
- <div class="aparte-tabs aparte-tabs--underline" role="tablist">
3346
- <button class="aparte-tabs__tab" role="tab" aria-selected="true">Preview</button>
3347
- <button class="aparte-tabs__tab" role="tab" aria-selected="false">Code</button>
3592
+ The markup below is the whole contract of the role, and all of it is yours to write:
3593
+ ONE tab stop for the list (`tabindex="0"` on the selected tab, `-1` on the rest),
3594
+ `aria-controls` from each tab to its `role="tabpanel"`, and `aria-labelledby` back.
3595
+ The app also owns the ArrowLeft/ArrowRight/Home/End handler that moves the selection
3596
+ and the stop together — `@aparte/plugin-artifacts`' card has a working one to copy.
3597
+ A tablist without those is louder than plain buttons and says less: every tab a tab
3598
+ stop, and none of them naming what it opens.
3599
+
3600
+ <div class="aparte-tabs aparte-tabs--underline" role="tablist" aria-label="Result view">
3601
+ <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-preview" aria-controls="tabs-u-preview-panel" aria-selected="true" tabindex="0">Preview</button>
3602
+ <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-code" aria-controls="tabs-u-code-panel" aria-selected="false" tabindex="-1">Code</button>
3348
3603
  </div>
3349
- <div class="aparte-tabs__panel" role="tabpanel">What the selected tab shows.</div>
3604
+ <div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-preview-panel" aria-labelledby="tabs-u-preview">What the selected tab shows.</div>
3605
+ <div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-code-panel" aria-labelledby="tabs-u-code" hidden>What the other tab shows.</div>
3350
3606
 
3351
- <div class="aparte-tabs aparte-tabs--segmented" role="tablist">
3352
- <button class="aparte-tabs__tab" role="tab" aria-selected="true">Light</button>
3353
- <button class="aparte-tabs__tab" role="tab" aria-selected="false">Dark</button>
3354
- <button class="aparte-tabs__tab" role="tab" aria-selected="false">System</button>
3607
+ <div class="aparte-tabs aparte-tabs--segmented" role="tablist" aria-label="Theme">
3608
+ <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-light" aria-controls="tabs-s-light-panel" aria-selected="true" tabindex="0">Light</button>
3609
+ <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-dark" aria-controls="tabs-s-dark-panel" aria-selected="false" tabindex="-1">Dark</button>
3610
+ <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-system" aria-controls="tabs-s-system-panel" aria-selected="false" tabindex="-1">System</button>
3355
3611
  </div>
3612
+ <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-light-panel" aria-labelledby="tabs-s-light">The light theme's settings.</div>
3613
+ <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-dark-panel" aria-labelledby="tabs-s-dark" hidden>The dark theme's settings.</div>
3614
+ <div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-system-panel" aria-labelledby="tabs-s-system" hidden>Whatever the system asks for.</div>
3356
3615
  ========================================= */
3357
3616
 
3358
3617
  .aparte-tabs {
@@ -3427,7 +3686,7 @@ aparte-icon {
3427
3686
  }
3428
3687
 
3429
3688
  .aparte-tabs__tab:focus-visible {
3430
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3689
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3431
3690
  outline-offset: var(--aparte-focus-outline-offset);
3432
3691
  }
3433
3692
 
@@ -3444,10 +3703,13 @@ aparte-icon {
3444
3703
 
3445
3704
  .aparte-tabs__panel {
3446
3705
  padding-block: var(--aparte-space-6);
3706
+ /* The tab's own inline inset, so the panel's text starts under the label of the tab
3707
+ that opened it rather than 11px to its left. */
3708
+ padding-inline: var(--aparte-space-5);
3447
3709
  }
3448
3710
 
3449
3711
  .aparte-tabs__panel:focus-visible {
3450
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3712
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3451
3713
  outline-offset: var(--aparte-focus-outline-offset);
3452
3714
  }
3453
3715
 
@@ -3471,6 +3733,9 @@ aparte-icon {
3471
3733
  its own raised chip, rather than an underline beneath a plain row. Same markup,
3472
3734
  same states; only the container and the active tab's fill change. */
3473
3735
  .aparte-tabs--segmented {
3736
+ /* A control, not a bar: the track hugs its segments. As a block-level flex row it
3737
+ inherited its container's width — 1207px of track for 160px of chips at 1280. */
3738
+ display: inline-flex;
3474
3739
  gap: 0;
3475
3740
  padding: var(--aparte-space-1);
3476
3741
  background: var(--aparte-surface-2);
@@ -3490,6 +3755,11 @@ aparte-icon {
3490
3755
  .aparte-tabs--segmented .aparte-tabs__tab[aria-selected='true'] {
3491
3756
  background: var(--aparte-surface-1);
3492
3757
  color: var(--aparte-text);
3758
+ /* Raised RELATIVE to the track, not an absolute surface level: surface-1 over
3759
+ surface-2 read as raised in light and as sunken in dark, the same rule inverting
3760
+ its elevation with the theme. A 1px ring in the border colour says "on top" on
3761
+ either ground. */
3762
+ box-shadow: 0 0 0 var(--aparte-border-width) var(--aparte-border);
3493
3763
  }
3494
3764
  /* =========================================
3495
3765
  aparte-accordion — stacked disclosures
@@ -3506,7 +3776,21 @@ aparte-icon {
3506
3776
  Shipping
3507
3777
  <aparte-icon name="expand"></aparte-icon>
3508
3778
  </summary>
3509
- <div class="aparte-accordion__panel">Free above 50 €.</div>
3779
+ <div class="aparte-accordion__panel">Free above 50 €, otherwise 4,90 €. Two to four working days.</div>
3780
+ </details>
3781
+ <details class="aparte-accordion__item">
3782
+ <summary class="aparte-accordion__header">
3783
+ Returns
3784
+ <aparte-icon name="expand"></aparte-icon>
3785
+ </summary>
3786
+ <div class="aparte-accordion__panel">Thirty days, in the original packaging.</div>
3787
+ </details>
3788
+ <details class="aparte-accordion__item">
3789
+ <summary class="aparte-accordion__header">
3790
+ Payment
3791
+ <aparte-icon name="expand"></aparte-icon>
3792
+ </summary>
3793
+ <div class="aparte-accordion__panel">Card, PayPal, or an invoice for a company account.</div>
3510
3794
  </details>
3511
3795
  </div>
3512
3796
 
@@ -3574,7 +3858,7 @@ aparte-icon {
3574
3858
  }
3575
3859
 
3576
3860
  .aparte-accordion__header:focus-visible {
3577
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3861
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3578
3862
  outline-offset: var(--aparte-focus-outline-offset);
3579
3863
  }
3580
3864
 
@@ -3584,13 +3868,22 @@ aparte-icon {
3584
3868
  opacity: var(--aparte-disabled-opacity);
3585
3869
  }
3586
3870
 
3587
- .aparte-accordion__icon {
3871
+ /* Two spellings for the chevron, because the header's documented markup is a bare
3872
+ `<aparte-icon>` and a renderer that wraps a raw glyph needs a class to hang it on:
3873
+ the wrapper `.aparte-accordion__icon` (the thinking block), or an `<aparte-icon>` /
3874
+ `svg.aparte-icon` sitting directly in the header. Both are sized and both turn — the
3875
+ documented form used to match neither rule, so the kit's own example showed an open
3876
+ panel under a chevron still pointing down. */
3877
+ .aparte-accordion__icon,
3878
+ .aparte-accordion__header > aparte-icon,
3879
+ .aparte-accordion__header > .aparte-icon {
3880
+ display: inline-flex;
3588
3881
  flex-shrink: 0;
3589
- /* Sized off the same step the button recipe already uses for an icon glyph
3590
- (`--aparte-btn-icon-size: var(--aparte-space-8)`), so a disclosure chevron
3591
- reads as the same weight of icon as everywhere else in the library. */
3592
- width: var(--aparte-space-8);
3593
- height: var(--aparte-space-8);
3882
+ /* Fed to the glyph through its own knob, not drawn as a width/height: the icon
3883
+ recipe reads `--aparte-icon-size`, and a box declared here around it would leave
3884
+ the glyph at its loose default. The step is the one the button recipe uses for an
3885
+ icon glyph, so a disclosure chevron reads as the same weight of icon everywhere. */
3886
+ --aparte-icon-size: var(--aparte-space-8);
3594
3887
  color: var(--aparte-text-muted);
3595
3888
  transition: transform var(--aparte-duration-slow) var(--aparte-ease);
3596
3889
  }
@@ -3599,7 +3892,11 @@ aparte-icon {
3599
3892
  puts aria-expanded on the header itself; native <details> puts `open` on the
3600
3893
  ancestor and gives the browser no attribute to select on at all. */
3601
3894
  .aparte-accordion__header[aria-expanded='true'] .aparte-accordion__icon,
3602
- details[open] > .aparte-accordion__header .aparte-accordion__icon {
3895
+ .aparte-accordion__header[aria-expanded='true'] > aparte-icon,
3896
+ .aparte-accordion__header[aria-expanded='true'] > .aparte-icon,
3897
+ details[open] > .aparte-accordion__header .aparte-accordion__icon,
3898
+ details[open] > .aparte-accordion__header > aparte-icon,
3899
+ details[open] > .aparte-accordion__header > .aparte-icon {
3603
3900
  transform: rotate(180deg);
3604
3901
  }
3605
3902
 
@@ -3673,10 +3970,13 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3673
3970
  width: max-content;
3674
3971
  min-width: var(--aparte-menu-min-width);
3675
3972
  max-width: var(--aparte-menu-max-width);
3973
+ /* The two caps are the box's outer width, padding and border included — same rule
3974
+ as the popover, for the same reason. */
3975
+ box-sizing: border-box;
3676
3976
  padding: var(--aparte-space-2);
3677
3977
  background: var(--aparte-surface-1);
3678
3978
  border: var(--aparte-border-width) solid var(--aparte-border);
3679
- border-radius: var(--aparte-radius-lg);
3979
+ border-radius: var(--aparte-radius-menu);
3680
3980
  box-shadow: var(--aparte-menu-shadow);
3681
3981
  color: var(--aparte-text);
3682
3982
  z-index: var(--aparte-z-dropdown);
@@ -3722,7 +4022,7 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3722
4022
  }
3723
4023
 
3724
4024
  .aparte-menu__item:focus-visible {
3725
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
4025
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3726
4026
  outline-offset: var(--aparte-focus-outline-offset);
3727
4027
  }
3728
4028
 
@@ -3735,14 +4035,14 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3735
4035
  pointer-events: none;
3736
4036
  }
3737
4037
 
3738
- /* The check mark for menuitemcheckbox / menuitemradio. Reserved as an invisible
3739
- glyph on every checkable item (matched by the presence of aria-checked, true or
3740
- false) so labels in a mixed checked/unchecked list stay aligned to one gutter;
3741
- a plain role="menuitem" without aria-checked gets no gutter at all. Text colour
3742
- rather than a fill or a box-shadow is deliberate: a glyph inherits `color`,
3743
- which forced-colors mode preserves, so the checked state survives that mode
3744
- with no separate override. */
3745
- .aparte-menu__item[aria-checked]::before {
4038
+ /* The check mark for menuitemcheckbox / menuitemradio. The gutter is reserved on EVERY
4039
+ item of a panel that holds a checkable one — as an invisible glyph — so all the
4040
+ labels of that panel share one edge. It used to be reserved per checkable item, and
4041
+ a plain role="menuitem" beside a checkable one started 16px further left: two label
4042
+ edges in one menu. Text colour rather than a fill or a box-shadow is deliberate: a
4043
+ glyph inherits `color`, which forced-colors mode preserves, so the checked state
4044
+ survives that mode with no separate override. */
4045
+ .aparte-menu:has(.aparte-menu__item[aria-checked]) .aparte-menu__item::before {
3746
4046
  content: '✓';
3747
4047
  flex-shrink: 0;
3748
4048
  visibility: hidden;
@@ -3817,11 +4117,14 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3817
4117
  display: flex;
3818
4118
  flex-direction: column;
3819
4119
  gap: var(--aparte-space-3);
4120
+ /* The cap is the box's outer width: it measured 342px for a documented 320 (padding
4121
+ and border outside the cap) until the recipe said so itself. */
4122
+ box-sizing: border-box;
3820
4123
  max-width: var(--aparte-popover-max-width);
3821
4124
  padding: var(--aparte-space-5);
3822
4125
  background: var(--aparte-surface-1);
3823
4126
  border: var(--aparte-border-width) solid var(--aparte-border);
3824
- border-radius: var(--aparte-radius-lg);
4127
+ border-radius: var(--aparte-radius-popover);
3825
4128
  box-shadow: var(--aparte-popover-shadow);
3826
4129
  color: var(--aparte-text);
3827
4130
  font-size: var(--aparte-font-size-md);
@@ -3884,7 +4187,7 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3884
4187
  background: var(--aparte-surface-1);
3885
4188
  color: var(--aparte-text);
3886
4189
  border: var(--aparte-border-width) solid var(--aparte-border);
3887
- border-radius: var(--aparte-radius-xl);
4190
+ border-radius: var(--aparte-radius-dialog);
3888
4191
  box-shadow: var(--aparte-menu-shadow);
3889
4192
  }
3890
4193
 
@@ -3960,11 +4263,20 @@ body:has(.aparte-dialog[open]) {
3960
4263
  .aparte-dialog:modal {
3961
4264
  width: 100%;
3962
4265
  max-width: 100%;
3963
- height: 100dvh;
4266
+ /* As tall as its content, capped at the screen, sitting on the bottom edge: a sheet
4267
+ stretched to 100dvh held a label, a field and two buttons over 586px of nothing.
4268
+ `fit-content`, not `auto` — on a positioned box with both block insets set, an
4269
+ auto height FILLS them (CSS 2 §10.6.4: the auto margins go to 0 and the height
4270
+ is solved for), so `auto` measured the same 720 the explicit 100dvh had. What
4271
+ the UA gives a dialog is `fit-content`, and it is what lets the auto top margin
4272
+ push the sheet onto the bottom edge. And the safe areas: the ✕ used to sit 6px
4273
+ from the physical top. */
4274
+ height: fit-content;
3964
4275
  max-height: 100dvh;
3965
- margin: 0;
4276
+ margin: auto 0 0;
4277
+ padding-block: env(safe-area-inset-top, 0px) env(safe-area-inset-bottom, 0px);
3966
4278
  border: 0;
3967
- border-radius: 0;
4279
+ border-radius: var(--aparte-radius-dialog) var(--aparte-radius-dialog) 0 0;
3968
4280
  }
3969
4281
  }
3970
4282
 
@@ -4001,23 +4313,22 @@ body:has(.aparte-dialog[open]) {
4001
4313
  and skips the dark-theme override block for that reason; the artifact "paper" tokens
4002
4314
  are the same decision made for the same reason.
4003
4315
 
4004
- <span style="position: relative; display: inline-block; margin-block-start: 2.5rem">
4005
- <div class="aparte-tooltip" role="tooltip"
4006
- style="position: absolute; bottom: calc(100% + 8px); left: 50%; translate: -50% 0">
4316
+ <span class="aparte-tooltip-anchor" style="margin-block-start: 2.5rem; margin-inline-start: 5rem">
4317
+ <div class="aparte-tooltip" data-side="top" role="tooltip">
4007
4318
  Copy to clipboard
4008
4319
  <span class="aparte-tooltip__arrow"></span>
4009
4320
  </div>
4010
4321
  <button class="aparte-btn aparte-btn--icon" aria-label="Copy"><aparte-icon name="copy"></aparte-icon></button>
4011
4322
  </span>
4012
4323
 
4013
- The two inline styles are the caller's half, shown rather than described: this file
4014
- draws the box and the arrow, and NOTHING here positions either. An example that
4015
- omitted them rendered as a black label with an arrow pointing at empty space, which
4016
- is what the preview frame beside it showed.
4017
-
4018
- The arrow points DOWN by default — the common case, a tooltip above the thing it
4019
- labels — and `data-side="bottom"` flips it. Placing the box is yours, as with the
4020
- popover; only the arrow's two positions are here.
4324
+ `data-side` places it — `top`, `bottom`, `start` or `end` — against the
4325
+ `.aparte-tooltip-anchor` that wraps the trigger, `--aparte-tooltip-gap` away, and turns
4326
+ the arrow to match. The two inline styles that used to do this in the example were a
4327
+ knob missing, not a division of labour: a demo that needs inline styles to work is a
4328
+ recipe with a parameter it forgot. What stays yours is the viewport: nothing here flips
4329
+ a tooltip that would leave the screen — that needs script, a positioning library's job.
4330
+ Without `data-side` the box is not positioned at all, as before, and its placement is
4331
+ yours (the one inline style left in the example is the demo's own margin).
4021
4332
 
4022
4333
  `pointer-events: none` is not a detail: a tooltip describes what the pointer is over,
4023
4334
  so it must never itself become that thing, or hovering it re-triggers the hover that
@@ -4034,11 +4345,17 @@ body:has(.aparte-dialog[open]) {
4034
4345
 
4035
4346
  position: relative;
4036
4347
  display: inline-block;
4348
+ vertical-align: middle;
4349
+ /* As wide as its label, up to the cap. With a `max-width` and no intrinsic width an
4350
+ absolutely-positioned chip shrinks to `min-content`: "Copy to clipboard" broke into
4351
+ two lines at every width, 71px wide — the widest word plus padding. */
4352
+ width: max-content;
4353
+ box-sizing: border-box;
4037
4354
  max-width: var(--aparte-tooltip-max-width);
4038
4355
  padding: var(--aparte-space-2) var(--aparte-space-4);
4039
4356
  background: var(--aparte-tooltip-bg);
4040
4357
  color: var(--aparte-tooltip-color);
4041
- border-radius: var(--aparte-radius-sm);
4358
+ border-radius: var(--aparte-radius-tooltip);
4042
4359
  box-shadow: var(--aparte-tooltip-shadow);
4043
4360
  font-size: var(--aparte-font-size-sm);
4044
4361
  line-height: var(--aparte-line-height-snug);
@@ -4050,12 +4367,49 @@ body:has(.aparte-dialog[open]) {
4050
4367
  pointer-events: none;
4051
4368
  }
4052
4369
 
4053
- /* Points down by default — the common case of a tooltip sitting above the thing
4054
- it labels. `[data-side="bottom"]` flips it for a tooltip placed below instead.
4055
- Horizontal centring uses inline-start + a negative inline-start margin rather
4056
- than a transform, so it re-centres correctly under `direction: rtl` without a
4057
- second rule. Placement to either side is the caller's own transform math, the
4058
- same "position is the caller's job" split as the popover above. */
4370
+ /* Where the box goes: the anchor is the trigger's wrapper and the tooltip is placed
4371
+ against it. `translate` centres the box on the axis across the side: with a logical
4372
+ inset of 50% the box's start edge sits at the anchor's middle, and moving it back by
4373
+ half its own size centres it — in both writing directions, because the inset flips
4374
+ with the direction and the translate is half the box either way. */
4375
+ .aparte-tooltip-anchor {
4376
+ position: relative;
4377
+ display: inline-block;
4378
+ vertical-align: middle;
4379
+ }
4380
+
4381
+ .aparte-tooltip[data-side] {
4382
+ position: absolute;
4383
+ }
4384
+
4385
+ .aparte-tooltip[data-side='top'] {
4386
+ inset-block-end: calc(100% + var(--aparte-tooltip-gap));
4387
+ inset-inline-start: 50%;
4388
+ translate: -50% 0;
4389
+ }
4390
+
4391
+ .aparte-tooltip[data-side='bottom'] {
4392
+ inset-block-start: calc(100% + var(--aparte-tooltip-gap));
4393
+ inset-inline-start: 50%;
4394
+ translate: -50% 0;
4395
+ }
4396
+
4397
+ .aparte-tooltip[data-side='start'] {
4398
+ inset-inline-end: calc(100% + var(--aparte-tooltip-gap));
4399
+ inset-block-start: 50%;
4400
+ translate: 0 -50%;
4401
+ }
4402
+
4403
+ .aparte-tooltip[data-side='end'] {
4404
+ inset-inline-start: calc(100% + var(--aparte-tooltip-gap));
4405
+ inset-block-start: 50%;
4406
+ translate: 0 -50%;
4407
+ }
4408
+
4409
+ /* The arrow points DOWN by default — the common case, a tooltip above the thing it
4410
+ labels — and each `data-side` turns it toward the anchor. Centring on the box's edge
4411
+ uses a logical inset plus a negative margin rather than a transform, so it re-centres
4412
+ under `direction: rtl` without a second rule. */
4059
4413
  .aparte-tooltip__arrow {
4060
4414
  position: absolute;
4061
4415
  inset-inline-start: 50%;
@@ -4074,6 +4428,26 @@ body:has(.aparte-dialog[open]) {
4074
4428
  inset-block-start: calc(var(--aparte-space-4) * -0.5);
4075
4429
  }
4076
4430
 
4431
+ /* On the two inline sides the arrow sits on the edge that faces the anchor, centred
4432
+ vertically: the box is to the START of the trigger, so its arrow is on its END edge,
4433
+ and the reverse. */
4434
+ .aparte-tooltip[data-side='start'] .aparte-tooltip__arrow {
4435
+ inset-block-end: auto;
4436
+ inset-block-start: 50%;
4437
+ inset-inline-start: auto;
4438
+ inset-inline-end: calc(var(--aparte-space-4) * -0.5);
4439
+ margin-inline-start: 0;
4440
+ margin-block-start: calc(var(--aparte-space-4) * -0.5);
4441
+ }
4442
+
4443
+ .aparte-tooltip[data-side='end'] .aparte-tooltip__arrow {
4444
+ inset-block-end: auto;
4445
+ inset-block-start: 50%;
4446
+ inset-inline-start: calc(var(--aparte-space-4) * -0.5);
4447
+ margin-inline-start: 0;
4448
+ margin-block-start: calc(var(--aparte-space-4) * -0.5);
4449
+ }
4450
+
4077
4451
  /* ── Forced colors ───────────────────────────────────────────────────────────────
4078
4452
  The menu and the popover already carry a real border by default, so those two
4079
4453
  boxes survive forced-colors mode once background and box-shadow are both
@@ -4116,10 +4490,16 @@ aparte-select {
4116
4490
  align-items: center;
4117
4491
  justify-content: space-between;
4118
4492
  gap: var(--aparte-space-4);
4493
+ box-sizing: border-box;
4494
+ min-block-size: var(--aparte-field-size);
4119
4495
  padding: var(--aparte-space-4) var(--aparte-space-6);
4496
+ /* Its own size: the trigger declared none and took the host page's, so every
4497
+ integrator saw a different select. The control step, like the field and the
4498
+ button. */
4499
+ font-size: var(--aparte-font-size-md);
4120
4500
  background: var(--aparte-select-bg, var(--aparte-surface-1));
4121
- border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border));
4122
- border-radius: var(--aparte-select-radius, var(--aparte-radius-lg));
4501
+ border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border-control));
4502
+ border-radius: var(--aparte-radius-select);
4123
4503
  cursor: pointer;
4124
4504
  transition: border-color var(--aparte-duration-base), box-shadow var(--aparte-duration-base);
4125
4505
  }
@@ -4128,15 +4508,16 @@ aparte-select {
4128
4508
  border-color: var(--aparte-select-border-hover, var(--aparte-primary));
4129
4509
  }
4130
4510
 
4131
- .aparte-select-trigger:focus {
4132
- outline: none;
4133
- border-color: var(--aparte-select-border-focus, var(--aparte-primary));
4134
- box-shadow: var(--aparte-select-ring, var(--aparte-focus-ring));
4511
+ .aparte-select-trigger:focus-visible {
4512
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
4513
+ outline-offset: var(--aparte-focus-outline-offset);
4135
4514
  }
4136
4515
 
4137
4516
  aparte-select[disabled] .aparte-select-trigger {
4138
- opacity: var(--aparte-disabled-opacity);
4139
4517
  cursor: not-allowed;
4518
+ background: var(--aparte-surface-2);
4519
+ color: var(--aparte-text-muted);
4520
+ border-color: var(--aparte-border);
4140
4521
  }
4141
4522
 
4142
4523
  /* The label is a grid with ONE cell: the visible text and, under it, a hidden stack of
@@ -4196,8 +4577,10 @@ aparte-select[open] .aparte-select-chevron {
4196
4577
  position: fixed;
4197
4578
  /* top / left / width are set dynamically by _updatePosition() */
4198
4579
  background: var(--aparte-select-dropdown-bg, var(--aparte-surface-1));
4199
- border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border));
4200
- border-radius: var(--aparte-select-radius, var(--aparte-radius-lg));
4580
+ /* The panel is a region, so its edge is the region border; `--aparte-select-border`
4581
+ is the TRIGGER's edge, a control's, and defaults to the control border. */
4582
+ border: var(--aparte-border-width) solid var(--aparte-border);
4583
+ border-radius: var(--aparte-radius-select);
4201
4584
  box-shadow: var(--aparte-select-shadow);
4202
4585
  z-index: var(--aparte-select-z, var(--aparte-z-dropdown));
4203
4586
  max-height: var(--aparte-select-dropdown-max-height);
@@ -4221,8 +4604,11 @@ aparte-select[open] .aparte-select-chevron {
4221
4604
  font-family: inherit;
4222
4605
  }
4223
4606
 
4224
- .aparte-select-search:focus {
4225
- outline: none;
4607
+ /* Inside, not outside: the field sits at the top of a scrolling panel, and an outset
4608
+ ring would be clipped by it. The one documented exception to the shared offset. */
4609
+ .aparte-select-search:focus-visible {
4610
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
4611
+ outline-offset: calc(-1 * var(--aparte-focus-outline-width));
4226
4612
  }
4227
4613
 
4228
4614
  /* Options container */
@@ -4359,6 +4745,7 @@ aparte-optgroup[collapsed] .aparte-optgroup-chevron {
4359
4745
  }
4360
4746
 
4361
4747
  .aparte-spinner-small {
4748
+ box-sizing: border-box;
4362
4749
  width: var(--aparte-select-spinner-size);
4363
4750
  height: var(--aparte-select-spinner-size);
4364
4751
  border: 2px solid currentColor;
@@ -4366,6 +4753,12 @@ aparte-optgroup[collapsed] .aparte-optgroup-chevron {
4366
4753
  border-radius: 50%;
4367
4754
  /* `aparte-spin` is declared once, in base.css — see the shared-motion block there. */
4368
4755
  animation: aparte-spin var(--aparte-duration-spin) linear infinite;
4756
+ }
4757
+
4758
+ /* The element's own sweep in responsive.css already stops this one; the rule is here
4759
+ so the recipe stops on its own terms too, like every other looping recipe. */
4760
+ @media (prefers-reduced-motion: reduce) {
4761
+ .aparte-spinner-small { animation: none; }
4369
4762
  }/**
4370
4763
  * AparteProgressSpinner — CSS
4371
4764
  *
@@ -4391,16 +4784,23 @@ aparte-progress-spinner svg {
4391
4784
  overflow: visible;
4392
4785
  }
4393
4786
 
4787
+ /* The same stroke as the CSS ring (`.aparte-spinner`), in SCREEN pixels: without
4788
+ `vector-effect` a stroke is in viewBox units and scaled with the box — 2.5 of 24 was
4789
+ 1.67px in a 16px spinner, antialiased everywhere and 50 % lighter than its sibling.
4790
+ The track is a visible reference in the control-edge colour: at 15 % of the ink a
4791
+ determinate 62 % was the percentage of a circle nobody could see. */
4394
4792
  .aparte-spinner-track {
4395
4793
  fill: none;
4396
- stroke: var(--aparte-spinner-track, color-mix(in srgb, currentColor 15%, transparent));
4397
- stroke-width: var(--aparte-spinner-stroke, 2.5);
4794
+ stroke: var(--aparte-spinner-track, var(--aparte-border-control));
4795
+ stroke-width: var(--aparte-spinner-thickness);
4796
+ vector-effect: non-scaling-stroke;
4398
4797
  }
4399
4798
 
4400
4799
  .aparte-spinner-fill {
4401
4800
  fill: none;
4402
4801
  stroke: var(--aparte-spinner-color, currentColor);
4403
- stroke-width: var(--aparte-spinner-stroke, 2.5);
4802
+ stroke-width: var(--aparte-spinner-thickness);
4803
+ vector-effect: non-scaling-stroke;
4404
4804
  stroke-linecap: round;
4405
4805
  transition: stroke-dashoffset 0.25s var(--aparte-ease);
4406
4806
  }
@@ -4435,7 +4835,10 @@ aparte-progress-spinner:not([value]) svg {
4435
4835
 
4436
4836
  /* Both host shapes core recognises get the same layout: the vanilla
4437
4837
  <aparte-chat> element AND a framework wrapper's `[data-aparte-chat]` root
4438
- (React/Vue/Svelte/Angular render `<div data-aparte-chat>`). `[data-aparte-chat]`
4838
+ (React/Vue/Svelte render `<div data-aparte-chat class="aparte-chat-container">`;
4839
+ Angular's host IS `<aparte-chat>` and its shell is the inner
4840
+ `.aparte-chat-container`, which carries no attribute here — the overlay rules
4841
+ below list that third shape for exactly that reason). `[data-aparte-chat]`
4439
4842
  is the SAME selector core uses to resolve the host (see aparte-client /
4440
4843
  aparte-chat-bubble), so one rule covers both — no wrapper-specific class. */
4441
4844
  aparte-chat,
@@ -4448,6 +4851,10 @@ aparte-chat,
4448
4851
  height: 100%;
4449
4852
  width: 100%;
4450
4853
  overflow: hidden;
4854
+ /* The height an author sets IS the box: with the bottom gap below outside it,
4855
+ `style="height: 24rem"` rendered 336px, and a split's seam hung 16px under both
4856
+ panels. */
4857
+ box-sizing: border-box;
4451
4858
  /* The composer used to touch the bottom edge as soon as a conversation started: the
4452
4859
  viewport gives 16px above the composer and there was nothing below it. An app
4453
4860
  cannot fix this from outside without padding the container, which also shrinks the
@@ -4509,16 +4916,26 @@ aparte-chat[overlay-composer] > aparte-chat-viewport,
4509
4916
  z-index: 0;
4510
4917
  }
4511
4918
 
4512
- /* The bottom stack paints over the absolutely positioned viewport. `:not()` is
4513
- exact here: the only child that is not the viewport IS the stack (the scroll
4514
- button lives inside the viewport). */
4515
- aparte-chat[overlay-composer] > :not(aparte-chat-viewport),
4516
- [data-aparte-chat][overlay-composer] > :not(aparte-chat-viewport),
4517
- .aparte-chat-container[overlay-composer] > :not(aparte-chat-viewport) {
4919
+ /* The bottom stack paints over the absolutely positioned viewport. The `:not()` names
4920
+ what is NOT the stack: the viewport (the scroll button lives inside it), and the
4921
+ scroll rail, which is a child of the shell too. `position: relative` here beat the
4922
+ rail's own `absolute`, so in overlay mode the floating rail dropped into the flow
4923
+ and pushed the composer down — the one child that floats by design has to be named. */
4924
+ aparte-chat[overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail),
4925
+ [data-aparte-chat][overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail),
4926
+ .aparte-chat-container[overlay-composer] > :not(aparte-chat-viewport, aparte-scroll-rail) {
4518
4927
  position: relative;
4519
4928
  z-index: var(--aparte-z-raised);
4520
4929
  }
4521
4930
 
4931
+ /* A thing that floats has to be seen floating. The overlaid composer's shell shares the
4932
+ transcript's ground, so a z-index alone left it flat on the words it covers. */
4933
+ aparte-chat[overlay-composer] .aparte-composer-shell,
4934
+ [data-aparte-chat][overlay-composer] .aparte-composer-shell,
4935
+ .aparte-chat-container[overlay-composer] .aparte-composer-shell {
4936
+ box-shadow: var(--aparte-composer-overlay-shadow);
4937
+ }
4938
+
4522
4939
  /* center-empty: the shell centers the composer as a welcome state until the first
4523
4940
  message, then slides to the normal layout. The component toggles [data-empty]
4524
4941
  on itself by watching its viewport — only `flex-grow` animates, so the composer
@@ -4541,6 +4958,17 @@ aparte-chat[center-empty][data-empty] > aparte-chat-viewport {
4541
4958
  height: auto;
4542
4959
  }
4543
4960
 
4961
+ /* And no block room at all: at `auto` the empty viewport still stood 32px tall — the
4962
+ rows' wrapper keeps its block padding with no row in it — so `center` centred three
4963
+ items of which the first was invisible, and the welcome group landed 16px low,
4964
+ measured. The padding goes, not the box: capping the viewport itself left a 32px
4965
+ scroll surface inside a 0px box, and "an empty transcript must not overflow" is a
4966
+ browser test every example runs. Inline geometry is kept — the composer's inset is
4967
+ measured from this element. */
4968
+ aparte-chat[center-empty][data-empty] > aparte-chat-viewport .aparte-messages-wrapper {
4969
+ padding-block: 0;
4970
+ }
4971
+
4544
4972
  /* =========================================
4545
4973
  aparte-chat-viewport
4546
4974
  ========================================= */
@@ -4607,16 +5035,16 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
4607
5035
 
4608
5036
  /* ─── Scroll-to-bottom button ─────────────────────────────────────────────── */
4609
5037
 
4610
- /* The button wears `aparte-btn--lg aparte-btn--circle`, and that IS its shape: 36px is
4611
- exactly `--aparte-btn-size-lg`, and `--circle` is the round one. So the box, the
4612
- corner and the 20px glyph all come from the recipe, and what stays here is only what
4613
- is genuinely this button's — where it floats, and its own surface.
5038
+ /* The button wears `aparte-btn--lg aparte-btn--circle`: `--lg` gives it the 20px glyph
5039
+ and the lg step of the scale (36px), `--circle` the round corner, and
5040
+ `--aparte-scroll-btn-size` (the same step, named for this button so a theme can move
5041
+ it alone) is fed to the recipe below. What stays here is only what is genuinely this
5042
+ button's — where it floats, and its own surface.
4614
5043
 
4615
5044
  It used to redraw all three: its own width/height, its own `border-radius: 50%`, and
4616
5045
  an icon-size override that existed only because the box was declared OUTSIDE the size
4617
- scale, so the glyph stayed at the medium default — a 16px arrow in a 36px circle, 44%
4618
- of its box where the rest of the library reads at ~57%. Naming the size instead makes
4619
- the ratio fall out for free, and the override disappears with it.
5046
+ scale, so the glyph stayed at the medium default — a 16px arrow in a 36px circle.
5047
+ Naming the size on the recipe's own token makes the override disappear.
4620
5048
 
4621
5049
  `--aparte-scroll-btn-size` stays the public knob, fed to the recipe. */
4622
5050
  .aparte-scroll-btn {
@@ -4888,7 +5316,11 @@ aparte-chat-bubble {
4888
5316
  box-sizing: border-box;
4889
5317
  display: flex;
4890
5318
  gap: var(--aparte-message-gap);
4891
- padding: var(--aparte-message-padding);
5319
+ /* The two parts, not the composite `--aparte-message-padding` that used to join them
5320
+ on `:root`: a custom property is substituted where it is declared, so the composite
5321
+ froze both parts at the root and the narrow-container override of them in
5322
+ responsive.css never reached this row. */
5323
+ padding: var(--aparte-message-padding-block) var(--aparte-message-padding-inline);
4892
5324
  width: 100%;
4893
5325
  max-width: var(--aparte-message-max-width);
4894
5326
  margin: 0 auto;
@@ -4976,6 +5408,11 @@ aparte-chat-bubble {
4976
5408
  opacity: 0;
4977
5409
  transition: opacity var(--aparte-duration-base) var(--aparte-ease);
4978
5410
  height: var(--aparte-action-bar-btn-size);
5411
+ /* The INK meets the column, not the box: a glyph is centred in its 24px button, so
5412
+ its ink began half the slack — (24 − 12) / 2 — to the right of the paragraph above.
5413
+ Measured at +5 to +8px in 14 previews. The bar takes that slack back and the box
5414
+ overhangs into the message padding, where nothing else lives. */
5415
+ margin-inline-start: calc((var(--aparte-icon-size-sm) - var(--aparte-btn-size-sm)) / 2);
4979
5416
  }
4980
5417
 
4981
5418
  .aparte-message:hover .aparte-action-bar,
@@ -5040,8 +5477,6 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
5040
5477
  inset-block-end: calc(-1 * (var(--aparte-message-padding-block) + var(--aparte-message-gap)));
5041
5478
  margin: 0;
5042
5479
  z-index: 1;
5043
- /* Smaller than the last reply's 28px: this one lives in the padding. */
5044
- --aparte-action-bar-btn-size: 24px;
5045
5480
  }
5046
5481
  }
5047
5482
 
@@ -5090,12 +5525,11 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
5090
5525
  display: none;
5091
5526
  }
5092
5527
 
5528
+ /* The arrows wear `--sm` and the recipe draws the box: this rule used to redraw
5529
+ width/height to a 20px token over it — two sources for one measure. */
5093
5530
  .aparte-branch-prev,
5094
5531
  .aparte-branch-next {
5095
5532
  color: var(--aparte-branch-picker-btn-color);
5096
- width: var(--aparte-branch-picker-btn-size);
5097
- height: var(--aparte-branch-picker-btn-size);
5098
- font-size: var(--aparte-branch-picker-btn-icon-size);
5099
5533
  }
5100
5534
 
5101
5535
  .aparte-branch-prev:hover:not(:disabled),
@@ -5104,9 +5538,11 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
5104
5538
  background: var(--aparte-action-bar-btn-hover-bg);
5105
5539
  }
5106
5540
 
5541
+ /* The button recipe draws the disabled state (a neutral ground, the muted ink); an
5542
+ opacity over it read the arrow at 1.74:1 — as absent, where the picker means "no
5543
+ further". `default` and not `not-allowed`: the end of a list is not a refusal. */
5107
5544
  .aparte-branch-prev:disabled,
5108
5545
  .aparte-branch-next:disabled {
5109
- opacity: var(--aparte-disabled-opacity);
5110
5546
  cursor: default;
5111
5547
  }
5112
5548
 
@@ -5206,7 +5642,9 @@ aparte-chat-bubble[data-kind="compaction"] .aparte-message-content {
5206
5642
  Unscoped so it greys the composer in EVERY host (vanilla shell + all wrappers),
5207
5643
  not only a `[data-aparte-chat]` direct child. */
5208
5644
  aparte-composer[data-model-gated] {
5209
- opacity: var(--aparte-disabled-opacity);
5645
+ /* No group fade: each control draws its own disabled state (the editor's muted ink,
5646
+ the send button's neutral ground), so the text stays legible while it waits. */
5647
+ cursor: not-allowed;
5210
5648
  }
5211
5649
  aparte-composer[data-model-gated] aparte-composer-send {
5212
5650
  pointer-events: none;
@@ -5362,7 +5800,7 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
5362
5800
 
5363
5801
  /* Disabled state */
5364
5802
  .aparte-editor[contenteditable="false"] {
5365
- opacity: var(--aparte-disabled-opacity);
5803
+ color: var(--aparte-text-muted);
5366
5804
  cursor: not-allowed;
5367
5805
  }
5368
5806
 
@@ -5403,9 +5841,9 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
5403
5841
  }
5404
5842
 
5405
5843
  .aparte-send-button:disabled {
5406
- opacity: var(--aparte-disabled-opacity);
5407
5844
  cursor: not-allowed;
5408
- background: var(--aparte-send-disabled-bg, var(--aparte-primary));
5845
+ background: var(--aparte-send-disabled-bg, var(--aparte-surface-2));
5846
+ color: var(--aparte-text-muted);
5409
5847
  }
5410
5848
 
5411
5849
  /* =========================================
@@ -5499,11 +5937,10 @@ aparte-model-selector[hidden],
5499
5937
  border-color: var(--aparte-primary);
5500
5938
  }
5501
5939
 
5502
- .aparte-provider-select:focus,
5503
- .aparte-model-select:focus {
5504
- outline: none;
5505
- border-color: var(--aparte-primary);
5506
- box-shadow: var(--aparte-focus-ring);
5940
+ .aparte-provider-select:focus-visible,
5941
+ .aparte-model-select:focus-visible {
5942
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
5943
+ outline-offset: var(--aparte-focus-outline-offset);
5507
5944
  }
5508
5945
 
5509
5946
  /* Dark mode overrides for selects */
@@ -5546,13 +5983,14 @@ aparte-composer {
5546
5983
  it would then be the containing block of the `position: fixed` select dropdown
5547
5984
  that exists to escape `overflow: hidden` (select.css, and the same warning in
5548
5985
  split.css). So below 520px the transcript tightens to 8px and this stays at 16.
5549
- A flat 16 was measured against a step: the step wins for vanilla at phone width
5550
- and LOSES for framework-managed, which is the majority path, because the
5551
- transcript's own step targets a class framework mode never builds. Flat is a
5552
- uniform 10px worst case in both modes — which is the scrollbar gutter the
5553
- transcript reserves and the composer cannot, and which is 0 on the overlay
5554
- scrollbars every phone has. */
5555
- padding-inline: var(--aparte-viewport-padding);
5986
+ So the composer does not guess: the viewport MEASURES where its rows start — the
5987
+ transcript's padding plus the scrollbar gutter it reserves on both edges, at
5988
+ whatever step its container query has taken — and publishes it on the chat host as
5989
+ `--aparte-transcript-inset`. Padding by that puts the composer's box on the row's
5990
+ box at every width. Measured before: 10px apart at 768, the gutter's half at 1280,
5991
+ three left edges 12px apart on the reading-column demo. The fallback is for a
5992
+ composer with no viewport beside it. */
5993
+ padding-inline: var(--aparte-transcript-inset, var(--aparte-viewport-padding));
5556
5994
  }
5557
5995
 
5558
5996
  aparte-composer-input {
@@ -5565,7 +6003,7 @@ aparte-composer-input {
5565
6003
  aparte-composer-input .aparte-ci-editor {
5566
6004
  display: block;
5567
6005
  width: 100%;
5568
- min-height: var(--aparte-composer-control-size, 44px);
6006
+ min-height: var(--aparte-composer-control-size);
5569
6007
  padding: var(--aparte-input-padding-y) var(--aparte-input-padding-x);
5570
6008
  font-size: var(--aparte-input-font-size);
5571
6009
  line-height: var(--aparte-input-line-height);
@@ -5596,7 +6034,7 @@ aparte-composer-input .aparte-ci-editor:empty::before {
5596
6034
  }
5597
6035
 
5598
6036
  aparte-composer-input .aparte-ci-editor[aria-disabled="true"] {
5599
- opacity: var(--aparte-disabled-opacity);
6037
+ color: var(--aparte-text-muted);
5600
6038
  cursor: not-allowed;
5601
6039
  }
5602
6040
 
@@ -5688,7 +6126,7 @@ aparte-composer.aparte-is-dragover:not(:has(.aparte-composer-shell)) {
5688
6126
 
5689
6127
  /* Inner row — flex container for input + buttons */
5690
6128
  /* Action buttons sized to the input's single-line height — they derive from
5691
- --aparte-composer-control-size (default 44px), so a theme resizes the whole
6129
+ --aparte-composer-control-size (default: the lg step, 36px; the touch target under a coarse pointer), so a theme resizes the whole
5692
6130
  composer control set with ONE variable and alignment holds. Combined with
5693
6131
  the row's `align-items: flex-end` this makes the buttons read as vertically
5694
6132
  centered against a single line of text, and keeps them anchored to the
@@ -5720,7 +6158,11 @@ aparte-composer.aparte-is-dragover:not(:has(.aparte-composer-shell)) {
5720
6158
  align-items: flex-end;
5721
6159
  gap: var(--aparte-space-4);
5722
6160
  padding: var(--aparte-space-4) var(--aparte-space-6);
5723
- --aparte-composer-row-control-size: var(--aparte-composer-control-size, 44px);
6161
+ /* The lg step at rest — 36px, what `--aparte-send-btn-size` says — and the touch
6162
+ target under a coarse pointer (responsive.css). It was 44 everywhere, which made
6163
+ the send button the one control the coarse-pointer block could not enlarge because
6164
+ it already was, and a 62px bar around 15px of text on a desktop. */
6165
+ --aparte-composer-row-control-size: var(--aparte-composer-control-size);
5724
6166
  --aparte-btn-size: var(--aparte-composer-row-control-size);
5725
6167
  }
5726
6168
 
@@ -5838,6 +6280,10 @@ aparte-composer-cancel .aparte-cc-button:hover {
5838
6280
  `.aparte-accordion__header` / `__item` / `__panel` and adds these two lines: a
5839
6281
  quieter face than a real accordion header, and the padding the left rail needs. */
5840
6282
  .aparte-thinking-header {
6283
+ /* The chevron sits beside the label: the accordion recipe puts it at the row's far
6284
+ end, which is what an accordion is, but this is a one-line disclosure, and "Reasoning"
6285
+ and its chevron were 692px apart at 1280 (T18). */
6286
+ justify-content: flex-start;
5841
6287
  padding: var(--aparte-space-1) 0 var(--aparte-space-1) var(--aparte-space-5);
5842
6288
  font-size: var(--aparte-font-size-sm);
5843
6289
  color: var(--aparte-thinking-text);
@@ -5869,7 +6315,7 @@ aparte-composer-cancel .aparte-cc-button:hover {
5869
6315
  margin-inline-end: var(--aparte-space-4);
5870
6316
  /* Logical property */
5871
6317
  text-transform: uppercase;
5872
- opacity: 0.7;
6318
+ /* The header's ink is already the muted one; an `opacity: .7` on top muted it twice. */
5873
6319
  }
5874
6320
 
5875
6321
 
@@ -5928,11 +6374,14 @@ aparte-composer-cancel .aparte-cc-button:hover {
5928
6374
  flex: none;
5929
6375
  width: var(--aparte-space-8);
5930
6376
  height: var(--aparte-space-8);
5931
- opacity: 0.5;
5932
- transition: transform var(--aparte-duration-base);
6377
+ /* Quiet is a colour, not an opacity: at 0.5 the chevron measured 3.00:1 — on the
6378
+ WCAG floor exactly — and it is the control that reveals a `delete_file`'s
6379
+ arguments. The muted ink at rest, the full ink under the pointer. */
6380
+ color: var(--aparte-text-muted);
6381
+ transition: transform var(--aparte-duration-base), color var(--aparte-duration-base);
5933
6382
  }
5934
6383
  .aparte-tool-toggle svg { width: 100%; height: 100%; display: block; }
5935
- .aparte-tool-summary:hover .aparte-tool-toggle { opacity: 0.9; }
6384
+ .aparte-tool-summary:hover .aparte-tool-toggle { color: var(--aparte-text); }
5936
6385
  details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180deg); }
5937
6386
  /* A rule down the leading edge, so the disclosure reads as belonging to the
5938
6387
  pill above it rather than continuing the message text below. Without it
@@ -5947,7 +6396,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5947
6396
  }
5948
6397
  .aparte-tool-part-label {
5949
6398
  display: block; font-size: var(--aparte-font-size-xs); text-transform: uppercase;
5950
- letter-spacing: 0.04em; opacity: 0.55; margin-bottom: var(--aparte-space-1);
6399
+ letter-spacing: 0.04em; color: var(--aparte-text-muted); margin-bottom: var(--aparte-space-1);
5951
6400
  }
5952
6401
  /* A tool's input and result WRAP. They are a message, a path, a JSON object — not
5953
6402
  code to read line by line — and a `<pre>` keeps `white-space: pre` by default, so
@@ -5969,6 +6418,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5969
6418
  green, which made a finished step shout louder than the reply it belongs to. */
5970
6419
  .aparte-tool-label {
5971
6420
  display: inline-flex;
6421
+ vertical-align: middle;
5972
6422
  align-items: center;
5973
6423
  gap: var(--aparte-space-3);
5974
6424
  min-width: 0;
@@ -5985,7 +6435,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5985
6435
  place-items: center;
5986
6436
  width: 1em; height: 1em;
5987
6437
  flex: none;
5988
- opacity: 0.7;
6438
+ color: var(--aparte-text-muted);
5989
6439
  }
5990
6440
  .aparte-tool-icon svg { width: 1em; height: 1em; display: block; }
5991
6441
  .aparte-tool-name { font-family: var(--aparte-code-font-family); }
@@ -5993,9 +6443,12 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5993
6443
  Pushed to the far end by DOM order plus an auto inline-start margin, which is
5994
6444
  the placement rule this repo already settled on: no left/right in a name, so
5995
6445
  a right-to-left locale mirrors it for free. */
6446
+ /* Beside the tool's name, not at the row's far end: with `margin-inline-start: auto` the
6447
+ state word drifted 63px from the name at 375 and 596px at 1280. The state belongs to
6448
+ the label (T18). */
5996
6449
  .aparte-tool-state {
5997
- display: inline-flex; align-items: center; gap: var(--aparte-space-2);
5998
- margin-inline-start: auto;
6450
+ display: inline-flex;
6451
+ vertical-align: middle; align-items: center; gap: var(--aparte-space-2);
5999
6452
  font-size: var(--aparte-font-size-sm); font-weight: var(--aparte-font-weight-medium);
6000
6453
  color: var(--aparte-text-muted);
6001
6454
  user-select: none;
@@ -6031,44 +6484,22 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
6031
6484
  }
6032
6485
 
6033
6486
  /* Pipeline waiting */
6034
- .aparte-error-icon-wrapper {
6035
- flex-shrink: 0;
6036
- color: var(--aparte-error-solid);
6037
- display: flex;
6038
- align-items: flex-start;
6039
- padding-top: var(--aparte-space-1);
6040
- }
6041
-
6042
- .aparte-error-icon-wrapper svg {
6043
- width: var(--aparte-error-icon-size);
6044
- height: var(--aparte-error-icon-size);
6045
- }
6046
-
6047
- .aparte-error-content {
6048
- flex: 1;
6049
- min-width: 0;
6050
- }
6051
-
6052
- .aparte-error-title {
6053
- font-weight: var(--aparte-font-weight-semibold);
6054
- font-size: var(--aparte-font-size-lg);
6055
- margin-bottom: var(--aparte-space-2);
6056
- color: var(--aparte-error-title);
6057
- }
6058
-
6059
- .aparte-error-message {
6060
- font-size: var(--aparte-font-size-lg);
6061
- line-height: var(--aparte-line-height-normal);
6062
- }
6063
-
6064
- .aparte-error-details {
6065
- margin-top: var(--aparte-space-4);
6066
- padding-top: var(--aparte-space-4);
6067
- border-top: var(--aparte-border-width) solid var(--aparte-error-border);
6487
+ /* The error segment WEARS the alert recipe — frame and parts (`aparte-alert__icon`,
6488
+ `__body`, `__title`, `__message` come from display/alert.css). It used to put the
6489
+ recipe's class on its root and redraw every part under classes of its own, with a 20px
6490
+ literal for the icon among derived tokens and a title colour nothing else used. What is
6491
+ genuinely the segment's own is the one part the alert has no word for: the details
6492
+ block — a transport's stack, a status line — set as code under the message. */
6493
+ .aparte-segment-error__details {
6494
+ margin: var(--aparte-space-4) 0 0;
6495
+ padding-block-start: var(--aparte-space-4);
6496
+ border-block-start: var(--aparte-border-width) solid var(--aparte-error-border);
6068
6497
  font-family: var(--aparte-code-font-family);
6069
6498
  font-size: var(--aparte-font-size-sm);
6070
- opacity: 0.9;
6499
+ line-height: var(--aparte-line-height-normal);
6500
+ color: var(--aparte-text-secondary);
6071
6501
  white-space: pre-wrap;
6502
+ overflow-wrap: anywhere;
6072
6503
  }
6073
6504
 
6074
6505
  /* ─────────────────────────────────────────────────────────────────────────────
@@ -6182,7 +6613,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
6182
6613
  a consumer's screenshot, then here. The padding gives the ring that much room and
6183
6614
  the negative margin gives it back to the layout, so nothing moves; `scroll-padding`
6184
6615
  keeps a keyboard-focused option's ring inside the viewport when the list scrolls. */
6185
- --aparte-elic-ring-room: calc(var(--aparte-focus-outline-width) + var(--aparte-btn-focus-offset));
6616
+ --aparte-elic-ring-room: calc(var(--aparte-focus-outline-width) + var(--aparte-focus-outline-offset));
6186
6617
  padding: var(--aparte-elic-ring-room);
6187
6618
  margin: calc(-1 * var(--aparte-elic-ring-room));
6188
6619
  scroll-padding: var(--aparte-elic-ring-room);
@@ -6226,7 +6657,10 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
6226
6657
  .aparte-elic-options {
6227
6658
  display: flex;
6228
6659
  flex-direction: column;
6229
- gap: var(--aparte-space-1);
6660
+ /* At least the focus ring's outset: at 2px the next row painted over the bottom of
6661
+ the focused row's ring and ~8px of it survived. Any list of options that wears an
6662
+ outset ring needs a gap at least as wide as the offset. */
6663
+ gap: var(--aparte-space-3);
6230
6664
  }
6231
6665
 
6232
6666
  /* The row is `.aparte-field-choice`; the panel only tightens its padding, because a
@@ -6300,22 +6734,19 @@ textarea.aparte-elic-text {
6300
6734
  min-height: var(--aparte-elic-textarea-min-height);
6301
6735
  }
6302
6736
 
6303
- .aparte-elic-other-input:focus,
6304
- .aparte-elic-text:focus {
6305
- border-color: var(--aparte-primary);
6306
- }
6307
-
6308
6737
  .aparte-elic-option--recommended {
6309
6738
  border-color: var(--aparte-primary);
6310
6739
  background: color-mix(in srgb, var(--aparte-primary) 6%, transparent);
6311
6740
  }
6312
6741
  /* One ring at a time. The recommended option is the first to receive focus, and its
6313
6742
  tinted border under the recipe's focus ring read as a double outline (UI audit
6314
- 2026-08-28, §8.3 #9). While it holds focus the ring is the indicator; the tint
6315
- comes back when focus leaves. */
6743
+ 2026-08-28, §8.3 #9). While it holds focus the ring is the one edge, so the border
6744
+ steps aside — and ONLY the border. This rule used to clear the background too, which
6745
+ made the recommended row the only one in the list with no ground at all: sampled on
6746
+ the preview it rendered the page byte for byte while its sisters did not. The
6747
+ recommended state draws in addition to the resting state, never instead of it. */
6316
6748
  .aparte-elic-option--recommended:focus-within {
6317
- background: transparent;
6318
- border-color: var(--aparte-border-focus);
6749
+ border-color: transparent;
6319
6750
  }
6320
6751
 
6321
6752
  /* The whole-request escape, in the corner and out of the way of anything that
@@ -6424,7 +6855,7 @@ textarea.aparte-elic-text {
6424
6855
 
6425
6856
  .aparte-approval-args:focus-visible {
6426
6857
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6427
- outline-offset: 1px;
6858
+ outline-offset: var(--aparte-focus-outline-offset);
6428
6859
  }
6429
6860
 
6430
6861
  /* The refusal's reason, in the user's words. Quiet by default and one line tall: it is
@@ -6444,9 +6875,9 @@ textarea.aparte-elic-text {
6444
6875
  min-height: 1.6em;
6445
6876
  }
6446
6877
 
6447
- .aparte-approval-instruction:focus {
6448
- outline: none;
6449
- border-top-color: var(--aparte-accent);
6878
+ .aparte-approval-instruction:focus-visible {
6879
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6880
+ outline-offset: var(--aparte-focus-outline-offset);
6450
6881
  }
6451
6882
 
6452
6883
  .aparte-approval-instruction::placeholder {
@@ -6595,8 +7026,8 @@ textarea.aparte-elic-text {
6595
7026
  /* Inside the edge: the list clips horizontally (`overflow-x: hidden` in base.css), and
6596
7027
  a ring drawn outside the row was cut on both sides. */
6597
7028
  .aparte-conv-item__select:focus-visible {
6598
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
6599
- outline-offset: calc(-1 * var(--aparte-focus-outline-width));
7029
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
7030
+ outline-offset: var(--aparte-focus-outline-offset);
6600
7031
  }
6601
7032
 
6602
7033
  .aparte-conv-item__title {
@@ -6617,15 +7048,22 @@ textarea.aparte-elic-text {
6617
7048
  font-weight: var(--aparte-conv-item-font-weight-active);
6618
7049
  }
6619
7050
 
7051
+ /* The selection mark: a bar the width of `--aparte-mark-bar` down the row's start
7052
+ edge. Painted as the first pixels of a pseudo the SIZE of the row that inherits its
7053
+ radius, so the bar follows the curve — drawn as a 2px box of its own it stood square
7054
+ in a 9px corner and a sliver of the page's ground showed between the two. Not
7055
+ `overflow: hidden` on the row: that would clip the title button's focus ring. */
6620
7056
  .aparte-conv-item--active::before {
6621
7057
  content: "";
6622
7058
  position: absolute;
6623
- inset-block: 0;
6624
- inset-inline-start: 0;
6625
- width: var(--aparte-mark-bar);
6626
- background: var(--aparte-primary-ink);
7059
+ inset: 0;
7060
+ border-radius: inherit;
7061
+ background: linear-gradient(to right, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
6627
7062
  pointer-events: none;
6628
7063
  }
7064
+ .aparte-conv-item--active:dir(rtl)::before {
7065
+ background: linear-gradient(to left, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
7066
+ }
6629
7067
 
6630
7068
  .aparte-conv-item--archived .aparte-conv-item__title {
6631
7069
  font-style: italic;
@@ -6639,8 +7077,8 @@ textarea.aparte-elic-text {
6639
7077
  .aparte-conv-item__more {
6640
7078
  flex-shrink: 0;
6641
7079
  opacity: 0;
6642
- width: var(--aparte-conv-action-btn-size);
6643
- height: var(--aparte-conv-action-btn-size);
7080
+ /* Fed to the recipe, not drawn here: the token names the size, the button draws it. */
7081
+ --aparte-btn-size: var(--aparte-conv-action-btn-size);
6644
7082
  color: var(--aparte-text-muted);
6645
7083
  }
6646
7084
 
@@ -6718,6 +7156,11 @@ aparte-suggestions[data-empty] {
6718
7156
  flex-wrap: wrap;
6719
7157
  gap: var(--aparte-space-2);
6720
7158
  padding-block: var(--aparte-space-2);
7159
+ /* The composer's column: past its 800px cap the chips no longer followed the composer,
7160
+ 188px of offset on a wide host. Same measure, same centring. */
7161
+ box-sizing: border-box;
7162
+ max-inline-size: var(--aparte-message-max-width);
7163
+ margin-inline: auto;
6721
7164
  }
6722
7165
 
6723
7166
  /* A starter's label is a short phrase; it must not become a two-line chip. */
@@ -6753,6 +7196,9 @@ aparte-context[data-empty] {
6753
7196
  .aparte-context .aparte-progress {
6754
7197
  flex: 1;
6755
7198
  min-width: 4rem;
7199
+ /* A gauge has a measure: unbounded, the bar stretched to 1046×4px across a toolbar,
7200
+ 88 % of its width for a line that says one number. */
7201
+ max-inline-size: 16rem;
6756
7202
  }
6757
7203
 
6758
7204
  .aparte-context__text {
@@ -6782,10 +7228,13 @@ aparte-context[data-level="danger"] .aparte-progress__bar {
6782
7228
  transform: rotate(-90deg);
6783
7229
  }
6784
7230
 
7231
+ /* As heavy as the bar variant, in screen pixels: the same gauge was drawn at two
7232
+ weights — a 4px bar and a 2.44px antialiased ring. */
6785
7233
  .aparte-context__track,
6786
7234
  .aparte-context__value {
6787
7235
  fill: none;
6788
- stroke-width: var(--aparte-context-ring-stroke);
7236
+ stroke-width: var(--aparte-progress-height);
7237
+ vector-effect: non-scaling-stroke;
6789
7238
  }
6790
7239
 
6791
7240
  .aparte-context__track {
@@ -6820,12 +7269,30 @@ aparte-context[data-level="danger"] .aparte-context__value {
6820
7269
  * chat host. `:has()` gives the host its containing block only while a rail is inside
6821
7270
  * it — a bare `position: relative` on every chat would have moved any absolutely
6822
7271
  * positioned child a consumer already placed there.
7272
+ *
7273
+ * The three shell shapes shell.css already lays out, because an absolute element lands
7274
+ * in the nearest POSITIONED ancestor and a shape missing from this list is a rail that
7275
+ * escaped to the page: the vanilla `<aparte-chat>`, the `[data-aparte-chat]` div
7276
+ * React/Vue/Svelte render, and Angular's inner `.aparte-chat-container` (its host IS
7277
+ * `<aparte-chat>`, but the rail is a child of that inner div). The first two shapes are
7278
+ * both true of the wrapper div, which carries the class and the attribute — listing all
7279
+ * three costs one selector and closes the Angular hole.
6823
7280
  */
6824
7281
  aparte-chat:has(> aparte-scroll-rail),
6825
- [data-aparte-chat]:has(> aparte-scroll-rail) {
7282
+ [data-aparte-chat]:has(> aparte-scroll-rail),
7283
+ .aparte-chat-container:has(> aparte-scroll-rail) {
6826
7284
  position: relative;
6827
7285
  }
6828
7286
 
7287
+ /* The block padding is the room the END zones need, and it is not decoration.
7288
+ The rail clips (`overflow: hidden`, which cuts at the PADDING box) and the list has
7289
+ none of its own, so with no padding here the first tick's top edge sits exactly on the
7290
+ clip line. A zone grows symmetrically — half of `hit − thickness` above and below the
7291
+ line — so the first tick would lose its upper half and the last tick its lower half,
7292
+ to paint AND to hit-testing: 13px of the 24px, on the two ticks a reader aims at most
7293
+ ("jump to the first message", "jump to the latest"). Padding by exactly that half puts
7294
+ the clip line outside every zone instead of through the two end ones. `box-sizing`
7295
+ keeps `max-height` measuring the same box it measured before. */
6829
7296
  aparte-scroll-rail {
6830
7297
  position: absolute;
6831
7298
  inset-inline-end: var(--aparte-space-2);
@@ -6833,7 +7300,9 @@ aparte-scroll-rail {
6833
7300
  transform: translateY(-50%);
6834
7301
  z-index: var(--aparte-z-floating);
6835
7302
  display: block;
7303
+ box-sizing: border-box;
6836
7304
  width: var(--aparte-scroll-rail-width);
7305
+ padding-block: calc((var(--aparte-scroll-rail-hit-size) - var(--aparte-scroll-rail-tick-thickness)) / 2);
6837
7306
  max-height: calc(100% - 2 * var(--aparte-space-8));
6838
7307
  overflow: hidden;
6839
7308
  }
@@ -6853,7 +7322,7 @@ aparte-scroll-rail[data-empty] {
6853
7322
  gap: var(--aparte-scroll-rail-gap);
6854
7323
  }
6855
7324
 
6856
- /* A tick is a real button, drawn as a line. The hit area is the gap around it, drawn
7325
+ /* A tick is a real button, drawn as a line. The hit area is the zone around it, drawn
6857
7326
  by the pseudo-element so the line itself can stay two pixels. */
6858
7327
  .aparte-scroll-rail__tick {
6859
7328
  appearance: none;
@@ -6865,17 +7334,32 @@ aparte-scroll-rail[data-empty] {
6865
7334
  padding: 0;
6866
7335
  border: 0;
6867
7336
  border-radius: var(--aparte-radius-full);
6868
- background: var(--aparte-border);
7337
+ /* A tick is a thing you can press; at rest in the region colour it sat at 1.15:1, a
7338
+ 14×2px dash under the perception threshold. */
7339
+ background: var(--aparte-border-control);
6869
7340
  cursor: pointer;
6870
7341
  transition:
6871
7342
  width var(--aparte-duration-fast) var(--aparte-ease),
6872
7343
  background var(--aparte-duration-fast) var(--aparte-ease);
6873
7344
  }
6874
7345
 
7346
+ /* The pressable zone: 24px square around a 14×2 line (WCAG 2.5.8). It used to be 22×10
7347
+ — half the 8px gap in the block axis, one spacing step in the inline one — and no
7348
+ spacing exemption covered it, because at a 10px pitch a neighbour's 24px circle
7349
+ overlaps this one's.
7350
+ Block: grow symmetrically to the hit size. The gap is `hit − thickness`, so the pitch
7351
+ is exactly the hit size and two zones meet without overlapping — and the rail pads its
7352
+ block axis by the same half-growth, so the clip does not eat the first and last zone.
7353
+ Inline: grow INWARD only. The rail clips (`overflow: hidden`) and ticks are
7354
+ end-aligned, so a centred zone would hang past the end edge and come back cut to
7355
+ 19px; anchored to the tick's end edge it stays whole, and the rail's width carries
7356
+ the hit size as its floor so the column has room for it. */
6875
7357
  .aparte-scroll-rail__tick::before {
6876
7358
  content: "";
6877
7359
  position: absolute;
6878
- inset: calc(-1 * var(--aparte-scroll-rail-gap) / 2) calc(-1 * var(--aparte-space-2));
7360
+ inset-block: calc((var(--aparte-scroll-rail-tick-thickness) - var(--aparte-scroll-rail-hit-size)) / 2);
7361
+ inset-inline-end: 0;
7362
+ inset-inline-start: calc(var(--aparte-scroll-rail-tick-size) - var(--aparte-scroll-rail-hit-size));
6879
7363
  }
6880
7364
 
6881
7365
  .aparte-scroll-rail__tick:hover {
@@ -6888,7 +7372,7 @@ aparte-scroll-rail[data-empty] {
6888
7372
  }
6889
7373
 
6890
7374
  .aparte-scroll-rail__tick:focus-visible {
6891
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
7375
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6892
7376
  outline-offset: var(--aparte-focus-outline-offset);
6893
7377
  }
6894
7378
 
@@ -6971,7 +7455,10 @@ aparte-sidebar,
6971
7455
  display: flex;
6972
7456
  align-items: center;
6973
7457
  gap: var(--aparte-space-3);
6974
- padding: var(--aparte-space-4) var(--aparte-space-4) var(--aparte-space-2);
7458
+ /* Inline, every region reads `--aparte-sidebar-inset`: the header and footer padded
7459
+ 16 while the search and the body padded 12, and the column's content stood on two
7460
+ vertical axes 4px apart — measured as four left edges on the app-shell demo. */
7461
+ padding: var(--aparte-space-4) var(--aparte-sidebar-inset) var(--aparte-space-2);
6975
7462
  }
6976
7463
 
6977
7464
  .aparte-sidebar__brand {
@@ -6984,8 +7471,8 @@ aparte-sidebar,
6984
7471
  }
6985
7472
 
6986
7473
  .aparte-sidebar__search {
6987
- margin: var(--aparte-space-2) var(--aparte-space-3);
6988
- min-width: calc(var(--aparte-sidebar-width) - 2 * var(--aparte-space-3));
7474
+ margin: var(--aparte-space-2) var(--aparte-sidebar-inset);
7475
+ min-width: calc(var(--aparte-sidebar-width) - 2 * var(--aparte-sidebar-inset));
6989
7476
  }
6990
7477
 
6991
7478
  /* The body is the one region that scrolls; the list inside keeps its own gap. */
@@ -6994,7 +7481,7 @@ aparte-sidebar,
6994
7481
  min-height: 0;
6995
7482
  overflow-y: auto;
6996
7483
  overflow-x: hidden;
6997
- padding: var(--aparte-space-2) var(--aparte-space-3);
7484
+ padding: var(--aparte-space-2) var(--aparte-sidebar-inset);
6998
7485
  scrollbar-width: thin;
6999
7486
  scrollbar-color: var(--aparte-scrollbar-thumb) var(--aparte-scrollbar-track);
7000
7487
  }
@@ -7003,7 +7490,7 @@ aparte-sidebar,
7003
7490
  display: flex;
7004
7491
  align-items: center;
7005
7492
  gap: var(--aparte-space-3);
7006
- padding: var(--aparte-space-3) var(--aparte-space-4);
7493
+ padding: var(--aparte-space-3) var(--aparte-sidebar-inset);
7007
7494
  border-block-start: var(--aparte-border-width) solid var(--aparte-border);
7008
7495
  font-size: var(--aparte-font-size-sm);
7009
7496
  }
@@ -7048,7 +7535,7 @@ aparte-sidebar,
7048
7535
  * under 48rem, where the sidebar is a drawer; above it the column is simply there.
7049
7536
  *
7050
7537
  * <header class="aparte-app-header">
7051
- * <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle>☰</button>
7538
+ * <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
7052
7539
  * <span class="aparte-app-header__title">Deploy checklist</span>
7053
7540
  * <div class="aparte-app-header__actions">
7054
7541
  * <span class="aparte-tag">gpt-4.1</span>
@@ -7109,15 +7596,38 @@ aparte-sidebar,
7109
7596
  * `height: 100dvh` for a page — and everything inside follows. Under 48rem the sidebar
7110
7597
  * leaves the grid to become a drawer, and the header's toggle appears.
7111
7598
  *
7112
- * <div class="aparte-app-shell" style="height: 18rem">
7599
+ * <div class="aparte-app-shell" style="height: 24rem">
7113
7600
  * <aside class="aparte-sidebar">
7114
- * <div class="aparte-sidebar__header"><span class="aparte-sidebar__brand">aparté</span></div>
7115
- * <div class="aparte-sidebar__body"><aparte-conversation-list></aparte-conversation-list></div>
7601
+ * <div class="aparte-sidebar__header">
7602
+ * <span class="aparte-sidebar__brand">aparté</span>
7603
+ * <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat"><aparte-icon name="edit"></aparte-icon></button>
7604
+ * </div>
7605
+ * <div class="aparte-sidebar__search aparte-field-group">
7606
+ * <input class="aparte-field aparte-field--sm" type="search" placeholder="Search">
7607
+ * </div>
7608
+ * <div class="aparte-sidebar__body">
7609
+ * <div class="aparte-conv-group" role="group" aria-label="Today">
7610
+ * <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Today</div>
7611
+ * <div class="aparte-menu__item aparte-conv-item aparte-conv-item--active"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Deploy checklist</span></button></div>
7612
+ * <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Rename the staging bucket</span></button></div>
7613
+ * </div>
7614
+ * <div class="aparte-conv-group" role="group" aria-label="Yesterday">
7615
+ * <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Yesterday</div>
7616
+ * <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Why is total a float?</span></button></div>
7617
+ * </div>
7618
+ * </div>
7619
+ * <div class="aparte-sidebar__footer"><span class="aparte-avatar aparte-avatar--sm">P</span> Paul</div>
7116
7620
  * </aside>
7117
- * <header class="aparte-app-header"><span class="aparte-app-header__title">Deploy checklist</span></header>
7621
+ * <header class="aparte-app-header">
7622
+ * <span class="aparte-app-header__title">Deploy checklist</span>
7623
+ * <div class="aparte-app-header__actions"><span class="aparte-tag">gpt-4.1</span></div>
7624
+ * </header>
7118
7625
  * <main class="aparte-app-shell__main">
7119
7626
  * <aparte-chat>
7120
- * <aparte-chat-viewport></aparte-chat-viewport>
7627
+ * <aparte-chat-viewport>
7628
+ * <aparte-chat-bubble message-id="u1" data-role="user" content="Walk me through the deploy."></aparte-chat-bubble>
7629
+ * <aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Build, run the gate, tag, then push the tag — the workflow publishes from it."></aparte-chat-bubble>
7630
+ * </aparte-chat-viewport>
7121
7631
  * <aparte-composer>
7122
7632
  * <div class="aparte-composer-shell">
7123
7633
  * <div class="aparte-composer-row">
@@ -7174,6 +7684,28 @@ aparte-sidebar,
7174
7684
  min-height: 0;
7175
7685
  height: auto;
7176
7686
  }
7687
+
7688
+ /* A phone folds the shell to one column. The sidebar ELEMENT already leaves the grid
7689
+ under 48rem — it becomes a fixed drawer behind the header's toggle — but the grid
7690
+ kept its first column for it, and a class-only sidebar had no story at all: at 375px
7691
+ the shell gave 259 of its 303px to the sidebar and left the chat a 43px band with a
7692
+ send button cut in half. Same query as the element's own, so the recipe teaches the
7693
+ screen the element renders. */
7694
+ @media (max-width: 48rem) {
7695
+ .aparte-app-shell {
7696
+ grid-template-columns: minmax(0, 1fr);
7697
+ }
7698
+ .aparte-app-shell > .aparte-app-header,
7699
+ .aparte-app-shell__main {
7700
+ grid-column: 1;
7701
+ }
7702
+ /* A sidebar that is not (yet) a drawer — the class recipe alone, or the element
7703
+ before it upgrades — folds out of the flow rather than eating the column. */
7704
+ .aparte-app-shell > .aparte-sidebar:not([data-drawer]),
7705
+ .aparte-app-shell > aparte-sidebar:not([data-drawer]) {
7706
+ display: none;
7707
+ }
7708
+ }
7177
7709
  /*
7178
7710
  * aparté — the seam: two panes side by side, and the grid that lets one grow while
7179
7711
  * the other gives way. The fourth piece of the shell, after the sidebar, the header
@@ -7265,35 +7797,47 @@ aparte-split > [data-aparte-chat],
7265
7797
  box-sizing: border-box;
7266
7798
  border: 0;
7267
7799
  padding: 0;
7268
- background: var(--aparte-border);
7800
+ /* A transparent TRACK: the seam is the 1px line painted by `::before`, centred in it,
7801
+ and the grip is a short bar drawn on the track under the pointer. The track used
7802
+ to be the seam — 4px of paint, four times the kit's rule, with nothing to hold. */
7803
+ background: transparent;
7804
+ background-repeat: no-repeat;
7805
+ background-position: center;
7269
7806
  cursor: col-resize;
7270
7807
  touch-action: none;
7808
+ }
7809
+
7810
+ .aparte-split__handle::before {
7811
+ content: '';
7812
+ position: absolute;
7813
+ inset-block: 0;
7814
+ inset-inline-start: calc(50% - var(--aparte-split-seam-width) / 2);
7815
+ inline-size: var(--aparte-split-seam-width);
7816
+ background: var(--aparte-border);
7271
7817
  transition: background-color var(--aparte-duration-fast) var(--aparte-ease);
7272
7818
  }
7273
7819
 
7274
- .aparte-split__handle:hover,
7275
- .aparte-split__handle[data-dragging] {
7820
+ .aparte-split__handle:hover::before,
7821
+ .aparte-split__handle[data-dragging]::before {
7276
7822
  background: var(--aparte-primary);
7277
7823
  }
7278
7824
 
7279
- /* A real outline, and the ring kept as decoration.
7280
-
7281
- The seam's whole keyboard story is arrowing it, and its only focus indicator was
7282
- `--aparte-focus-ring` — a soft wash that measures 1.39:1 against the page in the
7283
- light palette and 1.83:1 in the dark one, where WCAG asks 3:1 of a focus indicator.
7284
- `outline: none` with nothing in its place is the shape of that failure: the
7285
- indicator was not weak, it was absent, on a 4px band with `border: 0` that a
7286
- keyboard user has no other way to find.
7825
+ /* The grip: the convention of every editor's split, so the seam reads as something to
7826
+ take hold of. Drawn as a background so it needs no third element. */
7827
+ .aparte-split__handle:hover,
7828
+ .aparte-split__handle[data-dragging] {
7829
+ background-image: linear-gradient(var(--aparte-primary), var(--aparte-primary));
7830
+ background-size: calc(var(--aparte-split-seam-width) * 3) var(--aparte-space-8);
7831
+ }
7287
7832
 
7288
- The outline is what carries the contrast (3.54:1 light, 7.36:1 dark, measured); the
7289
- shadow stays because it reads as a glow AROUND the seam and the two do not fight.
7290
- The forced-colors entry in responsive.css keeps overriding the colour only — it is
7291
- now overriding an outline that exists rather than substituting for one that does
7292
- not. */
7833
+ /* The one ring. The seam's whole keyboard story is arrowing it, and its only focus
7834
+ indicator used to be a soft wash at 1.39:1 against the page — not weak, absent, on a
7835
+ 4px band with `border: 0` that a keyboard user has no other way to find. The outline
7836
+ carries the contrast (3.54:1 light, 7.36:1 dark, measured), and it is the same outline
7837
+ every other control draws. */
7293
7838
  .aparte-split__handle:focus-visible {
7294
7839
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
7295
- outline-offset: 0;
7296
- box-shadow: var(--aparte-focus-ring);
7840
+ outline-offset: var(--aparte-focus-outline-offset);
7297
7841
  }
7298
7842
 
7299
7843
  /* The hit slop: a 4px seam is a 4px target, and a pointer misses it. The pseudo-element
@@ -7382,6 +7926,14 @@ aparte-split[orientation='vertical'][primary='end']:not([data-stacked]),
7382
7926
  aparte-split[orientation='vertical'] > .aparte-split__handle,
7383
7927
  .aparte-split--vertical > .aparte-split__handle {
7384
7928
  cursor: row-resize;
7929
+ background-size: var(--aparte-space-8) calc(var(--aparte-split-seam-width) * 3);
7930
+ }
7931
+ aparte-split[orientation='vertical'] > .aparte-split__handle::before,
7932
+ .aparte-split--vertical > .aparte-split__handle::before {
7933
+ inset-block: calc(50% - var(--aparte-split-seam-width) / 2) auto;
7934
+ inset-inline: 0;
7935
+ inline-size: auto;
7936
+ block-size: var(--aparte-split-seam-width);
7385
7937
  }
7386
7938
 
7387
7939
  aparte-split[orientation='vertical'] > .aparte-split__handle::after,
@@ -7639,6 +8191,13 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7639
8191
  .aparte-code-content-wrapper pre {
7640
8192
  margin: 0;
7641
8193
  padding: var(--aparte-space-6);
8194
+ /* The code typeface, at a code size. This rule declared neither and the block fell
8195
+ into the UA's generic `monospace` (Courier New on Windows) at the PROSE's size and
8196
+ leading; only inline code read the family. On the wrapper's `pre`, whatever class
8197
+ the highlighter puts on its replacement. */
8198
+ font-family: var(--aparte-code-font-family);
8199
+ font-size: var(--aparte-code-block-font-size);
8200
+ line-height: var(--aparte-code-block-line-height);
7642
8201
  /* Wrap long lines instead of a horizontal scrollbar. `pre-wrap` honours
7643
8202
  newlines + indentation but soft-wraps overflowing lines; `overflow-wrap:
7644
8203
  anywhere` breaks unbreakable tokens (long URLs, hashes). Applies to both
@@ -7674,23 +8233,11 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7674
8233
  }
7675
8234
  }
7676
8235
 
7677
- /* Windows high contrast. In forced-colors mode the UA repaints colours and
7678
- drops box-shadow entirely — so the focus indicators drawn with
7679
- --aparte-focus-ring, and the error ring on an avatar, do not merely change
7680
- colour, they VANISH. An outline is painted, and `Highlight` is the system's
7681
- own focus colour, so the indicator survives without fighting the user's
7682
- palette. Only the indicators that disappear are restated here: everything
7683
- whose affordance is text or a border already survives on its own — which the
7684
- split's seam does not, being a 4px band with `border: 0` whose entire keyboard
7685
- story is arrowing it. */
8236
+ /* Windows high contrast. In forced-colors mode the UA repaints colours and drops
8237
+ box-shadow entirely. Every focus indicator is an outline now, which forced colours
8238
+ paint on their own — the one indicator drawn with a box-shadow is the error ring on
8239
+ an avatar, restated here as an outline so it does not vanish. */
7686
8240
  @media (forced-colors: active) {
7687
- .aparte-thumb--image[role='button']:focus-visible,
7688
- .aparte-split__handle:focus-visible,
7689
- .aparte-provider-select:focus,
7690
- .aparte-model-select:focus {
7691
- outline: var(--aparte-focus-outline-width) solid Highlight;
7692
- }
7693
-
7694
8241
  .aparte-message[data-error] .aparte-avatar {
7695
8242
  outline: var(--aparte-focus-outline-width) solid CanvasText;
7696
8243
  }
@@ -7774,15 +8321,28 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7774
8321
  opacity: 1;
7775
8322
  }
7776
8323
 
7777
- /* Nor the ✕ on a pending attachment, which is the only way to remove one. */
8324
+ /* Nor the ✕ on a pending attachment, which is the only way to remove one — and
8325
+ seeing it is no use at 18px, under the 24 of WCAG 2.2 SC 2.5.8. 24 is not a new
8326
+ number: it is the box `aparte-btn--sm` already draws, so this is the component
8327
+ giving the recipe back its own size on touch rather than out-specifying it.
8328
+ Not `--aparte-touch-target-size`: 44px inside a 72px tile would swallow the
8329
+ picture, and full parity with the neighbouring controls needs the tile to grow
8330
+ with it — a second decision, not this floor. */
7778
8331
  .aparte-thumb__remove {
7779
8332
  opacity: 1;
8333
+ --aparte-thumb-remove-size: var(--aparte-btn-size-sm);
7780
8334
  }
7781
8335
 
7782
8336
  /* Hover cannot reveal the row's ⋯ on touch — keep it visible and tappable. */
7783
8337
  .aparte-conv-item__more {
7784
8338
  opacity: 1;
7785
- --aparte-conv-action-btn-size: 28px;
8339
+ --aparte-conv-action-btn-size: var(--aparte-touch-target-size);
8340
+ }
8341
+
8342
+ /* The composer's own controls: 36 at rest, the touch target here — the send button, the
8343
+ input's action button and the editor's single-line height move together. */
8344
+ .aparte-composer-row {
8345
+ --aparte-composer-control-size: var(--aparte-touch-target-size);
7786
8346
  }
7787
8347
 
7788
8348
  /* A 4px seam is no target for a finger — the same modality rule the buttons above