@redseed/redseed-ui-tailwindcss 7.67.0 → 7.69.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.
@@ -30,16 +30,23 @@
30
30
 
31
31
  /* A linked category has to look like a link. A badge with no pointer and no
32
32
  hover reads as a label, which is what most categories are, so the ones that
33
- genuinely filter a list get the library's always-on underline (the LinkSlot
34
- convention) plus a hover state on the pill itself. Half-measures are the
35
- failure here: a pointer alone is not a visible affordance. */
33
+ genuinely filter a list get a pointer, a hover state on the pill, and the
34
+ underline on hover.
35
+
36
+ Underline on hover rather than at rest, which is where this started and was
37
+ wrong: it borrowed LinkSlot's always-on rule, but a pill is not a run of text
38
+ in a paragraph. Its own shape already separates it from what is around it, so
39
+ a resting underline reads as a mistake rather than as a link — the same
40
+ treatment a table cell link gets, where colour and weight carry it and the
41
+ underline is the hover affordance. */
36
42
  .rsui-category-list__link {
37
43
  @apply block rounded-full no-underline;
38
44
  @apply focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-primary outline-hidden;
39
45
  }
40
46
 
41
47
  .rsui-category-list__link .rsui-badge {
42
- @apply cursor-pointer underline underline-offset-2 transition-colors;
48
+ @apply cursor-pointer no-underline transition-colors;
49
+ @apply hover:underline underline-offset-2;
43
50
  }
44
51
 
45
52
  /* One grey step darker, not the usual `hover:bg-background-secondary`. That is the
@@ -56,8 +63,15 @@
56
63
 
57
64
  Sibling to the list rather than inside it, so it wraps onto its own line at
58
65
  phone widths — which is where the cap does most of its work. */
