@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,136 @@
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ * Component — Load state · a vocabulary so nothing spins forever
3
+ * Names the treatment for every way a load can go, so no consumer reaches
4
+ * for a spinner on a state that will never resolve:
5
+ * resolving, ETA known → .juno-arc / .juno-beacon / .juno-bar (loader.css)
6
+ * layout known, no data → .juno-skeleton (skeleton.css)
7
+ * working, NO ETA → .juno-shimmer — motion without a completion promise
8
+ * failed (terminal) → .juno-fault — static, never animates
9
+ * legitimately none → .juno-empty — static, never animates
10
+ * Full decision table + a11y contract: docs/components/load-state.md.
11
+ *
12
+ * Also ships an optional CSS-only switch: mark each treatment's wrapper
13
+ * with data-juno-when, set data-juno-state on the .juno-state parent, and
14
+ * the matching wrapper alone gets display:flex. Zero JS in junoui — the
15
+ * app still decides WHEN a fetch becomes an error or an empty result.
16
+ * Usage:
17
+ * <div class="juno-state" data-juno-state="processing">
18
+ * <div data-juno-when="loading">
19
+ * <div class="juno-arc juno-arc--indeterminate" role="status" aria-label="Loading"></div>
20
+ * </div>
21
+ * <div data-juno-when="processing" aria-busy="true" aria-live="polite">
22
+ * <div class="juno-shimmer" style="block-size: 120px;"></div>
23
+ * </div>
24
+ * <div data-juno-when="error">
25
+ * <div class="juno-fault" role="status">
26
+ * <span class="juno-fault__icon" aria-hidden="true">!</span>
27
+ * <p>Couldn't load this.</p>
28
+ * </div>
29
+ * </div>
30
+ * <div data-juno-when="empty">
31
+ * <div class="juno-empty">
32
+ * <span class="juno-empty__icon" aria-hidden="true">∅</span>
33
+ * <p>Nothing here yet.</p>
34
+ * </div>
35
+ * </div>
36
+ * </div>
37
+ * ════════════════════════════════════════════════════════════════════ */
38
+
39
+ /* ── STATE SWITCH — mechanical only ─────────────────────────────────────
40
+ Hide every marked child; show only the one(s) whose data-juno-when token
41
+ list contains the parent's current data-juno-state. */
42
+ .juno-state > [data-juno-when] { display: none; }
43
+
44
+ .juno-state[data-juno-state='loading'] > [data-juno-when~='loading'],
45
+ .juno-state[data-juno-state='processing'] > [data-juno-when~='processing'],
46
+ .juno-state[data-juno-state='error'] > [data-juno-when~='error'],
47
+ .juno-state[data-juno-state='empty'] > [data-juno-when~='empty'] { display: flex; }
48
+
49
+ /* ── SHIMMER — work in progress, no completion promise ──────────────────
50
+ For server-side work with no ETA (transcoding, indexing…): a spinner
51
+ would spin forever, and a .juno-skeleton implies a known layout that
52
+ doesn't exist here either — just "something is happening". Shares
53
+ .juno-skeleton's compositor-only band verbatim (same @keyframes,
54
+ defined once in skeleton.css — not redefined here) so there is one
55
+ shimmer implementation, not two. Mark the region aria-busy="true". */
56
+ .juno-shimmer {
57
+ position: relative;
58
+ overflow: hidden;
59
+ background: var(--juno-s2);
60
+ border-radius: var(--juno-radius-3);
61
+ }
62
+
63
+ .juno-shimmer::before {
64
+ content: '';
65
+ position: absolute;
66
+ inset: 0;
67
+ background: linear-gradient(
68
+ 100deg,
69
+ transparent 20%,
70
+ color-mix(in srgb, var(--juno-s3) 70%, transparent) 50%,
71
+ transparent 80%
72
+ );
73
+ animation: juno-skeleton-shimmer var(--juno-shimmer-dur, 1.4s) ease-in-out infinite;
74
+ }
75
+
76
+ /* ── FAULT — terminal, never animates ────────────────────────────────────
77
+ The load will not resolve on its own (404, decode failure, permanent
78
+ backend error). Static by design — motion here would lie about the
79
+ state. Role defaults to caution; recolor with a .juno--<role> class for
80
+ a different severity, never hardcode a color. This is a per-region
81
+ fault, not a page-level alert — .juno-alert (role="alert") is for that;
82
+ here the app wires role="status" so AT announces the failure once,
83
+ politely, without interrupting. */
84
+ .juno-fault {
85
+ --juno-role: var(--juno-caution);
86
+
87
+ display: flex;
88
+ flex-direction: column;
89
+ align-items: center;
90
+ justify-content: center;
91
+ gap: var(--juno-space-8);
92
+ padding: var(--juno-space-24);
93
+ text-align: center;
94
+ color: var(--juno-role);
95
+ }
96
+
97
+ .juno-fault__icon {
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ inline-size: var(--juno-space-40);
102
+ block-size: var(--juno-space-40);
103
+ border-radius: var(--juno-radius-8);
104
+ border: var(--juno-border-width-1) solid currentcolor;
105
+ font-family: var(--juno-font-family-mono);
106
+ font-size: var(--juno-font-size-18);
107
+ }
108
+
109
+ /* ── EMPTY — legitimately nothing, terminal ──────────────────────────────
110
+ The load resolved; there is no data, and none will appear without new
111
+ input from the user. Generalized, table-agnostic form of the existing
112
+ .juno-table__empty (table.css) — same anatomy, any region. Neutral
113
+ (muted) — an empty result isn't a failure, don't tint it like one. */
114
+ .juno-empty {
115
+ display: flex;
116
+ flex-direction: column;
117
+ align-items: center;
118
+ justify-content: center;
119
+ gap: var(--juno-space-12);
120
+ padding: var(--juno-space-56) var(--juno-space-24);
121
+ text-align: center;
122
+ color: var(--juno-label);
123
+ }
124
+
125
+ .juno-empty__icon {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ inline-size: var(--juno-space-40);
130
+ block-size: var(--juno-space-40);
131
+ border-radius: var(--juno-radius-8);
132
+ border: var(--juno-border-width-1) solid var(--juno-control-edge);
133
+ color: var(--juno-muted);
134
+ font-family: var(--juno-font-family-mono);
135
+ font-size: var(--juno-font-size-18);
136
+ }
@@ -41,6 +41,12 @@
41
41
  animation: juno-arc-rot 1.1s steps(12) infinite; /* mechanical, 12-step sweep */
