@hraness/design-kit 0.31.1 → 0.32.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.
Files changed (42) hide show
  1. package/AGENT_SETUP.md +89 -0
  2. package/ARTICLE_COPY.md +1 -1
  3. package/MARKETING_COPY.md +13 -1
  4. package/README.md +13 -4
  5. package/dist/{chunk-7b1586eb.js → chunk-4d79eskk.js} +62 -33
  6. package/dist/{chunk-44djxk16.js → chunk-95p0j72v.js} +643 -612
  7. package/dist/{chunk-eh71jz57.js → chunk-vst6p4wd.js} +96 -9
  8. package/dist/index.js +6 -2
  9. package/dist/mockups/client.js +19 -18
  10. package/dist/provider-marks.js +1 -1
  11. package/dist/react/index.js +1615 -649
  12. package/dist/react/server.js +4 -2
  13. package/dist/stylex-manifest.json +1 -1
  14. package/dist/stylex.css +180 -4
  15. package/package.json +7 -3
  16. package/portfolio-inventory.json +1 -1
  17. package/src/agent-setup.ts +47 -0
  18. package/src/index.ts +1 -0
  19. package/src/mockups/client.tsx +26 -22
  20. package/src/mockups.css +44 -13
  21. package/src/provider-marks.generated.ts +16 -0
  22. package/src/provider-marks.ts +14 -15
  23. package/src/react/agent-setup-prompt.stylex.ts +196 -0
  24. package/src/react/agent-setup-prompt.tsx +413 -0
  25. package/src/react/article.tsx +12 -20
  26. package/src/react/design-gallery.tsx +28 -1
  27. package/src/react/index.ts +1 -0
  28. package/src/react/launch-beats.tsx +7 -4
  29. package/src/react/product-marketing.stylex.ts +8 -0
  30. package/src/react/product-marketing.tsx +37 -10
  31. package/src/react/provider-mark.stylex.ts +17 -10
  32. package/src/react/provider-mark.tsx +6 -16
  33. package/vendor/provider-marks/MEM0-LICENSE +201 -0
  34. package/vendor/provider-marks/SUPERMEMORY-LICENSE +21 -0
  35. package/vendor/provider-marks/UPSTREAM.md +12 -1
  36. package/vendor/provider-marks/lobehub/githubcopilot.svg +1 -0
  37. package/vendor/provider-marks/lobehub/obsidian-color.svg +1 -0
  38. package/vendor/provider-marks/lobehub/obsidian.svg +1 -0
  39. package/vendor/provider-marks/mem0-art.svg +1 -0
  40. package/vendor/provider-marks/mem0-glyph.svg +1 -0
  41. package/vendor/provider-marks/mem0.svg +1 -0
  42. package/vendor/provider-marks/supermemory.svg +1 -0
