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
@@ -3,6 +3,7 @@ import { cleanup, render, screen } from '@testing-library/react';
3
3
  import type { Occurrence } from 'cwip/calendar';
4
4
  import { dayKeyOf } from 'cwip/calendar';
5
5
  import { EventChip } from './EventChip.js';
6
+ import { calendarColorClasses } from './colorUtils.js';
6
7
  import type { CalendarEventBase } from './types.js';
7
8
 
8
9
  afterEach(cleanup);
@@ -69,14 +70,17 @@ describe('EventChip', () => {
69
70
  expect(container.querySelector('button')?.className).toContain('line-through');
70
71
  });
71
72
 
72
- test('hex color resolves to inline backgroundColor style', () => {
73
- // guardrails-ignore no-hardcoded-colors: test fixture asserting on literal event
74
- // color prop, not themeable chrome — calendar events carry arbitrary user colors.
75
- const hex: CalendarEventBase = { ...base, color: '#4f86f7' };
76
- const { container } = render(<EventChip occurrence={makeOccurrence(hex)} onClick={() => {}} />);
77
- const style = (container.querySelector('button') as HTMLButtonElement).style;
78
- // happy-dom preserves hex; browsers normalize to rgb — accept either
73
+ test('the time is in the name, so a phone that shows only the title still says when', () => {
74
+ render(<EventChip occurrence={makeOccurrence()} onClick={() => {}} />);
75
+ expect(screen.getByRole('button').getAttribute('aria-label')).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM) Stand-up$/);
76
+ });
77
+
78
+ test('a hex colour is a TINT with same-hue ink, never a solid slab', () => {
79
+ // happy-dom drops `color-mix()` from a style, so the resolver is asserted; the browser spec
80
+ // (e2e/calendar-time.spec.ts) reads the painted colour.
81
+ // guardrails-ignore no-hardcoded-colors: test fixture asserting on literal event color, not themeable chrome
82
+ const { chipStyle } = calendarColorClasses('#4f86f7');
79
83
  // guardrails-ignore no-hardcoded-colors: test fixture asserting on literal event color, not themeable chrome
80
- expect(style.backgroundColor).toMatch(/^(#4f86f7|rgb\(79,\s*134,\s*247\))$/i);
84
+ expect(chipStyle).toEqual({ backgroundColor: 'color-mix(in srgb, #4f86f7 18%, transparent)', color: 'color-mix(in srgb, #4f86f7 55%, var(--foreground))' });
81
85
  });
82
86
  });
