@junoput01/junoui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +23 -19
  3. package/dist/css/juno-custom-media.css +32 -0
  4. package/dist/css/juno.css +1216 -60
  5. package/dist/icons/inline.js +22 -0
  6. package/docs/accessibility.md +55 -34
  7. package/docs/boot-shell.md +295 -0
  8. package/docs/components/README.md +46 -43
  9. package/docs/components/dock.md +167 -8
  10. package/docs/components/drawer.md +47 -7
  11. package/docs/components/fold-slot.md +31 -0
  12. package/docs/components/icon-loader.md +85 -18
  13. package/docs/components/icon.md +21 -0
  14. package/docs/components/load-state.md +131 -0
  15. package/docs/components/loader.md +13 -5
  16. package/docs/components/pillbar.md +157 -10
  17. package/docs/components/reload.md +41 -0
  18. package/docs/components/skeleton.md +22 -15
  19. package/docs/components/thumb.md +34 -14
  20. package/docs/design-guidelines.md +26 -0
  21. package/docs/getting-started.md +23 -0
  22. package/docs/ios-conformance.md +224 -0
  23. package/docs/layout.md +71 -1
  24. package/docs/web.md +6 -0
  25. package/package.json +5 -2
  26. package/src/css/base.css +169 -6
  27. package/src/css/components/dock.css +322 -0
  28. package/src/css/components/drawer.css +40 -3
  29. package/src/css/components/fold-slot.css +44 -0
  30. package/src/css/components/icon-loader.css +32 -18
  31. package/src/css/components/icon.css +6 -4
  32. package/src/css/components/load-state.css +136 -0
  33. package/src/css/components/loader.css +6 -0
  34. package/src/css/components/menu.css +4 -0
  35. package/src/css/components/modal.css +24 -3
  36. package/src/css/components/navbar.css +5 -1
  37. package/src/css/components/pillbar.css +207 -7
  38. package/src/css/components/reload.css +48 -0
  39. package/src/css/components/skeleton.css +41 -13
  40. package/src/css/components/tabs.css +5 -0
  41. package/src/css/components/thumb.css +63 -1
  42. package/src/css/components/toast.css +5 -1
  43. package/src/css/density.css +22 -0
  44. package/src/css/layout.css +30 -2
  45. package/src/css/utilities.css +4 -1
package/dist/css/juno.css CHANGED
@@ -364,7 +364,37 @@
364
364
  /* System color scheme. Without data-juno-mode the theme follows the OS
365
365
  (`prefers-color-scheme`; see juno-tokens.css) — `dark light` here lets the
366
366
  UA pick matching scrollbars/form chrome. An explicit mode pins both. */
367
- :root { color-scheme: dark light; }
367
+ :root {
368
+ color-scheme: dark light;
369
+
370
+ /* Floating-nav scroll clearance — a page/scroller that a fixed dock or
371
+ pillbar floats over reserves this much room at its foot so the last row
372
+ clears the overlay. Consumers write `padding-block-end:
373
+ var(--juno-dock-clearance)` and stay correct when the dock geometry
374
+ changes — no hand-rolled calc() per app. Web-only (they carry env()), so
375
+ they live in the CSS layer, not the cross-platform token set.
376
+ --juno-dock-clearance ≈ pill height + bottom margin + breathing room;
377
+ --juno-pillbar-clearance suits the shorter centered pill. */
378
+ --juno-dock-clearance: calc(var(--juno-space-72) + var(--juno-space-20) + env(safe-area-inset-bottom, 0px));
379
+ --juno-pillbar-clearance: calc(var(--juno-space-72) + env(safe-area-inset-bottom, 0px));
380
+
381
+ /* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
382
+ imperative JS (scrollTo/scrollIntoView smooth behavior, rAF-driven transforms,
383
+ an "animate or not" branch) — so the preference is also exposed as a custom
384
+ property script can read with one getComputedStyle call, no matchMedia
385
+ listener required for a one-shot check:
386
+ getComputedStyle(document.documentElement)
387
+ .getPropertyValue('--juno-motion').trim() !== 'none'
388
+ A component MAY author its durations through the scale rather than a raw
389
+ duration token — `calc(var(--juno-motion-duration-base) * var(--juno-motion-scale))`
390
+ — so the reduced-motion override below collapses them to 0 without its own
391
+ per-component media query. Web-only DOM contract, so it lives
392
+ here rather than in the cross-platform token set (see --juno-dock-clearance
393
+ above for the same reasoning). */
394
+ --juno-motion: auto;
395
+ --juno-motion-scale: 1;
396
+ }
397
+
368
398
  :root[data-juno-mode='dark'] { color-scheme: dark; }
369
399
 
370
400
  /* Control surfaces. On dark panels the hairline border (~23% L) disappears, so
@@ -441,17 +471,102 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
441
471
  @media (pointer: coarse) {
442
472
  :root { --juno-size-tap-min: var(--juno-size-tap-comfortable); }
443
473
 
444
- /* iOS Safari zooms the page onto any focused text field whose font-size is
445
- below 16px. Hold text-entry controls (.juno-input covers input, textarea
446
- and the select's inner control) at a 16px floor on touch so focusing never
447
- triggers that jump. max() keeps the floor even under a scaled-down
448
- --juno-font-scale, and still grows when scaled up. */
474
+ /* iOS Safari is widely observed to zoom the page onto a focused text field
475
+ whose font-size is under 16px. NOTE: no primary WebKit/Apple source states
476
+ this it is empirical behavior, not published spec, so don't cite it as
477
+ one (see docs/ios-conformance.md and ticket 20260803-032). The mitigation
478
+ is harmless either way: hold text-entry controls (.juno-input covers input,
479
+ textarea and the select's inner control) at a 16px floor on touch. max()
480
+ keeps the floor even under a scaled-down --juno-font-scale, and still grows
481
+ when scaled up. */
449
482
  .juno-input { font-size: max(16px, var(--juno-font-size-16)); }
483
+
484
+ /* Kill the UA tap-highlight square on the interactive surfaces so it never
485
+ flashes past a rounded control on tap. Consumers were adding this by hand
486
+ per component; make it a first-class touch default. See 20260802-020. */
487
+ :where(
488
+ .juno-btn,
489
+ .juno-dock__item,
490
+ .juno-pillbar__item,
491
+ .juno-tabs__tab,
492
+ .juno-list__item,
493
+ .juno-menu__item
494
+ ) {
495
+ -webkit-tap-highlight-color: transparent;
496
+ }
450
497
  }
451
498
 
499
+ /* Tappable primitives opt out of double-tap-to-zoom. A browser that still
500
+ recognises that gesture has to WAIT after the first tap to see whether a
501
+ second one is coming, which reads as a late, mushy tap on exactly the
502
+ surfaces a phone UI is built from. `manipulation` keeps panning and
503
+ pinch-zoom (so the page stays zoomable — never `none` here, that would be an
504
+ a11y regression) and drops only the double-tap.
505
+ NOT inside the pointer:coarse block above: a hybrid device (touch laptop,
506
+ iPad with a trackpad) reports a fine primary pointer while still taking
507
+ touch input, and the property is inert on a mouse anyway.
508
+ Community convention — no primary Apple/WebKit source names it; see
509
+ docs/ios-conformance.md. Named components only, so a consumer's own elements
510
+ are untouched. See 20260803-038. */
511
+ :where(
512
+ .juno-btn,
513
+ .juno-dock__item,
514
+ .juno-pillbar__item,
515
+ .juno-pillbar__overflow,
516
+ .juno-tabs__tab,
517
+ .juno-list__item,
518
+ .juno-menu__item,
519
+ .juno-seg__option,
520
+ .juno-chip,
521
+ .juno-toggle-btn
522
+ ) {
523
+ touch-action: manipulation;
524
+ }
525
+
526
+ /* Gesture-owned surfaces — for an element whose pointer events are fully
527
+ driven by app JS (drag-pan, pinch-zoom, swipe classification: a state
528
+ machine, not a native scroller). The UA must get out of the way: no
529
+ scroll/zoom gesture recognition, no callout menu on long-press, no text
530
+ selection, no tap-highlight flash. Community convention — no primary
531
+ Apple/WebKit source names any of these four properties for iOS, see
532
+ docs/ios-conformance.md; do not attribute them to Apple. Opt-in utility
533
+ class (unlike the pointer:coarse reset above, which targets junoui's own
534
+ named components), so it isn't gated behind a pointer-type query — apply
535
+ it only to elements a gesture handler actually owns.
536
+ --juno-touch-action narrows the axis without overriding the rest of the
537
+ block; default `none` hands every axis to JS. */
538
+ .juno-gesture-surface {
539
+ touch-action: var(--juno-touch-action, none);
540
+ -webkit-touch-callout: none;
541
+ user-select: none;
542
+ -webkit-tap-highlight-color: transparent;
543
+ }
544
+
545
+ /* Single-axis escape hatches: the UA keeps native scrolling on one axis
546
+ while JS reads gestures on the other (e.g. a vertical list row that also
547
+ supports horizontal swipe-to-reveal). Equivalent to
548
+ `--juno-touch-action: pan-x|pan-y` on .juno-gesture-surface, offered as
549
+ standalone classes for elements that only need the axis lock and none of
550
+ the callout/selection/tap-highlight resets. */
551
+ .juno-pan-x { touch-action: pan-x; }
552
+ .juno-pan-y { touch-action: pan-y; }
553
+
452
554
  @keyframes juno-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
453
555
 