42
42
  }
43
43
 
44
+ /* the 12-step sweep reads as jitter under ~24px (e.g. rung around a small
45
+ badge via .juno-icon-loader) — swap to a continuous rotation there */
46
+ .juno-arc--smooth.juno-arc--indeterminate {
47
+ animation-timing-function: linear;
48
+ }
49
+
44
50
  /* centred readout for determinate arcs — place a span inside */
45
51
  .juno-arc__label {
46
52
  position: absolute;
@@ -65,6 +65,10 @@
65
65
  align-items: center;
66
66
  gap: var(--juno-gap-control);
67
67
  inline-size: 100%;
68
+
69
+ /* Dock overflow routes here on phones, making this a touch surface; hold the
70
+ tap minimum (auto-promotes to 44px on coarse pointers). See 20260802-020. */
71
+ min-block-size: var(--juno-size-tap-min);
68
72
  padding: var(--juno-space-8) var(--juno-space-10);
69
73
  background: transparent;
70
74
  border: none;
@@ -45,6 +45,11 @@
45
45
  }
46
46
 
47
47
  .juno-modal[open] {
48
+ /* Column so __body can be the bounded scroll port: the surface caps its own
49
+ height (85dvh as a sheet) and clips, so without this a tall body is simply
50
+ cut off and unreachable. See 20260803-029. */
51
+ display: flex;
52
+ flex-direction: column;
48
53
  opacity: 1;
49
54
  transform: translateY(0) scale(1);
50
55
  }
