@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30
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/package.json +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +53 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +56 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { Toolbar } from "./Toolbar";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "primitives/Button" };
|
|
8
|
+
|
|
9
|
+
export const Variants: Story = {
|
|
10
|
+
render: () => (
|
|
11
|
+
<Toolbar>
|
|
12
|
+
<Button variant="solid" marginRight={2} onPress={() => {}}>
|
|
13
|
+
{" Save ⏎ "}
|
|
14
|
+
</Button>
|
|
15
|
+
<Button marginRight={2} onPress={() => {}}>
|
|
16
|
+
{" Cancel esc "}
|
|
17
|
+
</Button>
|
|
18
|
+
<Button variant="accent-text" onPress={() => {}}>
|
|
19
|
+
{"Submit review (2) ⏎"}
|
|
20
|
+
</Button>
|
|
21
|
+
</Toolbar>
|
|
22
|
+
),
|
|
23
|
+
expectedColors: [DARK.accent, DARK.accentInk, DARK.textDim],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Disabled: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<Button isDisabled onPress={() => {}}>
|
|
29
|
+
{" Edit "}
|
|
30
|
+
</Button>
|
|
31
|
+
),
|
|
32
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word-button: a pressable label. `onPress` unifies mouse and (future)
|
|
3
|
+
* keyboard activation; `isDisabled` swallows presses without changing the
|
|
4
|
+
* rendered label, so read-only affordances keep their place in the layout.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { Theme } from "../../theme";
|
|
9
|
+
import { useComponentTheme } from "../theme-context";
|
|
10
|
+
|
|
11
|
+
export interface ButtonProps {
|
|
12
|
+
/** Unified activation: mouse release today, keyboard activation tomorrow. */
|
|
13
|
+
onPress: () => void;
|
|
14
|
+
isDisabled?: boolean;
|
|
15
|
+
/** solid = accent-filled call to action; plain = quiet word-button. */
|
|
16
|
+
variant?: "solid" | "plain" | "accent-text";
|
|
17
|
+
marginRight?: number;
|
|
18
|
+
theme?: Theme;
|
|
19
|
+
children: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Button({
|
|
23
|
+
onPress,
|
|
24
|
+
isDisabled = false,
|
|
25
|
+
variant = "plain",
|
|
26
|
+
marginRight,
|
|
27
|
+
theme,
|
|
28
|
+
children,
|
|
29
|
+
}: ButtonProps): React.ReactNode {
|
|
30
|
+
const tokens = useComponentTheme(theme);
|
|
31
|
+
const backgroundColor = variant === "solid" ? tokens.accent : undefined;
|
|
32
|
+
const foreground =
|
|
33
|
+
variant === "solid" ? tokens.accentInk : variant === "accent-text" ? tokens.accent : tokens.textDim;
|
|
34
|
+
return (
|
|
35
|
+
<box
|
|
36
|
+
style={{ backgroundColor, marginRight }}
|
|
37
|
+
onMouseUp={() => {
|
|
38
|
+
if (!isDisabled) onPress();
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
<text fg={foreground}>{children}</text>
|
|
42
|
+
</box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Card } from "./Card";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "primitives/Card" };
|
|
7
|
+
|
|
8
|
+
export const Titled: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Card title=" a titled card " contentRows={2}>
|
|
11
|
+
<text>first content row</text>
|
|
12
|
+
<text>second content row</text>
|
|
13
|
+
</Card>
|
|
14
|
+
),
|
|
15
|
+
expectedColors: [DARK.border, DARK.elevated],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const AccentBorder: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<Card title=" accented " contentRows={1} borderColor={DARK.accent}>
|
|
21
|
+
<text>content</text>
|
|
22
|
+
</Card>
|
|
23
|
+
),
|
|
24
|
+
expectedColors: [DARK.accent],
|
|
25
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bordered rounded box with an optional title. The card owns the
|
|
3
|
+
* height-derivation rule: its height is always the declared content row
|
|
4
|
+
* count plus the two border rows, so layout math and the mounted box can
|
|
5
|
+
* never drift apart - border-collapse bugs die here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { Theme } from "../../theme";
|
|
10
|
+
import { useComponentTheme } from "../theme-context";
|
|
11
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
12
|
+
|
|
13
|
+
export interface CardProps {
|
|
14
|
+
title?: string;
|
|
15
|
+
/** Number of content rows inside the border; the box adds the two border rows. */
|
|
16
|
+
contentRows: number;
|
|
17
|
+
borderColor?: string;
|
|
18
|
+
backgroundColor?: string;
|
|
19
|
+
marginLeft?: number;
|
|
20
|
+
marginRight?: number;
|
|
21
|
+
theme?: Theme;
|
|
22
|
+
children: React.ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The one height rule: content rows plus the top and bottom border rows. */
|
|
26
|
+
export function cardHeight(contentRows: number): number {
|
|
27
|
+
return contentRows + 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Card({
|
|
31
|
+
title,
|
|
32
|
+
contentRows,
|
|
33
|
+
borderColor,
|
|
34
|
+
backgroundColor,
|
|
35
|
+
marginLeft,
|
|
36
|
+
marginRight,
|
|
37
|
+
theme,
|
|
38
|
+
children,
|
|
39
|
+
}: CardProps): React.ReactNode {
|
|
40
|
+
const tokens = useComponentTheme(theme);
|
|
41
|
+
return (
|
|
42
|
+
<box
|
|
43
|
+
style={{
|
|
44
|
+
height: cardHeight(contentRows),
|
|
45
|
+
marginLeft,
|
|
46
|
+
marginRight,
|
|
47
|
+
border: true,
|
|
48
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
49
|
+
borderColor: borderColor ?? tokens.border,
|
|
50
|
+
backgroundColor: backgroundColor ?? tokens.elevated,
|
|
51
|
+
flexDirection: "column",
|
|
52
|
+
paddingLeft: 1,
|
|
53
|
+
}}
|
|
54
|
+
title={title}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</box>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Dialog } from "./Dialog";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "primitives/Dialog" };
|
|
7
|
+
|
|
8
|
+
export const Open: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Dialog isOpen title=" a modal " width={40} height={8}>
|
|
11
|
+
<text>dialog body content</text>
|
|
12
|
+
</Dialog>
|
|
13
|
+
),
|
|
14
|
+
expectedColors: [DARK.accent, DARK.backdrop],
|
|
15
|
+
size: { width: 60, height: 16 },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const Closed: Story = {
|
|
19
|
+
render: () => (
|
|
20
|
+
<Dialog isOpen={false} title=" hidden " width={40} height={8}>
|
|
21
|
+
<text>never rendered</text>
|
|
22
|
+
</Dialog>
|
|
23
|
+
),
|
|
24
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centered modal over a dimmed backdrop. The dialog fills the terminal with
|
|
3
|
+
* the backdrop token and centers a bordered panel; content composes inside.
|
|
4
|
+
* `isOpen` keeps the mount decision with the caller-visible prop vocabulary.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
import type { Theme } from "../../theme";
|
|
9
|
+
import { useComponentTheme } from "../theme-context";
|
|
10
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
11
|
+
|
|
12
|
+
export interface DialogProps {
|
|
13
|
+
isOpen: boolean;
|
|
14
|
+
title?: string;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
theme?: Theme;
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Dialog({ isOpen, title, width, height, theme, children }: DialogProps): React.ReactNode {
|
|
22
|
+
const tokens = useComponentTheme(theme);
|
|
23
|
+
if (!isOpen) return null;
|
|
24
|
+
return (
|
|
25
|
+
<box
|
|
26
|
+
style={{
|
|
27
|
+
position: "absolute",
|
|
28
|
+
left: 0,
|
|
29
|
+
top: 0,
|
|
30
|
+
width: "100%",
|
|
31
|
+
height: "100%",
|
|
32
|
+
backgroundColor: tokens.backdrop,
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
justifyContent: "center",
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<box
|
|
38
|
+
style={{
|
|
39
|
+
width,
|
|
40
|
+
height,
|
|
41
|
+
border: true,
|
|
42
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
43
|
+
borderColor: tokens.accent,
|
|
44
|
+
backgroundColor: tokens.panel,
|
|
45
|
+
flexDirection: "column",
|
|
46
|
+
}}
|
|
47
|
+
title={title}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</box>
|
|
51
|
+
</box>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { StatusBar } from "./StatusBar";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "primitives/StatusBar" };
|
|
7
|
+
|
|
8
|
+
export const HintLine: Story = {
|
|
9
|
+
render: () => <StatusBar>j/k move · v span · c comment · q quit</StatusBar>,
|
|
10
|
+
expectedColors: [DARK.panel, DARK.textDim],
|
|
11
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-row panel bar for the status/hint line. Content renders as dim text;
|
|
3
|
+
* callers pass richer spans as children when a segment needs another tone.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import type { Theme } from "../../theme";
|
|
8
|
+
import { useComponentTheme } from "../theme-context";
|
|
9
|
+
|
|
10
|
+
export interface StatusBarProps {
|
|
11
|
+
theme?: Theme;
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function StatusBar({ theme, children }: StatusBarProps): React.ReactNode {
|
|
16
|
+
const tokens = useComponentTheme(theme);
|
|
17
|
+
return (
|
|
18
|
+
<box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1 }}>
|
|
19
|
+
<text fg={tokens.textDim}>{children}</text>
|
|
20
|
+
</box>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Tab, TabList, Tabs } from "./Tabs";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "primitives/Tabs" };
|
|
7
|
+
|
|
8
|
+
export const TwoTabs: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Tabs selectedKey="review" onSelectionChange={() => {}}>
|
|
11
|
+
<TabList>
|
|
12
|
+
<Tab id="review">Review (2)</Tab>
|
|
13
|
+
<Tab id="agent">Agent</Tab>
|
|
14
|
+
</TabList>
|
|
15
|
+
</Tabs>
|
|
16
|
+
),
|
|
17
|
+
expectedColors: [DARK.accent, DARK.textDim],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const SecondSelected: Story = {
|
|
21
|
+
render: () => (
|
|
22
|
+
<Tabs selectedKey="agent" onSelectionChange={() => {}}>
|
|
23
|
+
<TabList>
|
|
24
|
+
<Tab id="review">Review (0)</Tab>
|
|
25
|
+
<Tab id="agent">Agent</Tab>
|
|
26
|
+
</TabList>
|
|
27
|
+
</Tabs>
|
|
28
|
+
),
|
|
29
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs / TabList / Tab over the native tab-select renderable. The composition
|
|
3
|
+
* follows the ARIA tabs pattern: Tabs owns the controlled selection
|
|
4
|
+
* (`selectedKey` + `onSelectionChange`), TabList renders the strip, Tab
|
|
5
|
+
* declares one item. Selection stays controlled from outside so the keyboard
|
|
6
|
+
* grammar (not the strip) remains the key owner; clicks map through the
|
|
7
|
+
* native tab geometry.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { createContext, useContext, useEffect, useRef } from "react";
|
|
11
|
+
import type { MouseEvent, TabSelectRenderable } from "@opentui/core";
|
|
12
|
+
import type { Theme } from "../../theme";
|
|
13
|
+
import { useComponentTheme } from "../theme-context";
|
|
14
|
+
|
|
15
|
+
interface TabsContextValue {
|
|
16
|
+
selectedKey: string;
|
|
17
|
+
onSelectionChange: (key: string) => void;
|
|
18
|
+
/** Text color of the selected tab; defaults to the accent token. */
|
|
19
|
+
selectedColor?: string;
|
|
20
|
+
theme?: Theme;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
24
|
+
|
|
25
|
+
export interface TabsProps {
|
|
26
|
+
selectedKey: string;
|
|
27
|
+
onSelectionChange: (key: string) => void;
|
|
28
|
+
selectedColor?: string;
|
|
29
|
+
theme?: Theme;
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Tabs({ selectedKey, onSelectionChange, selectedColor, theme, children }: TabsProps): React.ReactNode {
|
|
34
|
+
return (
|
|
35
|
+
<TabsContext.Provider value={{ selectedKey, onSelectionChange, selectedColor, theme }}>
|
|
36
|
+
{children}
|
|
37
|
+
</TabsContext.Provider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TabProps {
|
|
42
|
+
id: string;
|
|
43
|
+
children: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Declarative item: TabList collects Tab children into the native strip. */
|
|
47
|
+
export function Tab(_props: TabProps): React.ReactNode {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TabListProps {
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function TabList({ children }: TabListProps): React.ReactNode {
|
|
56
|
+
const tabs = useContext(TabsContext);
|
|
57
|
+
if (!tabs) throw new Error("TabList must render inside Tabs");
|
|
58
|
+
const tokens = useComponentTheme(tabs.theme);
|
|
59
|
+
const items: { id: string; label: string }[] = [];
|
|
60
|
+
React.Children.forEach(children, (child) => {
|
|
61
|
+
if (React.isValidElement<TabProps>(child) && typeof child.props.id === "string") {
|
|
62
|
+
items.push({ id: child.props.id, label: child.props.children });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const tabWidth = Math.max(...items.map((item) => item.label.length)) + 2;
|
|
66
|
+
const selectedIndex = Math.max(
|
|
67
|
+
0,
|
|
68
|
+
items.findIndex((item) => item.id === tabs.selectedKey),
|
|
69
|
+
);
|
|
70
|
+
const stripRef = useRef<TabSelectRenderable | null>(null);
|
|
71
|
+
// selection is controlled: mirror the selected key into the native strip
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
stripRef.current?.setSelectedIndex(selectedIndex);
|
|
74
|
+
}, [selectedIndex, items.length]);
|
|
75
|
+
const onMouseUp = (event: MouseEvent): void => {
|
|
76
|
+
const strip = stripRef.current;
|
|
77
|
+
if (!strip) return;
|
|
78
|
+
const index = Math.floor((event.x - strip.x) / tabWidth);
|
|
79
|
+
const item = items[index];
|
|
80
|
+
if (item) tabs.onSelectionChange(item.id);
|
|
81
|
+
};
|
|
82
|
+
return (
|
|
83
|
+
<tab-select
|
|
84
|
+
ref={stripRef}
|
|
85
|
+
options={items.map((item) => ({ name: item.label, description: "", value: item.id }))}
|
|
86
|
+
tabWidth={tabWidth}
|
|
87
|
+
showUnderline={false}
|
|
88
|
+
showDescription={false}
|
|
89
|
+
showScrollArrows={false}
|
|
90
|
+
textColor={tokens.textDim}
|
|
91
|
+
selectedTextColor={tabs.selectedColor ?? tokens.accent}
|
|
92
|
+
selectedBackgroundColor={tokens.elevated}
|
|
93
|
+
onMouseUp={onMouseUp}
|
|
94
|
+
style={{ height: 1, width: tabWidth * items.length }}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Story, StoryMeta } from "../story";
|
|
3
|
+
import { Button } from "./Button";
|
|
4
|
+
import { Toolbar } from "./Toolbar";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "primitives/Toolbar" };
|
|
7
|
+
|
|
8
|
+
export const ActionRow: Story = {
|
|
9
|
+
render: () => (
|
|
10
|
+
<Toolbar>
|
|
11
|
+
<Button variant="solid" marginRight={2} onPress={() => {}}>
|
|
12
|
+
{" Submit "}
|
|
13
|
+
</Button>
|
|
14
|
+
<Button onPress={() => {}}>{" Cancel "}</Button>
|
|
15
|
+
</Toolbar>
|
|
16
|
+
),
|
|
17
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Horizontal action row: children flow left to right on one row. The classic
|
|
3
|
+
* home of Button groups (Save/Cancel, Submit/Cancel).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
export interface ToolbarProps {
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Toolbar({ children }: ToolbarProps): React.ReactNode {
|
|
13
|
+
return <box style={{ flexDirection: "row", height: 1 }}>{children}</box>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locks the single frame border rule: every bordered frame reads this token,
|
|
3
|
+
* so a regression to square corners (or any other style) fails here first,
|
|
4
|
+
* before the slower VRT snapshots catch it. Decision: rounded (issue #116).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
import { FRAME_BORDER_STYLE } from "./frame";
|
|
9
|
+
|
|
10
|
+
test("frames use the rounded border style", () => {
|
|
11
|
+
expect(FRAME_BORDER_STYLE).toBe("rounded");
|
|
12
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one frame border rule for the design system. Every bordered frame -
|
|
3
|
+
* cards, dialogs, and the stories gallery chrome - reads its corner style
|
|
4
|
+
* from FRAME_BORDER_STYLE, so the rounded-corner decision lives in a single
|
|
5
|
+
* place and can never drift between frames. Buttons stay text-first and
|
|
6
|
+
* borderless: the frame they sit in carries the border, not the button.
|
|
7
|
+
*
|
|
8
|
+
* Decision: rounded on the frames, not on the buttons (issue #116).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BorderStyle } from "@opentui/core";
|
|
12
|
+
|
|
13
|
+
/** Corner style shared by every bordered frame in the app. */
|
|
14
|
+
export const FRAME_BORDER_STYLE: BorderStyle = "rounded";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive stories catalog (`bun run stories`): a left list of
|
|
3
|
+
* component modules (j/k), h/l cycling through the module's stories, and the
|
|
4
|
+
* selected story rendered live in the preview pane. The same story objects
|
|
5
|
+
* back the snapshot harness - what this TUI shows is what the tests assert.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, { useMemo, useState } from "react";
|
|
9
|
+
import { createCliRenderer } from "@opentui/core";
|
|
10
|
+
import { createRoot, useKeyboard } from "@opentui/react";
|
|
11
|
+
import { DARK } from "../theme";
|
|
12
|
+
import { ThemeProvider } from "./theme-context";
|
|
13
|
+
import { loadStories, type LoadedStory } from "./story";
|
|
14
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
15
|
+
|
|
16
|
+
function StoriesApp({ stories, onExit }: { stories: LoadedStory[]; onExit: () => void }): React.ReactNode {
|
|
17
|
+
const moduleTitles = useMemo(() => [...new Set(stories.map((story) => story.moduleTitle))], [stories]);
|
|
18
|
+
const [moduleIndex, setModuleIndex] = useState(0);
|
|
19
|
+
const [storyIndex, setStoryIndex] = useState(0);
|
|
20
|
+
const moduleTitle = moduleTitles[moduleIndex]!;
|
|
21
|
+
const moduleStories = stories.filter((story) => story.moduleTitle === moduleTitle);
|
|
22
|
+
const current = moduleStories[Math.min(storyIndex, moduleStories.length - 1)]!;
|
|
23
|
+
|
|
24
|
+
useKeyboard((key) => {
|
|
25
|
+
if (key.name === "q") onExit();
|
|
26
|
+
else if (key.name === "j" || key.name === "down") {
|
|
27
|
+
setModuleIndex((index) => Math.min(moduleTitles.length - 1, index + 1));
|
|
28
|
+
setStoryIndex(0);
|
|
29
|
+
} else if (key.name === "k" || key.name === "up") {
|
|
30
|
+
setModuleIndex((index) => Math.max(0, index - 1));
|
|
31
|
+
setStoryIndex(0);
|
|
32
|
+
} else if (key.name === "l" || key.name === "right") {
|
|
33
|
+
setStoryIndex((index) => (index + 1) % moduleStories.length);
|
|
34
|
+
} else if (key.name === "h" || key.name === "left") {
|
|
35
|
+
setStoryIndex((index) => (index - 1 + moduleStories.length) % moduleStories.length);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<ThemeProvider theme={DARK}>
|
|
41
|
+
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: DARK.bg }}>
|
|
42
|
+
<box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
|
|
43
|
+
<text fg={DARK.text}>
|
|
44
|
+
<span fg={DARK.accent}>cueloop stories</span>
|
|
45
|
+
<span fg={DARK.textDim}>
|
|
46
|
+
{" "}· {moduleTitle} / {current.storyName}
|
|
47
|
+
</span>
|
|
48
|
+
</text>
|
|
49
|
+
</box>
|
|
50
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
51
|
+
<box style={{ width: 28, backgroundColor: DARK.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
|
|
52
|
+
{moduleTitles.map((title, index) => (
|
|
53
|
+
<text key={title} fg={index === moduleIndex ? DARK.accent : DARK.textMuted}>
|
|
54
|
+
{index === moduleIndex ? "▸ " : " "}
|
|
55
|
+
{title}
|
|
56
|
+
</text>
|
|
57
|
+
))}
|
|
58
|
+
</box>
|
|
59
|
+
<box
|
|
60
|
+
style={{
|
|
61
|
+
flexGrow: 1,
|
|
62
|
+
flexDirection: "column",
|
|
63
|
+
border: true,
|
|
64
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
65
|
+
borderColor: DARK.border,
|
|
66
|
+
}}
|
|
67
|
+
title={` ${current.storyName} (${moduleStories.indexOf(current) + 1}/${moduleStories.length}) `}
|
|
68
|
+
>
|
|
69
|
+
{/* remount per story so component-local state never leaks across */}
|
|
70
|
+
<box key={`${moduleTitle}/${current.storyName}`} style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
71
|
+
{current.story.render()}
|
|
72
|
+
</box>
|
|
73
|
+
</box>
|
|
74
|
+
</box>
|
|
75
|
+
<box style={{ height: 1, backgroundColor: DARK.panel, paddingLeft: 1 }}>
|
|
76
|
+
<text fg={DARK.textDim}>j/k component · h/l story · q quit</text>
|
|
77
|
+
</box>
|
|
78
|
+
</box>
|
|
79
|
+
</ThemeProvider>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (import.meta.main) {
|
|
84
|
+
const stories = await loadStories();
|
|
85
|
+
const renderer = await createCliRenderer();
|
|
86
|
+
createRoot(renderer).render(
|
|
87
|
+
<StoriesApp
|
|
88
|
+
stories={stories}
|
|
89
|
+
onExit={() => {
|
|
90
|
+
renderer.destroy();
|
|
91
|
+
queueMicrotask(() => process.exit(0));
|
|
92
|
+
}}
|
|
93
|
+
/>,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The catalog IS the regression suite: every exported story renders in
|
|
3
|
+
* a virtual terminal, its char frame snapshots, and its declared colors must
|
|
4
|
+
* appear among the styled spans. A component file without a colocated
|
|
5
|
+
* stories file fails the harness, so the safety net cannot silently thin.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import { RGBA } from "@opentui/core";
|
|
11
|
+
import { componentFilesMissingStories, loadStories } from "./story";
|
|
12
|
+
|
|
13
|
+
const stories = await loadStories();
|
|
14
|
+
|
|
15
|
+
function hexOf(color: RGBA): string {
|
|
16
|
+
const [red, green, blue] = color.toInts();
|
|
17
|
+
return "#" + [red, green, blue].map((part) => part!.toString(16).padStart(2, "0")).join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("stories catalog", () => {
|
|
21
|
+
test("every component file ships stories", () => {
|
|
22
|
+
expect(componentFilesMissingStories()).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("the catalog is not empty", () => {
|
|
26
|
+
expect(stories.length).toBeGreaterThan(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for (const { moduleTitle, storyName, story } of stories) {
|
|
30
|
+
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
31
|
+
// Arrange
|
|
32
|
+
const size = story.size ?? { width: 80, height: 24 };
|
|
33
|
+
|
|
34
|
+
// Act
|
|
35
|
+
const setup = await testRender(story.render(), size);
|
|
36
|
+
await setup.waitForVisualIdle();
|
|
37
|
+
const frame = setup.captureCharFrame();
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
41
|
+
if (story.expectedColors?.length) {
|
|
42
|
+
const seenColors = new Set<string>();
|
|
43
|
+
for (const line of setup.captureSpans().lines) {
|
|
44
|
+
for (const span of line.spans) {
|
|
45
|
+
seenColors.add(hexOf(span.fg));
|
|
46
|
+
seenColors.add(hexOf(span.bg));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const expectedColor of story.expectedColors) {
|
|
50
|
+
expect(seenColors).toContain(expectedColor.toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
setup.renderer.destroy();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|