cursedbelt 5.55.2 → 5.58.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 (174) hide show
  1. package/dist/react/charts/tokens.js +4 -4
  2. package/dist/react/code-editor/CodeEditor.d.ts.map +1 -1
  3. package/dist/react/code-editor/CodeEditor.js +2 -1
  4. package/dist/react/code-editor/CodeEditor.js.map +1 -1
  5. package/dist/react/code-editor/lineAbove.d.ts +17 -0
  6. package/dist/react/code-editor/lineAbove.d.ts.map +1 -0
  7. package/dist/react/code-editor/lineAbove.js +34 -0
  8. package/dist/react/code-editor/lineAbove.js.map +1 -0
  9. package/dist/react/companion-link/CompanionLinkStatus.d.ts.map +1 -1
  10. package/dist/react/companion-link/CompanionLinkStatus.js +2 -1
  11. package/dist/react/companion-link/CompanionLinkStatus.js.map +1 -1
  12. package/dist/react/components/Accordion.d.ts.map +1 -1
  13. package/dist/react/components/Accordion.js +2 -2
  14. package/dist/react/components/Accordion.js.map +1 -1
  15. package/dist/react/components/Badge.d.ts +20 -1
  16. package/dist/react/components/Badge.d.ts.map +1 -1
  17. package/dist/react/components/Badge.js +30 -3
  18. package/dist/react/components/Badge.js.map +1 -1
  19. package/dist/react/components/Button.d.ts.map +1 -1
  20. package/dist/react/components/Button.js +8 -2
  21. package/dist/react/components/Button.js.map +1 -1
  22. package/dist/react/components/Collapsible.d.ts.map +1 -1
  23. package/dist/react/components/Collapsible.js +2 -2
  24. package/dist/react/components/Collapsible.js.map +1 -1
  25. package/dist/react/components/DisclosureButton.d.ts +2 -2
  26. package/dist/react/components/DisclosureButton.d.ts.map +1 -1
  27. package/dist/react/components/DisclosureButton.js +9 -6
  28. package/dist/react/components/DisclosureButton.js.map +1 -1
  29. package/dist/react/components/EditorPage.d.ts +78 -0
  30. package/dist/react/components/EditorPage.d.ts.map +1 -0
  31. package/dist/react/components/EditorPage.js +133 -0
  32. package/dist/react/components/EditorPage.js.map +1 -0
  33. package/dist/react/components/IconPicker.d.ts +70 -0
  34. package/dist/react/components/IconPicker.d.ts.map +1 -0
  35. package/dist/react/components/IconPicker.js +175 -0
  36. package/dist/react/components/IconPicker.js.map +1 -0
  37. package/dist/react/components/PropertyList.d.ts +45 -0
  38. package/dist/react/components/PropertyList.d.ts.map +1 -0
  39. package/dist/react/components/PropertyList.js +12 -0
  40. package/dist/react/components/PropertyList.js.map +1 -0
  41. package/dist/react/components/Wizard/ReviewStep.d.ts +1 -1
  42. package/dist/react/components/Wizard/ReviewStep.d.ts.map +1 -1
  43. package/dist/react/components/Wizard/ReviewStep.js +3 -3
  44. package/dist/react/components/Wizard/ReviewStep.js.map +1 -1
  45. package/dist/react/components/editorPage.css +421 -0
  46. package/dist/react/components/formKit.d.ts +3 -1
  47. package/dist/react/components/formKit.d.ts.map +1 -1
  48. package/dist/react/components/formKit.js +3 -1
  49. package/dist/react/components/formKit.js.map +1 -1
  50. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  51. package/dist/react/file-tree/FileTree.js +7 -0
  52. package/dist/react/file-tree/FileTree.js.map +1 -1
  53. package/dist/react/file-tree/fileTree.css +14 -0
  54. package/dist/react/filter-rail/FilterRail.js +1 -1
  55. package/dist/react/filter-rail/FilterRail.js.map +1 -1
  56. package/dist/react/filter-rail/RailSection.js +2 -2
  57. package/dist/react/filter-rail/RailSection.js.map +1 -1
  58. package/dist/react/filter-rail/headerContextMenu.d.ts.map +1 -1
  59. package/dist/react/filter-rail/headerContextMenu.js +20 -1
  60. package/dist/react/filter-rail/headerContextMenu.js.map +1 -1
  61. package/dist/react/hooks/longPressContextMenu.d.ts +35 -0
  62. package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -0
  63. package/dist/react/hooks/longPressContextMenu.js +136 -0
  64. package/dist/react/hooks/longPressContextMenu.js.map +1 -0
  65. package/dist/react/hooks/useLongPressContextMenu.d.ts +4 -0
  66. package/dist/react/hooks/useLongPressContextMenu.d.ts.map +1 -0
  67. package/dist/react/hooks/useLongPressContextMenu.js +12 -0
  68. package/dist/react/hooks/useLongPressContextMenu.js.map +1 -0
  69. package/dist/react/index.d.ts +4 -1
  70. package/dist/react/index.d.ts.map +1 -1
  71. package/dist/react/index.js +3 -0
  72. package/dist/react/index.js.map +1 -1
  73. package/dist/react/line-keys/index.d.ts +2 -0
  74. package/dist/react/line-keys/index.d.ts.map +1 -0
  75. package/dist/react/line-keys/index.js +2 -0
  76. package/dist/react/line-keys/index.js.map +1 -0
  77. package/dist/react/line-keys/lineKeys.d.ts +62 -0
  78. package/dist/react/line-keys/lineKeys.d.ts.map +1 -0
  79. package/dist/react/line-keys/lineKeys.js +159 -0
  80. package/dist/react/line-keys/lineKeys.js.map +1 -0
  81. package/dist/react/master-detail/DetailPane.d.ts +3 -0
  82. package/dist/react/master-detail/DetailPane.d.ts.map +1 -1
  83. package/dist/react/master-detail/DetailPane.js +5 -2
  84. package/dist/react/master-detail/DetailPane.js.map +1 -1
  85. package/dist/react/nav/SideNav.js +1 -1
  86. package/dist/react/nav/SideNav.js.map +1 -1
  87. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  88. package/dist/styles-areas/analytics.css +14 -14
  89. package/dist/styles-areas/charts.css +3 -3
  90. package/dist/styles-areas/companion-link.css +2 -2
  91. package/dist/styles-areas/core.css +50 -45
  92. package/dist/styles-areas/data-table.css +15 -16
  93. package/dist/styles-areas/disk-usage.css +4 -4
  94. package/dist/styles-areas/fields.css +11 -11
  95. package/dist/styles-areas/folder-tree.css +3 -3
  96. package/dist/styles-areas/layout-engine.css +2 -2
  97. package/dist/styles-areas/master-detail.css +4 -5
  98. package/dist/styles-areas/media-gallery.css +9 -9
  99. package/dist/styles-areas/media.css +3 -3
  100. package/dist/styles-areas/nav.css +5 -5
  101. package/dist/styles-areas/notifications.css +3 -3
  102. package/dist/styles-areas/problem-detail.css +3 -3
  103. package/dist/styles-areas/sharing.css +2 -3
  104. package/dist/styles-areas/stats.css +3 -3
  105. package/dist/styles-areas/wizard.css +7 -8
  106. package/dist/styles-areas/workbench.css +4 -4
  107. package/dist/styles-utilities.css +49 -10
  108. package/dist/theme.css +38 -22
  109. package/package.json +14 -2
  110. package/scripts/guardrails-baseline.json +2 -1
  111. package/scripts/guardrailsEnforce.spec.ts +32 -0
  112. package/scripts/guardrailsEnforce.ts +67 -1
  113. package/scripts/styleAreas.ts +3 -0
  114. package/src/react/charts/tokens.ts +4 -4
  115. package/src/react/code-editor/CodeEditor.tsx +2 -1
  116. package/src/react/code-editor/lineAbove.spec.ts +36 -0
  117. package/src/react/code-editor/lineAbove.ts +35 -0
  118. package/src/react/companion-link/CompanionLinkStatus.tsx +10 -12
  119. package/src/react/components/Accordion.tsx +5 -4
  120. package/src/react/components/Badge.capsule.spec.ts +1 -1
  121. package/src/react/components/Badge.spec.tsx +22 -1
  122. package/src/react/components/Badge.tsx +66 -11
  123. package/src/react/components/Button.spec.tsx +5 -4
  124. package/src/react/components/Button.tsx +8 -2
  125. package/src/react/components/Collapsible.tsx +5 -4
  126. package/src/react/components/DisclosureButton.spec.tsx +47 -0
  127. package/src/react/components/DisclosureButton.tsx +11 -7
  128. package/src/react/components/EditorPage.spec.tsx +106 -0
  129. package/src/react/components/EditorPage.tsx +281 -0
  130. package/src/react/components/IconPicker.spec.tsx +84 -0
  131. package/src/react/components/IconPicker.tsx +289 -0
  132. package/src/react/components/PropertyList.spec.tsx +110 -0
  133. package/src/react/components/PropertyList.tsx +92 -0
  134. package/src/react/components/Wizard/ReviewStep.tsx +7 -7
  135. package/src/react/components/editorPage.css +421 -0
  136. package/src/react/components/formKit.ts +10 -1
  137. package/src/react/components/formKitStylesheets.spec.ts +47 -0
  138. package/src/react/file-tree/FileTree.spec.tsx +19 -0
  139. package/src/react/file-tree/FileTree.tsx +8 -0
  140. package/src/react/file-tree/fileTree.css +14 -0
  141. package/src/react/filter-rail/FilterRail.tsx +1 -1
  142. package/src/react/filter-rail/RailSection.tsx +2 -2
  143. package/src/react/filter-rail/headerContextMenu.spec.tsx +52 -0
  144. package/src/react/filter-rail/headerContextMenu.ts +19 -1
  145. package/src/react/hooks/longPressContextMenu.spec.ts +102 -0
  146. package/src/react/hooks/longPressContextMenu.ts +135 -0
  147. package/src/react/hooks/useLongPressContextMenu.ts +15 -0
  148. package/src/react/index.ts +11 -1
  149. package/src/react/line-keys/index.ts +1 -0
  150. package/src/react/line-keys/lineKeys.spec.tsx +176 -0
  151. package/src/react/line-keys/lineKeys.ts +176 -0
  152. package/src/react/master-detail/DetailPane.tsx +14 -18
  153. package/src/react/nav/SideNav.tsx +1 -1
  154. package/src/styles-areas/analytics.css +14 -14
  155. package/src/styles-areas/charts.css +3 -3
  156. package/src/styles-areas/companion-link.css +2 -2
  157. package/src/styles-areas/core.css +50 -45
  158. package/src/styles-areas/data-table.css +15 -16
  159. package/src/styles-areas/disk-usage.css +4 -4
  160. package/src/styles-areas/fields.css +11 -11
  161. package/src/styles-areas/folder-tree.css +3 -3
  162. package/src/styles-areas/layout-engine.css +2 -2
  163. package/src/styles-areas/master-detail.css +4 -5
  164. package/src/styles-areas/media-gallery.css +9 -9
  165. package/src/styles-areas/media.css +3 -3
  166. package/src/styles-areas/nav.css +5 -5
  167. package/src/styles-areas/notifications.css +3 -3
  168. package/src/styles-areas/problem-detail.css +3 -3
  169. package/src/styles-areas/sharing.css +2 -3
  170. package/src/styles-areas/stats.css +3 -3
  171. package/src/styles-areas/wizard.css +7 -8
  172. package/src/styles-areas/workbench.css +4 -4
  173. package/src/styles-utilities.css +49 -10
  174. package/src/theme.css +38 -22