@@ -60,6 +65,7 @@
60
65
  .juno-modal::backdrop {
61
66
  background: rgb(0 0 0);
62
67
  opacity: var(--juno-opacity-scrim);
68
+ -webkit-backdrop-filter: blur(1.5px);
63
69
  backdrop-filter: blur(1.5px);
64
70
  }
65
71
 
@@ -100,7 +106,19 @@
100
106
 
101
107
  .juno-modal__close:hover { background: var(--juno-s2); color: var(--juno-data); }
102
108
 
103
- .juno-modal__body { padding: var(--juno-space-12) var(--juno-pad-surface-inline) var(--juno-pad-surface-inline); }
109
+ .juno-modal__body {
110
+ padding: var(--juno-space-12) var(--juno-pad-surface-inline) var(--juno-pad-surface-inline);
111
+
112
+ /* The scroll port (the docs already promised it "scrolls on its own").
113
+ min-block-size:0 lets a flex item shrink below its content so overflow-y
114
+ actually engages. overscroll-behavior stops the scroll chaining to the page
115
+ behind once the body hits its end — the rubber-band-under-the-sheet effect
116
+ on iOS. NOTE: overscroll-behavior is community convention here, not Apple
117
+ guidance; no primary Apple source covers it. See 20260803-029. */
118
+ min-block-size: 0;
119
+ overflow-y: auto;
120
+ overscroll-behavior: contain;
121
+ }
104
122
 
105
123
  .juno-modal__title {
106
124
  font-family: var(--juno-font-family-sans);
@@ -151,9 +169,12 @@
151
169
  .juno-modal:not(.juno-drawer)[open] { transform: translateY(100%); }
152
170
  }
153
171
 
154
- /* keep the foot's actions above the home indicator */
172
+ /* keep the foot's actions above the home indicator.
173
+ The env() fallback MUST carry a unit: inside calc(), a unitless `0` is a
174
+ <number>, the addition is invalid, and the whole declaration is dropped —
175
+ silently losing the padding on every device without a safe area. */
155
176
  .juno-modal:not(.juno-drawer) .juno-modal__body {
156
- padding-block-end: calc(var(--juno-pad-surface-inline) + env(safe-area-inset-bottom, 0));
177
+ padding-block-end: calc(var(--juno-pad-surface-inline) + env(safe-area-inset-bottom, 0px));
157
178
  }
158
179
 
159
180
  /* full-width actions read better on a sheet */
@@ -16,7 +16,7 @@
16
16
  * </a>
17
17
  * <h1 class="juno-navbar__title">Album</h1>
18
18
  * <div class="juno-navbar__actions">
19
- * <button class="juno-btn juno-btn--sm juno-btn--ghost">EDIT</button>
19
+ * <button class="juno-btn juno-btn--ghost">EDIT</button>
20
20
  * </div>
21
21
  * </header>
22
22
  * ════════════════════════════════════════════════════════════════════ */
@@ -97,6 +97,10 @@
97
97
  gap: var(--juno-space-4);
98
98
  }
99
99
 
