cursedbelt 5.46.2 → 5.47.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/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/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
|
@@ -57,7 +57,10 @@ export function AuthCard({
|
|
|
57
57
|
<div
|
|
58
58
|
className={cn(
|
|
59
59
|
'flex w-full flex-col items-center justify-center gap-6 p-4',
|
|
60
|
-
|
|
60
|
+
// The page colour belongs to the full-screen page only. Embedded, the card sits on its
|
|
61
|
+
// host's surface — painting `bg-background` there drew a slab around it, a box on a box
|
|
62
|
+
// inside the host's panel (forge task 2466).
|
|
63
|
+
aurora ? 'dark cb-aurora cb-auth-aurora' : fullScreen && 'bg-background',
|
|
61
64
|
fullScreen && 'min-h-dvh',
|
|
62
65
|
className,
|
|
63
66
|
)}
|
|
@@ -36,17 +36,13 @@ export function PasswordSetFields({
|
|
|
36
36
|
|
|
37
37
|
return (
|
|
38
38
|
<>
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{showStrength ? (
|
|
47
|
-
<PasswordStrengthMeter value={password ?? ''} minLength={minPasswordLength} />
|
|
48
|
-
) : null}
|
|
49
|
-
</div>
|
|
39
|
+
<AuthPasswordField
|
|
40
|
+
name={passwordName}
|
|
41
|
+
label={passwordLabel}
|
|
42
|
+
autoComplete='new-password'
|
|
43
|
+
autoFocus={autoFocus}
|
|
44
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
45
|
+
/>
|
|
50
46
|
{showConfirm ? (
|
|
51
47
|
<AuthPasswordField name={confirmName} label={confirmLabel} autoComplete='new-password' />
|
|
52
48
|
) : null}
|
|
@@ -40,9 +40,11 @@ export function PasswordStrengthMeter({
|
|
|
40
40
|
if (!value) return null;
|
|
41
41
|
const tone = SEGMENT_TONE[level as Exclude<PasswordStrengthLevel, 'empty'>];
|
|
42
42
|
|
|
43
|
+
// ONE line (forge task 2466): it sits in a field's error slot (`Field status`), which is one
|
|
44
|
+
// line of `text-sm` tall, so the bars and the word share a row rather than stacking.
|
|
43
45
|
return (
|
|
44
|
-
<div className={cn('flex
|
|
45
|
-
<div className='flex gap-1' aria-hidden>
|
|
46
|
+
<div className={cn('flex min-h-[1lh] items-center gap-3 text-sm', className)}>
|
|
47
|
+
<div className='flex flex-1 gap-1' aria-hidden>
|
|
46
48
|
{[0, 1, 2, 3].map((i) => (
|
|
47
49
|
<span
|
|
48
50
|
key={i}
|
|
@@ -54,7 +56,7 @@ export function PasswordStrengthMeter({
|
|
|
54
56
|
))}
|
|
55
57
|
</div>
|
|
56
58
|
<p
|
|
57
|
-
className='text-xs text-muted-foreground'
|
|
59
|
+
className='m-0 shrink-0 text-xs text-muted-foreground'
|
|
58
60
|
role='status'
|
|
59
61
|
aria-label={`Password strength: ${LABEL[level]}`}
|
|
60
62
|
>
|
|
@@ -82,12 +82,12 @@ export function RegisterForm({
|
|
|
82
82
|
>
|
|
83
83
|
{children}
|
|
84
84
|
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
<AuthPasswordField
|
|
86
|
+
name='password'
|
|
87
|
+
label='Password'
|
|
88
|
+
autoComplete='new-password'
|
|
89
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
90
|
+
/>
|
|
91
91
|
|
|
92
92
|
<AuthPasswordField
|
|
93
93
|
name='confirmPassword'
|
|
@@ -52,6 +52,23 @@ describe('SignupForm', () => {
|
|
|
52
52
|
expect(queryByText(/Password strength/)).not.toBeNull();
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
test('a password validates on BLUR, like every other field — not only on submit (task 2466)', async () => {
|
|
56
|
+
const { container, findByText } = render(<SignupForm onSubmit={async () => {}} />);
|
|
57
|
+
const password = container.querySelector('#cb-f-password') as HTMLInputElement;
|
|
58
|
+
fireEvent.change(password, { target: { value: 'short' } });
|
|
59
|
+
fireEvent.blur(password);
|
|
60
|
+
await findByText('Use at least 8 characters');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("the terms error is a field error like every other — icon, and wired to the checkbox (task 2466)", async () => {
|
|
64
|
+
const { getByRole, findByText } = render(<SignupForm onSubmit={async () => {}} terms='I agree to the terms' />);
|
|
65
|
+
fireEvent.click(getByRole('button', { name: 'Create account' }));
|
|
66
|
+
const error = (await findByText('Please accept to continue')).closest('p') as HTMLElement;
|
|
67
|
+
expect(error.hasAttribute('data-cb-field-error')).toBe(true);
|
|
68
|
+
expect(error.querySelector('svg')).not.toBeNull();
|
|
69
|
+
expect(getByRole('checkbox').getAttribute('aria-describedby')).toBe(error.id);
|
|
70
|
+
});
|
|
71
|
+
|
|
55
72
|
test('requires the terms checkbox when terms are given', async () => {
|
|
56
73
|
const onSubmit = mock(async () => {});
|
|
57
74
|
const { container, getByRole, findByText } = render(
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type ReactNode, useMemo } from 'react';
|
|
1
|
+
import { type ReactNode, useId, useMemo } from 'react';
|
|
2
2
|
import { Controller, useForm } from 'react-hook-form';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { makeFormConfig } from '../../lib/form.js';
|
|
5
5
|
import { Checkbox } from '../Checkbox.js';
|
|
6
|
+
import { FieldErrorText } from '../fieldErrorText.js';
|
|
6
7
|
import { AuthForm } from './AuthForm.js';
|
|
7
8
|
import { AuthPasswordField, AuthTextField } from './fields.js';
|
|
8
9
|
import { PasswordStrengthMeter } from './PasswordStrengthMeter.js';
|
|
@@ -93,6 +94,7 @@ export function SignupForm({
|
|
|
93
94
|
);
|
|
94
95
|
|
|
95
96
|
const password = form.watch('password');
|
|
97
|
+
const termsErrorId = useId();
|
|
96
98
|
|
|
97
99
|
return (
|
|
98
100
|
<AuthForm
|
|
@@ -115,12 +117,12 @@ export function SignupForm({
|
|
|
115
117
|
|
|
116
118
|
{children}
|
|
117
119
|
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
<AuthPasswordField
|
|
121
|
+
name='password'
|
|
122
|
+
label='Password'
|
|
123
|
+
autoComplete='new-password'
|
|
124
|
+
status={showStrength && password ? <PasswordStrengthMeter value={password} minLength={minPasswordLength} /> : undefined}
|
|
125
|
+
/>
|
|
124
126
|
|
|
125
127
|
{showConfirm ? (
|
|
126
128
|
<AuthPasswordField
|
|
@@ -140,12 +142,11 @@ export function SignupForm({
|
|
|
140
142
|
checked={field.value}
|
|
141
143
|
onCheckedChange={(v) => field.onChange(v === true)}
|
|
142
144
|
aria-invalid={form.formState.errors.acceptTerms ? true : undefined}
|
|
145
|
+
aria-describedby={form.formState.errors.acceptTerms ? termsErrorId : undefined}
|
|
143
146
|
label={terms}
|
|
144
147
|
/>
|
|
145
148
|
{form.formState.errors.acceptTerms ? (
|
|
146
|
-
<
|
|
147
|
-
{form.formState.errors.acceptTerms.message as string}
|
|
148
|
-
</p>
|
|
149
|
+
<FieldErrorText id={termsErrorId}>{form.formState.errors.acceptTerms.message as string}</FieldErrorText>
|
|
149
150
|
) : null}
|
|
150
151
|
</div>
|
|
151
152
|
)}
|
|
@@ -32,6 +32,33 @@ describe('TotpEntry', () => {
|
|
|
32
32
|
expect(onSubmit).toHaveBeenCalledWith({ code: 'ABCDE-FGHIJ', recovery: true });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test('a refused code is cleared and the caret goes back to the first box (task 2466)', async () => {
|
|
36
|
+
const onSubmit = mock(async () => {
|
|
37
|
+
throw { message: 'That code has expired.' };
|
|
38
|
+
});
|
|
39
|
+
const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
|
|
40
|
+
const boxes = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
|
|
41
|
+
for (let i = 0; i < 6; i++) fireEvent.change(boxes[i], { target: { value: String(i + 1) } });
|
|
42
|
+
fireEvent.click(getByRole('button', { name: 'Verify' }));
|
|
43
|
+
await findByText('That code has expired.');
|
|
44
|
+
const after = Array.from(container.querySelectorAll('input')) as HTMLInputElement[];
|
|
45
|
+
await waitFor(() => expect(after.map((b) => b.value).join('')).toBe(''));
|
|
46
|
+
expect(document.activeElement).toBe(after[0]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a refused RECOVERY code stays, for the user to correct', async () => {
|
|
50
|
+
const onSubmit = mock(async () => {
|
|
51
|
+
throw { message: 'That recovery code was already used.' };
|
|
52
|
+
});
|
|
53
|
+
const { container, getByRole, findByText } = render(<TotpEntry onSubmit={onSubmit} autoSubmit={false} />);
|
|
54
|
+
fireEvent.click(getByRole('button', { name: 'Use a recovery code instead' }));
|
|
55
|
+
const input = container.querySelector('#cb-f-code') as HTMLInputElement;
|
|
56
|
+
fireEvent.change(input, { target: { value: 'ABCDE-FGHIJ' } });
|
|
57
|
+
fireEvent.click(getByRole('button', { name: 'Verify' }));
|
|
58
|
+
await findByText('That recovery code was already used.');
|
|
59
|
+
expect((container.querySelector('#cb-f-code') as HTMLInputElement).value).toBe('ABCDE-FGHIJ');
|
|
60
|
+
});
|
|
61
|
+
|
|
35
62
|
test('omits the recovery toggle when allowRecoveryCode is false', () => {
|
|
36
63
|
const { queryByRole } = render(
|
|
37
64
|
<TotpEntry onSubmit={async () => {}} allowRecoveryCode={false} autoSubmit={false} />,
|
|
@@ -72,11 +72,26 @@ export function TotpEntry({
|
|
|
72
72
|
form.clearErrors();
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
// A refused authenticator code is dead — it rotates in seconds — so clear it and put the caret
|
|
76
|
+
// back on the first box, the way GitHub and Stripe do (forge task 2466). A recovery code is
|
|
77
|
+
// long and typed by hand, so it stays for the user to correct.
|
|
78
|
+
const submit: AuthSubmitHandler<TotpValues> = async (values) => {
|
|
79
|
+
try {
|
|
80
|
+
return await onSubmit(values);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!values.recovery) {
|
|
83
|
+
form.setValue('code', '');
|
|
84
|
+
formRef.current?.querySelector<HTMLInputElement>('input')?.focus();
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
75
90
|
return (
|
|
76
91
|
<AuthForm
|
|
77
92
|
form={form}
|
|
78
93
|
formRef={formRef}
|
|
79
|
-
onSubmit={
|
|
94
|
+
onSubmit={submit}
|
|
80
95
|
onSuccess={onSuccess}
|
|
81
96
|
mapError={mapError}
|
|
82
97
|
submitLabel={submitLabel}
|
|
@@ -128,6 +143,8 @@ export function TotpEntry({
|
|
|
128
143
|
value={field.value}
|
|
129
144
|
onChange={field.onChange}
|
|
130
145
|
length={length}
|
|
146
|
+
// Spread across the column, so the boxes end where every other row does.
|
|
147
|
+
className='w-full justify-between'
|
|
131
148
|
onComplete={autoSubmit ? () => formRef.current?.requestSubmit() : undefined}
|
|
132
149
|
aria-label='Authentication code'
|
|
133
150
|
aria-invalid={aria['aria-invalid']}
|
|
@@ -68,6 +68,8 @@ export interface AuthPasswordFieldProps {
|
|
|
68
68
|
description?: ReactNode;
|
|
69
69
|
required?: boolean;
|
|
70
70
|
autoFocus?: boolean;
|
|
71
|
+
/** Live feedback under the field — the strength meter — in its error slot ({@link Field}'s `status`). */
|
|
72
|
+
status?: ReactNode;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -83,10 +85,11 @@ export function AuthPasswordField({
|
|
|
83
85
|
description,
|
|
84
86
|
required,
|
|
85
87
|
autoFocus,
|
|
88
|
+
status,
|
|
86
89
|
}: AuthPasswordFieldProps) {
|
|
87
90
|
const { control } = useFormContext();
|
|
88
91
|
return (
|
|
89
|
-
<Field name={name} label={label} description={description} required={required} reserveErrorSpace>
|
|
92
|
+
<Field name={name} label={label} description={description} required={required} reserveErrorSpace status={status}>
|
|
90
93
|
{(aria) => (
|
|
91
94
|
<Controller
|
|
92
95
|
name={name}
|
|
@@ -97,6 +100,10 @@ export function AuthPasswordField({
|
|
|
97
100
|
name={field.name}
|
|
98
101
|
value={field.value ?? ''}
|
|
99
102
|
onChange={field.onChange}
|
|
103
|
+
// 🔴 Without this a password never validated on blur, only on submit — the forms
|
|
104
|
+
// are `mode: 'onBlur'`, and RHF only learns of a blur through `field.onBlur`
|
|
105
|
+
// (forge task 2466: "Use at least 8 characters" never showed until submit).
|
|
106
|
+
onBlur={field.onBlur}
|
|
100
107
|
label={labelText(label, name)}
|
|
101
108
|
placeholder={placeholder}
|
|
102
109
|
autoComplete={autoComplete}
|
|
@@ -24,6 +24,15 @@ describe('DayAgenda', () => {
|
|
|
24
24
|
expect(screen.getByText('Nothing planned')).toBeTruthy();
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
test('an empty day points at Add only when there is an Add to press', () => {
|
|
28
|
+
const { unmount } = render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} />);
|
|
29
|
+
expect(screen.getByText('No events on this day.')).toBeTruthy();
|
|
30
|
+
expect(screen.queryByText(/Add/)).toBeNull();
|
|
31
|
+
unmount();
|
|
32
|
+
render(<DayAgenda dayDate={DAY} events={[]} onSelectOccurrence={() => {}} onAdd={() => {}} />);
|
|
33
|
+
expect(screen.getByText('Use Add to create an event on this day.')).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
27
36
|
test('renders an event title', () => {
|
|
28
37
|
const events = [makeEvent({ id: 'e1', title: 'Morning run' })];
|
|
29
38
|
render(<DayAgenda dayDate={DAY} events={events} onSelectOccurrence={() => {}} />);
|
|
@@ -89,7 +89,8 @@ export function DayAgenda({
|
|
|
89
89
|
<EmptyState
|
|
90
90
|
icon={<CalendarIcon className='h-6 w-6' />}
|
|
91
91
|
title='Nothing planned'
|
|
92
|
-
|
|
92
|
+
// Only point at a button that is there: a read-only agenda has no Add (forge task 2591).
|
|
93
|
+
description={onAdd ? 'Use Add to create an event on this day.' : 'No events on this day.'}
|
|
93
94
|
/>
|
|
94
95
|
) : (
|
|
95
96
|
<ul className='flex flex-col gap-1'>
|
|
@@ -140,7 +141,9 @@ function AgendaItem({ occurrence, color, calendarName, onSelect }: AgendaItemPro
|
|
|
140
141
|
style={dotStyle}
|
|
141
142
|
aria-hidden='true'
|
|
142
143
|
/>
|
|
143
|
-
|
|
144
|
+
{/* Wide enough for "12:00 PM" on one line — at w-16 it wrapped and pushed its title
|
|
145
|
+
out of line with every other row's (forge task 2591). */}
|
|
146
|
+
<span className='w-20 shrink-0 whitespace-nowrap font-semibold tabular-nums text-muted-foreground'>
|
|
144
147
|
{occurrence.allDay ? 'All day' : fmtTime(occurrence.startAt)}
|
|
145
148
|
</span>
|
|
146
149
|
<span
|
|
@@ -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 });
|