cursedbelt 5.78.0 → 5.79.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/canvas-hud/HudControls.d.ts +8 -2
- package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
- package/dist/react/canvas-hud/HudControls.js +6 -3
- package/dist/react/canvas-hud/HudControls.js.map +1 -1
- package/dist/react/components/AppShell.d.ts +14 -0
- package/dist/react/components/AppShell.d.ts.map +1 -1
- package/dist/react/components/AppShell.js +7 -31
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
- package/dist/react/components/ChipFilterStrip.js +2 -18
- package/dist/react/components/ChipFilterStrip.js.map +1 -1
- package/dist/react/components/KanbanBoard.d.ts +55 -0
- package/dist/react/components/KanbanBoard.d.ts.map +1 -0
- package/dist/react/components/KanbanBoard.js +89 -0
- package/dist/react/components/KanbanBoard.js.map +1 -0
- package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
- package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
- package/dist/react/components/ResponsiveWorkbench.js +33 -4
- package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
- package/dist/react/components/SelectField.d.ts +18 -48
- package/dist/react/components/SelectField.d.ts.map +1 -1
- package/dist/react/components/SelectField.js +88 -11
- package/dist/react/components/SelectField.js.map +1 -1
- package/dist/react/components/SelectFieldImpl.d.ts +7 -0
- package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
- package/dist/react/components/SelectFieldImpl.js +26 -0
- package/dist/react/components/SelectFieldImpl.js.map +1 -0
- package/dist/react/components/filterChip.d.ts +8 -0
- package/dist/react/components/filterChip.d.ts.map +1 -0
- package/dist/react/components/filterChip.js +20 -0
- package/dist/react/components/filterChip.js.map +1 -0
- package/dist/react/hooks/pointerDnd.d.ts +37 -3
- package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
- package/dist/react/hooks/pointerDnd.js +101 -9
- package/dist/react/hooks/pointerDnd.js.map +1 -1
- package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
- package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
- package/dist/react/hooks/pragmaticDnd.js +9 -3
- package/dist/react/hooks/pragmaticDnd.js.map +1 -1
- package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
- package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
- package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
- package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/selectField.d.ts +72 -0
- package/dist/react/lib/selectField.d.ts.map +1 -0
- package/dist/react/lib/selectField.js +23 -0
- package/dist/react/lib/selectField.js.map +1 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +2 -1
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +2 -1
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/denseGallery.css +2 -0
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +6 -7
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
- package/dist/react/nav/CommandBar.d.ts.map +1 -1
- package/dist/react/nav/CommandBar.js +2 -1
- package/dist/react/nav/CommandBar.js.map +1 -1
- package/dist/react/palette/CommandPalette.d.ts +15 -1
- package/dist/react/palette/CommandPalette.d.ts.map +1 -1
- package/dist/react/palette/CommandPalette.js +58 -8
- package/dist/react/palette/CommandPalette.js.map +1 -1
- package/dist/react/palette/Kbd.d.ts +15 -0
- package/dist/react/palette/Kbd.d.ts.map +1 -0
- package/dist/react/palette/Kbd.js +17 -0
- package/dist/react/palette/Kbd.js.map +1 -0
- package/dist/react/palette/ShortcutSheet.d.ts +30 -0
- package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
- package/dist/react/palette/ShortcutSheet.js +15 -0
- package/dist/react/palette/ShortcutSheet.js.map +1 -0
- package/dist/react/palette/commandSources.d.ts +14 -0
- package/dist/react/palette/commandSources.d.ts.map +1 -1
- package/dist/react/palette/commandSources.js +11 -1
- package/dist/react/palette/commandSources.js.map +1 -1
- package/dist/react/palette/index.d.ts +4 -2
- package/dist/react/palette/index.d.ts.map +1 -1
- package/dist/react/palette/index.js +2 -0
- package/dist/react/palette/index.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/canvas-hud.css +2 -2
- package/dist/styles-areas/kanban.css +31 -0
- package/dist/styles-areas/palette.css +3 -3
- package/dist/styles-areas/workbench.css +15 -13
- package/dist/styles-utilities.css +42 -0
- package/package.json +3 -2
- package/scripts/guardrails-baseline.json +2 -1
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +77 -6
- package/scripts/styleAreas.ts +2 -0
- package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
- package/src/react/canvas-hud/HudControls.tsx +33 -9
- package/src/react/components/AppShell.spec.tsx +40 -0
- package/src/react/components/AppShell.tsx +25 -27
- package/src/react/components/ChipFilterStrip.tsx +2 -38
- package/src/react/components/KanbanBoard.spec.tsx +89 -0
- package/src/react/components/KanbanBoard.tsx +270 -0
- package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
- package/src/react/components/ResponsiveWorkbench.tsx +69 -4
- package/src/react/components/SelectField.spec.tsx +117 -0
- package/src/react/components/SelectField.tsx +131 -166
- package/src/react/components/SelectFieldImpl.tsx +140 -0
- package/src/react/components/filterChip.tsx +43 -0
- package/src/react/hooks/pointerDnd.spec.ts +89 -3
- package/src/react/hooks/pointerDnd.ts +103 -8
- package/src/react/hooks/pragmaticDnd.ts +13 -2
- package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
- package/src/react/index.ts +3 -0
- package/src/react/lib/floatingStyles.spec.ts +1 -1
- package/src/react/lib/selectField.ts +96 -0
- package/src/react/media/VideoChapterEditor.tsx +3 -2
- package/src/react/media/VideoCutEditor.tsx +3 -2
- package/src/react/media/denseGallery.css +2 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
- package/src/react/media-gallery/MediaGallery.tsx +16 -18
- package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
- package/src/react/nav/CommandBar.tsx +2 -3
- package/src/react/palette/CommandPalette.spec.tsx +73 -0
- package/src/react/palette/CommandPalette.tsx +153 -13
- package/src/react/palette/Kbd.tsx +46 -0
- package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
- package/src/react/palette/ShortcutSheet.tsx +110 -0
- package/src/react/palette/commandSources.tsx +30 -1
- package/src/react/palette/index.ts +9 -0
- package/src/styles-areas/canvas-hud.css +2 -2
- package/src/styles-areas/kanban.css +31 -0
- package/src/styles-areas/palette.css +3 -3
- package/src/styles-areas/workbench.css +15 -13
- package/src/styles-utilities.css +42 -0
|
@@ -297,6 +297,9 @@
|
|
|
297
297
|
.bottom-6 {
|
|
298
298
|
bottom: calc(var(--spacing) * 6);
|
|
299
299
|
}
|
|
300
|
+
.bottom-\[max\(env\(safe-area-inset-bottom\,0px\)\,0\.75rem\)\] {
|
|
301
|
+
bottom: max(env(safe-area-inset-bottom,0px), 0.75rem);
|
|
302
|
+
}
|
|
300
303
|
.-left-1\.5 {
|
|
301
304
|
left: calc(var(--spacing) * -1.5);
|
|
302
305
|
}
|
|
@@ -1323,6 +1326,9 @@
|
|
|
1323
1326
|
.resize-y {
|
|
1324
1327
|
resize: vertical;
|
|
1325
1328
|
}
|
|
1329
|
+
.snap-start {
|
|
1330
|
+
scroll-snap-align: start;
|
|
1331
|
+
}
|
|
1326
1332
|
.\[scrollbar-width\:none\] {
|
|
1327
1333
|
scrollbar-width: none;
|
|
1328
1334
|
}
|
|
@@ -1576,6 +1582,9 @@
|
|
|
1576
1582
|
.overscroll-contain {
|
|
1577
1583
|
overscroll-behavior: contain;
|
|
1578
1584
|
}
|
|
1585
|
+
.overscroll-x-auto {
|
|
1586
|
+
overscroll-behavior-x: auto;
|
|
1587
|
+
}
|
|
1579
1588
|
.rounded {
|
|
1580
1589
|
border-radius: 0.25rem;
|
|
1581
1590
|
}
|
|
@@ -2648,6 +2657,9 @@
|
|
|
2648
2657
|
.pt-1 {
|
|
2649
2658
|
padding-top: var(--spacing);
|
|
2650
2659
|
}
|
|
2660
|
+
.pt-1\.5 {
|
|
2661
|
+
padding-top: calc(var(--spacing) * 1.5);
|
|
2662
|
+
}
|
|
2651
2663
|
.pt-2 {
|
|
2652
2664
|
padding-top: calc(var(--spacing) * 2);
|
|
2653
2665
|
}
|
|
@@ -3201,6 +3213,10 @@
|
|
|
3201
3213
|
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
|
3202
3214
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
3203
3215
|
}
|
|
3216
|
+
.shadow-2xl {
|
|
3217
|
+
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
|
|
3218
|
+
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
3219
|
+
}
|
|
3204
3220
|
.shadow-\[0_0_0_1px_rgba\(0\,0\,0\,0\.55\)\] {
|
|
3205
3221
|
--tw-shadow: 0 0 0 1px var(--tw-shadow-color, rgba(0,0,0,0.55));
|
|
3206
3222
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
@@ -3295,6 +3311,12 @@
|
|
|
3295
3311
|
--tw-ring-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
|
3296
3312
|
}
|
|
3297
3313
|
}
|
|
3314
|
+
.ring-accent\/50 {
|
|
3315
|
+
--tw-ring-color: var(--accent);
|
|
3316
|
+
@supports (color: color-mix(in lab, red, red)) {
|
|
3317
|
+
--tw-ring-color: color-mix(in oklab, var(--accent) 50%, transparent);
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3298
3320
|
.ring-background {
|
|
3299
3321
|
--tw-ring-color: var(--background);
|
|
3300
3322
|
}
|
|
@@ -3979,6 +4001,9 @@
|
|
|
3979
4001
|
--tw-outline-style: none;
|
|
3980
4002
|
outline-style: none;
|
|
3981
4003
|
}
|
|
4004
|
+
.focus-visible\:border-accent:focus-visible {
|
|
4005
|
+
border-color: var(--accent);
|
|
4006
|
+
}
|
|
3982
4007
|
.focus-visible\:border-border-strong:focus-visible {
|
|
3983
4008
|
border-color: var(--border-strong);
|
|
3984
4009
|
}
|
|
@@ -4482,6 +4507,14 @@
|
|
|
4482
4507
|
scale: var(--tw-scale-x) var(--tw-scale-y);
|
|
4483
4508
|
}
|
|
4484
4509
|
}
|
|
4510
|
+
@media (width < 64rem) {
|
|
4511
|
+
.max-lg\:snap-x {
|
|
4512
|
+
scroll-snap-type: x var(--tw-scroll-snap-strictness);
|
|
4513
|
+
}
|
|
4514
|
+
.max-lg\:snap-mandatory {
|
|
4515
|
+
--tw-scroll-snap-strictness: mandatory;
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4485
4518
|
@media (width < 48rem) {
|
|
4486
4519
|
.max-md\:absolute {
|
|
4487
4520
|
position: absolute;
|
|
@@ -4495,6 +4528,9 @@
|
|
|
4495
4528
|
.max-md\:hidden {
|
|
4496
4529
|
display: none;
|
|
4497
4530
|
}
|
|
4531
|
+
.max-md\:w-full {
|
|
4532
|
+
width: 100%;
|
|
4533
|
+
}
|
|
4498
4534
|
}
|
|
4499
4535
|
@media (width < 40rem) {
|
|
4500
4536
|
.max-sm\:inset-x-0 {
|
|
@@ -5785,6 +5821,11 @@
|
|
|
5785
5821
|
inherits: false;
|
|
5786
5822
|
initial-value: 1;
|
|
5787
5823
|
}
|
|
5824
|
+
@property --tw-scroll-snap-strictness {
|
|
5825
|
+
syntax: "*";
|
|
5826
|
+
inherits: false;
|
|
5827
|
+
initial-value: proximity;
|
|
5828
|
+
}
|
|
5788
5829
|
@property --tw-content {
|
|
5789
5830
|
syntax: "*";
|
|
5790
5831
|
initial-value: "";
|
|
@@ -5915,6 +5956,7 @@
|
|
|
5915
5956
|
--tw-scale-x: 1;
|
|
5916
5957
|
--tw-scale-y: 1;
|
|
5917
5958
|
--tw-scale-z: 1;
|
|
5959
|
+
--tw-scroll-snap-strictness: proximity;
|
|
5918
5960
|
--tw-content: "";
|
|
5919
5961
|
}
|
|
5920
5962
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.79.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"demo:check": "tsc --noEmit --project demo/tsconfig.json",
|
|
76
76
|
"demo:paths": "bun run demo/generatePaths.ts --write",
|
|
77
77
|
"demo:coverage": "bun run scripts/demoCoverage.ts",
|
|
78
|
-
"guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,raw-button-resets-face,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-hand-rolled-property-list,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-select,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover,no-hand-rolled-scroll-memory,no-hand-rolled-surface,no-inline-style,no-arbitrary-length,opt-out-count,opt-out-needs-cause --scope cursedbelt src/react src/styles.css",
|
|
78
|
+
"guardrails:report": "bun run scripts/guardrailsEnforce.ts no-raw-action-button,raw-button-resets-face,no-raw-overflow,no-raw-inputs,no-hardcoded-colors,no-raw-modal-footer,no-unguarded-editing-modal,control-height-tokens,no-raw-zindex,no-raw-data-table,no-hand-rolled-property-list,no-raw-kbd,no-tailwind-palette,no-raw-title-attr,micro-typography-floor,choice-needs-empty-state,no-select-as-action-menu,no-native-select,no-native-dialog,no-fast-tooltip,no-raw-password,no-raw-lazy,no-raw-popover,no-hand-rolled-scroll-memory,no-hand-rolled-surface,no-inline-style,no-arbitrary-length,opt-out-count,opt-out-needs-cause --scope cursedbelt src/react src/styles.css",
|
|
79
79
|
"lint": "biome lint --error-on-warnings src e2e demo scripts",
|
|
80
80
|
"styles": "bun run styles:static && bun run styles:utilities && bun run styles:areas",
|
|
81
81
|
"styles:static": "bun run scripts/generateStaticStyles.ts",
|
|
@@ -657,6 +657,7 @@
|
|
|
657
657
|
"./react/file-tree/geometry.json": "./src/react/file-tree/fileTreeGeometry.fixture.json",
|
|
658
658
|
"./styles-areas/filter-rail.css": "./src/styles-areas/filter-rail.css",
|
|
659
659
|
"./styles-areas/folder-tree.css": "./src/styles-areas/folder-tree.css",
|
|
660
|
+
"./styles-areas/kanban.css": "./src/styles-areas/kanban.css",
|
|
660
661
|
"./styles-areas/keep-awake.css": "./src/styles-areas/keep-awake.css",
|
|
661
662
|
"./styles-areas/layout-engine.css": "./src/styles-areas/layout-engine.css",
|
|
662
663
|
"./styles-areas/markdown.css": "./src/styles-areas/markdown.css",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"no-unguarded-editing-modal": 0,
|
|
12
12
|
"no-raw-data-table": 0,
|
|
13
13
|
"no-hand-rolled-property-list": 0,
|
|
14
|
+
"no-raw-kbd": 0,
|
|
14
15
|
"no-tailwind-palette": 0,
|
|
15
16
|
"no-raw-title-attr": 0,
|
|
16
17
|
"no-local-reimplementation": 0,
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
"no-hand-rolled-surface": 0,
|
|
23
24
|
"no-inline-style": 73,
|
|
24
25
|
"no-arbitrary-length": 0,
|
|
25
|
-
"opt-out-count":
|
|
26
|
+
"opt-out-count": 273,
|
|
26
27
|
"opt-out-needs-cause": 255
|
|
27
28
|
},
|
|
28
29
|
"cursedshell": {
|
|
@@ -1054,6 +1054,15 @@ describe('no-native-select — the OS draws its list (owner, 2026-10-10)', () =>
|
|
|
1054
1054
|
expect(check('no-native-select', source)).toHaveLength(2);
|
|
1055
1055
|
});
|
|
1056
1056
|
|
|
1057
|
+
test('a raw <select> is the same OS list, and counts too (forge task 3097)', () => {
|
|
1058
|
+
const source = `<select className="cs-select" value={rule.field} onChange={(e) => change(e.target.value)}>
|
|
1059
|
+
{FIELDS.map((f) => <option key={f}>{f}</option>)}
|
|
1060
|
+
</select>`;
|
|
1061
|
+
expect(check('no-native-select', source)).toHaveLength(1);
|
|
1062
|
+
expect(check('no-native-select', `{/* a \`<select>\` here would be an OS list */}\n<SelectField options={o} />`)).toHaveLength(0);
|
|
1063
|
+
expect(check('no-native-select', `<selection-bar />`)).toHaveLength(0);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1057
1066
|
test('the styled controls, and a comment that names it, are not one', () => {
|
|
1058
1067
|
expect(check('no-native-select', `<SelectField value={sort} options={SORTS} />`)).toHaveLength(0);
|
|
1059
1068
|
expect(check('no-native-select', `// was a <NativeSelect> until 2026-10-10\n<PropertyPicker name="Type" />`)).toHaveLength(0);
|
|
@@ -1442,6 +1451,36 @@ describe('--baseline (a consuming repo\'s ratchet)', () => {
|
|
|
1442
1451
|
);
|
|
1443
1452
|
});
|
|
1444
1453
|
|
|
1454
|
+
test('🔴 a row for a rule this belt does not have yet only warns — the app is behind a bump, not drifting (task 3098)', async () => {
|
|
1455
|
+
// 2026-10-13: a new rule's row went into every app's baseline with the belt still
|
|
1456
|
+
// unpublished, and the six apps on the LOCKED belt went red at once.
|
|
1457
|
+
await withFixture(
|
|
1458
|
+
{ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2, 'no-rule-from-the-future': 0 }) },
|
|
1459
|
+
async (dir) => {
|
|
1460
|
+
const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
|
|
1461
|
+
expect(report).toContain('this cursedbelt does not have yet: no-rule-from-the-future');
|
|
1462
|
+
expect(report).not.toContain('nobody scans');
|
|
1463
|
+
expect(report).toContain('✓ no-hardcoded-colors: 2 violation(s)');
|
|
1464
|
+
expect(code).toBe(0);
|
|
1465
|
+
},
|
|
1466
|
+
);
|
|
1467
|
+
});
|
|
1468
|
+
|
|
1469
|
+
test('a row ahead of the belt does not excuse a row the belt knows and does not scan', async () => {
|
|
1470
|
+
await withFixture(
|
|
1471
|
+
{
|
|
1472
|
+
...FIXTURE,
|
|
1473
|
+
'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2, 'no-raw-zindex': 0, 'no-rule-from-the-future': 0 }),
|
|
1474
|
+
},
|
|
1475
|
+
async (dir) => {
|
|
1476
|
+
const { code, report } = await runCli(dir, ['no-hardcoded-colors', '--baseline', 'scripts/baseline.json', 'src']);
|
|
1477
|
+
expect(code).toBe(2);
|
|
1478
|
+
expect(report).toContain('row(s) for rules nobody scans: no-raw-zindex');
|
|
1479
|
+
expect(report).not.toContain('nobody scans: no-raw-zindex, no-rule-from-the-future');
|
|
1480
|
+
},
|
|
1481
|
+
);
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1445
1484
|
test('a scanned rule with no row is refused — a silent 0 demands the whole pile at once', async () => {
|
|
1446
1485
|
await withFixture({ ...FIXTURE, 'scripts/baseline.json': JSON.stringify({ 'no-hardcoded-colors': 2 }) }, async (dir) => {
|
|
1447
1486
|
const { code, report } = await runCli(dir, [
|
|
@@ -1651,6 +1690,29 @@ describe('no-hand-rolled-property-list', () => {
|
|
|
1651
1690
|
});
|
|
1652
1691
|
});
|
|
1653
1692
|
|
|
1693
|
+
describe('no-raw-kbd', () => {
|
|
1694
|
+
const rule = RULES['no-raw-kbd'];
|
|
1695
|
+
test('a raw <kbd> in app source is a violation — desk\'s switcher foot drew its own', () => {
|
|
1696
|
+
const found = rule.check('src/ui/QuickSwitcher.tsx', `<footer>\n <kbd>↑</kbd> move\n</footer>`);
|
|
1697
|
+
expect(found).toHaveLength(1);
|
|
1698
|
+
expect(found[0]?.line).toBe(2);
|
|
1699
|
+
});
|
|
1700
|
+
test('the belt\'s Kbd is the fix, and is not flagged', () => {
|
|
1701
|
+
expect(rule.check('src/ui/QuickSwitcher.tsx', `<Kbd keys="$mod+k" /> <Kbd>↑</Kbd>`)).toHaveLength(0);
|
|
1702
|
+
});
|
|
1703
|
+
test('an opt-out with a reason on the block above is honoured; a comment mentioning <kbd> is not a violation', () => {
|
|
1704
|
+
expect(
|
|
1705
|
+
rule.check('src/ui/Menu.tsx', `// guardrails-ignore no-raw-kbd: a menu accelerator is bare text,\n// not a cap\n<kbd>{shortcut}</kbd>`),
|
|
1706
|
+
).toHaveLength(0);
|
|
1707
|
+
expect(rule.check('src/ui/Menu.tsx', `// the old <kbd> here was hand-drawn`)).toHaveLength(0);
|
|
1708
|
+
});
|
|
1709
|
+
test('exempt by construction: Kbd itself, fixtures and stylesheets', () => {
|
|
1710
|
+
for (const file of ['src/react/palette/Kbd.tsx', 'src/ui/Foot.spec.tsx', 'src/ui/foot.css']) {
|
|
1711
|
+
expect(rule.check(file, `<kbd>K</kbd>`)).toHaveLength(0);
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1654
1716
|
describe('no-raw-nav', () => {
|
|
1655
1717
|
test('🔴 flags a hand-rolled <nav> — the header music shipped on 2026-09-25', () => {
|
|
1656
1718
|
expect(
|
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
// `Property`. On the ELEMENT, like `no-raw-data-table`. The belt's own PropertyList, the
|
|
77
77
|
// markdown/rich-text renderers and `*.spec|test.*` fixtures are exempt by construction.
|
|
78
78
|
//
|
|
79
|
+
// no-raw-kbd — forge task 3087 (2026-10-13): no hand-drawn `<kbd>` — a key is cursedbelt's
|
|
80
|
+
// `Kbd` (`<Kbd keys="$mod+k" />` → ⌘K / Ctrl K), and an app's `?` list is `ShortcutSheet`.
|
|
81
|
+
// On the ELEMENT. The belt's own `Kbd`, stylesheets and `*.spec|test.*` fixtures are exempt.
|
|
82
|
+
//
|
|
79
83
|
// no-raw-data-table — DESIGN.md §3 "Visual Formatting & Data Presentation" (owner, 2026-08-14): no hand-rolled `<table>` —
|
|
80
84
|
// use `cursedbelt/react/data-table`'s `DataTable`, which carries sorting, a global
|
|
81
85
|
// filter, column visibility, pagination and the phone card tier. The rule is on the
|
|
@@ -113,7 +117,7 @@
|
|
|
113
117
|
// Semantic classes are never matched — `text-xs` IS 12px. A non-text glyph takes
|
|
114
118
|
// the per-line opt-out.
|
|
115
119
|
//
|
|
116
|
-
// no-native-select — owner, 2026-10-10: a `NativeSelect`'s list is drawn by the OS, where no
|
|
120
|
+
// no-native-select — owner, 2026-10-10: a `NativeSelect`'s (or raw `<select>`'s) list is drawn by the OS, where no
|
|
117
121
|
// stylesheet reaches it, so it breaks the app's cohesion. `SelectField` / `ComboboxField` /
|
|
118
122
|
// `PropertyPicker` / `ChoiceMenu` instead; a use where the OS picker is genuinely better says
|
|
119
123
|
// why with `// guardrails-ignore no-native-select: <why>` on the line above. Ratcheted per app.
|
|
@@ -1057,6 +1061,7 @@ const CONTROL_HEIGHT_FILES = new Set([
|
|
|
1057
1061
|
"IconButton.tsx",
|
|
1058
1062
|
"Input.tsx",
|
|
1059
1063
|
"SelectField.tsx",
|
|
1064
|
+
"SelectFieldImpl.tsx",
|
|
1060
1065
|
"ComboboxField.tsx",
|
|
1061
1066
|
"Autocomplete.tsx",
|
|
1062
1067
|
"TagsInput.tsx",
|
|
@@ -1296,6 +1301,48 @@ const noHandRolledPropertyList: Rule = {
|
|
|
1296
1301
|
},
|
|
1297
1302
|
};
|
|
1298
1303
|
|
|
1304
|
+
// ── no-raw-kbd (forge task 3087, 2026-10-13) ───────────────────────────────────
|
|
1305
|
+
// By 2026-10-13 four apps drew the same key four ways: vault's cap was mono with a 2px bottom
|
|
1306
|
+
// border, desk's a bare browser `<kbd>`, family's its own grey box, music's another — and ⌘ showed
|
|
1307
|
+
// to a Windows reader wherever a glyph had been typed by hand. cursedbelt 5.79.0's `Kbd` is the
|
|
1308
|
+
// one cap, formatted per platform from a binding (`<Kbd keys="$mod+k" />`), or drawn as written
|
|
1309
|
+
// from children (`<Kbd>↑</Kbd>`); `ShortcutSheet` is the `?` list built from it.
|
|
1310
|
+
//
|
|
1311
|
+
// So the rule is on the ELEMENT, like `no-hand-rolled-property-list`: a `<kbd` in app source is
|
|
1312
|
+
// a violation. Exempt by construction: `Kbd` itself, `DropdownMenu`'s accelerator column (bare
|
|
1313
|
+
// text in the platform's notation, as macOS's and Linear's menus draw it — not a cap), and
|
|
1314
|
+
// `*.spec|test.*` fixtures. A key named in
|
|
1315
|
+
// prose is still a key — it is `<Kbd>`; a BUTTON named in prose is `<strong>`, never a cap.
|
|
1316
|
+
const RAW_KBD_RE = /<kbd\b/g;
|
|
1317
|
+
const RAW_KBD_IGNORE_RE = /guardrails-ignore\s+no-raw-kbd\s*:/;
|
|
1318
|
+
const RAW_KBD_EXEMPT_FRAGMENTS = ["react/palette/Kbd.tsx", "react/components/DropdownMenu.tsx"];
|
|
1319
|
+
|
|
1320
|
+
const noRawKbd: Rule = {
|
|
1321
|
+
name: "no-raw-kbd",
|
|
1322
|
+
description:
|
|
1323
|
+
"forge task 3087: no hand-drawn <kbd> — a key cap is cursedbelt's Kbd (cursedbelt/react: <Kbd keys=\"$mod+k\" /> draws ⌘K on a Mac and Ctrl K elsewhere; <Kbd>↑</Kbd> draws a literal), and an app's ? list is ShortcutSheet. A raw <kbd> is how every app picks its own cap, and how ⌘ reaches a Windows reader.",
|
|
1324
|
+
check(filePath, source) {
|
|
1325
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1326
|
+
if (extname(normalized) === ".css" || isTestSource(normalized)) return [];
|
|
1327
|
+
if (RAW_KBD_EXEMPT_FRAGMENTS.some((fragment) => normalized.includes(fragment))) return [];
|
|
1328
|
+
const violations: Violation[] = [];
|
|
1329
|
+
const scannable = blankComments(source);
|
|
1330
|
+
const lines = source.split("\n");
|
|
1331
|
+
for (const match of scannable.matchAll(RAW_KBD_RE)) {
|
|
1332
|
+
const line = lineOf(source, match.index ?? 0);
|
|
1333
|
+
const block: string[] = [];
|
|
1334
|
+
for (let i = line - 2; i >= 0 && block.length < 12; i--) {
|
|
1335
|
+
const raw = lines[i] ?? "";
|
|
1336
|
+
if (raw.trim() === "") break;
|
|
1337
|
+
block.push(raw);
|
|
1338
|
+
}
|
|
1339
|
+
if (RAW_KBD_IGNORE_RE.test(block.join("\n"))) continue;
|
|
1340
|
+
violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
1341
|
+
}
|
|
1342
|
+
return violations;
|
|
1343
|
+
},
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1299
1346
|
// ── no-unguarded-editing-modal ──────────────────────────────────────────────
|
|
1300
1347
|
// The owner lost a half-typed password by clicking outside an editing modal,
|
|
1301
1348
|
// which dismissed it and threw the form away with no confirmation. `Modal`/
|
|
@@ -2041,7 +2088,9 @@ const noSelectAsActionMenu: Rule = {
|
|
|
2041
2088
|
// macOS Chrome a grey system menu no stylesheet reaches — so every app that used one had a control
|
|
2042
2089
|
// that was not the kit's. `no-select-as-action-menu` caught only the action-menu shape of it.
|
|
2043
2090
|
//
|
|
2044
|
-
// Every app now counts its `<NativeSelect` uses
|
|
2091
|
+
// Every app now counts its `<NativeSelect` uses — and its raw `<select>`s, the same OS list without
|
|
2092
|
+
// even the kit's frame (forge task 3097: desk had eight under `no-raw-inputs`, which no count of
|
|
2093
|
+
// NativeSelect could see) — in its two-sided ratchet, so the number only falls:
|
|
2045
2094
|
// `SelectField` / `ComboboxField` for a field, `PropertyPicker` for a record's property pill,
|
|
2046
2095
|
// `ChoiceMenu` to choose-and-act. A use that is genuinely right — a form on a phone where the OS
|
|
2047
2096
|
// wheel IS the better control — says so on the line above:
|
|
@@ -2051,18 +2100,19 @@ const NATIVE_SELECT_IGNORE_RE = /guardrails-ignore\s+no-native-select\s*:/;
|
|
|
2051
2100
|
const noNativeSelect: Rule = {
|
|
2052
2101
|
name: "no-native-select",
|
|
2053
2102
|
description:
|
|
2054
|
-
"Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `PropertyPicker` for a record's property (Type, Folder, Tags), or `ChoiceMenu` to choose-and-act. Where the OS picker is the better control, say why on the line above: `// guardrails-ignore no-native-select: <why>`.",
|
|
2103
|
+
"Owner, 2026-10-10: unstyled dropdowns break the app's cohesiveness. A `NativeSelect`'s (or a raw `<select>`'s) list is drawn by the OS, outside the page, where no stylesheet reaches it. Use `SelectField` / `ComboboxField` for a form field, `PropertyPicker` for a record's property (Type, Folder, Tags), or `ChoiceMenu` to choose-and-act. Where the OS picker is the better control, say why on the line above: `// guardrails-ignore no-native-select: <why>`.",
|
|
2055
2104
|
check(filePath, source) {
|
|
2056
2105
|
const unix = filePath.replace(/\\/g, "/");
|
|
2057
2106
|
// The kit is held to its own law (forge task 3063: its DataTable filter builder and
|
|
2058
2107
|
// FocusViewer drew OS menus into every app whose count read 0). Only the control itself
|
|
2059
2108
|
// may render one.
|
|
2060
2109
|
if (/(^|\/)(?:packages|libs)\/cursedbelt\/src\/react\/components\/NativeSelect\.tsx$/.test(unix)) return [];
|
|
2110
|
+
if (isBeltOwnSource(filePath) && /(^|\/)src\/react\/components\/NativeSelect\.tsx$/.test(unix)) return [];
|
|
2061
2111
|
if (/\.(?:spec|test)\.[tj]sx?$/.test(unix)) return [];
|
|
2062
2112
|
const scannable = blankComments(source);
|
|
2063
2113
|
const lines = source.split("\n");
|
|
2064
2114
|
const out: Violation[] = [];
|
|
2065
|
-
for (const match of scannable.matchAll(/<NativeSelect\b/g)) {
|
|
2115
|
+
for (const match of scannable.matchAll(/<(?:NativeSelect|select)\b/g)) {
|
|
2066
2116
|
const line = lineOf(source, match.index ?? 0);
|
|
2067
2117
|
if (NATIVE_SELECT_IGNORE_RE.test(lines[line - 1] ?? "") || NATIVE_SELECT_IGNORE_RE.test(blockAbove(lines, line))) continue;
|
|
2068
2118
|
out.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
@@ -3022,6 +3072,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
3022
3072
|
[noRawZindex.name]: noRawZindex,
|
|
3023
3073
|
[noRawDataTable.name]: noRawDataTable,
|
|
3024
3074
|
[noHandRolledPropertyList.name]: noHandRolledPropertyList,
|
|
3075
|
+
[noRawKbd.name]: noRawKbd,
|
|
3025
3076
|
[noTailwindPalette.name]: noTailwindPalette,
|
|
3026
3077
|
[themeColoursResolve.name]: themeColoursResolve,
|
|
3027
3078
|
[noRawTitleAttr.name]: noRawTitleAttr,
|
|
@@ -3206,12 +3257,25 @@ function parseFlatBaseline(json: string, label: string): FlatBaseline {
|
|
|
3206
3257
|
function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
|
|
3207
3258
|
const complaints: string[] = [];
|
|
3208
3259
|
const missing = ruleNames.filter((name) => !(name in rows));
|
|
3209
|
-
const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
|
|
3260
|
+
const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name) && name in RULES);
|
|
3210
3261
|
if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
|
|
3211
3262
|
if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
|
|
3212
3263
|
return complaints;
|
|
3213
3264
|
}
|
|
3214
3265
|
|
|
3266
|
+
/**
|
|
3267
|
+
* Rows naming a rule this belt has never heard of — forge task 3098. A new rule lands
|
|
3268
|
+
* in the belt's tree and every consumer's baseline gets its row in the same pass, but a
|
|
3269
|
+
* consumer still on the LOCKED belt then carries a row ahead of its library: not drift,
|
|
3270
|
+
* a bump not made yet. Refusing it red every app off the tree at once (2026-10-13, six
|
|
3271
|
+
* fast-lane deploys). It is a warning; a row this belt KNOWS and does not scan is still
|
|
3272
|
+
* refused above, and a mistyped row cannot hide anything — the real rule's row is then
|
|
3273
|
+
* missing, which is red.
|
|
3274
|
+
*/
|
|
3275
|
+
function rowsAheadOfThisBelt(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
|
|
3276
|
+
return Object.keys(rows).filter((name) => !ruleNames.includes(name) && !(name in RULES));
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3215
3279
|
// ── `--reimplements-from-belt`: the name list, off this package's own exports ───
|
|
3216
3280
|
// `no-local-reimplementation` matches app-declared names against the ones cursedbelt
|
|
3217
3281
|
// exports (384 of them as this lands). A hand-kept copy of a dependency's export list
|
|
@@ -3322,6 +3386,13 @@ function main() {
|
|
|
3322
3386
|
console.error((error as Error).message);
|
|
3323
3387
|
process.exit(2);
|
|
3324
3388
|
}
|
|
3389
|
+
const ahead = rowsAheadOfThisBelt(flatBaseline, ruleNames);
|
|
3390
|
+
if (ahead.length > 0) {
|
|
3391
|
+
console.error(
|
|
3392
|
+
`⚠ ${baselinePath} has row(s) for rule(s) this cursedbelt does not have yet: ${ahead.join(", ")} — ` +
|
|
3393
|
+
"not scanned; they apply once the app is on a cursedbelt that ships them.",
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
3325
3396
|
const complaints = baselineDisagreements(flatBaseline, ruleNames);
|
|
3326
3397
|
if (complaints.length > 0) {
|
|
3327
3398
|
console.error(`${baselinePath} and the rules being scanned disagree:`);
|
|
@@ -3329,7 +3400,7 @@ function main() {
|
|
|
3329
3400
|
// A row with no rule stops here. A rule with no row — what a belt bump that adds a
|
|
3330
3401
|
// rule produces — is still scanned, so the run prints the exact row to record
|
|
3331
3402
|
// instead of making the bumping agent find the count by hand; it exits 2 at the end.
|
|
3332
|
-
if (Object.keys(flatBaseline).some((name) => !ruleNames.includes(name))) process.exit(2);
|
|
3403
|
+
if (Object.keys(flatBaseline).some((name) => !ruleNames.includes(name) && name in RULES)) process.exit(2);
|
|
3333
3404
|
for (const name of ruleNames) if (!(name in flatBaseline)) missingRows.add(name);
|
|
3334
3405
|
}
|
|
3335
3406
|
}
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -195,6 +195,7 @@ export const AREAS: readonly Area[] = [
|
|
|
195
195
|
'components/Section.tsx',
|
|
196
196
|
'components/SegmentedControl.tsx',
|
|
197
197
|
'components/SelectField.tsx',
|
|
198
|
+
'components/SelectFieldImpl.tsx',
|
|
198
199
|
'components/Skeleton.tsx',
|
|
199
200
|
'components/Slider.tsx',
|
|
200
201
|
'components/Spinner.tsx',
|
|
@@ -291,6 +292,7 @@ export const AREAS: readonly Area[] = [
|
|
|
291
292
|
// The breadcrumb's menus and pivot are `import()`ed by `nav/BreadcrumbTrail.tsx` (lazyImportFault).
|
|
292
293
|
roots: ['folder-tree', 'nav/BreadcrumbMenus.tsx', 'nav/BreadcrumbPivot.tsx'],
|
|
293
294
|
},
|
|
295
|
+
{ name: 'kanban', why: 'the Kanban board — columns of dragged cards; an app with no board renders none of it', roots: ['components/KanbanBoard.tsx'] },
|
|
294
296
|
{ name: 'keep-awake', why: 'the keep-awake toggle and its badge', roots: ['keep-awake'] },
|
|
295
297
|
{ name: 'layout-engine', why: 'the persisted pane/grid layout engine — apps with a fixed layout render none of it', roots: ['layoutEngine'] },
|
|
296
298
|
{ name: 'markdown', why: 'the rendered-markdown document', roots: ['markdown', 'markdown/MarkdownDoc.tsx'] },
|
|
@@ -26,6 +26,17 @@ describe('HudZoomControls (CNV-3)', () => {
|
|
|
26
26
|
expect(calls).toEqual(['out', 'in', 'fit', 'center']);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
+
test('vertical: a column with + on top and − under the percentage, announced as one', () => {
|
|
30
|
+
const { getByRole } = render(
|
|
31
|
+
<HudZoomControls orientation='vertical' scale={0.5} onZoomIn={() => {}} onZoomOut={() => {}} onFit={() => {}} />,
|
|
32
|
+
);
|
|
33
|
+
const group = getByRole('group', { name: 'View' });
|
|
34
|
+
expect(group.getAttribute('aria-orientation')).toBe('vertical');
|
|
35
|
+
expect(group.className).toContain('flex-col');
|
|
36
|
+
const names = Array.from(group.querySelectorAll('button')).map((b) => b.getAttribute('aria-label'));
|
|
37
|
+
expect(names).toEqual(['Zoom in', 'Zoom out', 'Fit to screen']);
|
|
38
|
+
});
|
|
39
|
+
|
|
29
40
|
test('no Center key when the canvas has nothing to centre on', () => {
|
|
30
41
|
const { queryByRole } = render(
|
|
31
42
|
<HudZoomControls scale={1} onZoomIn={() => {}} onZoomOut={() => {}} onFit={() => {}} />,
|
|
@@ -60,10 +60,16 @@ export interface HudZoomControlsProps {
|
|
|
60
60
|
/** The canvas's own utilities, drawn before the zoom keys in the same pill (CNV-4 — utilities
|
|
61
61
|
* only; preferences and primary actions belong in the page's panel). */
|
|
62
62
|
children?: ReactNode;
|
|
63
|
+
/**
|
|
64
|
+
* `vertical` stacks the keys down an edge — +, the zoom %, −, Fit, Center — the way Figma and
|
|
65
|
+
* Miro draw them on a phone, where a bottom-centre pill sits over the drawing and the thumb's
|
|
66
|
+
* sheet handle. Place it with `absolute right-3 top-1/2 -translate-y-1/2` or the like.
|
|
67
|
+
*/
|
|
68
|
+
orientation?: 'horizontal' | 'vertical';
|
|
63
69
|
className?: string;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
/** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center. */
|
|
72
|
+
/** CNV-3's essentials as one pill: −, the zoom %, +, Fit and Center (a column: + on top). */
|
|
67
73
|
export function HudZoomControls({
|
|
68
74
|
scale,
|
|
69
75
|
onZoomIn,
|
|
@@ -73,20 +79,38 @@ export function HudZoomControls({
|
|
|
73
79
|
fitLabel = 'Fit to screen',
|
|
74
80
|
centerLabel = 'Center on the selection',
|
|
75
81
|
children,
|
|
82
|
+
orientation = 'horizontal',
|
|
76
83
|
className,
|
|
77
84
|
}: HudZoomControlsProps) {
|
|
85
|
+
const vertical = orientation === 'vertical';
|
|
86
|
+
const zoomOut = (
|
|
87
|
+
<HudButton label='Zoom out' onClick={onZoomOut}>
|
|
88
|
+
<IconZoomOut />
|
|
89
|
+
</HudButton>
|
|
90
|
+
);
|
|
91
|
+
const zoomIn = (
|
|
92
|
+
<HudButton label='Zoom in' onClick={onZoomIn}>
|
|
93
|
+
<IconZoomIn />
|
|
94
|
+
</HudButton>
|
|
95
|
+
);
|
|
78
96
|
return (
|
|
79
|
-
<HudPill
|
|
97
|
+
<HudPill
|
|
98
|
+
role='group'
|
|
99
|
+
aria-label='View'
|
|
100
|
+
aria-orientation={orientation}
|
|
101
|
+
data-orientation={orientation}
|
|
102
|
+
className={cn('gap-0.5 rounded-lg px-1 py-1', vertical && 'flex-col py-1.5', className)}
|
|
103
|
+
>
|
|
80
104
|
{children}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
{/* Up the column is in: + on top, as on every map. */}
|
|
106
|
+
{vertical ? zoomIn : zoomOut}
|
|
107
|
+
<span
|
|
108
|
+
className={cn('text-center text-xs text-muted-foreground tabular-nums', vertical ? 'w-full' : 'min-w-11')}
|
|
109
|
+
data-testid='hud-zoom'
|
|
110
|
+
>
|
|
85
111
|
{Math.round(scale * 100)}%
|
|
86
112
|
</span>
|
|
87
|
-
|
|
88
|
-
<IconZoomIn />
|
|
89
|
-
</HudButton>
|
|
113
|
+
{vertical ? zoomOut : zoomIn}
|
|
90
114
|
<HudButton label={fitLabel} onClick={onFit}>
|
|
91
115
|
<IconFit />
|
|
92
116
|
</HudButton>
|
|
@@ -302,6 +302,46 @@ describe('AppShell', () => {
|
|
|
302
302
|
expect(q(account as HTMLElement, '[data-testid="avatar"]')).not.toBeNull();
|
|
303
303
|
});
|
|
304
304
|
|
|
305
|
+
test('headerCenter: a one-place app’s toolbar takes the center zone, in place of the command spine', () => {
|
|
306
|
+
const { container } = render(
|
|
307
|
+
<AppShell navHeader={<h1>App</h1>} headerCenter={<div data-testid='toolbar'>tools</div>}>
|
|
308
|
+
x
|
|
309
|
+
</AppShell>,
|
|
310
|
+
);
|
|
311
|
+
const center = q(container, '[data-cb-header-zone="center"]') as HTMLElement;
|
|
312
|
+
expect(q(center, '[data-testid="toolbar"]')).not.toBeNull();
|
|
313
|
+
expect(q(container, '[data-cb-commandbar]')).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('headerCenter is ignored when nav gives the header places to draw', () => {
|
|
317
|
+
const { container } = render(
|
|
318
|
+
<AppShell nav={nav} onNavigate={() => {}} headerCenter={<div data-testid='toolbar'>tools</div>}>
|
|
319
|
+
x
|
|
320
|
+
</AppShell>,
|
|
321
|
+
);
|
|
322
|
+
expect(q(container, '[data-testid="toolbar"]')).toBeNull();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('immersive: the header and the bottom chrome step aside, a banner stays, the content stays', () => {
|
|
326
|
+
const { container, rerender } = render(
|
|
327
|
+
<AppShell nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
|
|
328
|
+
page content
|
|
329
|
+
</AppShell>,
|
|
330
|
+
);
|
|
331
|
+
expect(q(container, '[data-cb-appheader]')).not.toBeNull();
|
|
332
|
+
expect(q(container, '[data-cb-placebar]')).not.toBeNull();
|
|
333
|
+
rerender(
|
|
334
|
+
<AppShell immersive nav={nav} onNavigate={() => {}} account={<span>A</span>} banners={[{ id: 'b', tone: 'error', message: 'Down' }]}>
|
|
335
|
+
page content
|
|
336
|
+
</AppShell>,
|
|
337
|
+
);
|
|
338
|
+
expect(q(container, '[data-cb-appshell]')?.hasAttribute('data-cb-immersive')).toBe(true);
|
|
339
|
+
expect(q(container, '[data-cb-appheader]')).toBeNull();
|
|
340
|
+
expect(q(container, '[data-cb-placebar]')).toBeNull();
|
|
341
|
+
expect(container.textContent).toContain('Down');
|
|
342
|
+
expect(container.textContent).toContain('page content');
|
|
343
|
+
});
|
|
344
|
+
|
|
305
345
|
test('the active place carries aria-current=page in every rail variant and the PlaceBar', () => {
|
|
306
346
|
const { container } = render(
|
|
307
347
|
<AppShell nav={station} activeHref='/console' onNavigate={() => {}}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ElementType, type ReactNode, type Ref, useEffect,
|
|
1
|
+
import { type ElementType, type ReactNode, type Ref, useEffect, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
allowAllGate,
|
|
4
4
|
deriveSideNavDomains,
|
|
@@ -13,6 +13,7 @@ import { whenIdle } from '../lib/whenIdle.js';
|
|
|
13
13
|
import { CommandBar } from '../nav/CommandBar.js';
|
|
14
14
|
import { type IconResolver, isDomainActive, type NavBadgeTone, renderIcon, routeClick } from '../nav/navShared.js';
|
|
15
15
|
import { PlaceBar } from '../nav/PlaceBar.js';
|
|
16
|
+
import { usePublishedBottomChrome } from '../hooks/usePublishedBottomChrome.js';
|
|
16
17
|
import { SideNav } from '../nav/SideNav.js';
|
|
17
18
|
import { TopBarPlaces } from '../nav/TopBar.js';
|
|
18
19
|
import { CommandSourceProvider, useCommandSources } from '../palette/commandSources.js';
|
|
@@ -137,6 +138,22 @@ export interface AppShellProps {
|
|
|
137
138
|
/** App slot at the trailing edge of the command spine (e.g. presence dots). */
|
|
138
139
|
commandBarExtra?: ReactNode;
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The header's center zone, for an app with no places and no command spine: a one-place
|
|
143
|
+
* canvas app's own toolbar (its depth switch, Save, Export) folded INTO the header instead of
|
|
144
|
+
* stacked under it as a second bar. On a phone that row is a quarter of what the canvas gets
|
|
145
|
+
* (arch, forge task 3059). Ignored when `nav` gives the header places to draw; otherwise it
|
|
146
|
+
* REPLACES the command spine (`onOpenCommand`, `onCapture`, `commandBarExtra`) — put them in it.
|
|
147
|
+
*/
|
|
148
|
+
headerCenter?: ReactNode;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Presentation mode: the header and the bottom chrome (dock, `PlaceBar`) step aside so the
|
|
152
|
+
* content has the whole viewport, until the app turns it off — a canvas's double-tap on blank
|
|
153
|
+
* space (Figma's and Miro's ⌘\). Banners stay: one is a fault the reader must still see.
|
|
154
|
+
*/
|
|
155
|
+
immersive?: boolean;
|
|
156
|
+
|
|
140
157
|
/** Account chrome behind the avatar (profile/settings/sign-out) — NOT a nav door
|
|
141
158
|
* (§6). Rendered at the trailing edge of the top spine. */
|
|
142
159
|
account?: ReactNode;
|
|
@@ -262,6 +279,8 @@ function Shell({
|
|
|
262
279
|
commandLabel,
|
|
263
280
|
commandHint,
|
|
264
281
|
commandBarExtra,
|
|
282
|
+
headerCenter,
|
|
283
|
+
immersive = false,
|
|
265
284
|
account,
|
|
266
285
|
bottomDock,
|
|
267
286
|
palette,
|
|
@@ -302,8 +321,8 @@ function Shell({
|
|
|
302
321
|
else window.location.assign(path);
|
|
303
322
|
};
|
|
304
323
|
|
|
305
|
-
const showSpine = Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra);
|
|
306
|
-
const showBottom = Boolean(bottomDock || nav);
|
|
324
|
+
const showSpine = !immersive && Boolean(nav || navHeader || account || openCommand || onCapture || commandBarExtra || headerCenter);
|
|
325
|
+
const showBottom = !immersive && Boolean(bottomDock || nav);
|
|
307
326
|
const bottomChromeRef = usePublishedBottomChrome(showBottom);
|
|
308
327
|
// Chrome that lets the field through. The rails/spine keep their own directional
|
|
309
328
|
// border either way — only the FILL changes, so the layout is byte-identical and
|
|
@@ -344,6 +363,7 @@ function Shell({
|
|
|
344
363
|
<div
|
|
345
364
|
data-cb-appshell=''
|
|
346
365
|
data-cb-nav-pattern={pattern}
|
|
366
|
+
data-cb-immersive={immersive ? '' : undefined}
|
|
347
367
|
className={cn(
|
|
348
368
|
'flex h-dvh flex-col overflow-hidden bg-background text-foreground',
|
|
349
369
|
// `isolation: isolate` is load-bearing, not decoration: the blobs blend with
|
|
@@ -397,6 +417,8 @@ function Shell({
|
|
|
397
417
|
linkProps: navLinkComponent ? undefined : { onClick: routeClick(d.path, onNavigate) },
|
|
398
418
|
}))}
|
|
399
419
|
/>
|
|
420
|
+
) : headerCenter ? (
|
|
421
|
+
headerCenter
|
|
400
422
|
) : (
|
|
401
423
|
commandBar
|
|
402
424
|
)}
|
|
@@ -507,27 +529,3 @@ function Shell({
|
|
|
507
529
|
);
|
|
508
530
|
}
|
|
509
531
|
|
|
510
|
-
/**
|
|
511
|
-
* Publishes the bottom chrome's height (dock + `PlaceBar` + safe area) as
|
|
512
|
-
* `--cb-bottom-chrome-h` on the root, so what floats over the page — the `Toaster`'s stack
|
|
513
|
-
* (OVL-9) — rises above the navigation instead of covering it. A `PlaceBar` hidden at `md` up
|
|
514
|
-
* measures 0, so the desktop stack drops back to the corner by itself.
|
|
515
|
-
*/
|
|
516
|
-
function usePublishedBottomChrome(shown: boolean) {
|
|
517
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
518
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `shown` mounts or drops the element the ref points at.
|
|
519
|
-
useLayoutEffect(() => {
|
|
520
|
-
const el = ref.current;
|
|
521
|
-
const root = document.documentElement;
|
|
522
|
-
if (!el) return;
|
|
523
|
-
const publish = () => root.style.setProperty('--cb-bottom-chrome-h', `${el.offsetHeight}px`);
|
|
524
|
-
publish();
|
|
525
|
-
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(publish);
|
|
526
|
-
observer?.observe(el);
|
|
527
|
-
return () => {
|
|
528
|
-
observer?.disconnect();
|
|
529
|
-
root.style.removeProperty('--cb-bottom-chrome-h');
|
|
530
|
-
};
|
|
531
|
-
}, [shown]);
|
|
532
|
-
return ref;
|
|
533
|
-
}
|