@@ -0,0 +1,35 @@
1
+ import { moveLineDown, moveLineUp } from '@codemirror/commands';
2
+ import { EditorSelection, type StateCommand } from '@codemirror/state';
3
+ import type { KeyBinding } from '@codemirror/view';
4
+
5
+ /**
6
+ * ⇧⌘↵ — a blank line ABOVE each cursor's line, keeping that line's indentation, the caret on it.
7
+ * VS Code's "Insert Line Above"; CodeMirror's `defaultKeymap` has the line below (`Mod-Enter`,
8
+ * `insertBlankLine`) and the delete (`Shift-Mod-k`) but not this one. The `<textarea>` half of the
9
+ * same three keys is `cursedbelt/react/line-keys`.
10
+ */
11
+ export const insertBlankLineAbove: StateCommand = ({ state, dispatch }) => {
12
+ if (state.readOnly) return false;
13
+ const changes = state.changeByRange((range) => {
14
+ const line = state.doc.lineAt(range.from);
15
+ const indent = /^[ \t]*/.exec(line.text)?.[0] ?? '';
16
+ return {
17
+ changes: { from: line.from, insert: `${indent}${state.lineBreak}` },
18
+ range: EditorSelection.cursor(line.from + indent.length),
19
+ };
20
+ });
21
+ dispatch(state.update(changes, { scrollIntoView: true, userEvent: 'input' }));
22
+ return true;
23
+ };
24
+
25
+ /**
26
+ * The line keys `CodeEditor` adds IN FRONT of `defaultKeymap`: ⇧⌘↵ above, and ⌘↑ / ⌘↓ moving the
27
+ * line — the owner's chord (2026-10-05), which in `defaultKeymap` is the Mac's jump to the top or
28
+ * bottom. ⌥↑ / ⌥↓, VS Code's own move, is already in `defaultKeymap`, and so is ⇧⌘↑ (select to
29
+ * the top), which stays. The same keys in a `<textarea>` are `cursedbelt/react/line-keys`.
30
+ */
31
+ export const lineKeyBindings: readonly KeyBinding[] = [
32
+ { key: 'Shift-Mod-Enter', run: insertBlankLineAbove },
33
+ { key: 'Mod-ArrowUp', run: moveLineUp },
34
+ { key: 'Mod-ArrowDown', run: moveLineDown },
35
+ ];
@@ -1,6 +1,7 @@
1
1
  import { formatTimeAgo } from 'cwip/format';
