@junoput01/junoui 0.5.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/docs/layout.md CHANGED
@@ -269,3 +269,38 @@ You opt in per region; nothing forces a markup shape.
269
269
  These are all CSS. Behavior that needs state (resize observers feeding app state,
270
270
  virtualization, drag-resizable panels) belongs in your app or a sibling
271
271
  `junoui-<framework>` package — not the design system.
272
+
273
+ ## Floating-nav clearance
274
+
275
+ A scroller that a fixed dock or pillbar floats over reserves room at its foot so
276
+ the last row clears the overlay:
277
+
278
+ ```css
279
+ .my-scroller {
280
+ padding-block-end: var(--juno-dock-clearance);
281
+ }
282
+ ```
283
+
284
+ The value is **derived from the control it clears**, not a constant:
285
+
286
+ | Prop | Is |
287
+ | ----------------------------- | ------------------------------------------------------------------------------------------ |
288
+ | `--juno-dock-h` | the floating pill's laid-out height — bubble + item padding + pill padding + border |
289
+ | `--juno-pillbar-h` | the pillbar's, one size down (no bubble) |
290
+ | `--juno-dock-clearance-scale` | multiplier on that height, default `1` — see below |
291
+ | `--juno-dock-clearance` | `height × scale + space-16 (the bar's own margin) + space-8 + env(safe-area-inset-bottom)` |
292
+
293
+ **Why derived matters.** Set `--juno-size-tap-comfortable` at `:root` — which
294
+ junoui invites, and which is the whole point of a token — and the pill grows;
295
+ the clearance grows with it. The previous constant did not, and past a 58px
296
+ bubble it reserved less than the pill's own height plus its margin, so content
297
+ hid under the dock with nothing reporting it.
298
+
299
+ **The scale knob is for bars that shrink while scrolling.** Reserve at the SMALL
300
+ size by setting `--juno-dock-clearance-scale` to your compact factor. Reserving
301
+ at the live size means the reservation changes after the last scroll event, and
302
+ content relayouts under a finger that has already stopped moving.
303
+
304
+ **Custom properties resolve where they are declared.** Override
305
+ `--juno-size-tap-comfortable` at `:root` (or override `--juno-dock-h` directly);
306
+ overriding it on a subtree does not re-derive a token computed at `:root`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoput01/junoui",
3
- "version": "0.5.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,7 +50,10 @@
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",
56
+ "./icons/install": "./dist/icons/install.js",
54
57
  "./package.json": "./package.json"
55
58
  },
56
59
  "files": [
@@ -67,7 +70,8 @@
67
70
  "build:tokens": "node style-dictionary.config.mjs",
68
71
  "build:icons": "node scripts/build-icons.mjs",
69
72
  "build:css": "node scripts/bundle-css.mjs",
70
- "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",
71
75
  "gen-docs": "node scripts/gen-docs.mjs",
72
76
  "clean": "node -e \"import('node:fs').then(fs=>fs.rmSync('dist',{recursive:true,force:true}))\"",
73
77
  "lint:css": "stylelint \"src/css/**/*.css\"",
@@ -80,6 +84,7 @@
80
84
  "showcase": "npm run build && node scripts/serve.mjs",
81
85
  "test:visual": "playwright test",
82
86
  "test:visual:update": "playwright test --update-snapshots",
87
+ "gate:consumer": "node scripts/consumer-gate.mjs",
83
88
  "changeset": "changeset",
84
89
  "version": "changeset version",
85
90
  "release": "npm run build && changeset publish"
@@ -94,5 +99,12 @@
94
99
  },
95
100
  "engines": {
96
101
  "node": ">=18"
97
- }
102
+ },
103
+ "browserslist": [
104
+ "iOS >= 17.0",
105
+ "Safari >= 17.0",
106
+ "Chrome >= 114",
107
+ "Edge >= 114",
108
+ "Firefox >= 125"
109
+ ]
98
110
  }
