@junoput01/junoui 0.2.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 (84) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +23 -19
  3. package/dist/css/juno-custom-media.css +32 -0
  4. package/dist/css/juno.css +1355 -40
  5. package/dist/icons/inline.js +22 -0
  6. package/docs/accessibility.md +188 -0
  7. package/docs/boot-shell.md +295 -0
  8. package/docs/components/README.md +71 -0
  9. package/docs/components/accordion.md +49 -0
  10. package/docs/components/alert.md +46 -0
  11. package/docs/components/avatar.md +45 -0
  12. package/docs/components/badge.md +35 -0
  13. package/docs/components/breadcrumb.md +31 -0
  14. package/docs/components/button.md +32 -0
  15. package/docs/components/card.md +41 -0
  16. package/docs/components/checkbox.md +41 -0
  17. package/docs/components/chip.md +44 -0
  18. package/docs/components/divider.md +32 -0
  19. package/docs/components/dock.md +215 -0
  20. package/docs/components/drawer.md +100 -0
  21. package/docs/components/field.md +44 -0
  22. package/docs/components/fold-slot.md +31 -0
  23. package/docs/components/gauge.md +48 -0
  24. package/docs/components/icon-loader.md +122 -0
  25. package/docs/components/icon.md +70 -0
  26. package/docs/components/input.md +45 -0
  27. package/docs/components/list.md +76 -0
  28. package/docs/components/load-state.md +131 -0
  29. package/docs/components/loader.md +88 -0
  30. package/docs/components/menu.md +54 -0
  31. package/docs/components/modal.md +52 -0
  32. package/docs/components/navbar.md +53 -0
  33. package/docs/components/pagination.md +42 -0
  34. package/docs/components/pillbar.md +211 -0
  35. package/docs/components/popover.md +43 -0
  36. package/docs/components/rail.md +54 -0
  37. package/docs/components/readout.md +39 -0
  38. package/docs/components/reload.md +41 -0
  39. package/docs/components/segmented.md +46 -0
  40. package/docs/components/select.md +33 -0
  41. package/docs/components/skeleton.md +45 -0
  42. package/docs/components/slider.md +48 -0
  43. package/docs/components/spark.md +51 -0
  44. package/docs/components/status.md +30 -0
  45. package/docs/components/stepper.md +46 -0
  46. package/docs/components/switch.md +38 -0
  47. package/docs/components/table.md +126 -0
  48. package/docs/components/tabs.md +61 -0
  49. package/docs/components/thumb.md +81 -0
  50. package/docs/components/toast.md +48 -0
  51. package/docs/components/toggle-button.md +40 -0
  52. package/docs/components/tooltip.md +63 -0
  53. package/docs/design-guidelines.md +173 -0
  54. package/docs/flutter.md +42 -0
  55. package/docs/getting-started.md +93 -0
  56. package/docs/integration.md +95 -0
  57. package/docs/ios-conformance.md +224 -0
  58. package/docs/layout.md +271 -0
  59. package/docs/native.md +52 -0
  60. package/docs/roadmap.md +106 -0
  61. package/docs/tokens-reference.md +223 -0
  62. package/docs/web.md +95 -0
  63. package/package.json +7 -3
  64. package/src/css/base.css +171 -1
  65. package/src/css/components/dock.css +335 -0
  66. package/src/css/components/drawer.css +40 -3
  67. package/src/css/components/fold-slot.css +44 -0
  68. package/src/css/components/icon-loader.css +67 -0
  69. package/src/css/components/icon.css +6 -4
  70. package/src/css/components/load-state.css +136 -0
  71. package/src/css/components/loader.css +6 -0
  72. package/src/css/components/menu.css +4 -0
  73. package/src/css/components/modal.css +24 -3
  74. package/src/css/components/navbar.css +5 -1
  75. package/src/css/components/pillbar.css +222 -7
  76. package/src/css/components/rail.css +8 -0
  77. package/src/css/components/reload.css +48 -0
  78. package/src/css/components/skeleton.css +41 -13
  79. package/src/css/components/tabs.css +5 -0
  80. package/src/css/components/thumb.css +63 -1
  81. package/src/css/components/toast.css +5 -1
  82. package/src/css/density.css +22 -0
  83. package/src/css/layout.css +95 -5
  84. 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
