cursedbelt 5.46.2 → 5.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
  2. package/dist/react/charts/IntervalTimeline.js +2 -2
  3. package/dist/react/charts/IntervalTimeline.js.map +1 -1
  4. package/dist/react/chat/DateMarker.d.ts +4 -0
  5. package/dist/react/chat/DateMarker.d.ts.map +1 -1
  6. package/dist/react/chat/DateMarker.js +15 -2
  7. package/dist/react/chat/DateMarker.js.map +1 -1
  8. package/dist/react/components/Button.d.ts.map +1 -1
  9. package/dist/react/components/Button.js +5 -0
  10. package/dist/react/components/Button.js.map +1 -1
  11. package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
  12. package/dist/react/components/DataTable/DataTable.js +1 -1
  13. package/dist/react/components/DataTable/DataTable.js.map +1 -1
  14. package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
  15. package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
  16. package/dist/react/components/DataTable/SortHeader.js +22 -5
  17. package/dist/react/components/DataTable/SortHeader.js.map +1 -1
  18. package/dist/react/components/DataTable/types.d.ts +10 -0
  19. package/dist/react/components/DataTable/types.d.ts.map +1 -1
  20. package/dist/react/components/DataTable/types.js.map +1 -1
  21. package/dist/react/components/DropdownMenu.js +10 -4
  22. package/dist/react/components/DropdownMenu.js.map +1 -1
  23. package/dist/react/components/Field.d.ts +9 -1
  24. package/dist/react/components/Field.d.ts.map +1 -1
  25. package/dist/react/components/Field.js +3 -3
  26. package/dist/react/components/Field.js.map +1 -1
  27. package/dist/react/components/SecretInput.d.ts +4 -2
  28. package/dist/react/components/SecretInput.d.ts.map +1 -1
  29. package/dist/react/components/SecretInput.js +2 -2
  30. package/dist/react/components/SecretInput.js.map +1 -1
  31. package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
  32. package/dist/react/components/auth/AuthCard.js +5 -1
  33. package/dist/react/components/auth/AuthCard.js.map +1 -1
  34. package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
  35. package/dist/react/components/auth/PasswordSetFields.js +2 -2
  36. package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
  37. package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
  38. package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
  39. package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
  40. package/dist/react/components/auth/RegisterForm.js +1 -1
  41. package/dist/react/components/auth/RegisterForm.js.map +1 -1
  42. package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
  43. package/dist/react/components/auth/SignupForm.js +4 -2
  44. package/dist/react/components/auth/SignupForm.js.map +1 -1
  45. package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
  46. package/dist/react/components/auth/TotpEntry.js +18 -1
  47. package/dist/react/components/auth/TotpEntry.js.map +1 -1
  48. package/dist/react/components/auth/fields.d.ts +3 -1
  49. package/dist/react/components/auth/fields.d.ts.map +1 -1
  50. package/dist/react/components/auth/fields.js +6 -2
  51. package/dist/react/components/auth/fields.js.map +1 -1
  52. package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
  53. package/dist/react/components/calendar/DayAgenda.js +4 -2
  54. package/dist/react/components/calendar/DayAgenda.js.map +1 -1
  55. package/dist/react/components/calendar/EventChip.d.ts +5 -1
  56. package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
  57. package/dist/react/components/calendar/EventChip.js +10 -3
  58. package/dist/react/components/calendar/EventChip.js.map +1 -1
  59. package/dist/react/components/calendar/MonthGrid.js +1 -1
  60. package/dist/react/components/calendar/MonthGrid.js.map +1 -1
  61. package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
  62. package/dist/react/components/calendar/WeekView.js +2 -2
  63. package/dist/react/components/calendar/WeekView.js.map +1 -1
  64. package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
  65. package/dist/react/components/calendar/colorUtils.js +13 -11
  66. package/dist/react/components/calendar/colorUtils.js.map +1 -1
  67. package/dist/react/components/fieldErrorText.d.ts +12 -0
  68. package/dist/react/components/fieldErrorText.d.ts.map +1 -0
  69. package/dist/react/components/fieldErrorText.js +12 -0
  70. package/dist/react/components/fieldErrorText.js.map +1 -0
  71. package/dist/react/deep-link/resume.d.ts.map +1 -1
  72. package/dist/react/deep-link/resume.js +9 -2
  73. package/dist/react/deep-link/resume.js.map +1 -1
  74. package/dist/react/file-tree/FileTree.d.ts +3 -0
  75. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  76. package/dist/react/file-tree/FileTree.js +12 -1
  77. package/dist/react/file-tree/FileTree.js.map +1 -1
  78. package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
  79. package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
  80. package/dist/react/file-tree/FolderViewMenus.js +51 -15
  81. package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
  82. package/dist/react/file-tree/folderView.d.ts +25 -4
  83. package/dist/react/file-tree/folderView.d.ts.map +1 -1
  84. package/dist/react/file-tree/folderView.js +49 -9
  85. package/dist/react/file-tree/folderView.js.map +1 -1
  86. package/dist/react/file-tree/index.d.ts +2 -1
  87. package/dist/react/file-tree/index.d.ts.map +1 -1
  88. package/dist/react/file-tree/index.js +2 -1
  89. package/dist/react/file-tree/index.js.map +1 -1
  90. package/dist/react/file-tree/useFolderView.d.ts +34 -4
  91. package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
  92. package/dist/react/file-tree/useFolderView.js +43 -11
  93. package/dist/react/file-tree/useFolderView.js.map +1 -1
  94. package/dist/react/media/DenseGalleryShell.d.ts +8 -1
  95. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  96. package/dist/react/media/DenseGalleryShell.js +33 -8
  97. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  98. package/dist/react/media/VideoPlayer.d.ts +12 -1
  99. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  100. package/dist/react/media/VideoPlayer.js +30 -2
  101. package/dist/react/media/VideoPlayer.js.map +1 -1
  102. package/dist/react/media/controlBar.d.ts +3 -2
  103. package/dist/react/media/controlBar.d.ts.map +1 -1
  104. package/dist/react/media/controlBar.js +16 -10
  105. package/dist/react/media/controlBar.js.map +1 -1
  106. package/dist/react/media/denseGallery.css +56 -18
  107. package/dist/react/media/videoPlayer.css +2 -1
  108. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  109. package/dist/react/media-gallery/MediaGallery.js +8 -4
  110. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  111. package/dist/styles-areas/analytics.css +26 -25
  112. package/dist/styles-areas/calendar.css +4 -4
  113. package/dist/styles-areas/data-table.css +26 -25
  114. package/dist/styles-utilities.css +24 -9
  115. package/package.json +5 -1
  116. package/scripts/demoCoverage.spec.ts +33 -0
  117. package/scripts/demoCoverage.ts +124 -0
  118. package/src/react/charts/IntervalTimeline.tsx +6 -4
  119. package/src/react/chat/DateMarker.tsx +22 -6
  120. package/src/react/chat/MessageThread.spec.tsx +25 -1
  121. package/src/react/components/Button.spec.tsx +11 -0
  122. package/src/react/components/Button.tsx +5 -0
  123. package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
  124. package/src/react/components/DataTable/DataTable.tsx +1 -0
  125. package/src/react/components/DataTable/SortHeader.tsx +45 -5
  126. package/src/react/components/DataTable/types.ts +10 -0
  127. package/src/react/components/DropdownMenu.spec.tsx +26 -0
  128. package/src/react/components/DropdownMenu.tsx +10 -3
  129. package/src/react/components/Field.spec.tsx +24 -0
  130. package/src/react/components/Field.tsx +15 -5
  131. package/src/react/components/SecretInput.tsx +5 -1
  132. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  133. package/src/react/components/auth/AuthCard.tsx +4 -1
  134. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  135. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  136. package/src/react/components/auth/RegisterForm.tsx +6 -6
  137. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  138. package/src/react/components/auth/SignupForm.tsx +11 -10
  139. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  140. package/src/react/components/auth/TotpEntry.tsx +18 -1
  141. package/src/react/components/auth/fields.tsx +8 -1
  142. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  143. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  144. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  145. package/src/react/components/calendar/EventChip.tsx +11 -6
  146. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  147. package/src/react/components/calendar/WeekView.tsx +5 -3
  148. package/src/react/components/calendar/colorUtils.ts +13 -11
  149. package/src/react/components/fieldErrorText.tsx +17 -0
  150. package/src/react/deep-link/resume.spec.tsx +16 -0
  151. package/src/react/deep-link/resume.ts +9 -2
  152. package/src/react/file-tree/FileTree.tsx +14 -1
  153. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  154. package/src/react/file-tree/folderView.spec.ts +63 -1
  155. package/src/react/file-tree/folderView.ts +63 -12
  156. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  157. package/src/react/file-tree/index.ts +15 -1
  158. package/src/react/file-tree/useFolderView.ts +73 -12
  159. package/src/react/media/DenseGalleryShell.spec.tsx +9 -7
  160. package/src/react/media/DenseGalleryShell.tsx +53 -11
  161. package/src/react/media/VideoPlayer.tsx +78 -2
  162. package/src/react/media/controlBar.tsx +17 -10
  163. package/src/react/media/denseGallery.css +56 -18
  164. package/src/react/media/videoPlayer.css +2 -1
  165. package/src/react/media-gallery/MediaGallery.spec.tsx +4 -4
  166. package/src/react/media-gallery/MediaGallery.tsx +12 -0
  167. package/src/styles-areas/analytics.css +26 -25
  168. package/src/styles-areas/calendar.css +4 -4
  169. package/src/styles-areas/data-table.css +26 -25
  170. package/src/styles-utilities.css +24 -9
