@junoput01/junoui 0.7.0 → 0.8.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.
package/dist/css/juno.css CHANGED
@@ -21,6 +21,16 @@
21
21
  --juno-shadow-1: 0 1px 2px rgb(0 0 0 / 0.30);
22
22
  --juno-shadow-2: 0 4px 14px rgb(0 0 0 / 0.35);
23
23
  --juno-shadow-3: 0 12px 32px rgb(0 0 0 / 0.50);
24
+ --juno-ink-canvas-ink: #FFFFFF;
25
+ --juno-ink-canvas-halo: #000000;
26
+ --juno-ink-canvas-halo-width: 2px;
27
+ --juno-ink-canvas-halo-width-lg: 3px;
28
+ --juno-ink-canvas-scrim: 0.28;
29
+ --juno-ink-vivid-nominal: oklch(76% 0.28 148);
30
+ --juno-ink-vivid-active: oklch(76% 0.24 205);
31
+ --juno-ink-vivid-target: oklch(70% 0.30 328);
32
+ --juno-ink-vivid-caution: oklch(82% 0.20 82);
33
+ --juno-ink-vivid-warning: oklch(68% 0.28 25);
24
34
  --juno-motion-duration-instant: 80ms;
25
35
  --juno-motion-duration-quick: 140ms;
26
36
  --juno-motion-duration-base: 200ms;
@@ -540,47 +550,18 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
540
550
  `.juno-input` font-size later in the bundle. It did, silently, until
541
551
  20260815-006's coarse-pointer project measured it. */
542
552
 
543
- /* Kill the UA tap-highlight square on the interactive surfaces so it never
544
- flashes past a rounded control on tap. Consumers were adding this by hand
545
- per component; make it a first-class touch default. See 20260802-020. */
546
- :where(
547
- .juno-btn,
548
- .juno-dock__item,
549
- .juno-pillbar__item,
550
- .juno-tabs__tab,
551
- .juno-list__row,
552
- .juno-menu__item
553
- ) {
554
- -webkit-tap-highlight-color: transparent;
555
- }
553
+ /* The tap-highlight default that used to live here is GENERATED now, from
554
+ src/css/touch-surfaces.mjs, together with the touch-action default that
555
+ shares its member list. Two hand-maintained lists had drifted from the
556
+ classes and from each other; see that file. */
556
557
  }
557
558
 
558
- /* Tappable primitives opt out of double-tap-to-zoom. A browser that still
559
- recognises that gesture has to WAIT after the first tap to see whether a
560
- second one is coming, which reads as a late, mushy tap on exactly the
561
- surfaces a phone UI is built from. `manipulation` keeps panning and
562
- pinch-zoom (so the page stays zoomable never `none` here, that would be an
563
- a11y regression) and drops only the double-tap.
564
- NOT inside the pointer:coarse block above: a hybrid device (touch laptop,
565
- iPad with a trackpad) reports a fine primary pointer while still taking
566
- touch input, and the property is inert on a mouse anyway.
567
- Community convention — no primary Apple/WebKit source names it; see
568
- docs/ios-conformance.md. Named components only, so a consumer's own elements
569
- are untouched. See 20260803-038. */
570
- :where(
571
- .juno-btn,
572
- .juno-dock__item,
573
- .juno-pillbar__item,
574
- .juno-pillbar__overflow,
575
- .juno-tabs__tab,
576
- .juno-list__row,
577
- .juno-menu__item,
578
- .juno-seg__opt,
579
- .juno-chip,
580
- .juno-toggle-btn
581
- ) {
582
- touch-action: manipulation;
583
- }
559
+ /* The touch defaults that used to be two hand-maintained `:where()` lists here
560
+ are GENERATED from src/css/touch-surfaces.mjs and emitted straight after this
561
+ file. Both lists had drifted from the classes (`.juno-seg__option`,
562
+ `.juno-list__item`: neither exists, so `:where()` matched nothing and the
563
+ rule parsed anyway) and from each other. One declared set now feeds both.
564
+ See 20260826-024 and docs/conformance-kit.md. */
584
565
 