66
+ /* min-width, because the label changes length as it toggles: "+4 more" is 57px
67
+ and "Show fewer" is 80px, so without a floor the whole row shifted 23px
68
+ sideways under the pointer mid-press. Measured. The value is the wider of the
69
+ two states, so the button never grows and never shrinks; text-align keeps the
70
+ shorter label from drifting left inside it.
71
+
72
+ Same fix the page header's meta toggle already carries, for the same reason. */
59
73
  .rsui-category-list__more {
60
- @apply inline-flex w-fit items-center whitespace-nowrap;
74
+ @apply inline-flex w-fit min-w-20 justify-center items-center whitespace-nowrap;
61
75
  @apply text-sm font-semibold no-underline cursor-pointer transition-colors;
62
76
  @apply text-button-tertiary hover:text-button-tertiary-hover;
63
77
  @apply hover:underline underline-offset-4 decoration-2;
@@ -167,7 +167,8 @@
167
167
  on it — and the alternative is inventing a second readable ink per tone to
168
168
  preserve a nicety. tone-light keeps its semantic colour, so it keeps the split. */
169
169
  .rsui-metric-card--tone-green .rsui-metric-card__comparison-context,
170
- .rsui-metric-card--tone-teal .rsui-metric-card__comparison-context {
170
+ .rsui-metric-card--tone-teal .rsui-metric-card__comparison-context,
171
+ .rsui-metric-card--tone-bloom .rsui-metric-card__comparison-context {
171
172
  color: var(--rsui-metric-card-ink-muted);
172
173
  }
173
174
 
@@ -247,6 +248,43 @@
247
248
  --rsui-metric-card-rule: rgba(255, 255, 255, 0.18);
248
249
  }
249
250
 
251
+ /* For a MetricCard sitting on PageHeader's bloom band, and the one tone whose surface is
252
+ NOT opaque.
253
+
254
+ This is not the header's own stats strip — that is PageHeaderStat, a 150px tile with a
255
+ serif figure, and the two are different objects. This tone is for the case where a full
256
+ MetricCard, with its comparison and trend, is the content sitting on a bloom surface.
257
+
258
+ The band it sits on runs Journey Green at one end to Warm Mauve at the other, so an
259
+ opaque tile is the wrong colour at one end of its own row whichever step it takes.
260
+ Warm Mauve 900 was tried first and reads as a mauve panel dropped on the green end.
261
+ A white scrim instead lets the band through, so each tile picks up the part of the
262
+ sweep it is standing on and the row reads as one thing.
263
+
264
+ THE COST, MEASURED, AND WHAT IT DECIDES. A scrim can only lighten, so the tile is
265
+ always lighter than the band beneath it, and the band's lightest point is already the
266
+ tightest place on it. Compositing 9% white over that point — rgb(133, 107, 129):
267
+
268
+ white 4.77:1 PASS
269
+ Grey 200 3.97:1 fails
270
+ Grey 300 3.34:1 fails
271
+
272
+ So this is the one tone with NO muted ink: --rsui-metric-card-ink-muted is white too,
273
+ and the step between a tile's number and its label is carried by size and weight, which
274
+ is what the design does anyway. Nothing darker than white clears AA through a scrim on
275
+ this band, so a muted colour here would be a quiet failure rather than a choice.
276
+
277
+ 9% is the ceiling, not a taste value: 12% takes white itself to 4.46:1. A hoverable
278
+ tile is safe because Card's hover scrim is DARK — it moves the tile away from the
279
+ limit, not towards it. Raise this figure and white goes under; re-measure rather than
280
+ nudge it. */
281
+ .rsui-metric-card--tone-bloom {
282
+ --rsui-metric-card-surface: rgba(255, 255, 255, 0.09);
283
+ --rsui-metric-card-ink: var(--Colors-Base-white);
284
+ --rsui-metric-card-ink-muted: var(--Colors-Base-white);
285
+ --rsui-metric-card-rule: rgba(255, 255, 255, 0.18);
286
+ }
287
+
250
288
  /* The white-label case: same layout, inverted ink. */
251
289
  .rsui-metric-card--tone-light {
252
290
  --rsui-metric-card-surface: var(--Colors-Modern-Bone-200);
@@ -273,17 +311,23 @@
273
311
  color: var(--rsui-metric-card-ink-muted);
274
312
  }
275
313
 
276
- /* The semantic status inks are unreadable on the two DARK tiles, measured:
314
+ /* The semantic status inks are unreadable on the DARK tiles, measured:
315
+
316
+ ink tone-green tone-teal tone-bloom
317
+ #0e5942 #0f373c (scrim)
318
+ --color-text-success #067647 1.46:1 2.26:1 1.20:1
319
+ --color-text-error #b42318 1.26:1 1.96:1 1.38:1
277
320
 
278
- ink on tone-green #0e5942 on tone-teal #0f373c
279
- --color-text-success #067647 1.46:1 2.26:1
280
- --color-text-error #b42318 1.26:1 1.96:1
321
+ tone-bloom is measured on the lightest tile the scrim can produce,
322
+ rgb(133, 107, 129) — the band's lightest point seen through 9% white.
281
323
 
282
- Both invisible — the same failure this file already documents for text-brand
324
+ All invisible — the same failure this file already documents for text-brand
283
325
  on a toned tile. So the comparison gives up its colour there and takes the
284
326
  tile's muted ink, exactly as the active state gives up its brand number, and
285
327
  the arrow direction plus the consumer's word carry the meaning on their own.
286
- Journey Green 200 measures 5.92:1 and Deep Teal 300 6.36:1, both clearing AA.
328
+ Journey Green 200 measures 5.92:1 and Deep Teal 300 6.36:1, both clearing AA;
329
+ on tone-bloom the muted ink IS white, for the reason that tone documents, so
330
+ the comparison lands on white at 4.75:1 there.
287
331
 
288
332
  tone-light is deliberately NOT in this list. Its surface is Modern Bone 200
289
333
  (#f9f8f6), where success measures 5.36:1 and error 6.19:1 — both pass AA, so
@@ -291,21 +335,25 @@
291
335
 
292
336
  (0,2,0), so it outranks the (0,1,0) sentiment modifiers wherever they sit. */
293
337
  .rsui-metric-card--tone-green .rsui-metric-card__comparison,
294
- .rsui-metric-card--tone-teal .rsui-metric-card__comparison {
338
+ .rsui-metric-card--tone-teal .rsui-metric-card__comparison,
339
+ .rsui-metric-card--tone-bloom .rsui-metric-card__comparison {
295
340
  color: var(--rsui-metric-card-ink-muted);
296
341
  }
297
342
 
298
343
  /* The solid badge goes with it. Filling it with the error ink on a dark tile would
299
344
  reproduce exactly the 1.26:1 the rule above exists to avoid, so it takes the
300
345
  tile's muted ink and puts the arrow in the tile's own surface colour — the
301
- same 5.92:1 and 6.36:1 pairing, read the other way round.
346
+ same 5.92:1 / 6.36:1 pairing, read the other way round. On tone-bloom the
347
+ surface is a scrim rather than a colour, so the knocked-out arrow shows the
348
+ band through it, which is the same effect one step further out.
302
349
 
303
350
  The ringed badge needs no rule of its own: its border is `currentColor`, and the
304
351
  rule above has already moved the comparison's ink to the tile's muted value, so
305
352
  the ring follows it. This is the tone where solid-vs-ringed does its real work,
306
353
  because once the colour is stripped it is the only sentiment signal left. */
307
354
  .rsui-metric-card--tone-green .rsui-metric-card__comparison--negative .rsui-metric-card__comparison-icon,
308
- .rsui-metric-card--tone-teal .rsui-metric-card__comparison--negative .rsui-metric-card__comparison-icon {
355
+ .rsui-metric-card--tone-teal .rsui-metric-card__comparison--negative .rsui-metric-card__comparison-icon,
356
+ .rsui-metric-card--tone-bloom .rsui-metric-card__comparison--negative .rsui-metric-card__comparison-icon {
309
357
  background-color: var(--rsui-metric-card-ink-muted);
310
358
  color: var(--rsui-metric-card-surface);
311
359
  }
@@ -61,8 +61,29 @@
61
61
  @apply sticky top-0 z-1 p-0 bg-background-secondary shrink-0 text-lg font-semibold;
62
62
  }
63
63
 
64
+ /* ---------------------------------------------------------------------------
65
+ Padding, and why it is written as three shorthands rather than @apply p-*
66
+
67
+ Each region used to pay for all four of its own sides — `p-space-md` on all
68
+ three — so the 16px below the header met the 16px above the body: 32px
69
+ between a dialog's title and its first line, 32px again before the buttons,
70
+ against 16px at the edges. The seams ended up twice the margin, which is
71
+ what you get when neighbours both pay for the gap between them.
72
+
73
+ So the two INNER edges are zeroed and the body carries them. Each region
74
+ still behaves when one of the others is absent, which matters because all
75
+ three modifiers are independent props a consumer can turn off.
76
+
77
+ The values are Material's dialog: 24 at every edge, 16 from the headline to
78
+ the supporting text, 24 from the supporting text to the buttons.
79
+
80
+ Longhand rather than Tailwind's `p-*` utilities on purpose — these are
81
+ three-value shorthands with a different value per edge, and `@apply
82
+ pt-space-lg px-space-lg pb-0` is four utilities to say what one declaration
83
+ says, with the seam buried in the last one.
84
+ --------------------------------------------------------------------------- */
64
85
  .rsui-modal__header--padded {
65
- @apply p-space-md;
86
+ padding: 1.5rem 1.5rem 0;
66
87
  }
67
88
 
68
89
  .rsui-modal__body {
@@ -71,8 +92,11 @@
71
92
  scrollbar-width: none;
72
93
  }
73
94
 
95
+ /* Carries both seams — 16px up to the header, 24px down to the footer — as well
96
+ as its own side edges. With no footer the 24px is simply the bottom of the
97
+ dialog, which is the same number, so nothing special is needed for that case. */
74
98
  .rsui-modal__body--padded {
75
- @apply p-space-md;
99
+ padding: 1rem 1.5rem 1.5rem;
76
100
  }
77
101
 
78
102
  .rsui-modal__body::-webkit-scrollbar {
@@ -85,9 +109,31 @@
85
109
  padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
86
110
  }
87
111
 
112
+ /* The footer pays for ALL FOUR of its own sides, unlike the header above.
113
+
114
+ That asymmetry is deliberate and it is about the banding. The header and body
115
+ share no visible boundary in the places it matters, so zeroing the header's
116
+ bottom edge and letting the body carry it reads as one gap. The footer does
117
+ not: it has its own background (background-secondary against the body's
118
+ primary), so the boundary is a visible edge and the buttons sit INSIDE a band
119
+ of their own. Borrowing the space above them from the body left them jammed
120
+ against the top of that band with 24px below — measured at 0 above, 24 below.
121
+
122
+ So the seam here is the body's 24px bottom plus the footer's 24px top. That is
123
+ more than the 24px between the title and the body, which is the right way
124
+ round: a button row is a different kind of thing from a paragraph, and the
125
+ band is already announcing that.
126
+
127
+ If the banding ever goes (the other half of #373), this should drop back to
128
+ `0 1.5rem 1.5rem` and let the body carry the seam, the way the header does.
129
+
130
+ The bottom keeps its safe-area floor — a footer is the last thing above the
131
+ home indicator on a phone, so the value is a minimum rather than a fixed
132
+ amount, and it rises with the padding (1rem -> 1.5rem) or a footer would sit
133
+ closer to the bottom edge than to its own buttons. */
88
134
  .rsui-modal__footer--padded {
89
- @apply p-space-md;
90
- padding-bottom: max(1rem, env(safe-area-inset-bottom));
135
+ padding: 1.5rem;
136
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
91
137
  }
92
138
 
93
139
  .rsui-modal__footer--start {
@@ -62,6 +62,27 @@
62
62
  @apply lg:text-[2.5rem] lg:leading-[1.15] lg:tracking-[-0.01em] lg:font-black;
63
63
  }
64
64
 
65
+ /* An eyebrow above the h1, matching card_header.css's: xs, semibold, uppercase, wide
66
+ tracking, in the secondary ink so it labels the title rather than competing with it.
67
+ The two are deliberately the same recipe — an eyebrow means the same thing on a page
68
+ as it does on a card, and a second type style for it would be a second answer to one
69
+ question.
70
+
71
+ 8px down to the title, the brand's smallest step, carried as a margin on this element
72
+ rather than as a gap on the parent so an absent eyebrow contributes nothing. Same
73
+ reason card_header.css gives. */
74
+ .rsui-page-header__eyebrow {
75
+ @apply flex items-center gap-x-space-xs mb-space-xs;
76
+ @apply text-xs font-semibold uppercase tracking-wider text-text-secondary;
77
+ }
78
+
79
+ /* A badge brings its own case, size and tracking, so it must not inherit the uppercase
80
+ treatment meant for plain text. Badge sets no text-transform of its own, so without
81
+ this the uppercase leaks in. */
82
+ .rsui-page-header__eyebrow .rsui-badge {
83
+ @apply normal-case tracking-normal;
84
+ }
85
+
65
86
  .rsui-page-header__title-text-container {
66
87
  @apply flex flex-row gap-x-space-lg items-center;
67
88
  }
@@ -75,6 +96,110 @@
75
96
  @apply text-base leading-normal text-text-secondary;
76
97
  }
77
98
 
99
+ /* ── The stats row ────────────────────────────────────────────────────────────
100
+ Ported from the /home/v2 twin rather than re-derived, because that band is the
101
+ thing this is meant to reproduce and it had already been worked out at every
102
+ width. Every number below is its number.
103
+
104
+ A wrapping row rather than a scrolling one: these are a handful of counts, and
105
+ a count you cannot see is not a summary.
106
+
107
+ Full width below lg, where __top is a column and the tiles sit under the title;
108
+ auto from lg up, where __top is a row and they sit beside it.
109
+ --------------------------------------------------------------------------- */
110
+ .rsui-page-header__stats {
111
+ /* stretch, not start: a two-line label must not leave its tile shorter than
112
+ the one beside it. */
113
+ @apply w-full flex items-stretch gap-[10px];
114
+ @apply lg:w-auto lg:shrink-0;
115
+ }
116
+
117
+ /* The band carries four things in one row at desktop width and needs two rows
118
+ before that. The twin wrapped at 1280 rather than at 1024 because the three
119
+ counts plus a trailing action need about 600px, which squeezes the title hard
120
+ long before the layout actually breaks. */
121
+ .rsui-page-header__top:has(.rsui-page-header__stats) {
122
+ @apply flex-wrap gap-[18px] lg:gap-[20px] xl:gap-[40px];
123
+ }
124
+
125
+ /* The title takes the rest of the row and the tiles keep their size, rather than
126
+ the two sharing it. min-width: 0 so a long title wraps instead of forcing the
127
+ tiles out of the row. */
128
+ .rsui-page-header__top:has(.rsui-page-header__stats) .rsui-page-header__title {
129
+ @apply lg:grow lg:min-w-0 lg:basis-full xl:basis-auto;
130
+ }
131
+
132
+ /* One tile. 150px is the twin's width, at which its three labels sit on one line.
133
+
134
+ The leading edge is reserved whether or not it is painted, so a tile carrying a
135
+ rail and one without still line up — the mistake worth avoiding here is adding
136
+ the border only on the accented tile and knocking that tile 3px out of the row. */
137
+ .rsui-page-header__stat {
138
+ @apply flex flex-col items-start gap-px;
139
+ @apply w-full px-[16px] py-[12px] text-left;
140
+ @apply border-0 border-s-[3px] border-solid border-s-transparent rounded-[10px];
141
+ @apply lg:w-[150px];
142
+
143
+ background: var(--rsui-page-header-tile, var(--color-background-secondary));
144
+ border-inline-start-color: var(--rsui-page-header-stat-accent, transparent);
145
+ font: inherit;
146
+ }
147
+
148
+ /* Below lg the row is full width, so the tiles share it rather than sitting at a
149
+ fixed size with the rest of the row empty beside them. */
150
+ .rsui-page-header__stats > .rsui-page-header__stat {
151
+ @apply flex-1 lg:flex-none;
152
+ }
153
+
154
+ /* Only a tile that DOES something gets the pointer and the hover. A stat that
155
+ only reports a number is not a control, and PageHeaderStat renders it as a div
156
+ precisely so it does not pretend to be one. */
157
+ .rsui-page-header__stat--interactive {
158
+ @apply cursor-pointer no-underline transition-colors duration-150;
159
+ }
160
+
161
+ /* Hover DARKENS on the dark tones rather than lightening, which is the opposite of the
162
+ twin and deliberate. Lifting a scrim from 9% to 16% takes white itself to 4.10:1 on the
163
+ band's lightest point — the hover state would be the one that fails. Darkening moves
164
+ every ink on the tile away from its limit instead, and still reads as a state change.
165
+ tone-light darkens too, through its own solid step. */
166
+ .rsui-page-header__stat--interactive:hover {
167
+ background: var(--rsui-page-header-tile-hover, var(--color-background-secondary-contrast));
168
+ }
169
+
170
+ /* The figure is the one place the serif is right at this size: it is a number
171
+ being read as a headline, which is the range the brand keeps Newsreader for.
172
+ tabular-nums so a row of counts does not shift as the numbers change. */
173
+ .rsui-page-header__stat-value {
174
+ @apply font-redseed-heading text-[2.875rem] font-semibold leading-none tabular-nums;
175
+
176
+ color: var(--rsui-page-header-ink, var(--color-text-primary));
177
+ }
178
+
179
+ /* The label takes the tone's INK rather than its muted ink, and the step down to it is
180
+ carried by size and weight — 46px semibold against 12px medium is already a long way.
181
+
182
+ That is a contrast result, not a preference. A tile is a scrim, so it is always lighter
183
+ than the band under it, and a muted ink measured against the BAND does not survive the
184
+ tile. Composited over each tone's own worst ground:
185
+
186
+ tone tile ground its muted ink white
187
+ green rgb(32, 92, 74) JourneyGreen300 4.18 7.79
188
+ teal rgb(34, 67, 71) DeepTeal300 5.32 10.75
189
+ bloom rgb(133, 107, 129) Grey 200 3.97 4.77
190
+
191
+ Two of the three fail, and bloom's is the band's lightest point — reachable, because a
192
+ header with no actions lets the title grow and pushes the tiles to the right edge, which
193
+ is exactly where that point is. In situ at 1440 with an action present the same tile
194
+ measures 6.83:1, but a library component does not get to assume the layout it is given.
195
+
196
+ tone-light resolves this to Grey 900 through the same rule, at 16.08:1 on its tile. */
197
+ .rsui-page-header__stat-label {
198
+ @apply text-xs font-medium leading-[1.3];
199
+
200
+ color: var(--rsui-page-header-ink, var(--color-text-primary));
201
+ }
202
+
78
203
  .rsui-page-header__status-actions {
79
204
  @apply flex-1 flex gap-x-space-xs items-center justify-between lg:pr-space-xs;
80
205
  }
@@ -83,26 +208,107 @@
83
208
  @apply flex-1 flex flex-wrap gap-space-xs items-center justify-end;
84
209
  }