@@ -440,11 +470,103 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
440
470
  the contract; a phone just reads the bigger one. */
441
471
  @media (pointer: coarse) {
442
472
  :root { --juno-size-tap-min: var(--juno-size-tap-comfortable); }
473
+
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. */
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
+ }
443
497
  }
444
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
+
445
554
  @keyframes juno-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
446
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
+
447
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
+
448
570
  *, *::before, *::after {
449
571
  animation-duration: 0.01ms !important;
450
572
  animation-iteration-count: 1 !important;
@@ -470,6 +592,54 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
470
592
  .juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
471
593
  }
472
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
+
473
643
  /* ════════════════════════════════════════════════════════════════════
474
644
  * junoui — layout layer (intrinsic & responsive)
475
645
  *
@@ -554,21 +724,111 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
554
724
  flex-basis: calc((var(--juno-switcher-threshold, var(--juno-bp-sm)) - 100%) * 999);
555
725
  }
556
726
 
557
- /* 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. */
558
756
  .juno-reel {
559
757
  display: flex;
560
758
  gap: var(--juno-reel-space, var(--juno-space-12));
561
759
  overflow-x: auto;
562
760
  overscroll-behavior-inline: contain;
563
- scroll-snap-type: inline mandatory;
761
+ scroll-snap-type: var(--juno-scroller-snap, inline mandatory);
564
762
  }
565
763
  .juno-reel > * { scroll-snap-align: start; flex: 0 0 auto; }
566
764
 
765
+ /* App shell — the standard product frame, as classes instead of the
766
+ copy-pasted \3c style> block every consumer used to hand-roll. A rail beside
767
+ a body column; the body holds an optional topbar, a scrolling main region,
768
+ and a dock/pillbar pinned at its foot. The MAIN region is the scroller
769
+ (not the page), so the dock never needs sticky/fixed to stay put and the
770
+ mobile address bar can't clip it. Height is 100dvh (dynamic viewport) so
771
+ it fills the visible area under shrinking browser chrome. Pairs with the
772
+ viewport helpers below to swap rail↔dock across md; see docs/layout.md#app-shell.
773
+ <div class="juno-app-shell">
774
+ <nav class="juno-rail juno-rail--responsive" aria-label="Primary">…</nav>
775
+ <div class="juno-app-shell__body">
776
+ <header class="juno-app-shell__topbar">…</header>
777
+ <main class="juno-app-shell__main">…</main>
778
+ <nav class="juno-dock juno-hide-from-md" aria-label="Primary">…</nav>
779
+ </div>
780
+ </div> */
781
+ .juno-app-shell {
782
+ display: flex;
783
+ block-size: 100dvh;
784
+ overflow: hidden; /* the main region scrolls, not the shell */
785
+ padding-inline: env(safe-area-inset-left, 0) env(safe-area-inset-right, 0);
786
+ }
787
+
788
+ .juno-app-shell__body {
789
+ flex: 1;
790
+ min-inline-size: 0;
791
+ display: flex;
792
+ flex-direction: column;
793
+ min-block-size: 0; /* let __main shrink and scroll instead of the shell */
794
+ }
795
+
796
+ .juno-app-shell__topbar {
797
+ flex-shrink: 0;
798
+ display: flex;
799
+ align-items: center;
800
+ gap: var(--juno-space-12);
801
+ min-block-size: var(--juno-app-shell-topbar-size, 46px);
802
+ padding-inline: var(--juno-pad-surface-inline);
803
+ padding-block-start: env(safe-area-inset-top, 0);
804
+ background: var(--juno-s1);
805
+ border-block-end: var(--juno-border-width-1) solid var(--juno-border);
806
+ }
807
+
808
+ .juno-app-shell__main {
809
+ flex: 1;
810
+ min-block-size: 0;
811
+ overflow: auto;
812
+ overscroll-behavior: contain;
813
+ padding: var(--juno-pad-surface-block) var(--juno-pad-surface-inline);
814
+ }
815
+
567
816
  /* Hide/show by viewport breakpoint — the rare case an intrinsic primitive
568
- can't express (e.g. swap a nav for a menu button). */
569
- @media (width <= 767.98px) { .juno-hide-below-md { display: none !important; } }
817
+ can't express (e.g. swap a nav for a menu button). `hide-below-X` hides
818
+ under breakpoint X; `hide-from-X` hides at X and up. `show-*` are the
819
+ readable inverse aliases (show-from-X ≡ hide-below-X, show-below-X ≡
820
+ hide-from-X) — same media rule, name whichever reads clearer at the site. */
821
+ @media (width <= 639.98px) { .juno-hide-below-sm, .juno-show-from-sm { display: none !important; } }
822
+
823
+ @media (width >= 640px) { .juno-hide-from-sm, .juno-show-below-sm { display: none !important; } }
824
+
825
+ @media (width <= 767.98px) { .juno-hide-below-md, .juno-show-from-md { display: none !important; } }
826
+
827
+ @media (width >= 768px) { .juno-hide-from-md, .juno-show-below-md { display: none !important; } }
828
+
829
+ @media (width <= 1023.98px) { .juno-hide-below-lg, .juno-show-from-lg { display: none !important; } }
570
830
 
