@humanforest/nuxt-layer 0.3.1 → 0.3.3

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@humanforest/nuxt-layer",
3
3
  "type": "module",
4
- "version": "0.3.1",
4
+ "version": "0.3.3",
5
5
  "description": "Forest design system as a Nuxt layer — extend it to inherit the theme, colour roles, icons and brand fonts in one line.",
6
6
  "main": "./nuxt.config.ts",
7
7
  "files": [
@@ -50,6 +50,12 @@ const props = withDefaults(
50
50
  area?: boolean;
51
51
  /** Marks the last point, so the series has an end rather than running off the edge. */
52
52
  endpoint?: boolean;
53
+ /**
54
+ * Takes the height its container gives it, with `height` as the floor. For a graphic in a space
55
+ * its surroundings have made taller (a KPI bleed row that a stretched card or a group has given
56
+ * more height), where the chart is the part that should grow. Works for both marks.
57
+ */
58
+ fill?: boolean;
53
59
  /**
54
60
  * Monotone interpolation, on by default. Monotone rather than a plain spline: it stays inside
55
61
  * the data's own range, so no peak appears between two points that neither of them reached.
@@ -71,7 +77,7 @@ const props = withDefaults(
71
77
  */
72
78
  mark?: 'line' | 'bar';
73
79
  }>(),
74
- { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
80
+ { height: 32, color: 'var(--ui-primary)', area: false, endpoint: false, fill: false, curve: true, mark: 'line', compare: undefined, compareColor: undefined },
75
81
  );
76
82
 
77
83
  if (import.meta.env.DEV) {
@@ -155,14 +161,14 @@ const endpointTop = computed(() => {
155
161
  </script>
156
162
 
157
163
  <template>
158
- <div class="relative w-full" :style="{ height: `${height}px`, color }">
164
+ <div class="relative w-full" :class="fill ? 'h-full' : ''" :style="fill ? { minHeight: `${height}px`, color } : { height: `${height}px`, color }">
159
165
  <!-- Plain DOM rather than a mark: there are no axes, scales or tooltip to share with the XY
160
166
  container, so a flex row of columns is the whole implementation and costs no chart runtime.
161
167
  The 2px gap is fixed, so the bucket count sets how thin the bars get: n buckets across w
162
168
  pixels leaves (w - 2(n - 1)) / n each. A tile 280px wide holds 12 buckets at 21px and 60 at
163
169
  2.7px — still bars; past roughly 60 the gap outweighs the bar and the row reads as a
164
170
  texture, where the measure belongs to a line instead. -->
165
- <div v-if="mark === 'bar'" class="flex h-full w-full items-end gap-0.5" aria-hidden="true">
171
+ <div v-if="mark === 'bar'" class="flex w-full items-end gap-0.5" :class="fill ? 'absolute inset-0' : 'h-full'" aria-hidden="true">
166
172
  <span
167
173
  v-for="(v, i) in data"
168
174
  :key="i"
@@ -180,12 +186,17 @@ const endpointTop = computed(() => {
180
186
  <!-- No bottom margin: the graphic sits on the tile's own bottom edge, and 2px of clearance
181
187
  there reads as a misaligned card rather than as breathing room. The top keeps its 2px so a
182
188
  peak is not clipped by the stroke's own width, and the right reserves room for the endpoint
183
- dot when there is one. -->
189
+ dot when there is one. Under `fill` the container is laid over the root rather than sizing
190
+ it: the root takes the space it is given, floored at `height`, and Unovis measures that box,
191
+ never its own SVG (which would feed back) and never its 300px fallback for an empty box.
192
+ An inline style, not a utility: Unovis's own stylesheet pins `.unovis-xy-container` to
193
+ `position: relative`, and a class loses to it. -->
184
194
  <VisXYContainer
185
195
  v-else
186
196
  :key="themeVersion"
197
+ :style="fill ? { position: 'absolute', inset: '0' } : undefined"
187
198
  :data="points"
188
- :height="height"
199
+ :height="fill ? undefined : height"
189
200
  :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
190
201
  :y-domain="domain ?? undefined"
191
202
  :duration="motionDuration('slow')"
@@ -11,10 +11,10 @@
11
11
  // every card in the system. Same reasoning as FlushCard, which is the other end of this axis: it
12
12
  // zeroes the body padding so a table runs to the card's edge; this one shrinks it to a visible gap.
13
13
  //
14
- // ★ THE GAP IS ONE VALUE DOING THREE JOBS. Setting --forest-card-pad drives (a) the body padding,
15
- // so the box is inset by it, (b) the concentric radius, because forest.css derives a nested card's
16
- // corner as `outer − pad`, and (c) FKpi's bleed margin, which pulls by the same var. Set it once
17
- // here and all three stay in step; set the padding with a class instead and they silently diverge.
14
+ // ★ THE GAP IS ONE VALUE DOING TWO JOBS. Setting --forest-card-pad drives (a) the body padding, so
15
+ // the box is inset by it, and (b) the concentric radius, because forest.css derives a nested card's
16
+ // corner as `outer − pad`. Set it once here and both stay in step; set the padding with a class
17
+ // instead and they silently diverge.
18
18
  //
19
19
  // ★ THE BODY DROPS ITS PADDING ON THE SIDE THE STRIP IS ON. The box is inset on three sides; on the
20
20
  // fourth it meets the strip directly, and the strip owns all the space there. Two consequences, both
@@ -36,11 +36,10 @@ import { computed, useSlots, watchEffect } from 'vue';
36
36
 
37
37
  type Gap = 4 | 8 | 12;
38
38
 
39
- // ★ ONE SOURCE OF TRUTH FOR THE GAP. The var is not a stylistic choice — two rules OUTSIDE this
40
- // component read it and no Tailwind class can reach either: forest.css derives the nested card's
41
- // concentric radius as `calc(--forest-card-radius - --forest-card-pad)`, and kpi.theme.ts pulls the
42
- // bleed by `-mx-[var(--forest-card-pad)]`. Measured: with the var set, box radii are 20/16/12 for
43
- // gaps 4/8/12; without it they all stay at 8.
39
+ // ★ ONE SOURCE OF TRUTH FOR THE GAP. The var is not a stylistic choice — a rule OUTSIDE this
40
+ // component reads it and no Tailwind class can reach it: forest.css derives the nested card's
41
+ // concentric radius as `calc(--forest-card-radius - --forest-card-pad)`. Measured: with the var set,
42
+ // box radii are 20/16/12 for gaps 4/8/12; without it they all stay at 8.
44
43
  // So the padding CLASS reads the same var rather than restating the number — otherwise the value
45
44
  // lives in two places and a future edit can move one and not the other. That also makes the body
46
45
  // classes gap-independent: only `pad` varies.
@@ -118,13 +117,20 @@ const props = withDefaults(
118
117
  * body rather than the card's.
119
118
  */
120
119
  flush?: boolean;
120
+ /**
121
+ * The card fills the height its row gives it instead of hugging its content, so cards in a row
122
+ * end level. Only for content that can take the extra height: an FKpi whose bleed chart is a
123
+ * `fill` sparkline grows into it. A bar, a table or a line of text cannot, and the spare height
124
+ * lands as a gap under it.
125
+ */
126
+ stretch?: boolean;
121
127
 
122
128
  /** Footer action label. `to` makes it a link; without one it is a button. */
123
129
  action?: string;
124
130
  to?: string;
125
131
 
126
132
  }>(),
127
- { gap: 8, outlined: true, bare: false, flush: false },
133
+ { gap: 8, outlined: true, bare: false, flush: false, stretch: false },
128
134
  );
129
135
 
130
136
  const slots = useSlots();
@@ -149,11 +155,14 @@ if (import.meta.env.DEV) {
149
155
 
150
156
 
151
157
 
152
- // Set as a var rather than a class so the radius and FKpi's bleed follow it.
158
+ // Set as a var rather than a class so the radius follows it.
153
159
  const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
154
160
 
161
+ // Under `stretch` the body takes whatever height the card is given beyond its strip.
155
162
  const ui = computed(() => ({
156
- body: hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none,
163
+ body: [hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none, props.stretch ? 'flex-1' : '']
164
+ .filter(Boolean)
165
+ .join(' '),
157
166
  header: STRIP.header,
158
167
  footer: STRIP.footer,
159
168
  }));
@@ -176,13 +185,15 @@ const ui = computed(() => ({
176
185
  // ★ `h-fit` IS LOAD-BEARING, not a layout preference. A grid or flex row stretches its children by
177
186
  // default, and a stretched card grows past its content — the extra height lands BELOW the box, so
178
187
  // the even inset turns into a lopsided gap at the bottom and the frame stops reading as a frame.
179
- // The card must hug its content vertically.
188
+ // The card must hug its content vertically. `stretch` drops it for a row whose cards should end
189
+ // level: the card becomes a flex column and its body takes the spare height, which only reads well
190
+ // when the content can grow into it.
180
191
  // ⚠ NOT `self-start`, which was the first cut: `align-self` acts on the CROSS axis, so in a flex
181
192
  // COLUMN it stops the card filling the width instead of capping its height — the card shrank to its
182
193
  // content in every column layout. `h-fit` caps the height and leaves width alone, which is the axis
183
194
  // actually at issue.
184
195
  const rootClass = computed(() =>
185
- ['h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
196
+ [props.stretch ? 'flex flex-col' : 'h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
186
197
  );
187
198
 
188
199
  // ★ THE BOX IS ALWAYS RAISED — there is no ramp option, on purpose. Letting the nesting ladder fill
@@ -198,23 +209,19 @@ const rootClass = computed(() =>
198
209
  // pads at ≥640px. The box keeps its own overflow-hidden either way, which is what clips a flush
199
210
  // child to the rounded corner; a child that must scroll (UTable) already scrolls itself.
200
211
  const boxUi = computed(() => ({
201
- root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}`,
212
+ root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}${props.stretch ? ' h-full' : ''}`,
202
213
  ...(props.flush ? { body: 'p-0 sm:p-0' } : {}),
203
214
  }));
204
215
 
205
216
  // ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
206
217
  // call site passes it straight to the child's `ui` without needing to know any of this:
207
- // · the child pads its ROOT (FKpi's root IS the padded grid), unlike UCard which pads its body
208
- // · pb is left OFF so FKpi's `visual-fit="bleed"` keeps its own `pb-0` and the graphic reaches the
209
- // bottom edge a blanket `p-4` here silently re-adds it
210
- // · the bleed margin is restated to match the reading padding actually in force. FKpi pulls by
211
- // `-mx-[var(--forest-card-pad)]`, which is the GAP not the 16px this box pads by so without
212
- // this the graphic stops short on both sides.
213
- // A workaround for FKpi's coupling to --forest-card-pad; the real fix belongs in kpi.theme.ts, at
214
- // which point the `visual` line here can go.
218
+ // · the card pad is the GAP, restated on the child for the corner the same reason as `boxUi`
219
+ // · the child's own padding goes through --forest-kpi-pad, because FKpi otherwise pads by the card
220
+ // pad, which is now the gap. The hook sets all four sides, and FKpi's bleed pulls by the same
221
+ // expression, so `visual-fit="bleed"` still zeroes the bottom and meets both edges. A padding
222
+ // class cannot do this: FKpi merges `ui.root` last, so a `pb-4` here would beat the bleed's `pb-0`.
215
223
  const selfBoxUi = computed(() => ({
216
- root: `bg-[var(--forest-card-inset)] px-4 pt-4 ${PAD_CLASS[props.gap]}`,
217
- visual: '-mx-4',
224
+ root: `bg-[var(--forest-card-inset)] [--forest-kpi-pad:1rem] ${PAD_CLASS[props.gap]}${props.stretch ? ' h-full' : ''}`,
218
225
  }));
219
226
  </script>
220
227
 
@@ -6,7 +6,7 @@
6
6
  // were both taken.
7
7
  //
8
8
  // The tile is a UCard, so it inherits the card surface and moves when cards move. Inside a group it
9
- // registers itself, spans four subgrid rows, and may become a tab or a toggle.
9
+ // registers itself, spans the group's subgrid rows, and may become a tab or a toggle.
10
10
  import { computed, inject, onBeforeUnmount, onMounted, useId, useSlots } from 'vue';
11
11
  import { kpiTheme, KPI_GROUP_KEY, type KpiSlot } from './kpi.theme';
12
12
  import { formatMetric, joinCaption, type KpiFormat } from './kpiFormat';
@@ -37,6 +37,14 @@ const props = withDefaults(
37
37
  /** Forces the figure's face. Unset lets the context's --fx-metric traits decide. */
38
38
  face?: 'tabular' | 'display';
39
39
 
40
+ /**
41
+ * Visually hides the label while keeping it the figure's accessible name — for a tile whose
42
+ * title is drawn outside it, such as FInsetCard's header strip. Use this rather than
43
+ * `ui.header: 'sr-only'`, which takes the row out of the grid — in a group, that pulls the
44
+ * figure out of line with its neighbours.
45
+ */
46
+ hideLabel?: boolean;
47
+
40
48
  /**
41
49
  * How the `visual` slot meets the card. `bleed` runs the graphic to the side and bottom edges —
42
50
  * right for a sparkline, which is data and nothing else. `inset` keeps the card's padding and
@@ -154,6 +162,7 @@ const styles = computed(() =>
154
162
  visualFit: slots.visual ? props.visualFit : undefined,
155
163
  intent: delta.value?.intent,
156
164
  face: props.face,
165
+ hideLabel: props.hideLabel,
157
166
  status: props.status,
158
167
  emphasis: props.emphasis,
159
168
  dimmed: isBlank.value,
@@ -244,9 +253,9 @@ const onKeydown = (e: KeyboardEvent) => {
244
253
  <span v-if="caveat" :id="caveatId">{{ captionLead ? ' · ' : '' }}{{ caveat }}</span>
245
254
  </p>
246
255
 
247
- <div v-if="$slots.visual" :class="cls('visual')"><slot name="visual" /></div>
256
+ <div v-if="$slots.visual" :class="cls('visual')" data-kpi-visual><slot name="visual" /></div>
248
257
  <slot name="insight" />
249
- <div v-if="$slots.links || $slots.footer" :class="cls('footer')">
258
+ <div v-if="$slots.links || $slots.footer" :class="cls('footer')" data-kpi-footer>
250
259
  <slot name="links" />
251
260
  <slot name="footer" />
252
261
  </div>
@@ -1,15 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  // A row of KPI tiles.
3
3
  //
4
- // The row is a four-row grid and each tile spans all four with grid-template-rows: subgrid, so
5
- // labels, values, captions and visuals align across the row however long any one label runs.
6
- // Without it, a two-line label pushes its own value down and nothing lines up.
4
+ // Each tile spans the group's rows with grid-template-rows: subgrid, so labels, values, captions and
5
+ // visuals align across the row however long any one label runs. Without it, a two-line label pushes
6
+ // its own value down and nothing lines up.
7
7
  //
8
8
  // Selection: single mode is a tablist with manual activation, pointing at the chart it controls;
9
9
  // multiple mode is a set of aria-pressed toggles, because more than one selected tile is not a tab
10
10
  // row. One live region for the whole row — a per-tile region turns a refresh into a chorus.
11
11
  import { computed, provide, reactive, ref, watch } from 'vue';
12
- import { KPI_GROUP_KEY, type KpiGroupContext } from './kpi.theme';
12
+ import { KPI_GROUP_KEY, kpiGroupRows, type KpiGroupContext } from './kpi.theme';
13
13
  import type { KpiFormat } from './kpiFormat';
14
14
  import {
15
15
  createKpiRegistry,
@@ -124,15 +124,17 @@ const gridStyle = computed(() =>
124
124
 
125
125
  <template>
126
126
  <div>
127
- <!-- Four content-sized rows. Not 1fr on the last one: the group is sized by its content, so 1fr
128
- resolves to whatever is left over nothing and a visual placed in that row overflows onto
129
- the caption above it. -->
127
+ <!-- No row template: the rows are implicit and content-sized. Never 1fr, which resolves to nothing
128
+ in a content-sized group and lets a visual overflow onto the caption above it. Each tile spans
129
+ kpiGroupRows of them, a count that grows only for parts some tile actually has, so a group
130
+ with no visual keeps no empty row and no gap in front of one. -->
130
131
  <div
131
132
  :role="selectable && !multiple ? 'tablist' : selectable ? 'group' : undefined"
132
133
  :aria-label="label"
133
134
  :aria-orientation="selectable && !multiple ? orientation : undefined"
134
- class="grid grid-rows-[auto_auto_auto_auto] items-stretch"
135
+ class="grid items-stretch"
135
136
  :class="[
137
+ kpiGroupRows,
136
138
  divided ? 'gap-px' : 'gap-4',
137
139
  // Ring outset, not inset: `divided` rules are this element's background through 1px gaps,
138
140
  // so the tiles sit flush to its border box and an inset stroke lands on them.
@@ -1,11 +1,11 @@
1
1
  // Presentation for FKpi. The surface comes from UCard — this recipe owns only what sits inside it.
2
2
  //
3
- // The value's type size is a container query on the tile itself, not a `size` prop. A tile in a
4
- // narrow column is narrow, and the number should follow the space it has rather than a breakpoint
3
+ // The value's type size is a container query on the tile's figure row, not a `size` prop. A tile in
4
+ // a narrow column is narrow, and the number should follow the space it has rather than a breakpoint
5
5
  // the author guessed at.
6
6
  //
7
- // Row alignment is subgrid: inside a group the tile spans four rows of the group's grid, so labels,
8
- // values, captions and visuals line up across the row however long any one label runs.
7
+ // Row alignment is subgrid: inside a group the tile spans the group's rows, so labels, values,
8
+ // captions and visuals line up across the row however long any one label runs.
9
9
  import type { InjectionKey } from 'vue';
10
10
  import { tv } from 'tailwind-variants';
11
11
  import type { KpiFormat } from './kpiFormat';
@@ -13,21 +13,31 @@ import type { KpiFormat } from './kpiFormat';
13
13
  export const kpiTheme = tv({
14
14
  slots: {
15
15
  // The card's own body is display:contents so the four parts can join the group's subgrid, which
16
- // means the padding has to live here instead. p-4 is the card's own uniform figure.
16
+ // means the padding has to live here instead, and it is the only padding on the tile.
17
17
  // Padding rides --forest-card-pad, the same var the card's concentric-radius maths reads: the
18
18
  // rounder the card, the roomier the inset, and a context that steps one steps both (1rem at
19
19
  // rest, 1.25rem in mobile and marketing). A hardcoded p-4 would leave the tile tight inside a
20
- // 32px-radius card. The card's own body padding is display:contents here, so this is the only
21
- // padding on the tile.
20
+ // 32px-radius card. --forest-kpi-pad overrides it where the card pad means something else:
21
+ // FInsetCard pins the card pad to its gap for the corner, and sets the tile's padding here. The
22
+ // bleed pulls by the same expression, so the graphic meets the edge the padding leaves.
23
+ //
24
+ // No row template: the rows are implicit, one per part present, so a tile without a visual has
25
+ // no empty track under its caption, and no gap in front of one. A bleed visual adds a flexible
26
+ // last row (`visualFit`); a grouped tile takes its rows from the group instead (`aligned`).
22
27
  //
23
28
  // One explicit minmax(0,1fr) column: without it the implicit column sizes to max-content, and a
24
29
  // chart with an intrinsic width (Unovis defaults to 300px) makes the tile's own grid wider than
25
30
  // the tile, clipping the right end of the graphic.
26
- root: 'relative grid grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_auto_auto] gap-1 p-[var(--forest-card-pad)] @container/kpi',
31
+ root: 'relative grid grid-cols-[minmax(0,1fr)] gap-1 p-[var(--forest-kpi-pad,var(--forest-card-pad))]',
27
32
  header: 'flex min-w-0 items-center gap-1.5',
28
33
  label: 'type-overline truncate text-muted',
29
34
  hint: 'shrink-0 text-dimmed',
30
- body: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
35
+ // ★ THE SIZE QUERIES' CONTAINER IS THE BODY, NOT THE ROOT. A container query is layout
36
+ // containment, and a layout-contained grid cannot be a subgrid: on the root, a grouped tile's
37
+ // `grid-template-rows: subgrid` computes to `none`, each tile sizes its own rows, and a group
38
+ // lines up only where its tiles happen to match. The body spans the tile's content width and
39
+ // holds every part that queries it.
40
+ body: 'flex flex-wrap items-baseline gap-x-2 gap-y-1 @container/kpi',
31
41
  // Face and transform ride the --fx-metric traits, so a context re-points them on the card root
32
42
  // and the figure follows — the same channel the card title uses for --fx-title. tabular-nums is
33
43
  // unconditional: it does what it can on the sans cut and is inert on Mohr, which has no tnum.
@@ -71,22 +81,36 @@ export const kpiTheme = tv({
71
81
  * `bleed` (the default) is for a graphic that IS data and nothing else, a sparkline being the
72
82
  * case: it runs through the tile's gutter to both side edges and sits on the bottom edge, which
73
83
  * the tile reaches by zeroing its own bottom padding rather than by a negative margin — a
74
- * margin would shrink the grid row and pull the graphic up over the caption.
84
+ * margin would shrink the grid row and pull the graphic up over the caption. Its row is the
85
+ * tile's last and flexible one, and the graphic spans it bottom-aligned: a tile given more
86
+ * height than it needs — a group's shared row, a stretched FInsetCard — grows a `fill`
87
+ * sparkline into the spare height and leaves any other graphic on the bottom edge.
75
88
  *
76
89
  * `inset` is for a graphic carrying text, FDistributionBar's legend being the case. Text set
77
90
  * against the card's edge reads as a mistake, so the padding stays. It also top-aligns, and
78
- * pins the tile's rows to the top with `content-start`: the tile is a subgrid item WITH
79
- * padding, so its four tracks are re-sized inside its own content box rather than inheriting
80
- * the group's positions. Two tiles then distribute their slack differently — a legend that
81
- * wraps in one and not its neighbour leaves the two bars at different heights — and packing
82
- * the rows to the top is what makes them agree. Measured: 15.6px apart without it, level with.
91
+ * packs the tile's rows to the top with `content-start`, so a tile stretched taller than its
92
+ * content keeps the extra height below the graphic rather than spread between its rows.
83
93
  */
84
94
  visualFit: {
85
- bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-card-pad)]' },
95
+ bleed: { root: 'pb-0 grid-rows-[auto_auto_auto_1fr]', visual: 'self-stretch flex flex-col justify-end -mx-[var(--forest-kpi-pad,var(--forest-card-pad))]' },
86
96
  inset: { root: 'content-start', visual: 'self-start' },
87
97
  },
98
+ // A grouped tile spans exactly the rows its group declares (kpiGroupRows), so a group with no
99
+ // visual has no empty last track and no group gap under its captions.
88
100
  aligned: {
89
- true: { root: 'row-span-4 grid-rows-subgrid' },
101
+ true: { root: 'row-span-(--forest-kpi-rows) grid-rows-subgrid' },
102
+ },
103
+ /**
104
+ * The label is visually hidden but stays the figure's accessible name — for a tile whose title
105
+ * is drawn OUTSIDE it, such as FInsetCard's header strip. `sr-only` goes on the LABEL, and the
106
+ * ROW only collapses to zero height: a grouped tile shares its rows with its neighbours, and
107
+ * a row taken out of the flow would pull the figure, caption and visual up a track, out of line
108
+ * with every tile whose label shows. `-mt-1` cancels the gap after the zero-height row, so a
109
+ * standalone figure sits on the tile's top padding; it must equal the root's `gap-1`. A grouped
110
+ * figure keeps its row (compound below).
111
+ */
112
+ hideLabel: {
113
+ true: { header: 'h-0 overflow-hidden', label: 'sr-only', body: '-mt-1' },
90
114
  },
91
115
  selectable: {
92
116
  true: {
@@ -148,6 +172,9 @@ export const kpiTheme = tv({
148
172
  // tile paints amber and the value's own colour carries the rest.
149
173
  { emphasis: 'surface', status: 'warn', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
150
174
  { emphasis: 'surface', status: 'critical', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
175
+ // Inside a group the figure keeps its row: the group lays every tile on shared subgrid rows, so
176
+ // pulling one figure up into the gap would misalign it against a neighbour whose label shows.
177
+ { hideLabel: true, aligned: true, class: { body: 'mt-0' } },
151
178
  ],
152
179
  defaultVariants: {
153
180
  status: 'ok',
@@ -155,6 +182,15 @@ export const kpiTheme = tv({
155
182
  },
156
183
  });
157
184
 
185
+ /**
186
+ * The group's row count, which every grouped tile spans: label, figure and caption, one more when any
187
+ * tile has a visual, one more when any has a footer. Declared on the group because the rows are
188
+ * shared, so no tile can count them alone, and counted with `:has()` so it follows the slots that
189
+ * actually render.
190
+ */
191
+ export const kpiGroupRows =
192
+ '[--forest-kpi-rows:3] has-[[data-kpi-visual]]:[--forest-kpi-rows:4] has-[[data-kpi-footer]]:[--forest-kpi-rows:4] has-[[data-kpi-visual]]:has-[[data-kpi-footer]]:[--forest-kpi-rows:5]';
193
+
158
194
  export type KpiSlot = keyof ReturnType<typeof kpiTheme>;
159
195
 
160
196
  export interface KpiGroupContext {