85
210
 
86
- .rsui-page-header__meta-action {
87
- @apply flex lg:hidden;
88
- }
89
-
90
211
  .rsui-page-header__search {
91
212
  @apply w-full pt-space-xs lg:pt-0 lg:w-auto;
92
213
  }
93
214
 
94
- .rsui-page-header__meta-modal {
95
- @apply lg:hidden;
215
+ /* ---------------------------------------------------------------------------
216
+ Meta
217
+
218
+ Visible at every width. It was `hidden lg:flex`, so the facts a header exists
219
+ to carry vanished below 1024px and a modal reproduced them — on a phone, where
220
+ a reader has least context to spare and least patience for a dialog.
221
+
222
+ A grid, not a flex row, and content-width rather than full-bleed: meta reads
223
+ as one cluster beside the title rather than a band across the page. Four
224
+ columns from sm up; two on a phone, because four 16px labels in 375px is not a
225
+ grid, it is a queue.
226
+
227
+ The cap is `:nth-child` rather than a count in JS. `#meta` is a slot and every
228
+ consumer fills it with a `v-for`, so the component cannot know how many facts
229
+ there are — but CSS does not need to know. Past the fourth, they are hidden
230
+ until the toggle expands the row.
231
+ --------------------------------------------------------------------------- */
232
+ /* The region is the grid. The facts sit inside a `display: contents` wrapper so
233
+ they become direct grid items without the toggle joining their `:nth-child`
234
+ sequence — see the note in PageHeader.vue. */
235
+ .rsui-page-header__meta-region {
236
+ @apply grid w-fit max-w-full items-start;
237
+ @apply gap-x-space-lg gap-y-space-md;
238
+
239
+ /* Columns sized to content, and the SAME count in both states.
240
+
241
+ An earlier version went five columns collapsed and four expanded, so the
242
+ toggle would sit in the row rather than wrap. That moved every fact
243
+ sideways when pressed — 13.7px with `1fr`, up to 40px with `max-content`,
244
+ because both make a column's width depend on something the toggle changes.
245
+ Holding the count fixed removes the question: the toggle occupies the
246
+ fifth cell while collapsed, and the fifth fact takes it once expanded.
247
+
248
+ The width has to be FIXED, not `max-content`. Holding the count steady is
249
+ not enough on its own: `max-content` sizes each column to whatever lands in
250
+ it, and expanding puts different facts in different columns, so they still
251
+ moved — 27px, measured. A fixed track depends on neither the count nor the
252
+ content.
253
+
254
+ 6rem, against a widest-ink measurement of 90px across every label and value
255
+ in the busiest real header. Anything longer wraps inside its own cell
256
+ rather than pushing its neighbours. */
257
+ grid-template-columns: repeat(2, 6rem);
96
258
  }
97
259
 
98
- .rsui-page-header__meta-modal__body {
99
- @apply flex flex-col gap-y-space-md;
260
+ @media (min-width: 40rem) {
261
+ .rsui-page-header__meta-region {
262
+ grid-template-columns: repeat(5, 6rem);
263
+ }
100
264
  }
101
265
 
102
266
  .rsui-page-header__meta {
103
- @apply hidden lg:flex gap-x-space-xl;
267
+ display: contents;
268
+ }
269
+
270
+ /* Past the cap, hidden until expanded. `> *` rather than a component class so it
271
+ does not care what a consumer puts in the slot — MetaInfo today, something
272
+ else tomorrow. The toggle is excluded by its own rule below, which comes
273
+ later and wins. */
274
+ .rsui-page-header__meta > *:nth-child(n + 5) {
275
+ @apply hidden;
276
+ }
277
+
278
+ .rsui-page-header__meta--expanded > *:nth-child(n + 5) {
279
+ @apply block;
104
280
  }
105
281
 
282
+ /* The toggle sits where the fifth fact would be, so pressing it expands the row
283
+ around it rather than moving it. Always shown, whatever the cap is doing. */
284
+ .rsui-page-header__meta-toggle {
285
+ /* Centred against the fact beside it, not `self-start`. A fact is a two-line
286
+ unit — a 16px label over a 16px value, 36px in all — so a 20px toggle
287
+ pinned to the top reads as belonging to the LABELS rather than to the pair.
288
+ Centre also survives the expanded state, where the toggle wraps to its own
289
+ row and has no value to sit level with. */
290
+ @apply inline-flex w-fit items-center whitespace-nowrap self-center;
291
+ @apply text-sm font-semibold no-underline cursor-pointer transition-colors;
292
+ @apply text-button-tertiary hover:text-button-tertiary-hover;
293
+ @apply hover:underline underline-offset-4 decoration-2;
294
+ @apply focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-button-tertiary outline-hidden;
295
+
296
+ /* min-width, because the label changes length as it toggles — "+2 more"
297
+ against "Show fewer" — and without a floor the row shifts sideways under
298
+ the pointer mid-press. Same fix CategoryList carries (#355). */
299
+ @apply min-w-20 justify-center;
300
+
301
+ /* The negative margin pulls it back from the grid's 32px column gap to the
302
+ 16px CategoryList uses. 32px is right between two FACTS, which read as
303
+ separate label/value pairs; it is too far for an affordance trailing them,
304
+ and the two rows sit one above the other in the same header — `+4 more`
305
+ under categories and `+3 more` under meta must not be spaced differently. */
306
+ @apply -ms-space-md;
307
+ }
308
+
309
+
310
+
311
+
106
312
  .rsui-page-header__avatar {
107
313
  @apply shrink-0 size-12;
108
314
  }
@@ -151,11 +357,36 @@
151
357
  @apply p-space-lg;
152
358
  }
153
359
 
360
+ /* The band reaches the viewport edges; its text does not. calc(50% - 50vw) pulls each
361
+ side out to the edge and the matching padding puts the content back on the container's
362
+ inner edge, so the greeting lines up with the logo and nav above it.
363
+
364
+ Measured in JS first, which is the wrong answer: a measured value goes stale whenever
365
+ the element resizes without the trigger you chose firing, and the band silently insets
366
+ itself again. CSS cannot go stale.
367
+
368
+ The vertical padding is the toned header's own and is not touched here, so this is
369
+ purely a horizontal escape. See the prop for the `overflow-x: clip` the document has to
370
+ carry. */
371
+ .rsui-page-header--bleed {
372
+ /* width: auto is load-bearing. The base rule sets w-full, and a box with an explicit
373
+ width does not grow when its margins go negative — it just shifts. With w-full held
374
+ the band moved left to the viewport edge and stopped short on the right by exactly
375
+ the amount it had moved, which looks like a one-sided bug and is not one. */
376
+ @apply w-auto;
377
+
378
+ margin-inline: calc(50% - 50vw);
379
+ padding-inline: calc(50vw - 50%);
380
+ }
381
+
154
382
  .rsui-page-header--tone-green {
155
383
  --rsui-page-header-bg: var(--Colors-Journey-Green-800);
156
384
  --rsui-page-header-ink: var(--Colors-Base-white);
157
385
  --rsui-page-header-ink-muted: var(--Colors-Journey-Green-300);
158
386
  --rsui-page-header-rule: rgba(255, 255, 255, 0.18);
387
+ --rsui-page-header-accent: var(--Colors-Warm-Yellow-400);
388
+ --rsui-page-header-tile: rgba(255, 255, 255, 0.09);
389
+ --rsui-page-header-tile-hover: rgba(0, 0, 0, 0.12);
159
390
  }
160
391
 
161
392
  .rsui-page-header--tone-teal {
@@ -163,6 +394,60 @@
163
394
  --rsui-page-header-ink: var(--Colors-Base-white);
164
395
  --rsui-page-header-ink-muted: var(--Colors-Deep-Teal-300);
165
396
  --rsui-page-header-rule: rgba(255, 255, 255, 0.18);
397
+ --rsui-page-header-accent: var(--Colors-Warm-Yellow-400);
398
+ --rsui-page-header-tile: rgba(255, 255, 255, 0.09);
399
+ --rsui-page-header-tile-hover: rgba(0, 0, 0, 0.12);
400
+ }
401
+
402
+ /* The brand's hero treatment: three soft radial washes over a green-to-mauve
403
+ sweep, so the band warms across its width instead of running dark green end to
404
+ end. Every colour is a primary identity ramp — Journey Green and Deep Teal
405
+ carry the base, Warm Yellow / Soft Lilac / Warm Mauve are the washes. No
406
+ secondary accents.
407
+
408
+ A TONE rather than a boolean layered over green or teal. The alphas below are
409
+ a contrast budget measured against THIS base sweep; put the same washes over a
410
+ different background and the measurement is void, so there is nothing to
411
+ compose with.
412
+
413
+ THE ALPHAS ARE MEASURED, NOT CHOSEN. All four layers were composited on a
414
+ 200x200 grid across eight band sizes from 1920x150 down to 375x320, and the
415
+ lightest point anywhere in the band taken as the worst case — the right test
416
+ for a library component, where a consumer may put text anywhere along the
417
+ band, rather than the worst point under one particular text box.
418
+
419
+ The lightest point is the Soft Lilac centre at 96% 30%, where the washes
420
+ combine to alpha 0.320 and the surface resolves to rgb(121, 92, 117).
421
+ Against it:
422
+
423
+ Base white 5.82:1 PASS
424
+ Grey 200 4.84:1 PASS <- the muted ink below
425
+ Deep Teal 200 4.00:1 large text only
426
+ Journey Green 300 3.12:1 large text only
427
+
428
+ Which is why the muted ink is NOT Journey Green 300, the muted step the green
429
+ tone uses: it fails AA at the lightest point of this band. Move a centre or
430
+ raise an alpha and this has to be measured again; do not eyeball it. */
431
+ .rsui-page-header--tone-bloom {
432
+ --rsui-page-header-bg:
433
+ /* Warm Yellow 500 — above the band, left of centre where the base is greenest */
434
+ radial-gradient(50% 130% at 44% -20%, rgba(238, 220, 91, 0.34) 0%, rgba(238, 220, 91, 0) 62%),
435
+ /* Soft Lilac 500 — the right edge, the pink */
436
+ radial-gradient(55% 130% at 96% 30%, rgba(233, 184, 222, 0.32) 0%, rgba(233, 184, 222, 0) 62%),
437
+ /* Warm Mauve 500 — below the band, deepening the purple end */
438
+ radial-gradient(70% 125% at 88% 112%, rgba(185, 125, 168, 0.38) 0%, rgba(185, 125, 168, 0) 65%),
439
+ /* base sweep: Journey Green 800 -> Deep Teal 700 -> Warm Mauve 950 */
440
+ linear-gradient(115deg, var(--Colors-Journey-Green-800) 0%, var(--Colors-Deep-Teal-700) 42%, var(--Colors-Warm-Mauve-950) 100%);
441
+ --rsui-page-header-ink: var(--Colors-Base-white);
442
+ --rsui-page-header-ink-muted: var(--Colors-Grey-200);
443
+ --rsui-page-header-rule: rgba(255, 255, 255, 0.18);
444
+ /* Warm Yellow 400, the twin's accent: 5.37:1 on this band. */
445
+ --rsui-page-header-accent: var(--Colors-Warm-Yellow-400);
446
+ /* A scrim rather than a colour, so each tile picks up the part of the sweep
447
+ it is standing on and a row of them reads as one thing rather than as
448
+ panels dropped on a band. */
449
+ --rsui-page-header-tile: rgba(255, 255, 255, 0.09);
450
+ --rsui-page-header-tile-hover: rgba(0, 0, 0, 0.12);
166
451
  }
167
452
 
168
453
  /* The white-label case. Same layout, inverted ink — a client's own background
@@ -172,6 +457,10 @@
172
457
  --rsui-page-header-ink: var(--Colors-Grey-900);
173
458
  --rsui-page-header-ink-muted: var(--Colors-Grey-600);
174
459
  --rsui-page-header-rule: var(--Colors-Grey-300);
460
+ /* A scrim is invisible on a light band, so this tone names solid steps. */
461
+ --rsui-page-header-accent: var(--Colors-Journey-Green-700);
462
+ --rsui-page-header-tile: var(--Colors-Modern-Bone-200);
463
+ --rsui-page-header-tile-hover: var(--Colors-Grey-200);
175
464
  }
