@junoput01/junoui 0.3.0 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +232 -0
  2. package/README.md +23 -19
  3. package/dist/css/juno-custom-media.css +32 -0
  4. package/dist/css/juno.css +1216 -60
  5. package/dist/icons/inline.js +22 -0
  6. package/dist/icons/juno-icons.svg +1 -0
  7. package/docs/accessibility.md +55 -34
  8. package/docs/boot-shell.md +295 -0
  9. package/docs/components/README.md +46 -43
  10. package/docs/components/dock.md +167 -8
  11. package/docs/components/drawer.md +47 -7
  12. package/docs/components/fold-slot.md +31 -0
  13. package/docs/components/icon-loader.md +85 -18
  14. package/docs/components/icon.md +21 -0
  15. package/docs/components/load-state.md +131 -0
  16. package/docs/components/loader.md +13 -5
  17. package/docs/components/pillbar.md +157 -10
  18. package/docs/components/reload.md +41 -0
  19. package/docs/components/skeleton.md +22 -15
  20. package/docs/components/thumb.md +34 -14
  21. package/docs/design-guidelines.md +26 -0
  22. package/docs/getting-started.md +23 -0
  23. package/docs/icon-subsetting.md +59 -0
  24. package/docs/ios-conformance.md +224 -0
  25. package/docs/layout.md +71 -1
  26. package/docs/web.md +6 -0
  27. package/package.json +7 -2
  28. package/src/css/base.css +169 -6
  29. package/src/css/components/dock.css +322 -0
  30. package/src/css/components/drawer.css +40 -3
  31. package/src/css/components/fold-slot.css +44 -0
  32. package/src/css/components/icon-loader.css +32 -18
  33. package/src/css/components/icon.css +6 -4
  34. package/src/css/components/load-state.css +136 -0
  35. package/src/css/components/loader.css +6 -0
  36. package/src/css/components/menu.css +4 -0
  37. package/src/css/components/modal.css +24 -3
  38. package/src/css/components/navbar.css +5 -1
  39. package/src/css/components/pillbar.css +207 -7
  40. package/src/css/components/reload.css +48 -0
  41. package/src/css/components/skeleton.css +41 -13
  42. package/src/css/components/tabs.css +5 -0
  43. package/src/css/components/thumb.css +63 -1
  44. package/src/css/components/toast.css +5 -1
  45. package/src/css/density.css +22 -0
  46. package/src/css/layout.css +30 -2
  47. package/src/css/utilities.css +4 -1
  48. package/src/icons/cloud-slash.svg +1 -0
  49. package/tools/subset-sprite.mjs +56 -0
