@junoput01/junoui 0.6.0 → 0.8.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 (42) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/README.md +17 -16
  3. package/dist/android/dimens.xml +2 -0
  4. package/dist/classes.json +1774 -0
  5. package/dist/css/juno-tokens.css +10 -0
  6. package/dist/css/juno.css +1057 -56
  7. package/dist/flutter/juno_tokens.dart +10 -0
  8. package/dist/ios/JunoTokens.swift +10 -0
  9. package/dist/js/tokens.js +16 -0
  10. package/dist/json/tokens.json +51 -0
  11. package/dist/rust/juno_tokens.rs +383 -0
  12. package/dist/scss/_juno-tokens.scss +10 -0
  13. package/docs/accessibility.md +6 -0
  14. package/docs/browser-support.md +3 -0
  15. package/docs/components/button.md +11 -2
  16. package/docs/components/canvas-ink.md +71 -0
  17. package/docs/components/dock.md +34 -0
  18. package/docs/components/fold-slot.md +26 -1
  19. package/docs/components/gizmo.md +114 -0
  20. package/docs/components/swatch.md +95 -0
  21. package/docs/components/tree.md +112 -0
  22. package/docs/conformance-kit.md +255 -0
  23. package/docs/getting-started.md +14 -0
  24. package/docs/integration.md +52 -6
  25. package/docs/ios-conformance.md +200 -3
  26. package/docs/ios-pwa.md +273 -0
  27. package/docs/native.md +38 -1
  28. package/docs/tokens-reference.md +15 -0
  29. package/package.json +8 -2
  30. package/src/css/base.css +35 -43
  31. package/src/css/components/button.css +42 -2
  32. package/src/css/components/canvas-ink.css +97 -0
  33. package/src/css/components/dock.css +65 -6
  34. package/src/css/components/fold-slot.css +49 -3
  35. package/src/css/components/gizmo.css +238 -0
  36. package/src/css/components/segmented.css +15 -2
  37. package/src/css/components/swatch.css +187 -0
  38. package/src/css/components/tree.css +259 -0
  39. package/src/css/touch-surfaces.mjs +95 -0
  40. package/tools/gizmo.mjs +144 -0
  41. package/tools/testing.mjs +177 -0
  42. package/tools/tree.mjs +178 -0
@@ -48,6 +48,16 @@ Two things depend on it, and both fail **silently**:
48
48
 
49
49
  Details and sources: [ios-conformance.md](./ios-conformance.md).
50
50
 
