@aparte/core 0.16.6 → 0.16.8

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 (69) hide show
  1. package/README.md +5 -4
  2. package/dist/client/aparte-client.d.ts +14 -0
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/components/bubble/aparte-chat-bubble.d.ts +33 -18
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  6. package/dist/components/chat/aparte-chat.d.ts +32 -25
  7. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  8. package/dist/components/composer/aparte-composer-action.d.ts +1 -1
  9. package/dist/components/composer/aparte-composer-add-attachment.d.ts +1 -1
  10. package/dist/components/composer/aparte-composer-attachments.d.ts +1 -1
  11. package/dist/components/composer/aparte-composer-cancel.d.ts +2 -2
  12. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  13. package/dist/components/composer/aparte-composer-input.d.ts +6 -6
  14. package/dist/components/composer/aparte-composer-send.d.ts +3 -3
  15. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  16. package/dist/components/composer/aparte-composer.d.ts +9 -2
  17. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  18. package/dist/components/context/aparte-context.d.ts +0 -1
  19. package/dist/components/context/aparte-context.d.ts.map +1 -1
  20. package/dist/components/conversation-list/aparte-conversation-list.d.ts +6 -6
  21. package/dist/components/elicitation/aparte-elicitation.d.ts +20 -18
  22. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  23. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts +9 -2
  24. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  25. package/dist/components/sidebar/aparte-sidebar.d.ts +16 -2
  26. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  27. package/dist/components/split/aparte-split.d.ts +9 -4
  28. package/dist/components/split/aparte-split.d.ts.map +1 -1
  29. package/dist/components/status/aparte-chat-status.d.ts +3 -2
  30. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  31. package/dist/components/viewport/aparte-chat-viewport.d.ts +27 -4
  32. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  33. package/dist/config/icon-provider.d.ts +6 -0
  34. package/dist/config/icon-provider.d.ts.map +1 -1
  35. package/dist/config/locale.d.ts +3 -1
  36. package/dist/config/locale.d.ts.map +1 -1
  37. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  38. package/dist/custom-elements.json +10458 -10325
  39. package/dist/{dialog-triggers-CVCxqKg3.js → dialog-triggers-D8wNICrT.js} +36 -19
  40. package/dist/dialog-triggers-D8wNICrT.js.map +1 -0
  41. package/dist/generated/element-attributes.d.ts +13 -3
  42. package/dist/generated/element-attributes.d.ts.map +1 -1
  43. package/dist/{glyphs-DQTeOj4w.js → glyphs-C8z2oJUg.js} +36 -27
  44. package/dist/{glyphs-DQTeOj4w.js.map → glyphs-C8z2oJUg.js.map} +1 -1
  45. package/dist/icons/extended.d.ts +0 -6
  46. package/dist/icons/extended.d.ts.map +1 -1
  47. package/dist/icons/glyphs.d.ts +13 -4
  48. package/dist/icons/glyphs.d.ts.map +1 -1
  49. package/dist/icons.js +28 -31
  50. package/dist/icons.js.map +1 -1
  51. package/dist/index.css +909 -357
  52. package/dist/index.js +176 -54
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.node.js +1 -1
  55. package/dist/primitives/icon/aparte-icon.d.ts +1 -1
  56. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts +2 -3
  57. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -1
  58. package/dist/primitives/select/aparte-optgroup.d.ts +1 -1
  59. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  60. package/dist/primitives/select/aparte-option.d.ts +4 -4
  61. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  62. package/dist/primitives/select/aparte-select.d.ts +21 -12
  63. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  64. package/dist/renderers/segments/code.d.ts.map +1 -1
  65. package/dist/renderers/segments/error.d.ts.map +1 -1
  66. package/dist/renderers/segments/thinking.d.ts.map +1 -1
  67. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  68. package/package.json +3 -2
  69. package/dist/dialog-triggers-CVCxqKg3.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,12 +321,9 @@
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
329
 
@@ -341,7 +332,6 @@
341
332
  /* Context gauge, ring variant — a diameter, and a stroke in the ring's own 36-unit
342
333
  box. Literals like the rail's: a 22px ring is a toolbar's measure, not a spacing step. */
343
334
  --aparte-context-ring-size: 22px;
344
- --aparte-context-ring-stroke: 4;
345
335
 
346
336
  /* Shell — the sidebar's column, the header's row and the split's seam. */
347
337
  --aparte-sidebar-width: 260px;
@@ -353,9 +343,15 @@
353
343
  is 12px because that is a target, and it grows to the touch target on a coarse
354
344
  pointer (responsive.css). */
355
345
  --aparte-split-position: 38%;
356
- --aparte-split-min: 20rem;
346
+ /* Never more than the container has: at 320px this floor was wider than a phone, so
347
+ the primary pane took everything and the end pane was annihilated to 0. */
348
+ --aparte-split-min: min(20rem, 100%);
357
349
  --aparte-split-max: 60%;
358
- --aparte-split-handle-size: 4px;
350
+ /* The seam's TRACK — the grab zone, and what the grid reserves. The painted line
351
+ inside it is `--aparte-split-seam-width` (derived layer): one token used to size
352
+ both, so the line could not be thinned without moving the layout, and at 4px it
353
+ was four times the kit's rule with no grip — an interaction drawn as a decoration. */
354
+ --aparte-split-handle-size: 12px;
359
355
  --aparte-split-hit-area: 12px;
360
356
  /* Sizes that are not steps of a scale. Each is off-scale on purpose — a 40px
361
357
  file tile is a tile, not fourteen spacing units — so each gets a name rather
@@ -406,6 +402,9 @@
406
402
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
407
403
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
408
404
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
405
+ /* The composer laid over the transcript (overlay-composer): a thing that floats has to
406
+ be seen floating, and its ground is the transcript's own. */
407
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
409
408
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
410
409
  --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
411
410
 
@@ -444,20 +443,122 @@
444
443
  --aparte-skeleton-text-last-width: 60%;
445
444
  --aparte-progress-indeterminate-width: 40%;
446
445
 
446
+ /* The control height scale — four steps, and EVERY control reads one: a text
447
+ button used to have no height (23px of padding and line-height) beside 24, 29, 32,
448
+ 36 and 44px neighbours, and the 36px family (send, the input's action button, the
449
+ scroll button) lived off the scale as literals. sm is the small control (action bar,
450
+ branch arrows, a row's ⋯), md the default button, lg the field and the composer's
451
+ controls at rest, xl the large button. */
447
452
  --aparte-btn-size-sm: 24px;
448
453
  --aparte-btn-size-md: 32px;
449
- --aparte-btn-size-lg: 40px;
454
+ --aparte-btn-size-lg: 36px;
455
+ --aparte-btn-size-xl: 40px;
456
+ /* The field family's literal sizes, beside the button's: the derived knobs
457
+ (paddings, radius) sit in the anchored layer below, like the button's own. */
458
+ --aparte-field-textarea-min-height: 80px;
459
+ --aparte-checkbox-size: 18px;
460
+ --aparte-radio-size: 18px;
461
+ --aparte-radio-dot-size: 8px;
462
+ --aparte-switch-width: 40px;
463
+ --aparte-switch-height: 22px;
464
+ --aparte-switch-thumb-inset: 2px;
465
+ --aparte-range-thumb-size: 16px;
466
+ --aparte-range-track-height: 4px;
450
467
 
451
- --aparte-focus-outline-offset: -2px;
452
468
  --aparte-tool-spinner-size: 10px;
453
469
  --aparte-model-select-chevron-room: 28px;
454
470
  }
455
471
 
472
+ /* =========================================
473
+ The system decides, by default.
474
+
475
+ Dark used to exist only behind the attribute: on a dark OS an un-attributed chat
476
+ rendered LIGHT on the host's dark page, unreadable, with no error. This block is
477
+ the DARK block above, verbatim, applied when the OS asks and the host has not
478
+ said otherwise — `data-aparte-theme="light"` on the root is the veto.
479
+
480
+ A duplicate on purpose (CSS cannot share one block between a media query and an
481
+ attribute selector): `check:derived-vars` holds the two byte-identical, so
482
+ editing one without the other fails the build rather than forking the theme.
483
+ No `color-scheme` here — it is not namespaced, and a library setting it on the
484
+ host's :root would flip the whole page's native controls.
485
+ ========================================= */
486
+ @media (prefers-color-scheme: dark) {
487
+ :root:not([data-aparte-theme="light"]) {
488
+ --aparte-bg: #17141c;
489
+ --aparte-surface-1: #211b28;
490
+ --aparte-surface-2: #2a2333;
491
+ --aparte-message-content-bg-assistant: transparent;
492
+ --aparte-text: #f2eee7;
493
+ --aparte-text-muted: #a89bb6;
494
+ --aparte-neutral: #a89bb6;
495
+ --aparte-border: #342a40;
496
+ --aparte-primary: #d9a24b;
497
+ --aparte-primary-hover: #e7c588;
498
+ --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
499
+ --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
500
+ --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
501
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
502
+ /* Declared once at :root it darkened a dark page by 8/255: the modal state vanished. */
503
+ --aparte-scrim: rgba(0, 0, 0, 0.6);
504
+ --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
505
+ --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
506
+ --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
507
+ --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
508
+ --aparte-ink-l: 0.85;
509
+ --aparte-error-bg: #2d1b1b;
510
+ --aparte-error-border: #7f1d1d;
511
+ --aparte-error-solid: #f87171;
512
+ --aparte-error-text: #fca5a5;
513
+ }
514
+ }
515
+
516
+ /* =========================================
517
+ Forced light — the veto, and the subtree escape.
518
+
519
+ New with the system default: without it, a host that wants light-always on a
520
+ dark OS has no lever, and a light island inside a dark page cannot exist. The
521
+ values are the light literals from the `:root` block above, one per property
522
+ the dark block overrides — `check:derived-vars` holds that pairing, so a light
523
+ literal edited above cannot drift from its copy here.
524
+ ========================================= */
525
+ [data-aparte-theme="light"] {
526
+ color-scheme: light;
527
+ --aparte-bg: #f6f2ea;
528
+ --aparte-surface-1: #fffdf8;
529
+ --aparte-surface-2: #f1ebdf;
530
+ --aparte-message-content-bg-assistant: transparent;
531
+ --aparte-text: #241f2b;
532
+ --aparte-text-muted: #6d6479;
533
+ --aparte-neutral: #6d6479;
534
+ --aparte-border: #e5ddce;
535
+ --aparte-primary: #b07d33;
536
+ --aparte-primary-hover: #a06f28;
537
+ --aparte-scroll-btn-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
538
+ --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
539
+ --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
540
+ --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 10px 28px rgba(0, 0, 0, 0.16);
541
+ --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
542
+ /* The composer laid over the transcript (overlay-composer): a thing that floats has to
543
+ be seen floating, and its ground is the transcript's own. */
544
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
545
+ --aparte-scrim: rgba(0, 0, 0, 0.35);
546
+ --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
547
+ --aparte-range-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
548
+ --aparte-ink-l: 0.40;
549
+ --aparte-error-bg: #fef2f2;
550
+ --aparte-error-border: #fecaca;
551
+ --aparte-error-solid: #dc2626;
552
+ --aparte-error-text: #991b1b;
553
+ }
554
+
555
+
456
556
  /* =========================================
457
557
  Dark Theme (App-controlled via attribute)
458
558
  Usage: <div data-aparte-theme="dark">...</div>
459
559
  ========================================= */
460
560
  [data-aparte-theme="dark"] {
561
+ color-scheme: dark;
461
562
  /* Backgrounds */
462
563
  --aparte-bg: #17141c;
463
564
  --aparte-surface-1: #211b28;
@@ -488,6 +589,8 @@
488
589
  --aparte-scroll-btn-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
489
590
  --aparte-select-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
490
591
  --aparte-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
592
+ --aparte-composer-overlay-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4);
593
+ --aparte-scrim: rgba(0, 0, 0, 0.6);
491
594
  --aparte-popover-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 10px 28px rgba(0, 0, 0, 0.55);
492
595
  --aparte-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
493
596
  --aparte-switch-thumb-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
@@ -507,7 +610,6 @@
507
610
  --aparte-error-border: #7f1d1d;
508
611
  --aparte-error-solid: #f87171;
509
612
  --aparte-error-text: #fca5a5;
510
- --aparte-error-title: #fecaca;
511
613
  }
512
614
 
513
615
  /* =========================================
@@ -573,8 +675,14 @@ aparte-chat {
573
675
  --aparte-font-size-xs: calc(0.6875rem * var(--aparte-font-scale));
574
676
  --aparte-font-size-sm: calc(0.75rem * var(--aparte-font-scale));
575
677
  --aparte-font-size-md: calc(0.8125rem * var(--aparte-font-scale));
576
- --aparte-font-size-lg: calc(0.875rem * var(--aparte-font-scale));
577
678
  --aparte-font-size-base: calc(0.9375rem * var(--aparte-font-scale));
679
+ /* Above the body, in the order the names say. `lg` used to be 0.875rem — SMALLER than
680
+ `base` — with nothing above it, so a title and a placeholder shared a size and a
681
+ card's title was smaller than its body. A scale whose name lies is worse than a
682
+ short one. */
683
+ --aparte-font-size-lg: calc(1.0625rem * var(--aparte-font-scale));
684
+ --aparte-font-size-xl: calc(1.25rem * var(--aparte-font-scale));
685
+ --aparte-font-size-2xl: calc(1.5rem * var(--aparte-font-scale));
578
686
 
579
687
  /* Elicitation and the conversation item, on the scale.
580
688
  They were literals off every step — 0.76 / 0.78 / 0.8 / 0.82rem and a `7px 10px`
@@ -600,7 +708,6 @@ aparte-chat {
600
708
  vertical half (#56) — a shorthand cannot be read by parts. */
601
709
  --aparte-message-padding-block: var(--aparte-space-8);
602
710
  --aparte-message-padding-inline: var(--aparte-space-6);
603
- --aparte-message-padding: var(--aparte-message-padding-block) var(--aparte-message-padding-inline);
604
711
  --aparte-message-content-padding: var(--aparte-space-5) var(--aparte-space-7);
605
712
  --aparte-action-bar-gap: var(--aparte-space-2);
606
713
  --aparte-branch-picker-gap: var(--aparte-space-2);