package/src/css/base.css CHANGED
@@ -19,13 +19,69 @@
19
19
  /* Floating-nav scroll clearance — a page/scroller that a fixed dock or
20
20
  pillbar floats over reserves this much room at its foot so the last row
21
21
  clears the overlay. Consumers write `padding-block-end:
22
- var(--juno-dock-clearance)` and stay correct when the dock geometry
23
- changes no hand-rolled calc() per app. Web-only (they carry env()), so
24
- they live in the CSS layer, not the cross-platform token set.
25
- --juno-dock-clearance pill height + bottom margin + breathing room;
26
- --juno-pillbar-clearance suits the shorter centered pill. */
27
- --juno-dock-clearance: calc(var(--juno-space-72) + var(--juno-space-20) + env(safe-area-inset-bottom, 0px));
28
- --juno-pillbar-clearance: calc(var(--juno-space-72) + env(safe-area-inset-bottom, 0px));
22
+ var(--juno-dock-clearance)`. Web-only (they carry env()), so they live in
23
+ the CSS layer, not the cross-platform token set.
24
+
25
+ DERIVED, not constant. These used to be `space-72 + space-20 + env()`,
26
+ which promised in this very comment to "stay correct when the dock
27
+ geometry changes" and could not: the floating pill measures 62px with the
28
+ default 44px bubble, and junoui explicitly invites a consumer to
29
+ parameterize that bubble (--juno-size-tap-comfortable). Past a 58px bubble
30
+ the old constant reserved LESS than the pill's own height plus its margin
31
+ — content hid under the dock, silently, on exactly the consumers who took
32
+ the invitation (20260815-026).
33
+
34
+ So the parts are named and the arithmetic is visible:
35
+ --juno-dock-h the pill's laid-out height: the bubble, the item's
36
+ padding-block, the pill's own padding, its border.
37
+ * -clearance-scale consumers that SHRINK the bar while scrolling
38
+ reserve at the small size, not the live one — a
39
+ reservation that tracks a value which changes after
40
+ the last scroll event relayouts content under a
41
+ finger that has already stopped moving. Default 1.
42
+ + space-16 + env() the pill's own bottom margin (dock.css), additive
43
+ because a floating element sits OFF the edge —
44
+ docs/ios-conformance.md, "max() vs addition".
45
+ + space-8 breathing room, so the last row is not flush. */
46
+ --juno-dock-h: calc(
47
+ var(--juno-size-tap-comfortable) + 4 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
48
+ );
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);
71
+ --juno-dock-clearance: calc(
72
+ var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
73
+ var(--juno-dock-clearance-breathing)
74
+ );
75
+
76
+ /* The pillbar is the same shape one size down: no bubble, so its height is
77
+ the tap target plus its own padding and border. */
78
+ --juno-pillbar-h: calc(
79
+ var(--juno-size-tap-comfortable) + 2 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
80
+ );
81
+ --juno-pillbar-clearance: calc(
82
+ var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
83
+ var(--juno-dock-clearance-breathing)
84
+ );
29
85
 
30
86
  /* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
31
87
  imperative JS (scrollTo/scrollIntoView smooth behavior, rAF-driven transforms,
@@ -127,8 +183,11 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
127
183
  is harmless either way: hold text-entry controls (.juno-input covers input,
128
184
  textarea and the select's inner control) at a 16px floor on touch. max()
129
185
  keeps the floor even under a scaled-down --juno-font-scale, and still grows
130
- when scaled up. */
131
- .juno-input { font-size: max(16px, var(--juno-font-size-16)); }
186
+ when scaled up.
187
+ THE RULE ITSELF LIVES IN components/input.css, not here: a media query adds
188
+ no specificity, so this file's `.juno-input` would lose to input.css's own
189
+ `.juno-input` font-size later in the bundle. It did, silently, until
190
+ 20260815-006's coarse-pointer project measured it. */
132
191
 
133
192
  /* Kill the UA tap-highlight square on the interactive surfaces so it never
134
193
  flashes past a rounded control on tap. Consumers were adding this by hand
@@ -138,7 +197,7 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
138
197
  .juno-dock__item,
139
198
  .juno-pillbar__item,
140
199
  .juno-tabs__tab,
141
- .juno-list__item,
200
+ .juno-list__row,
142
201
  .juno-menu__item
143
202
  ) {
144
203
  -webkit-tap-highlight-color: transparent;
@@ -163,9 +222,9 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
163
222
  .juno-pillbar__item,
164
223
  .juno-pillbar__overflow,
165
224
  .juno-tabs__tab,
166
- .juno-list__item,
225
+ .juno-list__row,
167
226
  .juno-menu__item,
168
- .juno-seg__option,
227
+ .juno-seg__opt,
169
228
  .juno-chip,
170
229
  .juno-toggle-btn
171
230
  ) {
@@ -229,17 +288,11 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
229
288
  :root { --juno-border: var(--juno-border-strong); }
230
289
  }
231
290
 
232
- /* Windows High Contrast / forced-colors: opt into the system palette so
233
- borders and focus stay visible when our colors are overridden. */
234
- @media (forced-colors: active) {
235
- *:focus-visible { outline-color: Highlight; }
236
-
237
- .juno-badge,
238
- .juno-btn,
239
- .juno-card,
240
- .juno-readout { border: 1px solid CanvasText; }
241
- .juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
242
- }
291
+ /* Windows High Contrast / forced-colors: the opt-in lives in
292
+ overrides.css, NOT here. A media query adds no specificity, and this file is
293
+ bundled BEFORE components/, so `.juno-btn { border: }` in button.css beat
294
+ the forced-colors border every time — same 0,1,0 selector, later in the
295
+ bundle. See src/css/overrides.css and ticket 20260815-029. */
243
296
 
244
297
  /* ── iOS standalone letterbox unlock ─────────────────────────────────────────
245
298
  iOS WebKit (observed through 26.6, iPhone 16 Pro, iOS 18.7) sizes a
@@ -288,3 +341,30 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
288
341
  }
289
342
  }
290
343
  }
344
+
345
+ /* ── Popover API absent (Safari/iOS < 17.0) ──────────────────────────────────
346
+ junoui's overlay surfaces are built on the native Popover API. Without it
347
+ `popovertarget` does nothing, so they cannot open — an inconvenience. The
348
+ part that is a BUG is that the UA rule which hides a closed popover
349
+ (`[popover]:not(:popover-open) { display: none }`) does not exist either, so
350
+ the panel participates in layout: .juno-menu and .juno-popover are
351
+ `position: fixed` with `opacity: 0` and no pointer-events reset, i.e. a
352
+ 256-280px INVISIBLE panel parked at its static position, swallowing taps on
353
+ whatever it covers. (.juno-tooltip__bubble escapes it — it inherits
354
+ `pointer-events: none` from the CSS-only tooltip rule — and is listed anyway
355
+ so the set matches the docs.)
356
+
357
+ So: hide them. Absent beats invisibly-present. Apps detect the same condition
358
+ with CSS.supports('selector(:popover-open)') and render a fallback.
359
+
360
+ @supports selector() is itself Safari 14.1+; below that this condition is
361
+ invalid, the block is skipped, and behaviour is what it is today. Fail-open,
362
+ and far below any supported floor. Guards live here rather than per component
363
+ so the next popover-based component inherits it — see docs/browser-support.md
364
+ for the rule about when a feature gap earns a guard at all (functional
365
+ failures only; a missing animation does not get one). */
366
+ @supports not selector(:popover-open) {
367
+ .juno-menu[popover],
368
+ .juno-popover[popover],
369
+ .juno-tooltip__bubble[popover] { display: none; }
370
+ }
@@ -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);