@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.
@@ -1,6 +1,6 @@
1
1
  # Integrating junoui into an app
2
2
 
3
- junoui is **presentational**: tokens + a CSS component layer, zero JS. It dresses a UI
3
+ junoui is **presentational**: tokens + a CSS component layer, no behavioural JS. It dresses a UI
4
4
  your app builds. This is the recipe for consuming it cleanly — and the rules for
5
5
  extending it without breaking the "one design across all apps" goal.
6
6
 
@@ -87,9 +87,55 @@ Do not remap junoui's roles to a brand hue — that erases the shared meaning ac
87
87
  Rule of thumb: extend **additively and namespaced**. If every app injects its own palette
88
88
  into junoui, the single-design guarantee dies.
89
89
 
90
- ## 7. Stateful behavior stays in your app
90
+ ## 7. Guarding your class names
91
91
 
92
- junoui ships no JS. Focus traps, list virtualization, popover positioning, data all
93
- yours (or a future sibling `junoui-<framework>` package). junoui gives you the stable,
94
- semantic DOM (real elements, BEM classes, ARIA hooks) to wire behavior + analytics onto;
95
- see [accessibility.md](./accessibility.md) for the per-component ARIA contract.
92
+ A `juno-*` class name in your source is a string that has to match something in
93
+ junoui's stylesheet, and nothing checks it. When it does not match, nothing
94
+ fails: the file compiles, the tests pass, and the element renders as unstyled UA
95
+ defaults. One consumer shipped eleven such names in a dialog; on a phone the
96
+ result was a confirm button off the bottom of the screen with no way to reach it.
97
+
98
+ junoui ships the check:
99
+
100
+ ```js
101
+ import { assertJunoClasses } from 'junoui/testing';
102
+
103
+ it('every juno class this app names exists', () => {
104
+ assertJunoClasses(['src/**/*.tsx'], {
105
+ // names YOUR stylesheet defines in the juno- namespace. Each one is a
106
+ // claim you are making — check it against your own sheet.
107
+ allowed: ['juno-icons-subset'],
108
+ });
109
+ });
110
+ ```
111
+
112
+ It reads `junoui/classes.json`, the manifest this build generates from its own
113
+ selectors, so it cannot drift from what you installed. It throws — no framework
114
+ needed — and it throws rather than passing when the globs match no files.
115
+
116
+ **What it answers:** "junoui ships nothing by this name." **What it does not:**
117
+ whether the class still does what your component assumes. A class that exists
118
+ but was repurposed upstream passes.
119
+
120
+ The manifest is readable directly (`junoui/classes.json`) for anything else you
121
+ want to assert: `all`, `public` (the documented subset), `roles`, `components`
122
+ grouped BEM-wise, plus the other namespaces junoui ships and a consumer writes
123
+ as bare strings — `tokens`, `keyframes`, `icons`.
124
+
125
+ ```js
126
+ import manifest from 'junoui/classes.json' with { type: 'json' };
127
+ manifest.components.seg; // { block, elements: ['juno-seg__opt'], modifiers: [...] }
128
+ ```
129
+
130
+ ## 8. Stateful behavior stays in your app
131
+
132
+ junoui ships no behavioural JS. Focus traps, scroll locking, gesture handling, list
133
+ virtualization, runtime popover positioning, data — all yours (or a future sibling
134
+ `junoui-<framework>` package). junoui gives you the stable, semantic DOM (real elements,
135
+ BEM classes, ARIA hooks) to wire behavior + analytics onto; see
136
+ [accessibility.md](./accessibility.md) for the per-component ARIA contract.
137
+
138
+ The only JS in the package is the two icon-sprite helpers, `icons/inline` and
139
+ `icons/install` — see [icon-subsetting.md](./icon-subsetting.md). The full
140
+ does-not-do list for phones and Home-Screen web apps is in
141
+ [ios-pwa.md](./ios-pwa.md).
@@ -7,6 +7,13 @@ 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). If you are about to
13
+ integrate and want the bounded version first — what is free, what you supply,
14
+ what junoui does not do — start at [ios-pwa.md](./ios-pwa.md) and come back
15
+ here for the derivations.
16
+
10
17
  > **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
11
18
  > returns an empty shell. Check the backing DocC JSON instead —
