@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.
- package/CHANGELOG.md +188 -0
- package/README.md +44 -14
- package/dist/classes.json +1614 -0
- package/dist/css/juno.css +339 -37
- package/dist/icons/inline.js +7 -9
- package/dist/icons/install.js +26 -0
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +176 -0
- package/docs/components/button.md +11 -2
- package/docs/components/dock.md +34 -0
- package/docs/components/fold-slot.md +26 -1
- package/docs/conformance-kit.md +243 -0
- package/docs/getting-started.md +36 -0
- package/docs/icon-subsetting.md +29 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +403 -4
- package/docs/ios-pwa.md +273 -0
- package/docs/layout.md +35 -0
- package/package.json +15 -3
- package/src/css/base.css +103 -23
- package/src/css/components/button.css +42 -2
- package/src/css/components/dock.css +65 -6
- package/src/css/components/fold-slot.css +49 -3
- package/src/css/components/input.css +12 -0
- package/src/css/components/pillbar.css +1 -1
- package/src/css/components/segmented.css +15 -2
- package/src/css/overrides.css +51 -0
- package/tools/testing.mjs +177 -0
package/docs/integration.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Integrating junoui into an app
|
|
2
2
|
|
|
3
|
-
junoui is **presentational**: tokens + a CSS component layer,
|
|
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.
|
|
90
|
+
## 7. Guarding your class names
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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).
|
package/docs/ios-conformance.md
CHANGED
|
@@ -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
|
|
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*`
|
|
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
|