bottleship 0.1.0-prototype.1 → 0.1.0-prototype.2
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/CHANGELOG.md +10 -0
- package/README.md +118 -117
- package/dist/src/boxes.d.ts +41 -39
- package/dist/src/boxes.js +181 -179
- package/dist/src/cli.js +298 -84
- package/dist/src/config.d.ts +3 -6
- package/dist/src/config.js +19 -85
- package/dist/src/init.d.ts +2 -4
- package/dist/src/init.js +92 -228
- package/dist/src/local.d.ts +8 -0
- package/dist/src/local.js +24 -0
- package/dist/src/onboard.d.ts +5 -0
- package/dist/src/onboard.js +25 -0
- package/dist/src/paths.d.ts +0 -1
- package/dist/src/paths.js +0 -1
- package/dist/src/provider.d.ts +16 -0
- package/dist/src/provider.js +81 -0
- package/dist/src/repo.d.ts +21 -0
- package/dist/src/repo.js +75 -0
- package/dist/src/review/comments.d.ts +66 -0
- package/dist/src/review/comments.js +344 -0
- package/dist/src/review/contract.d.ts +82 -0
- package/dist/src/review/contract.js +189 -0
- package/dist/src/review/github.d.ts +120 -0
- package/dist/src/review/github.js +160 -0
- package/dist/src/review/local.d.ts +14 -0
- package/dist/src/review/local.js +70 -0
- package/dist/src/review/loop.d.ts +12 -0
- package/dist/src/review/loop.js +119 -0
- package/dist/src/review/pr.d.ts +14 -0
- package/dist/src/review/pr.js +68 -0
- package/dist/src/review/prompt.d.ts +15 -0
- package/dist/src/review/prompt.js +48 -0
- package/dist/src/review/serve.d.ts +1 -0
- package/dist/src/review/serve.js +8 -0
- package/dist/src/review/server.d.ts +14 -0
- package/dist/src/review/server.js +484 -0
- package/dist/src/review/setting-up.d.ts +4 -0
- package/dist/src/review/setting-up.js +74 -0
- package/dist/src/review/start.d.ts +32 -0
- package/dist/src/review/start.js +140 -0
- package/dist/src/review/target.d.ts +20 -0
- package/dist/src/review/target.js +66 -0
- package/dist/src/review/template.d.ts +3 -0
- package/dist/src/review/template.js +13 -0
- package/dist/src/review/terminal.d.ts +4 -0
- package/dist/src/review/terminal.js +91 -0
- package/dist/src/types.d.ts +1 -71
- package/dist/src/ui.d.ts +6 -1
- package/dist/src/ui.js +22 -23
- package/dist/src/util.d.ts +11 -0
- package/dist/src/util.js +40 -0
- package/package.json +7 -14
- package/presets/agents/claude.json +1 -5
- package/presets/agents/codex.json +1 -10
- package/presets/agents/cursor.json +1 -9
- package/presets/agents/opencode.json +1 -14
- package/presets/providers/docker/create +12 -0
- package/presets/providers/docker/destroy +2 -0
- package/presets/providers/docker/exec +2 -0
- package/presets/providers/docker/list +4 -0
- package/presets/providers/docker/status +2 -0
- package/presets/providers/docker/url +4 -0
- package/review-app/KIT.md +106 -0
- package/review-app/WRITING.md +93 -0
- package/review-app/examples/rply-16.tsx +98 -0
- package/review-app/index.html +15 -0
- package/review-app/package-lock.json +2539 -0
- package/review-app/package.json +24 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
- package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
- package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
- package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
- package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
- package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
- package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
- package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
- package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
- package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
- package/review-app/playground/fixtures/rply-16/events.json +156 -0
- package/review-app/playground/fixtures/rply-16/instances.json +1 -0
- package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
- package/review-app/playground/gallery.tsx +127 -0
- package/review-app/playground/index.html +15 -0
- package/review-app/playground/main.tsx +26 -0
- package/review-app/playground/mock.ts +215 -0
- package/review-app/playground/playground.css +18 -0
- package/review-app/playground/vite.config.ts +32 -0
- package/review-app/scripts/check.cjs +47 -0
- package/review-app/scripts/clip.cjs +104 -0
- package/review-app/scripts/shot.cjs +66 -0
- package/review-app/src/App.tsx +132 -0
- package/review-app/src/data/box.json +1 -0
- package/review-app/src/data/hooks.diff +28 -0
- package/review-app/src/data/pr.diff +114 -0
- package/review-app/src/data/pr.json +68 -0
- package/review-app/src/data/review-comments.json +1 -0
- package/review-app/src/kit/agent.tsx +287 -0
- package/review-app/src/kit/chapter.tsx +178 -0
- package/review-app/src/kit/code.tsx +217 -0
- package/review-app/src/kit/comments.tsx +377 -0
- package/review-app/src/kit/finish.tsx +274 -0
- package/review-app/src/kit/flow.tsx +184 -0
- package/review-app/src/kit/index.ts +10 -0
- package/review-app/src/kit/kit.css +696 -0
- package/review-app/src/kit/live.ts +91 -0
- package/review-app/src/kit/media.tsx +348 -0
- package/review-app/src/kit/overview.tsx +161 -0
- package/review-app/src/kit/pr.ts +100 -0
- package/review-app/src/kit/pullrequest.tsx +298 -0
- package/review-app/src/kit/review.ts +74 -0
- package/review-app/src/kit/theme.ts +30 -0
- package/review-app/src/kit/ui.tsx +127 -0
- package/review-app/src/main.tsx +6 -0
- package/review-app/src/review.tsx +22 -0
- package/review-app/tsconfig.json +17 -0
- package/review-app/vite.config.ts +12 -0
- package/dist/presets/providers/command.d.ts +0 -3
- package/dist/presets/providers/command.js +0 -39
- package/dist/presets/providers/daytona.d.ts +0 -3
- package/dist/presets/providers/daytona.js +0 -173
- package/dist/presets/providers/docker.d.ts +0 -3
- package/dist/presets/providers/docker.js +0 -31
- package/dist/presets/providers/e2b.d.ts +0 -3
- package/dist/presets/providers/e2b.js +0 -124
- package/dist/presets/providers/exe.d.ts +0 -3
- package/dist/presets/providers/exe.js +0 -28
- package/dist/presets/providers/http.example.d.ts +0 -3
- package/dist/presets/providers/http.example.js +0 -47
- package/dist/presets/providers/local.d.ts +0 -3
- package/dist/presets/providers/local.js +0 -52
- package/dist/presets/providers/modal.d.ts +0 -3
- package/dist/presets/providers/modal.js +0 -117
- package/dist/presets/providers/ssh.d.ts +0 -3
- package/dist/presets/providers/ssh.js +0 -49
- package/dist/presets/providers/util.d.ts +0 -13
- package/dist/presets/providers/util.js +0 -102
- package/presets/providers/command.ts +0 -43
- package/presets/providers/daytona.ts +0 -181
- package/presets/providers/docker.ts +0 -38
- package/presets/providers/e2b.ts +0 -130
- package/presets/providers/exe.ts +0 -35
- package/presets/providers/http.example.ts +0 -64
- package/presets/providers/local.ts +0 -56
- package/presets/providers/modal.ts +0 -121
- package/presets/providers/ssh.ts +0 -54
- package/presets/providers/util.ts +0 -112
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The review server's live side: events from review hooks and step scripts, step runs, and where instances are reachable.
|
|
2
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import type { ReviewComment } from "./comments";
|
|
4
|
+
|
|
5
|
+
export type Status = "ok" | "fail" | "neutral";
|
|
6
|
+
|
|
7
|
+
export interface ReviewEvent {
|
|
8
|
+
seq: number;
|
|
9
|
+
instance: string;
|
|
10
|
+
kind: "request" | "hook" | "ok" | "fail";
|
|
11
|
+
text: string;
|
|
12
|
+
at?: string;
|
|
13
|
+
// set by the server: the step running when the event arrived
|
|
14
|
+
step?: string;
|
|
15
|
+
flow?: { id: string; edge?: [string, string]; node?: string; status?: Status; label?: string };
|
|
16
|
+
overlay?: { title: string; body?: string; status: Status };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StepState { id: string; status: "running" | "done" | "failed"; output?: string }
|
|
20
|
+
|
|
21
|
+
export interface Verdict { event?: "COMMENT" | "APPROVE" | "REQUEST_CHANGES"; body?: string }
|
|
22
|
+
// local reviews: whether the developer's agent is waiting for comments (bottleship agent review next)
|
|
23
|
+
export interface AgentState { local: boolean; listening: boolean }
|
|
24
|
+
// the change was updated after comments; refreshed once the tour shows it
|
|
25
|
+
export interface Update { n: number; at: string; note: string; refreshed?: string }
|
|
26
|
+
interface Live { events: ReviewEvent[]; steps: Record<string, StepState>; connected: boolean; comments: ReviewComment[]; verdict: Verdict; agent: AgentState; updates: Update[] }
|
|
27
|
+
|
|
28
|
+
let live: Live = { events: [], steps: {}, connected: false, comments: [], verdict: {}, agent: { local: false, listening: false }, updates: [] };
|
|
29
|
+
const listeners = new Set<() => void>();
|
|
30
|
+
const set = (next: Partial<Live>) => {
|
|
31
|
+
live = { ...live, ...next };
|
|
32
|
+
listeners.forEach((l) => l());
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let source: EventSource | undefined;
|
|
36
|
+
function connect() {
|
|
37
|
+
if (source) return;
|
|
38
|
+
source = new EventSource("api/events");
|
|
39
|
+
const on = (name: string, fn: (d: any) => void) => source!.addEventListener(name, (e) => fn(JSON.parse((e as MessageEvent).data)));
|
|
40
|
+
on("state", (d) => set({ steps: d.steps, events: [] }));
|
|
41
|
+
on("event", (e: ReviewEvent) => set({ events: [...live.events, e] }));
|
|
42
|
+
on("ready", () => set({ connected: true }));
|
|
43
|
+
on("step", (s: StepState) => set({ steps: { ...live.steps, [s.id]: s } }));
|
|
44
|
+
on("reset", () => set({ events: [], steps: {} }));
|
|
45
|
+
on("comments", (d: { comments: ReviewComment[] }) => set({ comments: d.comments }));
|
|
46
|
+
on("verdict", (v: Verdict) => set({ verdict: v }));
|
|
47
|
+
on("agent", (a: AgentState) => set({ agent: a }));
|
|
48
|
+
on("updates", (d: { updates: Update[] }) => set({ updates: d.updates }));
|
|
49
|
+
source.onerror = () => set({ connected: false });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// the comments change through these; the server streams the new list back to every open tab
|
|
53
|
+
export const setComments = (comments: ReviewComment[]) => set({ comments });
|
|
54
|
+
export const liveComments = () => live.comments;
|
|
55
|
+
// the verdict as it is now, for saves that land after the reviewer has changed it
|
|
56
|
+
export const liveVerdict = () => live.verdict;
|
|
57
|
+
|
|
58
|
+
// how the reviewer means to finish, sent with the review when they submit it
|
|
59
|
+
export async function saveVerdict(v: Verdict) {
|
|
60
|
+
set({ verdict: v });
|
|
61
|
+
await fetch("api/verdict", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(v) }).catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useLive(): Live {
|
|
65
|
+
connect();
|
|
66
|
+
return useSyncExternalStore(
|
|
67
|
+
(l) => (listeners.add(l), () => listeners.delete(l)),
|
|
68
|
+
() => live,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const post = (path: string) => fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
73
|
+
|
|
74
|
+
// runs ~/review/steps/<id>.sh in the box; events that arrive meanwhile are tagged with the step
|
|
75
|
+
export const runStep = (id: string) => post(`api/steps/${encodeURIComponent(id)}/run`);
|
|
76
|
+
export const resetEvents = () => post("api/reset");
|
|
77
|
+
|
|
78
|
+
// where the reviewer's browser reaches each port the box publishes
|
|
79
|
+
let urls: Promise<Record<string, string>> | undefined;
|
|
80
|
+
export function useUrl(port: number, path = "/"): string | undefined {
|
|
81
|
+
const [url, setUrl] = useState<string>();
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
urls ??= fetch("api/urls").then((r) => (r.ok ? r.json() : {}), () => ({}));
|
|
84
|
+
// the path goes under the instance's address, which may itself have a path
|
|
85
|
+
urls.then((m) => setUrl(new URL(path.replace(/^\/+/, ""), (m[port] ?? `http://localhost:${port}`).replace(/\/?$/, "/")).href));
|
|
86
|
+
}, [port, path]);
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// a file under ~/review in the box, e.g. a screenshot taken with scripts/shot.cjs
|
|
91
|
+
export const file = (path: string) => `files/${path.replace(/^\/+/, "")}`;
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// What a reviewer looks at: the running instances, screenshots, and two screenshots compared.
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
import { file, useUrl } from "./live";
|
|
5
|
+
import { useChapter, useNav, type Instance } from "./review";
|
|
6
|
+
import { Icon } from "./ui";
|
|
7
|
+
import { referToChat } from "./comments";
|
|
8
|
+
|
|
9
|
+
// whether anything answers at url; retries every few seconds until it does, e.g. while the app starts
|
|
10
|
+
function useReachable(url?: string): boolean | undefined {
|
|
11
|
+
const [ok, setOk] = useState<boolean>();
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!url) return;
|
|
14
|
+
let stop = false, timer: ReturnType<typeof setTimeout>;
|
|
15
|
+
const probe = () =>
|
|
16
|
+
fetch(url, { mode: "no-cors", cache: "no-store" }).then(
|
|
17
|
+
() => !stop && setOk(true),
|
|
18
|
+
() => { if (!stop) { setOk(false); timer = setTimeout(probe, 3000); } },
|
|
19
|
+
);
|
|
20
|
+
probe();
|
|
21
|
+
return () => { stop = true; clearTimeout(timer); };
|
|
22
|
+
}, [url]);
|
|
23
|
+
return ok;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// one instance's app, with nothing around it; the bar above names it
|
|
27
|
+
function Frame({ inst, hidden }: { inst: Instance; hidden?: boolean }) {
|
|
28
|
+
const url = useUrl(inst.port, inst.path);
|
|
29
|
+
const reachable = useReachable(url);
|
|
30
|
+
return (
|
|
31
|
+
<div className="frame" hidden={hidden}>
|
|
32
|
+
<div className="fview">
|
|
33
|
+
{url && reachable && <iframe src={url} title={inst.label} />}
|
|
34
|
+
{reachable === false && (
|
|
35
|
+
<div className="down">
|
|
36
|
+
<b>{inst.label} is not answering</b>
|
|
37
|
+
<span>Nothing responds on port {inst.port} in the box yet. It may still be starting; this retries every few seconds.</span>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// the status the instance's latest overlay reported, for its dot
|
|
46
|
+
function useStatus(id: string) {
|
|
47
|
+
const { events } = useChapter();
|
|
48
|
+
return [...events].reverse().find((e) => e.instance === id && e.overlay)?.overlay?.status;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Open({ inst }: { inst: Instance }) {
|
|
52
|
+
const url = useUrl(inst.port, inst.path);
|
|
53
|
+
return url ? <a className="icon-btn" href={url} target="_blank" rel="noreferrer" title={`Open ${url.replace(/^https?:\/\/[^/]+/, "") || "/"} in a new tab`} aria-label={`Open ${inst.label} in a new tab`}>{Icon.external}</a> : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// side by side: a label over each app
|
|
57
|
+
function InstanceLabel({ inst, lead }: { inst: Instance; lead?: ReactNode }) {
|
|
58
|
+
const status = useStatus(inst.id);
|
|
59
|
+
return (
|
|
60
|
+
<div className="live-label">
|
|
61
|
+
{lead}
|
|
62
|
+
<span className={`lamp${status ? " " + status : ""}`} />
|
|
63
|
+
<b>{inst.label}</b>
|
|
64
|
+
<Open inst={inst} />
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// below this width per app, the apps show one at a time
|
|
70
|
+
const MIN_WIDTH = 700;
|
|
71
|
+
|
|
72
|
+
// the guide column folds away to give the apps the width; it stays folded across chapters
|
|
73
|
+
// with live apps, and shows again on a chapter without them, which has no toggle
|
|
74
|
+
let guideOff = false;
|
|
75
|
+
function GuideToggle() {
|
|
76
|
+
const [off, setOff] = useState(guideOff);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
guideOff = off;
|
|
79
|
+
document.documentElement.toggleAttribute("data-guide-off", off);
|
|
80
|
+
return () => document.documentElement.removeAttribute("data-guide-off");
|
|
81
|
+
}, [off]);
|
|
82
|
+
const label = off ? "Show the guide" : "Hide the guide";
|
|
83
|
+
return <button className="icon-btn" onClick={() => setOff(!off)} title={label} aria-label={label} aria-pressed={off}>{Icon.panel}</button>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// base / head as a sliding switch, each with the status its overlay reported
|
|
87
|
+
function InstanceSwitch({ list, current, onChange }: { list: Instance[]; current: string; onChange: (id: string) => void }) {
|
|
88
|
+
const group = useRef<HTMLDivElement>(null);
|
|
89
|
+
const [pill, setPill] = useState<{ x: number; w: number; moved: boolean }>();
|
|
90
|
+
useLayoutEffect(() => {
|
|
91
|
+
const b = group.current?.querySelector<HTMLElement>(`[data-id="${current}"]`);
|
|
92
|
+
if (b) setPill((p) => ({ x: b.offsetLeft, w: b.offsetWidth, moved: !!p }));
|
|
93
|
+
}, [current]);
|
|
94
|
+
return (
|
|
95
|
+
<div className="modes" role="group" aria-label="Instance" ref={group}>
|
|
96
|
+
{pill && <span className={`pill${pill.moved ? " moved" : ""}`} style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
|
|
97
|
+
{list.map((i) => <SwitchItem key={i.id} inst={i} active={i.id === current} onClick={() => onChange(i.id)} />)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function SwitchItem({ inst, active, onClick }: { inst: Instance; active: boolean; onClick: () => void }) {
|
|
103
|
+
const status = useStatus(inst.id);
|
|
104
|
+
return <button data-id={inst.id} aria-pressed={active} onClick={onClick}><span className={`lamp${status ? " " + status : ""}`} />{inst.label}</button>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// the running instances: side by side when each gets MIN_WIDTH, otherwise one at a time with a switch (or S).
|
|
108
|
+
// Both stay loaded either way, so switching keeps their state. Each name carries the status its events reported.
|
|
109
|
+
export function Live({ ids }: { ids?: string[] }) {
|
|
110
|
+
const { instances } = useChapter();
|
|
111
|
+
const { chapter } = useNav();
|
|
112
|
+
const list = instances.filter((i) => !ids || ids.includes(i.id));
|
|
113
|
+
const [current, setCurrent] = useState(list[list.length - 1]?.id ?? "");
|
|
114
|
+
const body = useRef<HTMLDivElement>(null);
|
|
115
|
+
const [wide, setWide] = useState(true);
|
|
116
|
+
useLayoutEffect(() => {
|
|
117
|
+
if (!body.current) return;
|
|
118
|
+
const ro = new ResizeObserver(([e]) => setWide(list.length < 2 || e.contentRect.width / list.length >= MIN_WIDTH));
|
|
119
|
+
ro.observe(body.current);
|
|
120
|
+
return () => ro.disconnect();
|
|
121
|
+
}, [list.length]);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (wide) return;
|
|
124
|
+
const key = (e: KeyboardEvent) => {
|
|
125
|
+
if ((e.key !== "s" && e.key !== "S") || e.metaKey || e.ctrlKey || e.altKey || /input|textarea|select/i.test((e.target as HTMLElement).tagName)) return;
|
|
126
|
+
setCurrent((c) => list[(list.findIndex((i) => i.id === c) + 1) % list.length].id);
|
|
127
|
+
};
|
|
128
|
+
addEventListener("keydown", key);
|
|
129
|
+
return () => removeEventListener("keydown", key);
|
|
130
|
+
}, [wide, list]);
|
|
131
|
+
const shown = list.find((i) => i.id === current);
|
|
132
|
+
return (
|
|
133
|
+
<div className="live">
|
|
134
|
+
{wide ? (
|
|
135
|
+
<div className="live-labels" style={{ gridTemplateColumns: `repeat(${list.length}, minmax(0, 1fr))` }}>
|
|
136
|
+
{list.map((i, n) => <InstanceLabel key={i.id} inst={i} lead={n === 0 && chapter ? <GuideToggle /> : undefined} />)}
|
|
137
|
+
</div>
|
|
138
|
+
) : (
|
|
139
|
+
<div className="live-bar">
|
|
140
|
+
{chapter && <GuideToggle />}
|
|
141
|
+
<div className="live-right">
|
|
142
|
+
<kbd title="Press S to switch">S</kbd>
|
|
143
|
+
<InstanceSwitch list={list} current={current} onChange={setCurrent} />
|
|
144
|
+
{shown && <Open inst={shown} />}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<div className={`live-body${wide ? " wide" : ""}`} ref={body} style={wide ? { gridTemplateColumns: `repeat(${list.length}, minmax(0, 1fr))` } : undefined}>
|
|
149
|
+
{list.map((i) => <Frame key={i.id} inst={i} hidden={!wide && i.id !== current} />)}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// a screenshot opened full size over the page; Escape or a click closes it
|
|
156
|
+
function Zoom({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const key = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
159
|
+
addEventListener("keydown", key);
|
|
160
|
+
return () => removeEventListener("keydown", key);
|
|
161
|
+
}, [onClose]);
|
|
162
|
+
return createPortal(<div className="zoom" onClick={onClose} role="dialog" aria-label={alt}><img src={src} alt={alt} /></div>, document.body);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Img({ src, alt }: { src: string; alt: string }) {
|
|
166
|
+
const [open, setOpen] = useState(false);
|
|
167
|
+
return (
|
|
168
|
+
<>
|
|
169
|
+
<button className="media" onClick={() => setOpen(true)} title="Open full size"><img src={src} alt={alt} loading="lazy" /></button>
|
|
170
|
+
{open && <Zoom src={src} alt={alt} onClose={() => setOpen(false)} />}
|
|
171
|
+
</>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function FigHead({ title, children }: { title?: ReactNode; children?: ReactNode }) {
|
|
176
|
+
if (!title && !children) return null;
|
|
177
|
+
return <div className="fig-head"><div className="fig-title">{title}</div><div className="fig-actions">{children}</div></div>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// brings a picture into the chat with the agent, by its title or file
|
|
181
|
+
function AddToChat({ figure, title }: { figure: string; title?: ReactNode }) {
|
|
182
|
+
const name = typeof title === "string" && title ? title : figure.split("/").pop()!;
|
|
183
|
+
return <button className="icon-btn fig-remark" onClick={() => referToChat({ kind: "selection", text: figure, where: `the picture "${name}"` })} title="Add to the chat with the agent" aria-label="Add this picture to the chat with the agent">{Icon.cursorPlus}</button>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// a screenshot taken in the box with scripts/shot.cjs, under ~/review/shots
|
|
187
|
+
export function Shot({ src, alt = "", title, caption }: { src: string; alt?: string; title?: ReactNode; caption?: ReactNode }) {
|
|
188
|
+
return (
|
|
189
|
+
<figure className="fig">
|
|
190
|
+
<FigHead title={title}><AddToChat figure={src} title={title} /></FigHead>
|
|
191
|
+
<Img src={file(src)} alt={alt || (typeof title === "string" ? title : "")} />
|
|
192
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
193
|
+
</figure>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// a recording from scripts/clip.cjs: "shots/<name>.webm#t=<s>", where #t is where the page was ready
|
|
198
|
+
const clipSrc = (src: string) => {
|
|
199
|
+
const [path, t] = src.split("#t=");
|
|
200
|
+
return { url: file(path), start: Number(t) || 0 };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// a short recording of a flow, looping from where the page was ready; with base and head, both side by side and in
|
|
204
|
+
// step, so the same moment shows on each. Click to pause. With reduced motion it waits to be played.
|
|
205
|
+
export function Clip({ src, base, head, labels = ["Base", "Head"], title, caption }: { src?: string; base?: string; head?: string; labels?: [string, string]; title?: ReactNode; caption?: ReactNode }) {
|
|
206
|
+
const sources = (src ? [src] : [base, head]).filter((x): x is string => !!x).map(clipSrc);
|
|
207
|
+
const videos = useRef<(HTMLVideoElement | null)[]>([]);
|
|
208
|
+
const still = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
209
|
+
const [playing, setPlaying] = useState(!still);
|
|
210
|
+
const each = (fn: (v: HTMLVideoElement, i: number) => void) => videos.current.forEach((v, i) => v && fn(v, i));
|
|
211
|
+
// every video starts over when the longest one ends, so a pair stays in step
|
|
212
|
+
const restart = () => each((v, i) => { v.currentTime = sources[i].start; if (playing) void v.play().catch(() => {}); });
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
each((v) => (playing ? void v.play().catch(() => {}) : v.pause()));
|
|
215
|
+
}, [playing]);
|
|
216
|
+
const ended = () => videos.current.every((v) => !v || v.ended) && restart();
|
|
217
|
+
const toggle = () => setPlaying((p) => !p);
|
|
218
|
+
return (
|
|
219
|
+
<figure className="fig clip">
|
|
220
|
+
<FigHead title={title}>
|
|
221
|
+
<AddToChat figure={src ?? base ?? ""} title={title} />
|
|
222
|
+
<button className="icon-btn" onClick={toggle} title={playing ? "Pause" : "Play"} aria-label={playing ? "Pause" : "Play"}>{playing ? Icon.pause : Icon.play}</button>
|
|
223
|
+
</FigHead>
|
|
224
|
+
<div className={sources.length > 1 ? "cmp-side" : undefined}>
|
|
225
|
+
{sources.map((s, i) => (
|
|
226
|
+
<div className="tagged" key={s.url}>
|
|
227
|
+
<video
|
|
228
|
+
ref={(el) => { videos.current[i] = el; }}
|
|
229
|
+
className="media still"
|
|
230
|
+
src={`${s.url}#t=${s.start}`}
|
|
231
|
+
muted playsInline preload="auto"
|
|
232
|
+
autoPlay={!still}
|
|
233
|
+
onEnded={ended}
|
|
234
|
+
onClick={toggle}
|
|
235
|
+
/>
|
|
236
|
+
{sources.length > 1 && <Tag>{labels[i]}</Tag>}
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
241
|
+
</figure>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// marks every pixel that differs between two same-size screenshots
|
|
246
|
+
function DiffCanvas({ a, b }: { a: string; b: string }) {
|
|
247
|
+
const canvas = useRef<HTMLCanvasElement>(null);
|
|
248
|
+
const [note, setNote] = useState("");
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
const load = (s: string) => new Promise<HTMLImageElement>((ok, fail) => { const i = new Image(); i.onload = () => ok(i); i.onerror = fail; i.src = s; });
|
|
251
|
+
Promise.all([load(a), load(b)]).then(([x, y]) => {
|
|
252
|
+
const w = Math.max(x.width, y.width), h = Math.max(x.height, y.height);
|
|
253
|
+
const c = canvas.current!;
|
|
254
|
+
c.width = w;
|
|
255
|
+
c.height = h;
|
|
256
|
+
const ctx = c.getContext("2d")!;
|
|
257
|
+
const px = (img: HTMLImageElement) => { ctx.clearRect(0, 0, w, h); ctx.drawImage(img, 0, 0); return ctx.getImageData(0, 0, w, h); };
|
|
258
|
+
const da = px(x), db = px(y), out = ctx.createImageData(w, h);
|
|
259
|
+
let changed = 0;
|
|
260
|
+
for (let i = 0; i < da.data.length; i += 4) {
|
|
261
|
+
const d = Math.abs(da.data[i] - db.data[i]) + Math.abs(da.data[i + 1] - db.data[i + 1]) + Math.abs(da.data[i + 2] - db.data[i + 2]);
|
|
262
|
+
const g = 255 - (255 - (db.data[i] + db.data[i + 1] + db.data[i + 2]) / 3) * 0.25;
|
|
263
|
+
if (d > 24) {
|
|
264
|
+
changed++;
|
|
265
|
+
out.data.set([235, 87, 87, 255], i);
|
|
266
|
+
} else out.data.set([g, g, g, 255], i);
|
|
267
|
+
}
|
|
268
|
+
ctx.putImageData(out, 0, 0);
|
|
269
|
+
const pct = (changed / (w * h)) * 100;
|
|
270
|
+
setNote(changed === 0 ? "No pixels differ" : `${pct < 0.1 ? "<0.1" : pct.toFixed(1)}% of pixels differ, marked red`);
|
|
271
|
+
}, () => setNote("Could not load both screenshots"));
|
|
272
|
+
}, [a, b]);
|
|
273
|
+
return <div className="cmp-diff"><div className="media still"><canvas ref={canvas} /></div><div className="cmp-note">{note}</div></div>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const MODES = [["side", "Side by side"], ["slider", "Slider"], ["diff", "Difference"]] as const;
|
|
277
|
+
type Mode = (typeof MODES)[number][0];
|
|
278
|
+
|
|
279
|
+
// the compare modes; a background slides to the selected one
|
|
280
|
+
function Modes({ value, onChange }: { value: Mode; onChange: (m: Mode) => void }) {
|
|
281
|
+
const group = useRef<HTMLDivElement>(null);
|
|
282
|
+
const [pill, setPill] = useState<{ x: number; w: number; moved: boolean }>();
|
|
283
|
+
useLayoutEffect(() => {
|
|
284
|
+
const place = () => {
|
|
285
|
+
const b = group.current?.querySelector<HTMLElement>(`[data-mode="${value}"]`);
|
|
286
|
+
if (b) setPill((p) => ({ x: b.offsetLeft, w: b.offsetWidth, moved: !!p }));
|
|
287
|
+
};
|
|
288
|
+
place();
|
|
289
|
+
addEventListener("resize", place);
|
|
290
|
+
return () => removeEventListener("resize", place);
|
|
291
|
+
}, [value]);
|
|
292
|
+
return (
|
|
293
|
+
<div className="modes" role="group" aria-label="Compare as" ref={group}>
|
|
294
|
+
{pill && <span className={`pill${pill.moved ? " moved" : ""}`} style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
|
|
295
|
+
{MODES.map(([m, name]) => (
|
|
296
|
+
<button key={m} data-mode={m} aria-pressed={value === m} onClick={() => onChange(m)}>{name}</button>
|
|
297
|
+
))}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const Tag = ({ children, side }: { children: ReactNode; side?: "l" | "r" }) => <span className={`tag${side ? " " + side : ""}`}>{children}</span>;
|
|
303
|
+
|
|
304
|
+
// two screenshots of the same thing, e.g. base and head: side by side, a slider, or the pixels that differ
|
|
305
|
+
export function Compare({ base, head, labels = ["Base", "Head"], title, caption, mode: start = "side" }: { base: string; head: string; labels?: [string, string]; title?: ReactNode; caption?: ReactNode; mode?: "side" | "slider" | "diff" }) {
|
|
306
|
+
const [mode, setMode] = useState(start);
|
|
307
|
+
const [x, setX] = useState(50);
|
|
308
|
+
const [dragging, setDragging] = useState(false);
|
|
309
|
+
const move = (e: React.PointerEvent<HTMLDivElement>) => setX(Math.min(100, Math.max(0, ((e.clientX - e.currentTarget.getBoundingClientRect().left) / e.currentTarget.clientWidth) * 100)));
|
|
310
|
+
// arrows step the slider while it has focus, instead of turning the page
|
|
311
|
+
const key = (e: React.KeyboardEvent) => {
|
|
312
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
e.stopPropagation();
|
|
315
|
+
setX((v) => Math.min(100, Math.max(0, v + (e.key === "ArrowLeft" ? -5 : 5))));
|
|
316
|
+
};
|
|
317
|
+
return (
|
|
318
|
+
<figure className="fig compare">
|
|
319
|
+
<FigHead title={title}>
|
|
320
|
+
<AddToChat figure={base} title={title} />
|
|
321
|
+
<Modes value={mode} onChange={setMode} />
|
|
322
|
+
</FigHead>
|
|
323
|
+
{mode === "side" && (
|
|
324
|
+
<div className="cmp-side">
|
|
325
|
+
<div className="tagged"><Img src={file(base)} alt={labels[0]} /><Tag>{labels[0]}</Tag></div>
|
|
326
|
+
<div className="tagged"><Img src={file(head)} alt={labels[1]} /><Tag>{labels[1]}</Tag></div>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
{mode === "slider" && (
|
|
330
|
+
<div
|
|
331
|
+
className={`media still cmp-slider${dragging ? " dragging" : ""}`}
|
|
332
|
+
tabIndex={0} role="slider" aria-label={`${labels[0]} and ${labels[1]}`} aria-valuenow={Math.round(x)} aria-valuemin={0} aria-valuemax={100}
|
|
333
|
+
onKeyDown={key}
|
|
334
|
+
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDragging(true); move(e); }}
|
|
335
|
+
onPointerMove={(e) => e.buttons && move(e)}
|
|
336
|
+
onPointerUp={() => setDragging(false)}
|
|
337
|
+
>
|
|
338
|
+
<img src={file(head)} alt={labels[1]} draggable={false} />
|
|
339
|
+
<img src={file(base)} alt={labels[0]} className="top" draggable={false} style={{ clipPath: `inset(0 ${100 - x}% 0 0)` }} />
|
|
340
|
+
<div className="handle" style={{ left: `${x}%` }}><span className="grip"><svg viewBox="0 0 16 16"><path d="M6 4.5 2.5 8 6 11.5M10 4.5 13.5 8 10 11.5" /></svg></span></div>
|
|
341
|
+
<Tag side="l">{labels[0]}</Tag><Tag side="r">{labels[1]}</Tag>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
{mode === "diff" && <DiffCanvas a={file(base)} b={file(head)} />}
|
|
345
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
346
|
+
</figure>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The overview page: what the PR does, before and after, where it happens, what to look at and which claims hold.
|
|
2
|
+
import { useState, type ReactNode } from "react";
|
|
3
|
+
import { diffs, pr, shortTitle } from "./pr";
|
|
4
|
+
import { useNav, type Target } from "./review";
|
|
5
|
+
import { FileLine, Icon, Refs, Row, Rows, Section } from "./ui";
|
|
6
|
+
|
|
7
|
+
// the page frame: title and PR line on top, the files and the way into the tour at the bottom
|
|
8
|
+
export function Overview({ children }: { children: ReactNode }) {
|
|
9
|
+
const { review, go } = useNav();
|
|
10
|
+
return (
|
|
11
|
+
<section className="view orient">
|
|
12
|
+
<div>
|
|
13
|
+
<h1 className="title">{shortTitle}</h1>
|
|
14
|
+
<div className="meta">
|
|
15
|
+
<span className="who"><span className="avatar">{pr.author[0]?.toUpperCase()}</span>{pr.author}</span>
|
|
16
|
+
<span>{pr.repo} {pr.ref}</span>
|
|
17
|
+
<Refs from={pr.headRef} to={pr.baseRef} label={`${pr.headRef} into ${pr.baseRef}`} />
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
{children}
|
|
21
|
+
<Files />
|
|
22
|
+
<div className="cta">
|
|
23
|
+
{review.chapters.length > 0 && (
|
|
24
|
+
<button className="btn primary" onClick={() => go({ chapter: review.chapters[0].id })}>{Icon.play} Start the tour</button>
|
|
25
|
+
)}
|
|
26
|
+
<button className="btn ghost" onClick={() => go("changes")}>Skip to changes</button>
|
|
27
|
+
</div>
|
|
28
|
+
</section>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// the most changed files first, each a link to its diff; a long list shows the first few until asked
|
|
33
|
+
function Files() {
|
|
34
|
+
const { review, go } = useNav();
|
|
35
|
+
const [all, setAll] = useState(false);
|
|
36
|
+
const files = [...pr.files].sort((a, b) => b.add + b.del - (a.add + a.del));
|
|
37
|
+
const shown = all || files.length <= 10 ? files : files.slice(0, 8);
|
|
38
|
+
const target = (path: string): Target | undefined => {
|
|
39
|
+
const g = review.groups.find((x) => x.file === path);
|
|
40
|
+
return g ? { group: g.id } : diffs[path] ? { group: `file-${path}` } : undefined;
|
|
41
|
+
};
|
|
42
|
+
return (
|
|
43
|
+
<Section label="Files" count={files.length}>
|
|
44
|
+
<div className="file-list">
|
|
45
|
+
{shown.map((f) => {
|
|
46
|
+
const to = target(f.path);
|
|
47
|
+
const line = <FileLine path={f.path} add={f.add} del={f.del} />;
|
|
48
|
+
return to
|
|
49
|
+
? <button key={f.path} className="li file-li" onClick={() => go(to)}>{line}</button>
|
|
50
|
+
: <div key={f.path} className="li file-li">{line}</div>;
|
|
51
|
+
})}
|
|
52
|
+
{shown.length < files.length && (
|
|
53
|
+
<button className="li file-li files-more" onClick={() => setAll(true)}>Show all {files.length} files</button>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</Section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const Lead = ({ children }: { children: ReactNode }) => <p className="lead">{children}</p>;
|
|
61
|
+
|
|
62
|
+
// before above after, each a full line; the label sits in a narrow gutter
|
|
63
|
+
export function BeforeAfter({ before, after }: { before: ReactNode; after: ReactNode }) {
|
|
64
|
+
return (
|
|
65
|
+
<dl className="ba">
|
|
66
|
+
<dt>Before</dt><dd>{before}</dd>
|
|
67
|
+
<dt className="after">After</dt><dd className="after">{after}</dd>
|
|
68
|
+
</dl>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "Chapter 2" or "Code" for a link target
|
|
73
|
+
export function useTargetLabel() {
|
|
74
|
+
const { review } = useNav();
|
|
75
|
+
return (t: Target) =>
|
|
76
|
+
t === "overview" ? "Overview" : t === "changes" ? "Code" : t === "finish" ? "Finish" : t === "pr" ? "Pull request" : "chapter" in t ? `Chapter ${review.chapters.findIndex((c) => c.id === t.chapter) + 1}` : "Code";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// a finding, or a claim someone made (a person, a bot, the description) that checking it in the box did not confirm
|
|
80
|
+
export interface AttentionItem { text?: ReactNode; detail?: ReactNode; to?: Target; claim?: { source: string; quote: string; status: "fail" | "neutral"; label?: string } }
|
|
81
|
+
// a claim that checking it in the box confirmed
|
|
82
|
+
export interface HeldClaim { source: string; quote: string; to?: Target }
|
|
83
|
+
|
|
84
|
+
// why there were no claims to check
|
|
85
|
+
function noClaimsText() {
|
|
86
|
+
if (!pr.number) return pr.body.trim() ? "The author's note makes no claims about the code." : "No claims to check: there is no note from the author.";
|
|
87
|
+
const talk = pr.comments.length + pr.reviews.length + pr.reviewComments.length > 0;
|
|
88
|
+
if (!pr.body.trim() && !talk) return "No claims to check: the PR has no description or review comments.";
|
|
89
|
+
return "Nothing in the description or comments makes a claim about the code.";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const VERDICT = { fail: "Not true", neutral: "Partly true" };
|
|
93
|
+
|
|
94
|
+
// titles only, open one to read why it matters; claims that held are one quiet line below
|
|
95
|
+
export function Attention({ items, held = [] }: { items: AttentionItem[]; held?: HeldClaim[] }) {
|
|
96
|
+
const { go } = useNav();
|
|
97
|
+
const label = useTargetLabel();
|
|
98
|
+
const [open, setOpen] = useState<number | null>(null);
|
|
99
|
+
const [heldOpen, setHeldOpen] = useState(false);
|
|
100
|
+
const n = held.length;
|
|
101
|
+
const noClaims = !n && !items.some((a) => a.claim);
|
|
102
|
+
return (
|
|
103
|
+
<Section label="Worth a close look">
|
|
104
|
+
{items.length > 0 && (
|
|
105
|
+
<div className="acc-list">
|
|
106
|
+
{items.map((a, i) => {
|
|
107
|
+
const on = open === i;
|
|
108
|
+
const title = a.claim
|
|
109
|
+
? <><span className="acc-title"><q className="claim-q">{a.claim.quote}</q> <span className="claim-by">{a.claim.source}</span></span><span className={`chip ${a.claim.status}`}>{a.claim.label ?? VERDICT[a.claim.status]}</span></>
|
|
110
|
+
: <span className="acc-title">{a.text}</span>;
|
|
111
|
+
if (!a.detail && !a.to) return <div key={i} className="acc"><div className="acc-head"><span className="acc-chev" />{title}</div></div>;
|
|
112
|
+
return (
|
|
113
|
+
<div key={i} className="acc" data-open={on || undefined}>
|
|
114
|
+
<button className="acc-head" aria-expanded={on} onClick={() => setOpen(on ? null : i)}>
|
|
115
|
+
<span className="acc-chev">{Icon.chevron}</span>
|
|
116
|
+
{title}
|
|
117
|
+
</button>
|
|
118
|
+
<div className="acc-body" inert={!on}>
|
|
119
|
+
<div>
|
|
120
|
+
<div className="acc-inner">
|
|
121
|
+
{a.detail && <p className="prose">{a.detail}</p>}
|
|
122
|
+
{a.to && <button className="btn sm" onClick={() => go(a.to!)}>{Icon.play} {label(a.to)}</button>}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
})}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{noClaims && <p className="held-none">{noClaimsText()}</p>}
|
|
132
|
+
{n > 0 && (
|
|
133
|
+
<div className="held" data-open={heldOpen || undefined}>
|
|
134
|
+
<button className="held-btn" aria-expanded={heldOpen} onClick={() => setHeldOpen(!heldOpen)}>
|
|
135
|
+
<span className="held-ok">{Icon.check}</span>
|
|
136
|
+
{items.some((a) => a.claim) ? "Also checked" : "Checked"} {n} {n === 1 ? "claim" : "claims"}. {n === 1 ? "It holds." : n === 2 ? "Both hold." : "All hold."}
|
|
137
|
+
<span className="acc-chev">{Icon.chevron}</span>
|
|
138
|
+
</button>
|
|
139
|
+
<div className="acc-body" inert={!heldOpen}>
|
|
140
|
+
<div>
|
|
141
|
+
<ul className="held-list">
|
|
142
|
+
{held.map((c, i) => (
|
|
143
|
+
<li key={i}>
|
|
144
|
+
<span className="held-ok">{Icon.check}</span>
|
|
145
|
+
<span className="held-text"><span className="claim-by">{c.source}</span> <q>{c.quote}</q></span>
|
|
146
|
+
{c.to && <button className="link" onClick={() => go(c.to!)}>{label(c.to)}</button>}
|
|
147
|
+
</li>
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</Section>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// any content in a quiet card, e.g. a flow or a screenshot
|
|
159
|
+
export const Card = ({ children, caption }: { children: ReactNode; caption?: ReactNode }) => (
|
|
160
|
+
<div className="card pad">{children}{caption && <div className="cap">{caption}</div>}</div>
|
|
161
|
+
);
|