@humanforest/nuxt-layer 0.2.0 → 0.3.1

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.2.0",
4
+ "version": "0.3.1",
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": [
@@ -100,10 +100,22 @@ const yCompare = (d: Point) => d.c;
100
100
 
101
101
  const hasCompare = computed(() => !!props.compare?.some((v) => v !== null && v !== undefined));
102
102
 
103
- // Bars are measured from zero, not from the data's own floor. The line mark frames min-to-max
104
- // because it reads as a shape; a bar reads as a LENGTH, and a bar that starts anywhere but zero has
105
- // a length no longer proportional to its value. The domain therefore always contains zero, which
106
- // also puts negative readings below the baseline instead of quietly flipping them.
103
+ // The line marks share one scale, framed by the floor and ceiling of everything drawn on it — a
104
+ // line reads as a shape, and a shape wants its own range. Set by hand because an Unovis area is
105
+ // measured from a baseline: left to itself, `area` pulls the floor to zero and flattens the line it
106
+ // is filling under. `null` when there is nothing to draw, so the container keeps its own fallback.
107
+ const domain = computed<[number, number] | null>(() => {
108
+ const vals = [...props.data, ...(props.compare ?? [])].filter((v): v is number => typeof v === 'number');
109
+ return vals.length ? [Math.min(...vals), Math.max(...vals)] : null;
110
+ });
111
+ // An area is a band from its baseline to baseline + y, not a fill up to y, so reaching the line
112
+ // from the floor of the frame means passing the height above that floor.
113
+ const yFromFloor = (d: Point) => (d.v === null || !domain.value ? null : d.v - domain.value[0]);
114
+
115
+ // Bars are the exception: they are measured from zero, not from the data's own floor. A bar reads
116
+ // as a LENGTH, and a bar that starts anywhere but zero has a length no longer proportional to its
117
+ // value. The bar domain therefore always contains zero, which also puts negative readings below the
118
+ // baseline instead of quietly flipping them.
107
119
  const barDomain = computed(() => {
108
120
  const vals = props.data.filter((v): v is number => v !== null);
109
121
  const lo = Math.min(0, ...vals);
@@ -134,15 +146,11 @@ const last = computed(() => {
134
146
  for (let i = props.data.length - 1; i >= 0; i--) if (props.data[i] !== null) return props.data[i] as number;
135
147
  return null;
136
148
  });
137
- const bounds = computed(() => {
138
- const vals = props.data.filter((v): v is number => v !== null);
139
- return { min: Math.min(...vals), max: Math.max(...vals) };
140
- });
141
149
  // Fraction of the height the final point sits at, for the endpoint dot.
142
150
  const endpointTop = computed(() => {
143
- const { min, max } = bounds.value;
144
- if (last.value === null || max === min) return 50;
145
- return (1 - (last.value - min) / (max - min)) * 100;
151
+ const [lo, hi] = domain.value ?? [0, 0];
152
+ if (last.value === null || hi === lo) return 50;
153
+ return (1 - (last.value - lo) / (hi - lo)) * 100;
146
154
  });
147
155
  </script>
148
156
 
@@ -179,6 +187,7 @@ const endpointTop = computed(() => {
179
187
  :data="points"
180
188
  :height="height"
181
189
  :margin="{ top: 2, right: endpoint ? 4 : 0, bottom: 0, left: 0 }"
190
+ :y-domain="domain ?? undefined"
182
191
  :duration="motionDuration('slow')"
183
192
  >
184
193
  <!-- Comparison first, so the current series draws over it. Dashed and thinner: the dash says
@@ -194,7 +203,7 @@ const endpointTop = computed(() => {
194
203
  :opacity="0.45"
195
204
  :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear"
196
205
  />
197
- <VisArea v-if="area" :x="x" :y="y" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
206
+ <VisArea v-if="area" :x="x" :y="yFromFloor" :baseline="domain?.[0] ?? 0" :color="color" :opacity="0.12" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
198
207
  <VisLine :x="x" :y="y" :color="color" :line-width="1.5" :curve-type="curve ? CurveType.MonotoneX : CurveType.Linear" />
199
208
  </VisXYContainer>
200
209
  <span
@@ -1,14 +1,20 @@
1
1
  // colourEngine.ts — framework-free colour math: the canonical Forest colour-generation method.
2
2
  // All functions exported; consumed by the Forest colour documentation page.
3
3
 
4
+ // Colour vectors are fixed-length triples. Typed as a tuple rather than number[] so indexed
5
+ // reads are known-present: number[] makes every component `number | undefined` under
6
+ // noUncheckedIndexedAccess, which is how the apps that extend this layer typecheck it.
7
+ export type Vec3 = [number, number, number];
8
+
4
9
  // ---- OKLCH ↔ sRGB (compact port of scales.ts) ----
5
10
  export const srgbToLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
6
11
  export const linToSrgb = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
7
- export function hexToLin(hex: string): number[] {
12
+ export function hexToLin(hex: string): Vec3 {
8
13
  const c = hex.replace('#', '');
9
- return [0, 2, 4].map((i) => srgbToLin(parseInt(c.slice(i, i + 2), 16) / 255));
14
+ const ch = (i: number) => srgbToLin(parseInt(c.slice(i, i + 2), 16) / 255);
15
+ return [ch(0), ch(2), ch(4)];
10
16
  }
11
- export function linToOklab([r, g, b]: number[]): number[] {
17
+ export function linToOklab([r, g, b]: Vec3): Vec3 {
12
18
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
13
19
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
14
20
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
@@ -18,7 +24,7 @@ export function linToOklab([r, g, b]: number[]): number[] {
18
24
  0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
19
25
  ];
20
26
  }
21
- export function oklabToLin([L, a, b]: number[]): number[] {
27
+ export function oklabToLin([L, a, b]: Vec3): Vec3 {
22
28
  const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
23
29
  const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
24
30
  const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
@@ -134,12 +140,12 @@ export const hueOf = (hex: string) => hexToOklch(hex).H;
134
140
 
135
141
  // ---- Display-P3 gamut (wider than sRGB; both D65, so a plain linear matrix maps sRGB→P3) ----
136
142
  // Lets ramps reach chroma sRGB can't show, emitted as a color(display-p3 …) primary with a hex fallback.
137
- const sRGBlinToP3lin = ([r, g, b]: number[]): number[] => [
143
+ const sRGBlinToP3lin = ([r, g, b]: Vec3): Vec3 => [
138
144
  0.8224621 * r + 0.177538 * g,
139
145
  0.0331941 * r + 0.9668058 * g,
140
146
  0.0170827 * r + 0.0723974 * g + 0.9105199 * b,
141
147
  ];
142
- export const inGamutP3 = (linsrgb: number[]) => sRGBlinToP3lin(linsrgb).every((v) => v >= -0.001 && v <= 1.001);
148
+ export const inGamutP3 = (linsrgb: Vec3) => sRGBlinToP3lin(linsrgb).every((v) => v >= -0.001 && v <= 1.001);
143
149
  export function maxChromaP3(L: number, H: number) {
144
150
  const rad = (H * Math.PI) / 180, co = Math.cos(rad), si = Math.sin(rad);
145
151
  let lo = 0, hi = 0.4;
@@ -156,13 +162,14 @@ export function oklchToP3(L: number, C: number, H: number): string {
156
162
  c = lo;
157
163
  }
158
164
  const enc = (v: number) => Math.max(0, Math.min(1, linToSrgb(v)));
159
- const [r, g, b] = sRGBlinToP3lin(oklabToLin([L, c * co, c * si])).map(enc);
165
+ const [pr, pg, pb] = sRGBlinToP3lin(oklabToLin([L, c * co, c * si]));
166
+ const r = enc(pr), g = enc(pg), b = enc(pb);
160
167
  return `color(display-p3 ${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)})`;
161
168
  }
162
169
 
163
170
  // ---- Okhsl chroma model (Ottosson): smooth 3-anchor (C0 / Cmid / Cmax) rational interpolation ----
164
171
  export function okhslCuspPoint(H: number) { let bL = 0.35, bc = 0; for (let L = 0.35; L <= 0.99; L += 0.006) { const c = maxChromaAt(L, H); if (c > bc) { bc = c; bL = L; } } return { L: bL, C: bc }; }
165
- export function getSTmid(a: number, b: number) {
172
+ export function getSTmid(a: number, b: number): [number, number] {
166
173
  const S = 0.11516993 + 1 / (7.4477897 + 4.1590124 * b + a * (-2.19557347 + 1.75198401 * b + a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));
167
174
  const T = 0.11239642 + 1 / (1.6132032 - 0.68124379 * b + a * (0.40370612 + 0.90148123 * b + a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));
168
175
  return [S, T];
@@ -197,7 +204,7 @@ export const dEok = (h1: string, h2: string) => { const A = labOf(h1), B = labOf
197
204
  // STRESS ~47 vs CIEDE2000 ~29 on COMBVD). Author scales with dEok; GATE shipped data-viz palettes
198
205
  // on dE00 / dE00cvd. See COLOUR-DATAVIZ-RESEARCH.md.
199
206
  // CIELAB (D65) from linear-sRGB / hex.
200
- export function linToLab([r, g, b]: number[]): number[] {
207
+ export function linToLab([r, g, b]: Vec3): Vec3 {
201
208
  const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
202
209
  const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
203
210
  const Z = 0.0193339 * r + 0.119192 * g + 0.9503041 * b;
@@ -207,7 +214,7 @@ export function linToLab([r, g, b]: number[]): number[] {
207
214
  }
208
215
  export const labCie = (hex: string) => linToLab(hexToLin(hex));
209
216
  // CIEDE2000. Verified against the canonical Sharma et al. reference vectors (incl. the 1.0000 hue-wrap traps).
210
- export function ciede2000([L1, a1, b1]: number[], [L2, a2, b2]: number[]): number {
217
+ export function ciede2000([L1, a1, b1]: Vec3, [L2, a2, b2]: Vec3): number {
211
218
  const rad = Math.PI / 180, deg = 180 / Math.PI;
212
219
  const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2), Cb = (C1 + C2) / 2;
213
220
  const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
@@ -230,12 +237,16 @@ export const dE00 = (h1: string, h2: string) => Math.round(ciede2000(labCie(h1),
230
237
  // CVD simulation — Machado, Oliveira & Fernandes (2009), severity 1.0, applied in linear sRGB.
231
238
  export const CVD_TYPES = ['protan', 'deutan', 'tritan'] as const;
232
239
  export type CvdType = (typeof CVD_TYPES)[number];
233
- const CVD_M: Record<CvdType, number[][]> = {
240
+ const CVD_M: Record<CvdType, [Vec3, Vec3, Vec3]> = {
234
241
  protan: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
235
242
  deutan: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
236
243
  tritan: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
237
244
  };
238
- export const simulateCvdLin = (lin: number[], t: CvdType) => CVD_M[t].map((row) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]);
245
+ export const simulateCvdLin = (lin: Vec3, t: CvdType): Vec3 => {
246
+ const [m0, m1, m2] = CVD_M[t];
247
+ const dot = (row: Vec3) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2];
248
+ return [dot(m0), dot(m1), dot(m2)];
249
+ };
239
250
  export const simulateCvd = (hex: string, t: CvdType) => { const l = simulateCvdLin(hexToLin(hex), t); const to = (v: number) => Math.round(Math.max(0, Math.min(1, linToSrgb(v))) * 255).toString(16).padStart(2, '0'); return '#' + to(l[0]) + to(l[1]) + to(l[2]); };
240
251
  // Worst-case ΔE00 across normal + all three dichromacies — the number to gate categorical series on.
241
252
  export const dE00cvd = (h1: string, h2: string) => {
@@ -248,7 +259,7 @@ export const dE00cvd = (h1: string, h2: string) => {
248
259
  export interface DataVizGate { minSep: number; minBrand: number; minContrast: number; bgs: string[]; brand: string[]; }
249
260
  export function validateDataViz(hexes: string[], g: DataVizGate) {
250
261
  const pairs: { a: string; b: string; dE: number }[] = [];
251
- for (let i = 0; i < hexes.length; i++) for (let j = i + 1; j < hexes.length; j++) pairs.push({ a: hexes[i], b: hexes[j], dE: dE00cvd(hexes[i], hexes[j]) });
262
+ hexes.forEach((a, i) => hexes.slice(i + 1).forEach((b) => pairs.push({ a, b, dE: dE00cvd(a, b) })));
252
263
  const minMutual = Math.min(...pairs.map((p) => p.dE));
253
264
  const brand = hexes.map((h) => ({ h, dE: Math.min(...g.brand.map((b) => dE00(h, b))) }));
254
265
  const minBrand = Math.min(...brand.map((b) => b.dE));
@@ -387,7 +398,7 @@ export const TWL_WARM: Record<number, number> = { 0: 0.99, 50: 0.97, 100: 0.935,
387
398
  export const ladderFor = (fam: string) => (fam === 'warm' ? TWL_WARM : BRIGHT.has(fam) ? TWL_BRIGHT : TWL);
388
399
  export const TWFRAC: Record<number, number> = { 0: 0.16, 50: 0.34, 100: 0.62, 200: 0.72, 300: 0.84, 400: 0.9, 500: 0.9, 600: 0.88, 700: 0.84, 800: 0.72, 900: 0.58, 950: 0.44 };
389
400
  export const MUTE = 0.8;
390
- export const nearestOn = (hex: string, ladder: Record<number, number>) => { const L = hexToOklch(hex).L; let b = BRAND_SHADES[0], bd = Infinity; for (const s of BRAND_SHADES) { const d = Math.abs(ladder[s] - L); if (d < bd) { bd = d; b = s; } } return b; };
401
+ export const nearestOn = (hex: string, ladder: Record<number, number>) => { const L = hexToOklch(hex).L; let b = BRAND_SHADES[0]!, bd = Infinity; for (const s of BRAND_SHADES) { const d = Math.abs(ladder[s]! - L); if (d < bd) { bd = d; b = s; } } return b; };
391
402
  export const CMUL: Record<string, Record<number, number>> = {
392
403
  acid: { 0: 0.4, 50: 0.45, 100: 0.5, 200: 0.65, 300: 0.82 },
393
404
  primary: { 400: 1.15, 600: 1.3, 700: 1.4, 800: 1.4, 900: 1.3 },
@@ -411,9 +422,9 @@ export const TUNE: Record<string, Record<number, { L?: number; C?: number; H?: n
411
422
  // ---- buildTW: a simple generate-then-stamp ramp (retained as a comparison/utility) ----
412
423
  export function buildTW(H: number, pins: Record<number, string> = {}, ladder: Record<number, number> = TWL, cmul?: Record<number, number>, dHue = 0, sat = 0.9) {
413
424
  const o: Record<number, string> = {};
414
- const L5 = ladder[500], Lbot = ladder[950];
425
+ const L5 = ladder[500]!, Lbot = ladder[950]!;
415
426
  for (const s of BRAND_SHADES) {
416
- const L = ladder[s];
427
+ const L = ladder[s]!;
417
428
  const hh = dHue && L < L5 ? H + dHue * ((L5 - L) / (L5 - Lbot || 1)) : H;
418
429
  o[s] = oklchToHex(L, okhslChroma(L, hh, sat) * (cmul?.[s] ?? 1), hh);
419
430
  }
@@ -422,25 +433,28 @@ export function buildTW(H: number, pins: Record<number, string> = {}, ladder: Re
422
433
  }
423
434
 
424
435
  // ---- PCHIP machinery (monotone-cubic C(L) / H(L) splines through anchors) ----
436
+ // The indexing below is genuinely dynamic — the loop bounds guarantee each read is in range but
437
+ // TypeScript cannot see that, so the reads carry `!`. Guards would be dead branches, and casting
438
+ // to number would hide a real out-of-range read. Note pchipSlopes still assumes n >= 2, as before.
425
439
  export function pchipSlopes(xs: number[], ys: number[]) {
426
440
  const n = xs.length, h: number[] = [], d: number[] = [], m: number[] = new Array(n);
427
- for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1] - xs[i]; d[i] = (ys[i + 1] - ys[i]) / h[i]; }
428
- m[0] = d[0]; m[n - 1] = d[n - 2];
441
+ for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1]! - xs[i]!; d[i] = (ys[i + 1]! - ys[i]!) / h[i]!; }
442
+ m[0] = d[0]!; m[n - 1] = d[n - 2]!;
429
443
  for (let i = 1; i < n - 1; i++) {
430
- if (d[i - 1] * d[i] <= 0) m[i] = 0;
431
- else { const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]); }
444
+ if (d[i - 1]! * d[i]! <= 0) m[i] = 0;
445
+ else { const w1 = 2 * h[i]! + h[i - 1]!, w2 = h[i]! + 2 * h[i - 1]!; m[i] = (w1 + w2) / (w1 / d[i - 1]! + w2 / d[i]!); }
432
446
  }
433
447
  return m;
434
448
  }
435
449
  export function makeSpline(pts: { x: number; y: number }[]) {
436
450
  const s = [...pts].sort((a, b) => a.x - b.x);
437
451
  const xs = s.map((p) => p.x), ys = s.map((p) => p.y), m = pchipSlopes(xs, ys), n = xs.length;
438
- return (x: number) => {
439
- if (x <= xs[0]) return ys[0];
440
- if (x >= xs[n - 1]) return ys[n - 1];
441
- let i = 0; while (x > xs[i + 1]) i++;
442
- const hh = xs[i + 1] - xs[i], t = (x - xs[i]) / hh, t2 = t * t, t3 = t2 * t;
443
- return (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1];
452
+ return (x: number): number => {
453
+ if (x <= xs[0]!) return ys[0]!;
454
+ if (x >= xs[n - 1]!) return ys[n - 1]!;
455
+ let i = 0; while (x > xs[i + 1]!) i++;
456
+ const hh = xs[i + 1]! - xs[i]!, t = (x - xs[i]!) / hh, t2 = t * t, t3 = t2 * t;
457
+ return (2 * t3 - 3 * t2 + 1) * ys[i]! + (t3 - 2 * t2 + t) * hh * m[i]! + (-2 * t3 + 3 * t2) * ys[i + 1]! + (t3 - t2) * hh * m[i + 1]!;
444
458
  };
445
459
  }
446
460
  export const normH = (h: number) => ((h % 360) + 360) % 360;
@@ -489,7 +503,7 @@ export const FRAC_DARK = 0.4; // extreme-dark anchor (L0.20, below the floor). T
489
503
  // the shared dark cusp-fraction, so every family's darks stay rich. This anchor only extrapolates below 950.
490
504
  export const CHROMA_HEADROOM = 1.0; // multiplier on the comfort ceiling (1 = exact pin envelope)
491
505
  function buildBrandChromaProfile() {
492
- const allPins = Object.values(PINS).flat() as [string, string, number?][];
506
+ const allPins = Object.values(PINS).flat();
493
507
  // 1. cusp-fraction curve: pin chroma as a fraction of its own cusp (drop near-white pins — cusp too small).
494
508
  // Also drop deliberately-muted dark accents (Bark Brown: f≈0.45 at L0.30): authored low on purpose, it
495
509
  // would clash with Forest's rich dark pins (Darkest Forest rides ~0.94) and suppress the system's dark richness.
@@ -528,13 +542,13 @@ export type PchipOverrides = { sat?: number; chromaMul?: number; headroom?: numb
528
542
  // (the Lime mock), so it shouldn't shape the semantics. ----
529
543
  const HOUSE_ANCHORS = ['primary', 'warm'];
530
544
  const STEERED = new Set(['maple', 'river', 'amber']);
531
- const P3_TO_SRGBLIN = [[1.2249401, -0.2249404, 0], [-0.0420569, 1.0420571, 0], [-0.0196376, -0.0786361, 1.0982735]];
545
+ const P3_TO_SRGBLIN: [Vec3, Vec3, Vec3] = [[1.2249401, -0.2249404, 0], [-0.0420569, 1.0420571, 0], [-0.0196376, -0.0786361, 1.0982735]];
532
546
  // decode a built swatch string (hex or color(display-p3 …)) back to OKLCH.
533
547
  export function decodeOklch(s: string) {
534
548
  if (s[0] === '#') return hexToOklch(s);
535
549
  const m = s.match(/display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
536
550
  if (!m) return { L: 0, C: 0, H: 0 };
537
- const lin = [srgbToLin(+m[1]), srgbToLin(+m[2]), srgbToLin(+m[3])];
551
+ const lin: Vec3 = [srgbToLin(+m[1]!), srgbToLin(+m[2]!), srgbToLin(+m[3]!)];
538
552
  const r = P3_TO_SRGBLIN[0][0] * lin[0] + P3_TO_SRGBLIN[0][1] * lin[1] + P3_TO_SRGBLIN[0][2] * lin[2];
539
553
  const g = P3_TO_SRGBLIN[1][0] * lin[0] + P3_TO_SRGBLIN[1][1] * lin[1] + P3_TO_SRGBLIN[1][2] * lin[2];
540
554
  const b = P3_TO_SRGBLIN[2][0] * lin[0] + P3_TO_SRGBLIN[2][1] * lin[1] + P3_TO_SRGBLIN[2][2] * lin[2];
@@ -544,10 +558,11 @@ export function decodeOklch(s: string) {
544
558
  const _houseCache: Record<string, Record<number, number>> = {};
545
559
  // average chroma per level across the pinned brand anchors (Primary + Warm) — built canonically (NOT steered → no recursion), cached per gamut.
546
560
  export function houseChroma(gamut: 'srgb' | 'p3' = 'srgb'): Record<number, number> {
547
- if (_houseCache[gamut]) return _houseCache[gamut];
561
+ const cached = _houseCache[gamut];
562
+ if (cached) return cached;
548
563
  const ramps = HOUSE_ANCHORS.map((f) => buildPCHIP(f, gamut));
549
564
  const h: Record<number, number> = {};
550
- for (const s of BRAND_SHADES) { const cs = ramps.map((r) => decodeOklch(r[s]).C); h[s] = cs.reduce((a, b) => a + b, 0) / cs.length; }
565
+ for (const s of BRAND_SHADES) { const cs = ramps.map((r) => decodeOklch(r[s]!).C); h[s] = cs.reduce((a, b) => a + b, 0) / cs.length; }
551
566
  _houseCache[gamut] = h;
552
567
  return h;
553
568
  }
@@ -555,7 +570,7 @@ export function houseChroma(gamut: 'srgb' | 'p3' = 'srgb'): Record<number, numbe
555
570
  export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: PchipOverrides) {
556
571
  const maxC = gamut === 'p3' ? maxChromaP3 : maxChromaAt;
557
572
  const toStr = gamut === 'p3' ? oklchToP3 : oklchToHex;
558
- const base = hexToOklch(ov?.hue ?? HUE[fam]);
573
+ const base = hexToOklch(ov?.hue ?? HUE[fam]!); // an unknown family already threw here
559
574
  const H0 = normH(base.H);
560
575
  const ladder = ov?.ladder ?? ladderFor(fam);
561
576
  const sat = ov?.sat ?? SAT[fam] ?? 0.9;
@@ -572,12 +587,12 @@ export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: Pchi
572
587
  if (!anchors.some((a) => a.L < 0.34)) { const Ld = 0.24, hd = H0 + tors.d; anchors.push({ L: Ld, C: Math.min(okhslChroma(Ld, hd, sat), maxC(Ld, hd)), H: hd, hex: '' }); }
573
588
  anchors.sort((a, b) => a.L - b.L);
574
589
  // unwrap hue to dodge 0/360 wrap in the spline
575
- for (let i = 1; i < anchors.length; i++) { while (anchors[i].H - anchors[i - 1].H > 180) anchors[i].H -= 360; while (anchors[i].H - anchors[i - 1].H < -180) anchors[i].H += 360; }
590
+ for (let i = 1; i < anchors.length; i++) { const a = anchors[i]!, prev = anchors[i - 1]!; while (a.H - prev.H > 180) a.H -= 360; while (a.H - prev.H < -180) a.H += 360; }
576
591
  const hS = makeSpline(anchors.map((a) => ({ x: a.L, y: a.H })));
577
592
  // pin owns its level's L: override the ladder slot lightness with the pin's true L
578
593
  const Lad: Record<number, number> = { ...ladder };
579
- const pinHex: Record<number, string> = {};
580
- for (const [, hex, lvl] of list) { const o = hexToOklch(hex); const level = lvl ?? nearestOn(hex, ladder); Lad[level] = o.L; pinHex[level] = hex.toLowerCase(); }
594
+ const pinned: { level: number; hex: string }[] = [];
595
+ for (const [, hex, lvl] of list) { const po = hexToOklch(hex); const level = lvl ?? nearestOn(hex, ladder); Lad[level] = po.L; pinned.push({ level, hex: hex.toLowerCase() }); }
581
596
  const o: Record<number, string> = {};
582
597
  const tune = ov?.tune ?? TUNE[fam] ?? {};
583
598
  const cMul = ov?.chromaMul ?? 1; // global chroma dial — scales the derived curve (then gamut-clamped)
@@ -587,16 +602,16 @@ export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: Pchi
587
602
  const house = steered ? houseChroma(gamut) : null;
588
603
  for (const s of BRAND_SHADES) {
589
604
  const tu = tune[s];
590
- const L = s === 0 ? 0.985 : (tu?.L ?? Lad[s]);
605
+ const L = s === 0 ? 0.985 : (tu?.L ?? Lad[s]!);
591
606
  const hh = tu?.H != null ? tu.H : hS(L);
592
607
  // chroma: steered families take the house curve; brand families ride the cusp-fraction + comfort ceiling. Clamp to gamut.
593
- const target = house ? house[s] : brandChroma(L, hh, gamut, headroom);
608
+ const target = house ? house[s]! : brandChroma(L, hh, gamut, headroom);
594
609
  let C = Math.min(target * cMul, maxC(L, hh));
595
610
  if (tu?.C != null) C = Math.min(tu.C, maxC(L, hh));
596
611
  o[s] = toStr(L, C, hh);
597
612
  }
598
613
  // guarantee exact pin (sRGB brand hex; in p3 mode emit its display-p3 form — same in-gamut colour)
599
- for (const [lvl, hex] of Object.entries(pinHex)) { const po = hexToOklch(hex); o[+lvl] = gamut === 'p3' ? oklchToP3(po.L, po.C, po.H) : hex; }
614
+ for (const { level, hex } of pinned) { const po = hexToOklch(hex); o[level] = gamut === 'p3' ? oklchToP3(po.L, po.C, po.H) : hex; }
600
615
  return o;
601
616
  }
602
617
 
@@ -0,0 +1,256 @@
1
+ <script setup lang="ts">
2
+ // A card whose body is an INSET BOX: the box carries the content, and the card's own ground shows
3
+ // as ONE strip — a header above it or a footer below, never both. For dashboard tiles.
4
+ //
5
+ // <FInsetCard> …a chart… <template #footer>…</template> </FInsetCard>
6
+ // <FInsetCard :gap="4"> …a chart… <template #header>…</template> </FInsetCard>
7
+ // <FInsetCard bare> <template #default="{ boxUi }"> <FKpi :ui="boxUi" … />
8
+ //
9
+ // ★ WHY A WRAPPER, NOT A CARD VARIANT. Card variants are a FILL axis (soft/outline/subtle/solid +
10
+ // the tints). "Inset box" is a composition, and making it a variant would put its blast radius on
11
+ // every card in the system. Same reasoning as FlushCard, which is the other end of this axis: it
12
+ // zeroes the body padding so a table runs to the card's edge; this one shrinks it to a visible gap.
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.
18
+ //
19
+ // ★ THE BODY DROPS ITS PADDING ON THE SIDE THE STRIP IS ON. The box is inset on three sides; on the
20
+ // fourth it meets the strip directly, and the strip owns all the space there. Two consequences, both
21
+ // load-bearing: the box's edge lands flush against the strip (as it does in the reference), and the
22
+ // strip's `pt` and `pb` can then be EQUAL and actually read equal. Leave the body padding on and it
23
+ // stacks — the label sits `gap + pt` from the box but only `pb` from the card edge, which is the
24
+ // lopsided look this pattern kept producing.
25
+ //
26
+ // ★ THE STRIP RESTATES ITS PADDING UNDER THE FRAGMENT'S OWN VARIANT. Forest's `flatGap` carries
27
+ // `footer: '[&:not(:first-child)]:pt-0'` / `header: '[&:not(:last-child)]:pb-0'` to collapse a
28
+ // doubled gap on a normal card. Here the body already zeroed that side, so the collapse would eat
29
+ // the strip's only top padding. A bare `py-*` cannot answer it — the `:not()` selector out-specifies
30
+ // a plain class — so the value is restated under the same variant.
31
+ //
32
+ // ★ WHOLE CLASS STRINGS, NEVER CONCATENATED. Tailwind scans source text, so `p-${gap}` and
33
+ // `pt-[${n}px]` generate nothing at all — they fail silently, at runtime, with no error. Every
34
+ // combination is spelled out below. This is why `gap` is an enum and not a number.
35
+ import { computed, useSlots, watchEffect } from 'vue';
36
+
37
+ type Gap = 4 | 8 | 12;
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.
44
+ // So the padding CLASS reads the same var rather than restating the number — otherwise the value
45
+ // lives in two places and a future edit can move one and not the other. That also makes the body
46
+ // classes gap-independent: only `pad` varies.
47
+ const PAD: Record<Gap, string> = { 4: '0.25rem', 8: '0.5rem', 12: '0.75rem' };
48
+
49
+ // ★ THE BOX NEEDS THE VAR ON ITSELF, NOT JUST INHERITED. The bold contexts set
50
+ // `[--forest-card-pad:1.25rem]` on EVERY card root (mobile-theme.ts / marketing-theme.ts), and the
51
+ // box is a card — so the context's value lands on the box's own element and beats the frame's,
52
+ // which only reaches it by inheritance. The concentric rule then runs on the box with the CONTEXT's
53
+ // padding while the body was inset by OURS: 8px of inset with a corner struck for 20px.
54
+ // Restating the gap on the box makes both halves read the same number. Literal strings per gap
55
+ // because Tailwind scans source text and cannot generate `[--forest-card-pad:${x}]`.
56
+ const PAD_CLASS: Record<Gap, string> = {
57
+ 4: '[--forest-card-pad:0.25rem]',
58
+ 8: '[--forest-card-pad:0.5rem]',
59
+ 12: '[--forest-card-pad:0.75rem]',
60
+ };
61
+
62
+ // The body drops its padding on whichever side the strip is on — see the note above. Both
63
+ // breakpoints every time: the card fragment re-pins `sm:p-*`, so a bare `pb-0` still pads at ≥640px.
64
+ const BODY = {
65
+ none: 'p-[var(--forest-card-pad)] sm:p-[var(--forest-card-pad)]',
66
+ header: 'p-[var(--forest-card-pad)] pt-0 sm:p-[var(--forest-card-pad)] sm:pt-0',
67
+ footer: 'p-[var(--forest-card-pad)] pb-0 sm:p-[var(--forest-card-pad)] sm:pb-0',
68
+ } as const;
69
+
70
+ // Gap-independent, because the body no longer contributes on the strip's side: pt === pb.
71
+ // The 8px here deliberately equals the DEFAULT gap: the space around the box and the space around
72
+ // the strip's own label are then one number, so the frame reads as a single rhythm rather than two
73
+ // unrelated ones. Change the default gap and this should move with it.
74
+ // ★ THE STRIP OWNS ITS HEIGHT, THE CONTENT DOES NOT. Without a floor the band is as tall as whatever
75
+ // sits in it, so a footer holding a UButton (min-h-8, its touch target — see themes/button.ts, where
76
+ // `min-h` is the sole height driver) came out 48px against a header of text at ~40px, and a card
77
+ // with one of each looked lopsided. min-h-10 is that button plus the strip's own 8+8, so the tallest
78
+ // realistic content still fits and a plain text row is centred in the same band.
79
+ //
80
+ // `grid items-center` rather than `flex items-center`: a lone grid child spans the column, so a row
81
+ // using `ms-auto` to push its meta right still works. Under flex it would shrink to content and the
82
+ // meta would collapse leftward.
83
+ const STRIP = {
84
+ header: 'grid min-h-10 items-center px-4 pt-2 sm:px-4 [&:not(:last-child)]:pb-2',
85
+ footer: 'grid min-h-10 items-center px-4 pb-2 sm:px-4 [&:not(:first-child)]:pt-2',
86
+ } as const;
87
+
88
+ const props = withDefaults(
89
+ defineProps<{
90
+ /** The inset, in px. An enum because the classes must exist in source — see the note above. */
91
+ gap?: Gap;
92
+ /** The card's hairline. Uses the relative ring, so it holds at any surface level. */
93
+ outlined?: boolean;
94
+ /**
95
+ * The child already draws its own surface (an FKpi does), so this card must not draw a second
96
+ * one. Named for the exception rather than the rule so the common case stays free and the
97
+ * exception is a bare attribute — `<FInsetCard bare>` — since Vue's shorthand can only ever
98
+ * express `true`, which would make the opposite spelling cost `:box="false"` at every KPI.
99
+ */
100
+ bare?: boolean;
101
+
102
+ /**
103
+ * The strip's content, for the shape nearly every card wants: an icon, a title, something
104
+ * right-aligned above; a single action below. Given as props so a call site does not hand-roll
105
+ * the same flex row each time — and so the voice (overline/toned title, caption meta, muted
106
+ * action) is decided once here rather than re-chosen per page.
107
+ *
108
+ * The `#header` / `#footer` slots still win when a card needs a shape these cannot express.
109
+ */
110
+ icon?: string;
111
+ title?: string;
112
+ meta?: string;
113
+ /**
114
+ * The box draws no padding of its own, so the child can run to its edge and supply whatever
115
+ * gutters it wants. For content that already pads itself — a UTable pads its own cells, a map
116
+ * or a chart wants every pixel — where the box's 16px would sit outside the child's and read as
117
+ * a doubled margin. This is FlushCard's argument applied one level in: same lever, the box's
118
+ * body rather than the card's.
119
+ */
120
+ flush?: boolean;
121
+
122
+ /** Footer action label. `to` makes it a link; without one it is a button. */
123
+ action?: string;
124
+ to?: string;
125
+
126
+ }>(),
127
+ { gap: 8, outlined: true, bare: false, flush: false },
128
+ );
129
+
130
+ const slots = useSlots();
131
+
132
+ // A strip is present when its slot OR its props are supplied — the body's padding and the
133
+ // one-strip rule both key off this, so neither can be fooled by the prop form.
134
+ const hasHeader = computed(() => !!(slots.header || props.title));
135
+ const hasFooter = computed(() => !!(slots.footer || props.action));
136
+
137
+ // One strip is the whole idea: the box plus the ground it sits on. With both, the box becomes the
138
+ // filling in a sandwich, neither side can drop its padding, and the frame stops reading as a frame.
139
+ if (import.meta.env.DEV) {
140
+ watchEffect(() => {
141
+ if (hasHeader.value && hasFooter.value)
142
+ console.warn('[FInsetCard] `header` and `footer` together — this pattern takes one strip, not both.');
143
+ // `flush` un-pads the box; `bare` means there is no box to un-pad. Silent inertness is the kind
144
+ // of thing someone debugs for ten minutes, so say it.
145
+ if (props.bare && props.flush)
146
+ console.warn('[FInsetCard] `flush` does nothing under `bare` — bare draws no box, so there is no padding to remove.');
147
+ });
148
+ }
149
+
150
+
151
+
152
+ // Set as a var rather than a class so the radius and FKpi's bleed follow it.
153
+ const style = computed(() => ({ '--forest-card-pad': PAD[props.gap] }));
154
+
155
+ const ui = computed(() => ({
156
+ body: hasFooter.value ? BODY.footer : hasHeader.value ? BODY.header : BODY.none,
157
+ header: STRIP.header,
158
+ footer: STRIP.footer,
159
+ }));
160
+
161
+ // ★ THE FRAME IS ALWAYS `soft`, and the variant is not exposed. Probed 2026-09-10 with the prop
162
+ // restored and the divider suppressed, measuring frame vs box in both modes:
163
+ //
164
+ // soft frame l1 box page → box visible
165
+ // subtle frame l1 box page → box visible
166
+ // outline frame PAGE box page → BOX INVISIBLE, both modes
167
+ // solid frame inverted box page → visible, but a light box in a black card
168
+ //
169
+ // `outline` fills the frame with the page colour, and the box is pinned to the page colour too, so
170
+ // the inset box vanishes into its own frame — the one thing this component exists to draw. That is
171
+ // the real reason, and it is a property of the fill being pinned, not of the divider: `divide-y-0`
172
+ // suppressed the hairline in one class, so the divider was never the objection it looked like.
173
+ //
174
+ // `soft` + a ring, rather than `subtle`: subtle also restores divide-y, and the gap is what
175
+ // separates the strip here — a hairline in it would say the same thing twice.
176
+ // ★ `h-fit` IS LOAD-BEARING, not a layout preference. A grid or flex row stretches its children by
177
+ // default, and a stretched card grows past its content — the extra height lands BELOW the box, so
178
+ // 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.
180
+ // ⚠ NOT `self-start`, which was the first cut: `align-self` acts on the CROSS axis, so in a flex
181
+ // COLUMN it stops the card filling the width instead of capping its height — the card shrank to its
182
+ // content in every column layout. `h-fit` caps the height and leaves width alone, which is the axis
183
+ // actually at issue.
184
+ const rootClass = computed(() =>
185
+ ['h-fit', props.outlined ? 'ring ring-[var(--forest-card-ring)]' : ''].filter(Boolean).join(' '),
186
+ );
187
+
188
+ // ★ THE BOX IS ALWAYS RAISED — there is no ramp option, on purpose. Letting the nesting ladder fill
189
+ // the box steps it one level AWAY from the page, which in light means DARKER than the card it sits
190
+ // in. That reads as a recess, not as a panel, and it is not a look this pattern should be able to
191
+ // produce. --forest-card-inset (forest.css) is the raised surface: always lighter than the card, in
192
+ // both modes. It replaced a bare `bg-default`, which is the PAGE colour and so went darker than the
193
+ // card in dark — the box read as a hole punched in the frame.
194
+ //
195
+ // The box takes no padding of its own: UCard already pads its BODY slot, and adding p-* to the root
196
+ // stacks on top of it for double the intended space.
197
+ // Both breakpoints on the flush body: the card fragment re-pins `sm:p-4`, so a bare `p-0` still
198
+ // pads at ≥640px. The box keeps its own overflow-hidden either way, which is what clips a flush
199
+ // child to the rounded corner; a child that must scroll (UTable) already scrolls itself.
200
+ const boxUi = computed(() => ({
201
+ root: `bg-[var(--forest-card-inset)] ${PAD_CLASS[props.gap]}`,
202
+ ...(props.flush ? { body: 'p-0 sm:p-0' } : {}),
203
+ }));
204
+
205
+ // ★ FOR `bare`, WHERE THE CHILD IS ITS OWN SURFACE (an FKpi). Exposed as a slot prop so the
206
+ // 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.
215
+ const selfBoxUi = computed(() => ({
216
+ root: `bg-[var(--forest-card-inset)] px-4 pt-4 ${PAD_CLASS[props.gap]}`,
217
+ visual: '-mx-4',
218
+ }));
219
+ </script>
220
+
221
+ <template>
222
+ <UCard variant="soft" :class="rootClass" :style="style" :ui="ui">
223
+ <template v-if="hasHeader" #header>
224
+ <!-- The slot's FALLBACK is the prop form: pass `#header` and it replaces this wholesale. -->
225
+ <slot name="header">
226
+ <div class="flex items-center gap-2">
227
+ <UIcon v-if="icon" :name="icon" class="size-4 shrink-0 text-muted" />
228
+ <span class="type-overline truncate text-toned">{{ title }}</span>
229
+ <span v-if="meta" class="ms-auto shrink-0 type-caption text-muted">{{ meta }}</span>
230
+ </div>
231
+ </slot>
232
+ </template>
233
+
234
+ <UCard v-if="!bare" :ui="boxUi">
235
+ <slot />
236
+ </UCard>
237
+ <slot v-else :box-ui="selfBoxUi" />
238
+
239
+ <template v-if="hasFooter" #footer>
240
+ <!-- `xs` on purpose: the strip's floor is 40px, and a `md` button's 32px touch target plus the
241
+ strip's own 8+8 would push it to 48 and leave the header shorter than the footer. -->
242
+ <slot name="footer">
243
+ <UButton
244
+ :to="to"
245
+ size="xs"
246
+ variant="link"
247
+ color="neutral"
248
+ trailing-icon="i-lucide-arrow-right"
249
+ class="w-full justify-between px-0 text-muted"
250
+ >
251
+ {{ action }}
252
+ </UButton>
253
+ </slot>
254
+ </template>
255
+ </UCard>
256
+ </template>
@@ -0,0 +1,3 @@
1
+ // Forest card compositions. UCard itself is Nuxt UI's, themed in ../themes/card.ts — this entry
2
+ // carries only the compositions built ON it that a call site would otherwise hand-roll.
3
+ export { default as FInsetCard } from './FInsetCard.vue';
@@ -3,8 +3,9 @@
3
3
  // owns the boilerplate
4
4
  // every surface would otherwise repeat: the access-token guard (with a setup fallback), a lazy WebGL
5
5
  // init (the context is created only when the frame nears the viewport, so a page with several maps
6
- // doesn't pay for all of them up front), the load fade, cooperative gestures (so a page scrolls past
7
- // the map), and teardown. Emits `ready(map)` once the style has loaded add sources, layers, config
6
+ // doesn't pay for all of them up front), the load fade, and teardown. Cooperative gestures are
7
+ // available (`cooperative`) but OFF unless a surface asks: a map in a document should not swallow
8
+ // the scroll, and a map that IS the surface should not need two fingers to move. Emits `ready(map)` once the style has loaded — add sources, layers, config
8
9
  // and interactions in that handler.
9
10
  //
10
11
  // Token: pass `access-token`, or set `mapboxgl.accessToken` once at app start. Public `pk.…` tokens
@@ -75,7 +76,16 @@ const props = withDefaults(
75
76
  * the surface — only turn this off when the surface carries it elsewhere (e.g. an app's legal
76
77
  * screen, or chrome drawn above a bottom sheet). */
77
78
  attribution?: boolean;
78
- /** ⌘/ctrl-scroll to zoom — on by default so a page scrolls past the map */
79
+ /**
80
+ * Cooperative gestures. ONE flag, TWO behaviours — Mapbox does not separate them: scroll-zoom
81
+ * then needs ⌘/ctrl held, AND a touch pan needs TWO FINGERS (pitch, three). It is the second
82
+ * half that decides the default.
83
+ *
84
+ * OFF by default, which is Mapbox's own default too. A map is usually the subject of the
85
+ * surface it is on — an ops canvas, a rider finding a vehicle — and there one finger should
86
+ * move it. Turn it ON for a map embedded in a document, where a map that swallows the scroll
87
+ * traps the reader on the way past.
88
+ */
79
89
  cooperative?: boolean;
80
90
  /** create the WebGL context only when the frame nears the viewport */
81
91
  lazy?: boolean;
@@ -102,7 +112,7 @@ const props = withDefaults(
102
112
  lightPreset: 'auto',
103
113
  height: 'h-105',
104
114
  attribution: true,
105
- cooperative: true,
115
+ cooperative: false,
106
116
  lazy: true,
107
117
  frame: false,
108
118
  },
@@ -54,7 +54,12 @@ const props = withDefaults(
54
54
  // navigation and the weight split between two edges. It is what a console with a left rail does
55
55
  // everywhere (Linear, Figma, Retool). Pass `side="start"` where the panel IS the navigation for its
56
56
  // view, which in practice means a settings sub-nav.
57
- { behaviour: 'push', side: 'end', size: 'md' },
57
+ // ★ `close: undefined` is load-bearing, not noise. A type-declared `close?: boolean` compiles to
58
+ // `{ type: Boolean }`, and Vue casts an ABSENT boolean prop to `false` unless the options carry a
59
+ // `default` key — so without this `props.close ?? overlay` never sees `undefined`, and an overlay
60
+ // panel drew no close at all unless a consumer asked for one. Declaring the default keeps the three
61
+ // states the prop is written for: unset (follow the behaviour), true, false.
62
+ { behaviour: 'push', side: 'end', size: 'md', close: undefined },
58
63
  );
59
64
 
60
65
  const open = defineModel<boolean>('open', { default: true });
@@ -214,6 +219,20 @@ const sidebarUi = computed(() => ({
214
219
  // panel title and a card title share a baseline instead of nearly sharing one.
215
220
  header: 'min-h-0 p-4 pb-0',
216
221
  inner: overlay.value ? 'forest-canvas-chrome divide-y-0' : '',
222
+ // ★ THE PANEL OWNS DISMISS — the button it renders into `#actions`, wired to its own `open`. Stock adds a
223
+ // SECOND one under 1024px and it is inert: `canClose` is `close && collapsible !== 'none' ||
224
+ // isMobile`, and that `|| isMobile` survives this component's `collapsible="none"`, while the click
225
+ // writes USidebar's own `open` model — which this never binds and which changes nothing in the
226
+ // `collapsible="none"` branch (no `data-state`, no mobile menu). So it is a dead control, and on a
227
+ // phone it sat next to a live one.
228
+ //
229
+ // ★ Hidden through the theme rather than through `<template #close />`, which LOOKS like the tighter
230
+ // fix and does not work: Vue falls back to a slot's default content when the passed slot renders no
231
+ // valid vnode, so an empty template hands back exactly the button it was meant to suppress (measured
232
+ // — stock's close was still in the DOM). And not `:open` either: USidebar's
233
+ // `watch(isMobile, …, { immediate: true })` sets that model to `false` on crossing 1024px, so a bound
234
+ // panel would shut itself on a phone.
235
+ close: 'hidden',
217
236
  }));
218
237
  </script>
219
238
 
@@ -1297,6 +1297,20 @@
1297
1297
  --forest-card-l2: color-mix(in oklab, var(--ui-bg), var(--ui-text-highlighted) 8%);
1298
1298
  --forest-card-l3: color-mix(in oklab, var(--ui-bg), var(--ui-text-highlighted) 13%);
1299
1299
  }
1300
+ /* ── Inset surface — a RAISED panel sitting on a card, the inverse of the ramp above ──────────────
1301
+ The ramp steps AWAY from the page (light darker, dark lighter), which reads as a recess. A panel
1302
+ inset into a card's own ground is the opposite gesture: it should read as lifted, so it steps
1303
+ TOWARD white in both modes — i.e. always lighter than the card it sits in.
1304
+ Light lands back on the page plane (a white panel on a grey card, the dashboard look); dark cannot
1305
+ do that, because the page is the darkest thing there, so it borrows the ramp's own next level.
1306
+ Measured against a card at L1 (.967 light / .229 dark): this is .994 / .268 — lighter in both.
1307
+ ⚠ Declared on EVERY mode-boundary selector for the same reason the levels above are: the value
1308
+ derives from --ui-bg, a custom property resolves ONCE where it is declared, and on :root alone a
1309
+ nested force-light or force-dark island would inherit the outer mode's already-resolved colour.
1310
+ ⚠ NOT --ui-bg-elevated or --ui-bg-muted, which sound right and are not: both are DARKER than the
1311
+ Forest card surface in dark (.156 against the card's .229), so a panel on them reads as a hole. */
1312
+ :root, .light { --forest-card-inset: var(--ui-bg); }
1313
+ .dark { --forest-card-inset: var(--forest-card-l2); }
1300
1314
  /* Map — WHICH PALETTE OWNS WHICH REGION (José, 2026-07-26). Two rules, and they do not overlap:
1301
1315
  the DISC and the BADGE take the VIVID set (plus neutrals). They are categorical: they say WHICH
1302
1316
  kind of thing this is, and vivid separates further than the CVD-gated set does at marker size.