571
- @media (width >= 768px) { .juno-hide-from-md { display: none !important; } }
831
+ @media (width >= 1024px) { .juno-hide-from-lg, .juno-show-below-lg { display: none !important; } }
572
832
 
573
833
  /* ════════════════════════════════════════════════════════════════════
574
834
  * junoui — density layer
@@ -611,6 +871,28 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
611
871
  --juno-gap-content: var(--juno-space-4);
612
872
  }
613
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
+
614
896
  /* ════════════════════════════════════════════════════════════════════
615
897
  * junoui — type-scale layer
616
898
  * One multiplier, --juno-font-scale, scales every --juno-font-size-*
@@ -1401,6 +1683,14 @@ button.juno-chip {
1401
1683
  .juno-dock {
1402
1684
  --juno-role: var(--juno-active);
1403
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
+
1404
1694
  position: sticky;
1405
1695
  inset-block-end: 0;
1406
1696
  z-index: var(--juno-z-raised);
@@ -1409,6 +1699,9 @@ button.juno-chip {
1409
1699
  background: var(--juno-s1);
1410
1700
  border-block-start: var(--juno-border-width-1) solid var(--juno-border);
1411
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);
1412
1705
  }
1413
1706
 
1414
1707
  .juno-dock__item {
@@ -1421,6 +1714,13 @@ button.juno-chip {
1421
1714
  justify-content: center;
1422
1715
  gap: var(--juno-space-2);
1423
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;
1424
1724
  border-block-start: var(--juno-border-width-2) solid transparent;
1425
1725
  font-family: var(--juno-font-family-sans);
1426
1726
  font-size: var(--juno-font-size-10);
@@ -1453,6 +1753,323 @@ button.juno-chip {
1453
1753
  outline-offset: calc(-1 * var(--juno-space-2));
1454
1754
  }
1455
1755
 
1756
+ /* Fixed — same story as the pillbar: sticky only pins while the column
1757
+ overflows, so on a short page the dock lands mid-content. Pin to the
1758
+ viewport foot in a page-scroll shell where content height varies. Since
1759
+ fixed leaves the flow, reserve its height at the page foot (e.g.
1760
+ padding-block-end) so it doesn't cover the last row. In-flow
1761
+ .juno-app-shell (main is the scroller, dock at the body foot) needs none
1762
+ of this — prefer it. */
1763
+ .juno-dock--fixed {
1764
+ position: fixed;
1765
+ inset-inline: 0;
1766
+ inset-block-end: 0;
1767
+ }
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
+
1456
2073
  /* ════════════════════════════════════════════════════════════════════
1457
2074
  * Component — Status dot + label
1458
2075
  * Usage:
@@ -1501,6 +2118,12 @@ button.juno-chip {
1501
2118
  * Reuses .juno-modal for the surface + scrim; this file re-pins + re-slides.
1502
2119
  * Selectors are compound (.juno-modal.juno-drawer) so they override the
1503
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.
1504
2127
  * ════════════════════════════════════════════════════════════════════ */