176
465
 
177
466
  /* Ink application is tone-agnostic: only the variable values differ, so the
@@ -184,7 +473,57 @@
184
473
  color: var(--rsui-page-header-ink-muted);
185
474
  }
186
475
 
187
- .rsui-page-header--toned .rsui-page-header__meta {
476
+ /* The eyebrow takes the muted ink for the same reason the subtitle does: it is
477
+ the quieter of the two lines around the title, and text-text-secondary is a
478
+ dark grey that disappears on every tone but light. */
479
+ .rsui-page-header--toned .rsui-page-header__eyebrow {
480
+ color: var(--rsui-page-header-ink-muted);
481
+ }
482
+
483
+ /* ── The band's type ──────────────────────────────────────────────────────────
484
+ Ported from the /home/v2 twin, which is the design this tone reproduces.
485
+
486
+ A DELIBERATE DEPARTURE from the h1 rule at the top of this file, and worth a
487
+ reviewer's eye rather than a quiet landing. That rule drops Newsreader for the
488
+ sans steps, citing the book's restriction of Newsreader to Header 1 and its
489
+ own sizes — and it is right for a page title. This is not a page title; it is
490
+ a hero band, the same surface the marketing site sets in Newsreader, and it
491
+ carries a greeting rather than the name of a record. The departure is scoped
492
+ to this one tone, so every other header keeps the sans title.
493
+
494
+ 27px below lg, 36px from lg up — the twin's two sizes.
495
+ --------------------------------------------------------------------------- */
496
+ .rsui-page-header--tone-bloom .rsui-page-header__title h1 {
497
+ @apply font-redseed-heading text-[1.6875rem] font-semibold;
498
+ @apply leading-[1.1] tracking-[-0.01em];
499
+ @apply lg:text-[2.25rem] lg:leading-[1.1] lg:font-semibold;
500
+ }
501
+
502
+ /* The eyebrow sits tight to the greeting — 3px, not the 8px a card eyebrow takes
503
+ — because the two read as one block of address here rather than as a label and
504
+ the thing it labels. */
505
+ .rsui-page-header--tone-bloom .rsui-page-header__eyebrow {
506
+ @apply mb-[3px] tracking-[0.06em];
507
+ }
508
+
509
+ /* And the line under the title sits away from it, at 7px and one step down in
510
+ size: it is a second sentence, not a subtitle wrapping the first. */
511
+ .rsui-page-header--tone-bloom .rsui-page-header__subtitle {
512
+ @apply mt-[7px] text-sm;
513
+ }
514
+
515
+ /* The one word in that line that is the point of it. Available on every tone,
516
+ because a band with a highlight in it is not specific to this one. */
517
+ .rsui-page-header--toned .rsui-page-header__subtitle strong {
518
+ @apply font-semibold;
519
+
520
+ color: var(--rsui-page-header-accent, var(--rsui-page-header-ink));
521
+ }
522
+
523
+ /* On the REGION, not on `__meta`. The facts element is `display: contents` so it
524
+ generates no box at all — a border declared there paints nothing, and this
525
+ separator silently disappeared when the grid moved up a level. Measured. */
526
+ .rsui-page-header--toned .rsui-page-header__meta-region {
188
527
  border-top: 1px solid var(--rsui-page-header-rule);
189
528
 
190
529
  @apply mt-space-md pt-space-md;
@@ -229,3 +568,19 @@
229
568
  .rsui-page-header--toned .rsui-category-list__more {
230
569
  color: var(--rsui-page-header-ink);
231
570
  }
571
+
572
+ /* The meta toggle borrows the same tertiary green and disappears on the same dark
573
+ band — measured as near-invisible on Journey Green 800. Same fix, same reason:
574
+ an invisible control is worse than reaching for the tone's ink.
575
+
576
+ Hover included, because the hover colour is a second tertiary green and would
577
+ make the control vanish again the moment a pointer reached it. */
578
+ .rsui-page-header--toned .rsui-page-header__meta-toggle {
579
+ color: var(--rsui-page-header-ink);
580
+ }
581
+
582
+ /* Hover separately: the hover colour is a second tertiary green, so without this
583
+ the control vanishes again the moment a pointer reaches it. */
584
+ .rsui-page-header--toned .rsui-page-header__meta-toggle:hover {
585
+ color: var(--rsui-page-header-ink);
586
+ }
@@ -22,8 +22,19 @@
22
22
  @apply border-border-primary;
23
23
  }
