hd-garden 0.3.11 → 0.3.13

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.
@@ -46,6 +46,7 @@ batch 为 {batch}、序列 {thousands(seq)} 时,KV cache 要 **{bytes(mem)}**
46
46
  - 组件标签用大写开头,属性用名字传:字符串用双引号,数字、布尔、表达式用 `{}`。
47
47
  - 正文里可以写普通 Markdown 段落、加粗、列表;用 `{表达式}` 把会变的数字插进句子。字面的 `{` 写成 `\{`。
48
48
  - 不能写:标题、HTML 标签、图片语法、代码围栏、脚注、`import` / `export`。
49
+ - 目录表达不了的画布(可转的示意、逐步出现的图钉、一段自包含动画)用 `<Sandbox>`。`html` / `css` / `js` 必须是字面量,不能加载远程脚本、图片或样式。脚本里可以用 `GardenUI.openUrl`(只打开 `https`)、`GardenUI.copy`,以及 `GardenUI.issuePrompt`。`issuePrompt` 只有宿主实现了继续对话时才可用;博客阅读页没有这个能力。
49
50
 
50
51
  ## 二、状态和表达式
51
52
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { marked } from "marked";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useMemo, useState, useSyncExternalStore } from "react";
5
5
  import { COMPONENTS, GROUPS } from "../core/catalog.mjs";
6
6
  import { compileUiBlock } from "../core/compile.mjs";
7
7
  import { DEMOS, demoFence } from "../core/demos.mjs";
