@calliopelabs/ui 0.7.0 → 0.11.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 +253 -3
- package/dist/appearance/AppearancePicker.js +4 -2
- package/dist/appearance/AppearanceProvider.d.ts +10 -2
- package/dist/appearance/AppearanceProvider.js +6 -2
- package/dist/appearance/AppearanceScript.d.ts +15 -4
- package/dist/appearance/AppearanceScript.js +11 -5
- package/dist/appearance/appearance.js +1 -1
- package/dist/appearance/boot.d.ts +13 -2
- package/dist/appearance/boot.js +35 -5
- package/dist/appearance/product.d.ts +13 -0
- package/dist/appearance/product.js +2 -2
- package/dist/appearance/runtime-brand.d.ts +106 -0
- package/dist/appearance/runtime-brand.js +420 -0
- package/dist/css/appearance.js +147 -0
- package/dist/css/calliope.css +1221 -0
- package/dist/css/components.css +729 -0
- package/dist/css/tokens.css +491 -0
- package/dist/data/DefinitionList.d.ts +16 -0
- package/dist/data/DefinitionList.js +9 -0
- package/dist/data/Stat.d.ts +25 -0
- package/dist/data/Stat.js +19 -0
- package/dist/data/Tag.d.ts +21 -0
- package/dist/data/Tag.js +18 -0
- package/dist/data/types.d.ts +3 -2
- package/dist/data/use-local-table.d.ts +36 -0
- package/dist/data/use-local-table.js +127 -0
- package/dist/index.d.ts +42 -3
- package/dist/index.js +39 -1
- package/dist/nav/Nav.d.ts +2 -1
- package/dist/nav/Nav.js +34 -15
- package/dist/nav/model.d.ts +21 -0
- package/dist/page/PageHeader.d.ts +5 -3
- package/dist/page/PageHeader.js +5 -4
- package/dist/page/Panel.d.ts +19 -0
- package/dist/page/Panel.js +12 -0
- package/dist/primitives/Avatar.d.ts +37 -0
- package/dist/primitives/Avatar.js +43 -0
- package/dist/primitives/Field.d.ts +33 -0
- package/dist/primitives/Field.js +30 -0
- package/dist/primitives/Segmented.d.ts +30 -0
- package/dist/primitives/Segmented.js +20 -0
- package/dist/primitives/Select.d.ts +40 -0
- package/dist/primitives/Select.js +47 -0
- package/dist/primitives/Switch.d.ts +7 -0
- package/dist/primitives/Switch.js +8 -0
- package/dist/primitives/Textarea.d.ts +3 -0
- package/dist/primitives/Textarea.js +6 -0
- package/dist/provider/messages.d.ts +412 -0
- package/dist/provider/messages.js +633 -0
- package/dist/shell/BrandMark.d.ts +25 -3
- package/dist/shell/BrandMark.js +34 -4
- package/dist/shell/RailPanel.js +2 -2
- package/dist/shell/RailTree.js +2 -2
- package/dist/shell/Sidebar.d.ts +9 -2
- package/dist/shell/Sidebar.js +10 -4
- package/dist/shell/UserMenu.d.ts +3 -1
- package/dist/shell/UserMenu.js +3 -11
- package/dist/shell/rail-boot.d.ts +28 -0
- package/dist/shell/rail-boot.js +45 -0
- package/dist/shell/rail-state.d.ts +6 -1
- package/dist/shell/rail-state.js +18 -2
- package/dist/styles/preset.css +4 -0
- package/dist/styles/theme.css +36 -13
- package/dist/theme/css-layer.d.ts +19 -0
- package/dist/theme/css-layer.js +171 -0
- package/dist/theme/css.d.ts +1 -0
- package/dist/theme/css.js +45 -9
- package/dist/workflow/ArtifactCard.d.ts +47 -0
- package/dist/workflow/ArtifactCard.js +45 -0
- package/dist/workflow/DocumentReview.d.ts +74 -0
- package/dist/workflow/DocumentReview.js +54 -0
- package/dist/workflow/FormReview.d.ts +53 -0
- package/dist/workflow/FormReview.js +83 -0
- package/dist/workflow/GateBadge.d.ts +11 -0
- package/dist/workflow/GateBadge.js +14 -0
- package/dist/workflow/MediaReview.d.ts +63 -0
- package/dist/workflow/MediaReview.js +166 -0
- package/dist/workflow/ReviewPanel.d.ts +41 -0
- package/dist/workflow/ReviewPanel.js +59 -0
- package/dist/workflow/RunTimeline.d.ts +25 -0
- package/dist/workflow/RunTimeline.js +56 -0
- package/dist/workflow/WorkflowCard.d.ts +39 -0
- package/dist/workflow/WorkflowCard.js +56 -0
- package/dist/workflow/WorkflowGallery.d.ts +43 -0
- package/dist/workflow/WorkflowGallery.js +69 -0
- package/dist/workflow/canvas/WorkflowCanvas.d.ts +39 -0
- package/dist/workflow/canvas/WorkflowCanvas.js +648 -0
- package/dist/workflow/definition.d.ts +408 -0
- package/dist/workflow/definition.js +411 -0
- package/dist/workflow/editor/WorkflowDiff.d.ts +17 -0
- package/dist/workflow/editor/WorkflowDiff.js +25 -0
- package/dist/workflow/editor/WorkflowImportPreview.d.ts +19 -0
- package/dist/workflow/editor/WorkflowImportPreview.js +50 -0
- package/dist/workflow/editor/WorkflowInspector.d.ts +40 -0
- package/dist/workflow/editor/WorkflowInspector.js +349 -0
- package/dist/workflow/editor/WorkflowJsonView.d.ts +16 -0
- package/dist/workflow/editor/WorkflowJsonView.js +40 -0
- package/dist/workflow/editor/WorkflowPalette.d.ts +16 -0
- package/dist/workflow/editor/WorkflowPalette.js +36 -0
- package/dist/workflow/editor/WorkflowProblems.d.ts +20 -0
- package/dist/workflow/editor/WorkflowProblems.js +51 -0
- package/dist/workflow/editor/WorkflowWorkspace.d.ts +49 -0
- package/dist/workflow/editor/WorkflowWorkspace.js +154 -0
- package/dist/workflow/gallery.d.ts +21 -0
- package/dist/workflow/gallery.js +29 -0
- package/dist/workflow/layout.d.ts +49 -0
- package/dist/workflow/layout.js +162 -0
- package/dist/workflow/model.d.ts +50 -0
- package/dist/workflow/model.js +7 -0
- package/dist/workflow/palette.d.ts +34 -0
- package/dist/workflow/palette.js +70 -0
- package/dist/workflow/portable.d.ts +162 -0
- package/dist/workflow/portable.js +547 -0
- package/dist/workflow/problems.d.ts +38 -0
- package/dist/workflow/problems.js +409 -0
- package/dist/workflow/run.d.ts +94 -0
- package/dist/workflow/run.js +31 -0
- package/dist/workflow/workspace.d.ts +33 -0
- package/dist/workflow/workspace.js +17 -0
- package/package.json +6 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Gate } from "./run.js";
|
|
2
|
+
export interface GateBadgeProps {
|
|
3
|
+
gate: Pick<Gate, "label" | "status" | "approvals" | "required">;
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* A gate in one pill: what it asks, where it stands, and the quorum so far.
|
|
8
|
+
* The tone is the gate's status: waiting is a warning, approved ok, rejected
|
|
9
|
+
* an error, expired and skipped muted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function GateBadge({ gate, className }: GateBadgeProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
4
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
5
|
+
import { GATE_TONE } from "./run.js";
|
|
6
|
+
/**
|
|
7
|
+
* A gate in one pill: what it asks, where it stands, and the quorum so far.
|
|
8
|
+
* The tone is the gate's status: waiting is a warning, approved ok, rejected
|
|
9
|
+
* an error, expired and skipped muted.
|
|
10
|
+
*/
|
|
11
|
+
export function GateBadge({ gate, className }) {
|
|
12
|
+
const { messages } = useUI();
|
|
13
|
+
return (_jsx(StatusBadge, { tone: GATE_TONE[gate.status], "data-slot": "gate-badge", className: className, children: messages.run.gate(gate.label, gate.status, gate.approvals, gate.required) }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/** A rectangle on an image or a frame, as fractions of its width and height (0 to 1). */
|
|
3
|
+
export interface MediaRegion {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
/** Where a media comment points: a moment, a region, or both (a region of a frame). */
|
|
10
|
+
export interface MediaAnchor {
|
|
11
|
+
/** Seconds from the start. */
|
|
12
|
+
time?: number;
|
|
13
|
+
region?: MediaRegion;
|
|
14
|
+
}
|
|
15
|
+
/** A comment pinned to the media. */
|
|
16
|
+
export interface MediaComment extends MediaAnchor {
|
|
17
|
+
id: string;
|
|
18
|
+
author: string;
|
|
19
|
+
body: string;
|
|
20
|
+
resolved?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface MediaCaptionTrack {
|
|
23
|
+
src: string;
|
|
24
|
+
srcLang: string;
|
|
25
|
+
label: string;
|
|
26
|
+
}
|
|
27
|
+
export interface MediaReviewProps {
|
|
28
|
+
kind: "video" | "audio" | "image";
|
|
29
|
+
src: string;
|
|
30
|
+
/** Names the media: an image's alternative text, a player's label. */
|
|
31
|
+
title: string;
|
|
32
|
+
/** A video's still before it plays. */
|
|
33
|
+
poster?: string;
|
|
34
|
+
captions?: readonly MediaCaptionTrack[];
|
|
35
|
+
/** Seconds, for placing markers before the media's metadata loads. */
|
|
36
|
+
duration?: number;
|
|
37
|
+
comments: readonly MediaComment[];
|
|
38
|
+
activeCommentId?: string;
|
|
39
|
+
/** A marker was chosen: the viewer seeks to it; the host shows the comment. */
|
|
40
|
+
onCommentSelect?: (comment: MediaComment) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The person picked where their next comment points: the current time of a
|
|
43
|
+
* player, or a region drawn on an image. The host labels it (see
|
|
44
|
+
* `formatTimecode`) and hands it to ReviewPanel as `commentAnchor`.
|
|
45
|
+
*/
|
|
46
|
+
onAnchor?: (anchor: MediaAnchor) => void;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
/** Seconds as a timecode: "00:01:12". */
|
|
50
|
+
export declare function formatTimecode(seconds: number): string;
|
|
51
|
+
/**
|
|
52
|
+
* Media review for the media module: a video, audio or image with comments
|
|
53
|
+
* pinned to timecodes (markers on a rail under the player) or to regions
|
|
54
|
+
* (boxes over the image). Choosing a marker seeks the player and tells the
|
|
55
|
+
* host; "comment here" hands the host the current time, and on an image
|
|
56
|
+
* drawing a box hands it the region. "Mark a region" is the keyboard path on
|
|
57
|
+
* an image or a video frame: it drops a box that the arrow keys move and
|
|
58
|
+
* Shift with the arrows size, Enter hands the host the region (with the
|
|
59
|
+
* current time on a video) and Escape cancels. Plugs into ArtifactCard's
|
|
60
|
+
* preview and feeds ReviewPanel's anchors. The host supplies the media URL; the viewer
|
|
61
|
+
* fetches nothing itself beyond what the browser's media element does.
|
|
62
|
+
*/
|
|
63
|
+
export declare function MediaReview({ kind, src, title, poster, captions, duration: durationProp, comments, activeCommentId, onCommentSelect, onAnchor, className, }: MediaReviewProps): React.JSX.Element;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { CrosshairIcon, MessageSquarePlusIcon, SquareDashedIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Button } from "../primitives/Button.js";
|
|
7
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
8
|
+
/** Seconds as a timecode: "00:01:12". */
|
|
9
|
+
export function formatTimecode(seconds) {
|
|
10
|
+
const s = Math.max(0, Math.floor(seconds));
|
|
11
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
12
|
+
return `${pad(Math.floor(s / 3600))}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;
|
|
13
|
+
}
|
|
14
|
+
const percent = (fraction) => `${Math.min(100, Math.max(0, fraction * 100))}%`;
|
|
15
|
+
/** Where the keyboard box starts, and how far one arrow press moves or sizes it. */
|
|
16
|
+
const START = { x: 0.35, y: 0.35, width: 0.3, height: 0.3 };
|
|
17
|
+
const STEP = 0.05;
|
|
18
|
+
/**
|
|
19
|
+
* One arrow press on the keyboard box: move it, or with Shift size it, never
|
|
20
|
+
* past the picture's edges or smaller than one step.
|
|
21
|
+
*/
|
|
22
|
+
function nudge(region, key, resize) {
|
|
23
|
+
const dx = key === "ArrowRight" ? STEP : key === "ArrowLeft" ? -STEP : 0;
|
|
24
|
+
const dy = key === "ArrowDown" ? STEP : key === "ArrowUp" ? -STEP : 0;
|
|
25
|
+
if (!dx && !dy)
|
|
26
|
+
return null;
|
|
27
|
+
const clamp = (n, low, high) => Math.round(Math.min(high, Math.max(low, n)) * 100) / 100;
|
|
28
|
+
const { x, y, width, height } = region;
|
|
29
|
+
return resize
|
|
30
|
+
? { x, y, width: clamp(width + dx, STEP, 1 - x), height: clamp(height + dy, STEP, 1 - y) }
|
|
31
|
+
: { x: clamp(x + dx, 0, 1 - width), y: clamp(y + dy, 0, 1 - height), width, height };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Media review for the media module: a video, audio or image with comments
|
|
35
|
+
* pinned to timecodes (markers on a rail under the player) or to regions
|
|
36
|
+
* (boxes over the image). Choosing a marker seeks the player and tells the
|
|
37
|
+
* host; "comment here" hands the host the current time, and on an image
|
|
38
|
+
* drawing a box hands it the region. "Mark a region" is the keyboard path on
|
|
39
|
+
* an image or a video frame: it drops a box that the arrow keys move and
|
|
40
|
+
* Shift with the arrows size, Enter hands the host the region (with the
|
|
41
|
+
* current time on a video) and Escape cancels. Plugs into ArtifactCard's
|
|
42
|
+
* preview and feeds ReviewPanel's anchors. The host supplies the media URL; the viewer
|
|
43
|
+
* fetches nothing itself beyond what the browser's media element does.
|
|
44
|
+
*/
|
|
45
|
+
export function MediaReview({ kind, src, title, poster, captions = [], duration: durationProp, comments, activeCommentId, onCommentSelect, onAnchor, className, }) {
|
|
46
|
+
const { messages } = useUI();
|
|
47
|
+
const t = messages.media;
|
|
48
|
+
const player = React.useRef(null);
|
|
49
|
+
const [loaded, setLoaded] = React.useState(null);
|
|
50
|
+
const duration = loaded ?? durationProp ?? 0;
|
|
51
|
+
const timed = comments.filter((c) => c.time != null);
|
|
52
|
+
const regions = comments.filter((c) => c.region);
|
|
53
|
+
const id = React.useId();
|
|
54
|
+
const [mark, setMark] = React.useState(null);
|
|
55
|
+
const canMark = Boolean(onAnchor) && kind !== "audio";
|
|
56
|
+
// The host took commenting away: the open box goes, and does not come back with it.
|
|
57
|
+
if (mark && !canMark)
|
|
58
|
+
setMark(null);
|
|
59
|
+
const marking = canMark ? mark : null;
|
|
60
|
+
const box = React.useRef(null);
|
|
61
|
+
const markButton = React.useRef(null);
|
|
62
|
+
const wasMarking = React.useRef(false);
|
|
63
|
+
const isMarking = marking != null;
|
|
64
|
+
// Focus follows the box: onto it when it appears, back to "Mark a region" when it goes.
|
|
65
|
+
React.useEffect(() => {
|
|
66
|
+
if (isMarking)
|
|
67
|
+
box.current?.focus();
|
|
68
|
+
else if (wasMarking.current)
|
|
69
|
+
markButton.current?.focus();
|
|
70
|
+
wasMarking.current = isMarking;
|
|
71
|
+
}, [isMarking]);
|
|
72
|
+
function endMark(region) {
|
|
73
|
+
setMark(null);
|
|
74
|
+
if (region) {
|
|
75
|
+
onAnchor?.(kind === "video" ? { time: player.current?.currentTime ?? 0, region } : { region });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function select(comment) {
|
|
79
|
+
if (comment.time != null && player.current) {
|
|
80
|
+
try {
|
|
81
|
+
player.current.currentTime = comment.time;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A media element without metadata refuses a seek; the host still shows the comment.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
onCommentSelect?.(comment);
|
|
88
|
+
}
|
|
89
|
+
const regionButtons = regions.map((c) => (_jsx("button", { type: "button", onClick: () => select(c), "aria-label": t.regionComment(c.author, c.time != null ? formatTimecode(c.time) : undefined), "aria-current": c.id === activeCommentId || undefined, style: {
|
|
90
|
+
left: percent(c.region.x),
|
|
91
|
+
top: percent(c.region.y),
|
|
92
|
+
width: percent(c.region.width),
|
|
93
|
+
height: percent(c.region.height),
|
|
94
|
+
}, className: cn("absolute rounded-sm border-2 border-warning outline-none focus-visible:ring-2 focus-visible:ring-ring", c.id === activeCommentId && "border-primary bg-primary/10", c.resolved && "border-dashed opacity-60") }, c.id)));
|
|
95
|
+
const whole = (fraction) => Math.round(fraction * 100);
|
|
96
|
+
/*
|
|
97
|
+
* The box is an application: a two-dimensional move and resize has no
|
|
98
|
+
* widget role, and the role puts a screen reader in focus mode so the
|
|
99
|
+
* arrow keys reach the box. jsx-a11y counts the role as non-interactive.
|
|
100
|
+
*/
|
|
101
|
+
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
|
|
102
|
+
const regionMarker = marking && (_jsx("div", { ref: box, role: "application", tabIndex: 0, "aria-label": t.regionBox, "aria-describedby": `${id}-help ${id}-bounds`, onKeyDown: (event) => {
|
|
103
|
+
if (event.key === "Enter") {
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
endMark(marking);
|
|
106
|
+
}
|
|
107
|
+
else if (event.key === "Escape") {
|
|
108
|
+
// Cancels the box, not a dialog the review sits in.
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
event.stopPropagation();
|
|
111
|
+
endMark();
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const next = nudge(marking, event.key, event.shiftKey);
|
|
115
|
+
if (next) {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
setMark(next);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}, style: {
|
|
121
|
+
left: percent(marking.x),
|
|
122
|
+
top: percent(marking.y),
|
|
123
|
+
width: percent(marking.width),
|
|
124
|
+
height: percent(marking.height),
|
|
125
|
+
}, className: "pointer-events-auto absolute rounded-sm border-2 border-dashed border-primary bg-primary/10 outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background" }));
|
|
126
|
+
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
|
|
127
|
+
return (_jsxs("figure", { "data-slot": "media-review", "data-kind": kind, className: cn("flex min-w-0 flex-col gap-2", className), children: [_jsx("div", { className: "relative min-w-0 overflow-hidden rounded-lg border bg-muted/40", children: kind === "image" ? (_jsxs(RegionDrawer, { enabled: Boolean(onAnchor) && !marking, onRegion: (region) => onAnchor?.({ region }), children: [_jsx("img", { src: src, alt: title, className: "block h-auto w-full" }), regionButtons, regionMarker] })) : kind === "video" ? (_jsxs("div", { className: "relative", children: [_jsx("video", { ref: player, src: src, poster: poster, controls: true, preload: "metadata", "aria-label": title, onLoadedMetadata: (event) => setLoaded(event.currentTarget.duration || null), className: "block h-auto w-full", children: captions.map((track) => (_jsx("track", { kind: "captions", ...track }, track.src))) }), _jsxs("div", { className: "pointer-events-none absolute inset-0 [&>button]:pointer-events-auto", children: [regionButtons, regionMarker] })] })) : (_jsx("audio", { ref: player, src: src, controls: true, preload: "metadata", "aria-label": title, onLoadedMetadata: (event) => setLoaded(event.currentTarget.duration || null), className: "block w-full p-2", children: captions.map((track) => (_jsx("track", { kind: "captions", ...track }, track.src))) })) }), kind !== "image" && (_jsxs("div", { className: "flex min-w-0 items-center gap-3", children: [_jsx("ol", { "aria-label": t.markers, className: "relative h-6 min-w-0 flex-1 rounded-full bg-muted", children: timed.map((c) => (_jsx("li", { className: "absolute top-1/2 -translate-x-1/2 -translate-y-1/2", style: { left: duration ? percent(c.time / duration) : "0%" }, children: _jsx("button", { type: "button", onClick: () => select(c), "aria-label": t.timeComment(formatTimecode(c.time), c.author), "aria-current": c.id === activeCommentId || undefined, className: cn("block size-3 rounded-full border-2 border-background bg-warning outline-none focus-visible:ring-2 focus-visible:ring-ring", c.id === activeCommentId && "size-4 bg-primary", c.resolved && "bg-muted-foreground") }) }, c.id))) }), onAnchor && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => onAnchor({ time: player.current?.currentTime ?? 0 }), children: [_jsx(MessageSquarePlusIcon, { "aria-hidden": true }), t.commentHere] }))] })), canMark &&
|
|
128
|
+
(marking ? (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2", children: [_jsx("p", { id: `${id}-help`, className: "min-w-0 basis-full text-xs text-muted-foreground", children: t.regionHelp }), _jsx("span", { id: `${id}-bounds`, "aria-live": "polite", className: "min-w-0 flex-1 basis-56 font-mono text-xs tabular-nums", children: t.regionBounds(whole(marking.x), whole(marking.y), whole(marking.width), whole(marking.height)) }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => endMark(), children: t.cancel }), _jsxs(Button, { size: "sm", onClick: () => endMark(marking), children: [_jsx(MessageSquarePlusIcon, { "aria-hidden": true }), t.confirmRegion] })] })] })) : (_jsx("div", { className: "flex min-w-0", children: _jsxs(Button, { ref: markButton, size: "sm", variant: "outline", onClick: () => setMark(START), children: [_jsx(SquareDashedIcon, { "aria-hidden": true }), t.markRegion] }) }))), _jsxs("figcaption", { className: "flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: [_jsx("span", { className: "min-w-0 [overflow-wrap:anywhere]", children: title }), _jsx("span", { children: t.count(comments.length) }), kind === "image" && onAnchor && (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(CrosshairIcon, { "aria-hidden": true, className: "size-3.5" }), t.drawHint] }))] })] }));
|
|
129
|
+
}
|
|
130
|
+
/** Drag on the image to draw a region: the pointer path ("Mark a region" is the keyboard one). */
|
|
131
|
+
function RegionDrawer({ enabled, onRegion, children, }) {
|
|
132
|
+
const [start, setStart] = React.useState(null);
|
|
133
|
+
const [box, setBox] = React.useState(null);
|
|
134
|
+
const at = (event) => {
|
|
135
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
136
|
+
return {
|
|
137
|
+
x: rect.width ? (event.clientX - rect.left) / rect.width : 0,
|
|
138
|
+
y: rect.height ? (event.clientY - rect.top) / rect.height : 0,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
const span = (a, b) => ({
|
|
142
|
+
x: Math.min(a.x, b.x),
|
|
143
|
+
y: Math.min(a.y, b.y),
|
|
144
|
+
width: Math.abs(a.x - b.x),
|
|
145
|
+
height: Math.abs(a.y - b.y),
|
|
146
|
+
});
|
|
147
|
+
return (_jsxs("div", { className: cn("relative", enabled && "cursor-crosshair"), onPointerDown: (event) => {
|
|
148
|
+
if (!enabled || event.target !== event.currentTarget.querySelector("img"))
|
|
149
|
+
return;
|
|
150
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
151
|
+
setStart(at(event));
|
|
152
|
+
}, onPointerMove: (event) => start && setBox(span(start, at(event))), onPointerUp: (event) => {
|
|
153
|
+
if (!start)
|
|
154
|
+
return;
|
|
155
|
+
const region = span(start, at(event));
|
|
156
|
+
setStart(null);
|
|
157
|
+
setBox(null);
|
|
158
|
+
if (region.width > 0.01 && region.height > 0.01)
|
|
159
|
+
onRegion(region);
|
|
160
|
+
}, children: [children, box && (_jsx("span", { "aria-hidden": true, style: {
|
|
161
|
+
left: percent(box.x),
|
|
162
|
+
top: percent(box.y),
|
|
163
|
+
width: percent(box.width),
|
|
164
|
+
height: percent(box.height),
|
|
165
|
+
}, className: "pointer-events-none absolute rounded-sm border-2 border-dashed border-primary bg-primary/10" }))] }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type Gate, type ReviewComment, type ReviewDecision, type ReviewVerdict } from "./run.js";
|
|
3
|
+
export interface ReviewPanelProps {
|
|
4
|
+
/** What is under review, for the heading ("Review: Promo cut v3"). */
|
|
5
|
+
subject?: string;
|
|
6
|
+
verdict: ReviewVerdict;
|
|
7
|
+
/** The gate this review decides, when it decides one. */
|
|
8
|
+
gate?: Gate;
|
|
9
|
+
/** The decision that closed the review, when it is closed. */
|
|
10
|
+
decision?: ReviewDecision;
|
|
11
|
+
comments: readonly ReviewComment[];
|
|
12
|
+
/** Posts a comment. The composer shows only with it. A rejection keeps the draft and shows the error. */
|
|
13
|
+
onComment?: (body: string, anchor?: string) => Promise<unknown> | unknown;
|
|
14
|
+
/**
|
|
15
|
+
* Where the next comment points, picked in a module viewer (a timecode, a
|
|
16
|
+
* region, a clause, a field). Shown on the composer, cleared with `onClearAnchor`.
|
|
17
|
+
*/
|
|
18
|
+
commentAnchor?: string;
|
|
19
|
+
onClearAnchor?: () => void;
|
|
20
|
+
/** Makes each comment's anchor a button, so the viewer can go to it. */
|
|
21
|
+
onCommentSelect?: (comment: ReviewComment) => void;
|
|
22
|
+
activeCommentId?: string;
|
|
23
|
+
/** Approves, after the confirm step. Awaited; see ConfirmDialog. */
|
|
24
|
+
onApprove?: (reason: string) => Promise<unknown> | unknown;
|
|
25
|
+
/** Rejects, after the confirm step, with a required reason. */
|
|
26
|
+
onReject?: (reason: string) => Promise<unknown> | unknown;
|
|
27
|
+
/** Ask for a reason on approval too. */
|
|
28
|
+
approveReasonRequired?: boolean;
|
|
29
|
+
/** Why the person may not decide: the decision buttons show disabled with it. */
|
|
30
|
+
decisionDenied?: string;
|
|
31
|
+
headingLevel?: "h2" | "h3";
|
|
32
|
+
className?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The review of one artifact or stage: its verdict and gate, the comments
|
|
36
|
+
* (each with its anchor in the artifact), a composer, and approve and reject
|
|
37
|
+
* through the confirm flow, a rejection always with a reason. Module viewers
|
|
38
|
+
* sit beside it and feed it anchors; it never decides anything itself, the
|
|
39
|
+
* host's callbacks do.
|
|
40
|
+
*/
|
|
41
|
+
export declare function ReviewPanel({ subject, verdict, gate, decision, comments, onComment, commentAnchor, onClearAnchor, onCommentSelect, activeCommentId, onApprove, onReject, approveReasonRequired, decisionDenied, headingLevel, className, }: ReviewPanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { ConfirmDialog } from "../overlays/ConfirmDialog.js";
|
|
7
|
+
import { Button } from "../primitives/Button.js";
|
|
8
|
+
import { Textarea } from "../primitives/Textarea.js";
|
|
9
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
10
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
11
|
+
import { GateBadge } from "./GateBadge.js";
|
|
12
|
+
import { VERDICT_TONE, } from "./run.js";
|
|
13
|
+
/**
|
|
14
|
+
* The review of one artifact or stage: its verdict and gate, the comments
|
|
15
|
+
* (each with its anchor in the artifact), a composer, and approve and reject
|
|
16
|
+
* through the confirm flow, a rejection always with a reason. Module viewers
|
|
17
|
+
* sit beside it and feed it anchors; it never decides anything itself, the
|
|
18
|
+
* host's callbacks do.
|
|
19
|
+
*/
|
|
20
|
+
export function ReviewPanel({ subject, verdict, gate, decision, comments, onComment, commentAnchor, onClearAnchor, onCommentSelect, activeCommentId, onApprove, onReject, approveReasonRequired = false, decisionDenied, headingLevel = "h2", className, }) {
|
|
21
|
+
const { messages } = useUI();
|
|
22
|
+
const t = messages.review;
|
|
23
|
+
const Heading = headingLevel;
|
|
24
|
+
const SubHeading = headingLevel === "h2" ? "h3" : "h4";
|
|
25
|
+
const id = React.useId();
|
|
26
|
+
const [confirming, setConfirming] = React.useState(null);
|
|
27
|
+
const open = !decision && (!gate || gate.status === "waiting");
|
|
28
|
+
const canDecide = open && Boolean(onApprove || onReject);
|
|
29
|
+
return (_jsxs("section", { "aria-labelledby": `${id}-title`, "data-slot": "review-panel", className: cn("flex min-w-0 flex-col gap-4 rounded-lg border bg-card p-(--panel-pad) text-card-foreground", className), children: [_jsxs("header", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [_jsx(Heading, { id: `${id}-title`, className: "text-sm font-medium [overflow-wrap:anywhere]", children: subject ? t.titleFor(subject) : t.title }), _jsx(StatusBadge, { tone: VERDICT_TONE[verdict], children: t.verdict(verdict) }), gate && _jsx(GateBadge, { gate: gate })] }), decision && (_jsxs("p", { className: "text-sm [overflow-wrap:anywhere] text-muted-foreground", children: [t.decided(decision.verdict, decision.by, decision.at), decision.reason && (_jsxs(_Fragment, { children: [" ", _jsx("span", { className: "text-foreground", children: decision.reason })] }))] })), canDecide && (_jsxs("div", { className: "flex min-w-0 flex-col gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [onApprove && (_jsxs(Button, { size: "sm", disabled: decisionDenied != null, "aria-describedby": decisionDenied != null ? `${id}-denied` : undefined, onClick: () => setConfirming("approve"), children: [_jsx(CheckIcon, { "aria-hidden": true }), t.approve] })), onReject && (_jsxs(Button, { size: "sm", variant: "destructive", disabled: decisionDenied != null, "aria-describedby": decisionDenied != null ? `${id}-denied` : undefined, onClick: () => setConfirming("reject"), children: [_jsx(XIcon, { "aria-hidden": true }), t.reject] }))] }), decisionDenied != null && (_jsx("p", { id: `${id}-denied`, className: "text-xs text-muted-foreground", children: decisionDenied }))] }))] }), _jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsx(SubHeading, { className: "font-mono text-2xs font-medium tracking-wider text-muted-foreground uppercase", children: t.comments(comments.length) }), comments.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t.noComments })) : (_jsx("ol", { className: "flex min-w-0 flex-col gap-2", children: comments.map((comment) => (_jsxs("li", { "aria-current": comment.id === activeCommentId || undefined, className: cn("flex min-w-0 flex-col gap-1 rounded-lg border p-2.5", comment.id === activeCommentId && "border-primary", comment.resolved && "opacity-70"), children: [_jsxs("p", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs", children: [_jsx("span", { className: "font-medium", children: comment.author }), comment.at && _jsx("span", { className: "text-muted-foreground", children: comment.at }), comment.anchor &&
|
|
30
|
+
(onCommentSelect ? (_jsx("button", { type: "button", onClick: () => onCommentSelect(comment), "aria-label": t.goToAnchor(comment.anchor), className: "rounded-sm font-mono text-primary underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", children: comment.anchor })) : (_jsx("span", { className: "font-mono text-muted-foreground", children: comment.anchor }))), comment.resolved && (_jsx(StatusBadge, { tone: "muted", className: "ml-auto", children: t.resolved }))] }), _jsx("p", { className: "text-sm [overflow-wrap:anywhere] whitespace-pre-wrap", children: comment.body })] }, comment.id))) })), onComment && (_jsx(Composer, { onComment: onComment, anchor: commentAnchor, onClearAnchor: onClearAnchor }))] }), _jsx(ConfirmDialog, { open: confirming === "approve", onOpenChange: (next) => !next && setConfirming(null), title: t.approveTitle, description: t.approveDescription, confirmLabel: t.approve, reason: approveReasonRequired ? { label: t.reasonLabel } : undefined, onConfirm: (reason) => onApprove?.(reason) }), _jsx(ConfirmDialog, { open: confirming === "reject", onOpenChange: (next) => !next && setConfirming(null), title: t.rejectTitle, description: t.rejectDescription, confirmLabel: t.reject, destructive: true, reason: { label: t.reasonLabel }, onConfirm: (reason) => onReject?.(reason) })] }));
|
|
31
|
+
}
|
|
32
|
+
function Composer({ onComment, anchor, onClearAnchor, }) {
|
|
33
|
+
const { messages } = useUI();
|
|
34
|
+
const t = messages.review;
|
|
35
|
+
const id = React.useId();
|
|
36
|
+
const [draft, setDraft] = React.useState("");
|
|
37
|
+
const [pending, setPending] = React.useState(false);
|
|
38
|
+
const [error, setError] = React.useState(null);
|
|
39
|
+
async function submit(event) {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
const body = draft.trim();
|
|
42
|
+
if (!body || pending)
|
|
43
|
+
return;
|
|
44
|
+
setPending(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
try {
|
|
47
|
+
const result = await onComment(body, anchor);
|
|
48
|
+
if (result !== false)
|
|
49
|
+
setDraft("");
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
setError(err instanceof Error && err.message ? err.message : messages.confirm.failed);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
setPending(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return (_jsxs("form", { onSubmit: submit, className: "flex min-w-0 flex-col gap-2", children: [_jsx("label", { htmlFor: id, className: "sr-only", children: t.addComment }), anchor && (_jsxs("p", { className: "flex min-w-0 items-center gap-2 text-xs text-muted-foreground", children: [_jsx("span", { className: "[overflow-wrap:anywhere]", children: t.commentOn(anchor) }), onClearAnchor && (_jsx(Button, { type: "button", size: "xs", variant: "ghost", onClick: onClearAnchor, children: t.clearAnchor }))] })), _jsx(Textarea, { id: id, value: draft, rows: 2, disabled: pending, placeholder: t.addComment, "aria-invalid": error != null || undefined, "aria-describedby": error ? `${id}-error` : undefined, onChange: (event) => setDraft(event.target.value) }), error && (_jsx("p", { id: `${id}-error`, role: "alert", className: "text-xs text-danger-fg", children: error })), _jsx(Button, { type: "submit", size: "sm", variant: "outline", disabled: pending || !draft.trim(), className: "self-end", children: pending ? messages.confirm.working : t.post })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type RunStage } from "./run.js";
|
|
3
|
+
export interface RunTimelineProps {
|
|
4
|
+
stages: readonly RunStage[];
|
|
5
|
+
/** Names the timeline. Defaults to "Run stages". */
|
|
6
|
+
label?: string;
|
|
7
|
+
loading?: boolean;
|
|
8
|
+
error?: {
|
|
9
|
+
message: string;
|
|
10
|
+
} | null;
|
|
11
|
+
onRetry?: () => void;
|
|
12
|
+
/** Makes each stage's name a button, for a host that shows the stage beside the timeline. */
|
|
13
|
+
onStageSelect?: (stage: RunStage) => void;
|
|
14
|
+
selectedStageId?: string;
|
|
15
|
+
/** Stages whose inputs and outputs start open. */
|
|
16
|
+
defaultExpanded?: readonly string[];
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A run's stages in order, for any module: each with its status, timing,
|
|
21
|
+
* attempt and retries, a one-line detail, its gate when it is one, and its
|
|
22
|
+
* inputs and outputs summarized behind a disclosure. Process and automation
|
|
23
|
+
* runs read here as they are; other modules put their review surfaces beside it.
|
|
24
|
+
*/
|
|
25
|
+
export declare function RunTimeline({ stages, label, loading, error, onRetry, onStageSelect, selectedStageId, defaultExpanded, className, }: RunTimelineProps): React.JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ChevronRightIcon } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
7
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
8
|
+
import { EmptyState } from "../states/EmptyState.js";
|
|
9
|
+
import { ErrorState } from "../states/ErrorState.js";
|
|
10
|
+
import { StatusDot } from "../status/StatusDot.js";
|
|
11
|
+
import { GateBadge } from "./GateBadge.js";
|
|
12
|
+
import { RUN_STAGE_TONE } from "./run.js";
|
|
13
|
+
/**
|
|
14
|
+
* A run's stages in order, for any module: each with its status, timing,
|
|
15
|
+
* attempt and retries, a one-line detail, its gate when it is one, and its
|
|
16
|
+
* inputs and outputs summarized behind a disclosure. Process and automation
|
|
17
|
+
* runs read here as they are; other modules put their review surfaces beside it.
|
|
18
|
+
*/
|
|
19
|
+
export function RunTimeline({ stages, label, loading = false, error, onRetry, onStageSelect, selectedStageId, defaultExpanded = [], className, }) {
|
|
20
|
+
const { messages } = useUI();
|
|
21
|
+
const t = messages.run;
|
|
22
|
+
const name = label ?? t.timeline;
|
|
23
|
+
const id = React.useId();
|
|
24
|
+
const [expanded, setExpanded] = React.useState(() => new Set(defaultExpanded));
|
|
25
|
+
if (error) {
|
|
26
|
+
return (_jsx(ErrorState, { title: t.loadFailed(name), error: error, onRetry: onRetry, className: className }));
|
|
27
|
+
}
|
|
28
|
+
if (loading) {
|
|
29
|
+
return (_jsxs("div", { "aria-busy": true, className: cn("flex flex-col gap-4", className), children: [_jsx("p", { role: "status", className: "sr-only", children: messages.states.loading }), Array.from({ length: 4 }, (_, i) => (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Skeleton, { className: "size-3 rounded-full" }), _jsx(Skeleton, { className: "h-4 flex-1" }), _jsx(Skeleton, { className: "h-4 w-16" })] }, i)))] }));
|
|
30
|
+
}
|
|
31
|
+
if (stages.length === 0) {
|
|
32
|
+
return (_jsx(EmptyState, { title: t.noStages, description: t.noStagesDescription, className: className }));
|
|
33
|
+
}
|
|
34
|
+
return (_jsx("ol", { "aria-label": name, "data-slot": "run-timeline", className: cn("flex min-w-0 flex-col", className), children: stages.map((stage, index) => {
|
|
35
|
+
const open = expanded.has(stage.id);
|
|
36
|
+
const hasValues = Boolean(stage.inputs?.length || stage.outputs?.length);
|
|
37
|
+
const valuesId = `${id}-${index}-values`;
|
|
38
|
+
const last = index === stages.length - 1;
|
|
39
|
+
const selected = stage.id === selectedStageId;
|
|
40
|
+
const attempt = stage.attempt && (stage.attempt > 1 || stage.maxAttempts)
|
|
41
|
+
? t.attempt(stage.attempt, stage.maxAttempts)
|
|
42
|
+
: null;
|
|
43
|
+
return (_jsxs("li", { "data-status": stage.status, "aria-current": selected ? "step" : undefined, className: "relative flex min-w-0 gap-3 pb-4 last:pb-0", children: [!last && (_jsx("span", { "aria-hidden": true, className: "absolute top-4 bottom-0 left-1 w-px -translate-x-1/2 bg-border" })), _jsx(StatusDot, { tone: RUN_STAGE_TONE[stage.status], pulse: stage.status === "running", className: "mt-1.5 self-start" }), _jsxs("div", { className: cn("-my-1 flex min-w-0 flex-1 flex-col gap-1 rounded-lg px-2 py-1", selected && "bg-muted"), children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1", children: [_jsx("span", { className: "min-w-0 text-sm font-medium [overflow-wrap:anywhere]", children: onStageSelect ? (_jsx("button", { type: "button", onClick: () => onStageSelect(stage), className: "rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring", children: stage.label })) : (stage.label) }), _jsx("span", { className: "font-mono text-xs text-muted-foreground", children: t.stageStatus(stage.status) }), stage.gate && _jsx(GateBadge, { gate: stage.gate }), _jsxs("span", { className: "ml-auto flex flex-wrap gap-x-3 font-mono text-xs text-muted-foreground tabular-nums", children: [attempt && _jsx("span", { children: attempt }), stage.startedAt && _jsx("span", { children: t.startedAt(stage.startedAt) }), stage.duration && _jsx("span", { children: t.duration(stage.duration) })] })] }), stage.detail && (_jsx("p", { className: "text-sm [overflow-wrap:anywhere] text-muted-foreground", children: stage.detail })), hasValues && (_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsxs("button", { type: "button", "aria-expanded": open, "aria-controls": valuesId, onClick: () => setExpanded((current) => {
|
|
44
|
+
const next = new Set(current);
|
|
45
|
+
if (open)
|
|
46
|
+
next.delete(stage.id);
|
|
47
|
+
else
|
|
48
|
+
next.add(stage.id);
|
|
49
|
+
return next;
|
|
50
|
+
}), className: "inline-flex w-fit items-center gap-1 rounded-sm text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", children: [_jsx(ChevronRightIcon, { "aria-hidden": true, className: cn("size-3.5 transition-transform", open && "rotate-90") }), t.inputsOutputs(stage.inputs?.length ?? 0, stage.outputs?.length ?? 0)] }), open && (_jsxs("div", { id: valuesId, className: "grid min-w-0 gap-3 sm:grid-cols-2", children: [_jsx(Values, { title: t.inputs, values: stage.inputs }), _jsx(Values, { title: t.outputs, values: stage.outputs })] }))] }))] })] }, stage.id));
|
|
51
|
+
}) }));
|
|
52
|
+
}
|
|
53
|
+
function Values({ title, values }) {
|
|
54
|
+
const { messages } = useUI();
|
|
55
|
+
return (_jsxs("div", { className: "flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-2", children: [_jsx("p", { className: "font-mono text-2xs font-medium tracking-wider text-muted-foreground uppercase", children: title }), values?.length ? (_jsx("dl", { className: "grid min-w-0 grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1 text-xs", children: values.map((v, i) => (_jsxs(React.Fragment, { children: [_jsx("dt", { className: "text-muted-foreground", children: v.label }), _jsx("dd", { className: "font-mono [overflow-wrap:anywhere]", children: v.value })] }, i))) })) : (_jsx("p", { className: "text-xs text-muted-foreground", children: messages.run.none }))] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { WorkflowPermissions, WorkflowSummary } from "./model.js";
|
|
3
|
+
/** What a card can do, as the host permits it. An action renders only when its handler is given. */
|
|
4
|
+
export interface WorkflowCardActions {
|
|
5
|
+
/** Opens the workflow: the title becomes a link through the app's link component. */
|
|
6
|
+
href?: string;
|
|
7
|
+
/** Opens the workflow without a route: the title becomes a button. Ignored with `href`. */
|
|
8
|
+
onOpen?: () => void;
|
|
9
|
+
onEdit?: () => void;
|
|
10
|
+
/** Enabled only when the workflow is available here and `permissions.start` is not false. */
|
|
11
|
+
onStart?: () => void;
|
|
12
|
+
onExport?: () => void;
|
|
13
|
+
/** An action set to false is shown disabled, with the reason. */
|
|
14
|
+
permissions?: WorkflowPermissions;
|
|
15
|
+
}
|
|
16
|
+
export interface WorkflowCardProps extends WorkflowCardActions {
|
|
17
|
+
workflow: WorkflowSummary;
|
|
18
|
+
/** The host's picture of the workflow (a flow diagram, a thumbnail). Decorative unless it names itself. */
|
|
19
|
+
preview?: React.ReactNode;
|
|
20
|
+
/** A card in a grid, or a row in a list. */
|
|
21
|
+
layout?: "card" | "row";
|
|
22
|
+
/** For the document outline. Defaults to h3, under a page's h1 and a section's h2. */
|
|
23
|
+
headingLevel?: "h2" | "h3" | "h4";
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One saved workflow definition, as a gallery shows it: module and version,
|
|
28
|
+
* name and description, scope, revision and draft or published state, an
|
|
29
|
+
* optional host preview, and the actions the host permits. A workflow this
|
|
30
|
+
* factory cannot run, or that the person may not start, never shows an
|
|
31
|
+
* enabled start: the card says why instead. The card only reports intent;
|
|
32
|
+
* the host keeps authorization and execution.
|
|
33
|
+
*/
|
|
34
|
+
export declare function WorkflowCard({ workflow, preview, layout, headingLevel, href, onOpen, onEdit, onStart, onExport, permissions, className, }: WorkflowCardProps): React.JSX.Element;
|
|
35
|
+
/** A card's placeholder while the gallery loads, with the card's geometry. */
|
|
36
|
+
export declare function WorkflowCardSkeleton({ layout, className, }: {
|
|
37
|
+
layout?: "card" | "row";
|
|
38
|
+
className?: string;
|
|
39
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { Button } from "../primitives/Button.js";
|
|
6
|
+
import { Skeleton } from "../primitives/Skeleton.js";
|
|
7
|
+
import { useUI } from "../provider/UIProvider.js";
|
|
8
|
+
import { StatusBadge } from "../status/StatusBadge.js";
|
|
9
|
+
const STATUS_TONE = { draft: "info", published: "ok" };
|
|
10
|
+
const AVAILABILITY_TONE = { unsupported: "muted", "missing-capabilities": "warn" };
|
|
11
|
+
/**
|
|
12
|
+
* One saved workflow definition, as a gallery shows it: module and version,
|
|
13
|
+
* name and description, scope, revision and draft or published state, an
|
|
14
|
+
* optional host preview, and the actions the host permits. A workflow this
|
|
15
|
+
* factory cannot run, or that the person may not start, never shows an
|
|
16
|
+
* enabled start: the card says why instead. The card only reports intent;
|
|
17
|
+
* the host keeps authorization and execution.
|
|
18
|
+
*/
|
|
19
|
+
export function WorkflowCard({ workflow, preview, layout = "card", headingLevel = "h3", href, onOpen, onEdit, onStart, onExport, permissions = {}, className, }) {
|
|
20
|
+
const { Link, messages } = useUI();
|
|
21
|
+
const t = messages.workflow;
|
|
22
|
+
const id = React.useId();
|
|
23
|
+
const titleId = `${id}-title`;
|
|
24
|
+
const noteId = `${id}-note`;
|
|
25
|
+
const Heading = headingLevel;
|
|
26
|
+
const { availability } = workflow;
|
|
27
|
+
const row = layout === "row";
|
|
28
|
+
const handlers = [
|
|
29
|
+
["edit", onEdit],
|
|
30
|
+
["start", onStart],
|
|
31
|
+
["export", onExport],
|
|
32
|
+
];
|
|
33
|
+
const offered = handlers.filter(([, handler]) => handler);
|
|
34
|
+
const denied = offered.filter(([action]) => permissions[action] === false).map(([a]) => a);
|
|
35
|
+
const startBlocked = Boolean(onStart) && availability.status !== "available";
|
|
36
|
+
const notes = [];
|
|
37
|
+
if (startBlocked) {
|
|
38
|
+
notes.push(availability.reason ??
|
|
39
|
+
(availability.status === "unsupported"
|
|
40
|
+
? t.unsupported
|
|
41
|
+
: t.missingCapabilities(availability.missing ?? [])));
|
|
42
|
+
}
|
|
43
|
+
if (denied.length)
|
|
44
|
+
notes.push(t.denied(denied.map((a) => t.action(a))));
|
|
45
|
+
const titleClass = "outline-none after:absolute after:inset-0 after:rounded-lg hover:underline focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-inset";
|
|
46
|
+
const title = href ? (_jsx(Link, { href: href, className: titleClass, children: workflow.name })) : onOpen ? (_jsx("button", { type: "button", onClick: onOpen, className: cn(titleClass, "text-left"), children: workflow.name })) : (workflow.name);
|
|
47
|
+
return (_jsxs("article", { "data-slot": "workflow-card", "data-layout": layout, "data-status": workflow.status, "data-availability": availability.status, "aria-labelledby": titleId, className: cn("relative flex min-w-0 overflow-hidden rounded-lg border bg-card text-card-foreground", row ? "flex-col sm:flex-row sm:items-stretch" : "flex-col", className), children: [preview != null && (_jsx("div", { role: "img", "aria-label": t.preview(workflow.name), className: cn("flex shrink-0 items-center justify-center overflow-hidden bg-muted/40 text-muted-foreground", row ? "border-b sm:w-48 sm:border-r sm:border-b-0" : "h-28 border-b"), children: preview })), _jsxs("div", { className: cn("flex min-w-0 flex-1 gap-3 p-(--panel-pad)", row ? "flex-col md:flex-row md:items-start" : "flex-col"), children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1", children: [_jsx("span", { className: "min-w-0 truncate font-mono text-xs text-muted-foreground", children: t.module(workflow.module.label, workflow.module.version) }), _jsx(StatusBadge, { tone: STATUS_TONE[workflow.status], children: t.status(workflow.status) }), availability.status !== "available" && (_jsx(StatusBadge, { tone: AVAILABILITY_TONE[availability.status], children: t.availability(availability.status) }))] }), _jsx(Heading, { id: titleId, className: "line-clamp-2 text-sm font-medium [overflow-wrap:anywhere]", children: title }), workflow.description && (_jsx("p", { className: "line-clamp-3 text-sm [overflow-wrap:anywhere] text-muted-foreground", children: workflow.description })), _jsxs("p", { className: "flex min-w-0 flex-wrap gap-x-3 font-mono text-xs text-muted-foreground", children: [_jsx("span", { children: t.scope(workflow.scope) }), workflow.revision && (_jsx("span", { className: "min-w-0 truncate", children: t.revision(workflow.revision) }))] }), notes.length > 0 && (_jsx("p", { id: noteId, className: "text-xs [overflow-wrap:anywhere] text-muted-foreground", children: notes.join(" ") }))] }), offered.length > 0 && (_jsx("div", { className: cn("relative z-10 flex shrink-0 flex-wrap items-center gap-1.5", !row && "mt-auto border-t pt-3"), children: offered.map(([action, handler]) => {
|
|
48
|
+
const label = t.action(action);
|
|
49
|
+
const disabled = permissions[action] === false || (action === "start" && startBlocked);
|
|
50
|
+
return (_jsx(Button, { size: "sm", variant: action === "start" ? "default" : "outline", disabled: disabled, "aria-label": t.actionName(label, workflow.name), "aria-describedby": disabled && notes.length ? noteId : undefined, onClick: handler, children: label }, action));
|
|
51
|
+
}) }))] })] }));
|
|
52
|
+
}
|
|
53
|
+
/** A card's placeholder while the gallery loads, with the card's geometry. */
|
|
54
|
+
export function WorkflowCardSkeleton({ layout = "card", className, }) {
|
|
55
|
+
return (_jsxs("div", { "data-slot": "workflow-card-skeleton", "aria-hidden": true, className: cn("flex min-w-0 gap-3 overflow-hidden rounded-lg border bg-card p-(--panel-pad)", layout === "row" ? "flex-row items-center" : "flex-col", className), children: [layout === "card" && _jsx(Skeleton, { className: "h-20 w-full" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx(Skeleton, { className: "h-3 w-24" }), _jsx(Skeleton, { className: "h-4 w-2/3" }), _jsx(Skeleton, { className: "h-3 w-full" })] }), _jsx(Skeleton, { className: "h-7 w-20" })] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type WorkflowGalleryFilters } from "./gallery.js";
|
|
3
|
+
import type { WorkflowModule, WorkflowSummary } from "./model.js";
|
|
4
|
+
import { type WorkflowCardActions } from "./WorkflowCard.js";
|
|
5
|
+
export type WorkflowGalleryView = "grid" | "list";
|
|
6
|
+
export interface WorkflowGalleryProps {
|
|
7
|
+
/** The page title. Defaults to "Workflows". */
|
|
8
|
+
title?: React.ReactNode;
|
|
9
|
+
description?: React.ReactNode;
|
|
10
|
+
/** The workflows to show, already filtered by the host (see `filterWorkflows`). */
|
|
11
|
+
workflows: readonly WorkflowSummary[];
|
|
12
|
+
/** The modules the module filter offers. Leave empty to hide that filter. */
|
|
13
|
+
modules: readonly WorkflowModule[];
|
|
14
|
+
filters: WorkflowGalleryFilters;
|
|
15
|
+
onFiltersChange: (filters: WorkflowGalleryFilters) => void;
|
|
16
|
+
/** Grid of cards or list of rows. Uncontrolled without it. */
|
|
17
|
+
view?: WorkflowGalleryView;
|
|
18
|
+
defaultView?: WorkflowGalleryView;
|
|
19
|
+
onViewChange?: (view: WorkflowGalleryView) => void;
|
|
20
|
+
loading?: boolean;
|
|
21
|
+
error?: {
|
|
22
|
+
message: string;
|
|
23
|
+
} | null;
|
|
24
|
+
onRetry?: () => void;
|
|
25
|
+
/** Shown only when the person may create. */
|
|
26
|
+
onCreate?: () => void;
|
|
27
|
+
/** Shown only when the person may import. */
|
|
28
|
+
onImport?: () => void;
|
|
29
|
+
/** The actions the host permits on each workflow. */
|
|
30
|
+
actionsFor?: (workflow: WorkflowSummary) => WorkflowCardActions;
|
|
31
|
+
/** The host's preview of each workflow. */
|
|
32
|
+
renderPreview?: (workflow: WorkflowSummary) => React.ReactNode;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The workflow gallery: a full page of saved definitions over WorkflowCard,
|
|
37
|
+
* with search, a module filter, scope and status filters, grid and list
|
|
38
|
+
* views, and create and import when the host permits them. Host-driven: the
|
|
39
|
+
* filters are controlled and the host returns the matching workflows (a host
|
|
40
|
+
* holding the whole set uses `filterWorkflows`). Empty and no-matches are
|
|
41
|
+
* different states.
|
|
42
|
+
*/
|
|
43
|
+
export declare function WorkflowGallery({ title, description, workflows, modules, filters, onFiltersChange, view: viewProp, defaultView, onViewChange, loading, error, onRetry, onCreate, onImport, actionsFor, renderPreview, className, }: WorkflowGalleryProps): React.JSX.Element;
|