100
+ /* Top-bar action controls are a touch surface: hold the tap minimum
101
+ (auto-promotes to 44px on coarse pointers). See 20260802-020. */
102
+ .juno-navbar__actions > * { min-block-size: var(--juno-size-tap-min); }
103
+
100
104
  .juno-navbar__back:focus-visible {
101
105
  outline: var(--juno-border-width-2) solid var(--juno-active);
102
106
  outline-offset: calc(-1 * var(--juno-space-2));
@@ -19,23 +19,38 @@
19
19
  * <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-arrows-clockwise" /></svg>
20
20
  * </button>
21
21
  * </nav>
22
+ * Overflow: for variable-membership pills (an app-owned capacity planner
23
+ * decides how many __item fit, reading --juno-pillbar-item/-gap/-pad below),
24
+ * swap the last __item for .juno-pillbar__overflow anchoring a .juno-menu —
25
+ * see docs/components/pillbar.md#overflow-slot and menu.css.
22
26
  * ════════════════════════════════════════════════════════════════════ */
23
27
 
24
28
  .juno-pillbar {
25
29
  --juno-role: var(--juno-active);
26
30
 
31
+ /* Geometry contract — published so a consumer's overflow/capacity planner
32
+ (deciding how many __item fit before routing the rest to __overflow)
33
+ reads real values instead of re-declaring junoui's px constants in JS.
34
+ Base-rule values only; the corner variants below keep their own literal
35
+ offsets (distinct block vs. inline edges) and are not wired to -edge. */
36
+ --juno-pillbar-item: var(--juno-size-tap-comfortable);
37
+ --juno-pillbar-gap: var(--juno-space-2);
38
+ --juno-pillbar-pad: var(--juno-space-4);
39
+ --juno-pillbar-edge: var(--juno-space-16);
40
+
27
41
  position: sticky;
28
- inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
42
+ inset-block-end: calc(var(--juno-pillbar-edge) + env(safe-area-inset-bottom, 0px));
29
43
  z-index: var(--juno-z-raised);
30
44
  margin-inline: auto;
31
- margin-block-end: var(--juno-space-16);
45
+ margin-block-end: var(--juno-pillbar-edge);
32
46
  inline-size: fit-content;
33
- max-inline-size: calc(100% - 2 * var(--juno-space-16));
47
+ max-inline-size: calc(100% - 2 * var(--juno-pillbar-edge));
34
48
  display: flex;
35
49
  align-items: center;
36
- gap: var(--juno-space-2);
37
- padding: var(--juno-space-4);
50
+ gap: var(--juno-pillbar-gap);
51
+ padding: var(--juno-pillbar-pad);
38
52
  background: color-mix(in srgb, var(--juno-s1) 88%, transparent);
53
+ -webkit-backdrop-filter: blur(12px);
39
54
  backdrop-filter: blur(12px);
40
55
  border: var(--juno-border-width-1) solid var(--juno-border);
41
56
  border-radius: 999px;
@@ -46,8 +61,8 @@
46
61
  appearance: none;
47
62
  margin: 0;
48
63
  border: none;
49
- min-inline-size: var(--juno-size-tap-comfortable);
50
- min-block-size: var(--juno-size-tap-comfortable);
64
+ min-inline-size: var(--juno-pillbar-item);
65
+ min-block-size: var(--juno-pillbar-item);
51
66
  display: inline-flex;
52
67
  align-items: center;
53
68
  justify-content: center;
@@ -94,6 +109,54 @@
94
109
  cursor: not-allowed;
95
110
  }
96
111
 
112
+ /* Overflow slot — the "more" trigger for items that don't fit the pill.
113
+ Styled like __item (same tap target, radius, hover/focus) but a dedicated
114
+ element because it opens a menu instead of acting as a nav/toggle item.
115
+ Pair with .juno-menu (menu.css) via popovertarget — the invoker is the
116
+ menu's implicit anchor, so no extra anchor-positioning is needed here.
117
+ junoui ships the docking point only: DECIDING which items overflow (and
118
+ when) is app policy — mount/unmount this button and move the spilled
119
+ __item markup into the menu, sizing the decision off the
120
+ --juno-pillbar-item/-gap/-pad custom props above instead of hardcoded px.
121
+ Zero JS in this file; aria-expanded is toggled by the app or a stateless
122
+ enhancer, same convention as __item's [aria-pressed].
123
+ .juno-pillbar__toggle (the --collapsible variant's expand/collapse control,
124
+ below) shares this exact chrome: same circle, same states. */
125
+ .juno-pillbar__overflow,
126
+ .juno-pillbar__toggle {
127
+ appearance: none;
128
+ margin: 0;
129
+ border: none;
130
+ flex-shrink: 0;
131
+ min-inline-size: var(--juno-pillbar-item);
132
+ min-block-size: var(--juno-pillbar-item);
133
+ display: inline-flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ background: transparent;
137
+ border-radius: 999px;
138
+ color: var(--juno-label);
139
+ cursor: pointer;
140
+ transition:
141
+ color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard),
142
+ background-color var(--juno-motion-duration-quick) var(--juno-motion-ease-standard);
143
+ }
144
+
145
+ .juno-pillbar__overflow:hover,
146
+ .juno-pillbar__toggle:hover { color: var(--juno-data); }
147
+
148
+ .juno-pillbar__overflow[aria-expanded='true'],
149
+ .juno-pillbar__toggle[aria-expanded='true'] {
150
+ background: var(--juno-s3);
151
+ color: var(--juno-role);
152
+ }
153
+
154
+ .juno-pillbar__overflow:focus-visible,
155
+ .juno-pillbar__toggle:focus-visible {
156
+ outline: var(--juno-border-width-2) solid var(--juno-active);
157
+ outline-offset: calc(-1 * var(--juno-space-2));
158
+ }
159
+
97
160
  .juno-pillbar__sep {
98
161
  align-self: center;
99
162
  inline-size: var(--juno-border-width-1);
@@ -117,10 +180,147 @@
117
180
  z-index: var(--juno-z-anchored);
118
181
  }