@@ -1,10 +1,11 @@
1
1
  import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
2
  import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { useState } from 'react';
3
4
  import type { FileTreeNode } from 'cursedbelt-core/file-tree/file-tree-model';
4
5
  import { onPreferenceWrite } from '../media-gallery/preferenceBus.js';
5
6
  import { toast } from '../Toast.js';
6
7
  import { FileTree, type FileTreeProps, preloadFileTreePanels } from './FileTree.js';
7
- import { useFolderView } from './useFolderView.js';
8
+ import { type UseFolderViewOptions, useFolderView } from './useFolderView.js';
8
9
 
9
10
  /**
10
11
  * Hidden folders and folder tags, through the gestures the owner named: right-click a folder,
@@ -27,8 +28,8 @@ const TREE: FileTreeNode[] = [
27
28
  { id: 'readme', name: 'readme.md', kind: 'file' },
28
29
  ];
29
30
 
30
- function Harness(props: Partial<FileTreeProps>) {
31
- const folderView = useFolderView({ key: KEY });
31
+ function Harness({ options, ...props }: Partial<FileTreeProps> & { options?: Partial<UseFolderViewOptions> }) {
32
+ const folderView = useFolderView({ key: KEY, ...options });
32
33
  return (
33
34
  <div>
34
35
  {/* biome-ignore lint/a11y/noStaticElementInteractions: stands in for an app's header bar, whose right-click is exactly what is under test. */}