585
566
  /* Gesture-owned surfaces — for an element whose pointer events are fully
586
567
  driven by app JS (drag-pan, pinch-zoom, swipe classification: a state
@@ -720,6 +701,62 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
720
701
  .juno-tooltip__bubble[popover] { display: none; }
721
702
  }
722
703
 
704
+ /* GENERATED from src/css/touch-surfaces.mjs — do not edit here.
705
+ *
706
+ * Tappable primitives opt out of double-tap-to-zoom. A browser that still
707
+ * recognises that gesture has to WAIT after the first tap to see whether a
708
+ * second one is coming, which reads as a late, mushy tap on exactly the
709
+ * surfaces a phone UI is built from. `manipulation` keeps panning and
710
+ * pinch-zoom (so the page stays zoomable — never `none` here, that would be an
711
+ * a11y regression) and drops only the double-tap.
712
+ * Community convention — no primary Apple/WebKit source names it; see
713
+ * docs/ios-conformance.md. Named components only, so a consumer's own elements
714
+ * are untouched. See 20260803-038. */
715
+ :where(
716
+ .juno-btn,
717
+ .juno-chip,
718
+ .juno-dock__item,
719
+ .juno-list__row,
720
+ .juno-menu__item,
721
+ .juno-pillbar__item,
722
+ .juno-pillbar__overflow,
723
+ .juno-seg__opt,
724
+ .juno-tabs__tab,
725
+ .juno-toggle-btn,
726
+ .juno-tree__row,
727
+ .juno-gizmo__mark,
728
+ .juno-gizmo__center,
729
+ .juno-swatch--button,
730
+ .juno-palette__option
731
+ ) {
732
+ touch-action: manipulation;
733
+ }
734
+
735
+ /* And on touch, kill the UA tap-highlight square so it never flashes past a
736
+ * rounded control. Consumers were adding this by hand per component; it is a
737
+ * first-class touch default. See 20260802-020. */
738
+ @media (pointer: coarse) {
739
+ :where(
740
+ .juno-btn,
741
+ .juno-chip,
742
+ .juno-dock__item,
743
+ .juno-list__row,
744
+ .juno-menu__item,
745
+ .juno-pillbar__item,
746
+ .juno-pillbar__overflow,
747
+ .juno-seg__opt,
748
+ .juno-tabs__tab,
749
+ .juno-toggle-btn,
750
+ .juno-tree__row,
751
+ .juno-gizmo__mark,
752
+ .juno-gizmo__center,
753
+ .juno-swatch--button,
754
+ .juno-palette__option
755
+ ) {
756
+ -webkit-tap-highlight-color: transparent;
757
+ }
758
+ }
759
+
723
760
  /* ════════════════════════════════════════════════════════════════════
724
761
  * junoui — layout layer (intrinsic & responsive)
725
762
  *
@@ -1480,6 +1517,104 @@ details.juno-accordion__item:only-child {
1480
1517
  filter: none;
1481
1518
  }
1482
1519
 
1520
+ /* ════════════════════════════════════════════════════════════════════
1521
+ * Component — Canvas ink (marks drawn over arbitrary imagery)
1522
+ * For text and vector ink sitting on a photo, a map, a video frame or a
1523
+ * camera feed: anywhere the background is CONTENT rather than one of
1524
+ * junoui's surfaces.
1525
+ *
1526
+ * WHY IT IS NOT JUST A COLOUR. junoui's whole contrast story assumes a
1527
+ * controlled surface, s0 through s3 — every role colour's ratio is
1528
+ * computed against a known background. Over imagery there is no known
1529
+ * background. In one orthophoto a black shadow and a snowfield are
1530
+ * adjacent pixels, so no single ink colour is legible and no contrast
1531
+ * ratio can be asserted about one. The answer is a PAIR that spans the
1532
+ * luminance range: over a light backing the halo carries the contrast,
1533
+ * over a dark one the ink does. Neither half works alone, which is why
1534
+ * they are applied together by one class.
1535
+ *
1536
+ * NOT THEMED, on purpose. A satellite image does not get lighter because
1537
+ * the user chose light mode. Theming this pair would make it track the
1538
+ * app's surface, which is precisely the background it is NOT over.
1539
+ * Usage:
1540
+ * <figcaption class="juno-canvas-ink">Sector 7 · 1.2 km</figcaption>
1541
+ * <svg class="juno-canvas-ink"><path class="juno-canvas-ink__stroke" …/></svg>
1542
+ * <div class="juno-canvas-scrim">…chrome floating over the content…</div>
1543
+ * ════════════════════════════════════════════════════════════════════ */
1544
+
1545
+ /* Text. Four offset shadows rather than one blur: a blurred shadow fades at
1546
+ the glyph's corners, which is exactly where a thin stroke needs the most
1547
+ help, and it costs the same. `paint-order` is for SVG text, where a real
1548
+ stroke is available and better. */
1549
+ .juno-canvas-ink {
1550
+ color: var(--juno-ink-canvas-ink);
1551
+ paint-order: stroke fill;
1552
+ stroke: var(--juno-ink-canvas-halo);
1553
+ stroke-width: var(--juno-ink-canvas-halo-width);
1554
+ text-shadow:
1555
+ var(--juno-ink-canvas-halo-width) 0 0 var(--juno-ink-canvas-halo),
1556
+ calc(-1 * var(--juno-ink-canvas-halo-width)) 0 0 var(--juno-ink-canvas-halo),
1557
+ 0 var(--juno-ink-canvas-halo-width) 0 var(--juno-ink-canvas-halo),
1558
+ 0 calc(-1 * var(--juno-ink-canvas-halo-width)) 0 var(--juno-ink-canvas-halo);
1559
+ }
1560
+
1561
+ /* Display sizes: a 2px halo reads as a hairline against a heavier stroke. */
1562
+ .juno-canvas-ink--lg {
1563
+ stroke-width: var(--juno-ink-canvas-halo-width-lg);
1564
+ text-shadow:
1565
+ var(--juno-ink-canvas-halo-width-lg) 0 0 var(--juno-ink-canvas-halo),
1566
+ calc(-1 * var(--juno-ink-canvas-halo-width-lg)) 0 0 var(--juno-ink-canvas-halo),
1567
+ 0 var(--juno-ink-canvas-halo-width-lg) 0 var(--juno-ink-canvas-halo),
1568
+ 0 calc(-1 * var(--juno-ink-canvas-halo-width-lg)) 0 var(--juno-ink-canvas-halo);
1569
+ }
1570
+
1571
+ /* Vector ink — measurement lines, selection outlines. Two passes: the halo
1572
+ is the same path drawn wider underneath. In SVG give the halo element
1573
+ this class and the mark element __stroke; in canvas, stroke twice. */
1574
+ .juno-canvas-ink__halo {
1575
+ fill: none;
1576
+ stroke: var(--juno-ink-canvas-halo);
1577
+ stroke-width: calc(var(--juno-ink-canvas-halo-width) * 2);
1578
+ stroke-linecap: round;
1579
+ stroke-linejoin: round;
1580
+ }
1581
+
1582
+ .juno-canvas-ink__stroke {
1583
+ fill: none;
1584
+ stroke: var(--juno-ink-canvas-ink);
1585
+ stroke-linecap: round;
1586
+ stroke-linejoin: round;
1587
+ }
1588
+
1589
+ /* Status over canvas. The themed role hues are chosen against s0–s3 and wash
1590
+ out over a saturated backdrop, so these are the same hues at raised chroma.
1591
+ Louder than the panel roles by design — they will look wrong on a panel. */
1592
+ .juno-canvas-ink--nominal { color: var(--juno-ink-vivid-nominal); }
1593
+ .juno-canvas-ink--active { color: var(--juno-ink-vivid-active); }
1594
+ .juno-canvas-ink--target { color: var(--juno-ink-vivid-target); }
1595
+ .juno-canvas-ink--caution { color: var(--juno-ink-vivid-caution); }
1596
+ .juno-canvas-ink--warning { color: var(--juno-ink-vivid-warning); }
1597
+
1598
+ /* Chrome floating over live content. Deliberately NOT --juno-opacity-scrim
1599
+ (0.62): that value suppresses a modal's background, and here the background
1600
+ is the thing the chrome is annotating — greying it out defeats the purpose. */
1601
+ .juno-canvas-scrim {
1602
+ background: color-mix(in srgb, var(--juno-s0) calc(var(--juno-ink-canvas-scrim) * 100%), transparent);
1603
+ -webkit-backdrop-filter: blur(8px);
1604
+ backdrop-filter: blur(8px);
1605
+ }
1606
+
1607
+ /* A translucent bar over moving content is the case where reduced
1608
+ transparency matters most — the motion behind the text is the problem, not
1609
+ the blur. Fall back to the opaque surface. */
1610
+ @media (prefers-reduced-transparency: reduce) {
1611
+ .juno-canvas-scrim {
1612
+ background: var(--juno-s0);
1613
+ -webkit-backdrop-filter: none;
1614
+ backdrop-filter: none;
1615
+ }
1616
+ }
1617
+
1483
1618
  /* ════════════════════════════════════════════════════════════════════
1484
1619
  * Component — Card / panel
1485
1620
  * Usage:
@@ -2611,6 +2746,245 @@ button.juno-chip {
2611
2746
  }
2612
2747
  .juno-gauge--lg .juno-gauge__value { font-size: var(--juno-font-size-20); }
2613
2748
 
2749
+ /* ════════════════════════════════════════════════════════════════════
2750
+ * Component — Viewport orientation gizmo (compass ring + pitch arc)
2751
+ * The orientation widget any 3D or map viewport ships: a ring showing a
2752
+ * heading with clickable snap targets, a secondary arc for a second
2753
+ * angle (pitch / tilt / elevation) inside a clamped range, and a centre
2754
+ * target that resets the view.
2755
+ *
2756
+ * A RING, NOT A CUBE, and that is a design decision rather than a style
2757
+ * one. An Autodesk-style ViewCube is the wrong shape for anything with a
2758
+ * privileged up-vector — a map, a terrain, a site plan — because there is
2759
+ * no meaningful front, right or bottom face to click. A ring degrades to
2760
+ * the up-vector case and generalises to the free-orbit one; a cube does
2761
+ * not go the other way.
2762
+ *
2763
+ * THE APP OWNS THE CAMERA. It writes two angles in as custom properties
2764
+ * and junoui rotates the marks; nothing here stores or changes state.
2765
+ * element.style.setProperty('--juno-gizmo-heading', `${yaw}deg`);
2766
+ * element.style.setProperty('--juno-gizmo-pitch', `${pitch}deg`);
2767
+ *
2768
+ * Usage — see docs/components/gizmo.md for the full a11y contract, which
2769
+ * is the half apps get wrong and the reason this is upstream:
2770
+ * <div class="juno-gizmo" role="group" aria-label="View orientation">
2771
+ * <p class="juno-gizmo__readout" aria-live="polite">Facing north, 0 degrees.</p>
2772
+ * <div class="juno-gizmo__ring">
2773
+ * <span class="juno-gizmo__needle" aria-hidden="true"></span>
2774
+ * <button class="juno-gizmo__mark" style="--juno-gizmo-at:0deg"
2775
+ * aria-label="Face north" aria-current="true">N</button>
2776
+ * …seven more…
2777
+ * <button class="juno-gizmo__center" aria-label="Reset view">⌖</button>
2778
+ * </div>
2779
+ * <div class="juno-gizmo__arc">
2780
+ * <span class="juno-gizmo__arc-hand" aria-hidden="true"></span>
2781
+ * </div>
2782
+ * </div>
2783
+ * ════════════════════════════════════════════════════════════════════ */
2784
+
2785
+ .juno-gizmo {
2786
+ --juno-role: var(--juno-active);
2787
+
2788
+ /* The angles the app writes. Defaults are a level view facing north, so a
2789
+ gizmo that is mounted before the camera reports is not a blank circle. */
2790
+ --juno-gizmo-heading: 0deg;
2791
+ --juno-gizmo-pitch: 0deg;
2792
+
2793
+ /* MINIMUM DIAMETER, DERIVED — and derived from the CHORD, which is the
2794
+ correction that matters. N marks sit evenly around the rim, so the
2795
+ straight-line distance between two adjacent centres is d * sin(pi / N).
2796
+ For their targets not to overlap THAT must be at least one tap target:
2797
+
2798
+ d >= tap / sin(pi / N)
2799
+
2800
+ ...and the marks sit INSIDE the rim, so their centres are on a circle of
2801
+ radius d/2 - tap/2, not d/2. Substituting that and solving:
2802
+
2803
+ d >= tap * (1 / sin(pi / N) + 1)
2804
+
2805
+ THIS DERIVATION WAS WRONG TWICE, and the browser guard caught both. First
2806
+ it sized off the ARC between centres (N * tap / pi) — an arc is longer
2807
+ than its chord, so at N=8 and a 44px target that gave 112.05px whose chord
2808
+ is 42.9px, a 1.1px overlap on every neighbouring pair. Corrected to the
2809
+ chord, it still measured 27.16px between centres, because the inset had
2810
+ been left out of the radius. Neither error is visible in the source; both
2811
+ are one measurement away.
2812
+
2813
+ The tap floor MOVES, 24px to 44px on a coarse pointer, which is precisely
2814
+ when the ring has to grow; a hard-coded diameter ships eight overlapping
2815
+ targets to a phone. The 96px is an aesthetic floor, not a derived one —
2816
+ a 63px ring is legible-but-cramped on desktop — and max() keeps whichever
2817
+ is larger. */
2818
+ --juno-gizmo-marks: 8;
2819
+ --juno-gizmo-size: max(
2820
+ 96px,
2821
+ calc(var(--juno-size-tap-min) * (1 / sin(180deg / var(--juno-gizmo-marks)) + 1))
2822
+ );
2823
+
2824
+ display: inline-flex;
2825
+ flex-direction: column;
2826
+ align-items: center;
2827
+ gap: var(--juno-space-8);
2828
+ font-family: var(--juno-font-family-sans);
2829
+ color: var(--juno-data);
2830
+ }
2831
+
2832
+ /* The spoken state. Visible by default — a bearing is useful to everyone —
2833
+ but it is the live region either way, because a rotating needle announces
2834
+ nothing. Consumers that want it invisible use .juno-sr-only rather than
2835
+ display:none, which would take it out of the accessibility tree too. */
2836
+ .juno-gizmo__readout {
2837
+ margin: 0;
2838
+ font-size: var(--juno-font-size-11);
2839
+ font-variant-numeric: tabular-nums;
2840
+ letter-spacing: var(--juno-font-tracking-label);
2841
+ color: var(--juno-label);
2842
+ }
2843
+
2844
+ .juno-gizmo__ring {
2845
+ position: relative;
2846
+ inline-size: var(--juno-gizmo-size);
2847
+ block-size: var(--juno-gizmo-size);
2848
+ border-radius: 50%;
2849
+ border: var(--juno-border-width-1) solid var(--juno-border);
2850
+ background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
2851
+ }
2852
+
2853
+ /* The needle points where the camera looks. Rotated by the app's angle, and
2854
+ the transition runs on the motion scale so prefers-reduced-motion collapses
2855
+ it without this file needing its own media query (base.css owns that). */
2856
+ .juno-gizmo__needle {
2857
+ position: absolute;
2858
+ inset: 0;
2859
+ display: grid;
2860
+ place-items: start center;
2861
+ pointer-events: none;
2862
+ rotate: var(--juno-gizmo-heading);
2863
+ transition: rotate calc(var(--juno-motion-duration-base) * var(--juno-motion-scale))
2864
+ var(--juno-motion-ease-standard);
2865
+ }
2866
+
2867
+ .juno-gizmo__needle::before {
2868
+ content: '';
2869
+ inline-size: var(--juno-border-width-2);
2870
+ block-size: 38%;
2871
+ margin-block-start: var(--juno-space-4);
2872
+ background: linear-gradient(var(--juno-role), transparent);
2873
+ border-radius: var(--juno-border-width-2);
2874
+ }
2875
+
2876
+ /* ── snap targets ──────────────────────────────────────────────────────
2877
+ REAL BUTTONS. Not a canvas hit test, not a div with a click handler: a
2878
+ button is focusable, activates on Enter and Space, is announced as a
2879
+ control, and works with a screen reader's forms mode. That is most of
2880
+ this component's value and the reason it is upstream.
2881
+
2882
+ Positioned by rotating the mark out to the rim and counter-rotating the
2883
+ glyph, so the letter stays upright at every angle. --juno-gizmo-at is the
2884
+ mark's own bearing, set per element. */
2885
+ .juno-gizmo__mark {
2886
+ position: absolute;
2887
+ inset-block-start: 50%;
2888
+ inset-inline-start: 50%;
2889
+ inline-size: var(--juno-size-tap-min);
2890
+ block-size: var(--juno-size-tap-min);
2891
+ margin: calc(var(--juno-size-tap-min) / -2);
2892
+ display: grid;
2893
+ place-items: center;
2894
+ padding: 0;
2895
+ border: none;
2896
+ border-radius: 50%;
2897
+ background: transparent;
2898
+ color: var(--juno-label);
2899
+ font: inherit;
2900
+ font-size: var(--juno-font-size-11);
2901
+ font-weight: var(--juno-font-weight-bold);
2902
+ cursor: pointer;
2903
+ transform: rotate(var(--juno-gizmo-at, 0deg))
2904
+ translate(0, calc(var(--juno-gizmo-size) / -2 + var(--juno-size-tap-min) / 2))
2905
+ rotate(calc(-1 * var(--juno-gizmo-at, 0deg)));
2906
+ }
2907
+
2908
+ .juno-gizmo__mark:hover {
2909
+ color: var(--juno-data);
2910
+ background: var(--juno-s2);
2911
+ }
2912
+
2913
+ /* The mark the camera is currently nearest. aria-current, not a class: the
2914
+ app already has to say it for the screen reader, and a class would be a
2915
+ second copy of the same fact. */
2916
+ .juno-gizmo__mark[aria-current='true'] {
2917
+ color: var(--juno-role);
2918
+ background: var(--juno-s3);
2919
+ }
2920
+
2921
+ .juno-gizmo__mark:focus-visible,
2922
+ .juno-gizmo__center:focus-visible {
2923
+ outline: var(--juno-border-width-2) solid var(--juno-active);
2924
+ outline-offset: var(--juno-space-2);
2925
+ }
2926
+
2927
+ /* Centre target — reset to the default view. */
2928
+ .juno-gizmo__center {
2929
+ position: absolute;
2930
+ inset-block-start: 50%;
2931
+ inset-inline-start: 50%;
2932
+ inline-size: var(--juno-size-tap-min);
2933
+ block-size: var(--juno-size-tap-min);
2934
+ margin: calc(var(--juno-size-tap-min) / -2);
2935
+ display: grid;
2936
+ place-items: center;
2937
+ padding: 0;
2938
+ border: var(--juno-border-width-1) solid var(--juno-border);
2939
+ border-radius: 50%;
2940
+ background: var(--juno-s1);
2941
+ color: var(--juno-label);
2942
+ font: inherit;
2943
+ cursor: pointer;
2944
+ }
2945
+
2946
+ .juno-gizmo__center:hover {
2947
+ color: var(--juno-role);
2948
+ border-color: var(--juno-role);
2949
+ }
2950
+
2951
+ /* ── pitch arc ─────────────────────────────────────────────────────────
2952
+ The second angle, inside a CLAMPED range — a camera that can tilt 0..85°
2953
+ should not show a hand that can point anywhere, or the widget lies about
2954
+ what the app will accept. The clamp lives here so every consumer gets it
2955
+ without re-deriving it from its own camera limits. */
2956
+ .juno-gizmo__arc {
2957
+ --juno-gizmo-pitch-min: 0deg;
2958
+ --juno-gizmo-pitch-max: 85deg;
2959
+
2960
+ position: relative;
2961
+ inline-size: var(--juno-gizmo-size);
2962
+ block-size: calc(var(--juno-gizmo-size) / 2);
2963
+ overflow: hidden;
2964
+ border-block-end: var(--juno-border-width-1) solid var(--juno-border);
2965
+ }
2966
+
2967
+ .juno-gizmo__arc-hand {
2968
+ position: absolute;
2969
+ inset-block-end: 0;
2970
+ inset-inline-start: 50%;
2971
+ inline-size: var(--juno-border-width-2);
2972
+ block-size: 80%;
2973
+ background: var(--juno-role);
2974
+ transform-origin: bottom center;
2975
+ rotate: clamp(var(--juno-gizmo-pitch-min), var(--juno-gizmo-pitch), var(--juno-gizmo-pitch-max));
2976
+ transition: rotate calc(var(--juno-motion-duration-base) * var(--juno-motion-scale))
2977
+ var(--juno-motion-ease-standard);
2978
+ }
2979
+
2980
+ /* ── touch ─────────────────────────────────────────────────────────────
2981
+ Nothing to grow here, and that is the point: the marks are sized in
2982
+ --juno-size-tap-min, which base.css promotes to the comfortable target on a
2983
+ coarse pointer, and --juno-gizmo-size is derived from it — so the whole ring
2984
+ grows and the marks stay non-overlapping by construction. A gizmo that keeps
2985
+ its desktop diameter on a phone has eight overlapping targets, which is the
2986
+ defect this derivation exists to make impossible. */
2987
+
2614
2988
  /* ════════════════════════════════════════════════════════════════════
2615
2989
  * Component — Icon loader (the ring-a-control primitive)
2616
2990
  * A control ringed by the spinning arc while its section loads — the
@@ -4823,6 +5197,194 @@ button.juno-seg__opt:disabled {
4823
5197
  block-size: 100%;
4824
5198
  }
4825
5199
 
5200
+ /* ════════════════════════════════════════════════════════════════════
5201
+ * Component — Colour swatch + palette
5202
+ * Showing a user-chosen colour, and letting someone pick one. Diagrams,
5203
+ * calendars, tag and label systems, chart series colours, annotation
5204
+ * tools, theming UIs, kanban boards.
5205
+ *
5206
+ * THE HARD PART IS NOT THE SQUARE. A swatch shows an ARBITRARY colour, so
5207
+ * every piece of chrome on it — its border, its focus ring, its checked
5208
+ * indicator — has to stay visible against a colour junoui has never seen.
5209
+ * A single hairline fails at one end of the range: a dark border vanishes
5210
+ * on near-black, a light one vanishes on near-white, and the swatch that
5211
+ * loses its border is the one that has merged with the panel behind it.
5212
+ *
5213
+ * The answer is a PAIR of hairlines drawn as an inset and an outset ring.
5214
+ * They do NOT do the same job, which is the part worth reading twice: the
5215
+ * inset one composites over the SWATCH and edges it against its own fill,
5216
+ * the outset one composites over the PANEL and separates it from the
5217
+ * surface. With the swatch's own contrast against that panel, the boundary
5218
+ * has three ways to be visible and needs only one of them.
5219
+ *
5220
+ * Guarded by sweeping the swatch colour against both panels rather than by
5221
+ * asserting a border value (test/swatch.test.mjs).
5222
+ *
5223
+ * COLOUR IS NEVER THE ONLY SIGNAL. junoui's own standing rule, and a bare
5224
+ * swatch is exactly what violates it: every swatch carries an accessible
5225
+ * NAME, and the checked state carries a GLYPH, not just a hue.
5226
+ *
5227
+ * The app owns the colour list and which one is chosen.
5228
+ * Usage:
5229
+ * <button class="juno-swatch juno-swatch--button" style="--juno-swatch-color:#C41E3A"
5230
+ * aria-label="Crimson"></button>
5231
+ *
5232
+ * <div class="juno-popover" popover id="palette">
5233
+ * <div class="juno-palette" role="listbox" aria-label="Annotation colour">
5234
+ * <button class="juno-palette__option" role="option" aria-selected="true"
5235
+ * style="--juno-swatch-color:#C41E3A" aria-label="Crimson">
5236
+ * <svg class="juno-icon juno-palette__check" aria-hidden="true">
5237
+ * <use href="…#juno-i-check" /></svg>
5238
+ * </button>
5239
+ * </div>
5240
+ * </div>
5241
+ * ════════════════════════════════════════════════════════════════════ */
5242
+
5243
+ .juno-swatch {
5244
+ /* The colour the app is showing. Named rather than an inline `background`
5245
+ so the rings below can sit on top of it without the caller reassembling
5246
+ the whole box-shadow stack. */
5247
+ --juno-swatch-color: var(--juno-muted);
5248
+
5249
+ /* Sized off the control scale, so a swatch in a form row matches the
5250
+ controls beside it and grows with them on a coarse pointer. */
5251
+ --juno-swatch-size: var(--juno-size-tap-min);
5252
+
5253
+ display: inline-block;
5254
+ inline-size: var(--juno-swatch-size);
5255
+ block-size: var(--juno-swatch-size);
5256
+ flex-shrink: 0;
5257
+ border: none;
5258
+ border-radius: var(--juno-radius-3);
5259
+ background: var(--juno-swatch-color);
5260
+
5261
+ /* THE TWO-TONE RING, and the alphas are measured rather than chosen.
5262
+ The inset ring composites over the SWATCH and gives it an edge against
5263
+ its own fill; the outset ring composites over the PANEL — an outset
5264
+ box-shadow is outside the element — and separates the swatch from the
5265
+ surface behind it. Between them, plus the swatch's own contrast with the
5266
+ panel, the boundary is discernible whatever colour the app supplies.
5267
+
5268
+ At 0.45 / 0.35 that property FAILS: on a light panel a mid-grey swatch
5269
+ (around #919191) leaves the best of the three at 2.57:1, under the 3:1
5270
+ non-text floor. 0.65 on both takes the worst case to 3.39:1. The sweep in
5271
+ test/swatch.test.mjs is what measured it — the first version of that test
5272
+ asked the wrong question (both rings against the swatch) and reported 145
5273
+ failures; the right question is whether the BOUNDARY is visible, and it
5274
+ has three ways to be.
5275
+
5276
+ Deliberately not `border`, which would eat into the colour area and change
5277
+ the swatch's size with its style. */
5278
+ box-shadow:
5279
+ inset 0 0 0 var(--juno-border-width-1) rgb(0 0 0 / 0.65),
5280
+ 0 0 0 var(--juno-border-width-1) rgb(255 255 255 / 0.65);
5281
+ }
5282
+
5283
+ .juno-swatch--circle {
5284
+ border-radius: 50%;
5285
+ }
5286
+
5287
+ .juno-swatch--sm {
5288
+ --juno-swatch-size: var(--juno-space-16);
5289
+ }
5290
+
5291
+ .juno-swatch--lg {
5292
+ --juno-swatch-size: var(--juno-size-tap-comfortable);
5293
+ }
5294
+
5295
+ /* A swatch used as a trigger — the thing you click to open the palette. */
5296
+ .juno-swatch--button {
5297
+ padding: 0;
5298
+ cursor: pointer;
5299
+ }
5300
+
5301
+ /* FOCUS RINGS ON AN ARBITRARY HUE. The ring is drawn OUTSIDE the swatch,
5302
+ with an offset, so its contrast is against the panel — a known surface —
5303
+ rather than against a colour junoui cannot predict. A ring drawn on the
5304
+ swatch itself has the same unsolvable problem as the border, and "use a
5305
+ thicker ring" does not fix a hue collision. */
5306
+ .juno-swatch--button:focus-visible,
5307
+ .juno-palette__option:focus-visible {
5308
+ outline: var(--juno-border-width-2) solid var(--juno-active);
5309
+ outline-offset: var(--juno-space-4);
5310
+ }
5311
+
5312
+ /* ── palette ───────────────────────────────────────────────────────────
5313
+ A grid of swatches inside a .juno-popover. junoui ships the grid and the
5314
+ states; the app owns the list and which one is chosen. */
5315
+ .juno-palette {
5316
+ display: grid;
5317
+ grid-template-columns: repeat(var(--juno-palette-columns, 6), auto);
5318
+ gap: var(--juno-space-8);
5319
+ padding: var(--juno-space-4);
5320
+ }
5321
+
5322
+ .juno-palette__option {
5323
+ --juno-swatch-color: var(--juno-muted);
5324
+ --juno-swatch-size: var(--juno-size-tap-min);
5325
+
5326
+ position: relative;
5327
+ inline-size: var(--juno-swatch-size);
5328
+ block-size: var(--juno-swatch-size);
5329
+ display: grid;
5330
+ place-items: center;
5331
+ padding: 0;
5332
+ border: none;
5333
+ border-radius: var(--juno-radius-3);
5334
+ background: var(--juno-swatch-color);
5335
+ cursor: pointer;
5336
+ box-shadow:
5337
+ inset 0 0 0 var(--juno-border-width-1) rgb(0 0 0 / 0.65),
5338
+ 0 0 0 var(--juno-border-width-1) rgb(255 255 255 / 0.65);
5339
+ }
5340
+
5341
+ /* THE CHECKED STATE IS A GLYPH, NOT A HUE. Colour is never the only signal,
5342
+ and "the chosen one is the one that looks slightly different" is exactly
5343
+ the failure that rule exists to prevent — it is invisible to anyone who
5344
+ cannot separate the two hues, and to anyone looking at a screenshot.
5345
+
5346
+ The check itself sits on the arbitrary colour, so it gets the same
5347
+ treatment as the swatch's own ring: a light glyph with a dark halo, which
5348
+ is the canvas-ink pair at glyph scale. */
5349
+ .juno-palette__check {
5350
+ display: none;
5351
+ color: #fff;
5352
+ filter: drop-shadow(0 0 var(--juno-border-width-1) rgb(0 0 0 / 0.9))
5353
+ drop-shadow(0 0 var(--juno-border-width-2) rgb(0 0 0 / 0.7));
5354
+ }
5355
+
5356
+ .juno-palette__option[aria-selected='true'] .juno-palette__check,
5357
+ .juno-palette__option[aria-checked='true'] .juno-palette__check {
5358
+ display: block;
5359
+ }
5360
+
5361
+ /* ...and a second, non-glyph cue for the same state, because a check inside a
5362
+ 28px square is small: the selected option also grows a ring in the active
5363
+ role, drawn OUTSIDE the swatch where its contrast is against the panel. */
5364
+ .juno-palette__option[aria-selected='true'],
5365
+ .juno-palette__option[aria-checked='true'] {
5366
+ box-shadow:
5367
+ inset 0 0 0 var(--juno-border-width-1) rgb(0 0 0 / 0.65),
5368
+ 0 0 0 var(--juno-border-width-1) rgb(255 255 255 / 0.65),
5369
+ 0 0 0 calc(var(--juno-border-width-1) + var(--juno-border-width-2)) var(--juno-active);
5370
+ }
5371
+
5372
+ /* A swatch showing "no colour" — transparent, unset, inherit. Without this a
5373
+ consumer paints it in a mid grey and the user cannot tell "grey" from
5374
+ "none", which is a different thing to know. */
5375
+ .juno-swatch--none,
5376
+ .juno-palette__option--none {
5377
+ background:
5378
+ linear-gradient(
5379
+ to bottom right,
5380
+ transparent calc(50% - var(--juno-border-width-1)),
5381
+ var(--juno-warning) calc(50% - var(--juno-border-width-1)),
5382
+ var(--juno-warning) calc(50% + var(--juno-border-width-1)),
5383
+ transparent calc(50% + var(--juno-border-width-1))
5384
+ )
5385
+ var(--juno-s1);
5386
+ }
5387
+
4826
5388
  /* ════════════════════════════════════════════════════════════════════
4827
5389
  * Component — Switch (toggle) · labeled rocker
4828
5390
  * A wide squared slide-switch with the state printed inside the track
@@ -5965,6 +6527,266 @@ button.juno-seg__opt:disabled {
5965
6527
  inset-inline-start: calc(100% - var(--juno-space-4));
5966
6528
  }
5967
6529
 
6530
+ /* ════════════════════════════════════════════════════════════════════
6531
+ * Component — Tree / outliner (nested rows, disclosure, selection)
6532
+ * Layer stacks, file browsers, settings trees, org charts, comment
6533
+ * threads, nested navigation. Where .juno-list is flat and
6534
+ * .juno-accordion is single-level, this nests to arbitrary depth and
6535
+ * carries a selection.
6536
+ *
6537
+ * ZERO JS FOR THE VISUALS. Indentation comes from the nested `role=group`
6538
+ * lists the ARIA pattern already requires, so depth costs no custom
6539
+ * property and no inline style; collapse is `[aria-expanded='false']`
6540
+ * hiding the child group. The app owns the attribute, exactly like
6541
+ * __item's aria-pressed elsewhere in junoui.
6542
+ *
6543
+ * KEYBOARD IS NOT OPTIONAL and is not CSS. A tree without arrow-key
6544
+ * traversal and a roving tabindex is a list of buttons wearing tree
6545
+ * roles. junoui ships the contract in docs/components/tree.md and a
6546
+ * stateless enhancer at `junoui/tree` — use one or the other, but a tree
6547
+ * that has neither is not accessible and no stylesheet can fix it.
6548
+ *
6549
+ * Usage (the markup the roles require — see the doc for the full ARIA
6550
+ * contract, which is the half apps get wrong):
6551
+ * <ul class="juno-tree" role="tree" aria-label="Scene">
6552
+ * <li class="juno-tree__item" role="treeitem" aria-level="1"
6553
+ * aria-expanded="true" aria-selected="false">
6554
+ * <div class="juno-tree__row">
6555
+ * <button class="juno-tree__caret" tabindex="-1" aria-hidden="true"></button>
6556
+ * <svg class="juno-icon juno-tree__icon" aria-hidden="true">…</svg>
6557
+ * <span class="juno-tree__label">Terrain</span>
6558
+ * <span class="juno-tree__count">12</span>
6559
+ * <span class="juno-tree__trail">…</span>
6560
+ * <button class="juno-tree__handle" aria-label="Reorder Terrain"></button>
6561
+ * </div>
6562
+ * <ul class="juno-tree__group" role="group">…</ul>
6563
+ * </li>
6564
+ * </ul>
6565
+ * ════════════════════════════════════════════════════════════════════ */
6566
+
6567
+ .juno-tree {
6568
+ --juno-role: var(--juno-active);
6569
+
6570
+ /* One indent step. Applied per nested group, so depth is structural and
6571
+ arbitrary rather than a level number someone has to keep in sync. */
6572
+ --juno-tree-indent: var(--juno-space-16);
6573
+
6574
+ margin: 0;
6575
+ padding: 0;
6576
+ list-style: none;
6577
+ font-family: var(--juno-font-family-sans);
6578
+ font-size: var(--juno-font-size-13);
6579
+ color: var(--juno-data);
6580
+ }
6581
+
6582
+ .juno-tree__group {
6583
+ margin: 0;
6584
+ padding: 0;
6585
+ padding-inline-start: var(--juno-tree-indent);
6586
+ list-style: none;
6587
+ }
6588
+
6589
+ /* Collapsed: the child group goes. `display: none` and not a height
6590
+ animation — a tree collapses an unknown number of rows at an unknown
6591
+ depth, so there is no end value to transition to, and interpolate-size
6592
+ is not available everywhere junoui targets. */
6593
+ .juno-tree__item[aria-expanded='false'] > .juno-tree__group {
6594
+ display: none;
6595
+ }
6596
+
6597
+ .juno-tree__row {
6598
+ display: flex;
6599
+ align-items: center;
6600
+ gap: var(--juno-space-8);
6601
+ inline-size: 100%;
6602
+ min-block-size: var(--juno-size-tap-min);
6603
+ padding-inline: var(--juno-space-8);
6604
+ padding-block: var(--juno-space-4);
6605
+ border: none;
6606
+ border-radius: var(--juno-radius-3);
6607
+ background: transparent;
6608
+ color: inherit;
6609
+ font: inherit;
6610
+ text-align: start;
6611
+ text-decoration: none;
6612
+ cursor: pointer;
6613
+ }
6614
+
6615
+ .juno-tree__row:hover {
6616
+ background: var(--juno-s2);
6617
+ }
6618
+
6619
+ /* SELECTION is a different fact from hover and from aria-current, and the
6620
+ ticket is right that conflating them is the common bug: hover is where the
6621
+ pointer is, aria-current is which page you are on, aria-selected is what
6622
+ the next action will apply to. A layer stack has all three at once. */
6623
+ .juno-tree__item[aria-selected='true'] > .juno-tree__row {
6624
+ background: var(--juno-s3);
6625
+ color: var(--juno-role);
6626
+ }
6627
+
6628
+ .juno-tree__item[aria-current] > .juno-tree__row {
6629
+ box-shadow: inset var(--juno-border-width-2) 0 0 var(--juno-role);
6630
+ }
6631
+
6632
+ .juno-tree__row:focus-visible {
6633
+ outline: var(--juno-border-width-2) solid var(--juno-active);
6634
+ outline-offset: calc(-1 * var(--juno-border-width-2));
6635
+ }
6636
+
6637
+ /* ── disclosure caret ──────────────────────────────────────────────────
6638
+ aria-hidden and tabindex=-1 on purpose: the row itself is the treeitem
6639
+ and Left/Right already collapse and expand, so a separately focusable
6640
+ caret puts a second stop in the tab order for an action the row has. It
6641
+ stays a real button for the pointer. */
6642
+ .juno-tree__caret {
6643
+ flex-shrink: 0;
6644
+ inline-size: var(--juno-space-16);
6645
+ block-size: var(--juno-space-16);
6646
+ display: grid;
6647
+ place-items: center;
6648
+ padding: 0;
6649
+ border: none;
6650
+ background: transparent;
6651
+ color: var(--juno-label);
6652
+ cursor: pointer;
6653
+ transition: transform var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
6654
+ }
6655
+
6656
+ .juno-tree__caret::before {
6657
+ content: '';
6658
+ inline-size: 0;
6659
+ block-size: 0;
6660
+ border-block: calc(var(--juno-space-4) + 1px) solid transparent;
6661
+ border-inline-start: calc(var(--juno-space-4) + 2px) solid currentcolor;
6662
+ }
6663
+
6664
+ .juno-tree__item[aria-expanded='true'] > .juno-tree__row > .juno-tree__caret {
6665
+ transform: rotate(90deg);
6666
+ }
6667
+
6668
+ /* A leaf keeps the caret's box so labels line up down the column, but paints
6669
+ nothing — an empty gap would let sibling labels wander. */
6670
+ .juno-tree__item:not([aria-expanded]) > .juno-tree__row > .juno-tree__caret {
6671
+ visibility: hidden;
6672
+ cursor: default;
6673
+ }
6674
+
6675
+ .juno-tree__icon {
6676
+ flex-shrink: 0;
6677
+ color: var(--juno-label);
6678
+ }
6679
+
6680
+ .juno-tree__label {
6681
+ min-inline-size: 0;
6682
+ overflow: hidden;
6683
+ text-overflow: ellipsis;
6684
+ white-space: nowrap;
6685
+ }
6686
+
6687
+ /* Count badge on a group row. Pushed to the trailing edge with the rest of
6688
+ the trailing furniture. */
6689
+ .juno-tree__count {
6690
+ margin-inline-start: auto;
6691
+ flex-shrink: 0;
6692
+ font-size: var(--juno-font-size-11);
6693
+ font-variant-numeric: tabular-nums;
6694
+ color: var(--juno-muted);
6695
+ }
6696
+
6697
+ /* The same trailing-control slot .juno-list__row has: a switch, a menu
6698
+ trigger, a value. Follows the count when both are present. */
6699
+ .juno-tree__trail {
6700
+ margin-inline-start: auto;
6701
+ flex-shrink: 0;
6702
+ display: inline-flex;
6703
+ align-items: center;
6704
+ gap: var(--juno-space-8);
6705
+ }
6706
+
6707
+ .juno-tree__count + .juno-tree__trail {
6708
+ margin-inline-start: 0;
6709
+ }
6710
+
6711
+ /* ── reorder handle ────────────────────────────────────────────────────
6712
+ The AFFORDANCE and its hit area; the reorder logic is the app's, which is
6713
+ junoui's usual line. It is a real button and always visible on touch: a
6714
+ long-press-drag is the obvious gesture and the wrong one, because a tree
6715
+ sitting on or beside a pan/zoom surface has to let the pan win. An
6716
+ explicit handle is the only unambiguous target — see the doc.
6717
+ `touch-action: none` on the handle ALONE, so dragging it never scrolls
6718
+ while the rest of the row still pans normally. */
6719
+ .juno-tree__handle {
6720
+ flex-shrink: 0;
6721
+ inline-size: var(--juno-space-16);
6722
+ block-size: var(--juno-space-16);
6723
+ display: grid;
6724
+ place-items: center;
6725
+ padding: 0;
6726
+ border: none;
6727
+ background: transparent;
6728
+ color: var(--juno-muted);
6729
+ cursor: grab;
6730
+ touch-action: none;
6731
+ }
6732
+
6733
+ .juno-tree__handle:active {
6734
+ cursor: grabbing;
6735
+ }
6736
+
6737
+ .juno-tree__handle::before {
6738
+ content: '';
6739
+ inline-size: var(--juno-space-10);
6740
+ block-size: var(--juno-border-width-1);
6741
+ box-shadow:
6742
+ 0 calc(-1 * var(--juno-space-4)) 0 currentcolor,
6743
+ 0 var(--juno-space-4) 0 currentcolor;
6744
+ background: currentcolor;
6745
+ }
6746
+
6747
+ /* The row being dragged, while the app moves it. */
6748
+ .juno-tree__item[data-juno-dragging] > .juno-tree__row {
6749
+ opacity: var(--juno-opacity-muted);
6750
+ }
6751
+
6752
+ /* Drop target edge. A line rather than a filled row: the drop lands BETWEEN
6753
+ rows, and a filled highlight says "into this one", which is a different
6754
+ operation in a tree and the one users complain about. */
6755
+ .juno-tree__item[data-juno-drop='before'] > .juno-tree__row {
6756
+ box-shadow: inset 0 var(--juno-border-width-2) 0 var(--juno-active);
6757
+ }
6758
+
6759
+ .juno-tree__item[data-juno-drop='after'] > .juno-tree__row {
6760
+ box-shadow: inset 0 calc(-1 * var(--juno-border-width-2)) 0 var(--juno-active);
6761
+ }
6762
+
6763
+ .juno-tree__item[data-juno-drop='into'] > .juno-tree__row {
6764
+ outline: var(--juno-border-width-2) solid var(--juno-active);
6765
+ outline-offset: calc(-1 * var(--juno-border-width-2));
6766
+ }
6767
+
6768
+ /* ── touch ─────────────────────────────────────────────────────────────
6769
+ The caret and the handle paint small so a dense tree stays dense, but a
6770
+ 16px target is not tappable. Grow the HIT AREA only, with a transparent
6771
+ overlay, exactly as .nx-check does downstream: a 44px painted caret would
6772
+ swallow the row it sits in. The row's own floor comes from
6773
+ --juno-size-tap-min, which base.css promotes on a coarse pointer. */
6774
+ @media (pointer: coarse) {
6775
+ .juno-tree__caret,
6776
+ .juno-tree__handle {
6777
+ position: relative;
6778
+ }
6779
+
6780
+ .juno-tree__caret::after,
6781
+ .juno-tree__handle::after {
6782
+ content: '';
6783
+ position: absolute;
6784
+
6785
+ /* (44 - 16) / 2 = 14 */
6786
+ inset: calc(-1 * (var(--juno-size-tap-comfortable) - var(--juno-space-16)) / 2);
6787
+ }
6788
+ }
6789
+
5968
6790
  /* ════════════════════════════════════════════════════════════════════
5969
6791
  * junoui — utilities layer
5970
6792
  *