@junoput01/junoui 0.6.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +17 -16
  3. package/dist/android/dimens.xml +2 -0
  4. package/dist/classes.json +1774 -0
  5. package/dist/css/juno-tokens.css +10 -0
  6. package/dist/css/juno.css +1057 -56
  7. package/dist/flutter/juno_tokens.dart +10 -0
  8. package/dist/ios/JunoTokens.swift +10 -0
  9. package/dist/js/tokens.js +16 -0
  10. package/dist/json/tokens.json +51 -0
  11. package/dist/rust/juno_tokens.rs +383 -0
  12. package/dist/scss/_juno-tokens.scss +10 -0
  13. package/docs/accessibility.md +6 -0
  14. package/docs/browser-support.md +3 -0
  15. package/docs/components/button.md +11 -2
  16. package/docs/components/canvas-ink.md +71 -0
  17. package/docs/components/dock.md +34 -0
  18. package/docs/components/fold-slot.md +26 -1
  19. package/docs/components/gizmo.md +114 -0
  20. package/docs/components/swatch.md +95 -0
  21. package/docs/components/tree.md +112 -0
  22. package/docs/conformance-kit.md +255 -0
  23. package/docs/getting-started.md +14 -0
  24. package/docs/integration.md +52 -6
  25. package/docs/ios-conformance.md +200 -3
  26. package/docs/ios-pwa.md +273 -0
  27. package/docs/native.md +38 -1
  28. package/docs/tokens-reference.md +15 -0
  29. package/package.json +8 -2
  30. package/src/css/base.css +35 -43
  31. package/src/css/components/button.css +42 -2
  32. package/src/css/components/canvas-ink.css +97 -0
  33. package/src/css/components/dock.css +65 -6
  34. package/src/css/components/fold-slot.css +49 -3
  35. package/src/css/components/gizmo.css +238 -0
  36. package/src/css/components/segmented.css +15 -2
  37. package/src/css/components/swatch.css +187 -0
  38. package/src/css/components/tree.css +259 -0
  39. package/src/css/touch-surfaces.mjs +95 -0
  40. package/tools/gizmo.mjs +144 -0
  41. package/tools/testing.mjs +177 -0
  42. package/tools/tree.mjs +178 -0
@@ -113,6 +113,21 @@ Color encodes status, never decoration. Each role has exactly one meaning.
113
113
  | `shadow.2` | `0 4px 14px rgb(0 0 0 / 0.35)` | `var(--juno-shadow-2)` | `JunoTokens.shadow2` |
114
114
  | `shadow.3` | `0 12px 32px rgb(0 0 0 / 0.50)` | `var(--juno-shadow-3)` | `JunoTokens.shadow3` |
115
115
 
116
+ ### ink
117
+
118
+ | Token | Value | CSS variable | Flutter / iOS |
119
+ |---|---|---|---|
120
+ | `ink.canvas.ink` | `#FFFFFF` | `var(--juno-ink-canvas-ink)` | `JunoTokens.inkCanvasInk` |
121
+ | `ink.canvas.halo` | `#000000` | `var(--juno-ink-canvas-halo)` | `JunoTokens.inkCanvasHalo` |
122
+ | `ink.canvas.halo-width` | `2px` | `var(--juno-ink-canvas-halo-width)` | `JunoTokens.inkCanvasHalo-width` |
123
+ | `ink.canvas.halo-width-lg` | `3px` | `var(--juno-ink-canvas-halo-width-lg)` | `JunoTokens.inkCanvasHalo-width-lg` |
124
+ | `ink.canvas.scrim` | `0.28` | `var(--juno-ink-canvas-scrim)` | `JunoTokens.inkCanvasScrim` |
125
+ | `ink.vivid.nominal` | `oklch(76% 0.28 148)` | `var(--juno-ink-vivid-nominal)` | `JunoTokens.inkVividNominal` |
126
+ | `ink.vivid.active` | `oklch(76% 0.24 205)` | `var(--juno-ink-vivid-active)` | `JunoTokens.inkVividActive` |
127
+ | `ink.vivid.target` | `oklch(70% 0.30 328)` | `var(--juno-ink-vivid-target)` | `JunoTokens.inkVividTarget` |
128
+ | `ink.vivid.caution` | `oklch(82% 0.20 82)` | `var(--juno-ink-vivid-caution)` | `JunoTokens.inkVividCaution` |
129
+ | `ink.vivid.warning` | `oklch(68% 0.28 25)` | `var(--juno-ink-vivid-warning)` | `JunoTokens.inkVividWarning` |
130
+
116
131
  ### Motion