@@ -66,10 +67,10 @@ beforeEach(() => globalThis.localStorage.clear());
66
67
  afterEach(cleanup);
67
68
 
68
69
  describe('hiding a folder', () => {
69
- test('🔴 right-click a folder → Hide folder → it and its contents leave the tree', () => {
70
+ test('🔴 right-click a folder → Hide everywhere → it and its contents leave the tree', () => {
70
71
  render(<Harness />);
71
72
  fireEvent.contextMenu(row('Work'), { clientX: 10, clientY: 10 });
72
- fireEvent.click(item('Hide folder'));
73
+ fireEvent.click(item('Hide everywhere'));
73
74
  expect(screen.queryByText('Work')).toBeNull();
74
75
  expect(screen.queryByText('Dynatrace')).toBeNull();
75
76
  expect(screen.getByText('Home')).toBeDefined();
@@ -87,17 +88,17 @@ describe('hiding a folder', () => {
87
88
  try {
88
89
  render(<Harness />);
89
90
  fireEvent.contextMenu(row('Work'));
90
- fireEvent.click(item('Hide folder'));
91
+ fireEvent.click(item('Hide everywhere'));
91
92
  expect(said.map((s) => s.message)).toEqual(['Hid “Work”, on every device']);
92
93
  expect(screen.queryByText('Work')).toBeNull();
93
94
  act(() => said[0]?.undo());
94
95
  expect(screen.getByText('Work')).toBeDefined();
95
96
 
96
97
  fireEvent.contextMenu(row('Work'));
97
- fireEvent.click(item('Hide folder'));
98
+ fireEvent.click(item('Hide everywhere'));
98
99
  fireEvent.keyDown(row('Home'), { key: '.', code: 'Period', shiftKey: true, metaKey: true });
99
100
  fireEvent.contextMenu(row('Work'));
100
- fireEvent.click(item('Unhide folder'));
101
+ fireEvent.click(item('Unhide everywhere'));
101
102
  expect(said.at(-1)?.message).toBe('“Work” is back in the tree, on every device');
102
103
  expect(row('Work').getAttribute('data-hidden')).toBeNull();
103
104
  act(() => said.at(-1)?.undo());
@@ -110,7 +111,7 @@ describe('hiding a folder', () => {
110
111
  test('🔴 "Show hidden folders" draws them back — the hidden one italic-marked, its contents faded-marked', () => {
111
112
  render(<Harness />);
112
113
  fireEvent.contextMenu(row('Work'));
113
- fireEvent.click(item('Hide folder'));
114
+ fireEvent.click(item('Hide everywhere'));
114
115
  fireEvent.contextMenu(blank(), { clientX: 5, clientY: 300 });
115
116
  fireEvent.click(item(/Show hidden folders \(1\)/));
116
117
  expect(row('Work').getAttribute('data-hidden')).toBe('self');
@@ -121,14 +122,14 @@ describe('hiding a folder', () => {
121
122
  // Still hideable — the owner's "showing hidden folders still keeps them hideable": its own
122
123
  // menu now offers the way back.
123
124
  fireEvent.contextMenu(row('Work'));
124
- fireEvent.click(item('Unhide folder'));
125
+ fireEvent.click(item('Unhide everywhere'));
125
126
  expect(row('Work').getAttribute('data-hidden')).toBeNull();
126
127
  });
127
128
 
128
129
  test('the strip says hidden folders are showing, and its button leaves them out again', () => {
129
130
  render(<Harness />);
130
131
  fireEvent.contextMenu(row('Work'));
131
- fireEvent.click(item('Hide folder'));
132
+ fireEvent.click(item('Hide everywhere'));
132
133
  fireEvent.contextMenu(blank());
133
134
  fireEvent.click(item(/Show hidden folders/));
134
135
  expect(screen.getByText('Showing hidden')).toBeDefined();
@@ -140,7 +141,7 @@ describe('hiding a folder', () => {
140
141
  test('⇧⌘. on a row toggles hidden folders — Finder\'s chord', () => {
141
142
  render(<Harness />);
142
143
  fireEvent.contextMenu(row('Work'));
143
- fireEvent.click(item('Hide folder'));
144
+ fireEvent.click(item('Hide everywhere'));
144
145
  fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
145
146
  expect(row('Work').getAttribute('data-hidden')).toBe('self');
146
147
  fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
@@ -223,7 +224,7 @@ describe('🔴 what reaches other devices, and what does not', () => {
223
224
  try {
224
225
  render(<Harness />);
225
226
  fireEvent.contextMenu(row('Work'));
226
- fireEvent.click(item('Hide folder'));
227
+ fireEvent.click(item('Hide everywhere'));
227
228
  expect(heard).toEqual([KEY]);
228
229
  fireEvent.contextMenu(blank());
229
230
  fireEvent.click(item(/Show hidden folders/));
@@ -235,7 +236,100 @@ describe('🔴 what reaches other devices, and what does not', () => {
235
236
  });
236
237
  });
237
238
 
239
+ describe('🔴 hidden on THIS device only — desk\'s two lists, in the belt', () => {
240
+ test('Hide on this device leaves the tree, is never announced, and is stored on this device', () => {
241
+ const heard: string[] = [];
242
+ const stop = onPreferenceWrite((key) => heard.push(key));
243
+ try {
244
+ render(<Harness />);
245
+ fireEvent.contextMenu(row('Work'));
246
+ fireEvent.click(item('Hide on this device'));
247
+ expect(screen.queryByText('Work')).toBeNull();
248
+ expect(heard).toEqual([]);
249
+ expect(globalThis.localStorage.getItem(KEY)).toBeNull();
250
+ expect(globalThis.localStorage.getItem(`${KEY}:device`)).toContain('"hidden":["work"]');
251
+ } finally {
252
+ stop();
253
+ }
254
+ });
255
+
256
+ test('under Show hidden, the menu says WHICH list a folder is in, and unhides from that one', () => {
257
+ render(<Harness />);
258
+ fireEvent.contextMenu(row('Work'));
259
+ fireEvent.click(item('Hide on this device'));
260
+ fireEvent.contextMenu(row('Home'));
261
+ fireEvent.click(item('Hide everywhere'));
262
+ fireEvent.keyDown(row('readme.md'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
263
+
264
+ fireEvent.contextMenu(row('Work'));
265
+ expect(() => item('Unhide everywhere')).toThrow();
266
+ fireEvent.click(item('Unhide on this device'));
267
+ expect(row('Work').getAttribute('data-hidden')).toBeNull();
268
+
269
+ fireEvent.contextMenu(row('Home'));
270
+ expect(() => item('Unhide on this device')).toThrow();
271
+ fireEvent.click(item('Unhide everywhere'));
272
+ expect(row('Home').getAttribute('data-hidden')).toBeNull();
273
+ });
274
+ });
275
+
276
+ describe('🔴 the marks kept by the app — `store` and `device: \'memory\'`', () => {
277
+ test('with a store, a hide goes to the store and NOTHING touches localStorage or the bus', () => {
278
+ const written: (string | null)[] = [];
279
+ const heard: string[] = [];
280
+ const stop = onPreferenceWrite((key) => heard.push(key));
281
+ try {
282
+ function Kept() {
283
+ const [marks, setMarks] = useState<string | null>(null);
284
+ return (
285
+ <Harness
286
+ options={{
287
+ store: {
288
+ marks,
289
+ write: (next) => {
290
+ written.push(next);
291
+ setMarks(next);
292
+ },
293
+ },
294
+ device: 'memory',
295
+ }}
296
+ />
297
+ );
298
+ }
299
+ render(<Kept />);
300
+ fireEvent.contextMenu(row('Work'));
301
+ // No device half kept → one hide, which means everywhere.
302
+ expect(() => item('Hide on this device')).toThrow();
303
+ fireEvent.click(item('Hide folder'));
304
+ expect(screen.queryByText('Work')).toBeNull();
305
+ expect(written).toHaveLength(1);
306
+ expect(written[0]).toContain('"hidden":["work"]');
307
+ fireEvent.keyDown(row('Home'), { key: '>', code: 'Period', shiftKey: true, metaKey: true });
308
+ expect(row('Work').getAttribute('data-hidden')).toBe('self');
309
+ expect(globalThis.localStorage.length).toBe(0);
310
+ expect(heard).toEqual([]);
311
+ } finally {
312
+ stop();
313
+ }
314
+ });
315
+
316
+ test('the store is the truth: a value it hands in is what the tree draws', () => {
317
+ render(<Harness options={{ store: { marks: '{"hidden":["home"],"tags":{}}', write: () => undefined } }} />);
318
+ expect(screen.queryByText('Home')).toBeNull();
319
+ expect(screen.getByText('Work')).toBeDefined();
320
+ });
321
+ });
322
+
238
323
  describe('a row menu of the app\'s own', () => {
324
+ test('🔴 with onRowMenu and no rowMenu, right-click opens the APP\'s menu at the pointer', () => {
325
+ const opened: { id: string; x: number; y: number }[] = [];
326
+ render(<Harness onRowMenu={(node, at) => opened.push({ id: node.id, ...at })} />);
327
+ expect(fireEvent.contextMenu(row('readme.md'), { clientX: 40, clientY: 50 })).toBe(false);
328
+ expect(opened).toEqual([{ id: 'readme', x: 40, y: 50 }]);
329
+ // The tree drew no menu of its own over the app's.
330
+ expect(items()).toHaveLength(0);
331
+ });
332
+
239
333
  test('rowMenu items open on right-click, with Hide and Tags… after them on a folder', () => {
240
334
  let renamed = '';
241
335
  render(
@@ -246,7 +340,7 @@ describe('a row menu of the app\'s own', () => {
246
340
  fireEvent.contextMenu(row('Home'));
247
341
  const labels = items().map((el) => el.textContent);
248
342
  expect(labels[0]).toBe('Rename');
249
- expect(labels.some((l) => l?.startsWith('Hide folder'))).toBe(true);
343
+ expect(labels.some((l) => l?.startsWith('Hide everywhere'))).toBe(true);
250
344
  fireEvent.click(item('Rename'));
251
345
  expect(renamed).toBe('home');
252
346
  });
@@ -264,7 +358,8 @@ describe('a row menu of the app\'s own', () => {
264
358
  fireEvent.contextMenu(row('Home'));
265
359
  expect(items().map((el) => el.querySelector('.truncate')?.textContent)).toEqual([
266
360
  'Rename',
267
- 'Hide folder',
361
+ 'Hide on this device',
362
+ 'Hide everywhere',
268
363
  'Tags…',
269
364
  'Delete folder',
270
365
  ]);
@@ -274,7 +369,7 @@ describe('a row menu of the app\'s own', () => {
274
369
  render(<Harness />);
275
370
  act(() => row('Home').focus());
276
371
  fireEvent.keyDown(row('Home'), { key: 'F10', shiftKey: true });
277
- expect(item('Hide folder')).toBeDefined();
372
+ expect(item('Hide everywhere')).toBeDefined();
278
373
  });
279
374
 
280
375
  test('a tree with neither rowMenu nor folderView leaves right-click to the browser', () => {
@@ -65,4 +65,18 @@ export {
65
65
  folderViewMenuItems,
66
66
  withFolderItems,
67
67
  } from './FolderViewMenus.js';
68
- export { type FolderView, type UseFolderViewOptions, useFolderView } from './useFolderView.js';
68
+ export {
69
+ type FolderMarks,
70
+ type FolderViewLocal,
71
+ type HiddenScope,
72
+ parseFolderMarks,
73
+ remapFolderMarks,
74
+ serializeFolderMarks,
75
+ } from './folderView.js';
76
+ export {
77
+ type FolderMarksStore,
78
+ type FolderView,
79
+ type HideScope,
80
+ type UseFolderViewOptions,
81
+ useFolderView,
82
+ } from './useFolderView.js';
@@ -17,6 +17,13 @@
17
17
  * announced — *"Hiding or showing by tag can be stored per machine, so viewing the 'home'
18
18
  * tag at home doesn't change my work computer."*
19
19
  *
20
+ * An app that already keeps its own settings record hands the marks over instead — `store` — and
21
+ * then nothing goes to `localStorage` or the bus: desk carries them in its global settings blob
22
+ * beside the rest of its synced state, and vault seals them inside the vault, because its folder
23
+ * ids are folder NAMES and must never reach a disk in plaintext. `device: 'memory'` keeps the
24
+ * device half in page memory for the same reason; with it, "Hide on this device" is not offered,
25
+ * since a device list forgotten on reload would be a hide that silently undoes itself.
26
+ *
20
27
  * And because the tree's header belongs to the APP (collections' `FilterRail` section, desk's
21
28
  * explorer bar), the view menu has to be openable from outside the tree: the hook holds which
22
29
  * menu is open, and the tree draws it. `onContextMenu` is the one handler an app puts on its
@@ -27,6 +34,8 @@ import { useRememberedPreference } from '../media-gallery/rememberedPreference.j
27
34
  import {
28
35
  type FolderMarks,
29
36
  type FolderViewLocal,
37
+ type HiddenScope,
38
+ hiddenScope,
30
39
  normalizeTag,
31
40
  parseFolderMarks,
32
41
  parseFolderViewLocal,
@@ -50,14 +59,23 @@ export type FolderViewPanel =
50
59
  | { kind: 'view'; at: FolderViewPoint }
51
60
  | { kind: 'tags'; at: FolderViewPoint | null; ids: readonly string[] };
52
61
 
62
+ /** Which list a hide writes to, or an unhide takes from. */
63
+ export type HideScope = 'everywhere' | 'device';
64
+
53
65
  export interface FolderView {
54
66
  marks: FolderMarks;
55
67
  local: FolderViewLocal;
56
68
  /** Every tag in use, most-used first. */
57
69
  tags: readonly TagCount[];
70
+ /** Whether "Hide on this device" is offered — false when the device half is not kept. */
71
+ deviceHiding: boolean;
72
+ /** Which of the two lists hide this folder, if any. */
73
+ hiddenIn: (id: string) => HiddenScope | null;
58
74
 
59
- hide: (ids: readonly string[]) => void;
60
- unhide: (ids: readonly string[]) => void;
75
+ /** Hide these folders — everywhere unless `scope: 'device'`. */
76
+ hide: (ids: readonly string[], options?: { scope?: HideScope }) => void;
77
+ /** Unhide these folders from the one list named, or from both when none is. */
78
+ unhide: (ids: readonly string[], options?: { scope?: HideScope }) => void;
61
79
  /** Add or take away one tag on each folder named. */
62
80
  toggleTag: (ids: readonly string[], tag: string) => void;
63
81
  /** Replace one folder's tags. */
@@ -88,11 +106,39 @@ export interface UseFolderViewOptions {
88
106
  * origin must not hide each other's folders.
89
107
  */
90
108
  key: string;
109
+ /**
110
+ * The app keeps the marks itself — its own synced settings, or a sealed record. `marks` is
111
+ * what it holds now (`null` for none) and `write` is called with the next value to keep.
112
+ * Given a store, the marks never touch `localStorage` or the preference bus.
113
+ */
114
+ store?: FolderMarksStore;
115
+ /**
116
+ * Where this device's view (show hidden, the tag filter, the device-only hidden list) lives:
117
+ * `'local'` (the default) is `localStorage` under `${key}:device`, never announced;
118
+ * `'memory'` is this page only, for an app whose folder ids may not be written to disk.
119
+ */
120
+ device?: 'local' | 'memory';
91
121
  }
92
122
 
93
- export function useFolderView({ key }: UseFolderViewOptions): FolderView {
94
- const [rawMarks, writeMarks] = useRememberedPreference(key);
95
- const [rawLocal, writeLocal] = useRememberedPreference(`${key}:device`, { announce: false });
123
+ /** The marks, kept by the app — see {@link UseFolderViewOptions.store}. */
124
+ export interface FolderMarksStore {
125
+ /** The stored form (`serializeFolderMarks`), or `null` for no marks. */
126
+ marks: string | null;
127
+ write: (next: string | null) => void;
128
+ }
129
+
130
+ export function useFolderView({ key, store, device = 'local' }: UseFolderViewOptions): FolderView {
131
+ // Hooks run unconditionally; an undefined key is the remembered preference that keeps nothing.
132
+ const [rememberedMarks, rememberMarks] = useRememberedPreference(store ? undefined : key);
133
+ const [rememberedLocal, rememberLocal] = useRememberedPreference(
134
+ device === 'local' ? `${key}:device` : undefined,
135
+ { announce: false },
136
+ );
137
+ const [memoryLocal, setMemoryLocal] = useState<string | null>(null);
138
+ const rawMarks = store ? store.marks : rememberedMarks;
139
+ const writeMarks = store ? store.write : rememberMarks;
140
+ const rawLocal = device === 'local' ? rememberedLocal : memoryLocal;
141
+ const writeLocal = device === 'local' ? rememberLocal : setMemoryLocal;
96
142
  const marks = useMemo(() => parseFolderMarks(rawMarks), [rawMarks]);
97
143
  const local = useMemo(() => parseFolderViewLocal(rawLocal), [rawLocal]);
98
144
  const tags = useMemo(() => tagCounts(marks), [marks]);
@@ -114,13 +160,20 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
114
160
  },
115
161
  [writeMarks],
116
162
  );
163
+ // The same rule for this device's view: an Undo of "Hide on this device" must see the hide.
164
+ const latestLocal = useRef(local);
165
+ latestLocal.current = local;
117
166
  const saveLocal = useCallback(
118
167
  (next: FolderViewLocal) => {
119
- const empty = !next.showHidden && next.only.length === 0 && next.hide.length === 0;
168
+ if (next === latestLocal.current) return;
169
+ latestLocal.current = next;
170
+ const empty =
171
+ !next.showHidden && next.only.length === 0 && next.hide.length === 0 && next.hidden.length === 0;
120
172
  writeLocal(empty ? null : JSON.stringify(next));
121
173
  },
122
174
  [writeLocal],
123
175
  );
176
+ const deviceHiding = device === 'local';
124
177
 
125
178
  const onContextMenu = useCallback((event: { clientX: number; clientY: number; preventDefault: () => void }) => {
126
179
  event.preventDefault();
@@ -132,8 +185,16 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
132
185
  marks,
133
186
  local,
134
187
  tags,
135
- hide: (ids) => saveMarks(setHidden(latest.current, ids, true)),
136
- unhide: (ids) => saveMarks(setHidden(latest.current, ids, false)),
188
+ deviceHiding,
189
+ hiddenIn: (id) => hiddenScope(marks, local, id),
190
+ hide: (ids, options) => {
191
+ if (options?.scope === 'device' && deviceHiding) saveLocal(setHidden(latestLocal.current, ids, true));
192
+ else saveMarks(setHidden(latest.current, ids, true));
193
+ },
194
+ unhide: (ids, options) => {
195
+ if (options?.scope !== 'device') saveMarks(setHidden(latest.current, ids, false));
196
+ if (options?.scope !== 'everywhere') saveLocal(setHidden(latestLocal.current, ids, false));
197
+ },
137
198
  toggleTag: (ids, raw) => {
138
199
  const tag = normalizeTag(raw);
139
200
  if (!tag || ids.length === 0) return;
@@ -148,15 +209,15 @@ export function useFolderView({ key }: UseFolderViewOptions): FolderView {
148
209
  saveMarks(next);
149
210
  },
150
211
  setTags: (id, list) => saveMarks(setFolderTags(latest.current, id, list)),
151
- setShowHidden: (show) => saveLocal({ ...local, showHidden: show }),
152
- filterTag: (tag, mode) => saveLocal(setTagFilter(local, tag, mode)),
153
- clearTagFilter: () => saveLocal({ ...local, only: [], hide: [] }),
212
+ setShowHidden: (show) => saveLocal({ ...latestLocal.current, showHidden: show }),
213
+ filterTag: (tag, mode) => saveLocal(setTagFilter(latestLocal.current, tag, mode)),
214
+ clearTagFilter: () => saveLocal({ ...latestLocal.current, only: [], hide: [] }),
154
215
  panel,
155
216
  openViewMenu: (at) => setPanel({ kind: 'view', at }),
156
217
  openTagEditor: (ids, at) => setPanel({ kind: 'tags', at: at ?? null, ids }),
157
218
  closePanel: () => setPanel(null),
158
219
  onContextMenu,
159
220
  }),
160
- [marks, local, tags, panel, saveMarks, saveLocal, onContextMenu],
221
+ [marks, local, tags, deviceHiding, panel, saveMarks, saveLocal, onContextMenu],
161
222
  );
162
223
  }
@@ -16,7 +16,7 @@ describe('DenseGalleryDetail fullscreen toggle', () => {
16
16
  />,
17
17
  );
18
18
  expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
19
- expect(getByRole('button', { name: /fullscreen/i })).toBeDefined();
19
+ expect(getByRole('button', { name: 'Full window' })).toBeDefined();
20
20
  });
21
21
 
22
22
  test('clicking the toggle adds cbgd-watch-full and flips the button label', () => {
@@ -29,12 +29,14 @@ describe('DenseGalleryDetail fullscreen toggle', () => {
29
29
  renderTile={() => null}
30
30
  />,
31
31
  );
32
- const toggle = getByRole('button', { name: /fullscreen/i });
32
+ const toggle = getByRole('button', { name: 'Full window' });
33
33
  fireEvent.click(toggle);
34
34
  expect(container.querySelector('.cbgd-watch')?.className).toContain('cbgd-watch-full');
35
- expect(getByRole('button', { name: /shrink/i })).toBeDefined();
35
+ expect(toggle.getAttribute('aria-pressed')).toBe('true');
36
36
 
37
- fireEvent.click(getByRole('button', { name: /shrink/i }));
37
+ // The floating way out — the titlebar's toggle is below the fold once the picture fills
38
+ // the window.
39
+ fireEvent.click(getByRole('button', { name: 'Leave full window' }));
38
40
  expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
39
41
  });
40
42
  });
@@ -149,7 +151,7 @@ describe('leaving full-stage by double-clicking the picture', () => {
149
151
 
150
152
  test('a double-click on the frame leaves full-stage', () => {
151
153
  const { container, getByRole } = detail();
152
- fireEvent.click(getByRole('button', { name: /fullscreen/i }));
154
+ fireEvent.click(getByRole('button', { name: 'Full window' }));
153
155
  const frame = container.querySelector('.cbgd-watch-player-box');
154
156
  expect(frame).not.toBeNull();
155
157
  fireEvent.doubleClick(frame as Element);
@@ -186,7 +188,7 @@ describe('leaving full-stage by double-clicking the picture', () => {
186
188
  // `MediaGallery` passes `fullscreenOnDoubleClick={!fullStage}`, so while the shell is full
187
189
  // there is exactly one claimant on the gesture and it is this one.
188
190
  const { container, getByRole } = detail({ stageOwnsDoubleClick: true });
189
- fireEvent.click(getByRole('button', { name: /fullscreen/i }));
191
+ fireEvent.click(getByRole('button', { name: 'Full window' }));
190
192
  expect(container.querySelector('.cbgd-watch')?.className).toContain('cbgd-watch-full');
191
193
  fireEvent.doubleClick(container.querySelector('.cbgd-watch-player-box') as Element);
192
194
  expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
@@ -232,6 +234,6 @@ describe('leaving full-stage by double-clicking the picture', () => {
232
234
  />,
233
235
  );
234
236
  expect(container.querySelector('.cbgd-watch')?.className).not.toContain('cbgd-watch-full');
235
- expect(getByRole('button', { name: /fullscreen/i })).toBeDefined();
237
+ expect(getByRole('button', { name: 'Full window' })).toBeDefined();
236
238
  });
237
239
  });
@@ -434,6 +434,13 @@ export interface DenseGalleryDetailProps {
434
434
  * player is told to stand down there. See `leaveFullStage`.
435
435
  */
436
436
  stageOwnsDoubleClick?: boolean;
437
+ /**
438
+ * Does the STAGE draw its own full-window toggle? `true` for a Vidstack video, whose control
439
+ * bar carries one beside fullscreen (`VideoPlayer`'s `fillWindowSlots`). Otherwise the shell
440
+ * floats its own way out over the corner of the window while `fullStage` is on, because the
441
+ * titlebar's toggle is below the fold whenever the picture fills the window's height.
442
+ */
443
+ stageOffersFullStageToggle?: boolean;
437
444
  }
438
445
 
439
446
  /**
@@ -467,6 +474,7 @@ export function DenseGalleryDetail({
467
474
  fullStage: fullStageProp,
468
475
  onFullStageChange,
469
476
  stageOwnsDoubleClick = false,
477
+ stageOffersFullStageToggle = false,
470
478
  }: DenseGalleryDetailProps) {
471
479
  // The watch view is ONE scroll container; the related grid windows against it so the
472
480
  // stage scrolls off in normal flow rather than the grid owning a nested inner scroller.
@@ -565,10 +573,23 @@ export function DenseGalleryDetail({
565
573
  apply();
566
574
  return () => observer.disconnect();
567
575
  }, [watchEl]);
568
- // Fullscreen here means "take over the whole plugin container" — the rail and the
569
- // related grid below hide and the stage grows to fill the space they vacate. This is
570
- // independent of any player-level browser Fullscreen API toggle (e.g. CanvasVideoPlayer's
571
- // own button, which fullscreens just the `<video>`/`<canvas>` element).
576
+ /*
577
+ * 🔴 Full-stage means FULL WINDOW — the browser window, not the plugin and not the screen.
578
+ *
579
+ * Owner, 2026-10-02: *"I often want the video as big as it can be to fill the browser
580
+ * window, while still keeping the aspect ratio and every part of the content visible … I
581
+ * still want to be able to scroll down to see other media files … This mode should also
582
+ * hide the top rows above the video … and hide the sidebar area too, but still allow
583
+ * scrolling."* Until then this filled only the gallery's own container — the app's
584
+ * toolstrip, its sidebar and its activity column still took their share of the window —
585
+ * and hid the related grid outright, so there was nothing to scroll to.
586
+ *
587
+ * So the detail view lifts itself over the WHOLE window (`.cbgd-watch-full`, `position:
588
+ * fixed`), the frame is exactly one window tall, and the titlebar and the related grid
589
+ * follow it in the same scroll. It is not browser fullscreen, which takes the physical
590
+ * screen; that stays the player's own button. Lifting with CSS rather than a portal is the
591
+ * point: a portal remounts the stage, and the video would start over.
592
+ */
572
593
  const [ownFullStage, setOwnFullStage] = useState(false);
573
594
  // Controlled when the consumer passes it, uncontrolled otherwise — the ordinary React
574
595
  // shape, so a consumer that never heard of the prop is byte-for-byte unchanged.
@@ -580,6 +601,16 @@ export function DenseGalleryDetail({
580
601
  },
581
602
  [onFullStageChange],
582
603
  );
604
+ // Esc leaves full window, as it leaves every other mode that covers the page. Not while
605
+ // something else claimed the key first — an open menu or dialog closes and stays at that.
606
+ useEffect(() => {
607
+ if (!fullStage) return;
608
+ const onKey = (event: KeyboardEvent) => {
609
+ if (event.key === 'Escape' && !event.defaultPrevented) setFullStage(false);
610
+ };
611
+ window.addEventListener('keydown', onKey);
612
+ return () => window.removeEventListener('keydown', onKey);
613
+ }, [fullStage, setFullStage]);
583
614
 
584
615
  /*
585
616
  * 🔴 Double-click the picture to ENTER and LEAVE the shell's fullscreen.
@@ -752,8 +783,8 @@ export function DenseGalleryDetail({
752
783
  {actions}
753
784
  <Tooltip content={
754
785
  fullStage
755
- ? 'Shrink back — restore the sidebar and related grid'
756
- : 'Fill the plugin — hide the sidebar and related grid'
786
+ ? 'Leave full window (Esc)'
787
+ : 'Fill the window — the picture as big as the window, the files below'
757
788
  }>
758
789
  <button
759
790
  // guardrails-ignore no-raw-action-button: same self-contained `.cbgd-`
@@ -762,11 +793,10 @@ export function DenseGalleryDetail({
762
793
  type='button'
763
794
  className='cbgd-order-btn is-icon'
764
795
  aria-pressed={fullStage}
765
- // 🔴 The word moved into the accessible name rather than being deleted with
766
- // the glyph. It is also the STATE — `Shrink` is only offered while the stage
767
- // is already filling the frame — so a reader who cannot see which of the two
768
- // arrows is drawn still learns which way this press goes.
769
- aria-label={fullStage ? 'Shrink' : 'Fullscreen'}
796
+ // 🔴 One NAME, and the state in `aria-pressed` — the toggle-button pattern.
797
+ // It was `Fullscreen`/`Shrink` until 2026-10-02, and `Fullscreen` stopped being
798
+ // true the day this began filling the WINDOW rather than the screen.
799
+ aria-label='Full window'
770
800
  onClick={() => setFullStage(!fullStage)}
771
801
  >
772
802
  {fullStage ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
@@ -782,6 +812,18 @@ export function DenseGalleryDetail({
782
812
  ) : null}
783
813
  </div>
784
814
 
815
+ {fullStage && !stageOffersFullStageToggle ? (
816
+ <span className='cbgd-full-exit'>
817
+ <GalleryActionButton
818
+ iconOnly
819
+ tooltip='Leave full window (Esc)'
820
+ aria-label='Leave full window'
821
+ onClick={() => setFullStage(false)}
822
+ >
823
+ <Minimize2 aria-hidden />
824
+ </GalleryActionButton>
825
+ </span>
826
+ ) : null}
785
827
  <div className='cbgd-related'>
786
828
  {relatedLabel ? <div className='cbgd-section-head'>{relatedLabel}</div> : null}
787
829
  {relatedBody ?? (