@junoput01/junoui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ # The conformance kit — design
2
+
3
+ > **Status:** design, for review. Nothing here is implemented except where a
4
+ > section says "already shipped".
5
+ > **Ticket:** 20260826-036. **Owner:** iosdev.
6
+
7
+ ## The problem, stated from evidence
8
+
9
+ junoui is consumed by an app that then has to re-derive junoui's own facts to
10
+ use it safely. One week of auditing a single consumer (nexora) produced this
11
+ list, and every item is a thing the consumer had to build because the library
12
+ did not ship it:
13
+
14
+ | What the consumer built | Because junoui did not ship |
15
+ | --------------------------------------------------- | --------------------------------------------------- |
16
+ | `junoClasses.ts` + a class-name test | any statement of which class names exist |
17
+ | a cascade resolver (`cssCascade.ts`) | a way to ask what a property resolves to |
18
+ | `toppill_fit_verify.py`, `measure_mobile_chrome.py` | a way to measure a layout claim on a device profile |
19
+ | a three-bucket `env()` discipline, in prose | a rule for who adds the safe-area inset |
20
+ | `--nx-dock-clear` and the 320px arithmetic | a published item budget |
21
+ | local tap floors on four controls | floors on the primitives those controls imitate |
22
+
23
+ Two things make this worse than duplication.
24
+
25
+ **The consumer's copy drifts, silently and in the safe direction.** The dock
26
+ arithmetic subtracted 12px of inline padding where the pill spends 8, so every
27
+ per-item width was ~0.8px low for months. Nothing was visibly wrong. The number
28
+ was simply not the library's number any more.
29
+
30
+ **The library's own defects survive because nobody can see them from inside.**
31
+ `.juno-seg__option` sat in a `touch-action` list for as long as it existed; the
32
+ shipped class is `.juno-seg__opt`. `:where()` matched nothing, the rule parsed,
33
+ every other member kept working. One character, invisible to lint, to the
34
+ build, and to a screenshot. `.juno-list__item` was the same defect in the same
35
+ two lists. Both were found this week by a test written for the first one.
36
+
37
+ The kit's thesis: **mobile correctness is a library concern, and the library
38
+ should be able to prove its own claims.**
39
+
40
+ ## Design principles
41
+
42
+ 1. **A fact is published once and derived everywhere else.** If a consumer can
43
+ compute a number from junoui's geometry, junoui computes it and exports it.
44
+ `--juno-dock-edge-offset` is the shape: one declaration feeding both the
45
+ margin that paints and the clearance that reserves.
46
+ 2. **A name is generated, never typed twice.** Every list of junoui class names
47
+ inside junoui — `:where()` sets, docs tables, the manifest — comes from one
48
+ source. The `__option`/`__opt` class of defect stops being expressible.
49
+ 3. **A guard measures the artifact it is about.** Against the built bundle,
50
+ through a layout engine where used values matter, with a control proving the
51
+ measurement can register the defect. Stated as a rule because this kit's own
52
+ first test failed it: see [Anti-goals](#anti-goals).
53
+ 4. **The library ships the instrument, not just the answer.** A consumer that
54
+ can only read junoui's numbers still cannot check its own composition. The
55
+ doctor (F) exists so the answer is reproducible outside this repo.
56
+
57
+ ## A. Class contract shipped
58
+
59
+ **Ship** `dist/classes.json`, generated by `scripts/bundle-css.mjs` from the
60
+ selectors it already parses:
61
+
62
+ ```json
63
+ {
64
+ "version": "0.7.0",
65
+ "components": {
66
+ "seg": { "block": "juno-seg", "elements": ["juno-seg__opt"], "modifiers": ["juno-seg--sm"] }
67
+ },
68
+ "all": ["juno-seg", "juno-seg__opt", "..."]
69
+ }
70
+ ```
71
+
72
+ **Ship** `junoui/testing`, a framework-agnostic helper:
73
+
74
+ ```js
75
+ import { assertJunoClasses } from 'junoui/testing';
76
+ assertJunoClasses(['src/**/*.tsx']); // throws listing every juno-* class no rule defines
77
+ ```
78
+
79
+ Generalizes nexora's `junoClasses.ts`, which caught 11 misspelled BEM names
80
+ that compiled silently and broke actions on phones.
81
+
82
+ **Shipped**, with two revisions the implementation forced. Both came from
83
+ running the helper against a real consumer rather than from taste.
84
+
85
+ **The helper checks `all` by default, not `public`.** Measured on the 0.7.0
86
+ build: 310 classes have rules, 277 are named anywhere in `docs/`. The 33-name
87
+ difference is not an internals list — it is `juno-sr-only`, `juno-bg-s0`,
88
+ `juno-hide-below-lg`, `juno-eyebrow` and friends: public utilities nobody wrote
89
+ up. Defaulting to `public` would have failed consumers for using shipped API.
90
+ `public` stays available as a stricter surface, and the docs gap is junoui's to
91
+ close, tracked separately.
92
+
93
+ **The claim is "junoui ships nothing by this name", not "this is not a class".**
94
+ A consumer writes `junoPx('juno-pillbar-gap')` and `` `#juno-i-${n}` ``, and no
95
+ regex over source text distinguishes those from a class. First run against
96
+ nexora reported 24 names; 8 were tokens, an icon-id template and a keyframe —
97
+ all names junoui does ship. So the manifest carries `tokens`, `keyframes` and
98
+ `icons` alongside the classes, and component-local custom properties are read
99
+ from the **bundle**, not only `juno-tokens.css` (that alone accounted for three
100
+ of the eight).
101
+
102
+ Final state on nexora's 133 source files: the only reports are 13 in the fixture
103
+ file that deliberately names wrong classes, and 3 consumer-owned names.
104
+
105
+ ## B. Tap floors owned by the library
106
+
107
+ **Already shipped**, this week, and the evidence for the rest of the section:
108
+ `.juno-seg__opt` had no floor at all and computed 25.39px (20260826-025);
109
+ `.juno-btn--sm` shipped a 24px target that 40 consumer call sites used to mean
110
+ "secondary" (20260826-026).
111
+
112
+ **Remaining work:**
113
+
114
+ 1. **Audit every interactive primitive** for a coarse-pointer floor and record
115
+ the result in one table, in the repo, generated by the doctor rather than
116
+ written by hand. Anything that cannot hold 44 states why in that table.
117
+ 2. **Generate the `touch-action` and tap-highlight `:where()` lists from the
118
+ class manifest.** Today they are two hand-maintained lists that had drifted
119
+ from the classes _and from each other_ — the tap-highlight set is a strict
120
+ subset of the touch-action set, and nothing says whether that is deliberate.
121
+ Generated, the typo class of defect cannot exist and the divergence becomes
122
+ a visible decision.
123
+
124
+ **Open question for review:** the two lists should probably become one set with
125
+ a per-property opt-out, but "which primitives want `manipulation`" and "which
126
+ want the highlight killed" may genuinely differ. Needs a decision, not a merge.
127
+
128
+ ## C. Pointer-first responsiveness
129
+
130
+ **The defect:** a landscape iPhone is 844×390. It is wider than `md`, so a
131
+ width-keyed rail serves it the desktop rail on every screen, on a device held
132
+ in two hands. Width has never been the question.
133
+
134
+ **Ship one blessed mechanism**, and use it in junoui's own CSS so it cannot be
135
+ advisory:
136
+
137
+ ```css
138
+ /* the coarse-pointer condition, stated once */
139
+ @media (pointer: coarse) { ... }
140
+ ```
141
+
142
+ CSS has no portable way to name a media query (`@custom-media` is a draft and
143
+ junoui runs no PostCSS), so the honest form is: **junoui's CSS writes the
144
+ literal, and the string is exported for JS**, with a test asserting the two
145
+ agree.
146
+
147
+ ```js
148
+ import { COARSE_POINTER, useCoarsePointer } from 'junoui/testing'; // name TBD
149
+ ```
150
+
151
+ Key `rail--responsive`, the modal→sheet swap, and `density: auto` on it.
152
+
153
+ **Open question for review:** whether the condition is `(pointer: coarse)` or
154
+ `(pointer: coarse) and (max-height: …)`. A coarse-pointer 27" kiosk is a real
155
+ device and wants the rail. Nexora already ships a JS-side coarse branch in
156
+ Settings; that call site is the place to look before deciding.
157
+
158
+ ## D. Safe-area discipline in the library
159
+
160
+ Adopt nexora's three-bucket `env()` doc verbatim — it is the clearest statement
161
+ of this that exists in either repo, and junoui's own triage is missing its
162
+ third bucket:
163
+
164
+ | Bucket | Rule | Because |
165
+ | ------------------- | ------------------ | ------------------------------------------------------------------ |
166
+ | **edge padding** | `max(base, env())` | content already inside the box; the inset replaces the gap |
167
+ | **clearance** | `base + env()` | the chrome's offset already contains the inset; reserve must match |
168
+ | **floating chrome** | `base + env()` | a floating element sits off the edge; gap and inset stack |
169
+
170
+ Then: **every floating-chrome primitive applies its own bucket** — `.juno-toast`,
171
+ `.juno-pillbar`, `.juno-dock` — through a token the consumer can restate, which
172
+ `--juno-dock-edge-offset` already demonstrates. Extends the xreqs filed as
173
+ 20260803-037 and 20260815-053.
174
+
175
+ ## E. Published budgets
176
+
177
+ **Already shipped** for the dock (20260826-027): `--juno-dock-items`,
178
+ `--juno-dock-item-inline`, `--juno-dock-fit-inline`, `--juno-dock-chrome-inline`,
179
+ `--juno-dock-avail`, with the terms declared once and consumed by both the box
180
+ and the sum.
181
+
182
+ **Remaining:** the same treatment for `.juno-pillbar`, which publishes
183
+ `--juno-pillbar-item/-gap/-pad` but no derived budget, so a consumer's capacity
184
+ planner still does the division itself.
185
+
186
+ **Recorded limit, so nobody re-opens it:** junoui cannot publish a _scale_
187
+ floor. "What scale keeps a 44px target?" is `44px / item-width`, a ratio of two
188
+ lengths, and CSS cannot divide by a length. The library publishes the two
189
+ lengths; a consumer that must scale rather than drop compares them.
190
+
191
+ ## F. junoui doctor
192
+
193
+ A runnable conformance probe a consumer executes against **its own** build:
194
+
195
+ ```sh
196
+ npx junoui doctor --url http://localhost:5173 --profiles phone,phone-landscape
197
+ ```
198
+
199
+ Checks, generalized from nexora's `toppill_fit_verify.py` and
200
+ `measure_mobile_chrome.py`:
201
+
202
+ - every element matching a `public` manifest class holds its tap floor
203
+ - both orientations, because landscape is where the width assumption fails
204
+ - no horizontal overflow at 320px
205
+ - floating chrome clears the safe area, per bucket
206
+ - a consumer class does not out-resolve a junoui declaration it did not mean to
207
+
208
+ Reports as a table, exits non-zero, and prints what it did **not** cover.
209
+
210
+ ## Anti-goals
211
+
212
+ **No advisory rules.** Anything in G that the doctor cannot check is a
213
+ checklist item a reader will skip. Prefer fewer checks that run.
214
+
215
+ **No guard that has not been shown to fail.** This kit's own first tap-target
216
+ test passed with the fix deleted: it asserted against a showcase page, and
217
+ something in that page already held the height, so it could not fail. Measured:
218
+ 24px off the bundle, 44px off `/showcase/buttons.html`, same markup, same
219
+ emulation (20260826-030, cause still unidentified). Every check the doctor
220
+ ships must be mutation-tested against a reintroduced defect before it counts.
221
+
222
+ **No release gate that proves less than it claims.** `gate:consumer` reported
223
+ GREEN twice against a consumer checkout 260 commits behind its own develop —
224
+ the guard that would have failed did not exist on that branch (20260826-039).
225
+
226
+ ## Sequencing
227
+
228
+ | Slice | Contents | Depends on |
229
+ | ----- | -------------------------------------------- | ---------- |
230
+ | ~~1~~ | ~~A (manifest + helper)~~ — **shipped** | — |
231
+ | 2 | B2 (generate the `:where()` lists) | 1 |
232
+ | 3 | C (one pointer-first mechanism) | — |
233
+ | 4 | D (buckets + floating chrome owns its inset) | 3 |
234
+ | 5 | E (pillbar budget) | — |
235
+ | 6 | F (doctor) | 1, 3 |
236
+ | 7 | G (checklist, reduced to what F checks) | 6 |
237
+
238
+ Slice 1, B1 and E-for-dock are done. 024–027 are absorbed and closed.
239
+
240
+ **Review asks:** the three open questions above (A's public subset, B's two
241
+ lists, C's condition), and whether slice 6 is worth its cost before slice 1
242
+ lands — the doctor is the piece a consumer feels, and slices 1–5 are the pieces
243
+ that stop the defects existing.
@@ -48,6 +48,16 @@ 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
+ ### If the page can end up on a Home Screen
52
+
53
+ From iOS/iPadOS 26 that is every page — "there are now zero requirements for
54
+ 'installability'", and the user decides with an **Open as Web App** toggle when
55
+ they add it. So junoui's `display-mode: standalone` rules can run on a site that
56
+ never opted in. What to declare (or knowingly not declare), which status-bar
57
+ style does what, and the one stylesheet copy that must be inline in the `<head>`
58
+ rather than in your bundle:
59
+ [the consumer `<head>` contract](./ios-conformance.md#becoming-a-home-screen-web-app-the-consumer-head-contract).
60
+
51
61
  ## Browser support
52
62
 
53
63
  | | Safari / iOS | Chrome / Edge | Firefox |
@@ -70,6 +80,10 @@ autoprefixer of its own; the field is there for your build tooling.
70
80
  understand without reporting anything. The full audit, per feature, with the
71
81
  degrade-vs-break verdict: [browser-support.md](./browser-support.md).
72
82
 
83
+ Shipping to a phone or a Home Screen? [ios-pwa.md](./ios-pwa.md) is the
84
+ five-minute version — what junoui gives you, the eight things you must supply,
85
+ and what it does not do at all.
86
+
73
87
  ## The model
74
88
 
75
89
  - **Palette** — `standard` · `colorblind` · `soft`
@@ -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).
@@ -9,7 +9,10 @@ value into a wrong one later.
9
9
 
10
10
  This page is about **metrics and behaviour** on iOS. For _which iOS versions
11
11
  junoui runs on at all_ — the supported floor, what degrades below it, and what
12
- breaks — see [browser-support.md](./browser-support.md).
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.
13
16
 
14
17
  > **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
15
18
  > returns an empty shell. Check the backing DocC JSON instead —
@@ -269,6 +272,93 @@ that has a scrollbar. Every call site above either caps well below `100vw`
269
272
  scrollbar's width (`calc(100vw - var(--juno-space-24))`). Keep it that way; a
270
273
  bare `inline-size: 100vw` is a bug.
271
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
+
272
362
  ## Home-Screen standalone: the letterbox, and why `base.css` unlocks it
273
363
 
274
364
  **The fact, and it is the most expensive thing this codebase has learned about
@@ -358,6 +448,111 @@ that still misbehaves. When that holds, the unlock is dead weight and can be
358
448
  removed. Until then it is harmless where it does not apply, because the gate
359
449
  excludes every non-iOS and non-installed context.
360
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
+
361
556
  - Sources: nexora `CLAUDE.md` §15, entry dated 2026-08-13; the four-mode testbed
362
557
  `web/public/expansion-demo.html` (kept as a standing rig); 201 device readings
363
558
  collected by `scripts/viewport_probe_collect.py`. Tracked upstream as ticket
@@ -412,8 +607,10 @@ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
412
607
  added to the Home Screen opens as a web app by default** — "there are now zero
413
608
  requirements for 'installability'". junoui's CSS may therefore run in a
414
609
  standalone context, where `viewport-fit` and `env()` govern home-indicator and
415
- Dynamic Island clearance, for sites that never opted in. The concrete
416
- consequence is measured above 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
417
614
  [Home-Screen standalone: the letterbox](#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it) —
418
615
  a site that never asked to be a web app now inherits both the letterbox and the
419
616
  unlock.