@junoput01/junoui 0.3.0 → 0.4.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +23 -19
  3. package/dist/css/juno-custom-media.css +32 -0
  4. package/dist/css/juno.css +1216 -60
  5. package/dist/icons/inline.js +22 -0
  6. package/docs/accessibility.md +55 -34
  7. package/docs/boot-shell.md +295 -0
  8. package/docs/components/README.md +46 -43
  9. package/docs/components/dock.md +167 -8
  10. package/docs/components/drawer.md +47 -7
  11. package/docs/components/fold-slot.md +31 -0
  12. package/docs/components/icon-loader.md +85 -18
  13. package/docs/components/icon.md +21 -0
  14. package/docs/components/load-state.md +131 -0
  15. package/docs/components/loader.md +13 -5
  16. package/docs/components/pillbar.md +157 -10
  17. package/docs/components/reload.md +41 -0
  18. package/docs/components/skeleton.md +22 -15
  19. package/docs/components/thumb.md +34 -14
  20. package/docs/design-guidelines.md +26 -0
  21. package/docs/getting-started.md +23 -0
  22. package/docs/ios-conformance.md +224 -0
  23. package/docs/layout.md +71 -1
  24. package/docs/web.md +6 -0
  25. package/package.json +5 -2
  26. package/src/css/base.css +169 -6
  27. package/src/css/components/dock.css +322 -0
  28. package/src/css/components/drawer.css +40 -3
  29. package/src/css/components/fold-slot.css +44 -0
  30. package/src/css/components/icon-loader.css +32 -18
  31. package/src/css/components/icon.css +6 -4
  32. package/src/css/components/load-state.css +136 -0
  33. package/src/css/components/loader.css +6 -0
  34. package/src/css/components/menu.css +4 -0
  35. package/src/css/components/modal.css +24 -3
  36. package/src/css/components/navbar.css +5 -1
  37. package/src/css/components/pillbar.css +207 -7
  38. package/src/css/components/reload.css +48 -0
  39. package/src/css/components/skeleton.css +41 -13
  40. package/src/css/components/tabs.css +5 -0
  41. package/src/css/components/thumb.css +63 -1
  42. package/src/css/components/toast.css +5 -1
  43. package/src/css/density.css +22 -0
  44. package/src/css/layout.css +30 -2
  45. package/src/css/utilities.css +4 -1
