@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.
@@ -115,6 +115,12 @@ tokens land, and never removes a guarantee without a semver-major note.
115
115
  base layer raises `--juno-size-tap-min` to the comfortable 44px, so every
116
116
  control sized off the tap minimum grows on touch devices. A cascade override —
117
117
  the token values themselves don't change.
118
+ - **One control grows on one axis only.** `.juno-pagination`'s items read the tap
119
+ minimum for `min-inline-size` but carry a fixed `block-size` of 32px, so on a
120
+ coarse pointer they are 44 × 32 — above the 24px AA floor, below the 44px
121
+ enhanced one. Tracked as ticket 20260815-040; the numeric coarse-pointer check
122
+ in `test/visual/tap-targets.spec.mjs` does not cover pagination yet, which is
123
+ why it went unnoticed.
118
124
  - Hover-revealed affordances get a touch fallback: table row actions stay
119
125
  visible under `@media (hover: none)`.
120
126
 
@@ -0,0 +1,176 @@
1
+ # Browser support
2
+
3
+ What junoui requires, what it loses gracefully below that, and the short list of
4
+ things that genuinely **break**. Stated because the failures are otherwise
5
+ silent: an engine that does not understand a CSS at-rule, property or selector
6
+ **drops it without reporting anything**. Nobody finds out from a console; they
7
+ find out from a screenshot.
8
+
9
+ Audited against `dist/css/juno.css` at v0.5.0 (2026-08-15).
10
+
11
+ ## The floor
12
+
13
+ | | Safari / iOS | Chrome / Edge | Firefox |
14
+ | ------------------------------------------------------------ | ------------ | ------------- | ---------------------------------- |
15
+ | **Hard floor** — below this, things break | **17.0** | **114** | **125** |
16
+ | **Supported floor** — everything works, minor polish missing | **17.5** | 117 | 129 |
17
+ | **Full fidelity** — nothing missing | **26.0** | 125 | — (anchor positioning not shipped) |
18
+
19
+ **junoui's supported floor is Safari / iOS 17.5.** That is the version to put in
20
+ a consumer's own support matrix.
21
+
22
+ Read the three rows as three different questions:
23
+
24
+ - Below **17.0** the Popover API is absent and junoui's overlay surfaces stop
25
+ being overlays. See [What breaks](#what-breaks) — this is not a polish
26
+ question, and one of the two failure modes is worse than "the menu does not
27
+ open".
28
+ - Between **17.0 and 17.5** everything functions; entry animations are missing.
29
+ - Between **17.5 and 26.0** everything functions and animates; anchored
30
+ surfaces (menu, popover, tooltip) land at their static position instead of
31
+ next to their trigger, because CSS anchor positioning is a Safari 26 feature.
32
+ This is a real visual defect, not a nicety — but the surface still opens,
33
+ still closes, still light-dismisses, and the app can pin it (see below).
34
+
35
+ `package.json` carries a `browserslist` set to the **hard floor**, so tooling
36
+ reads the line below which junoui is broken rather than the line below which it
37
+ is imperfect. junoui itself runs no autoprefixer or transpiler — the field is a
38
+ declaration for consumers' build tools, not something junoui acts on.
39
+
40
+ ## Feature audit
41
+
42
+ Counts are occurrences in the built bundle.
43
+
44
+ | Feature | Uses | Safari / iOS | Below that |
45
+ | ------------------------------------------------------------------------------------- | ---- | ------------ | -------------------------------------------------------------------------------------------- |
46
+ | CSS anchor positioning (`position-area`, `position-try-fallbacks`, `position-anchor`) | 10 | **26.0** | degrades — [misplaced surfaces](#anchored-surfaces-lose-their-anchor) |
47
+ | `scrollbar-width` | 4 | 18.2 | **guarded** — `::-webkit-scrollbar` fallback ships |
48
+ | `backdrop-filter` (unprefixed) | 14 | 18.0 | **guarded** — `-webkit-backdrop-filter` ships beside every use |
49
+ | `@starting-style` | 10 | 17.5 | degrades — no entry animation |
50
+ | `transition-behavior` / `overlay` + `allow-discrete` | 10 | 17.4 | degrades — surfaces pop instead of fading |
51
+ | **Popover API** (`popover`, `popovertarget`, `:popover-open`) | 31 | **17.0** | **breaks** |
52
+ | `prefers-reduced-transparency` | 3 | 17.0 | degrades — blur stays on for users who asked it off |
53
+ | `@property` | 1 | 16.4 | degrades — the gauge jumps to its value instead of sweeping |
54
+ | `color-mix()` | 28 | 16.2 | degrades — role tints vanish (see the caveat below) |
55
+ | `@container` | 2 | 16.0 | degrades — card and table stop reflowing at narrow widths |
56
+ | `:has()` | 5 | 15.4 | degrades — the collapsible pillbar never collapses |
57
+ | `dvh` / `lvh` | 10 | 15.4 | breaks layout — `block-size` falls back to `auto`, the app shell collapses to content height |
58
+ | `oklch()` | 190 | 15.4 | breaks colour — every token value is `oklch()` |
59
+ | `aspect-ratio` | 7 | 15.0 | degrades — media tiles lose their ratio |
60
+ | `@supports selector()` | 1 | 14.1 | the Popover guard below is inert (fails open to today's behaviour) |
61
+
62
+ Two rows deserve a note.
63
+
64
+ **`color-mix()` is listed as a degrade, and that is technically true and
65
+ practically uncomfortable.** All 28 uses are tints, borders and shadows built
66
+ from `var(--juno-role)`; when the function is unsupported the declaration is
67
+ invalid and the property falls back to `transparent` or the previous cascade
68
+ value. Text and icon colour, which take `var(--juno-role)` directly, survive. So
69
+ nothing breaks — but role tinting is exactly the thing junoui exists to encode,
70
+ and losing it erodes the contract even while the layout holds. It sits below the
71
+ floor; it is listed so nobody discovers it by shipping to iOS 16.
72
+
73
+ **`oklch()` and `dvh` are below every floor in this document** and are listed for
74
+ completeness. If you are on iOS 15 you are not using junoui.
75
+
76
+ ## What breaks
77
+
78
+ Exactly one thing, at exactly one version, and it has two failure modes.
79
+
80
+ ### Popover API, Safari / iOS < 17.0
81
+
82
+ junoui's `.juno-menu`, `.juno-popover` and the top-layer `.juno-tooltip__bubble`
83
+ are built on the native Popover API. The open/close, light-dismiss and ESC
84
+ behaviour is the platform's; junoui ships only the surface. On an engine without
85
+ it:
86
+
87
+ 1. **They never open.** `popovertarget` does nothing, so there is no way to
88
+ reveal the panel. The pillbar's overflow slot routes to `.juno-menu`, so the
89
+ overflow items are unreachable — which on a phone is where the secondary
90
+ navigation lives.
91
+ 2. **Worse: `.juno-menu` and `.juno-popover` become invisible click-eaters.**
92
+ The UA rule that hides a closed popover (`display: none` on any `[popover]`
93
+ that is not `:popover-open`) does not exist on an unsupporting engine, so the
94
+ panel participates in layout. junoui's own base rule sets `opacity: 0`, so it is
95
+ invisible, but both surfaces are `position: fixed` with no `pointer-events`
96
+ reset. The result is a 256–280 px invisible fixed panel sitting over the page
97
+ at its static position, swallowing taps on whatever it covers.
98
+
99
+ (`.juno-tooltip__bubble` escapes this: it inherits `pointer-events: none`
100
+ from the CSS-only tooltip rule it shares.)
101
+
102
+ Failure mode 2 is the one that justifies a guard. A feature that is missing is
103
+ an inconvenience; a feature that is missing and eats input is a bug report that
104
+ looks like something else entirely. `base.css` therefore ships:
105
+
106
+ ```css
107
+ @supports not selector(:popover-open) {
108
+ .juno-menu[popover],
109
+ .juno-popover[popover],
110
+ .juno-tooltip__bubble[popover] {
111
+ display: none;
112
+ }
113
+ }
114
+ ```
115
+
116
+ This makes failure mode 1 the _only_ failure mode: the panel is absent instead
117
+ of invisibly present, the page underneath is fully usable, and the app can
118
+ detect the same condition in one line
119
+ (`CSS.supports('selector(:popover-open)')`) and render a non-popover fallback.
120
+
121
+ `@supports selector()` itself needs Safari 14.1; below that the guard's
122
+ condition is invalid, the block is skipped, and behaviour is exactly what it is
123
+ today. Fail-open, and far below any floor here.
124
+
125
+ ### Anchored surfaces lose their anchor
126
+
127
+ Not a break — recorded here because it is the largest visual regression between
128
+ 17.5 and 26.0 and it is easy to mistake for one.
129
+
130
+ `.juno-menu`, `.juno-popover` and the top-layer tooltip position themselves with
131
+ `position-area` / `position-try-fallbacks`, which is Safari **26.0**. Below it
132
+ those declarations drop and the surfaces keep `position: fixed; inset: auto;
133
+ margin: 0` — so they render at their static position rather than beside their
134
+ trigger. They still open, close, light-dismiss and trap nothing.
135
+
136
+ The documented remedy is the app's: pin `inset` on the element's `toggle` event.
137
+ It is already noted in [popover.md](./components/popover.md) and is the reason
138
+ the legacy `.juno-popover-anchor` wrapper is still exported.
139
+
140
+ ## Why this document exists
141
+
142
+ Two silent failures of this exact class shipped and were found by looking at a
143
+ phone, not by any tool:
144
+
145
+ - **`backdrop-filter` unprefixed only.** Unsupported below Safari 18.0, so the
146
+ declaration was dropped and every glass surface — pillbar, dock, modal scrim —
147
+ rendered flat on iOS 17. Fixed 2026-08-05 by shipping
148
+ `-webkit-backdrop-filter` beside each use; both are in the bundle now, and the
149
+ audit table above tracks them as guarded.
150
+ - **`scrollbar-width: none`.** Unsupported below Safari 18.2, so the scrollable
151
+ tab strip kept a visible scrollbar on iOS 17 and 18.0–18.1. Fixed the same day
152
+ with a `::-webkit-scrollbar { display: none }` companion.
153
+
154
+ Neither produced an error. Both were "modern CSS, ships everywhere" right up
155
+ until someone opened the page on the device. That is the argument for stating a
156
+ floor and for auditing against the built bundle rather than against intent: the
157
+ cost of a missing feature is paid by the consumer, silently, at a time and place
158
+ junoui never sees.
159
+
160
+ **When you add a feature below the floor, say so here.** A new at-rule,
161
+ selector or property whose support is newer than Safari 17.5 belongs in the
162
+ audit table with its degrade-or-break verdict, and if the verdict is _break_ it
163
+ needs an `@supports` guard in the same change. Guards are for functional
164
+ failures only — a missing animation does not get one, and wrapping cosmetic
165
+ declarations in `@supports` costs bytes and buys nothing.
166
+
167
+ ## Related
168
+
169
+ - [ios-pwa.md](./ios-pwa.md) — the five-minute version for a consumer about to
170
+ integrate: what these floors buy you, what you must ship yourself, and what
171
+ junoui does not do at all.
172
+ - [ios-conformance.md](./ios-conformance.md) — iOS metrics and behaviour: safe
173
+ areas, tap targets, the viewport-unit decision, and the Home-Screen standalone
174
+ letterbox.
175
+ - [getting-started.md](./getting-started.md) — the required `viewport` meta,
176
+ which is a separate silent failure with the same shape.
@@ -14,6 +14,7 @@
14
14
  | `.juno-btn` | Base. Primary (filled): background = role, text = `s0`. Default role = `active`. |
15
15
  | `.juno-btn--ghost` | Transparent, `data` text, `border` outline; hover → `s2`. |
16
16
  | `.juno-btn--sm` | Dense-toolbar size: `font.size.11`, `space.4` × `space.10`, `radius.3`. |
17
+ | `.juno-btn--dense` | On `--sm` only: keeps the 24px height on touch as well. Opt out by name. |
17
18
  | `.juno--<role>` | Sets the primary fill color. |
18
19
  | `:disabled` | `muted` fill, `label` text, not-allowed. |
19
20
 
@@ -28,5 +29,13 @@
28
29
  - Primary action filled with `nominal` (confirm) or `active` (apply).
29
30
  - `warning` fill only for genuinely critical/destructive actions.
30
31
  - One primary per group; everything else is ghost.
31
- - `--sm` drops below the WCAG tap-minimum on purpose — it is for dense **desktop**
32
- toolbars (40–46px chrome bars). Keep the default size anywhere touch is expected.
32
+ - `--sm` is a **density**, not a semantic. It is for dense desktop toolbars
33
+ (40–46px chrome bars): 24px on a fine pointer, the WCAG 2.2 AA floor (2.5.8)
34
+ exactly. On a **coarse pointer it promotes to `size.tap.min`** — type and
35
+ padding still shrink, but the tap target does not. Reaching for `--sm` to mean
36
+ "secondary" is the common mistake; `--ghost` is the secondary modifier and
37
+ stands alone at full size.
38
+ - `.juno-btn--dense` opts a `--sm` button back out of that promotion, for a
39
+ toolbar that is genuinely dense on touch (a scrubber, an editor rail). It does
40
+ nothing without `--sm`, on purpose: a dense touch target should be chosen by
41
+ name, never inherited from a size.
@@ -187,6 +187,40 @@ over scroll-event discretization — anything longer visibly lags the finger.
187
187
  plus the pill's padding and border), `--juno-dock-edge-gap` (what the
188
188
  floating margins take from `100%` — default `2 × space.12`).
189
189
 
190
+ ## How many items fit
191
+
192
+ `.juno-dock__item` is `flex: 1 1 0`, so the bar divides its inner width by
193
+ however many items are present. A consumer deciding **how many to render** — and
194
+ whether they still hold a tap target — reads that budget from junoui rather than
195
+ re-deriving it from the numbers in `dock.css`:
196
+
197
+ | Custom property | What it is |
198
+ | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
199
+ | `--juno-dock-items` | The item budget. **You set it** to what you render; junoui does not enforce it, it derives from it. |
200
+ | `--juno-dock-item-inline` | The width one item gets. A prediction of what the flex layout produces — asserted against the measured box in CI. |
201
+ | `--juno-dock-fit-inline` | The narrowest viewport at which every item still holds `--juno-size-tap-comfortable`. Below it, drop an item. |
202
+ | `--juno-dock-chrome-inline` | The bar's total inline chrome (margin + padding + border, both sides). `0` on the full-bleed bar, `34px` on the pill. |
203
+ | `--juno-dock-avail` | The width the budget divides. Defaults to `100vw`; override it when the bar is not viewport-wide. |
204
+
205
+ ```css
206
+ /* five destinations need 254px; below that, render four */
207
+ @media (max-width: 253px) {
208
+ .my-dock__item--secondary {
209
+ display: none;
210
+ }
211
+ }
212
+ ```
213
+
214
+ The margin, padding and border terms are declared **once** and consumed by both
215
+ the variant's own box and the sum above, so the budget cannot disagree with the
216
+ bar it describes — the same construction as `--juno-dock-edge-offset`.
217
+
218
+ **There is deliberately no scale floor.** "What scale keeps a 44px target?" is
219
+ `44px / --juno-dock-item-inline`, a ratio of two lengths, and CSS cannot divide
220
+ by a length. A consumer that must scale rather than drop compares those two
221
+ values itself. Prefer dropping an item: scaling a bar scales its hit areas with
222
+ it, which is the problem the floor was being computed to avoid.
223
+
190
224
  ## Anatomy (any platform)
191
225
 
192
226
  - Full-width bar on `s1`, hairline seam on the block-start edge; items split the
@@ -10,7 +10,7 @@ coming in, end-of-fade going out). Keep the element mounted; flip
10
10
 
11
11
  ```html
12
12
  <div class="juno-pillbar">
13
- <button class="juno-fold" data-juno-in aria-label="Scroll to top">
13
+ <button class="juno-fold juno-pillbar__item" data-juno-in aria-label="Scroll to top">
14
14
  <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-arrow-up" /></svg>
15
15
  </button>
16
16
  …other members…
@@ -24,6 +24,31 @@ coming in, end-of-fade going out). Keep the element mounted; flip
24
24
  | `--juno-fold-size` | The slot's open width (default `size.tap.comfortable`) — must be definite, transitions cannot run to an intrinsic size. |
25
25
  | `--juno-fold-gap` | The row's flex/grid gap (default `0px`) — the folded slot swallows one gap with a negative margin so the row closes completely. |
26
26
 
27
+ ## Composing it with a component class
28
+
29
+ The canonical use puts `.juno-fold` on an element that already carries a
30
+ component class — `.juno-pillbar__item`, `.juno-btn`, `.juno-chip` — because that
31
+ is where the capsule chrome (tap target, padding, hover, focus ring,
32
+ `aria-pressed`) lives. The fold is built for that, and two things about it are
33
+ worth knowing rather than rediscovering:
34
+
35
+ **Which floors it releases.** A border-box inline size has exactly three inputs
36
+ that can hold it above zero, and the folded state releases all three:
37
+ `min-inline-size` (a component's tap floor), `padding-inline`, and
38
+ `border-inline-width`. Each is in the fold's transition list too, so the content
39
+ neither snaps sideways as the fold starts nor jumps to full width when the slot
40
+ opens. Composed with `.juno-pillbar__item` and none of them released, the folded
41
+ slot lays out at 44px — the tap target — and the row never closes.
42
+
43
+ **It takes over the element's `transition`.** `transition` is a shorthand: two
44
+ rules setting it do not merge, the winner replaces the loser's whole list. The
45
+ fold's declarations are stated at attribute specificity so it wins that, and its
46
+ list therefore also carries the chrome properties (`color`, `background-color`)
47
+ the components animate — composing costs the capsule nothing. A component of your
48
+ own that composes with `.juno-fold` and needs a third property transitioned
49
+ should state it at higher specificity than `.juno-fold[data-juno-in]` (0,2,0),
50
+ repeating the fold's own entries, or the fold's list wins and yours is dropped.
51
+
27
52
  - The row's own width follows the slot, so the whole row slides open/closed.
28
53
  - `prefers-reduced-motion`: handled by the base layer (states still apply,
29
54
  transition durations collapse).
@@ -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,42 @@ 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
+
61
+ ## Browser support
62
+
63
+ | | Safari / iOS | Chrome / Edge | Firefox |
64
+ | ------------------------------------------- | ------------ | ------------- | ------- |
65
+ | **Supported floor** — everything works | **17.5** | 117 | 129 |
66
+ | **Hard floor** — below this, overlays break | 17.0 | 114 | 125 |
67
+
68
+ The hard floor is the Popover API. Below it, `.juno-menu`, `.juno-popover`, the
69
+ top-layer tooltip and the pillbar's overflow slot cannot open; junoui hides them
70
+ with an `@supports` guard so they are absent rather than invisible-and-blocking,
71
+ and your app can branch on `CSS.supports('selector(:popover-open)')`. Between
72
+ the two floors the only loss is entry animations (`@starting-style`). Anchored
73
+ placement for those surfaces is a Safari 26 feature — below it they open at
74
+ their static position, and the app can pin `inset` on the `toggle` event.
75
+
76
+ The hard floor is what `package.json`'s `browserslist` declares. junoui runs no
77
+ autoprefixer of its own; the field is there for your build tooling.
78
+
79
+ **Every one of these failures is silent** — an engine drops CSS it does not
80
+ understand without reporting anything. The full audit, per feature, with the
81
+ degrade-vs-break verdict: [browser-support.md](./browser-support.md).
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
+
51
87
  ## The model
52
88
 
53
89
  - **Palette** — `standard` · `colorblind` · `soft`
@@ -57,3 +57,32 @@ const iconSubset = {
57
57
  Keep the manifest where the app's own type for icon names is derived from it,
58
58
  so a name that is not in the manifest is a **compile** error rather than a
59
59
  blank space at runtime.
60
+
61
+ ## Injecting a subset
62
+
63
+ Safari intermittently drops external sprite refs (`<use href="file.svg#id">`),
64
+ so the reliable path is a **same-document** ref — which means the sprite has to
65
+ live in the document. `junoui/icons/inline` does that for the full set and
66
+ auto-installs on import; a consumer that subsets wants the injection without
67
+ the 25 kB of symbols, so the mechanism ships separately:
68
+
69
+ ```js
70
+ import { installSprite } from '@junoput01/junoui/icons/install';
71
+ import sprite from 'virtual:my-icon-subset'; // your build's subset (see above)
72
+
73
+ installSprite(sprite);
74
+ ```
75
+
76
+ | Export | Carries | Use |
77
+ | ---------------------- | ------------------------- | --------------------------------------------- |
78
+ | `junoui/icons/install` | ~1 kB, no icons | You subset. Pass your own sprite. |
79
+ | `junoui/icons/inline` | the full 66-symbol sprite | You don't subset. Import for the side effect. |
80
+
81
+ - **Both are id-guarded on the same `juno-icon-sprite` holder**, so importing
82
+ the full module alongside a subset does not produce two hidden holders
83
+ shadowing each other — whichever installs first wins and the second call is a
84
+ no-op returning `false`.
85
+ - `installSprite` returns `true` when it injected, `false` when a sprite was
86
+ already present or there is no document (server-side, pre-hydration).
87
+ - Subsetting only pays off if the full module never enters the bundle: import
88
+ `icons/install`, not `icons/inline`.