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.
- package/dist/react/charts/IntervalTimeline.d.ts.map +1 -1
- package/dist/react/charts/IntervalTimeline.js +2 -2
- package/dist/react/charts/IntervalTimeline.js.map +1 -1
- package/dist/react/chat/DateMarker.d.ts +4 -0
- package/dist/react/chat/DateMarker.d.ts.map +1 -1
- package/dist/react/chat/DateMarker.js +15 -2
- package/dist/react/chat/DateMarker.js.map +1 -1
- package/dist/react/components/Button.d.ts.map +1 -1
- package/dist/react/components/Button.js +5 -0
- package/dist/react/components/Button.js.map +1 -1
- package/dist/react/components/DataTable/DataTable.d.ts.map +1 -1
- package/dist/react/components/DataTable/DataTable.js +1 -1
- package/dist/react/components/DataTable/DataTable.js.map +1 -1
- package/dist/react/components/DataTable/SortHeader.d.ts +4 -1
- package/dist/react/components/DataTable/SortHeader.d.ts.map +1 -1
- package/dist/react/components/DataTable/SortHeader.js +22 -5
- package/dist/react/components/DataTable/SortHeader.js.map +1 -1
- package/dist/react/components/DataTable/types.d.ts +10 -0
- package/dist/react/components/DataTable/types.d.ts.map +1 -1
- package/dist/react/components/DataTable/types.js.map +1 -1
- package/dist/react/components/DropdownMenu.js +10 -4
- package/dist/react/components/DropdownMenu.js.map +1 -1
- package/dist/react/components/Field.d.ts +9 -1
- package/dist/react/components/Field.d.ts.map +1 -1
- package/dist/react/components/Field.js +3 -3
- package/dist/react/components/Field.js.map +1 -1
- package/dist/react/components/SecretInput.d.ts +4 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +2 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/auth/AuthCard.d.ts.map +1 -1
- package/dist/react/components/auth/AuthCard.js +5 -1
- package/dist/react/components/auth/AuthCard.js.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordSetFields.js +2 -2
- package/dist/react/components/auth/PasswordSetFields.js.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.d.ts.map +1 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js +3 -1
- package/dist/react/components/auth/PasswordStrengthMeter.js.map +1 -1
- package/dist/react/components/auth/RegisterForm.js +1 -1
- package/dist/react/components/auth/RegisterForm.js.map +1 -1
- package/dist/react/components/auth/SignupForm.d.ts.map +1 -1
- package/dist/react/components/auth/SignupForm.js +4 -2
- package/dist/react/components/auth/SignupForm.js.map +1 -1
- package/dist/react/components/auth/TotpEntry.d.ts.map +1 -1
- package/dist/react/components/auth/TotpEntry.js +18 -1
- package/dist/react/components/auth/TotpEntry.js.map +1 -1
- package/dist/react/components/auth/fields.d.ts +3 -1
- package/dist/react/components/auth/fields.d.ts.map +1 -1
- package/dist/react/components/auth/fields.js +6 -2
- package/dist/react/components/auth/fields.js.map +1 -1
- package/dist/react/components/calendar/DayAgenda.d.ts.map +1 -1
- package/dist/react/components/calendar/DayAgenda.js +4 -2
- package/dist/react/components/calendar/DayAgenda.js.map +1 -1
- package/dist/react/components/calendar/EventChip.d.ts +5 -1
- package/dist/react/components/calendar/EventChip.d.ts.map +1 -1
- package/dist/react/components/calendar/EventChip.js +10 -3
- package/dist/react/components/calendar/EventChip.js.map +1 -1
- package/dist/react/components/calendar/MonthGrid.js +1 -1
- package/dist/react/components/calendar/MonthGrid.js.map +1 -1
- package/dist/react/components/calendar/WeekView.d.ts.map +1 -1
- package/dist/react/components/calendar/WeekView.js +2 -2
- package/dist/react/components/calendar/WeekView.js.map +1 -1
- package/dist/react/components/calendar/colorUtils.d.ts.map +1 -1
- package/dist/react/components/calendar/colorUtils.js +13 -11
- package/dist/react/components/calendar/colorUtils.js.map +1 -1
- package/dist/react/components/fieldErrorText.d.ts +12 -0
- package/dist/react/components/fieldErrorText.d.ts.map +1 -0
- package/dist/react/components/fieldErrorText.js +12 -0
- package/dist/react/components/fieldErrorText.js.map +1 -0
- package/dist/react/deep-link/resume.d.ts.map +1 -1
- package/dist/react/deep-link/resume.js +9 -2
- package/dist/react/deep-link/resume.js.map +1 -1
- package/dist/react/file-tree/FileTree.d.ts +3 -0
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +12 -1
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts +3 -1
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +51 -15
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/folderView.d.ts +25 -4
- package/dist/react/file-tree/folderView.d.ts.map +1 -1
- package/dist/react/file-tree/folderView.js +49 -9
- package/dist/react/file-tree/folderView.js.map +1 -1
- package/dist/react/file-tree/index.d.ts +2 -1
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +2 -1
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +34 -4
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +43 -11
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +8 -1
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +33 -8
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +12 -1
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +30 -2
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/controlBar.d.ts +3 -2
- package/dist/react/media/controlBar.d.ts.map +1 -1
- package/dist/react/media/controlBar.js +16 -10
- package/dist/react/media/controlBar.js.map +1 -1
- package/dist/react/media/denseGallery.css +56 -18
- package/dist/react/media/videoPlayer.css +2 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +8 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/styles-areas/analytics.css +26 -25
- package/dist/styles-areas/calendar.css +4 -4
- package/dist/styles-areas/data-table.css +26 -25
- package/dist/styles-utilities.css +24 -9
- package/package.json +5 -1
- package/scripts/demoCoverage.spec.ts +33 -0
- package/scripts/demoCoverage.ts +124 -0
- package/src/react/charts/IntervalTimeline.tsx +6 -4
- package/src/react/chat/DateMarker.tsx +22 -6
- package/src/react/chat/MessageThread.spec.tsx +25 -1
- package/src/react/components/Button.spec.tsx +11 -0
- package/src/react/components/Button.tsx +5 -0
- package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
- package/src/react/components/DataTable/DataTable.tsx +1 -0
- package/src/react/components/DataTable/SortHeader.tsx +45 -5
- package/src/react/components/DataTable/types.ts +10 -0
- package/src/react/components/DropdownMenu.spec.tsx +26 -0
- package/src/react/components/DropdownMenu.tsx +10 -3
- package/src/react/components/Field.spec.tsx +24 -0
- package/src/react/components/Field.tsx +15 -5
- package/src/react/components/SecretInput.tsx +5 -1
- package/src/react/components/auth/AuthCard.spec.tsx +8 -0
- package/src/react/components/auth/AuthCard.tsx +4 -1
- package/src/react/components/auth/PasswordSetFields.tsx +7 -11
- package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
- package/src/react/components/auth/RegisterForm.tsx +6 -6
- package/src/react/components/auth/SignupForm.spec.tsx +17 -0
- package/src/react/components/auth/SignupForm.tsx +11 -10
- package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
- package/src/react/components/auth/TotpEntry.tsx +18 -1
- package/src/react/components/auth/fields.tsx +8 -1
- package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
- package/src/react/components/calendar/DayAgenda.tsx +5 -2
- package/src/react/components/calendar/EventChip.spec.tsx +12 -8
- package/src/react/components/calendar/EventChip.tsx +11 -6
- package/src/react/components/calendar/MonthGrid.tsx +1 -1
- package/src/react/components/calendar/WeekView.tsx +5 -3
- package/src/react/components/calendar/colorUtils.ts +13 -11
- package/src/react/components/fieldErrorText.tsx +17 -0
- package/src/react/deep-link/resume.spec.tsx +16 -0
- package/src/react/deep-link/resume.ts +9 -2
- package/src/react/file-tree/FileTree.tsx +14 -1
- package/src/react/file-tree/FolderViewMenus.tsx +57 -17
- package/src/react/file-tree/folderView.spec.ts +63 -1
- package/src/react/file-tree/folderView.ts +63 -12
- package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
- package/src/react/file-tree/index.ts +15 -1
- package/src/react/file-tree/useFolderView.ts +73 -12
- package/src/react/media/DenseGalleryShell.spec.tsx +9 -7
- package/src/react/media/DenseGalleryShell.tsx +53 -11
- package/src/react/media/VideoPlayer.tsx +78 -2
- package/src/react/media/controlBar.tsx +17 -10
- package/src/react/media/denseGallery.css +56 -18
- package/src/react/media/videoPlayer.css +2 -1
- package/src/react/media-gallery/MediaGallery.spec.tsx +4 -4
- package/src/react/media-gallery/MediaGallery.tsx +12 -0
- package/src/styles-areas/analytics.css +26 -25
- package/src/styles-areas/calendar.css +4 -4
- package/src/styles-areas/data-table.css +26 -25
- 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('
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// happy-dom
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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
|
-
'
|
|
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
|
-
{
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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: '
|
|
77
|
+
chip: '',
|
|
76
78
|
dot: '',
|
|
77
|
-
chipStyle:
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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.
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
*
|
|
17
|
-
* work computer"*, so this is never announced and
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
? [...
|
|
138
|
-
:
|
|
139
|
-
return next.length ===
|
|
140
|
-
?
|
|
141
|
-
: { ...
|
|
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 &&
|
|
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;
|