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.
- package/dist/react/charts/ChartCard.d.ts +9 -4
- package/dist/react/charts/ChartCard.d.ts.map +1 -1
- package/dist/react/charts/ChartCard.js +14 -13
- package/dist/react/charts/ChartCard.js.map +1 -1
- package/dist/react/charts/categorical.d.ts +4 -0
- package/dist/react/charts/categorical.d.ts.map +1 -1
- package/dist/react/charts/categorical.js +4 -0
- package/dist/react/charts/categorical.js.map +1 -1
- package/dist/react/charts/ink.d.ts +7 -0
- package/dist/react/charts/ink.d.ts.map +1 -1
- package/dist/react/charts/ink.js +29 -0
- package/dist/react/charts/ink.js.map +1 -1
- package/dist/react/charts/tokens.d.ts +24 -1
- package/dist/react/charts/tokens.d.ts.map +1 -1
- package/dist/react/charts/tokens.js +54 -3
- package/dist/react/charts/tokens.js.map +1 -1
- package/dist/react/components/AppShell.d.ts +9 -0
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +2 -2
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +9 -4
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/palette/CommandPalette.d.ts.map +1 -1
- package/dist/react/palette/CommandPalette.js +4 -1
- package/dist/react/palette/CommandPalette.js.map +1 -1
- package/dist/styles-areas/analytics.css +12 -11
- package/dist/styles-areas/data-table.css +11 -10
- package/dist/styles-utilities.css +12 -0
- package/dist/theme.css +4 -1
- package/package.json +1 -1
- package/src/react/charts/ChartCard.tsx +16 -17
- package/src/react/charts/accentCollision.spec.ts +87 -0
- package/src/react/charts/categorical.ts +4 -0
- package/src/react/charts/ink.ts +30 -0
- package/src/react/charts/tokens.ts +55 -4
- package/src/react/components/AppShell.spec.tsx +18 -0
- package/src/react/components/AppShell.tsx +18 -1
- package/src/react/components/DataTable/DataTable.tsx +9 -4
- package/src/react/palette/CommandPalette.spec.tsx +51 -0
- package/src/react/palette/CommandPalette.tsx +3 -1
- package/src/styles-areas/analytics.css +12 -11
- package/src/styles-areas/data-table.css +11 -10
- package/src/styles-utilities.css +12 -0
- 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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
package/src/react/charts/ink.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
30
|
-
@source inline("@max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0!
|
|
31
|
-
@source inline("@max-4xl:
|
|
32
|
-
@source inline("@max-6xl:
|
|
33
|
-
@source inline("
|
|
34
|
-
@source inline("
|
|
35
|
-
@source inline("bg-
|
|
36
|
-
@source inline("border-
|
|
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
|
|
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
|
-
*
|
|
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:
|
|
30
|
-
@source inline("@max-4xl:[&>*]:hidden @max-4xl:border-0 @max-4xl:invisible @max-4xl:min-w-0!
|
|
31
|
-
@source inline("@max-4xl:
|
|
32
|
-
@source inline("@max-6xl:
|
|
33
|
-
@source inline("
|
|
34
|
-
@source inline("
|
|
35
|
-
@source inline("
|
|
36
|
-
@source inline("
|
|
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");
|
package/src/styles-utilities.css
CHANGED
|
@@ -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 */
|