basis-design 0.1.0 → 0.2.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/src/bridge.ts CHANGED
@@ -1,163 +1,173 @@
1
- import { useCallback, useEffect, useReducer, useRef } from "react";
2
- import type {
3
- AgentState,
4
- Baseline,
5
- ChatItem,
6
- ClientMessage,
7
- DesignState,
8
- HistoryEntry,
9
- ExportWriteResult,
10
- ProjectEntry,
11
- ServerMessage,
12
- Snapshot,
13
- } from "../shared/protocol.ts";
14
- import type { ReviewState } from "../shared/review.ts";
15
-
16
- export interface BridgeState {
17
- connected: boolean;
18
- /** The open project, and every project on the list. */
19
- project: ProjectEntry | null;
20
- projects: ProjectEntry[];
21
- /** The project being opened, while Basis switches to it. */
22
- switching: ProjectEntry | null;
23
- /** Why the last project request failed, until the next one. */
24
- /** The last failed project request's answer; a new object each time, so a repeated message is a new answer. */
25
- projectError: { message: string } | null;
26
- design: DesignState | null;
27
- history: HistoryEntry[];
28
- agent: AgentState | null;
29
- chat: ChatItem[];
30
- /** History snapshots fetched for inspection, by entry id. */
31
- snapshots: Record<string, Snapshot>;
32
- /** The open review, if any. */
33
- review: ReviewState | null;
34
- /** Past versions materialised for reviews, by commit id. */
35
- baselines: Record<string, Baseline>;
36
- /** Port of the sandboxed preview origin, from the server. */
37
- previewPort: number | null;
38
- exportManifest: { folder: string; files: { path: string; bytes: number }[] } | null;
39
- exportResult: ExportWriteResult | null;
40
- }
41
-
42
- const initial: BridgeState = {
43
- connected: false,
44
- project: null,
45
- projects: [],
46
- switching: null,
47
- projectError: null,
48
- design: null,
49
- history: [],
50
- agent: null,
51
- chat: [],
52
- snapshots: {},
53
- review: null,
54
- baselines: {},
55
- previewPort: null,
56
- exportManifest: null,
57
- exportResult: null,
58
- };
59
-
60
- type Action = ServerMessage | { type: "connected"; connected: boolean };
61
-
62
- function reduce(state: BridgeState, action: Action): BridgeState {
63
- switch (action.type) {
64
- case "connected":
65
- return { ...state, connected: action.connected };
66
- case "hello": {
67
- // A hello for another project replaces everything that belonged to the previous one.
68
- const same = state.project?.id === action.project.id;
69
- return {
70
- ...(same ? state : { ...initial, projectError: state.projectError }),
71
- connected: true,
72
- project: action.project,
73
- projects: action.projects,
74
- switching: null,
75
- previewPort: action.previewPort,
76
- design: action.design,
77
- history: action.history,
78
- agent: action.agent,
79
- chat: action.chat,
80
- review: action.review,
81
- // The server may have restarted and removed its copies of past versions; ask for them again.
82
- baselines: {},
83
- };
84
- }
85
- case "projects":
86
- // The open project's entry can change too (a product repo linked).
87
- return {
88
- ...state,
89
- projects: action.projects,
90
- project: action.projects.find((entry) => entry.id === state.project?.id) ?? state.project,
91
- };
92
- case "project-switching":
93
- return { ...state, switching: action.project, projectError: null };
94
- case "project-error":
95
- return { ...state, projectError: { message: action.message } };
96
- case "design":
97
- return { ...state, design: action.design };
98
- case "history":
99
- return { ...state, history: action.history };
100
- case "agent":
101
- return { ...state, agent: action.agent };
102
- case "chat-reset":
103
- return { ...state, chat: [] };
104
- case "chat-upsert": {
105
- const index = state.chat.findIndex((item) => item.id === action.item.id);
106
- if (index === -1) return { ...state, chat: [...state.chat, action.item] };
107
- const chat = state.chat.slice();
108
- chat[index] = action.item;
109
- return { ...state, chat };
110
- }
111
- case "export-manifest":
112
- return { ...state, exportManifest: { folder: action.folder, files: action.files } };
113
- case "export-result":
114
- return { ...state, exportResult: action.result };
115
- case "review":
116
- // A review that ends takes the server's copies of past versions with it.
117
- return { ...state, review: action.review, baselines: action.review ? state.baselines : {} };
118
- case "chat-remove":
119
- return { ...state, chat: state.chat.filter((item) => item.id !== action.id) };
120
- case "baseline":
121
- return { ...state, baselines: { ...state.baselines, [action.baseline.id]: action.baseline } };
122
- case "snapshot":
123
- return { ...state, snapshots: { ...state.snapshots, [action.snapshot.id]: action.snapshot } };
124
- }
125
- }
126
-
127
- /** Keeps a WebSocket to the Basis server open and mirrors its state. */
128
- export function useBridge(): { state: BridgeState; send: (message: ClientMessage) => void } {
129
- const [state, dispatch] = useReducer(reduce, initial);
130
- const socket = useRef<WebSocket | null>(null);
131
-
132
- useEffect(() => {
133
- let closed = false;
134
- let retry: ReturnType<typeof setTimeout> | undefined;
135
- const connect = () => {
136
- const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/bridge`);
137
- socket.current = ws;
138
- ws.onmessage = (event) => dispatch(JSON.parse(String(event.data)) as ServerMessage);
139
- ws.onclose = () => {
140
- dispatch({ type: "connected", connected: false });
141
- if (!closed) retry = setTimeout(connect, 1000);
142
- };
143
- };
144
- connect();
145
- return () => {
146
- closed = true;
147
- clearTimeout(retry);
148
- const ws = socket.current;
149
- if (!ws) return;
150
- ws.onmessage = null;
151
- ws.onclose = null;
152
- // Closing a socket that is still connecting logs a browser warning, so let it open first.
153
- if (ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close();
154
- else ws.close();
155
- };
156
- }, []);
157
-
158
- const send = useCallback((message: ClientMessage) => {
159
- if (socket.current?.readyState === WebSocket.OPEN) socket.current.send(JSON.stringify(message));
160
- }, []);
161
-
162
- return { state, send };
163
- }
1
+ import { useCallback, useEffect, useReducer, useRef } from "react";
2
+ import type {
3
+ AgentState,
4
+ Baseline,
5
+ ChatItem,
6
+ ClientMessage,
7
+ DesignState,
8
+ HistoryEntry,
9
+ ExportWriteResult,
10
+ ProjectEntry,
11
+ ServerMessage,
12
+ Snapshot,
13
+ VerifyKitResult,
14
+ } from "../shared/protocol.ts";
15
+ import type { ReviewState } from "../shared/review.ts";
16
+
17
+ export interface BridgeState {
18
+ connected: boolean;
19
+ /** The open project, and every project on the list. */
20
+ project: ProjectEntry | null;
21
+ projects: ProjectEntry[];
22
+ /** The project being opened, while Basis switches to it. */
23
+ switching: ProjectEntry | null;
24
+ /** Why the last project request failed, until the next one. */
25
+ /** The last failed project request's answer; a new object each time, so a repeated message is a new answer. */
26
+ projectError: { message: string } | null;
27
+ design: DesignState | null;
28
+ history: HistoryEntry[];
29
+ agent: AgentState | null;
30
+ chat: ChatItem[];
31
+ /** History snapshots fetched for inspection, by entry id. */
32
+ snapshots: Record<string, Snapshot>;
33
+ /** The open review, if any. */
34
+ review: ReviewState | null;
35
+ /** Past versions materialised for reviews, by commit id. */
36
+ baselines: Record<string, Baseline>;
37
+ /** Port of the sandboxed preview origin, from the server. */
38
+ previewPort: number | null;
39
+ exportManifest: { folder: string; files: { path: string; bytes: number }[] } | null;
40
+ exportResult: ExportWriteResult | null;
41
+ /** The verification kit being built (its current step), or null. */
42
+ verifyProgress: { step: string; done?: number; total?: number } | null;
43
+ verifyResult: VerifyKitResult | null;
44
+ }
45
+
46
+ const initial: BridgeState = {
47
+ connected: false,
48
+ project: null,
49
+ projects: [],
50
+ switching: null,
51
+ projectError: null,
52
+ design: null,
53
+ history: [],
54
+ agent: null,
55
+ chat: [],
56
+ snapshots: {},
57
+ review: null,
58
+ baselines: {},
59
+ previewPort: null,
60
+ exportManifest: null,
61
+ exportResult: null,
62
+ verifyProgress: null,
63
+ verifyResult: null,
64
+ };
65
+
66
+ type Action = ServerMessage | { type: "connected"; connected: boolean };
67
+
68
+ function reduce(state: BridgeState, action: Action): BridgeState {
69
+ switch (action.type) {
70
+ case "connected":
71
+ return { ...state, connected: action.connected };
72
+ case "hello": {
73
+ // A hello for another project replaces everything that belonged to the previous one.
74
+ const same = state.project?.id === action.project.id;
75
+ return {
76
+ ...(same ? state : { ...initial, projectError: state.projectError }),
77
+ connected: true,
78
+ project: action.project,
79
+ projects: action.projects,
80
+ switching: null,
81
+ previewPort: action.previewPort,
82
+ design: action.design,
83
+ history: action.history,
84
+ agent: action.agent,
85
+ chat: action.chat,
86
+ review: action.review,
87
+ // The server may have restarted and removed its copies of past versions; ask for them again.
88
+ baselines: {},
89
+ };
90
+ }
91
+ case "projects":
92
+ // The open project's entry can change too (a product repo linked).
93
+ return {
94
+ ...state,
95
+ projects: action.projects,
96
+ project: action.projects.find((entry) => entry.id === state.project?.id) ?? state.project,
97
+ };
98
+ case "project-switching":
99
+ return { ...state, switching: action.project, projectError: null };
100
+ case "project-error":
101
+ return { ...state, projectError: { message: action.message } };
102
+ case "design":
103
+ return { ...state, design: action.design };
104
+ case "history":
105
+ return { ...state, history: action.history };
106
+ case "agent":
107
+ return { ...state, agent: action.agent };
108
+ case "chat-reset":
109
+ return { ...state, chat: [] };
110
+ case "chat-upsert": {
111
+ const index = state.chat.findIndex((item) => item.id === action.item.id);
112
+ if (index === -1) return { ...state, chat: [...state.chat, action.item] };
113
+ const chat = state.chat.slice();
114
+ chat[index] = action.item;
115
+ return { ...state, chat };
116
+ }
117
+ case "export-manifest":
118
+ return { ...state, exportManifest: { folder: action.folder, files: action.files } };
119
+ case "export-result":
120
+ return { ...state, exportResult: action.result };
121
+ case "verify-progress":
122
+ return { ...state, verifyProgress: { step: action.step, done: action.done, total: action.total } };
123
+ case "verify-result":
124
+ return { ...state, verifyProgress: null, verifyResult: action.result };
125
+ case "review":
126
+ // A review that ends takes the server's copies of past versions with it.
127
+ return { ...state, review: action.review, baselines: action.review ? state.baselines : {} };
128
+ case "chat-remove":
129
+ return { ...state, chat: state.chat.filter((item) => item.id !== action.id) };
130
+ case "baseline":
131
+ return { ...state, baselines: { ...state.baselines, [action.baseline.id]: action.baseline } };
132
+ case "snapshot":
133
+ return { ...state, snapshots: { ...state.snapshots, [action.snapshot.id]: action.snapshot } };
134
+ }
135
+ }
136
+
137
+ /** Keeps a WebSocket to the Basis server open and mirrors its state. */
138
+ export function useBridge(): { state: BridgeState; send: (message: ClientMessage) => void } {
139
+ const [state, dispatch] = useReducer(reduce, initial);
140
+ const socket = useRef<WebSocket | null>(null);
141
+
142
+ useEffect(() => {
143
+ let closed = false;
144
+ let retry: ReturnType<typeof setTimeout> | undefined;
145
+ const connect = () => {
146
+ const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/bridge`);
147
+ socket.current = ws;
148
+ ws.onmessage = (event) => dispatch(JSON.parse(String(event.data)) as ServerMessage);
149
+ ws.onclose = () => {
150
+ dispatch({ type: "connected", connected: false });
151
+ if (!closed) retry = setTimeout(connect, 1000);
152
+ };
153
+ };
154
+ connect();
155
+ return () => {
156
+ closed = true;
157
+ clearTimeout(retry);
158
+ const ws = socket.current;
159
+ if (!ws) return;
160
+ ws.onmessage = null;
161
+ ws.onclose = null;
162
+ // Closing a socket that is still connecting logs a browser warning, so let it open first.
163
+ if (ws.readyState === WebSocket.CONNECTING) ws.onopen = () => ws.close();
164
+ else ws.close();
165
+ };
166
+ }, []);
167
+
168
+ const send = useCallback((message: ClientMessage) => {
169
+ if (socket.current?.readyState === WebSocket.OPEN) socket.current.send(JSON.stringify(message));
170
+ }, []);
171
+
172
+ return { state, send };
173
+ }
@@ -0,0 +1,50 @@
1
+ /*
2
+ * The page a captured screen renders on, and nothing else: no gallery around it, no transitions, no caret. The
3
+ * base below is what a screen inherits; an implementation that is checked against the screenshots needs the
4
+ * same (the kit's manifest repeats it as `base`).
5
+ */
6
+
7
+ *,
8
+ *::before,
9
+ *::after {
10
+ box-sizing: border-box;
11
+ transition: none !important;
12
+ animation: none !important;
13
+ caret-color: transparent !important;
14
+ }
15
+
16
+ html,
17
+ body {
18
+ margin: 0;
19
+ }
20
+
21
+ html {
22
+ scrollbar-width: none;
23
+ }
24
+
25
+ html::-webkit-scrollbar {
26
+ display: none;
27
+ }
28
+
29
+ .ds-root {
30
+ min-height: 100vh;
31
+ background: var(--color-bg);
32
+ color: var(--color-text);
33
+ font-family: var(--font-sans);
34
+ font-size: var(--text-base);
35
+ line-height: var(--leading-normal);
36
+ color-scheme: light;
37
+ }
38
+
39
+ .ds-root[data-theme="dark"] {
40
+ color-scheme: dark;
41
+ }
42
+
43
+ .capture-problem {
44
+ padding: 24px;
45
+ font:
46
+ 14px/1.5 ui-monospace,
47
+ Consolas,
48
+ monospace;
49
+ color: #b42318;
50
+ }
@@ -0,0 +1,146 @@
1
+ import { Component, useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { captureShots, READY_ATTRIBUTE, type CaptureView } from "../../shared/capture.ts";
4
+ import { parseDesignText, type Design } from "../../shared/design.ts";
5
+ import { previewCss } from "../../shared/tokens.ts";
6
+ import type { ScreenProps, SpecimenBlock } from "../../template/components/specimen.ts";
7
+ import { loadSpecimens, type LoadedSpecimen } from "../preview/specimens.tsx";
8
+ import "./capture.css";
9
+
10
+ /*
11
+ * One screen of a view, alone on the page at the size the browser gives it, for the verification kit's
12
+ * screenshots: `?view=<view id>&screen=<screen id>&theme=light|dark`. `?list` instead reports every view and
13
+ * the shots its screens ask for (window.__basisViews). The design comes from design.json beside this page, so
14
+ * the same page runs on Basis' preview server and as the kit's static copy of the design.
15
+ */
16
+
17
+ declare global {
18
+ interface Window {
19
+ __basisViews?: { views: CaptureView[]; problems: string[] };
20
+ }
21
+ }
22
+
23
+ const params = new URLSearchParams(location.search);
24
+ type FlowBlock = Extract<SpecimenBlock, { kind: "flow" }>;
25
+
26
+ function flows(specimen: LoadedSpecimen): FlowBlock[] {
27
+ return specimen.blocks.filter((block): block is FlowBlock => block.kind === "flow");
28
+ }
29
+
30
+ function views(): { views: CaptureView[]; problems: string[] } {
31
+ const { specimens, problems } = loadSpecimens();
32
+ const list = specimens
33
+ .filter((specimen) => specimen.page === "views")
34
+ .map((specimen) => ({
35
+ id: specimen.id,
36
+ title: specimen.title,
37
+ screens: flows(specimen).flatMap((flow) =>
38
+ flow.screens.map((screen) => ({ id: screen.id, label: screen.label, shots: captureShots(flow.capture) })),
39
+ ),
40
+ }))
41
+ .filter((view) => view.screens.length > 0);
42
+ return { views: list, problems: problems.map((problem) => `${problem.file}: ${problem.message}`) };
43
+ }
44
+
45
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
46
+
47
+ /**
48
+ * Marks the page ready once it has settled: fonts loaded, images decoded, and the layout the same size twice in
49
+ * a row. Timers rather than animation frames, which a background tab does not run.
50
+ */
51
+ async function markReady(): Promise<void> {
52
+ await document.fonts.ready;
53
+ await Promise.all([...document.images].map((image) => image.decode().catch(() => undefined)));
54
+ let last = "";
55
+ for (let attempt = 0; attempt < 40; attempt++) {
56
+ await wait(50);
57
+ const size = `${document.documentElement.scrollWidth}x${document.documentElement.scrollHeight}`;
58
+ if (size === last) break;
59
+ last = size;
60
+ }
61
+ document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
62
+ }
63
+
64
+ class Boundary extends Component<{ children: ReactNode }, { error: unknown }> {
65
+ state = { error: null as unknown };
66
+ static getDerivedStateFromError(error: unknown) {
67
+ return { error };
68
+ }
69
+ componentDidCatch() {
70
+ document.documentElement.setAttribute("data-basis-error", "true");
71
+ }
72
+ render() {
73
+ const { error } = this.state;
74
+ if (error === null) return this.props.children;
75
+ return <p className="capture-problem">This screen failed to render: {String(error)}</p>;
76
+ }
77
+ }
78
+
79
+ function Screen({ design, theme }: { design: Design; theme: "light" | "dark" }) {
80
+ const { specimens } = useMemo(() => loadSpecimens(), []);
81
+ const view = specimens.find((specimen) => specimen.page === "views" && specimen.id === params.get("view"));
82
+ const screens = view ? flows(view).flatMap((flow) => flow.screens) : [];
83
+ // The kit's copy of the design is clickable: `go` moves through the flow as in the gallery.
84
+ const [position, setPosition] = useState<{ id: string; data?: unknown }>({
85
+ id: params.get("screen") ?? screens[0]?.id ?? "",
86
+ });
87
+ const screen = screens.find((item) => item.id === position.id);
88
+
89
+ useEffect(() => {
90
+ void markReady();
91
+ }, [position]);
92
+
93
+ if (!view || !screen) {
94
+ return (
95
+ <p className="capture-problem">
96
+ No screen "{position.id}" in the view "{params.get("view")}".
97
+ </p>
98
+ );
99
+ }
100
+ const go: ScreenProps["go"] = (id, data) => {
101
+ const next = new URLSearchParams(location.search);
102
+ next.set("screen", id);
103
+ history.replaceState(null, "", `?${next}`);
104
+ document.documentElement.removeAttribute(READY_ATTRIBUTE);
105
+ setPosition({ id, data });
106
+ };
107
+ const Render = screen.render;
108
+ return (
109
+ <div className="ds-root" data-theme={theme}>
110
+ <Boundary key={screen.id}>
111
+ <Render design={design as unknown as ScreenProps["design"]} go={go} data={position.data} />
112
+ </Boundary>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function Page({ design }: { design: Design }) {
118
+ const theme = params.get("theme") === "dark" ? "dark" : "light";
119
+ return (
120
+ <>
121
+ <style>{previewCss(design, ".ds-root")}</style>
122
+ {design.typography.fontImports.map((href) => (
123
+ <link key={href} rel="stylesheet" href={href} />
124
+ ))}
125
+ <Screen design={design} theme={theme} />
126
+ </>
127
+ );
128
+ }
129
+
130
+ async function start(): Promise<void> {
131
+ const root = createRoot(document.getElementById("root")!);
132
+ if (params.has("list")) {
133
+ window.__basisViews = views();
134
+ document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
135
+ return;
136
+ }
137
+ const result = parseDesignText(await (await fetch("./design.json", { cache: "no-store" })).text());
138
+ if (!result.ok) {
139
+ root.render(<p className="capture-problem">design.json is not valid: {result.errors.join("; ")}</p>);
140
+ document.documentElement.setAttribute(READY_ATTRIBUTE, "true");
141
+ return;
142
+ }
143
+ root.render(<Page design={result.design} />);
144
+ }
145
+
146
+ void start();
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef } from "react";
1
+ import { useLayoutEffect, useRef, type ReactNode } from "react";
2
2
  import type { Annotation } from "../../shared/annotations.ts";
3
3
  import {
4
4
  AGENT_IDS,
@@ -38,6 +38,8 @@ interface ChatPanelProps {
38
38
  onTogglePick: () => void;
39
39
  /** True while a review is open. */
40
40
  reviewing: boolean;
41
+ /** The handle that resizes the panel; inside it, so it belongs to the panel's landmark. */
42
+ resizer?: ReactNode;
41
43
  }
42
44
 
43
45
  const STATUS_TEXT: Record<AgentState["status"], string> = {
@@ -58,6 +60,7 @@ export function ChatPanel({
58
60
  onDraft,
59
61
  send,
60
62
  composerRef,
63
+ resizer,
61
64
  ...composer
62
65
  }: ChatPanelProps) {
63
66
  const scroller = useRef<HTMLDivElement>(null);
@@ -116,9 +119,14 @@ export function ChatPanel({
116
119
  </div>
117
120
  )}
118
121
 
122
+ {/* Focusable, so the conversation scrolls from the keyboard too; not role="log", which would read the
123
+ streamed reply aloud as it arrives. */}
119
124
  <div
120
125
  ref={scroller}
121
126
  className={styles.log}
127
+ role="region"
128
+ aria-label="Conversation"
129
+ tabIndex={0}
122
130
  onScroll={(event) => {
123
131
  const el = event.currentTarget;
124
132
  pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
@@ -213,6 +221,7 @@ export function ChatPanel({
213
221
  }}
214
222
  {...composer}
215
223
  />
224
+ {resizer}
216
225
  </aside>
217
226
  );
218
227
  }
@@ -53,6 +53,26 @@ function isComponent(value: unknown): value is ComponentType<never> {
53
53
  return typeof value === "function" || (typeof value === "object" && value !== null && "$$typeof" in value);
54
54
  }
55
55
 
56
+ /** What is wrong with a flow block's `capture`, or null when it is absent or well formed. */
57
+ function captureProblem(capture: unknown): string | null {
58
+ if (capture === undefined) return null;
59
+ if (!capture || typeof capture !== "object") return "capture must be an object: { widths, height, themes }";
60
+ const { widths, height, themes } = capture as Record<string, unknown>;
61
+ if (widths !== undefined && (!Array.isArray(widths) || widths.some((width) => typeof width !== "number"))) {
62
+ return "capture.widths must be an array of numbers (viewport widths in px)";
63
+ }
64
+ if (height !== undefined && height !== "full" && typeof height !== "number") {
65
+ return 'capture.height must be "full" or a number (viewport height in px)';
66
+ }
67
+ if (
68
+ themes !== undefined &&
69
+ (!Array.isArray(themes) || themes.some((theme) => theme !== "light" && theme !== "dark"))
70
+ ) {
71
+ return 'capture.themes must be an array of "light" and "dark"';
72
+ }
73
+ return null;
74
+ }
75
+
56
76
  /** Why a block cannot be rendered, or null when it is well formed. */
57
77
  function blockProblem(block: unknown): string | null {
58
78
  if (!block || typeof block !== "object") return "a block is not an object";
@@ -78,6 +98,7 @@ function blockProblem(block: unknown): string | null {
78
98
  if (screens.some((screen) => !screen || typeof screen.id !== "string" || !isComponent(screen.render))) {
79
99
  return "every screen needs an id, a label and a render component";
80
100
  }
101
+ return captureProblem(b.capture);
81
102
  } else if (!isComponent(b.render)) {
82
103
  return `a ${String(b.kind)} block needs a render component`;
83
104
  }