cursedbelt 5.96.0 → 5.97.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/components/AppShell.js +6 -1
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/react/components/ConfirmDialog.js +1 -1
- package/dist/react/components/ConfirmDialog.js.map +1 -1
- package/dist/react/components/PaneSplit.d.ts +11 -2
- package/dist/react/components/PaneSplit.d.ts.map +1 -1
- package/dist/react/components/PaneSplit.js +12 -11
- package/dist/react/components/PaneSplit.js.map +1 -1
- package/dist/react/components/PanelGrid.d.ts +14 -8
- package/dist/react/components/PanelGrid.d.ts.map +1 -1
- package/dist/react/components/PanelGrid.js +10 -3
- package/dist/react/components/PanelGrid.js.map +1 -1
- package/dist/react/components/SplitPane.d.ts +3 -9
- package/dist/react/components/SplitPane.d.ts.map +1 -1
- package/dist/react/components/SplitPane.js +0 -3
- package/dist/react/components/SplitPane.js.map +1 -1
- package/dist/react/components/TagsEditor.d.ts +15 -0
- package/dist/react/components/TagsEditor.d.ts.map +1 -0
- package/dist/react/components/TagsEditor.js +93 -0
- package/dist/react/components/TagsEditor.js.map +1 -0
- package/dist/react/components/TagsPromptDialog.d.ts +25 -0
- package/dist/react/components/TagsPromptDialog.d.ts.map +1 -0
- package/dist/react/components/TagsPromptDialog.js +38 -0
- package/dist/react/components/TagsPromptDialog.js.map +1 -0
- package/dist/react/components/paneSplit.css +8 -0
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +2 -0
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +11 -1
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +3 -2
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +16 -5
- package/dist/react/filter-rail/railResize.d.ts.map +1 -1
- package/dist/react/filter-rail/railResize.js +27 -9
- package/dist/react/filter-rail/railResize.js.map +1 -1
- package/dist/react/filter-rail/railWidth.d.ts +10 -5
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
- package/dist/react/filter-rail/railWidth.js +13 -8
- package/dist/react/filter-rail/railWidth.js.map +1 -1
- package/dist/react/hooks/longPressContextMenu.d.ts +10 -1
- package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -1
- package/dist/react/hooks/longPressContextMenu.js +37 -3
- package/dist/react/hooks/longPressContextMenu.js.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/paneReach.d.ts +36 -0
- package/dist/react/lib/paneReach.d.ts.map +1 -0
- package/dist/react/lib/paneReach.js +72 -0
- package/dist/react/lib/paneReach.js.map +1 -0
- package/dist/react/lib/tagList.d.ts +16 -0
- package/dist/react/lib/tagList.d.ts.map +1 -0
- package/dist/react/lib/tagList.js +30 -0
- package/dist/react/lib/tagList.js.map +1 -0
- package/dist/react/master-detail/MasterDetail.d.ts +6 -3
- package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
- package/dist/react/master-detail/MasterDetail.js +3 -2
- package/dist/react/master-detail/MasterDetail.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts +8 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +10 -1
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +3 -1
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +10 -5
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +3 -1
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +14 -6
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +3 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +14 -6
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +3 -1
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +13 -5
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +27 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +39 -0
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/videoEnhance.d.ts +1 -1
- package/dist/react/media/videoEnhance.js +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +4 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/tagsPrompt.d.ts +15 -0
- package/dist/react/tagsPrompt.d.ts.map +1 -0
- package/dist/react/tagsPrompt.js +30 -0
- package/dist/react/tagsPrompt.js.map +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/fields.css +4 -4
- package/dist/styles-areas/overlays.css +3 -2
- package/dist/styles-utilities.css +7 -0
- package/dist/testing/phoneRules.js +3 -3
- package/dist/testing/phoneRules.js.map +1 -1
- package/dist/theme.css +13 -0
- package/package.json +8 -1
- package/scripts/guardrailsEnforce.spec.ts +25 -0
- package/scripts/guardrailsEnforce.ts +61 -0
- package/scripts/styleAreas.ts +5 -0
- package/src/looks.spec.ts +34 -0
- package/src/react/components/AppShell.spec.tsx +23 -0
- package/src/react/components/AppShell.tsx +6 -1
- package/src/react/components/ConfirmDialog.spec.tsx +41 -0
- package/src/react/components/ConfirmDialog.tsx +12 -2
- package/src/react/components/PaneSplit.spec.tsx +26 -3
- package/src/react/components/PaneSplit.tsx +26 -13
- package/src/react/components/PanelGrid.tsx +16 -11
- package/src/react/components/SplitPane.spec.tsx +10 -5
- package/src/react/components/SplitPane.tsx +3 -12
- package/src/react/components/TagsEditor.tsx +140 -0
- package/src/react/components/TagsPromptDialog.spec.tsx +74 -0
- package/src/react/components/TagsPromptDialog.tsx +108 -0
- package/src/react/components/paneSplit.css +8 -0
- package/src/react/file-tree/FolderViewMenus.tsx +1 -0
- package/src/react/file-tree/folderViewTree.spec.tsx +15 -0
- package/src/react/file-tree/useFolderView.ts +13 -2
- package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
- package/src/react/filter-rail/railResize.spec.tsx +27 -5
- package/src/react/filter-rail/railResize.tsx +46 -15
- package/src/react/filter-rail/railWidth.ts +13 -8
- package/src/react/hooks/longPressContextMenu.spec.ts +50 -0
- package/src/react/hooks/longPressContextMenu.ts +39 -3
- package/src/react/index.ts +4 -0
- package/src/react/lib/paneReach.spec.ts +58 -0
- package/src/react/lib/paneReach.ts +65 -0
- package/src/react/lib/tagList.spec.ts +19 -0
- package/src/react/lib/tagList.ts +29 -0
- package/src/react/master-detail/MasterDetail.tsx +10 -5
- package/src/react/media/FocusViewer.spec.tsx +19 -0
- package/src/react/media/FocusViewer.tsx +17 -0
- package/src/react/media/FrameGrabber.tsx +12 -5
- package/src/react/media/VideoChapterEditor.tsx +16 -6
- package/src/react/media/VideoCutEditor.tsx +17 -6
- package/src/react/media/VideoTurner.tsx +14 -5
- package/src/react/media/editorVideoSource.spec.tsx +71 -0
- package/src/react/media/hlsSource.ts +43 -0
- package/src/react/media/videoEnhance.ts +1 -1
- package/src/react/media-gallery/MediaGallery.tsx +4 -0
- package/src/react/tagsPrompt.tsx +33 -0
- package/src/styles-areas/fields.css +4 -4
- package/src/styles-areas/overlays.css +3 -2
- package/src/styles-utilities.css +7 -0
- package/src/testing/phoneRules.ts +2 -2
- package/src/theme.css +13 -0
|
@@ -163,6 +163,11 @@
|
|
|
163
163
|
// <Name,…>`; with no list the rule is inert rather than guessing. Near spellings count
|
|
164
164
|
// too (task 2381): `CheckBox`, `StatusBadge`, `Stat`, `ToastStack`.
|
|
165
165
|
//
|
|
166
|
+
// no-hand-rolled-divider — owner, 2026-10-27: every divider drags as far as the hand takes
|
|
167
|
+
// it, shutting a pane included. An app imports `react-resizable-panels` only through
|
|
168
|
+
// cursedbelt's `SplitPane`/`PanelGrid`, and a seam it draws itself (a `separator` role
|
|
169
|
+
// with a pointer handler) takes its geometry from `reachSize`/`stepSize`. See its block.
|
|
170
|
+
//
|
|
166
171
|
// no-inline-style — DESIGN.md §3/§4 (task 2381): `style={{…}}` may carry only a CSS
|
|
167
172
|
// custom property or COMPUTED geometry; a literal size, color or font is a violation.
|
|
168
173
|
//
|
|
@@ -3101,6 +3106,61 @@ const noArbitraryLength: Rule = {
|
|
|
3101
3106
|
},
|
|
3102
3107
|
};
|
|
3103
3108
|
|
|
3109
|
+
// ── no-hand-rolled-divider (forge task 3839, 2026-10-27) ──────────────────────────
|
|
3110
|
+
// The owner: *"For all dividers … that I can drag to size the panes, let me drag them as far as I
|
|
3111
|
+
// want including shutting a pane completely … I don't want any panes to block me."* station's
|
|
3112
|
+
// Queue capped its task list at 36% (`MasterDetail`'s `max`), and desk's own seams each kept a
|
|
3113
|
+
// min and a max of their own, so every app's divider stopped somewhere different and none could
|
|
3114
|
+
// be shut.
|
|
3115
|
+
//
|
|
3116
|
+
// cursedbelt's dividers obey one rule (`src/react/lib/paneReach.ts`): no maximum, and a minimum
|
|
3117
|
+
// that is a SNAP — below half of it the pane shuts. `SplitPane`/`PanelGrid` cannot be capped (the
|
|
3118
|
+
// props are gone), `PaneSplit` and `useRailResize` call the rule. So in app source:
|
|
3119
|
+
// - importing `react-resizable-panels` directly is a violation — it is how a `maxSize` comes back;
|
|
3120
|
+
// - a seam drawn by hand (`role="separator"` in a file with a pointer or mouse-down handler) is
|
|
3121
|
+
// a violation unless the file imports `reachSize` or `stepSize` from cursedbelt, which is the
|
|
3122
|
+
// whole of what makes a hand-drawn seam obey the rule.
|
|
3123
|
+
// A drag that is not a pane divider (a spreadsheet's column-width grip) opts out on the block
|
|
3124
|
+
// above with `guardrails-ignore no-hand-rolled-divider: <why>`.
|
|
3125
|
+
const RAW_PANELS_IMPORT_RE = /from\s+["']react-resizable-panels["']/g;
|
|
3126
|
+
const SEPARATOR_ROLE_RE = /role\s*[=:]\s*\{?\s*["']separator["']/g;
|
|
3127
|
+
const SEAM_DRAG_RE = /\bon(?:Pointer|Mouse)Down\b|["'](?:pointer|mouse)down["']/;
|
|
3128
|
+
const REACH_IMPORT_RE = /import\s*\{[^}]*\b(?:reachSize|stepSize)\b[^}]*\}\s*from\s*["']cursedbelt\//;
|
|
3129
|
+
const DIVIDER_IGNORE_RE = /guardrails-ignore\s+no-hand-rolled-divider\s*:/;
|
|
3130
|
+
|
|
3131
|
+
const noHandRolledDivider: Rule = {
|
|
3132
|
+
name: "no-hand-rolled-divider",
|
|
3133
|
+
description:
|
|
3134
|
+
"owner, 2026-10-27 (forge task 3839): every divider drags as far as the hand takes it, shutting a pane included. Split panes are cursedbelt's SplitPane / PanelGrid / MasterDetail / PaneSplit / useRailResize — never react-resizable-panels directly (that is how a maxSize comes back) — and a seam an app draws itself takes its geometry from cursedbelt's reachSize/stepSize (cursedbelt/react/pane-split), so no pane has a maximum and any pane can be shut.",
|
|
3135
|
+
check(filePath, source) {
|
|
3136
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
3137
|
+
if (extname(normalized) === ".css" || isTestSource(normalized)) return [];
|
|
3138
|
+
const scannable = blankComments(source);
|
|
3139
|
+
const lines = source.split("\n");
|
|
3140
|
+
const optedOut = (line: number) => {
|
|
3141
|
+
const block: string[] = [];
|
|
3142
|
+
for (let i = line - 2; i >= 0 && block.length < 12; i--) {
|
|
3143
|
+
const raw = lines[i] ?? "";
|
|
3144
|
+
if (raw.trim() === "") break;
|
|
3145
|
+
block.push(raw);
|
|
3146
|
+
}
|
|
3147
|
+
return DIVIDER_IGNORE_RE.test(block.join("\n"));
|
|
3148
|
+
};
|
|
3149
|
+
const violations: Violation[] = [];
|
|
3150
|
+
for (const match of scannable.matchAll(RAW_PANELS_IMPORT_RE)) {
|
|
3151
|
+
const line = lineOf(source, match.index ?? 0);
|
|
3152
|
+
if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
3153
|
+
}
|
|
3154
|
+
if (SEAM_DRAG_RE.test(scannable) && !REACH_IMPORT_RE.test(scannable)) {
|
|
3155
|
+
for (const match of scannable.matchAll(SEPARATOR_ROLE_RE)) {
|
|
3156
|
+
const line = lineOf(source, match.index ?? 0);
|
|
3157
|
+
if (!optedOut(line)) violations.push({ file: filePath, line, snippet: lines[line - 1]?.trim() ?? "" });
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
return violations;
|
|
3161
|
+
},
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3104
3164
|
export const RULES: Record<string, Rule> = {
|
|
3105
3165
|
[noRawActionButton.name]: noRawActionButton,
|
|
3106
3166
|
[rawButtonResetsFace.name]: rawButtonResetsFace,
|
|
@@ -3114,6 +3174,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
3114
3174
|
[noRawDataTable.name]: noRawDataTable,
|
|
3115
3175
|
[noHandRolledPropertyList.name]: noHandRolledPropertyList,
|
|
3116
3176
|
[noRawKbd.name]: noRawKbd,
|
|
3177
|
+
[noHandRolledDivider.name]: noHandRolledDivider,
|
|
3117
3178
|
[noTailwindPalette.name]: noTailwindPalette,
|
|
3118
3179
|
[themeColoursResolve.name]: themeColoursResolve,
|
|
3119
3180
|
[noRawTitleAttr.name]: noRawTitleAttr,
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -288,6 +288,8 @@ export const AREAS: readonly Area[] = [
|
|
|
288
288
|
'components/SwatchPicker.tsx',
|
|
289
289
|
'components/TagStrip.tsx',
|
|
290
290
|
'components/TagsInput.tsx',
|
|
291
|
+
// TagsInput's chips with the copy button and ⌘A/⌘C/⌘V (forge task 3812, lifted from photos).
|
|
292
|
+
'components/TagsEditor.tsx',
|
|
291
293
|
],
|
|
292
294
|
},
|
|
293
295
|
{ name: 'file-tree', why: 'the flat (non-nested) file list and its row chrome', roots: ['file-tree'] },
|
|
@@ -355,6 +357,9 @@ export const AREAS: readonly Area[] = [
|
|
|
355
357
|
'components/DiscardConfirmDialog.tsx',
|
|
356
358
|
'components/ImpactDialog.tsx',
|
|
357
359
|
'components/PromptDialog.tsx',
|
|
360
|
+
// PromptDialog's twin for a list of tags, and its imperative `promptTags` (forge task 3812).
|
|
361
|
+
'components/TagsPromptDialog.tsx',
|
|
362
|
+
'tagsPrompt.tsx',
|
|
358
363
|
'components/ElsewhereNotice.tsx',
|
|
359
364
|
'components/AddItemsMenu.tsx',
|
|
360
365
|
],
|
package/src/looks.spec.ts
CHANGED
|
@@ -126,6 +126,40 @@ describe('looks.css', () => {
|
|
|
126
126
|
}
|
|
127
127
|
expect(new Set(LOOKS.map((l) => l.id)).size).toBe(LOOKS.length);
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
test('highlighted text reads in every look and scheme, and the highlight can be seen on the card', () => {
|
|
131
|
+
// theme.css's `::selection`: the accent mixed into the card, the text in the foreground. Without
|
|
132
|
+
// it the browser's light highlight sat under a dark scheme's light text (desk, obsidian, 2026-10-27).
|
|
133
|
+
const plain = THEME.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
134
|
+
const rule = /(?:^|\})\s*::selection\s*\{([^{}]*)\}/.exec(plain)?.[1] ?? '';
|
|
135
|
+
const mix = /background-color:\s*color-mix\(in srgb, var\(--accent\) (\d+)%, var\(--card\)\)/.exec(rule);
|
|
136
|
+
expect(mix, 'theme.css has no `::selection` mixing the accent into the card — the browser highlight is back').not.toBeNull();
|
|
137
|
+
expect(rule).toMatch(/(?:^|[\s;])color:\s*var\(--foreground\)/);
|
|
138
|
+
const share = Number(mix?.[1]) / 100;
|
|
139
|
+
// The belt's own accent in each scheme; an app that rebrands it answers for its own.
|
|
140
|
+
const accent = { light: declarationsOf(THEME, ':root')?.get('--accent'), dark: declarationsOf(THEME, '.dark')?.get('--accent') };
|
|
141
|
+
const rgb = (hex: string) => [16, 8, 0].map((shift) => (Number.parseInt(hex.slice(1), 16) >> shift) & 255);
|
|
142
|
+
const failures: string[] = [];
|
|
143
|
+
for (const look of LOOKS) {
|
|
144
|
+
for (const scheme of ['light', 'dark'] as const) {
|
|
145
|
+
const decls =
|
|
146
|
+
look.id === 'aurora'
|
|
147
|
+
? scheme === 'light'
|
|
148
|
+
? auroraLight
|
|
149
|
+
: auroraDark
|
|
150
|
+
: (declarationsOf(LOOKS_CSS, scheme === 'light' ? lightSelector(look.id) : darkSelector(look.id)) as Decls);
|
|
151
|
+
const card = decls.get('--card') as string;
|
|
152
|
+
const painted = `#${rgb(accent[scheme] as string)
|
|
153
|
+
.map((c, i) => Math.round(c * share + (rgb(card)[i] as number) * (1 - share)).toString(16).padStart(2, '0'))
|
|
154
|
+
.join('')}`;
|
|
155
|
+
const text = contrast(decls.get('--foreground') as string, painted);
|
|
156
|
+
if (text < 4.5) failures.push(`${look.id}/${scheme}: selected text is ${text.toFixed(2)}:1 (< 4.5)`);
|
|
157
|
+
const seen = contrast(painted, card);
|
|
158
|
+
if (seen < 1.4) failures.push(`${look.id}/${scheme}: the highlight is ${seen.toFixed(2)}:1 on the card (< 1.4, invisible)`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
expect(failures).toEqual([]);
|
|
162
|
+
});
|
|
129
163
|
});
|
|
130
164
|
|
|
131
165
|
/**
|
|
@@ -135,6 +135,29 @@ describe('AppShell', () => {
|
|
|
135
135
|
expect(q(icon as HTMLElement, '[data-cb-rail-head] [data-cb-command-trigger]')).not.toBeNull();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test('🔴 a worded capture on the sidebar ⌘K row makes the trigger the square search button — the field had ~50px left (photos, 2026-10-27)', () => {
|
|
139
|
+
const head = (capture: boolean) => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<AppShell nav={station} navHeader={<span data-brand=''>Photos</span>} onOpenCommand={() => {}} commandLabel='Search the archive…' {...(capture ? { onCapture: () => {}, captureLabel: 'Upload' } : {})}>
|
|
142
|
+
x
|
|
143
|
+
</AppShell>,
|
|
144
|
+
);
|
|
145
|
+
const full = qa(container, '[data-cb-nav-rail]')[1] as HTMLElement;
|
|
146
|
+
const trigger = q(full, '[data-cb-rail-head] [data-cb-command-trigger]');
|
|
147
|
+
cleanup();
|
|
148
|
+
return trigger;
|
|
149
|
+
};
|
|
150
|
+
// Alone on its row, the trigger is the worded field…
|
|
151
|
+
const field = head(false);
|
|
152
|
+
expect(field?.getAttribute('aria-label')).toBeNull();
|
|
153
|
+
expect(field?.className).toContain('flex-1');
|
|
154
|
+
// …beside "+ Upload" it is the square, named by its words, so the row fits the 240px rail.
|
|
155
|
+
const square = head(true);
|
|
156
|
+
expect(square?.getAttribute('aria-label')).toBe('Search the archive…');
|
|
157
|
+
expect(square?.className).toContain('w-10');
|
|
158
|
+
expect(square?.className).not.toContain('flex-1');
|
|
159
|
+
});
|
|
160
|
+
|
|
138
161
|
test('a 2–5-place shell has no sidebar, so its header stays at every width', () => {
|
|
139
162
|
const { container } = render(<AppShell nav={nav}>x</AppShell>);
|
|
140
163
|
expect(q(container, '[data-cb-appheader]')?.className).not.toContain('md:hidden');
|
|
@@ -392,12 +392,17 @@ function Shell({
|
|
|
392
392
|
</IconButton>
|
|
393
393
|
) : null;
|
|
394
394
|
const hasCommandRow = (collapsed: boolean) => Boolean(openCommand || (!collapsed && (onCapture || commandBarExtra)));
|
|
395
|
+
// A worded capture on the ⌘K row takes the field's room: in a 240px sidebar the fold, the field
|
|
396
|
+
// and "+ Upload" left the field ~50px, its placeholder cut to a sliver under the button (photos,
|
|
397
|
+
// 1440, 2026-10-27). With capture there the trigger is the square search button, Linear's sidebar
|
|
398
|
+
// head (a search icon beside the worded compose); the palette and ⌘K are unchanged.
|
|
399
|
+
const squareCommand = (collapsed: boolean) => collapsed || Boolean(onCapture);
|
|
395
400
|
const railCommand = (collapsed: boolean) => {
|
|
396
401
|
if (!hasCommandRow(collapsed)) return null;
|
|
397
402
|
const bar = (
|
|
398
403
|
<CommandBar
|
|
399
404
|
className={collapsed ? 'flex-col' : 'w-full min-w-0 flex-wrap'}
|
|
400
|
-
compact={collapsed}
|
|
405
|
+
compact={squareCommand(collapsed)}
|
|
401
406
|
onCapture={collapsed ? undefined : onCapture}
|
|
402
407
|
captureLabel={captureLabel}
|
|
403
408
|
onOpenCommand={openCommand}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
3
|
+
import { ConfirmDialog } from './ConfirmDialog.js';
|
|
4
|
+
|
|
5
|
+
afterEach(cleanup);
|
|
6
|
+
|
|
7
|
+
const noop = () => {};
|
|
8
|
+
|
|
9
|
+
describe('ConfirmDialog message', () => {
|
|
10
|
+
test('a sentence is a <p>, and it describes the dialog', () => {
|
|
11
|
+
render(<ConfirmDialog message='Gone for good.' onCancel={noop} onConfirm={noop} open title='Delete?' />);
|
|
12
|
+
const message = screen.getByText('Gone for good.');
|
|
13
|
+
expect(message.tagName).toBe('P');
|
|
14
|
+
expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBe(message.id);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('a list (or a field) is a <div> — never block content inside a <p>', () => {
|
|
18
|
+
render(
|
|
19
|
+
<ConfirmDialog
|
|
20
|
+
message={
|
|
21
|
+
<div data-testid='body'>
|
|
22
|
+
<strong>Why it needs a better copy</strong>
|
|
23
|
+
<ul>
|
|
24
|
+
<li>Poor sound</li>
|
|
25
|
+
</ul>
|
|
26
|
+
</div>
|
|
27
|
+
}
|
|
28
|
+
onCancel={noop}
|
|
29
|
+
onConfirm={noop}
|
|
30
|
+
open
|
|
31
|
+
title='Mark it?'
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
const body = screen.getByTestId('body');
|
|
35
|
+
expect(body.closest('p')).toBeNull();
|
|
36
|
+
// Still the dialog's description, so a screen reader reads it with the title.
|
|
37
|
+
const description = body.parentElement as HTMLElement;
|
|
38
|
+
expect(description.tagName).toBe('DIV');
|
|
39
|
+
expect(screen.getByRole('dialog').getAttribute('aria-describedby')).toBe(description.id);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -77,8 +77,18 @@ export function ConfirmDialog({
|
|
|
77
77
|
{title}
|
|
78
78
|
</DialogTitle>
|
|
79
79
|
{message ? (
|
|
80
|
-
<
|
|
81
|
-
|
|
80
|
+
// Text is a sentence, so a <p>. Anything else — a field, a checkbox, a list — is a
|
|
81
|
+
// <div>: a <p> may hold phrasing content only, and React warns at the first <div> a
|
|
82
|
+
// caller's message draws inside one (music's bad-copy reason field, forge task 3811).
|
|
83
|
+
<DialogDescription
|
|
84
|
+
asChild={typeof message !== 'string' && typeof message !== 'number'}
|
|
85
|
+
className='text-sm text-muted-foreground'
|
|
86
|
+
>
|
|
87
|
+
{typeof message === 'string' || typeof message === 'number' ? (
|
|
88
|
+
message
|
|
89
|
+
) : (
|
|
90
|
+
<div>{message}</div>
|
|
91
|
+
)}
|
|
82
92
|
</DialogDescription>
|
|
83
93
|
) : null}
|
|
84
94
|
</div>
|
|
@@ -48,13 +48,36 @@ describe('PaneSplit', () => {
|
|
|
48
48
|
expect(sizes[1]).toBeCloseTo(0.48);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
// The owner, 2026-10-27: no divider is a wall — a pane can be shut, from either side.
|
|
52
|
+
test('Home and End shut the pane on either side of the seam', () => {
|
|
52
53
|
const onResize = draw();
|
|
53
54
|
const [, outer] = screen.getAllByRole('separator');
|
|
55
|
+
expect([outer!.getAttribute('aria-valuemin'), outer!.getAttribute('aria-valuemax')]).toEqual(['0', '100']);
|
|
54
56
|
fireEvent.keyDown(outer!, { key: 'Home' });
|
|
55
57
|
expect(onResize.mock.calls[0]![0]).toEqual([]);
|
|
56
|
-
expect(onResize.mock.calls[0]![1]
|
|
58
|
+
expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
|
|
57
59
|
fireEvent.keyDown(outer!, { key: 'End' });
|
|
58
|
-
expect(onResize.mock.calls[1]![1]
|
|
60
|
+
expect(onResize.mock.calls[1]![1]).toEqual([1, 0]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an arrow from the snap shuts the pane, and the next one opens it at the snap', () => {
|
|
64
|
+
const onResize = mock();
|
|
65
|
+
const at = (share: number): PaneSplitNode<{ id: string }> => ({
|
|
66
|
+
kind: 'split',
|
|
67
|
+
direction: 'row',
|
|
68
|
+
sizes: [share, 1 - share],
|
|
69
|
+
children: [
|
|
70
|
+
{ kind: 'leaf', pane: { id: 'a' } },
|
|
71
|
+
{ kind: 'leaf', pane: { id: 'b' } },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
const { rerender } = render(<PaneSplit node={at(0.12)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
|
|
75
|
+
fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowLeft' });
|
|
76
|
+
expect(onResize.mock.calls[0]![1]).toEqual([0, 1]);
|
|
77
|
+
rerender(<PaneSplit node={at(0)} paneKey={(pane) => pane.id} renderPane={(pane) => <section aria-label={pane.id} />} onResize={onResize} />);
|
|
78
|
+
// A shut pane draws nothing, but its seam is still there to bring it back.
|
|
79
|
+
expect(screen.getByLabelText('a').parentElement!.hasAttribute('data-shut')).toBe(true);
|
|
80
|
+
fireEvent.keyDown(screen.getByRole('separator'), { key: 'ArrowRight' });
|
|
81
|
+
expect(onResize.mock.calls[1]![1][0]).toBeCloseTo(0.12);
|
|
59
82
|
});
|
|
60
83
|
});
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* and the arithmetic that turns a pointer into a pair of fractions. Unlike `PanelGrid` it owns no
|
|
12
12
|
* sizes and persists nothing, which is what an app that stores its layout as a setting needs.
|
|
13
13
|
*
|
|
14
|
-
* 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End
|
|
14
|
+
* 🔴 **The seam is keyboard-operable.** Arrow keys move it by 2%, Home/End shut the pane either side.
|
|
15
|
+
*
|
|
16
|
+
* 🔴 **No seam is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want including
|
|
17
|
+
* shutting a pane completely"*). `minFraction` is a snap, not a floor: `lib/paneReach.ts` shuts a pane
|
|
18
|
+
* dragged below half of it, and a shut pane's seam stays at the edge to drag it open again.
|
|
15
19
|
*
|
|
16
20
|
* 🔴 **The hit strip lives inside the seam's own track** — a real flex item, never a bar hung over
|
|
17
21
|
* the boundary. An invisible strip centered on the seam sits over the first pixels of the pane beside
|
|
@@ -25,8 +29,12 @@
|
|
|
25
29
|
|
|
26
30
|
import type { CSSProperties, JSX, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from 'react';
|
|
27
31
|
import { useCallback, useRef } from 'react';
|
|
32
|
+
import { reachSize, stepSize } from '../lib/paneReach.js';
|
|
28
33
|
import './paneSplit.css';
|
|
29
34
|
|
|
35
|
+
/** The rule every divider obeys — re-exported for a seam an app draws itself (guardrails' `no-hand-rolled-divider`). */
|
|
36
|
+
export { reachSize, stepSize } from '../lib/paneReach.js';
|
|
37
|
+
|
|
30
38
|
export type PaneSplitDirection = 'row' | 'column';
|
|
31
39
|
|
|
32
40
|
export type PaneSplitNode<P> =
|
|
@@ -43,7 +51,10 @@ export interface PaneSplitProps<P> {
|
|
|
43
51
|
renderPane: (pane: P) => JSX.Element;
|
|
44
52
|
/** A seam moved: the split at `path` now has these fractions. */
|
|
45
53
|
onResize: (path: PaneSplitPath, sizes: number[]) => void;
|
|
46
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The snap: a pane dragged below half of this share shuts, and between half and all of it holds
|
|
56
|
+
* here (`lib/paneReach.ts`). Never a floor — any pane can be shut. Default 0.12.
|
|
57
|
+
*/
|
|
47
58
|
minFraction?: number;
|
|
48
59
|
/** Where `node` sits — only for a tree drawn from below its root. */
|
|
49
60
|
path?: PaneSplitPath;
|
|
@@ -79,16 +90,15 @@ function SplitNode<P>({
|
|
|
79
90
|
|
|
80
91
|
/** Set the share of the pair either side of seam `index`; only those two panes move. */
|
|
81
92
|
const setShare = useCallback(
|
|
82
|
-
(index: number,
|
|
93
|
+
(index: number, bounded: number) => {
|
|
83
94
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
84
95
|
if (pair <= 0) return;
|
|
85
|
-
const bounded = Math.min(Math.max(share, minFraction), 1 - minFraction);
|
|
86
96
|
const sizes = [...node.sizes];
|
|
87
97
|
sizes[index] = pair * bounded;
|
|
88
98
|
sizes[index + 1] = pair * (1 - bounded);
|
|
89
99
|
onResize(path, sizes);
|
|
90
100
|
},
|
|
91
|
-
[node.sizes,
|
|
101
|
+
[node.sizes, onResize, path],
|
|
92
102
|
);
|
|
93
103
|
|
|
94
104
|
const moveSeam = (index: number, position: number) => {
|
|
@@ -100,13 +110,13 @@ function SplitNode<P>({
|
|
|
100
110
|
const before = node.sizes.slice(0, index).reduce((sum, size) => sum + size, 0);
|
|
101
111
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
102
112
|
if (pair <= 0) return;
|
|
103
|
-
setShare(index, (offset - before) / pair);
|
|
113
|
+
setShare(index, reachSize((offset - before) / pair, minFraction, 1));
|
|
104
114
|
};
|
|
105
115
|
|
|
106
116
|
const nudgeSeam = (index: number, delta: number) => {
|
|
107
117
|
const pair = (node.sizes[index] ?? 0) + (node.sizes[index + 1] ?? 0);
|
|
108
118
|
if (pair <= 0) return;
|
|
109
|
-
setShare(index, (node.sizes[index] ?? 0) / pair
|
|
119
|
+
setShare(index, stepSize((node.sizes[index] ?? 0) / pair, delta, minFraction, 1));
|
|
110
120
|
};
|
|
111
121
|
|
|
112
122
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
|
|
@@ -114,8 +124,8 @@ function SplitNode<P>({
|
|
|
114
124
|
const forward = node.direction === 'row' ? 'ArrowRight' : 'ArrowDown';
|
|
115
125
|
if (event.key === back) nudgeSeam(index, -0.02);
|
|
116
126
|
else if (event.key === forward) nudgeSeam(index, 0.02);
|
|
117
|
-
else if (event.key === 'Home')
|
|
118
|
-
else if (event.key === 'End')
|
|
127
|
+
else if (event.key === 'Home') setShare(index, 0);
|
|
128
|
+
else if (event.key === 'End') setShare(index, 1);
|
|
119
129
|
else return;
|
|
120
130
|
event.preventDefault();
|
|
121
131
|
};
|
|
@@ -138,7 +148,6 @@ function SplitNode<P>({
|
|
|
138
148
|
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
|
139
149
|
};
|
|
140
150
|
|
|
141
|
-
const minPercent = Math.round(minFraction * 100);
|
|
142
151
|
return (
|
|
143
152
|
<div className="cb-panesplit" data-direction={node.direction} ref={frame}>
|
|
144
153
|
{node.children.map((child, index) => (
|
|
@@ -149,8 +158,8 @@ function SplitNode<P>({
|
|
|
149
158
|
seam={{
|
|
150
159
|
direction: node.direction,
|
|
151
160
|
percent: Math.round((node.sizes[index] ?? 0) * 100),
|
|
152
|
-
min:
|
|
153
|
-
max: 100
|
|
161
|
+
min: 0,
|
|
162
|
+
max: 100,
|
|
154
163
|
onKeyDown: (event) => onKeyDown(event, index),
|
|
155
164
|
onPointerDown: (event) => onPointerDown(event, index),
|
|
156
165
|
onPointerMove,
|
|
@@ -178,7 +187,11 @@ interface SeamProps {
|
|
|
178
187
|
function ChildAndSeam({ last, size, seam, children }: { last: boolean; size: number; seam: SeamProps; children: JSX.Element }): JSX.Element {
|
|
179
188
|
return (
|
|
180
189
|
<>
|
|
181
|
-
<div
|
|
190
|
+
<div
|
|
191
|
+
className="cb-panesplit__cell"
|
|
192
|
+
data-shut={size <= 0 ? '' : undefined}
|
|
193
|
+
style={{ '--cb-panesplit-share': size } as CSSProperties}
|
|
194
|
+
>
|
|
182
195
|
{children}
|
|
183
196
|
</div>
|
|
184
197
|
{!last && (
|
|
@@ -18,11 +18,11 @@ export interface PanelGridLeaf {
|
|
|
18
18
|
* this exact confusion collapsed a contact list to ~34px).
|
|
19
19
|
*/
|
|
20
20
|
defaultSize?: number | string;
|
|
21
|
+
/**
|
|
22
|
+
* The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
|
|
23
|
+
* it holds here. Same rule as `lib/paneReach.ts`, applied by the library's collapsible panel.
|
|
24
|
+
*/
|
|
21
25
|
minSize?: number | string;
|
|
22
|
-
maxSize?: number | string;
|
|
23
|
-
/** Panel collapses to `collapsedSize` when dragged below `minSize`. */
|
|
24
|
-
collapsible?: boolean;
|
|
25
|
-
collapsedSize?: number | string;
|
|
26
26
|
className?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -38,10 +38,9 @@ export interface PanelGridSplit {
|
|
|
38
38
|
resizable?: boolean;
|
|
39
39
|
/** Divider look for THIS split's dividers (overrides the grid-level setting). */
|
|
40
40
|
separatorVariant?: PanelSeparatorVariant;
|
|
41
|
-
/** Sizing of this split when it is itself a cell of a parent split. */
|
|
41
|
+
/** Sizing of this split when it is itself a cell of a parent split (`minSize` is a snap, as on a leaf). */
|
|
42
42
|
defaultSize?: number | string;
|
|
43
43
|
minSize?: number | string;
|
|
44
|
-
maxSize?: number | string;
|
|
45
44
|
className?: string;
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -97,7 +96,7 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
|
|
|
97
96
|
* root={{
|
|
98
97
|
* type: 'split', id: 'row', direction: 'horizontal',
|
|
99
98
|
* children: [
|
|
100
|
-
* { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px'
|
|
99
|
+
* { id: 'sidebar', content: <Nav />, defaultSize: '240px', minSize: '160px' },
|
|
101
100
|
* {
|
|
102
101
|
* type: 'split', id: 'main', direction: 'vertical',
|
|
103
102
|
* children: [
|
|
@@ -110,7 +109,14 @@ function leavesOf(node: PanelGridNode): PanelGridLeaf[] {
|
|
|
110
109
|
* />
|
|
111
110
|
* ```
|
|
112
111
|
*
|
|
113
|
-
*
|
|
112
|
+
* 🔴 **No divider is a wall** (the owner, 2026-10-27: *"let me drag them as far as I want
|
|
113
|
+
* including shutting a pane completely … I don't want any panes to block me"*). So there is no
|
|
114
|
+
* `maxSize` and no `collapsible` to turn off: EVERY cell is collapsible to 0, and its `minSize`
|
|
115
|
+
* is the snap `lib/paneReach.ts` describes — below half of it the cell shuts, so the cell beside it
|
|
116
|
+
* can take the whole split. station's Queue capped its task list at 36% and cut off the titles it
|
|
117
|
+
* was opened to read; that is the bug this removes the means to write.
|
|
118
|
+
*
|
|
119
|
+
* Keyboard resizing (Enter shuts/opens a pane), hit-target enlargement, and
|
|
114
120
|
* aria roles come from react-resizable-panels; the divider look comes from
|
|
115
121
|
* `PanelSeparator` (single hairline + invisible wide hotspot — panel content
|
|
116
122
|
* must not draw its own border against a divider).
|
|
@@ -295,9 +301,8 @@ function GridCell({
|
|
|
295
301
|
id={child.id}
|
|
296
302
|
defaultSize={child.defaultSize}
|
|
297
303
|
minSize={child.minSize}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
collapsedSize={isSplit(child) ? undefined : child.collapsedSize}
|
|
304
|
+
collapsible
|
|
305
|
+
collapsedSize={0}
|
|
301
306
|
className={cn('min-h-0 min-w-0', child.className)}
|
|
302
307
|
>
|
|
303
308
|
{isSplit(child) ? (
|
|
@@ -105,15 +105,20 @@ describe('SplitPane', () => {
|
|
|
105
105
|
window.matchMedia = original;
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// The owner, 2026-10-27: no divider is a wall. There is no way to ASK for one — a cap or an
|
|
109
|
+
// un-collapsible pane does not type-check — and the drag reaching both edges is proven in a real
|
|
110
|
+
// browser (`e2e/pane-reach.spec.ts`).
|
|
111
|
+
test('a pane cannot be capped or made un-shuttable', () => {
|
|
109
112
|
const panels: SplitPaneItem[] = [
|
|
110
|
-
{ id: 'main', content: <div>Main</div
|
|
111
|
-
|
|
113
|
+
{ id: 'main', content: <div>Main</div>, minSize: '16rem' },
|
|
114
|
+
// @ts-expect-error — `maxSize` is gone: a pane grows until its neighbor shuts.
|
|
115
|
+
{ id: 'side', content: <div>Side</div>, maxSize: '40%' },
|
|
116
|
+
// @ts-expect-error — `collapsible` is gone: every pane can be shut.
|
|
117
|
+
{ id: 'third', content: <div>Third</div>, collapsible: false },
|
|
112
118
|
];
|
|
113
119
|
const { getByText, container } = render(<SplitPane panels={panels} />);
|
|
114
120
|
expect(getByText('Main')).toBeTruthy();
|
|
115
121
|
expect(getByText('Side')).toBeTruthy();
|
|
116
|
-
|
|
117
|
-
expect(container.querySelectorAll('[data-panel]').length).toBe(2);
|
|
122
|
+
expect(container.querySelectorAll('[data-panel]').length).toBe(3);
|
|
118
123
|
});
|
|
119
124
|
});
|
|
@@ -12,17 +12,11 @@ export interface SplitPaneItem {
|
|
|
12
12
|
* number is NOT treated as a percentage (it silently becomes a pixel size).
|
|
13
13
|
*/
|
|
14
14
|
defaultSize?: number | string;
|
|
15
|
-
/** Minimum size constraint. */
|
|
16
|
-
minSize?: number | string;
|
|
17
|
-
/** Maximum size constraint. */
|
|
18
|
-
maxSize?: number | string;
|
|
19
15
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* The snap, never a floor: dragged below half of this the pane SHUTS, between half and all of it
|
|
17
|
+
* it holds here (`lib/paneReach.ts`). There is no maximum and no opting out — see `PanelGrid`.
|
|
22
18
|
*/
|
|
23
|
-
|
|
24
|
-
/** Size when collapsed; defaults to 0. */
|
|
25
|
-
collapsedSize?: number | string;
|
|
19
|
+
minSize?: number | string;
|
|
26
20
|
className?: string;
|
|
27
21
|
}
|
|
28
22
|
|
|
@@ -70,9 +64,6 @@ export function SplitPane({
|
|
|
70
64
|
content: p.content,
|
|
71
65
|
defaultSize: p.defaultSize,
|
|
72
66
|
minSize: p.minSize,
|
|
73
|
-
maxSize: p.maxSize,
|
|
74
|
-
collapsible: p.collapsible,
|
|
75
|
-
collapsedSize: p.collapsedSize,
|
|
76
67
|
className: p.className,
|
|
77
68
|
}));
|
|
78
69
|
return (
|