@@ -68,11 +68,45 @@ function PropTable({ spec }: { spec: Spec }) {
68
68
 
69
69
  const CATEGORIES = ["配方", ...GROUPS];
70
70
 
71
+ const hashListeners = new Set<() => void>();
72
+
73
+ function subscribeCatalogHash(onStoreChange: () => void) {
74
+ hashListeners.add(onStoreChange);
75
+ window.addEventListener("hashchange", onStoreChange);
76
+ return () => {
77
+ hashListeners.delete(onStoreChange);
78
+ window.removeEventListener("hashchange", onStoreChange);
79
+ };
80
+ }
81
+
82
+ function categoryFromHash(hash: string): string {
83
+ const id = hash.replace(/^#/, "");
84
+ if (id.startsWith("component-")) {
85
+ const spec = COMPONENTS[id.slice("component-".length) as keyof typeof COMPONENTS] as Spec | undefined;
86
+ if (spec?.group) return spec.group;
87
+ } else if (id === "配方" || id.startsWith("recipe-")) return "配方";
88
+ else {
89
+ try {
90
+ const decoded = decodeURIComponent(id);
91
+ if ((CATEGORIES as string[]).includes(decoded)) return decoded;
92
+ } catch {
93
+ // A malformed hash stays on the default category.
94
+ }
95
+ }
96
+ return "展示";
97
+ }
98
+
99
+ function replaceCatalogHash(name: string) {
100
+ window.history.replaceState(null, "", `#${encodeURIComponent(name)}`);
101
+ for (const listener of hashListeners) listener();
102
+ }
103
+
71
104
  /** Category rail on the left, a grid of name + preview cards on the right. */
72
105
  export function CatalogPage({ recipes = [], onStart }: { recipes?: RecipeSource[]; onStart?: (fence: string) => void }) {
73
106
  const parsedRecipes = useMemo(() => recipes.map(parseRecipe).filter((recipe) => recipe.fence), [recipes]);
74
107
  const [query, setQuery] = useState("");
75
- const [category, setCategory] = useState("展示");
108
+ const hash = useSyncExternalStore(subscribeCatalogHash, () => window.location.hash, () => "");
109
+ const category = categoryFromHash(hash);
76
110
  const [openId, setOpenId] = useState<string | null>(null);
77
111
  const normalized = query.trim().toLowerCase();
78
112
 
@@ -108,19 +142,9 @@ export function CatalogPage({ recipes = [], onStart }: { recipes?: RecipeSource[
108
142
  const activeCategory = categories.includes(category) ? category : categories[0];
109
143
  const cards = activeCategory ? cardsFor(activeCategory) : [];
110
144
 
111
- useEffect(() => {
112
- const id = window.location.hash.replace(/^#/, "");
113
- if (id.startsWith("component-")) {
114
- const spec = COMPONENTS[id.slice("component-".length) as keyof typeof COMPONENTS] as Spec | undefined;
115
- if (spec?.group) setCategory(spec.group);
116
- } else if (id === "配方" || id.startsWith("recipe-")) setCategory("配方");
117
- else if ((CATEGORIES as string[]).includes(decodeURIComponent(id))) setCategory(decodeURIComponent(id));
118
- }, []);
119
-
120
145
  const chooseCategory = (name: string) => {
121
- setCategory(name);
122
146
  setOpenId(null);
123
- window.history.replaceState(null, "", `#${encodeURIComponent(name)}`);
147
+ replaceCatalogHash(name);
124
148
  };
125
149
 
126
150
  return (
@@ -8,7 +8,7 @@ import { asNumber, asString, evalExpr, expandChildren, propValue, registerCompon
8
8
  import type { Env, Lookup, UiNode } from "../types";
9
9
 
10
10
  const SERIES = new Set(["Line", "Area", "Bar", "Point"]);
11
- const DEFAULT_TONES = ["plain", "accent", "new", "same"];
11
+ const DEFAULT_TONES = ["accent", "muted", "new", "same"];
12
12
  const MARGIN = { top: 14, right: 16, bottom: 30, left: 60 };
13
13
 
14
14
  type Scale = { map: (value: number) => number; ticks: number[] };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { buildSrcdoc, isHttpsUrl, readSandboxMessage, sandboxRemote } from "../../host.mjs";
5
+ import { asString, propValue, registerComponents, useHost, useRuntime } from "../runtime";
6
+ import type { Env, UiNode } from "../types";
7
+
8
+ function Sandbox({ node, env }: { node: UiNode; env: Env }) {
9
+ const runtime = useRuntime();
10
+ const host = useHost();
11
+ const frameRef = useRef<HTMLIFrameElement>(null);
12
+ const [height, setHeight] = useState(180);
13
+ const [srcDoc, setSrcDoc] = useState("");
14
+ const html = asString(propValue(runtime, node, "html", env));
15
+ const css = asString(propValue(runtime, node, "css", env));
16
+ const js = asString(propValue(runtime, node, "js", env));
17
+ const title = asString(propValue(runtime, node, "title", env), "交互");
18
+ const blocked = sandboxRemote(html) || sandboxRemote(css) || sandboxRemote(js);
19
+
20
+ useEffect(() => {
21
+ if (blocked) return;
22
+ const paint = () => {
23
+ const theme = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
24
+ setSrcDoc(buildSrcdoc({ html, css, js, canPrompt: typeof host.issuePrompt === "function", theme }));
25
+ };
26
+ paint();
27
+ const observer = new MutationObserver(paint);
28
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
29
+ return () => observer.disconnect();
30
+ }, [blocked, html, css, js, host]);
31
+
32
+ useEffect(() => {
33
+ const onMessage = (event: MessageEvent) => {
34
+ const frame = frameRef.current;
35
+ if (!frame || event.source !== frame.contentWindow) return;
36
+ const message = readSandboxMessage(event.data);
37
+ if (!message) return;
38
+ if (message.type === "height") setHeight(message.height);
39
+ if (message.type === "openUrl" && isHttpsUrl(message.url)) {
40
+ if (host.openUrl) host.openUrl(message.url);
41
+ else window.open(message.url, "_blank", "noopener,noreferrer");
42
+ }
43
+ if (message.type === "issuePrompt") host.issuePrompt?.(message.text);
44
+ if (message.type === "copy") {
45
+ if (host.copy) host.copy(message.text);
46
+ else void navigator.clipboard?.writeText(message.text);
47
+ }
48
+ };
49
+ window.addEventListener("message", onMessage);
50
+ return () => window.removeEventListener("message", onMessage);
51
+ }, [host]);
52
+
53
+ if (blocked) return <p className="garden-ui-error-inline">{blocked}</p>;
54
+
55
+ return (
56
+ <iframe
57
+ ref={frameRef}
58
+ className="garden-ui-sandbox"
59
+ title={title}
60
+ sandbox="allow-scripts"
61
+ referrerPolicy="no-referrer"
62
+ srcDoc={srcDoc}
63
+ style={{ height }}
64
+ />
65
+ );
66
+ }
67
+
68
+ registerComponents({ Sandbox });
@@ -5,16 +5,18 @@ import "./components/inputs";
5
5
  import "./components/display";
6
6
  import "./components/chart";
7
7
  import "./components/media";
8
- import { UiRuntime } from "./runtime";
8
+ import "./components/sandbox";
9
+ import { UiRuntime, type UiHost } from "./runtime";
9
10
  import type { Diagnostic, UiPayload } from "./types";
10
11
 
11
12
  export { UiRuntime } from "./runtime";
13
+ export type { UiHost } from "./runtime";
12
14
  export type { Diagnostic, UiNode, UiPayload } from "./types";
13
15
 
14
- export function UiFigure({ payload }: { payload: UiPayload }) {
16
+ export function UiFigure({ payload, host }: { payload: UiPayload; host?: UiHost }) {
15
17
  return (
16
18
  <figure className="garden-ui" lang="en-US" id={`ui-${payload.id}`}>
17
- <UiRuntime payload={payload} />
19
+ <UiRuntime payload={payload} host={host} />
18
20
  <figcaption>{payload.caption}</figcaption>
19
21
  </figure>
20
22
  );
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import { compileUiBlock } from "../core/compile.mjs";
4
+ import { PARITY_FIXTURES } from "../parity-fixtures.mjs";
5
+ import { UiFigure } from "./index";
6
+ import type { UiPayload } from "./types";
7
+
8
+ function payloadFor(body: string): UiPayload | null {
9
+ return compileUiBlock(body).payload as UiPayload | null;
10
+ }
11
+
12
+ export function ParityInteractions() {
13
+ return (
14
+ <>
15
+ {PARITY_FIXTURES.map((fixture) => {
16
+ const payload = payloadFor(fixture.body);
17
+ return (
18
+ <section id={fixture.id} key={fixture.id} style={{ width: fixture.width, marginBottom: 32 }}>
19
+ {payload ? <UiFigure payload={payload} /> : <p>对照块没有通过检查。</p>}
20
+ </section>
21
+ );
22
+ })}
23
+ </>
24
+ );
25
+ }
@@ -24,6 +24,18 @@ export type Runtime = {
24
24
 
25
25
  const RuntimeContext = createContext<Runtime | null>(null);
26
26
 
27
+ export type UiHost = {
28
+ openUrl?: (url: string) => void;
29
+ issuePrompt?: (text: string) => void;
30
+ copy?: (text: string) => void;
31
+ };
32
+
33
+ const HostContext = createContext<UiHost>({});
34
+
35
+ export function useHost() {
36
+ return useContext(HostContext);
37
+ }
38
+
27
39
  export function useRuntime() {
28
40
  const runtime = useContext(RuntimeContext);
29
41
  if (!runtime) throw new Error("Garden UI component outside UiRuntime");
@@ -204,7 +216,9 @@ function carryOver(defaults: Record<string, unknown>, previous: Record<string, u
204
216
  return next;
205
217
  }
206
218
 
207
- export function UiRuntime({ payload }: { payload: UiPayload }) {
219
+ const EMPTY_HOST: UiHost = {};
220
+
221
+ export function UiRuntime({ payload, host }: { payload: UiPayload; host?: UiHost }) {
208
222
  const decls = useMemo(() => collectDecls(payload.root), [payload.root]);
209
223
  const defaults = useMemo(() => defaultStateValues(decls) as Record<string, unknown>, [decls]);
210
224
  const [state, setStateValue] = useState<Record<string, unknown>>(defaults);
@@ -230,10 +244,12 @@ export function UiRuntime({ payload }: { payload: UiPayload }) {
230
244
  [payload.id, payload.root, decls, data, defaults, state, scope, sliders, setState, reset, scopeFor],
231
245
  );
232
246
  return (
233
- <RuntimeContext.Provider value={runtime}>
234
- <div className="garden-ui-body">
235
- <RenderNodes nodes={payload.root.c} env={{ locals: [] }} />
236
- </div>
237
- </RuntimeContext.Provider>
247
+ <HostContext.Provider value={host ?? EMPTY_HOST}>
248
+ <RuntimeContext.Provider value={runtime}>
249
+ <div className="garden-ui-body">
250
+ <RenderNodes nodes={payload.root.c} env={{ locals: [] }} />
251
+ </div>
252
+ </RuntimeContext.Provider>
253
+ </HostContext.Provider>
238
254
  );
239
255
  }
package/ui/react/ui.css CHANGED
@@ -7,9 +7,11 @@
7
7
  .garden-catalog,
8
8
  .garden-ui-preview {
9
9
  --gui-bg: #fff;
10
- --gui-chart: transparent;
10
+ --gui-chart: #f4f4f4;
11
11
  --gui-track: #e4e4e4;
12
12
  --gui-thumb: #fff;
13
+ --gui-slider-track: #0d0d0d1f;
14
+ --gui-slider-thumb: #333;
13
15
  --gui-text: #0d0d0d;
14
16
  --gui-muted: #5d5d5d;
15
17
  --gui-subtle: #8f8f8f;
@@ -36,6 +38,12 @@
36
38
  --gui-popover-border: #0000000d;
37
39
  --gui-shadow: 0 2px 6px #00000014;
38
40
  --gui-code-bg: #f9f9f9;
41
+ --gui-accent: #0285ff;
42
+ --gui-series-plain: #171717;
43
+ --gui-series-accent: #0285ff;
44
+ --gui-series-new: #00a240;
45
+ --gui-series-waste: #e02e2a;
46
+ --gui-series-same: #924ff7;
39
47
  --gui-info: #539af8;
40
48
  --gui-info-text: #0169cc;
41
49
  --gui-info-soft: #0285ff1a;
@@ -82,6 +90,8 @@
82
90
  --gui-segment-shadow: none;
83
91
  --gui-track: #3a3a3a;
84
92
  --gui-thumb: #fff;
93
+ --gui-slider-track: #ffffff26;
94
+ --gui-slider-thumb: #ededed;
85
95
  --gui-progress: #0285ff;
86
96
  --gui-progress-track: #303030;
87
97
  --gui-focus: #3a83f7;
@@ -89,6 +99,10 @@
89
99
  --gui-popover-border: #ffffff26;
90
100
  --gui-shadow: 0 2px 8px #00000066;
91
101
  --gui-code-bg: #171717;
102
+ --gui-accent: #0285ff;
103
+ --gui-series-plain: #ededed;
104
+ --gui-series-new: #04b84c;
105
+ --gui-series-waste: #fa423e;
92
106
  --gui-info: #0285ff;
93
107
  --gui-info-text: #66b5ff;
94
108
  --gui-info-soft: #0285ff26;
@@ -276,7 +290,7 @@
276
290
  .garden-ui-steps-head strong { color: var(--gui-text); font-size: 16px; font-weight: 600; line-height: 1.4; }
277
291
  .garden-ui-steps-head span { flex-shrink: 0; color: var(--gui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
278
292
 
279
- .garden-ui-steps .garden-ui-progress { height: 2px; margin: 2px 0 0; }
293
+ .garden-ui-steps .garden-ui-progress { height: 3px; margin: 2px 0 0; }
280
294
 
281
295
  .garden-ui-steps-stage { display: grid; }
282
296
 
@@ -386,7 +400,7 @@
386
400
 
387
401
  .garden-ui-button.garden-ui-tone-accent:hover { border-color: var(--gui-solid-hover); background: var(--gui-solid-hover); }
388
402
 
389
- /* slider: thin track, a round thumb sitting on it, as in ChatGPT's scenario slider */
403
+ /* slider: 24px track, 18px thumb inset 3px, no filled portion */
390
404
  .garden-ui-slider { display: flex; flex-direction: column; gap: 8px; }
391
405
 
392
406
  .garden-ui-slider label {
@@ -403,12 +417,12 @@
403
417
 
404
418
  .garden-ui-slider-track { padding: 8px 0; }
405
419
 
406
- .garden-ui-slider-track:has(input:focus-visible) { outline: 2px solid var(--gui-focus); outline-offset: 4px; border-radius: 999px; }
420
+ .garden-ui-slider-track:has(input:focus-visible) { outline: 2px solid var(--gui-focus); outline-offset: 3px; border-radius: 999px; }
407
421
 
408
422
  .garden-ui-slider input[type="range"] {
409
423
  display: block;
410
424
  width: 100%;
411
- height: 22px;
425
+ height: 38px;
412
426
  margin: 0;
413
427
  background: transparent;
414
428
  cursor: ew-resize;
@@ -419,39 +433,39 @@
419
433
  .garden-ui-slider input[type="range"]:focus-visible { outline: none; }
420
434
 
421
435
  .garden-ui-slider input[type="range"]::-webkit-slider-runnable-track {
422
- height: 8px;
436
+ height: 24px;
423
437
  border: 0;
424
438
  border-radius: 999px;
425
- background: var(--gui-track);
439
+ background: var(--gui-slider-track);
426
440
  }
427
441
 
428
442
  .garden-ui-slider input[type="range"]::-moz-range-track {
429
- height: 8px;
443
+ height: 24px;
430
444
  border: 0;
431
445
  border-radius: 999px;
432
- background: var(--gui-track);
446
+ background: var(--gui-slider-track);
433
447
  }
434
448
 
435
449
  .garden-ui-slider input[type="range"]::-webkit-slider-thumb {
436
- width: 22px;
437
- height: 22px;
438
- margin-top: -7px;
439
- border: 1px solid #0000001a;
450
+ width: 18px;
451
+ height: 18px;
452
+ margin-top: 3px;
453
+ border: 0;
440
454
  border-radius: 50%;
441
- background: var(--gui-thumb);
442
- box-shadow: 0 1px 4px #00000040;
455
+ background: var(--gui-slider-thumb);
456
+ box-shadow: none;
443
457
  -webkit-appearance: none;
444
458
  appearance: none;
445
459
  transition: transform 120ms ease;
446
460
  }
447
461
 
448
462
  .garden-ui-slider input[type="range"]::-moz-range-thumb {
449
- width: 22px;
450
- height: 22px;
451
- border: 1px solid #0000001a;
463
+ width: 18px;
464
+ height: 18px;
465
+ border: 0;
452
466
  border-radius: 50%;
453
- background: var(--gui-thumb);
454
- box-shadow: 0 1px 4px #00000040;
467
+ background: var(--gui-slider-thumb);
468
+ box-shadow: none;
455
469
  }
456
470
 
457
471
  .garden-ui-slider input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.08); }
@@ -802,16 +816,16 @@
802
816
  }
803
817
  .garden-ui-chart-canvas:focus-visible { outline: 2px solid var(--gui-focus); outline-offset: 2px; }
804
818
 
805
- .garden-ui-series-plain { --gui-series: #539af8; }
806
- .garden-ui-series-accent { --gui-series: #539af8; }
807
- .garden-ui-series-new { --gui-series: #6bc67f; }
808
- .garden-ui-series-waste { --gui-series: #ef8b57; }
809
- .garden-ui-series-same { --gui-series: #ef8b57; }
819
+ .garden-ui-series-plain { --gui-series: var(--gui-series-plain); }
820
+ .garden-ui-series-accent { --gui-series: var(--gui-series-accent); }
821
+ .garden-ui-series-new { --gui-series: var(--gui-series-new); }
822
+ .garden-ui-series-waste { --gui-series: var(--gui-series-waste); }
823
+ .garden-ui-series-same { --gui-series: var(--gui-series-same); }
810
824
  .garden-ui-series-muted,
811
825
  .garden-ui-series-cached { --gui-series: var(--gui-subtle); }
812
826
 
813
827
  .garden-ui-chart-grid { stroke: var(--gui-border); stroke-width: 1; stroke-dasharray: 2 4; }
814
- .garden-ui-chart-axis { stroke: transparent; stroke-width: 1; }
828
+ .garden-ui-chart-axis { stroke: var(--gui-border); stroke-width: 1; }
815
829
  .garden-ui-chart .garden-ui-legend { justify-content: center; }
816
830
  .garden-ui-chart-tick,
817
831
  .garden-ui-chart-label { fill: var(--gui-subtle); font-family: var(--gui-font); font-size: 12px; }
@@ -889,8 +903,8 @@
889
903
  }
890
904
 
891
905
  .garden-ui-code code { padding: 0; border-radius: 0; background: none; color: var(--gui-text); font-size: 13px; }
892
- .garden-ui-code-line { display: block; padding: 0 14px 0 0; border-left: 2px solid transparent; }
893
- .garden-ui-code-line[data-highlight="true"] { border-left-color: var(--gui-info); background: var(--gui-info-soft); }
906
+ .garden-ui-code-line { display: block; padding: 0 14px 0 12px; box-shadow: inset 2px 0 0 transparent; }
907
+ .garden-ui-code-line[data-highlight="true"] { background: var(--gui-info-soft); box-shadow: inset 2px 0 0 var(--gui-accent); }
894
908
  .garden-ui-code-number { display: inline-block; width: 3em; padding-right: 1em; color: var(--gui-subtle); text-align: right; user-select: none; }
895
909
 
896
910
  .garden-ui-diagram-canvas { overflow-x: auto; padding: 12px; border-radius: 12px; background: var(--gui-code-bg); }
@@ -908,10 +922,19 @@
908
922
  .garden-ui-diagram-canvas .garden-ui-diagram-on rect,
909
923
  .garden-ui-diagram-canvas .garden-ui-diagram-on polygon,
910
924
  .garden-ui-diagram-canvas .garden-ui-diagram-on circle,
911
- .garden-ui-diagram-canvas rect.garden-ui-diagram-on { fill: var(--gui-info-soft) !important; stroke: var(--gui-info) !important; }
925
+ .garden-ui-diagram-canvas rect.garden-ui-diagram-on { fill: var(--gui-info-soft) !important; stroke: var(--gui-accent) !important; }
912
926
  .garden-ui-diagram-canvas .garden-ui-diagram-on .nodeLabel,
913
- .garden-ui-diagram-canvas .garden-ui-diagram-on text { color: var(--gui-info-text) !important; fill: var(--gui-info-text) !important; }
914
- .garden-ui-diagram-canvas path.garden-ui-diagram-on { stroke: var(--gui-info) !important; stroke-width: 2px !important; }
927
+ .garden-ui-diagram-canvas .garden-ui-diagram-on text { color: var(--gui-accent) !important; fill: var(--gui-accent) !important; }
928
+ .garden-ui-diagram-canvas path.garden-ui-diagram-on { stroke: var(--gui-accent) !important; stroke-width: 2px !important; }
929
+
930
+ .garden-ui-sandbox {
931
+ display: block;
932
+ width: 100%;
933
+ min-height: 48px;
934
+ border: 0;
935
+ border-radius: 12px;
936
+ background: transparent;
937
+ }
915
938
 
916
939
  /* intent: the author's words, shown like a message bubble */
917
940
  .garden-ui-intent { border: 0; background: transparent; }