24
24
 
25
+ /* A clickable header is a control, so it takes the library's focus ring rather
26
+ than the browser's. Without `outline-hidden` a `tabindex="0"` div falls through
27
+ to the UA default ring, which is the wrong colour and shape for this library.
28
+
29
+ Keyboard only, verified with a real click rather than a dispatched event: the
30
+ browser knows a genuine mouse press from a synthetic one, and `:focus-visible`
31
+ is false after the real thing. (A dispatched MouseEvent reports true even on a
32
+ plain <button>, so a probe built that way proves nothing.)
33
+
34
+ Same declaration CardHeader already carries for the same situation. */
25
35
  .rsui-section-header--clickable {
26
36
  @apply cursor-pointer;
37
+ @apply focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-primary outline-hidden;
27
38
  }
28
39
 
29
40
  .rsui-section-header__header {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redseed/redseed-ui-tailwindcss",
3
- "version": "7.67.0",
3
+ "version": "7.69.0",
4
4
  "description": "RedSeed UI Tailwindcss",
5
5
  "main": "index.js",
6
6
  "style": "index.css",
package/tokens.css CHANGED
@@ -11,7 +11,14 @@
11
11
  --color-text-success: #067647;
12
12
  --color-text-warning: #b54708;
13
13
  --color-text-error: #b42318;
14
- --color-text-info: #0077cc;
14
+ /* #0077cc measured 4.46:1 on its own fill (--color-background-info, #f6fbfd),
15
+ just under the 4.5 AA threshold for body text. #0070c0 is the smallest step
16
+ that clears it — 4.94:1 — so the info blue moves as little as it can while
17
+ being legible. Every other ground it sits on improves too: 4.66 -> 5.15 on
18
+ white.
19
+ --color-background-info-contrast keeps #0077cc deliberately: it is a
20
+ BACKGROUND, so darkening it would change surfaces rather than fix text. */
21
+ --color-text-info: #0070c0;
15
22
  /* Warm-Mauve-800, not -600: the AI treatments set this over the pale AI gradient
16
23
  (#ebe0fa -> #fff6eb), where -600 measured 2.93:1 and failed WCAG AA for 14px text.
17
24
  -800 clears it at 4.52:1 at the gradient's worst point. Consumed by Badge AI,