556
+ /* Softer opacity pulse — a "working / refreshing" cadence that dims rather
557
+ than fully blinks out (used by the reload indicator and any live dot that
558
+ wants a gentler beat than juno-blink). */
559
+ @keyframes juno-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
560
+
454
561
  @media (prefers-reduced-motion: reduce) {
562
+ /* Flip the JS-readable contract declared at the top of this file. Without
563
+ this the property would always report `auto` and script could never see
564
+ the preference — the whole point of exposing it. */
565
+ :root {
566
+ --juno-motion: none;
567
+ --juno-motion-scale: 0;
568
+ }
569
+
455
570
  *, *::before, *::after {
456
571
  animation-duration: 0.01ms !important;
457
572
  animation-iteration-count: 1 !important;
@@ -477,6 +592,54 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
477
592
  .juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
478
593
  }
479
594
 
595
+ /* ── iOS standalone letterbox unlock ─────────────────────────────────────────
596
+ iOS WebKit (observed through 26.6, iPhone 16 Pro, iOS 18.7) sizes a
597
+ Home-Screen standalone window whose DOCUMENT cannot scroll as if a
598
+ retractable browser toolbar existed: `screen.height − status bar`, pinned to
599
+ the top. The reserved strip — exactly env(safe-area-inset-top) — surfaces at
600
+ the BOTTOM of the glass, outside the window, painting black on every screen.
601
+ The same install with a scrollable document gets the whole screen.
602
+
603
+ Established by measurement, not inference (nexora 20260812-006): 201 device
604
+ readings; a four-structure A/B on one install where doc-scroll = full screen
605
+ and fixed-shell = letterboxed on every cold launch; and this exact mechanism
606
+ — the shell kept, the document left scrollable behind it by an invisible
607
+ in-flow spacer — verified full-screen on the device before landing here.
608
+ Transient scrollability is NOT enough (seven timed interventions, five
609
+ controlled runs, all negative): iOS samples the resting structure.
610
+
611
+ So: keep the document scrollable behind the app. The spacer is body::after
612
+ (a consumer that needs body::after for itself overrides this at the same
613
+ gate — none of ours do), invisible, 1px wide, and taller than the LARGE
614
+ viewport so the document always overflows whatever window iOS grants.
615
+ `overscroll-behavior: none` keeps the ghost scroller from rubber-banding;
616
+ apps put `overscroll-behavior: contain` on their real scrollers so an
617
+ inner fling never chains into it.
618
+
619
+ Gate, all three required:
620
+ display-mode: standalone only installed apps are letterboxed
621
+ pointer: coarse touch devices — keeps macOS Dock apps out
622
+ -webkit-touch-callout support iOS/iPadOS WebKit only
623
+ Selectors carry `html:root` (0,1,2) on purpose: app resets commonly declare
624
+ `body { overflow: hidden }` at (0,0,1) AFTER this sheet, and the unlock must
625
+ win the cascade without !important. */
626
+ @media (display-mode: standalone) and (pointer: coarse) {
627
+ @supports (-webkit-touch-callout: none) {
628
+ html:root,
629
+ html:root body { overflow: visible; }
630
+ html:root body { overscroll-behavior: none; }
631
+
632
+ html:root body::after {
633
+ content: '';
634
+ display: block;
635
+ width: 1px;
636
+ height: calc(100lvh + 80px);
637
+ visibility: hidden;
638
+ pointer-events: none;
639
+ }
640
+ }
641
+ }
642
+
480
643
  /* ════════════════════════════════════════════════════════════════════
481
644
  * junoui — layout layer (intrinsic & responsive)
482
645
  *
@@ -561,13 +724,41 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
561
724
  flex-basis: calc((var(--juno-switcher-threshold, var(--juno-bp-sm)) - 100%) * 999);
562
725
  }
563
726
 
564
- /* Reelhorizontal scroll row (overflowing chips, timelines). */
727
+ /* Scrollerbare scroll-container primitive underneath every scrolling
728
+ region in the library (.juno-reel, .juno-app-shell__main, tab strips…).
729
+ Three knobs, each a custom prop with the sane-default fallback: overflow
730
+ axis, overscroll containment, and snap type — so a consumer opts into
731
+ containment/snap without re-deriving them, and opts OUT (snap: none is
732
+ the default here) without fighting a hardcoded value. `--x`/`--y` pick a
733
+ single scroll axis; `--bare` hides the scrollbar cross-browser; a
734
+ `.juno-snap` child opts into the snap stop once the container snaps.
735
+ <div class="juno-scroller juno-scroller--y">…</div>
736
+ <div class="juno-scroller juno-scroller--x juno-scroller--bare"
737
+ style="--juno-scroller-snap:x proximity">
738
+ <div class="juno-snap">…</div>
739
+ </div> */
740
+ .juno-scroller {
741
+ overflow: var(--juno-scroller-overflow, auto);
742
+ overscroll-behavior: var(--juno-scroller-overscroll, contain);
743
+ scroll-snap-type: var(--juno-scroller-snap, none);
744
+ }
745
+ .juno-scroller--x { overflow: auto hidden; }
746
+ .juno-scroller--y { overflow: hidden auto; }
747
+ .juno-scroller--bare { scrollbar-width: none; }
748
+ .juno-scroller--bare::-webkit-scrollbar { display: none; }
749
+ .juno-scroller > .juno-snap { scroll-snap-align: var(--juno-snap-align, start); }
750
+
751
+ /* Reel — horizontal scroll row (overflowing chips, timelines). A
752
+ .juno-scroller preset: single inline axis, mandatory snap by default —
753
+ override the mode per instance with --juno-scroller-snap (e.g. `inline
754
+ proximity` for a softer magnet, or `none` to opt out and keep only the
755
+ containment + layout). Default output is unchanged from before. */
565
756
  .juno-reel {
566
757
  display: flex;
567
758
  gap: var(--juno-reel-space, var(--juno-space-12));
568
759
  overflow-x: auto;
569
760
  overscroll-behavior-inline: contain;
570
- scroll-snap-type: inline mandatory;
761
+ scroll-snap-type: var(--juno-scroller-snap, inline mandatory);
571
762
  }
572
763
  .juno-reel > * { scroll-snap-align: start; flex: 0 0 auto; }
573
764
 
@@ -680,6 +871,28 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
680
871
  --juno-gap-content: var(--juno-space-4);
681
872
  }
682
873
 
874
+ /* auto — OPT-IN. `comfortable`/`compact` never change meaning; a consumer
875
+ * must explicitly write `data-juno-density="auto"` to get this behavior.
876
+ * Baseline (any pointer, any viewport ≥ 640px) is comfortable — no rule
877
+ * needed here, it inherits the :root defaults above by not matching
878
+ * `[data-juno-density='compact']`. Only a small-viewport COARSE pointer
879
+ * (phone, not a touch laptop) re-densifies, mirroring the precedent already
880
+ * set by base.css's own `@media (pointer: coarse)` tap-target rule — this is
881
+ * a viewport-level document concern (set on <html>/<body>), not a component
882
+ * in an unknown layout, so a viewport query is the right tool here (container
883
+ * queries can't see pointer type anyway).
884
+ *
885
+ * NEVER shrinks a control's min-height — --juno-size-tap-min stays governed
886
+ * solely by base.css's coarse-pointer rule (20260802-020 / WCAG 2.5.5).
887
+ * This block only shrinks padding/gap/tile sizing, same contract as compact. */
888
+ @media (pointer: coarse) and (width <= 639.98px) {
889
+ [data-juno-density='auto'] {
890
+ --juno-tile-min: 104px;
891
+ --juno-gap-content: var(--juno-space-2);
892
+ --juno-pad-surface-inline: var(--juno-space-12);
893
+ }
894
+ }
895
+
683
896
  /* ════════════════════════════════════════════════════════════════════
684
897
  * junoui — type-scale layer
685
898
  * One multiplier, --juno-font-scale, scales every --juno-font-size-*
@@ -1470,6 +1683,14 @@ button.juno-chip {
1470
1683
  .juno-dock {
1471
1684
  --juno-role: var(--juno-active);
1472
1685
 
1686
+ /* Consumer-driven shrink (e.g. dim/compact the bar on scroll) — default 1,
1687
+ a no-op. Applies to the whole bar regardless of variant (plain/--fixed/
1688
+ --pill/--float/--icon). Transition duration is authored through the
1689
+ motion scale (see base.css's JS-readable --juno-motion contract) so
1690
+ prefers-reduced-motion collapses it to 0 without a component-local
1691
+ media query. */
1692
+ --juno-dock-scale: 1;
1693
+
1473
1694
  position: sticky;
1474
1695
  inset-block-end: 0;
1475
1696
  z-index: var(--juno-z-raised);
@@ -1478,6 +1699,9 @@ button.juno-chip {
1478
1699
  background: var(--juno-s1);
1479
1700
  border-block-start: var(--juno-border-width-1) solid var(--juno-border);
1480
1701
  padding-block-end: env(safe-area-inset-bottom, 0);
1702
+ transform: scale(var(--juno-dock-scale));
1703
+ transform-origin: bottom center;
1704
+ transition: transform calc(var(--juno-motion-duration-quick) * var(--juno-motion-scale)) var(--juno-motion-ease-standard);
1481
1705
  }
1482
1706
 