@@ -0,0 +1,413 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
4
+
5
+ import type { ProviderMarkDescriptor } from "../provider-marks.js";
6
+ import { agentSetupClassName as cx } from "./agent-setup-prompt.stylex.js";
7
+ import { ProviderMark } from "./provider-mark.js";
8
+ import { SyntaxCode } from "./syntax-code.js";
9
+
10
+ export interface AgentSetupTarget {
11
+ readonly id: string;
12
+ readonly label: string;
13
+ readonly mark: ProviderMarkDescriptor | string;
14
+ /** The complete destination, including any caller-authored prompt prefill. */
15
+ readonly href: string;
16
+ readonly mode?: "prefill" | "copy-and-open";
17
+ }
18
+
19
+ export interface AgentSetupPromptProps {
20
+ readonly prompt: string;
21
+ readonly label?: string;
22
+ readonly targets?: readonly AgentSetupTarget[];
23
+ readonly className?: string;
24
+ /** Runs once after a successful copy; callback failures do not change copy feedback. */
25
+ readonly onCopied?: () => void;
26
+ }
27
+
28
+ export interface AgentCommand {
29
+ readonly id: string;
30
+ readonly label: string;
31
+ readonly mark: ProviderMarkDescriptor | string;
32
+ readonly command: string;
33
+ /** A configuration path when the source is file content instead of a shell command. */
34
+ readonly filename?: string;
35
+ }
36
+
37
+ export interface AgentCommandTabsProps {
38
+ readonly commands: readonly AgentCommand[];
39
+ readonly label?: string;
40
+ readonly initial?: string;
41
+ readonly className?: string;
42
+ }
43
+
44
+ type CopyState = "idle" | "copying" | "copied" | "failed";
45
+ type CopyResult = Readonly<{ ok: boolean; selected: boolean }>;
46
+
47
+ function commandSubject(entry: AgentCommand): string {
48
+ return `${entry.label} ${entry.filename === undefined ? "command" : "configuration"}`;
49
+ }
50
+
51
+ function assertText(value: string, name: string): void {
52
+ if (typeof value !== "string" || value.trim() === "") throw new RangeError(`${name} must contain text.`);
53
+ }
54
+
55
+ function assertEntries(entries: readonly { id: string; label: string }[], name: string): void {
56
+ const ids = new Set<string>();
57
+ for (const entry of entries) {
58
+ if (typeof entry.id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/u.test(entry.id) || ids.has(entry.id)) throw new RangeError(`${name} ids must be unique and use letters, numbers, underscores, or hyphens.`);
59
+ assertText(entry.label, `${name} label`);
60
+ ids.add(entry.id);
61
+ }
62
+ }
63
+
64
+ function assertHref(href: string): void {
65
+ if (typeof href !== "string" || /\s/u.test(href) || [...href].some((character) => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f)) throw new RangeError("Agent setup destinations must be valid links.");
66
+ try {
67
+ const url = new URL(href, "https://example.invalid");
68
+ if (!["https:", "http:", "codex:"].includes(url.protocol)) throw new Error("Unsupported protocol.");
69
+ if (href === "" || url.username !== "" || url.password !== "") throw new Error("Invalid destination.");
70
+ } catch {
71
+ throw new RangeError("Agent setup destinations must be HTTP, HTTPS, or Codex links without credentials.");
72
+ }
73
+ }
74
+
75
+ /** The source node contains the complete string, even while its preview is clipped. */
76
+ async function copyText(text: string, fallback: () => HTMLElement | null): Promise<CopyResult> {
77
+ try {
78
+ if (typeof navigator !== "undefined" && navigator.clipboard !== undefined) {
79
+ await navigator.clipboard.writeText(text);
80
+ return { ok: true, selected: false };
81
+ }
82
+ } catch {
83
+ // A rendered source remains available when clipboard permission is denied.
84
+ }
85
+ const source = fallback();
86
+ if (source === null) return { ok: false, selected: false };
87
+ const documentValue = source.ownerDocument;
88
+ const focused = documentValue.activeElement as HTMLElement | null;
89
+ let buffer: HTMLTextAreaElement | null = null;
90
+ let copied = false;
91
+ let exactEvent = false;
92
+ const copyExact = (event: ClipboardEvent) => {
93
+ if (documentValue.activeElement !== buffer || event.clipboardData === null) return;
94
+ try {
95
+ event.clipboardData.setData("text/plain", text);
96
+ event.preventDefault();
97
+ exactEvent = event.defaultPrevented;
98
+ } catch { /* A matching textarea value can still use native copying. */ }
99
+ };
100
+ try {
101
+ buffer = documentValue.createElement("textarea");
102
+ buffer.value = text;
103
+ buffer.readOnly = true;
104
+ buffer.tabIndex = -1;
105
+ buffer.setAttribute("data-hraness-copy-buffer", "");
106
+ buffer.style.cssText = "position:fixed;left:0;top:0;width:1px;height:1px;padding:0;border:0;opacity:0;overflow:hidden;resize:none;pointer-events:none";
107
+ documentValue.body.append(buffer);
108
+ buffer.focus({ preventScroll: true });
109
+ buffer.select();
110
+ buffer.setSelectionRange(0, buffer.value.length);
111
+ documentValue.addEventListener("copy", copyExact);
112
+ copied = documentValue.execCommand("copy") === true && (buffer.value === text || exactEvent);
113
+ } catch {
114
+ copied = false;
115
+ } finally {
116
+ documentValue.removeEventListener("copy", copyExact);
117
+ buffer?.remove();
118
+ if (copied && focused?.isConnected === true && typeof focused.focus === "function") {
119
+ try { focused.focus({ preventScroll: true }); } catch { /* Copy success does not depend on focus restoration. */ }
120
+ }
121
+ }
122
+ if (copied) return { ok: true, selected: false };
123
+ let selected = false;
124
+ try {
125
+ const selection = documentValue.defaultView?.getSelection();
126
+ if (selection !== null && selection !== undefined) {
127
+ const range = documentValue.createRange();
128
+ range.selectNodeContents(source);
129
+ selection.removeAllRanges();
130
+ selection.addRange(range);
131
+ source.focus();
132
+ selected = true;
133
+ }
134
+ return { ok: false, selected };
135
+ } catch {
136
+ return { ok: false, selected };
137
+ }
138
+ }
139
+
140
+ function useCopy(text: string, subject: string, fallback: () => HTMLElement | null, onCopied?: () => void) {
141
+ const [state, setState] = useState<CopyState>("idle");
142
+ const [message, setMessage] = useState("");
143
+ const generation = useRef(0);
144
+ const busy = useRef(false);
145
+ const latestText = useRef(text);
146
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
147
+ latestText.current = text;
148
+
149
+ useEffect(() => {
150
+ generation.current += 1;
151
+ busy.current = false;
152
+ setState("idle");
153
+ setMessage("");
154
+ return () => {
155
+ generation.current += 1;
156
+ busy.current = false;
157
+ if (timer.current !== null) clearTimeout(timer.current);
158
+ };
159
+ }, [text, subject]);
160
+
161
+ const copy = useCallback(async () => {
162
+ if (busy.current) return false;
163
+ if (timer.current !== null) clearTimeout(timer.current);
164
+ busy.current = true;
165
+ const request = ++generation.current;
166
+ setState("copying");
167
+ setMessage(`Copying ${subject}.`);
168
+ const result = await copyText(text, () => generation.current === request && latestText.current === text ? fallback() : null);
169
+ if (generation.current !== request || latestText.current !== text) return false;
170
+ busy.current = false;
171
+ setState(result.ok ? "copied" : "failed");
172
+ setMessage(result.ok ? `Copied ${subject}.` : result.selected
173
+ ? `Copy failed. The ${subject} is selected; copy it with your keyboard.`
174
+ : `Copy failed. Select the ${subject} and copy it with your keyboard.`);
175
+ if (result.ok && onCopied !== undefined) {
176
+ try { onCopied(); } catch { /* Copy succeeded independently of the caller's hook. */ }
177
+ }
178
+ timer.current = setTimeout(() => {
179
+ if (generation.current !== request) return;
180
+ setState("idle");
181
+ setMessage("");
182
+ }, 2000);
183
+ return result.ok && generation.current === request && latestText.current === text;
184
+ }, [fallback, onCopied, subject, text]);
185
+
186
+ return { state, message, copy };
187
+ }
188
+
189
+ function CopyGlyph({ copied }: Readonly<{ copied: boolean }>) {
190
+ return (
191
+ <svg aria-hidden="true" className={cx(["copyIcon"])} fill="none" focusable="false" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} viewBox="0 0 24 24">
192
+ {copied ? <path d="M5 12.5l4.5 4.5L19 7.5" /> : <><rect height="12" rx="2" width="12" x="8" y="8" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></>}
193
+ </svg>
194
+ );
195
+ }
196
+
197
+ function CopyButton({ copy, overlay = false, state, subject }: Readonly<{ copy: () => Promise<boolean>; overlay?: boolean; state: CopyState; subject: string }>) {
198
+ return (
199
+ <button aria-busy={state === "copying" || undefined} aria-label={`${state === "copied" ? "Copied" : "Copy"} ${subject}`} className={cx(["copy", overlay && "copyOverlay"])} data-copy-state={state} disabled={state === "copying"} onClick={() => { void copy(); }} type="button">
200
+ <CopyGlyph copied={state === "copied"} />
201
+ <span>{state === "copied" ? "Copied" : state === "copying" ? "Copying" : state === "failed" ? "Copy failed" : "Copy"}</span>
202
+ </button>
203
+ );
204
+ }
205
+
206
+ function CopyStatus({ children }: Readonly<{ children: ReactNode }>) {
207
+ return <p aria-atomic="true" aria-live="polite" className={cx(["status"])} role="status">{children}</p>;
208
+ }
209
+
210
+ function closeReservedTab(tab: Window | null): void {
211
+ try { if (tab !== null && !tab.closed) tab.close(); } catch { /* An already closed reservation needs no cleanup. */ }
212
+ }
213
+
214
+ function reserveTargetTab(owner: Window | null): Window | null {
215
+ let tab: Window | null = null;
216
+ try {
217
+ // Reserve during activation; async clipboard work can outlive popup permission.
218
+ tab = owner?.open("about:blank", "_blank") ?? null;
219
+ if (tab !== null) {
220
+ tab.opener = null;
221
+ const policy = tab.document.createElement("meta");
222
+ policy.name = "referrer";
223
+ policy.content = "no-referrer";
224
+ tab.document.head.append(policy);
225
+ }
226
+ return tab;
227
+ } catch {
228
+ closeReservedTab(tab);
229
+ return null;
230
+ }
231
+ }
232
+
233
+ /** A quiet prompt preview with the complete source behind a native disclosure. */
234
+ export function AgentSetupPrompt({ prompt, label = "Agent setup", targets = [], className, onCopied }: AgentSetupPromptProps) {
235
+ assertText(prompt, "Agent setup prompt");
236
+ assertText(label, "Agent setup label");
237
+ assertEntries(targets, "Agent setup target");
238
+ for (const target of targets) {
239
+ assertHref(target.href);
240
+ if (target.mode !== undefined && target.mode !== "prefill" && target.mode !== "copy-and-open") throw new RangeError("Unsupported agent setup target mode.");
241
+ }
242
+ const id = useId();
243
+ const [expanded, setExpanded] = useState(false);
244
+ const [handoffMessage, setHandoffMessage] = useState("");
245
+ const details = useRef<HTMLDetailsElement>(null);
246
+ const full = useRef<HTMLPreElement>(null);
247
+ const destinationKey = JSON.stringify(targets.map(({ id: targetId, href, mode }) => [targetId, href, mode ?? "prefill"]));
248
+ const latestHandoff = useRef({ prompt, destinationKey });
249
+ latestHandoff.current = { prompt, destinationKey };
250
+ const pendingHandoff = useRef<Readonly<{ tab: Window | null }> | null>(null);
251
+ const fallback = useCallback(() => {
252
+ if (details.current !== null) details.current.open = true;
253
+ setExpanded(true);
254
+ return full.current;
255
+ }, []);
256
+ const { state, message, copy } = useCopy(prompt, "setup prompt", fallback, onCopied);
257
+ const copyPrompt = useCallback(() => {
258
+ setHandoffMessage("");
259
+ return copy();
260
+ }, [copy]);
261
+
262
+ useEffect(() => {
263
+ setHandoffMessage("");
264
+ return () => {
265
+ const pending = pendingHandoff.current;
266
+ pendingHandoff.current = null;
267
+ closeReservedTab(pending?.tab ?? null);
268
+ };
269
+ }, [destinationKey, prompt]);
270
+
271
+ const copyAndOpen = (event: MouseEvent<HTMLAnchorElement>, target: AgentSetupTarget) => {
272
+ if (event.type === "auxclick" && event.button !== 1) return;
273
+ event.preventDefault();
274
+ if (pendingHandoff.current !== null || state === "copying") return;
275
+ const href = event.currentTarget.href;
276
+ // Begin clipboard work before a new foreground tab can blur this document.
277
+ const copying = copyPrompt();
278
+ const request = { tab: reserveTargetTab(event.currentTarget.ownerDocument.defaultView) };
279
+ pendingHandoff.current = request;
280
+ void (async () => {
281
+ const copied = await copying;
282
+ if (pendingHandoff.current !== request || latestHandoff.current.prompt !== prompt || latestHandoff.current.destinationKey !== destinationKey) {
283
+ closeReservedTab(request.tab);
284
+ return;
285
+ }
286
+ pendingHandoff.current = null;
287
+ if (!copied) {
288
+ closeReservedTab(request.tab);
289
+ return;
290
+ }
291
+ if (request.tab !== null && !request.tab.closed) {
292
+ try {
293
+ request.tab.location.replace(href);
294
+ return;
295
+ } catch { closeReservedTab(request.tab); }
296
+ }
297
+ setHandoffMessage(`Copied setup prompt. Open ${target.label} in a new tab.`);
298
+ })();
299
+ };
300
+
301
+ useEffect(() => {
302
+ if (typeof window.matchMedia !== "function") return;
303
+ const query = window.matchMedia("(forced-colors: active)");
304
+ const apply = () => {
305
+ if (query.matches) {
306
+ if (details.current !== null) details.current.open = true;
307
+ setExpanded(true);
308
+ }
309
+ };
310
+ apply();
311
+ query.addEventListener("change", apply);
312
+ return () => { query.removeEventListener("change", apply); };
313
+ }, []);
314
+
315
+ return (
316
+ <section aria-label={label} className={cx(["root"], className)} data-hraness-agent-setup-prompt="">
317
+ <div className={cx(["layout", targets.length > 0 && "withTargets"])}>
318
+ <div className={cx(["frame"])} data-copy-state={state}>
319
+ <div className={cx(["preview"])} hidden={expanded}>
320
+ <pre aria-label={`${label} preview`} className={cx(["pre", "previewText"])}>{prompt}</pre>
321
+ </div>
322
+ <details className={cx(["details"])} onToggle={(event) => {
323
+ setExpanded(event.currentTarget.open);
324
+ }} open={expanded} ref={details}>
325
+ <summary className={cx(["summary"])}>{expanded ? "Hide full prompt" : "Show full prompt"}</summary>
326
+ <pre aria-label={`${label} full prompt`} className={cx(["pre", "full"])} ref={full} tabIndex={0}>{prompt}</pre>
327
+ </details>
328
+ <CopyButton copy={copyPrompt} overlay state={state} subject="setup prompt" />
329
+ </div>
330
+ {targets.length === 0 ? null : (
331
+ <aside aria-labelledby={`${id}-targets`} className={cx(["targets"])}>
332
+ <p className={cx(["targetsLabel"])} id={`${id}-targets`}>Open in</p>
333
+ <ul className={cx(["targetList"])}>
334
+ {targets.map((target) => (
335
+ <li key={target.id}>
336
+ <a aria-busy={target.mode === "copy-and-open" && state === "copying" || undefined} aria-disabled={target.mode === "copy-and-open" && state === "copying" || undefined} aria-label={target.mode === "copy-and-open" ? `Copy prompt and open ${target.label}` : undefined} className={cx(["target"])} data-agent-target={target.id} data-agent-target-mode={target.mode ?? "prefill"} href={target.href} onAuxClick={target.mode === "copy-and-open" ? (event) => { copyAndOpen(event, target); } : undefined} onClick={target.mode === "copy-and-open" ? (event) => { copyAndOpen(event, target); } : undefined} rel="noopener noreferrer" target="_blank">
337
+ <ProviderMark mark={target.mark} size={20} tone="inherit" />
338
+ <span>{target.label}</span>
339
+ </a>
340
+ </li>
341
+ ))}
342
+ </ul>
343
+ </aside>
344
+ )}
345
+ </div>
346
+ <CopyStatus>{handoffMessage || message}</CopyStatus>
347
+ </section>
348
+ );
349
+ }
350
+
351
+ /** Provider-labelled command tabs with one exact-string copy action per panel. */
352
+ export function AgentCommandTabs({ commands, label = "Agent commands", initial, className }: AgentCommandTabsProps) {
353
+ if (commands.length === 0) throw new RangeError("AgentCommandTabs needs at least one command.");
354
+ assertText(label, "Agent command tabs label");
355
+ assertEntries(commands, "Agent command");
356
+ for (const entry of commands) {
357
+ assertText(entry.command, "Agent command");
358
+ if (entry.filename !== undefined) assertText(entry.filename, "Agent configuration filename");
359
+ }
360
+ if (initial !== undefined && !commands.some((entry) => entry.id === initial)) throw new RangeError("Initial agent command must be listed.");
361
+ const first = commands[0];
362
+ if (first === undefined) throw new RangeError("AgentCommandTabs needs at least one command.");
363
+ const id = useId();
364
+ const [selected, setSelected] = useState(initial ?? first.id);
365
+ const current = commands.find((entry) => entry.id === selected) ?? first;
366
+ const tabs = useRef(new Map<string, HTMLButtonElement>());
367
+ const sources = useRef(new Map<string, HTMLPreElement>());
368
+ const fallback = useCallback(() => sources.current.get(current.id) ?? null, [current.id]);
369
+ const { state, message, copy } = useCopy(current.command, commandSubject(current), fallback);
370
+
371
+ const onTabKey = (event: KeyboardEvent<HTMLDivElement>) => {
372
+ const position = commands.findIndex((entry) => entry.id === current.id);
373
+ let next: number;
374
+ if (event.key === "ArrowRight") next = (position + 1) % commands.length;
375
+ else if (event.key === "ArrowLeft") next = (position - 1 + commands.length) % commands.length;
376
+ else if (event.key === "Home") next = 0;
377
+ else if (event.key === "End") next = commands.length - 1;
378
+ else return;
379
+ event.preventDefault();
380
+ const target = commands[next];
381
+ if (target === undefined) return;
382
+ setSelected(target.id);
383
+ tabs.current.get(target.id)?.focus();
384
+ };
385
+
386
+ return (
387
+ <section aria-label={label} className={cx(["root", "commandRoot"], className)} data-hraness-agent-command-tabs="" data-selected-agent={current.id}>
388
+ <div aria-label={label} className={cx(["tablist"])} onKeyDown={onTabKey} role="tablist">
389
+ {commands.map((entry) => (
390
+ <button aria-controls={`${id}-panel-${entry.id}`} aria-selected={entry.id === current.id} className={cx(["tab", entry.id === current.id && "tabSelected"])} data-agent={entry.id} id={`${id}-tab-${entry.id}`} key={entry.id} onClick={() => { setSelected(entry.id); }} ref={(node) => { if (node === null) tabs.current.delete(entry.id); else tabs.current.set(entry.id, node); }} role="tab" tabIndex={entry.id === current.id ? 0 : -1} type="button">
391
+ <ProviderMark mark={entry.mark} size={20} tone="inherit" />
392
+ <span>{entry.label}</span>
393
+ </button>
394
+ ))}
395
+ </div>
396
+ {commands.map((entry) => (
397
+ <div aria-labelledby={`${id}-tab-${entry.id}`} className={cx(["panel"])} data-agent={entry.id} hidden={entry.id !== current.id} id={`${id}-panel-${entry.id}`} key={entry.id} role="tabpanel">
398
+ <div className={cx(["frame"])}>
399
+ <div className={cx(["commandBar"])}>
400
+ <div className={cx(["commandLabels"])}>
401
+ <p className={cx(["panelLabel"])}><ProviderMark mark={entry.mark} size={20} tone="inherit" />{entry.label}</p>
402
+ {entry.filename === undefined ? null : <span className={cx(["filename"])}>{entry.filename}</span>}
403
+ </div>
404
+ <CopyButton copy={copy} state={entry.id === current.id ? state : "idle"} subject={commandSubject(entry)} />
405
+ </div>
406
+ <pre aria-label={commandSubject(entry)} className={cx(["pre", "commandText"])} ref={(node) => { if (node === null) sources.current.delete(entry.id); else sources.current.set(entry.id, node); }} tabIndex={0}><SyntaxCode className={cx(["code"])} code={entry.command} styles="classes" /></pre>
407
+ </div>
408
+ </div>
409
+ ))}
410
+ <CopyStatus>{message}</CopyStatus>
411
+ </section>
412
+ );
413
+ }
@@ -301,23 +301,15 @@ export function ArticleIndex({
301
301
  );
302
302
  }
