ionbase-ui 0.27.0 → 0.29.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "ionbase-ui",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "usage": "Pick a component here, then read dist/meta/<Name>.json for its full contract.",
5
5
  "hooks": [
6
6
  "useToast"
@@ -119,7 +119,7 @@
119
119
  "hasIntent": true
120
120
  },
121
121
  "AvatarGradient": {
122
- "summary": "Avatar's initials-only sibling: a lit, bevelled disc in one of eight colours, for people with no photo.",
122
+ "summary": "Avatar's initials-only sibling: a tinted, bevelled disc in one of seven colours, for people with no photo.",
123
123
  "status": "stable",
124
124
  "variants": {
125
125
  "size": [
@@ -135,8 +135,7 @@
135
135
  "pink",
136
136
  "orange",
137
137
  "green",
138
- "red",
139
- "light"
138
+ "red"
140
139
  ]
141
140
  },
142
141
  "detail": "dist/meta/AvatarGradient.json",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "ionbase-ui",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "usage": "A pattern is a documented composition of components, not a component. Read one here, then read the contracts of what it composes.",
5
5
  "patterns": {
6
6
  "DataTable": {
@@ -1,8 +1,9 @@
1
1
  /*
2
2
  * Avatar Gradient
3
3
  *
4
- * Measured from Figma `Avatar Gradient` (1054:305). Circle only — the set has
5
- * no Shape axis, because a gradient this directional does not survive a corner.
4
+ * Measured from Figma `Avatar Gradient` (1054:305), re-read after the
5
+ * 2026-08-28 redesign. Circle only — the set has no Shape axis, because a
6
+ * gradient this directional does not survive a corner.
6
7
  *
7
8
  * Sizes and the type ramp are Avatar's, including regular weight at Mini:
8
9
  *
@@ -11,19 +12,24 @@
11
12
  * medium 40 type 16/24 medium
12
13
  * large 48 type 20/32 medium
13
14
  *
14
- * EVERY GRADIENT IS A PAIR ALREADY IN THE PALETTE
15
+ * IT USED TO BE THE OTHER WAY UP, AND THE CONTRAST GATE IS WHY IT IS NOT
15
16
  *
16
- * `--color-<hue>-500` to `--color-<hue>-600`, and `light` is gray 100 to 200.
17
- * Nothing here needed a new token, which is worth stating because the opposite
18
- * would have been the expensive outcome: eight bespoke gradient tokens that
19
- * Figma owns and this repo could only copy.
17
+ * The first version was a saturated disc — `color/<hue>/500` to `/600` — with
18
+ * white initials. Four of the seven failed WCAG AA at 3.5 to 3.8:1, and the fix
19
+ * was made in Figma rather than here: the disc is now a pale tint and the
20
+ * initials carry the colour. Do not "restore" the saturated version. It is not
21
+ * a style preference that changed.
20
22
  *
21
- * The middle stop is the arithmetic midpoint of the two ends, sitting at 60%
22
- * rather than 50%. That offset is the whole shape of it — a plain two-stop
23
- * gradient puts the midpoint at 50% and reads flatter. `color-mix` is how the
24
- * midpoint stays derived from the pair instead of being a third literal, and
25
- * it is also the only way to write it at all: `color-no-hex` forbids the hex,
26
- * and there is no token for a value halfway between two tokens.
23
+ * THREE RUNGS, THREE TOKENS
24
+ *
25
+ * 0% color/<hue>/300 the deepest tint, at the TOP
26
+ * 60% color/<hue>/200
27
+ * 100% color/<hue>/50 nearly white, at the bottom
28
+ *
29
+ * Every stop is variable-bound in Figma, so all three are real tokens and the
30
+ * `color-mix` midpoint the old two-rung version needed is gone. Slate is the
31
+ * gray ramp; every other colour is its own hue at the same three rungs, which
32
+ * is what makes this a system rather than seven pictures.
27
33
  */
28
34
  .ion-avatar-gradient {
29
35
  --ion-avatar-gradient-size: var(--spacing-40);
@@ -32,19 +38,24 @@
32
38
  --ion-avatar-gradient-font-weight: var(--font-weight-medium);
33
39
 
34
40
  /* Slate, which is also the block default — Figma's default variant. */
35
- --ion-avatar-gradient-from: var(--color-gray-500);
36
- --ion-avatar-gradient-to: var(--color-gray-600);
41
+ --ion-avatar-gradient-from: var(--color-gray-300);
42
+ --ion-avatar-gradient-mid: var(--color-gray-200);
43
+ --ion-avatar-gradient-to: var(--color-gray-50);
37
44
 
38
45
  /*
39
- * White, from the primitive ramp rather than `--text-on-color`.
46
+ * The initials, from the primitive ramp rather than a semantic text token.
40
47
  *
41
- * NOT AN OVERSIGHT, AND DO NOT "FIX" IT TO THE SEMANTIC TOKEN. The surface
42
- * under this text is built from primitives, so it does not theme; the dark
43
- * theme flips `--text-on-color` to black, which would put black initials on
44
- * an unchanged mid-blue disc. A foreground has to theme exactly as much as
45
- * the background it sits on, and this one sits on a background that does not.
48
+ * NOT AN OVERSIGHT, AND DO NOT "FIX" IT TO `--text-*`. The disc under this
49
+ * text is built from primitives, so it does not theme; a semantic foreground
50
+ * would flip in the dark theme and leave the background alone. An earlier
51
+ * draft did exactly that and the contrast gate caught it at 1.05:1. A
52
+ * foreground has to theme exactly as much as the background it sits on.
53
+ *
54
+ * In Figma these are bound to a `palette/1`..`palette/7` collection the token
55
+ * export does not yet cover. Each one resolves to the primitive named here,
56
+ * so nothing is lost today — see AGENTS.md.
46
57
  */
47
- --ion-avatar-gradient-text: var(--color-base-white);
58
+ --ion-avatar-gradient-text: var(--color-gray-600);
48
59
 
49
60
  /*
50
61
  * The sheen: a white radial highlight low and centre, over the linear
@@ -56,28 +67,21 @@
56
67
  --ion-avatar-gradient-sheen-mid: 3.6%;
57
68
 
58
69
  /*
59
- * Bevel and lift.
70
+ * Bevel. Two inner shadows and no drop shadow — the redesign dropped the
71
+ * lift, and these no longer scale with the box the way they did: Figma now
72
+ * carries the same 1px / -2px offsets at all four sizes.
60
73
  *
61
74
  * A LOCAL, BECAUSE A RAW COLOUR IN A `box-shadow` DECLARATION IS BANNED — and
62
75
  * the ban is right. Its reason is that a raw colour is a place dark mode has
63
76
  * to be fixed by hand, once per occurrence; naming it here makes this file
64
- * exactly one such place instead of five, which is the same trade
77
+ * exactly one such place instead of four, which is the same trade
65
78
  * `elevation.css` makes for the shared ramp. These are local Figma effects,
66
79
  * not effect STYLES, so they are not in that ramp and cannot be: the exporter
67
80
  * only sees named styles, and there is nothing here for it to name.
68
- *
69
- * All three offsets are derived from the box rather than written four times:
70
- * Figma scales the effect with the component, so the arithmetic IS the
71
- * design. S/24 and S/12 for the inner shade, S/48 for the top highlight, and
72
- * S/16 and S/6 for the drop.
73
81
  */
74
- --ion-avatar-gradient-elevation:
75
- inset 0 calc(-1 * var(--ion-avatar-gradient-size) / 24)
76
- calc(var(--ion-avatar-gradient-size) / 12) 0 rgb(0 0 0 / 12%),
77
- inset 0 calc(var(--ion-avatar-gradient-size) / 48)
78
- calc(var(--ion-avatar-gradient-size) / 48 * 1.4) 0 rgb(255 255 255 / 24%),
79
- 0 calc(var(--ion-avatar-gradient-size) / 16)
80
- calc(var(--ion-avatar-gradient-size) / 6) 0 rgb(13 15 23 / 12%);
82
+ --ion-avatar-gradient-bevel:
83
+ inset 0 -2px 4px 0 rgb(0 0 0 / 12%),
84
+ inset 0 1px 1.4px 0 rgb(255 255 255 / 24%);
81
85
 
82
86
  display: inline-flex;
83
87
  align-items: center;
@@ -90,13 +94,17 @@
90
94
  color: var(--ion-avatar-gradient-text);
91
95
 
92
96
  /*
93
- * The flat colour under the gradient is the `from` end, and that choice is
97
+ * The flat colour under the gradient is the `to` end, and that choice is
94
98
  * load-bearing twice over. It is the fallback if `background-image` never
95
- * paints, and it is the lightest part of the disc — so it is also the worst
96
- * case for white text, which is what makes `verify-contrast` measure the
97
- * pairing that actually needs measuring rather than a flattering one.
99
+ * paints, and it is the palest part of the disc — so it is also the worst
100
+ * case for the dark initials, which is what makes `verify-contrast` measure
101
+ * the pairing that actually needs measuring rather than a flattering one.
102
+ *
103
+ * It was the `from` end before the redesign, for the same reason read the
104
+ * other way round: the text was white then, so its worst case was the
105
+ * lightest stop it sat on. Flip this if the disc is ever inverted again.
98
106
  */
99
- background-color: var(--ion-avatar-gradient-from);
107
+ background-color: var(--ion-avatar-gradient-to);
100
108
  background-image:
101
109
  radial-gradient(
102
110
  55.556% 64.103% at 50% 70.513%,
@@ -107,15 +115,10 @@
107
115
  linear-gradient(
108
116
  180deg,
109
117
  var(--ion-avatar-gradient-from) 0%,
110
- color-mix(
111
- in srgb,
112
- var(--ion-avatar-gradient-from),
113
- var(--ion-avatar-gradient-to)
114
- )
115
- 60%,
118
+ var(--ion-avatar-gradient-mid) 60%,
116
119
  var(--ion-avatar-gradient-to) 100%
117
120
  );
118
- box-shadow: var(--ion-avatar-gradient-elevation);
121
+ box-shadow: var(--ion-avatar-gradient-bevel);
119
122
  font-family: var(--font-family-sans);
120
123
  font-size: var(--ion-avatar-gradient-font-size);
121
124
  line-height: var(--ion-avatar-gradient-line-height);
@@ -143,10 +146,16 @@
143
146
  }
144
147
 
145
148
  /*
146
- * The eight colours. Each declares its own `color` and `background-color`
149
+ * The seven colours. Each declares its own `color` and `background-color`
147
150
  * rather than inheriting them from the block: the contrast gate pairs what one
148
151
  * rule declares, and a colour it has to find on a different scope resolves
149
152
  * through the cascade to something that never renders together.
153
+ *
154
+ * All seven take `600`, including pink. It took `700` here until the 2026-08-28
155
+ * primitive re-export, and that was not a design choice — the pink ramp shifted
156
+ * down a rung, so the value this component wants (#c3135c) simply moved from
157
+ * `700` to `600`. Renaming under a stable name is the failure the token
158
+ * pipeline is built to catch; it caught this one.
150
159
  */
151
160
  .ion-avatar-gradient--slate,
152
161
  .ion-avatar-gradient--blue,
@@ -156,71 +165,54 @@
156
165
  .ion-avatar-gradient--green,
157
166
  .ion-avatar-gradient--red {
158
167
  color: var(--ion-avatar-gradient-text);
159
- background-color: var(--ion-avatar-gradient-from);
168
+ background-color: var(--ion-avatar-gradient-to);
160
169
  }
161
170
 
162
171
  .ion-avatar-gradient--slate {
163
- --ion-avatar-gradient-from: var(--color-gray-500);
164
- --ion-avatar-gradient-to: var(--color-gray-600);
172
+ --ion-avatar-gradient-from: var(--color-gray-300);
173
+ --ion-avatar-gradient-mid: var(--color-gray-200);
174
+ --ion-avatar-gradient-to: var(--color-gray-50);
175
+ --ion-avatar-gradient-text: var(--color-gray-600);
165
176
  }
166
177
 
167
178
  .ion-avatar-gradient--blue {
168
- --ion-avatar-gradient-from: var(--color-blue-500);
169
- --ion-avatar-gradient-to: var(--color-blue-600);
179
+ --ion-avatar-gradient-from: var(--color-blue-300);
180
+ --ion-avatar-gradient-mid: var(--color-blue-200);
181
+ --ion-avatar-gradient-to: var(--color-blue-50);
182
+ --ion-avatar-gradient-text: var(--color-blue-600);
170
183
  }
171
184
 
172
185
  .ion-avatar-gradient--violet {
173
- --ion-avatar-gradient-from: var(--color-purple-500);
174
- --ion-avatar-gradient-to: var(--color-purple-600);
186
+ --ion-avatar-gradient-from: var(--color-purple-300);
187
+ --ion-avatar-gradient-mid: var(--color-purple-200);
188
+ --ion-avatar-gradient-to: var(--color-purple-50);
189
+ --ion-avatar-gradient-text: var(--color-purple-600);
175
190
  }
176
191
 
177
192
  .ion-avatar-gradient--pink {
178
- --ion-avatar-gradient-from: var(--color-pink-500);
179
- --ion-avatar-gradient-to: var(--color-pink-600);
193
+ --ion-avatar-gradient-from: var(--color-pink-300);
194
+ --ion-avatar-gradient-mid: var(--color-pink-200);
195
+ --ion-avatar-gradient-to: var(--color-pink-50);
196
+ --ion-avatar-gradient-text: var(--color-pink-600);
180
197
  }
181
198
 
182
199
  .ion-avatar-gradient--orange {
183
- --ion-avatar-gradient-from: var(--color-orange-500);
184
- --ion-avatar-gradient-to: var(--color-orange-600);
200
+ --ion-avatar-gradient-from: var(--color-orange-300);
201
+ --ion-avatar-gradient-mid: var(--color-orange-200);
202
+ --ion-avatar-gradient-to: var(--color-orange-50);
203
+ --ion-avatar-gradient-text: var(--color-orange-600);
185
204
  }
186
205
 
187
206
  .ion-avatar-gradient--green {
188
- --ion-avatar-gradient-from: var(--color-green-500);
189
- --ion-avatar-gradient-to: var(--color-green-600);
207
+ --ion-avatar-gradient-from: var(--color-green-300);
208
+ --ion-avatar-gradient-mid: var(--color-green-200);
209
+ --ion-avatar-gradient-to: var(--color-green-50);
210
+ --ion-avatar-gradient-text: var(--color-green-600);
190
211
  }
191
212
 
192
213
  .ion-avatar-gradient--red {
193
- --ion-avatar-gradient-from: var(--color-red-500);
194
- --ion-avatar-gradient-to: var(--color-red-600);
195
- }
196
-
197
- /*
198
- * Light is the odd one and gets dark text, a stronger sheen and a gentler
199
- * bevel — Figma halves the shade to 5% and doubles the highlight to 50%,
200
- * because the same values that read as depth on a saturated disc read as dirt
201
- * on a near-white one.
202
- *
203
- * Its text is `--color-gray-900`, not `--text-default`, for exactly the reason
204
- * the seven colours use `--color-base-white` rather than `--text-on-color`.
205
- * The first pass used the semantic token here and the contrast gate caught it
206
- * in the dark theme at 1.05:1 — `--text-default` themes to near-white while
207
- * `--color-gray-100` underneath it does not, so the initials disappeared. A
208
- * foreground has to theme exactly as much as the background it sits on.
209
- */
210
- .ion-avatar-gradient--light {
211
- --ion-avatar-gradient-from: var(--color-gray-100);
212
- --ion-avatar-gradient-to: var(--color-gray-200);
213
- --ion-avatar-gradient-text: var(--color-gray-900);
214
- --ion-avatar-gradient-sheen: 32%;
215
- --ion-avatar-gradient-sheen-mid: 6.4%;
216
- --ion-avatar-gradient-elevation:
217
- inset 0 calc(-1 * var(--ion-avatar-gradient-size) / 24)
218
- calc(var(--ion-avatar-gradient-size) / 12) 0 rgb(0 0 0 / 5%),
219
- inset 0 calc(var(--ion-avatar-gradient-size) / 48)
220
- calc(var(--ion-avatar-gradient-size) / 48 * 1.4) 0 rgb(255 255 255 / 50%),
221
- 0 calc(var(--ion-avatar-gradient-size) / 16)
222
- calc(var(--ion-avatar-gradient-size) / 6) 0 rgb(13 15 23 / 12%);
223
-
224
- color: var(--ion-avatar-gradient-text);
225
- background-color: var(--ion-avatar-gradient-from);
214
+ --ion-avatar-gradient-from: var(--color-red-300);
215
+ --ion-avatar-gradient-mid: var(--color-red-200);
216
+ --ion-avatar-gradient-to: var(--color-red-50);
217
+ --ion-avatar-gradient-text: var(--color-red-600);
226
218
  }
@@ -17,16 +17,29 @@
17
17
  --ion-button-elevation: var(--ion-shadow-none);
18
18
 
19
19
  /*
20
- * ELEVATION IS INDEXED BY SIZE, NOT BY VARIANT
20
+ * ELEVATION IS INDEXED BY SIZE; THE VARIANT PICKS THE FAMILY
21
21
  *
22
- * Measured: Primary Brand, Primary Neutral, Secondary and Destructive all sit
23
- * on the same rung at a given size — Small is `xs`, Medium `sm`, Large `lg`.
24
- * The variant chooses only the *family* it presses into, so these are slots
25
- * the size modifiers reassign and the variants select from. This replaces the
22
+ * Each of these is a rung reassigned by the size modifiers, and a variant
23
+ * selects one. That is the whole structure — no variant restates a shadow
24
+ * value, and no size modifier knows which variant is asking. It replaces the
26
25
  * old per-variant `button-raised` / `button-surface` pair, which encoded a
27
26
  * distinction Figma no longer draws.
27
+ *
28
+ * `raised` and `shadow` are two DIFFERENT rest families, and which one a
29
+ * variant takes is a real design decision rather than a spelling of the same
30
+ * idea. `Raised/Lifted` is embossed — two inset highlights that read as a
31
+ * bevel — and it only works over a solid, saturated fill. `Shadow` is a plain
32
+ * drop shadow. The two light-surfaced variants, Secondary and Primary Soft,
33
+ * are on `shadow`; the four solid ones are on `raised`. Measured across all
34
+ * four sizes of both Figma sets on 2026-08-28.
35
+ *
36
+ * The two ramps are not the same shape, which is the trap here. `raised` runs
37
+ * xs / sm / lg / lg, but `shadow` runs xs / sm / md / md — Large and XLarge
38
+ * share `Shadow/md`, where the raised family jumps to `lg`. Assuming one ramp
39
+ * from the other puts Large one rung too heavy.
28
40
  */
29
41
  --ion-button-raised: var(--ion-shadow-raised-lifted-sm);
42
+ --ion-button-shadow: var(--ion-shadow-shadow-sm);
30
43
  --ion-button-inset-lifted: var(--ion-shadow-inset-lifted-sm);
31
44
  --ion-button-inset-flush: var(--ion-shadow-inset-flush-sm);
32
45
 
@@ -137,6 +150,7 @@
137
150
  --ion-button-font-size: var(--type-body-sm);
138
151
  --ion-button-line-height: var(--type-body-sm-line-height);
139
152
  --ion-button-raised: var(--ion-shadow-raised-lifted-xs);
153
+ --ion-button-shadow: var(--ion-shadow-shadow-xs);
140
154
  --ion-button-inset-lifted: var(--ion-shadow-inset-lifted-xs);
141
155
  --ion-button-inset-flush: var(--ion-shadow-inset-flush-xs);
142
156
  }
@@ -149,6 +163,9 @@
149
163
  --ion-button-font-size: var(--type-body-md);
150
164
  --ion-button-line-height: var(--type-body-md-line-height);
151
165
  --ion-button-raised: var(--ion-shadow-raised-lifted-lg);
166
+
167
+ /* `md`, not `lg` — the shadow ramp tops out a rung below the raised one. */
168
+ --ion-button-shadow: var(--ion-shadow-shadow-md);
152
169
  --ion-button-inset-lifted: var(--ion-shadow-inset-lifted-lg);
153
170
  --ion-button-inset-flush: var(--ion-shadow-inset-flush-lg);
154
171
  }
@@ -179,13 +196,15 @@
179
196
  * the one rung where the icon does NOT run ahead of its label (24 vs 20, where
180
197
  * Large is 24 vs 18). Reproduced as measured.
181
198
  *
182
- * Elevation reuses the `lg` rungs, and this is where "there is no xl rung" would
183
- * be the easy wrong answer. `Raised/Lifted/xl` and `Raised/Flush/xl` DO exist as
184
- * effect styles — it is the *inset* families that stop at lg, so there is no
185
- * complete xl rung to press into. Figma binds Raised/Lifted/lg, Inset/Lifted/lg
186
- * and Inset/Flush/lg on XLarge exactly as on Large. These three lines restate
199
+ * Elevation reuses the Large rungs, and this is where "there is no xl rung"
200
+ * would be the easy wrong answer. `Raised/Lifted/xl` and `Raised/Flush/xl` DO
201
+ * exist as effect styles — it is the *inset* families that stop at lg, so there
202
+ * is no complete xl rung to press into. Figma binds Raised/Lifted/lg,
203
+ * Inset/Lifted/lg and Inset/Flush/lg on XLarge exactly as on Large, and
204
+ * `Shadow/md` for the two shadow-family variants — also exactly as on Large,
205
+ * even though `Shadow/lg` and `Shadow/xl` both exist. These four lines restate
187
206
  * the inherited defaults on purpose, so the next reader does not have to go and
188
- * check which half of the family exists.
207
+ * check which half of each family exists.
189
208
  */
190
209
  .ion-button--xl {
191
210
  --ion-button-size: var(--spacing-56);
@@ -195,6 +214,7 @@
195
214
  --ion-button-font-size: var(--type-body-lg);
196
215
  --ion-button-line-height: var(--type-body-lg-line-height);
197
216
  --ion-button-raised: var(--ion-shadow-raised-lifted-lg);
217
+ --ion-button-shadow: var(--ion-shadow-shadow-md);
198
218
  --ion-button-inset-lifted: var(--ion-shadow-inset-lifted-lg);
199
219
  --ion-button-inset-flush: var(--ion-shadow-inset-flush-lg);
200
220
  }
@@ -232,9 +252,9 @@
232
252
  *
233
253
  * HOVER DOES NOT CHANGE ELEVATION — only the surface colour moves. Measured:
234
254
  * every Hover variant in Figma carries the same effect style as its Default,
235
- * across all four elevated types. Reproduced as measured rather than preserved
236
- * from the old `button-raised-hover` step, which encodes a difference the
237
- * current design no longer draws.
255
+ * across all six elevated types and both Figma sets. Reproduced as measured
256
+ * rather than preserved from the old `button-raised-hover` step, which encodes
257
+ * a difference the current design no longer draws.
238
258
  */
239
259
 
240
260
  /* Primary Brand Variant */
@@ -314,10 +334,12 @@
314
334
  * (secondary). Measured on all four sizes.
315
335
  *
316
336
  * Elevation presses flush like Secondary, not lifted like Brand — a tinted
317
- * surface has too little contrast for the lifted inset to register.
337
+ * surface has too little contrast for the lifted inset to register. It rests on
338
+ * the `shadow` family for the same reason: the embossed `Raised/Lifted` bevel
339
+ * needs a solid saturated fill under it, and a subtle tint is not one.
318
340
  */
319
341
  .ion-button--primary-soft {
320
- --ion-button-elevation: var(--ion-button-raised);
342
+ --ion-button-elevation: var(--ion-button-shadow);
321
343
  --ion-button-pressed: var(--ion-button-inset-flush);
322
344
 
323
345
  background-color: var(--surface-primary-subtle);
@@ -343,9 +365,16 @@
343
365
  border-color: var(--border-primary);
344
366
  }
345
367
 
346
- /* Secondary Variant — presses flush, losing its lift; brand and destructive keep theirs. */
368
+ /*
369
+ * Secondary Variant — presses flush, losing its lift; brand and destructive keep
370
+ * theirs.
371
+ *
372
+ * Rests on the `shadow` family, not `raised`. Figma moved it there along with
373
+ * Primary Soft: both sit on a light surface, and `Raised/Lifted`'s inset
374
+ * highlight reads as a smudge over white rather than as a bevel.
375
+ */
347
376
  .ion-button--secondary {
348
- --ion-button-elevation: var(--ion-button-raised);
377
+ --ion-button-elevation: var(--ion-button-shadow);
349
378
  --ion-button-pressed: var(--ion-button-inset-flush);
350
379
 
351
380
  background-color: var(--surface-default);
@@ -1,7 +1,9 @@
1
1
  /*
2
2
  * Scroll Progress
3
3
  *
4
- * Measured from Figma `Line` / `Progress` / `Progress Heading` (191:927).
4
+ * Measured from Figma `Line` / `Progress` (191:968) / `Progress Heading`
5
+ * (191:1021), re-synced August 2026 after the sizes, colours and spacing were
6
+ * retuned on the canvas.
5
7
  *
6
8
  * FIGMA SPECIFIES STATES, NOT SCROLL-TRACKING
7
9
  *
@@ -13,11 +15,20 @@
13
15
  * scrolls, whether the page is one document or many, and how "the active
14
16
  * section" is defined are all facts only the consuming app has.
15
17
  *
16
- * TICKS ARE 2px, NOT 1px — MEASURED, NOT A TYPO
18
+ * TICKS ARE 2px TALL AND 24px WIDE, WITH ROUND ENDS
17
19
  *
18
20
  * Figma's `Line` stroke is 2, `border-width/thick`, not `/default`. Easy to
19
21
  * misread as a mistake since every hairline elsewhere in the system is 1px;
20
- * checked against the node rather than assumed.
22
+ * checked against the node rather than assumed. The instances are 24px wide
23
+ * (`spacing/24`) even though the `Line` component's own frame is still 32 —
24
+ * the instance override is what renders, and it is what this file follows.
25
+ * The stroke is `stroke-linecap="round"`, hence `--radius-full`.
26
+ *
27
+ * THE PERCENTAGE IS TWO TYPE SIZES, NOT ONE
28
+ *
29
+ * The number sits on `type/body-sm` and the `%` sign a rung down on
30
+ * `type/caption`. It looks like a rendering artefact in the Figma export and
31
+ * is not — the two spans carry different bound size variables.
21
32
  *
22
33
  * THE FLYOUT IS A DISCLOSURE, NOT A MENU
23
34
  *
@@ -59,25 +70,37 @@
59
70
  outline-offset: 2px;
60
71
  }
61
72
 
73
+ /*
74
+ * `--text-tertiary` in every state. The rail used to lift to
75
+ * `--text-secondary` on hover; the retuned `Status=Hover` variant keeps the
76
+ * percentage tertiary and lets the panel opening be the whole feedback, so
77
+ * that rule is gone rather than kept as a local invention.
78
+ */
62
79
  .ion-scroll-progress__percent {
63
80
  color: var(--text-tertiary);
64
81
  font-family: var(--font-family-sans);
65
82
  font-size: var(--type-body-sm);
66
83
  line-height: var(--type-body-sm-line-height);
67
- transition: color var(--ion-duration-base) var(--ion-ease-out);
84
+ text-align: center;
85
+ }
86
+
87
+ .ion-scroll-progress__percent-sign {
88
+ font-size: var(--type-caption);
89
+ line-height: var(--type-caption-line-height);
68
90
  }
69
91
 
70
92
  .ion-scroll-progress__rail {
71
93
  display: flex;
72
94
  flex-direction: column;
73
95
  align-items: center;
74
- gap: var(--spacing-12);
96
+ gap: var(--spacing-8);
75
97
  }
76
98
 
77
99
  .ion-scroll-progress__tick {
78
- width: var(--spacing-32);
100
+ width: var(--spacing-24);
79
101
  height: var(--border-width-thick);
80
102
  background-color: var(--border-strong);
103
+ border-radius: var(--radius-full);
81
104
  transition: background-color var(--ion-duration-base) var(--ion-ease-out);
82
105
  }
83
106
 
@@ -85,22 +108,31 @@
85
108
  background-color: var(--border-stronger);
86
109
  }
87
110
 
88
- @media (hover: hover) {
89
- .ion-scroll-progress__trigger:hover .ion-scroll-progress__percent {
90
- color: var(--text-secondary);
91
- }
92
- }
93
-
94
- .ion-scroll-progress__trigger[data-hovered='true']
95
- .ion-scroll-progress__percent {
96
- color: var(--text-secondary);
97
- }
98
-
99
111
  /*
100
112
  * Hidden via visibility + opacity, not `display: none` — the transition needs
101
113
  * a property to animate, and `visibility` still pulls the panel's buttons out
102
114
  * of tab order and the accessibility tree while closed, same as a native
103
115
  * `<details>` collapses its content.
116
+ *
117
+ * `width`, not `min-width`: Figma fixes the panel at 220 and truncates each
118
+ * row rather than letting a long heading widen the flyout — see the ellipsis
119
+ * on `__heading`.
120
+ *
121
+ * CAPPED AT 60vh, THE SAME RUNG AS THE POPOVER BODY
122
+ *
123
+ * Figma draws seven rows and stops, because a Figma frame has no viewport to
124
+ * overflow. A real document index is as long as the document, and an
125
+ * uncapped centred panel grows off both edges of the screen at once — the
126
+ * rows a reader most wants are the ones that leave first. `60vh` is not a new
127
+ * number: `.ion-popover__body` already caps against the viewport at exactly
128
+ * that, and this is the same problem.
129
+ *
130
+ * `overscroll-behavior: contain` matters more here than in most flyouts. This
131
+ * component reports reading position, so letting a wheel gesture chain into
132
+ * the document once the list bottoms out would scroll the page — and move the
133
+ * very percentage the reader opened the panel to act on. Keyboard users are
134
+ * unaffected either way: the rows are real buttons in tab order, so Tab
135
+ * scrolls each one into view without touching the wheel.
104
136
  */
105
137
  .ion-scroll-progress__panel {
106
138
  position: absolute;
@@ -109,12 +141,16 @@
109
141
  z-index: 10;
110
142
  display: flex;
111
143
  flex-direction: column;
112
- gap: var(--spacing-12);
113
- min-width: 220px;
144
+ gap: var(--spacing-4);
145
+ width: 220px;
146
+ max-height: 60vh;
147
+ overflow-y: auto;
148
+ overscroll-behavior: contain;
114
149
  padding: var(--spacing-8);
115
150
  background-color: var(--surface-default);
116
151
  border: var(--border-width-default) solid var(--border-default);
117
152
  border-radius: var(--radius-md);
153
+ box-shadow: var(--ion-shadow-shadow-md);
118
154
  opacity: 0;
119
155
  visibility: hidden;
120
156
  pointer-events: none;
@@ -132,39 +168,60 @@
132
168
  transform: translateY(-50%) translateX(0);
133
169
  }
134
170
 
171
+ /*
172
+ * `display: block`, not `flex`: `text-overflow: ellipsis` does not apply to an
173
+ * anonymous flex item, so a flex row here would clip the label with no ellipsis
174
+ * at all. Padding plus the body-sm line height gives Figma's 32px row exactly.
175
+ *
176
+ * `flex-shrink: 0` is what makes the panel's `max-height` scroll instead of
177
+ * squash, and the reason is not obvious. A column flex item is normally
178
+ * protected from shrinking past its content by the automatic minimum size —
179
+ * but that protection is dropped for any item whose `overflow` is not
180
+ * `visible`, and the ellipsis above sets `overflow: hidden` on every row. So
181
+ * the rows would have compressed toward 0 to fit the cap, and the panel would
182
+ * never have overflowed at all.
183
+ */
135
184
  .ion-scroll-progress__heading {
136
- display: flex;
137
- align-items: center;
138
- padding: var(--spacing-2) var(--spacing-8);
185
+ display: block;
186
+ flex-shrink: 0;
187
+ width: 100%;
188
+ padding: var(--spacing-6) var(--spacing-8);
139
189
  background-color: transparent;
140
190
  border: var(--border-width-default) solid transparent;
141
191
  border-radius: var(--radius-md);
142
- color: var(--text-secondary);
192
+ color: var(--text-tertiary);
143
193
  font-family: var(--font-family-sans);
144
194
  font-size: var(--type-body-sm);
145
195
  line-height: var(--type-body-sm-line-height);
146
196
  font-weight: var(--font-weight-regular);
147
197
  text-align: left;
198
+ overflow: hidden;
199
+ text-overflow: ellipsis;
200
+ white-space: nowrap;
148
201
  cursor: pointer;
149
202
  transition:
150
203
  background-color var(--ion-duration-base) var(--ion-ease-out),
151
- border-color var(--ion-duration-base) var(--ion-ease-out);
204
+ border-color var(--ion-duration-base) var(--ion-ease-out),
205
+ color var(--ion-duration-base) var(--ion-ease-out);
152
206
  }
153
207
 
154
208
  @media (hover: hover) {
155
209
  .ion-scroll-progress__heading:hover {
156
210
  background-color: var(--surface-muted);
157
211
  border-color: var(--border-strong);
212
+ color: var(--text-default);
158
213
  }
159
214
  }
160
215
 
161
216
  .ion-scroll-progress__heading[data-hovered='true'] {
162
217
  background-color: var(--surface-muted);
163
218
  border-color: var(--border-strong);
219
+ color: var(--text-default);
164
220
  }
165
221
 
166
222
  .ion-scroll-progress__heading--selected {
167
223
  background-color: var(--surface-muted);
224
+ color: var(--text-secondary);
168
225
  }
169
226
 
170
227
  .ion-scroll-progress__heading:focus-visible {