@junoput01/junoui 0.4.0 → 0.6.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 +97 -0
- package/README.md +29 -0
- package/dist/css/juno.css +144 -21
- package/dist/icons/inline.js +8 -10
- package/dist/icons/install.js +26 -0
- package/dist/icons/juno-icons.svg +1 -0
- package/docs/browser-support.md +173 -0
- package/docs/getting-started.md +22 -0
- package/docs/icon-subsetting.md +88 -0
- package/docs/ios-conformance.md +206 -4
- package/docs/layout.md +35 -0
- package/package.json +13 -2
- package/src/css/base.css +79 -20
- package/src/css/components/input.css +12 -0
- package/src/css/components/pillbar.css +1 -1
- package/src/css/overrides.css +51 -0
- package/src/icons/cloud-slash.svg +1 -0
- package/tools/subset-sprite.mjs +56 -0
|
@@ -0,0 +1,173 @@
|
|
|
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-conformance.md](./ios-conformance.md) — iOS metrics and behaviour: safe
|
|
170
|
+
areas, tap targets, the viewport-unit decision, and the Home-Screen standalone
|
|
171
|
+
letterbox.
|
|
172
|
+
- [getting-started.md](./getting-started.md) — the required `viewport` meta,
|
|
173
|
+
which is a separate silent failure with the same shape.
|
package/docs/getting-started.md
CHANGED
|
@@ -48,6 +48,28 @@ 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
|
+
## Browser support
|
|
52
|
+
|
|
53
|
+
| | Safari / iOS | Chrome / Edge | Firefox |
|
|
54
|
+
| ------------------------------------------- | ------------ | ------------- | ------- |
|
|
55
|
+
| **Supported floor** — everything works | **17.5** | 117 | 129 |
|
|
56
|
+
| **Hard floor** — below this, overlays break | 17.0 | 114 | 125 |
|
|
57
|
+
|
|
58
|
+
The hard floor is the Popover API. Below it, `.juno-menu`, `.juno-popover`, the
|
|
59
|
+
top-layer tooltip and the pillbar's overflow slot cannot open; junoui hides them
|
|
60
|
+
with an `@supports` guard so they are absent rather than invisible-and-blocking,
|
|
61
|
+
and your app can branch on `CSS.supports('selector(:popover-open)')`. Between
|
|
62
|
+
the two floors the only loss is entry animations (`@starting-style`). Anchored
|
|
63
|
+
placement for those surfaces is a Safari 26 feature — below it they open at
|
|
64
|
+
their static position, and the app can pin `inset` on the `toggle` event.
|
|
65
|
+
|
|
66
|
+
The hard floor is what `package.json`'s `browserslist` declares. junoui runs no
|
|
67
|
+
autoprefixer of its own; the field is there for your build tooling.
|
|
68
|
+
|
|
69
|
+
**Every one of these failures is silent** — an engine drops CSS it does not
|
|
70
|
+
understand without reporting anything. The full audit, per feature, with the
|
|
71
|
+
degrade-vs-break verdict: [browser-support.md](./browser-support.md).
|
|
72
|
+
|
|
51
73
|
## The model
|
|
52
74
|
|
|
53
75
|
- **Palette** — `standard` · `colorblind` · `soft`
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Icon subsetting
|
|
2
|
+
|
|
3
|
+
junoui's sprite carries **every** icon it ships. That is right for a `<use>`
|
|
4
|
+
reference to an external file (the browser fetches it once, caches it, and
|
|
5
|
+
unused symbols cost nothing to render) — but Safari intermittently drops
|
|
6
|
+
external sprite refs, so apps inline the sprite instead
|
|
7
|
+
([`junoui/icons/inline`](../scripts/build-icons.mjs)), and an inlined sprite
|
|
8
|
+
puts every icon in the app's main bundle.
|
|
9
|
+
|
|
10
|
+
`junoui/subset` is the build-time half: a pure, dependency-free Node module a
|
|
11
|
+
consumer's build step runs over the shipped sprite.
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { subsetSprite, spriteSymbolNames } from '@junoput01/junoui/subset';
|
|
16
|
+
|
|
17
|
+
const sprite = readFileSync(new URL(import.meta.resolve('@junoput01/junoui/icons')), 'utf8');
|
|
18
|
+
const svg = subsetSprite(sprite, ['gear', 'x', 'squares-four']);
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Export | Returns |
|
|
22
|
+
| ----------------------------- | -------------------------------------------------------------------- |
|
|
23
|
+
| `spriteSymbolNames(sprite)` | every icon name the sprite defines, in document order. |
|
|
24
|
+
| `subsetSprite(sprite, names)` | the same sprite carrying only `names` — banner and wrapper verbatim. |
|
|
25
|
+
|
|
26
|
+
- **Names are bare icon names** (`'gear'`), never the prefixed symbol id.
|
|
27
|
+
- **An unknown name throws.** A silently dropped symbol renders as an empty
|
|
28
|
+
`<svg>` in the app, which nobody notices until a user reports a blank space —
|
|
29
|
+
so it fails the build instead.
|
|
30
|
+
- **The output is stable**: symbol order follows the sprite, not the request,
|
|
31
|
+
so a reordered list produces a byte-identical file.
|
|
32
|
+
- **The MIT Phosphor notice survives the trim** — carrying it is a
|
|
33
|
+
redistribution condition, so the banner is part of the contract, not
|
|
34
|
+
decoration.
|
|
35
|
+
- Node-only tooling on purpose: subsetting at runtime would defeat the point.
|
|
36
|
+
|
|
37
|
+
## Vite
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
// vite.config.ts — a virtual module holding just this app's icons
|
|
41
|
+
import { subsetSprite } from '@junoput01/junoui/subset';
|
|
42
|
+
|
|
43
|
+
const ICONS = ['gear', 'x', 'squares-four']; // the app's manifest
|
|
44
|
+
const VIRTUAL = 'virtual:juno-icons-subset';
|
|
45
|
+
|
|
46
|
+
const iconSubset = {
|
|
47
|
+
name: 'juno-icon-subset',
|
|
48
|
+
resolveId: (id) => (id === VIRTUAL ? `\0${VIRTUAL}` : null),
|
|
49
|
+
load(id) {
|
|
50
|
+
if (id !== `\0${VIRTUAL}`) return null;
|
|
51
|
+
const sprite = readFileSync(new URL(import.meta.resolve('@junoput01/junoui/icons')), 'utf8');
|
|
52
|
+
return `export default ${JSON.stringify(subsetSprite(sprite, ICONS))}`;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Keep the manifest where the app's own type for icon names is derived from it,
|
|
58
|
+
so a name that is not in the manifest is a **compile** error rather than a
|
|
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`.
|
package/docs/ios-conformance.md
CHANGED
|
@@ -7,6 +7,10 @@ Most numbers the design community attributes to Apple are not in Apple's text.
|
|
|
7
7
|
This page exists so nobody re-derives folklore, and so nobody "fixes" a correct
|
|
8
8
|
value into a wrong one later.
|
|
9
9
|
|
|
10
|
+
This page is about **metrics and behaviour** on iOS. For _which iOS versions
|
|
11
|
+
junoui runs on at all_ — the supported floor, what degrades below it, and what
|
|
12
|
+
breaks — see [browser-support.md](./browser-support.md).
|
|
13
|
+
|
|
10
14
|
> **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
|
|
11
15
|
> returns an empty shell. Check the backing DocC JSON instead —
|
|
12
16
|
> `developer.apple.com/tutorials/data/design/human-interface-guidelines/<page>.json`.
|
|
@@ -157,11 +161,10 @@ to `lv*`** — that is the spec-level cause of the classic `100vh` overflow: a
|
|
|
157
161
|
junoui uses **zero raw `vh`**. Full-height surfaces use `dvh`
|
|
158
162
|
(`layout.css`, `drawer.css`) and `85dvh` caps the bottom sheet.
|
|
159
163
|
|
|
160
|
-
Caveats
|
|
164
|
+
Caveats that constrain the choice:
|
|
161
165
|
|
|
162
166
|
- `dv*` is explicitly **not stable** and not guaranteed to update every frame,
|
|
163
|
-
so it can churn while the address bar collapses. `sv*`
|
|
164
|
-
a surface must never overflow.
|
|
167
|
+
so it can churn while the address bar collapses. `sv*` never overflows.
|
|
165
168
|
- iOS shipped viewport-unit bugs into the **iOS 26** era: Safari 26.0 fixed
|
|
166
169
|
`lvh`/`vh` being sized against the _small_ viewport in `SFSafariViewController`.
|
|
167
170
|
The underlying WebKit bug (255708, filed 2023) is **still open**, so Apple's
|
|
@@ -176,6 +179,193 @@ Caveats worth knowing before changing any of that:
|
|
|
176
179
|
[Safari 26.0 release notes](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes),
|
|
177
180
|
[WebKit bug 255708](https://bugs.webkit.org/show_bug.cgi?id=255708).
|
|
178
181
|
|
|
182
|
+
### The decision: `dvh` stays at both call sites
|
|
183
|
+
|
|
184
|
+
Decided 2026-08-15 (ticket 20260803-033, step 1). `dvh` is kept for **both**
|
|
185
|
+
`.juno-app-shell` and `.juno-drawer`, against the "`sv*` is the calm choice"
|
|
186
|
+
instinct above. The reasoning below is the part to read: it generalises, the
|
|
187
|
+
verdict does not.
|
|
188
|
+
|
|
189
|
+
**First, price the two options at the only moment they differ** — when browser
|
|
190
|
+
chrome retracts. There is no other moment. Before it and after it, whichever
|
|
191
|
+
unit you picked is simply the current viewport.
|
|
192
|
+
|
|
193
|
+
| | what it costs when chrome retracts |
|
|
194
|
+
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
195
|
+
| `dvh` | The surface **grows**, so it relayouts. `dv*` is not frame-guaranteed, so that growth can arrive late or in steps. If the surface contains a scroller, the scroll port changes height _during the scroll that caused the retraction_. |
|
|
196
|
+
| `svh` | The surface **does not move** — and is now short by `lvh − svh`. That gap is permanent for the rest of the session, and it is at the bottom edge, where the page background shows through under whatever the surface pinned there. |
|
|
197
|
+
|
|
198
|
+
So this is not "stable vs twitchy". It is **one relayout** against **a permanent
|
|
199
|
+
dead strip**. Pick by asking which of those the surface can afford.
|
|
200
|
+
|
|
201
|
+
**`.juno-app-shell` — `block-size: 100dvh` (`layout.css`).** The shell's whole
|
|
202
|
+
job is to hold the dock against the bottom edge. Under `svh` the dock detaches
|
|
203
|
+
from that edge the first time the user scrolls and stays detached, floating
|
|
204
|
+
`lvh − svh` px up with page background beneath it — a visible defect on every
|
|
205
|
+
subsequent frame, under the primary navigation. The `dvh` relayout it avoids is
|
|
206
|
+
cheap here by construction: the shell is `overflow: hidden` and the scrolling
|
|
207
|
+
lives in `.juno-app-shell__main`, so growing the shell grows the scroll port and
|
|
208
|
+
moves the dock. **No text reflows** — no line breaking, no measured content, no
|
|
209
|
+
intrinsic sizing is touched. One cheap relayout beats a permanent gap.
|
|
210
|
+
|
|
211
|
+
**`.juno-drawer` — `block-size: 100dvh; max-block-size: 100dvh`
|
|
212
|
+
(`drawer.css`), and the same for the `--bottom` sheet's `60dvh`/`92dvh` and the
|
|
213
|
+
modal's `85dvh` cap.** Here the `dv*` instability **cannot bite at all**, and
|
|
214
|
+
that is the whole argument. The drawer is a `<dialog>` opened with
|
|
215
|
+
`showModal()`: it is in the top layer and the document beneath it is inert. iOS
|
|
216
|
+
Safari retracts and expands chrome in response to _document_ scroll — an inner
|
|
217
|
+
overflow scroller does not drive it — so chrome cannot change state for the
|
|
218
|
+
drawer's whole lifetime. `dvh` therefore never churns, and `svh` cannot prevent
|
|
219
|
+
an overflow that cannot happen. What `svh` _would_ still do is leave the gap:
|
|
220
|
+
open the drawer after scrolling (the ordinary case — you scroll, then reach for
|
|
221
|
+
the menu) and chrome is already retracted, so a `svh` drawer stops
|
|
222
|
+
`lvh − svh` px short of the bottom of a screen it is supposed to fill. `svh`
|
|
223
|
+
here is a pure loss.
|
|
224
|
+
|
|
225
|
+
> **The exception, and it is the app's to own.** A `<dialog>` opened with the
|
|
226
|
+
> `open` **attribute** instead of `showModal()` is _not_ in the top layer and
|
|
227
|
+
> does **not** block page scroll. Behind such a drawer the page scrolls, chrome
|
|
228
|
+
> retracts, and the churn is live. junoui's CSS cannot tell the two open paths
|
|
229
|
+
> apart. The drawer is documented as `showModal()`
|
|
230
|
+
> ([drawer.md](./components/drawer.md)); open it non-modally and the churn is yours.
|
|
231
|
+
|
|
232
|
+
### The rule, for a component that does not exist yet
|
|
233
|
+
|
|
234
|
+
Ask one question — **can browser chrome change state while this surface is on
|
|
235
|
+
screen?** — and then:
|
|
236
|
+
|
|
237
|
+
1. **It can, and the surface is anchored to the bottom edge** (or contains
|
|
238
|
+
something that is: a dock, a pillbar, a sticky footer) → **`dvh`**. You are
|
|
239
|
+
buying edge-adherence and paying one relayout per chrome transition.
|
|
240
|
+
2. **It can, and the surface must not resize once laid out** — content whose
|
|
241
|
+
height JS measures, a canvas, an animation mid-flight, anything where a
|
|
242
|
+
late reflow is worse than a gap → **`svh`**. You are buying stability and
|
|
243
|
+
paying up to `lvh − svh` of dead space, permanently.
|
|
244
|
+
3. **It cannot** — top-layer surfaces (`showModal()` dialogs, `popover`), where
|
|
245
|
+
the page beneath is inert → **`dvh`**, always. `svh` there buys nothing and
|
|
246
|
+
still pays the gap on any surface opened while chrome is retracted.
|
|
247
|
+
4. **Never `vh` / `lvh` for a height that must fit.** `vh == lvh` sizes as if
|
|
248
|
+
chrome were retracted, so on a page where it is _not_, the box overflows by
|
|
249
|
+
exactly the chrome's height. That is the classic `100vh` bug and it is a
|
|
250
|
+
spec consequence, not a browser bug. The one legitimate `lvh` in this
|
|
251
|
+
codebase is the standalone unlock's spacer (`base.css`), which is _supposed_
|
|
252
|
+
to overflow.
|
|
253
|
+
|
|
254
|
+
**In `display-mode: standalone` this whole decision is moot** — there is no
|
|
255
|
+
retractable chrome, so `svh == dvh`. Measured on the device: `100dvh`,
|
|
256
|
+
`100svh` and `100%` all resolve to 812 while `100lvh` and `100vh` resolve to
|
|
257
|
+
874, which is the letterbox defect below, not a chrome transition.
|
|
258
|
+
|
|
259
|
+
### A note on raw `vw`
|
|
260
|
+
|
|
261
|
+
"Zero raw `vh`" is about **`vh`**. junoui does use raw `vw` in five places
|
|
262
|
+
(`layout.css:18`, `pillbar.css:231`, `popover.css:33`, `toast.css:26`,
|
|
263
|
+
`drawer.css:116`) and that is deliberate: horizontal chrome does not retract,
|
|
264
|
+
so `vw`/`lvw`/`svw`/`dvw` are the same number and the `vh` trap has no
|
|
265
|
+
horizontal twin. The real `vw` hazard is different — `100vw` includes the
|
|
266
|
+
classic scrollbar gutter, so a full-bleed `100vw` box overflows a desktop page
|
|
267
|
+
that has a scrollbar. Every call site above either caps well below `100vw`
|
|
268
|
+
(`min(…, 240px)`, `min(360px, …)`, `85vw`, `clamp()`) or subtracts more than a
|
|
269
|
+
scrollbar's width (`calc(100vw - var(--juno-space-24))`). Keep it that way; a
|
|
270
|
+
bare `inline-size: 100vw` is a bug.
|
|
271
|
+
|
|
272
|
+
## Home-Screen standalone: the letterbox, and why `base.css` unlocks it
|
|
273
|
+
|
|
274
|
+
**The fact, and it is the most expensive thing this codebase has learned about
|
|
275
|
+
iOS: in `display-mode: standalone`, iOS sizes the window from the document's
|
|
276
|
+
RESTING scrollability at launch, and letterboxes a document that cannot scroll
|
|
277
|
+
by exactly `env(safe-area-inset-top)`.**
|
|
278
|
+
|
|
279
|
+
Measured, not inferred. iPhone 16 Pro (402×874 pt), iOS 18.7 / Safari 26.6:
|
|
280
|
+
|
|
281
|
+
| | |
|
|
282
|
+
| ---------------------------------------------- | ------- |
|
|
283
|
+
| `screen.height` | **874** |
|
|
284
|
+
| `window.innerHeight` · `visualViewport.height` | **812** |
|
|
285
|
+
| `100dvh` · `100svh` · `100%` | **812** |
|
|
286
|
+
| `100lvh` · `100vh` | **874** |
|
|
287
|
+
| `env(safe-area-inset-top)` | **62** |
|
|
288
|
+
| `env(safe-area-inset-bottom)` | 34 |
|
|
289
|
+
| `window.screenY` | 0 |
|
|
290
|
+
|
|
291
|
+
`874 − 812 = 62 = env(safe-area-inset-top)`, exactly. WebKit sizes the
|
|
292
|
+
standalone window as if a retractable toolbar existed, subtracts its height,
|
|
293
|
+
pins the window to the **top**, and then never covers the strip it reserved — so
|
|
294
|
+
the bottom 62 px of the display sits outside the web view and paints black on
|
|
295
|
+
every screen. This is a spec violation on its face: in standalone there is no
|
|
296
|
+
retractable browser UI, so the large and dynamic viewports **must** be equal
|
|
297
|
+
(css-values-4 §6.1.2.1), and here they differ by 62.
|
|
298
|
+
|
|
299
|
+
### It is the resting structure, and only the resting structure
|
|
300
|
+
|
|
301
|
+
One install, four document structures, switched by a pill and **persisted across
|
|
302
|
+
cold launches** (`localStorage`), verdict taken per launch:
|
|
303
|
+
|
|
304
|
+
| document structure at rest | window |
|
|
305
|
+
| --------------------------------------------------------------------------------------- | --------------------- |
|
|
306
|
+
| document scrolls normally | **874** — full screen |
|
|
307
|
+
| fixed shell, an inner scroller, document cannot scroll | 812 — letterboxed |
|
|
308
|
+
| fixed shell, nothing scrollable anywhere | 812 — letterboxed |
|
|
309
|
+
| fixed shell **+ the document left scrollable behind it** by an invisible in-flow spacer | **874** — full screen |
|
|
310
|
+
|
|
311
|
+
**Transient scrollability is not enough.** Seven in-page interventions across
|
|
312
|
+
five controlled runs — with a placebo pinned to the first slot, rotation of the
|
|
313
|
+
rest, and `prior`/`during`/`afterUndo` sampling — all measured 812. Every one of
|
|
314
|
+
them varied scrollability for ~300 ms mid-session and undid itself. iOS samples
|
|
315
|
+
the structure at launch; the axis that decides the window was never varied.
|
|
316
|
+
|
|
317
|
+
Two device-proven negatives, recorded so nobody spends another round on them:
|
|
318
|
+
|
|
319
|
+
- **`apple-mobile-web-app-status-bar-style` makes no difference.** `black` and
|
|
320
|
+
`black-translucent` were each tested with a fresh Home-Screen install. Both
|
|
321
|
+
letterbox identically.
|
|
322
|
+
- **Nothing applied after first paint reaches it** — see the seven interventions
|
|
323
|
+
above. The window does correct itself to 874 spontaneously, between 6 seconds
|
|
324
|
+
and 43 minutes after navigation, and then holds for the life of that document;
|
|
325
|
+
a reload starts a new document, which starts letterboxed again.
|
|
326
|
+
|
|
327
|
+
### What junoui does about it
|
|
328
|
+
|
|
329
|
+
`base.css` carries the **iOS standalone letterbox unlock**: keep the document
|
|
330
|
+
scrollable behind the app, using an invisible `body::after` spacer taller than
|
|
331
|
+
the large viewport, so the document always overflows whatever window iOS grants.
|
|
332
|
+
`overscroll-behavior: none` stops the ghost scroller rubber-banding; apps put
|
|
333
|
+
`overscroll-behavior: contain` on their real scrollers so an inner fling never
|
|
334
|
+
chains into it.
|
|
335
|
+
|
|
336
|
+
The gate is three conditions, all required — `display-mode: standalone` (only
|
|
337
|
+
installed apps letterbox), `pointer: coarse` (keeps macOS Dock apps out), and
|
|
338
|
+
`@supports (-webkit-touch-callout: none)` (iOS/iPadOS WebKit only). Selectors
|
|
339
|
+
carry `html:root` (specificity 0,1,2) deliberately: app resets commonly declare
|
|
340
|
+
`body { overflow: hidden }` at (0,0,1) _after_ this sheet, and the unlock has to
|
|
341
|
+
win the cascade without `!important`.
|
|
342
|
+
|
|
343
|
+
Consumer obligations, both silent if missed:
|
|
344
|
+
|
|
345
|
+
- **Ship the unlock at first parse.** iOS samples at launch, and a bundled
|
|
346
|
+
stylesheet arrives after it. An app whose shell paints before its CSS bundle
|
|
347
|
+
must inline a copy of the unlock in the document head — junoui's copy in
|
|
348
|
+
`juno.css` is too late on its own.
|
|
349
|
+
- **Do not override `body::after`.** The spacer is `body::after`; a consumer
|
|
350
|
+
that needs that pseudo-element for itself must reproduce the spacer at the
|
|
351
|
+
same gate. None of junoui's own components use it.
|
|
352
|
+
|
|
353
|
+
### How to know it is fixed upstream
|
|
354
|
+
|
|
355
|
+
In standalone, `window.innerHeight === screen.height` **and
|
|
356
|
+
`100lvh === 100dvh`**, for a document that **cannot** scroll — that is the case
|
|
357
|
+
that still misbehaves. When that holds, the unlock is dead weight and can be
|
|
358
|
+
removed. Until then it is harmless where it does not apply, because the gate
|
|
359
|
+
excludes every non-iOS and non-installed context.
|
|
360
|
+
|
|
361
|
+
- Sources: nexora `CLAUDE.md` §15, entry dated 2026-08-13; the four-mode testbed
|
|
362
|
+
`web/public/expansion-demo.html` (kept as a standing rig); 201 device readings
|
|
363
|
+
collected by `scripts/viewport_probe_collect.py`. Tracked upstream as ticket
|
|
364
|
+
20260812-006, drafted for WebKit Bugzilla / Feedback Assistant and awaiting
|
|
365
|
+
filing. Related Apple Developer Forums threads: 800798, 798014 (iOS 26
|
|
366
|
+
safe-area insets wrong until a background/resume — the "corrects itself later"
|
|
367
|
+
shape matches exactly), 797124.
|
|
368
|
+
|
|
179
369
|
## Typography
|
|
180
370
|
|
|
181
371
|
iOS: **17 pt default body size, 11 pt minimum**; Dynamic Type must accommodate
|
|
@@ -194,6 +384,14 @@ iOS Safari is widely observed to zoom the page onto a focused field under 16px.
|
|
|
194
384
|
behavior, not published spec. The mitigation is harmless, so it stays — but do
|
|
195
385
|
not cite it as documented, and re-verify it on iOS 26.
|
|
196
386
|
|
|
387
|
+
The floor is now a checked claim, not an asserted one:
|
|
388
|
+
`test/visual/tap-targets.spec.mjs` reads the computed `font-size` of a
|
|
389
|
+
`.juno-input` under the coarse-pointer Playwright project and asserts `>= 16`.
|
|
390
|
+
That check is also what found the floor had never worked — the rule sat in
|
|
391
|
+
`base.css`'s `@media (pointer: coarse)` block, where a media query adds no
|
|
392
|
+
specificity, so `components/input.css`'s own `.juno-input` font-size beat it on
|
|
393
|
+
source order. It now lives in `input.css`. Keep it there.
|
|
394
|
+
|
|
197
395
|
## Things a stylesheet controls that Apple says nothing about
|
|
198
396
|
|
|
199
397
|
No primary Apple source was found for any of: `touch-action`,
|
|
@@ -214,7 +412,11 @@ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
|
|
|
214
412
|
added to the Home Screen opens as a web app by default** — "there are now zero
|
|
215
413
|
requirements for 'installability'". junoui's CSS may therefore run in a
|
|
216
414
|
standalone context, where `viewport-fit` and `env()` govern home-indicator and
|
|
217
|
-
Dynamic Island clearance, for sites that never opted in.
|
|
415
|
+
Dynamic Island clearance, for sites that never opted in. The concrete
|
|
416
|
+
consequence is measured above in
|
|
417
|
+
[Home-Screen standalone: the letterbox](#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it) —
|
|
418
|
+
a site that never asked to be a web app now inherits both the letterbox and the
|
|
419
|
+
unlock.
|
|
218
420
|
|
|
219
421
|
Unconfirmed leads, tracked in ticket 20260803-034: `vh` reportedly pinning to
|
|
220
422
|
`window.outerHeight`; three new tab modes yielding different `innerHeight`; a
|
package/docs/layout.md
CHANGED
|
@@ -269,3 +269,38 @@ You opt in per region; nothing forces a markup shape.
|
|
|
269
269
|
These are all CSS. Behavior that needs state (resize observers feeding app state,
|
|
270
270
|
virtualization, drag-resizable panels) belongs in your app or a sibling
|
|
271
271
|
`junoui-<framework>` package — not the design system.
|
|
272
|
+
|
|
273
|
+
## Floating-nav clearance
|
|
274
|
+
|
|
275
|
+
A scroller that a fixed dock or pillbar floats over reserves room at its foot so
|
|
276
|
+
the last row clears the overlay:
|
|
277
|
+
|
|
278
|
+
```css
|
|
279
|
+
.my-scroller {
|
|
280
|
+
padding-block-end: var(--juno-dock-clearance);
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The value is **derived from the control it clears**, not a constant:
|
|
285
|
+
|
|
286
|
+
| Prop | Is |
|
|
287
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------ |
|
|
288
|
+
| `--juno-dock-h` | the floating pill's laid-out height — bubble + item padding + pill padding + border |
|
|
289
|
+
| `--juno-pillbar-h` | the pillbar's, one size down (no bubble) |
|
|
290
|
+
| `--juno-dock-clearance-scale` | multiplier on that height, default `1` — see below |
|
|
291
|
+
| `--juno-dock-clearance` | `height × scale + space-16 (the bar's own margin) + space-8 + env(safe-area-inset-bottom)` |
|
|
292
|
+
|
|
293
|
+
**Why derived matters.** Set `--juno-size-tap-comfortable` at `:root` — which
|
|
294
|
+
junoui invites, and which is the whole point of a token — and the pill grows;
|
|
295
|
+
the clearance grows with it. The previous constant did not, and past a 58px
|
|
296
|
+
bubble it reserved less than the pill's own height plus its margin, so content
|
|
297
|
+
hid under the dock with nothing reporting it.
|
|
298
|
+
|
|
299
|
+
**The scale knob is for bars that shrink while scrolling.** Reserve at the SMALL
|
|
300
|
+
size by setting `--juno-dock-clearance-scale` to your compact factor. Reserving
|
|
301
|
+
at the live size means the reservation changes after the last scroll event, and
|
|
302
|
+
content relayouts under a finger that has already stopped moving.
|
|
303
|
+
|
|
304
|
+
**Custom properties resolve where they are declared.** Override
|
|
305
|
+
`--juno-size-tap-comfortable` at `:root` (or override `--juno-dock-h` directly);
|
|
306
|
+
overriding it on a subtree does not re-derive a token computed at `:root`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junoput01/junoui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "junoui — a token-driven design system. Color carries semantic meaning, never decoration: every hue has one assigned role (NOMINAL / ACTIVE / TARGET / CAUTION / WARNING). Ships multi-platform tokens (CSS, SCSS, JS/TS, JSON, Android, iOS, Flutter) plus a framework-agnostic CSS component layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,11 +49,14 @@
|
|
|
49
49
|
"./ios": "./dist/ios/JunoTokens.swift",
|
|
50
50
|
"./flutter": "./dist/flutter/juno_tokens.dart",
|
|
51
51
|
"./icons": "./dist/icons/juno-icons.svg",
|
|
52
|
+
"./subset": "./tools/subset-sprite.mjs",
|
|
52
53
|
"./icons/inline": "./dist/icons/inline.js",
|
|
54
|
+
"./icons/install": "./dist/icons/install.js",
|
|
53
55
|
"./package.json": "./package.json"
|
|
54
56
|
},
|
|
55
57
|
"files": [
|
|
56
58
|
"dist",
|
|
59
|
+
"tools",
|
|
57
60
|
"src/css",
|
|
58
61
|
"src/icons",
|
|
59
62
|
"src/fonts",
|
|
@@ -78,6 +81,7 @@
|
|
|
78
81
|
"showcase": "npm run build && node scripts/serve.mjs",
|
|
79
82
|
"test:visual": "playwright test",
|
|
80
83
|
"test:visual:update": "playwright test --update-snapshots",
|
|
84
|
+
"gate:consumer": "node scripts/consumer-gate.mjs",
|
|
81
85
|
"changeset": "changeset",
|
|
82
86
|
"version": "changeset version",
|
|
83
87
|
"release": "npm run build && changeset publish"
|
|
@@ -92,5 +96,12 @@
|
|
|
92
96
|
},
|
|
93
97
|
"engines": {
|
|
94
98
|
"node": ">=18"
|
|
95
|
-
}
|
|
99
|
+
},
|
|
100
|
+
"browserslist": [
|
|
101
|
+
"iOS >= 17.0",
|
|
102
|
+
"Safari >= 17.0",
|
|
103
|
+
"Chrome >= 114",
|
|
104
|
+
"Edge >= 114",
|
|
105
|
+
"Firefox >= 125"
|
|
106
|
+
]
|
|
96
107
|
}
|