117
132
 
118
133
  | Token | Value | CSS variable | Flutter / iOS |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoput01/junoui",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "junoui — a token-driven design system. Color carries semantic meaning, never decoration: every hue has one assigned role (NOMINAL / ACTIVE / TARGET / CAUTION / WARNING). Ships multi-platform tokens (CSS, SCSS, JS/TS, JSON, Android, iOS, Flutter) plus a framework-agnostic CSS component layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,8 +48,13 @@
48
48
  "./android/dimens": "./dist/android/dimens.xml",
49
49
  "./ios": "./dist/ios/JunoTokens.swift",
50
50
  "./flutter": "./dist/flutter/juno_tokens.dart",
51
+ "./rust": "./dist/rust/juno_tokens.rs",
51
52
  "./icons": "./dist/icons/juno-icons.svg",
52
53
  "./subset": "./tools/subset-sprite.mjs",
54
+ "./tree": "./tools/tree.mjs",
55
+ "./gizmo": "./tools/gizmo.mjs",
56
+ "./testing": "./tools/testing.mjs",
57
+ "./classes.json": "./dist/classes.json",
53
58
  "./icons/inline": "./dist/icons/inline.js",
54
59
  "./icons/install": "./dist/icons/install.js",
55
60
  "./package.json": "./package.json"
@@ -68,7 +73,8 @@
68
73
  "build:tokens": "node style-dictionary.config.mjs",
69
74
  "build:icons": "node scripts/build-icons.mjs",
70
75
  "build:css": "node scripts/bundle-css.mjs",
71
- "build": "npm run build:tokens && npm run build:icons && npm run build:css",
76
+ "build:classes": "node scripts/build-classes.mjs",
77
+ "build": "npm run build:tokens && npm run build:icons && npm run build:css && npm run build:classes",
72
78
  "gen-docs": "node scripts/gen-docs.mjs",
73
79
  "clean": "node -e \"import('node:fs').then(fs=>fs.rmSync('dist',{recursive:true,force:true}))\"",
74
80
  "lint:css": "stylelint \"src/css/**/*.css\"",
package/src/css/base.css CHANGED
@@ -47,9 +47,30 @@
47
47
  var(--juno-size-tap-comfortable) + 4 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
48
48
  );
49
49
  --juno-dock-clearance-scale: 1;
50
+
51
+ /* The bar's own offset from the bottom edge — declared ONCE and consumed by
52
+ both the bar's margin (dock.css) and the clearance below, so the two cannot
53
+ disagree about where the bar sits.
54
+
55
+ That is the whole reason this is a token rather than a literal in each
56
+ place. junoui's default is additive, per docs/ios-conformance.md: a
57
+ FLOATING element sits off the edge, so its gap and the inset stack. A
58
+ consumer whose design wants the bar flush above the home indicator instead
59
+ writes `max(8px, env(safe-area-inset-bottom, 0px))` here and BOTH the
60
+ margin and the reservation follow it. Before this token the form was baked
61
+ into each site separately, so a consumer that changed one silently
62
+ disagreed with the other — measured at 16px of dead band at inset 0 and
63
+ 24px at inset 34 (20260815-055), and no single --juno-dock-h could
64
+ reconcile them because one side added the inset and the other maxed it. */
65
+ --juno-dock-edge-offset: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
66
+
67
+ /* Gap between the bar's top edge and the last row of content. Zero is a
68
+ legitimate setting: a translucent bar that content is MEANT to scroll under
69
+ wants overlap, not breathing room. */
70
+ --juno-dock-clearance-breathing: var(--juno-space-8);
50
71
  --juno-dock-clearance: calc(
51
- var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-space-16) +
52
- var(--juno-space-8) + env(safe-area-inset-bottom, 0px)
72
+ var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
73
+ var(--juno-dock-clearance-breathing)
53
74
  );
54
75
 
