cursedbelt 3.0.2 → 4.0.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/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +111 -31
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -200,6 +200,76 @@
|
|
|
200
200
|
cursor: grabbing;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/*
|
|
204
|
+
* ── The pinned section ───────────────────────────────────────────────────────────────────
|
|
205
|
+
*
|
|
206
|
+
* Owner, 2026-09-13: *"pin open files where they stay at the top of the file tree in a new
|
|
207
|
+
* section above the folders."* It is a SIBLING of `.cbft-scroll`, not a block inside it — see
|
|
208
|
+
* the note above it in `FileTree.tsx` — so "stay at the top" is structural rather than a
|
|
209
|
+
* `position: sticky` that has to out-paint the rows it covers.
|
|
210
|
+
*
|
|
211
|
+
* 🔴 It may shrink and it may scroll, and it may never take the tree. `.cbft-scroll` is
|
|
212
|
+
* `flex: 1 1 auto` with `min-block-size: 0`, so an uncapped section of forty pins would
|
|
213
|
+
* squeeze the tree it is a shortcut INTO down to nothing — a feature that eats the thing it
|
|
214
|
+
* exists to help you reach. 40% is the cap; past it this box scrolls on its own.
|
|
215
|
+
*/
|
|
216
|
+
.cbft-pinned {
|
|
217
|
+
flex: 0 1 auto;
|
|
218
|
+
min-block-size: 0;
|
|
219
|
+
max-block-size: 40%;
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
/* The one line that says the section ends and the tree begins. A border rather than a
|
|
223
|
+
heavier fill: the rows inside are the same rows, and a tinted band would read as a
|
|
224
|
+
different KIND of row rather than as the same row in a second place. */
|
|
225
|
+
border-block-end: 1px solid var(--border);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.cbft-pinned-list {
|
|
229
|
+
display: flex;
|
|
230
|
+
flex-direction: column;
|
|
231
|
+
/* The same 4px band `.cbft-list` takes, for the same reason. */
|
|
232
|
+
padding-block: 4px;
|
|
233
|
+
/* A plain scroller, and a scroller it must be: the section is capped above, so without
|
|
234
|
+
this a pin past the cap is unreachable. `ScrollArea` is not usable here for the reason
|
|
235
|
+
`.cbft-scroll` records — the tree hit-tests these boxes during a drag.
|
|
236
|
+
guardrails-ignore no-raw-overflow: see above. */
|
|
237
|
+
overflow-y: auto;
|
|
238
|
+
overscroll-behavior: contain;
|
|
239
|
+
scrollbar-width: thin;
|
|
240
|
+
scrollbar-color: var(--cbft-scroll-thumb) transparent;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* The heading. 12px is DESIGN.md §4's floor exactly, and this is the smallest thing in the
|
|
245
|
+
* component that is genuinely TEXT — a section label a person reads, not a glyph.
|
|
246
|
+
*
|
|
247
|
+
* Uppercase with tracking, which is what every sidebar section header in this design system
|
|
248
|
+
* does (and what VS Code's "OPEN EDITORS" does): it separates the label from the row names
|
|
249
|
+
* under it by shape rather than by size, so the label can stay legible without competing with
|
|
250
|
+
* the rows for weight.
|
|
251
|
+
*/
|
|
252
|
+
.cbft-pinned-head {
|
|
253
|
+
flex: none;
|
|
254
|
+
padding: 0.375rem var(--cbft-inset) 0.125rem;
|
|
255
|
+
font-size: 0.75rem;
|
|
256
|
+
font-weight: 600;
|
|
257
|
+
letter-spacing: 0.06em;
|
|
258
|
+
text-transform: uppercase;
|
|
259
|
+
color: var(--muted-foreground);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/*
|
|
263
|
+
* 🔴 A pinned copy has NO indentation guides, whatever depth its real row is at.
|
|
264
|
+
*
|
|
265
|
+
* `--cbft-depth` is 0 on every row in this section, which already blanks the repeating
|
|
266
|
+
* gradient — this states it so that a future change to the guide's arithmetic cannot bring
|
|
267
|
+
* back a line pointing at a parent that is not drawn here.
|
|
268
|
+
*/
|
|
269
|
+
.cbft-pinned-list .cbft-row {
|
|
270
|
+
background-image: none;
|
|
271
|
+
}
|
|
272
|
+
|
|
203
273
|
.cbft-empty {
|
|
204
274
|
padding: 0.75rem 0.875rem;
|
|
205
275
|
font-size: 0.8125rem;
|
|
@@ -371,6 +441,19 @@
|
|
|
371
441
|
opacity: 0.4;
|
|
372
442
|
}
|
|
373
443
|
|
|
444
|
+
/*
|
|
445
|
+
* Held by a ⌘X that has not been pasted yet — Finder's own cue, and the same language as the
|
|
446
|
+
* row in the air above, one step weaker.
|
|
447
|
+
*
|
|
448
|
+
* 🔴 It has to be VISIBLE, and this is the reason the clipboard lives in the component rather
|
|
449
|
+
* than in a prop. A cut with no mark is a keystroke that appears to have done nothing, so the
|
|
450
|
+
* next thing a person does is press it again on something else — and then ⌘V moves the wrong
|
|
451
|
+
* rows. Escape puts it down; see `FileTree.tsx`'s `clipboard`.
|
|
452
|
+
*/
|
|
453
|
+
.cbft-row[data-cut] {
|
|
454
|
+
opacity: 0.55;
|
|
455
|
+
}
|
|
456
|
+
|
|
374
457
|
/* Where it would land. A ring rather than a fill, so the row underneath stays readable. */
|
|
375
458
|
.cbft-row[data-into] {
|
|
376
459
|
outline: 2px solid var(--accent-solid, var(--accent));
|
|
@@ -530,6 +613,74 @@
|
|
|
530
613
|
color: var(--muted-foreground);
|
|
531
614
|
}
|
|
532
615
|
|
|
616
|
+
/*
|
|
617
|
+
* ── The pin ──────────────────────────────────────────────────────────────────────────────
|
|
618
|
+
*
|
|
619
|
+
* 🔴 It rides in a `.cbft-tools` shelf of its own rather than inventing a fourth resting
|
|
620
|
+
* state, which is how it inherits the whole contract below in one class: 40% at rest, solid
|
|
621
|
+
* on hover / focus / selection, solid AND a 44px box on a coarse pointer, and `inTools`
|
|
622
|
+
* already stopping a press on it from selecting the row or starting a drag. A hand-rolled
|
|
623
|
+
* opacity here is exactly the drift the shelf's own header records (*"a value is copied and
|
|
624
|
+
* a rule is not"*).
|
|
625
|
+
*
|
|
626
|
+
* The glyph is 12px in the same 16px box the twisty and the type icon use, so a row with a
|
|
627
|
+
* pin is not one pixel taller than a row without one — `file-tree-geometry.json` pins the
|
|
628
|
+
* row's height to the glyph, and the pin is the third thing to go into that box.
|
|
629
|
+
*/
|
|
630
|
+
.cbft-pin {
|
|
631
|
+
flex: 0 0 var(--cbft-glyph);
|
|
632
|
+
display: inline-flex;
|
|
633
|
+
align-items: center;
|
|
634
|
+
justify-content: center;
|
|
635
|
+
inline-size: var(--cbft-glyph);
|
|
636
|
+
block-size: var(--cbft-glyph);
|
|
637
|
+
padding: 0;
|
|
638
|
+
border: 0;
|
|
639
|
+
background: none;
|
|
640
|
+
color: var(--muted-foreground);
|
|
641
|
+
cursor: pointer;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.cbft-pin:hover {
|
|
645
|
+
color: var(--cbft-fg);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/*
|
|
649
|
+
* 🔴 A PINNED row's pin is lit and never fades, in both copies.
|
|
650
|
+
*
|
|
651
|
+
* It is the only thing on the row in the folder that says *"this is also at the top"*, and a
|
|
652
|
+
* state mark that is invisible until hovered is a state nobody can scan for — the same
|
|
653
|
+
* failure the shelf's 40% floor exists to prevent, one step further. The colour is the
|
|
654
|
+
* accent, which is this tree's one strong mark and correct here for the same reason it is
|
|
655
|
+
* correct on the focus ring: it answers a different question from "where am I".
|
|
656
|
+
*/
|
|
657
|
+
.cbft-row[data-pinned] .cbft-pin-shelf {
|
|
658
|
+
opacity: 1;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.cbft-row[data-pinned] .cbft-pin {
|
|
662
|
+
color: var(--accent-solid, var(--accent));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/*
|
|
666
|
+
* Filled, not outlined — the one-glance difference between pinned and pinnable, and the half
|
|
667
|
+
* of it that survives for anyone who cannot see the colour change at all.
|
|
668
|
+
*
|
|
669
|
+
* 🔴 On the `<svg>` itself, not inherited from the button. The element carries `fill="none"`
|
|
670
|
+
* as a presentation attribute, and a presentation attribute on an element beats a value
|
|
671
|
+
* INHERITED from its parent — so `fill` on `.cbft-pin` would be silently ignored. Any rule
|
|
672
|
+
* targeting the svg wins, which is what this is.
|
|
673
|
+
*/
|
|
674
|
+
.cbft-row[data-pinned] .cbft-pin svg {
|
|
675
|
+
fill: currentColor;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.cbft-pin:focus-visible {
|
|
679
|
+
outline: 2px solid var(--ring, var(--accent-solid));
|
|
680
|
+
outline-offset: -2px;
|
|
681
|
+
border-radius: var(--radius-sm, 0.375rem);
|
|
682
|
+
}
|
|
683
|
+
|
|
533
684
|
/*
|
|
534
685
|
* 🔴 Quiet, never invisible.
|
|
535
686
|
*
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type FileTreeNode,
|
|
4
|
+
flattenFileTree,
|
|
5
|
+
} from 'cursedbelt-core/file-tree/file-tree-model';
|
|
6
|
+
import {
|
|
7
|
+
draggedIdsOf,
|
|
8
|
+
nodesByIds,
|
|
9
|
+
planWrap,
|
|
10
|
+
refuseCopy,
|
|
11
|
+
refuseWrap,
|
|
12
|
+
resolveMultiDrop,
|
|
13
|
+
selectionRange,
|
|
14
|
+
toggleSelected,
|
|
15
|
+
topmostSelected,
|
|
16
|
+
wrapFolderName,
|
|
17
|
+
} from './fileTreeSelection';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The multi-selection arithmetic, with no renderer anywhere near it.
|
|
21
|
+
*
|
|
22
|
+
* 🔴 This is the half of the feature that CAN be tested. happy-dom computes no layout, so a
|
|
23
|
+
* pointer drag in `FileTree.spec.tsx` hit-tests against zeros — which is why the drop rules
|
|
24
|
+
* for one dragged row live in `core/file-tree/fileTreeModel.spec.ts` and the rules for N live
|
|
25
|
+
* here. Asserting "a drag of three rows refuses a folder inside one of them" through a DOM
|
|
26
|
+
* would produce a test that passes on any implementation.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const folder = (id: string, name: string, children: FileTreeNode[] = []): FileTreeNode => ({
|
|
30
|
+
id,
|
|
31
|
+
name,
|
|
32
|
+
kind: 'folder',
|
|
33
|
+
children,
|
|
34
|
+
});
|
|
35
|
+
const file = (id: string, name: string): FileTreeNode => ({ id, name, kind: 'file' });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Simpsons/
|
|
39
|
+
* screencaps/
|
|
40
|
+
* s04.png
|
|
41
|
+
* s05.png
|
|
42
|
+
* poster.png
|
|
43
|
+
* Dinos/ (empty)
|
|
44
|
+
* Travels/
|
|
45
|
+
* loose.txt
|
|
46
|
+
*/
|
|
47
|
+
const TREE: FileTreeNode[] = [
|
|
48
|
+
folder('simpsons', 'Simpsons', [
|
|
49
|
+
folder('caps', 'screencaps', [file('s04', 's04.png'), file('s05', 's05.png')]),
|
|
50
|
+
file('poster', 'poster.png'),
|
|
51
|
+
]),
|
|
52
|
+
folder('dinos', 'Dinos'),
|
|
53
|
+
folder('travels', 'Travels'),
|
|
54
|
+
file('loose', 'loose.txt'),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const ROWS = flattenFileTree(TREE, []);
|
|
58
|
+
/** The same tree with `screencaps` shut — the case a range must not reach into. */
|
|
59
|
+
const SHUT_ROWS = flattenFileTree(TREE, ['caps']);
|
|
60
|
+
|
|
61
|
+
describe('selectionRange — what shift-click covers', () => {
|
|
62
|
+
test('every row from the anchor to the click, inclusive', () => {
|
|
63
|
+
expect(selectionRange(ROWS, 's04', 'travels')).toEqual(['s04', 's05', 'poster', 'dinos', 'travels']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('it runs the same both ways — a range has no direction', () => {
|
|
67
|
+
expect(selectionRange(ROWS, 'travels', 's04')).toEqual(selectionRange(ROWS, 's04', 'travels'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an anchor that IS the click is a selection of one', () => {
|
|
71
|
+
expect(selectionRange(ROWS, 'dinos', 'dinos')).toEqual(['dinos']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('🔴 it covers the rows ON SCREEN, never the children of a shut folder', () => {
|
|
75
|
+
// The rule that keeps ⌘⌫ from deleting things nobody drew: a range is what you can see
|
|
76
|
+
// yourself covering. `screencaps` is shut, so its two files are not in it at all — the
|
|
77
|
+
// folder row is, because that one IS on screen.
|
|
78
|
+
expect(selectionRange(SHUT_ROWS, 'simpsons', 'dinos')).toEqual(['simpsons', 'caps', 'poster', 'dinos']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('no anchor yet degenerates to the clicked row, not to the whole list', () => {
|
|
82
|
+
expect(selectionRange(ROWS, null, 'dinos')).toEqual(['dinos']);
|
|
83
|
+
// Same answer for an anchor the tree has since re-derived away — the honest reading of
|
|
84
|
+
// "from where?" when there is no where.
|
|
85
|
+
expect(selectionRange(ROWS, 'deleted-since', 'dinos')).toEqual(['dinos']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a click on a row that is not drawn selects nothing', () => {
|
|
89
|
+
expect(selectionRange(ROWS, 'dinos', 'ghost')).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('toggleSelected — what ⌘-click leaves behind', () => {
|
|
94
|
+
test('in if it was out, out if it was in', () => {
|
|
95
|
+
expect(toggleSelected(['a'], 'b')).toEqual(['a', 'b']);
|
|
96
|
+
expect(toggleSelected(['a', 'b'], 'a')).toEqual(['b']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the order of the rest survives a toggle', () => {
|
|
100
|
+
// The drag chip counts this and a consumer's "first selected" reads it, so re-sorting
|
|
101
|
+
// would shuffle a selection under the person still building it.
|
|
102
|
+
expect(toggleSelected(['c', 'a', 'b'], 'a')).toEqual(['c', 'b']);
|
|
103
|
+
expect(toggleSelected(['c', 'a'], 'b')).toEqual(['c', 'a', 'b']);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('topmostSelected — the row inside another selected row is not a second move', () => {
|
|
108
|
+
test('🔴 a selected child of a selected folder is dropped', () => {
|
|
109
|
+
// Without this, dragging `screencaps` and `s04` into `Travels` moves the folder AND the
|
|
110
|
+
// file, so a file that was two levels deep comes out one level deep beside its own former
|
|
111
|
+
// parent. The person asked for one move and got the tree flattened.
|
|
112
|
+
expect(topmostSelected(TREE, ['caps', 's04'])).toEqual(['caps']);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a GRANDchild too, not only a direct one', () => {
|
|
116
|
+
expect(topmostSelected(TREE, ['simpsons', 's04'])).toEqual(['simpsons']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('unrelated rows all survive, in the order they were given', () => {
|
|
120
|
+
expect(topmostSelected(TREE, ['travels', 'dinos', 'loose'])).toEqual(['travels', 'dinos', 'loose']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a row the tree no longer holds is kept rather than silently dropped', () => {
|
|
124
|
+
// `parentIdOf` answers null for a row this tree does not contain, so the walk ends and
|
|
125
|
+
// the id passes through — the consumer is the one that knows whether it still exists.
|
|
126
|
+
expect(topmostSelected(TREE, ['ghost'])).toEqual(['ghost']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('resolveMultiDrop — whether a drop is legal for N dragged ids', () => {
|
|
131
|
+
const rowFor = (id: string) => ROWS.find((row) => row.node.id === id) ?? null;
|
|
132
|
+
|
|
133
|
+
test('all of them may go there, so the drop is offered', () => {
|
|
134
|
+
expect(
|
|
135
|
+
resolveMultiDrop({ nodes: TREE, row: rowFor('travels'), offsetRatio: 0.5, dragIds: ['dinos', 'loose'] }),
|
|
136
|
+
).toEqual({ target: { kind: 'into', parentId: 'travels' }, refused: false, reason: null });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the blank space below the tree is the top level for a whole selection', () => {
|
|
140
|
+
// *"drag and drop them to another folder or to the root outside of any folder"* — the one
|
|
141
|
+
// gesture with no target row, and it must survive the plural.
|
|
142
|
+
expect(
|
|
143
|
+
resolveMultiDrop({ nodes: TREE, row: null, offsetRatio: 0.5, dragIds: ['s04', 's05'] }),
|
|
144
|
+
).toEqual({ target: { kind: 'root' }, refused: false, reason: null });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('🔴 ONE refusal refuses the whole drop, and its words are what comes back', () => {
|
|
148
|
+
// The worst outcome of a multi-drag is the one that half-lands: four rows moved, the
|
|
149
|
+
// fifth silently left behind, and a tree that looks like it obeyed.
|
|
150
|
+
const offer = resolveMultiDrop({
|
|
151
|
+
nodes: TREE,
|
|
152
|
+
row: rowFor('caps'),
|
|
153
|
+
offsetRatio: 0.5,
|
|
154
|
+
dragIds: ['dinos', 'simpsons'],
|
|
155
|
+
});
|
|
156
|
+
expect(offer.refused).toBe(true);
|
|
157
|
+
expect(offer.target).toBeNull();
|
|
158
|
+
expect(offer.reason).toBe('A folder cannot go inside one of its own folders');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a folder dragged beside its own target refuses — not only the target itself', () => {
|
|
162
|
+
const offer = resolveMultiDrop({
|
|
163
|
+
nodes: TREE,
|
|
164
|
+
row: rowFor('dinos'),
|
|
165
|
+
offsetRatio: 0.5,
|
|
166
|
+
dragIds: ['travels', 'dinos'],
|
|
167
|
+
});
|
|
168
|
+
expect(offer.refused).toBe(true);
|
|
169
|
+
expect(offer.reason).toBe('A folder cannot go inside itself');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a target the consumer's `canDrop` vetoes for ANY one of them refuses for all", () => {
|
|
173
|
+
// `canDrop` is asked once per dragged id, so a veto that depends on WHICH row is moving
|
|
174
|
+
// still gets asked about each one.
|
|
175
|
+
const offer = resolveMultiDrop({
|
|
176
|
+
nodes: TREE,
|
|
177
|
+
row: rowFor('travels'),
|
|
178
|
+
offsetRatio: 0.5,
|
|
179
|
+
dragIds: ['dinos', 'loose'],
|
|
180
|
+
canDrop: (dragId) => (dragId === 'loose' ? 'That file is pinned' : null),
|
|
181
|
+
});
|
|
182
|
+
expect(offer.refused).toBe(true);
|
|
183
|
+
expect(offer.reason).toBe('That file is pinned');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('`acceptsDrop: false` on the target refuses the set, with the consumer’s words', () => {
|
|
187
|
+
const locked: FileTreeNode[] = [
|
|
188
|
+
{ id: 'vault', name: 'Vault', kind: 'folder', acceptsDrop: false, children: [] },
|
|
189
|
+
file('a', 'a.txt'),
|
|
190
|
+
file('b', 'b.txt'),
|
|
191
|
+
];
|
|
192
|
+
const rows = flattenFileTree(locked, []);
|
|
193
|
+
const offer = resolveMultiDrop({
|
|
194
|
+
nodes: locked,
|
|
195
|
+
row: rows[0] ?? null,
|
|
196
|
+
offsetRatio: 0.5,
|
|
197
|
+
dragIds: ['a', 'b'],
|
|
198
|
+
lockedReason: 'That album is locked',
|
|
199
|
+
});
|
|
200
|
+
expect(offer).toEqual({ target: null, refused: true, reason: 'That album is locked' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('no dragged ids at all is an EXTERNAL payload — the same thing `dragId: null` means', () => {
|
|
204
|
+
expect(
|
|
205
|
+
resolveMultiDrop({ nodes: TREE, row: rowFor('simpsons'), offsetRatio: 0.5, dragIds: [] }),
|
|
206
|
+
).toEqual({ target: { kind: 'into', parentId: 'simpsons' }, refused: false, reason: null });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('a file row still means the folder that row is in, for all of them', () => {
|
|
210
|
+
expect(
|
|
211
|
+
resolveMultiDrop({ nodes: TREE, row: rowFor('poster'), offsetRatio: 0.5, dragIds: ['dinos', 'travels'] }),
|
|
212
|
+
).toEqual({ target: { kind: 'into', parentId: 'simpsons' }, refused: false, reason: null });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('🔴 pasting a copy back into the SAME folder is legal — the owner asked for it by name', () => {
|
|
216
|
+
// *"Copying should allow copying in another folder or the same folder."* A row's own
|
|
217
|
+
// parent is not a descendant of it, so the rule that refuses copy-into-itself leaves this
|
|
218
|
+
// alone. `s04` and `s05` live in `screencaps`.
|
|
219
|
+
expect(
|
|
220
|
+
resolveMultiDrop({ nodes: TREE, row: rowFor('caps'), offsetRatio: 0.5, dragIds: ['s04', 's05'] }),
|
|
221
|
+
).toEqual({ target: { kind: 'into', parentId: 'caps' }, refused: false, reason: null });
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe('refuseCopy — the consumer’s veto on the one clipboard verb that is not universal', () => {
|
|
226
|
+
test('no veto wired means every row may be copied', () => {
|
|
227
|
+
expect(refuseCopy(TREE, ['dinos', 'loose'], undefined)).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('🔴 the first row that refuses is the one that speaks', () => {
|
|
231
|
+
// `apps/music`'s folders are derived from the `folder` column of the tracks in them, so
|
|
232
|
+
// copying one would mean duplicating audio there is exactly one copy of.
|
|
233
|
+
expect(
|
|
234
|
+
refuseCopy(TREE, ['loose', 'dinos'], (node) =>
|
|
235
|
+
node.kind === 'folder' ? 'A folder here is derived, not a thing to duplicate' : null,
|
|
236
|
+
),
|
|
237
|
+
).toBe('A folder here is derived, not a thing to duplicate');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('a veto that allows everything asked of it returns null', () => {
|
|
241
|
+
expect(refuseCopy(TREE, ['loose'], (node) => (node.kind === 'folder' ? 'no' : null))).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* *"allow make dir from file where dir wraps file location and gets name of file"* — the
|
|
247
|
+
* three requirements in that sentence, one test each.
|
|
248
|
+
*/
|
|
249
|
+
describe('planWrap — New Folder with Selection, before any of it is drawn', () => {
|
|
250
|
+
test('🔴 the folder goes where the file IS, and is named after it without the extension', () => {
|
|
251
|
+
expect(planWrap(TREE, ['s04'])).toEqual({
|
|
252
|
+
ids: ['s04'],
|
|
253
|
+
// Not the root, and not inside the selection: `s04.png` lives in `screencaps`, so that
|
|
254
|
+
// is where the folder wrapping it is made.
|
|
255
|
+
parentId: 'caps',
|
|
256
|
+
name: 's04',
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a top-level row wraps at the top level', () => {
|
|
261
|
+
expect(planWrap(TREE, ['loose'])?.parentId).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('🔴 a multi-wrap is named after the first in TREE order, not the first clicked', () => {
|
|
265
|
+
// A selection is built by clicking, so ['s05', 's04'] is an ordinary thing to hold —
|
|
266
|
+
// and Finder names the folder after the first ITEM, which is the first in the list.
|
|
267
|
+
const plan = planWrap(TREE, ['s05', 's04']);
|
|
268
|
+
expect(plan?.name).toBe('s04');
|
|
269
|
+
expect(plan?.ids).toEqual(['s04', 's05']);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('🔴 a row inside another chosen row is not moved twice', () => {
|
|
273
|
+
// The same rule `topmostSelected` holds for a drag: wrapping a folder wraps what is in
|
|
274
|
+
// it, so naming the child as well would flatten it out of its own parent.
|
|
275
|
+
expect(planWrap(TREE, ['caps', 's04'])?.ids).toEqual(['caps']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('ids the tree no longer holds are forgotten, and a wrap of nothing is null', () => {
|
|
279
|
+
expect(planWrap(TREE, ['ghost', 'loose'])?.ids).toEqual(['loose']);
|
|
280
|
+
expect(planWrap(TREE, ['ghost'])).toBeNull();
|
|
281
|
+
expect(planWrap(TREE, [])).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('wrapFolderName — where a name ends', () => {
|
|
286
|
+
test('the extension comes off, and a name without one is kept whole', () => {
|
|
287
|
+
expect(wrapFolderName('beach.jpg')).toBe('beach');
|
|
288
|
+
expect(wrapFolderName('2026.09.16 notes.md')).toBe('2026.09.16 notes');
|
|
289
|
+
expect(wrapFolderName('Travels')).toBe('Travels');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('🔴 a name that is ALL extension keeps every character', () => {
|
|
293
|
+
// `.gitignore` has no basename to offer, and a field pre-filled with the empty string is
|
|
294
|
+
// worse than one pre-filled with the name it came from.
|
|
295
|
+
expect(wrapFolderName('.gitignore')).toBe('.gitignore');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe('refuseWrap — the same veto shape, for a tree whose folders are derived', () => {
|
|
300
|
+
test('no veto wired means anything may be wrapped', () => {
|
|
301
|
+
expect(refuseWrap(TREE, ['loose'], undefined)).toBeNull();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('🔴 the first row that refuses is the one that speaks', () => {
|
|
305
|
+
// `apps/music` has no folder to MAKE: its folders are derived from the tracks in them.
|
|
306
|
+
expect(
|
|
307
|
+
refuseWrap(TREE, ['loose', 'dinos'], (node) =>
|
|
308
|
+
node.kind === 'folder' ? 'Folders here are derived from the tracks' : null,
|
|
309
|
+
),
|
|
310
|
+
).toBe('Folders here are derived from the tracks');
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('the small shared readers', () => {
|
|
315
|
+
test('nodesByIds keeps the order asked for and skips what the tree lost', () => {
|
|
316
|
+
// A selection outlives a refresh: the server re-derives the tree between a ⌘-click and a
|
|
317
|
+
// ⌘⌫ and one of the rows is gone.
|
|
318
|
+
expect(nodesByIds(TREE, ['travels', 'ghost', 's04']).map((n) => n.name)).toEqual([
|
|
319
|
+
'Travels',
|
|
320
|
+
's04.png',
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('draggedIdsOf is the union of what every carried row takes with it', () => {
|
|
325
|
+
expect([...draggedIdsOf(TREE, ['caps', 'dinos'])].sort()).toEqual(['caps', 'dinos', 's04', 's05']);
|
|
326
|
+
});
|
|
327
|
+
});
|