@junoput01/junoui 0.6.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 +104 -0
- package/README.md +17 -16
- package/dist/classes.json +1614 -0
- package/dist/css/juno.css +199 -20
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +3 -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 +14 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +200 -3
- package/docs/ios-pwa.md +273 -0
- package/package.json +5 -2
- package/src/css/base.css +28 -7
- 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/segmented.css +15 -2
- 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/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,6 +50,8 @@
|
|
|
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",
|
|
54
56
|
"./icons/install": "./dist/icons/install.js",
|
|
55
57
|
"./package.json": "./package.json"
|
|
@@ -68,7 +70,8 @@
|
|
|
68
70
|
"build:tokens": "node style-dictionary.config.mjs",
|
|
69
71
|
"build:icons": "node scripts/build-icons.mjs",
|
|
70
72
|
"build:css": "node scripts/bundle-css.mjs",
|
|
71
|
-
"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",
|
|
72
75
|
"gen-docs": "node scripts/gen-docs.mjs",
|
|
73
76
|
"clean": "node -e \"import('node:fs').then(fs=>fs.rmSync('dist',{recursive:true,force:true}))\"",
|
|
74
77
|
"lint:css": "stylelint \"src/css/**/*.css\"",
|
package/src/css/base.css
CHANGED
|
@@ -47,9 +47,30 @@
|
|
|
47
47
|
var(--juno-size-tap-comfortable) + 4 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
|
|
48
48
|
);
|
|
49
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);
|
|
50
71
|
--juno-dock-clearance: calc(
|
|
51
|
-
var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-
|
|
52
|
-
var(--juno-
|
|
72
|
+
var(--juno-dock-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
|
|
73
|
+
var(--juno-dock-clearance-breathing)
|
|
53
74
|
);
|
|
54
75
|
|
|
55
76
|
/* The pillbar is the same shape one size down: no bubble, so its height is
|
|
@@ -58,8 +79,8 @@
|
|
|
58
79
|
var(--juno-size-tap-comfortable) + 2 * var(--juno-space-4) + 2 * var(--juno-border-width-1)
|
|
59
80
|
);
|
|
60
81
|
--juno-pillbar-clearance: calc(
|
|
61
|
-
var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-
|
|
62
|
-
var(--juno-
|
|
82
|
+
var(--juno-pillbar-h) * var(--juno-dock-clearance-scale) + var(--juno-dock-edge-offset) +
|
|
83
|
+
var(--juno-dock-clearance-breathing)
|
|
63
84
|
);
|
|
64
85
|
|
|
65
86
|
/* JS-readable motion contract. CSS-only `prefers-reduced-motion` never reaches
|
|
@@ -176,7 +197,7 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
176
197
|
.juno-dock__item,
|
|
177
198
|
.juno-pillbar__item,
|
|
178
199
|
.juno-tabs__tab,
|
|
179
|
-
.juno-
|
|
200
|
+
.juno-list__row,
|
|
180
201
|
.juno-menu__item
|
|
181
202
|
) {
|
|
182
203
|
-webkit-tap-highlight-color: transparent;
|
|
@@ -201,9 +222,9 @@ code, kbd, samp, pre { font-family: var(--juno-font-family-mono); }
|
|
|
201
222
|
.juno-pillbar__item,
|
|
202
223
|
.juno-pillbar__overflow,
|
|
203
224
|
.juno-tabs__tab,
|
|
204
|
-
.juno-
|
|
225
|
+
.juno-list__row,
|
|
205
226
|
.juno-menu__item,
|
|
206
|
-
.juno-
|
|
227
|
+
.juno-seg__opt,
|
|
207
228
|
.juno-chip,
|
|
208
229
|
.juno-toggle-btn
|
|
209
230
|
) {
|
|
@@ -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);
|
|
@@ -29,6 +29,55 @@
|
|
|
29
29
|
media query. */
|
|
30
30
|
--juno-dock-scale: 1;
|
|
31
31
|
|
|
32
|
+
/* ── Horizontal budget contract ──────────────────────────────────────
|
|
33
|
+
Published so a consumer's capacity planner ("how many items fit, and
|
|
34
|
+
do they still hold a tap target?") reads junoui's real geometry
|
|
35
|
+
instead of re-deriving it from the numbers in this file. Two
|
|
36
|
+
consumers had derived it by hand, twice, in prose — and both had
|
|
37
|
+
drifted: they subtracted 12px of inline padding where the pill
|
|
38
|
+
actually spends 8, so their per-item width came out ~0.8px low at
|
|
39
|
+
every viewport (20260826-027).
|
|
40
|
+
|
|
41
|
+
--juno-dock-items the item budget. junoui does not enforce it —
|
|
42
|
+
__item is `flex: 1 1 0`, so the layout divides
|
|
43
|
+
by however many items are present — it is the
|
|
44
|
+
number the DERIVATIONS below are about, and the
|
|
45
|
+
consumer sets it to what it renders.
|
|
46
|
+
--juno-dock-item-inline the width one item gets. A prediction of what
|
|
47
|
+
the flex layout produces, not an input to it;
|
|
48
|
+
asserted against the measured box in the test
|
|
49
|
+
suite so it cannot drift from the layout.
|
|
50
|
+
--juno-dock-fit-inline the narrowest viewport at which every item
|
|
51
|
+
still holds --juno-size-tap-comfortable. Use it
|
|
52
|
+
in a media query and DROP an item; that is the
|
|
53
|
+
answer junoui can give exactly.
|
|
54
|
+
|
|
55
|
+
A SCALE floor (44px / the item's width) is deliberately not published:
|
|
56
|
+
it is a ratio of two lengths, and CSS cannot divide by a length. A
|
|
57
|
+
consumer that must scale rather than drop compares
|
|
58
|
+
--juno-dock-item-inline against --juno-size-tap-comfortable itself.
|
|
59
|
+
|
|
60
|
+
The three inline terms are declared ONCE here and consumed by both the
|
|
61
|
+
variants' own margin/padding/border and the sums below, so the budget
|
|
62
|
+
cannot disagree with the bar it describes — the same reason
|
|
63
|
+
--juno-dock-edge-offset exists (20260815-055). The base bar is
|
|
64
|
+
full-bleed with a top-edge border only, so all three are zero; --pill
|
|
65
|
+
and --float set them. */
|
|
66
|
+
--juno-dock-items: 5;
|
|
67
|
+
--juno-dock-margin-inline: 0px;
|
|
68
|
+
--juno-dock-pad-inline: 0px;
|
|
69
|
+
--juno-dock-border-inline: 0px;
|
|
70
|
+
--juno-dock-chrome-inline: calc(
|
|
71
|
+
2 * (var(--juno-dock-margin-inline) + var(--juno-dock-pad-inline) + var(--juno-dock-border-inline))
|
|
72
|
+
);
|
|
73
|
+
--juno-dock-avail: 100vw;
|
|
74
|
+
--juno-dock-item-inline: calc(
|
|
75
|
+
(var(--juno-dock-avail) - var(--juno-dock-chrome-inline)) / var(--juno-dock-items)
|
|
76
|
+
);
|
|
77
|
+
--juno-dock-fit-inline: calc(
|
|
78
|
+
var(--juno-dock-items) * var(--juno-size-tap-comfortable) + var(--juno-dock-chrome-inline)
|
|
79
|
+
);
|
|
80
|
+
|
|
32
81
|
position: sticky;
|
|
33
82
|
inset-block-end: 0;
|
|
34
83
|
z-index: var(--juno-z-raised);
|
|
@@ -123,16 +172,21 @@
|
|
|
123
172
|
.juno-dock--pill {
|
|
124
173
|
--juno-role: var(--juno-active);
|
|
125
174
|
|
|
175
|
+
/* the three inline terms the budget contract sums — see .juno-dock */
|
|
176
|
+
--juno-dock-margin-inline: var(--juno-space-12);
|
|
177
|
+
--juno-dock-pad-inline: var(--juno-space-4);
|
|
178
|
+
--juno-dock-border-inline: var(--juno-border-width-1);
|
|
179
|
+
|
|
126
180
|
position: fixed;
|
|
127
181
|
inset-inline: 0;
|
|
128
182
|
inset-block-end: 0;
|
|
129
183
|
z-index: var(--juno-z-raised);
|
|
130
|
-
margin: 0 var(--juno-
|
|
131
|
-
padding: var(--juno-
|
|
184
|
+
margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
|
|
185
|
+
padding: var(--juno-dock-pad-inline);
|
|
132
186
|
background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
|
|
133
187
|
-webkit-backdrop-filter: blur(12px);
|
|
134
188
|
backdrop-filter: blur(12px);
|
|
135
|
-
border: var(--juno-border-
|
|
189
|
+
border: var(--juno-dock-border-inline) solid var(--juno-border);
|
|
136
190
|
border-radius: 999px;
|
|
137
191
|
box-shadow: var(--juno-shadow-2);
|
|
138
192
|
}
|
|
@@ -226,17 +280,22 @@
|
|
|
226
280
|
Out of flow (fixed) so the page scrolls UNDER it — reserve room on the
|
|
227
281
|
scroller with padding-block-end: var(--juno-dock-clearance). */
|
|
228
282
|
.juno-dock--float {
|
|
283
|
+
/* the three inline terms the budget contract sums — see .juno-dock */
|
|
284
|
+
--juno-dock-margin-inline: var(--juno-space-12);
|
|
285
|
+
--juno-dock-pad-inline: var(--juno-space-4);
|
|
286
|
+
--juno-dock-border-inline: var(--juno-border-width-1);
|
|
287
|
+
|
|
229
288
|
position: fixed;
|
|
230
289
|
inset-inline: 0;
|
|
231
290
|
inset-block-end: 0;
|
|
232
291
|
z-index: var(--juno-z-raised);
|
|
233
|
-
margin: 0 var(--juno-
|
|
234
|
-
padding: var(--juno-
|
|
292
|
+
margin: 0 var(--juno-dock-margin-inline) var(--juno-dock-edge-offset);
|
|
293
|
+
padding: var(--juno-dock-pad-inline);
|
|
235
294
|
overflow: hidden;
|
|
236
295
|
background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
|
|
237
296
|
-webkit-backdrop-filter: blur(12px);
|
|
238
297
|
backdrop-filter: blur(12px);
|
|
239
|
-
border: var(--juno-border-
|
|
298
|
+
border: var(--juno-dock-border-inline) solid var(--juno-border);
|
|
240
299
|
border-radius: 999px;
|
|
241
300
|
box-shadow: var(--juno-shadow-2);
|
|
242
301
|
}
|
|
@@ -12,17 +12,44 @@
|
|
|
12
12
|
* gap, the folded slot still occupies one gap — name it in
|
|
13
13
|
* --juno-fold-gap and the fold swallows it with a negative margin.
|
|
14
14
|
*
|
|
15
|
+
* COMPOSITION. The canonical use puts .juno-fold on an element that is
|
|
16
|
+
* already a component — a .juno-pillbar__item, a .juno-btn, a .juno-chip
|
|
17
|
+
* — because that is where the capsule chrome (tap floor, padding, hover,
|
|
18
|
+
* focus ring, aria-pressed) lives. Two things follow, and both are
|
|
19
|
+
* load-bearing rather than defensive:
|
|
20
|
+
*
|
|
21
|
+
* 1. The fold's own declarations are stated at ATTRIBUTE specificity
|
|
22
|
+
* (0,2,0), on both branches of data-juno-in, i.e. always. A component
|
|
23
|
+
* class is (0,1,0), and `transition` is a SHORTHAND: a later rule does
|
|
24
|
+
* not add to the list, it replaces it. Measured in Chromium against the
|
|
25
|
+
* built bundle, before this was raised: on a
|
|
26
|
+
* .juno-fold.juno-pillbar__item, transition-property resolved to
|
|
27
|
+
* `color, background-color` — pillbar.css sorts after this file, so the
|
|
28
|
+
* fold had no transition at all and jumped shut instead of folding.
|
|
29
|
+
* inline-size and overflow would lose the same way to a component that
|
|
30
|
+
* sets either.
|
|
31
|
+
*
|
|
32
|
+
* 2. Owning `transition` means owning ALL of it, so the capsule chrome
|
|
33
|
+
* properties are in the fold's list too, at the quick duration the
|
|
34
|
+
* components themselves use. Dropping them would trade a broken fold
|
|
35
|
+
* for a broken hover.
|
|
36
|
+
*
|
|
37
|
+
* Which floors the fold RELEASES when folded — see the folded rule below:
|
|
38
|
+
* min-inline-size, padding-inline, border-inline-width. Those are exactly
|
|
39
|
+
* the three inputs that can hold a border-box inline size above zero.
|
|
40
|
+
*
|
|
15
41
|
* Canonical use: a transient action in a toolbar/pill row (a
|
|
16
42
|
* scroll-to-top arrow, a contextual button) whose arrival should slide
|
|
17
43
|
* the row open rather than jump it.
|
|
18
44
|
* Usage:
|
|
19
45
|
* <div style="display:flex; gap:4px"> (any row)
|
|
20
|
-
* <button class="juno-fold" data-juno-in aria-label="Scroll to top">…</button>
|
|
46
|
+
* <button class="juno-fold juno-pillbar__item" data-juno-in aria-label="Scroll to top">…</button>
|
|
21
47
|
* …other members…
|
|
22
48
|
* </div>
|
|
23
49
|
* prefers-reduced-motion needs nothing component-local — the base
|
|
24
50
|
* layer zeroes every transition duration. */
|
|
25
|
-
.juno-fold
|
|
51
|
+
.juno-fold[data-juno-in],
|
|
52
|
+
.juno-fold:not([data-juno-in]) {
|
|
26
53
|
--juno-fold-size: var(--juno-size-tap-comfortable);
|
|
27
54
|
--juno-fold-gap: 0px;
|
|
28
55
|
|
|
@@ -31,13 +58,32 @@
|
|
|
31
58
|
overflow: hidden;
|
|
32
59
|
transition:
|
|
33
60
|
inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
61
|
+
min-inline-size var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
62
|
+
padding var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
63
|
+
border-width var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
34
64
|
margin var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
35
65
|
opacity var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
36
|
-
visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard)
|
|
66
|
+
visibility var(--juno-motion-duration-base) var(--juno-motion-ease-standard),
|
|
67
|
+
color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard),
|
|
68
|
+
background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
|
|
37
69
|
}
|
|
38
70
|
|
|
71
|
+
/* Folded away. A min-* floor clamps the USED value whichever rule won the
|
|
72
|
+
cascade, and a border-box inline size can never resolve below its own
|
|
73
|
+
padding plus border — so a composed capsule holds the slot open however
|
|
74
|
+
hard the fold pushes. Measured against the built bundle, composed with
|
|
75
|
+
.juno-pillbar__item at a 390px viewport: 44px with both floors in place
|
|
76
|
+
(the tap target), 20px with min-inline-size released (the padding alone),
|
|
77
|
+
0px with all of them released.
|
|
78
|
+
min-inline-size is in the transition list above for the OPENING half:
|
|
79
|
+
released to 0 it would otherwise snap back to the capsule's tap floor the
|
|
80
|
+
instant data-juno-in returns, and the used width — max(inline-size,
|
|
81
|
+
min-inline-size) — would jump to full width with nothing to animate. */
|
|
39
82
|
.juno-fold:not([data-juno-in]) {
|
|
40
83
|
inline-size: 0;
|
|
84
|
+
min-inline-size: 0;
|
|
85
|
+
padding-inline: 0;
|
|
86
|
+
border-inline-width: 0;
|
|
41
87
|
margin-inline-end: calc(-1 * var(--juno-fold-gap));
|
|
42
88
|
opacity: 0;
|
|
43
89
|
visibility: hidden;
|
|
@@ -38,11 +38,20 @@
|
|
|
38
38
|
opacity: 0;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/* the pill — a span after the radio, or the aria-pressed button itself
|
|
41
|
+
/* the pill — a span after the radio, or the aria-pressed button itself.
|
|
42
|
+
The tap floor is on the PAINTED box, not on the label that wraps it: the
|
|
43
|
+
label is a bare inline-flex and takes its height from this. Segmented is a
|
|
44
|
+
phone-first control and is routinely the only control on a whole settings
|
|
45
|
+
section, so it holds --juno-size-tap-min like .juno-btn does — 24px (WCAG
|
|
46
|
+
2.2 AA, 2.5.8) on a fine pointer, promoted to the 44px comfortable target
|
|
47
|
+
under (pointer: coarse) by base.css. Without it the pill computed ~25px
|
|
48
|
+
from its padding alone, which is the AA floor by accident and nothing on
|
|
49
|
+
touch. See 20260826-025. */
|
|
42
50
|
.juno-seg__opt input + span,
|
|
43
51
|
button.juno-seg__opt {
|
|
44
52
|
display: inline-flex;
|
|
45
53
|
align-items: center;
|
|
54
|
+
min-block-size: var(--juno-size-tap-min);
|
|
46
55
|
gap: var(--juno-gap-control);
|
|
47
56
|
font-family: var(--juno-font-family-sans);
|
|
48
57
|
font-size: var(--juno-font-size-11);
|
|
@@ -90,7 +99,11 @@ button.juno-seg__opt:disabled {
|
|
|
90
99
|
cursor: not-allowed;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
/* Small —
|
|
102
|
+
/* Small — dense toolbars: less type, tighter padding. UNLIKE .juno-btn--sm it
|
|
103
|
+
does NOT drop below the tap floor, which the base rule above holds: --sm is a
|
|
104
|
+
density modifier here, not an opt-out of the touch target. A segmented row is
|
|
105
|
+
often the only control on a settings section, so a sub-tap variant of it has
|
|
106
|
+
no safe use on a phone. */
|
|
94
107
|
.juno-seg--sm .juno-seg__opt input + span,
|
|
95
108
|
.juno-seg--sm > button.juno-seg__opt {
|
|
96
109
|
font-size: var(--juno-font-size-10);
|