cursedbelt 5.72.0 → 5.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/react/charts/ChartCard.d.ts +9 -4
  2. package/dist/react/charts/ChartCard.d.ts.map +1 -1
  3. package/dist/react/charts/ChartCard.js +14 -13
  4. package/dist/react/charts/ChartCard.js.map +1 -1
  5. package/dist/react/charts/categorical.d.ts +4 -0
  6. package/dist/react/charts/categorical.d.ts.map +1 -1
  7. package/dist/react/charts/categorical.js +4 -0
  8. package/dist/react/charts/categorical.js.map +1 -1
  9. package/dist/react/charts/ink.d.ts +7 -0
  10. package/dist/react/charts/ink.d.ts.map +1 -1
  11. package/dist/react/charts/ink.js +29 -0
  12. package/dist/react/charts/ink.js.map +1 -1
  13. package/dist/react/charts/tokens.d.ts +24 -1
  14. package/dist/react/charts/tokens.d.ts.map +1 -1
  15. package/dist/react/charts/tokens.js +54 -3
  16. package/dist/react/charts/tokens.js.map +1 -1
  17. package/dist/react/components/AppShell.d.ts +9 -0
  18. package/dist/react/components/AppShell.d.ts.map +1 -1
  19. package/dist/react/components/AppShell.js +2 -2
  20. package/dist/react/components/AppShell.js.map +1 -1
  21. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  22. package/dist/react/components/DataTable/DataTable.js +9 -4
  23. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  24. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  25. package/dist/react/palette/CommandPalette.js +4 -1
  26. package/dist/react/palette/CommandPalette.js.map +1 -1
  27. package/dist/styles-areas/analytics.css +12 -11
  28. package/dist/styles-areas/data-table.css +11 -10
  29. package/dist/styles-utilities.css +12 -0
  30. package/dist/theme.css +4 -1
  31. package/package.json +1 -1
  32. package/src/react/charts/ChartCard.tsx +16 -17
  33. package/src/react/charts/accentCollision.spec.ts +87 -0
  34. package/src/react/charts/categorical.ts +4 -0
  35. package/src/react/charts/ink.ts +30 -0
  36. package/src/react/charts/tokens.ts +55 -4
  37. package/src/react/components/AppShell.spec.tsx +18 -0
  38. package/src/react/components/AppShell.tsx +18 -1
  39. package/src/react/components/DataTable/DataTable.tsx +9 -4
  40. package/src/react/palette/CommandPalette.spec.tsx +51 -0
  41. package/src/react/palette/CommandPalette.tsx +3 -1
  42. package/src/styles-areas/analytics.css +12 -11
  43. package/src/styles-areas/data-table.css +11 -10
  44. package/src/styles-utilities.css +12 -0
  45. package/src/theme.css +4 -1
@@ -7,7 +7,8 @@ import { Surface } from '../components/Surface.js';
7
7
  import { useElementSize } from '../hooks/useResizeObserver.js';
8
8
  import { cn } from '../lib/cn.js';
9
9
  import { CHART_PHONE_MAX, ChartCardContext, chartHeight } from './chartBox.js';
10
- import type { ChartTone } from './tokens.js';
10
+ import { seriesColor } from './shared.js';
11
+ import { type ChartTokens, type ChartTone, useChartTokens } from './tokens.js';
11
12
 
12
13
  /** One entry of the header legend — a dot and its label (CHT-2). */