@@ -617,14 +724,15 @@ aparte-chat {
617
724
  --aparte-input-padding-y: var(--aparte-space-5);
618
725
  --aparte-conv-list-gap: var(--aparte-space-1);
619
726
  --aparte-conv-item-gap: var(--aparte-space-3);
620
- --aparte-elic-option-radius: var(--aparte-radius-lg);
727
+ --aparte-elic-option-radius: var(--aparte-radius-md);
621
728
  --aparte-elic-input-radius: var(--aparte-radius-md);
622
729
  --aparte-tool-row-radius: var(--aparte-radius-md);
623
- --aparte-avatar-font-size: var(--aparte-font-size-lg);
624
- --aparte-name-font-size: var(--aparte-font-size-lg);
730
+ /* The split's painted seam: the kit's rule, inside a 12px track. */
731
+ --aparte-split-seam-width: var(--aparte-border-width);
732
+ --aparte-name-font-size: var(--aparte-font-size-md);
625
733
  --aparte-timestamp-font-size: var(--aparte-font-size-sm);
626
734
  --aparte-branch-picker-label-size: var(--aparte-font-size-sm);
627
- --aparte-input-font-size: var(--aparte-font-size-lg);
735
+ --aparte-input-font-size: var(--aparte-font-size-base);
628
736
  --aparte-content-font-size: var(--aparte-font-size-base);
629
737
  --aparte-input-editor-font-size: var(--aparte-font-size-base);
630
738
  --aparte-status-font-size: var(--aparte-font-size-md);
@@ -637,6 +745,9 @@ aparte-chat {
637
745
  moves --aparte-space-unit or --aparte-font-scale carries them along. */
638
746
  /* A tooltip is an INVERTED surface: dark on a light theme, light on a dark one.
639
747
  Reading the text pair gives exactly that, and for free — no third palette. */
748
+ /* The distance between a placed tooltip (`data-side`) and its anchor: the arrow's
749
+ half-diagonal, so the tip lands on the trigger rather than 2px short of it. */
750
+ --aparte-tooltip-gap: var(--aparte-space-3);
640
751
  --aparte-tooltip-bg: var(--aparte-text);
641
752
  --aparte-tooltip-color: var(--aparte-text-inverse);
642
753
 
@@ -672,6 +783,14 @@ aparte-chat {
672
783
  --aparte-alert-padding: var(--aparte-space-4) var(--aparte-space-5);
673
784
  --aparte-alert-radius: var(--aparte-radius-md);
674
785
  --aparte-alert-rail-width: var(--aparte-space-1);
786
+ /* The icon scale, in rem times the type factor — the one scale in the theme that
787
+ was pinned in px while every type size followed `--aparte-font-scale`, so a glyph
788
+ beside text shrank optically the moment a reader enlarged the text. The loose
789
+ default is 16 (it was 14, and its documentation already said 16). */
790
+ --aparte-icon-size: calc(1rem * var(--aparte-font-scale));
791
+ --aparte-icon-size-sm: calc(0.75rem * var(--aparte-font-scale));
792
+ --aparte-icon-size-lg: calc(1.125rem * var(--aparte-font-scale));
793
+ --aparte-icon-size-xl: calc(1.25rem * var(--aparte-font-scale));
675
794
  --aparte-alert-icon-size: var(--aparte-space-8);
676
795
  --aparte-alert-dismiss-size: var(--aparte-btn-size-sm);
677
796
  --aparte-alert-title-font-size: var(--aparte-font-size-md);
@@ -679,8 +798,7 @@ aparte-chat {
679
798
 
680
799
  --aparte-avatar-fallback-bg: var(--aparte-surface-3);
681
800
  --aparte-avatar-fallback-color: var(--aparte-text);
682
- --aparte-avatar-group-overlap: var(--aparte-space-3);
683
- --aparte-avatar-group-ring-width: var(--aparte-border-width);
801
+ --aparte-avatar-group-ring-width: calc(var(--aparte-border-width) * 2);
684
802
  --aparte-avatar-group-ring-color: var(--aparte-surface-1);
685
803
 
686
804
  --aparte-badge-font-size: var(--aparte-font-size-2xs);
@@ -701,7 +819,7 @@ aparte-chat {
701
819
  --aparte-card-header-padding: var(--aparte-space-4) var(--aparte-space-5);
702
820
  --aparte-card-body-padding: var(--aparte-space-5);
703
821
  --aparte-card-footer-padding: var(--aparte-space-4) var(--aparte-space-5);
704
- --aparte-card-header-font-size: var(--aparte-font-size-lg);
822
+ --aparte-card-header-font-size: var(--aparte-font-size-base);
705
823
 
706
824
  --aparte-divider-margin: var(--aparte-space-5);
707
825
  --aparte-divider-label-gap: var(--aparte-space-4);
@@ -717,22 +835,56 @@ aparte-chat {
717
835
  --aparte-progress-height: var(--aparte-space-2);
718
836
  --aparte-progress-fill: var(--aparte-primary);
719
837
 
720
- --aparte-skeleton-base: var(--aparte-surface-2);
838
+ /* The ground of a gauge or a placeholder — the thing a value is read against, so it
839
+ has to be seen. Derived RELATIVE to the page, not a surface level: a level sat at
840
+ two different distances from the ground in light and dark. */
841
+ --aparte-track: color-mix(in srgb, var(--aparte-text) 8%, var(--aparte-bg));
842
+ --aparte-skeleton-base: var(--aparte-track);
843
+ /* The rail is as wide as its widest tick (the current one is 1.6× the resting size);
844
+ it used to be a 20px literal clipping a 22.4px tick. */
845
+ --aparte-scroll-rail-width: calc(var(--aparte-scroll-rail-tick-size) * 1.6);
721
846
  --aparte-skeleton-shine: var(--aparte-surface-3);
722
- --aparte-skeleton-text-height: var(--aparte-space-6);
723
- --aparte-skeleton-text-gap: var(--aparte-space-3);
847
+ /* The line it stands for: as tall as the content type, and a gap that is the rest of
848
+ the content line, so the text that replaces it lands where it was (T21). It was 12px
849
+ on an 18px step for a 16px line on a 27px step — a third of a line of jump per line. */
850
+ --aparte-skeleton-text-height: var(--aparte-content-font-size);
851
+ --aparte-skeleton-text-gap: calc(var(--aparte-content-font-size) * (var(--aparte-content-line-height) - 1));
724
852
  --aparte-skeleton-circle-size: var(--aparte-avatar-size);
725
853
 
726
854
  --aparte-spinner-thickness: var(--aparte-space-1);
727
855
 
728
- --aparte-field-error-ring: 0 0 0 var(--aparte-focus-outline-width) var(--aparte-error-solid);
856
+
857
+ /* The field family's knobs, beside the button's — they used to be declared on
858
+ `.aparte-field` itself, which is why `.aparte-field-group` (their PARENT) and
859
+ `.aparte-color` (a sibling recipe) read a property that did not exist there and
860
+ rendered SQUARE: a custom property is substituted where it is declared and only
861
+ inherits downwards. Same reason they belong here rather than there: the theming
862
+ guide sends a consumer to `:root`, and every other family's knobs are here.
863
+ The *channels* stay local on purpose (`--aparte-checkbox-intent` and friends are
864
+ filled by a modifier, not set by a theme). */
865
+ --aparte-field-padding: var(--aparte-space-2) var(--aparte-space-3);
866
+ --aparte-field-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
867
+ --aparte-field-padding-lg: var(--aparte-space-3) var(--aparte-space-4);
868
+ --aparte-field-radius: var(--aparte-radius-md);
869
+ /* The field's resting height is the lg step of the control scale, one step above the
870
+ default button and level with the composer's controls; `--sm` and `--lg` sit one
871
+ step either side. It used to be padding plus line-height, ~29px, the second
872
+ tallest thing in a row of five different heights. */
873
+ --aparte-field-size: var(--aparte-btn-size-lg);
874
+ --aparte-field-size-sm: var(--aparte-btn-size-md);
875
+ --aparte-field-size-lg: var(--aparte-btn-size-xl);
876
+ /* The 36px family, on the scale: named, not literal. */
877
+ --aparte-send-btn-size: var(--aparte-btn-size-lg);
878
+ --aparte-input-action-btn-size: var(--aparte-btn-size-lg);
879
+ --aparte-scroll-btn-size: var(--aparte-btn-size-lg);
729
880
 
730
881
  --aparte-btn-gap: var(--aparte-space-2);
731
882
  --aparte-btn-padding: var(--aparte-space-2) var(--aparte-space-3);
732
883
  --aparte-btn-radius: var(--aparte-radius-md);
733
- --aparte-btn-font-size: var(--aparte-font-size-sm);
884
+ /* Control text is the md step (14px). One step lower was the origin of the "poor"
885
+ look: 13px labels in 32px boxes, beside 15px prose. */
886
+ --aparte-btn-font-size: var(--aparte-font-size-md);
734
887
  --aparte-btn-bg-hover: var(--aparte-surface-2);
735
- --aparte-btn-focus-offset: var(--aparte-space-1);
736
888
  --aparte-btn-bg-active: var(--aparte-surface-3);
737
889
  --aparte-btn-bg-toggled: var(--aparte-surface-2);
738
890
  --aparte-btn-color-toggled: var(--aparte-text);
@@ -742,11 +894,17 @@ aparte-chat {
742
894
  --aparte-btn-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
743
895
  --aparte-btn-padding-lg: var(--aparte-space-3) var(--aparte-space-5);
744
896
 
897
+ /* One knob for the composer's control set — the send button, the input's action
898
+ buttons, the editor's single-line height: the lg step at rest, the touch target
899
+ under a coarse pointer (responsive.css). It used to be undeclared, with `44px`
900
+ written as a fallback at every reader, so a desktop composer was a 62px bar around
901
+ 15px of text and the coarse-pointer block had nothing left to enlarge. */
902
+ --aparte-composer-control-size: var(--aparte-btn-size-lg);
745
903
  --aparte-input-container-min-height: var(--aparte-touch-target-size);
746
- --aparte-elic-message-size: var(--aparte-font-size-md);
747
- --aparte-elic-title-size: var(--aparte-font-size-md);
904
+ --aparte-elic-message-size: var(--aparte-font-size-base);
905
+ --aparte-elic-title-size: var(--aparte-font-size-base);
748
906
  --aparte-elic-desc-size: var(--aparte-font-size-sm);
749
- --aparte-elic-option-title-size: var(--aparte-font-size-lg);
907
+ --aparte-elic-option-title-size: var(--aparte-font-size-md);
750
908
  --aparte-elic-option-desc-size: var(--aparte-font-size-sm);
751
909
  --aparte-elic-skip-size: var(--aparte-font-size-md);
752
910
  --aparte-elic-step-size: var(--aparte-font-size-sm);
@@ -756,6 +914,17 @@ aparte-chat {
756
914
 
757
915
  /* Borders */
758
916
  --aparte-border-focus: var(--aparte-primary);
917
+ /* THE focus ring: a 2px outline in this colour, one spacing step OUTSIDE the box, on
918
+ every control. It used to be drawn two ways — this outline on seventeen recipes and a
919
+ soft box-shadow wash (30 % of the accent, 1.39:1 against the page) on five — and the
920
+ offset was −2px, inside the box, so on a bordered row the ring sat as a second
921
+ concentric line and the next row painted over its edge. */
922
+ --aparte-focus-outline-offset: var(--aparte-space-1);
923
+ /* The edge of a CONTROL — a field, a choice box, the select's trigger. `--aparte-border`
924
+ separates two regions and is right to be faint; a thing someone can touch has to be
925
+ seen, and six previews showed a control with no visible edge. Derived from the ink
926
+ and the ground so both schemes follow; the mix is a first setting. */
927
+ --aparte-border-control: color-mix(in srgb, var(--aparte-text) 30%, var(--aparte-surface-1));
759
928
 
760
929
  /* Component-specific radius (overridable per app) */
761
930
  --aparte-radius-input: var(--aparte-radius-lg);
@@ -763,19 +932,25 @@ aparte-chat {
763
932
  --aparte-radius-send-btn: var(--aparte-radius-md);
764
933
  --aparte-radius-action-btn: var(--aparte-radius-sm);
765
934
  --aparte-radius-select: var(--aparte-radius-md);
766
- --aparte-radius-avatar: var(--aparte-radius-md);
767
935
  --aparte-radius-bubble: var(--aparte-radius-lg);
936
+ /* The bubble's corner IS the bubble radius above: it was a 14px literal off the scale,
937
+ the most visible corner of the transcript, and the knob named for it drove nothing. */
938
+ --aparte-message-content-radius: var(--aparte-radius-bubble);
768
939
  --aparte-radius-code: var(--aparte-radius-lg);
769
940
  --aparte-radius-thinking: var(--aparte-radius-lg);
770
941
  --aparte-radius-error: var(--aparte-radius-md);
942
+ /* The floating surfaces. They read a step of the scale directly, so a theme that
943
+ wanted square menus and round bubbles had to move the step — and every other
944
+ reader of it. */
945
+ --aparte-radius-menu: var(--aparte-radius-lg);
946
+ --aparte-radius-popover: var(--aparte-radius-lg);
947
+ --aparte-radius-dialog: var(--aparte-radius-xl);
948
+ --aparte-radius-tooltip: var(--aparte-radius-sm);
771
949
 
772
950
  /* The error ring on an avatar. A full shadow value, like
773
951
  --aparte-scroll-btn-shadow: the whole ring is the knob, not its width. */
774
952
  --aparte-avatar-error-ring: 0 0 0 2px var(--aparte-error-solid);
775
953
 
776
- /* Focus ring — derived from the accent so it themes automatically. */
777
- --aparte-focus-ring: 0 0 0 3px color-mix(in srgb, var(--aparte-primary) 30%, transparent);
778
-
779
954
  /* Messages */
780
955
  --aparte-message-content-text-user: var(--aparte-text);
781
956
  --aparte-message-content-text-assistant: var(--aparte-text);
@@ -785,7 +960,9 @@ aparte-chat {
785
960
  in sRGB rather than derived with `oklch(from …)`, because a wash has to keep the
786
961
  SURFACE's lightness, not the accent's; 12 % is where the default palette reads as a
787
962
  tint, not a fill, on both grounds. */
788
- --aparte-message-content-bg-user: color-mix(in srgb, var(--aparte-primary) 12%, var(--aparte-surface-1));
963
+ /* The mark's own tint: at 12 % the user bubble — the one mark that says "this turn is
964
+ yours" — sat at 1.03:1 on the light page. */
965
+ --aparte-message-content-bg-user: color-mix(in srgb, var(--aparte-primary) var(--aparte-mark-tint), var(--aparte-surface-1));
789
966
  /* The third surface: the second, pulled 6 % toward the text. Close to what the old
790
967
  literals encoded (`#e7dfce` from `#f1ebdf` light, `#342a40` from `#2a2333` dark —
791
968
  5 to 9 % per channel, not one figure), now one rule that follows the masters. */
@@ -813,6 +990,12 @@ aparte-chat {
813
990
  --aparte-thumb-radius: var(--aparte-radius-lg);
814
991
 
815
992
  /* Action Bar */
993
+ /* The small controls of a turn and of a conversation row are the recipe's small step
994
+ (24px, the WCAG 2.5.8 floor). The action bar used to feed 28 with a 24 exception for
995
+ the last user turn, the branch arrows redrew 20 over their own `--sm`, the row's
996
+ `⋯` redrew 20: three control heights in one 14px row. */
997
+ --aparte-action-bar-btn-size: var(--aparte-btn-size-sm);
998
+ --aparte-conv-action-btn-size: var(--aparte-btn-size-sm);
816
999
  --aparte-action-bar-btn-color: var(--aparte-text-muted);
817
1000
  --aparte-action-bar-btn-hover-bg: var(--aparte-surface-2);
818
1001
  --aparte-action-bar-btn-hover-color: var(--aparte-text);
@@ -823,11 +1006,10 @@ aparte-chat {
823
1006
  --aparte-branch-picker-label-color: var(--aparte-text-muted);
824
1007
 
825
1008
  /* Avatars */
826
- --aparte-avatar-radius: var(--aparte-radius-avatar);
827
1009
  --aparte-avatar-bg-user: var(--aparte-primary);
828
1010
  --aparte-avatar-text-user: var(--aparte-text-inverse);
829
1011
  --aparte-avatar-bg-assistant: var(--aparte-surface-3);
830
- --aparte-avatar-text-assistant: var(--aparte-text-inverse);
1012
+ --aparte-avatar-text-assistant: var(--aparte-text);
831
1013
 
832
1014
  /* Text Sizes */
833
1015
  --aparte-name-color: var(--aparte-text);
@@ -856,6 +1038,10 @@ aparte-chat {
856
1038
  --aparte-code-header-bg: var(--aparte-surface-2);
857
1039
  --aparte-code-border: var(--aparte-border);
858
1040
  --aparte-code-header-text: var(--aparte-text-muted);
1041
+ /* The block's own type. It used to inherit the prose's size and leading (16px on 1.7)
1042
+ under the browser's generic monospace — the largest, airiest text in the message. */
1043
+ --aparte-code-block-font-size: var(--aparte-font-size-md);
1044
+ --aparte-code-block-line-height: var(--aparte-line-height-normal);
859
1045
 
860
1046
  /* Scroll-to-bottom button */
861
1047
 
@@ -875,6 +1061,9 @@ aparte-chat {
875
1061
 
876
1062
  /* Shell */
877
1063
  --aparte-sidebar-bg: var(--aparte-surface-2);
1064
+ /* The one inline inset of the column's four regions — header, search, body, footer.
1065
+ They used to carry three (16 / 12 / 12 / 16) and the content sat on two axes. */
1066
+ --aparte-sidebar-inset: var(--aparte-space-3);
878
1067
 
879
1068
  /* Conversation List */
880
1069
  --aparte-conv-item-radius: var(--aparte-radius-md);
@@ -954,6 +1143,28 @@ aparte-chat {
954
1143
  animation: aparte-pulse var(--aparte-duration-pulse) infinite ease-in-out;
955
1144
  }
956
1145
 
1146
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
1147
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
1148
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
1149
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
1150
+ the way display/icon.css already does. */
1151
+ @media (prefers-reduced-motion: reduce) {
1152
+ .aparte-dot { animation: none; }
1153
+ }
1154
+
1155
+ /* `hidden` means hidden. 71 recipes in these sheets set `display` on their base class,
1156
+ and an author declaration beats the browser's own `[hidden] { display: none }` — so a
1157
+ hidden `.aparte-btn` was out of the accessibility tree and still painted: the copy
1158
+ button of a tool-only turn stood there with `hidden` on it. Scoped to our own classes
1159
+ (a prefix match, not a bare `[hidden]`, so a host's markup is not touched), and
1160
+ `!important` because the whole point is to outrank the recipe's `display`. The
1161
+ handful of per-element `[hidden]` rules that predate this stay; they are now
1162
+ redundant, not wrong. */
1163
+ [class^="aparte-"][hidden],
1164
+ [class*=" aparte-"][hidden] {
1165
+ display: none !important;
1166
+ }
1167
+
957
1168
  /* =========================================
958
1169
  aparte-conversation-list
959
1170
  ========================================= */
@@ -985,7 +1196,7 @@ aparte-conversation-list {
985
1196
  .aparte-link:hover { color: var(--aparte-link-color-hover); }
986
1197
  .aparte-link:focus-visible {
987
1198
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
988
- outline-offset: var(--aparte-btn-focus-offset);
1199
+ outline-offset: var(--aparte-focus-outline-offset);
989
1200
  }
990
1201
  /* Quiet — for a link inside a dense row, where a coloured one would shout. It keeps
991
1202
  the underline, which is the part that carries the meaning. */
@@ -1008,8 +1219,11 @@ aparte-conversation-list {
1008
1219
  same thing at slightly different rhythms; a travelling wave, where a row wants one,
1009
1220
  comes from an animation-delay on its later dots, not from a skewed curve. */
1010
1221
  @keyframes aparte-pulse {
1011
- 0%, 100% { opacity: 0.3; transform: scale(0.85); }
1012
- 50% { opacity: 1; transform: scale(1.1); }
1222
+ /* Opacity alone: the scale that used to ride along made a row of waiting dots change
1223
+ width in a loop. And a floor above the perception threshold — at 0.3 the status
1224
+ dot spent half of every cycle at 1.55:1, and a capture froze it there. */
1225
+ 0%, 100% { opacity: var(--aparte-pulse-floor); }
1226
+ 50% { opacity: 1; }
1013
1227
  }
1014
1228
 
1015
1229
  /* One rotation, for every spinner and every spinning glyph. */
@@ -1030,9 +1244,9 @@ aparte-conversation-list {
1030
1244
  * <aparte-icon name="copy"></aparte-icon>
1031
1245
  * </button>
1032
1246
  * <div class="aparte-btn-group" role="group" aria-label="Versions">
1033
- * <button class="aparte-btn aparte-btn--surface">‹</button>
1247
+ * <button class="aparte-btn aparte-btn--surface" aria-label="Previous"><aparte-icon name="prevBranch"></aparte-icon></button>
1034
1248
  * <button class="aparte-btn aparte-btn--surface">1 / 2</button>
1035
- * <button class="aparte-btn aparte-btn--surface">›</button>
1249
+ * <button class="aparte-btn aparte-btn--surface" aria-label="Next"><aparte-icon name="nextBranch"></aparte-icon></button>
1036
1250
  * </div>
1037
1251
  * <div class="aparte-btn-group" role="group" aria-label="View">
1038
1252
  * <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="true">Chat</button>
@@ -1059,6 +1273,13 @@ aparte-conversation-list {
1059
1273
  */
1060
1274
 
1061
1275
  .aparte-btn {
1276
+ /* The glyph reads this: an `<aparte-icon>` (or a raw `svg.aparte-icon`) inside a button
1277
+ takes the button's icon size, per size modifier. */
1278
+ --aparte-icon-size: var(--aparte-btn-icon-size);
1279
+ /* The box, for EVERY button: `--icon` and `--circle` used to be the only readers of
1280
+ the size, so a text button was padding plus line-height — 23px beside a 32px icon
1281
+ button in the same row. The size is declared here and read below as a minimum. */
1282
+ --aparte-btn-size: var(--aparte-btn-size-md);
1062
1283
  /* The intent, as a value. Every fill below reads THIS and nothing else, which is
1063
1284
  what keeps the two axes independent. */
1064
1285
  --aparte-btn-intent: var(--aparte-neutral);
@@ -1077,6 +1298,8 @@ aparte-conversation-list {
1077
1298
  --aparte-btn-intent-ink: var(--aparte-btn-intent);
1078
1299
 
1079
1300
  display: inline-flex;
1301
+
1302
+ vertical-align: middle;
1080
1303
  align-items: center;
1081
1304
  justify-content: center;
1082
1305
  gap: var(--aparte-btn-gap);
@@ -1087,6 +1310,7 @@ aparte-conversation-list {
1087
1310
  be set by the composer's own row rule, which is exactly the kind of thing a recipe
1088
1311
  should not need a caller for. */
1089
1312
  box-sizing: border-box;
1313
+ min-block-size: var(--aparte-btn-size);
1090
1314
  padding: var(--aparte-btn-padding);
1091
1315
  border: var(--aparte-border-width) solid transparent;
1092
1316
  border-radius: var(--aparte-btn-radius);
@@ -1241,15 +1465,21 @@ aparte-conversation-list {
1241
1465
  .aparte-btn--soft:active:not(:disabled) { background: color-mix(in srgb, var(--aparte-btn-intent) 30%, transparent); }
1242
1466
  .aparte-btn--solid:active:not(:disabled) { background: var(--aparte-btn-intent); filter: brightness(0.93); }
1243
1467
 
1468
+ /* Drawn, not faded. Opacity on the whole button faded the glyph with its ground and
1469
+ read at 2:1 on the send button; "inactive" and "disappearing" are not the same
1470
+ message. A neutral ground, the muted ink, no intent — whatever the fill was. */
1244
1471
  .aparte-btn:disabled,
1245
1472
  .aparte-btn[aria-disabled='true'] {
1246
1473
  cursor: not-allowed;
1247
- opacity: var(--aparte-disabled-opacity);
1474
+ background: var(--aparte-surface-2);
1475
+ color: var(--aparte-text-muted);
1476
+ border-color: transparent;
1477
+ filter: none;
1248
1478
  }
1249
1479
 
1250
1480
  .aparte-btn:focus-visible {
1251
- outline: var(--aparte-focus-outline-width) solid var(--aparte-btn-intent);
1252
- outline-offset: var(--aparte-btn-focus-offset);
1481
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1482
+ outline-offset: var(--aparte-focus-outline-offset);
1253
1483
  }
1254
1484
 
1255
1485
  /* Toggled — a disclosure that is open, a toggle that is on, a tab that is current. */
@@ -1282,25 +1512,39 @@ aparte-conversation-list {
1282
1512
  .aparte-btn--block { display: flex; width: 100%; }
1283
1513
 
1284
1514
  .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); }
1285
- .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); }
1515
+ .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); }
1516
+ .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); }
1286
1517
  .aparte-btn--sm.aparte-btn--icon,
1287
1518
  .aparte-btn--sm.aparte-btn--circle,
1288
1519
  .aparte-btn--lg.aparte-btn--icon,
1289
- .aparte-btn--lg.aparte-btn--circle { padding: 0; }
1520
+ .aparte-btn--lg.aparte-btn--circle,
1521
+ .aparte-btn--xl.aparte-btn--icon,
1522
+ .aparte-btn--xl.aparte-btn--circle { padding: 0; }
1290
1523
 
1291
1524
  /* An icon inside a button never takes the pointer: the click belongs to the button,
1292
- and an SVG target makes `event.target` lie to a delegated listener. It is sized with
1293
- the button so the two axes stay in step. */
1525
+ and an SVG target makes `event.target` lie to a delegated listener. */
1294
1526
  .aparte-btn > svg,
1527
+ .aparte-btn > aparte-icon,
1295
1528
  .aparte-btn > .aparte-btn__icon {
1296
1529
  pointer-events: none;
1297
1530
  flex-shrink: 0;
1531
+ }
1532
+
1533
+ /* A raw SVG or a consumer's own icon box is sized with the button so the two axes stay
1534
+ in step. An `<aparte-icon>` is NOT sized here: it renders its `<svg>` inside itself, a
1535
+ grandchild this child combinator never reached, so the documented markup kept the
1536
+ glyph at its loose 14px default in a button that asked for 16. The recipe feeds the
1537
+ icon's own knob instead — `--aparte-icon-size`, declared on `.aparte-btn` above — and
1538
+ `display/icon.css` draws it, at `--sm`'s 12 and `--lg`'s 20 like everything else. */
1539
+ .aparte-btn > svg:not(.aparte-icon),
1540
+ .aparte-btn > .aparte-btn__icon {
1298
1541
  width: var(--aparte-btn-icon-size);
1299
1542
  height: var(--aparte-btn-icon-size);
1300
1543
  }
1301
1544
 
1302
1545
  /* ── Group — buttons that read as one control. ───────────────────────────────── */
1303
- .aparte-btn-group { display: inline-flex; }
1546
+ .aparte-btn-group { display: inline-flex;
1547
+ vertical-align: middle; }
1304
1548
  .aparte-btn-group > .aparte-btn { border-radius: 0; }
1305
1549
  /* Logical properties, so the joined edge follows the writing direction instead of
1306
1550
  assuming left-to-right. */
@@ -1384,24 +1628,24 @@ aparte-conversation-list {
1384
1628
  /* ── The shared text-entry recipe ────────────────────────────────────────────── */
1385
1629
 
1386
1630
  .aparte-field {
1387
- /* Padding recipe, declared here: nothing in the shared token file defines a field
1388
- padding yet, so this file owns its own default and size variants. */
1389
- --aparte-field-padding: var(--aparte-space-2) var(--aparte-space-3);
1390
- /* Same reason, and a gap the recipe had: every other recipe names its own radius
1391
- (`--aparte-btn-radius`, `--aparte-tag-radius`, `--aparte-field-choice-radius`) and
1392
- this one hardcoded the scale step, so a field could not be re-cornered from its
1393
- own element the way every sibling can. */
1394
- --aparte-field-radius: var(--aparte-radius-md);
1395
-
1631
+ /* Every knob this family reads lives in `theme.css` on `:root` — including the
1632
+ radius and the paddings, which used to be declared right here. That was the one
1633
+ construction bug of the kit: `.aparte-field-group` is this element's PARENT and
1634
+ `.aparte-color` a sibling recipe, both read `--aparte-field-radius`, and a custom
1635
+ property declared here is invisible to both, so they computed `border-radius: 0`
1636
+ and every field group in the library rendered square. */
1396
1637
  box-sizing: border-box;
1397
1638
  display: block;
1398
1639
  width: 100%;
1640
+ /* A resting height from the control scale, so a field and the button beside it stop
1641
+ disagreeing by 6px; the size modifiers move it a step either way. */
1642
+ min-block-size: var(--aparte-field-size);
1399
1643
  font: inherit;
1400
- font-size: var(--aparte-font-size-sm);
1644
+ font-size: var(--aparte-font-size-md);
1401
1645
  line-height: var(--aparte-line-height-normal);
1402
1646
  color: var(--aparte-text);
1403
1647
  background: var(--aparte-surface-1);
1404
- border: var(--aparte-border-width) solid var(--aparte-border);
1648
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1405
1649
  border-radius: var(--aparte-field-radius);
1406
1650
  padding: var(--aparte-field-padding);
1407
1651
  transition:
@@ -1436,10 +1680,13 @@ aparte-conversation-list {
1436
1680
  border-color: var(--aparte-border-focus);
1437
1681
  }
1438
1682
 
1683
+ /* Drawn, not faded: the muted ink on the quieter ground, and the edge back to the region
1684
+ colour. Opacity faded the text with its box and read under 3:1. */
1439
1685
  .aparte-field:disabled {
1440
1686
  cursor: not-allowed;
1441
- opacity: var(--aparte-disabled-opacity);
1442
1687
  background: var(--aparte-surface-2);
1688
+ color: var(--aparte-text-muted);
1689
+ border-color: var(--aparte-border);
1443
1690
  }
1444
1691
 
1445
1692
  .aparte-field[readonly] {
@@ -1453,37 +1700,35 @@ aparte-conversation-list {
1453
1700
  border-color: var(--aparte-error);
1454
1701
  }
1455
1702
 
1703
+ /* The one ring (see --aparte-focus-outline-offset in theme.css): an outline outside the
1704
+ box, and the border left alone — a highlighted border under an offset ring read as two
1705
+ concentric lines. An invalid field's ring takes the error colour. */
1456
1706
  .aparte-field:focus-visible {
1457
- outline: none;
1458
- border-color: var(--aparte-border-focus);
1459
- box-shadow: var(--aparte-focus-ring);
1707
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1708
+ outline-offset: var(--aparte-focus-outline-offset);
1460
1709
  }
1461
1710
 
1462
1711
  .aparte-field[aria-invalid='true']:focus-visible {
1463
- border-color: var(--aparte-error);
1464
- box-shadow: var(--aparte-field-error-ring);
1712
+ outline-color: var(--aparte-error-solid);
1465
1713
  }
1466
1714
 
1467
1715
  /* ── Sizes ───────────────────────────────────────────────────────────────────── */
1468
1716
 
1469
1717
  .aparte-field--sm {
1470
- --aparte-field-padding-sm: var(--aparte-space-1) var(--aparte-space-2);
1471
-
1718
+ --aparte-field-size: var(--aparte-field-size-sm);
1472
1719
  padding: var(--aparte-field-padding-sm);
1473
- font-size: var(--aparte-font-size-xs);
1720
+ font-size: var(--aparte-font-size-sm);
1474
1721
  }
1475
1722
 
1476
1723
  .aparte-field--lg {
1477
- --aparte-field-padding-lg: var(--aparte-space-3) var(--aparte-space-4);
1478
-
1724
+ --aparte-field-size: var(--aparte-field-size-lg);
1479
1725
  padding: var(--aparte-field-padding-lg);
1480
- font-size: var(--aparte-font-size-md);
1726
+ font-size: var(--aparte-font-size-base);
1481
1727
  }
1482
1728
 
1483
1729
  /* ── Textarea ────────────────────────────────────────────────────────────────── */
1484
1730
 
1485
1731
  .aparte-field--textarea {
1486
- --aparte-field-textarea-min-height: 80px;
1487
1732
 
1488
1733
  display: block;
1489
1734
  min-block-size: var(--aparte-field-textarea-min-height);
@@ -1498,7 +1743,7 @@ aparte-conversation-list {
1498
1743
  display: flex;
1499
1744
  align-items: stretch;
1500
1745
  background: var(--aparte-surface-1);
1501
- border: var(--aparte-border-width) solid var(--aparte-border);
1746
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1502
1747
  border-radius: var(--aparte-field-radius);
1503
1748
  /* The addons' ground follows the group's corners; the focus ring is a shadow on the
1504
1749
  group itself, outside this box, so it is not clipped. */
@@ -1520,8 +1765,15 @@ aparte-conversation-list {
1520
1765
  border-radius: 0;
1521
1766
  }
1522
1767
 
1768
+ /* The GROUP draws the ring (below): the field inside it draws none. */
1523
1769
  .aparte-field-group > .aparte-field:focus-visible {
1524
- box-shadow: none;
1770
+ outline: none;
1771
+ }
1772
+
1773
+ /* A button in the group is as tall as the field it sits beside — the one row where the
1774
+ two recipes meet, and the one where a 4px difference shows. */
1775
+ .aparte-field-group > .aparte-btn {
1776
+ --aparte-btn-size: var(--aparte-field-size);
1525
1777
  }
1526
1778
 
1527
1779
  /* An addon has a ground of its own and a rule against the field: on the field's own
@@ -1548,8 +1800,8 @@ aparte-conversation-list {
1548
1800
  }
1549
1801
 
1550
1802
  .aparte-field-group:focus-within {
1551
- border-color: var(--aparte-border-focus);
1552
- box-shadow: var(--aparte-focus-ring);
1803
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1804
+ outline-offset: var(--aparte-focus-outline-offset);
1553
1805
  }
1554
1806
 
1555
1807
  /* `aria-invalid` lives on the field itself, not the wrapper the app didn't write a
@@ -1560,7 +1812,7 @@ aparte-conversation-list {
1560
1812
  }
1561
1813
 
1562
1814
  .aparte-field-group:has(.aparte-field[aria-invalid='true']):focus-within {
1563
- box-shadow: var(--aparte-field-error-ring);
1815
+ outline-color: var(--aparte-error-solid);
1564
1816
  }
1565
1817
 
1566
1818
  /* ── Checkbox ────────────────────────────────────────────────────────────────── */
@@ -1584,7 +1836,6 @@ aparte-conversation-list {
1584
1836
  --aparte-derived-ink: var(--aparte-on-intent);
1585
1837
  /* No shared control-size token covers a checkbox yet (the button's own sizes are
1586
1838
  button-specific), so its box is declared here. */
1587
- --aparte-checkbox-size: 18px;
1588
1839
 
1589
1840
  box-sizing: border-box;
1590
1841
  appearance: none;
@@ -1595,7 +1846,7 @@ aparte-conversation-list {
1595
1846
  place-content: center;
1596
1847
  width: var(--aparte-checkbox-size);
1597
1848
  height: var(--aparte-checkbox-size);
1598
- border: var(--aparte-border-width) solid var(--aparte-border);
1849
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1599
1850
  border-radius: var(--aparte-radius-sm);
1600
1851
  background: var(--aparte-surface-1);
1601
1852
  cursor: pointer;
@@ -1680,7 +1931,7 @@ aparte-conversation-list {
1680
1931
  }
1681
1932
 
1682
1933
  .aparte-checkbox:focus-visible {
1683
- outline: var(--aparte-focus-outline-width) solid var(--aparte-checkbox-intent);
1934
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1684
1935
  outline-offset: var(--aparte-focus-outline-offset);
1685
1936
  }
1686
1937
 
@@ -1710,8 +1961,6 @@ aparte-conversation-list {
1710
1961
  used. `middle` and not `text-bottom`: the label may wrap. */
1711
1962
  vertical-align: middle;
1712
1963
  --aparte-radio-intent: var(--aparte-primary);
1713
- --aparte-radio-size: 18px;
1714
- --aparte-radio-dot-size: 8px;
1715
1964
 
1716
1965
  box-sizing: border-box;
1717
1966
  appearance: none;
@@ -1722,7 +1971,7 @@ aparte-conversation-list {
1722
1971
  place-content: center;
1723
1972
  width: var(--aparte-radio-size);
1724
1973
  height: var(--aparte-radio-size);
1725
- border: var(--aparte-border-width) solid var(--aparte-border);
1974
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1726
1975
  border-radius: var(--aparte-radius-full);
1727
1976
  background: var(--aparte-surface-1);
1728
1977
  cursor: pointer;
@@ -1764,7 +2013,7 @@ aparte-conversation-list {
1764
2013
  }
1765
2014
 
1766
2015
  .aparte-radio:focus-visible {
1767
- outline: var(--aparte-focus-outline-width) solid var(--aparte-radio-intent);
2016
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1768
2017
  outline-offset: var(--aparte-focus-outline-offset);
1769
2018
  }
1770
2019
 
@@ -1795,9 +2044,6 @@ aparte-conversation-list {
1795
2044
  has to live here where `:checked` can override it — a central default could never
1796
2045
  react to the class + state combo. */
1797
2046
  --aparte-switch-track: var(--aparte-surface-3);
1798
- --aparte-switch-width: 40px;
1799
- --aparte-switch-height: 22px;
1800
- --aparte-switch-thumb-inset: 2px;
1801
2047
  /* DERIVED from the track, not a fourth literal to keep in step with the other three:
1802
2048
  the thumb used to be 16px in a 20px track with a 1px border and a 2px top inset —
1803
2049
  2px of air above it and none below, visibly low ("le cercle pas centré"). The
@@ -1816,7 +2062,7 @@ aparte-conversation-list {
1816
2062
  flex-shrink: 0;
1817
2063
  inline-size: var(--aparte-switch-width);
1818
2064
  block-size: var(--aparte-switch-height);
1819
- border: var(--aparte-border-width) solid var(--aparte-border);
2065
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
1820
2066
  border-radius: var(--aparte-radius-full);
1821
2067
  background: var(--aparte-switch-track);
1822
2068
  cursor: pointer;
@@ -1864,7 +2110,7 @@ aparte-conversation-list {
1864
2110
  }
1865
2111
 
1866
2112
  .aparte-switch:focus-visible {
1867
- outline: var(--aparte-focus-outline-width) solid var(--aparte-switch-intent);
2113
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1868
2114
  outline-offset: var(--aparte-focus-outline-offset);
1869
2115
  }
1870
2116
 
@@ -1888,8 +2134,6 @@ aparte-conversation-list {
1888
2134
 
1889
2135
  .aparte-range {
1890
2136
  --aparte-range-intent: var(--aparte-primary);
1891
- --aparte-range-thumb-size: 16px;
1892
- --aparte-range-track-height: 4px;
1893
2137
 
1894
2138
  appearance: none;
1895
2139
  -webkit-appearance: none;
@@ -1959,11 +2203,11 @@ aparte-conversation-list {
1959
2203
  .aparte-range:active::-moz-range-thumb { transform: scale(1.15); }
1960
2204
 
1961
2205
  .aparte-range:focus-visible::-webkit-slider-thumb {
1962
- outline: var(--aparte-focus-outline-width) solid var(--aparte-range-intent);
2206
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1963
2207
  outline-offset: var(--aparte-focus-outline-offset);
1964
2208
  }
1965
2209
  .aparte-range:focus-visible::-moz-range-thumb {
1966
- outline: var(--aparte-focus-outline-width) solid var(--aparte-range-intent);
2210
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
1967
2211
  outline-offset: var(--aparte-focus-outline-offset);
1968
2212
  }
1969
2213
 
@@ -1984,6 +2228,7 @@ aparte-conversation-list {
1984
2228
 
1985
2229
  .aparte-field-label {
1986
2230
  display: inline-block;
2231
+ vertical-align: middle;
1987
2232
  margin-block-end: var(--aparte-space-1);
1988
2233
  font-size: var(--aparte-font-size-sm);
1989
2234
  font-weight: var(--aparte-font-weight-medium);
@@ -2071,7 +2316,7 @@ aparte-conversation-list {
2071
2316
  inline-size: var(--aparte-color-size);
2072
2317
  block-size: var(--aparte-color-size);
2073
2318
  padding: 0;
2074
- border: var(--aparte-border-width) solid var(--aparte-border);
2319
+ border: var(--aparte-border-width) solid var(--aparte-border-control);
2075
2320
  border-radius: var(--aparte-field-radius);
2076
2321
  background: none;
2077
2322
  cursor: pointer;
@@ -2081,7 +2326,7 @@ aparte-conversation-list {
2081
2326
  .aparte-color::-moz-color-swatch { border: none; border-radius: var(--aparte-radius-sm); }
2082
2327
  .aparte-color:focus-visible {
2083
2328
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2084
- outline-offset: var(--aparte-btn-focus-offset);
2329
+ outline-offset: var(--aparte-focus-outline-offset);
2085
2330
  }
2086
2331
  .aparte-color:disabled { cursor: not-allowed; opacity: var(--aparte-disabled-opacity); }
2087
2332
 
@@ -2137,6 +2382,7 @@ aparte-conversation-list {
2137
2382
  would invite a click. */
2138
2383
  .aparte-output {
2139
2384
  display: inline-block;
2385
+ vertical-align: middle;
2140
2386
  padding: var(--aparte-output-padding);
2141
2387
  border-radius: var(--aparte-radius-sm);
2142
2388
  background: var(--aparte-output-bg);
@@ -2219,19 +2465,22 @@ aparte-conversation-list {
2219
2465
  }
2220
2466
  .aparte-field-choice:has(:focus-visible) {
2221
2467
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2222
- outline-offset: var(--aparte-btn-focus-offset);
2468
+ outline-offset: var(--aparte-focus-outline-offset);
2223
2469
  }
2224
2470
 
2225
2471
  .aparte-field-choice__body { flex: 1 1 auto; min-width: 0; }
2226
2472
  /* =========================================
2227
2473
  aparte-avatar — sizes, shape, image + initials fallback
2228
2474
  =========================================
2229
- --aparte-avatar-size, --aparte-avatar-radius and --aparte-avatar-font-size are
2230
- NOT invented here: they already exist centrally for the message avatar
2231
- (bubble.css), and this component deliberately reads the same three rather than
2232
- declare its own — a generic avatar and a chat-message avatar are the same
2233
- object, and a size or shape modifier applied here resizes a role-coloured
2234
- message avatar too, for free, instead of only a second, disconnected one.
2475
+ `--aparte-avatar-size` is NOT invented here: it already exists centrally for the
2476
+ message avatar (bubble.css), and this component reads the same one — a generic
2477
+ avatar and a chat-message avatar are the same object, and a size modifier applied
2478
+ here resizes a role-coloured message avatar too. The corner, the initials and the
2479
+ group's overlap are FRACTIONS of that size (`--aparte-avatar-radius-ratio`,
2480
+ `--aparte-avatar-initials-ratio`, `--aparte-avatar-overlap-ratio`), computed on the
2481
+ element so a size modifier moves them: as absolute tokens the 40px avatar drew the
2482
+ same 11px initials as the 32px one, the corner drifted from squircle to square up
2483
+ the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one.
2235
2484
 
2236
2485
  <span class="aparte-avatar aparte-avatar--sm">AP</span>
2237
2486
  <span class="aparte-avatar">JD</span>
@@ -2246,16 +2495,17 @@ aparte-conversation-list {
2246
2495
  .aparte-avatar {
2247
2496
  position: relative;
2248
2497
  display: inline-flex;
2498
+ vertical-align: middle;
2249
2499
  flex-shrink: 0;
2250
2500
  align-items: center;
2251
2501
  justify-content: center;
2252
2502
  overflow: hidden;
2253
2503
  width: var(--aparte-avatar-size);
2254
2504
  height: var(--aparte-avatar-size);
2255
- border-radius: var(--aparte-avatar-radius);
2505
+ border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio));
2256
2506
  background: var(--aparte-avatar-fallback-bg);
2257
2507
  color: var(--aparte-avatar-fallback-color);
2258
- font-size: var(--aparte-avatar-font-size);
2508
+ font-size: calc(var(--aparte-avatar-size) * var(--aparte-avatar-initials-ratio));
2259
2509
  font-weight: var(--aparte-font-weight-semibold);
2260
2510
  line-height: var(--aparte-line-height-none);
2261
2511
  text-transform: uppercase;
@@ -2279,13 +2529,13 @@ aparte-conversation-list {
2279
2529
 
2280
2530
  /* Square is the default shape and needs no class (it is what the message avatar
2281
2531
  already renders); named anyway so a square avatar can say so rather than omit. */
2282
- .aparte-avatar--square { border-radius: var(--aparte-avatar-radius); }
2532
+ .aparte-avatar--square { border-radius: calc(var(--aparte-avatar-size) * var(--aparte-avatar-radius-ratio)); }
2283
2533
  .aparte-avatar--circle { border-radius: var(--aparte-radius-full); }
2284
2534
 
2285
- .aparte-avatar--xs { --aparte-avatar-size: var(--aparte-avatar-size-xs); --aparte-avatar-font-size: var(--aparte-font-size-2xs); }
2286
- .aparte-avatar--sm { --aparte-avatar-size: var(--aparte-avatar-size-sm); --aparte-avatar-font-size: var(--aparte-font-size-xs); }
2287
- .aparte-avatar--lg { --aparte-avatar-size: var(--aparte-avatar-size-lg); --aparte-avatar-font-size: var(--aparte-font-size-lg); }
2288
- .aparte-avatar--xl { --aparte-avatar-size: var(--aparte-avatar-size-xl); --aparte-avatar-font-size: var(--aparte-font-size-lg); }
2535
+ .aparte-avatar--xs { --aparte-avatar-size: var(--aparte-avatar-size-xs); }
2536
+ .aparte-avatar--sm { --aparte-avatar-size: var(--aparte-avatar-size-sm); }
2537
+ .aparte-avatar--lg { --aparte-avatar-size: var(--aparte-avatar-size-lg); }
2538
+ .aparte-avatar--xl { --aparte-avatar-size: var(--aparte-avatar-size-xl); }
2289
2539
 
2290
2540
  /* ── Group — a fan of avatars that read as one party. ─────────────────────────
2291
2541
  No z-index is set: later siblings simply paint over earlier ones in normal
@@ -2293,13 +2543,14 @@ aparte-conversation-list {
2293
2543
  the one thing z-index would otherwise be needed for. */
2294
2544
  .aparte-avatar-group {
2295
2545
  display: inline-flex;
2546
+ vertical-align: middle;
2296
2547
  align-items: center;
2297
2548
  }
2298
2549
  .aparte-avatar-group > .aparte-avatar {
2299
2550
  box-shadow: 0 0 0 var(--aparte-avatar-group-ring-width) var(--aparte-avatar-group-ring-color);
2300
2551
  }
2301
2552
  .aparte-avatar-group > .aparte-avatar:not(:first-child) {
2302
- margin-inline-start: calc(var(--aparte-avatar-group-overlap) * -1);
2553
+ margin-inline-start: calc(var(--aparte-avatar-size) * var(--aparte-avatar-overlap-ratio) * -1);
2303
2554
  }
2304
2555
  .aparte-avatar-group > .aparte-avatar:hover {
2305
2556
  z-index: var(--aparte-z-raised);
@@ -2418,6 +2669,8 @@ aparte-icon {
2418
2669
  --aparte-derived-ink: var(--aparte-on-intent);
2419
2670
 
2420
2671
  display: inline-flex;
2672
+
2673
+ vertical-align: middle;
2421
2674
  align-items: center;
2422
2675
  justify-content: center;
2423
2676
  gap: var(--aparte-badge-gap);
@@ -2515,6 +2768,7 @@ aparte-icon {
2515
2768
  .aparte-tag {
2516
2769
  box-sizing: border-box;
2517
2770
  display: inline-flex;
2771
+ vertical-align: middle;
2518
2772
  align-items: center;
2519
2773
  gap: var(--aparte-tag-gap);
2520
2774
  max-width: 100%;
@@ -2577,8 +2831,8 @@ aparte-icon {
2577
2831
  * <span class="aparte-thumbnail aparte-thumbnail--lg"><img class="aparte-thumbnail__image"
2578
2832
  * 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"
2579
2833
  * alt="A screenshot"></span>
2580
- * <span class="aparte-thumbnail"><span class="aparte-thumbnail__ext">PDF</span></span>
2581
- * <span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__ext">MD</span></span>
2834
+ * <span class="aparte-thumbnail"><span class="aparte-thumbnail__label">PDF</span></span>
2835
+ * <span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__label">MD</span></span>
2582
2836
  *
2583
2837
  * Distinct from `.aparte-avatar`, which is the same shape for a different reason: an
2584
2838
  * avatar stands for a PERSON and falls back to initials, so it carries type styling and
@@ -2685,14 +2939,8 @@ aparte-icon {
2685
2939
  }
2686
2940
 
2687
2941
  .aparte-thumb--image[role='button']:focus-visible {
2688
- box-shadow: var(--aparte-focus-ring);
2689
- }
2690
-
2691
- .aparte-thumb__img {
2692
- width: 100%;
2693
- height: 100%;
2694
- object-fit: cover;
2695
- display: block;
2942
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
2943
+ outline-offset: var(--aparte-focus-outline-offset);
2696
2944
  }
2697
2945
 
2698
2946
  .aparte-thumb--file {
@@ -2701,13 +2949,6 @@ aparte-icon {
2701
2949
  justify-content: center;
2702
2950
  }
2703
2951
 
2704
- .aparte-thumb__ext {
2705
- font-size: var(--aparte-font-size-xs);
2706
- font-weight: var(--aparte-font-weight-bold);
2707
- letter-spacing: 0.03em;
2708
- color: var(--aparte-text-muted);
2709
- }
2710
-
2711
2952
  .aparte-thumb__name {
2712
2953
  position: absolute;
2713
2954
  left: 0;
@@ -2738,9 +2979,13 @@ aparte-icon {
2738
2979
  */
2739
2980
 
2740
2981
  .aparte-spinner {
2741
-
2742
2982
  display: inline-block;
2983
+ vertical-align: middle;
2743
2984
  flex-shrink: 0;
2985
+ /* The token is the ring's outer size, border included: without this the ring painted
2986
+ 16/20/28 for tokens of 12/16/24 (the 2px stroke added on each side), and painted
2987
+ the token's value only on a host page with its own border-box reset. */
2988
+ box-sizing: border-box;
2744
2989
  width: var(--aparte-spinner-size);
2745
2990
  height: var(--aparte-spinner-size);
2746
2991
  border: var(--aparte-spinner-thickness) solid currentColor;
@@ -2755,6 +3000,15 @@ aparte-icon {
2755
3000
 
2756
3001
  .aparte-spinner--sm { --aparte-spinner-size: var(--aparte-spinner-size-sm); }
2757
3002
  .aparte-spinner--lg { --aparte-spinner-size: var(--aparte-spinner-size-lg); }
3003
+
3004
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3005
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3006
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3007
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3008
+ the way display/icon.css already does. */
3009
+ @media (prefers-reduced-motion: reduce) {
3010
+ .aparte-spinner { animation: none; }
3011
+ }
2758
3012
  /* =========================================
2759
3013
  aparte-progress — determinate bar + indeterminate variant
2760
3014
  =========================================
@@ -2779,7 +3033,9 @@ aparte-icon {
2779
3033
  width: 100%;
2780
3034
  height: var(--aparte-progress-height);
2781
3035
  border-radius: var(--aparte-radius-full);
2782
- background: var(--aparte-surface-3);
3036
+ /* The track: a reference the value is read against, so it has to be seen. A surface
3037
+ level put it at 1.06:1 in light and 1.20:1 in dark — one token, two distances. */
3038
+ background: var(--aparte-track);
2783
3039
  }
2784
3040
 
2785
3041
  .aparte-progress__bar {
@@ -2797,6 +3053,21 @@ aparte-icon {
2797
3053
  animation: aparte-progress-slide var(--aparte-duration-pulse) var(--aparte-ease) infinite;
2798
3054
  }
2799
3055
 
3056
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3057
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3058
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3059
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3060
+ the way display/icon.css already does.
3061
+ Stopped, the sliding segment would sit at one spot and read as a value; the whole
3062
+ track filled says "busy" without naming an amount. */
3063
+ @media (prefers-reduced-motion: reduce) {
3064
+ .aparte-progress--indeterminate .aparte-progress__bar {
3065
+ animation: none;
3066
+ width: 100%;
3067
+ inset-inline-start: 0;
3068
+ }
3069
+ }
3070
+
2800
3071
  /* inset-inline-start, not transform: translateX — a transform has no logical
2801
3072
  axis, so an RTL page would slide the indeterminate segment backwards. Animating
2802
3073
  a logical inset instead makes the sweep follow reading direction automatically. */
@@ -2829,10 +3100,15 @@ aparte-icon {
2829
3100
  <div>
2830
3101
  <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
2831
3102
  <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
2832
- <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span>
3103
+ <span class="aparte-skeleton aparte-skeleton--text" style="max-inline-size: 60%" aria-hidden="true"></span>
2833
3104
  </div>
2834
- <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 1 / -1; block-size: 64px" aria-hidden="true"></span>
3105
+ <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 2" aria-hidden="true"></span>
2835
3106
  </div>
3107
+
3108
+ The last line is shorter because the last line of a paragraph is, and the block sits
3109
+ in the text column, under the lines it stands for — not across the avatar's. The
3110
+ rect's height is the family's token (`--aparte-skeleton-rect-height`), not an inline
3111
+ value that contradicts it.
2836
3112
  */
2837
3113
 
2838
3114
  .aparte-skeleton {
@@ -2849,6 +3125,19 @@ aparte-icon {
2849
3125
  animation: aparte-skeleton-shimmer var(--aparte-duration-pulse) linear infinite;
2850
3126
  }
2851
3127
 
3128
+ /* Under reduced motion the loop stops outright. The duration tokens are reset to
3129
+ 0.01ms in responsive.css, but that leaves `infinite` in place: a 0.01ms cycle
3130
+ is not stillness, it is a flicker. The descendant sweep there does not reach a
3131
+ recipe used outside aparté's own elements, so each looping recipe stops itself,
3132
+ the way display/icon.css already does.
3133
+ The shine goes with the shimmer: a stopped gradient would sit as a pale band. */
3134
+ @media (prefers-reduced-motion: reduce) {
3135
+ .aparte-skeleton {
3136
+ animation: none;
3137
+ background-image: none;
3138
+ }
3139
+ }
3140
+
2852
3141
  .aparte-skeleton--text {
2853
3142
  width: 100%;
2854
3143
  height: var(--aparte-skeleton-text-height);
@@ -2856,7 +3145,7 @@ aparte-icon {
2856
3145
  }
2857
3146
  .aparte-skeleton--text + .aparte-skeleton--text { margin-block-start: var(--aparte-skeleton-text-gap); }
2858
3147
  /* The ragged last line reads as "text", where a full-width block reads as a bar. */
2859
- .aparte-skeleton--text:last-child { width: var(--aparte-skeleton-text-last-width); }
3148
+ .aparte-skeleton--text:last-child:not(:first-child) { width: var(--aparte-skeleton-text-last-width); }
2860
3149
 
2861
3150
  .aparte-skeleton--circle {
2862
3151
  width: var(--aparte-skeleton-circle-size);
@@ -2944,6 +3233,7 @@ aparte-icon {
2944
3233
  <button class="aparte-alert__dismiss" aria-label="Dismiss"><aparte-icon name="close"></aparte-icon></button>
2945
3234
  </div>
2946
3235
  <div class="aparte-alert aparte-alert--danger" role="alert">
3236
+ <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span>
2947
3237
  <div class="aparte-alert__body">
2948
3238
  <p class="aparte-alert__message">The request was refused: no API key is set.</p>
2949
3239
  </div>
@@ -3034,7 +3324,7 @@ aparte-icon {
3034
3324
  }
3035
3325
  .aparte-alert__dismiss:active:not(:disabled) { filter: brightness(0.93); }
3036
3326
  .aparte-alert__dismiss:focus-visible {
3037
- outline: var(--aparte-focus-outline-width) solid var(--aparte-alert-intent);
3327
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3038
3328
  outline-offset: var(--aparte-focus-outline-offset);
3039
3329
  }
3040
3330
  .aparte-alert__dismiss:disabled { opacity: var(--aparte-disabled-opacity); cursor: not-allowed; pointer-events: none; }
@@ -3073,6 +3363,12 @@ aparte-icon {
3073
3363
  padding: var(--aparte-card-body-padding);
3074
3364
  }
3075
3365
 
3366
+ /* The body owns its edges: a paragraph's block margins fold into the padding instead of
3367
+ stacking on it. The sheet's own example puts a <p> here, and the body band measured
3368
+ twice the height of the header and footer beside it. */
3369
+ .aparte-card__body > :first-child { margin-block-start: 0; }
3370
+ .aparte-card__body > :last-child { margin-block-end: 0; }
3371
+
3076
3372
  .aparte-card__footer {
3077
3373
  display: flex;
3078
3374
  align-items: center;
@@ -3347,7 +3643,7 @@ aparte-icon {
3347
3643
  }
3348
3644
 
3349
3645
  .aparte-tabs__tab:focus-visible {
3350
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3646
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3351
3647
  outline-offset: var(--aparte-focus-outline-offset);
3352
3648
  }
3353
3649
 
@@ -3364,10 +3660,13 @@ aparte-icon {
3364
3660
 
3365
3661
  .aparte-tabs__panel {
3366
3662
  padding-block: var(--aparte-space-6);
3663
+ /* The tab's own inline inset, so the panel's text starts under the label of the tab
3664
+ that opened it rather than 11px to its left. */
3665
+ padding-inline: var(--aparte-space-5);
3367
3666
  }
3368
3667
 
3369
3668
  .aparte-tabs__panel:focus-visible {
3370
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3669
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3371
3670
  outline-offset: var(--aparte-focus-outline-offset);
3372
3671
  }
3373
3672
 
@@ -3391,6 +3690,9 @@ aparte-icon {
3391
3690
  its own raised chip, rather than an underline beneath a plain row. Same markup,
3392
3691
  same states; only the container and the active tab's fill change. */
3393
3692
  .aparte-tabs--segmented {
3693
+ /* A control, not a bar: the track hugs its segments. As a block-level flex row it
3694
+ inherited its container's width — 1207px of track for 160px of chips at 1280. */
3695
+ display: inline-flex;
3394
3696
  gap: 0;
3395
3697
  padding: var(--aparte-space-1);
3396
3698
  background: var(--aparte-surface-2);
@@ -3410,6 +3712,11 @@ aparte-icon {
3410
3712
  .aparte-tabs--segmented .aparte-tabs__tab[aria-selected='true'] {
3411
3713
  background: var(--aparte-surface-1);
3412
3714
  color: var(--aparte-text);
3715
+ /* Raised RELATIVE to the track, not an absolute surface level: surface-1 over
3716
+ surface-2 read as raised in light and as sunken in dark, the same rule inverting
3717
+ its elevation with the theme. A 1px ring in the border colour says "on top" on
3718
+ either ground. */
3719
+ box-shadow: 0 0 0 var(--aparte-border-width) var(--aparte-border);
3413
3720
  }
3414
3721
  /* =========================================
3415
3722
  aparte-accordion — stacked disclosures
@@ -3426,7 +3733,21 @@ aparte-icon {
3426
3733
  Shipping
3427
3734
  <aparte-icon name="expand"></aparte-icon>
3428
3735
  </summary>
3429
- <div class="aparte-accordion__panel">Free above 50 €.</div>
3736
+ <div class="aparte-accordion__panel">Free above 50 €, otherwise 4,90 €. Two to four working days.</div>
3737
+ </details>
3738
+ <details class="aparte-accordion__item">
3739
+ <summary class="aparte-accordion__header">
3740
+ Returns
3741
+ <aparte-icon name="expand"></aparte-icon>
3742
+ </summary>
3743
+ <div class="aparte-accordion__panel">Thirty days, in the original packaging.</div>
3744
+ </details>
3745
+ <details class="aparte-accordion__item">
3746
+ <summary class="aparte-accordion__header">
3747
+ Payment
3748
+ <aparte-icon name="expand"></aparte-icon>
3749
+ </summary>
3750
+ <div class="aparte-accordion__panel">Card, PayPal, or an invoice for a company account.</div>
3430
3751
  </details>
3431
3752
  </div>
3432
3753
 
@@ -3494,7 +3815,7 @@ aparte-icon {
3494
3815
  }
3495
3816
 
3496
3817
  .aparte-accordion__header:focus-visible {
3497
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3818
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3498
3819
  outline-offset: var(--aparte-focus-outline-offset);
3499
3820
  }
3500
3821
 
@@ -3504,13 +3825,22 @@ aparte-icon {
3504
3825
  opacity: var(--aparte-disabled-opacity);
3505
3826
  }
3506
3827
 
3507
- .aparte-accordion__icon {
3828
+ /* Two spellings for the chevron, because the header's documented markup is a bare
3829
+ `<aparte-icon>` and a renderer that wraps a raw glyph needs a class to hang it on:
3830
+ the wrapper `.aparte-accordion__icon` (the thinking block), or an `<aparte-icon>` /
3831
+ `svg.aparte-icon` sitting directly in the header. Both are sized and both turn — the
3832
+ documented form used to match neither rule, so the kit's own example showed an open
3833
+ panel under a chevron still pointing down. */
3834
+ .aparte-accordion__icon,
3835
+ .aparte-accordion__header > aparte-icon,
3836
+ .aparte-accordion__header > .aparte-icon {
3837
+ display: inline-flex;
3508
3838
  flex-shrink: 0;
3509
- /* Sized off the same step the button recipe already uses for an icon glyph
3510
- (`--aparte-btn-icon-size: var(--aparte-space-8)`), so a disclosure chevron
3511
- reads as the same weight of icon as everywhere else in the library. */
3512
- width: var(--aparte-space-8);
3513
- height: var(--aparte-space-8);
3839
+ /* Fed to the glyph through its own knob, not drawn as a width/height: the icon
3840
+ recipe reads `--aparte-icon-size`, and a box declared here around it would leave
3841
+ the glyph at its loose default. The step is the one the button recipe uses for an
3842
+ icon glyph, so a disclosure chevron reads as the same weight of icon everywhere. */
3843
+ --aparte-icon-size: var(--aparte-space-8);
3514
3844
  color: var(--aparte-text-muted);
3515
3845
  transition: transform var(--aparte-duration-slow) var(--aparte-ease);
3516
3846
  }
@@ -3519,7 +3849,11 @@ aparte-icon {
3519
3849
  puts aria-expanded on the header itself; native <details> puts `open` on the
3520
3850
  ancestor and gives the browser no attribute to select on at all. */
3521
3851
  .aparte-accordion__header[aria-expanded='true'] .aparte-accordion__icon,
3522
- details[open] > .aparte-accordion__header .aparte-accordion__icon {
3852
+ .aparte-accordion__header[aria-expanded='true'] > aparte-icon,
3853
+ .aparte-accordion__header[aria-expanded='true'] > .aparte-icon,
3854
+ details[open] > .aparte-accordion__header .aparte-accordion__icon,
3855
+ details[open] > .aparte-accordion__header > aparte-icon,
3856
+ details[open] > .aparte-accordion__header > .aparte-icon {
3523
3857
  transform: rotate(180deg);
3524
3858
  }
3525
3859
 
@@ -3593,10 +3927,13 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3593
3927
  width: max-content;
3594
3928
  min-width: var(--aparte-menu-min-width);
3595
3929
  max-width: var(--aparte-menu-max-width);
3930
+ /* The two caps are the box's outer width, padding and border included — same rule
3931
+ as the popover, for the same reason. */
3932
+ box-sizing: border-box;
3596
3933
  padding: var(--aparte-space-2);
3597
3934
  background: var(--aparte-surface-1);
3598
3935
  border: var(--aparte-border-width) solid var(--aparte-border);
3599
- border-radius: var(--aparte-radius-lg);
3936
+ border-radius: var(--aparte-radius-menu);
3600
3937
  box-shadow: var(--aparte-menu-shadow);
3601
3938
  color: var(--aparte-text);
3602
3939
  z-index: var(--aparte-z-dropdown);
@@ -3642,7 +3979,7 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3642
3979
  }
3643
3980
 
3644
3981
  .aparte-menu__item:focus-visible {
3645
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
3982
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
3646
3983
  outline-offset: var(--aparte-focus-outline-offset);
3647
3984
  }
3648
3985
 
@@ -3655,14 +3992,14 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3655
3992
  pointer-events: none;
3656
3993
  }
3657
3994
 
3658
- /* The check mark for menuitemcheckbox / menuitemradio. Reserved as an invisible
3659
- glyph on every checkable item (matched by the presence of aria-checked, true or
3660
- false) so labels in a mixed checked/unchecked list stay aligned to one gutter;
3661
- a plain role="menuitem" without aria-checked gets no gutter at all. Text colour
3662
- rather than a fill or a box-shadow is deliberate: a glyph inherits `color`,
3663
- which forced-colors mode preserves, so the checked state survives that mode
3664
- with no separate override. */
3665
- .aparte-menu__item[aria-checked]::before {
3995
+ /* The check mark for menuitemcheckbox / menuitemradio. The gutter is reserved on EVERY
3996
+ item of a panel that holds a checkable one — as an invisible glyph — so all the
3997
+ labels of that panel share one edge. It used to be reserved per checkable item, and
3998
+ a plain role="menuitem" beside a checkable one started 16px further left: two label
3999
+ edges in one menu. Text colour rather than a fill or a box-shadow is deliberate: a
4000
+ glyph inherits `color`, which forced-colors mode preserves, so the checked state
4001
+ survives that mode with no separate override. */
4002
+ .aparte-menu:has(.aparte-menu__item[aria-checked]) .aparte-menu__item::before {
3666
4003
  content: '✓';
3667
4004
  flex-shrink: 0;
3668
4005
  visibility: hidden;
@@ -3737,11 +4074,14 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3737
4074
  display: flex;
3738
4075
  flex-direction: column;
3739
4076
  gap: var(--aparte-space-3);
4077
+ /* The cap is the box's outer width: it measured 342px for a documented 320 (padding
4078
+ and border outside the cap) until the recipe said so itself. */
4079
+ box-sizing: border-box;
3740
4080
  max-width: var(--aparte-popover-max-width);
3741
4081
  padding: var(--aparte-space-5);
3742
4082
  background: var(--aparte-surface-1);
3743
4083
  border: var(--aparte-border-width) solid var(--aparte-border);
3744
- border-radius: var(--aparte-radius-lg);
4084
+ border-radius: var(--aparte-radius-popover);
3745
4085
  box-shadow: var(--aparte-popover-shadow);
3746
4086
  color: var(--aparte-text);
3747
4087
  font-size: var(--aparte-font-size-md);
@@ -3804,7 +4144,7 @@ details[open] > .aparte-accordion__header .aparte-accordion__icon {
3804
4144
  background: var(--aparte-surface-1);
3805
4145
  color: var(--aparte-text);
3806
4146
  border: var(--aparte-border-width) solid var(--aparte-border);
3807
- border-radius: var(--aparte-radius-xl);
4147
+ border-radius: var(--aparte-radius-dialog);
3808
4148
  box-shadow: var(--aparte-menu-shadow);
3809
4149
  }
3810
4150
 
@@ -3880,11 +4220,20 @@ body:has(.aparte-dialog[open]) {
3880
4220
  .aparte-dialog:modal {
3881
4221
  width: 100%;
3882
4222
  max-width: 100%;
3883
- height: 100dvh;
4223
+ /* As tall as its content, capped at the screen, sitting on the bottom edge: a sheet
4224
+ stretched to 100dvh held a label, a field and two buttons over 586px of nothing.
4225
+ `fit-content`, not `auto` — on a positioned box with both block insets set, an
4226
+ auto height FILLS them (CSS 2 §10.6.4: the auto margins go to 0 and the height
4227
+ is solved for), so `auto` measured the same 720 the explicit 100dvh had. What
4228
+ the UA gives a dialog is `fit-content`, and it is what lets the auto top margin
4229
+ push the sheet onto the bottom edge. And the safe areas: the ✕ used to sit 6px
4230
+ from the physical top. */
4231
+ height: fit-content;
3884
4232
  max-height: 100dvh;
3885
- margin: 0;
4233
+ margin: auto 0 0;
4234
+ padding-block: env(safe-area-inset-top, 0px) env(safe-area-inset-bottom, 0px);
3886
4235
  border: 0;
3887
- border-radius: 0;
4236
+ border-radius: var(--aparte-radius-dialog) var(--aparte-radius-dialog) 0 0;
3888
4237
  }
3889
4238
  }
3890
4239
 
@@ -3921,23 +4270,22 @@ body:has(.aparte-dialog[open]) {
3921
4270
  and skips the dark-theme override block for that reason; the artifact "paper" tokens
3922
4271
  are the same decision made for the same reason.
3923
4272
 
3924
- <span style="position: relative; display: inline-block; margin-block-start: 2.5rem">
3925
- <div class="aparte-tooltip" role="tooltip"
3926
- style="position: absolute; bottom: calc(100% + 8px); left: 50%; translate: -50% 0">
4273
+ <span class="aparte-tooltip-anchor" style="margin-block-start: 2.5rem; margin-inline-start: 5rem">
4274
+ <div class="aparte-tooltip" data-side="top" role="tooltip">
3927
4275
  Copy to clipboard
3928
4276
  <span class="aparte-tooltip__arrow"></span>
3929
4277
  </div>
3930
4278
  <button class="aparte-btn aparte-btn--icon" aria-label="Copy"><aparte-icon name="copy"></aparte-icon></button>
3931
4279
  </span>
3932
4280
 
3933
- The two inline styles are the caller's half, shown rather than described: this file
3934
- draws the box and the arrow, and NOTHING here positions either. An example that
3935
- omitted them rendered as a black label with an arrow pointing at empty space, which
3936
- is what the preview frame beside it showed.
3937
-
3938
- The arrow points DOWN by default — the common case, a tooltip above the thing it
3939
- labels — and `data-side="bottom"` flips it. Placing the box is yours, as with the
3940
- popover; only the arrow's two positions are here.
4281
+ `data-side` places it — `top`, `bottom`, `start` or `end` — against the
4282
+ `.aparte-tooltip-anchor` that wraps the trigger, `--aparte-tooltip-gap` away, and turns
4283
+ the arrow to match. The two inline styles that used to do this in the example were a
4284
+ knob missing, not a division of labour: a demo that needs inline styles to work is a
4285
+ recipe with a parameter it forgot. What stays yours is the viewport: nothing here flips
4286
+ a tooltip that would leave the screen — that needs script, a positioning library's job.
4287
+ Without `data-side` the box is not positioned at all, as before, and its placement is
4288
+ yours (the one inline style left in the example is the demo's own margin).
3941
4289
 
3942
4290
  `pointer-events: none` is not a detail: a tooltip describes what the pointer is over,
3943
4291
  so it must never itself become that thing, or hovering it re-triggers the hover that
@@ -3954,11 +4302,17 @@ body:has(.aparte-dialog[open]) {
3954
4302
 
3955
4303
  position: relative;
3956
4304
  display: inline-block;
4305
+ vertical-align: middle;
4306
+ /* As wide as its label, up to the cap. With a `max-width` and no intrinsic width an
4307
+ absolutely-positioned chip shrinks to `min-content`: "Copy to clipboard" broke into
4308
+ two lines at every width, 71px wide — the widest word plus padding. */
4309
+ width: max-content;
4310
+ box-sizing: border-box;
3957
4311
  max-width: var(--aparte-tooltip-max-width);
3958
4312
  padding: var(--aparte-space-2) var(--aparte-space-4);
3959
4313
  background: var(--aparte-tooltip-bg);
3960
4314
  color: var(--aparte-tooltip-color);
3961
- border-radius: var(--aparte-radius-sm);
4315
+ border-radius: var(--aparte-radius-tooltip);
3962
4316
  box-shadow: var(--aparte-tooltip-shadow);
3963
4317
  font-size: var(--aparte-font-size-sm);
3964
4318
  line-height: var(--aparte-line-height-snug);
@@ -3970,12 +4324,49 @@ body:has(.aparte-dialog[open]) {
3970
4324
  pointer-events: none;
3971
4325
  }
3972
4326
 
3973
- /* Points down by default — the common case of a tooltip sitting above the thing
3974
- it labels. `[data-side="bottom"]` flips it for a tooltip placed below instead.
3975
- Horizontal centring uses inline-start + a negative inline-start margin rather
3976
- than a transform, so it re-centres correctly under `direction: rtl` without a
3977
- second rule. Placement to either side is the caller's own transform math, the
3978
- same "position is the caller's job" split as the popover above. */
4327
+ /* Where the box goes: the anchor is the trigger's wrapper and the tooltip is placed
4328
+ against it. `translate` centres the box on the axis across the side: with a logical
4329
+ inset of 50% the box's start edge sits at the anchor's middle, and moving it back by
4330
+ half its own size centres it — in both writing directions, because the inset flips
4331
+ with the direction and the translate is half the box either way. */
4332
+ .aparte-tooltip-anchor {
4333
+ position: relative;
4334
+ display: inline-block;
4335
+ vertical-align: middle;
4336
+ }
4337
+
4338
+ .aparte-tooltip[data-side] {
4339
+ position: absolute;
4340
+ }
4341
+
4342
+ .aparte-tooltip[data-side='top'] {
4343
+ inset-block-end: calc(100% + var(--aparte-tooltip-gap));
4344
+ inset-inline-start: 50%;
4345
+ translate: -50% 0;
4346
+ }
4347
+
4348
+ .aparte-tooltip[data-side='bottom'] {
4349
+ inset-block-start: calc(100% + var(--aparte-tooltip-gap));
4350
+ inset-inline-start: 50%;
4351
+ translate: -50% 0;
4352
+ }
4353
+
4354
+ .aparte-tooltip[data-side='start'] {
4355
+ inset-inline-end: calc(100% + var(--aparte-tooltip-gap));
4356
+ inset-block-start: 50%;
4357
+ translate: 0 -50%;
4358
+ }
4359
+
4360
+ .aparte-tooltip[data-side='end'] {
4361
+ inset-inline-start: calc(100% + var(--aparte-tooltip-gap));
4362
+ inset-block-start: 50%;
4363
+ translate: 0 -50%;
4364
+ }
4365
+
4366
+ /* The arrow points DOWN by default — the common case, a tooltip above the thing it
4367
+ labels — and each `data-side` turns it toward the anchor. Centring on the box's edge
4368
+ uses a logical inset plus a negative margin rather than a transform, so it re-centres
4369
+ under `direction: rtl` without a second rule. */
3979
4370
  .aparte-tooltip__arrow {
3980
4371
  position: absolute;
3981
4372
  inset-inline-start: 50%;
@@ -3994,6 +4385,26 @@ body:has(.aparte-dialog[open]) {
3994
4385
  inset-block-start: calc(var(--aparte-space-4) * -0.5);
3995
4386
  }
3996
4387
 
4388
+ /* On the two inline sides the arrow sits on the edge that faces the anchor, centred
4389
+ vertically: the box is to the START of the trigger, so its arrow is on its END edge,
4390
+ and the reverse. */
4391
+ .aparte-tooltip[data-side='start'] .aparte-tooltip__arrow {
4392
+ inset-block-end: auto;
4393
+ inset-block-start: 50%;
4394
+ inset-inline-start: auto;
4395
+ inset-inline-end: calc(var(--aparte-space-4) * -0.5);
4396
+ margin-inline-start: 0;
4397
+ margin-block-start: calc(var(--aparte-space-4) * -0.5);
4398
+ }
4399
+
4400
+ .aparte-tooltip[data-side='end'] .aparte-tooltip__arrow {
4401
+ inset-block-end: auto;
4402
+ inset-block-start: 50%;
4403
+ inset-inline-start: calc(var(--aparte-space-4) * -0.5);
4404
+ margin-inline-start: 0;
4405
+ margin-block-start: calc(var(--aparte-space-4) * -0.5);
4406
+ }
4407
+
3997
4408
  /* ── Forced colors ───────────────────────────────────────────────────────────────
3998
4409
  The menu and the popover already carry a real border by default, so those two
3999
4410
  boxes survive forced-colors mode once background and box-shadow are both
@@ -4036,10 +4447,16 @@ aparte-select {
4036
4447
  align-items: center;
4037
4448
  justify-content: space-between;
4038
4449
  gap: var(--aparte-space-4);
4450
+ box-sizing: border-box;
4451
+ min-block-size: var(--aparte-field-size);
4039
4452
  padding: var(--aparte-space-4) var(--aparte-space-6);
4453
+ /* Its own size: the trigger declared none and took the host page's, so every
4454
+ integrator saw a different select. The control step, like the field and the
4455
+ button. */
4456
+ font-size: var(--aparte-font-size-md);
4040
4457
  background: var(--aparte-select-bg, var(--aparte-surface-1));
4041
- border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border));
4042
- border-radius: var(--aparte-select-radius, var(--aparte-radius-lg));
4458
+ border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border-control));
4459
+ border-radius: var(--aparte-radius-select);
4043
4460
  cursor: pointer;
4044
4461
  transition: border-color var(--aparte-duration-base), box-shadow var(--aparte-duration-base);
4045
4462
  }
@@ -4048,15 +4465,16 @@ aparte-select {
4048
4465
  border-color: var(--aparte-select-border-hover, var(--aparte-primary));
4049
4466
  }
4050
4467
 
4051
- .aparte-select-trigger:focus {
4052
- outline: none;
4053
- border-color: var(--aparte-select-border-focus, var(--aparte-primary));
4054
- box-shadow: var(--aparte-select-ring, var(--aparte-focus-ring));
4468
+ .aparte-select-trigger:focus-visible {
4469
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
4470
+ outline-offset: var(--aparte-focus-outline-offset);
4055
4471
  }
4056
4472
 
4057
4473
  aparte-select[disabled] .aparte-select-trigger {
4058
- opacity: var(--aparte-disabled-opacity);
4059
4474
  cursor: not-allowed;
4475
+ background: var(--aparte-surface-2);
4476
+ color: var(--aparte-text-muted);
4477
+ border-color: var(--aparte-border);
4060
4478
  }
4061
4479
 
4062
4480
  /* The label is a grid with ONE cell: the visible text and, under it, a hidden stack of
@@ -4116,8 +4534,10 @@ aparte-select[open] .aparte-select-chevron {
4116
4534
  position: fixed;
4117
4535
  /* top / left / width are set dynamically by _updatePosition() */
4118
4536
  background: var(--aparte-select-dropdown-bg, var(--aparte-surface-1));
4119
- border: var(--aparte-border-width) solid var(--aparte-select-border, var(--aparte-border));
4120
- border-radius: var(--aparte-select-radius, var(--aparte-radius-lg));
4537
+ /* The panel is a region, so its edge is the region border; `--aparte-select-border`
4538
+ is the TRIGGER's edge, a control's, and defaults to the control border. */
4539
+ border: var(--aparte-border-width) solid var(--aparte-border);
4540
+ border-radius: var(--aparte-radius-select);
4121
4541
  box-shadow: var(--aparte-select-shadow);
4122
4542
  z-index: var(--aparte-select-z, var(--aparte-z-dropdown));
4123
4543
  max-height: var(--aparte-select-dropdown-max-height);
@@ -4141,8 +4561,11 @@ aparte-select[open] .aparte-select-chevron {
4141
4561
  font-family: inherit;
4142
4562
  }
4143
4563
 
4144
- .aparte-select-search:focus {
4145
- outline: none;
4564
+ /* Inside, not outside: the field sits at the top of a scrolling panel, and an outset
4565
+ ring would be clipped by it. The one documented exception to the shared offset. */
4566
+ .aparte-select-search:focus-visible {
4567
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
4568
+ outline-offset: calc(-1 * var(--aparte-focus-outline-width));
4146
4569
  }
4147
4570
 
4148
4571
  /* Options container */
@@ -4279,6 +4702,7 @@ aparte-optgroup[collapsed] .aparte-optgroup-chevron {
4279
4702
  }
4280
4703
 
4281
4704
  .aparte-spinner-small {
4705
+ box-sizing: border-box;
4282
4706
  width: var(--aparte-select-spinner-size);
4283
4707
  height: var(--aparte-select-spinner-size);
4284
4708
  border: 2px solid currentColor;
@@ -4286,6 +4710,12 @@ aparte-optgroup[collapsed] .aparte-optgroup-chevron {
4286
4710
  border-radius: 50%;
4287
4711
  /* `aparte-spin` is declared once, in base.css — see the shared-motion block there. */
4288
4712
  animation: aparte-spin var(--aparte-duration-spin) linear infinite;
4713
+ }
4714
+
4715
+ /* The element's own sweep in responsive.css already stops this one; the rule is here
4716
+ so the recipe stops on its own terms too, like every other looping recipe. */
4717
+ @media (prefers-reduced-motion: reduce) {
4718
+ .aparte-spinner-small { animation: none; }
4289
4719
  }/**
4290
4720
  * AparteProgressSpinner — CSS
4291
4721
  *
@@ -4311,16 +4741,23 @@ aparte-progress-spinner svg {
4311
4741
  overflow: visible;
4312
4742
  }
4313
4743
 
4744
+ /* The same stroke as the CSS ring (`.aparte-spinner`), in SCREEN pixels: without
4745
+ `vector-effect` a stroke is in viewBox units and scaled with the box — 2.5 of 24 was
4746
+ 1.67px in a 16px spinner, antialiased everywhere and 50 % lighter than its sibling.
4747
+ The track is a visible reference in the control-edge colour: at 15 % of the ink a
4748
+ determinate 62 % was the percentage of a circle nobody could see. */
4314
4749
  .aparte-spinner-track {
4315
4750
  fill: none;
4316
- stroke: var(--aparte-spinner-track, color-mix(in srgb, currentColor 15%, transparent));
4317
- stroke-width: var(--aparte-spinner-stroke, 2.5);
4751
+ stroke: var(--aparte-spinner-track, var(--aparte-border-control));
4752
+ stroke-width: var(--aparte-spinner-thickness);
4753
+ vector-effect: non-scaling-stroke;
4318
4754
  }
4319
4755
 
4320
4756
  .aparte-spinner-fill {
4321
4757
  fill: none;
4322
4758
  stroke: var(--aparte-spinner-color, currentColor);
4323
- stroke-width: var(--aparte-spinner-stroke, 2.5);
4759
+ stroke-width: var(--aparte-spinner-thickness);
4760
+ vector-effect: non-scaling-stroke;
4324
4761
  stroke-linecap: round;
4325
4762
  transition: stroke-dashoffset 0.25s var(--aparte-ease);
4326
4763
  }
@@ -4368,6 +4805,10 @@ aparte-chat,
4368
4805
  height: 100%;
4369
4806
  width: 100%;
4370
4807
  overflow: hidden;
4808
+ /* The height an author sets IS the box: with the bottom gap below outside it,
4809
+ `style="height: 24rem"` rendered 336px, and a split's seam hung 16px under both
4810
+ panels. */
4811
+ box-sizing: border-box;
4371
4812
  /* The composer used to touch the bottom edge as soon as a conversation started: the
4372
4813
  viewport gives 16px above the composer and there was nothing below it. An app
4373
4814
  cannot fix this from outside without padding the container, which also shrinks the
@@ -4439,6 +4880,14 @@ aparte-chat[overlay-composer] > :not(aparte-chat-viewport),
4439
4880
  z-index: var(--aparte-z-raised);
4440
4881
  }
4441
4882
 
4883
+ /* A thing that floats has to be seen floating. The overlaid composer's shell shares the
4884
+ transcript's ground, so a z-index alone left it flat on the words it covers. */
4885
+ aparte-chat[overlay-composer] .aparte-composer-shell,
4886
+ [data-aparte-chat][overlay-composer] .aparte-composer-shell,
4887
+ .aparte-chat-container[overlay-composer] .aparte-composer-shell {
4888
+ box-shadow: var(--aparte-composer-overlay-shadow);
4889
+ }
4890
+
4442
4891
  /* center-empty: the shell centers the composer as a welcome state until the first
4443
4892
  message, then slides to the normal layout. The component toggles [data-empty]
4444
4893
  on itself by watching its viewport — only `flex-grow` animates, so the composer
@@ -4461,6 +4910,17 @@ aparte-chat[center-empty][data-empty] > aparte-chat-viewport {
4461
4910
  height: auto;
4462
4911
  }
4463
4912
 
4913
+ /* And no block room at all: at `auto` the empty viewport still stood 32px tall — the
4914
+ rows' wrapper keeps its block padding with no row in it — so `center` centred three
4915
+ items of which the first was invisible, and the welcome group landed 16px low,
4916
+ measured. The padding goes, not the box: capping the viewport itself left a 32px
4917
+ scroll surface inside a 0px box, and "an empty transcript must not overflow" is a
4918
+ browser test every example runs. Inline geometry is kept — the composer's inset is
4919
+ measured from this element. */
4920
+ aparte-chat[center-empty][data-empty] > aparte-chat-viewport .aparte-messages-wrapper {
4921
+ padding-block: 0;
4922
+ }
4923
+
4464
4924
  /* =========================================
4465
4925
  aparte-chat-viewport
4466
4926
  ========================================= */
@@ -4527,16 +4987,16 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
4527
4987
 
4528
4988
  /* ─── Scroll-to-bottom button ─────────────────────────────────────────────── */
4529
4989
 
4530
- /* The button wears `aparte-btn--lg aparte-btn--circle`, and that IS its shape: 36px is
4531
- exactly `--aparte-btn-size-lg`, and `--circle` is the round one. So the box, the
4532
- corner and the 20px glyph all come from the recipe, and what stays here is only what
4533
- is genuinely this button's — where it floats, and its own surface.
4990
+ /* The button wears `aparte-btn--lg aparte-btn--circle`: `--lg` gives it the 20px glyph
4991
+ and the lg step of the scale (36px), `--circle` the round corner, and
4992
+ `--aparte-scroll-btn-size` (the same step, named for this button so a theme can move
4993
+ it alone) is fed to the recipe below. What stays here is only what is genuinely this
4994
+ button's — where it floats, and its own surface.
4534
4995
 
4535
4996
  It used to redraw all three: its own width/height, its own `border-radius: 50%`, and
4536
4997
  an icon-size override that existed only because the box was declared OUTSIDE the size
4537
- scale, so the glyph stayed at the medium default — a 16px arrow in a 36px circle, 44%
4538
- of its box where the rest of the library reads at ~57%. Naming the size instead makes
4539
- the ratio fall out for free, and the override disappears with it.
4998
+ scale, so the glyph stayed at the medium default — a 16px arrow in a 36px circle.
4999
+ Naming the size on the recipe's own token makes the override disappear.
4540
5000
 
4541
5001
  `--aparte-scroll-btn-size` stays the public knob, fed to the recipe. */
4542
5002
  .aparte-scroll-btn {
@@ -4808,7 +5268,11 @@ aparte-chat-bubble {
4808
5268
  box-sizing: border-box;
4809
5269
  display: flex;
4810
5270
  gap: var(--aparte-message-gap);
4811
- padding: var(--aparte-message-padding);
5271
+ /* The two parts, not the composite `--aparte-message-padding` that used to join them
5272
+ on `:root`: a custom property is substituted where it is declared, so the composite
5273
+ froze both parts at the root and the narrow-container override of them in
5274
+ responsive.css never reached this row. */
5275
+ padding: var(--aparte-message-padding-block) var(--aparte-message-padding-inline);
4812
5276
  width: 100%;
4813
5277
  max-width: var(--aparte-message-max-width);
4814
5278
  margin: 0 auto;
@@ -4896,6 +5360,11 @@ aparte-chat-bubble {
4896
5360
  opacity: 0;
4897
5361
  transition: opacity var(--aparte-duration-base) var(--aparte-ease);
4898
5362
  height: var(--aparte-action-bar-btn-size);
5363
+ /* The INK meets the column, not the box: a glyph is centred in its 24px button, so
5364
+ its ink began half the slack — (24 − 12) / 2 — to the right of the paragraph above.
5365
+ Measured at +5 to +8px in 14 previews. The bar takes that slack back and the box
5366
+ overhangs into the message padding, where nothing else lives. */
5367
+ margin-inline-start: calc((var(--aparte-icon-size-sm) - var(--aparte-btn-size-sm)) / 2);
4899
5368
  }
4900
5369
 
4901
5370
  .aparte-message:hover .aparte-action-bar,
@@ -4960,8 +5429,6 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
4960
5429
  inset-block-end: calc(-1 * (var(--aparte-message-padding-block) + var(--aparte-message-gap)));
4961
5430
  margin: 0;
4962
5431
  z-index: 1;
4963
- /* Smaller than the last reply's 28px: this one lives in the padding. */
4964
- --aparte-action-bar-btn-size: 24px;
4965
5432
  }
4966
5433
  }
4967
5434
 
@@ -5010,12 +5477,11 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
5010
5477
  display: none;
5011
5478
  }
5012
5479
 
5480
+ /* The arrows wear `--sm` and the recipe draws the box: this rule used to redraw
5481
+ width/height to a 20px token over it — two sources for one measure. */
5013
5482
  .aparte-branch-prev,
5014
5483
  .aparte-branch-next {
5015
5484
  color: var(--aparte-branch-picker-btn-color);
5016
- width: var(--aparte-branch-picker-btn-size);
5017
- height: var(--aparte-branch-picker-btn-size);
5018
- font-size: var(--aparte-branch-picker-btn-icon-size);
5019
5485
  }
5020
5486
 
5021
5487
  .aparte-branch-prev:hover:not(:disabled),
@@ -5024,9 +5490,11 @@ aparte-chat-bubble:last-of-type .aparte-message[data-role="assistant"]:not(.apar
5024
5490
  background: var(--aparte-action-bar-btn-hover-bg);
5025
5491
  }
5026
5492
 
5493
+ /* The button recipe draws the disabled state (a neutral ground, the muted ink); an
5494
+ opacity over it read the arrow at 1.74:1 — as absent, where the picker means "no
5495
+ further". `default` and not `not-allowed`: the end of a list is not a refusal. */
5027
5496
  .aparte-branch-prev:disabled,
5028
5497
  .aparte-branch-next:disabled {
5029
- opacity: var(--aparte-disabled-opacity);
5030
5498
  cursor: default;
5031
5499
  }
5032
5500
 
@@ -5126,7 +5594,9 @@ aparte-chat-bubble[data-kind="compaction"] .aparte-message-content {
5126
5594
  Unscoped so it greys the composer in EVERY host (vanilla shell + all wrappers),
5127
5595
  not only a `[data-aparte-chat]` direct child. */
5128
5596
  aparte-composer[data-model-gated] {
5129
- opacity: var(--aparte-disabled-opacity);
5597
+ /* No group fade: each control draws its own disabled state (the editor's muted ink,
5598
+ the send button's neutral ground), so the text stays legible while it waits. */
5599
+ cursor: not-allowed;
5130
5600
  }
5131
5601
  aparte-composer[data-model-gated] aparte-composer-send {
5132
5602
  pointer-events: none;
@@ -5282,7 +5752,7 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
5282
5752
 
5283
5753
  /* Disabled state */
5284
5754
  .aparte-editor[contenteditable="false"] {
5285
- opacity: var(--aparte-disabled-opacity);
5755
+ color: var(--aparte-text-muted);
5286
5756
  cursor: not-allowed;
5287
5757
  }
5288
5758
 
@@ -5323,9 +5793,9 @@ aparte-chat-viewport.aparte-viewport--framework::-webkit-scrollbar-thumb {
5323
5793
  }
5324
5794
 
5325
5795
  .aparte-send-button:disabled {
5326
- opacity: var(--aparte-disabled-opacity);
5327
5796
  cursor: not-allowed;
5328
- background: var(--aparte-send-disabled-bg, var(--aparte-primary));
5797
+ background: var(--aparte-send-disabled-bg, var(--aparte-surface-2));
5798
+ color: var(--aparte-text-muted);
5329
5799
  }
5330
5800
 
5331
5801
  /* =========================================
@@ -5419,11 +5889,10 @@ aparte-model-selector[hidden],
5419
5889
  border-color: var(--aparte-primary);
5420
5890
  }
5421
5891
 
5422
- .aparte-provider-select:focus,
5423
- .aparte-model-select:focus {
5424
- outline: none;
5425
- border-color: var(--aparte-primary);
5426
- box-shadow: var(--aparte-focus-ring);
5892
+ .aparte-provider-select:focus-visible,
5893
+ .aparte-model-select:focus-visible {
5894
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
5895
+ outline-offset: var(--aparte-focus-outline-offset);
5427
5896
  }
5428
5897
 
5429
5898
  /* Dark mode overrides for selects */
@@ -5466,13 +5935,14 @@ aparte-composer {
5466
5935
  it would then be the containing block of the `position: fixed` select dropdown
5467
5936
  that exists to escape `overflow: hidden` (select.css, and the same warning in
5468
5937
  split.css). So below 520px the transcript tightens to 8px and this stays at 16.
5469
- A flat 16 was measured against a step: the step wins for vanilla at phone width
5470
- and LOSES for framework-managed, which is the majority path, because the
5471
- transcript's own step targets a class framework mode never builds. Flat is a
5472
- uniform 10px worst case in both modes — which is the scrollbar gutter the
5473
- transcript reserves and the composer cannot, and which is 0 on the overlay
5474
- scrollbars every phone has. */
5475
- padding-inline: var(--aparte-viewport-padding);
5938
+ So the composer does not guess: the viewport MEASURES where its rows start — the
5939
+ transcript's padding plus the scrollbar gutter it reserves on both edges, at
5940
+ whatever step its container query has taken — and publishes it on the chat host as
5941
+ `--aparte-transcript-inset`. Padding by that puts the composer's box on the row's
5942
+ box at every width. Measured before: 10px apart at 768, the gutter's half at 1280,
5943
+ three left edges 12px apart on the reading-column demo. The fallback is for a
5944
+ composer with no viewport beside it. */
5945
+ padding-inline: var(--aparte-transcript-inset, var(--aparte-viewport-padding));
5476
5946
  }
5477
5947
 
5478
5948
  aparte-composer-input {
@@ -5485,7 +5955,7 @@ aparte-composer-input {
5485
5955
  aparte-composer-input .aparte-ci-editor {
5486
5956
  display: block;
5487
5957
  width: 100%;
5488
- min-height: var(--aparte-composer-control-size, 44px);
5958
+ min-height: var(--aparte-composer-control-size);
5489
5959
  padding: var(--aparte-input-padding-y) var(--aparte-input-padding-x);
5490
5960
  font-size: var(--aparte-input-font-size);
5491
5961
  line-height: var(--aparte-input-line-height);
@@ -5516,7 +5986,7 @@ aparte-composer-input .aparte-ci-editor:empty::before {
5516
5986
  }
5517
5987
 
5518
5988
  aparte-composer-input .aparte-ci-editor[aria-disabled="true"] {
5519
- opacity: var(--aparte-disabled-opacity);
5989
+ color: var(--aparte-text-muted);
5520
5990
  cursor: not-allowed;
5521
5991
  }
5522
5992
 
@@ -5608,7 +6078,7 @@ aparte-composer.aparte-is-dragover:not(:has(.aparte-composer-shell)) {
5608
6078
 
5609
6079
  /* Inner row — flex container for input + buttons */
5610
6080
  /* Action buttons sized to the input's single-line height — they derive from
5611
- --aparte-composer-control-size (default 44px), so a theme resizes the whole
6081
+ --aparte-composer-control-size (default: the lg step, 36px; the touch target under a coarse pointer), so a theme resizes the whole
5612
6082
  composer control set with ONE variable and alignment holds. Combined with
5613
6083
  the row's `align-items: flex-end` this makes the buttons read as vertically
5614
6084
  centered against a single line of text, and keeps them anchored to the
@@ -5640,7 +6110,11 @@ aparte-composer.aparte-is-dragover:not(:has(.aparte-composer-shell)) {
5640
6110
  align-items: flex-end;
5641
6111
  gap: var(--aparte-space-4);
5642
6112
  padding: var(--aparte-space-4) var(--aparte-space-6);
5643
- --aparte-composer-row-control-size: var(--aparte-composer-control-size, 44px);
6113
+ /* The lg step at rest — 36px, what `--aparte-send-btn-size` says — and the touch
6114
+ target under a coarse pointer (responsive.css). It was 44 everywhere, which made
6115
+ the send button the one control the coarse-pointer block could not enlarge because
6116
+ it already was, and a 62px bar around 15px of text on a desktop. */
6117
+ --aparte-composer-row-control-size: var(--aparte-composer-control-size);
5644
6118
  --aparte-btn-size: var(--aparte-composer-row-control-size);
5645
6119
  }
5646
6120
 
@@ -5758,6 +6232,10 @@ aparte-composer-cancel .aparte-cc-button:hover {
5758
6232
  `.aparte-accordion__header` / `__item` / `__panel` and adds these two lines: a
5759
6233
  quieter face than a real accordion header, and the padding the left rail needs. */
5760
6234
  .aparte-thinking-header {
6235
+ /* The chevron sits beside the label: the accordion recipe puts it at the row's far
6236
+ end, which is what an accordion is, but this is a one-line disclosure, and "Reasoning"
6237
+ and its chevron were 692px apart at 1280 (T18). */
6238
+ justify-content: flex-start;
5761
6239
  padding: var(--aparte-space-1) 0 var(--aparte-space-1) var(--aparte-space-5);
5762
6240
  font-size: var(--aparte-font-size-sm);
5763
6241
  color: var(--aparte-thinking-text);
@@ -5789,7 +6267,7 @@ aparte-composer-cancel .aparte-cc-button:hover {
5789
6267
  margin-inline-end: var(--aparte-space-4);
5790
6268
  /* Logical property */
5791
6269
  text-transform: uppercase;
5792
- opacity: 0.7;
6270
+ /* The header's ink is already the muted one; an `opacity: .7` on top muted it twice. */
5793
6271
  }
5794
6272
 
5795
6273
 
@@ -5848,11 +6326,14 @@ aparte-composer-cancel .aparte-cc-button:hover {
5848
6326
  flex: none;
5849
6327
  width: var(--aparte-space-8);
5850
6328
  height: var(--aparte-space-8);
5851
- opacity: 0.5;
5852
- transition: transform var(--aparte-duration-base);
6329
+ /* Quiet is a colour, not an opacity: at 0.5 the chevron measured 3.00:1 — on the
6330
+ WCAG floor exactly — and it is the control that reveals a `delete_file`'s
6331
+ arguments. The muted ink at rest, the full ink under the pointer. */
6332
+ color: var(--aparte-text-muted);
6333
+ transition: transform var(--aparte-duration-base), color var(--aparte-duration-base);
5853
6334
  }
5854
6335
  .aparte-tool-toggle svg { width: 100%; height: 100%; display: block; }
5855
- .aparte-tool-summary:hover .aparte-tool-toggle { opacity: 0.9; }
6336
+ .aparte-tool-summary:hover .aparte-tool-toggle { color: var(--aparte-text); }
5856
6337
  details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180deg); }
5857
6338
  /* A rule down the leading edge, so the disclosure reads as belonging to the
5858
6339
  pill above it rather than continuing the message text below. Without it
@@ -5867,7 +6348,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5867
6348
  }
5868
6349
  .aparte-tool-part-label {
5869
6350
  display: block; font-size: var(--aparte-font-size-xs); text-transform: uppercase;
5870
- letter-spacing: 0.04em; opacity: 0.55; margin-bottom: var(--aparte-space-1);
6351
+ letter-spacing: 0.04em; color: var(--aparte-text-muted); margin-bottom: var(--aparte-space-1);
5871
6352
  }
5872
6353
  /* A tool's input and result WRAP. They are a message, a path, a JSON object — not
5873
6354
  code to read line by line — and a `<pre>` keeps `white-space: pre` by default, so
@@ -5889,6 +6370,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5889
6370
  green, which made a finished step shout louder than the reply it belongs to. */
5890
6371
  .aparte-tool-label {
5891
6372
  display: inline-flex;
6373
+ vertical-align: middle;
5892
6374
  align-items: center;
5893
6375
  gap: var(--aparte-space-3);
5894
6376
  min-width: 0;
@@ -5905,7 +6387,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5905
6387
  place-items: center;
5906
6388
  width: 1em; height: 1em;
5907
6389
  flex: none;
5908
- opacity: 0.7;
6390
+ color: var(--aparte-text-muted);
5909
6391
  }
5910
6392
  .aparte-tool-icon svg { width: 1em; height: 1em; display: block; }
5911
6393
  .aparte-tool-name { font-family: var(--aparte-code-font-family); }
@@ -5913,9 +6395,12 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5913
6395
  Pushed to the far end by DOM order plus an auto inline-start margin, which is
5914
6396
  the placement rule this repo already settled on: no left/right in a name, so
5915
6397
  a right-to-left locale mirrors it for free. */
6398
+ /* Beside the tool's name, not at the row's far end: with `margin-inline-start: auto` the
6399
+ state word drifted 63px from the name at 375 and 596px at 1280. The state belongs to
6400
+ the label (T18). */
5916
6401
  .aparte-tool-state {
5917
- display: inline-flex; align-items: center; gap: var(--aparte-space-2);
5918
- margin-inline-start: auto;
6402
+ display: inline-flex;
6403
+ vertical-align: middle; align-items: center; gap: var(--aparte-space-2);
5919
6404
  font-size: var(--aparte-font-size-sm); font-weight: var(--aparte-font-weight-medium);
5920
6405
  color: var(--aparte-text-muted);
5921
6406
  user-select: none;
@@ -5951,44 +6436,22 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
5951
6436
  }
5952
6437
 
5953
6438
  /* Pipeline waiting */
5954
- .aparte-error-icon-wrapper {
5955
- flex-shrink: 0;
5956
- color: var(--aparte-error-solid);
5957
- display: flex;
5958
- align-items: flex-start;
5959
- padding-top: var(--aparte-space-1);
5960
- }
5961
-
5962
- .aparte-error-icon-wrapper svg {
5963
- width: var(--aparte-error-icon-size);
5964
- height: var(--aparte-error-icon-size);
5965
- }
5966
-
5967
- .aparte-error-content {
5968
- flex: 1;
5969
- min-width: 0;
5970
- }
5971
-
5972
- .aparte-error-title {
5973
- font-weight: var(--aparte-font-weight-semibold);
5974
- font-size: var(--aparte-font-size-lg);
5975
- margin-bottom: var(--aparte-space-2);
5976
- color: var(--aparte-error-title);
5977
- }
5978
-
5979
- .aparte-error-message {
5980
- font-size: var(--aparte-font-size-lg);
5981
- line-height: var(--aparte-line-height-normal);
5982
- }
5983
-
5984
- .aparte-error-details {
5985
- margin-top: var(--aparte-space-4);
5986
- padding-top: var(--aparte-space-4);
5987
- border-top: var(--aparte-border-width) solid var(--aparte-error-border);
6439
+ /* The error segment WEARS the alert recipe — frame and parts (`aparte-alert__icon`,
6440
+ `__body`, `__title`, `__message` come from display/alert.css). It used to put the
6441
+ recipe's class on its root and redraw every part under classes of its own, with a 20px
6442
+ literal for the icon among derived tokens and a title colour nothing else used. What is
6443
+ genuinely the segment's own is the one part the alert has no word for: the details
6444
+ block — a transport's stack, a status line — set as code under the message. */
6445
+ .aparte-segment-error__details {
6446
+ margin: var(--aparte-space-4) 0 0;
6447
+ padding-block-start: var(--aparte-space-4);
6448
+ border-block-start: var(--aparte-border-width) solid var(--aparte-error-border);
5988
6449
  font-family: var(--aparte-code-font-family);
5989
6450
  font-size: var(--aparte-font-size-sm);
5990
- opacity: 0.9;
6451
+ line-height: var(--aparte-line-height-normal);
6452
+ color: var(--aparte-text-secondary);
5991
6453
  white-space: pre-wrap;
6454
+ overflow-wrap: anywhere;
5992
6455
  }
5993
6456
 
5994
6457
  /* ─────────────────────────────────────────────────────────────────────────────
@@ -6102,7 +6565,7 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
6102
6565
  a consumer's screenshot, then here. The padding gives the ring that much room and
6103
6566
  the negative margin gives it back to the layout, so nothing moves; `scroll-padding`
6104
6567
  keeps a keyboard-focused option's ring inside the viewport when the list scrolls. */
6105
- --aparte-elic-ring-room: calc(var(--aparte-focus-outline-width) + var(--aparte-btn-focus-offset));
6568
+ --aparte-elic-ring-room: calc(var(--aparte-focus-outline-width) + var(--aparte-focus-outline-offset));
6106
6569
  padding: var(--aparte-elic-ring-room);
6107
6570
  margin: calc(-1 * var(--aparte-elic-ring-room));
6108
6571
  scroll-padding: var(--aparte-elic-ring-room);
@@ -6146,7 +6609,10 @@ details[open] > .aparte-tool-summary .aparte-tool-toggle { transform: rotate(180
6146
6609
  .aparte-elic-options {
6147
6610
  display: flex;
6148
6611
  flex-direction: column;
6149
- gap: var(--aparte-space-1);
6612
+ /* At least the focus ring's outset: at 2px the next row painted over the bottom of
6613
+ the focused row's ring and ~8px of it survived. Any list of options that wears an
6614
+ outset ring needs a gap at least as wide as the offset. */
6615
+ gap: var(--aparte-space-3);
6150
6616
  }
6151
6617
 
6152
6618
  /* The row is `.aparte-field-choice`; the panel only tightens its padding, because a
@@ -6220,22 +6686,19 @@ textarea.aparte-elic-text {
6220
6686
  min-height: var(--aparte-elic-textarea-min-height);
6221
6687
  }
6222
6688
 
6223
- .aparte-elic-other-input:focus,
6224
- .aparte-elic-text:focus {
6225
- border-color: var(--aparte-primary);
6226
- }
6227
-
6228
6689
  .aparte-elic-option--recommended {
6229
6690
  border-color: var(--aparte-primary);
6230
6691
  background: color-mix(in srgb, var(--aparte-primary) 6%, transparent);
6231
6692
  }
6232
6693
  /* One ring at a time. The recommended option is the first to receive focus, and its
6233
6694
  tinted border under the recipe's focus ring read as a double outline (UI audit
6234
- 2026-08-28, §8.3 #9). While it holds focus the ring is the indicator; the tint
6235
- comes back when focus leaves. */
6695
+ 2026-08-28, §8.3 #9). While it holds focus the ring is the one edge, so the border
6696
+ steps aside — and ONLY the border. This rule used to clear the background too, which
6697
+ made the recommended row the only one in the list with no ground at all: sampled on
6698
+ the preview it rendered the page byte for byte while its sisters did not. The
6699
+ recommended state draws in addition to the resting state, never instead of it. */
6236
6700
  .aparte-elic-option--recommended:focus-within {
6237
- background: transparent;
6238
- border-color: var(--aparte-border-focus);
6701
+ border-color: transparent;
6239
6702
  }
6240
6703
 
6241
6704
  /* The whole-request escape, in the corner and out of the way of anything that
@@ -6344,7 +6807,7 @@ textarea.aparte-elic-text {
6344
6807
 
6345
6808
  .aparte-approval-args:focus-visible {
6346
6809
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6347
- outline-offset: 1px;
6810
+ outline-offset: var(--aparte-focus-outline-offset);
6348
6811
  }
6349
6812
 
6350
6813
  /* The refusal's reason, in the user's words. Quiet by default and one line tall: it is
@@ -6364,9 +6827,9 @@ textarea.aparte-elic-text {
6364
6827
  min-height: 1.6em;
6365
6828
  }
6366
6829
 
6367
- .aparte-approval-instruction:focus {
6368
- outline: none;
6369
- border-top-color: var(--aparte-accent);
6830
+ .aparte-approval-instruction:focus-visible {
6831
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6832
+ outline-offset: var(--aparte-focus-outline-offset);
6370
6833
  }
6371
6834
 
6372
6835
  .aparte-approval-instruction::placeholder {
@@ -6515,8 +6978,8 @@ textarea.aparte-elic-text {
6515
6978
  /* Inside the edge: the list clips horizontally (`overflow-x: hidden` in base.css), and
6516
6979
  a ring drawn outside the row was cut on both sides. */
6517
6980
  .aparte-conv-item__select:focus-visible {
6518
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
6519
- outline-offset: calc(-1 * var(--aparte-focus-outline-width));
6981
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6982
+ outline-offset: var(--aparte-focus-outline-offset);
6520
6983
  }
6521
6984
 
6522
6985
  .aparte-conv-item__title {
@@ -6537,15 +7000,22 @@ textarea.aparte-elic-text {
6537
7000
  font-weight: var(--aparte-conv-item-font-weight-active);
6538
7001
  }
6539
7002
 
7003
+ /* The selection mark: a bar the width of `--aparte-mark-bar` down the row's start
7004
+ edge. Painted as the first pixels of a pseudo the SIZE of the row that inherits its
7005
+ radius, so the bar follows the curve — drawn as a 2px box of its own it stood square
7006
+ in a 9px corner and a sliver of the page's ground showed between the two. Not
7007
+ `overflow: hidden` on the row: that would clip the title button's focus ring. */
6540
7008
  .aparte-conv-item--active::before {
6541
7009
  content: "";
6542
7010
  position: absolute;
6543
- inset-block: 0;
6544
- inset-inline-start: 0;
6545
- width: var(--aparte-mark-bar);
6546
- background: var(--aparte-primary-ink);
7011
+ inset: 0;
7012
+ border-radius: inherit;
7013
+ background: linear-gradient(to right, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
6547
7014
  pointer-events: none;
6548
7015
  }
7016
+ .aparte-conv-item--active:dir(rtl)::before {
7017
+ background: linear-gradient(to left, var(--aparte-primary-ink) var(--aparte-mark-bar), transparent 0);
7018
+ }
6549
7019
 
6550
7020
  .aparte-conv-item--archived .aparte-conv-item__title {
6551
7021
  font-style: italic;
@@ -6559,8 +7029,8 @@ textarea.aparte-elic-text {
6559
7029
  .aparte-conv-item__more {
6560
7030
  flex-shrink: 0;
6561
7031
  opacity: 0;
6562
- width: var(--aparte-conv-action-btn-size);
6563
- height: var(--aparte-conv-action-btn-size);
7032
+ /* Fed to the recipe, not drawn here: the token names the size, the button draws it. */
7033
+ --aparte-btn-size: var(--aparte-conv-action-btn-size);
6564
7034
  color: var(--aparte-text-muted);
6565
7035
  }
6566
7036
 
@@ -6638,6 +7108,11 @@ aparte-suggestions[data-empty] {
6638
7108
  flex-wrap: wrap;
6639
7109
  gap: var(--aparte-space-2);
6640
7110
  padding-block: var(--aparte-space-2);
7111
+ /* The composer's column: past its 800px cap the chips no longer followed the composer,
7112
+ 188px of offset on a wide host. Same measure, same centring. */
7113
+ box-sizing: border-box;
7114
+ max-inline-size: var(--aparte-message-max-width);
7115
+ margin-inline: auto;
6641
7116
  }
6642
7117
 
6643
7118
  /* A starter's label is a short phrase; it must not become a two-line chip. */
@@ -6673,6 +7148,9 @@ aparte-context[data-empty] {
6673
7148
  .aparte-context .aparte-progress {
6674
7149
  flex: 1;
6675
7150
  min-width: 4rem;
7151
+ /* A gauge has a measure: unbounded, the bar stretched to 1046×4px across a toolbar,
7152
+ 88 % of its width for a line that says one number. */
7153
+ max-inline-size: 16rem;
6676
7154
  }
6677
7155
 
6678
7156
  .aparte-context__text {
@@ -6702,10 +7180,13 @@ aparte-context[data-level="danger"] .aparte-progress__bar {
6702
7180
  transform: rotate(-90deg);
6703
7181
  }
6704
7182
 
7183
+ /* As heavy as the bar variant, in screen pixels: the same gauge was drawn at two
7184
+ weights — a 4px bar and a 2.44px antialiased ring. */
6705
7185
  .aparte-context__track,
6706
7186
  .aparte-context__value {
6707
7187
  fill: none;
6708
- stroke-width: var(--aparte-context-ring-stroke);
7188
+ stroke-width: var(--aparte-progress-height);
7189
+ vector-effect: non-scaling-stroke;
6709
7190
  }
6710
7191
 
6711
7192
  .aparte-context__track {
@@ -6785,7 +7266,9 @@ aparte-scroll-rail[data-empty] {
6785
7266
  padding: 0;
6786
7267
  border: 0;
6787
7268
  border-radius: var(--aparte-radius-full);
6788
- background: var(--aparte-border);
7269
+ /* A tick is a thing you can press; at rest in the region colour it sat at 1.15:1, a
7270
+ 14×2px dash under the perception threshold. */
7271
+ background: var(--aparte-border-control);
6789
7272
  cursor: pointer;
6790
7273
  transition:
6791
7274
  width var(--aparte-duration-fast) var(--aparte-ease),
@@ -6808,7 +7291,7 @@ aparte-scroll-rail[data-empty] {
6808
7291
  }
6809
7292
 
6810
7293
  .aparte-scroll-rail__tick:focus-visible {
6811
- outline: var(--aparte-focus-outline-width) solid var(--aparte-primary);
7294
+ outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
6812
7295
  outline-offset: var(--aparte-focus-outline-offset);
6813
7296
  }
6814
7297
 
@@ -6891,7 +7374,10 @@ aparte-sidebar,
6891
7374
  display: flex;
6892
7375
  align-items: center;
6893
7376
  gap: var(--aparte-space-3);
6894
- padding: var(--aparte-space-4) var(--aparte-space-4) var(--aparte-space-2);
7377
+ /* Inline, every region reads `--aparte-sidebar-inset`: the header and footer padded
7378
+ 16 while the search and the body padded 12, and the column's content stood on two
7379
+ vertical axes 4px apart — measured as four left edges on the app-shell demo. */
7380
+ padding: var(--aparte-space-4) var(--aparte-sidebar-inset) var(--aparte-space-2);
6895
7381
  }
6896
7382
 
6897
7383
  .aparte-sidebar__brand {
@@ -6904,8 +7390,8 @@ aparte-sidebar,
6904
7390
  }
6905
7391
 
6906
7392
  .aparte-sidebar__search {
6907
- margin: var(--aparte-space-2) var(--aparte-space-3);
6908
- min-width: calc(var(--aparte-sidebar-width) - 2 * var(--aparte-space-3));
7393
+ margin: var(--aparte-space-2) var(--aparte-sidebar-inset);
7394
+ min-width: calc(var(--aparte-sidebar-width) - 2 * var(--aparte-sidebar-inset));
6909
7395
  }
6910
7396
 
6911
7397
  /* The body is the one region that scrolls; the list inside keeps its own gap. */
@@ -6914,7 +7400,7 @@ aparte-sidebar,
6914
7400
  min-height: 0;
6915
7401
  overflow-y: auto;
6916
7402
  overflow-x: hidden;
6917
- padding: var(--aparte-space-2) var(--aparte-space-3);
7403
+ padding: var(--aparte-space-2) var(--aparte-sidebar-inset);
6918
7404
  scrollbar-width: thin;
6919
7405
  scrollbar-color: var(--aparte-scrollbar-thumb) var(--aparte-scrollbar-track);
6920
7406
  }
@@ -6923,7 +7409,7 @@ aparte-sidebar,
6923
7409
  display: flex;
6924
7410
  align-items: center;
6925
7411
  gap: var(--aparte-space-3);
6926
- padding: var(--aparte-space-3) var(--aparte-space-4);
7412
+ padding: var(--aparte-space-3) var(--aparte-sidebar-inset);
6927
7413
  border-block-start: var(--aparte-border-width) solid var(--aparte-border);
6928
7414
  font-size: var(--aparte-font-size-sm);
6929
7415
  }
@@ -6968,7 +7454,7 @@ aparte-sidebar,
6968
7454
  * under 48rem, where the sidebar is a drawer; above it the column is simply there.
6969
7455
  *
6970
7456
  * <header class="aparte-app-header">
6971
- * <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle>☰</button>
7457
+ * <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>
6972
7458
  * <span class="aparte-app-header__title">Deploy checklist</span>
6973
7459
  * <div class="aparte-app-header__actions">
6974
7460
  * <span class="aparte-tag">gpt-4.1</span>
@@ -7029,15 +7515,38 @@ aparte-sidebar,
7029
7515
  * `height: 100dvh` for a page — and everything inside follows. Under 48rem the sidebar
7030
7516
  * leaves the grid to become a drawer, and the header's toggle appears.
7031
7517
  *
7032
- * <div class="aparte-app-shell" style="height: 18rem">
7518
+ * <div class="aparte-app-shell" style="height: 24rem">
7033
7519
  * <aside class="aparte-sidebar">
7034
- * <div class="aparte-sidebar__header"><span class="aparte-sidebar__brand">aparté</span></div>
7035
- * <div class="aparte-sidebar__body"><aparte-conversation-list></aparte-conversation-list></div>
7520
+ * <div class="aparte-sidebar__header">
7521
+ * <span class="aparte-sidebar__brand">aparté</span>
7522
+ * <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat"><aparte-icon name="edit"></aparte-icon></button>
7523
+ * </div>
7524
+ * <div class="aparte-sidebar__search aparte-field-group">
7525
+ * <input class="aparte-field aparte-field--sm" type="search" placeholder="Search">
7526
+ * </div>
7527
+ * <div class="aparte-sidebar__body">
7528
+ * <div class="aparte-conv-group" role="group" aria-label="Today">
7529
+ * <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Today</div>
7530
+ * <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>
7531
+ * <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>
7532
+ * </div>
7533
+ * <div class="aparte-conv-group" role="group" aria-label="Yesterday">
7534
+ * <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Yesterday</div>
7535
+ * <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>
7536
+ * </div>
7537
+ * </div>
7538
+ * <div class="aparte-sidebar__footer"><span class="aparte-avatar aparte-avatar--sm">P</span> Paul</div>
7036
7539
  * </aside>
7037
- * <header class="aparte-app-header"><span class="aparte-app-header__title">Deploy checklist</span></header>
7540
+ * <header class="aparte-app-header">
7541
+ * <span class="aparte-app-header__title">Deploy checklist</span>
7542
+ * <div class="aparte-app-header__actions"><span class="aparte-tag">gpt-4.1</span></div>
7543
+ * </header>
7038
7544
  * <main class="aparte-app-shell__main">
7039
7545
  * <aparte-chat>
7040
- * <aparte-chat-viewport></aparte-chat-viewport>
7546
+ * <aparte-chat-viewport>
7547
+ * <aparte-chat-bubble message-id="u1" data-role="user" content="Walk me through the deploy."></aparte-chat-bubble>
7548
+ * <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>
7549
+ * </aparte-chat-viewport>
7041
7550
  * <aparte-composer>
7042
7551
  * <div class="aparte-composer-shell">
7043
7552
  * <div class="aparte-composer-row">
@@ -7094,6 +7603,28 @@ aparte-sidebar,
7094
7603
  min-height: 0;
7095
7604
  height: auto;
7096
7605
  }
7606
+
7607
+ /* A phone folds the shell to one column. The sidebar ELEMENT already leaves the grid
7608
+ under 48rem — it becomes a fixed drawer behind the header's toggle — but the grid
7609
+ kept its first column for it, and a class-only sidebar had no story at all: at 375px
7610
+ the shell gave 259 of its 303px to the sidebar and left the chat a 43px band with a
7611
+ send button cut in half. Same query as the element's own, so the recipe teaches the
7612
+ screen the element renders. */
7613
+ @media (max-width: 48rem) {
7614
+ .aparte-app-shell {
7615
+ grid-template-columns: minmax(0, 1fr);
7616
+ }
7617
+ .aparte-app-shell > .aparte-app-header,
7618
+ .aparte-app-shell__main {
7619
+ grid-column: 1;
7620
+ }
7621
+ /* A sidebar that is not (yet) a drawer — the class recipe alone, or the element
7622
+ before it upgrades — folds out of the flow rather than eating the column. */
7623
+ .aparte-app-shell > .aparte-sidebar:not([data-drawer]),
7624
+ .aparte-app-shell > aparte-sidebar:not([data-drawer]) {
7625
+ display: none;
7626
+ }
7627
+ }
7097
7628
  /*
7098
7629
  * aparté — the seam: two panes side by side, and the grid that lets one grow while
7099
7630
  * the other gives way. The fourth piece of the shell, after the sidebar, the header
@@ -7185,35 +7716,47 @@ aparte-split > [data-aparte-chat],
7185
7716
  box-sizing: border-box;
7186
7717
  border: 0;
7187
7718
  padding: 0;
7188
- background: var(--aparte-border);
7719
+ /* A transparent TRACK: the seam is the 1px line painted by `::before`, centred in it,
7720
+ and the grip is a short bar drawn on the track under the pointer. The track used
7721
+ to be the seam — 4px of paint, four times the kit's rule, with nothing to hold. */
7722
+ background: transparent;
7723
+ background-repeat: no-repeat;
7724
+ background-position: center;
7189
7725
  cursor: col-resize;
7190
7726
  touch-action: none;
7727
+ }
7728
+
7729
+ .aparte-split__handle::before {
7730
+ content: '';
7731
+ position: absolute;
7732
+ inset-block: 0;
7733
+ inset-inline-start: calc(50% - var(--aparte-split-seam-width) / 2);
7734
+ inline-size: var(--aparte-split-seam-width);
7735
+ background: var(--aparte-border);
7191
7736
  transition: background-color var(--aparte-duration-fast) var(--aparte-ease);
7192
7737
  }
7193
7738
 
7194
- .aparte-split__handle:hover,
7195
- .aparte-split__handle[data-dragging] {
7739
+ .aparte-split__handle:hover::before,
7740
+ .aparte-split__handle[data-dragging]::before {
7196
7741
  background: var(--aparte-primary);
7197
7742
  }
7198
7743
 
7199
- /* A real outline, and the ring kept as decoration.
7200
-
7201
- The seam's whole keyboard story is arrowing it, and its only focus indicator was
7202
- `--aparte-focus-ring` — a soft wash that measures 1.39:1 against the page in the
7203
- light palette and 1.83:1 in the dark one, where WCAG asks 3:1 of a focus indicator.
7204
- `outline: none` with nothing in its place is the shape of that failure: the
7205
- indicator was not weak, it was absent, on a 4px band with `border: 0` that a
7206
- keyboard user has no other way to find.
7744
+ /* The grip: the convention of every editor's split, so the seam reads as something to
7745
+ take hold of. Drawn as a background so it needs no third element. */
7746
+ .aparte-split__handle:hover,
7747
+ .aparte-split__handle[data-dragging] {
7748
+ background-image: linear-gradient(var(--aparte-primary), var(--aparte-primary));
7749
+ background-size: calc(var(--aparte-split-seam-width) * 3) var(--aparte-space-8);
7750
+ }
7207
7751
 
7208
- The outline is what carries the contrast (3.54:1 light, 7.36:1 dark, measured); the
7209
- shadow stays because it reads as a glow AROUND the seam and the two do not fight.
7210
- The forced-colors entry in responsive.css keeps overriding the colour only — it is
7211
- now overriding an outline that exists rather than substituting for one that does
7212
- not. */
7752
+ /* The one ring. The seam's whole keyboard story is arrowing it, and its only focus
7753
+ indicator used to be a soft wash at 1.39:1 against the page — not weak, absent, on a
7754
+ 4px band with `border: 0` that a keyboard user has no other way to find. The outline
7755
+ carries the contrast (3.54:1 light, 7.36:1 dark, measured), and it is the same outline
7756
+ every other control draws. */
7213
7757
  .aparte-split__handle:focus-visible {
7214
7758
  outline: var(--aparte-focus-outline-width) solid var(--aparte-border-focus);
7215
- outline-offset: 0;
7216
- box-shadow: var(--aparte-focus-ring);
7759
+ outline-offset: var(--aparte-focus-outline-offset);
7217
7760
  }
7218
7761
 
7219
7762
  /* The hit slop: a 4px seam is a 4px target, and a pointer misses it. The pseudo-element
@@ -7302,6 +7845,14 @@ aparte-split[orientation='vertical'][primary='end']:not([data-stacked]),
7302
7845
  aparte-split[orientation='vertical'] > .aparte-split__handle,
7303
7846
  .aparte-split--vertical > .aparte-split__handle {
7304
7847
  cursor: row-resize;
7848
+ background-size: var(--aparte-space-8) calc(var(--aparte-split-seam-width) * 3);
7849
+ }
7850
+ aparte-split[orientation='vertical'] > .aparte-split__handle::before,
7851
+ .aparte-split--vertical > .aparte-split__handle::before {
7852
+ inset-block: calc(50% - var(--aparte-split-seam-width) / 2) auto;
7853
+ inset-inline: 0;
7854
+ inline-size: auto;
7855
+ block-size: var(--aparte-split-seam-width);
7305
7856
  }
7306
7857
 
7307
7858
  aparte-split[orientation='vertical'] > .aparte-split__handle::after,
@@ -7559,6 +8110,13 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7559
8110
  .aparte-code-content-wrapper pre {
7560
8111
  margin: 0;
7561
8112
  padding: var(--aparte-space-6);
8113
+ /* The code typeface, at a code size. This rule declared neither and the block fell
8114
+ into the UA's generic `monospace` (Courier New on Windows) at the PROSE's size and
8115
+ leading; only inline code read the family. On the wrapper's `pre`, whatever class
8116
+ the highlighter puts on its replacement. */
8117
+ font-family: var(--aparte-code-font-family);
8118
+ font-size: var(--aparte-code-block-font-size);
8119
+ line-height: var(--aparte-code-block-line-height);
7562
8120
  /* Wrap long lines instead of a horizontal scrollbar. `pre-wrap` honours
7563
8121
  newlines + indentation but soft-wraps overflowing lines; `overflow-wrap:
7564
8122
  anywhere` breaks unbreakable tokens (long URLs, hashes). Applies to both
@@ -7594,23 +8152,11 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7594
8152
  }
7595
8153
  }
7596
8154
 
7597
- /* Windows high contrast. In forced-colors mode the UA repaints colours and
7598
- drops box-shadow entirely — so the focus indicators drawn with
7599
- --aparte-focus-ring, and the error ring on an avatar, do not merely change
7600
- colour, they VANISH. An outline is painted, and `Highlight` is the system's
7601
- own focus colour, so the indicator survives without fighting the user's
7602
- palette. Only the indicators that disappear are restated here: everything
7603
- whose affordance is text or a border already survives on its own — which the
7604
- split's seam does not, being a 4px band with `border: 0` whose entire keyboard
7605
- story is arrowing it. */
8155
+ /* Windows high contrast. In forced-colors mode the UA repaints colours and drops
8156
+ box-shadow entirely. Every focus indicator is an outline now, which forced colours
8157
+ paint on their own — the one indicator drawn with a box-shadow is the error ring on
8158
+ an avatar, restated here as an outline so it does not vanish. */
7606
8159
  @media (forced-colors: active) {
7607
- .aparte-thumb--image[role='button']:focus-visible,
7608
- .aparte-split__handle:focus-visible,
7609
- .aparte-provider-select:focus,
7610
- .aparte-model-select:focus {
7611
- outline: var(--aparte-focus-outline-width) solid Highlight;
7612
- }
7613
-
7614
8160
  .aparte-message[data-error] .aparte-avatar {
7615
8161
  outline: var(--aparte-focus-outline-width) solid CanvasText;
7616
8162
  }
@@ -7702,7 +8248,13 @@ aparte-split[disabled] > .aparte-split__handle:hover {
7702
8248
  /* Hover cannot reveal the row's ⋯ on touch — keep it visible and tappable. */
7703
8249
  .aparte-conv-item__more {
7704
8250
  opacity: 1;
7705
- --aparte-conv-action-btn-size: 28px;
8251
+ --aparte-conv-action-btn-size: var(--aparte-touch-target-size);
8252
+ }
8253
+
8254
+ /* The composer's own controls: 36 at rest, the touch target here — the send button, the
8255
+ input's action button and the editor's single-line height move together. */
8256
+ .aparte-composer-row {
8257
+ --aparte-composer-control-size: var(--aparte-touch-target-size);
7706
8258
  }
7707
8259
 
7708
8260
  /* A 4px seam is no target for a finger — the same modality rule the buttons above