@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/accessibility.md
CHANGED
|
@@ -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`
|
|
32
|
-
|
|
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.
|
package/docs/components/dock.md
CHANGED
|
@@ -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.
|
package/docs/getting-started.md
CHANGED
|
@@ -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`
|
package/docs/icon-subsetting.md
CHANGED
|
@@ -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`.
|