@@ -0,0 +1,224 @@
1
+ # iOS conformance
2
+
3
+ What junoui encodes for iOS, **with sources**, and — just as important — what it
4
+ deliberately does _not_ encode because no primary source supports it.
5
+
6
+ Most numbers the design community attributes to Apple are not in Apple's text.
7
+ This page exists so nobody re-derives folklore, and so nobody "fixes" a correct
8
+ value into a wrong one later.
9
+
10
+ > **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
11
+ > returns an empty shell. Check the backing DocC JSON instead —
12
+ > `developer.apple.com/tutorials/data/design/human-interface-guidelines/<page>.json`.
13
+ > Also pin a Wayback snapshot: the HIG silently drops guidance (see the 44pt
14
+ > story below). State verified 2026-08-03.
15
+
16
+ ## The units question, first
17
+
18
+ **One Apple point = one CSS pixel.** Apple's `pt` is a density-independent
19
+ point, not the CSS typographic point; `devicePixelRatio` absorbs @2x/@3x. So
20
+ Apple's 44pt is CSS `44px`.
21
+
22
+ Converting through the CSS unit (`1pt = 1/72in`, `1px = 1/96in` → 44pt =
23
+ 58.67px) is **wrong**. If you ever see 58.67 in this codebase, it is a bug.
24
+
25
+ This identity holds only when the page ships
26
+ `<meta name="viewport" content="width=device-width, initial-scale=1">`. Without
27
+ it iOS Safari lays out at ~980px and every metric drifts.
28
+
29
+ - Source: [css-values-4](https://www.w3.org/TR/css-values-4/) for the CSS units,
30
+ [WebKit iPhone X guide](https://webkit.org/blog/7929/designing-websites-for-iphone-x/)
31
+ for the viewport model.
32
+
33
+ ## Touch targets
34
+
35
+ | What | Value | Status |
36
+ | --------------------------------------------- | -------------------------------------------------------- | --------------------------- |
37
+ | WCAG 2.2 SC 2.5.8 Target Size (Minimum) | **24×24 CSS px** | **Hard requirement** for AA |
38
+ | WCAG 2.2 SC 2.5.5 Target Size (Enhanced) | **44×44 CSS px** | AAA (recommendation tier) |
39
+ | HIG Buttons — hit region | **44×44 pt** ("needs a hit region of at least 44×44 pt") | Recommendation |
40
+ | HIG Accessibility — control size (iOS/iPadOS) | **44×44 pt default, 28×28 pt minimum** | Recommendation |
41
+ | HIG Accessibility — padding between controls | **~12 pt** bezeled, **~24 pt** non-bezeled | Recommendation, hedged |
42
+
43
+ junoui encodes `--juno-size-tap-min` = 24px (the AA floor) and
44
+ `--juno-size-tap-comfortable` = 44px, and promotes the former to the latter
45
+ under `@media (pointer: coarse)` — see [accessibility.md](./accessibility.md).
46
+
47
+ - Sources: [WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html),
48
+ [WCAG 2.5.5](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html),
49
+ [HIG Accessibility](https://developer.apple.com/design/human-interface-guidelines/accessibility),
50
+ [HIG Buttons](https://developer.apple.com/design/human-interface-guidelines/buttons).
51
+
52
+ ### Folklore, named
53
+
54
+ - **"Apple mandates a 44pt _minimum_ tap target — see HIG Layout."** That
55
+ sentence has been **deleted**. The current Layout page contains no tap-target
56
+ number at all (`"44x44"` and `"tappable"` both return zero hits); a 2023
57
+ Wayback snapshot of the same endpoint still has it. Every checklist citing
58
+ that URL cites a dead page. What survives is the Buttons wording above.
59
+ - **"44px because WCAG."** Wrong at AA — the AA floor is 24px. 44 is Apple's
60
+ number, which WCAG AAA happens to match. W3C never says it derived 44 from
61
+ Apple.
62
+
63
+ ## Spacing
64
+
65
+ **Apple publishes no numeric spacing scale for iOS.** No 4pt or 8pt grid, no
66
+ named spacing steps, no layout-margin or gutter constants, no readable-width
67
+ number. Standard margins are delegated to system layout guides.
68
+
69
+ The only Apple-published numbers found anywhere:
70
+
71
+ - **~12 pt / ~24 pt** control padding (HIG Accessibility, hedged: "in general",
72
+ "about", "works well").
73
+ - **8 pt** — `UIView.directionalLayoutMargins` default. This is a **UIKit API
74
+ default, not an HIG design rule**; cite it as such.
75
+
76
+ So: **"Apple's 8pt grid" and "HIG standard margins are 16–20pt" are folklore.**
77
+ junoui's spacing scale is junoui's own; do not claim Apple provenance for it.
78
+
79
+ - Source: [HIG Layout](https://developer.apple.com/design/human-interface-guidelines/layout),
80
+ [UIView.directionalLayoutMargins](https://developer.apple.com/documentation/uikit/uiview/directionallayoutmargins).
81
+
82
+ ## Safe areas
83
+
84
+ `env(safe-area-inset-top|right|bottom|left)` — four variables, iOS 11 (shipped
85
+ as `constant()` in 11.0, renamed `env()` in 11.2). There is no numeric constant
86
+ to hardcode; values are system-supplied and vary by device.
87
+
88
+ **They are inert unless the page opts in.** `viewport-fit` defaults to `auto`,
89
+ and WebKit reports every inset as `0` until you set `cover`. `contain` does
90
+ _not_ opt out — only `cover` does. junoui is a stylesheet and cannot set this
91
+ for you, so it is stated as a hard requirement in
92
+ [getting-started.md](./getting-started.md), every showcase page carries it, and
93
+ a build test enforces that.
94
+
95
+ - Sources: [WebKit iPhone X guide](https://webkit.org/blog/7929/designing-websites-for-iphone-x/),
96
+ [WebKit bug 272779](https://bugs.webkit.org/show_bug.cgi?id=272779),
97
+ [css-env-1](https://www.w3.org/TR/css-env-1/).
98
+ - Spec ownership, for citation hygiene: `env()` and `safe-area-inset-*` are
99
+ **css-env-1**; `viewport-fit` is **css-round-display-1**. CSS Viewport Module
100
+ L1 defines none of them — citing it is misattribution.
101
+
102
+ ### `max()` vs addition — the rule
103
+
104
+ WebKit's documented pattern is `padding-left: max(12px, env(safe-area-inset-left))`
105
+ — "the default padding **or** the safe area inset, whichever is greater". (The
106
+ `12px` there is an arbitrary demo value, not an Apple metric.)
107
+
108
+ That pattern applies to **padding on a surface that reaches the screen edge**:
109
+ the inset's job is to push content clear of the cutout, so it _replaces_ your
110
+ baseline rather than stacking on it.
111
+
112
+ It does **not** apply to a **floating element positioned off the edge**, where
113
+ addition is correct:
114
+
115
+ ```css
116
+ /* floating pill: sit 16px ABOVE the home-indicator region */
117
+ inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
118
+
119
+ /* NOT max() — that would park the pill flush against the indicator */
120
+ ```
121
+
122
+ Nor to the **clearance tokens** (`--juno-dock-clearance`,
123
+ `--juno-pillbar-clearance`), where content must clear the control's height _and_
124
+ the inset beneath it — genuinely additive.
125
+
126
+ junoui's call sites were audited against this rule (2026-08-03) and the additive
127
+ ones are correct as written. Do not "fix" them to `max()` without re-reading
128
+ this section.
129
+
130
+ ### The unit trap
131
+
132
+ Inside `calc()`, an env() fallback **must carry a unit**:
133
+
134
+ ```css
135
+ /* WRONG — unitless 0 is a <number>, the sum is invalid, and the whole
136
+ DECLARATION is dropped (it does not evaluate to zero) */
137
+ padding-block-end: calc(var(--juno-space-12) + env(safe-area-inset-bottom, 0));
138
+
139
+ /* RIGHT */
140
+ padding-block-end: calc(var(--juno-space-12) + env(safe-area-inset-bottom, 0px));
141
+ ```
142
+
143
+ A bare (non-`calc`) value may keep a unitless `0`. A build test enforces this.
144
+ Note this follows from ordinary CSS type rules; no Apple/WebKit source states it
145
+ specifically. The adjacent _confirmed_ unit pitfall is that the
146
+ `@supports (padding: max(0px))` feature test needs a unit — and that `@supports`
147
+ wrapper is itself obsolete boilerplate in 2026.
148
+
149
+ ## Viewport units
150
+
151
+ CSS defines three families by how dynamic browser chrome is treated:
152
+ `sv*` (chrome assumed **expanded** — smallest), `lv*` (assumed **retracted** —
153
+ largest), `dv*` (tracked live). **The unprefixed `vh`/`vw` are normatively equal
154
+ to `lv*`** — that is the spec-level cause of the classic `100vh` overflow: a
155
+ `100vh` box is sized as if the toolbar were retracted.
156
+
157
+ junoui uses **zero raw `vh`**. Full-height surfaces use `dvh`
158
+ (`layout.css`, `drawer.css`) and `85dvh` caps the bottom sheet.
159
+
160
+ Caveats worth knowing before changing any of that:
161
+
162
+ - `dv*` is explicitly **not stable** and not guaranteed to update every frame,
163
+ so it can churn while the address bar collapses. `sv*` is the calm choice when
164
+ a surface must never overflow.
165
+ - iOS shipped viewport-unit bugs into the **iOS 26** era: Safari 26.0 fixed
166
+ `lvh`/`vh` being sized against the _small_ viewport in `SFSafariViewController`.
167
+ The underlying WebKit bug (255708, filed 2023) is **still open**, so Apple's
168
+ "Fixed" is stronger than the tracker supports. Scope is the in-app browser used
169
+ by Slack/X — not standalone Safari, and not `WKWebView`-based in-app browsers
170
+ (Instagram/Facebook), a separate unfixed path.
171
+ - Do **not** reach for `env(safe-area-max-inset-*)` as a fix. It is in css-env-1
172
+ but could not be confirmed shipping in any engine.
173
+
174
+ - Sources: [css-values-4 §6.1.2.1](https://www.w3.org/TR/css-values-4/),
175
+ [csswg-drafts#6454](https://github.com/w3c/csswg-drafts/issues/6454),
176
+ [Safari 26.0 release notes](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes),
177
+ [WebKit bug 255708](https://bugs.webkit.org/show_bug.cgi?id=255708).
178
+
179
+ ## Typography
180
+
181
+ iOS: **17 pt default body size, 11 pt minimum**; Dynamic Type must accommodate
182
+ enlargement to **200%**. Contrast minimums are 4.5:1 up to 17pt, 3:1 at 18pt or
183
+ bold. Since 1pt = 1 CSS px, that is 17px / 11px.
184
+
185
+ Confidence: medium — extracted from the HIG Accessibility page but not
186
+ adversarially re-verified. Re-check before encoding as a hard constraint.
187
+
188
+ ### The 16px input rule
189
+
190
+ junoui holds text-entry controls at a 16px floor on coarse pointers, because
191
+ iOS Safari is widely observed to zoom the page onto a focused field under 16px.
192
+
193
+ **No primary WebKit or Apple source for this was found.** It is empirical
194
+ behavior, not published spec. The mitigation is harmless, so it stays — but do
195
+ not cite it as documented, and re-verify it on iOS 26.
196
+
197
+ ## Things a stylesheet controls that Apple says nothing about
198
+
199
+ No primary Apple source was found for any of: `touch-action`,
200
+ `-webkit-tap-highlight-color`, `overscroll-behavior`, momentum scrolling, or
201
+ `scroll-snap` on iOS. Where junoui uses these (the coarse-pointer tap-highlight
202
+ reset, `overscroll-behavior: contain` on the modal body), it is **community
203
+ convention** — sensible, but do not attribute it to Apple.
204
+
205
+ `-webkit-overflow-scrolling: touch` is legacy and must not be reintroduced.
206
+
207
+ ## Open risk: iOS 26
208
+
209
+ The claim "iOS 26 changed nothing about safe areas, viewport-fit, touch
210
+ behavior, or focus zoom" was **refuted** during verification, so junoui's iOS 26
211
+ behavior is unverified rather than confirmed-safe.
212
+
213
+ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
214
+ added to the Home Screen opens as a web app by default** — "there are now zero
215
+ requirements for 'installability'". junoui's CSS may therefore run in a
216
+ standalone context, where `viewport-fit` and `env()` govern home-indicator and
217
+ Dynamic Island clearance, for sites that never opted in.
218
+
219
+ Unconfirmed leads, tracked in ticket 20260803-034: `vh` reportedly pinning to
220
+ `window.outerHeight`; three new tab modes yielding different `innerHeight`; a
221
+ reported iPadOS 26 windowed-mode bug where `env(safe-area-inset-*)` returns
222
+ nothing. Resolving these needs a physical device, not more documentation.
223
+
224
+ - Source: [WebKit features in Safari 26.0](https://webkit.org/blog/17333/webkit-features-in-safari-26-0/).
package/docs/layout.md CHANGED
@@ -36,6 +36,7 @@ default — override per instance inline.
36
36
  | `.juno-grid-auto--tiles` | Media wall wired to the density layer | `--juno-tile-min` / `--juno-gap-content` |
37
37
  | `.juno-sidebar` | Aside + fluid content, stacks when tight | `--juno-sidebar-width` (`280px`) |
38
38
  | `.juno-switcher` | N-up or all-stacked at a threshold | `--juno-switcher-threshold` (`bp-sm`) |
39
+ | `.juno-scroller` | Bare scroll container (axis/snap/bar) | `--juno-scroller-snap` (`none`) |
39
40
  | `.juno-reel` | Horizontal scroll-snap row | `--juno-reel-space` (`space-12`) |
40
41
 
41
42
  ### Examples
@@ -75,6 +76,73 @@ default — override per instance inline.
75
76
  `data-juno-density` attribute re-densifies controls, surfaces **and** content
76
77
  grids together.
77
78
 
79
+ ## Scroller
80
+
81
+ Every scrolling region in the library — `.juno-reel`, `.juno-app-shell__main`,
82
+ tab strips — is the same three knobs: overflow axis, overscroll containment,
83
+ snap type. `.juno-scroller` ships them as overridable custom props instead of
84
+ each consumer re-deriving (and usually forgetting `overscroll-behavior`, which
85
+ lets iOS pull-to-refresh/scroll-chain through an inner scroller into the
86
+ page). Defaults: `overflow: auto` (both axes), `overscroll-behavior: contain`,
87
+ no snap.
88
+
89
+ ```html
90
+ <!-- vertical list scroller with containment, no snap -->
91
+ <div class="juno-scroller juno-scroller--y">…</div>
92
+
93
+ <!-- horizontal, scrollbar hidden, softer "proximity" snap, opt-in stops -->
94
+ <div
95
+ class="juno-scroller juno-scroller--x juno-scroller--bare"
96
+ style="--juno-scroller-snap: x proximity;"
97
+ >
98
+ <div class="juno-snap">…</div>
99
+ <div class="juno-snap">…</div>
100
+ </div>
101
+ ```
102
+
103
+ - `.juno-scroller--x` — horizontal axis only (`overflow: auto hidden`)
104
+ - `.juno-scroller--y` — vertical axis only (`overflow: hidden auto`)
105
+ - `.juno-scroller--bare` — hides the scrollbar (Firefox + WebKit)
106
+ - `.juno-snap` — on a child: opts into `scroll-snap-align`
107
+
108
+ `.juno-reel` is `.juno-scroller`'s horizontal-snap preset baked into one
109
+ class: it now reads its `scroll-snap-type` from the same `--juno-scroller-snap`
110
+ prop (default unchanged: `inline mandatory`), so the mode is overridable
111
+ per instance instead of hardcoded — e.g. a "magnet" strip that wants
112
+ `proximity` instead of a stepped `mandatory` feel:
113
+
114
+ ```html
115
+ <div class="juno-reel" style="--juno-scroller-snap: inline proximity;">…</div>
116
+ ```
117
+
118
+ ## Gesture surfaces
119
+
120
+ For an element whose pointer events an app JS layer owns outright — drag-pan,
121
+ pinch-zoom, swipe classification, anything that is a state machine rather than
122
+ native scrolling — junoui ships the CSS side of that contract as one class.
123
+ The gesture handler itself is the app's job (or a sibling `junoui-<framework>`
124
+ package): junoui declares the surface, never the logic.
125
+
126
+ ```html
127
+ <div class="juno-gesture-surface" id="viewport"><!-- JS drag/pinch handlers attach here --></div>
128
+
129
+ <!-- only needs the touch-action axis lock, not the full reset -->
130
+ <li class="juno-list__item juno-pan-x"><!-- swipe-to-reveal row --></li>
131
+ ```
132
+
133
+ `.juno-gesture-surface` sets `touch-action: var(--juno-touch-action, none)`,
134
+ `-webkit-touch-callout: none`, `user-select: none` and
135
+ `-webkit-tap-highlight-color: transparent` — so the UA never fights the
136
+ handler with its own scroll/zoom recognition, long-press callout, text
137
+ selection or tap flash. Override `--juno-touch-action` per instance to hand
138
+ back one axis (`pan-x` / `pan-y`) instead of all of them. `.juno-pan-x` /
139
+ `.juno-pan-y` are standalone single-axis classes for elements that want the
140
+ axis lock alone.
141
+
142
+ These four properties are **community convention, not Apple-documented
143
+ behavior** — see [ios-conformance.md](./ios-conformance.md) before citing them
144
+ as a platform requirement.
145
+
78
146
  ## App shell
79
147
 
80
148
  Every product app assembles the same frame; `.juno-app-shell` ships it as
@@ -117,7 +185,9 @@ What the primitive encodes so you don't have to:
117
185
  short-page pitfall of sticky nav bars (below) can't happen here.
118
186
  - **Safe-area insets** — the shell pads for landscape notches
119
187
  (`inset-left`/`right`), the topbar for `inset-top`, the dock for
120
- `inset-bottom`.
188
+ `inset-bottom`. **This requires `viewport-fit=cover` in your page's viewport
189
+ meta** ([getting-started](./getting-started.md#required-the-viewport-meta)) —
190
+ without it iOS reports every inset as `0` and none of this padding happens.
121
191
 
122
192
  Collapse the rail by toggling `.juno-rail--collapsed` (one class; the width
123
193
  transition and label hiding are built in). Trays/detail panels: the
package/docs/web.md CHANGED
@@ -87,3 +87,9 @@ import '@junoput01/junoui/fonts.css'; // opt-in: self-hosted B612 woff2, no netw
87
87
 
88
88
  …or bring your own B612 (e.g. `@fontsource/b612`). Without either, the UI falls back to
89
89
  system sans/mono. See [integration.md](./integration.md#2-fonts-opt-in-csp-safe).
90
+
91
+ ## Boot performance
92
+
93
+ An app that wants chrome on screen at the first HTML parse — before `juno.css`
94
+ or any bundle arrives — and everything else loaded in the background follows
95
+ the boot-shell ladder: [boot-shell.md](./boot-shell.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junoput01/junoui",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -29,7 +29,8 @@
29
29
  "module": "./dist/js/tokens.js",
30
30
  "types": "./dist/js/tokens.d.ts",
31
31
  "sideEffects": [
32
- "**/*.css"
32
+ "**/*.css",
33
+ "./dist/icons/inline.js"
33
34
  ],
34
35
  "exports": {
35
36
  ".": {
@@ -39,6 +40,7 @@
39
40
  },
40
41
  "./css": "./dist/css/juno.css",
41
42
  "./css/tokens": "./dist/css/juno-tokens.css",
43
+ "./css/custom-media": "./dist/css/juno-custom-media.css",
42
44
  "./fonts.css": "./dist/css/juno-fonts.css",
43
45
  "./scss": "./dist/scss/_juno-tokens.scss",
44
46
  "./tokens.json": "./dist/json/tokens.json",
@@ -47,6 +49,7 @@
47
49
  "./ios": "./dist/ios/JunoTokens.swift",
48
50
  "./flutter": "./dist/flutter/juno_tokens.dart",
49
51
  "./icons": "./dist/icons/juno-icons.svg",
52
+ "./icons/inline": "./dist/icons/inline.js",
50
53
  "./package.json": "./package.json"
51
54
  },
52
55
  "files": [
package/src/css/base.css CHANGED
@@ -13,7 +13,37 @@
13
13
  /* System color scheme. Without data-juno-mode the theme follows the OS
14
14
  (`prefers-color-scheme`; see juno-tokens.css) — `dark light` here lets the
15
15
  UA pick matching scrollbars/form chrome. An explicit mode pins both. */
16
- :root { color-scheme: dark light; }
16
+ :root {
17
+ color-scheme: dark light;
18
+
19
+ /* Floating-nav scroll clearance — a page/scroller that a fixed dock or
20
+ pillbar floats over reserves this much room at its foot so the last row
21
+ clears the overlay. Consumers write `padding-block-end:
22
+ var(--juno-dock-clearance)` and stay correct when the dock geometry
23
+ changes — no hand-rolled calc() per app. Web-only (they carry env()), so
24
+ they live in the CSS layer, not the cross-platform token set.
25
+ --juno-dock-clearance ≈ pill height + bottom margin + breathing room;
26
+ --juno-pillbar-clearance suits the shorter centered pill. */
27
+ --juno-dock-clearance: calc(var(--juno-space-72) + var(--juno-space-20) + env(safe-area-inset-bottom, 0px));
28
+ --juno-pillbar-clearance: calc(var(--juno-space-72) + env(safe-area-inset-bottom, 0px));
29
+
30
+ /* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
31
+ imperative JS (scrollTo/scrollIntoView smooth behavior, rAF-driven transforms,
32
+ an "animate or not" branch) — so the preference is also exposed as a custom
33
+ property script can read with one getComputedStyle call, no matchMedia
34
+ listener required for a one-shot check:
35
+ getComputedStyle(document.documentElement)
36
+ .getPropertyValue('--juno-motion').trim() !== 'none'
37
+ A component MAY author its durations through the scale rather than a raw
38
+ duration token — `calc(var(--juno-motion-duration-base) * var(--juno-motion-scale))`
39
+ — so the reduced-motion override below collapses them to 0 without its own
40
+ per-component media query. Web-only DOM contract, so it lives
41
+ here rather than in the cross-platform token set (see --juno-dock-clearance
42
+ above for the same reasoning). */
43
+ --juno-motion: auto;
44
+ --juno-motion-scale: 1;
45
+ }
46
+
17
47
  :root[data-juno-mode='dark'] { color-scheme: dark; }
18
48
 
19
49
  /* Control surfaces. On dark panels the hairline border (~23% L) disappears, so
@@ -90,17 +120,102 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
90
120
  @media (pointer: coarse) {
91
121
  :root { --juno-size-tap-min: var(--juno-size-tap-comfortable); }
92
122
 
93
- /* iOS Safari zooms the page onto any focused text field whose font-size is
94
- below 16px. Hold text-entry controls (.juno-input covers input, textarea
95
- and the select's inner control) at a 16px floor on touch so focusing never
96
- triggers that jump. max() keeps the floor even under a scaled-down
97
- --juno-font-scale, and still grows when scaled up. */
123
+ /* iOS Safari is widely observed to zoom the page onto a focused text field
124
+ whose font-size is under 16px. NOTE: no primary WebKit/Apple source states
125
+ this it is empirical behavior, not published spec, so don't cite it as
126
+ one (see docs/ios-conformance.md and ticket 20260803-032). The mitigation
127
+ is harmless either way: hold text-entry controls (.juno-input covers input,
128
+ textarea and the select's inner control) at a 16px floor on touch. max()
129
+ keeps the floor even under a scaled-down --juno-font-scale, and still grows
130
+ when scaled up. */
98
131
  .juno-input { font-size: max(16px, var(--juno-font-size-16)); }
132
+
133
+ /* Kill the UA tap-highlight square on the interactive surfaces so it never
134
+ flashes past a rounded control on tap. Consumers were adding this by hand
135
+ per component; make it a first-class touch default. See 20260802-020. */
136
+ :where(
137
+ .juno-btn,
138
+ .juno-dock__item,
139
+ .juno-pillbar__item,
140
+ .juno-tabs__tab,
141
+ .juno-list__item,
142
+ .juno-menu__item
143
+ ) {
144
+ -webkit-tap-highlight-color: transparent;
145
+ }
146
+ }
147
+
148
+ /* Tappable primitives opt out of double-tap-to-zoom. A browser that still
149
+ recognises that gesture has to WAIT after the first tap to see whether a
150
+ second one is coming, which reads as a late, mushy tap on exactly the
151
+ surfaces a phone UI is built from. `manipulation` keeps panning and
152
+ pinch-zoom (so the page stays zoomable — never `none` here, that would be an
153
+ a11y regression) and drops only the double-tap.
154
+ NOT inside the pointer:coarse block above: a hybrid device (touch laptop,
155
+ iPad with a trackpad) reports a fine primary pointer while still taking
156
+ touch input, and the property is inert on a mouse anyway.
157
+ Community convention — no primary Apple/WebKit source names it; see
158
+ docs/ios-conformance.md. Named components only, so a consumer's own elements
159
+ are untouched. See 20260803-038. */
160
+ :where(
161
+ .juno-btn,
162
+ .juno-dock__item,
163
+ .juno-pillbar__item,
164
+ .juno-pillbar__overflow,
165
+ .juno-tabs__tab,
166
+ .juno-list__item,
167
+ .juno-menu__item,
168
+ .juno-seg__option,
169
+ .juno-chip,
170
+ .juno-toggle-btn
171
+ ) {
172
+ touch-action: manipulation;
173
+ }
174
+
175
+ /* Gesture-owned surfaces — for an element whose pointer events are fully
176
+ driven by app JS (drag-pan, pinch-zoom, swipe classification: a state
177
+ machine, not a native scroller). The UA must get out of the way: no
178
+ scroll/zoom gesture recognition, no callout menu on long-press, no text
179
+ selection, no tap-highlight flash. Community convention — no primary
180
+ Apple/WebKit source names any of these four properties for iOS, see
181
+ docs/ios-conformance.md; do not attribute them to Apple. Opt-in utility
182
+ class (unlike the pointer:coarse reset above, which targets junoui's own
183
+ named components), so it isn't gated behind a pointer-type query — apply
184
+ it only to elements a gesture handler actually owns.
185
+ --juno-touch-action narrows the axis without overriding the rest of the
186
+ block; default `none` hands every axis to JS. */
187
+ .juno-gesture-surface {
188
+ touch-action: var(--juno-touch-action, none);
189
+ -webkit-touch-callout: none;
190
+ user-select: none;
191
+ -webkit-tap-highlight-color: transparent;
99
192
  }
100
193
 
194
+ /* Single-axis escape hatches: the UA keeps native scrolling on one axis
195
+ while JS reads gestures on the other (e.g. a vertical list row that also
196
+ supports horizontal swipe-to-reveal). Equivalent to
197
+ `--juno-touch-action: pan-x|pan-y` on .juno-gesture-surface, offered as
198
+ standalone classes for elements that only need the axis lock and none of
199
+ the callout/selection/tap-highlight resets. */
200
+ .juno-pan-x { touch-action: pan-x; }
201
+ .juno-pan-y { touch-action: pan-y; }
202
+
101
203
  @keyframes juno-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
102
204
 
205
+ /* Softer opacity pulse — a "working / refreshing" cadence that dims rather
206
+ than fully blinks out (used by the reload indicator and any live dot that
207
+ wants a gentler beat than juno-blink). */
208
+ @keyframes juno-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
209
+
103
210
  @media (prefers-reduced-motion: reduce) {
211
+ /* Flip the JS-readable contract declared at the top of this file. Without
212
+ this the property would always report `auto` and script could never see
213
+ the preference — the whole point of exposing it. */
214
+ :root {
215
+ --juno-motion: none;
216
+ --juno-motion-scale: 0;
217
+ }
218
+
104
219
  *, *::before, *::after {
105
220
  animation-duration: 0.01ms !important;
106
221
  animation-iteration-count: 1 !important;
@@ -125,3 +240,51 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
125
240
  .juno-readout { border: 1px solid CanvasText; }
126
241
  .juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
127
242
  }
243
+
244
+ /* ── iOS standalone letterbox unlock ─────────────────────────────────────────
245
+ iOS WebKit (observed through 26.6, iPhone 16 Pro, iOS 18.7) sizes a
246
+ Home-Screen standalone window whose DOCUMENT cannot scroll as if a
247
+ retractable browser toolbar existed: `screen.height − status bar`, pinned to
248
+ the top. The reserved strip — exactly env(safe-area-inset-top) — surfaces at
249
+ the BOTTOM of the glass, outside the window, painting black on every screen.
250
+ The same install with a scrollable document gets the whole screen.
251
+
252
+ Established by measurement, not inference (nexora 20260812-006): 201 device
253
+ readings; a four-structure A/B on one install where doc-scroll = full screen
254
+ and fixed-shell = letterboxed on every cold launch; and this exact mechanism
255
+ — the shell kept, the document left scrollable behind it by an invisible
256
+ in-flow spacer — verified full-screen on the device before landing here.
257
+ Transient scrollability is NOT enough (seven timed interventions, five
258
+ controlled runs, all negative): iOS samples the resting structure.
259
+
260
+ So: keep the document scrollable behind the app. The spacer is body::after
261
+ (a consumer that needs body::after for itself overrides this at the same
262
+ gate — none of ours do), invisible, 1px wide, and taller than the LARGE
263
+ viewport so the document always overflows whatever window iOS grants.
264
+ `overscroll-behavior: none` keeps the ghost scroller from rubber-banding;
265
+ apps put `overscroll-behavior: contain` on their real scrollers so an
266
+ inner fling never chains into it.
267
+
268
+ Gate, all three required:
269
+ display-mode: standalone only installed apps are letterboxed
270
+ pointer: coarse touch devices — keeps macOS Dock apps out
271
+ -webkit-touch-callout support iOS/iPadOS WebKit only
272
+ Selectors carry `html:root` (0,1,2) on purpose: app resets commonly declare
273
+ `body { overflow: hidden }` at (0,0,1) AFTER this sheet, and the unlock must
274
+ win the cascade without !important. */
275
+ @media (display-mode: standalone) and (pointer: coarse) {
276
+ @supports (-webkit-touch-callout: none) {
277
+ html:root,
278
+ html:root body { overflow: visible; }
279
+ html:root body { overscroll-behavior: none; }
280
+
281
+ html:root body::after {
282
+ content: '';
283
+ display: block;
284
+ width: 1px;
285
+ height: calc(100lvh + 80px);
286
+ visibility: hidden;
287
+ pointer-events: none;
288
+ }
289
+ }
290
+ }