@elabs-ai/components-ui 4.0.0 → 4.1.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/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { WorkspacePicker } from "./workspace-picker";
|
|
5
|
+
import { formatLastOpened } from "./workspace-picker-state";
|
|
6
|
+
import type { Workspace } from "./workspace-picker-state";
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
|
|
10
|
+
// cmdk calls Element.scrollIntoView to keep the auto-highlighted item visible.
|
|
11
|
+
// jsdom doesn't implement it and this package's vitest setup deliberately ships
|
|
12
|
+
// no global stub, so third-party libraries that don't feature-detect get a
|
|
13
|
+
// local, scoped stub — the same pattern as model-picker.test.tsx and
|
|
14
|
+
// combobox.test.tsx.
|
|
15
|
+
let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
originalScrollIntoView = Element.prototype.scrollIntoView;
|
|
18
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
Element.prototype.scrollIntoView = originalScrollIntoView;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// No `LocaleProvider` wrapper: `useLocale()` falls back to the shipped English
|
|
25
|
+
// defaults, so these assertions prove the real `ui.workspacePicker.*` catalogue in
|
|
26
|
+
// `packages/ui/src/components/locale-provider/messages.ts` rather than a local stub
|
|
27
|
+
// that would keep passing if the central entry were missing or misspelled.
|
|
28
|
+
function renderPicker(ui: React.ReactElement) {
|
|
29
|
+
return render(ui);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const workspaces: Workspace[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "ws-1",
|
|
35
|
+
name: "elabs-components",
|
|
36
|
+
path: "~/dev/elabs-components",
|
|
37
|
+
lastOpenedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "ws-2",
|
|
41
|
+
name: "brainless",
|
|
42
|
+
path: "~/dev/brainless",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const open = async () => {
|
|
47
|
+
await userEvent.click(screen.getAllByRole("combobox")[0]!);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("formatLastOpened", () => {
|
|
51
|
+
const now = Date.parse("2026-09-01T12:00:00.000Z");
|
|
52
|
+
|
|
53
|
+
it("picks minutes for recent times", () => {
|
|
54
|
+
expect(formatLastOpened(new Date(now - 5 * 60 * 1000), "en-US", now)).toBe("5 minutes ago");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("picks hours once minutes would read awkwardly large", () => {
|
|
58
|
+
expect(formatLastOpened(new Date(now - 3 * 60 * 60 * 1000), "en-US", now)).toBe("3 hours ago");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("picks days once hours would read awkwardly large", () => {
|
|
62
|
+
expect(formatLastOpened(new Date(now - 2 * 24 * 60 * 60 * 1000), "en-US", now)).toBe(
|
|
63
|
+
"2 days ago",
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("formats a future time as relative-forward", () => {
|
|
68
|
+
expect(formatLastOpened(new Date(now + 10 * 60 * 1000), "en-US", now)).toBe("in 10 minutes");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("WorkspacePicker", () => {
|
|
73
|
+
it("renders each workspace's path as truncating secondary text", async () => {
|
|
74
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} />);
|
|
75
|
+
await open();
|
|
76
|
+
const description = screen.getByText("~/dev/elabs-components");
|
|
77
|
+
expect(description).toBeInTheDocument();
|
|
78
|
+
expect(description).toHaveClass("truncate");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("marks the current workspace in the row's accessible name, not only by icon", async () => {
|
|
82
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
|
|
83
|
+
await open();
|
|
84
|
+
const current = screen.getByRole("option", { name: /elabs-components/ });
|
|
85
|
+
const other = screen.getByRole("option", { name: /brainless/ });
|
|
86
|
+
expect(current).toHaveAccessibleName(/current/i);
|
|
87
|
+
expect(other).not.toHaveAccessibleName(/current/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("names the trigger from the current workspace, and falls back to a placeholder", async () => {
|
|
91
|
+
const { unmount } = renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
|
|
92
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("elabs-components");
|
|
93
|
+
unmount();
|
|
94
|
+
|
|
95
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} />);
|
|
96
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports the whole Workspace object on selection, not just the id", async () => {
|
|
100
|
+
const onSelect = vi.fn();
|
|
101
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} onSelect={onSelect} />);
|
|
102
|
+
await open();
|
|
103
|
+
await userEvent.click(screen.getByText("brainless"));
|
|
104
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(onSelect).toHaveBeenCalledWith(workspaces[1]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("shows a designed empty state whose one action is the free-text path entry", async () => {
|
|
109
|
+
renderPicker(<WorkspacePicker workspaces={[]} onSubmitPath={vi.fn()} />);
|
|
110
|
+
await open();
|
|
111
|
+
// ModelPicker's own empty panel — reused, not reimplemented.
|
|
112
|
+
expect(screen.getByText("Nothing to show yet")).toBeInTheDocument();
|
|
113
|
+
expect(screen.queryByRole("option")).not.toBeInTheDocument();
|
|
114
|
+
// The action: the free-text path entry always renders, even with zero rows.
|
|
115
|
+
expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("validates non-empty free text before reporting the raw string", async () => {
|
|
119
|
+
const onSubmitPath = vi.fn();
|
|
120
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} />);
|
|
121
|
+
await open();
|
|
122
|
+
const submit = screen.getByRole("button", { name: "Open" });
|
|
123
|
+
|
|
124
|
+
await userEvent.click(submit);
|
|
125
|
+
expect(onSubmitPath).not.toHaveBeenCalled();
|
|
126
|
+
|
|
127
|
+
const input = screen.getByPlaceholderText("/path/to/project…");
|
|
128
|
+
await userEvent.type(input, " ");
|
|
129
|
+
await userEvent.click(submit);
|
|
130
|
+
expect(onSubmitPath).not.toHaveBeenCalled();
|
|
131
|
+
|
|
132
|
+
await userEvent.clear(input);
|
|
133
|
+
await userEvent.type(input, "/tmp/new-workspace");
|
|
134
|
+
await userEvent.click(submit);
|
|
135
|
+
expect(onSubmitPath).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("performs no filesystem access — onSubmitPath is the only side effect of a submit", async () => {
|
|
140
|
+
const onSubmitPath = vi.fn();
|
|
141
|
+
const onSelect = vi.fn();
|
|
142
|
+
renderPicker(
|
|
143
|
+
<WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
|
|
144
|
+
);
|
|
145
|
+
await open();
|
|
146
|
+
const input = screen.getByPlaceholderText("/path/to/project…");
|
|
147
|
+
await userEvent.type(input, "/tmp/new-workspace");
|
|
148
|
+
await userEvent.click(screen.getByRole("button", { name: "Open" }));
|
|
149
|
+
expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
|
|
150
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
151
|
+
// The field clears after a successful submit.
|
|
152
|
+
expect(input).toHaveValue("");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("submits the path on Enter instead of cmdk hijacking it as list navigation", async () => {
|
|
156
|
+
const onSubmitPath = vi.fn();
|
|
157
|
+
const onSelect = vi.fn();
|
|
158
|
+
renderPicker(
|
|
159
|
+
<WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
|
|
160
|
+
);
|
|
161
|
+
await open();
|
|
162
|
+
const input = screen.getByPlaceholderText("/path/to/project…");
|
|
163
|
+
await userEvent.type(input, "/tmp/other-workspace{Enter}");
|
|
164
|
+
expect(onSubmitPath).toHaveBeenCalledWith("/tmp/other-workspace");
|
|
165
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("truncates a very long path without overflow", async () => {
|
|
169
|
+
const longPath = "~/dev/" + "very-long-segment/".repeat(20) + "elabs-components";
|
|
170
|
+
renderPicker(
|
|
171
|
+
<WorkspacePicker workspaces={[{ id: "ws-long", name: "long-one", path: longPath }]} />,
|
|
172
|
+
);
|
|
173
|
+
await open();
|
|
174
|
+
const description = screen.getByText(longPath);
|
|
175
|
+
expect(description).toHaveClass("truncate");
|
|
176
|
+
// The truncating span is a min-w-0 flex child — the min-w-0 + flex-1 rule
|
|
177
|
+
// that lets truncate actually clip instead of overflowing.
|
|
178
|
+
expect(description.parentElement).toHaveClass("min-w-0");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("uses the caller's explicit aria-label over the default", async () => {
|
|
182
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} aria-label="Session workspace" />);
|
|
183
|
+
expect(screen.getByRole("combobox")).toHaveAccessibleName("Session workspace");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("renders the shipped English default with no LocaleProvider mounted", () => {
|
|
187
|
+
// No provider: `t()` falls back to `DEFAULT_MESSAGES[key] ?? key`. The keys
|
|
188
|
+
// landed in messages.ts with this component, so the fallback is now real
|
|
189
|
+
// microcopy — a regression that dropped them would surface here as the raw
|
|
190
|
+
// key string instead.
|
|
191
|
+
render(<WorkspacePicker workspaces={workspaces} />);
|
|
192
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("scopes the workspace-picker data-slot to the root, distinct from model-picker", async () => {
|
|
196
|
+
const { container } = renderPicker(<WorkspacePicker workspaces={workspaces} />);
|
|
197
|
+
expect(container.querySelector('[data-slot="workspace-picker"]')).toBeInTheDocument();
|
|
198
|
+
expect(container.querySelector('[data-slot="model-picker"]')).not.toBeInTheDocument();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("renders the free-text path form outside CommandList — the search query cannot hide it", async () => {
|
|
202
|
+
renderPicker(<WorkspacePicker workspaces={workspaces} />);
|
|
203
|
+
await open();
|
|
204
|
+
await userEvent.type(screen.getByPlaceholderText("Search workspaces…"), "no-match-xyz");
|
|
205
|
+
expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { Button } from "../button";
|
|
6
|
+
import { Input } from "../input";
|
|
7
|
+
import { useLocale } from "../locale-provider";
|
|
8
|
+
import { ModelPicker, type ModelPickerGroup, type ModelPickerStatus } from "../model-picker";
|
|
9
|
+
import { formatLastOpened, type Workspace } from "./workspace-picker-state";
|
|
10
|
+
|
|
11
|
+
export type { Workspace };
|
|
12
|
+
|
|
13
|
+
export interface WorkspacePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
14
|
+
/** Recent workspaces, most-relevant first — the caller owns ordering. */
|
|
15
|
+
workspaces: Workspace[];
|
|
16
|
+
/** The currently open workspace's id. */
|
|
17
|
+
currentId?: string;
|
|
18
|
+
/** Fires with the chosen workspace; the popover closes first. */
|
|
19
|
+
onSelect?: (workspace: Workspace) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Free-text path entry, rendered in `ModelPicker`'s `footer` slot below the
|
|
22
|
+
* list. Fires with the raw string the user typed once it validates as
|
|
23
|
+
* non-empty — this component performs no filesystem access of any kind
|
|
24
|
+
* (presentation layer, D5); resolving, normalizing or checking the path is
|
|
25
|
+
* the caller's job.
|
|
26
|
+
*/
|
|
27
|
+
onSubmitPath?: (path: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Drives the same four-body state grid as `ModelPicker` — see
|
|
30
|
+
* `ModelPickerStatus`. Default `"ready"`, inherited from `ModelPicker`.
|
|
31
|
+
*/
|
|
32
|
+
status?: ModelPickerStatus;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Accessible name for the trigger and the popover — what the control is FOR. */
|
|
35
|
+
"aria-label"?: string;
|
|
36
|
+
/** Controlled popover open state — see `ModelPicker`'s controlled contract. */
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recent-workspace switcher — issue #111, decided in
|
|
43
|
+
* `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 7.
|
|
44
|
+
*
|
|
45
|
+
* A `ModelPicker` PRESET, not a new picker: `ModelPickerItem`'s
|
|
46
|
+
* `id`/`label`/`description`/`meta` shape is already a superset of what a
|
|
47
|
+
* workspace needs, so this component only supplies workspace-shaped
|
|
48
|
+
* defaults — it maps `path` into the truncating `description` slot, formats
|
|
49
|
+
* `lastOpenedAt` with `Intl.RelativeTimeFormat`, marks the current workspace
|
|
50
|
+
* in the row's TEXT (never colour alone — `ModelPicker`'s check glyph is
|
|
51
|
+
* `aria-hidden` and cannot carry that on its own), and renders the free-text
|
|
52
|
+
* path entry into `ModelPicker`'s `footer` slot so it sits below the list,
|
|
53
|
+
* un-filtered by the search query.
|
|
54
|
+
*
|
|
55
|
+
* Unlike `TeamSwitcher`, this has no `Sidebar` dependency — it works anywhere
|
|
56
|
+
* a picker is needed (a session launch card, a command palette), not only
|
|
57
|
+
* inside a sidebar.
|
|
58
|
+
*/
|
|
59
|
+
export const WorkspacePicker = forwardRef<HTMLDivElement, WorkspacePickerProps>(
|
|
60
|
+
function WorkspacePicker(
|
|
61
|
+
{
|
|
62
|
+
workspaces,
|
|
63
|
+
currentId,
|
|
64
|
+
onSelect,
|
|
65
|
+
onSubmitPath,
|
|
66
|
+
status,
|
|
67
|
+
disabled,
|
|
68
|
+
className,
|
|
69
|
+
open,
|
|
70
|
+
onOpenChange,
|
|
71
|
+
"aria-label": ariaLabelProp,
|
|
72
|
+
...props
|
|
73
|
+
},
|
|
74
|
+
ref,
|
|
75
|
+
) {
|
|
76
|
+
const { t, locale } = useLocale();
|
|
77
|
+
const [path, setPath] = useState("");
|
|
78
|
+
|
|
79
|
+
const current = workspaces.find((workspace) => workspace.id === currentId);
|
|
80
|
+
const triggerLabel = current?.name ?? t("ui.workspacePicker.placeholder");
|
|
81
|
+
const ariaLabel = ariaLabelProp ?? t("ui.workspacePicker.label");
|
|
82
|
+
|
|
83
|
+
const group: ModelPickerGroup = {
|
|
84
|
+
key: "workspaces",
|
|
85
|
+
label: t("ui.workspacePicker.recent"),
|
|
86
|
+
items: workspaces.map((workspace) => {
|
|
87
|
+
const isCurrent = workspace.id === currentId;
|
|
88
|
+
const meta: string[] = [];
|
|
89
|
+
if (workspace.lastOpenedAt) {
|
|
90
|
+
meta.push(formatLastOpened(workspace.lastOpenedAt, locale));
|
|
91
|
+
}
|
|
92
|
+
// Marked in TEXT so the marker reaches the row's accessible name and
|
|
93
|
+
// survives greyscale (accessibility.md's colour-is-never-the-only-
|
|
94
|
+
// channel test) — ModelPicker's own check glyph is `aria-hidden` and
|
|
95
|
+
// cannot carry this alone.
|
|
96
|
+
if (isCurrent) meta.push(t("ui.workspacePicker.current"));
|
|
97
|
+
return {
|
|
98
|
+
id: workspace.id,
|
|
99
|
+
label: workspace.name,
|
|
100
|
+
description: workspace.path,
|
|
101
|
+
meta,
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleSelect = (item: { id: string }) => {
|
|
107
|
+
const workspace = workspaces.find((w) => w.id === item.id);
|
|
108
|
+
if (workspace) onSelect?.(workspace);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
if (path.trim() === "") return;
|
|
114
|
+
onSubmitPath?.(path);
|
|
115
|
+
setPath("");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<ModelPicker
|
|
120
|
+
ref={ref}
|
|
121
|
+
data-slot="workspace-picker"
|
|
122
|
+
className={cn(className)}
|
|
123
|
+
groups={[group]}
|
|
124
|
+
value={currentId}
|
|
125
|
+
onSelect={handleSelect}
|
|
126
|
+
status={status}
|
|
127
|
+
triggerLabel={triggerLabel}
|
|
128
|
+
aria-label={ariaLabel}
|
|
129
|
+
searchPlaceholder={t("ui.workspacePicker.searchPlaceholder")}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
open={open}
|
|
132
|
+
onOpenChange={onOpenChange}
|
|
133
|
+
footer={
|
|
134
|
+
// Outside `CommandList` on purpose (ModelPicker's own contract): a
|
|
135
|
+
// persistent action row, never filtered by the search query and
|
|
136
|
+
// never part of cmdk's roving selection.
|
|
137
|
+
//
|
|
138
|
+
// `onKeyDown` stops propagation because cmdk binds ITS OWN keydown
|
|
139
|
+
// handler on the `Command` root and reacts to Enter/Home/End/
|
|
140
|
+
// ArrowUp/ArrowDown regardless of which descendant is focused —
|
|
141
|
+
// without this, pressing Enter here would move the list's
|
|
142
|
+
// highlight instead of submitting the path.
|
|
143
|
+
<form
|
|
144
|
+
data-slot="workspace-picker-path-form"
|
|
145
|
+
onSubmit={handleSubmit}
|
|
146
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
147
|
+
className="flex items-center gap-2 border-t p-2"
|
|
148
|
+
>
|
|
149
|
+
<Input
|
|
150
|
+
data-slot="workspace-picker-path-input"
|
|
151
|
+
value={path}
|
|
152
|
+
onChange={(event) => setPath(event.target.value)}
|
|
153
|
+
placeholder={t("ui.workspacePicker.pathPlaceholder")}
|
|
154
|
+
aria-label={t("ui.workspacePicker.pathLabel")}
|
|
155
|
+
className="h-8 min-w-0 flex-1"
|
|
156
|
+
/>
|
|
157
|
+
<Button
|
|
158
|
+
type="submit"
|
|
159
|
+
size="sm"
|
|
160
|
+
variant="outline"
|
|
161
|
+
aria-disabled={path.trim() === ""}
|
|
162
|
+
data-slot="workspace-picker-path-submit"
|
|
163
|
+
>
|
|
164
|
+
{t("ui.workspacePicker.openPath")}
|
|
165
|
+
</Button>
|
|
166
|
+
</form>
|
|
167
|
+
}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Empty list / zero-data-yet state. Three line-art rows read as "empty list"
|
|
6
|
+
* on their own silhouette — no accent mark, per the accent-semantics
|
|
7
|
+
* convention in `illustration-base.tsx` (#48 finding 1): a floating "add"
|
|
8
|
+
* badge here would repeat information the rows already supply, not add any.
|
|
9
|
+
*/
|
|
10
|
+
export const EmptyListIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
11
|
+
function EmptyListIllustration({ size, className, ...props }, ref) {
|
|
12
|
+
return (
|
|
13
|
+
<svg
|
|
14
|
+
ref={ref}
|
|
15
|
+
{...illustrationSvgProps(size, className)}
|
|
16
|
+
data-slot="empty-list-illustration"
|
|
17
|
+
{...props}
|
|
18
|
+
>
|
|
19
|
+
<rect x="36" y="68" width="64" height="10" rx="5" />
|
|
20
|
+
<rect x="36" y="90" width="64" height="10" rx="5" />
|
|
21
|
+
<rect x="36" y="112" width="44" height="10" rx="5" />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
EmptyListIllustration.displayName = "EmptyListIllustration";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
illustrationSvgProps,
|
|
4
|
+
illustrationAccent,
|
|
5
|
+
type IllustrationProps,
|
|
6
|
+
} from "./illustration-base";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recoverable-failure state. A line-art document split into two halves with a
|
|
10
|
+
* visible gap between them — distinguishable from `NoAccessIllustration`/
|
|
11
|
+
* `OfflineIllustration` by silhouette alone (a split document reads as
|
|
12
|
+
* "broken" even in greyscale), per the WCAG 1.4.1 "never color-only" rule.
|
|
13
|
+
* The exclamation mark IS meaning-bearing (per the accent-semantics
|
|
14
|
+
* convention in `illustration-base.tsx`, #48 finding 1) — it turns "torn
|
|
15
|
+
* document" into "warning", information the silhouette alone doesn't carry —
|
|
16
|
+
* so it stays, in the upper-right quadrant of the right-hand half, clear of
|
|
17
|
+
* both documents' outlines.
|
|
18
|
+
*
|
|
19
|
+
* Redrawn for #24 fix round 1 (P1-3) — the previous cracked-card + tiny alert
|
|
20
|
+
* triangle read as a lightning bolt or a broken-image placeholder and the
|
|
21
|
+
* triangle's exclamation collapsed to an illegible blob at 4rem.
|
|
22
|
+
*
|
|
23
|
+
* Redrawn again for #48 finding 3 — the mark previously sat at (76-84,
|
|
24
|
+
* 54-95), exactly where the two document halves' torn edges converge, so it
|
|
25
|
+
* read as a knot of overlapping lines rather than a "!". Moved to
|
|
26
|
+
* (98-113, 44-85), 8-15 units clear of the nearest tear line at every
|
|
27
|
+
* y-coordinate it spans.
|
|
28
|
+
*/
|
|
29
|
+
export const ErrorIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
30
|
+
function ErrorIllustration({ size, className, ...props }, ref) {
|
|
31
|
+
return (
|
|
32
|
+
<svg
|
|
33
|
+
ref={ref}
|
|
34
|
+
{...illustrationSvgProps(size, className)}
|
|
35
|
+
data-slot="error-illustration"
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<path d="M36,32 L83.06,32 L71.29,55.16 L92.47,69.06 L66.59,92.24 L76,120 L36,120 Z" />
|
|
39
|
+
<path d="M124,36 L124,124 L84,124 L74.59,96.24 L100.47,73.06 L79.29,59.16 L91.06,36 Z" />
|
|
40
|
+
<rect
|
|
41
|
+
x="104"
|
|
42
|
+
y="46"
|
|
43
|
+
width="8"
|
|
44
|
+
height="24"
|
|
45
|
+
rx="4"
|
|
46
|
+
style={{ fill: illustrationAccent("var(--destructive-text)") }}
|
|
47
|
+
stroke="none"
|
|
48
|
+
/>
|
|
49
|
+
<circle
|
|
50
|
+
cx="108"
|
|
51
|
+
cy="80"
|
|
52
|
+
r="5"
|
|
53
|
+
style={{ fill: illustrationAccent("var(--destructive-text)") }}
|
|
54
|
+
stroke="none"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
ErrorIllustration.displayName = "ErrorIllustration";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* First-run / onboarding state. A line-art planted flag — reads as "start
|
|
6
|
+
* here" on its own silhouette, distinct from `SuccessIllustration`. No
|
|
7
|
+
* accent mark, per the accent-semantics convention in `illustration-base.tsx`
|
|
8
|
+
* (#48 finding 1): a decorative sparkle here would repeat information the
|
|
9
|
+
* flag already supplies, not add any.
|
|
10
|
+
*/
|
|
11
|
+
export const FirstRunIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
12
|
+
function FirstRunIllustration({ size, className, ...props }, ref) {
|
|
13
|
+
return (
|
|
14
|
+
<svg
|
|
15
|
+
ref={ref}
|
|
16
|
+
{...illustrationSvgProps(size, className)}
|
|
17
|
+
data-slot="first-run-illustration"
|
|
18
|
+
{...props}
|
|
19
|
+
>
|
|
20
|
+
<line x1="64" y1="122" x2="64" y2="46" />
|
|
21
|
+
<path d="M64,46 L100,54 L86,64 L100,74 L64,82 Z" strokeLinejoin="round" />
|
|
22
|
+
<line x1="36" y1="122" x2="90" y2="122" />
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
FirstRunIllustration.displayName = "FirstRunIllustration";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared 160×160 canvas every illustration draws on. Keeping one constant
|
|
6
|
+
* means every illustration's hand-authored coordinates line up on the same
|
|
7
|
+
* grid — every subject's ink is drawn to fill an ~88×88 box centered at
|
|
8
|
+
* (80, 78) (#24 fix round 1, P1-1), so the seven read as one optically-sized
|
|
9
|
+
* family instead of varying 2.1× against each other.
|
|
10
|
+
*/
|
|
11
|
+
export const ILLUSTRATION_VIEW_BOX = "0 0 160 160";
|
|
12
|
+
|
|
13
|
+
export interface IllustrationProps extends SVGProps<SVGSVGElement> {
|
|
14
|
+
/**
|
|
15
|
+
* Rendered width & height (any CSS length — pass a `rem` value to stay
|
|
16
|
+
* density/zoom-aware). Illustrations are drawn to read clearly from about
|
|
17
|
+
* `4rem` (64px) up to `10rem` (160px).
|
|
18
|
+
* @default "7rem"
|
|
19
|
+
*/
|
|
20
|
+
size?: string | number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `<svg>`-level attributes every illustration shares: canvas, stroke
|
|
25
|
+
* defaults (`currentColor`, so it themes with whatever text color the caller
|
|
26
|
+
* or ambient context applies — e.g. `StatePanel` tints the error kind's slot
|
|
27
|
+
* `text-destructive`), rem-based sizing, and decorative a11y (`aria-hidden`,
|
|
28
|
+
* `role="presentation"`) — every illustration is decorative; the state's
|
|
29
|
+
* title/description text is the accessible content, never the artwork.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately does NOT include `data-slot` — each illustration file declares
|
|
32
|
+
* its own `data-slot="<name>-illustration"` literal alongside this spread, so
|
|
33
|
+
* the stable-selector convention (`.claude/rules/component-api.md`) is
|
|
34
|
+
* visible per-component, not hidden behind a shared factory.
|
|
35
|
+
*/
|
|
36
|
+
export function illustrationSvgProps(size: string | number = "7rem", className?: string) {
|
|
37
|
+
return {
|
|
38
|
+
viewBox: ILLUSTRATION_VIEW_BOX,
|
|
39
|
+
width: size,
|
|
40
|
+
height: size,
|
|
41
|
+
fill: "none",
|
|
42
|
+
stroke: "currentColor",
|
|
43
|
+
strokeWidth: 4,
|
|
44
|
+
strokeLinecap: "round" as const,
|
|
45
|
+
strokeLinejoin: "round" as const,
|
|
46
|
+
"aria-hidden": true as const,
|
|
47
|
+
role: "presentation" as const,
|
|
48
|
+
focusable: "false" as const,
|
|
49
|
+
className: cn("shrink-0", className),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Accent-semantics convention (#48 finding 1): an illustration draws at most
|
|
55
|
+
* ONE accent mark (via `illustrationAccent()` below), and it exists ONLY
|
|
56
|
+
* when it carries information the silhouette (the `currentColor`-stroked
|
|
57
|
+
* subject) does not already supply on its own — never as ornamental
|
|
58
|
+
* flourish wearing a meaning-bearing token. Applied across the seven
|
|
59
|
+
* shipped illustrations:
|
|
60
|
+
* - **Meaning-bearing (kept):** the "×" that turns a magnifying glass into
|
|
61
|
+
* "no results" (`NoResultsIllustration`), the keyhole that turns a padlock
|
|
62
|
+
* body into "restricted" (`NoAccessIllustration`), the checkmark that
|
|
63
|
+
* turns a bare ring into "success" (`SuccessIllustration`), the
|
|
64
|
+
* exclamation that turns a torn document into "warning" rather than
|
|
65
|
+
* merely "broken" (`ErrorIllustration`).
|
|
66
|
+
* - **Was decorative-only, removed (#48):** the "add" badge on
|
|
67
|
+
* `EmptyListIllustration` (three rows already read as "empty list" on
|
|
68
|
+
* their own), the sparkle on `FirstRunIllustration` (the planted flag
|
|
69
|
+
* already reads as "start here"), and the broken-link fragments on
|
|
70
|
+
* `OfflineIllustration` (a slashed cloud already reads as "disconnected" —
|
|
71
|
+
* the universal "no signal" idiom). Their accent-free silhouettes carry
|
|
72
|
+
* the full state alone, matching how `ErrorIllustration`'s silhouette
|
|
73
|
+
* already did before this convention was written down.
|
|
74
|
+
* Every illustration is `aria-hidden` regardless (the state's title/
|
|
75
|
+
* description text is the ACCESSIBLE channel — see `illustrationSvgProps`
|
|
76
|
+
* below), so this is a visual-consistency rule, not an a11y requirement on
|
|
77
|
+
* its own; don't regress the silhouette-carries-meaning property while
|
|
78
|
+
* redrawing any of the four kept accents above.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The custom property every illustration's single meaning-bearing accent
|
|
83
|
+
* reads through. `StatePanel` sets it on the illustration slot's wrapper for
|
|
84
|
+
* `kind="error"` (`var(--destructive-text)`) so the accent follows the
|
|
85
|
+
* panel's tint instead of staying pinned to one hue inside a re-colored slot
|
|
86
|
+
* (#24 fix round 1, P0-2) — without this, an illustration whose accent
|
|
87
|
+
* defaults to the brand hue renders a lime badge on a red error panel. Falls
|
|
88
|
+
* back to each illustration's own default hue when no ambient override is
|
|
89
|
+
* set.
|
|
90
|
+
*/
|
|
91
|
+
export const ILLUSTRATION_ACCENT_VAR = "--illustration-accent";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the `style` value for an illustration's accent ink: honors the
|
|
95
|
+
* ambient `--illustration-accent` override when set, otherwise `fallback`.
|
|
96
|
+
* `fallback` must be the correct **mark rung** for the accent's hue — e.g.
|
|
97
|
+
* `var(--primary-text)` (NOT `var(--primary)`, a plate color that measures
|
|
98
|
+
* 1.42:1 on `--card` in the `light` theme — #24 fix round 1, P0-1; see
|
|
99
|
+
* `.claude/rules/styling-and-tokens.md` "Which status rung a graphical MARK
|
|
100
|
+
* reaches for"), `var(--success-text)` for a positive-outcome accent, or
|
|
101
|
+
* `var(--destructive-text)` for an error accent — the TEXT rung, deliberately
|
|
102
|
+
* distinct from the FILL rung (`--destructive`) `StatePanel` puts the
|
|
103
|
+
* SUBJECT on for `kind="error"`, so the two don't collapse onto one colour
|
|
104
|
+
* (#48 finding 2).
|
|
105
|
+
*/
|
|
106
|
+
export function illustrationAccent(fallback: string): string {
|
|
107
|
+
return `var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`;
|
|
108
|
+
}
|