@@ -0,0 +1,131 @@
1
+ # Load state
2
+
3
+ A vocabulary for every way a load can go, so nothing spins forever. junoui already had
4
+ "resolving" (arc/beacon/bar, both indeterminate and determinate) and "layout known, not
5
+ loaded yet" ([skeleton](./skeleton.md)). This adds the two treatments those don't cover —
6
+ **working with no ETA** and **failed** — plus a table-agnostic **empty** state and an
7
+ optional CSS-only switch to show exactly one at a time. Zero JS: junoui ships the look and
8
+ the ARIA contract per state; deciding _when_ a load becomes a fault or an empty result is
9
+ the app's job.
10
+
11
+ ## The decision table
12
+
13
+ | State | Treatment | Why |
14
+ | ------------------------------- | ------------------------- | ------------------------------------------------------------ |
15
+ | No data yet, about to fetch | `.juno-beacon` | "no bytes yet" — a pulse, not a promise of progress. |
16
+ | Fetching, will resolve | `.juno-arc` / `.juno-bar` | Real progress or a bounded wait — see [loader](./loader.md). |
17
+ | Layout known, content pending | `.juno-skeleton` | Placeholder mirrors the shape of what's coming. |
18
+ | Server working, **no ETA** | `.juno-shimmer` | Motion _without_ a completion promise. |
19
+ | Failed — **terminal** | `.juno-fault` | Static. A spinner on a 404 spins forever. |
20
+ | Legitimately nothing — terminal | `.juno-empty` | Static. Not a failure — don't tint it like one. |
21
+
22
+ `.juno-shimmer` and `.juno-fault` are the two treatments this ticket adds; `.juno-empty` is
23
+ the generalized, table-agnostic form of the existing `.juno-table__empty` (same anatomy,
24
+ usable outside a table).
25
+
26
+ ## Web
27
+
28
+ ### Shimmer — work in progress, no completion promise
29
+
30
+ ```html
31
+ <div class="juno-shimmer" style="block-size: 120px;" aria-busy="true" aria-live="polite"></div>
32
+ ```
33
+
34
+ Shares `.juno-skeleton`'s compositor-only band (same `@keyframes juno-skeleton-shimmer`,
35
+ defined once in `skeleton.css`) — one shimmer implementation, applied to a solid fill
36
+ instead of a placeholder shape. Override cadence with `--juno-shimmer-dur` (default
37
+ `1.4s`).
38
+
39
+ ### Fault — terminal, never animates
40
+
41
+ ```html
42
+ <div class="juno-fault" role="status">
43
+ <span class="juno-fault__icon" aria-hidden="true">!</span>
44
+ <p>Couldn't load this image.</p>
45
+ </div>
46
+ ```
47
+
48
+ Role defaults to `caution`; add a `.juno--<role>` class to recolor (`.juno--warning` for a
49
+ harder failure) — never hardcode a color. `role="status"` on the fault itself is a
50
+ per-region, polite announcement (this fired once, doesn't need to interrupt); for a
51
+ page-level urgent message use [`.juno-alert`](./alert.md) with `role="alert"` instead.
52
+
53
+ ### Empty — legitimately nothing, terminal
54
+
55
+ ```html
56
+ <div class="juno-empty">
57
+ <span class="juno-empty__icon" aria-hidden="true">∅</span>
58
+ <p>Nothing here yet.</p>
59
+ </div>
60
+ ```
61
+
62
+ ### The switch (optional)
63
+
64
+ CSS-only, one visible child at a time. The app sets `data-juno-state` on the parent as
65
+ its fetch/rendition state changes; junoui does no state inference.
66
+
67
+ ```html
68
+ <div class="juno-state" data-juno-state="processing">
69
+ <div data-juno-when="loading">
70
+ <div class="juno-arc juno-arc--indeterminate" role="status" aria-label="Loading"></div>
71
+ </div>
72
+ <div data-juno-when="processing" aria-busy="true" aria-live="polite">
73
+ <div class="juno-shimmer" style="block-size: 120px;"></div>
74
+ </div>
75
+ <div data-juno-when="error">
76
+ <div class="juno-fault" role="status">
77
+ <span class="juno-fault__icon" aria-hidden="true">!</span>
78
+ <p>Couldn't load this.</p>
79
+ </div>
80
+ </div>
81
+ <div data-juno-when="empty">
82
+ <div class="juno-empty">
83
+ <span class="juno-empty__icon" aria-hidden="true">∅</span>
84
+ <p>Nothing here yet.</p>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ ```
89
+
90
+ | Class / attribute | Effect |
91
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
92
+ | `.juno-state` | Switch container. |
93
+ | `[data-juno-when]` | A wrapper hidden by default; shown when its (space-separated) token list contains the parent's current state. |
94
+ | `.juno-state[data-juno-state='…']` | Set by the app: `loading` \| `processing` \| `error` \| `empty`. |
95
+ | `.juno-shimmer` | Compositor-only shimmer band on a solid fill (shares skeleton's `@keyframes`). |
96
+ | `--juno-shimmer-dur` | Shimmer cycle length (default `1.4s`). |
97
+ | `.juno-fault` / `.juno-fault__icon` | Terminal failure card. Role `caution` by default. |
98
+ | `.juno-empty` / `.juno-empty__icon` | Terminal empty-result card. Neutral (`label`/`muted`), same anatomy as `.juno-table__empty`. |
99
+
100
+ ## Anatomy (any platform)
101
+
102
+ - Shimmer: solid `s2` fill, `s3` highlight band at 70% opacity sliding on `transform`
103
+ only, 1.4s ease-in-out loop — identical mechanism to skeleton, different semantic use.
104
+ - Fault: column, centered, `space.8` gap, `space.24` padding, role-colored text and icon
105
+ outline (`radius.8` box, `border.width.1`). Never animates.
106
+ - Empty: column, centered, `space.12` gap, `space.56`/`space.24` padding, `label`/`muted`
107
+ text and icon outline (`radius.8` box). Never animates.
108
+
109
+ ## Usage
110
+
111
+ - **Shimmer vs. skeleton:** skeleton implies a known layout waiting for content (first
112
+ paint); shimmer implies ongoing server-side work with no bound on when it finishes
113
+ (transcoding, indexing, re-encoding). Don't use skeleton for the latter — the moment a
114
+ skeleton is on screen, the layout it promises should be about to land.
115
+ Reference: `web/src/media/loadview.ts` (nexora) — spinner only while nothing has
116
+ started, shimmer for `processing`, static fault for `error`; "a spinner on a 404 spins
117
+ forever."
118
+ - **Fault vs. alert:** a fault is a _region_ replacing content that failed to load
119
+ (an image tile, a card). An [alert](./alert.md) is a page-level message. Don't reach for
120
+ `.juno-alert` inside a grid cell.
121
+ - **Empty is not a failure:** legitimately-zero-results (an empty search, a fresh account)
122
+ is a distinct terminal state from an error — keep it neutral, offer a next action
123
+ (a button/link) rather than an apology.
124
+ - **The switch is optional.** Nothing stops rendering `.juno-shimmer` / `.juno-fault` /
125
+ `.juno-empty` directly and swapping them with app-side conditional rendering; `.juno-state`
126
+ exists for markup that's easier to keep all-present (e.g. server-rendered) and toggle
127
+ by attribute.
128
+ - **a11y, per state:** `loading`/`processing` → `aria-busy="true"` (+ `aria-live="polite"`
129
+ if the region isn't otherwise announced); `.juno-fault` → `role="status"` on itself;
130
+ `.juno-empty` → no extra role, it's static content. See
131
+ [accessibility.md](../accessibility.md) for the full per-component ARIA contract.
@@ -26,15 +26,23 @@ Color comes from `--juno-role` (default `active`); add a `.juno--<role>` class t
26
26
  </div>
27
27
  ```
28
28
 
29
- | Class / var | Effect |
30
- | --------------------------------------------------- | ---------------------------------------------------------------- |
31
- | `.juno-arc` | Ring; determinate sweep = progress (conic gradient + ring mask). |
32
- | `.juno-arc--indeterminate` | 12-step mechanical rotation. |
33
- | `--juno-arc-size` (76px) · `--juno-arc-width` (4px) | Diameter / stroke. |
29
+ | Class / var | Effect |
30
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
31
+ | `.juno-arc` | Ring; determinate sweep = progress (conic gradient + ring mask). |
32
+ | `.juno-arc--indeterminate` | 12-step mechanical rotation. |
33
+ | `.juno-arc--smooth` | With `--indeterminate`: continuous (linear) rotation instead of the 12-step sweep use under ~24px, where the steps read as jitter. |
34
+ | `--juno-arc-size` (76px) · `--juno-arc-width` (4px) | Diameter / stroke. |
34
35
 
35
36
  > The ring uses a CSS mask, which clips children. Put `.juno-arc__label` as a
36
37
  > **sibling** over a positioned wrapper, not inside `.juno-arc`.
37
38
 
39
+ > To ring an _arbitrary control_ (a button, a badge, an avatar) without
40
+ > changing its own box, don't reposition `.juno-arc` by hand — compose
41
+ > [`.juno-icon-loader`](./icon-loader.md), junoui's one ring-a-thing
42
+ > primitive. It stacks the arc and the control on a single grid cell sized
43
+ > off two custom props, so nothing you wrap ever resizes when the ring
44
+ > appears.
45
+
38
46
  ## Beacon — radiating pulse
39
47
 
40
48
  ```html
@@ -23,16 +23,161 @@ optional divider. Zero JS.
23
23
  </nav>
24
24
  ```
25
25
 
26
- | Class / prop | Effect |
27
- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
28
- | `.juno-pillbar` | Sticky floating pill: blurred translucent `s1`, hairline, `shadow.2`. |
29
- | `.juno-pillbar__item` | Round tap target ≥ `size.tap.comfortable`; icon with optional label. |
30
- | `.juno-pillbar__label` | Inline text next to the icon — truncates at 12ch. |
31
- | `.juno-pillbar__sep` | Vertical hairline between item groups (e.g. destinations vs. actions). |
32
- | `[aria-current]` | Active destination: `s3` pill fill + role color. Attribute, not class. |
33
- | `[aria-pressed]` | Same active look for toggle buttons. |
34
- | `.juno-pillbar--fixed` | Fix to the viewport (`position: fixed`), floating its usual `space.16` + safe-area above the foot — for page-scroll shells where sticky won't pin. |
35
- | `.juno--<role>` | Active color (default `active`). |
26
+ | Class / prop | Effect |
27
+ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
28
+ | `.juno-pillbar` | Sticky floating pill: blurred translucent `s1`, hairline, `shadow.2`. |
29
+ | `.juno-pillbar__item` | Round tap target ≥ `size.tap.comfortable`; icon with optional label. |
30
+ | `.juno-pillbar__label` | Inline text next to the icon — truncates at 12ch. |
31
+ | `.juno-pillbar__sep` | Vertical hairline between item groups (e.g. destinations vs. actions). |
32
+ | `[aria-current]` | Active destination: `s3` pill fill + role color. Attribute, not class. |
33
+ | `[aria-pressed]` | Same active look for toggle buttons. |
34
+ | `.juno-pillbar--fixed` | Fix to the viewport (`position: fixed`), floating its usual `space.16` + safe-area above the foot — for page-scroll shells where sticky won't pin. |
35
+ | `.juno-pillbar--top-right` / `--top-left` / `--bottom-right` / `--bottom-left` | Fix as a floating cluster in one viewport corner (safe-area-clamped), instead of the centered bottom bar. |
36
+ | `.juno-pillbar__input` | Borderless search field inside the pill (`min(52vw, 240px)`, 16px font floor). |
37
+ | `.juno-pillbar__overflow` | "More" trigger for items that don't fit — anchors a [menu](./menu.md) via `popovertarget`. |
38
+ | `.juno-pillbar--collapsible` | The pill folds into a single circle (`__toggle`) and animates back to full width — see [Collapsible](#collapsible). |
39
+ | `.juno-pillbar__toggle` | The collapsible pill's circular expand/collapse control; state on `aria-expanded`, same chrome as `__overflow`. |
40
+ | `.juno-pillbar__tray` | Wrapper whose width animates shut — holds one child wrapping the usual `__item` markup. |
41
+ | `.juno--<role>` | Active color (default `active`). |
42
+
43
+ ### Geometry custom props
44
+
45
+ Read from `.juno-pillbar` (or its computed style) instead of hardcoding these
46
+ in an app-side capacity planner — they're the single source of truth for how
47
+ many `__item` fit before the rest should route to `__overflow`.
48
+
49
+ | Prop | Default | Meaning |
50
+ | --------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `--juno-pillbar-item` | `size.tap.comfortable` | Item tap-target side (square). |
52
+ | `--juno-pillbar-gap` | `space.2` | Gap between items. |
53
+ | `--juno-pillbar-pad` | `space.4` | Pill's own inner padding. |
54
+ | `--juno-pillbar-edge` | `space.16` | Base (bottom-center) placement's offset from the viewport edge. Not used by the corner modifiers, which keep their own distinct block/inline offsets. |
55
+
56
+ ## Corner placement
57
+
58
+ The base pillbar is a centered bottom bar. The corner modifiers pin it as a
59
+ floating cluster in one viewport corner instead — e.g. a top-right
60
+ search/filter cluster over a full-bleed grid. Each flips to `position: fixed`
61
+ and clamps the corner with `env(safe-area-inset-*)` so it never lands under a
62
+ notch or the home indicator; the base blur/border/shadow carries over. For the
63
+ bottom corners, reserve scroll clearance with
64
+ `padding-block-end: var(--juno-pillbar-clearance)` if the pill overlaps
65
+ content.
66
+
67
+ ## Input slot
68
+
69
+ `.juno-pillbar__input` is an expandable search field that lives inside the
70
+ pill, reading as part of it rather than a boxed control.
71
+
72
+ ```html
73
+ <div class="juno-pillbar juno-pillbar--top-right">
74
+ <input
75
+ class="juno-pillbar__input"
76
+ type="search"
77
+ aria-label="Search library"
78
+ placeholder="Search…"
79
+ />
80
+ <button class="juno-pillbar__item" aria-label="Search" aria-pressed="true">
81
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-magnifying-glass" /></svg>
82
+ </button>
83
+ </div>
84
+ ```
85
+
86
+ - The field is borderless/transparent; its font-size is held at
87
+ `max(16px, …)` because iOS Safari zooms the whole page onto any focused text
88
+ field under 16px (the same floor the base [input](./input.md) applies on
89
+ touch).
90
+ - **A placeholder is not a label** — give the input an `aria-label` or a
91
+ visually-hidden `<label>`.
92
+
93
+ ## Overflow slot
94
+
95
+ `.juno-pillbar__overflow` is a "more" trigger, styled like `__item`, for
96
+ pills with variable membership (e.g. a top-right action pill whose item count
97
+ depends on the current view). It opens a [`.juno-menu`](./menu.md) holding
98
+ whatever didn't fit — zero JS, via the native Popover API:
99
+
100
+ ```html
101
+ <div class="juno-pillbar juno-pillbar--top-right">
102
+ <button class="juno-pillbar__item" aria-label="Search" aria-pressed="true">
103
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-magnifying-glass" /></svg>
104
+ </button>
105
+ <button class="juno-pillbar__item" aria-label="Filter">
106
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-funnel" /></svg>
107
+ </button>
108
+ <button
109
+ class="juno-pillbar__overflow"
110
+ popovertarget="pillbar-more"
111
+ aria-haspopup="menu"
112
+ aria-expanded="false"
113
+ aria-label="More"
114
+ >
115
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
116
+ </button>
117
+ </div>
118
+ <ul class="juno-menu" id="pillbar-more" popover role="menu">
119
+ <li>
120
+ <button
121
+ class="juno-menu__item"
122
+ role="menuitem"
123
+ popovertarget="pillbar-more"
124
+ popovertargetaction="hide"
125
+ >
126
+ <span class="juno-menu__icon">⤓</span>Export
127
+ </button>
128
+ </li>
129
+ </ul>
130
+ ```
131
+
132
+ - **junoui ships the docking point, not the collapse policy.** Deciding
133
+ _which_ items overflow and _when_ is the app's job — read the
134
+ [geometry custom props](#geometry-custom-props) to do the capacity math,
135
+ then conditionally render `.juno-pillbar__overflow` and move the spilled
136
+ `__item`s into the menu. This keeps one source of truth for the pixel
137
+ constants instead of an app re-declaring them in JS.
138
+ - The invoker (`popovertarget`) is the menu's implicit anchor, so
139
+ `.juno-menu`'s own `position-try-fallbacks` handles flipping it clear of the
140
+ viewport edge — no extra positioning needed in the pill.
141
+ - `aria-expanded` mirrors the menu's open state like `[aria-pressed]` does for
142
+ toggle items; toggle it from the app or a stateless enhancer (junoui itself
143
+ ships no JS).
144
+
145
+ ## Collapsible
146
+
147
+ `.juno-pillbar--collapsible` lets the whole pill fold into a single circular
148
+ button and expand back to full width on demand — for toolbars that should get
149
+ out of the content's way (e.g. a media viewer's action bar). State lives on
150
+ the toggle's `aria-expanded`; junoui ships no JS, the app flips the attribute.
151
+
152
+ ```html
153
+ <nav class="juno-pillbar juno-pillbar--collapsible" aria-label="Tools">
154
+ <button class="juno-pillbar__toggle" aria-expanded="false" aria-label="Show toolbar">
155
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
156
+ </button>
157
+ <div class="juno-pillbar__tray">
158
+ <div>
159
+ <button class="juno-pillbar__item" aria-label="Share">…</button>
160
+ <span class="juno-pillbar__sep"></span>
161
+ <button class="juno-pillbar__item" aria-label="Delete">…</button>
162
+ </div>
163
+ </div>
164
+ </nav>
165
+ ```
166
+
167
+ - **The tray needs exactly one child wrapper** (any element). The expansion is
168
+ a `grid-template-columns: 0fr ↔ 1fr` transition — the only widely-supported
169
+ way to animate to an intrinsic width (Safari 16+) — and the track needs a
170
+ single shrinkable child to collapse.
171
+ - DOM order is free: toggle-first reads naturally for a left-anchored pill,
172
+ toggle-last for a right-anchored corner (`--bottom-right`), so the circle
173
+ stays put and the tray grows out of it.
174
+ - Collapsed, the tray goes `visibility: hidden` at the end of the slide, which
175
+ also removes its items from the tab order. **Collapsing while focus is
176
+ inside the tray is the app's edge:** move focus to the toggle first.
177
+ - The toggle's glyph rotates a quarter-turn while expanded; swap the icon from
178
+ the app instead if you want e.g. dots → ×.
179
+ - `prefers-reduced-motion` is handled by the base layer (every transition
180
+ collapses to ~0ms) — the states still apply, just without the slide.
36
181
 
37
182
  ## Anatomy (any platform)
38
183
 
@@ -47,6 +192,8 @@ optional divider. Zero JS.
47
192
 
48
193
  - Same rules as the dock: 3–5 destinations, overflow goes behind a "More" item.
49
194
  Pick **one** bottom pattern per screen — dock _or_ pillbar, never both.
195
+ - Variable-membership action pills (not fixed destinations) use the
196
+ [overflow slot](#overflow-slot) instead of hand-rolling a "more" menu.
50
197
  - Sticky, not fixed: place it last inside the scrolling column; it floats over
51
198
  content while staying in flow. Best inside the
52
199
  [`.juno-app-shell`](../layout.md#app-shell) frame, whose `__main` is the
@@ -0,0 +1,41 @@
1
+ # Reload
2
+
3
+ The non-blocking counterpart to the [skeleton](./skeleton.md). A skeleton
4
+ stands in for content that isn't there yet (first paint); the reload indicator
5
+ signals a refresh happening _over_ content that's already on screen — the stale
6
+ data stays readable and interactive while fresh data lands.
7
+
8
+ It's a small pulsing dot with a soft halo, centered as a fixed overlay that
9
+ eats no pointer events, so the page underneath stays fully usable.
10
+
11
+ ## Web
12
+
13
+ ```html
14
+ <div class="juno-reload" role="status" aria-label="Reloading">
15
+ <span class="juno-reload__dot"></span>
16
+ </div>
17
+ ```
18
+
19
+ | Class / prop | Effect |
20
+ | ------------------- | ------------------------------------------------------------------- |
21
+ | `.juno-reload` | Fixed, centered, `pointer-events: none` overlay. |
22
+ | `.juno-reload__dot` | `space.16` role-colored dot with a soft halo, gentle opacity pulse. |
23
+ | `.juno--<role>` | Dot color (default `active`). |
24
+
25
+ ## Usage
26
+
27
+ - Render it while a **background refetch is in flight over existing content**;
28
+ drop it when the request settles. For first-paint (no content yet) use the
29
+ [skeleton](./skeleton.md) instead.
30
+ - The app owns the state (zero JS in the component) — mount/unmount it, or
31
+ toggle a `hidden` attribute.
32
+ - The pulse uses the shared `juno-pulse` keyframe — a soft dim, not a full
33
+ blink. `prefers-reduced-motion` stops the pulse (via the base layer); the
34
+ dot stays visible so the "refreshing" state is still conveyed.
35
+
36
+ ## Accessibility
37
+
38
+ - `role="status"` + `aria-label` announces the refresh **politely**, without
39
+ stealing focus — it's a status, not an alert. Don't use `role="alert"`.
40
+ - Keep it non-blocking: `pointer-events: none` ensures it never traps clicks on
41
+ the content it floats over.
@@ -1,8 +1,9 @@
1
1
  # Skeleton
2
2
 
3
3
  A shimmering placeholder that stands in for content not yet loaded — same shimmer as
4
- the [table](./table.md) row skeleton, generalized to text lines, blocks, and circles.
5
- Zero JS; honors `prefers-reduced-motion` (shimmer → static).
4
+ the [table](./table.md) row skeleton, generalized to text lines, blocks, circles, and
5
+ aspect-ratio tiles. Zero JS; honors `prefers-reduced-motion` (shimmer → static) via the
6
+ global motion contract.
6
7
 
7
8
  ## Web
8
9
 
@@ -12,27 +13,33 @@ Zero JS; honors `prefers-reduced-motion` (shimmer → static).
12
13
  <span class="juno-skeleton juno-skeleton--text" style="inline-size: 60%"></span>
13
14
  <span class="juno-skeleton juno-skeleton--text"></span>
14
15
  <span class="juno-skeleton juno-skeleton--block"></span>
16
+ <span class="juno-skeleton juno-skeleton--tile"></span>
15
17
  </div>
16
18
  ```
17
19
 
18
- | Class | Effect |
19
- | ------------------------ | ---------------------------------------------------------------- |
20
- | `.juno-skeleton` | Shimmering bar; height `--juno-skeleton-h` (default `space.16`). |
21
- | `.juno-skeleton--text` | One text line (`1em` tall, soft radius). |
22
- | `.juno-skeleton--circle` | Round avatar / icon placeholder (square via the height). |
23
- | `.juno-skeleton--block` | Larger surface (card / media), `space.56` tall. |
24
- | `--juno-skeleton-h` | Override the height (any length). |
20
+ | Class | Effect |
21
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
22
+ | `.juno-skeleton` | Shimmering bar; height `--juno-skeleton-h` (default `space.16`). |
23
+ | `.juno-skeleton--text` | One text line (`1em` tall, soft radius). |
24
+ | `.juno-skeleton--circle` | Round avatar / icon placeholder (square via the height). |
25
+ | `.juno-skeleton--block` | Larger surface (card / media), `space.56` tall. |
26
+ | `.juno-skeleton--tile` | Content-box mode: sized by `--juno-skeleton-ratio` instead of a fixed height — the media-grid case (a tile matching a real image/card's aspect ratio). |
27
+ | `--juno-skeleton-h` | Override the height (any length). |
28
+ | `--juno-skeleton-ratio` | `.juno-skeleton--tile` only — the aspect ratio (default `1`, e.g. `16 / 9`). |
25
29
 
26
30
  ## Usage
27
31
 
28
32
  - Wrap the loading region in `aria-busy="true"` (and `aria-live="polite"`) so assistive
29
33
  tech announces the pending state; swap in the real content when it arrives.
30
- - Mirror the **shape** of the content it replaces — line widths, an avatar circle — so
31
- the layout doesn't jump on load.
32
- - Width is layout-driven: set `inline-size` (e.g. `60%`) per line; height via the variant
33
- or `--juno-skeleton-h`.
34
+ - Mirror the **shape** of the content it replaces — line widths, an avatar circle, a
35
+ tile's aspect ratio — so the layout doesn't jump on load.
36
+ - Width is layout-driven: set `inline-size` (e.g. `60%`) per line; height via the variant,
37
+ `--juno-skeleton-h`, or (for `--tile`) `--juno-skeleton-ratio`.
34
38
 
35
39
  ## Anatomy (any platform)
36
40
 
37
- - `s2`→`s3`→`s2` horizontal gradient, `background-size: 200%`, animated 1.2s ease-in-out
38
- loop. Radius `3` (block `4`, text `2`). Static fill under reduced-motion.
41
+ - Solid `s2` fill; the shimmer is a separate overlay layer sliding across on `transform`
42
+ only (never `background-position`), so it animates on the compositor instead of
43
+ repainting the gradient every frame — cheap even with hundreds of skeletons on screen.
44
+ Highlight band `s3` at 70% opacity, 1.2s ease-in-out loop. Radius `3` (block/tile `4`,
45
+ text `2`). Static fill under reduced motion.
@@ -8,37 +8,54 @@ that is **missing or failed**.
8
8
  ## Web
9
9
 
10
10
  ```html
11
- <!-- normal: image covers the placeholder -->
12
- <figure class="juno-thumb" style="aspect-ratio: 1">
11
+ <!-- normal: image covers the placeholder; aspect defaults to square -->
12
+ <figure class="juno-thumb">
13
13
  <img src="…" alt="Sunset clip" onerror="this.remove()" />
14
14
  </figure>
15
15
 
16
- <!-- known-missing: ship no <img>; the placeholder just shows -->
16
+ <!-- non-square: override the ratio, not the aspect-ratio property -->
17
17
  <figure
18
18
  class="juno-thumb juno-thumb--video"
19
- style="aspect-ratio: 16/9"
19
+ style="--juno-thumb-ratio: 16/9"
20
20
  role="img"
21
21
  aria-label="Preview unavailable"
22
22
  >
23
23
  <figcaption class="juno-thumb__label">Unavailable</figcaption>
24
24
  </figure>
25
+
26
+ <!-- selected, with corner overlays: a check top-left, a duration top-right -->
27
+ <figure class="juno-thumb juno-thumb--selected">
28
+ <img src="…" alt="Sunset clip" onerror="this.remove()" />
29
+ <span class="juno-thumb__corner juno-thumb__corner--top-start badge badge--sm">✓</span>
30
+ <span class="juno-thumb__corner juno-thumb__corner--top-end badge badge--sm">0:42</span>
31
+ </figure>
25
32
  ```
26
33
 
27
- | Class / attr | Effect |
28
- | ------------------------- | -------------------------------------------------------------- |
29
- | `.juno-thumb` | Frame: `s2` fill, hairline border, `radius.3`, centered glyph. |
30
- | `.juno-thumb--video` | Play glyph instead of the image glyph. |
31
- | `.juno-thumb__label` | Optional uppercase micro-caption under the glyph. |
32
- | `> img` / `> video` | Covers the frame (`object-fit: cover`). |
33
- | `onerror="this.remove()"` | The whole JS contract optional, stateless, one attribute. |
34
+ | Class / attr | Effect |
35
+ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
36
+ | `.juno-thumb` | Frame: `s2` fill, hairline border, `radius.3`, centered glyph, aspect locked to `--juno-thumb-ratio` (default `1`). |
37
+ | `.juno-thumb--flush` | Drops the border/radius full-bleed tiles in a tight wall. |
38
+ | `.juno-thumb--selected` | Inset outline (`--juno-active`) never a border, so nothing reflows on toggle. Apps own the state; junoui never adds/removes the class. |
39
+ | `.juno-thumb--video` | Play glyph instead of the image glyph. |
40
+ | `.juno-thumb__label` | Optional uppercase micro-caption under the glyph. |
41
+ | `.juno-thumb__corner` | Absolutely-positioned slot over the media, `space.4` inset from the frame edge, above the media (`z-index: 1`). Pair with one position modifier. |
42
+ | `.juno-thumb__corner--top-start` / `--top-right` / `--bottom-left` / `--bottom-right` | Anchors the slot to a corner using logical `inset-block-*` / `inset-inline-*` — start/end swap correctly under `dir="rtl"`. |
43
+ | `> img` / `> video` | Covers the frame (`object-fit: cover`). |
44
+ | `onerror="this.remove()"` | The whole JS contract — optional, stateless, one attribute. |
34
45
 
35
46
  ## Anatomy (any platform)
36
47
 
37
- - Frame: surface `s2`, 1px `border`, radius `3`; size from the app
38
- (`aspect-ratio` inline or the [tiles grid](../layout.md)).
48
+ - Frame: surface `s2`, 1px `border`, radius `3`; aspect locked by
49
+ `--juno-thumb-ratio` (default square) so a media wall's scroll height is
50
+ stable before anything loads — override per instance
51
+ (`style="--juno-thumb-ratio: 16/9"`) or via the [tiles grid](../layout.md).
39
52
  - Glyph: 28% of the frame (capped `space.32`), `muted` color — a missing thumb
40
53
  is **not** a warning state; the placeholder stays neutral.
41
54
  - Media covers the full frame, center-cropped.
55
+ - Selection is an inset outline, not a border — the frame's box never
56
+ changes size, so a wall re-flowing selection doesn't jitter.
57
+ - Corner slots are presentational anchors only; what occupies them (check
58
+ icon, duration chip, storage-tier badge) is app vocabulary.
42
59
 
43
60
  ## Failure contract
44
61
 
@@ -58,4 +75,7 @@ explicit:
58
75
  video poster slots, attachment previews.
59
76
  - Keep the placeholder neutral; if failure _matters_ (broken pipeline), say it
60
77
  with a [badge](./badge.md) or [alert](./alert.md) next to the thumb.
61
- - Retry, lazy-load, LQIP/blur-up: app or `junoui-<framework>` territory.
78
+ - Multi-select galleries: toggle `--selected` per tile and put a check in a
79
+ `__corner` slot; duration/storage-tier chips are `__corner` occupants too.
80
+ - Retry, lazy-load, LQIP/blur-up, and the selection/click behavior itself:
81
+ app or `junoui-<framework>` territory.
@@ -40,6 +40,15 @@ Neutral / structural roles carry no status meaning:
40
40
  stop value-change jitter. Use `tabular-nums`.
41
41
  - **B612** — all non-numeric UI: headings, labels, navigation, buttons.
42
42
  - Headings are uppercase with wide tracking; values are mono and bright (`data`).
43
+ - `.juno-label` reads an optional `--juno-label-size` knob (falls back to
44
+ `--juno-font-size-13`) so a context can resize labels — e.g. a compact list —
45
+ without forking the class: set the custom property on an ancestor, never on
46
+ `.juno-label` itself.
47
+ ```html
48
+ <div style="--juno-label-size: var(--juno-font-size-11)">
49
+ <span class="juno-label">Signal strength</span>
50
+ </div>
51
+ ```
43
52
 
44
53
  ## Date & time
45
54
 
@@ -123,6 +132,23 @@ edges. Interactive controls keep their `min-height` (WCAG tap target); only padd
123
132
  shrinks. New components should use the aliases for internal padding to inherit
124
133
  density for free; add a new archetype only when one is genuinely needed.
125
134
 
135
+ A third value, `auto`, re-densifies for small **coarse-pointer** viewports (phone,
136
+ `pointer: coarse` and width ≤ 640px) — a data-heavy layout gets its compact-ish
137
+ padding back on a phone without the consumer hand-tracking breakpoints:
138
+
139
+ ```html
140
+ <body data-juno-density="auto">
141
+ <!-- comfortable everywhere else; re-densifies only on a narrow touch phone -->
142
+ </body>
143
+ ```
144
+
145
+ `auto` is **opt-in only** — existing `comfortable`/compact-pinned consumers render
146
+ byte-identical, nothing changes silently. It never touches a control's
147
+ `min-height`; `--juno-size-tap-min` still only grows (never shrinks) under
148
+ `base.css`'s own `@media (pointer: coarse)` rule, so `auto` can't undo the WCAG
149
+ tap-target work. Only `--juno-tile-min` / `--juno-gap-content` /
150
+ `--juno-pad-surface-inline` shrink, same as `compact`.
151
+
126
152
  ## Accessibility
127
153
 
128
154
  Accessibility is a core design goal, held to published standards — **WCAG 2.2** and
@@ -25,6 +25,29 @@ cd vendor/junoui && npm install # `prepare` builds dist/
25
25
 
26
26
  `npm install` runs the `prepare` script, so `dist/` is always built for you.
27
27
 
28
+ ## Required: the viewport meta
29
+
30
+ Ship this on every page. It is not optional if you use anything that touches a
31
+ phone edge — the dock, pillbar, navbar, drawer, bottom sheet, toast, or the
32
+ app-shell:
33
+
34
+ ```html
35
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
36
+ ```
37
+
38
+ Two things depend on it, and both fail **silently**:
39
+
40
+ - **`viewport-fit=cover` turns the safe area on.** iOS defaults `viewport-fit`
41
+ to `auto`, and WebKit reports every `env(safe-area-inset-*)` as `0` unless you
42
+ opt in with `cover` (`contain` does _not_ opt out). junoui is a stylesheet — it
43
+ cannot set this meta for you. Without it, every safe-area guarantee in the
44
+ library quietly becomes a no-op and content sits under the home indicator.
45
+ - **`width=device-width, initial-scale=1` makes 1 CSS px equal 1 Apple point**,
46
+ which is what makes junoui's `44px` tap targets actually 44pt on the device.
47
+ Without it iOS Safari lays out at ~980px wide and every metric is off.
48
+
49
+ Details and sources: [ios-conformance.md](./ios-conformance.md).
50
+
28
51
  ## The model
29
52
 
30
53
  - **Palette** — `standard` · `colorblind` · `soft`