@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.
- package/CHANGELOG.md +104 -0
- package/README.md +17 -16
- package/dist/classes.json +1614 -0
- package/dist/css/juno.css +199 -20
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +3 -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 +14 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +200 -3
- package/docs/ios-pwa.md +273 -0
- package/package.json +5 -2
- package/src/css/base.css +28 -7
- 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/segmented.css +15 -2
- package/tools/testing.mjs +177 -0
|
@@ -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.
|
package/docs/getting-started.md
CHANGED
|
@@ -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`
|
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
|
@@ -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.
|
|
416
|
-
|
|
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.
|