@junoput01/junoui 0.4.0 → 0.6.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,173 @@
1
+ # Browser support
2
+
3
+ What junoui requires, what it loses gracefully below that, and the short list of
4
+ things that genuinely **break**. Stated because the failures are otherwise
5
+ silent: an engine that does not understand a CSS at-rule, property or selector
6
+ **drops it without reporting anything**. Nobody finds out from a console; they
7
+ find out from a screenshot.
8
+
9
+ Audited against `dist/css/juno.css` at v0.5.0 (2026-08-15).
10
+
11
+ ## The floor
12
+
13
+ | | Safari / iOS | Chrome / Edge | Firefox |
14
+ | ------------------------------------------------------------ | ------------ | ------------- | ---------------------------------- |
15
+ | **Hard floor** — below this, things break | **17.0** | **114** | **125** |
16
+ | **Supported floor** — everything works, minor polish missing | **17.5** | 117 | 129 |
17
+ | **Full fidelity** — nothing missing | **26.0** | 125 | — (anchor positioning not shipped) |
18
+
19
+ **junoui's supported floor is Safari / iOS 17.5.** That is the version to put in
20
+ a consumer's own support matrix.
21
+
22
+ Read the three rows as three different questions:
23
+
24
+ - Below **17.0** the Popover API is absent and junoui's overlay surfaces stop
25
+ being overlays. See [What breaks](#what-breaks) — this is not a polish
26
+ question, and one of the two failure modes is worse than "the menu does not
27
+ open".
28
+ - Between **17.0 and 17.5** everything functions; entry animations are missing.
29
+ - Between **17.5 and 26.0** everything functions and animates; anchored
30
+ surfaces (menu, popover, tooltip) land at their static position instead of
31
+ next to their trigger, because CSS anchor positioning is a Safari 26 feature.
32
+ This is a real visual defect, not a nicety — but the surface still opens,
33
+ still closes, still light-dismisses, and the app can pin it (see below).
34
+
35
+ `package.json` carries a `browserslist` set to the **hard floor**, so tooling
36
+ reads the line below which junoui is broken rather than the line below which it
37
+ is imperfect. junoui itself runs no autoprefixer or transpiler — the field is a
38
+ declaration for consumers' build tools, not something junoui acts on.
39
+
40
+ ## Feature audit
41
+
42
+ Counts are occurrences in the built bundle.
43
+
44
+ | Feature | Uses | Safari / iOS | Below that |
45
+ | ------------------------------------------------------------------------------------- | ---- | ------------ | -------------------------------------------------------------------------------------------- |
46
+ | CSS anchor positioning (`position-area`, `position-try-fallbacks`, `position-anchor`) | 10 | **26.0** | degrades — [misplaced surfaces](#anchored-surfaces-lose-their-anchor) |
47
+ | `scrollbar-width` | 4 | 18.2 | **guarded** — `::-webkit-scrollbar` fallback ships |
48
+ | `backdrop-filter` (unprefixed) | 14 | 18.0 | **guarded** — `-webkit-backdrop-filter` ships beside every use |
49
+ | `@starting-style` | 10 | 17.5 | degrades — no entry animation |
50
+ | `transition-behavior` / `overlay` + `allow-discrete` | 10 | 17.4 | degrades — surfaces pop instead of fading |
51
+ | **Popover API** (`popover`, `popovertarget`, `:popover-open`) | 31 | **17.0** | **breaks** |
52
+ | `prefers-reduced-transparency` | 3 | 17.0 | degrades — blur stays on for users who asked it off |
53
+ | `@property` | 1 | 16.4 | degrades — the gauge jumps to its value instead of sweeping |
54
+ | `color-mix()` | 28 | 16.2 | degrades — role tints vanish (see the caveat below) |
55
+ | `@container` | 2 | 16.0 | degrades — card and table stop reflowing at narrow widths |
56
+ | `:has()` | 5 | 15.4 | degrades — the collapsible pillbar never collapses |
57
+ | `dvh` / `lvh` | 10 | 15.4 | breaks layout — `block-size` falls back to `auto`, the app shell collapses to content height |
58
+ | `oklch()` | 190 | 15.4 | breaks colour — every token value is `oklch()` |
59
+ | `aspect-ratio` | 7 | 15.0 | degrades — media tiles lose their ratio |
60
+ | `@supports selector()` | 1 | 14.1 | the Popover guard below is inert (fails open to today's behaviour) |
61
+
62
+ Two rows deserve a note.
63
+
64
+ **`color-mix()` is listed as a degrade, and that is technically true and
65
+ practically uncomfortable.** All 28 uses are tints, borders and shadows built
66
+ from `var(--juno-role)`; when the function is unsupported the declaration is
67
+ invalid and the property falls back to `transparent` or the previous cascade
68
+ value. Text and icon colour, which take `var(--juno-role)` directly, survive. So
69
+ nothing breaks — but role tinting is exactly the thing junoui exists to encode,
70
+ and losing it erodes the contract even while the layout holds. It sits below the
71
+ floor; it is listed so nobody discovers it by shipping to iOS 16.
72
+
73
+ **`oklch()` and `dvh` are below every floor in this document** and are listed for
74
+ completeness. If you are on iOS 15 you are not using junoui.
75
+
76
+ ## What breaks
77
+
78
+ Exactly one thing, at exactly one version, and it has two failure modes.
79
+
80
+ ### Popover API, Safari / iOS < 17.0
81
+
82
+ junoui's `.juno-menu`, `.juno-popover` and the top-layer `.juno-tooltip__bubble`
83
+ are built on the native Popover API. The open/close, light-dismiss and ESC
84
+ behaviour is the platform's; junoui ships only the surface. On an engine without
85
+ it:
86
+
87
+ 1. **They never open.** `popovertarget` does nothing, so there is no way to
88
+ reveal the panel. The pillbar's overflow slot routes to `.juno-menu`, so the
89
+ overflow items are unreachable — which on a phone is where the secondary
90
+ navigation lives.
91
+ 2. **Worse: `.juno-menu` and `.juno-popover` become invisible click-eaters.**
92
+ The UA rule that hides a closed popover (`display: none` on any `[popover]`
93
+ that is not `:popover-open`) does not exist on an unsupporting engine, so the
94
+ panel participates in layout. junoui's own base rule sets `opacity: 0`, so it is
95
+ invisible, but both surfaces are `position: fixed` with no `pointer-events`
96
+ reset. The result is a 256–280 px invisible fixed panel sitting over the page
97
+ at its static position, swallowing taps on whatever it covers.
98
+
99
+ (`.juno-tooltip__bubble` escapes this: it inherits `pointer-events: none`
100
+ from the CSS-only tooltip rule it shares.)
101
+
102
+ Failure mode 2 is the one that justifies a guard. A feature that is missing is
103
+ an inconvenience; a feature that is missing and eats input is a bug report that
104
+ looks like something else entirely. `base.css` therefore ships:
105
+
106
+ ```css
107
+ @supports not selector(:popover-open) {
108
+ .juno-menu[popover],
109
+ .juno-popover[popover],
110
+ .juno-tooltip__bubble[popover] {
111
+ display: none;
112
+ }
113
+ }
114
+ ```
115
+
116
+ This makes failure mode 1 the _only_ failure mode: the panel is absent instead
117
+ of invisibly present, the page underneath is fully usable, and the app can
118
+ detect the same condition in one line
119
+ (`CSS.supports('selector(:popover-open)')`) and render a non-popover fallback.
120
+
121
+ `@supports selector()` itself needs Safari 14.1; below that the guard's
122
+ condition is invalid, the block is skipped, and behaviour is exactly what it is
123
+ today. Fail-open, and far below any floor here.
124
+
125
+ ### Anchored surfaces lose their anchor
126
+
127
+ Not a break — recorded here because it is the largest visual regression between
128
+ 17.5 and 26.0 and it is easy to mistake for one.
129
+
130
+ `.juno-menu`, `.juno-popover` and the top-layer tooltip position themselves with
131
+ `position-area` / `position-try-fallbacks`, which is Safari **26.0**. Below it
132
+ those declarations drop and the surfaces keep `position: fixed; inset: auto;
133
+ margin: 0` — so they render at their static position rather than beside their
134
+ trigger. They still open, close, light-dismiss and trap nothing.
135
+
136
+ The documented remedy is the app's: pin `inset` on the element's `toggle` event.
137
+ It is already noted in [popover.md](./components/popover.md) and is the reason
138
+ the legacy `.juno-popover-anchor` wrapper is still exported.
139
+
140
+ ## Why this document exists
141
+
142
+ Two silent failures of this exact class shipped and were found by looking at a
143
+ phone, not by any tool:
144
+
145
+ - **`backdrop-filter` unprefixed only.** Unsupported below Safari 18.0, so the
146
+ declaration was dropped and every glass surface — pillbar, dock, modal scrim —
147
+ rendered flat on iOS 17. Fixed 2026-08-05 by shipping
148
+ `-webkit-backdrop-filter` beside each use; both are in the bundle now, and the
149
+ audit table above tracks them as guarded.
150
+ - **`scrollbar-width: none`.** Unsupported below Safari 18.2, so the scrollable
151
+ tab strip kept a visible scrollbar on iOS 17 and 18.0–18.1. Fixed the same day
152
+ with a `::-webkit-scrollbar { display: none }` companion.
153
+
154
+ Neither produced an error. Both were "modern CSS, ships everywhere" right up
155
+ until someone opened the page on the device. That is the argument for stating a
156
+ floor and for auditing against the built bundle rather than against intent: the
157
+ cost of a missing feature is paid by the consumer, silently, at a time and place
158
+ junoui never sees.
159
+
160
+ **When you add a feature below the floor, say so here.** A new at-rule,
161
+ selector or property whose support is newer than Safari 17.5 belongs in the
162
+ audit table with its degrade-or-break verdict, and if the verdict is _break_ it
163
+ needs an `@supports` guard in the same change. Guards are for functional
164
+ failures only — a missing animation does not get one, and wrapping cosmetic
165
+ declarations in `@supports` costs bytes and buys nothing.
166
+
167
+ ## Related
168
+
169
+ - [ios-conformance.md](./ios-conformance.md) — iOS metrics and behaviour: safe
170
+ areas, tap targets, the viewport-unit decision, and the Home-Screen standalone
171
+ letterbox.
172
+ - [getting-started.md](./getting-started.md) — the required `viewport` meta,
173
+ which is a separate silent failure with the same shape.
@@ -48,6 +48,28 @@ Two things depend on it, and both fail **silently**:
48
48
 
49
49
  Details and sources: [ios-conformance.md](./ios-conformance.md).
50
50
 
51
+ ## Browser support
52
+
53
+ | | Safari / iOS | Chrome / Edge | Firefox |
54
+ | ------------------------------------------- | ------------ | ------------- | ------- |
55
+ | **Supported floor** — everything works | **17.5** | 117 | 129 |
56
+ | **Hard floor** — below this, overlays break | 17.0 | 114 | 125 |
57
+
58
+ The hard floor is the Popover API. Below it, `.juno-menu`, `.juno-popover`, the
59
+ top-layer tooltip and the pillbar's overflow slot cannot open; junoui hides them
60
+ with an `@supports` guard so they are absent rather than invisible-and-blocking,
61
+ and your app can branch on `CSS.supports('selector(:popover-open)')`. Between
62
+ the two floors the only loss is entry animations (`@starting-style`). Anchored
63
+ placement for those surfaces is a Safari 26 feature — below it they open at
64
+ their static position, and the app can pin `inset` on the `toggle` event.
65
+
66
+ The hard floor is what `package.json`'s `browserslist` declares. junoui runs no
67
+ autoprefixer of its own; the field is there for your build tooling.
68
+
69
+ **Every one of these failures is silent** — an engine drops CSS it does not
70
+ understand without reporting anything. The full audit, per feature, with the
71
+ degrade-vs-break verdict: [browser-support.md](./browser-support.md).
72
+
51
73
  ## The model
52
74
 
53
75
  - **Palette** — `standard` · `colorblind` · `soft`
@@ -0,0 +1,88 @@
1
+ # Icon subsetting
2
+
3
+ junoui's sprite carries **every** icon it ships. That is right for a `<use>`
4
+ reference to an external file (the browser fetches it once, caches it, and
5
+ unused symbols cost nothing to render) — but Safari intermittently drops
6
+ external sprite refs, so apps inline the sprite instead
7
+ ([`junoui/icons/inline`](../scripts/build-icons.mjs)), and an inlined sprite
8
+ puts every icon in the app's main bundle.
9
+
10
+ `junoui/subset` is the build-time half: a pure, dependency-free Node module a
11
+ consumer's build step runs over the shipped sprite.
12
+
13
+ ```js
14
+ import { readFileSync } from 'node:fs';
15
+ import { subsetSprite, spriteSymbolNames } from '@junoput01/junoui/subset';
16
+
17
+ const sprite = readFileSync(new URL(import.meta.resolve('@junoput01/junoui/icons')), 'utf8');
18
+ const svg = subsetSprite(sprite, ['gear', 'x', 'squares-four']);
19
+ ```
20
+
21
+ | Export | Returns |
22
+ | ----------------------------- | -------------------------------------------------------------------- |
23
+ | `spriteSymbolNames(sprite)` | every icon name the sprite defines, in document order. |
24
+ | `subsetSprite(sprite, names)` | the same sprite carrying only `names` — banner and wrapper verbatim. |
25
+
26
+ - **Names are bare icon names** (`'gear'`), never the prefixed symbol id.
27
+ - **An unknown name throws.** A silently dropped symbol renders as an empty
28
+ `<svg>` in the app, which nobody notices until a user reports a blank space —
29
+ so it fails the build instead.
30
+ - **The output is stable**: symbol order follows the sprite, not the request,
31
+ so a reordered list produces a byte-identical file.
32
+ - **The MIT Phosphor notice survives the trim** — carrying it is a
33
+ redistribution condition, so the banner is part of the contract, not
34
+ decoration.
35
+ - Node-only tooling on purpose: subsetting at runtime would defeat the point.
36
+
37
+ ## Vite
38
+
39
+ ```js
40
+ // vite.config.ts — a virtual module holding just this app's icons
41
+ import { subsetSprite } from '@junoput01/junoui/subset';
42
+
43
+ const ICONS = ['gear', 'x', 'squares-four']; // the app's manifest
44
+ const VIRTUAL = 'virtual:juno-icons-subset';
45
+
46
+ const iconSubset = {
47
+ name: 'juno-icon-subset',
48
+ resolveId: (id) => (id === VIRTUAL ? `\0${VIRTUAL}` : null),
49
+ load(id) {
50
+ if (id !== `\0${VIRTUAL}`) return null;
51
+ const sprite = readFileSync(new URL(import.meta.resolve('@junoput01/junoui/icons')), 'utf8');
52
+ return `export default ${JSON.stringify(subsetSprite(sprite, ICONS))}`;
53
+ },
54
+ };
55
+ ```
56
+
57
+ Keep the manifest where the app's own type for icon names is derived from it,
58
+ so a name that is not in the manifest is a **compile** error rather than a
59
+ blank space at runtime.
60
+
61
+ ## Injecting a subset
62
+
63
+ Safari intermittently drops external sprite refs (`<use href="file.svg#id">`),
64
+ so the reliable path is a **same-document** ref — which means the sprite has to
65
+ live in the document. `junoui/icons/inline` does that for the full set and
66
+ auto-installs on import; a consumer that subsets wants the injection without
67
+ the 25 kB of symbols, so the mechanism ships separately:
68
+
69
+ ```js
70
+ import { installSprite } from '@junoput01/junoui/icons/install';
71
+ import sprite from 'virtual:my-icon-subset'; // your build's subset (see above)
72
+
73
+ installSprite(sprite);
74
+ ```
75
+
76
+ | Export | Carries | Use |
77
+ | ---------------------- | ------------------------- | --------------------------------------------- |
78
+ | `junoui/icons/install` | ~1 kB, no icons | You subset. Pass your own sprite. |
79
+ | `junoui/icons/inline` | the full 66-symbol sprite | You don't subset. Import for the side effect. |
80
+
81
+ - **Both are id-guarded on the same `juno-icon-sprite` holder**, so importing
82
+ the full module alongside a subset does not produce two hidden holders
83
+ shadowing each other — whichever installs first wins and the second call is a
84
+ no-op returning `false`.
85
+ - `installSprite` returns `true` when it injected, `false` when a sprite was
86
+ already present or there is no document (server-side, pre-hydration).
87
+ - Subsetting only pays off if the full module never enters the bundle: import
88
+ `icons/install`, not `icons/inline`.
@@ -7,6 +7,10 @@ Most numbers the design community attributes to Apple are not in Apple's text.
7
7
  This page exists so nobody re-derives folklore, and so nobody "fixes" a correct
8
8
  value into a wrong one later.
9
9
 
10
+ This page is about **metrics and behaviour** on iOS. For _which iOS versions
11
+ junoui runs on at all_ — the supported floor, what degrades below it, and what
12
+ breaks — see [browser-support.md](./browser-support.md).
13
+
10
14
  > **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
11
15
  > returns an empty shell. Check the backing DocC JSON instead —
12
16
  > `developer.apple.com/tutorials/data/design/human-interface-guidelines/<page>.json`.
@@ -157,11 +161,10 @@ to `lv*`** — that is the spec-level cause of the classic `100vh` overflow: a
157
161
  junoui uses **zero raw `vh`**. Full-height surfaces use `dvh`
158
162
  (`layout.css`, `drawer.css`) and `85dvh` caps the bottom sheet.
159
163
 
160
- Caveats worth knowing before changing any of that:
164
+ Caveats that constrain the choice:
161
165
 
162
166
  - `dv*` is explicitly **not stable** and not guaranteed to update every frame,
163
- so it can churn while the address bar collapses. `sv*` is the calm choice when
164
- a surface must never overflow.
167
+ so it can churn while the address bar collapses. `sv*` never overflows.
165
168
  - iOS shipped viewport-unit bugs into the **iOS 26** era: Safari 26.0 fixed
166
169
  `lvh`/`vh` being sized against the _small_ viewport in `SFSafariViewController`.
167
170
  The underlying WebKit bug (255708, filed 2023) is **still open**, so Apple's
@@ -176,6 +179,193 @@ Caveats worth knowing before changing any of that:
176
179
  [Safari 26.0 release notes](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes),
177
180
  [WebKit bug 255708](https://bugs.webkit.org/show_bug.cgi?id=255708).
178
181
 
182
+ ### The decision: `dvh` stays at both call sites
183
+
184
+ Decided 2026-08-15 (ticket 20260803-033, step 1). `dvh` is kept for **both**
185
+ `.juno-app-shell` and `.juno-drawer`, against the "`sv*` is the calm choice"
186
+ instinct above. The reasoning below is the part to read: it generalises, the
187
+ verdict does not.
188
+
189
+ **First, price the two options at the only moment they differ** — when browser
190
+ chrome retracts. There is no other moment. Before it and after it, whichever
191
+ unit you picked is simply the current viewport.
192
+
193
+ | | what it costs when chrome retracts |
194
+ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `dvh` | The surface **grows**, so it relayouts. `dv*` is not frame-guaranteed, so that growth can arrive late or in steps. If the surface contains a scroller, the scroll port changes height _during the scroll that caused the retraction_. |
196
+ | `svh` | The surface **does not move** — and is now short by `lvh − svh`. That gap is permanent for the rest of the session, and it is at the bottom edge, where the page background shows through under whatever the surface pinned there. |
197
+
198
+ So this is not "stable vs twitchy". It is **one relayout** against **a permanent
199
+ dead strip**. Pick by asking which of those the surface can afford.
200
+
201
+ **`.juno-app-shell` — `block-size: 100dvh` (`layout.css`).** The shell's whole
202
+ job is to hold the dock against the bottom edge. Under `svh` the dock detaches
203
+ from that edge the first time the user scrolls and stays detached, floating
204
+ `lvh − svh` px up with page background beneath it — a visible defect on every
205
+ subsequent frame, under the primary navigation. The `dvh` relayout it avoids is
206
+ cheap here by construction: the shell is `overflow: hidden` and the scrolling
207
+ lives in `.juno-app-shell__main`, so growing the shell grows the scroll port and
208
+ moves the dock. **No text reflows** — no line breaking, no measured content, no
209
+ intrinsic sizing is touched. One cheap relayout beats a permanent gap.
210
+
211
+ **`.juno-drawer` — `block-size: 100dvh; max-block-size: 100dvh`
212
+ (`drawer.css`), and the same for the `--bottom` sheet's `60dvh`/`92dvh` and the
213
+ modal's `85dvh` cap.** Here the `dv*` instability **cannot bite at all**, and
214
+ that is the whole argument. The drawer is a `<dialog>` opened with
215
+ `showModal()`: it is in the top layer and the document beneath it is inert. iOS
216
+ Safari retracts and expands chrome in response to _document_ scroll — an inner
217
+ overflow scroller does not drive it — so chrome cannot change state for the
218
+ drawer's whole lifetime. `dvh` therefore never churns, and `svh` cannot prevent
219
+ an overflow that cannot happen. What `svh` _would_ still do is leave the gap:
220
+ open the drawer after scrolling (the ordinary case — you scroll, then reach for
221
+ the menu) and chrome is already retracted, so a `svh` drawer stops
222
+ `lvh − svh` px short of the bottom of a screen it is supposed to fill. `svh`
223
+ here is a pure loss.
224
+
225
+ > **The exception, and it is the app's to own.** A `<dialog>` opened with the
226
+ > `open` **attribute** instead of `showModal()` is _not_ in the top layer and
227
+ > does **not** block page scroll. Behind such a drawer the page scrolls, chrome
228
+ > retracts, and the churn is live. junoui's CSS cannot tell the two open paths
229
+ > apart. The drawer is documented as `showModal()`
230
+ > ([drawer.md](./components/drawer.md)); open it non-modally and the churn is yours.
231
+
232
+ ### The rule, for a component that does not exist yet
233
+
234
+ Ask one question — **can browser chrome change state while this surface is on
235
+ screen?** — and then:
236
+
237
+ 1. **It can, and the surface is anchored to the bottom edge** (or contains
238
+ something that is: a dock, a pillbar, a sticky footer) → **`dvh`**. You are
239
+ buying edge-adherence and paying one relayout per chrome transition.
240
+ 2. **It can, and the surface must not resize once laid out** — content whose
241
+ height JS measures, a canvas, an animation mid-flight, anything where a
242
+ late reflow is worse than a gap → **`svh`**. You are buying stability and
243
+ paying up to `lvh − svh` of dead space, permanently.
244
+ 3. **It cannot** — top-layer surfaces (`showModal()` dialogs, `popover`), where
245
+ the page beneath is inert → **`dvh`**, always. `svh` there buys nothing and
246
+ still pays the gap on any surface opened while chrome is retracted.
247
+ 4. **Never `vh` / `lvh` for a height that must fit.** `vh == lvh` sizes as if
248
+ chrome were retracted, so on a page where it is _not_, the box overflows by
249
+ exactly the chrome's height. That is the classic `100vh` bug and it is a
250
+ spec consequence, not a browser bug. The one legitimate `lvh` in this
251
+ codebase is the standalone unlock's spacer (`base.css`), which is _supposed_
252
+ to overflow.
253
+
254
+ **In `display-mode: standalone` this whole decision is moot** — there is no
255
+ retractable chrome, so `svh == dvh`. Measured on the device: `100dvh`,
256
+ `100svh` and `100%` all resolve to 812 while `100lvh` and `100vh` resolve to
257
+ 874, which is the letterbox defect below, not a chrome transition.
258
+
259
+ ### A note on raw `vw`
260
+
261
+ "Zero raw `vh`" is about **`vh`**. junoui does use raw `vw` in five places
262
+ (`layout.css:18`, `pillbar.css:231`, `popover.css:33`, `toast.css:26`,
263
+ `drawer.css:116`) and that is deliberate: horizontal chrome does not retract,
264
+ so `vw`/`lvw`/`svw`/`dvw` are the same number and the `vh` trap has no
265
+ horizontal twin. The real `vw` hazard is different — `100vw` includes the
266
+ classic scrollbar gutter, so a full-bleed `100vw` box overflows a desktop page
267
+ that has a scrollbar. Every call site above either caps well below `100vw`
268
+ (`min(…, 240px)`, `min(360px, …)`, `85vw`, `clamp()`) or subtracts more than a
269
+ scrollbar's width (`calc(100vw - var(--juno-space-24))`). Keep it that way; a
270
+ bare `inline-size: 100vw` is a bug.
271
+
272
+ ## Home-Screen standalone: the letterbox, and why `base.css` unlocks it
273
+
274
+ **The fact, and it is the most expensive thing this codebase has learned about
275
+ iOS: in `display-mode: standalone`, iOS sizes the window from the document's
276
+ RESTING scrollability at launch, and letterboxes a document that cannot scroll
277
+ by exactly `env(safe-area-inset-top)`.**
278
+
279
+ Measured, not inferred. iPhone 16 Pro (402×874 pt), iOS 18.7 / Safari 26.6:
280
+
281
+ | | |
282
+ | ---------------------------------------------- | ------- |
283
+ | `screen.height` | **874** |
284
+ | `window.innerHeight` · `visualViewport.height` | **812** |
285
+ | `100dvh` · `100svh` · `100%` | **812** |
286
+ | `100lvh` · `100vh` | **874** |
287
+ | `env(safe-area-inset-top)` | **62** |
288
+ | `env(safe-area-inset-bottom)` | 34 |
289
+ | `window.screenY` | 0 |
290
+
291
+ `874 − 812 = 62 = env(safe-area-inset-top)`, exactly. WebKit sizes the
292
+ standalone window as if a retractable toolbar existed, subtracts its height,
293
+ pins the window to the **top**, and then never covers the strip it reserved — so
294
+ the bottom 62 px of the display sits outside the web view and paints black on
295
+ every screen. This is a spec violation on its face: in standalone there is no
296
+ retractable browser UI, so the large and dynamic viewports **must** be equal
297
+ (css-values-4 §6.1.2.1), and here they differ by 62.
298
+
299
+ ### It is the resting structure, and only the resting structure
300
+
301
+ One install, four document structures, switched by a pill and **persisted across
302
+ cold launches** (`localStorage`), verdict taken per launch:
303
+
304
+ | document structure at rest | window |
305
+ | --------------------------------------------------------------------------------------- | --------------------- |
306
+ | document scrolls normally | **874** — full screen |
307
+ | fixed shell, an inner scroller, document cannot scroll | 812 — letterboxed |
308
+ | fixed shell, nothing scrollable anywhere | 812 — letterboxed |
309
+ | fixed shell **+ the document left scrollable behind it** by an invisible in-flow spacer | **874** — full screen |
310
+
311
+ **Transient scrollability is not enough.** Seven in-page interventions across
312
+ five controlled runs — with a placebo pinned to the first slot, rotation of the
313
+ rest, and `prior`/`during`/`afterUndo` sampling — all measured 812. Every one of
314
+ them varied scrollability for ~300 ms mid-session and undid itself. iOS samples
315
+ the structure at launch; the axis that decides the window was never varied.
316
+
317
+ Two device-proven negatives, recorded so nobody spends another round on them:
318
+
319
+ - **`apple-mobile-web-app-status-bar-style` makes no difference.** `black` and
320
+ `black-translucent` were each tested with a fresh Home-Screen install. Both
321
+ letterbox identically.
322
+ - **Nothing applied after first paint reaches it** — see the seven interventions
323
+ above. The window does correct itself to 874 spontaneously, between 6 seconds
324
+ and 43 minutes after navigation, and then holds for the life of that document;
325
+ a reload starts a new document, which starts letterboxed again.
326
+
327
+ ### What junoui does about it
328
+
329
+ `base.css` carries the **iOS standalone letterbox unlock**: keep the document
330
+ scrollable behind the app, using an invisible `body::after` spacer taller than
331
+ the large viewport, so the document always overflows whatever window iOS grants.
332
+ `overscroll-behavior: none` stops the ghost scroller rubber-banding; apps put
333
+ `overscroll-behavior: contain` on their real scrollers so an inner fling never
334
+ chains into it.
335
+
336
+ The gate is three conditions, all required — `display-mode: standalone` (only
337
+ installed apps letterbox), `pointer: coarse` (keeps macOS Dock apps out), and
338
+ `@supports (-webkit-touch-callout: none)` (iOS/iPadOS WebKit only). Selectors
339
+ carry `html:root` (specificity 0,1,2) deliberately: app resets commonly declare
340
+ `body { overflow: hidden }` at (0,0,1) _after_ this sheet, and the unlock has to
341
+ win the cascade without `!important`.
342
+
343
+ Consumer obligations, both silent if missed:
344
+
345
+ - **Ship the unlock at first parse.** iOS samples at launch, and a bundled
346
+ stylesheet arrives after it. An app whose shell paints before its CSS bundle
347
+ must inline a copy of the unlock in the document head — junoui's copy in
348
+ `juno.css` is too late on its own.
349
+ - **Do not override `body::after`.** The spacer is `body::after`; a consumer
350
+ that needs that pseudo-element for itself must reproduce the spacer at the
351
+ same gate. None of junoui's own components use it.
352
+
353
+ ### How to know it is fixed upstream
354
+
355
+ In standalone, `window.innerHeight === screen.height` **and
356
+ `100lvh === 100dvh`**, for a document that **cannot** scroll — that is the case
357
+ that still misbehaves. When that holds, the unlock is dead weight and can be
358
+ removed. Until then it is harmless where it does not apply, because the gate
359
+ excludes every non-iOS and non-installed context.
360
+
361
+ - Sources: nexora `CLAUDE.md` §15, entry dated 2026-08-13; the four-mode testbed
362
+ `web/public/expansion-demo.html` (kept as a standing rig); 201 device readings
363
+ collected by `scripts/viewport_probe_collect.py`. Tracked upstream as ticket
364
+ 20260812-006, drafted for WebKit Bugzilla / Feedback Assistant and awaiting
365
+ filing. Related Apple Developer Forums threads: 800798, 798014 (iOS 26
366
+ safe-area insets wrong until a background/resume — the "corrects itself later"
367
+ shape matches exactly), 797124.
368
+
179
369
  ## Typography
180
370
 
181
371
  iOS: **17 pt default body size, 11 pt minimum**; Dynamic Type must accommodate
@@ -194,6 +384,14 @@ iOS Safari is widely observed to zoom the page onto a focused field under 16px.
194
384
  behavior, not published spec. The mitigation is harmless, so it stays — but do
195
385
  not cite it as documented, and re-verify it on iOS 26.
196
386
 
387
+ The floor is now a checked claim, not an asserted one:
388
+ `test/visual/tap-targets.spec.mjs` reads the computed `font-size` of a
389
+ `.juno-input` under the coarse-pointer Playwright project and asserts `>= 16`.
390
+ That check is also what found the floor had never worked — the rule sat in
391
+ `base.css`'s `@media (pointer: coarse)` block, where a media query adds no
392
+ specificity, so `components/input.css`'s own `.juno-input` font-size beat it on
393
+ source order. It now lives in `input.css`. Keep it there.
394
+
197
395
  ## Things a stylesheet controls that Apple says nothing about
198
396
 
199
397
  No primary Apple source was found for any of: `touch-action`,
@@ -214,7 +412,11 @@ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
214
412
  added to the Home Screen opens as a web app by default** — "there are now zero
215
413
  requirements for 'installability'". junoui's CSS may therefore run in a
216
414
  standalone context, where `viewport-fit` and `env()` govern home-indicator and
217
- Dynamic Island clearance, for sites that never opted in.
415
+ Dynamic Island clearance, for sites that never opted in. The concrete
416
+ consequence is measured above in
417
+ [Home-Screen standalone: the letterbox](#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it) —
418
+ a site that never asked to be a web app now inherits both the letterbox and the
419
+ unlock.
218
420
 
219
421
  Unconfirmed leads, tracked in ticket 20260803-034: `vh` reportedly pinning to
220
422
  `window.outerHeight`; three new tab modes yielding different `innerHeight`; a
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.4.0",
3
+ "version": "0.6.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",
@@ -49,11 +49,14 @@
49
49
  "./ios": "./dist/ios/JunoTokens.swift",
50
50
  "./flutter": "./dist/flutter/juno_tokens.dart",
51
51
  "./icons": "./dist/icons/juno-icons.svg",
52
+ "./subset": "./tools/subset-sprite.mjs",
52
53
  "./icons/inline": "./dist/icons/inline.js",
54
+ "./icons/install": "./dist/icons/install.js",
53
55
  "./package.json": "./package.json"
54
56
  },
55
57
  "files": [
56
58
  "dist",
59
+ "tools",
57
60
  "src/css",
58
61
  "src/icons",
59
62
  "src/fonts",
@@ -78,6 +81,7 @@
78
81
  "showcase": "npm run build && node scripts/serve.mjs",
79
82
  "test:visual": "playwright test",
80
83
  "test:visual:update": "playwright test --update-snapshots",
84
+ "gate:consumer": "node scripts/consumer-gate.mjs",
81
85
  "changeset": "changeset",
82
86
  "version": "changeset version",
83
87
  "release": "npm run build && changeset publish"
@@ -92,5 +96,12 @@
92
96
  },
93
97
  "engines": {
94
98
  "node": ">=18"
95
- }
99
+ },
100
+ "browserslist": [
101
+ "iOS >= 17.0",
102
+ "Safari >= 17.0",
103
+ "Chrome >= 114",
104
+ "Edge >= 114",
105
+ "Firefox >= 125"
106
+ ]
96
107
  }