303
303
 
304
- /** What an article visual is. The label opens the caption so readers can tell a mockup from a screenshot. */
304
+ /** What an article visual is, retained as machine-readable figure metadata. */
305
305
  export const articleFigureKinds = ["illustration", "screenshot", "recording", "diagram", "chart", "table"] as const;
306
306
  export type ArticleFigureKind = (typeof articleFigureKinds)[number];
307
307
 
308
- const FIGURE_LABELS: Readonly<Record<ArticleFigureKind, string>> = {
309
- illustration: "Illustration",
310
- screenshot: "Screenshot",
311
- recording: "Recording",
312
- diagram: "Diagram",
313
- chart: "Chart",
314
- table: "Table",
315
- };
316
-
317
- function FigureCaption({ caption, credit, kind }: Readonly<{ caption: ReactNode; credit?: ReactNode; kind: ArticleFigureKind }>) {
308
+ function FigureCaption({ caption, credit }: Readonly<{ caption?: ReactNode; credit?: ReactNode }>) {
309
+ if ((caption === undefined || caption === null || caption === false || caption === "")
310
+ && (credit === undefined || credit === null || credit === false || credit === "")) return null;
318
311
  return (
319
312
  <figcaption className="plain-publication__figure-caption">
320
- <span className="plain-publication__figure-label">{FIGURE_LABELS[kind]}.</span>{" "}
321
313
  {caption}
322
314
  {credit === undefined || credit === null || credit === "" ? null : <small className="plain-publication__figure-credit">{credit}</small>}
323
315
  </figcaption>
@@ -325,10 +317,9 @@ function FigureCaption({ caption, credit, kind }: Readonly<{ caption: ReactNode;
325
317
  }
326
318
 
327
319
  /**
328
- * A labelled figure in the article body. The caption opens with the kind,
329
- * such as "Illustration." for a code-built mockup, so an invented screen is
330
- * never mistaken for a screenshot. `width="wide"` lets the figure extend past
331
- * the text measure on wide screens.
320
+ * A figure with optional useful context and credit. Its children own their
321
+ * accessible descriptions; use `label` for a composite visual without one.
322
+ * `width="wide"` extends past the text measure on wide screens.
332
323
  */
333
324
  export function ArticleFigure({
334
325
  caption,
@@ -337,22 +328,23 @@ export function ArticleFigure({
337
328
  credit,
338
329
  id,
339
330
  kind,
331
+ label,
340
332
  width = "text",
341
333
  }: Readonly<{
342
- caption: ReactNode;
334
+ caption?: ReactNode;
343
335
  children: ReactNode;
344
336
  className?: string;
345
337
  credit?: ReactNode;
346
338
  id?: string;
347
339
  kind: ArticleFigureKind;
340
+ label?: string;
348
341
  width?: "text" | "wide";
349
342
  }>) {
350
343
  if (!articleFigureKinds.includes(kind)) throw new RangeError(`Unknown article figure kind: ${String(kind)}.`);
351
- if (caption === undefined || caption === null || caption === "") throw new RangeError("Article figures need a caption.");
352
344
  return (
353
- <figure className={joinClasses("plain-publication__figure", className)} data-figure-kind={kind} data-width={width} id={id}>
345
+ <figure aria-label={label} className={joinClasses("plain-publication__figure", className)} data-figure-kind={kind} data-width={width} id={id}>
354
346
  <div className="plain-publication__figure-body">{children}</div>
355
- <FigureCaption caption={caption} credit={credit} kind={kind} />
347
+ <FigureCaption caption={caption} credit={credit} />
356
348
  </figure>
357
349
  );
358
350
  }
@@ -20,6 +20,7 @@ import { Chart01Icon, CodeIcon, DashboardSquare01Icon } from "@hugeicons/core-fr
20
20
  import { useState } from "react";
21
21
 
22
22
  import { AnimatedRailStage } from "./animated-rail-stage.js";
23
+ import { AgentCommandTabs, AgentSetupPrompt } from "./agent-setup-prompt.js";
23
24
  import { AppShell } from "./app-shell.js";
24
25
  import {
25
26
  ArticleCallout,
@@ -45,6 +46,7 @@ import { PlatformBadges } from "./platform-icons.js";
45
46
  import { PlatformInstall } from "./platform-install.js";
46
47
  import { RelativeTime } from "./relative-time.js";
47
48
  import {
49
+ MarketingActionLink,
48
50
  MarketingCallToAction,
49
51
  MarketingCardRow,
50
52
  MarketingCodeBlock,
@@ -392,6 +394,20 @@ export function DesignSystemGallery({
392
394
  ]}
393
395
  />
394
396
  </MarketingInstallPanel>
397
+ <MarketingSection heading="Ask your agent to set it up." headingId="gallery-agent-setup" headingLevel={3} label="Agent setup">
398
+ <AgentSetupPrompt
399
+ label="Relay agent setup"
400
+ prompt={"Install Relay from https://relay.example/install.sh.\nRead the project instructions before making changes.\nRun relay init in this workspace and create a job named sample.\nRun the job, then show me its log."}
401
+ targets={[
402
+ { href: "https://claude.ai/new", id: "claude", label: "Claude", mark: "claudecode", mode: "copy-and-open" },
403
+ { href: "https://chatgpt.com/", id: "chatgpt", label: "ChatGPT", mark: "codex", mode: "copy-and-open" },
404
+ ]}
405
+ />
406
+ <AgentCommandTabs commands={[
407
+ { command: "claude mcp add relay -- relay serve", id: "claude", label: "Claude Code", mark: "claudecode" },
408
+ { command: "codex mcp add relay -- relay serve", id: "codex", label: "Codex", mark: "codex" },
409
+ ]} label="Relay agent commands" />
410
+ </MarketingSection>
395
411
  <MarketingPrimitives
396
412
  heading="Three objects cover most work."
397
413
  headingId="design-gallery-primitives-title"
@@ -404,7 +420,18 @@ export function DesignSystemGallery({
404
420
  label="Primitives"
405
421
  summary="People and agents use the same three objects, so a job you start by hand is one an agent can rerun."
406
422
  />
407
- <MarketingSection heading="A job keeps its name everywhere." headingId="gallery-marketing-section" headingLevel={3} label="Workflow" layout="split-reverse" summary="Start it from the CLI and check on it from code; both see the same job.">
423
+ <MarketingSection
424
+ heading="A job keeps its name everywhere."
425
+ headingContent={<>
426
+ <MarketingCodeBlock code="relay run job-01" />
427
+ <MarketingActionLink href="#gallery-install" label="Run your first job" />
428
+ </>}
429
+ headingId="gallery-marketing-section"
430
+ headingLevel={3}
431
+ label="Workflow"
432
+ layout="split-reverse"
433
+ summary="Start it from the CLI and check on it from code; both see the same job."
434
+ >
408
435
  <MarketingSectionLabel size="body">Reference</MarketingSectionLabel>
409
436
  <p>Consumer-owned content can include <a href="#gallery-install">links</a> and <code>inline code</code>.</p>
410
437
  </MarketingSection>
@@ -1,4 +1,5 @@
1
1
  export * from "./animated-rail-stage.js";
2
+ export * from "./agent-setup-prompt.js";
2
3
  export * from "./app-shell.js";
3
4
  export * from "./article.js";
4
5
  export * from "./aurora-dots-background.js";
@@ -29,9 +29,9 @@ export function launchBeatAnchor(beat: Pick<LaunchBeat, "id">): string {
29
29
  /**
30
30
  * The beats of an "Introducing a product" post, in order. Each beat renders
31
31
  * as a section with a `#beat-<id>` anchor, its headline, its post as the
32
- * paragraph, and exactly one visual from `renderVisual`, captioned with the
33
- * beat's alt text and labelled by kind. Pass beats already resolved against
34
- * the launch facts.
32
+ * paragraph, and exactly one visual from `renderVisual`. Alt text names the
33
+ * visual accessibly; an optional caption adds context. Pass beats already
34
+ * resolved against the launch facts.
35
35
  */
36
36
  export function LaunchBeats({
37
37
  beats,
@@ -39,6 +39,7 @@ export function LaunchBeats({
39
39
  detailLabel = "More on this",
40
40
  headingLevel = 2,
41
41
  renderVisual,
42
+ caption,
42
43
  }: Readonly<{
43
44
  beats: readonly LaunchBeat[];
44
45
  className?: string;
@@ -47,6 +48,8 @@ export function LaunchBeats({
47
48
  headingLevel?: 2 | 3 | 4;
48
49
  /** Returns the one visual for a beat: a mockup, a video, or a diagram image. */
49
50
  renderVisual: (beat: LaunchBeat) => ReactNode;
51
+ /** Optional visible context. Alt text is never repeated as a caption. */
52
+ caption?: (beat: LaunchBeat) => ReactNode;
50
53
  }>) {
51
54
  assertLaunchBeats(beats);
52
55
  const Heading = HEADING_TAGS[headingLevel];
@@ -61,7 +64,7 @@ export function LaunchBeats({
61
64
  <section aria-labelledby={`${anchor}-heading`} className="plain-publication__beat" data-part={beat.part} id={anchor} key={beat.id}>
62
65
  <Heading id={`${anchor}-heading`}>{beat.headline}</Heading>
63
66
  <p>{beat.post}</p>
64
- <ArticleFigure caption={beat.alt} kind={figureKind(beat)}>{visual}</ArticleFigure>
67
+ <ArticleFigure caption={caption?.(beat)} kind={figureKind(beat)} label={beat.alt}>{visual}</ArticleFigure>
65
68
  {beat.detailHref === undefined ? null : (
66
69
  <p className="plain-publication__beat-detail"><a href={beat.detailHref}>{detailLabel}</a></p>
67
70
  )}
@@ -1549,6 +1549,13 @@ export const marketingStyles = stylex.create({
1549
1549
  "font-size": "1.125rem",
1550
1550
  "line-height": "1.5"
1551
1551
  },
1552
+ "section__heading_content": {
1553
+ display: "grid",
1554
+ gap: "1.25rem",
1555
+ "min-inline-size": 0,
1556
+ "margin-block-start": "1.5rem",
1557
+ "justify-items": "start",
1558
+ },
1552
1559
  "section__body": {
1553
1560
  "min-inline-size": "0",
1554
1561
  "display": "grid",
@@ -4316,6 +4323,7 @@ const recipes = {
4316
4323
  "hraness-marketing-section__label": { "default": marketingStyles.section__label, "body": [marketingStyles.section__label, marketingStyles.sectionLabelBody] },
4317
4324
  "hraness-marketing-section__heading": { "default": marketingStyles.section__heading },
4318
4325
  "hraness-marketing-section__summary": { "default": marketingStyles.section__summary },
4326
+ "hraness-marketing-section__heading-content": { "default": marketingStyles.section__heading_content },
4319
4327
  "hraness-marketing-section__body": { "default": marketingStyles.section__body },
4320
4328
  "hraness-marketing-primitives": { "default": marketingStyles.primitives },
4321
4329
  "hraness-marketing-primitives__header": { "default": marketingStyles.primitives__header },