119
182
 
183
+ /* Corner placement — a floating cluster pinned to one viewport corner rather
184
+ than the centered bottom bar (e.g. a top-right search/filter cluster, or a
185
+ bottom-right action pill). Each variant is self-contained: it flips to
186
+ fixed, clears the base's bottom-center geometry (auto margins / bottom
187
+ inset), and clamps the corner with safe-area insets so it never lands under
188
+ a notch or the home indicator. Keep the base blur/border/shadow. Pair with a
189
+ scroll-clearance reservation on the scroller if the corner overlaps content
190
+ (var(--juno-pillbar-clearance) for the bottom corners). */
191
+ .juno-pillbar--top-right,
192
+ .juno-pillbar--top-left,
193
+ .juno-pillbar--bottom-right,
194
+ .juno-pillbar--bottom-left {
195
+ position: fixed;
196
+ inset-block: auto;
197
+ inset-inline: auto;
198
+ z-index: var(--juno-z-anchored);
199
+ margin: 0;
200
+ }
201
+
202
+ .juno-pillbar--top-right {
203
+ inset-block-start: calc(var(--juno-space-10) + env(safe-area-inset-top, 0px));
204
+ inset-inline-end: calc(var(--juno-space-12) + env(safe-area-inset-right, 0px));
205
+ }
206
+
207
+ .juno-pillbar--top-left {
208
+ inset-block-start: calc(var(--juno-space-10) + env(safe-area-inset-top, 0px));
209
+ inset-inline-start: calc(var(--juno-space-12) + env(safe-area-inset-left, 0px));
210
+ }
211
+
212
+ .juno-pillbar--bottom-right {
213
+ inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
214
+ inset-inline-end: calc(var(--juno-space-12) + env(safe-area-inset-right, 0px));
215
+ }
216
+
217
+ .juno-pillbar--bottom-left {
218
+ inset-block-end: calc(var(--juno-space-16) + env(safe-area-inset-bottom, 0px));
219
+ inset-inline-start: calc(var(--juno-space-12) + env(safe-area-inset-left, 0px));
220
+ }
221
+
222
+ /* Input slot — an expandable search field living inside the pill. Borderless
223
+ and transparent so it reads as part of the pill, not a boxed control. The
224
+ font-size floor matters: iOS Safari zooms the whole page onto any focused
225
+ text field under 16px, so hold this at max(16px, …) exactly like the base
226
+ .juno-input touch rule. Give it an accessible label (aria-label or a
227
+ visually-hidden <label>) — the placeholder is not a label. */
228
+ .juno-pillbar__input {
229
+ flex: 0 1 auto;
230
+ min-inline-size: 0;
231
+ inline-size: min(52vw, 240px);
232
+ margin: 0;
233
+ padding-inline: var(--juno-space-8);
234
+ border: none;
235
+ background: transparent;
236
+ outline: none;
237
+ font-family: var(--juno-font-family-sans);
238
+ font-size: max(16px, var(--juno-font-size-16));
239
+ color: var(--juno-data);
240
+ }
241
+
242
+ .juno-pillbar__input::placeholder { color: var(--juno-muted); }
243
+
244
+ .juno-pillbar__input:focus-visible {
245
+ outline: none;
246
+ }
247
+
248
+ /* Collapsible — the whole pill folds into a single circular toggle, and a
249
+ tap expands it back to full width. State lives on the toggle's
250
+ aria-expanded (app-owned, like __item's aria-pressed — zero JS here);
251
+ the bar reads it via :has(), so DOM order of toggle vs. tray is free
252
+ (toggle-first for a left-anchored pill, toggle-last for right-anchored).
253
+
254
+ Usage:
255
+ <nav class="juno-pillbar juno-pillbar--collapsible" aria-label="Tools">
256
+ <button class="juno-pillbar__toggle" aria-expanded="false" aria-label="Show toolbar">
257
+ <svg class="juno-icon" aria-hidden="true"><use href="…#juno-i-dots-three" /></svg>
258
+ </button>
259
+ <div class="juno-pillbar__tray">
260
+ <div><!-- single wrapper, any element -->
261
+ …the usual __item / __sep / __input children…
262
+ </div>
263
+ </div>
264
+ </nav>
265
+
266
+ Mechanism: the tray is a one-column grid whose track animates 0fr ↔ 1fr —
267
+ the only widely-supported way to TRANSITION to an intrinsic width (Safari 16+;
268
+ width: fit-content and interpolate-size aren't animatable there). The track
269
+ needs one shrinkable child, hence the mandatory single wrapper (any element:
270
+ it's selected as > *, so there is no class to forget). visibility rides the
271
+ same transition — discrete animation keeps the tray visible while it slides
272
+ shut, then hides it, which also drops the hidden items from the tab order
273
+ (collapsing while focus is inside the tray is the app's edge to handle:
274
+ move focus to the toggle first). prefers-reduced-motion needs nothing
275
+ component-local — the base layer zeroes every transition duration. */
276
+ .juno-pillbar--collapsible {
277
+ transition: gap var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
278
+ }
279
+
280
+ .juno-pillbar__tray {
281
+ display: grid;
282
+ grid-template-columns: 1fr;
283
+ transition:
284
+ grid-template-columns var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard),
285
+ opacity var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard),
286
+ visibility var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
287
+ }
288
+
289
+ .juno-pillbar__tray > * {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: var(--juno-pillbar-gap);
293
+ min-inline-size: 0;
294
+ overflow: hidden;
295
+ }
296
+
297
+ .juno-pillbar--collapsible:has(> .juno-pillbar__toggle[aria-expanded='false']) {
298
+ /* gap → 0 so the collapsed pill is exactly the toggle's circle, whichever
299
+ side the tray sits on (a negative margin would have to know the side) */
300
+ gap: 0;
301
+ }
302
+
303
+ .juno-pillbar--collapsible:has(> .juno-pillbar__toggle[aria-expanded='false']) .juno-pillbar__tray {
304
+ grid-template-columns: 0fr;
305
+ opacity: 0;
306
+ visibility: hidden;
307
+ }
308
+
309
+ /* the toggle's glyph turns a quarter as the tray opens — motion reads as
310
+ "this button did it" even when the expansion pushes the pill off-center */
311
+ .juno-pillbar__toggle .juno-icon {
312
+ transition: transform var(--juno-motion-duration-deliberate) var(--juno-motion-ease-standard);
313
+ }
314
+
315
+ .juno-pillbar__toggle[aria-expanded='true'] .juno-icon {
316
+ transform: rotate(90deg);
317
+ }
318
+
120
319
  /* reduced-transparency preference → solid surface, no blur */