2
2
  import { Accordion } from '../components/Accordion.js';
3
3
  import { Badge } from '../components/Badge.js';
4
+ import { Property, PropertyList } from '../components/PropertyList.js';
4
5
  import { Skeleton } from '../components/Skeleton.js';
5
6
  import { StatusPill } from '../components/StatusPill.js';
6
7
  import { cn } from '../lib/cn.js';
@@ -86,18 +87,15 @@ export function CompanionLinkStatus({ view, loaded, now = Date.now(), className
86
87
  );
87
88
 
88
89
  const detail = (
89
- <dl className='grid grid-cols-2 gap-x-4 gap-y-1 text-xs'>
90
- <dt className='text-muted-foreground'>Connection</dt>
91
- <dd className='text-right font-medium'>{CONNECTION_LABEL[view.connection]}</dd>
92
- <dt className='text-muted-foreground'>Last heard</dt>
93
- <dd className='text-right'>{view.connection === 'never' ? '—' : heard}</dd>
94
- <dt className='text-muted-foreground'>Mirror cursor</dt>
95
- <dd className='text-right cb-code'>{view.haveUp}</dd>
96
- <dt className='text-muted-foreground'>Command backlog</dt>
97
- <dd className='text-right'>{view.backlog}</dd>
98
- <dt className='text-muted-foreground'>Synced channels</dt>
99
- <dd className='text-right'>{view.channels.length}</dd>
100
- </dl>
90
+ <PropertyList density='compact'>
91
+ <Property label='Connection'>{CONNECTION_LABEL[view.connection]}</Property>
92
+ <Property label='Last heard'>{view.connection === 'never' ? '—' : heard}</Property>
93
+ <Property label='Mirror cursor' mono>
94
+ {view.haveUp}
95
+ </Property>
96
+ <Property label='Command backlog'>{String(view.backlog)}</Property>
97
+ <Property label='Synced channels'>{String(view.channels.length)}</Property>
98
+ </PropertyList>
101
99
  );
102
100
 
103
101
  return (
@@ -1,5 +1,5 @@
1
1
  import * as RadixAccordion from '@radix-ui/react-accordion';
2
- import { ChevronDown } from 'lucide-react';
2
+ import { ChevronRight } from 'lucide-react';
3
3
  import type { ReactNode } from 'react';
4
4
  import { cn } from '../lib/cn.js';
5
5
 
@@ -67,9 +67,10 @@ export function Accordion({
67
67
  <RadixAccordion.Header>
68
68
  {/* Disabled = muted ink, as SelectableRow: half-opacity faded the chevron and the
69
69
  divider-adjacent text into the surface (forge task 2594). */}
70
- <RadixAccordion.Trigger className='group flex w-full items-center justify-between gap-2 py-3 text-left text-sm font-medium text-foreground transition-colors hover:text-accent disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'>
71
- {item.trigger}
72
- <ChevronDown className='size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180' />
70
+ <RadixAccordion.Trigger className='group flex w-full items-center justify-start gap-2 py-3 text-left text-sm font-medium text-foreground transition-colors hover:text-accent disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'>
71
+ {/* The arrow leads (FRM-18), and the trigger takes the rest of the row. */}
72
+ <ChevronRight aria-hidden className='size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-90' />
73
+ <span className='min-w-0 flex-1'>{item.trigger}</span>
73
74
  </RadixAccordion.Trigger>
74
75
  </RadixAccordion.Header>
75
76
  <RadixAccordion.Content
@@ -160,7 +160,7 @@ describe('Badge — every tone is a visible capsule (DESIGN.md §3, task 2286)',
160
160
 
161
161
  test('🔴 the check can go red: the pre-fix neutral (a bare --muted fill) is invisible on the dark raised card', () => {
162
162
  const found = invisibleCapsules({ neutral: 'bg-muted text-muted-foreground' });
163
- expect(found).toContain('neutral (fill --muted) on dark --card-raised: ΔE00 5.3');
163
+ expect(found).toContain('neutral (fill --muted) on dark --card-raised: ΔE00 3.4');
164
164
  });
165
165
 
166
166
  test('a border it draws is scored instead of its fill — and a border that is itself invisible fails', () => {
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { cleanup, render } from '@testing-library/react';
3
- import { Badge, type BadgeTone } from './Badge.js';
3
+ import { BADGE_DOT, BADGE_TONE, Badge, type BadgeTone } from './Badge.js';
4
4
 
5
5
  afterEach(cleanup);
6
6
 
@@ -79,4 +79,25 @@ describe('Badge — title is a themed tooltip, never the OS one', () => {
79
79
  expect(container.querySelector('[title]')).toBeNull();
80
80
  expect(container.textContent).toContain('disabled');
81
81
  });
82
+
83
+ // 🔴 The owner's 2026-10-05 comparison: a solid PROD/STAGE capsule on every tree row read as
84
+ // "competing focal points" beside Linear. `dot` carries the same fact without a fill.
85
+ test("appearance='dot' draws a hue dot beside plain text — no fill, no edge, no capsule", () => {
86
+ const { getByText } = render(
87
+ <Badge appearance='dot' tone='error'>
88
+ Prod
89
+ </Badge>,
90
+ );
91
+ const badge = getByText('Prod');
92
+ expect(badge.getAttribute('data-appearance')).toBe('dot');
93
+ expect(badge.className).not.toMatch(/(^|\s)(bg-|border|rounded-full|px-)/);
94
+ expect(badge.className).toContain('text-muted-foreground');
95
+ const dot = badge.querySelector('span[aria-hidden="true"]');
96
+ expect(dot?.className).toContain('bg-destructive');
97
+ expect(dot?.className).toContain('size-2');
98
+ });
99
+
100
+ test('every tone has a dot, so a tone cannot ship without one', () => {
101
+ for (const tone of Object.keys(BADGE_TONE) as BadgeTone[]) expect(BADGE_DOT[tone]).toMatch(/^bg-/);
102
+ });
82
103
  });
@@ -70,22 +70,77 @@ export const BADGE_TONE = {
70
70
 
71
71
  export type BadgeTone = keyof typeof BADGE_TONE;
72
72
 
73
+ /**
74
+ * The dot each tone draws under `appearance='dot'` — the tone's own hue, at full strength, on a
75
+ * mark 8px across. Typed against `BadgeTone` so a tone added above cannot ship without one.
76
+ */
77
+ export const BADGE_DOT: Record<BadgeTone, string> = {
78
+ default: 'bg-muted-foreground',
79
+ neutral: 'bg-muted-foreground',
80
+ outline: 'bg-muted-foreground',
81
+ primary: 'bg-accent-solid',
82
+ accent: 'bg-accent',
83
+ success: 'bg-success',
84
+ warning: 'bg-warning',
85
+ error: 'bg-destructive',
86
+ destructive: 'bg-destructive',
87
+ emerald: 'bg-tone-emerald-fg',
88
+ sky: 'bg-tone-sky-fg',
89
+ cyan: 'bg-tone-cyan-fg',
90
+ amber: 'bg-tone-amber-fg',
91
+ purple: 'bg-tone-purple-fg',
92
+ rose: 'bg-tone-rose-fg',
93
+ orange: 'bg-tone-orange-fg',
94
+ slate: 'bg-tone-slate-fg',
95
+ lime: 'bg-tone-lime-fg',
96
+ teal: 'bg-tone-teal-fg',
97
+ violet: 'bg-tone-violet-fg',
98
+ fuchsia: 'bg-tone-fuchsia-fg',
99
+ };
100
+
101
+ /**
102
+ * `filled` is the capsule. `dot` is Linear's: a small dot in the tone's hue and the word in
103
+ * ordinary text, with no fill and no edge.
104
+ *
105
+ * 🔴 Reach for `dot` when the badge REPEATS — one per row of a list or a tree. The owner's
106
+ * 2026-10-05 comparison lost on exactly this: desk's Components tree drew a solid red `PROD` or
107
+ * orange `STAGE` capsule on every row, and the judge read them as "competing focal points". A
108
+ * filled capsule is the loudest thing in a row, so a column of them outranks the names beside
109
+ * it; a column of dots carries the same fact and lets the names lead (DAT-4: status pills are
110
+ * rationed). One filled capsule in a header is still right.
111
+ */
112
+ export type BadgeAppearance = 'filled' | 'dot';
113
+
73
114
  export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
74
115
  /** Semantic or categorical tone (default `default`). */
75
116
  tone?: BadgeTone;
117
+ /** `filled` (default) is the capsule; `dot` is a hue dot beside plain text. See {@link BadgeAppearance}. */
118
+ appearance?: BadgeAppearance;
76
119
  }
77
120
 
78
- export function Badge({ tone = 'default', className, title, ...props }: BadgeProps) {
79
- const pill = (
80
- <span
81
- className={cn(
82
- 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
83
- BADGE_TONE[tone],
84
- className,
85
- )}
86
- {...props}
87
- />
88
- );
121
+ export function Badge({ tone = 'default', appearance = 'filled', className, title, children, ...props }: BadgeProps) {
122
+ const pill =
123
+ appearance === 'dot' ? (
124
+ <span
125
+ data-appearance='dot'
126
+ className={cn('inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground', className)}
127
+ {...props}
128
+ >
129
+ <span aria-hidden='true' className={cn('size-2 flex-none rounded-full', BADGE_DOT[tone])} />
130
+ {children}
131
+ </span>
132
+ ) : (
133
+ <span
134
+ className={cn(
135
+ 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium',
136
+ BADGE_TONE[tone],
137
+ className,
138
+ )}
139
+ {...props}
140
+ >
141
+ {children}
142
+ </span>
143
+ );
89
144
  // 🔴 `title` becomes a THEMED tooltip, never the OS one (2026-08-23, OPT unit 36).
90
145
  //
91
146
  // `BadgeProps extends HTMLAttributes<HTMLSpanElement>`, so `title` type-checked
@@ -26,10 +26,11 @@ describe('Button', () => {
26
26
  test('default variant is secondary (neutral filled outline), size md', () => {
27
27
  const { getByRole } = render(<Button tooltip='Hi'>Hi</Button>);
28
28
  const cls = getByRole('button').className;
29
- // Secondary = card bg + hairline + raised shadow (real button presence, not a
30
- // bare accent outline that reads as a static tag — STANDARDS §4c/§4d).
31
- expect(cls).toContain('border-border-strong');
32
- expect(cls).toContain('bg-card');
29
+ // Secondary = a muted FILL + the quiet hairline + raised shadow: real button presence
30
+ // without the full-strength outline that read as a row of boxes beside Linear (task 2822).
31
+ expect(cls).toContain('border-border');
32
+ expect(cls).not.toContain('border-border-strong');
33
+ expect(cls).toContain('bg-muted');
33
34
  expect(cls).toContain('shadow-raised');
34
35
  expect(cls).toContain('h-[var(--control-h-md)]'); // md, the shared control-height token
35
36
  });
@@ -50,7 +50,7 @@ export const buttonVariants = cva(
50
50
  variants: {
51
51
  // The 3-weight action taxonomy (task cbux-42, STANDARDS.md §4c):
52
52
  // primary — the ONE solid hero action per view.
53
- // secondary — outline; the everyday supporting action.
53
+ // secondary — filled neutral, hairline edge; the everyday supporting action.
54
54
  // tertiary — low-contrast text/icon weight for row utilities and
55
55
  // toolbars ("ghost" is the legacy alias — same rendering,
56
56
  // both stay valid; new code should say what it means).
@@ -73,8 +73,14 @@ export const buttonVariants = cva(
73
73
  // not an actionable button (owner UX teardown 2026-07-15 §4). The accent
74
74
  // stays reserved for `primary`, so the one hero action per view keeps its
75
75
  // pull while every supporting action still has real button presence.
76
+ // 🔴 A FILLED neutral with a hairline edge since 5.58.0 — Linear's secondary. It was
77
+ // `border-border-strong bg-card`: the card's own ground inside a full-strength 3:1
78
+ // outline, so a toolbar of them was a row of bright boxes ("stark block borders", the
79
+ // judge's words when desk lost to Linear on 2026-10-05, task 2822). The fill carries the
80
+ // presence now and the edge is the quiet `--border`; a labelled button is identified by
81
+ // its label and fill, so the 3:1 non-text floor that FIELD borders hold does not apply.
76
82
  secondary:
77
- 'border border-border-strong bg-card text-foreground shadow-raised hover:bg-muted focus-visible:ring-accent',
83
+ 'border border-border bg-muted text-foreground shadow-raised hover:bg-border focus-visible:ring-accent',
78
84
  tertiary: 'text-foreground hover:bg-muted focus-visible:ring-accent',
79
85
  ghost: 'text-foreground hover:bg-muted focus-visible:ring-accent',
80
86
  link: 'h-auto px-0 py-0 text-accent underline-offset-4 hover:underline focus-visible:ring-accent',
@@ -1,5 +1,5 @@
1
1
  import * as RadixCollapsible from '@radix-ui/react-collapsible';
2
- import { ChevronDown } from 'lucide-react';
2
+ import { ChevronRight } from 'lucide-react';
3
3
  import type { ReactNode } from 'react';
4
4
  import { cn } from '../lib/cn.js';
5
5
  import { TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
@@ -54,10 +54,11 @@ export function Collapsible({
54
54
  // guardrails-ignore no-raw-action-button: this IS the Collapsible section-toggle
55
55
  // primitive — its own trigger is necessarily a raw <button> (Radix Trigger asChild).
56
56
  type='button'
57
- className={cn(RAW_BUTTON, `group flex w-full items-center justify-between gap-2 py-2 text-left text-sm font-medium text-foreground transition-colors hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`)}
57
+ className={cn(RAW_BUTTON, `group flex w-full items-center justify-start gap-2 py-2 text-left text-sm font-medium text-foreground transition-colors hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${TAP_TARGET_HEIGHT}`)}
58
58
  >
59
- {trigger}
60
- <ChevronDown className='size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180' />
59
+ {/* The arrow leads (FRM-18), and the trigger takes the rest of the row. */}
60
+ <ChevronRight aria-hidden className='size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-90' />
61
+ <span className='min-w-0 flex-1'>{trigger}</span>
61
62
  </button>
62
63
  </RadixCollapsible.Trigger>
63
64
  <RadixCollapsible.Content
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, render } from '@testing-library/react';
3
+ import { Accordion } from './Accordion.js';
4
+ import { Collapsible } from './Collapsible.js';
5
+ import { DisclosureButton } from './DisclosureButton.js';
6
+
7
+ afterEach(cleanup);
8
+
9
+ /** FRM-18: the arrow LEADS — the trigger's first child is the chevron, the label comes after. */
10
+ function arrowLeads(button: HTMLElement, label: string): void {
11
+ const first = button.firstElementChild;
12
+ expect(first?.tagName.toLowerCase()).toBe('svg');
13
+ expect(first?.getAttribute('class') ?? '').toContain('lucide-chevron-right');
14
+ expect(button.textContent).toBe(label);
15
+ }
16
+
17
+ describe('disclosure arrows sit on the leading edge (FRM-18, forge task 2802)', () => {
18
+ test('DisclosureButton: chevron first, turned down when open', () => {
19
+ const view = render(
20
+ <DisclosureButton open={false} onToggle={() => {}}>
21
+ Advanced
22
+ </DisclosureButton>,
23
+ );
24
+ const button = view.getByRole('button', { name: 'Advanced' });
25
+ arrowLeads(button, 'Advanced');
26
+ expect(button.firstElementChild?.getAttribute('class') ?? '').not.toContain('rotate-90');
27
+ view.rerender(
28
+ <DisclosureButton open onToggle={() => {}}>
29
+ Advanced
30
+ </DisclosureButton>,
31
+ );
32
+ expect(button.firstElementChild?.getAttribute('class') ?? '').toContain('rotate-90');
33
+ });
34
+
35
+ test('Collapsible and Accordion triggers lead with the chevron too', () => {
36
+ const view = render(
37
+ <>
38
+ <Collapsible trigger='Options'>
39
+ <p>body</p>
40
+ </Collapsible>
41
+ <Accordion items={[{ value: 'a', trigger: 'Billing', content: 'x' }]} />
42
+ </>,
43
+ );
44
+ arrowLeads(view.getByRole('button', { name: 'Options' }), 'Options');
45
+ arrowLeads(view.getByRole('button', { name: 'Billing' }), 'Billing');
46
+ });
47
+ });
@@ -1,8 +1,11 @@
1
1
  // Simple disclosure/collapse primitives — a rotating-chevron arrow and a button
2
- // wrapper that pairs children with the arrow. Used anywhere a lightweight toggle
2
+ // wrapper that pairs the arrow with children. Used anywhere a lightweight toggle
3
3
  // trigger is needed without the full Radix Collapsible overhead.
4
+ //
5
+ // The arrow LEADS (design law FRM-18, the owner's rule): a right-pointing chevron before the
6
+ // label that turns to point down when open — Finder's, Notion's and VS Code's disclosure.
4
7
 
5
- import { ChevronDown } from 'lucide-react';
8
+ import { ChevronRight } from 'lucide-react';
6
9
  import type { ReactNode } from 'react';
7
10
  import { cn } from '../lib/cn.js';
8
11
  import { RAW_BUTTON } from '../lib/cn.js';
@@ -12,13 +15,14 @@ export interface DisclosureArrowProps {
12
15
  className?: string;
13
16
  }
14
17
 
15
- /** A chevron that rotates 180° when `open` is true. */
18
+ /** A right-pointing chevron that turns 90° to point down when `open` is true. */
16
19
  export function DisclosureArrow({ open, className }: DisclosureArrowProps) {
17
20
  return (
18
- <ChevronDown
21
+ <ChevronRight
22
+ aria-hidden
19
23
  className={cn(
20
24
  'size-4 shrink-0 transition-transform duration-200',
21
- open && 'rotate-180',
25
+ open && 'rotate-90',
22
26
  className,
23
27
  )}
24
28
  />
@@ -37,7 +41,7 @@ export interface DisclosureButtonProps {
37
41
  classNames?: DisclosureButtonClassNames;
38
42
  }
39
43
 
40
- /** A button that reveals content on toggle — renders children + a {@link DisclosureArrow}. */
44
+ /** A button that reveals content on toggle — a leading {@link DisclosureArrow}, then children. */
41
45
  export function DisclosureButton({
42
46
  open,
43
47
  onToggle,
@@ -59,8 +63,8 @@ export function DisclosureButton({
59
63
  aria-expanded={open}
60
64
  onClick={onToggle}
61
65
  >
62
- {children}
63
66
  <DisclosureArrow open={open} className={classNames?.arrow} />
67
+ {children}
64
68
  </button>
65
69
  );
66
70
  }
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { Button } from './Button.js';
4
+ import { EditorPage } from './EditorPage.js';
5
+ import { Input } from './Input.js';
6
+ import { Switch } from './Switch.js';
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ localStorage.clear();
11
+ });
12
+
13
+ function Page(props: { onBack?: () => void; storageKey?: string; isDirty?: boolean }) {
14
+ return (
15
+ <EditorPage
16
+ title='New template'
17
+ backLabel='Templates'
18
+ onBack={props.onBack ?? (() => {})}
19
+ isDirty={props.isDirty}
20
+ storageKey={props.storageKey}
21
+ actions={<Button tooltip='Save the template'>Save</Button>}
22
+ preview={<p>the card</p>}
23
+ previewLabel='Card'
24
+ previewTools={<Switch label='Show names' />}
25
+ >
26
+ <Input aria-label='Name' />
27
+ </EditorPage>
28
+ );
29
+ }
30
+
31
+ describe('EditorPage', () => {
32
+ test('a page, not a dialog box: the form, the preview and the actions are all on it at once', () => {
33
+ const view = render(<Page />);
34
+ expect(view.getByRole('dialog', { name: 'New template' })).toBeTruthy();
35
+ expect(view.getByLabelText('Name')).toBeTruthy();
36
+ expect(view.getByRole('region', { name: 'Card' }).textContent).toContain('the card');
37
+ expect(view.getByRole('button', { name: 'Save' })).toBeTruthy();
38
+ expect(view.getByLabelText('Show names')).toBeTruthy();
39
+ });
40
+
41
+ test('the form is quiet by default — Linear’s look, inside .cbfk-quiet', () => {
42
+ const view = render(<Page />);
43
+ expect(view.getByLabelText('Name').closest('.cbfk-quiet')).not.toBeNull();
44
+ });
45
+
46
+ test('Back and Escape both leave', () => {
47
+ let left = 0;
48
+ const view = render(<Page onBack={() => { left += 1; }} />);
49
+ fireEvent.click(view.getByRole('button', { name: 'Templates' }));
50
+ fireEvent.keyDown(view.getByLabelText('Name'), { key: 'Escape' });
51
+ expect(left).toBe(2);
52
+ });
53
+
54
+ test('Escape on an open disclosure still leaves; on an open popup trigger it is the popup’s', () => {
55
+ let left = 0;
56
+ const view = render(
57
+ <EditorPage title='New' onBack={() => { left += 1; }} preview={null}>
58
+ <Button tooltip='Fold the row' aria-expanded>Row settings</Button>
59
+ <Button tooltip='Pick one' aria-haspopup='listbox' aria-expanded>Language</Button>
60
+ </EditorPage>,
61
+ );
62
+ fireEvent.keyDown(view.getByRole('button', { name: 'Language' }), { key: 'Escape' });
63
+ expect(left).toBe(0);
64
+ fireEvent.keyDown(view.getByRole('button', { name: 'Row settings' }), { key: 'Escape' });
65
+ expect(left).toBe(1);
66
+ });
67
+
68
+ test('with unsaved edits, Back and Escape ask first — Keep editing stays, Discard leaves', () => {
69
+ let left = 0;
70
+ const view = render(<Page isDirty onBack={() => { left += 1; }} />);
71
+ fireEvent.click(view.getByRole('button', { name: 'Templates' }));
72
+ expect(left).toBe(0);
73
+ expect(view.getByRole('dialog', { name: 'Discard changes?' })).toBeTruthy();
74
+ fireEvent.keyDown(view.getByRole('dialog', { name: 'Discard changes?' }), { key: 'Escape' });
75
+ expect(view.queryByRole('dialog', { name: 'Discard changes?' })).toBeNull();
76
+ fireEvent.keyDown(view.getByLabelText('Name'), { key: 'Escape' });
77
+ expect(left).toBe(0);
78
+ fireEvent.click(view.getByRole('button', { name: 'Discard changes' }));
79
+ expect(left).toBe(1);
80
+ });
81
+
82
+ test('the divider moves by keyboard within its limits and the split is kept', () => {
83
+ const view = render(<Page storageKey='test-split' />);
84
+ const divider = view.getByRole('separator');
85
+ expect(divider.getAttribute('aria-valuenow')).toBe('50');
86
+ fireEvent.keyDown(divider, { key: 'ArrowRight' });
87
+ expect(divider.getAttribute('aria-valuenow')).toBe('52');
88
+ fireEvent.keyDown(divider, { key: 'End' });
89
+ expect(divider.getAttribute('aria-valuenow')).toBe('75');
90
+ fireEvent.keyDown(divider, { key: 'ArrowRight', shiftKey: true });
91
+ expect(divider.getAttribute('aria-valuenow')).toBe('75');
92
+ expect(localStorage.getItem('test-split')).toBe('0.75');
93
+ cleanup();
94
+ const again = render(<Page storageKey='test-split' />);
95
+ expect(again.getByRole('separator').getAttribute('aria-valuenow')).toBe('75');
96
+ });
97
+
98
+ test('on a phone one side shows at a time — the switch says which', () => {
99
+ const view = render(<Page />);
100
+ const root = view.getByRole('dialog');
101
+ expect(root.getAttribute('data-mode')).toBe('edit');
102
+ fireEvent.click(view.getByRole('tab', { name: 'Card' }));
103
+ expect(root.getAttribute('data-mode')).toBe('preview');
104
+ expect(view.getByRole('tab', { name: 'Card' }).getAttribute('aria-selected')).toBe('true');
105
+ });
106
+ });