@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect } from "storybook/test";
|
|
2
3
|
import {
|
|
3
4
|
Breadcrumb,
|
|
4
5
|
BreadcrumbItem,
|
|
@@ -45,3 +46,40 @@ export const Default: Story = {
|
|
|
45
46
|
</Breadcrumb>
|
|
46
47
|
),
|
|
47
48
|
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* #310 — `BreadcrumbLink` had no focus-visible styling of its own, so keyboard
|
|
52
|
+
* focus fell straight through to the browser's own default ring instead of
|
|
53
|
+
* the library's token-driven indicator (`focus-ring`, ADR 0027) every other
|
|
54
|
+
* interactive control carries. A class-string assertion would be worthless
|
|
55
|
+
* (it wouldn't prove anything actually paints) so this locks the RENDERED
|
|
56
|
+
* indicator: Tab to the first link and assert a real box-shadow exists,
|
|
57
|
+
* mirroring `Button`'s `CompoundFocusIndicator` lock.
|
|
58
|
+
*/
|
|
59
|
+
export const FocusIndicator: Story = {
|
|
60
|
+
name: "Focus indicator (#310)",
|
|
61
|
+
render: () => (
|
|
62
|
+
<Breadcrumb>
|
|
63
|
+
<BreadcrumbList>
|
|
64
|
+
<BreadcrumbItem>
|
|
65
|
+
<BreadcrumbLink href="#">Home</BreadcrumbLink>
|
|
66
|
+
</BreadcrumbItem>
|
|
67
|
+
<BreadcrumbSeparator />
|
|
68
|
+
<BreadcrumbItem>
|
|
69
|
+
<BreadcrumbPage>brand-ui</BreadcrumbPage>
|
|
70
|
+
</BreadcrumbItem>
|
|
71
|
+
</BreadcrumbList>
|
|
72
|
+
</Breadcrumb>
|
|
73
|
+
),
|
|
74
|
+
play: async ({ canvas, userEvent }) => {
|
|
75
|
+
const link = canvas.getByRole("link", { name: "Home" });
|
|
76
|
+
|
|
77
|
+
// Tab rather than .focus(): `:focus-visible` is what `focus-ring` keys
|
|
78
|
+
// on, and a programmatic focus does not reliably match it.
|
|
79
|
+
await userEvent.tab();
|
|
80
|
+
await expect(link).toHaveFocus();
|
|
81
|
+
|
|
82
|
+
const focused = getComputedStyle(link);
|
|
83
|
+
await expect(focused.boxShadow).not.toBe("none");
|
|
84
|
+
},
|
|
85
|
+
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
|
-
import {
|
|
3
|
+
import { LocaleProvider } from "../locale-provider";
|
|
4
|
+
import {
|
|
5
|
+
Breadcrumb,
|
|
6
|
+
BreadcrumbEllipsis,
|
|
7
|
+
BreadcrumbItem,
|
|
8
|
+
BreadcrumbList,
|
|
9
|
+
BreadcrumbPage,
|
|
10
|
+
} from "./breadcrumb";
|
|
4
11
|
describe("Breadcrumb", () => {
|
|
5
12
|
it("renders the current page", () => {
|
|
6
13
|
render(
|
|
@@ -15,3 +22,19 @@ describe("Breadcrumb", () => {
|
|
|
15
22
|
expect(screen.getByText("Here")).toHaveAttribute("aria-current", "page");
|
|
16
23
|
});
|
|
17
24
|
});
|
|
25
|
+
|
|
26
|
+
describe("BreadcrumbEllipsis — microcopy (#18)", () => {
|
|
27
|
+
it("keeps its shipped sr-only 'More' text with no LocaleProvider", () => {
|
|
28
|
+
render(<BreadcrumbEllipsis />);
|
|
29
|
+
expect(screen.getByText("More")).toHaveClass("sr-only");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("translates via the reused generic `more` key under a LocaleProvider", () => {
|
|
33
|
+
render(
|
|
34
|
+
<LocaleProvider locale="de-DE" messages={{ more: "Mehr" }}>
|
|
35
|
+
<BreadcrumbEllipsis />
|
|
36
|
+
</LocaleProvider>,
|
|
37
|
+
);
|
|
38
|
+
expect(screen.getByText("Mehr")).toHaveClass("sr-only");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -2,6 +2,7 @@ import { forwardRef, type ComponentProps, type ReactNode } from "react";
|
|
|
2
2
|
import { Slot } from "@radix-ui/react-slot";
|
|
3
3
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
|
+
import { useLocale } from "../locale-provider";
|
|
5
6
|
|
|
6
7
|
export const Breadcrumb = forwardRef<HTMLElement, ComponentProps<"nav">>(
|
|
7
8
|
function Breadcrumb(props, ref) {
|
|
@@ -37,7 +38,10 @@ export const BreadcrumbLink = forwardRef<
|
|
|
37
38
|
return (
|
|
38
39
|
<Comp
|
|
39
40
|
ref={ref}
|
|
40
|
-
className={cn(
|
|
41
|
+
className={cn(
|
|
42
|
+
"rounded-sm transition-colors duration-fast hover:text-foreground focus-ring",
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
41
45
|
{...props}
|
|
42
46
|
/>
|
|
43
47
|
);
|
|
@@ -69,6 +73,7 @@ export function BreadcrumbSeparator({ children, className, ...props }: Component
|
|
|
69
73
|
);
|
|
70
74
|
}
|
|
71
75
|
export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span">) {
|
|
76
|
+
const { t } = useLocale();
|
|
72
77
|
return (
|
|
73
78
|
<span
|
|
74
79
|
role="presentation"
|
|
@@ -77,7 +82,7 @@ export function BreadcrumbEllipsis({ className, ...props }: ComponentProps<"span
|
|
|
77
82
|
{...props}
|
|
78
83
|
>
|
|
79
84
|
<MoreHorizontal className="size-4" />
|
|
80
|
-
<span className="sr-only">
|
|
85
|
+
<span className="sr-only">{t("more")}</span>
|
|
81
86
|
</span>
|
|
82
87
|
);
|
|
83
88
|
}
|
|
@@ -41,17 +41,30 @@ export const Ghost: Story = { args: { variant: "ghost" } };
|
|
|
41
41
|
// themes.css/Tailwind failed to load, this would be the transparent default
|
|
42
42
|
// `rgba(0, 0, 0, 0)` and fail — where a plain `toBeVisible()` would still pass
|
|
43
43
|
// on the unstyled element.
|
|
44
|
-
//
|
|
45
|
-
// The harness applies the shipped DEFAULT theme (`light`) to
|
|
46
|
-
// `document.documentElement` (see `apps/docs/.storybook/preview.tsx`'s
|
|
47
|
-
// `withTheme` decorator, #402), so `--primary` here is light's own
|
|
48
|
-
// value (`oklch(0.553 0.143 153)` in themes.css) — not the unbranded `:root`
|
|
49
|
-
// fallback this test asserted against before #402 fixed the harness to
|
|
50
|
-
// actually apply a theme.
|
|
51
44
|
export const CssCheck: Story = {
|
|
52
45
|
play: async ({ canvas }) => {
|
|
53
46
|
const btn = canvas.getByRole("button", { name: "Button" });
|
|
54
|
-
|
|
47
|
+
|
|
48
|
+
// Create a hidden reference element styled with the same token
|
|
49
|
+
// to derive the expected value dynamically, avoiding hardcoded snapshots
|
|
50
|
+
const ref = document.createElement("div");
|
|
51
|
+
ref.className = "bg-primary";
|
|
52
|
+
ref.style.visibility = "hidden";
|
|
53
|
+
ref.style.position = "absolute";
|
|
54
|
+
document.body.appendChild(ref);
|
|
55
|
+
|
|
56
|
+
const buttonBgColor = getComputedStyle(btn).backgroundColor;
|
|
57
|
+
const referenceBgColor = getComputedStyle(ref).backgroundColor;
|
|
58
|
+
|
|
59
|
+
// Verify the token system loaded by comparing button color to reference
|
|
60
|
+
await expect(buttonBgColor).toBe(referenceBgColor);
|
|
61
|
+
|
|
62
|
+
// Verify the token system actually loaded a non-transparent color
|
|
63
|
+
// (catches the case where themes.css/Tailwind failed to load)
|
|
64
|
+
await expect(buttonBgColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
65
|
+
|
|
66
|
+
// Clean up
|
|
67
|
+
ref.remove();
|
|
55
68
|
},
|
|
56
69
|
};
|
|
57
70
|
|
|
@@ -68,3 +81,98 @@ export const AllVariants: Story = {
|
|
|
68
81
|
</div>
|
|
69
82
|
),
|
|
70
83
|
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* #67 — the RENDERED lock on the compound focus indicator (ADR 0027 Amendment).
|
|
87
|
+
*
|
|
88
|
+
* A class-string assertion would be worthless here: `focus-ring` could name a
|
|
89
|
+
* single-layer ring and the class would still be present while the ratio stayed
|
|
90
|
+
* at 1.36:1, which is the exact failure this story exists to prevent. So this
|
|
91
|
+
* play function measures the SHIPPED pixels — it reads the computed indicator
|
|
92
|
+
* layers off a really-focused button, resolves the ground the indicator is
|
|
93
|
+
* actually drawn on by walking up to the first non-transparent ancestor, and
|
|
94
|
+
* asserts at least one layer clears WCAG 1.4.11 (3:1) against it.
|
|
95
|
+
*
|
|
96
|
+
* It is theme-agnostic on purpose: whichever theme the run resolves (the
|
|
97
|
+
* toolbar global, or `STORYBOOK_THEME=<slug>` headless — see
|
|
98
|
+
* @.claude/rules/storybook-mcp.md § Themes) the SAME assertion has to hold, so
|
|
99
|
+
* one story covers `light`, `dark` and any consumer theme a fork adds.
|
|
100
|
+
*/
|
|
101
|
+
export const CompoundFocusIndicator: Story = {
|
|
102
|
+
name: "Compound focus indicator (WCAG 1.4.11)",
|
|
103
|
+
play: async ({ canvas, userEvent }) => {
|
|
104
|
+
const btn = canvas.getByRole("button", { name: "Button" });
|
|
105
|
+
|
|
106
|
+
// Tab rather than .focus(): `:focus-visible` is what the utility keys on,
|
|
107
|
+
// and a programmatic focus does not reliably match it.
|
|
108
|
+
await userEvent.tab();
|
|
109
|
+
await expect(btn).toHaveFocus();
|
|
110
|
+
|
|
111
|
+
/** Resolve a token to the rgb() the browser actually paints. */
|
|
112
|
+
const resolve = (token: string) => {
|
|
113
|
+
const probe = document.createElement("div");
|
|
114
|
+
probe.style.cssText = `position:absolute;visibility:hidden;background-color:var(${token})`;
|
|
115
|
+
btn.parentElement?.appendChild(probe);
|
|
116
|
+
const value = getComputedStyle(probe).backgroundColor;
|
|
117
|
+
probe.remove();
|
|
118
|
+
return value;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Colours come back in whatever space the author wrote — Chromium echoes
|
|
122
|
+
// `oklch()` verbatim for an oklch token — so convert through a 1x1 canvas
|
|
123
|
+
// rather than a regex: the canvas does the same CSS Color 4 → sRGB
|
|
124
|
+
// conversion the compositor does, for any colour syntax a theme may use.
|
|
125
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
126
|
+
if (ctx == null) throw new Error("no 2d context");
|
|
127
|
+
const srgb = (value: string): [number, number, number] => {
|
|
128
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
129
|
+
ctx.fillStyle = value;
|
|
130
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
131
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
132
|
+
return [r as number, g as number, b as number];
|
|
133
|
+
};
|
|
134
|
+
const luminance = (value: string) => {
|
|
135
|
+
const [r, g, b] = srgb(value).map((c) => {
|
|
136
|
+
const s = c / 255;
|
|
137
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
138
|
+
}) as [number, number, number];
|
|
139
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
140
|
+
};
|
|
141
|
+
const ratio = (a: string, b: string) => {
|
|
142
|
+
const [la, lb] = [luminance(a), luminance(b)];
|
|
143
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// The ground the indicator is drawn ON — walked, not assumed, and started at
|
|
147
|
+
// the PARENT: the indicator sits outside the button, so the button's own
|
|
148
|
+
// `--primary` fill is not the surface it has to be legible against.
|
|
149
|
+
let ground = "rgba(0, 0, 0, 0)";
|
|
150
|
+
for (let el: HTMLElement | null = btn.parentElement; el != null; el = el.parentElement) {
|
|
151
|
+
const bg = getComputedStyle(el).backgroundColor;
|
|
152
|
+
if (bg !== "rgba(0, 0, 0, 0)" && bg !== "transparent") {
|
|
153
|
+
ground = bg;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await expect(ground).not.toBe("rgba(0, 0, 0, 0)");
|
|
158
|
+
|
|
159
|
+
// BOTH layers must actually be emitted — a compound indicator with one layer
|
|
160
|
+
// missing is the single-layer ring this issue was filed about.
|
|
161
|
+
const focused = getComputedStyle(btn);
|
|
162
|
+
await expect(focused.boxShadow).not.toBe("none");
|
|
163
|
+
await expect(focused.outlineStyle).toBe("solid");
|
|
164
|
+
await expect(parseFloat(focused.outlineWidth)).toBeGreaterThan(0);
|
|
165
|
+
|
|
166
|
+
const ringLayer = resolve("--ring");
|
|
167
|
+
const contourLayer = focused.outlineColor;
|
|
168
|
+
// The contour layer really is the token, not currentColor or a fallback.
|
|
169
|
+
await expect(contourLayer).toBe(resolve("--ring-contour"));
|
|
170
|
+
|
|
171
|
+
const best = Math.max(ratio(ringLayer, ground), ratio(contourLayer, ground));
|
|
172
|
+
await expect(
|
|
173
|
+
best,
|
|
174
|
+
`focus indicator on ${ground}: ring ${ringLayer} = ${ratio(ringLayer, ground).toFixed(2)}, ` +
|
|
175
|
+
`contour ${contourLayer} = ${ratio(contourLayer, ground).toFixed(2)}`,
|
|
176
|
+
).toBeGreaterThanOrEqual(3);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
|
|
6
6
|
export const buttonVariants = cva(
|
|
7
|
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-md text-body font-medium transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-
|
|
7
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-md text-body font-medium transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
@@ -70,6 +70,11 @@ export const Default: Story = {
|
|
|
70
70
|
|
|
71
71
|
/** Opt-in hover-lift for clickable cards (gated by the motion system). */
|
|
72
72
|
export const Interactive: Story = {
|
|
73
|
+
// The suite runs under `prefers-reduced-motion: reduce`
|
|
74
|
+
// (`apps/docs/vitest.config.ts`, #125), so the gated `--t-base` this asserts
|
|
75
|
+
// would read as the floor. The lift is what this story exists to prove, so it
|
|
76
|
+
// opts back into full motion explicitly.
|
|
77
|
+
globals: { motionPref: "full" },
|
|
73
78
|
render: () => (
|
|
74
79
|
<Card interactive className="max-w-sm" tabIndex={0} role="button">
|
|
75
80
|
<CardHeader>
|
|
@@ -153,17 +158,17 @@ export const DetailSideHover: Story = {
|
|
|
153
158
|
</p>
|
|
154
159
|
<ul className="text-body space-y-1">
|
|
155
160
|
<li>
|
|
156
|
-
<button className="text-foreground hover:underline focus-
|
|
161
|
+
<button className="text-foreground hover:underline focus-ring rounded-sm px-1">
|
|
157
162
|
View report
|
|
158
163
|
</button>
|
|
159
164
|
</li>
|
|
160
165
|
<li>
|
|
161
|
-
<button className="text-foreground hover:underline focus-
|
|
166
|
+
<button className="text-foreground hover:underline focus-ring rounded-sm px-1">
|
|
162
167
|
Export CSV
|
|
163
168
|
</button>
|
|
164
169
|
</li>
|
|
165
170
|
<li>
|
|
166
|
-
<button className="text-foreground hover:underline focus-
|
|
171
|
+
<button className="text-foreground hover:underline focus-ring rounded-sm px-1">
|
|
167
172
|
Share
|
|
168
173
|
</button>
|
|
169
174
|
</li>
|
|
@@ -223,10 +228,10 @@ export const DetailBottomHover: Story = {
|
|
|
223
228
|
className="h-full"
|
|
224
229
|
detail={
|
|
225
230
|
<div className="flex items-center gap-3">
|
|
226
|
-
<button className="text-body text-foreground hover:underline focus-
|
|
231
|
+
<button className="text-body text-foreground hover:underline focus-ring rounded-sm px-1">
|
|
227
232
|
Drill down
|
|
228
233
|
</button>
|
|
229
|
-
<button className="text-body text-foreground hover:underline focus-
|
|
234
|
+
<button className="text-body text-foreground hover:underline focus-ring rounded-sm px-1">
|
|
230
235
|
Download
|
|
231
236
|
</button>
|
|
232
237
|
</div>
|
|
@@ -390,7 +395,7 @@ export const DetailHoverKeyboardReveal: Story = {
|
|
|
390
395
|
<div className="flex flex-col gap-2 pt-1">
|
|
391
396
|
<button
|
|
392
397
|
data-testid="detail-action"
|
|
393
|
-
className="text-body text-foreground hover:underline focus-
|
|
398
|
+
className="text-body text-foreground hover:underline focus-ring rounded-sm px-1 text-start"
|
|
394
399
|
>
|
|
395
400
|
Open details
|
|
396
401
|
</button>
|
|
@@ -23,7 +23,7 @@ export const cardVariants = cva("rounded-lg border bg-card text-card-foreground
|
|
|
23
23
|
// duration-* utility; the transform is neutralized under OS reduced-motion
|
|
24
24
|
// (the shadow/border still respond — "reduced != none").
|
|
25
25
|
interactive: {
|
|
26
|
-
true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-base ease-standard hover:-translate-y-1 hover:border-ring/40 hover:shadow-md focus-
|
|
26
|
+
true: "cursor-pointer transition-[translate,box-shadow,border-color] duration-base ease-standard hover:-translate-y-1 hover:border-ring/40 hover:shadow-md focus-ring motion-reduce:hover:translate-y-0",
|
|
27
27
|
false: "",
|
|
28
28
|
},
|
|
29
29
|
},
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
2
|
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
+
import { type CheckResult } from "../../lib/check-result";
|
|
4
5
|
import {
|
|
5
6
|
ChangeReview,
|
|
6
7
|
ChangeReviewHeader,
|
|
@@ -42,6 +43,41 @@ const PROVENANCE = {
|
|
|
42
43
|
note: "run-abc123",
|
|
43
44
|
};
|
|
44
45
|
|
|
46
|
+
// ─── Sample hunks with verification checks (#112) ─────────────────────────────
|
|
47
|
+
|
|
48
|
+
const HUNKS_WITH_CHECKS: ChangeHunk[] = [
|
|
49
|
+
{
|
|
50
|
+
id: "checked-1",
|
|
51
|
+
title: "src/api/handler.ts",
|
|
52
|
+
status: "modified",
|
|
53
|
+
before: `function handle(req) { return req.body; }`,
|
|
54
|
+
after: `function handle(req: Request) { return req.body; }`,
|
|
55
|
+
checks: [
|
|
56
|
+
{ label: "eslint", ok: true, durationMs: 214 },
|
|
57
|
+
{
|
|
58
|
+
label: "tsc",
|
|
59
|
+
ok: false,
|
|
60
|
+
durationMs: 890,
|
|
61
|
+
detail:
|
|
62
|
+
"src/api/handler.ts:1:31 — Parameter 'req' implicitly has an 'any' type, and the " +
|
|
63
|
+
"surrounding function type annotation does not resolve it. This is a deliberately " +
|
|
64
|
+
"long detail line so it renders behind a collapsed disclosure by default.",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "checked-2",
|
|
70
|
+
title: "src/api/handler.test.ts",
|
|
71
|
+
status: "added",
|
|
72
|
+
after: `it("handles a request", () => { ... });`,
|
|
73
|
+
checks: [
|
|
74
|
+
{ label: "pre_tool_use", ok: true, phase: "before" },
|
|
75
|
+
{ label: "eslint", ok: true, phase: "after", durationMs: 120 },
|
|
76
|
+
{ label: "vitest", ok: true, phase: "after", detail: "3 passed", durationMs: 1420 },
|
|
77
|
+
] satisfies CheckResult[],
|
|
78
|
+
},
|
|
79
|
+
];
|
|
80
|
+
|
|
45
81
|
// ─── Meta ─────────────────────────────────────────────────────────────────────
|
|
46
82
|
|
|
47
83
|
const meta = {
|
|
@@ -53,10 +89,30 @@ const meta = {
|
|
|
53
89
|
docs: {
|
|
54
90
|
description: {
|
|
55
91
|
component:
|
|
92
|
+
"The ACCEPT/REJECT diff surface; read-only patch lines are `AI/DiffView` and an " +
|
|
93
|
+
"editable side-by-side is `Editor/DiffEditor` — see " +
|
|
94
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
56
95
|
"AI-edit trust gate. When an agent proposes edits, the human reviews them " +
|
|
57
96
|
"hunk-by-hunk and accepts or rejects each before they apply. Compound " +
|
|
58
97
|
"component with lifted state — compose parts for custom layouts, or use the " +
|
|
59
|
-
"convenience `<ChangeReview>` root
|
|
98
|
+
"convenience `<ChangeReview>` root.\n\n" +
|
|
99
|
+
"**Ships from `@elabs-ai/components-ui`**, not from the package this story " +
|
|
100
|
+
"group names. Stories are grouped by what a component is *for*, which " +
|
|
101
|
+
"`docs/STORYBOOK_GUIDELINES.md` names as intentional and cites this component " +
|
|
102
|
+
"as the worked example.\n\n" +
|
|
103
|
+
"**A hunk’s `before` / `after` are prose, not diff lines.** They are ordinary " +
|
|
104
|
+
"`ReactNode`s, drawn as a deliberately quiet no-fill comparison — a struck " +
|
|
105
|
+
"muted “before” above a plain “after”, with the status rail carrying the " +
|
|
106
|
+
"polarity. This component is not a line renderer and is not going to become " +
|
|
107
|
+
"one.\n\n" +
|
|
108
|
+
"**Structured `DiffLine[]` hunks arrive by injection.** The app that depends " +
|
|
109
|
+
"on both packages passes a `<DiffView>` (`@elabs-ai/components-ai`) through " +
|
|
110
|
+
"the `renderHunk` render-prop or a hunk’s `after` slot; neither package " +
|
|
111
|
+
"imports the other. `ChangeHunk` gains no `lines` field and `DiffLine` " +
|
|
112
|
+
"never moves — a ratified seam, recorded in § 3 of the adoption-architecture " +
|
|
113
|
+
"decision. The *Injected hunk renderer* story below shows the shape with a " +
|
|
114
|
+
"stand-in node; the real wiring is *Composed into ChangeReview (ui)* on " +
|
|
115
|
+
"[AI/DiffView](?path=/docs/ai-diffview--docs).",
|
|
60
116
|
},
|
|
61
117
|
},
|
|
62
118
|
},
|
|
@@ -239,6 +295,55 @@ export const CustomRenderer: Story = {
|
|
|
239
295
|
},
|
|
240
296
|
};
|
|
241
297
|
|
|
298
|
+
// ─── Injected hunk renderer (the DiffView seam) ───────────────────────────────
|
|
299
|
+
//
|
|
300
|
+
// BINDING SEAM — docs/decisions/2026-09-01-brainless-adoption-architecture.md § 3.
|
|
301
|
+
// `ChangeHunk` gains no `lines` field and `DiffLine` never moves into this
|
|
302
|
+
// package. An app that wants structured patch rows inside the trust gate injects
|
|
303
|
+
// a renderer through `renderHunk` (or a hunk's `after` slot); in a real app that
|
|
304
|
+
// node is `<DiffView lines={…} />` from `@elabs-ai/components-ai`.
|
|
305
|
+
//
|
|
306
|
+
// This package has no dependency on that one in EITHER direction — that is the
|
|
307
|
+
// whole point of the seam — so the story stands in for it with a plain node of
|
|
308
|
+
// the same shape. The real composition is the `ChangeReviewComposition` story in
|
|
309
|
+
// `packages/ai/src/diff-view.stories.tsx`, which can import both.
|
|
310
|
+
|
|
311
|
+
function StandInLineRenderer({ file }: { file: string }) {
|
|
312
|
+
return (
|
|
313
|
+
<div
|
|
314
|
+
data-testid={`injected-${file}`}
|
|
315
|
+
className="rounded-md bg-surface-muted p-3 text-meta text-muted-foreground"
|
|
316
|
+
>
|
|
317
|
+
<span className="font-mono">{`<DiffView lines={…} />`}</span> renders the patch rows for{" "}
|
|
318
|
+
<span className="font-medium text-foreground">{file}</span> here.
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const InjectedHunkRenderer: Story = {
|
|
324
|
+
args: {
|
|
325
|
+
hunks: HUNKS,
|
|
326
|
+
renderHunk: (hunk) => <StandInLineRenderer file={hunk.title ?? hunk.id} />,
|
|
327
|
+
},
|
|
328
|
+
play: async ({ canvasElement }) => {
|
|
329
|
+
const canvas = within(canvasElement);
|
|
330
|
+
|
|
331
|
+
// The injected node replaces the default before/after prose on every hunk…
|
|
332
|
+
await waitFor(() => expect(canvas.getByTestId("injected-config/settings.json")).toBeVisible());
|
|
333
|
+
expect(canvas.getAllByTestId(/^injected-/)).toHaveLength(3);
|
|
334
|
+
|
|
335
|
+
// …and the default prose comparison is gone, because the seam replaces it.
|
|
336
|
+
expect(canvas.queryByText('"timeout": 30')).not.toBeInTheDocument();
|
|
337
|
+
|
|
338
|
+
// Approve/reject still belongs to ChangeReview, not to the injected renderer.
|
|
339
|
+
await userEvent.click(
|
|
340
|
+
canvas.getByRole("button", { name: /approve hunk: config\/settings\.json/i }),
|
|
341
|
+
);
|
|
342
|
+
await waitFor(() => expect(canvas.getByText("1 of 3 approved")).toBeVisible());
|
|
343
|
+
expect(canvas.getByTestId("injected-config/settings.json")).toBeVisible();
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
242
347
|
// ─── Compound (manual layout) ─────────────────────────────────────────────────
|
|
243
348
|
|
|
244
349
|
export const Compound: Story = {
|
|
@@ -286,3 +391,42 @@ edge cases properly and includes proper error boundaries.`,
|
|
|
286
391
|
],
|
|
287
392
|
},
|
|
288
393
|
};
|
|
394
|
+
|
|
395
|
+
// ─── With verification checks (#112) ──────────────────────────────────────────
|
|
396
|
+
// Evidence a reviewer needs (lint / types / tests / policy hooks) rendered
|
|
397
|
+
// alongside the diff. A failing check informs the decision — it never blocks
|
|
398
|
+
// approval, so `checked-1` (which carries a failing `tsc` check) can still be
|
|
399
|
+
// approved below.
|
|
400
|
+
|
|
401
|
+
export const WithChecks: Story = {
|
|
402
|
+
args: {
|
|
403
|
+
hunks: HUNKS_WITH_CHECKS,
|
|
404
|
+
},
|
|
405
|
+
play: async ({ canvasElement }) => {
|
|
406
|
+
const canvas = within(canvasElement);
|
|
407
|
+
await waitFor(() => expect(canvas.getByText("src/api/handler.ts")).toBeVisible());
|
|
408
|
+
|
|
409
|
+
// Passing and failing checks are distinguishable by accessible text —
|
|
410
|
+
// never by colour alone.
|
|
411
|
+
await waitFor(() => expect(canvas.getAllByText("Passed").length).toBeGreaterThan(0));
|
|
412
|
+
await waitFor(() => expect(canvas.getAllByText("Failed").length).toBeGreaterThan(0));
|
|
413
|
+
|
|
414
|
+
// The long `tsc` detail is collapsed by default…
|
|
415
|
+
const showDetail = canvas.getByRole("button", { name: /show detail/i });
|
|
416
|
+
expect(canvas.queryByText(/implicitly has an 'any' type/)).not.toBeInTheDocument();
|
|
417
|
+
// …and expands on click.
|
|
418
|
+
await userEvent.click(showDetail);
|
|
419
|
+
await waitFor(() => expect(canvas.getByText(/implicitly has an 'any' type/)).toBeVisible());
|
|
420
|
+
|
|
421
|
+
// Both phases present on the second hunk → grouped "Before" / "After".
|
|
422
|
+
await waitFor(() => expect(canvas.getByText("Before")).toBeVisible());
|
|
423
|
+
await waitFor(() => expect(canvas.getByText("After")).toBeVisible());
|
|
424
|
+
|
|
425
|
+
// A failing check does not block approval — it only informs it.
|
|
426
|
+
const approveFirstHunk = canvas.getByRole("button", {
|
|
427
|
+
name: /approve hunk: src\/api\/handler\.ts/i,
|
|
428
|
+
});
|
|
429
|
+
await userEvent.click(approveFirstHunk);
|
|
430
|
+
await waitFor(() => expect(canvas.getByText("1 of 2 approved")).toBeVisible());
|
|
431
|
+
},
|
|
432
|
+
};
|
|
@@ -65,6 +65,26 @@ describe("ChangeReview — render", () => {
|
|
|
65
65
|
);
|
|
66
66
|
expect(screen.getByTestId("custom-prov")).toBeInTheDocument();
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
it("exposes structured provenance as a named group reachable by assistive tech", () => {
|
|
70
|
+
// aria-label on a role-less <div> is prohibited and silently discarded (#159) —
|
|
71
|
+
// assert the accessible name actually resolves via the ARIA role tree, not just
|
|
72
|
+
// that the attribute is present in the DOM.
|
|
73
|
+
render(
|
|
74
|
+
<ChangeReview
|
|
75
|
+
hunks={HUNKS}
|
|
76
|
+
provenance={{ author: "Atlas Agent", model: "claude-sonnet-4-6", timestamp: "14:02" }}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
expect(screen.getByRole("group", { name: "Change provenance" })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("exposes ReactNode provenance as a named group reachable by assistive tech", () => {
|
|
83
|
+
render(
|
|
84
|
+
<ChangeReview hunks={HUNKS} provenance={<span data-testid="custom-prov">Custom</span>} />,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByRole("group", { name: "Change provenance" })).toBeInTheDocument();
|
|
87
|
+
});
|
|
68
88
|
});
|
|
69
89
|
|
|
70
90
|
// ─── Initial state with controlled approved set ───────────────────────────────
|
|
@@ -232,6 +252,129 @@ describe("ChangeReview — accessibility", () => {
|
|
|
232
252
|
});
|
|
233
253
|
});
|
|
234
254
|
|
|
255
|
+
// ─── Checks (verification evidence, #112) ─────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("ChangeReview — checks", () => {
|
|
258
|
+
it("shows a failing check's status in accessible text with an icon distinct from a passing check's", () => {
|
|
259
|
+
const hunksWithChecks: ChangeHunk[] = [
|
|
260
|
+
{
|
|
261
|
+
id: "h1",
|
|
262
|
+
title: "config/a.json",
|
|
263
|
+
status: "modified",
|
|
264
|
+
before: "old",
|
|
265
|
+
after: "new",
|
|
266
|
+
checks: [
|
|
267
|
+
{ label: "eslint", ok: true },
|
|
268
|
+
{ label: "tsc", ok: false, detail: "Type 'string' is not assignable to type 'number'." },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
render(<ChangeReview hunks={hunksWithChecks} />);
|
|
273
|
+
|
|
274
|
+
// Accessible text carries the pass/fail status word — not color alone.
|
|
275
|
+
const passed = screen.getByText("Passed");
|
|
276
|
+
const failed = screen.getByText("Failed");
|
|
277
|
+
expect(passed).toBeInTheDocument();
|
|
278
|
+
expect(failed).toBeInTheDocument();
|
|
279
|
+
|
|
280
|
+
// The icon differs between a passing and a failing check (StatusIcon's
|
|
281
|
+
// canonical `complete` vs `failed` glyphs — never colour alone).
|
|
282
|
+
const passRow = passed.closest('[data-slot="change-review-hunk-check"]')!;
|
|
283
|
+
const failRow = failed.closest('[data-slot="change-review-hunk-check"]')!;
|
|
284
|
+
const passIcon = passRow.querySelector("svg[data-status]");
|
|
285
|
+
const failIcon = failRow.querySelector("svg[data-status]");
|
|
286
|
+
expect(passIcon).toHaveAttribute("data-status", "complete");
|
|
287
|
+
expect(failIcon).toHaveAttribute("data-status", "failed");
|
|
288
|
+
expect(passIcon?.getAttribute("data-status")).not.toBe(failIcon?.getAttribute("data-status"));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("allows approving a hunk that carries a failing check", async () => {
|
|
292
|
+
const user = userEvent.setup();
|
|
293
|
+
const hunksWithFailingCheck: ChangeHunk[] = [
|
|
294
|
+
{
|
|
295
|
+
id: "h1",
|
|
296
|
+
title: "config/a.json",
|
|
297
|
+
status: "modified",
|
|
298
|
+
before: "old",
|
|
299
|
+
after: "new",
|
|
300
|
+
checks: [{ label: "tsc", ok: false, detail: "Type error" }],
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
render(<ChangeReview hunks={hunksWithFailingCheck} />);
|
|
304
|
+
|
|
305
|
+
expect(screen.getByText("Failed")).toBeInTheDocument();
|
|
306
|
+
|
|
307
|
+
const btn = screen.getByRole("button", { name: /approve hunk: config\/a\.json/i });
|
|
308
|
+
await user.click(btn);
|
|
309
|
+
|
|
310
|
+
expect(screen.getByText("1 of 1 approved")).toBeInTheDocument();
|
|
311
|
+
expect(screen.getByText("Approved")).toBeInTheDocument();
|
|
312
|
+
// The failing check is still shown — informational, not blocking.
|
|
313
|
+
expect(screen.getByText("Failed")).toBeInTheDocument();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("does not render a checks section when a hunk has no checks (strictly additive)", () => {
|
|
317
|
+
render(<ChangeReview hunks={HUNKS} />);
|
|
318
|
+
expect(screen.queryByText("Passed")).not.toBeInTheDocument();
|
|
319
|
+
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("groups checks into Before / After sections only when both phases are present", () => {
|
|
323
|
+
const bothPhases: ChangeHunk[] = [
|
|
324
|
+
{
|
|
325
|
+
id: "h1",
|
|
326
|
+
checks: [
|
|
327
|
+
{ label: "pre_tool_use", ok: true, phase: "before" },
|
|
328
|
+
{ label: "post_tool_use", ok: true, phase: "after" },
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
render(<ChangeReview hunks={bothPhases} />);
|
|
333
|
+
expect(screen.getByText("Before")).toBeInTheDocument();
|
|
334
|
+
expect(screen.getByText("After")).toBeInTheDocument();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("does not render phase headers when only one phase is present", () => {
|
|
338
|
+
const onePhase: ChangeHunk[] = [
|
|
339
|
+
{
|
|
340
|
+
id: "h1",
|
|
341
|
+
checks: [{ label: "eslint", ok: true, phase: "before" }],
|
|
342
|
+
},
|
|
343
|
+
];
|
|
344
|
+
render(<ChangeReview hunks={onePhase} />);
|
|
345
|
+
expect(screen.queryByText("Before")).not.toBeInTheDocument();
|
|
346
|
+
expect(screen.queryByText("After")).not.toBeInTheDocument();
|
|
347
|
+
expect(screen.getByText("eslint")).toBeInTheDocument();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("renders a short detail line inline and shows the duration", () => {
|
|
351
|
+
const hunksWithDuration: ChangeHunk[] = [
|
|
352
|
+
{ id: "h1", checks: [{ label: "eslint", ok: true, detail: "0 problems", durationMs: 128 }] },
|
|
353
|
+
];
|
|
354
|
+
render(<ChangeReview hunks={hunksWithDuration} />);
|
|
355
|
+
expect(screen.getByText("0 problems")).toBeInTheDocument();
|
|
356
|
+
expect(screen.getByText("128ms")).toBeInTheDocument();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("collapses a long detail line behind a disclosure, expandable on click", async () => {
|
|
360
|
+
const user = userEvent.setup();
|
|
361
|
+
const longDetail =
|
|
362
|
+
"This is a deliberately long detail line that exceeds the inline collapse " +
|
|
363
|
+
"threshold and should therefore render behind a disclosure toggle by default.";
|
|
364
|
+
const hunksWithLongDetail: ChangeHunk[] = [
|
|
365
|
+
{ id: "h1", checks: [{ label: "tsc", ok: false, detail: longDetail }] },
|
|
366
|
+
];
|
|
367
|
+
render(<ChangeReview hunks={hunksWithLongDetail} />);
|
|
368
|
+
|
|
369
|
+
// Collapsed by default: the detail text is not yet in the document.
|
|
370
|
+
expect(screen.queryByText(longDetail)).not.toBeInTheDocument();
|
|
371
|
+
const trigger = screen.getByRole("button", { name: /show detail/i });
|
|
372
|
+
|
|
373
|
+
await user.click(trigger);
|
|
374
|
+
expect(await screen.findByText(longDetail)).toBeInTheDocument();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
235
378
|
// ─── Compound usage ───────────────────────────────────────────────────────────
|
|
236
379
|
|
|
237
380
|
describe("ChangeReview — compound component parts", () => {
|