@junoput01/junoui 0.6.0 → 0.7.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.
@@ -0,0 +1,273 @@
1
+ # junoui on iOS, and in a Home-Screen web app
2
+
3
+ The bounded claim, in one page: **what you get for free, what you must supply,
4
+ and what junoui explicitly does not do.** Read it before integrating; the pages
5
+ it links carry the detail and the derivations.
6
+
7
+ Every line here carries a number or a source. Where a claim has neither, it is
8
+ not on this page.
9
+
10
+ **What the numbers were measured against.** junoui **v0.5.0**; the CSS feature
11
+ audit is against the built `dist/css/juno.css` on **2026-08-15**; the device
12
+ readings are an **iPhone 16 Pro (402×874 pt), iOS 18.7 / Safari 26.6**; the
13
+ touch-ergonomics assertions run in Playwright at **390×844** with `hasTouch` +
14
+ `isMobile`. One device, one build. Nothing here is extrapolated to iPads or to
15
+ Android.
16
+
17
+ ---
18
+
19
+ ## The floor
20
+
21
+ | | Safari / iOS | What that line means |
22
+ | ----------------------------------------------------- | ------------ | --------------------------------------------------- |
23
+ | **Hard floor** — below this, things break | **17.0** | the Popover API. Overlays stop being overlays |
24
+ | **Supported floor** — put this in your support matrix | **17.5** | everything works; entry animations missing below it |
25
+ | **Full fidelity** | **26.0** | CSS anchor positioning |
26
+
27
+ **The interesting gap is 17.5 → 26.0, not 16.x → 17.5.** Between 17.5 and 26.0
28
+ menus, popovers and tooltips open, close and light-dismiss correctly — and land
29
+ at their **static position** instead of beside their trigger, because
30
+ `position-area` / `position-try-fallbacks` / `position-anchor` (**10 uses**) are
31
+ Safari 26. The surface works; it is in the wrong place. That is a real visual
32
+ defect and it is easy to mistake for a break.
33
+
34
+ Below **17.0** the Popover API is absent and junoui **hides** `.juno-menu`,
35
+ `.juno-popover` and the top-layer tooltip behind
36
+ `@supports not selector(:popover-open)`. That is not a fix. Unguarded, the UA
37
+ rule that hides a closed popover does not exist either, so a **256–280 px
38
+ invisible fixed panel** sits at its static position swallowing taps on whatever
39
+ it covers. junoui trades that for "the panel is absent" — absent beats
40
+ invisibly-present — and your app branches on
41
+ `CSS.supports('selector(:popover-open)')`.
42
+
43
+ Full per-feature audit, with the degrade-or-break verdict for all 15 features:
44
+ [browser-support.md](./browser-support.md).
45
+
46
+ ---
47
+
48
+ ## What you get for free
49
+
50
+ ### Touch ergonomics
51
+
52
+ - **44 px tap targets on coarse pointers.** `--juno-size-tap-min` is **24 px**
53
+ (the WCAG 2.2 **2.5.8** AA floor) and flips to `--juno-size-tap-comfortable` =
54
+ **44 px** under `@media (pointer: coarse)`. Everything that sizes off it
55
+ inherits the promotion: `.juno-btn` (`min-height`), `.juno-input` and
56
+ `textarea` (`min-block-size`, the latter at 3×), `.juno-menu__item`,
57
+ `.juno-navbar__actions > *`, and `.juno-modal__close` on both axes.
58
+ **One exception, stated so you can size around it:** `.juno-pagination`'s
59
+ items take the promotion on the **inline** axis only — their `block-size` is a
60
+ fixed `--juno-space-32`, so they are 44 × 32 on touch. That clears the 24 px
61
+ AA floor and not the 44 px comfortable one (ticket 20260815-040).
62
+ - **Verified numerically, not by screenshot.**
63
+ `test/visual/tap-targets.spec.mjs` asserts the computed `min-height` **and**
64
+ the rendered box under **both** Playwright projects — `44px` under
65
+ `chromium-coarse`, `24px` under `chromium`. Keying the expectation by project
66
+ is what makes it a check rather than a baseline: if the coarse project ever
67
+ stops emulating touch it starts producing desktop numbers and **fails**
68
+ instead of quietly re-recording them.
69
+ - **A 16 px floor on text entry**, so iOS Safari does not zoom the page onto a
70
+ focused field: `.juno-input { font-size: max(16px, …) }`. Two caveats, both
71
+ load-bearing. **No primary WebKit or Apple source states the zoom behaviour** —
72
+ it is empirical, and the mitigation is kept because it is harmless, not
73
+ because it is documented. And it **never applied on any touch device until
74
+ 2026-08-15**: the rule sat in `base.css`'s `@media (pointer: coarse)` block,
75
+ where a media query adds no specificity, so `input.css`'s own `.juno-input`
76
+ font-size beat it on source order. The numeric check above is what found it.
77
+ - **The UA tap-highlight square is off** on `.juno-btn`, `.juno-dock__item`,
78
+ `.juno-pillbar__item`, `.juno-tabs__tab`, `.juno-list__item`,
79
+ `.juno-menu__item`. Community convention — **no Apple source** — so it is
80
+ listed as a default, not a conformance claim.
81
+
82
+ ### Safe areas
83
+
84
+ `env(safe-area-inset-*)` is used **21 times across 8 files**, and the rule for
85
+ which arithmetic applies is not one rule but **three buckets**:
86
+
87
+ | Bucket | Arithmetic | Why |
88
+ | ---------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ |
89
+ | Padding on a surface that reaches the edge | `max(baseline, env(…))` — the inset **replaces** | the inset's job is to clear the cutout, not to stack |
90
+ | A floating element positioned **off** the edge | `calc(baseline + env(…))` — **additive** | `max()` would park the pill flush against the home indicator |
91
+ | The clearance tokens | `calc(height + … + env(…))` — **additive** | content must clear the control's height _and_ the inset |
92
+
93
+ Get this wrong in either direction and nothing errors. The derivation, with the
94
+ WebKit source: [ios-conformance.md](./ios-conformance.md#max-vs-addition--the-rule).
95
+
96
+ **All of it is inert until you ship `viewport-fit=cover`** — WebKit reports
97
+ every inset as `0` until then, and `contain` does not opt out. See
98
+ [what you must supply](#what-you-must-supply).
99
+
100
+ ### Floating-nav clearance, derived rather than constant
101
+
102
+ A page under a floating dock or pillbar reserves room at its foot with
103
+ `padding-block-end: var(--juno-dock-clearance)`. Those tokens are **computed
104
+ from the bar's own parts**, not hardcoded:
105
+
106
+ ```
107
+ --juno-dock-h = tap-comfortable + 4×space-4 + 2×border-1 = 62px at defaults
108
+ --juno-pillbar-h = tap-comfortable + 2×space-4 + 2×border-1 = 54px at defaults
109
+
110
+ --juno-dock-clearance = dock-h × scale + 16 + 8 + env(safe-area-inset-bottom)
111
+ --juno-pillbar-clearance = pillbar-h × scale + 16 + 8 + env(safe-area-inset-bottom)
112
+ ```
113
+
114
+ — 86 px and 78 px plus the inset, at default tokens. **Why derived matters:**
115
+ the old constants (`space-72 + space-20 + env()`) promised in a code comment to
116
+ "stay correct when the dock geometry changes" and could not. junoui explicitly
117
+ invites you to re-parameterise the bubble via `--juno-size-tap-comfortable`, and
118
+ past a **58 px** bubble the constant reserved **less** than the pill's own
119
+ height plus its margin — content hid under the dock, silently, on exactly the
120
+ consumers who took the invitation (ticket 20260815-026).
121
+
122
+ `--juno-dock-clearance-scale` (default `1`) is the knob for a bar that **shrinks
123
+ while scrolling**: set it to the shrunken ratio so the reservation is made at
124
+ the small size. Reserving against a live height means relayouting content under
125
+ a finger that has already stopped moving.
126
+
127
+ ### The Home-Screen standalone unlock
128
+
129
+ In `display-mode: standalone`, iOS sizes the window from the document's
130
+ **resting** scrollability at launch and letterboxes a document that cannot
131
+ scroll by exactly `env(safe-area-inset-top)` — measured **874 − 812 = 62**, a
132
+ black strip at the bottom of the glass on every screen. `base.css` keeps the
133
+ document scrollable behind your fixed shell with an invisible `body::after`
134
+ spacer, behind a **three-condition gate, all required**:
135
+
136
+ ```
137
+ @media (display-mode: standalone) only installed apps letterbox
138
+ and (pointer: coarse) keeps macOS Dock apps out
139
+ @supports (-webkit-touch-callout: none) iOS/iPadOS WebKit only
140
+ ```
141
+
142
+ **A bundled stylesheet cannot satisfy the obligation this creates** — see
143
+ [what you must supply](#what-you-must-supply). The measurements, the
144
+ four-structure A/B, and the 201 device readings behind it:
145
+ [ios-conformance.md](./ios-conformance.md#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it).
146
+
147
+ ### Phone-shaped component behaviour
148
+
149
+ | Behaviour | Triggered by |
150
+ | ----------------------------------------------------------------------- | --------------------------------------------------------------- |
151
+ | Modal becomes a bottom sheet — full width, bottom-anchored, `85dvh` cap | `@media (width <= 639.98px)` |
152
+ | Toast stack goes full width at the bottom edge and slides up | `@media (width <= 639.98px)` |
153
+ | Table stacks into rows | `@container (max-width: 480px)` |
154
+ | Tab strip scrolls sideways instead of wrapping | `overflow: auto hidden` + `overscroll-behavior-inline: contain` |
155
+
156
+ Note the third row is a **container** query, not a viewport one: it reflows by
157
+ the space the table is in, not by the size of the phone. And the first is
158
+ deliberately _not_ — a modal is always in the top layer, so the screen **is**
159
+ its container.
160
+
161
+ ### Silent-failure guards already paid for
162
+
163
+ Two defects of this class shipped and were found by looking at a phone, not by
164
+ any tool — both fixed 2026-08-05, both now permanent:
165
+
166
+ - `-webkit-backdrop-filter` ships beside **all 7** unprefixed
167
+ `backdrop-filter` declarations (unsupported below Safari 18.0 — every glass
168
+ surface rendered flat on iOS 17).
169
+ - A `::-webkit-scrollbar { display: none }` companion ships beside **all 3**
170
+ `scrollbar-width` declarations (unsupported below Safari 18.2 — the
171
+ scrollable tab strip kept a visible scrollbar on iOS 17 through 18.1).
172
+
173
+ > Both are 7 and 3 **declarations**, verified in `dist/css/juno.css`. The audit
174
+ > table in [browser-support.md](./browser-support.md) reports 14 and 4 because
175
+ > it counts string occurrences, and `-webkit-backdrop-filter` contains
176
+ > `backdrop-filter` — so each guarded pair is counted twice. Same fact, two
177
+ > units.
178
+
179
+ And **zero raw `vh`** — grep the bundle and there are none. Full-height surfaces
180
+ use `dvh` (**9 uses**) because `vh == lvh` normatively and a `100vh` box is
181
+ sized as if the toolbar were retracted; the single `lvh` in the bundle is the
182
+ standalone unlock's spacer, which is _supposed_ to overflow. The reasoning, and
183
+ the rule for a component that does not exist yet:
184
+ [ios-conformance.md](./ios-conformance.md#the-rule-for-a-component-that-does-not-exist-yet).
185
+
186
+ ---
187
+
188
+ ## What you must supply
189
+
190
+ junoui is a stylesheet. Everything below is outside what CSS can reach.
191
+
192
+ | # | You ship | If you don't |
193
+ | --- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
194
+ | 1 | `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` | every safe-area guarantee becomes a no-op, silently; and 1 CSS px stops equalling 1 pt |
195
+ | 2 | A standalone declaration — manifest `"display": "standalone"` or `apple-mobile-web-app-capable` — **or a decision not to** | from iOS 26 the **user** grants standalone at add-to-Home-Screen time and junoui's standalone CSS runs anyway |
196
+ | 3 | **An inline copy of the letterbox unlock in `<head>`**, if your shell paints before its CSS bundle | iOS samples the document at launch; `juno.css` arrives after, so the app letterboxes with the unlock present |
197
+ | 4 | Leave `body::after` alone, or reproduce the spacer at the same gate | the unlock has no spacer and the window letterboxes |
198
+ | 5 | `overscroll-behavior: contain` on your real scrollers | an inner fling chains into the unlock's ghost scroller |
199
+ | 6 | A popover fallback below Safari 17.0, branched on `CSS.supports('selector(:popover-open)')` | overflow navigation is unreachable — junoui hid the panel rather than leave it eating taps |
200
+ | 7 | Pinning `inset` on the `toggle` event below Safari 26.0 | anchored surfaces open at their static position |
201
+ | 8 | `data-juno-letterboxed` on `<html>`, if you want to react to a letterboxed window | nothing; the flag is opt-in — but invent your own attribute name and your CSS can never move into junoui |
202
+
203
+ **#3 is the one that catches people**, because it is silent in both directions:
204
+ miss it and the app letterboxes with a correct stylesheet installed, ship it and
205
+ nothing confirms it worked except a screenshot of the device. Rows 2, 3 and 8
206
+ are specified in full — declarations, status-bar-style semantics, the exact
207
+ letterbox predicate — in
208
+ [the consumer `<head>` contract](./ios-conformance.md#becoming-a-home-screen-web-app-the-consumer-head-contract).
209
+
210
+ ---
211
+
212
+ ## What junoui explicitly does not do
213
+
214
+ This half is why the page exists. None of the following is planned, partial, or
215
+ "coming to the roadmap" — it is out of scope by design.
216
+
217
+ **No JS behaviour.** No focus traps, no scroll locking, no gesture or swipe
218
+ handling, no runtime popover positioning, no list virtualization, no state. Every
219
+ component renders with zero JS; what is stateful is the platform's (`<dialog>`,
220
+ `popover`, `<details>`) or yours. junoui does ship two JS entry points —
221
+ `icons/inline` and `icons/install` — and they are **icon-sprite helpers, nothing
222
+ more**.
223
+
224
+ **No service worker, no offline story, no cache.** No precaching, no purge, no
225
+ versioning, no `skipWaiting` policy. [boot-shell.md](./boot-shell.md) documents
226
+ the five-rung boot pattern including an app-shell service worker — that is a
227
+ **recipe your app implements**, with a reference implementation in another
228
+ repository. junoui ships none of that code.
229
+
230
+ **No native chrome.** `dist/ios/JunoTokens.swift` is **token values only**. There
231
+ are no UIKit or SwiftUI components. `.juno-dock` and `.juno-navbar` are CSS that
232
+ _looks_ like a bar; they are not the system bar and do not inherit its
233
+ behaviours.
234
+
235
+ **Nothing about in-app browsers.** SFSafariViewController and WKWebView-based
236
+ in-app browsers (Instagram, Facebook) are a **separate, unfixed path** and
237
+ junoui has tested neither. The one datum on record: Safari 26.0's notes claim a
238
+ fix for `lvh`/`vh` being sized against the small viewport in
239
+ SFSafariViewController, but **WebKit bug 255708 (filed 2023) is still open**, so
240
+ that "Fixed" is stronger than the tracker supports.
241
+
242
+ **No app-shell assets.** No web app manifest, no Home-Screen icons, no splash
243
+ screens, no install prompt. `src/icons/` is a **UI icon sprite** — the icons
244
+ inside your interface, not the icon on the Home Screen.
245
+
246
+ **No guarantee below Safari / iOS 17.0**, and the losses compound rather than
247
+ arriving all at once: **16.2** drops `color-mix()` (**28 uses** — every role
248
+ tint, border and shadow built from `var(--juno-role)`, which is the contract
249
+ junoui exists to encode); **16.0** drops `@container`; **15.4** drops `oklch()`
250
+ (**190 uses** — every token value) and `dvh`/`lvh` (**10 uses** — the app shell
251
+ collapses to content height). There is no fallback layer for any of them, and
252
+ none is planned. If you are on iOS 15 you are not using junoui.
253
+
254
+ **No verified iOS 26 story.** The claim "iOS 26 changed nothing about safe
255
+ areas, viewport-fit, touch behaviour or focus zoom" was **refuted** during
256
+ verification, so junoui's iOS 26 behaviour is _unverified_, not
257
+ _confirmed-safe_. Open leads — `vh` reportedly pinning to `window.outerHeight`,
258
+ three new tab modes yielding different `innerHeight`, iPadOS 26 windowed mode
259
+ reportedly returning nothing for `env(safe-area-inset-*)` — need a physical
260
+ device, not more documentation.
261
+
262
+ ---
263
+
264
+ ## Where the detail lives
265
+
266
+ | Page | What it settles |
267
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
268
+ | [ios-conformance.md](./ios-conformance.md) | every iOS metric with its source, the folklore named, the letterbox, the `<head>` contract |
269
+ | [browser-support.md](./browser-support.md) | the three floors, all 15 features with degrade-or-break, the two shipped silent failures |
270
+ | [getting-started.md](./getting-started.md) | the required viewport meta and what depends on it |
271
+ | [accessibility.md](./accessibility.md) | the WCAG 2.2 criterion behind each target size, and the per-component ARIA contract |
272
+ | [integration.md](./integration.md) | import order, the token bridge, and where app-specific things go |
273
+ | [boot-shell.md](./boot-shell.md) | the boot ladder — a pattern to implement, not code junoui ships |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoput01/junoui",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -50,6 +50,8 @@
50
50
  "./flutter": "./dist/flutter/juno_tokens.dart",
51
51
  "./icons": "./dist/icons/juno-icons.svg",
52
52
  "./subset": "./tools/subset-sprite.mjs",
53
+ "./testing": "./tools/testing.mjs",
54
+ "./classes.json": "./dist/classes.json",
53
55
  "./icons/inline": "./dist/icons/inline.js",
54
56
  "./icons/install": "./dist/icons/install.js",
55
57
  "./package.json": "./package.json"
@@ -68,7 +70,8 @@
68
70
  "build:tokens": "node style-dictionary.config.mjs",
69
71
  "build:icons": "node scripts/build-icons.mjs",
70
72
  "build:css": "node scripts/bundle-css.mjs",
71
- "build": "npm run build:tokens && npm run build:icons && npm run build:css",
73
+ "build:classes": "node scripts/build-classes.mjs",
74
+ "build": "npm run build:tokens && npm run build:icons && npm run build:css && npm run build:classes",
72
75
  "gen-docs": "node scripts/gen-docs.mjs",
73
76
  "clean": "node -e \"import('node:fs').then(fs=>fs.rmSync('dist',{recursive:true,force:true}))\"",
74
77
  "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
@@ -176,7 +197,7 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
176
197
  .juno-dock__item,
177
198
  .juno-pillbar__item,
178
199
  .juno-tabs__tab,
179
- .juno-list__item,
200
+ .juno-list__row,
180
201
  .juno-menu__item
181
202
  ) {
182
203
  -webkit-tap-highlight-color: transparent;
@@ -201,9 +222,9 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
201
222
  .juno-pillbar__item,
202
223
  .juno-pillbar__overflow,
203
224
  .juno-tabs__tab,
204
- .juno-list__item,
225
+ .juno-list__row,
205
226
  .juno-menu__item,
206
- .juno-seg__option,
227
+ .juno-seg__opt,
207
228
  .juno-chip,
208
229
  .juno-toggle-btn
209
230
  ) {
@@ -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);
@@ -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;
@@ -38,11 +38,20 @@
38
38
  opacity: 0;
39
39
  }
40
40
 
41
- /* the pill — a span after the radio, or the aria-pressed button itself */
41
+ /* the pill — a span after the radio, or the aria-pressed button itself.
42
+ The tap floor is on the PAINTED box, not on the label that wraps it: the
43
+ label is a bare inline-flex and takes its height from this. Segmented is a
44
+ phone-first control and is routinely the only control on a whole settings
45
+ section, so it holds --juno-size-tap-min like .juno-btn does — 24px (WCAG
46
+ 2.2 AA, 2.5.8) on a fine pointer, promoted to the 44px comfortable target
47
+ under (pointer: coarse) by base.css. Without it the pill computed ~25px
48
+ from its padding alone, which is the AA floor by accident and nothing on
49
+ touch. See 20260826-025. */
42
50
  .juno-seg__opt input + span,
43
51
  button.juno-seg__opt {
44
52
  display: inline-flex;
45
53
  align-items: center;
54
+ min-block-size: var(--juno-size-tap-min);
46
55
  gap: var(--juno-gap-control);
47
56
  font-family: var(--juno-font-family-sans);
48
57
  font-size: var(--juno-font-size-11);
@@ -90,7 +99,11 @@ button.juno-seg__opt:disabled {
90
99
  cursor: not-allowed;
91
100
  }
92
101
 
93
- /* Small — matches .juno-btn--sm; for dense toolbars */
102
+ /* Small — dense toolbars: less type, tighter padding. UNLIKE .juno-btn--sm it
103
+ does NOT drop below the tap floor, which the base rule above holds: --sm is a
104
+ density modifier here, not an opt-out of the touch target. A segmented row is
105
+ often the only control on a settings section, so a sub-tap variant of it has
106
+ no safe use on a phone. */
94
107
  .juno-seg--sm .juno-seg__opt input + span,
95
108
  .juno-seg--sm > button.juno-seg__opt {
96
109
  font-size: var(--juno-font-size-10);