121
320
  @media (prefers-reduced-transparency: reduce) {
122
321
  .juno-pillbar {
123
322
  background: var(--juno-s1);
323
+ -webkit-backdrop-filter: none;
124
324
  backdrop-filter: none;
125
325
  }
126
326
  }
@@ -0,0 +1,48 @@
1
+ /* ════════════════════════════════════════════════════════════════════
2
+ * Component — Reload indicator (refetch-in-place)
3
+ * The non-blocking counterpart to the skeleton. Skeletons stand in for
4
+ * content that isn't there yet (first paint); the reload dot signals a
5
+ * refresh happening OVER content that's already on screen — the stale data
6
+ * stays readable and interactive while fresh data lands.
7
+ *
8
+ * A small pulsing dot with a soft halo, centered as a fixed overlay that
9
+ * eats no pointer events (the page underneath stays fully usable). Color
10
+ * comes from --juno-role (default = active). The app owns the state: render
11
+ * it while a background refetch is in flight, drop it when it settles.
12
+ *
13
+ * a11y: role="status" + aria-label so assistive tech announces the refresh
14
+ * politely without stealing focus. Keep it a live status, not an alert.
15
+ * Usage:
16
+ * <div class="juno-reload" role="status" aria-label="Reloading">
17
+ * <span class="juno-reload__dot"></span>
18
+ * </div>
19
+ * ════════════════════════════════════════════════════════════════════ */
20
+
21
+ .juno-reload {
22
+ --juno-role: var(--juno-active);
23
+
24
+ position: fixed;
25
+ inset-block-start: 50%;
26
+ inset-inline-start: 50%;
27
+ transform: translate(-50%, -50%);
28
+ z-index: var(--juno-z-anchored);
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ pointer-events: none; /* refresh is non-blocking — never trap clicks */
33
+ }
34
+
35
+ .juno-reload__dot {
36
+ inline-size: var(--juno-space-16);
37
+ block-size: var(--juno-space-16);
38
+ border-radius: 50%;
39
+ background: var(--juno-role);
40
+
41
+ /* soft halo so the dot reads over any surface without a hard edge */
42
+ box-shadow: 0 0 0 var(--juno-space-8) color-mix(in srgb, var(--juno-role) 20%, transparent);
43
+ animation: juno-pulse 1.4s ease-in-out infinite;
44
+ }
45
+
46
+ /* prefers-reduced-motion: the base layer already slows every animation to a
47
+ stop; the dot + halo stay visible (steady), so the "refreshing" state is
48
+ still conveyed without the pulse. */
@@ -2,14 +2,20 @@
2
2
  * Component — Skeleton · loading placeholder
