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.
Files changed (145) 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/styles-areas/analytics.css +26 -25
  95. package/dist/styles-areas/calendar.css +4 -4
  96. package/dist/styles-areas/data-table.css +26 -25
  97. package/dist/styles-utilities.css +24 -9
  98. package/package.json +5 -1
  99. package/scripts/demoCoverage.spec.ts +33 -0
  100. package/scripts/demoCoverage.ts +124 -0
  101. package/src/react/charts/IntervalTimeline.tsx +6 -4
  102. package/src/react/chat/DateMarker.tsx +22 -6
  103. package/src/react/chat/MessageThread.spec.tsx +25 -1
  104. package/src/react/components/Button.spec.tsx +11 -0
  105. package/src/react/components/Button.tsx +5 -0
  106. package/src/react/components/DataTable/DataTable.spec.tsx +43 -0
  107. package/src/react/components/DataTable/DataTable.tsx +1 -0
  108. package/src/react/components/DataTable/SortHeader.tsx +45 -5
  109. package/src/react/components/DataTable/types.ts +10 -0
  110. package/src/react/components/DropdownMenu.spec.tsx +26 -0
  111. package/src/react/components/DropdownMenu.tsx +10 -3
  112. package/src/react/components/Field.spec.tsx +24 -0
  113. package/src/react/components/Field.tsx +15 -5
  114. package/src/react/components/SecretInput.tsx +5 -1
  115. package/src/react/components/auth/AuthCard.spec.tsx +8 -0
  116. package/src/react/components/auth/AuthCard.tsx +4 -1
  117. package/src/react/components/auth/PasswordSetFields.tsx +7 -11
  118. package/src/react/components/auth/PasswordStrengthMeter.tsx +5 -3
  119. package/src/react/components/auth/RegisterForm.tsx +6 -6
  120. package/src/react/components/auth/SignupForm.spec.tsx +17 -0
  121. package/src/react/components/auth/SignupForm.tsx +11 -10
  122. package/src/react/components/auth/TotpEntry.spec.tsx +27 -0
  123. package/src/react/components/auth/TotpEntry.tsx +18 -1
  124. package/src/react/components/auth/fields.tsx +8 -1
  125. package/src/react/components/calendar/DayAgenda.spec.tsx +9 -0
  126. package/src/react/components/calendar/DayAgenda.tsx +5 -2
  127. package/src/react/components/calendar/EventChip.spec.tsx +12 -8
  128. package/src/react/components/calendar/EventChip.tsx +11 -6
  129. package/src/react/components/calendar/MonthGrid.tsx +1 -1
  130. package/src/react/components/calendar/WeekView.tsx +5 -3
  131. package/src/react/components/calendar/colorUtils.ts +13 -11
  132. package/src/react/components/fieldErrorText.tsx +17 -0
  133. package/src/react/deep-link/resume.spec.tsx +16 -0
  134. package/src/react/deep-link/resume.ts +9 -2
  135. package/src/react/file-tree/FileTree.tsx +14 -1
  136. package/src/react/file-tree/FolderViewMenus.tsx +57 -17
  137. package/src/react/file-tree/folderView.spec.ts +63 -1
  138. package/src/react/file-tree/folderView.ts +63 -12
  139. package/src/react/file-tree/folderViewTree.spec.tsx +111 -16
  140. package/src/react/file-tree/index.ts +15 -1
  141. package/src/react/file-tree/useFolderView.ts +73 -12
  142. package/src/styles-areas/analytics.css +26 -25
  143. package/src/styles-areas/calendar.css +4 -4
  144. package/src/styles-areas/data-table.css +26 -25
  145. 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
- aurora ? 'dark cb-aurora cb-auth-aurora' : 'bg-background',
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
- <div className='flex flex-col gap-1.5'>
40
- <AuthPasswordField
41
- name={passwordName}
42
- label={passwordLabel}
43
- autoComplete='new-password'
44
- autoFocus={autoFocus}
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 flex-col gap-1', className)}>
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
- <div className='flex flex-col gap-1.5'>
86
- <AuthPasswordField name='password' label='Password' autoComplete='new-password' />
87
- {showStrength ? (
88
- <PasswordStrengthMeter value={password} minLength={minPasswordLength} />
89
- ) : null}
90
- </div>
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
- <div className='flex flex-col gap-1.5'>
119
- <AuthPasswordField name='password' label='Password' autoComplete='new-password' />
120
- {showStrength ? (
121
- <PasswordStrengthMeter value={password} minLength={minPasswordLength} />
122
- ) : null}
123
- </div>
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
- <p role='alert' className='text-sm text-field-error'>
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={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
- description="Tap 'Add' to create an event."
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
- <span className='w-16 shrink-0 font-semibold tabular-nums text-muted-foreground'>
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('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 });