55
76
  /* The pillbar is the same shape one size down: no bubble, so its height is
@@ -58,8 +79,8 @@
58
79
  var(--juno-size-tap-comfortable) + 2 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
59
80
  );
60
81
  --juno-pillbar-clearance: calc(
61
- var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-space-16) +
62
- var(--juno-space-8) + env(safe-area-inset-bottom, 0px)
82
+ var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
83
+ var(--juno-dock-clearance-breathing)
63
84
  );
64
85
 
65
86
  /* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
@@ -168,47 +189,18 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
168
189
  `.juno-input` font-size later in the bundle. It did, silently, until
169
190
  20260815-006's coarse-pointer project measured it. */
170
191
 
171
- /* Kill the UA tap-highlight square on the interactive surfaces so it never
172
- flashes past a rounded control on tap. Consumers were adding this by hand
173
- per component; make it a first-class touch default. See 20260802-020. */
174
- :where(
175
- .juno-btn,
176
- .juno-dock__item,
177
- .juno-pillbar__item,
178
- .juno-tabs__tab,
179
- .juno-list__item,
180
- .juno-menu__item
181
- ) {
182
- -webkit-tap-highlight-color: transparent;
183
- }
192
+ /* The tap-highlight default that used to live here is GENERATED now, from
193
+ src/css/touch-surfaces.mjs, together with the touch-action default that
194
+ shares its member list. Two hand-maintained lists had drifted from the
195
+ classes and from each other; see that file. */
184
196
  }
185
197
 
186
- /* Tappable primitives opt out of double-tap-to-zoom. A browser that still
187
- recognises that gesture has to WAIT after the first tap to see whether a
188
- second one is coming, which reads as a late, mushy tap on exactly the
189
- surfaces a phone UI is built from. `manipulation` keeps panning and
190
- pinch-zoom (so the page stays zoomable never `none` here, that would be an
191
- a11y regression) and drops only the double-tap.
192
- NOT inside the pointer:coarse block above: a hybrid device (touch laptop,
193
- iPad with a trackpad) reports a fine primary pointer while still taking
194
- touch input, and the property is inert on a mouse anyway.
195
- Community convention — no primary Apple/WebKit source names it; see
196
- docs/ios-conformance.md. Named components only, so a consumer's own elements
197
- are untouched. See 20260803-038. */
198
- :where(
199
- .juno-btn,
200
- .juno-dock__item,
201
- .juno-pillbar__item,
202
- .juno-pillbar__overflow,
203
- .juno-tabs__tab,
204
- .juno-list__item,
205
- .juno-menu__item,
206
- .juno-seg__option,
207
- .juno-chip,
208
- .juno-toggle-btn
209
- ) {
210
- touch-action: manipulation;
211
- }
198
+ /* The touch defaults that used to be two hand-maintained `:where()` lists here
199
+ are GENERATED from src/css/touch-surfaces.mjs and emitted straight after this
200
+ file. Both lists had drifted from the classes (`.juno-seg__option`,
201
+ `.juno-list__item`: neither exists, so `:where()` matched nothing and the
202
+ rule parsed anyway) and from each other. One declared set now feeds both.
203
+ See 20260826-024 and docs/conformance-kit.md. */
212
204
 
213
205
  /* Gesture-owned surfaces — for an element whose pointer events are fully
214
206
  driven by app JS (drag-pan, pinch-zoom, swipe classification: a state
@@ -40,8 +40,10 @@
40
40
  .juno-btn--ghost:hover { background: var(--juno-s2); filter: none; }
41
41
 
42
42
  /* Small — for dense desktop toolbars (40–46px bars) where the default
43
- control would not fit. Deliberately under the WCAG tap-min target:
44
- keep the default size for touch surfaces. Combines with --ghost + roles. */
43
+ control would not fit. Combines with --ghost + roles.
44
+ On a FINE pointer this is 24px: the WCAG 2.2 AA floor (2.5.8) exactly, and
45
+ no more. On a coarse pointer it promotes to the comfortable target — see
46
+ below. */
45
47
  .juno-btn--sm {
46
48
  min-height: var(--juno-space-24);
47
49
  font-size: var(--juno-font-size-11);
@@ -50,6 +52,44 @@
50
52
  border-radius: var(--juno-radius-3);
51
53
  }
52
54
 