1483
1707
  .juno-dock__item {
@@ -1490,6 +1714,13 @@ button.juno-chip {
1490
1714
  justify-content: center;
1491
1715
  gap: var(--juno-space-2);
1492
1716
  padding-block: var(--juno-space-4);
1717
+
1718
+ /* Full reset, not just the top edge: a plain `border-block-start` override
1719
+ leaves the UA default border on the other three sides of a <button>
1720
+ item (links have none, so this went unnoticed until <button> showed up
1721
+ in the pill variant's usage). Re-add only the edge that carries the
1722
+ active accent. */
1723
+ border: none;
1493
1724
  border-block-start: var(--juno-border-width-2) solid transparent;
1494
1725
  font-family: var(--juno-font-family-sans);
1495
1726
  font-size: var(--juno-font-size-10);
@@ -1535,6 +1766,310 @@ button.juno-chip {
1535
1766
  inset-block-end: 0;
1536
1767
  }
1537
1768
 
1769
+ /* ── Pill variant — the full-width bar becomes a rounded floating pill with
1770
+ big glyphs in circular bubbles. Labels drop; each icon sits in a circular
1771
+ .juno-dock__bubble that fills only on the active tab (not the whole item).
1772
+ Out of flow (fixed) so the page scrolls UNDER it — reserve room on the
1773
+ scroller with padding-block-end: var(--juno-dock-clearance).
1774
+ Usage — pair the bubble with .juno-icon-loader, junoui's concentric-stack
1775
+ primitive, so a loading arc can be dropped in without re-rolling geometry:
1776
+ <nav class="juno-dock juno-dock--pill juno-hide-from-md" aria-label="Primary">
1777
+ <a class="juno-dock__item" href="…" aria-current="page" aria-label="Library">
1778
+ <span class="juno-dock__bubble juno-icon-loader">
1779
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-squares-four" /></svg>
1780
+ </span>
1781
+ </a>
1782
+ </nav>
1783
+ Labels are hidden, so every item MUST carry an aria-label (on the <a>/<button>)
1784
+ — the glyph alone is not an accessible name. */
1785
+ .juno-dock--pill {
1786
+ --juno-role: var(--juno-active);
1787
+
1788
+ position: fixed;
1789
+ inset-inline: 0;
1790
+ inset-block-end: 0;
1791
+ z-index: var(--juno-z-raised);
1792
+ margin: 0 var(--juno-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
1793
+ padding: var(--juno-space-4);
1794
+ background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
1795
+ -webkit-backdrop-filter: blur(12px);
1796
+ backdrop-filter: blur(12px);
1797
+ border: var(--juno-border-width-1) solid var(--juno-border);
1798
+ border-radius: 999px;
1799
+ box-shadow: var(--juno-shadow-2);
1800
+ }
1801
+
1802
+ .juno-dock--pill .juno-dock__item {
1803
+ min-block-size: 0;
1804
+ gap: 0;
1805
+ padding-block: var(--juno-space-4);
1806
+
1807
+ /* full reset: the base only zeroes the block-start border, but the pill has
1808
+ no top edge to accent — the active state moves to the bubble. Also kill the
1809
+ UA tap-highlight so no square flashes past the round bubble on tap. */
1810
+ border: none;
1811
+ background: none;
1812
+ -webkit-tap-highlight-color: transparent;
1813
+ }
1814
+
1815
+ .juno-dock--pill .juno-dock__label { display: none; }
1816
+
1817
+ /* Focus lands on the bubble so the ring hugs the round target, not the wide
1818
+ item box. */
1819
+ .juno-dock--pill .juno-dock__item:focus-visible { outline: none; }
1820
+
1821
+ .juno-dock--pill .juno-dock__item:focus-visible .juno-dock__bubble {
1822
+ outline: var(--juno-border-width-2) solid var(--juno-active);
1823
+ outline-offset: var(--juno-space-2);
1824
+ }
1825
+
1826
+ /* The bubble is a definite-size circular surface that centres its glyph. It
1827
+ declares the same single-cell grid as .juno-icon-loader so composing the
1828
+ two (see the usage block above) can't fight over `display`, and so the
1829
+ ring — when one is present — is concentric with the glyph. */
1830
+ .juno-dock__bubble {
1831
+ /* Section-loading ring — with .juno-icon-loader on the same element, an
1832
+ indeterminate .juno-arc dropped inside rings the bubble's EDGE (diameter
1833
+ = the bubble, thin stroke) instead of the glyph. Because the bubble's box
1834
+ is definite, adding or removing the arc never resizes it. */
1835
+ --juno-icon-loader-ring: var(--juno-size-tap-comfortable);
1836
+ --juno-icon-loader-ring-width: 2px;
1837
+
1838
+ display: grid;
1839
+ place-items: center;
1840
+ inline-size: var(--juno-size-tap-comfortable);
1841
+ block-size: var(--juno-size-tap-comfortable);
1842
+ border-radius: 50%;
1843
+ transition: background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
1844
+ }
1845
+
1846
+ /* Explicit big glyph so the pill reads at a glance. Set on the glyph itself so
1847
+ it wins regardless of context (the base .juno-icon no longer pins a size —
1848
+ see 20260727-011). */
1849
+ .juno-dock--pill .juno-icon { --juno-icon-size: var(--juno-space-32); }
1850
+
1851
+ /* Active reads on the bubble alone — suppress the base full-item fill so only
1852
+ the circular highlight shows. */
1853
+ .juno-dock--pill .juno-dock__item[aria-current] {
1854
+ background: none;
1855
+ color: var(--juno-role);
1856
+ }
1857
+
1858
+ .juno-dock--pill .juno-dock__item[aria-current] .juno-dock__bubble {
1859
+ background: var(--juno-s3);
1860
+ }
1861
+
1862
+ /* reduced-transparency → solid surface, no blur (matches the pillbar) */
1863
+ @media (prefers-reduced-transparency: reduce) {
1864
+ .juno-dock--pill {
1865
+ background: var(--juno-s1);
1866
+ -webkit-backdrop-filter: none;
1867
+ backdrop-filter: none;
1868
+ }
1869
+ }
1870
+
1871
+ /* ── Float variant — the pillbar's floating capsule chrome (fixed, rounded
1872
+ shell, blur, shadow, off-edge margin) applied to the dock's OWN item model:
1873
+ unlike --pill it keeps icon+label items and the full-item active fill —
1874
+ only the exterior changes. Combine with --icon below for the bubble
1875
+ treatment inside a capsule (the old --pill look, decomposed). `border`
1876
+ fully replaces the base's `border-block-start` (no top seam on a capsule),
1877
+ and `padding` fully replaces the base's safe-area padding (the margin
1878
+ below carries the safe area instead — additive offset off the edge, not
1879
+ `max()`: see docs/ios-conformance.md). `overflow: hidden` clips the
1880
+ items' square corners to the capsule's rounded shape.
1881
+ Usage:
1882
+ <nav class="juno-dock juno-dock--float juno-hide-from-md" aria-label="Primary">
1883
+ <a class="juno-dock__item" href="…" aria-current="page">
1884
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-squares-four" /></svg>
1885
+ <span class="juno-dock__label">Library</span>
1886
+ </a>
1887
+ </nav>
1888
+ Out of flow (fixed) so the page scrolls UNDER it — reserve room on the
1889
+ scroller with padding-block-end: var(--juno-dock-clearance). */
1890
+ .juno-dock--float {
1891
+ position: fixed;
1892
+ inset-inline: 0;
1893
+ inset-block-end: 0;
1894
+ z-index: var(--juno-z-raised);
1895
+ margin: 0 var(--juno-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
1896
+ padding: var(--juno-space-4);
1897
+ overflow: hidden;
1898
+ background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
1899
+ -webkit-backdrop-filter: blur(12px);
1900
+ backdrop-filter: blur(12px);
1901
+ border: var(--juno-border-width-1) solid var(--juno-border);
1902
+ border-radius: 999px;
1903
+ box-shadow: var(--juno-shadow-2);
1904
+ }
1905
+
1906
+ /* reduced-transparency → solid surface, no blur (matches the pillbar / --pill) */
1907
+ @media (prefers-reduced-transparency: reduce) {
1908
+ .juno-dock--float {
1909
+ background: var(--juno-s1);
1910
+ -webkit-backdrop-filter: none;
1911
+ backdrop-filter: none;
1912
+ }
1913
+ }
1914
+
1915
+ /* ── Icon variant — labels drop, glyphs grow, and the active state moves to
1916
+ a circular .juno-dock__bubble instead of filling the whole item, WITHOUT
1917
+ forcing the floating capsule chrome (that's --float, above — combine both
1918
+ for the old --pill look). Usable standalone on a plain sticky/--fixed bar
1919
+ for a compact, icon-only full-width dock.
1920
+ Usage — same .juno-dock__bubble contract as --pill (pair with
1921
+ .juno-icon-loader; see docs/components/dock.md#pill-variant):
1922
+ <nav class="juno-dock juno-dock--icon juno-hide-from-md" aria-label="Primary">
1923
+ <a class="juno-dock__item" href="…" aria-current="page" aria-label="Library">
1924
+ <span class="juno-dock__bubble juno-icon-loader">
1925
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-squares-four" /></svg>
1926
+ </span>
1927
+ </a>
1928
+ </nav>
1929
+ Labels are hidden, so every item MUST carry an aria-label — the glyph
1930
+ alone is not an accessible name. */
1931
+ .juno-dock--icon .juno-dock__item {
1932
+ gap: 0;
1933
+
1934
+ /* accent moves to the bubble; kill the item-edge border in both states */
1935
+ border-color: transparent;
1936
+ }
1937
+
1938
+ .juno-dock--icon .juno-dock__label { display: none; }
1939
+
1940
+ /* Explicit big glyph so the icon-only bar reads at a glance — set on the
1941
+ glyph itself so it wins regardless of context (the base .juno-icon no
1942
+ longer pins a size — see 20260727-011). */
1943
+ .juno-dock--icon .juno-icon { --juno-icon-size: var(--juno-space-32); }
1944
+
1945
+ /* Focus lands on the bubble so the ring hugs the round target, not the wide
1946
+ item box. */
1947
+ .juno-dock--icon .juno-dock__item:focus-visible { outline: none; }
1948
+
1949
+ .juno-dock--icon .juno-dock__item:focus-visible .juno-dock__bubble {
1950
+ outline: var(--juno-border-width-2) solid var(--juno-active);
1951
+ outline-offset: var(--juno-space-2);
1952
+ }
1953
+
1954
+ /* Active reads on the bubble alone — suppress the base full-item fill so
1955
+ only the circular highlight shows. */
1956
+ .juno-dock--icon .juno-dock__item[aria-current] {
1957
+ background: none;
1958
+ color: var(--juno-role);
1959
+ }
1960
+
1961
+ .juno-dock--icon .juno-dock__item[aria-current] .juno-dock__bubble {
1962
+ background: var(--juno-s3);
1963
+ }
1964
+
1965
+ /* ── Collapsible variant — the whole bar folds into a single circular
1966
+ .juno-dock__knob at the inline-end edge, and unfolds back to full width.
1967
+ Driven by ONE inherited custom prop the app writes (junoui ships no JS):
1968
+ --juno-dock-fold, 0 = fully open … 1 = the circle. Write it per scroll
1969
+ frame for a gesture-tracked fold (set it on the dock, an ancestor, or
1970
+ :root — it is only CONSUMED here, never declared, so an inherited value
1971
+ always lands), or flip it 0/1 for a plain toggle.
1972
+
1973
+ The fold has two phases, split at --juno-dock-fold-split: first the bar
1974
+ SHRINKS in place to --juno-dock-fold-scale (transform, no relayout),
1975
+ then it SLIDES shut to --juno-dock-collapsed-size. Width interpolates
1976
+ between two definite lengths because a transition cannot run to or from
1977
+ an intrinsic size; --juno-dock-fold-smoothing is smoothing over
1978
+ scroll-event discretization, not the fold itself — anything longer lags
1979
+ the finger driving it.
1980
+
1981
+ Compose with a fixed placement (--pill, --float, or --fixed): the fold
1982
+ re-anchors the bar to the inline-end edge, which only means something
1983
+ out of flow. Transform-origin is the physical bottom right — the fold's
1984
+ own scale REPLACES the base --juno-dock-scale hook (one transform slot);
1985
+ RTL consumers flip the origin and inset overrides together.
1986
+
1987
+ Structure — items live in a tray; the knob is the circle's face:
1988
+ <nav class="juno-dock juno-dock--pill juno-dock--collapsible …"
1989
+ data-juno-collapsed>
1990
+ <div class="juno-dock__tray"> …the usual __item children… </div>
1991
+ <button class="juno-dock__knob" aria-label="Show navigation">
1992
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
1993
+ </button>
1994
+ </nav>
1995
+
1996
+ data-juno-collapsed is the END state, set by the app only at fold = 1
1997
+ (and removed the moment the fold reopens): it flips the tray to
1998
+ visibility:hidden — which removes its items from the tab order — and
1999
+ reveals the knob. Mid-fold both stay live; the tray's opacity empties
2000
+ ahead of the slide (×1.6) so the glyphs are gone before the circle
2001
+ closes. The knob MUST carry an aria-label; collapsing while focus is
2002
+ inside the tray is the app's edge — move focus to the knob first.
2003
+ prefers-reduced-motion needs nothing component-local: the base layer
2004
+ zeroes every transition, and the fold value itself is app-driven. */
2005
+ .juno-dock--collapsible {
2006
+ --juno-dock-fold-split: 0.35;
2007
+ --juno-dock-fold-scale: 0.78;
2008
+ --juno-dock-collapsed-size: calc(
2009
+ var(--juno-size-tap-comfortable) + 2 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
2010
+ );
2011
+ --juno-dock-edge-gap: calc(2 * var(--juno-space-12));
2012
+ --juno-dock-fold-smoothing: 90ms;
2013
+
2014
+ /* per-phase normalized progress, derived — never set these directly */
2015
+ --juno-dock-fold-shrink: min(1, var(--juno-dock-fold, 0) / var(--juno-dock-fold-split));
2016
+ --juno-dock-fold-slide: clamp(
2017
+ 0,
2018
+ (var(--juno-dock-fold, 0) - var(--juno-dock-fold-split)) / (1 - var(--juno-dock-fold-split)),
2019
+ 1
2020
+ );
2021
+
2022
+ inset-inline-start: auto;
2023
+ inline-size: calc(
2024
+ var(--juno-dock-collapsed-size) +
2025
+ (100% - var(--juno-dock-edge-gap) - var(--juno-dock-collapsed-size)) *
2026
+ (1 - var(--juno-dock-fold-slide))
2027
+ );
2028
+ overflow: hidden;
2029
+ transform: scale(calc(1 - (1 - var(--juno-dock-fold-scale)) * var(--juno-dock-fold-shrink)));
2030
+ transform-origin: bottom right;
2031
+ transition:
2032
+ transform var(--juno-dock-fold-smoothing) linear,
2033
+ inline-size var(--juno-dock-fold-smoothing) linear;
2034
+ }
2035
+
2036
+ .juno-dock__tray {
2037
+ flex: 1;
2038
+ min-inline-size: 0;
2039
+ display: flex;
2040
+ align-items: stretch;
2041
+ opacity: calc(1 - var(--juno-dock-fold-slide, 0) * 1.6);
2042
+ transition: opacity var(--juno-dock-fold-smoothing, 90ms) linear;
2043
+ }
2044
+
2045
+ .juno-dock__knob {
2046
+ position: absolute;
2047
+ inset-block: 0;
2048
+ inset-inline-end: var(--juno-space-4);
2049
+ margin-block: auto;
2050
+ inline-size: var(--juno-size-tap-comfortable);
2051
+ block-size: var(--juno-size-tap-comfortable);
2052
+ display: grid;
2053
+ place-items: center;
2054
+ padding: 0;
2055
+ border: none;
2056
+ border-radius: 50%;
2057
+ background: none;
2058
+ color: var(--juno-label);
2059
+ cursor: pointer;
2060
+ -webkit-tap-highlight-color: transparent;
2061
+ opacity: 0;
2062
+ visibility: hidden;
2063
+ transition: opacity var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
2064
+ }
2065
+
2066
+ .juno-dock--collapsible[data-juno-collapsed] .juno-dock__tray { visibility: hidden; }
2067
+
2068
+ .juno-dock--collapsible[data-juno-collapsed] .juno-dock__knob {
2069
+ opacity: 1;
2070
+ visibility: visible;
2071
+ }
2072
+
1538
2073
  /* ════════════════════════════════════════════════════════════════════
1539
2074
  * Component — Status dot + label
1540
2075
  * Usage:
@@ -1583,6 +2118,12 @@ button.juno-chip {
1583
2118
  * Reuses .juno-modal for the surface + scrim; this file re-pins + re-slides.
1584
2119
  * Selectors are compound (.juno-modal.juno-drawer) so they override the
1585
2120
  * modal's centered transform regardless of bundle order.
2121
+ *
2122
+ * --bottom is a real bottom sheet: a grab handle (.juno-sheet__handle,
2123
+ * decorative) plus a CSS height knob (--juno-sheet-h, default 60dvh —
2124
+ * apps swap it for peek/half/full snap points, capped by --juno-sheet-max).
2125
+ * No drag gesture ships here: resizing/dismissing by drag is stateful and
2126
+ * stays in the app. See docs/components/drawer.md and 20260802-019.
1586
2127
  * ════════════════════════════════════════════════════════════════════ */
1587
2128
 
1588
2129
  .juno-modal.juno-drawer {
@@ -1628,15 +2169,24 @@ button.juno-chip {
1628
2169
  .juno-modal.juno-drawer--bottom {
1629
2170
  inline-size: 100%;
1630
2171
  max-inline-size: 100%;
1631
- block-size: 60dvh;
2172
+
2173
+ /* height knob: swap --juno-sheet-h for snap points (peek/half/full).
2174
+ --juno-sheet-max is the hard ceiling so a swapped value can't run past
2175
+ it. CSS-only — the drag gesture a real snap-point picker needs is
2176
+ stateful and stays in the app. */
2177
+ block-size: var(--juno-sheet-h, 60dvh);
2178
+ max-block-size: var(--juno-sheet-max, 92dvh);
1632
2179
  margin-block: auto 0;
1633
2180
  margin-inline: 0;
1634
2181
  border: none;
1635
2182
  border-block-start: var(--juno-border-width-1) solid var(--juno-border);
2183
+ border-start-start-radius: var(--juno-radius-8);
2184
+ border-start-end-radius: var(--juno-radius-8);
1636
2185
  transform: translateY(100%);
1637
2186
 
1638
- /* keep content above the home indicator */
1639
- padding-block-end: env(safe-area-inset-bottom, 0);
2187
+ /* safe area moves to .juno-modal__body below (the scroll port) — padding
2188
+ here would just be dead space under the grab handle. See 20260802-019. */
2189
+ padding-block-end: 0;
1640
2190
  }
1641
2191
 
1642
2192
  .juno-modal.juno-drawer--bottom[open] { transform: translateY(0); }
@@ -1645,6 +2195,28 @@ button.juno-chip {
1645
2195
  .juno-modal.juno-drawer--bottom[open] { transform: translateY(100%); }
1646
2196
  }
1647
2197
 
2198
+ /* grab handle — visual affordance only. aria-hidden in markup: the dialog's
2199
+ own aria-labelledby (→ the title) stays the accessible name. Dragging it
2200
+ to resize/dismiss is stateful, so junoui draws it but never wires a
2201
+ gesture to it — see docs/accessibility.md. */
2202
+ .juno-sheet__handle {
2203
+ inline-size: var(--juno-space-40);
2204
+ block-size: var(--juno-space-4);
2205
+ margin: var(--juno-space-8) auto;
2206
+ border-radius: 999px;
2207
+ background: var(--juno-border-strong);
2208
+ }
2209
+
2210
+ /* the scrolling body owns the safe area now, mirroring modal.css's mobile
2211
+ sheet. Compound selector for specificity: drawer.css sorts before
2212
+ modal.css in the bundle (scripts/bundle-css.mjs), so a bare
2213
+ .juno-modal__body override here would lose to modal.css's own rule at
2214
+ equal specificity. env() fallback keeps its unit inside calc() — see
2215
+ docs/ios-conformance.md. */
2216
+ .juno-modal.juno-drawer--bottom .juno-modal__body {
2217
+ padding-block-end: calc(var(--juno-pad-surface-inline) + env(safe-area-inset-bottom, 0px));
2218
+ }
2219
+
1648
2220
  /* ── narrow viewports ────────────────────────────────────────────────────
1649
2221
  Side drawers cap below the full width so a sliver of scrim survives —
1650
2222
  the visual cue (and tap target) that "outside" closes the panel. */
@@ -1690,6 +2262,51 @@ button.juno-chip {
1690
2262
 
1691
2263
  .juno-field__error { font-size: var(--juno-font-size-11); color: var(--juno-warning); }
1692
2264
 
2265
+ /* ════════════════════════════════════════════════════════════════════
2266
+ * Component — Fold slot (animated presence for a row member)
2267
+ * A slot inside any flex/grid row that animates between PRESENT and
2268
+ * FOLDED-AWAY instead of popping in and out of the DOM: its inline-size
2269
+ * folds to zero, it fades, and at the end of the fold it leaves the
2270
+ * tab order via visibility (discrete transition: instant coming in,
2271
+ * end-of-fade going out). Keep the element mounted; drive the state
2272
+ * with the data-juno-in attribute — zero JS here, the app owns when.
2273
+ *
2274
+ * The size must be DEFINITE (--juno-fold-size) because a transition
2275
+ * cannot run to or from an intrinsic width. If the row uses a flex/grid
2276
+ * gap, the folded slot still occupies one gap — name it in
2277
+ * --juno-fold-gap and the fold swallows it with a negative margin.
2278
+ *
2279
+ * Canonical use: a transient action in a toolbar/pill row (a
2280
+ * scroll-to-top arrow, a contextual button) whose arrival should slide
2281
+ * the row open rather than jump it.
2282
+ * Usage:
2283
+ * <div style="display:flex; gap:4px"> (any row)
2284
+ * <button class="juno-fold" data-juno-in aria-label="Scroll to top">…</button>
2285
+ * …other members…
2286
+ * </div>
2287
+ * prefers-reduced-motion needs nothing component-local — the base
2288
+ * layer zeroes every transition duration. */
2289
+ .juno-fold {
2290
+ --juno-fold-size: var(--juno-size-tap-comfortable);
2291
+ --juno-fold-gap: 0px;
2292
+
2293
+ flex-shrink: 0;
2294
+ inline-size: var(--juno-fold-size);
2295
+ overflow: hidden;
2296
+ transition:
2297
+ inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
2298
+ margin var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
2299
+ opacity var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
2300
+ visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard);
2301
+ }
2302
+
2303
+ .juno-fold:not([data-juno-in]) {
2304
+ inline-size: 0;
2305
+ margin-inline-end: calc(-1 * var(--juno-fold-gap));
2306
+ opacity: 0;
2307
+ visibility: hidden;
2308
+ }
2309
+
1693
2310
  /* ════════════════════════════════════════════════════════════════════
1694
2311
  * Component — Gauge (determinate metric ring)
1695
2312
  * A fixed-value ring for readouts (CPU / RAM / DISK …) — the metric
@@ -1770,11 +2387,26 @@ button.juno-chip {
1770
2387
  .juno-gauge--lg .juno-gauge__value { font-size: var(--juno-font-size-20); }
1771
2388
 
1772
2389
  /* ════════════════════════════════════════════════════════════════════
1773
- * Component — Icon loader (nav loading ring)
1774
- * A destination icon ringed by the spinning arc while that section loads —
1775
- * the "this is loading" affordance for a rail / dock / pillbar item. The
1776
- * icon stays static on top; the arc sizes in em around it and eats no
1777
- * pointer events, so the nav item still clicks through.
2390
+ * Component — Icon loader (the ring-a-control primitive)
2391
+ * A control ringed by the spinning arc while its section loads — the
2392
+ * "this is loading" affordance for a rail / dock / pillbar item, but also
2393
+ * any other control an app needs to ring: an icon button, a badge, an
2394
+ * avatar. What it wraps stays static on top; the arc rings it and eats no
2395
+ * pointer events, so the wrapped control still clicks through.
2396
+ *
2397
+ * This is the ONLY concentric-ring mechanism in junoui: anything that
2398
+ * wants an arc around it — a nav glyph, a dock bubble, an arbitrary
2399
+ * button or badge — composes this class instead of re-rolling absolute
2400
+ * positioning. Both ring dimensions are custom props, so one wrapper
2401
+ * serves a 14px glyph and a 44px bubble:
2402
+ * --juno-icon-loader-ring diameter (default 1.9em — em, so it
2403
+ * tracks the glyph it wraps)
2404
+ * --juno-icon-loader-ring-width stroke (default 0.14em)
2405
+ * A host with a definite box (a dock bubble, an icon button) sets the
2406
+ * diameter to that box's size, so the ring hugs the edge and the box does
2407
+ * not resize when the arc appears or leaves — ring it without changing
2408
+ * its box. Below ~24px, add .juno-arc--smooth (loader.css) alongside
2409
+ * --indeterminate: the default 12-step sweep reads as jitter that small.
1778
2410
  *
1779
2411
  * The app owns the state: add .juno-arc--indeterminate to spin, drop it
1780
2412
  * when loaded (gate the blink to first-load, not every refetch). For a
@@ -1789,37 +2421,36 @@ button.juno-chip {
1789
2421
  * </a>
1790
2422
  *
1791
2423
  * Gotcha encoded here: the arc's rotate animation overrides `transform`, so
1792
- * the ring is centred with inset:0 + margin:auto — never translate(-50%,-50%),
2424
+ * the ring is centred with a single-cell grid — never translate(-50%,-50%),
1793
2425
  * which the spin would clobber.
1794
2426
  * ════════════════════════════════════════════════════════════════════ */
1795
2427
 
1796
2428
  .juno-icon-loader {
1797
2429
  --juno-role: var(--juno-active);
1798
2430
 
1799
- /* Single-cell grid: the icon and the ring occupy the SAME cell and are
1800
- both centred in it, so they're guaranteed concentric — no transform (the
1801
- arc's rotation would clobber a translate) and no fragile margin math. The
1802
- ring is the larger child, so the cell sizes to it (--juno-icon-loader-ring). */
2431
+ /* Single-cell grid: every child occupies the SAME cell and is centred in
2432
+ it, so the ring and what it rings are guaranteed concentric — no
2433
+ transform (the arc's rotation would clobber a translate) and no fragile
2434
+ margin math. With no explicit size the cell sizes to its largest child
2435
+ (normally the ring, --juno-icon-loader-ring); give the host a definite
2436
+ inline/block size to pin the cell and keep the box jitter-free. */
1803
2437
  display: inline-grid;
1804
2438
  place-items: center;
1805
2439
  line-height: 0; /* drop the inline strut so the box hugs the ring */
1806
2440
  }
1807
2441
 
1808
- .juno-icon-loader > .juno-icon,
1809
- .juno-icon-loader > .juno-arc {
2442
+ /* every child shares the one cell; the ringed content paints above the ring */
2443
+ .juno-icon-loader > * {
1810
2444
  grid-area: 1 / 1;
1811
- }
1812
-
1813
- /* icon sits above the ring */
1814
- .juno-icon-loader > .juno-icon {
1815
2445
  z-index: 1;
1816
2446
  }
1817
2447
 
1818
- /* the ring: sized in em (relative to the icon), transparent to clicks */
2448
+ /* the ring: sized from the two custom props, transparent to clicks */
1819
2449
  .juno-icon-loader > .juno-arc {
1820
2450
  --juno-arc-size: var(--juno-icon-loader-ring, 1.9em);
1821
- --juno-arc-width: 0.14em;
2451
+ --juno-arc-width: var(--juno-icon-loader-ring-width, 0.14em);
1822
2452
 
2453
+ z-index: 0;
1823
2454
  pointer-events: none;
1824
2455
  }
1825
2456
 
@@ -1841,11 +2472,13 @@ button.juno-chip {
1841
2472
  * ════════════════════════════════════════════════════════════════════ */
1842
2473
 
1843
2474
  .juno-icon {
1844
- --juno-icon-size: 1.25em;
1845
-
2475
+ /* Default lives in the var() fallback, NOT as an element-level declaration:
2476
+ declaring --juno-icon-size on the element would shadow an ancestor that
2477
+ sets it for contextual sizing (e.g. .juno-list__chevron). Explicit
2478
+ modifiers below still set it on the element and win. See 20260727-011. */
1846
2479
  display: inline-block;
1847
- inline-size: var(--juno-icon-size);
1848
- block-size: var(--juno-icon-size);
2480
+ inline-size: var(--juno-icon-size, 1.25em);
2481
+ block-size: var(--juno-icon-size, 1.25em);
1849
2482
  flex-shrink: 0;
1850
2483
  vertical-align: -0.2em; /* optical baseline alignment next to text */
1851
2484
  fill: currentcolor;
@@ -2056,6 +2689,143 @@ button.juno-list__row:disabled {
2056
2689
  color: var(--juno-muted);
2057
2690
  }
2058
2691
 
2692
+ /* ════════════════════════════════════════════════════════════════════
2693
+ * Component — Load state · a vocabulary so nothing spins forever
2694
+ * Names the treatment for every way a load can go, so no consumer reaches
2695
+ * for a spinner on a state that will never resolve:
2696
+ * resolving, ETA known → .juno-arc / .juno-beacon / .juno-bar (loader.css)
2697
+ * layout known, no data → .juno-skeleton (skeleton.css)
2698
+ * working, NO ETA → .juno-shimmer — motion without a completion promise
2699
+ * failed (terminal) → .juno-fault — static, never animates
2700
+ * legitimately none → .juno-empty — static, never animates
2701
+ * Full decision table + a11y contract: docs/components/load-state.md.
2702
+ *
2703
+ * Also ships an optional CSS-only switch: mark each treatment's wrapper
2704
+ * with data-juno-when, set data-juno-state on the .juno-state parent, and
2705
+ * the matching wrapper alone gets display:flex. Zero JS in junoui — the
2706
+ * app still decides WHEN a fetch becomes an error or an empty result.
2707
+ * Usage:
2708
+ * <div class="juno-state" data-juno-state="processing">
2709
+ * <div data-juno-when="loading">
2710
+ * <div class="juno-arc juno-arc--indeterminate" role="status" aria-label="Loading"></div>
2711
+ * </div>
2712
+ * <div data-juno-when="processing" aria-busy="true" aria-live="polite">
2713
+ * <div class="juno-shimmer" style="block-size: 120px;"></div>
2714
+ * </div>
2715
+ * <div data-juno-when="error">
2716
+ * <div class="juno-fault" role="status">
2717
+ * <span class="juno-fault__icon" aria-hidden="true">!</span>
2718
+ * <p>Couldn't load this.</p>
2719
+ * </div>
2720
+ * </div>
2721
+ * <div data-juno-when="empty">
2722
+ * <div class="juno-empty">
2723
+ * <span class="juno-empty__icon" aria-hidden="true">∅</span>
2724
+ * <p>Nothing here yet.</p>
2725
+ * </div>
2726
+ * </div>
2727
+ * </div>
2728
+ * ════════════════════════════════════════════════════════════════════ */
2729
+
2730
+ /* ── STATE SWITCH — mechanical only ─────────────────────────────────────
2731
+ Hide every marked child; show only the one(s) whose data-juno-when token
2732
+ list contains the parent's current data-juno-state. */
2733
+ .juno-state > [data-juno-when] { display: none; }
2734
+
2735
+ .juno-state[data-juno-state='loading'] > [data-juno-when~='loading'],
2736
+ .juno-state[data-juno-state='processing'] > [data-juno-when~='processing'],
2737
+ .juno-state[data-juno-state='error'] > [data-juno-when~='error'],
2738
+ .juno-state[data-juno-state='empty'] > [data-juno-when~='empty'] { display: flex; }
2739
+
2740
+ /* ── SHIMMER — work in progress, no completion promise ──────────────────
2741
+ For server-side work with no ETA (transcoding, indexing…): a spinner
2742
+ would spin forever, and a .juno-skeleton implies a known layout that
2743
+ doesn't exist here either — just "something is happening". Shares
2744
+ .juno-skeleton's compositor-only band verbatim (same @keyframes,
2745
+ defined once in skeleton.css — not redefined here) so there is one
2746
+ shimmer implementation, not two. Mark the region aria-busy="true". */
2747
+ .juno-shimmer {
2748
+ position: relative;
2749
+ overflow: hidden;
2750
+ background: var(--juno-s2);
2751
+ border-radius: var(--juno-radius-3);
2752
+ }
2753
+
2754
+ .juno-shimmer::before {
2755
+ content: '';
2756
+ position: absolute;
2757
+ inset: 0;
2758
+ background: linear-gradient(
2759
+ 100deg,
2760
+ transparent 20%,
2761
+ color-mix(in srgb, var(--juno-s3) 70%, transparent) 50%,
2762
+ transparent 80%
2763
+ );
2764
+ animation: juno-skeleton-shimmer var(--juno-shimmer-dur, 1.4s) ease-in-out infinite;
2765
+ }
2766
+
2767
+ /* ── FAULT — terminal, never animates ────────────────────────────────────
2768
+ The load will not resolve on its own (404, decode failure, permanent
2769
+ backend error). Static by design — motion here would lie about the
2770
+ state. Role defaults to caution; recolor with a .juno--<role> class for
2771
+ a different severity, never hardcode a color. This is a per-region
2772
+ fault, not a page-level alert — .juno-alert (role="alert") is for that;
2773
+ here the app wires role="status" so AT announces the failure once,
2774
+ politely, without interrupting. */
2775
+ .juno-fault {
2776
+ --juno-role: var(--juno-caution);
2777
+
2778
+ display: flex;
2779
+ flex-direction: column;
2780
+ align-items: center;
2781
+ justify-content: center;
2782
+ gap: var(--juno-space-8);
2783
+ padding: var(--juno-space-24);
2784
+ text-align: center;
2785
+ color: var(--juno-role);
2786
+ }
2787
+
2788
+ .juno-fault__icon {
2789
+ display: flex;
2790
+ align-items: center;
2791
+ justify-content: center;
2792
+ inline-size: var(--juno-space-40);
2793
+ block-size: var(--juno-space-40);
2794
+ border-radius: var(--juno-radius-8);
2795
+ border: var(--juno-border-width-1) solid currentcolor;
2796
+ font-family: var(--juno-font-family-mono);
2797
+ font-size: var(--juno-font-size-18);
2798
+ }
2799
+
2800
+ /* ── EMPTY — legitimately nothing, terminal ──────────────────────────────
2801
+ The load resolved; there is no data, and none will appear without new
2802
+ input from the user. Generalized, table-agnostic form of the existing
2803
+ .juno-table__empty (table.css) — same anatomy, any region. Neutral
2804
+ (muted) — an empty result isn't a failure, don't tint it like one. */
2805
+ .juno-empty {
2806
+ display: flex;
2807
+ flex-direction: column;
2808
+ align-items: center;
2809
+ justify-content: center;
2810
+ gap: var(--juno-space-12);
2811
+ padding: var(--juno-space-56) var(--juno-space-24);
2812
+ text-align: center;
2813
+ color: var(--juno-label);
2814
+ }
2815
+
2816
+ .juno-empty__icon {
2817
+ display: flex;
2818
+ align-items: center;
2819
+ justify-content: center;
2820
+ inline-size: var(--juno-space-40);
2821
+ block-size: var(--juno-space-40);
2822
+ border-radius: var(--juno-radius-8);
2823
+ border: var(--juno-border-width-1) solid var(--juno-control-edge);
2824
+ color: var(--juno-muted);
2825
+ font-family: var(--juno-font-family-mono);
2826
+ font-size: var(--juno-font-size-18);
2827
+ }
2828
+
2059
2829
  /* ════════════════════════════════════════════════════════════════════
2060
2830
  * Component — Loaders (Arc · Beacon · Linear bar)
2061
2831
  *
@@ -2099,6 +2869,12 @@ button.juno-list__row:disabled {
2099
2869
  animation: juno-arc-rot 1.1s steps(12) infinite; /* mechanical, 12-step sweep */
2100
2870
  }
2101
2871
 
2872
+ /* the 12-step sweep reads as jitter under ~24px (e.g. rung around a small
2873
+ badge via .juno-icon-loader) — swap to a continuous rotation there */
2874
+ .juno-arc--smooth.juno-arc--indeterminate {
2875
+ animation-timing-function: linear;
2876
+ }
2877
+
2102
2878
  /* centred readout for determinate arcs — place a span inside */
2103
2879
  .juno-arc__label {
2104
2880
  position: absolute;
@@ -2283,6 +3059,10 @@ button.juno-list__row:disabled {
2283
3059
  align-items: center;
2284
3060
  gap: var(--juno-gap-control);
2285
3061
  inline-size: 100%;
3062
+
3063
+ /* Dock overflow routes here on phones, making this a touch surface; hold the
3064
+ tap minimum (auto-promotes to 44px on coarse pointers). See 20260802-020. */
3065
+ min-block-size: var(--juno-size-tap-min);
2286
3066
  padding: var(--juno-space-8) var(--juno-space-10);
2287
3067
  background: transparent;
2288
3068
  border: none;
@@ -2378,6 +3158,11 @@ button.juno-list__row:disabled {
2378
3158
  }
2379
3159
 
2380
3160
  .juno-modal[open] {
3161
+ /* Column so __body can be the bounded scroll port: the surface caps its own
3162
+ height (85dvh as a sheet) and clips, so without this a tall body is simply
3163
+ cut off and unreachable. See 20260803-029. */
3164
+ display: flex;
3165
+ flex-direction: column;
2381
3166
  opacity: 1;
2382
3167
  transform: translateY(0) scale(1);
2383
3168
  }
@@ -2393,6 +3178,7 @@ button.juno-list__row:disabled {
2393
3178
  .juno-modal::backdrop {
2394
3179
  background: rgb(0 0 0);
2395
3180
  opacity: var(--juno-opacity-scrim);
3181
+ -webkit-backdrop-filter: blur(1.5px);
2396
3182
  backdrop-filter: blur(1.5px);
2397
3183
  }
2398
3184
 
@@ -2433,7 +3219,19 @@ button.juno-list__row:disabled {
2433
3219
 
2434
3220
  .juno-modal__close:hover { background: var(--juno-s2); color: var(--juno-data); }
2435
3221
 
2436
- .juno-modal__body { padding: var(--juno-space-12) var(--juno-pad-surface-inline) var(--juno-pad-surface-inline); }
3222
+ .juno-modal__body {
3223
+ padding: var(--juno-space-12) var(--juno-pad-surface-inline) var(--juno-pad-surface-inline);
3224
+
3225
+ /* The scroll port (the docs already promised it "scrolls on its own").
3226
+ min-block-size:0 lets a flex item shrink below its content so overflow-y
3227
+ actually engages. overscroll-behavior stops the scroll chaining to the page
3228
+ behind once the body hits its end — the rubber-band-under-the-sheet effect
3229
+ on iOS. NOTE: overscroll-behavior is community convention here, not Apple
3230
+ guidance; no primary Apple source covers it. See 20260803-029. */
3231
+ min-block-size: 0;
3232
+ overflow-y: auto;
3233
+ overscroll-behavior: contain;
3234
+ }
2437
3235
 
2438
3236
  .juno-modal__title {
2439
3237
  font-family: var(--juno-font-family-sans);
@@ -2484,9 +3282,12 @@ button.juno-list__row:disabled {
2484
3282
  .juno-modal:not(.juno-drawer)[open] { transform: translateY(100%); }
2485
3283
  }
2486
3284
 
2487
- /* keep the foot's actions above the home indicator */
3285
+ /* keep the foot's actions above the home indicator.
3286
+ The env() fallback MUST carry a unit: inside calc(), a unitless `0` is a
3287
+ <number>, the addition is invalid, and the whole declaration is dropped —
3288
+ silently losing the padding on every device without a safe area. */
2488
3289
  .juno-modal:not(.juno-drawer) .juno-modal__body {
2489
- padding-block-end: calc(var(--juno-pad-surface-inline) + env(safe-area-inset-bottom, 0));
3290
+ padding-block-end: calc(var(--juno-pad-surface-inline) + env(safe-area-inset-bottom, 0px));
2490
3291
  }
2491
3292
 
2492
3293
  /* full-width actions read better on a sheet */
@@ -2511,7 +3312,7 @@ button.juno-list__row:disabled {
2511
3312
  * </a>
2512
3313
  * <h1 class="juno-navbar__title">Album</h1>
2513
3314
  * <div class="juno-navbar__actions">
2514
- * <button class="juno-btn juno-btn--sm juno-btn--ghost">EDIT</button>
3315
+ * <button class="juno-btn juno-btn--ghost">EDIT</button>
2515
3316
  * </div>
2516
3317
  * </header>
2517
3318
  * ════════════════════════════════════════════════════════════════════ */
@@ -2592,6 +3393,10 @@ button.juno-list__row:disabled {
2592
3393
  gap: var(--juno-space-4);
2593
3394
  }
2594
3395
 
3396
+ /* Top-bar action controls are a touch surface: hold the tap minimum
3397
+ (auto-promotes to 44px on coarse pointers). See 20260802-020. */
3398
+ .juno-navbar__actions > * { min-block-size: var(--juno-size-tap-min); }
3399
+
2595
3400
  .juno-navbar__back:focus-visible {
2596
3401
  outline: var(--juno-border-width-2) solid var(--juno-active);
2597
3402
  outline-offset: calc(-1 * var(--juno-space-2));
@@ -2691,23 +3496,38 @@ button.juno-list__row:disabled {
2691
3496
  * <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-arrows-clockwise" /></svg>
2692
3497
  * </button>
2693
3498
  * </nav>
3499
+ * Overflow: for variable-membership pills (an app-owned capacity planner
3500
+ * decides how many __item fit, reading --juno-pillbar-item/-gap/-pad below),
3501
+ * swap the last __item for .juno-pillbar__overflow anchoring a .juno-menu —
3502
+ * see docs/components/pillbar.md#overflow-slot and menu.css.
2694
3503
  * ════════════════════════════════════════════════════════════════════ */
2695
3504
 
2696
3505
  .juno-pillbar {
2697
3506
  --juno-role: var(--juno-active);
2698
3507
 
3508
+ /* Geometry contract — published so a consumer's overflow/capacity planner
3509
+ (deciding how many __item fit before routing the rest to __overflow)
3510
+ reads real values instead of re-declaring junoui's px constants in JS.
3511
+ Base-rule values only; the corner variants below keep their own literal
3512
+ offsets (distinct block vs. inline edges) and are not wired to -edge. */
3513
+ --juno-pillbar-item: var(--juno-size-tap-comfortable);
3514
+ --juno-pillbar-gap: var(--juno-space-2);
3515
+ --juno-pillbar-pad: var(--juno-space-4);
3516
+ --juno-pillbar-edge: var(--juno-space-16);
3517
+
2699
3518
  position: sticky;
2700
- inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
3519
+ inset-block-end: calc(var(--juno-pillbar-edge) + env(safe-area-inset-bottom, 0px));
2701
3520
  z-index: var(--juno-z-raised);
2702
3521
  margin-inline: auto;
2703
- margin-block-end: var(--juno-space-16);
3522
+ margin-block-end: var(--juno-pillbar-edge);
2704
3523
  inline-size: fit-content;
2705
- max-inline-size: calc(100% - 2 * var(--juno-space-16));
3524
+ max-inline-size: calc(100% - 2 * var(--juno-pillbar-edge));
2706
3525
  display: flex;
2707
3526
  align-items: center;
2708
- gap: var(--juno-space-2);
2709
- padding: var(--juno-space-4);
3527
+ gap: var(--juno-pillbar-gap);
3528
+ padding: var(--juno-pillbar-pad);
2710
3529
  background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
3530
+ -webkit-backdrop-filter: blur(12px);
2711
3531
  backdrop-filter: blur(12px);
2712
3532
  border: var(--juno-border-width-1) solid var(--juno-border);
2713
3533
  border-radius: 999px;
@@ -2718,8 +3538,8 @@ button.juno-list__row:disabled {
2718
3538
  appearance: none;
2719
3539
  margin: 0;
2720
3540
  border: none;
2721
- min-inline-size: var(--juno-size-tap-comfortable);
2722
- min-block-size: var(--juno-size-tap-comfortable);
3541
+ min-inline-size: var(--juno-pillbar-item);
3542
+ min-block-size: var(--juno-pillbar-item);
2723
3543
  display: inline-flex;
2724
3544
  align-items: center;
2725
3545
  justify-content: center;
@@ -2766,6 +3586,54 @@ button.juno-list__row:disabled {
2766
3586
  cursor: not-allowed;
2767
3587
  }
2768
3588
 
3589
+ /* Overflow slot — the "more" trigger for items that don't fit the pill.
3590
+ Styled like __item (same tap target, radius, hover/focus) but a dedicated
3591
+ element because it opens a menu instead of acting as a nav/toggle item.
3592
+ Pair with .juno-menu (menu.css) via popovertarget — the invoker is the
3593
+ menu's implicit anchor, so no extra anchor-positioning is needed here.
3594
+ junoui ships the docking point only: DECIDING which items overflow (and
3595
+ when) is app policy — mount/unmount this button and move the spilled
3596
+ __item markup into the menu, sizing the decision off the
3597
+ --juno-pillbar-item/-gap/-pad custom props above instead of hardcoded px.
3598
+ Zero JS in this file; aria-expanded is toggled by the app or a stateless
3599
+ enhancer, same convention as __item's [aria-pressed].
3600
+ .juno-pillbar__toggle (the --collapsible variant's expand/collapse control,
3601
+ below) shares this exact chrome: same circle, same states. */
3602
+ .juno-pillbar__overflow,
3603
+ .juno-pillbar__toggle {
3604
+ appearance: none;
3605
+ margin: 0;
3606
+ border: none;
3607
+ flex-shrink: 0;
3608
+ min-inline-size: var(--juno-pillbar-item);
3609
+ min-block-size: var(--juno-pillbar-item);
3610
+ display: inline-flex;
3611
+ align-items: center;
3612
+ justify-content: center;
3613
+ background: transparent;
3614
+ border-radius: 999px;
3615
+ color: var(--juno-label);
3616
+ cursor: pointer;
3617
+ transition:
3618
+ color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard),
3619
+ background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
3620
+ }
3621
+
3622
+ .juno-pillbar__overflow:hover,
3623
+ .juno-pillbar__toggle:hover { color: var(--juno-data); }
3624
+
3625
+ .juno-pillbar__overflow[aria-expanded='true'],
3626
+ .juno-pillbar__toggle[aria-expanded='true'] {
3627
+ background: var(--juno-s3);
3628
+ color: var(--juno-role);
3629
+ }
3630
+
3631
+ .juno-pillbar__overflow:focus-visible,
3632
+ .juno-pillbar__toggle:focus-visible {
3633
+ outline: var(--juno-border-width-2) solid var(--juno-active);
3634
+ outline-offset: calc(-1 * var(--juno-space-2));
3635
+ }
3636
+
2769
3637
  .juno-pillbar__sep {
2770
3638
  align-self: center;
2771
3639
  inline-size: var(--juno-border-width-1);
@@ -2789,10 +3657,147 @@ button.juno-list__row:disabled {
2789
3657
  z-index: var(--juno-z-anchored);
2790
3658
  }
2791
3659
 
3660
+ /* Corner placement — a floating cluster pinned to one viewport corner rather
3661
+ than the centered bottom bar (e.g. a top-right search/filter cluster, or a
3662
+ bottom-right action pill). Each variant is self-contained: it flips to
3663
+ fixed, clears the base's bottom-center geometry (auto margins / bottom
3664
+ inset), and clamps the corner with safe-area insets so it never lands under
3665
+ a notch or the home indicator. Keep the base blur/border/shadow. Pair with a
3666
+ scroll-clearance reservation on the scroller if the corner overlaps content
3667
+ (var(--juno-pillbar-clearance) for the bottom corners). */
3668
+ .juno-pillbar--top-right,
3669
+ .juno-pillbar--top-left,
3670
+ .juno-pillbar--bottom-right,
3671
+ .juno-pillbar--bottom-left {
3672
+ position: fixed;
3673
+ inset-block: auto;
3674
+ inset-inline: auto;
3675
+ z-index: var(--juno-z-anchored);
3676
+ margin: 0;
3677
+ }
3678
+
3679
+ .juno-pillbar--top-right {
3680
+ inset-block-start: calc(var(--juno-space-10) + env(safe-area-inset-top, 0px));
3681
+ inset-inline-end: calc(var(--juno-space-12) + env(safe-area-inset-right, 0px));
3682
+ }
3683
+
3684
+ .juno-pillbar--top-left {
3685
+ inset-block-start: calc(var(--juno-space-10) + env(safe-area-inset-top, 0px));
3686
+ inset-inline-start: calc(var(--juno-space-12) + env(safe-area-inset-left, 0px));
3687
+ }
3688
+
3689
+ .juno-pillbar--bottom-right {
3690
+ inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
3691
+ inset-inline-end: calc(var(--juno-space-12) + env(safe-area-inset-right, 0px));
3692
+ }
3693
+
3694
+ .juno-pillbar--bottom-left {
3695
+ inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
3696
+ inset-inline-start: calc(var(--juno-space-12) + env(safe-area-inset-left, 0px));
3697
+ }
3698
+
3699
+ /* Input slot — an expandable search field living inside the pill. Borderless
3700
+ and transparent so it reads as part of the pill, not a boxed control. The
3701
+ font-size floor matters: iOS Safari zooms the whole page onto any focused
3702
+ text field under 16px, so hold this at max(16px, …) exactly like the base
3703
+ .juno-input touch rule. Give it an accessible label (aria-label or a
3704
+ visually-hidden <label>) — the placeholder is not a label. */
3705
+ .juno-pillbar__input {
3706
+ flex: 0 1 auto;
3707
+ min-inline-size: 0;
3708
+ inline-size: min(52vw, 240px);
3709
+ margin: 0;
3710
+ padding-inline: var(--juno-space-8);
3711
+ border: none;
3712
+ background: transparent;
3713
+ outline: none;
3714
+ font-family: var(--juno-font-family-sans);
3715
+ font-size: max(16px, var(--juno-font-size-16));
3716
+ color: var(--juno-data);
3717
+ }
3718
+
3719
+ .juno-pillbar__input::placeholder { color: var(--juno-muted); }
3720
+
3721
+ .juno-pillbar__input:focus-visible {
3722
+ outline: none;
3723
+ }
3724
+
3725
+ /* Collapsible — the whole pill folds into a single circular toggle, and a
3726
+ tap expands it back to full width. State lives on the toggle's
3727
+ aria-expanded (app-owned, like __item's aria-pressed — zero JS here);
3728
+ the bar reads it via :has(), so DOM order of toggle vs. tray is free
3729
+ (toggle-first for a left-anchored pill, toggle-last for right-anchored).
3730
+
3731
+ Usage:
3732
+ <nav class="juno-pillbar juno-pillbar--collapsible" aria-label="Tools">
3733
+ <button class="juno-pillbar__toggle" aria-expanded="false" aria-label="Show toolbar">
3734
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
3735
+ </button>
3736
+ <div class="juno-pillbar__tray">
3737
+ <div><!-- single wrapper, any element -->
3738
+ …the usual __item / __sep / __input children…
3739
+ </div>
3740
+ </div>
3741
+ </nav>
3742
+
3743
+ Mechanism: the tray is a one-column grid whose track animates 0fr ↔ 1fr —
3744
+ the only widely-supported way to TRANSITION to an intrinsic width (Safari 16+;
3745
+ width: fit-content and interpolate-size aren't animatable there). The track
3746
+ needs one shrinkable child, hence the mandatory single wrapper (any element:
3747
+ it's selected as > *, so there is no class to forget). visibility rides the
3748
+ same transition — discrete animation keeps the tray visible while it slides
3749
+ shut, then hides it, which also drops the hidden items from the tab order
3750
+ (collapsing while focus is inside the tray is the app's edge to handle:
3751
+ move focus to the toggle first). prefers-reduced-motion needs nothing
3752
+ component-local — the base layer zeroes every transition duration. */
3753
+ .juno-pillbar--collapsible {
3754
+ transition: gap var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
3755
+ }
3756
+
3757
+ .juno-pillbar__tray {
3758
+ display: grid;
3759
+ grid-template-columns: 1fr;
3760
+ transition:
3761
+ grid-template-columns var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard),
3762
+ opacity var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard),
3763
+ visibility var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
3764
+ }
3765
+
3766
+ .juno-pillbar__tray > * {
3767
+ display: flex;
3768
+ align-items: center;
3769
+ gap: var(--juno-pillbar-gap);
3770
+ min-inline-size: 0;
3771
+ overflow: hidden;
3772
+ }
3773
+
3774
+ .juno-pillbar--collapsible:has(> .juno-pillbar__toggle[aria-expanded='false']) {
3775
+ /* gap → 0 so the collapsed pill is exactly the toggle's circle, whichever
3776
+ side the tray sits on (a negative margin would have to know the side) */
3777
+ gap: 0;
3778
+ }
3779
+
3780
+ .juno-pillbar--collapsible:has(> .juno-pillbar__toggle[aria-expanded='false']) .juno-pillbar__tray {
3781
+ grid-template-columns: 0fr;
3782
+ opacity: 0;
3783
+ visibility: hidden;
3784
+ }
3785
+
3786
+ /* the toggle's glyph turns a quarter as the tray opens — motion reads as
3787
+ "this button did it" even when the expansion pushes the pill off-center */
3788
+ .juno-pillbar__toggle .juno-icon {
3789
+ transition: transform var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
3790
+ }
3791
+
3792
+ .juno-pillbar__toggle[aria-expanded='true'] .juno-icon {
3793
+ transform: rotate(90deg);
3794
+ }
3795
+
2792
3796
  /* reduced-transparency preference → solid surface, no blur */
2793
3797
  @media (prefers-reduced-transparency: reduce) {
2794
3798
  .juno-pillbar {
2795
3799
  background: var(--juno-s1);
3800
+ -webkit-backdrop-filter: none;
2796
3801
  backdrop-filter: none;
2797
3802
  }
2798
3803
  }
@@ -3022,6 +4027,55 @@ button.juno-list__row:disabled {
3022
4027
  margin-top: var(--juno-space-4);
3023
4028
  }
3024
4029
 
4030
+ /* ════════════════════════════════════════════════════════════════════
4031
+ * Component — Reload indicator (refetch-in-place)
4032
+ * The non-blocking counterpart to the skeleton. Skeletons stand in for
4033
+ * content that isn't there yet (first paint); the reload dot signals a
4034
+ * refresh happening OVER content that's already on screen — the stale data
4035
+ * stays readable and interactive while fresh data lands.
4036
+ *
4037
+ * A small pulsing dot with a soft halo, centered as a fixed overlay that
4038
+ * eats no pointer events (the page underneath stays fully usable). Color
4039
+ * comes from --juno-role (default = active). The app owns the state: render
4040
+ * it while a background refetch is in flight, drop it when it settles.
4041
+ *
4042
+ * a11y: role="status" + aria-label so assistive tech announces the refresh
4043
+ * politely without stealing focus. Keep it a live status, not an alert.
4044
+ * Usage:
4045
+ * <div class="juno-reload" role="status" aria-label="Reloading">
4046
+ * <span class="juno-reload__dot"></span>
4047
+ * </div>
4048
+ * ════════════════════════════════════════════════════════════════════ */
4049
+
4050
+ .juno-reload {
4051
+ --juno-role: var(--juno-active);
4052
+
4053
+ position: fixed;
4054
+ inset-block-start: 50%;
4055
+ inset-inline-start: 50%;
4056
+ transform: translate(-50%, -50%);
4057
+ z-index: var(--juno-z-anchored);
4058
+ display: flex;
4059
+ align-items: center;
4060
+ justify-content: center;
4061
+ pointer-events: none; /* refresh is non-blocking — never trap clicks */
4062
+ }
4063
+
4064
+ .juno-reload__dot {
4065
+ inline-size: var(--juno-space-16);
4066
+ block-size: var(--juno-space-16);
4067
+ border-radius: 50%;
4068
+ background: var(--juno-role);
4069
+
4070
+ /* soft halo so the dot reads over any surface without a hard edge */
4071
+ box-shadow: 0 0 0 var(--juno-space-8) color-mix(in srgb, var(--juno-role) 20%, transparent);
4072
+ animation: juno-pulse 1.4s ease-in-out infinite;
4073
+ }
4074
+
4075
+ /* prefers-reduced-motion: the base layer already slows every animation to a
4076
+ stop; the dot + halo stay visible (steady), so the "refreshing" state is
4077
+ still conveyed without the pulse. */
4078
+
3025
4079
  /* ════════════════════════════════════════════════════════════════════
3026
4080
  * Component — Segmented control
3027
4081
  * Exclusive-choice pill row (filters, mode switchers, sort pickers).
@@ -3165,14 +4219,20 @@ button.juno-seg__opt:disabled {
3165
4219
  * Component — Skeleton · loading placeholder
3166
4220
  * A shimmering block that stands in for content not yet loaded. Same
3167
4221
  * shimmer language as the table's row skeleton, generalized: text lines,
3168
- * blocks, and circles (avatars). Zero JS size it with width / height or
3169
- * --juno-skeleton-h. Honors prefers-reduced-motion (shimmer → static).
3170
- * Mark the loading region aria-busy="true" so AT announces it.
4222
+ * blocks, circles (avatars), and aspect-ratio tiles (media grids). Zero
4223
+ * JS — size it with width / height, --juno-skeleton-h, or (for --tile)
4224
+ * --juno-skeleton-ratio. The shimmer is a `::before` overlay animated on
4225
+ * transform/opacity only — never background-position — so it stays on
4226
+ * the compositor instead of repainting the gradient every frame; the
4227
+ * base fill is a plain solid color. Reduced motion is handled globally
4228
+ * (base.css collapses all animation-duration to ~0), no local override
4229
+ * needed here. Mark the loading region aria-busy="true" so AT announces it.
3171
4230
  * Usage:
3172
4231
  * <div aria-busy="true" aria-live="polite">
3173
4232
  * <span class="juno-skeleton juno-skeleton--circle" style="--juno-skeleton-h: 48px;"></span>
3174
4233
  * <span class="juno-skeleton juno-skeleton--text" style="inline-size: 60%;"></span>
3175
4234
  * <span class="juno-skeleton juno-skeleton--text"></span>
4235
+ * <span class="juno-skeleton juno-skeleton--tile"></span>
3176
4236
  * </div>
3177
4237
  * ════════════════════════════════════════════════════════════════════ */
3178
4238
 
@@ -3180,11 +4240,27 @@ button.juno-seg__opt:disabled {
3180
4240
  --juno-skeleton-h: var(--juno-space-16);
3181
4241
 
3182
4242
  display: block;
4243
+ position: relative;
4244
+ overflow: hidden;
3183
4245
  inline-size: 100%;
3184
4246
  block-size: var(--juno-skeleton-h);
3185
4247
  border-radius: var(--juno-radius-3);
3186
- background: linear-gradient(90deg, var(--juno-s2) 0%, var(--juno-s3) 50%, var(--juno-s2) 100%);
3187
- background-size: 200% 100%;
4248
+ background: var(--juno-s2);
4249
+ }
4250
+
4251
+ /* the shimmer itself: a diagonal highlight band that slides across via
4252
+ transform only (compositor-only — no layout/paint per frame). Clipped to
4253
+ the parent's border-radius by the overflow: hidden above. */
4254
+ .juno-skeleton::before {
4255
+ content: '';
4256
+ position: absolute;
4257
+ inset: 0;
4258
+ background: linear-gradient(
4259
+ 100deg,
4260
+ transparent 20%,
4261
+ color-mix(in srgb, var(--juno-s3) 70%, transparent) 50%,
4262
+ transparent 80%
4263
+ );
3188
4264
  animation: juno-skeleton-shimmer 1.2s ease-in-out infinite;
3189
4265
  }
3190
4266
 
@@ -3208,19 +4284,25 @@ button.juno-seg__opt:disabled {
3208
4284
  border-radius: var(--juno-radius-4);
3209
4285
  }
3210
4286
 
4287
+ /* content-box mode: size from an aspect ratio instead of a fixed height —
4288
+ the dominant case for media-grid tiles standing in for a real image/card
4289
+ of known proportions. Default 1 (square); override per instance with
4290
+ --juno-skeleton-ratio (e.g. "16 / 9"). */
4291
+ .juno-skeleton--tile {
4292
+ --juno-skeleton-ratio: 1;
4293
+
4294
+ aspect-ratio: var(--juno-skeleton-ratio);
4295
+ block-size: auto;
4296
+ border-radius: var(--juno-radius-4);
4297
+ }
4298
+
3211
4299
  @keyframes juno-skeleton-shimmer {
3212
4300
  from {
3213
- background-position: 200% 0;
4301
+ transform: translateX(-100%);
3214
4302
  }
3215
4303
 
3216
4304
  to {
3217
- background-position: -200% 0;
3218
- }
3219
- }
3220
-
3221
- @media (prefers-reduced-motion: reduce) {
3222
- .juno-skeleton {
3223
- animation: none;
4305
+ transform: translateX(100%);
3224
4306
  }
3225
4307
  }
3226
4308
 
@@ -4098,6 +5180,11 @@ button.juno-seg__opt:disabled {
4098
5180
  scrollbar-width: none;
4099
5181
  }
4100
5182
 
5183
+ /* scrollbar-width only reached Safari 18.2, so the scrollable strip still shows
5184
+ a bar on older iOS — keep the WebKit pseudo-element alongside it (same pair
5185
+ .juno-scroller--bare uses). */
5186
+ .juno-tabs__list::-webkit-scrollbar { display: none; }
5187
+
4101
5188
  .juno-tabs__tab {
4102
5189
  appearance: none;
4103
5190
  margin: 0;
@@ -4145,10 +5232,22 @@ button.juno-seg__opt:disabled {
4145
5232
  * <figure class="juno-thumb" style="aspect-ratio: 1">
4146
5233
  * <img src="…" alt="Sunset clip" onerror="this.remove()" />
4147
5234
  * </figure>
4148
- * <!-- known-missing: ship no <img>; label the figure instead -->
5235
+ * \3c !-- known-missing: ship no <img>; label the figure instead -->
4149
5236
  * <figure class="juno-thumb juno-thumb--video" role="img" aria-label="Preview unavailable"></figure>
4150
5237
  * The onerror one-liner is the whole JS contract (optional, stateless):
4151
5238
  * a broken image removes itself and the placeholder underneath shows.
5239
+ * Aspect is locked by default (--juno-thumb-ratio, square) so a media wall's
5240
+ * scroll height is stable before anything loads; override per instance with
5241
+ * style="--juno-thumb-ratio: 16/9" (an inline aspect-ratio still wins too).
5242
+ * Selection is an outline (never a border — a border would shift the frame)
5243
+ * toggled by a class/data-attr; junoui never decides what's selected.
5244
+ * Corner slots anchor badges/duration chips over the media with logical
5245
+ * inset so they swap sides correctly under dir="rtl".
5246
+ * Usage:
5247
+ * <figure class="juno-thumb juno-thumb--selected">
5248
+ * <img src="…" alt="Sunset clip" onerror="this.remove()" />
5249
+ * <span class="juno-thumb__corner juno-thumb__corner--top-end badge">0:42</span>
5250
+ * </figure>
4152
5251
  * ════════════════════════════════════════════════════════════════════ */
4153
5252
 
4154
5253
  .juno-thumb {
@@ -4159,12 +5258,30 @@ button.juno-seg__opt:disabled {
4159
5258
  justify-content: center;
4160
5259
  gap: var(--juno-space-4);
4161
5260
  margin: 0;
5261
+ aspect-ratio: var(--juno-thumb-ratio, 1);
4162
5262
  overflow: hidden;
4163
5263
  background: var(--juno-s2);
4164
5264
  border: var(--juno-border-width-1) solid var(--juno-border);
4165
5265
  border-radius: var(--juno-radius-3);
4166
5266
  }
4167
5267
 
5268
+ /* Flush — drop the border/radius for tight walls where the frame's own edge
5269
+ would fight the grid (full-bleed tiles); the media stays edge-to-edge via
5270
+ object-fit: cover regardless. */
5271
+ .juno-thumb--flush {
5272
+ border: none;
5273
+ border-radius: 0;
5274
+ }
5275
+
5276
+ /* Selected — an inset outline, not a border: it overlays the existing frame
5277
+ edge instead of adding to the box, so nothing reflows when selection
5278
+ toggles. Role-neutral (--juno-active), matching the checkbox/switch focus
5279
+ ring elsewhere in the system. */
5280
+ .juno-thumb--selected {
5281
+ outline: var(--juno-border-width-2) solid var(--juno-active);
5282
+ outline-offset: calc(-1 * var(--juno-border-width-2));
5283
+ }
5284
+
4168
5285
  /* placeholder glyph (Phosphor "images", masked so it takes any color) */
4169
5286
  .juno-thumb::before {
4170
5287
  --juno-thumb-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath d='M160,88a16,16,0,1,1,16,16A16,16,0,0,1,160,88Zm76-32V160a20,20,0,0,1-20,20H204v20a20,20,0,0,1-20,20H40a20,20,0,0,1-20-20V88A20,20,0,0,1,40,68H60V56A20,20,0,0,1,80,36H216A20,20,0,0,1,236,56ZM180,180H80a20,20,0,0,1-20-20V92H44V196H180Zm-21.66-24L124,121.66,89.66,156ZM212,60H84v67.72l25.86-25.86a20,20,0,0,1,28.28,0L192.28,156H212Z'/%3E%3C/svg%3E");
@@ -4204,6 +5321,38 @@ button.juno-seg__opt:disabled {
4204
5321
  object-fit: cover;
4205
5322
  }
4206
5323
 
5324
+ /* Corner overlay — anchor a badge, check, or duration chip over the media
5325
+ without joining flow or touching the frame's aspect-ratio. Pick one
5326
+ position modifier; the base only sets stacking + inset from the frame
5327
+ edge. Logical inset (not top/left) so start/end swap under dir="rtl" —
5328
+ and the modifiers are named start/end for the same reason: a `--top-right`
5329
+ that renders top-LEFT in RTL would be a trap. */
5330
+ .juno-thumb__corner {
5331
+ position: absolute;
5332
+ z-index: 1;
5333
+ margin: var(--juno-space-4);
5334
+ }
5335
+
5336
+ .juno-thumb__corner--top-start {
5337
+ inset-block-start: 0;
5338
+ inset-inline-start: 0;
5339
+ }
5340
+
5341
+ .juno-thumb__corner--top-end {
5342
+ inset-block-start: 0;
5343
+ inset-inline-end: 0;
5344
+ }
5345
+
5346
+ .juno-thumb__corner--bottom-start {
5347
+ inset-block-end: 0;
5348
+ inset-inline-start: 0;
5349
+ }
5350
+
5351
+ .juno-thumb__corner--bottom-end {
5352
+ inset-block-end: 0;
5353
+ inset-inline-end: 0;
5354
+ }
5355
+
4207
5356
  /* ════════════════════════════════════════════════════════════════════
4208
5357
  * Component — Toast / snackbar
4209
5358
  * A transient, floating notification. Same role-rail language as the
@@ -4297,7 +5446,11 @@ button.juno-seg__opt:disabled {
4297
5446
  @media (width <= 639.98px) {
4298
5447
  .juno-toast-stack {
4299
5448
  inset-inline: var(--juno-space-12);
4300
- inset-block-end: calc(var(--juno-space-12) + env(safe-area-inset-bottom, 0));
5449
+
5450
+ /* unit-bearing env() fallback: inside calc() a unitless `0` is a <number>,
5451
+ which invalidates the sum and drops the whole declaration (the stack
5452
+ would then sit flush at 0 on any device without a safe area). */
5453
+ inset-block-end: calc(var(--juno-space-12) + env(safe-area-inset-bottom, 0px));
4301
5454
  inline-size: auto;
4302
5455
  }
4303
5456
 
@@ -4604,8 +5757,11 @@ button.juno-seg__opt:disabled {
4604
5757
  font-variant-numeric: tabular-nums;
4605
5758
  }
4606
5759
 
5760
+ /* --juno-label-size: optional size override, set by an ancestor (e.g. a
5761
+ * density wrapper or a "compact" section) — never declared here, so it
5762
+ * isn't shadowed for descendants that also want to read it. */
4607
5763
  .juno-label {
4608
- font-size: var(--juno-font-size-13);
5764
+ font-size: var(--juno-label-size, var(--juno-font-size-13));
4609
5765
  color: var(--juno-label);
4610
5766
  }
4611
5767