13
14
  export interface ChartCardLegendItem {
@@ -23,7 +24,7 @@ export interface ChartCardProps {
23
24
  subtitle?: ReactNode;
24
25
  /**
25
26
  * The legend, drawn inline in the header instead of under the plot, in SERIES ORDER: item
26
- * `i` takes `--chart-{i+1}`, the color the chart gives its series `i` (CHT-7), unless a
27
+ * `i` takes the color the chart gives its series `i` (CHT-7), unless a
27
28
  * `tone` says otherwise. When set, a chart inside the card leaves its own legend off.
28
29
  * Long legends wrap; on a phone-width card they fold into a "Legend" button.
29
30
  */
@@ -44,27 +45,24 @@ export interface ChartCardProps {
44
45
  className?: string;
45
46
  }
46
47
 
47
- const TONE_VAR: Record<ChartTone, string> = {
48
- accent: 'var(--accent)',
49
- success: 'var(--success)',
50
- warning: 'var(--warning)',
51
- danger: 'var(--destructive)',
52
- neutral: 'var(--muted-foreground)',
53
- };
54
-
55
- /** CHT-7: one color order across every chart — the same `--chart-*` the chart's series take. */
56
- export function legendColor(item: ChartCardLegendItem, index: number): string {
57
- return item.tone ? TONE_VAR[item.tone] : `var(--chart-${(index % 8) + 1})`;
48
+ /**
49
+ * CHT-7: one color order across every chart — the color the chart gives its series `index`,
50
+ * read from the same tokens (`useChartTokens`). Not `var(--chart-N)`: the tokens move a stock
51
+ * hue that collides with the app's accent (`clearTheAccent`), and a CSS var cannot, so the
52
+ * legend dot would disagree with its band (forge task 2976).
53
+ */
54
+ export function legendColor(item: ChartCardLegendItem, index: number, tokens: ChartTokens): string {
55
+ return seriesColor(tokens, item, index);
58
56
  }
59
57
 
60
- function LegendList({ items, className }: { items: ChartCardLegendItem[]; className?: string }) {
58
+ function LegendList({ items, tokens, className }: { items: ChartCardLegendItem[]; tokens: ChartTokens; className?: string }) {
61
59
  return (
62
60
  <ul data-cb-chart-legend='' className={cn('m-0 flex list-none flex-wrap items-center gap-x-3 gap-y-1 p-0 text-xs', className)}>
63
61
  {items.map((item, i) => (
64
62
  <li key={item.label} className='flex min-w-0 items-center gap-1.5 text-muted-foreground'>
65
63
  {/* An SVG fill, not an inline style: the color is data (the series' slot), not layout. */}
66
64
  <svg aria-hidden className='size-2 shrink-0' viewBox='0 0 8 8'>
67
- <circle cx='4' cy='4' r='4' fill={legendColor(item, i)} />
65
+ <circle cx='4' cy='4' r='4' fill={legendColor(item, i, tokens)} />
68
66
  </svg>
69
67
  <span className='truncate'>{item.label}</span>
70
68
  </li>
@@ -95,6 +93,7 @@ export function ChartCard({
95
93
  className,
96
94
  }: ChartCardProps) {
97
95
  const bodyRef = useRef<HTMLDivElement>(null);
96
+ const tokens = useChartTokens(bodyRef);
98
97
  const { width } = useElementSize(bodyRef, { layout: true });
99
98
  const plotHeight = height ?? chartHeight(width);
100
99
  const phone = width > 0 && width < CHART_PHONE_MAX;
@@ -111,7 +110,7 @@ export function ChartCard({
111
110
  {subtitle != null && <p className='truncate text-xs tabular-nums text-muted-foreground'>{subtitle}</p>}
112
111
  </div>
113
112
  <div className='flex min-w-0 flex-wrap items-center justify-end gap-x-3 gap-y-2'>
114
- {legend && legend.length > 0 && !foldLegend && <LegendList items={legend} className='justify-end' />}
113
+ {legend && legend.length > 0 && !foldLegend && <LegendList items={legend} tokens={tokens} className='justify-end' />}
115
114
  {foldLegend && legend && (
116
115
  <Popover
117
116
  trigger={
@@ -122,7 +121,7 @@ export function ChartCard({
122
121
  align='end'
123
122
  label='Chart legend'
124
123
  >
125
- <LegendList items={legend} className='flex-col items-start' />
124
+ <LegendList items={legend} tokens={tokens} className='flex-col items-start' />
126
125
  </Popover>
127
126
  )}
128
127
  {actions}
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { CATEGORICAL_ORDER, categoricalColor } from './categorical.js';
3
+ import { okDistance } from './ink.js';
4
+ import { deriveChartTokens } from './tokens.js';
5
+
6
+ // The palette validator's normal-vision floor (OKLab ×100), and the emerald the categorical
7
+ // order was validated with — `SERIES_FLOOR` and `VALIDATED_EMERALD` in tokens.ts, spelled
8
+ // out here so this spec reads the bug on a tree that does not have them.
9
+ const SERIES_FLOOR = 15;
10
+ const VALIDATED_EMERALD = { light: '#047857', dark: '#34d399' };
11
+
12
+ // Forge task 2976: under station's violet accent the first series and the fourth were both
13
+ // violet (ΔE 11.7, under the palette validator's normal-vision floor of 15), so the two
14
+ // biggest bands of a stacked chart read as one. These are theme.css's stock chart tokens.
15
+ const STOCK = {
16
+ light: {
17
+ '--background': '#fcfcfb',
18
+ '--chart-2': '#0ea5e9',
19
+ '--chart-3': '#f59e0b',
20
+ '--chart-4': '#8b5cf6',
21
+ '--chart-5': '#ec4899',
22
+ '--chart-6': '#14b8a6',
23
+ '--chart-7': '#ef4444',
24
+ '--chart-8': '#6366f1',
25
+ },
26
+ dark: {
27
+ '--background': '#08090a',
28
+ '--chart-2': '#38bdf8',
29
+ '--chart-3': '#fbbf24',
30
+ '--chart-4': '#a78bfa',
31
+ '--chart-5': '#f472b6',
32
+ '--chart-6': '#2dd4bf',
33
+ '--chart-7': '#f87171',
34
+ '--chart-8': '#818cf8',
35
+ },
36
+ } as const;
37
+
38
+ const tokensFor = (mode: 'light' | 'dark', accent: string, chartAccent?: string) => {
39
+ const vars: Record<string, string> = { ...STOCK[mode], '--accent': accent };
40
+ if (chartAccent) vars['--chart-accent'] = chartAccent;
41
+ return deriveChartTokens((name) => vars[name] ?? '');
42
+ };
43
+
44
+ // The safe five, every pair (a positional chart and a stacked legend put any two side by side).
45
+ const worstPair = (colors: string[]) => {
46
+ let worst = Number.POSITIVE_INFINITY;
47
+ for (let i = 0; i < colors.length; i++)
48
+ for (let j = i + 1; j < colors.length; j++) worst = Math.min(worst, okDistance(colors[i], colors[j]) ?? 0);
49
+ return worst;
50
+ };
51
+
52
+ describe('the safe five stay five whatever the accent is', () => {
53
+ // [name, mode, --accent, --chart-accent]. Station's dark --accent is a TEXT lavender
54
+ // (#c4b5fd, OKLab L 0.81) 13.9 from the stock sky even after the violet trades, so station
55
+ // gives charts the stock dark violet as --chart-accent.
56
+ const cases: Array<[string, 'light' | 'dark', string, string | undefined]> = [
57
+ ['station violet, light', 'light', '#6d28d9', undefined],
58
+ ['station violet, dark', 'dark', '#c4b5fd', '#a78bfa'],
59
+ ['stock violet itself, light', 'light', '#8b5cf6', undefined],
60
+ ['a sky accent, dark', 'dark', '#38bdf8', undefined],
61
+ ['the stock emerald, light', 'light', '#047857', undefined],
62
+ ['the stock emerald, dark', 'dark', '#34d399', undefined],
63
+ ];
64
+ for (const [name, mode, accent, chartAccent] of cases) {
65
+ test(`${name}: no two of the five fall under the normal-vision floor`, () => {
66
+ const t = tokensFor(mode, accent, chartAccent);
67
+ const five = CATEGORICAL_ORDER.map((_, i) => categoricalColor(t, i));
68
+ expect(worstPair(five)).toBeGreaterThanOrEqual(SERIES_FLOOR);
69
+ // Slot 0 is still the app's colour: a single series is the brand.
70
+ expect(t.series[0]).toBe(chartAccent ?? accent);
71
+ });
72
+ }
73
+
74
+ test('a text-weight accent alone stays the lead when no --chart-accent is set', () => {
75
+ expect(tokensFor('dark', '#c4b5fd').series[0]).toBe('#c4b5fd');
76
+ });
77
+
78
+ test('under a violet accent the stock violet slot takes the validated emerald for its mode', () => {
79
+ expect(tokensFor('light', '#6d28d9').series[3]).toBe(VALIDATED_EMERALD.light);
80
+ expect(tokensFor('dark', '#c4b5fd').series[3]).toBe(VALIDATED_EMERALD.dark);
81
+ });
82
+
83
+ test('the stock emerald accent changes nothing — the order was validated on it', () => {
84
+ const t = tokensFor('light', '#047857');
85
+ expect(t.series.slice(1)).toEqual(Object.entries(STOCK.light).filter(([k]) => k !== '--background').map(([, v]) => v));
86
+ });
87
+ });
@@ -51,6 +51,10 @@ import type { ChartTokens } from './tokens.js';
51
51
  * emerald (the accent) → violet → amber → sky → pink. Teal, red and indigo are
52
52
  * deliberately absent: teal collides with the accent AND with pink, red is
53
53
  * reserved as a status colour (`danger`), and indigo collides with violet.
54
+ *
55
+ * Slot 0 is the APP's accent, not always emerald: when it lands on one of the
56
+ * other four (station's violet), `clearTheAccent` in `tokens.ts` gives that slot
57
+ * the validated emerald, so the five stay the validated five (forge task 2976).
54
58
  */
55
59
  export const CATEGORICAL_ORDER = [0, 3, 2, 1, 4] as const;
56
60
 
@@ -40,3 +40,33 @@ function parse(color: string): [number, number, number] | null {
40
40
  if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
41
41
  return null;
42
42
  }
43
+
44
+ /**
45
+ * How far apart two colours look to a reader with full colour vision: Euclidean distance in
46
+ * OKLab, ×100 — the same "ΔE (normal)" the palette validator prints. Under 15 is the
47
+ * validator's normal-vision floor, a hard FAIL: two series that close read as one. Null when
48
+ * either colour does not parse.
49
+ */
50
+ export function okDistance(x: string, y: string): number | null {
51
+ const a = parse(x.trim());
52
+ const b = parse(y.trim());
53
+ if (!a || !b) return null;
54
+ const [l1, a1, b1] = oklab(a);
55
+ const [l2, a2, b2] = oklab(b);
56
+ return Math.hypot(l1 - l2, a1 - a2, b1 - b2) * 100;
57
+ }
58
+
59
+ function oklab(rgb: [number, number, number]): [number, number, number] {
60
+ const [r, g, b] = rgb.map((v) => {
61
+ const c = v / 255;
62
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
63
+ }) as [number, number, number];
64
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
65
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
66
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
67
+ return [
68
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
69
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
70
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
71
+ ];
72
+ }
@@ -1,4 +1,6 @@
1
1
  import { type RefObject, useCallback, useEffect, useLayoutEffect, useState } from 'react';
2
+ import { CATEGORICAL_ORDER } from './categorical.js';
3
+ import { inkOn, okDistance } from './ink.js';
2
4
 
3
5
  // The token bridge: turns cursedbelt's design tokens (theme.css) into a stable color
4
6
  // set for recharts. Charts read it off their OWN element (see useChartTokens) so a
@@ -10,7 +12,7 @@ import { type RefObject, useCallback, useEffect, useLayoutEffect, useState } fro
10
12
  export type ChartTone = 'accent' | 'success' | 'warning' | 'danger' | 'neutral';
11
13
 
12
14
  export type ChartTokens = {
13
- /** 8 categorical series colors (--chart-1 … --chart-8); chart-1 tracks the accent. */
15
+ /** 8 categorical series colors (--chart-1 … --chart-8); chart-1 tracks the accent (or an app's --chart-accent). */
14
16
  series: string[];
15
17
  /** --accent — single-series highlight (sparkline, lone line/area/bar). */
16
18
  accent: string;
@@ -77,13 +79,17 @@ export function deriveChartTokens(read: (name: string) => string): ChartTokens {
77
79
  return !v || v.startsWith('var(') ? fallback : v;
78
80
  };
79
81
  const accent = get('--accent', FALLBACK_TOKENS.accent);
80
- const series = FALLBACK_SERIES.map((fb, i) => (i === 0 ? accent : get(`--chart-${i + 1}`, fb)));
82
+ const background = get('--background', FALLBACK_TOKENS.background);
83
+ // An accent tuned as TEXT (station's dark lavender, 10:1 on the card) can be too pale to be a
84
+ // series; an app gives charts a mark-weight one in `--chart-accent`, and series 0 takes it.
85
+ const lead = get('--chart-accent', accent);
86
+ const stock = FALLBACK_SERIES.map((fb, i) => (i === 0 ? lead : get(`--chart-${i + 1}`, fb)));
81
87
  return {
82
- series,
88
+ series: clearTheAccent(stock, background),
83
89
  accent,
84
90
  foreground: get('--foreground', FALLBACK_TOKENS.foreground),
85
91
  foregroundMuted: get('--muted-foreground', FALLBACK_TOKENS.foregroundMuted),
86
- background: get('--background', FALLBACK_TOKENS.background),
92
+ background,
87
93
  surface: get('--popover', FALLBACK_TOKENS.surface),
88
94
  border: get('--border', FALLBACK_TOKENS.border),
89
95
  success: get('--success', FALLBACK_TOKENS.success),
@@ -92,6 +98,51 @@ export function deriveChartTokens(read: (name: string) => string): ChartTokens {
92
98
  };
93
99
  }
94
100
 
101
+ /**
102
+ * The emerald the categorical order was validated with (`categorical.ts`), per mode: the
103
+ * stock light and dark `--accent`. It stands in for the stock hue an app's accent duplicates.
104
+ */
105
+ export const VALIDATED_EMERALD = { light: '#047857', dark: '#34d399' } as const;
106
+
107
+ /** The validator's normal-vision floor (OKLab ×100): two series closer than this read as one. */
108
+ export const SERIES_FLOOR = 15;
109
+
110
+ /**
111
+ * Keep the five safe categorical slots five whatever the app's accent is (forge task 2976).
112
+ *
113
+ * The order in `categorical.ts` was validated with an EMERALD accent in slot 0. An app whose
114
+ * accent is violet (station: #6d28d9 light, #c4b5fd dark) puts slot 0 within ΔE 11.7 of the
115
+ * stock violet in slot 3 — the two biggest bands of a stacked chart then read as one purple.
116
+ * So the safe slot the accent lands on gives up its stock hue for the emerald the order was
117
+ * validated with, and the set of five is again the validated five (violet, emerald, amber,
118
+ * sky, pink pass every CVD check in both modes). Slot 0 stays the accent, so a single series
119
+ * is still the brand. Slots past the ceiling (teal, red, indigo) are not touched: they were
120
+ * never safe, and a chart that reaches them has a fold-to-Other problem, not a colour one.
121
+ */
122
+ export function clearTheAccent(series: string[], background: string): string[] {
123
+ const accent = series[0];
124
+ if (accent == null) return series;
125
+ const emerald = inkOn(background, [VALIDATED_EMERALD.light, VALIDATED_EMERALD.dark]);
126
+ const emeraldGap = okDistance(accent, emerald);
127
+ // An accent that IS the emerald (the stock theme) has nothing to give back.
128
+ if (emeraldGap == null || emeraldGap < SERIES_FLOOR) return series;
129
+ // The NEAREST safe slot under the floor trades its hue — there is one emerald to give, and a
130
+ // second would be a duplicate, which is worse than a near one.
131
+ let nearest = -1;
132
+ let nearestGap = SERIES_FLOOR;
133
+ for (const slot of CATEGORICAL_ORDER) {
134
+ const gap = slot === 0 ? null : okDistance(accent, series[slot] ?? '');
135
+ if (gap != null && gap < nearestGap) {
136
+ nearest = slot;
137
+ nearestGap = gap;
138
+ }
139
+ }
140
+ if (nearest < 0) return series;
141
+ const out = [...series];
142
+ out[nearest] = emerald;
143
+ return out;
144
+ }
145
+
95
146
  /** Read the live tokens off a DOM element (resolves the cascade at that element). */
96
147
  export function readElementTokens(el: Element): ChartTokens {
97
148
  const cs = getComputedStyle(el);
@@ -80,6 +80,24 @@ describe('AppShell', () => {
80
80
  expect(q(container, '[data-cb-appheader] [data-cb-topbar-nav]')).toBeNull();
81
81
  });
82
82
 
83
+ test('a controlled navCollapsedSections folds the 240px rail, and a header press asks the app', () => {
84
+ const toggled: [string, boolean][] = [];
85
+ const { container } = render(
86
+ <AppShell nav={station} navCollapsedSections={['Limits']} onNavSectionToggle={(s, c) => toggled.push([s, c])}>
87
+ x
88
+ </AppShell>,
89
+ );
90
+ const rail = qa(container, '[data-cb-nav-rail]')[1] as HTMLElement;
91
+ // Limits is folded — its places are not drawn; Insight is open.
92
+ expect(rail.textContent).not.toContain('Connections');
93
+ expect(rail.textContent).toContain('Metrics');
94
+ const header = qa(rail, 'button').find((b) => b.textContent === 'Insight') as HTMLElement;
95
+ fireEvent.click(header);
96
+ expect(toggled).toEqual([['Insight', true]]);
97
+ // Controlled: nothing folds until the app says so.
98
+ expect(rail.textContent).toContain('Metrics');
99
+ });
100
+
83
101
  test('NAV-3 · 2–5 places: labeled places in the header center, no sidebar, every place on the PlaceBar', () => {
84
102
  const { container, getAllByRole } = render(
85
103
  <AppShell nav={nav} activeHref='/library' onNavigate={() => {}}>
@@ -68,6 +68,16 @@ export interface AppShellProps {
68
68
  navPrefs?: NavPreferences;
69
69
  onNavPrefsChange?: (next: NavPreferences) => void;
70
70
 
71
+ /**
72
+ * The sidebar sections folded shut, by name — controlled, so the app can remember the
73
+ * reader's choice (and open the section a search just landed in) without turning on the
74
+ * drag/hide/recolor customize UI `onNavPrefsChange` brings. Unset, folding lasts the session.
75
+ */
76
+ navCollapsedSections?: string[];
77
+ /** A section header was pressed while `navCollapsedSections` is controlled; `collapsed` is
78
+ * the section's NEXT state. */
79
+ onNavSectionToggle?: (section: string, collapsed: boolean) => void;
80
+
71
81
  /** Dynamic per-place badge (unread counts), keyed by domain id. */
72
82
  navBadges?: Record<string, number | string>;
73
83
  /** How those counts READ — `alert` (default, red) for "this needs you", `neutral` or
@@ -237,6 +247,8 @@ function Shell({
237
247
  resolveIcon,
238
248
  navPrefs,
239
249
  onNavPrefsChange,
250
+ navCollapsedSections,
251
+ onNavSectionToggle,
240
252
  navBadges,
241
253
  navBadgeTone,
242
254
  navHeader,
@@ -431,7 +443,12 @@ function Shell({
431
443
  chrome,
432
444
  )}
433
445
  >
434
- <SideNav {...sideNavProps} onPrefsChange={onNavPrefsChange} />
446
+ <SideNav
447
+ {...sideNavProps}
448
+ onPrefsChange={onNavPrefsChange}
449
+ collapsedSections={navCollapsedSections}
450
+ onSectionToggle={onNavSectionToggle}
451
+ />
435
452
  </aside>
436
453
  </>
437
454
  )}
@@ -871,15 +871,20 @@ function readPath(row: unknown, key: string): unknown {
871
871
  * string is a text node — it overflowed the zero-wide cell and printed across its neighbour at
872
872
  * phone width (desk's folder list, forge task 2762). Hidden visibility hides the text and takes
873
873
  * it out of the accessibility tree, without touching the column's box.
874
+ *
875
+ * 🔴 And a font size of zero: hidden text still LAYS OUT, and a table's auto layout hands each
876
+ * column a share of the spare width by its content's width — so a collapsed column whose cells
877
+ * are bare strings ("3m 16s") stood 45px wide and blank between its neighbours (station's Insights
878
+ * kinds table at 1440, forge task 2975). Text at size zero has no width, and the column is 0px.
874
879
  */
875
880
  const HIDE_BELOW_CLASS = {
876
- sm: '@max-2xl:w-0! @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:border-0 @max-2xl:invisible @max-2xl:[&>*]:hidden',
877
- md: '@max-4xl:w-0! @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:border-0 @max-4xl:invisible @max-4xl:[&>*]:hidden',
878
- lg: '@max-6xl:w-0! @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:border-0 @max-6xl:invisible @max-6xl:[&>*]:hidden',
881
+ sm: '@max-2xl:w-0! @max-2xl:min-w-0! @max-2xl:p-0! @max-2xl:border-0 @max-2xl:invisible @max-2xl:text-[length:0] @max-2xl:[&>*]:hidden',
882
+ md: '@max-4xl:w-0! @max-4xl:min-w-0! @max-4xl:p-0! @max-4xl:border-0 @max-4xl:invisible @max-4xl:text-[length:0] @max-4xl:[&>*]:hidden',
883
+ lg: '@max-6xl:w-0! @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:border-0 @max-6xl:invisible @max-6xl:text-[length:0] @max-6xl:[&>*]:hidden',
879
884
  } as const;
880
885
 
881
886
  /** `ColDef.priority`'s collapse — {@link HIDE_BELOW_CLASS}'s, unconditional, for the same reason. */
882
- const COLLAPSED_CLASS = 'w-0! min-w-0! p-0! border-0 invisible [&>*]:hidden';
887
+ const COLLAPSED_CLASS = 'w-0! min-w-0! p-0! border-0 invisible text-[length:0] [&>*]:hidden';
883
888
  const NOTHING_DROPPED: ReadonlySet<string> = new Set();
884
889
 
885
890
  /**
@@ -177,6 +177,57 @@ describe('fuzzy page results', () => {
177
177
  });
178
178
  });
179
179
 
180
+ describe('finding a page by what it holds (forge task 2969)', () => {
181
+ // A place, and a tab of another one addressed by its query (station marks such a tab
182
+ // `searchOnly` — cursedbelt-core 2.13.0 — so it is the palette's to find and never a rail row).
183
+ const held: NavigationModel = {
184
+ domains: [
185
+ {
186
+ id: 'connections',
187
+ label: 'Connections',
188
+ icon: 'key',
189
+ path: '/connections',
190
+ description: 'The sign-ins and API tokens your apps run on',
191
+ groups: [],
192
+ },
193
+ {
194
+ id: 'performance',
195
+ label: 'Performance',
196
+ icon: 'activity',
197
+ path: '/performance',
198
+ groups: [
199
+ {
200
+ id: 'performance-parts',
201
+ label: 'Performance',
202
+ items: [
203
+ { id: 'agents', label: 'Agents', path: '/performance?tab=agents', description: 'What each agent cost' },
204
+ ],
205
+ },
206
+ ],
207
+ },
208
+ ],
209
+ };
210
+
211
+ test('a hit shows what it holds under its name, and Enter opens the first', () => {
212
+ const onNavigate = mock((_: string) => {});
213
+ render(<CommandPalette model={held} onNavigate={onNavigate} shortcuts={[]} open onOpenChange={() => {}} />);
214
+ type('conn');
215
+ // Its description is the row's second line — what it holds, not its place's name again.
216
+ expect(screen.getByText('The sign-ins and API tokens your apps run on')).toBeTruthy();
217
+ fireEvent.keyDown(input(), { key: 'Enter' });
218
+ expect(onNavigate).toHaveBeenCalledWith('/connections');
219
+ });
220
+
221
+ test('a tab is found by its name and opened at its address', () => {
222
+ const onNavigate = mock((_: string) => {});
223
+ render(<CommandPalette model={held} onNavigate={onNavigate} shortcuts={[]} open onOpenChange={() => {}} />);
224
+ type('agents');
225
+ expect(screen.getByText('What each agent cost')).toBeTruthy();
226
+ fireEvent.keyDown(input(), { key: 'Enter' });
227
+ expect(onNavigate).toHaveBeenCalledWith('/performance?tab=agents');
228
+ });
229
+ });
230
+
180
231
  describe('gating (ctgr + roles)', () => {
181
232
  test('hiddenInCtgr items are filtered before they reach the palette', () => {
182
233
  const { rerender } = render(
@@ -388,7 +388,9 @@ export function CommandPalette({
388
388
  <ItemBody
389
389
  icon={renderIcon(resolveIcon, item.icon)}
390
390
  label={item.label}
391
- sub={item.groupLabel ?? item.domainLabel ?? item.description}
391
+ // What the page HOLDS, first: the group heading above already names
392
+ // its place, and a hit found by a word in its description should show it.
393
+ sub={item.description ?? item.groupLabel ?? item.domainLabel}
392
394
  />
393
395
  </CommandItem>
394
396
  ))}
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 151 candidate(s).
21
+ * 155 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -26,14 +26,15 @@
26
26
  */
27
27
  @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl @max-2xl:[&>*]:hidden");
28
28
  @source inline("@max-2xl:border-0 @max-2xl:inline-flex @max-2xl:invisible @max-2xl:justify-center");
29
- @source inline("@max-2xl:min-w-0! @max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:w-0!");
30
- @source inline("@max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0! @max-4xl:p-0!");
31
- @source inline("@max-4xl:w-0! @max-6xl:[&>*]:hidden @max-6xl:border-0 @max-6xl:invisible @max-6xl:min-w-0!");
32
- @source inline("@max-6xl:p-0! @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden [&>*]:hidden");
33
- @source inline("[&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing");
34
- @source inline("appearance-none basis basis-full bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t");
35
- @source inline("bg-muted/30 bg-muted/50 border-accent-solid border-accent/60 border-field-error border-solid");
36
- @source inline("border-t-2 col collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
29
+ @source inline("@max-2xl:min-w-0! @max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:text-[length:0]");
30
+ @source inline("@max-2xl:w-0! @max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0!");
31
+ @source inline("@max-4xl:p-0! @max-4xl:text-[length:0] @max-4xl:w-0! @max-6xl:[&>*]:hidden @max-6xl:border-0");
32
+ @source inline("@max-6xl:invisible @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:text-[length:0] @max-6xl:w-0!");
33
+ @source inline("@max-xl:contents @xl:basis-auto @xl:block @xl:hidden [&>*]:hidden [&>*]:max-w-none");
34
+ @source inline("[&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing appearance-none");
35
+ @source inline("basis basis-full bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
36
+ @source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col collapse");
37
+ @source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
37
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
38
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
39
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
@@ -46,5 +47,5 @@
46
47
  @source inline("min-w-max ml-1.5 mr-2 mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1");
47
48
  @source inline("pr-10 pr-16 pr-3 pr-9 pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
48
49
  @source inline("shadow-[1px_0_0_0_var(--color-border)] shrink-[2] size-7 slide-in-from-top-1 table-fixed");
49
- @source inline("text-accent-solid-foreground text-field-placeholder text-success text-xl to-transparent top-3");
50
- @source inline("transition-[transform,opacity] w-0 w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
50
+ @source inline("text-[length:0] text-accent-solid-foreground text-field-placeholder text-success text-xl");
51
+ @source inline("to-transparent top-3 transition-[transform,opacity] w-0 w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 146 candidate(s).
21
+ * 150 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -26,14 +26,15 @@
26
26
  */
27
27
  @source inline("-m-1 -mt-2 -mt-6 -mx-1 -my-2.5 -my-3 @3xl:block @3xl:hidden @max-2xl @max-2xl:[&>*]:hidden");
28
28
  @source inline("@max-2xl:border-0 @max-2xl:inline-flex @max-2xl:invisible @max-2xl:justify-center");
29
- @source inline("@max-2xl:min-w-0! @max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:w-0!");
30
- @source inline("@max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0! @max-4xl:p-0!");
31
- @source inline("@max-4xl:w-0! @max-6xl:[&>*]:hidden @max-6xl:border-0 @max-6xl:invisible @max-6xl:min-w-0!");
32
- @source inline("@max-6xl:p-0! @max-6xl:w-0! @max-xl:contents @xl:basis-auto @xl:block @xl:hidden [&>*]:hidden");
33
- @source inline("[&>*]:max-w-none [&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing");
34
- @source inline("appearance-none basis-full bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30");
35
- @source inline("bg-muted/50 border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col");
36
- @source inline("collapse cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
29
+ @source inline("@max-2xl:min-w-0! @max-2xl:min-w-11 @max-2xl:p-0! @max-2xl:sr-only @max-2xl:text-[length:0]");
30
+ @source inline("@max-2xl:w-0! @max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0!");
31
+ @source inline("@max-4xl:p-0! @max-4xl:text-[length:0] @max-4xl:w-0! @max-6xl:[&>*]:hidden @max-6xl:border-0");
32
+ @source inline("@max-6xl:invisible @max-6xl:min-w-0! @max-6xl:p-0! @max-6xl:text-[length:0] @max-6xl:w-0!");
33
+ @source inline("@max-xl:contents @xl:basis-auto @xl:block @xl:hidden [&>*]:hidden [&>*]:max-w-none");
34
+ @source inline("[&>svg]:size-3.5 [&_svg]:size-3 [overflow-wrap:anywhere] active:cursor-grabbing appearance-none");
35
+ @source inline("basis-full bg-[var(--cb-frozen-bg)] bg-card/95 bg-gradient-to-t bg-muted/30 bg-muted/50");
36
+ @source inline("border-accent-solid border-accent/60 border-field-error border-solid border-t-2 col collapse");
37
+ @source inline("cursor-grab data-[ending-style]:opacity-0 data-[ending-style]:scale-95");
37
38
  @source inline("data-[highlighted]:bg-accent/10 data-[highlighted]:text-accent-strong");
38
39
  @source inline("data-[starting-style]:opacity-0 data-[starting-style]:scale-95 disabled:opacity-40 duration-100");
39
40
  @source inline("fade-in focus-visible:ring-field-ring/40 focus-within:border-field-ring focus-within:ring-2");
@@ -45,6 +46,6 @@
45
46
  @source inline("min-h-20 min-h-dvh min-w-0! min-w-24 min-w-52 min-w-[var(--anchor-width)] min-w-max ml-1.5 mr-2");
46
47
  @source inline("mt-1.5 mt-3 mt-4 not-italic ordinal p-0! pb-1.5 pl-1.5 pl-2 pr-0.5 pr-1 pr-10 pr-16 pr-3 pr-9");
47
48
  @source inline("pt-1 resize-none resize-y right-3 ring-field-error/30 self self-stretch");
48
- @source inline("shadow-[1px_0_0_0_var(--color-border)] size-7 slide-in-from-top-1 table-fixed");
49
+ @source inline("shadow-[1px_0_0_0_var(--color-border)] size-7 slide-in-from-top-1 table-fixed text-[length:0]");
49
50
  @source inline("text-accent-solid-foreground text-field-placeholder text-success text-xl to-transparent top-3");
50
51
  @source inline("transition-[transform,opacity] w-0! w-11 w-12 w-20 w-32 w-36 w-40 w-88");
@@ -2790,6 +2790,9 @@
2790
2790
  font-size: var(--text-xs);
2791
2791
  line-height: var(--tw-leading, var(--text-xs--line-height));
2792
2792
  }
2793
+ .text-\[length\:0\] {
2794
+ font-size: 0;
2795
+ }
2793
2796
  .text-\[length\:var\(--text-code-block\)\] {
2794
2797
  font-size: var(--text-code-block);
2795
2798
  }
@@ -4841,6 +4844,9 @@
4841
4844
  .\@max-6xl\:p-0\! {
4842
4845
  padding: 0px !important;
4843
4846
  }
4847
+ .\@max-6xl\:text-\[length\:0\] {
4848
+ font-size: 0;
4849
+ }
4844
4850
  }
4845
4851
  @container (width < 56rem) {
4846
4852
  .\@max-4xl\:invisible {
@@ -4859,6 +4865,9 @@
4859
4865
  .\@max-4xl\:p-0\! {
4860
4866
  padding: 0px !important;
4861
4867
  }
4868
+ .\@max-4xl\:text-\[length\:0\] {
4869
+ font-size: 0;
4870
+ }
4862
4871
  }
4863
4872
  @container (width < 42rem) {
4864
4873
  .\@max-2xl\:invisible {
@@ -4897,6 +4906,9 @@
4897
4906
  .\@max-2xl\:p-0\! {
4898
4907
  padding: 0px !important;
4899
4908
  }
4909
+ .\@max-2xl\:text-\[length\:0\] {
4910
+ font-size: 0;
4911
+ }
4900
4912
  }
4901
4913
  @container (width < 36rem) {
4902
4914
  .\@max-xl\:contents {
package/src/theme.css CHANGED
@@ -359,7 +359,10 @@
359
359
  (ca emerald / ru violet) for free — apps still override only the four accent
360
360
  tokens. The rest are a fixed, legible categorical ramp tuned for the light bg.
361
361
  Read by useChartTokens (cursedbelt/react/charts) off the chart's own element,
362
- so dark + accent scopes cascade correctly. */
362
+ so dark + accent scopes cascade correctly. An app whose --accent is tuned as
363
+ TEXT (too pale to be a band) may declare --chart-accent, which series 0 takes
364
+ instead; it is read only there, so it is deliberately not declared here.
365
+ A stock hue the lead duplicates trades for emerald (clearTheAccent, task 2976). */
363
366
  --chart-1: var(--accent);
364
367
  --chart-2: #0ea5e9; /* sky */ /* guardrails-ignore no-hardcoded-colors: this file IS the theme token source of truth — every value here is necessarily a literal color definition */
365
368
  --chart-3: #f59e0b; /* amber */ /* guardrails-ignore no-hardcoded-colors: this file IS the theme token source of truth — every value here is necessarily a literal color definition */