12
19
  > `developer.apple.com/tutorials/data/design/human-interface-guidelines/<page>.json`.
@@ -157,11 +164,10 @@ to `lv*`** — that is the spec-level cause of the classic `100vh` overflow: a
157
164
  junoui uses **zero raw `vh`**. Full-height surfaces use `dvh`
158
165
  (`layout.css`, `drawer.css`) and `85dvh` caps the bottom sheet.
159
166
 
160
- Caveats worth knowing before changing any of that:
167
+ Caveats that constrain the choice:
161
168
 
162
169
  - `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.
170
+ so it can churn while the address bar collapses. `sv*` never overflows.
165
171
  - iOS shipped viewport-unit bugs into the **iOS 26** era: Safari 26.0 fixed
166
172
  `lvh`/`vh` being sized against the _small_ viewport in `SFSafariViewController`.
167
173
  The underlying WebKit bug (255708, filed 2023) is **still open**, so Apple's
@@ -176,6 +182,385 @@ Caveats worth knowing before changing any of that:
176
182
  [Safari 26.0 release notes](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes),
177
183
  [WebKit bug 255708](https://bugs.webkit.org/show_bug.cgi?id=255708).
178
184
 
185
+ ### The decision: `dvh` stays at both call sites
186
+
187
+ Decided 2026-08-15 (ticket 20260803-033, step 1). `dvh` is kept for **both**
188
+ `.juno-app-shell` and `.juno-drawer`, against the "`sv*` is the calm choice"
189
+ instinct above. The reasoning below is the part to read: it generalises, the
190
+ verdict does not.
191
+
192
+ **First, price the two options at the only moment they differ** — when browser
193
+ chrome retracts. There is no other moment. Before it and after it, whichever
194
+ unit you picked is simply the current viewport.
195
+
196
+ | | what it costs when chrome retracts |
197
+ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
198
+ | `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_. |
199
+ | `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. |
200
+
201
+ So this is not "stable vs twitchy". It is **one relayout** against **a permanent
202
+ dead strip**. Pick by asking which of those the surface can afford.
203
+
204
+ **`.juno-app-shell` — `block-size: 100dvh` (`layout.css`).** The shell's whole
205
+ job is to hold the dock against the bottom edge. Under `svh` the dock detaches
206
+ from that edge the first time the user scrolls and stays detached, floating
207
+ `lvh − svh` px up with page background beneath it — a visible defect on every
208
+ subsequent frame, under the primary navigation. The `dvh` relayout it avoids is
209
+ cheap here by construction: the shell is `overflow: hidden` and the scrolling
210
+ lives in `.juno-app-shell__main`, so growing the shell grows the scroll port and
211
+ moves the dock. **No text reflows** — no line breaking, no measured content, no
212
+ intrinsic sizing is touched. One cheap relayout beats a permanent gap.
213
+
214
+ **`.juno-drawer` — `block-size: 100dvh; max-block-size: 100dvh`
215
+ (`drawer.css`), and the same for the `--bottom` sheet's `60dvh`/`92dvh` and the
216
+ modal's `85dvh` cap.** Here the `dv*` instability **cannot bite at all**, and
217
+ that is the whole argument. The drawer is a `<dialog>` opened with
218
+ `showModal()`: it is in the top layer and the document beneath it is inert. iOS
219
+ Safari retracts and expands chrome in response to _document_ scroll — an inner
220
+ overflow scroller does not drive it — so chrome cannot change state for the
221
+ drawer's whole lifetime. `dvh` therefore never churns, and `svh` cannot prevent
222
+ an overflow that cannot happen. What `svh` _would_ still do is leave the gap:
223
+ open the drawer after scrolling (the ordinary case — you scroll, then reach for
224
+ the menu) and chrome is already retracted, so a `svh` drawer stops
225
+ `lvh − svh` px short of the bottom of a screen it is supposed to fill. `svh`
226
+ here is a pure loss.
227
+
228
+ > **The exception, and it is the app's to own.** A `<dialog>` opened with the
229
+ > `open` **attribute** instead of `showModal()` is _not_ in the top layer and
230
+ > does **not** block page scroll. Behind such a drawer the page scrolls, chrome
231
+ > retracts, and the churn is live. junoui's CSS cannot tell the two open paths
232
+ > apart. The drawer is documented as `showModal()`
233
+ > ([drawer.md](./components/drawer.md)); open it non-modally and the churn is yours.
234
+
235
+ ### The rule, for a component that does not exist yet
236
+
237
+ Ask one question — **can browser chrome change state while this surface is on
238
+ screen?** — and then:
239
+
240
+ 1. **It can, and the surface is anchored to the bottom edge** (or contains
241
+ something that is: a dock, a pillbar, a sticky footer) → **`dvh`**. You are
242
+ buying edge-adherence and paying one relayout per chrome transition.
243
+ 2. **It can, and the surface must not resize once laid out** — content whose
244
+ height JS measures, a canvas, an animation mid-flight, anything where a
245
+ late reflow is worse than a gap → **`svh`**. You are buying stability and
246
+ paying up to `lvh − svh` of dead space, permanently.
247
+ 3. **It cannot** — top-layer surfaces (`showModal()` dialogs, `popover`), where
248
+ the page beneath is inert → **`dvh`**, always. `svh` there buys nothing and
249
+ still pays the gap on any surface opened while chrome is retracted.
250
+ 4. **Never `vh` / `lvh` for a height that must fit.** `vh == lvh` sizes as if
251
+ chrome were retracted, so on a page where it is _not_, the box overflows by
252
+ exactly the chrome's height. That is the classic `100vh` bug and it is a
253
+ spec consequence, not a browser bug. The one legitimate `lvh` in this
254
+ codebase is the standalone unlock's spacer (`base.css`), which is _supposed_
255
+ to overflow.
256
+
257
+ **In `display-mode: standalone` this whole decision is moot** — there is no
258
+ retractable chrome, so `svh == dvh`. Measured on the device: `100dvh`,
259
+ `100svh` and `100%` all resolve to 812 while `100lvh` and `100vh` resolve to
260
+ 874, which is the letterbox defect below, not a chrome transition.
261
+
262
+ ### A note on raw `vw`
263
+
264
+ "Zero raw `vh`" is about **`vh`**. junoui does use raw `vw` in five places
265
+ (`layout.css:18`, `pillbar.css:231`, `popover.css:33`, `toast.css:26`,
266
+ `drawer.css:116`) and that is deliberate: horizontal chrome does not retract,
267
+ so `vw`/`lvw`/`svw`/`dvw` are the same number and the `vh` trap has no
268
+ horizontal twin. The real `vw` hazard is different — `100vw` includes the
269
+ classic scrollbar gutter, so a full-bleed `100vw` box overflows a desktop page
270
+ that has a scrollbar. Every call site above either caps well below `100vw`
271
+ (`min(…, 240px)`, `min(360px, …)`, `85vw`, `clamp()`) or subtracts more than a
272
+ scrollbar's width (`calc(100vw - var(--juno-space-24))`). Keep it that way; a
273
+ bare `inline-size: 100vw` is a bug.
274
+
275
+ ## Becoming a Home-Screen web app: the consumer `<head>` contract
276
+
277
+ Everything in the next section is gated on `display-mode: standalone`. That mode
278
+ is not something junoui can enter for you and not something your CSS decides —
279
+ it is decided in the `<head>`, and now also by the user. So: what makes a page a
280
+ Home-Screen web app at all.
281
+
282
+ **Two ways in, and as of iOS 26 a third that nobody opts into.**
283
+
284
+ | Declaration | What it does | Source |
285
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | Manifest `"display": "standalone"` (or `"fullscreen"`) | The standards route: "create a manifest file … and serve it along with your website" | [WebKit — Web Push for Web Apps](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) |
287
+ | `<meta name="apple-mobile-web-app-capable" content="yes">` | Apple extension, iOS only: "Sets whether a web application runs in full-screen mode" | [Apple — Supported Meta Tags](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html) |
288
+ | **Nothing at all, on iOS/iPadOS 26** | "By default, every website added to the Home Screen opens as a web app" | [WebKit features in Safari 26.0](https://webkit.org/blog/17333/webkit-features-in-safari-26-0/) |
289
+
290
+ Ship one of the first two if you _want_ standalone. Ship neither and, from iOS
291
+ 26, you may get it anyway:
292
+
293
+ > "Simply put, there are now zero requirements for 'installability' in Safari."
294
+ > … "If the user prefers to add a bookmark for their browser, they can disable
295
+ > 'Open as Web App' when adding to Home Screen."
296
+
297
+ Read that second sentence carefully, because it is the part that changes how you
298
+ test: **standalone is now a user choice made at add-to-Home-Screen time, not a
299
+ property of your document.** The same build is both a tab and a web app
300
+ depending on a toggle you never see. Every `display-mode: standalone` rule
301
+ junoui ships — the letterbox unlock below is the whole list — therefore runs for
302
+ consumers who never asked for it, on a device where nothing in their `<head>`
303
+ said "web app".
304
+
305
+ `window.navigator.standalone` (read-only Boolean, Apple extension) reports the
306
+ answer at runtime; `matchMedia('(display-mode: standalone)')` is the standard
307
+ test and the one junoui's CSS uses. Check both — see
308
+ [the letterbox flag](#the-letterbox-flag-data-juno-letterboxed) for the exact
309
+ predicate.
310
+
311
+ ### The status bar style, and what it is not for
312
+
313
+ `<meta name="apple-mobile-web-app-status-bar-style" content="…">` takes exactly
314
+ three values, and Apple states it "has no effect unless you first specify
315
+ full-screen mode using `apple-mobile-web-app-capable`":
316
+
317
+ | Value | Status bar | Web content |
318
+ | ------------------- | --------------------- | ------------------------------------------------------- |
319
+ | `default` | normal | displayed **below** the status bar |
320
+ | `black` | black background | displayed **below** the status bar |
321
+ | `black-translucent` | black and translucent | **entire screen**, partially obscured by the status bar |
322
+
323
+ `default` is the default. Only `black-translucent` puts your content under the
324
+ status bar, which is the case where `env(safe-area-inset-top)` stops being
325
+ decorative and starts being the thing keeping your top row readable.
326
+
327
+ **It is not a letterbox remedy.** `black` and `black-translucent` were each
328
+ tested with a fresh Home-Screen install on the device; both letterbox
329
+ identically (see the next section). Pick the value for the status bar you want
330
+ and nothing else.
331
+
332
+ - Source: [Apple — Supported Meta Tags](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html)
333
+ (archived; carries no deprecation banner, and no replacement page for these
334
+ two tags was found on the current developer site).
335
+
336
+ ### The `<head>` junoui actually needs
337
+
338
+ Three lines, in one place, none of which a stylesheet can supply:
339
+
340
+ ```html
341
+ <!-- 1. required always — safe areas are inert without viewport-fit=cover,
342
+ and 1 CSS px = 1 pt only with width=device-width -->
343
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
344
+
345
+ <!-- 2. only if you want standalone deliberately; from iOS 26 the user can
346
+ grant it without you -->
347
+ <meta name="apple-mobile-web-app-capable" content="yes" />
348
+ <!-- or a manifest with "display": "standalone" -->
349
+
350
+ <!-- 3. if your shell paints before its CSS bundle: an inline copy of the
351
+ standalone unlock, because iOS samples the document at launch -->
352
+ <style>
353
+ /* … see "Consumer obligations" below … */
354
+ </style>
355
+ ```
356
+
357
+ Line 1 is expanded, with the failure modes, in
358
+ [getting-started.md](./getting-started.md). Line 3 is the first-parse obligation
359
+ below, and it is the one that is silent both ways: miss it and the app
360
+ letterboxes, ship it and nothing tells you it worked except a screenshot.
361
+
362
+ ## Home-Screen standalone: the letterbox, and why `base.css` unlocks it
363
+
364
+ **The fact, and it is the most expensive thing this codebase has learned about
365
+ iOS: in `display-mode: standalone`, iOS sizes the window from the document's
366
+ RESTING scrollability at launch, and letterboxes a document that cannot scroll
367
+ by exactly `env(safe-area-inset-top)`.**
368
+
369
+ Measured, not inferred. iPhone 16 Pro (402×874 pt), iOS 18.7 / Safari 26.6:
370
+
371
+ | | |
372
+ | ---------------------------------------------- | ------- |
373
+ | `screen.height` | **874** |
374
+ | `window.innerHeight` · `visualViewport.height` | **812** |
375
+ | `100dvh` · `100svh` · `100%` | **812** |
376
+ | `100lvh` · `100vh` | **874** |
377
+ | `env(safe-area-inset-top)` | **62** |
378
+ | `env(safe-area-inset-bottom)` | 34 |
379
+ | `window.screenY` | 0 |
380
+
381
+ `874 − 812 = 62 = env(safe-area-inset-top)`, exactly. WebKit sizes the
382
+ standalone window as if a retractable toolbar existed, subtracts its height,
383
+ pins the window to the **top**, and then never covers the strip it reserved — so
384
+ the bottom 62 px of the display sits outside the web view and paints black on
385
+ every screen. This is a spec violation on its face: in standalone there is no
386
+ retractable browser UI, so the large and dynamic viewports **must** be equal
387
+ (css-values-4 §6.1.2.1), and here they differ by 62.
388
+
389
+ ### It is the resting structure, and only the resting structure
390
+
391
+ One install, four document structures, switched by a pill and **persisted across
392
+ cold launches** (`localStorage`), verdict taken per launch:
393
+
394
+ | document structure at rest | window |
395
+ | --------------------------------------------------------------------------------------- | --------------------- |
396
+ | document scrolls normally | **874** — full screen |
397
+ | fixed shell, an inner scroller, document cannot scroll | 812 — letterboxed |
398
+ | fixed shell, nothing scrollable anywhere | 812 — letterboxed |
399
+ | fixed shell **+ the document left scrollable behind it** by an invisible in-flow spacer | **874** — full screen |
400
+
401
+ **Transient scrollability is not enough.** Seven in-page interventions across
402
+ five controlled runs — with a placebo pinned to the first slot, rotation of the
403
+ rest, and `prior`/`during`/`afterUndo` sampling — all measured 812. Every one of
404
+ them varied scrollability for ~300 ms mid-session and undid itself. iOS samples
405
+ the structure at launch; the axis that decides the window was never varied.
406
+
407
+ Two device-proven negatives, recorded so nobody spends another round on them:
408
+
409
+ - **`apple-mobile-web-app-status-bar-style` makes no difference.** `black` and
410
+ `black-translucent` were each tested with a fresh Home-Screen install. Both
411
+ letterbox identically.
412
+ - **Nothing applied after first paint reaches it** — see the seven interventions
413
+ above. The window does correct itself to 874 spontaneously, between 6 seconds
414
+ and 43 minutes after navigation, and then holds for the life of that document;
415
+ a reload starts a new document, which starts letterboxed again.
416
+
417
+ ### What junoui does about it
418
+
419
+ `base.css` carries the **iOS standalone letterbox unlock**: keep the document
420
+ scrollable behind the app, using an invisible `body::after` spacer taller than
421
+ the large viewport, so the document always overflows whatever window iOS grants.
422
+ `overscroll-behavior: none` stops the ghost scroller rubber-banding; apps put
423
+ `overscroll-behavior: contain` on their real scrollers so an inner fling never
424
+ chains into it.
425
+
426
+ The gate is three conditions, all required — `display-mode: standalone` (only
427
+ installed apps letterbox), `pointer: coarse` (keeps macOS Dock apps out), and
428
+ `@supports (-webkit-touch-callout: none)` (iOS/iPadOS WebKit only). Selectors
429
+ carry `html:root` (specificity 0,1,2) deliberately: app resets commonly declare
430
+ `body { overflow: hidden }` at (0,0,1) _after_ this sheet, and the unlock has to
431
+ win the cascade without `!important`.
432
+
433
+ Consumer obligations, both silent if missed:
434
+
435
+ - **Ship the unlock at first parse.** iOS samples at launch, and a bundled
436
+ stylesheet arrives after it. An app whose shell paints before its CSS bundle
437
+ must inline a copy of the unlock in the document head — junoui's copy in
438
+ `juno.css` is too late on its own.
439
+ - **Do not override `body::after`.** The spacer is `body::after`; a consumer
440
+ that needs that pseudo-element for itself must reproduce the spacer at the
441
+ same gate. None of junoui's own components use it.
442
+
443
+ ### How to know it is fixed upstream
444
+
445
+ In standalone, `window.innerHeight === screen.height` **and
446
+ `100lvh === 100dvh`**, for a document that **cannot** scroll — that is the case
447
+ that still misbehaves. When that holds, the unlock is dead weight and can be
448
+ removed. Until then it is harmless where it does not apply, because the gate
449
+ excludes every non-iOS and non-installed context.
450
+
451
+ ### The letterbox flag: `data-juno-letterboxed`
452
+
453
+ The unlock above _prevents_ the letterbox. Nothing above lets a page **react to
454
+ it happening anyway**, and it does happen — to a page whose first-parse copy of
455
+ the unlock is missing, whose `body::after` is taken, or which is not a junoui
456
+ page at all. So junoui fixes the name of that fact, once, here.
457
+
458
+ **The contract, in four lines:**
459
+
460
+ | | |
461
+ | --------------- | ---------------------------------------------------------------------------------------------------- |
462
+ | Attribute | `data-juno-letterboxed`, boolean (present or absent — the value is never read) |
463
+ | Element | the root element, `<html>` |
464
+ | Who sets it | **the app.** junoui ships no JS for this; see below |
465
+ | What it asserts | this window is in standalone display mode **and** is shorter than the screen it is on. Nothing else. |
466
+
467
+ **The predicate, so two consumers compute the same answer:**
468
+
469
+ ```js
470
+ const SLACK_PX = 1;
471
+
472
+ const standalone =
473
+ matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
474
+
475
+ function isLetterboxed({ standalone, innerHeight, screenHeight }) {
476
+ if (!standalone) return false;
477
+ if (!screenHeight || !innerHeight) return false; // the browser is not telling us
478
+ return innerHeight < screenHeight - SLACK_PX;
479
+ }
480
+ ```
481
+
482
+ Each guard earns its place:
483
+
484
+ - **Standalone is part of the question, not an optimisation.** In a browser tab
485
+ the window is legitimately shorter than the screen — that is where the
486
+ toolbars are — and `env(safe-area-inset-*)` reads `0` there anyway, so
487
+ flagging a tab would be both wrong and pointless.
488
+ - **One pixel of slack**, because the comparison is between two integers from
489
+ different APIs describing the same screen; an exact test is brittle for no
490
+ benefit.
491
+ - **A zero guard**, because `screen.height === 0` means the browser is not
492
+ answering, and "shorter than nothing" is not a verdict.
493
+
494
+ **Watch it; do not sample it once.** The window corrects itself to the full
495
+ height unprompted, between **6 seconds and 43 minutes** after navigation, and
496
+ then holds for the life of that document. Re-evaluate on
497
+ `visualViewport.resize` — the event that fired for all seven observed
498
+ corrections — plus `window.resize` and `orientationchange`, because rotation
499
+ changes both dimensions and only the latter is guaranteed to report it.
500
+
501
+ **What CSS may key off it — one thing.** Inside the 812 px window
502
+ `env(safe-area-inset-bottom)` still reports **34**, but the home indicator is at
503
+ screen y 840–874, _outside_ the window. A layout that correctly honours the
504
+ inset is reserving room for something that is not in the view, on an edge
505
+ already 62 px clear of the glass. So the sanctioned use is to zero the bottom
506
+ inset, and only the bottom inset:
507
+
508
+ ```css
509
+ :root {
510
+ --app-safe-bottom: env(safe-area-inset-bottom, 0px);
511
+ }
512
+ html[data-juno-letterboxed] {
513
+ --app-safe-bottom: 0px;
514
+ }
515
+ ```
516
+
517
+ Do **not** key the top inset off it, and do not use it to disable the unlock.
518
+ The 62 px top strip is genuinely reserved by the window manager; only the bottom
519
+ inset is a phantom.
520
+
521
+ **What junoui does with it today: nothing, and that is deliberate.** junoui's
522
+ own clearances and the nine other `env(safe-area-inset-bottom)` call sites read
523
+ the raw `env()` — eleven in all, across `base.css` (2), `dock.css` (3),
524
+ `pillbar.css` (3), `modal.css`, `drawer.css` and `toast.css` — so a junoui app
525
+ in a letterboxed window over-reserves at the bottom regardless of what the app
526
+ sets: an `env()` inside a component's `calc()` is not overridable from app CSS.
527
+ Fixing that means routing every bottom call site through a single
528
+ `--juno-safe-bottom` indirection, which is a CSS change across six files with a
529
+ visual-regression pass of its own. It is tracked as
530
+ ticket 20260815-039, and doing it by halves is worse than not doing it: convert
531
+ the clearance tokens but not `dock.css`'s margin and the bar moves while its
532
+ clearance does not.
533
+
534
+ **Why no junoui JS module.** junoui already ships two JS entry points
535
+ (`icons/inline`, `icons/install`), so a third would not be unprecedented. It is
536
+ still wrong here: the module is ~30 lines with no junoui-specific content — it
537
+ reads two DOM globals and writes an attribute — and shipping it would put a
538
+ workaround into the public API, with a semver commitment, a release, and a test,
539
+ for a defect whose entire purpose is to be deleted when WebKit fixes the window.
540
+ A **name** costs nothing and is free to delete. Re-open this decision if the
541
+ flag ever gates junoui's own CSS (20260815-039): a rule junoui ships with no
542
+ supported way to satisfy it would be worse than either half alone.
543
+
544
+ **It is not an upstream-fix detector.** The test in the previous section needs a
545
+ document that _cannot_ scroll; the unlock makes the document scroll, so a
546
+ correctly integrated junoui app reads `false` while the bug is very much alive.
547
+ The flag catches residual cases, not the platform.
548
+
549
+ **Prior art, and why the name is fixed now rather than later.** nexora sets
550
+ `data-nx-letterboxed` from `web/src/letterbox.ts` — that module's predicate is
551
+ the one reproduced above, and it predates this contract. New consumers use
552
+ `data-juno-letterboxed`. The cost of leaving it unnamed is that the next
553
+ consumer invents a third spelling for the same fact and none of the three CSS
554
+ rules can ever move into junoui.
555
+
556
+ - Sources: nexora `CLAUDE.md` §15, entry dated 2026-08-13; the four-mode testbed
557
+ `web/public/expansion-demo.html` (kept as a standing rig); 201 device readings
558
+ collected by `scripts/viewport_probe_collect.py`. Tracked upstream as ticket
559
+ 20260812-006, drafted for WebKit Bugzilla / Feedback Assistant and awaiting
560
+ filing. Related Apple Developer Forums threads: 800798, 798014 (iOS 26
561
+ safe-area insets wrong until a background/resume — the "corrects itself later"
562
+ shape matches exactly), 797124.
563
+
179
564
  ## Typography
180
565
 
181
566
  iOS: **17 pt default body size, 11 pt minimum**; Dynamic Type must accommodate
@@ -194,6 +579,14 @@ iOS Safari is widely observed to zoom the page onto a focused field under 16px.
194
579
  behavior, not published spec. The mitigation is harmless, so it stays — but do
195
580
  not cite it as documented, and re-verify it on iOS 26.
196
581
 
582
+ The floor is now a checked claim, not an asserted one:
583
+ `test/visual/tap-targets.spec.mjs` reads the computed `font-size` of a
584
+ `.juno-input` under the coarse-pointer Playwright project and asserts `>= 16`.
585
+ That check is also what found the floor had never worked — the rule sat in
586
+ `base.css`'s `@media (pointer: coarse)` block, where a media query adds no
587
+ specificity, so `components/input.css`'s own `.juno-input` font-size beat it on
588
+ source order. It now lives in `input.css`. Keep it there.
589
+
197
590
  ## Things a stylesheet controls that Apple says nothing about
198
591
 
199
592
  No primary Apple source was found for any of: `touch-action`,
@@ -214,7 +607,13 @@ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
214
607
  added to the Home Screen opens as a web app by default** — "there are now zero
215
608
  requirements for 'installability'". junoui's CSS may therefore run in a
216
609
  standalone context, where `viewport-fit` and `env()` govern home-indicator and
217
- Dynamic Island clearance, for sites that never opted in.
610
+ Dynamic Island clearance, for sites that never opted in. What that means for
611
+ your `<head>` is
612
+ [the consumer contract above](#becoming-a-home-screen-web-app-the-consumer-head-contract);
613
+ the concrete consequence is measured in
614
+ [Home-Screen standalone: the letterbox](#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it) —
615
+ a site that never asked to be a web app now inherits both the letterbox and the
616
+ unlock.
218
617
 
219
618
  Unconfirmed leads, tracked in ticket 20260803-034: `vh` reportedly pinning to
220
619
  `window.outerHeight`; three new tab modes yielding different `innerHeight`; a