@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/ios-pwa.md
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# junoui on iOS, and in a Home-Screen web app
|
|
2
|
+
|
|
3
|
+
The bounded claim, in one page: **what you get for free, what you must supply,
|
|
4
|
+
and what junoui explicitly does not do.** Read it before integrating; the pages
|
|
5
|
+
it links carry the detail and the derivations.
|
|
6
|
+
|
|
7
|
+
Every line here carries a number or a source. Where a claim has neither, it is
|
|
8
|
+
not on this page.
|
|
9
|
+
|
|
10
|
+
**What the numbers were measured against.** junoui **v0.5.0**; the CSS feature
|
|
11
|
+
audit is against the built `dist/css/juno.css` on **2026-08-15**; the device
|
|
12
|
+
readings are an **iPhone 16 Pro (402×874 pt), iOS 18.7 / Safari 26.6**; the
|
|
13
|
+
touch-ergonomics assertions run in Playwright at **390×844** with `hasTouch` +
|
|
14
|
+
`isMobile`. One device, one build. Nothing here is extrapolated to iPads or to
|
|
15
|
+
Android.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The floor
|
|
20
|
+
|
|
21
|
+
| | Safari / iOS | What that line means |
|
|
22
|
+
| ----------------------------------------------------- | ------------ | --------------------------------------------------- |
|
|
23
|
+
| **Hard floor** — below this, things break | **17.0** | the Popover API. Overlays stop being overlays |
|
|
24
|
+
| **Supported floor** — put this in your support matrix | **17.5** | everything works; entry animations missing below it |
|
|
25
|
+
| **Full fidelity** | **26.0** | CSS anchor positioning |
|
|
26
|
+
|
|
27
|
+
**The interesting gap is 17.5 → 26.0, not 16.x → 17.5.** Between 17.5 and 26.0
|
|
28
|
+
menus, popovers and tooltips open, close and light-dismiss correctly — and land
|
|
29
|
+
at their **static position** instead of beside their trigger, because
|
|
30
|
+
`position-area` / `position-try-fallbacks` / `position-anchor` (**10 uses**) are
|
|
31
|
+
Safari 26. The surface works; it is in the wrong place. That is a real visual
|
|
32
|
+
defect and it is easy to mistake for a break.
|
|
33
|
+
|
|
34
|
+
Below **17.0** the Popover API is absent and junoui **hides** `.juno-menu`,
|
|
35
|
+
`.juno-popover` and the top-layer tooltip behind
|
|
36
|
+
`@supports not selector(:popover-open)`. That is not a fix. Unguarded, the UA
|
|
37
|
+
rule that hides a closed popover does not exist either, so a **256–280 px
|
|
38
|
+
invisible fixed panel** sits at its static position swallowing taps on whatever
|
|
39
|
+
it covers. junoui trades that for "the panel is absent" — absent beats
|
|
40
|
+
invisibly-present — and your app branches on
|
|
41
|
+
`CSS.supports('selector(:popover-open)')`.
|
|
42
|
+
|
|
43
|
+
Full per-feature audit, with the degrade-or-break verdict for all 15 features:
|
|
44
|
+
[browser-support.md](./browser-support.md).
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## What you get for free
|
|
49
|
+
|
|
50
|
+
### Touch ergonomics
|
|
51
|
+
|
|
52
|
+
- **44 px tap targets on coarse pointers.** `--juno-size-tap-min` is **24 px**
|
|
53
|
+
(the WCAG 2.2 **2.5.8** AA floor) and flips to `--juno-size-tap-comfortable` =
|
|
54
|
+
**44 px** under `@media (pointer: coarse)`. Everything that sizes off it
|
|
55
|
+
inherits the promotion: `.juno-btn` (`min-height`), `.juno-input` and
|
|
56
|
+
`textarea` (`min-block-size`, the latter at 3×), `.juno-menu__item`,
|
|
57
|
+
`.juno-navbar__actions > *`, and `.juno-modal__close` on both axes.
|
|
58
|
+
**One exception, stated so you can size around it:** `.juno-pagination`'s
|
|
59
|
+
items take the promotion on the **inline** axis only — their `block-size` is a
|
|
60
|
+
fixed `--juno-space-32`, so they are 44 × 32 on touch. That clears the 24 px
|
|
61
|
+
AA floor and not the 44 px comfortable one (ticket 20260815-040).
|
|
62
|
+
- **Verified numerically, not by screenshot.**
|
|
63
|
+
`test/visual/tap-targets.spec.mjs` asserts the computed `min-height` **and**
|
|
64
|
+
the rendered box under **both** Playwright projects — `44px` under
|
|
65
|
+
`chromium-coarse`, `24px` under `chromium`. Keying the expectation by project
|
|
66
|
+
is what makes it a check rather than a baseline: if the coarse project ever
|
|
67
|
+
stops emulating touch it starts producing desktop numbers and **fails**
|
|
68
|
+
instead of quietly re-recording them.
|
|
69
|
+
- **A 16 px floor on text entry**, so iOS Safari does not zoom the page onto a
|
|
70
|
+
focused field: `.juno-input { font-size: max(16px, …) }`. Two caveats, both
|
|
71
|
+
load-bearing. **No primary WebKit or Apple source states the zoom behaviour** —
|
|
72
|
+
it is empirical, and the mitigation is kept because it is harmless, not
|
|
73
|
+
because it is documented. And it **never applied on any touch device until
|
|
74
|
+
2026-08-15**: the rule sat in `base.css`'s `@media (pointer: coarse)` block,
|
|
75
|
+
where a media query adds no specificity, so `input.css`'s own `.juno-input`
|
|
76
|
+
font-size beat it on source order. The numeric check above is what found it.
|
|
77
|
+
- **The UA tap-highlight square is off** on `.juno-btn`, `.juno-dock__item`,
|
|
78
|
+
`.juno-pillbar__item`, `.juno-tabs__tab`, `.juno-list__item`,
|
|
79
|
+
`.juno-menu__item`. Community convention — **no Apple source** — so it is
|
|
80
|
+
listed as a default, not a conformance claim.
|
|
81
|
+
|
|
82
|
+
### Safe areas
|
|
83
|
+
|
|
84
|
+
`env(safe-area-inset-*)` is used **21 times across 8 files**, and the rule for
|
|
85
|
+
which arithmetic applies is not one rule but **three buckets**:
|
|
86
|
+
|
|
87
|
+
| Bucket | Arithmetic | Why |
|
|
88
|
+
| ---------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------ |
|
|
89
|
+
| Padding on a surface that reaches the edge | `max(baseline, env(…))` — the inset **replaces** | the inset's job is to clear the cutout, not to stack |
|
|
90
|
+
| A floating element positioned **off** the edge | `calc(baseline + env(…))` — **additive** | `max()` would park the pill flush against the home indicator |
|
|
91
|
+
| The clearance tokens | `calc(height + … + env(…))` — **additive** | content must clear the control's height _and_ the inset |
|
|
92
|
+
|
|
93
|
+
Get this wrong in either direction and nothing errors. The derivation, with the
|
|
94
|
+
WebKit source: [ios-conformance.md](./ios-conformance.md#max-vs-addition--the-rule).
|
|
95
|
+
|
|
96
|
+
**All of it is inert until you ship `viewport-fit=cover`** — WebKit reports
|
|
97
|
+
every inset as `0` until then, and `contain` does not opt out. See
|
|
98
|
+
[what you must supply](#what-you-must-supply).
|
|
99
|
+
|
|
100
|
+
### Floating-nav clearance, derived rather than constant
|
|
101
|
+
|
|
102
|
+
A page under a floating dock or pillbar reserves room at its foot with
|
|
103
|
+
`padding-block-end: var(--juno-dock-clearance)`. Those tokens are **computed
|
|
104
|
+
from the bar's own parts**, not hardcoded:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
--juno-dock-h = tap-comfortable + 4×space-4 + 2×border-1 = 62px at defaults
|
|
108
|
+
--juno-pillbar-h = tap-comfortable + 2×space-4 + 2×border-1 = 54px at defaults
|
|
109
|
+
|
|
110
|
+
--juno-dock-clearance = dock-h × scale + 16 + 8 + env(safe-area-inset-bottom)
|
|
111
|
+
--juno-pillbar-clearance = pillbar-h × scale + 16 + 8 + env(safe-area-inset-bottom)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
— 86 px and 78 px plus the inset, at default tokens. **Why derived matters:**
|
|
115
|
+
the old constants (`space-72 + space-20 + env()`) promised in a code comment to
|
|
116
|
+
"stay correct when the dock geometry changes" and could not. junoui explicitly
|
|
117
|
+
invites you to re-parameterise the bubble via `--juno-size-tap-comfortable`, and
|
|
118
|
+
past a **58 px** bubble the constant reserved **less** than the pill's own
|
|
119
|
+
height plus its margin — content hid under the dock, silently, on exactly the
|
|
120
|
+
consumers who took the invitation (ticket 20260815-026).
|
|
121
|
+
|
|
122
|
+
`--juno-dock-clearance-scale` (default `1`) is the knob for a bar that **shrinks
|
|
123
|
+
while scrolling**: set it to the shrunken ratio so the reservation is made at
|
|
124
|
+
the small size. Reserving against a live height means relayouting content under
|
|
125
|
+
a finger that has already stopped moving.
|
|
126
|
+
|
|
127
|
+
### The Home-Screen standalone unlock
|
|
128
|
+
|
|
129
|
+
In `display-mode: standalone`, iOS sizes the window from the document's
|
|
130
|
+
**resting** scrollability at launch and letterboxes a document that cannot
|
|
131
|
+
scroll by exactly `env(safe-area-inset-top)` — measured **874 − 812 = 62**, a
|
|
132
|
+
black strip at the bottom of the glass on every screen. `base.css` keeps the
|
|
133
|
+
document scrollable behind your fixed shell with an invisible `body::after`
|
|
134
|
+
spacer, behind a **three-condition gate, all required**:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
@media (display-mode: standalone) only installed apps letterbox
|
|
138
|
+
and (pointer: coarse) keeps macOS Dock apps out
|
|
139
|
+
@supports (-webkit-touch-callout: none) iOS/iPadOS WebKit only
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**A bundled stylesheet cannot satisfy the obligation this creates** — see
|
|
143
|
+
[what you must supply](#what-you-must-supply). The measurements, the
|
|
144
|
+
four-structure A/B, and the 201 device readings behind it:
|
|
145
|
+
[ios-conformance.md](./ios-conformance.md#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it).
|
|
146
|
+
|
|
147
|
+
### Phone-shaped component behaviour
|
|
148
|
+
|
|
149
|
+
| Behaviour | Triggered by |
|
|
150
|
+
| ----------------------------------------------------------------------- | --------------------------------------------------------------- |
|
|
151
|
+
| Modal becomes a bottom sheet — full width, bottom-anchored, `85dvh` cap | `@media (width <= 639.98px)` |
|
|
152
|
+
| Toast stack goes full width at the bottom edge and slides up | `@media (width <= 639.98px)` |
|
|
153
|
+
| Table stacks into rows | `@container (max-width: 480px)` |
|
|
154
|
+
| Tab strip scrolls sideways instead of wrapping | `overflow: auto hidden` + `overscroll-behavior-inline: contain` |
|
|
155
|
+
|
|
156
|
+
Note the third row is a **container** query, not a viewport one: it reflows by
|
|
157
|
+
the space the table is in, not by the size of the phone. And the first is
|
|
158
|
+
deliberately _not_ — a modal is always in the top layer, so the screen **is**
|
|
159
|
+
its container.
|
|
160
|
+
|
|
161
|
+
### Silent-failure guards already paid for
|
|
162
|
+
|
|
163
|
+
Two defects of this class shipped and were found by looking at a phone, not by
|
|
164
|
+
any tool — both fixed 2026-08-05, both now permanent:
|
|
165
|
+
|
|
166
|
+
- `-webkit-backdrop-filter` ships beside **all 7** unprefixed
|
|
167
|
+
`backdrop-filter` declarations (unsupported below Safari 18.0 — every glass
|
|
168
|
+
surface rendered flat on iOS 17).
|
|
169
|
+
- A `::-webkit-scrollbar { display: none }` companion ships beside **all 3**
|
|
170
|
+
`scrollbar-width` declarations (unsupported below Safari 18.2 — the
|
|
171
|
+
scrollable tab strip kept a visible scrollbar on iOS 17 through 18.1).
|
|
172
|
+
|
|
173
|
+
> Both are 7 and 3 **declarations**, verified in `dist/css/juno.css`. The audit
|
|
174
|
+
> table in [browser-support.md](./browser-support.md) reports 14 and 4 because
|
|
175
|
+
> it counts string occurrences, and `-webkit-backdrop-filter` contains
|
|
176
|
+
> `backdrop-filter` — so each guarded pair is counted twice. Same fact, two
|
|
177
|
+
> units.
|
|
178
|
+
|
|
179
|
+
And **zero raw `vh`** — grep the bundle and there are none. Full-height surfaces
|
|
180
|
+
use `dvh` (**9 uses**) because `vh == lvh` normatively and a `100vh` box is
|
|
181
|
+
sized as if the toolbar were retracted; the single `lvh` in the bundle is the
|
|
182
|
+
standalone unlock's spacer, which is _supposed_ to overflow. The reasoning, and
|
|
183
|
+
the rule for a component that does not exist yet:
|
|
184
|
+
[ios-conformance.md](./ios-conformance.md#the-rule-for-a-component-that-does-not-exist-yet).
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## What you must supply
|
|
189
|
+
|
|
190
|
+
junoui is a stylesheet. Everything below is outside what CSS can reach.
|
|
191
|
+
|
|
192
|
+
| # | You ship | If you don't |
|
|
193
|
+
| --- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
194
|
+
| 1 | `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">` | every safe-area guarantee becomes a no-op, silently; and 1 CSS px stops equalling 1 pt |
|
|
195
|
+
| 2 | A standalone declaration — manifest `"display": "standalone"` or `apple-mobile-web-app-capable` — **or a decision not to** | from iOS 26 the **user** grants standalone at add-to-Home-Screen time and junoui's standalone CSS runs anyway |
|
|
196
|
+
| 3 | **An inline copy of the letterbox unlock in `<head>`**, if your shell paints before its CSS bundle | iOS samples the document at launch; `juno.css` arrives after, so the app letterboxes with the unlock present |
|
|
197
|
+
| 4 | Leave `body::after` alone, or reproduce the spacer at the same gate | the unlock has no spacer and the window letterboxes |
|
|
198
|
+
| 5 | `overscroll-behavior: contain` on your real scrollers | an inner fling chains into the unlock's ghost scroller |
|
|
199
|
+
| 6 | A popover fallback below Safari 17.0, branched on `CSS.supports('selector(:popover-open)')` | overflow navigation is unreachable — junoui hid the panel rather than leave it eating taps |
|
|
200
|
+
| 7 | Pinning `inset` on the `toggle` event below Safari 26.0 | anchored surfaces open at their static position |
|
|
201
|
+
| 8 | `data-juno-letterboxed` on `<html>`, if you want to react to a letterboxed window | nothing; the flag is opt-in — but invent your own attribute name and your CSS can never move into junoui |
|
|
202
|
+
|
|
203
|
+
**#3 is the one that catches people**, because it is silent in both directions:
|
|
204
|
+
miss it and the app letterboxes with a correct stylesheet installed, ship it and
|
|
205
|
+
nothing confirms it worked except a screenshot of the device. Rows 2, 3 and 8
|
|
206
|
+
are specified in full — declarations, status-bar-style semantics, the exact
|
|
207
|
+
letterbox predicate — in
|
|
208
|
+
[the consumer `<head>` contract](./ios-conformance.md#becoming-a-home-screen-web-app-the-consumer-head-contract).
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## What junoui explicitly does not do
|
|
213
|
+
|
|
214
|
+
This half is why the page exists. None of the following is planned, partial, or
|
|
215
|
+
"coming to the roadmap" — it is out of scope by design.
|
|
216
|
+
|
|
217
|
+
**No JS behaviour.** No focus traps, no scroll locking, no gesture or swipe
|
|
218
|
+
handling, no runtime popover positioning, no list virtualization, no state. Every
|
|
219
|
+
component renders with zero JS; what is stateful is the platform's (`<dialog>`,
|
|
220
|
+
`popover`, `<details>`) or yours. junoui does ship two JS entry points —
|
|
221
|
+
`icons/inline` and `icons/install` — and they are **icon-sprite helpers, nothing
|
|
222
|
+
more**.
|
|
223
|
+
|
|
224
|
+
**No service worker, no offline story, no cache.** No precaching, no purge, no
|
|
225
|
+
versioning, no `skipWaiting` policy. [boot-shell.md](./boot-shell.md) documents
|
|
226
|
+
the five-rung boot pattern including an app-shell service worker — that is a
|
|
227
|
+
**recipe your app implements**, with a reference implementation in another
|
|
228
|
+
repository. junoui ships none of that code.
|
|
229
|
+
|
|
230
|
+
**No native chrome.** `dist/ios/JunoTokens.swift` is **token values only**. There
|
|
231
|
+
are no UIKit or SwiftUI components. `.juno-dock` and `.juno-navbar` are CSS that
|
|
232
|
+
_looks_ like a bar; they are not the system bar and do not inherit its
|
|
233
|
+
behaviours.
|
|
234
|
+
|
|
235
|
+
**Nothing about in-app browsers.** SFSafariViewController and WKWebView-based
|
|
236
|
+
in-app browsers (Instagram, Facebook) are a **separate, unfixed path** and
|
|
237
|
+
junoui has tested neither. The one datum on record: Safari 26.0's notes claim a
|
|
238
|
+
fix for `lvh`/`vh` being sized against the small viewport in
|
|
239
|
+
SFSafariViewController, but **WebKit bug 255708 (filed 2023) is still open**, so
|
|
240
|
+
that "Fixed" is stronger than the tracker supports.
|
|
241
|
+
|
|
242
|
+
**No app-shell assets.** No web app manifest, no Home-Screen icons, no splash
|
|
243
|
+
screens, no install prompt. `src/icons/` is a **UI icon sprite** — the icons
|
|
244
|
+
inside your interface, not the icon on the Home Screen.
|
|
245
|
+
|
|
246
|
+
**No guarantee below Safari / iOS 17.0**, and the losses compound rather than
|
|
247
|
+
arriving all at once: **16.2** drops `color-mix()` (**28 uses** — every role
|
|
248
|
+
tint, border and shadow built from `var(--juno-role)`, which is the contract
|
|
249
|
+
junoui exists to encode); **16.0** drops `@container`; **15.4** drops `oklch()`
|
|
250
|
+
(**190 uses** — every token value) and `dvh`/`lvh` (**10 uses** — the app shell
|
|
251
|
+
collapses to content height). There is no fallback layer for any of them, and
|
|
252
|
+
none is planned. If you are on iOS 15 you are not using junoui.
|
|
253
|
+
|
|
254
|
+
**No verified iOS 26 story.** The claim "iOS 26 changed nothing about safe
|
|
255
|
+
areas, viewport-fit, touch behaviour or focus zoom" was **refuted** during
|
|
256
|
+
verification, so junoui's iOS 26 behaviour is _unverified_, not
|
|
257
|
+
_confirmed-safe_. Open leads — `vh` reportedly pinning to `window.outerHeight`,
|
|
258
|
+
three new tab modes yielding different `innerHeight`, iPadOS 26 windowed mode
|
|
259
|
+
reportedly returning nothing for `env(safe-area-inset-*)` — need a physical
|
|
260
|
+
device, not more documentation.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Where the detail lives
|
|
265
|
+
|
|
266
|
+
| Page | What it settles |
|
|
267
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
|
268
|
+
| [ios-conformance.md](./ios-conformance.md) | every iOS metric with its source, the folklore named, the letterbox, the `<head>` contract |
|
|
269
|
+
| [browser-support.md](./browser-support.md) | the three floors, all 15 features with degrade-or-break, the two shipped silent failures |
|
|
270
|
+
| [getting-started.md](./getting-started.md) | the required viewport meta and what depends on it |
|
|
271
|
+
| [accessibility.md](./accessibility.md) | the WCAG 2.2 criterion behind each target size, and the per-component ARIA contract |
|
|
272
|
+
| [integration.md](./integration.md) | import order, the token bridge, and where app-specific things go |
|
|
273
|
+
| [boot-shell.md](./boot-shell.md) | the boot ladder — a pattern to implement, not code junoui ships |
|
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.7.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",
|
|
@@ -50,7 +50,10 @@
|
|
|
50
50
|
"./flutter": "./dist/flutter/juno_tokens.dart",
|
|
51
51
|
"./icons": "./dist/icons/juno-icons.svg",
|
|
52
52
|
"./subset": "./tools/subset-sprite.mjs",
|
|
53
|
+
"./testing": "./tools/testing.mjs",
|
|
54
|
+
"./classes.json": "./dist/classes.json",
|
|
53
55
|
"./icons/inline": "./dist/icons/inline.js",
|
|
56
|
+
"./icons/install": "./dist/icons/install.js",
|
|
54
57
|
"./package.json": "./package.json"
|
|
55
58
|
},
|
|
56
59
|
"files": [
|
|
@@ -67,7 +70,8 @@
|
|
|
67
70
|
"build:tokens": "node style-dictionary.config.mjs",
|
|
68
71
|
"build:icons": "node scripts/build-icons.mjs",
|
|
69
72
|
"build:css": "node scripts/bundle-css.mjs",
|
|
70
|
-
"build": "
|
|
73
|
+
"build:classes": "node scripts/build-classes.mjs",
|
|
74
|
+
"build": "npm run build:tokens && npm run build:icons && npm run build:css && npm run build:classes",
|
|
71
75
|
"gen-docs": "node scripts/gen-docs.mjs",
|
|
72
76
|
"clean": "node -e \"import('node:fs').then(fs=>fs.rmSync('dist',{recursive:true,force:true}))\"",
|
|
73
77
|
"lint:css": "stylelint \"src/css/**/*.css\"",
|
|
@@ -80,6 +84,7 @@
|
|
|
80
84
|
"showcase": "npm run build && node scripts/serve.mjs",
|
|
81
85
|
"test:visual": "playwright test",
|
|
82
86
|
"test:visual:update": "playwright test --update-snapshots",
|
|
87
|
+
"gate:consumer": "node scripts/consumer-gate.mjs",
|
|
83
88
|
"changeset": "changeset",
|
|
84
89
|
"version": "changeset version",
|
|
85
90
|
"release": "npm run build && changeset publish"
|
|
@@ -94,5 +99,12 @@
|
|
|
94
99
|
},
|
|
95
100
|
"engines": {
|
|
96
101
|
"node": ">=18"
|
|
97
|
-
}
|
|
102
|
+
},
|
|
103
|
+
"browserslist": [
|
|
104
|
+
"iOS >= 17.0",
|
|
105
|
+
"Safari >= 17.0",
|
|
106
|
+
"Chrome >= 114",
|
|
107
|
+
"Edge >= 114",
|
|
108
|
+
"Firefox >= 125"
|
|
109
|
+
]
|
|
98
110
|
}
|
package/src/css/base.css
CHANGED
|
@@ -19,13 +19,69 @@
|
|
|
19
19
|
/* Floating-nav scroll clearance — a page/scroller that a fixed dock or
|
|
20
20
|
pillbar floats over reserves this much room at its foot so the last row
|
|
21
21
|
clears the overlay. Consumers write `padding-block-end:
|
|
22
|
-
var(--juno-dock-clearance)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
var(--juno-dock-clearance)`. Web-only (they carry env()), so they live in
|
|
23
|
+
the CSS layer, not the cross-platform token set.
|
|
24
|
+
|
|
25
|
+
DERIVED, not constant. These used to be `space-72 + space-20 + env()`,
|
|
26
|
+
which promised in this very comment to "stay correct when the dock
|
|
27
|
+
geometry changes" and could not: the floating pill measures 62px with the
|
|
28
|
+
default 44px bubble, and junoui explicitly invites a consumer to
|
|
29
|
+
parameterize that bubble (--juno-size-tap-comfortable). Past a 58px bubble
|
|
30
|
+
the old constant reserved LESS than the pill's own height plus its margin
|
|
31
|
+
— content hid under the dock, silently, on exactly the consumers who took
|
|
32
|
+
the invitation (20260815-026).
|
|
33
|
+
|
|
34
|
+
So the parts are named and the arithmetic is visible:
|
|
35
|
+
--juno-dock-h the pill's laid-out height: the bubble, the item's
|
|
36
|
+
padding-block, the pill's own padding, its border.
|
|
37
|
+
* -clearance-scale consumers that SHRINK the bar while scrolling
|
|
38
|
+
reserve at the small size, not the live one — a
|
|
39
|
+
reservation that tracks a value which changes after
|
|
40
|
+
the last scroll event relayouts content under a
|
|
41
|
+
finger that has already stopped moving. Default 1.
|
|
42
|
+
+ space-16 + env() the pill's own bottom margin (dock.css), additive
|
|
43
|
+
because a floating element sits OFF the edge —
|
|
44
|
+
docs/ios-conformance.md, "max() vs addition".
|
|
45
|
+
+ space-8 breathing room, so the last row is not flush. */
|
|
46
|
+
--juno-dock-h: calc(
|
|
47
|
+
var(--juno-size-tap-comfortable) + 4 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
|
|
48
|
+
);
|
|
49
|
+
--juno-dock-clearance-scale: 1;
|
|
50
|
+
|
|
51
|
+
/* The bar's own offset from the bottom edge — declared ONCE and consumed by
|
|
52
|
+
both the bar's margin (dock.css) and the clearance below, so the two cannot
|
|
53
|
+
disagree about where the bar sits.
|
|
54
|
+
|
|
55
|
+
That is the whole reason this is a token rather than a literal in each
|
|
56
|
+
place. junoui's default is additive, per docs/ios-conformance.md: a
|
|
57
|
+
FLOATING element sits off the edge, so its gap and the inset stack. A
|
|
58
|
+
consumer whose design wants the bar flush above the home indicator instead
|
|
59
|
+
writes `max(8px, env(safe-area-inset-bottom, 0px))` here and BOTH the
|
|
60
|
+
margin and the reservation follow it. Before this token the form was baked
|
|
61
|
+
into each site separately, so a consumer that changed one silently
|
|
62
|
+
disagreed with the other — measured at 16px of dead band at inset 0 and
|
|
63
|
+
24px at inset 34 (20260815-055), and no single --juno-dock-h could
|
|
64
|
+
reconcile them because one side added the inset and the other maxed it. */
|
|
65
|
+
--juno-dock-edge-offset: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
|
|
66
|
+
|
|
67
|
+
/* Gap between the bar's top edge and the last row of content. Zero is a
|
|
68
|
+
legitimate setting: a translucent bar that content is MEANT to scroll under
|
|
69
|
+
wants overlap, not breathing room. */
|
|
70
|
+
--juno-dock-clearance-breathing: var(--juno-space-8);
|
|
71
|
+
--juno-dock-clearance: calc(
|
|
72
|
+
var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
|
|
73
|
+
var(--juno-dock-clearance-breathing)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
/* The pillbar is the same shape one size down: no bubble, so its height is
|
|
77
|
+
the tap target plus its own padding and border. */
|
|
78
|
+
--juno-pillbar-h: calc(
|
|
79
|
+
var(--juno-size-tap-comfortable) + 2 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
|
|
80
|
+
);
|
|
81
|
+
--juno-pillbar-clearance: calc(
|
|
82
|
+
var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
|
|
83
|
+
var(--juno-dock-clearance-breathing)
|
|
84
|
+
);
|
|
29
85
|
|
|
30
86
|
/* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
|
|
31
87
|
imperative JS (scrollTo/scrollIntoView smooth behavior, rAF-driven transforms,
|
|
@@ -127,8 +183,11 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
127
183
|
is harmless either way: hold text-entry controls (.juno-input covers input,
|
|
128
184
|
textarea and the select's inner control) at a 16px floor on touch. max()
|
|
129
185
|
keeps the floor even under a scaled-down --juno-font-scale, and still grows
|
|
130
|
-
when scaled up.
|
|
131
|
-
|
|
186
|
+
when scaled up.
|
|
187
|
+
THE RULE ITSELF LIVES IN components/input.css, not here: a media query adds
|
|
188
|
+
no specificity, so this file's `.juno-input` would lose to input.css's own
|
|
189
|
+
`.juno-input` font-size later in the bundle. It did, silently, until
|
|
190
|
+
20260815-006's coarse-pointer project measured it. */
|
|
132
191
|
|
|
133
192
|
/* Kill the UA tap-highlight square on the interactive surfaces so it never
|
|
134
193
|
flashes past a rounded control on tap. Consumers were adding this by hand
|
|
@@ -138,7 +197,7 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
138
197
|
.juno-dock__item,
|
|
139
198
|
.juno-pillbar__item,
|
|
140
199
|
.juno-tabs__tab,
|
|
141
|
-
.juno-
|
|
200
|
+
.juno-list__row,
|
|
142
201
|
.juno-menu__item
|
|
143
202
|
) {
|
|
144
203
|
-webkit-tap-highlight-color: transparent;
|
|
@@ -163,9 +222,9 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
163
222
|
.juno-pillbar__item,
|
|
164
223
|
.juno-pillbar__overflow,
|
|
165
224
|
.juno-tabs__tab,
|
|
166
|
-
.juno-
|
|
225
|
+
.juno-list__row,
|
|
167
226
|
.juno-menu__item,
|
|
168
|
-
.juno-
|
|
227
|
+
.juno-seg__opt,
|
|
169
228
|
.juno-chip,
|
|
170
229
|
.juno-toggle-btn
|
|
171
230
|
) {
|
|
@@ -229,17 +288,11 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
229
288
|
:root { --juno-border: var(--juno-border-strong); }
|
|
230
289
|
}
|
|
231
290
|
|
|
232
|
-
/* Windows High Contrast / forced-colors:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
.juno-badge,
|
|
238
|
-
.juno-btn,
|
|
239
|
-
.juno-card,
|
|
240
|
-
.juno-readout { border: 1px solid CanvasText; }
|
|
241
|
-
.juno-badge { forced-color-adjust: none; } /* keep status fill meaningful */
|
|
242
|
-
}
|
|
291
|
+
/* Windows High Contrast / forced-colors: the opt-in lives in
|
|
292
|
+
overrides.css, NOT here. A media query adds no specificity, and this file is
|
|
293
|
+
bundled BEFORE components/, so `.juno-btn { border: … }` in button.css beat
|
|
294
|
+
the forced-colors border every time — same 0,1,0 selector, later in the
|
|
295
|
+
bundle. See src/css/overrides.css and ticket 20260815-029. */
|
|
243
296
|
|
|
244
297
|
/* ── iOS standalone letterbox unlock ─────────────────────────────────────────
|
|
245
298
|
iOS WebKit (observed through 26.6, iPhone 16 Pro, iOS 18.7) sizes a
|
|
@@ -288,3 +341,30 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
288
341
|
}
|
|
289
342
|
}
|
|
290
343
|
}
|
|
344
|
+
|
|
345
|
+
/* ── Popover API absent (Safari/iOS < 17.0) ──────────────────────────────────
|
|
346
|
+
junoui's overlay surfaces are built on the native Popover API. Without it
|
|
347
|
+
`popovertarget` does nothing, so they cannot open — an inconvenience. The
|
|
348
|
+
part that is a BUG is that the UA rule which hides a closed popover
|
|
349
|
+
(`[popover]:not(:popover-open) { display: none }`) does not exist either, so
|
|
350
|
+
the panel participates in layout: .juno-menu and .juno-popover are
|
|
351
|
+
`position: fixed` with `opacity: 0` and no pointer-events reset, i.e. a
|
|
352
|
+
256-280px INVISIBLE panel parked at its static position, swallowing taps on
|
|
353
|
+
whatever it covers. (.juno-tooltip__bubble escapes it — it inherits
|
|
354
|
+
`pointer-events: none` from the CSS-only tooltip rule — and is listed anyway
|
|
355
|
+
so the set matches the docs.)
|
|
356
|
+
|
|
357
|
+
So: hide them. Absent beats invisibly-present. Apps detect the same condition
|
|
358
|
+
with CSS.supports('selector(:popover-open)') and render a fallback.
|
|
359
|
+
|
|
360
|
+
@supports selector() is itself Safari 14.1+; below that this condition is
|
|
361
|
+
invalid, the block is skipped, and behaviour is what it is today. Fail-open,
|
|
362
|
+
and far below any supported floor. Guards live here rather than per component
|
|
363
|
+
so the next popover-based component inherits it — see docs/browser-support.md
|
|
364
|
+
for the rule about when a feature gap earns a guard at all (functional
|
|
365
|
+
failures only; a missing animation does not get one). */
|
|
366
|
+
@supports not selector(:popover-open) {
|
|
367
|
+
.juno-menu[popover],
|
|
368
|
+
.juno-popover[popover],
|
|
369
|
+
.juno-tooltip__bubble[popover] { display: none; }
|
|
370
|
+
}
|
|
@@ -40,8 +40,10 @@
|
|
|
40
40
|
.juno-btn--ghost:hover { background: var(--juno-s2); filter: none; }
|
|
41
41
|
|
|
42
42
|
/* Small — for dense desktop toolbars (40–46px bars) where the default
|
|
43
|
-
control would not fit.
|
|
44
|
-
|
|
43
|
+
control would not fit. Combines with --ghost + roles.
|
|
44
|
+
On a FINE pointer this is 24px: the WCAG 2.2 AA floor (2.5.8) exactly, and
|
|
45
|
+
no more. On a coarse pointer it promotes to the comfortable target — see
|
|
46
|
+
below. */
|
|
45
47
|
.juno-btn--sm {
|
|
46
48
|
min-height: var(--juno-space-24);
|
|
47
49
|
font-size: var(--juno-font-size-11);
|
|
@@ -50,6 +52,44 @@
|
|
|
50
52
|
border-radius: var(--juno-radius-3);
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/* --sm names a DENSITY, and consumers reach for it as a SEMANTIC. Audited
|
|
56
|
+
across one app: 40 call sites, nearly all of them `--sm --ghost` meaning
|
|
57
|
+
"secondary", shipping a 24px target on a phone — and junoui's own showcase
|
|
58
|
+
does it twice in a navbar action slot. A size modifier should not quietly
|
|
59
|
+
become a tap-target decision, so on touch it holds the same floor every
|
|
60
|
+
other control does. Type and padding still shrink: --sm stays a density
|
|
61
|
+
modifier, it just stops being a touch-target one (20260826-026).
|
|
62
|
+
|
|
63
|
+
THE RULE LIVES HERE, not in base.css's (pointer: coarse) block: a media
|
|
64
|
+
query adds no specificity, so a `.juno-btn--sm` there would lose to this
|
|
65
|
+
file's own `.juno-btn--sm` later in the bundle. That is not a guess — it is
|
|
66
|
+
what silently happened to the .juno-input font floor until 20260815-006
|
|
67
|
+
measured it.
|
|
68
|
+
|
|
69
|
+
--dense is the opt-out, and it is deliberately only meaningful ON --sm:
|
|
70
|
+
a dense touch toolbar is a real thing (a media scrubber, an editor rail),
|
|
71
|
+
but it is a decision someone has to make by name rather than inherit from a
|
|
72
|
+
size.
|
|
73
|
+
|
|
74
|
+
The two rules below are REDUNDANT ON PURPOSE, and mutation testing is how
|
|
75
|
+
that became a decision rather than an accident: deleting either one leaves
|
|
76
|
+
--dense at 24px. The `:not()` excludes a dense button from the promotion, so
|
|
77
|
+
the pairing below it is dead weight — until the `:not()` names the wrong
|
|
78
|
+
class, at which point the pairing (also (0,2,0), and later in the file) is
|
|
79
|
+
the only thing holding the opt-out. Each covers the other's failure, so a
|
|
80
|
+
single edit to either cannot silently promote a --dense button. Both
|
|
81
|
+
selectors are (0,2,0) and mutually exclusive in the correct state, so they
|
|
82
|
+
never race. */
|
|
83
|
+
@media (pointer: coarse) {
|
|
84
|
+
.juno-btn--sm:not(.juno-btn--dense) {
|
|
85
|
+
min-height: var(--juno-size-tap-min);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.juno-btn--sm.juno-btn--dense {
|
|
89
|
+
min-height: var(--juno-space-24);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
53
93
|
.juno-btn:disabled {
|
|
54
94
|
background: var(--juno-muted);
|
|
55
95
|
color: var(--juno-label);
|