1505
2128
 
1506
2129
  .juno-modal.juno-drawer {
@@ -1546,15 +2169,24 @@ button.juno-chip {
1546
2169
  .juno-modal.juno-drawer--bottom {
1547
2170
  inline-size: 100%;
1548
2171
  max-inline-size: 100%;
1549
- 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);
1550
2179
  margin-block: auto 0;
1551
2180
  margin-inline: 0;
1552
2181
  border: none;
1553
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);
1554
2185
  transform: translateY(100%);
1555
2186
 
1556
- /* keep content above the home indicator */
1557
- 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;
1558
2190
  }
1559
2191
 
1560
2192
  .juno-modal.juno-drawer--bottom[open] { transform: translateY(0); }
@@ -1563,6 +2195,28 @@ button.juno-chip {
1563
2195
  .juno-modal.juno-drawer--bottom[open] { transform: translateY(100%); }
1564
2196
  }
1565
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
+
1566
2220
  /* ── narrow viewports ────────────────────────────────────────────────────
1567
2221
  Side drawers cap below the full width so a sliver of scrim survives —
1568
2222
  the visual cue (and tap target) that "outside" closes the panel. */
@@ -1608,6 +2262,51 @@ button.juno-chip {
1608
2262
 
1609
2263
  .juno-field__error { font-size: var(--juno-font-size-11); color: var(--juno-warning); }
1610
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
+
1611
2310
  /* ════════════════════════════════════════════════════════════════════
1612
2311
  * Component — Gauge (determinate metric ring)
1613
2312
  * A fixed-value ring for readouts (CPU / RAM / DISK …) — the metric
@@ -1687,6 +2386,74 @@ button.juno-chip {
1687
2386
  }
1688
2387
  .juno-gauge--lg .juno-gauge__value { font-size: var(--juno-font-size-20); }
1689
2388
 
2389
+ /* ════════════════════════════════════════════════════════════════════
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.
2410
+ *
2411
+ * The app owns the state: add .juno-arc--indeterminate to spin, drop it
2412
+ * when loaded (gate the blink to first-load, not every refetch). For a
2413
+ * determinate ring, set --juno-progress on the .juno-arc instead.
2414
+ * Usage:
2415
+ * <a class="juno-rail__item" href="…" aria-current="page">
2416
+ * <span class="juno-icon-loader">
2417
+ * <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-squares-four" /></svg>
2418
+ * <span class="juno-arc juno-arc--indeterminate" role="status" aria-label="Loading"></span>
2419
+ * </span>
2420
+ * <span class="juno-rail__label">Library</span>
2421
+ * </a>
2422
+ *
2423
+ * Gotcha encoded here: the arc's rotate animation overrides `transform`, so
2424
+ * the ring is centred with a single-cell grid — never translate(-50%,-50%),
2425
+ * which the spin would clobber.
2426
+ * ════════════════════════════════════════════════════════════════════ */
2427
+
2428
+ .juno-icon-loader {
2429
+ --juno-role: var(--juno-active);
2430
+
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. */
2437
+ display: inline-grid;
2438
+ place-items: center;
2439
+ line-height: 0; /* drop the inline strut so the box hugs the ring */
2440
+ }
2441
+
2442
+ /* every child shares the one cell; the ringed content paints above the ring */
2443
+ .juno-icon-loader > * {
2444
+ grid-area: 1 / 1;
2445
+ z-index: 1;
2446
+ }
2447
+
2448
+ /* the ring: sized from the two custom props, transparent to clicks */
2449
+ .juno-icon-loader > .juno-arc {
2450
+ --juno-arc-size: var(--juno-icon-loader-ring, 1.9em);
2451
+ --juno-arc-width: var(--juno-icon-loader-ring-width, 0.14em);
2452
+
2453
+ z-index: 0;
2454
+ pointer-events: none;
2455
+ }
2456
+
1690
2457
  /* ════════════════════════════════════════════════════════════════════
1691
2458
  * Component — Icon · inline SVG glyph
1692
2459
  * A zero-JS icon primitive. Reference a symbol from the sprite; the glyph
@@ -1705,11 +2472,13 @@ button.juno-chip {
1705
2472
  * ════════════════════════════════════════════════════════════════════ */
1706
2473
 
1707
2474
  .juno-icon {
1708
- --juno-icon-size: 1.25em;
1709
-
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. */
1710
2479
  display: inline-block;
1711
- inline-size: var(--juno-icon-size);
1712
- block-size: var(--juno-icon-size);
2480
+ inline-size: var(--juno-icon-size, 1.25em);
2481
+ block-size: var(--juno-icon-size, 1.25em);
1713
2482
  flex-shrink: 0;
1714
2483
  vertical-align: -0.2em; /* optical baseline alignment next to text */
1715
2484
  fill: currentcolor;
@@ -1920,6 +2689,143 @@ button.juno-list__row:disabled {
1920
2689
  color: var(--juno-muted);
1921
2690
  }
1922
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
+
1923
2829
  /* ════════════════════════════════════════════════════════════════════
1924
2830
  * Component — Loaders (Arc · Beacon · Linear bar)
1925
2831
  *
@@ -1963,6 +2869,12 @@ button.juno-list__row:disabled {
1963
2869
  animation: juno-arc-rot 1.1s steps(12) infinite; /* mechanical, 12-step sweep */
1964
2870
  }
1965
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
+
1966
2878
  /* centred readout for determinate arcs — place a span inside */
1967
2879
  .juno-arc__label {
1968
2880
  position: absolute;
@@ -2147,6 +3059,10 @@ button.juno-list__row:disabled {
2147
3059
  align-items: center;
2148
3060
  gap: var(--juno-gap-control);
2149
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);
2150
3066
  padding: var(--juno-space-8) var(--juno-space-10);
2151
3067
  background: transparent;
2152
3068
  border: none;
@@ -2242,6 +3158,11 @@ button.juno-list__row:disabled {
2242
3158
  }
2243
3159
 
2244
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;
2245
3166
  opacity: 1;
2246
3167
  transform: translateY(0) scale(1);
2247
3168
  }
@@ -2257,6 +3178,7 @@ button.juno-list__row:disabled {
2257
3178
  .juno-modal::backdrop {
2258
3179
  background: rgb(0 0 0);
2259
3180
  opacity: var(--juno-opacity-scrim);
3181
+ -webkit-backdrop-filter: blur(1.5px);
2260
3182
  backdrop-filter: blur(1.5px);
2261
3183
  }
2262
3184
 
@@ -2297,7 +3219,19 @@ button.juno-list__row:disabled {
2297
3219
 
2298
3220
  .juno-modal__close:hover { background: var(--juno-s2); color: var(--juno-data); }
2299
3221
 
2300
- .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
+ }
2301
3235
 
2302
3236
  .juno-modal__title {
2303
3237
  font-family: var(--juno-font-family-sans);
@@ -2348,9 +3282,12 @@ button.juno-list__row:disabled {
2348
3282
  .juno-modal:not(.juno-drawer)[open] { transform: translateY(100%); }
2349
3283
  }
2350
3284
 
2351
- /* 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. */
2352
3289
  .juno-modal:not(.juno-drawer) .juno-modal__body {
2353
- 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));
2354
3291
  }
2355
3292
 
2356
3293
  /* full-width actions read better on a sheet */
@@ -2375,7 +3312,7 @@ button.juno-list__row:disabled {
2375
3312
  * </a>
2376
3313
  * <h1 class="juno-navbar__title">Album</h1>
2377
3314
  * <div class="juno-navbar__actions">
2378
- * <button class="juno-btn juno-btn--sm juno-btn--ghost">EDIT</button>
3315
+ * <button class="juno-btn juno-btn--ghost">EDIT</button>
2379
3316
  * </div>
2380
3317
  * </header>
2381
3318
  * ════════════════════════════════════════════════════════════════════ */
@@ -2456,6 +3393,10 @@ button.juno-list__row:disabled {
2456
3393
  gap: var(--juno-space-4);
2457
3394
  }
2458
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
+
2459
3400
  .juno-navbar__back:focus-visible {
2460
3401
  outline: var(--juno-border-width-2) solid var(--juno-active);
2461
3402
  outline-offset: calc(-1 * var(--juno-space-2));
@@ -2555,23 +3496,38 @@ button.juno-list__row:disabled {
2555
3496
  * <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-arrows-clockwise" /></svg>
2556
3497
  * </button>
2557
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.
2558
3503
  * ════════════════════════════════════════════════════════════════════ */
2559
3504
 
2560
3505
  .juno-pillbar {
2561
3506
  --juno-role: var(--juno-active);
2562
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
+
2563
3518
  position: sticky;
2564
- 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));
2565
3520
  z-index: var(--juno-z-raised);
2566
3521
  margin-inline: auto;
2567
- margin-block-end: var(--juno-space-16);
3522
+ margin-block-end: var(--juno-pillbar-edge);
2568
3523
  inline-size: fit-content;
2569
- max-inline-size: calc(100% - 2 * var(--juno-space-16));
3524
+ max-inline-size: calc(100% - 2 * var(--juno-pillbar-edge));
2570
3525
  display: flex;
2571
3526
  align-items: center;
2572
- gap: var(--juno-space-2);
2573
- padding: var(--juno-space-4);
3527
+ gap: var(--juno-pillbar-gap);
3528
+ padding: var(--juno-pillbar-pad);
2574
3529
  background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
3530
+ -webkit-backdrop-filter: blur(12px);
2575
3531
  backdrop-filter: blur(12px);
2576
3532
  border: var(--juno-border-width-1) solid var(--juno-border);
2577
3533
  border-radius: 999px;
@@ -2582,8 +3538,8 @@ button.juno-list__row:disabled {
2582
3538
  appearance: none;
2583
3539
  margin: 0;
2584
3540
  border: none;
2585
- min-inline-size: var(--juno-size-tap-comfortable);
2586
- min-block-size: var(--juno-size-tap-comfortable);
3541
+ min-inline-size: var(--juno-pillbar-item);
3542
+ min-block-size: var(--juno-pillbar-item);
2587
3543
  display: inline-flex;
2588
3544
  align-items: center;
2589
3545
  justify-content: center;
@@ -2630,6 +3586,54 @@ button.juno-list__row:disabled {
2630
3586
  cursor: not-allowed;
2631
3587
  }
2632
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
+
2633
3637
  .juno-pillbar__sep {
2634
3638
  align-self: center;
2635
3639
  inline-size: var(--juno-border-width-1);
@@ -2638,10 +3642,162 @@ button.juno-list__row:disabled {
2638
3642
  flex-shrink: 0;
2639
3643
  }
2640
3644
 
3645
+ /* Fixed — pin to the viewport instead of flowing at the column's end.
3646
+ Sticky only pins while the column overflows; on a short (non-scrolling)
3647
+ page the sticky pillbar lands mid-content. Use --fixed in a page-scroll
3648
+ shell where content height varies. Centered via inset-inline:0 + the base
3649
+ margin-inline:auto (NOT translateX, which the app-shell model avoids
3650
+ anyway). Leaves the viewport foot free — reserve space so it doesn't cover
3651
+ the last row (e.g. padding-block-end on the scroll region). Prefer the
3652
+ in-flow .juno-app-shell (main scrolls, pillbar sits at the body foot) —
3653
+ that needs no fixed at all. */
3654
+ .juno-pillbar--fixed {
3655
+ position: fixed;
3656
+ inset-inline: 0;
3657
+ z-index: var(--juno-z-anchored);
3658
+ }
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
+
2641
3796
  /* reduced-transparency preference → solid surface, no blur */
2642
3797
  @media (prefers-reduced-transparency: reduce) {
2643
3798
  .juno-pillbar {
2644
3799
  background: var(--juno-s1);
3800
+ -webkit-backdrop-filter: none;
2645
3801
  backdrop-filter: none;
2646
3802
  }
2647
3803
  }
@@ -2817,6 +3973,14 @@ button.juno-list__row:disabled {
2817
3973
  padding-inline: 0;
2818
3974
  }
2819
3975
 
3976
+ /* Responsive — the rail self-hides below md, so you pair it with a
3977
+ .juno-dock (or .juno-pillbar) carrying .juno-hide-from-md and get the
3978
+ rail↔dock swap without hanging a juno-hide-below-md on the rail yourself.
3979
+ Reciprocal of the dock's md pairing; see dock.css / docs/layout.md#app-shell. */
3980
+ @media (width <= 767.98px) {
3981
+ .juno-rail--responsive { display: none !important; }
3982
+ }
3983
+
2820
3984
  /* ════════════════════════════════════════════════════════════════════
2821
3985
  * Component — Data readout tile
2822
3986
  * Usage:
@@ -2863,6 +4027,55 @@ button.juno-list__row:disabled {
2863
4027
  margin-top: var(--juno-space-4);
2864
4028
  }
2865
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
+
2866
4079
  /* ════════════════════════════════════════════════════════════════════
2867
4080
  * Component — Segmented control
2868
4081
  * Exclusive-choice pill row (filters, mode switchers, sort pickers).
@@ -3006,14 +4219,20 @@ button.juno-seg__opt:disabled {
3006
4219
  * Component — Skeleton · loading placeholder
3007
4220
  * A shimmering block that stands in for content not yet loaded. Same
3008
4221
  * shimmer language as the table's row skeleton, generalized: text lines,
3009
- * blocks, and circles (avatars). Zero JS size it with width / height or
3010
- * --juno-skeleton-h. Honors prefers-reduced-motion (shimmer → static).
3011
- * 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.
3012
4230
  * Usage:
3013
4231
  * <div aria-busy="true" aria-live="polite">
3014
4232
  * <span class="juno-skeleton juno-skeleton--circle" style="--juno-skeleton-h: 48px;"></span>
3015
4233
  * <span class="juno-skeleton juno-skeleton--text" style="inline-size: 60%;"></span>
3016
4234
  * <span class="juno-skeleton juno-skeleton--text"></span>
4235
+ * <span class="juno-skeleton juno-skeleton--tile"></span>
3017
4236
  * </div>
3018
4237
  * ════════════════════════════════════════════════════════════════════ */
3019
4238
 
@@ -3021,11 +4240,27 @@ button.juno-seg__opt:disabled {
3021
4240
  --juno-skeleton-h: var(--juno-space-16);
3022
4241
 
3023
4242
  display: block;
4243
+ position: relative;
4244
+ overflow: hidden;
3024
4245
  inline-size: 100%;
3025
4246
  block-size: var(--juno-skeleton-h);
3026
4247
  border-radius: var(--juno-radius-3);
3027
- background: linear-gradient(90deg, var(--juno-s2) 0%, var(--juno-s3) 50%, var(--juno-s2) 100%);
3028
- 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
+ );
3029
4264
  animation: juno-skeleton-shimmer 1.2s ease-in-out infinite;
3030
4265
  }
3031
4266
 
@@ -3049,19 +4284,25 @@ button.juno-seg__opt:disabled {
3049
4284
  border-radius: var(--juno-radius-4);
3050
4285
  }
3051
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
+
3052
4299
  @keyframes juno-skeleton-shimmer {
3053
4300
  from {
3054
- background-position: 200% 0;
4301
+ transform: translateX(-100%);
3055
4302
  }
3056
4303
 
3057
4304
  to {
3058
- background-position: -200% 0;
3059
- }
3060
- }
3061
-
3062
- @media (prefers-reduced-motion: reduce) {
3063
- .juno-skeleton {
3064
- animation: none;
4305
+ transform: translateX(100%);
3065
4306
  }
3066
4307
  }
3067
4308
 
@@ -3939,6 +5180,11 @@ button.juno-seg__opt:disabled {
3939
5180
  scrollbar-width: none;
3940
5181
  }
3941
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
+
3942
5188
  .juno-tabs__tab {
3943
5189
  appearance: none;
3944
5190
  margin: 0;
@@ -3986,10 +5232,22 @@ button.juno-seg__opt:disabled {
3986
5232
  * <figure class="juno-thumb" style="aspect-ratio: 1">
3987
5233
  * <img src="…" alt="Sunset clip" onerror="this.remove()" />
3988
5234
  * </figure>
3989
- * <!-- known-missing: ship no <img>; label the figure instead -->
5235
+ * \3c !-- known-missing: ship no <img>; label the figure instead -->
3990
5236
  * <figure class="juno-thumb juno-thumb--video" role="img" aria-label="Preview unavailable"></figure>
3991
5237
  * The onerror one-liner is the whole JS contract (optional, stateless):
3992
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>
3993
5251
  * ════════════════════════════════════════════════════════════════════ */
3994
5252
 
3995
5253
  .juno-thumb {
@@ -4000,12 +5258,30 @@ button.juno-seg__opt:disabled {
4000
5258
  justify-content: center;
4001
5259
  gap: var(--juno-space-4);
4002
5260
  margin: 0;
5261
+ aspect-ratio: var(--juno-thumb-ratio, 1);
4003
5262
  overflow: hidden;
4004
5263
  background: var(--juno-s2);
4005
5264
  border: var(--juno-border-width-1) solid var(--juno-border);
4006
5265
  border-radius: var(--juno-radius-3);
4007
5266
  }
4008
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
+
4009
5285
  /* placeholder glyph (Phosphor "images", masked so it takes any color) */
4010
5286
  .juno-thumb::before {
4011
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");
@@ -4045,6 +5321,38 @@ button.juno-seg__opt:disabled {
4045
5321
  object-fit: cover;
4046
5322
  }
4047
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
+
4048
5356
  /* ════════════════════════════════════════════════════════════════════
4049
5357
  * Component — Toast / snackbar
4050
5358
  * A transient, floating notification. Same role-rail language as the
@@ -4138,7 +5446,11 @@ button.juno-seg__opt:disabled {
4138
5446
  @media (width <= 639.98px) {
4139
5447
  .juno-toast-stack {
4140
5448
  inset-inline: var(--juno-space-12);
4141
- 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));
4142
5454
  inline-size: auto;
4143
5455
  }
4144
5456
 
@@ -4445,8 +5757,11 @@ button.juno-seg__opt:disabled {
4445
5757
  font-variant-numeric: tabular-nums;
4446
5758
  }
4447
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. */
4448
5763
  .juno-label {
4449
- font-size: var(--juno-font-size-13);
5764
+ font-size: var(--juno-label-size, var(--juno-font-size-13));
4450
5765
  color: var(--juno-label);
4451
5766
  }
4452
5767