@@ -15,7 +15,11 @@ export interface EventChipProps {
15
15
  /**
16
16
  * A compact event pill for month and week calendar grid cells. Shows the time
17
17
  * (if not all-day) and title in the event's color; struck through when done.
18
- * Mobile: time stacks above title; desktop: side by side.
18
+ *
19
+ * Below `sm` the chip is the TITLE alone, one line: a month cell on a 390px phone is ~50px wide,
20
+ * and time stacked over title left "9:00 A" over "Sta…" — neither readable (forge task 2591).
21
+ * Apple Calendar's and Google Calendar's phone month views both drop the time for the title; the
22
+ * time stays in the accessible name and the tooltip, and the week and day views show it.
19
23
  */
20
24
  export function EventChip({ occurrence, onClick, color }: EventChipProps) {
21
25
  const { event } = occurrence;
@@ -24,7 +28,10 @@ export function EventChip({ occurrence, onClick, color }: EventChipProps) {
24
28
  const done = occurrence.done;
25
29
  const pending = event.source === 'timeoff' && event.pending;
26
30
 
27
- const label = pending ? `${event.title} — not yet on your work calendar` : event.title;
31
+ const time = event.allDay ? null : fmtTime(occurrence.startAt);
32
+ // The name carries the time as well: on a phone the chip shows the title alone.
33
+ const named = time ? `${time} ${event.title}` : event.title;
34
+ const label = pending ? `${named} — not yet on your work calendar` : named;
28
35
 
29
36
  return (
30
37
  <Tooltip content={label} className='block w-full' multiline={pending}>
@@ -42,16 +49,14 @@ export function EventChip({ occurrence, onClick, color }: EventChipProps) {
42
49
  className={cn(
43
50
  'flex w-full min-w-0 truncate rounded px-1 py-0.5 text-left text-caption leading-tight',
44
51
  'min-h-11 sm:min-h-0',
45
- 'flex-col gap-0 sm:flex-row sm:items-center sm:gap-1 sm:px-1.5 sm:text-xs',
52
+ 'items-center gap-1 sm:px-1.5 sm:text-xs',
46
53
  chip,
47
54
  done && 'line-through opacity-60',
48
55
  pending && 'outline-dashed outline-1 outline-tone-amber-ink',
49
56
  )}
50
57
  style={chipStyle}
51
58
  >
52
- {!event.allDay && (
53
- <span className='shrink-0 font-semibold tabular-nums'>{fmtTime(occurrence.startAt)}</span>
54
- )}
59
+ {time && <span className='hidden shrink-0 font-semibold tabular-nums sm:inline'>{time}</span>}
55
60
  {pending && <span className='shrink-0'>⚠</span>}
56
61
  <span className='min-w-0 truncate'>{event.title}</span>
57
62
  </button>
@@ -129,7 +129,7 @@ export function MonthGrid({
129
129
  className={cn(
130
130
  'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
131
131
  ctx.isToday
132
- ? 'bg-accent-solid text-white'
132
+ ? 'bg-accent-solid text-accent-solid-foreground'
133
133
  : ctx.inMonth
134
134
  ? 'text-foreground'
135
135
  : // 🔴 Muted AND faded. `text-muted-foreground` alone was the same
@@ -65,7 +65,9 @@ export function WeekView({
65
65
  return (
66
66
  <Surface level="flat" pad="none" radius="xl" className={cn('overflow-hidden bg-card', className)}>
67
67
  {/* Weekday header with date numbers */}
68
- <div className='grid grid-cols-[repeat(7,minmax(0,1fr))] border-b border-border bg-muted/40'>
68
+ {/* The month grid's rules (MonthGrid.tsx): a softened header line and /40 inner rules, so the
69
+ two views of one calendar draw the same grid (forge task 2591). */}
70
+ <div className='grid grid-cols-[repeat(7,minmax(0,1fr))] border-b border-border/60 bg-muted/40'>
69
71
  {days.map((dayKey, i) => (
70
72
  <div key={dayKey} className='flex min-w-0 flex-col items-center px-1 py-2'>
71
73
  <span className='text-caption font-semibold uppercase tracking-wide text-muted-foreground sm:text-xs'>
@@ -74,7 +76,7 @@ export function WeekView({
74
76
  <span
75
77
  className={cn(
76
78
  'mt-0.5 flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold',
77
- dayKey === today ? 'bg-accent-solid text-white' : 'text-foreground',
79
+ dayKey === today ? 'bg-accent-solid text-accent-solid-foreground' : 'text-foreground',
78
80
  )}
79
81
  >
80
82
  {parseDayKey(dayKey).day}
@@ -97,7 +99,7 @@ export function WeekView({
97
99
  onClick={() => onSelectDay(day)}
98
100
  className={cn(
99
101
  'group flex min-h-40 min-w-0 cursor-pointer flex-col gap-1 overflow-hidden',
100
- 'border-r border-border p-1 last:border-r-0',
102
+ 'border-r border-border/40 p-1 last:border-r-0',
101
103
  'hover:bg-muted/30',
102
104
  dayKey === today && 'bg-accent/5',
103
105
  )}
@@ -53,15 +53,17 @@ const FALLBACK = COLOR_MAP.slate!;
53
53
  export const isHexColor = (color?: string): color is string =>
54
54
  !!color && /^#[0-9a-f]{6}$/i.test(color);
55
55
 
56
- /** WCAG relative luminance — returns near-black or near-white for readable contrast. */
57
- const readableTextOn = (hex: string): string => {
58
- const n = Number.parseInt(hex.slice(1), 16);
59
- const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
60
- const s = c / 255;
61
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
62
- }) as [number, number, number];
63
- return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.5 ? '#111827' : '#ffffff';
64
- };
56
+ /**
57
+ * A custom colour drawn the way the named ones are: a soft tint of the hue with ink of the same hue
58
+ * pulled toward the theme's foreground — so it reads in light AND dark, and sits beside the
59
+ * `bg-sky-100 text-sky-800` chips as a peer. Until forge task 2591 a hex was a SOLID fill with
60
+ * white or black text, the loudest mark on the grid whatever it meant (Google Calendar and Linear
61
+ * both tint a custom colour rather than fill with it).
62
+ */
63
+ const hexChip = (hex: string) => ({
64
+ backgroundColor: `color-mix(in srgb, ${hex} 18%, transparent)`,
65
+ color: `color-mix(in srgb, ${hex} 55%, var(--foreground))`,
66
+ });
65
67
 
66
68
  // ─── Public API ───────────────────────────────────────────────────────────────
67
69
 
@@ -72,9 +74,9 @@ const readableTextOn = (hex: string): string => {
72
74
  export const calendarColorClasses = (color?: string): ColorClasses => {
73
75
  if (isHexColor(color)) {
74
76
  return {
75
- chip: 'font-medium',
77
+ chip: '',
76
78
  dot: '',
77
- chipStyle: { backgroundColor: color, color: readableTextOn(color) },
79
+ chipStyle: hexChip(color),
78
80
  dotStyle: { backgroundColor: color },
79
81
  };
80
82
  }
@@ -0,0 +1,17 @@
1
+ import { AlertCircle } from 'lucide-react';
2
+ import type { ReactNode } from 'react';
3
+
4
+ /**
5
+ * The inline error row a {@link Field} draws under its control — icon, colour and size in one
6
+ * place. Internal: a control that cannot sit in a `Field` (SignupForm's terms checkbox, whose
7
+ * label is the checkbox's own) draws this, so its error reads like every other one (forge task
8
+ * 2466 found that checkbox's error with no icon, as a bare red line).
9
+ */
10
+ export function FieldErrorText({ id, children }: { id?: string; children: ReactNode }) {
11
+ return (
12
+ <p id={id} role='alert' className='m-0 flex items-center gap-1.5 text-sm text-field-error' data-cb-field-error=''>
13
+ <AlertCircle className='size-4 shrink-0' aria-hidden />
14
+ {children}
15
+ </p>
16
+ );
17
+ }
@@ -237,4 +237,20 @@ describe('createResumeClient', () => {
237
237
  });
238
238
  expect(await dead.read()).toBe(null);
239
239
  });
240
+
241
+ test('write and clear read their answer to the end, so the request finishes', async () => {
242
+ // Chromium reports a fetch whose body nobody reads as still loading: Playwright's
243
+ // `networkidle` then never comes, and music's design spec waited out its 10s cap at every
244
+ // place it audited — 200s of a 300s gate (forge task 2574).
245
+ const answers: Response[] = [];
246
+ const fetchImpl = (async () => {
247
+ const answer = new Response(JSON.stringify({ ok: true }));
248
+ answers.push(answer);
249
+ return answer;
250
+ }) as unknown as typeof fetch;
251
+ const client = createResumeClient({ key: 'library', fetchImpl });
252
+ await client.write('#/queue');
253
+ await client.clear();
254
+ expect(answers.map((a) => a.bodyUsed)).toEqual([true, true]);
255
+ });
240
256
  });
@@ -60,6 +60,13 @@ export function createResumeClient(options: ResumeClientOptions = {}): ResumeCli
60
60
  const doFetch = options.fetchImpl ?? fetch;
61
61
  return doFetch(`${basePath}?key=${encodeURIComponent(key)}`, { credentials: 'same-origin', ...init });
62
62
  };
63
+ /**
64
+ * A write's answer is read to the end even though nothing uses it: Chromium reports an unread
65
+ * body as a request still loading, so a page that wrote its place never reaches network-idle.
66
+ */
67
+ const send = async (init: RequestInit): Promise<void> => {
68
+ await (await call(init)).text();
69
+ };
63
70
  return {
64
71
  async read() {
65
72
  try {
@@ -73,7 +80,7 @@ export function createResumeClient(options: ResumeClientOptions = {}): ResumeCli
73
80
  },
74
81
  async write(href) {
75
82
  try {
76
- await call({
83
+ await send({
77
84
  method: 'PUT',
78
85
  headers: { 'content-type': 'application/json' },
79
86
  body: JSON.stringify({ location: href }),
@@ -84,7 +91,7 @@ export function createResumeClient(options: ResumeClientOptions = {}): ResumeCli
84
91
  },
85
92
  async clear() {
86
93
  try {
87
- await call({ method: 'DELETE' });
94
+ await send({ method: 'DELETE' });
88
95
  } catch {
89
96
  /* as above */
90
97
  }
@@ -608,6 +608,9 @@ export interface FileTreeProps {
608
608
  * The row's menu on **right-click** (and ⌃-click, and Shift+F10 when {@link onRowMenu} is not
609
609
  * wired) — drawn by the tree at the pointer, VS Code's and Finder's gesture.
610
610
  *
611
+ * With {@link onRowMenu} and no `rowMenu`, a right-click calls `onRowMenu` at the pointer
612
+ * instead and the app draws its own menu — spread `folderViewMenuItems` into it.
613
+ *
611
614
  * Owner, 2026-09-30: *"add an option to the right click menu to hide a folder"*. Hand it the
612
615
  * SAME list your ⋯ menu draws (the shape is `DropdownMenu`'s), so the two can never offer
613
616
  * different things; {@link folderView}'s "Hide folder" and "Tags…" are appended to it on a
@@ -2699,7 +2702,17 @@ export function FileTree({
2699
2702
  }}
2700
2703
  onFocus={() => setFocused({ id: node.id, pinned: pinnedCopy })}
2701
2704
  onContextMenu={(event) => {
2702
- if (inTools(event) || !hasRowMenu(node)) return;
2705
+ if (inTools(event)) return;
2706
+ // An app that draws its own row menu (`onRowMenu`, no `rowMenu`) gets the right-click
2707
+ // too, at the pointer — VS Code's gesture, and the one menu its ⋯ already opens.
2708
+ if (!rowMenu && onRowMenu) {
2709
+ event.preventDefault();
2710
+ event.stopPropagation();
2711
+ setFocused({ id: node.id, pinned: pinnedCopy });
2712
+ onRowMenu(node, { x: event.clientX, y: event.clientY });
2713
+ return;
2714
+ }
2715
+ if (!hasRowMenu(node)) return;
2703
2716
  event.preventDefault();
2704
2717
  event.stopPropagation();
2705
2718
  openRowMenu(node, pinnedCopy, { x: event.clientX, y: event.clientY });
@@ -72,7 +72,9 @@ export function folderTargets(
72
72
  }
73
73
 
74
74
  /**
75
- * "Hide folder" and "Tags…" for a folder — the row's half of the feature, as menu items.
75
+ * "Hide on this device", "Hide everywhere" and "Tags…" for a folder — the row's half of the
76
+ * feature, as menu items. With no device half kept (`useFolderView({ device: 'memory' })`) the
77
+ * two hides are one "Hide folder", which means everywhere.
76
78
  *
77
79
  * Exported so an app's own ⋯ menu offers exactly what the right-click does: spread it into
78
80
  * the list you hand `DropdownMenu`. `ids` is the folders it acts on (see {@link folderTargets});
@@ -82,24 +84,47 @@ export function folderViewMenuItems(
82
84
  view: FolderView,
83
85
  node: FileTreeNode,
84
86
  ids: readonly string[] = [node.id],
85
- hiddenState: 'self' | 'inherited' | null = view.marks.hidden.includes(node.id) ? 'self' : null,
87
+ hiddenState: 'self' | 'inherited' | null = view.hiddenIn(node.id) ? 'self' : null,
86
88
  ): FileTreeMenuItem[] {
87
89
  if (node.kind !== 'folder') return [];
88
90
  const many = ids.length > 1;
89
91
  const what = many ? `${ids.length} folders` : `“${node.name}”`;
90
92
  const items: FileTreeMenuItem[] = [];
93
+ const everywhere = () => {
94
+ view.hide(ids);
95
+ said(`Hid ${what}, on every device`, () => view.unhide(ids, { scope: 'everywhere' }), many ? '“Show hidden folders” (⇧⌘.) shows them again' : '“Show hidden folders” (⇧⌘.) shows it again');
96
+ };
91
97
  if (hiddenState === 'self') {
92
- items.push({
93
- key: 'fv-unhide',
94
- label: many ? `Unhide ${ids.length} folders` : 'Unhide folder',
95
- title: `Show ${what} in the tree again, on every device`,
96
- onClick: () => {
97
- view.unhide(ids);
98
- said(`${many ? `${ids.length} folders are` : `${what} is`} back in the tree, on every device`, () =>
99
- view.hide(ids),
100
- );
101
- },
102
- });
98
+ // Which list holds it decides the words: "Unhide" alone would not say where it comes back.
99
+ const scope = ids.length === 1 ? view.hiddenIn(node.id) : null;
100
+ const inDevice = scope === 'device' || scope === 'both' || (scope === null && ids.some((id) => view.local.hidden.includes(id)));
101
+ const inEverywhere = scope === 'everywhere' || scope === 'both' || (scope === null && ids.some((id) => view.marks.hidden.includes(id)));
102
+ if (inDevice)
103
+ items.push({
104
+ key: 'fv-unhide-device',
105
+ label: many ? `Unhide ${ids.length} folders on this device` : 'Unhide on this device',
106
+ title: inEverywhere
107
+ ? `Take ${what} off this device's list — it stays hidden everywhere until that is undone too`
108
+ : `Show ${what} in the tree on this device again`,
109
+ onClick: () => {
110
+ view.unhide(ids, { scope: 'device' });
111
+ said(`${many ? `${ids.length} folders are` : `${what} is`} back on this device`, () =>
112
+ view.hide(ids, { scope: 'device' }),
113
+ );
114
+ },
115
+ });
116
+ if (inEverywhere)
117
+ items.push({
118
+ key: 'fv-unhide',
119
+ label: many ? `Unhide ${ids.length} folders everywhere` : 'Unhide everywhere',
120
+ title: `Show ${what} in the tree again, on every device`,
121
+ onClick: () => {
122
+ view.unhide(ids, { scope: 'everywhere' });
123
+ said(`${many ? `${ids.length} folders are` : `${what} is`} back in the tree, on every device`, () =>
124
+ view.hide(ids),
125
+ );
126
+ },
127
+ });
103
128
  } else if (hiddenState === 'inherited') {
104
129
  items.push({
105
130
  key: 'fv-unhide',
@@ -107,15 +132,30 @@ export function folderViewMenuItems(
107
132
  disabled: true,
108
133
  title: 'It is inside a hidden folder — unhide that folder to bring this one back',
109
134
  });
135
+ } else if (view.deviceHiding) {
136
+ items.push(
137
+ {
138
+ key: 'fv-hide-device',
139
+ label: many ? `Hide ${ids.length} folders on this device` : 'Hide on this device',
140
+ title: `Leave ${what} out of the tree in this browser only. “Show hidden folders” (⇧⌘.) brings it back.`,
141
+ onClick: () => {
142
+ view.hide(ids, { scope: 'device' });
143
+ said(`Hid ${what}, on this device`, () => view.unhide(ids, { scope: 'device' }), '“Show hidden folders” (⇧⌘.) shows it again');
144
+ },
145
+ },
146
+ {
147
+ key: 'fv-hide',
148
+ label: many ? `Hide ${ids.length} folders everywhere` : 'Hide everywhere',
149
+ title: `Leave ${what} out of the tree on every device. “Show hidden folders” (⇧⌘.) brings it back.`,
150
+ onClick: everywhere,
151
+ },
152
+ );
110
153
  } else {
111
154
  items.push({
112
155
  key: 'fv-hide',
113
156
  label: many ? `Hide ${ids.length} folders` : 'Hide folder',
114
157
  title: `Leave ${what} out of the tree on every device. “Show hidden folders” (⇧⌘.) brings it back.`,
115
- onClick: () => {
116
- view.hide(ids);
117
- said(`Hid ${what}, on every device`, () => view.unhide(ids), many ? '“Show hidden folders” (⇧⌘.) shows them again' : '“Show hidden folders” (⇧⌘.) shows it again');
118
- },
158
+ onClick: everywhere,
119
159
  });
120
160
  }
121
161
  const current = view.marks.tags[node.id] ?? [];
@@ -6,9 +6,11 @@ import {
6
6
  EMPTY_FOLDER_VIEW,
7
7
  type FolderMarks,
8
8
  type FolderViewLocal,
9
+ hiddenScope,
9
10
  normalizeTag,
10
11
  parseFolderMarks,
11
12
  parseFolderViewLocal,
13
+ remapFolderMarks,
12
14
  serializeFolderMarks,
13
15
  setHidden,
14
16
  setTagFilter,
@@ -94,6 +96,41 @@ describe('hiding a folder', () => {
94
96
  });
95
97
  });
96
98
 
99
+ describe('hidden on THIS device only', () => {
100
+ test('🔴 a folder in the device list is gone from the tree, subtree and all, like an everywhere one', () => {
101
+ const out = applyFolderView(TREE, MARKS, view({ hidden: ['work'] }));
102
+ expect(ids(out.nodes)).not.toContain('work');
103
+ expect(ids(out.nodes)).not.toContain('dynatrace');
104
+ expect(out.hiddenCount).toBe(1);
105
+ });
106
+
107
+ test('"show hidden" draws a device-hidden folder back, marked `self`', () => {
108
+ const out = applyFolderView(TREE, MARKS, view({ hidden: ['work'], showHidden: true }));
109
+ expect(out.hiddenMarks.get('work')).toBe('self');
110
+ expect(out.hiddenMarks.get('dynatrace')).toBe('inherited');
111
+ });
112
+
113
+ test('one folder in both lists counts once', () => {
114
+ const out = applyFolderView(TREE, { ...MARKS, hidden: ['work'] }, view({ hidden: ['work'] }));
115
+ expect(out.hiddenCount).toBe(1);
116
+ });
117
+
118
+ test('hiddenScope names which list holds a folder', () => {
119
+ const marks = { ...MARKS, hidden: ['work', 'home'] };
120
+ const local = view({ hidden: ['home', 'code'] });
121
+ expect(hiddenScope(marks, local, 'work')).toBe('everywhere');
122
+ expect(hiddenScope(marks, local, 'code')).toBe('device');
123
+ expect(hiddenScope(marks, local, 'home')).toBe('both');
124
+ expect(hiddenScope(marks, local, 'scratch')).toBeNull();
125
+ });
126
+
127
+ test('setHidden works on the device list too', () => {
128
+ const local = setHidden(EMPTY_FOLDER_VIEW, ['work'], true);
129
+ expect(local.hidden).toEqual(['work']);
130
+ expect(local.showHidden).toBe(false);
131
+ });
132
+ });
133
+
97
134
  describe('filtering by tag (this device)', () => {
98
135
  test('"only home + code" keeps those folders WITH their subtrees, and the path to a nested one', () => {
99
136
  const out = applyFolderView(TREE, MARKS, view({ only: ['home', 'code'] }));
@@ -159,6 +196,30 @@ describe('tags', () => {
159
196
  });
160
197
  });
161
198
 
199
+ describe('remapFolderMarks — a path-keyed tree renamed a folder', () => {
200
+ const marks: FolderMarks = {
201
+ hidden: ['a/b', 'c'],
202
+ tags: { 'a/b': ['work'], 'a/b/d': ['home'], c: ['code'] },
203
+ };
204
+ const rename = (id: string) => (id === 'a/b' || id.startsWith('a/b/') ? `x${id.slice(3)}` : id);
205
+
206
+ test('🔴 hidden and tags follow the folder and everything under it', () => {
207
+ const out = remapFolderMarks(marks, rename);
208
+ expect(out.hidden).toEqual(['x', 'c']);
209
+ expect(out.tags).toEqual({ x: ['work'], 'x/d': ['home'], c: ['code'] });
210
+ });
211
+
212
+ test('null forgets a mark — a deleted folder', () => {
213
+ const out = remapFolderMarks(marks, (id) => (id === 'c' ? null : id));
214
+ expect(out.hidden).toEqual(['a/b']);
215
+ expect(out.tags.c).toBeUndefined();
216
+ });
217
+
218
+ test('nothing moved → the same value back', () => {
219
+ expect(remapFolderMarks(marks, (id) => id)).toBe(marks);
220
+ });
221
+ });
222
+
162
223
  describe('storage', () => {
163
224
  test('marks round-trip, and empty marks store as nothing so the key can be forgotten', () => {
164
225
  const marks: FolderMarks = { hidden: ['a'], tags: { b: ['home'] } };
@@ -169,6 +230,7 @@ describe('storage', () => {
169
230
  test('🔴 a corrupt value reads as NOTHING rather than throwing — it must never cost the tree', () => {
170
231
  expect(parseFolderMarks('{not json')).toEqual(EMPTY_FOLDER_MARKS);
171
232
  expect(parseFolderMarks('{"hidden":"x","tags":[1]}')).toEqual({ hidden: [], tags: {} });
172
- expect(parseFolderViewLocal('[]')).toEqual({ showHidden: false, only: [], hide: [] });
233
+ expect(parseFolderViewLocal('[]')).toEqual({ showHidden: false, only: [], hide: [], hidden: [] });
234
+ expect(parseFolderViewLocal('{"hidden":["a",1,"a",""]}').hidden).toEqual(['a']);
173
235
  });
174
236
  });
@@ -13,8 +13,11 @@
13
13
  * folder, so it follows the person to every device (the app syncs it — see
14
14
  * `useFolderView`, which announces it on the preference bus like every other synced key).
15
15
  * · {@link FolderViewLocal} — HOW this device is looking: whether hidden folders are drawn,
16
- * and which tags it is narrowed to. *"viewing the 'home' tag at home doesn't change my
17
- * work computer"*, so this is never announced and never leaves the browser.
16
+ * which tags it is narrowed to, and the folders hidden on THIS device only. *"viewing the
17
+ * 'home' tag at home doesn't change my work computer"*, so this is never announced and
18
+ * never leaves the browser. The device-only list is desk's owner request, 2026-09-30:
19
+ * *"a way to hide on the current device or to hide everywhere … and unhide them on just
20
+ * the device or unhide them everywhere"* — a folder hidden by either list is hidden.
18
21
  *
19
22
  * Hiding a folder in general and filtering by tag are therefore different verbs on purpose:
20
23
  * one is a decision about the folder, the other is a lens on the tree.
@@ -50,6 +53,8 @@ export interface FolderViewLocal {
50
53
  only: readonly string[];
51
54
  /** Leave out folders carrying any of these. Wins over {@link only}. */
52
55
  hide: readonly string[];
56
+ /** Folders hidden on this device only — {@link FolderMarks.hidden}'s per-device twin. */
57
+ hidden: readonly string[];
53
58
  }
54
59
 
55
60
  export const EMPTY_FOLDER_MARKS: FolderMarks = Object.freeze({
@@ -61,6 +66,7 @@ export const EMPTY_FOLDER_VIEW: FolderViewLocal = Object.freeze({
61
66
  showHidden: false,
62
67
  only: Object.freeze([]) as readonly string[],
63
68
  hide: Object.freeze([]) as readonly string[],
69
+ hidden: Object.freeze([]) as readonly string[],
64
70
  });
65
71
 
66
72
  /** Longest tag kept. A tag is a word or two; anything longer is a sentence in the wrong field. */
@@ -120,25 +126,69 @@ export function serializeFolderMarks(marks: FolderMarks): string | null {
120
126
  export function parseFolderViewLocal(raw: string | null | undefined): FolderViewLocal {
121
127
  if (!raw) return EMPTY_FOLDER_VIEW;
122
128
  try {
123
- const value = JSON.parse(raw) as { showHidden?: unknown; only?: unknown; hide?: unknown };
129
+ const value = JSON.parse(raw) as { showHidden?: unknown; only?: unknown; hide?: unknown; hidden?: unknown };
124
130
  return {
125
131
  showHidden: value.showHidden === true,
126
132
  only: Array.isArray(value.only) ? uniqueTags(value.only as string[]) : [],
127
133
  hide: Array.isArray(value.hide) ? uniqueTags(value.hide as string[]) : [],
134
+ hidden: Array.isArray(value.hidden)
135
+ ? [...new Set(value.hidden.filter((id): id is string => typeof id === 'string' && id !== ''))]
136
+ : [],
128
137
  };
129
138
  } catch {
130
139
  return EMPTY_FOLDER_VIEW;
131
140
  }
132
141
  }
133
142
 
134
- /** Hide or unhide these ids — a new value; the input is never touched. */
135
- export function setHidden(marks: FolderMarks, ids: readonly string[], hidden: boolean): FolderMarks {
143
+ /**
144
+ * Hide or unhide these ids in either list — {@link FolderMarks} (everywhere) or
145
+ * {@link FolderViewLocal} (this device). A new value; the input is never touched, and an
146
+ * unchanged list hands the input back so a caller can tell nothing happened.
147
+ */
148
+ export function setHidden<T extends { hidden: readonly string[] }>(
149
+ holder: T,
150
+ ids: readonly string[],
151
+ hidden: boolean,
152
+ ): T {
136
153
  const next = hidden
137
- ? [...marks.hidden, ...ids.filter((id) => !marks.hidden.includes(id))]
138
- : marks.hidden.filter((id) => !ids.includes(id));
139
- return next.length === marks.hidden.length && next.every((id, i) => id === marks.hidden[i])
140
- ? marks
141
- : { ...marks, hidden: next };
154
+ ? [...holder.hidden, ...ids.filter((id) => !holder.hidden.includes(id))]
155
+ : holder.hidden.filter((id) => !ids.includes(id));
156
+ return next.length === holder.hidden.length && next.every((id, i) => id === holder.hidden[i])
157
+ ? holder
158
+ : { ...holder, hidden: next };
159
+ }
160
+
161
+ /**
162
+ * Re-key the marks after folders were renamed, moved or deleted — for a tree whose folder ids
163
+ * are PATHS (music, vault), where a rename hands the folder and everything under it a new id.
164
+ * `move(id)` answers each marked id with its new id, the same id to keep it, or `null` to forget
165
+ * it. Hidden and tags both follow; the input comes back untouched when nothing moved.
166
+ */
167
+ export function remapFolderMarks(marks: FolderMarks, move: (id: string) => string | null): FolderMarks {
168
+ let changed = false;
169
+ const hidden: string[] = [];
170
+ for (const id of marks.hidden) {
171
+ const to = move(id);
172
+ if (to !== id) changed = true;
173
+ if (to !== null && !hidden.includes(to)) hidden.push(to);
174
+ }
175
+ const tags: Record<string, readonly string[]> = {};
176
+ for (const [id, list] of Object.entries(marks.tags)) {
177
+ const to = move(id);
178
+ if (to !== id) changed = true;
179
+ if (to === null) continue;
180
+ tags[to] = tags[to] ? uniqueTags([...tags[to], ...list]) : list;
181
+ }
182
+ return changed ? { hidden, tags } : marks;
183
+ }
184
+
185
+ /** Where a folder is hidden, if it is: which of the two lists name it. */
186
+ export type HiddenScope = 'everywhere' | 'device' | 'both';
187
+
188
+ export function hiddenScope(marks: FolderMarks, local: FolderViewLocal, id: string): HiddenScope | null {
189
+ const everywhere = marks.hidden.includes(id);
190
+ const device = local.hidden.includes(id);
191
+ return everywhere && device ? 'both' : everywhere ? 'everywhere' : device ? 'device' : null;
142
192
  }
143
193
 
144
194
  /** Replace one folder's tags. An empty list forgets the folder entirely. */
@@ -242,9 +292,10 @@ export function applyFolderView(
242
292
  ): FolderViewResult {
243
293
  const hiddenMarks = new Map<string, 'self' | 'inherited'>();
244
294
  const filtering = tagFilterActive(local);
245
- if (marks.hidden.length === 0 && !filtering) return { nodes, hiddenMarks, hiddenCount: 0 };
295
+ if (marks.hidden.length === 0 && local.hidden.length === 0 && !filtering)
296
+ return { nodes, hiddenMarks, hiddenCount: 0 };
246
297
 
247
- const hiddenSet = new Set(marks.hidden);
298
+ const hiddenSet = new Set([...marks.hidden, ...local.hidden]);
248
299
  const only = new Set(local.only);
249
300
  const hide = new Set(local.hide);
250
301
  let hiddenCount = 0;