51
+ ### If the page can end up on a Home Screen
52
+
53
+ From iOS/iPadOS 26 that is every page — "there are now zero requirements for
54
+ 'installability'", and the user decides with an **Open as Web App** toggle when
55
+ they add it. So junoui's `display-mode: standalone` rules can run on a site that
56
+ never opted in. What to declare (or knowingly not declare), which status-bar
57
+ style does what, and the one stylesheet copy that must be inline in the `<head>`
58
+ rather than in your bundle:
59
+ [the consumer `<head>` contract](./ios-conformance.md#becoming-a-home-screen-web-app-the-consumer-head-contract).
60
+
51
61
  ## Browser support
52
62
 
53
63
  | | Safari / iOS | Chrome / Edge | Firefox |
@@ -70,6 +80,10 @@ autoprefixer of its own; the field is there for your build tooling.
70
80
  understand without reporting anything. The full audit, per feature, with the
71
81
  degrade-vs-break verdict: [browser-support.md](./browser-support.md).
72
82
 
83
+ Shipping to a phone or a Home Screen? [ios-pwa.md](./ios-pwa.md) is the
84
+ five-minute version — what junoui gives you, the eight things you must supply,
85
+ and what it does not do at all.
86
+
73
87
  ## The model
74
88
 
75
89
  - **Palette** — `standard` · `colorblind` · `soft`
@@ -1,6 +1,6 @@
1
1
  # Integrating junoui into an app
2
2
 
3
- junoui is **presentational**: tokens + a CSS component layer, zero JS. It dresses a UI
3
+ junoui is **presentational**: tokens + a CSS component layer, no behavioural JS. It dresses a UI
4
4
  your app builds. This is the recipe for consuming it cleanly — and the rules for
5
5
  extending it without breaking the "one design across all apps" goal.
6
6
 
@@ -87,9 +87,55 @@ Do not remap junoui's roles to a brand hue — that erases the shared meaning ac
87
87
  Rule of thumb: extend **additively and namespaced**. If every app injects its own palette
88
88
  into junoui, the single-design guarantee dies.
89
89
 
90
- ## 7. Stateful behavior stays in your app
90
+ ## 7. Guarding your class names
91
91
 
92
- junoui ships no JS. Focus traps, list virtualization, popover positioning, data all
93
- yours (or a future sibling `junoui-<framework>` package). junoui gives you the stable,
94
- semantic DOM (real elements, BEM classes, ARIA hooks) to wire behavior + analytics onto;
95
- see [accessibility.md](./accessibility.md) for the per-component ARIA contract.
92
+ A `juno-*` class name in your source is a string that has to match something in
93
+ junoui's stylesheet, and nothing checks it. When it does not match, nothing
94
+ fails: the file compiles, the tests pass, and the element renders as unstyled UA
95
+ defaults. One consumer shipped eleven such names in a dialog; on a phone the
96
+ result was a confirm button off the bottom of the screen with no way to reach it.
97
+
98
+ junoui ships the check:
99
+
100
+ ```js
101
+ import { assertJunoClasses } from 'junoui/testing';
102
+
103
+ it('every juno class this app names exists', () => {
104
+ assertJunoClasses(['src/**/*.tsx'], {
105
+ // names YOUR stylesheet defines in the juno- namespace. Each one is a
106
+ // claim you are making — check it against your own sheet.
107
+ allowed: ['juno-icons-subset'],
108
+ });
109
+ });
110
+ ```
111
+
112
+ It reads `junoui/classes.json`, the manifest this build generates from its own
113
+ selectors, so it cannot drift from what you installed. It throws — no framework
114
+ needed — and it throws rather than passing when the globs match no files.
115
+
116
+ **What it answers:** "junoui ships nothing by this name." **What it does not:**
117
+ whether the class still does what your component assumes. A class that exists
118
+ but was repurposed upstream passes.
119
+
120
+ The manifest is readable directly (`junoui/classes.json`) for anything else you
121
+ want to assert: `all`, `public` (the documented subset), `roles`, `components`
122
+ grouped BEM-wise, plus the other namespaces junoui ships and a consumer writes
123
+ as bare strings — `tokens`, `keyframes`, `icons`.
124
+
125
+ ```js
126
+ import manifest from 'junoui/classes.json' with { type: 'json' };
127
+ manifest.components.seg; // { block, elements: ['juno-seg__opt'], modifiers: [...] }
128
+ ```
129
+
130
+ ## 8. Stateful behavior stays in your app
131
+
132
+ junoui ships no behavioural JS. Focus traps, scroll locking, gesture handling, list
133
+ virtualization, runtime popover positioning, data — all yours (or a future sibling
134
+ `junoui-<framework>` package). junoui gives you the stable, semantic DOM (real elements,
135
+ BEM classes, ARIA hooks) to wire behavior + analytics onto; see
136
+ [accessibility.md](./accessibility.md) for the per-component ARIA contract.
137
+
138
+ The only JS in the package is the two icon-sprite helpers, `icons/inline` and
139
+ `icons/install` — see [icon-subsetting.md](./icon-subsetting.md). The full
140
+ does-not-do list for phones and Home-Screen web apps is in
141
+ [ios-pwa.md](./ios-pwa.md).
@@ -9,7 +9,10 @@ value into a wrong one later.
9
9
 
10
10
  This page is about **metrics and behaviour** on iOS. For _which iOS versions
11
11
  junoui runs on at all_ — the supported floor, what degrades below it, and what
12
- breaks — see [browser-support.md](./browser-support.md).
12
+ breaks — see [browser-support.md](./browser-support.md). If you are about to
13
+ integrate and want the bounded version first — what is free, what you supply,
14
+ what junoui does not do — start at [ios-pwa.md](./ios-pwa.md) and come back
15
+ here for the derivations.
13
16
 
14
17
  > **Verifying anything here.** Apple's HIG is a JavaScript app: a plain `curl`
15
18
  > returns an empty shell. Check the backing DocC JSON instead —
@@ -269,6 +272,93 @@ that has a scrollbar. Every call site above either caps well below `100vw`
269
272
  scrollbar's width (`calc(100vw - var(--juno-space-24))`). Keep it that way; a
270
273
  bare `inline-size: 100vw` is a bug.
271
274
 
275
+ ## Becoming a Home-Screen web app: the consumer `<head>` contract
276
+
277
+ Everything in the next section is gated on `display-mode: standalone`. That mode
278
+ is not something junoui can enter for you and not something your CSS decides —
279
+ it is decided in the `<head>`, and now also by the user. So: what makes a page a
280
+ Home-Screen web app at all.
281
+
282
+ **Two ways in, and as of iOS 26 a third that nobody opts into.**
283
+
284
+ | Declaration | What it does | Source |
285
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
286
+ | Manifest `"display": "standalone"` (or `"fullscreen"`) | The standards route: "create a manifest file … and serve it along with your website" | [WebKit — Web Push for Web Apps](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) |
287
+ | `<meta name="apple-mobile-web-app-capable" content="yes">` | Apple extension, iOS only: "Sets whether a web application runs in full-screen mode" | [Apple — Supported Meta Tags](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html) |
288
+ | **Nothing at all, on iOS/iPadOS 26** | "By default, every website added to the Home Screen opens as a web app" | [WebKit features in Safari 26.0](https://webkit.org/blog/17333/webkit-features-in-safari-26-0/) |
289
+
290
+ Ship one of the first two if you _want_ standalone. Ship neither and, from iOS
291
+ 26, you may get it anyway:
292
+
293
+ > "Simply put, there are now zero requirements for 'installability' in Safari."
294
+ > … "If the user prefers to add a bookmark for their browser, they can disable
295
+ > 'Open as Web App' when adding to Home Screen."
296
+
297
+ Read that second sentence carefully, because it is the part that changes how you
298
+ test: **standalone is now a user choice made at add-to-Home-Screen time, not a
299
+ property of your document.** The same build is both a tab and a web app
300
+ depending on a toggle you never see. Every `display-mode: standalone` rule
301
+ junoui ships — the letterbox unlock below is the whole list — therefore runs for
302
+ consumers who never asked for it, on a device where nothing in their `<head>`
303
+ said "web app".
304
+
305
+ `window.navigator.standalone` (read-only Boolean, Apple extension) reports the
306
+ answer at runtime; `matchMedia('(display-mode: standalone)')` is the standard
307
+ test and the one junoui's CSS uses. Check both — see
308
+ [the letterbox flag](#the-letterbox-flag-data-juno-letterboxed) for the exact
309
+ predicate.
310
+
311
+ ### The status bar style, and what it is not for
312
+
313
+ `<meta name="apple-mobile-web-app-status-bar-style" content="…">` takes exactly
314
+ three values, and Apple states it "has no effect unless you first specify
315
+ full-screen mode using `apple-mobile-web-app-capable`":
316
+
317
+ | Value | Status bar | Web content |
318
+ | ------------------- | --------------------- | ------------------------------------------------------- |
319
+ | `default` | normal | displayed **below** the status bar |
320
+ | `black` | black background | displayed **below** the status bar |
321
+ | `black-translucent` | black and translucent | **entire screen**, partially obscured by the status bar |
322
+
323
+ `default` is the default. Only `black-translucent` puts your content under the
324
+ status bar, which is the case where `env(safe-area-inset-top)` stops being
325
+ decorative and starts being the thing keeping your top row readable.
326
+
327
+ **It is not a letterbox remedy.** `black` and `black-translucent` were each
328
+ tested with a fresh Home-Screen install on the device; both letterbox
329
+ identically (see the next section). Pick the value for the status bar you want
330
+ and nothing else.
331
+
332
+ - Source: [Apple — Supported Meta Tags](https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariHTMLRef/Articles/MetaTags.html)
333
+ (archived; carries no deprecation banner, and no replacement page for these
334
+ two tags was found on the current developer site).
335
+
336
+ ### The `<head>` junoui actually needs
337
+
338
+ Three lines, in one place, none of which a stylesheet can supply:
339
+
340
+ ```html
341
+ <!-- 1. required always — safe areas are inert without viewport-fit=cover,
342
+ and 1 CSS px = 1 pt only with width=device-width -->
343
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
344
+
345
+ <!-- 2. only if you want standalone deliberately; from iOS 26 the user can
346
+ grant it without you -->
347
+ <meta name="apple-mobile-web-app-capable" content="yes" />
348
+ <!-- or a manifest with "display": "standalone" -->
349
+
350
+ <!-- 3. if your shell paints before its CSS bundle: an inline copy of the
351
+ standalone unlock, because iOS samples the document at launch -->
352
+ <style>
353
+ /* … see "Consumer obligations" below … */
354
+ </style>
355
+ ```
356
+
357
+ Line 1 is expanded, with the failure modes, in
358
+ [getting-started.md](./getting-started.md). Line 3 is the first-parse obligation
359
+ below, and it is the one that is silent both ways: miss it and the app
360
+ letterboxes, ship it and nothing tells you it worked except a screenshot.
361
+
272
362
  ## Home-Screen standalone: the letterbox, and why `base.css` unlocks it
273
363
 
274
364
  **The fact, and it is the most expensive thing this codebase has learned about
@@ -358,6 +448,111 @@ that still misbehaves. When that holds, the unlock is dead weight and can be
358
448
  removed. Until then it is harmless where it does not apply, because the gate
359
449
  excludes every non-iOS and non-installed context.
360
450
 
451
+ ### The letterbox flag: `data-juno-letterboxed`
452
+
453
+ The unlock above _prevents_ the letterbox. Nothing above lets a page **react to
454
+ it happening anyway**, and it does happen — to a page whose first-parse copy of
455
+ the unlock is missing, whose `body::after` is taken, or which is not a junoui
456
+ page at all. So junoui fixes the name of that fact, once, here.
457
+
458
+ **The contract, in four lines:**
459
+
460
+ | | |
461
+ | --------------- | ---------------------------------------------------------------------------------------------------- |
462
+ | Attribute | `data-juno-letterboxed`, boolean (present or absent — the value is never read) |
463
+ | Element | the root element, `<html>` |
464
+ | Who sets it | **the app.** junoui ships no JS for this; see below |
465
+ | What it asserts | this window is in standalone display mode **and** is shorter than the screen it is on. Nothing else. |
466
+
467
+ **The predicate, so two consumers compute the same answer:**
468
+
469
+ ```js
470
+ const SLACK_PX = 1;
471
+
472
+ const standalone =
473
+ matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
474
+
475
+ function isLetterboxed({ standalone, innerHeight, screenHeight }) {
476
+ if (!standalone) return false;
477
+ if (!screenHeight || !innerHeight) return false; // the browser is not telling us
478
+ return innerHeight < screenHeight - SLACK_PX;
479
+ }
480
+ ```
481
+
482
+ Each guard earns its place:
483
+
484
+ - **Standalone is part of the question, not an optimisation.** In a browser tab
485
+ the window is legitimately shorter than the screen — that is where the
486
+ toolbars are — and `env(safe-area-inset-*)` reads `0` there anyway, so
487
+ flagging a tab would be both wrong and pointless.
488
+ - **One pixel of slack**, because the comparison is between two integers from
489
+ different APIs describing the same screen; an exact test is brittle for no
490
+ benefit.
491
+ - **A zero guard**, because `screen.height === 0` means the browser is not
492
+ answering, and "shorter than nothing" is not a verdict.
493
+
494
+ **Watch it; do not sample it once.** The window corrects itself to the full
495
+ height unprompted, between **6 seconds and 43 minutes** after navigation, and
496
+ then holds for the life of that document. Re-evaluate on
497
+ `visualViewport.resize` — the event that fired for all seven observed
498
+ corrections — plus `window.resize` and `orientationchange`, because rotation
499
+ changes both dimensions and only the latter is guaranteed to report it.
500
+
501
+ **What CSS may key off it — one thing.** Inside the 812 px window
502
+ `env(safe-area-inset-bottom)` still reports **34**, but the home indicator is at
503
+ screen y 840–874, _outside_ the window. A layout that correctly honours the
504
+ inset is reserving room for something that is not in the view, on an edge
505
+ already 62 px clear of the glass. So the sanctioned use is to zero the bottom
506
+ inset, and only the bottom inset:
507
+
508
+ ```css
509
+ :root {
510
+ --app-safe-bottom: env(safe-area-inset-bottom, 0px);
511
+ }
512
+ html[data-juno-letterboxed] {
513
+ --app-safe-bottom: 0px;
514
+ }
515
+ ```
516
+
517
+ Do **not** key the top inset off it, and do not use it to disable the unlock.
518
+ The 62 px top strip is genuinely reserved by the window manager; only the bottom
519
+ inset is a phantom.
520
+
521
+ **What junoui does with it today: nothing, and that is deliberate.** junoui's
522
+ own clearances and the nine other `env(safe-area-inset-bottom)` call sites read
523
+ the raw `env()` — eleven in all, across `base.css` (2), `dock.css` (3),
524
+ `pillbar.css` (3), `modal.css`, `drawer.css` and `toast.css` — so a junoui app
525
+ in a letterboxed window over-reserves at the bottom regardless of what the app
526
+ sets: an `env()` inside a component's `calc()` is not overridable from app CSS.
527
+ Fixing that means routing every bottom call site through a single
528
+ `--juno-safe-bottom` indirection, which is a CSS change across six files with a
529
+ visual-regression pass of its own. It is tracked as
530
+ ticket 20260815-039, and doing it by halves is worse than not doing it: convert
531
+ the clearance tokens but not `dock.css`'s margin and the bar moves while its
532
+ clearance does not.
533
+
534
+ **Why no junoui JS module.** junoui already ships two JS entry points
535
+ (`icons/inline`, `icons/install`), so a third would not be unprecedented. It is
536
+ still wrong here: the module is ~30 lines with no junoui-specific content — it
537
+ reads two DOM globals and writes an attribute — and shipping it would put a
538
+ workaround into the public API, with a semver commitment, a release, and a test,
539
+ for a defect whose entire purpose is to be deleted when WebKit fixes the window.
540
+ A **name** costs nothing and is free to delete. Re-open this decision if the
541
+ flag ever gates junoui's own CSS (20260815-039): a rule junoui ships with no
542
+ supported way to satisfy it would be worse than either half alone.
543
+
544
+ **It is not an upstream-fix detector.** The test in the previous section needs a
545
+ document that _cannot_ scroll; the unlock makes the document scroll, so a
546
+ correctly integrated junoui app reads `false` while the bug is very much alive.
547
+ The flag catches residual cases, not the platform.
548
+
549
+ **Prior art, and why the name is fixed now rather than later.** nexora sets
550
+ `data-nx-letterboxed` from `web/src/letterbox.ts` — that module's predicate is
551
+ the one reproduced above, and it predates this contract. New consumers use
552
+ `data-juno-letterboxed`. The cost of leaving it unnamed is that the next
553
+ consumer invents a third spelling for the same fact and none of the three CSS
554
+ rules can ever move into junoui.
555
+
361
556
  - Sources: nexora `CLAUDE.md` §15, entry dated 2026-08-13; the four-mode testbed
362
557
  `web/public/expansion-demo.html` (kept as a standing rig); 201 device readings
363
558
  collected by `scripts/viewport_probe_collect.py`. Tracked upstream as ticket
@@ -412,8 +607,10 @@ One confirmed change raises the stakes: as of iOS/iPadOS 26, **every website
412
607
  added to the Home Screen opens as a web app by default** — "there are now zero
413
608
  requirements for 'installability'". junoui's CSS may therefore run in a
414
609
  standalone context, where `viewport-fit` and `env()` govern home-indicator and
415
- Dynamic Island clearance, for sites that never opted in. The concrete
416
- consequence is measured above in
610
+ Dynamic Island clearance, for sites that never opted in. What that means for
611
+ your `<head>` is
612
+ [the consumer contract above](#becoming-a-home-screen-web-app-the-consumer-head-contract);
613
+ the concrete consequence is measured in
417
614
  [Home-Screen standalone: the letterbox](#home-screen-standalone-the-letterbox-and-why-basecss-unlocks-it) —
418
615
  a site that never asked to be a web app now inherits both the letterbox and the
419
616
  unlock.
@@ -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/native.md CHANGED
@@ -1,4 +1,4 @@
1
- # Native mobile (Android / iOS)
1
+ # Native (Android / iOS / Rust)
2
2
 
3
3
  Native platforms can't parse `oklch()`, so colors are pre-converted to sRGB hex
4
4
  during the build. Values match the web rendering.
@@ -43,6 +43,43 @@ let body: CGFloat = JunoTokens.fontSize14
43
43
  - Colors are `UIColor` constants named `<palette><Mode><Role>` (camelCase).
44
44
  - Dimensions are `CGFloat` constants.
45
45
 
46
+ ## Rust
47
+
48
+ Any native Rust stack — egui, iced, Slint, Bevy, Dioxus desktop, Tauri's Rust
49
+ side. Vendor `dist/rust/juno_tokens.rs` into your crate, or add junoui as a
50
+ build dependency and copy it in `build.rs`.
51
+
52
+ ```rust
53
+ include!(concat!(env!("OUT_DIR"), "/juno_tokens.rs")); // or `mod juno_tokens;`
54
+
55
+ let bg = STANDARD_DARK.s0.to_f32_array(); // a whole theme, picked at runtime
56
+ let accent = STANDARD_DARK_ACTIVE; // or one role, flat
57
+ let gap = SPACE_16; // f32 px
58
+ let fade = MOTION_DURATION_BASE_MS; // f32 milliseconds
59
+ ```
60
+
61
+ | Emitted as | From |
62
+ | -------------------------------------------------- | ---------------------------------------------------- |
63
+ | `Rgba` (`hex()`, `to_f32_array()`, `with_alpha()`) | every color token |
64
+ | `Palette` + one const per palette/mode | the same colors, grouped |
65
+ | `f32` | `px` lengths |
66
+ | `f32`, suffixed `_MS` | `ms` durations |
67
+ | `i32` | whole numbers (z-index, font weight) |
68
+ | `f32` | ratios (opacity, line height) |
69
+ | `&str` | CSS-authored values, verbatim (shadows, font stacks) |
70
+
71
+ **`to_f32_array()` is sRGB-encoded, not linear.** If your pipeline wants linear
72
+ (wgpu with a non-sRGB surface format), convert at your boundary — junoui cannot
73
+ know which surface you created.
74
+
75
+ **Shadows and font stacks are shipped unparsed.** A Rust renderer cannot consume
76
+ `0 4px 14px rgb(0 0 0 / 0.35)` directly; they are here so the values live in one
77
+ place, not as ready-made native input.
78
+
79
+ **Do not transcribe these values into your own crate.** That copy is exactly
80
+ what this target exists to stop — it goes stale on the first patch release and
81
+ no lint catches it.
82
+
46
83
  ## Keeping in sync
47
84
 
48
85
  Re-run `npm run build` in junoui and re-copy the files (or script the copy in your