55
+ /* --sm names a DENSITY, and consumers reach for it as a SEMANTIC. Audited
56
+ across one app: 40 call sites, nearly all of them `--sm --ghost` meaning
57
+ "secondary", shipping a 24px target on a phone — and junoui's own showcase
58
+ does it twice in a navbar action slot. A size modifier should not quietly
59
+ become a tap-target decision, so on touch it holds the same floor every
60
+ other control does. Type and padding still shrink: --sm stays a density
61
+ modifier, it just stops being a touch-target one (20260826-026).
62
+
63
+ THE RULE LIVES HERE, not in base.css's (pointer: coarse) block: a media
64
+ query adds no specificity, so a `.juno-btn--sm` there would lose to this
65
+ file's own `.juno-btn--sm` later in the bundle. That is not a guess — it is
66
+ what silently happened to the .juno-input font floor until 20260815-006
67
+ measured it.
68
+
69
+ --dense is the opt-out, and it is deliberately only meaningful ON --sm:
70
+ a dense touch toolbar is a real thing (a media scrubber, an editor rail),
71
+ but it is a decision someone has to make by name rather than inherit from a
72
+ size.
73
+
74
+ The two rules below are REDUNDANT ON PURPOSE, and mutation testing is how
75
+ that became a decision rather than an accident: deleting either one leaves
76
+ --dense at 24px. The `:not()` excludes a dense button from the promotion, so
77
+ the pairing below it is dead weight — until the `:not()` names the wrong
78
+ class, at which point the pairing (also (0,2,0), and later in the file) is
79
+ the only thing holding the opt-out. Each covers the other's failure, so a
80
+ single edit to either cannot silently promote a --dense button. Both
81
+ selectors are (0,2,0) and mutually exclusive in the correct state, so they
82
+ never race. */
83
+ @media (pointer: coarse) {
84
+ .juno-btn--sm:not(.juno-btn--dense) {
85
+ min-height: var(--juno-size-tap-min);
86
+ }
87
+
88
+ .juno-btn--sm.juno-btn--dense {
89
+ min-height: var(--juno-space-24);
90
+ }
91
+ }
92
+
53
93
  .juno-btn:disabled {
54
94
  background: var(--juno-muted);
55
95
  color: var(--juno-label);
@@ -0,0 +1,97 @@
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ * Component — Canvas ink (marks drawn over arbitrary imagery)
3
+ * For text and vector ink sitting on a photo, a map, a video frame or a
4
+ * camera feed: anywhere the background is CONTENT rather than one of
5
+ * junoui's surfaces.
6
+ *
7
+ * WHY IT IS NOT JUST A COLOUR. junoui's whole contrast story assumes a
8
+ * controlled surface, s0 through s3 — every role colour's ratio is
9
+ * computed against a known background. Over imagery there is no known
10
+ * background. In one orthophoto a black shadow and a snowfield are
11
+ * adjacent pixels, so no single ink colour is legible and no contrast
12
+ * ratio can be asserted about one. The answer is a PAIR that spans the
13
+ * luminance range: over a light backing the halo carries the contrast,
14
+ * over a dark one the ink does. Neither half works alone, which is why
15
+ * they are applied together by one class.
16
+ *
17
+ * NOT THEMED, on purpose. A satellite image does not get lighter because
18
+ * the user chose light mode. Theming this pair would make it track the
19
+ * app's surface, which is precisely the background it is NOT over.
20
+ * Usage:
21
+ * <figcaption class="juno-canvas-ink">Sector 7 · 1.2 km</figcaption>
22
+ * <svg class="juno-canvas-ink"><path class="juno-canvas-ink__stroke" …/></svg>
23
+ * <div class="juno-canvas-scrim">…chrome floating over the content…</div>
24
+ * ════════════════════════════════════════════════════════════════════ */
25
+
26
+ /* Text. Four offset shadows rather than one blur: a blurred shadow fades at
27
+ the glyph's corners, which is exactly where a thin stroke needs the most
28
+ help, and it costs the same. `paint-order` is for SVG text, where a real
29
+ stroke is available and better. */
30
+ .juno-canvas-ink {
31
+ color: var(--juno-ink-canvas-ink);
32
+ paint-order: stroke fill;
33
+ stroke: var(--juno-ink-canvas-halo);
34
+ stroke-width: var(--juno-ink-canvas-halo-width);
35
+ text-shadow:
36
+ var(--juno-ink-canvas-halo-width) 0 0 var(--juno-ink-canvas-halo),
37
+ calc(-1 * var(--juno-ink-canvas-halo-width)) 0 0 var(--juno-ink-canvas-halo),
38
+ 0 var(--juno-ink-canvas-halo-width) 0 var(--juno-ink-canvas-halo),
39
+ 0 calc(-1 * var(--juno-ink-canvas-halo-width)) 0 var(--juno-ink-canvas-halo);
40
+ }
41
+
42
+ /* Display sizes: a 2px halo reads as a hairline against a heavier stroke. */
43
+ .juno-canvas-ink--lg {
44
+ stroke-width: var(--juno-ink-canvas-halo-width-lg);
45
+ text-shadow:
46
+ var(--juno-ink-canvas-halo-width-lg) 0 0 var(--juno-ink-canvas-halo),
47
+ calc(-1 * var(--juno-ink-canvas-halo-width-lg)) 0 0 var(--juno-ink-canvas-halo),
48
+ 0 var(--juno-ink-canvas-halo-width-lg) 0 var(--juno-ink-canvas-halo),
49
+ 0 calc(-1 * var(--juno-ink-canvas-halo-width-lg)) 0 var(--juno-ink-canvas-halo);
50
+ }
51
+
52
+ /* Vector ink — measurement lines, selection outlines. Two passes: the halo
53
+ is the same path drawn wider underneath. In SVG give the halo element
54
+ this class and the mark element __stroke; in canvas, stroke twice. */
55
+ .juno-canvas-ink__halo {
56
+ fill: none;
57
+ stroke: var(--juno-ink-canvas-halo);
58
+ stroke-width: calc(var(--juno-ink-canvas-halo-width) * 2);
59
+ stroke-linecap: round;
60
+ stroke-linejoin: round;
61
+ }
62
+
63
+ .juno-canvas-ink__stroke {
64
+ fill: none;
65
+ stroke: var(--juno-ink-canvas-ink);
66
+ stroke-linecap: round;
67
+ stroke-linejoin: round;
68
+ }
69
+
70
+ /* Status over canvas. The themed role hues are chosen against s0–s3 and wash
71
+ out over a saturated backdrop, so these are the same hues at raised chroma.
72
+ Louder than the panel roles by design — they will look wrong on a panel. */
73
+ .juno-canvas-ink--nominal { color: var(--juno-ink-vivid-nominal); }
74
+ .juno-canvas-ink--active { color: var(--juno-ink-vivid-active); }
75
+ .juno-canvas-ink--target { color: var(--juno-ink-vivid-target); }
76
+ .juno-canvas-ink--caution { color: var(--juno-ink-vivid-caution); }
77
+ .juno-canvas-ink--warning { color: var(--juno-ink-vivid-warning); }
78
+
79
+ /* Chrome floating over live content. Deliberately NOT --juno-opacity-scrim
80
+ (0.62): that value suppresses a modal's background, and here the background
81
+ is the thing the chrome is annotating — greying it out defeats the purpose. */
82
+ .juno-canvas-scrim {
83
+ background: color-mix(in srgb, var(--juno-s0) calc(var(--juno-ink-canvas-scrim) * 100%), transparent);
84
+ -webkit-backdrop-filter: blur(8px);
85
+ backdrop-filter: blur(8px);
86
+ }
87
+
88
+ /* A translucent bar over moving content is the case where reduced
89
+ transparency matters most — the motion behind the text is the problem, not
90
+ the blur. Fall back to the opaque surface. */
91
+ @media (prefers-reduced-transparency: reduce) {
92
+ .juno-canvas-scrim {
93
+ background: var(--juno-s0);
94
+ -webkit-backdrop-filter: none;
95
+ backdrop-filter: none;
96
+ }
97
+ }
@@ -29,6 +29,55 @@
29
29
  media query. */
30
30
  --juno-dock-scale: 1;
31
31
 
32
+ /* ── Horizontal budget contract ──────────────────────────────────────
33
+ Published so a consumer's capacity planner ("how many items fit, and
34
+ do they still hold a tap target?") reads junoui's real geometry
35
+ instead of re-deriving it from the numbers in this file. Two
36
+ consumers had derived it by hand, twice, in prose — and both had
37
+ drifted: they subtracted 12px of inline padding where the pill
38
+ actually spends 8, so their per-item width came out ~0.8px low at
39
+ every viewport (20260826-027).
40
+
41
+ --juno-dock-items the item budget. junoui does not enforce it —
42
+ __item is `flex: 1 1 0`, so the layout divides
43
+ by however many items are present — it is the
44
+ number the DERIVATIONS below are about, and the
45
+ consumer sets it to what it renders.
46
+ --juno-dock-item-inline the width one item gets. A prediction of what
47
+ the flex layout produces, not an input to it;
48
+ asserted against the measured box in the test
49
+ suite so it cannot drift from the layout.
50
+ --juno-dock-fit-inline the narrowest viewport at which every item
51
+ still holds --juno-size-tap-comfortable. Use it
52
+ in a media query and DROP an item; that is the
53
+ answer junoui can give exactly.
54
+
55
+ A SCALE floor (44px / the item's width) is deliberately not published:
56
+ it is a ratio of two lengths, and CSS cannot divide by a length. A
57
+ consumer that must scale rather than drop compares
58
+ --juno-dock-item-inline against --juno-size-tap-comfortable itself.
59
+
60
+ The three inline terms are declared ONCE here and consumed by both the
61
+ variants' own margin/padding/border and the sums below, so the budget
62
+ cannot disagree with the bar it describes — the same reason
63
+ --juno-dock-edge-offset exists (20260815-055). The base bar is
64
+ full-bleed with a top-edge border only, so all three are zero; --pill
65
+ and --float set them. */
66
+ --juno-dock-items: 5;
67
+ --juno-dock-margin-inline: 0px;
68
+ --juno-dock-pad-inline: 0px;
69
+ --juno-dock-border-inline: 0px;
70
+ --juno-dock-chrome-inline: calc(
71
+ 2 * (var(--juno-dock-margin-inline) + var(--juno-dock-pad-inline) + var(--juno-dock-border-inline))
72
+ );
73
+ --juno-dock-avail: 100vw;
74
+ --juno-dock-item-inline: calc(
75
+ (var(--juno-dock-avail) - var(--juno-dock-chrome-inline)) / var(--juno-dock-items)
76
+ );
77
+ --juno-dock-fit-inline: calc(
78
+ var(--juno-dock-items) * var(--juno-size-tap-comfortable) + var(--juno-dock-chrome-inline)
79
+ );
80
+
32
81
  position: sticky;
33
82
  inset-block-end: 0;
34
83
  z-index: var(--juno-z-raised);
@@ -123,16 +172,21 @@
123
172
  .juno-dock--pill {
124
173
  --juno-role: var(--juno-active);
125
174
 
175
+ /* the three inline terms the budget contract sums — see .juno-dock */
176
+ --juno-dock-margin-inline: var(--juno-space-12);
177
+ --juno-dock-pad-inline: var(--juno-space-4);
178
+ --juno-dock-border-inline: var(--juno-border-width-1);
179
+
126
180
  position: fixed;
127
181
  inset-inline: 0;
128
182
  inset-block-end: 0;
129
183
  z-index: var(--juno-z-raised);
130
- margin: 0 var(--juno-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
131
- padding: var(--juno-space-4);
184
+ margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
185
+ padding: var(--juno-dock-pad-inline);
132
186
  background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
133
187
  -webkit-backdrop-filter: blur(12px);
134
188
  backdrop-filter: blur(12px);
135
- border: var(--juno-border-width-1) solid var(--juno-border);
189
+ border: var(--juno-dock-border-inline) solid var(--juno-border);
136
190
  border-radius: 999px;
137
191
  box-shadow: var(--juno-shadow-2);
138
192
  }
@@ -226,17 +280,22 @@
226
280
  Out of flow (fixed) so the page scrolls UNDER it — reserve room on the
227
281
  scroller with padding-block-end: var(--juno-dock-clearance). */
228
282
  .juno-dock--float {
283
+ /* the three inline terms the budget contract sums — see .juno-dock */
284
+ --juno-dock-margin-inline: var(--juno-space-12);
285
+ --juno-dock-pad-inline: var(--juno-space-4);
286
+ --juno-dock-border-inline: var(--juno-border-width-1);
287
+
229
288
  position: fixed;
230
289
  inset-inline: 0;
231
290
  inset-block-end: 0;
232
291
  z-index: var(--juno-z-raised);
233
- margin: 0 var(--juno-space-12) calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
234
- padding: var(--juno-space-4);
292
+ margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
293
+ padding: var(--juno-dock-pad-inline);
235
294
  overflow: hidden;
236
295
  background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
237
296
  -webkit-backdrop-filter: blur(12px);
238
297
  backdrop-filter: blur(12px);
239
- border: var(--juno-border-width-1) solid var(--juno-border);
298
+ border: var(--juno-dock-border-inline) solid var(--juno-border);
240
299
  border-radius: 999px;
241
300
  box-shadow: var(--juno-shadow-2);
242
301
  }
@@ -12,17 +12,44 @@
12
12
  * gap, the folded slot still occupies one gap — name it in
13
13
  * --juno-fold-gap and the fold swallows it with a negative margin.
14
14
  *
15
+ * COMPOSITION. The canonical use puts .juno-fold on an element that is
16
+ * already a component — a .juno-pillbar__item, a .juno-btn, a .juno-chip
17
+ * — because that is where the capsule chrome (tap floor, padding, hover,
18
+ * focus ring, aria-pressed) lives. Two things follow, and both are
19
+ * load-bearing rather than defensive:
20
+ *
21
+ * 1. The fold's own declarations are stated at ATTRIBUTE specificity
22
+ * (0,2,0), on both branches of data-juno-in, i.e. always. A component
23
+ * class is (0,1,0), and `transition` is a SHORTHAND: a later rule does
24
+ * not add to the list, it replaces it. Measured in Chromium against the
25
+ * built bundle, before this was raised: on a
26
+ * .juno-fold.juno-pillbar__item, transition-property resolved to
27
+ * `color, background-color` — pillbar.css sorts after this file, so the
28
+ * fold had no transition at all and jumped shut instead of folding.
29
+ * inline-size and overflow would lose the same way to a component that
30
+ * sets either.
31
+ *
32
+ * 2. Owning `transition` means owning ALL of it, so the capsule chrome
33
+ * properties are in the fold's list too, at the quick duration the
34
+ * components themselves use. Dropping them would trade a broken fold
35
+ * for a broken hover.
36
+ *
37
+ * Which floors the fold RELEASES when folded — see the folded rule below:
38
+ * min-inline-size, padding-inline, border-inline-width. Those are exactly
39
+ * the three inputs that can hold a border-box inline size above zero.
40
+ *
15
41
  * Canonical use: a transient action in a toolbar/pill row (a
16
42
  * scroll-to-top arrow, a contextual button) whose arrival should slide
17
43
  * the row open rather than jump it.
18
44
  * Usage:
19
45
  * <div style="display:flex; gap:4px"> (any row)
20
- * <button class="juno-fold" data-juno-in aria-label="Scroll to top">…</button>
46
+ * <button class="juno-fold juno-pillbar__item" data-juno-in aria-label="Scroll to top">…</button>
21
47
  * …other members…
22
48
  * </div>
23
49
  * prefers-reduced-motion needs nothing component-local — the base
24
50
  * layer zeroes every transition duration. */
25
- .juno-fold {
51
+ .juno-fold[data-juno-in],
52
+ .juno-fold:not([data-juno-in]) {
26
53
  --juno-fold-size: var(--juno-size-tap-comfortable);
27
54
  --juno-fold-gap: 0px;
28
55
 
@@ -31,13 +58,32 @@
31
58
  overflow: hidden;
32
59
  transition:
33
60
  inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
61
+ min-inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
62
+ padding var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
63
+ border-width var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
34
64
  margin var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
35
65
  opacity var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
36
- visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard);
66
+ visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
67
+ color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard),
68
+ background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
37
69
  }
38
70
 
71
+ /* Folded away. A min-* floor clamps the USED value whichever rule won the
72
+ cascade, and a border-box inline size can never resolve below its own
73
+ padding plus border — so a composed capsule holds the slot open however
74
+ hard the fold pushes. Measured against the built bundle, composed with
75
+ .juno-pillbar__item at a 390px viewport: 44px with both floors in place
76
+ (the tap target), 20px with min-inline-size released (the padding alone),
77
+ 0px with all of them released.
78
+ min-inline-size is in the transition list above for the OPENING half:
79
+ released to 0 it would otherwise snap back to the capsule's tap floor the
80
+ instant data-juno-in returns, and the used width — max(inline-size,
81
+ min-inline-size) — would jump to full width with nothing to animate. */
39
82
  .juno-fold:not([data-juno-in]) {
40
83
  inline-size: 0;
84
+ min-inline-size: 0;
85
+ padding-inline: 0;
86
+ border-inline-width: 0;
41
87
  margin-inline-end: calc(-1 * var(--juno-fold-gap));
42
88
  opacity: 0;
43
89
  visibility: hidden;