3
3
  * A shimmering block that stands in for content not yet loaded. Same
4
4
  * shimmer language as the table's row skeleton, generalized: text lines,
5
- * blocks, and circles (avatars). Zero JS size it with width / height or
6
- * --juno-skeleton-h. Honors prefers-reduced-motion (shimmer → static).
7
- * Mark the loading region aria-busy="true" so AT announces it.
5
+ * blocks, circles (avatars), and aspect-ratio tiles (media grids). Zero
6
+ * JS — size it with width / height, --juno-skeleton-h, or (for --tile)
7
+ * --juno-skeleton-ratio. The shimmer is a `::before` overlay animated on
8
+ * transform/opacity only — never background-position — so it stays on
9
+ * the compositor instead of repainting the gradient every frame; the
10
+ * base fill is a plain solid color. Reduced motion is handled globally
11
+ * (base.css collapses all animation-duration to ~0), no local override
12
+ * needed here. Mark the loading region aria-busy="true" so AT announces it.
8
13
  * Usage:
9
14
  * <div aria-busy="true" aria-live="polite">
10
15
  * <span class="juno-skeleton juno-skeleton--circle" style="--juno-skeleton-h: 48px;"></span>
11
16
  * <span class="juno-skeleton juno-skeleton--text" style="inline-size: 60%;"></span>
12
17
  * <span class="juno-skeleton juno-skeleton--text"></span>
18
+ * <span class="juno-skeleton juno-skeleton--tile"></span>
13
19
  * </div>
14
20
  * ════════════════════════════════════════════════════════════════════ */
15
21
 
@@ -17,11 +23,27 @@
17
23
  --juno-skeleton-h: var(--juno-space-16);
18
24
 
19
25
  display: block;
26
+ position: relative;
27
+ overflow: hidden;
20
28
  inline-size: 100%;
21
29
  block-size: var(--juno-skeleton-h);
22
30
  border-radius: var(--juno-radius-3);
23
- background: linear-gradient(90deg, var(--juno-s2) 0%, var(--juno-s3) 50%, var(--juno-s2) 100%);
24
- background-size: 200% 100%;
31
+ background: var(--juno-s2);
32
+ }
33
+
34
+ /* the shimmer itself: a diagonal highlight band that slides across via
35
+ transform only (compositor-only — no layout/paint per frame). Clipped to
36
+ the parent's border-radius by the overflow: hidden above. */
37
+ .juno-skeleton::before {
38
+ content: '';
39
+ position: absolute;
40
+ inset: 0;
41
+ background: linear-gradient(
42
+ 100deg,
43
+ transparent 20%,
44
+ color-mix(in srgb, var(--juno-s3) 70%, transparent) 50%,
45
+ transparent 80%
46
+ );
25
47
  animation: juno-skeleton-shimmer 1.2s ease-in-out infinite;
26
48
  }
27
49
 
@@ -45,18 +67,24 @@
45
67
  border-radius: var(--juno-radius-4);
46
68
  }
47
69
 
70
+ /* content-box mode: size from an aspect ratio instead of a fixed height —
71
+ the dominant case for media-grid tiles standing in for a real image/card
72
+ of known proportions. Default 1 (square); override per instance with
73
+ --juno-skeleton-ratio (e.g. "16 / 9"). */
74
+ .juno-skeleton--tile {
75
+ --juno-skeleton-ratio: 1;
76
+
77
+ aspect-ratio: var(--juno-skeleton-ratio);
78
+ block-size: auto;
79
+ border-radius: var(--juno-radius-4);
80
+ }
81
+
48
82
  @keyframes juno-skeleton-shimmer {
49
83
  from {
50
- background-position: 200% 0;
84
+ transform: translateX(-100%);
51
85
  }
52
86
 
53
87
  to {
54
- background-position: -200% 0;
55
- }
56
- }
57
-
58
- @media (prefers-reduced-motion: reduce) {
59
- .juno-skeleton {
60
- animation: none;
88
+ transform: translateX(100%);
61
89
  }
62
90
  }
@@ -29,6 +29,11 @@
29
29
  scrollbar-width: none;
30
30
  }
31
31
 
32
+ /* scrollbar-width only reached Safari 18.2, so the scrollable strip still shows
33
+ a bar on older iOS — keep the WebKit pseudo-element alongside it (same pair
34
+ .juno-scroller--bare uses). */
35
+ .juno-tabs__list::-webkit-scrollbar { display: none; }
36
+
32
37
  .juno-tabs__tab {
33
38
  appearance: none;
34
39
  margin: 0;