claude-artifact-framework 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -3
- package/dist/index.esm.js +666 -14
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +77 -9
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +143 -5
- package/src/appState.js +34 -3
- package/src/blocks.js +177 -3
- package/src/chat.js +248 -0
- package/src/records.js +64 -5
- package/src/steps.js +106 -0
- package/src/theme.js +9 -0
package/src/chat.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// chat.js — the conversation layout: tutors, advisors, generators, roleplay.
|
|
2
|
+
//
|
|
3
|
+
// Every piece of the network contract here is copied from code proven inside
|
|
4
|
+
// the real artifact runtime (the PDF annotator), not designed on paper:
|
|
5
|
+
//
|
|
6
|
+
// endpoint fetch("https://api.anthropic.com/v1/messages") with NO api
|
|
7
|
+
// key — the artifact runtime proxies the call and injects auth.
|
|
8
|
+
// streaming stream: true works; SSE parsed via content_block_delta.
|
|
9
|
+
// tools the proxy does NOT support native function calling. Tools are
|
|
10
|
+
// prompted: the model replies with a ```tool_call fenced block
|
|
11
|
+
// holding a JSON array; results go back as a user message
|
|
12
|
+
// tagged [TOOL_RESULT]; loop capped at 8 rounds.
|
|
13
|
+
//
|
|
14
|
+
// The author declares the system prompt and optionally tools that operate on
|
|
15
|
+
// the app's data through ctx.update — a chat that can drive the app. The
|
|
16
|
+
// framework owns everything that normally ships half-built: message state,
|
|
17
|
+
// live streaming, the busy state, the tool loop, and errors as visible
|
|
18
|
+
// bubbles instead of silent console noise.
|
|
19
|
+
//
|
|
20
|
+
// State split, same philosophy as data/view: `api` holds the exact messages
|
|
21
|
+
// the API needs (including raw tool_call text and [TOOL_RESULT] payloads);
|
|
22
|
+
// `log` holds what a human should see (stripped text, tool pills, errors).
|
|
23
|
+
// Both persist under data.chat, so a conversation survives reload. The
|
|
24
|
+
// in-flight streaming bubble is deliberately ephemeral local state.
|
|
25
|
+
|
|
26
|
+
import { createElement as h, useState, useEffect, useRef } from "react";
|
|
27
|
+
|
|
28
|
+
export const CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
|
|
29
|
+
|
|
30
|
+
// Proven default through the artifact proxy.
|
|
31
|
+
const DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
32
|
+
const MAX_ROUNDS = 8;
|
|
33
|
+
|
|
34
|
+
async function streamCall({ model, maxTokens, system, messages, onText }) {
|
|
35
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok || !res.body) {
|
|
41
|
+
let detail = "";
|
|
42
|
+
try {
|
|
43
|
+
const b = await res.json();
|
|
44
|
+
detail = (b && b.error && b.error.message) || JSON.stringify(b);
|
|
45
|
+
} catch {
|
|
46
|
+
detail = await res.text().catch(() => "");
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
|
|
49
|
+
}
|
|
50
|
+
const reader = res.body.getReader();
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
let full = "";
|
|
53
|
+
let buf = ""; // reassembles SSE events split across network chunks
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buf += decoder.decode(value, { stream: true });
|
|
58
|
+
const lines = buf.split("\n");
|
|
59
|
+
buf = lines.pop();
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!line.startsWith("data: ")) continue;
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(line.slice(6));
|
|
64
|
+
if (j.type === "content_block_delta" && j.delta && j.delta.text) {
|
|
65
|
+
full += j.delta.text;
|
|
66
|
+
if (onText) onText(full);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// non-JSON lines (pings, [DONE]) are expected — skip
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return full;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseToolCalls(text) {
|
|
77
|
+
const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
|
|
78
|
+
if (!match) return null;
|
|
79
|
+
try {
|
|
80
|
+
const calls = JSON.parse(match[1]);
|
|
81
|
+
return Array.isArray(calls) && calls.length ? calls : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function stripToolCallBlock(text) {
|
|
88
|
+
return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The instructions the model needs to use prompted tools. Appended to the
|
|
92
|
+
// author's system prompt only when tools are declared.
|
|
93
|
+
function toolProtocol(tools) {
|
|
94
|
+
const list = Object.entries(tools)
|
|
95
|
+
.map(([name, t]) => `- ${name}: ${t.description}\n Input: ${JSON.stringify(t.input || {})}`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
return `
|
|
98
|
+
|
|
99
|
+
You have tools available:
|
|
100
|
+
${list}
|
|
101
|
+
|
|
102
|
+
This environment does not support native function calling. To use a tool, reply ONLY with a \`\`\`tool_call code block containing a JSON array, with no other text in that turn. Example:
|
|
103
|
+
|
|
104
|
+
\`\`\`tool_call
|
|
105
|
+
[{ "name": "tool_name", "input": { "param": "value" } }]
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
You may include several calls in one array. NEVER invent a tool's result — emit the pure tool_call block and wait for the real result, which arrives in the next message tagged [TOOL_RESULT]. When you have everything you need, answer in natural language with no tool_call block.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function runTools(calls, tools, ctx) {
|
|
112
|
+
return calls.map((call) => {
|
|
113
|
+
const tool = tools[call.name];
|
|
114
|
+
if (!tool) {
|
|
115
|
+
// Fed back to the model, loud and in-band: it can correct itself on the
|
|
116
|
+
// next round instead of hallucinating a result.
|
|
117
|
+
return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = tool.run(call.input || {}, ctx);
|
|
121
|
+
return { name: call.name, result: result === undefined ? { ok: true } : result };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { name: call.name, error: String((e && e.message) || e) };
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Bubble({ m }) {
|
|
129
|
+
if (m.role === "tools") {
|
|
130
|
+
return h("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
if (m.role === "error") {
|
|
133
|
+
return h(
|
|
134
|
+
"div",
|
|
135
|
+
{ className: "caf-msg caf-msg-error" },
|
|
136
|
+
h("strong", null, "The call failed"),
|
|
137
|
+
h("span", null, m.content),
|
|
138
|
+
h("span", { className: "caf-hint" }, "Your message is kept — send again to retry.")
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return h("div", { className: m.role === "user" ? "caf-msg caf-msg-user" : "caf-msg caf-msg-assistant" }, m.content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function ChatLayout({ spec, ctx }) {
|
|
145
|
+
const c = spec.chat;
|
|
146
|
+
const [draft, setDraft] = useState("");
|
|
147
|
+
const [busy, setBusy] = useState(false);
|
|
148
|
+
const [live, setLive] = useState(null);
|
|
149
|
+
const endRef = useRef(null);
|
|
150
|
+
|
|
151
|
+
const chat = ctx.data.chat || { api: [], log: [] };
|
|
152
|
+
const log = chat.log || [];
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
|
|
156
|
+
}, [log.length, live]);
|
|
157
|
+
|
|
158
|
+
async function send() {
|
|
159
|
+
const text = draft.trim();
|
|
160
|
+
if (!text || busy) return;
|
|
161
|
+
setDraft("");
|
|
162
|
+
setBusy(true);
|
|
163
|
+
|
|
164
|
+
// api/log are threaded locally through the loop; ctx.data would be stale
|
|
165
|
+
// inside this closure after each update().
|
|
166
|
+
let api = [...(chat.api || []), { role: "user", content: text }];
|
|
167
|
+
let logNow = [...log, { role: "user", content: text }];
|
|
168
|
+
const commit = () => ctx.update((d) => { d.chat = { api, log: logNow }; });
|
|
169
|
+
commit();
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const system =
|
|
173
|
+
(typeof c.system === "function" ? c.system(ctx.data) : c.system || "") +
|
|
174
|
+
(c.tools ? toolProtocol(c.tools) : "");
|
|
175
|
+
|
|
176
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
177
|
+
const raw = await streamCall({
|
|
178
|
+
model: c.model || DEFAULT_MODEL,
|
|
179
|
+
maxTokens: c.maxTokens || 2000,
|
|
180
|
+
system,
|
|
181
|
+
messages: api,
|
|
182
|
+
onText: (t) => setLive(stripToolCallBlock(t)),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
api = [...api, { role: "assistant", content: raw }];
|
|
186
|
+
const shown = stripToolCallBlock(raw);
|
|
187
|
+
const calls = c.tools ? parseToolCalls(raw) : null;
|
|
188
|
+
|
|
189
|
+
if (!calls) {
|
|
190
|
+
logNow = [...logNow, { role: "assistant", content: shown || raw }];
|
|
191
|
+
commit();
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const results = runTools(calls, c.tools, ctx);
|
|
196
|
+
if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
|
|
197
|
+
logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
|
|
198
|
+
api = [
|
|
199
|
+
...api,
|
|
200
|
+
{
|
|
201
|
+
role: "user",
|
|
202
|
+
content: `[TOOL_RESULT]\n${JSON.stringify(results)}\n[/TOOL_RESULT]\n\nContinue with this information. If you have everything you need, answer in natural language without further tool_call.`,
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
commit();
|
|
206
|
+
setLive(null);
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {
|
|
209
|
+
logNow = [...logNow, { role: "error", content: String((e && e.message) || e) }];
|
|
210
|
+
commit();
|
|
211
|
+
} finally {
|
|
212
|
+
setLive(null);
|
|
213
|
+
setBusy(false);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return h(
|
|
218
|
+
"div",
|
|
219
|
+
{ className: "caf-panel caf-panel-single" },
|
|
220
|
+
h(
|
|
221
|
+
"div",
|
|
222
|
+
{ className: "caf-block caf-chat-log" },
|
|
223
|
+
c.greeting ? h("div", { className: "caf-msg caf-msg-assistant" }, c.greeting) : null,
|
|
224
|
+
log.map((m, i) => h(Bubble, { key: i, m })),
|
|
225
|
+
live !== null ? h("div", { className: "caf-msg caf-msg-assistant caf-msg-live" }, live || "…") : null,
|
|
226
|
+
busy && live === null ? h("div", { className: "caf-msg-tools" }, "thinking…") : null,
|
|
227
|
+
h("div", { ref: endRef })
|
|
228
|
+
),
|
|
229
|
+
h(
|
|
230
|
+
"form",
|
|
231
|
+
{
|
|
232
|
+
className: "caf-chat-input",
|
|
233
|
+
onSubmit: (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
send();
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
h("input", {
|
|
239
|
+
value: draft,
|
|
240
|
+
placeholder: c.placeholder || "Message…",
|
|
241
|
+
disabled: busy,
|
|
242
|
+
onChange: (e) => setDraft(e.target.value),
|
|
243
|
+
"aria-label": "Message",
|
|
244
|
+
}),
|
|
245
|
+
h("button", { type: "submit", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim() }, "Send")
|
|
246
|
+
)
|
|
247
|
+
);
|
|
248
|
+
}
|
package/src/records.js
CHANGED
|
@@ -18,9 +18,29 @@
|
|
|
18
18
|
// in data — restoring it on reload is correct, and deleting the open record
|
|
19
19
|
// simply falls back to the list.
|
|
20
20
|
|
|
21
|
-
import { createElement as h } from "react";
|
|
21
|
+
import { createElement as h, useState } from "react";
|
|
22
22
|
import { Field, formatValue } from "./blocks.js";
|
|
23
23
|
|
|
24
|
+
// Computed fields are derived at render time and never stored, so they can't
|
|
25
|
+
// go stale. Accepts `{ subtotal: (r) => ... }` or
|
|
26
|
+
// `{ subtotal: { label, format, value: (r) => ... } }`.
|
|
27
|
+
function computedEntries(spec) {
|
|
28
|
+
return Object.entries(spec.compute || {}).map(([k, v]) => [k, typeof v === "function" ? { value: v } : v]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withComputed(spec, rec) {
|
|
32
|
+
if (!spec.compute) return rec;
|
|
33
|
+
const out = { ...rec };
|
|
34
|
+
for (const [key, c] of computedEntries(spec)) {
|
|
35
|
+
try {
|
|
36
|
+
out[key] = c.value(rec);
|
|
37
|
+
} catch {
|
|
38
|
+
out[key] = undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export function recordDefaults(fields) {
|
|
25
45
|
const rec = {};
|
|
26
46
|
for (const f of fields) {
|
|
@@ -66,8 +86,16 @@ function Summary({ spec, records }) {
|
|
|
66
86
|
|
|
67
87
|
function ListScreen({ spec, ctx }) {
|
|
68
88
|
const label = spec.label || "item";
|
|
69
|
-
|
|
89
|
+
const [query, setQuery] = useState("");
|
|
90
|
+
let records = ctx.data.records.map((r) => withComputed(spec, r));
|
|
70
91
|
if (spec.sort) records = records.slice().sort(spec.sort);
|
|
92
|
+
const total = records.length;
|
|
93
|
+
const q = query.trim().toLowerCase();
|
|
94
|
+
if (spec.search && q) {
|
|
95
|
+
records = records.filter((r) =>
|
|
96
|
+
spec.fields.some((f) => String(r[f.key] ?? "").toLowerCase().includes(q))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
71
99
|
|
|
72
100
|
function add() {
|
|
73
101
|
const rec = { id: makeId(), ...recordDefaults(spec.fields) };
|
|
@@ -83,9 +111,25 @@ function ListScreen({ spec, ctx }) {
|
|
|
83
111
|
h(
|
|
84
112
|
"div",
|
|
85
113
|
{ className: "caf-toolbar" },
|
|
86
|
-
h(
|
|
114
|
+
h(
|
|
115
|
+
"span",
|
|
116
|
+
{ className: "caf-count" },
|
|
117
|
+
spec.search && q ? `${records.length} of ${total}` : `${total} ${label}${total === 1 ? "" : "s"}`
|
|
118
|
+
),
|
|
87
119
|
h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `Add ${label}`)
|
|
88
120
|
),
|
|
121
|
+
spec.search
|
|
122
|
+
? h("input", {
|
|
123
|
+
className: "caf-search",
|
|
124
|
+
value: query,
|
|
125
|
+
placeholder: `Search ${label}s…`,
|
|
126
|
+
"aria-label": "Search",
|
|
127
|
+
onChange: (e) => setQuery(e.target.value),
|
|
128
|
+
})
|
|
129
|
+
: null,
|
|
130
|
+
spec.search && q && records.length === 0 && total > 0
|
|
131
|
+
? h("div", { className: "caf-empty" }, `Nothing matches "${query}".`)
|
|
132
|
+
: null,
|
|
89
133
|
records.length === 0
|
|
90
134
|
? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
|
|
91
135
|
: records.map((rec) =>
|
|
@@ -134,7 +178,22 @@ function DetailScreen({ spec, ctx, record }) {
|
|
|
134
178
|
if (rec) rec[field.key] = v;
|
|
135
179
|
}),
|
|
136
180
|
})
|
|
137
|
-
)
|
|
181
|
+
),
|
|
182
|
+
spec.compute
|
|
183
|
+
? h(
|
|
184
|
+
"div",
|
|
185
|
+
{ className: "caf-output-grid caf-computed" },
|
|
186
|
+
computedEntries(spec).map(([key, c]) => {
|
|
187
|
+
const live = withComputed(spec, record);
|
|
188
|
+
return h(
|
|
189
|
+
"div",
|
|
190
|
+
{ key, className: "caf-stat" },
|
|
191
|
+
h("span", { className: "caf-stat-label" }, c.label || key),
|
|
192
|
+
h("span", { className: "caf-stat-value" }, formatValue(live[key], c.format))
|
|
193
|
+
);
|
|
194
|
+
})
|
|
195
|
+
)
|
|
196
|
+
: null
|
|
138
197
|
);
|
|
139
198
|
}
|
|
140
199
|
|
|
@@ -152,7 +211,7 @@ export function RecordsLayout({ spec, ctx }) {
|
|
|
152
211
|
return h(
|
|
153
212
|
"div",
|
|
154
213
|
{ className: "caf-panel caf-panel-single" },
|
|
155
|
-
h(Summary, { spec: rspec, records: ctx.data.records }),
|
|
214
|
+
h(Summary, { spec: rspec, records: ctx.data.records.map((r) => withComputed(rspec, r)) }),
|
|
156
215
|
h(ListScreen, { spec: rspec, ctx })
|
|
157
216
|
);
|
|
158
217
|
}
|
package/src/steps.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// steps.js — the wizard layout: quizzes, assessments, staged calculators,
|
|
2
|
+
// long forms, onboarding.
|
|
3
|
+
//
|
|
4
|
+
// The author declares the sequence; next/back/progress/result are
|
|
5
|
+
// framework-owned. Three rules keep it un-breakable:
|
|
6
|
+
//
|
|
7
|
+
// one data pool step fields write into the same flat `data` every other
|
|
8
|
+
// block uses, so a result step is just a function of data —
|
|
9
|
+
// no "collect answers" plumbing to half-build.
|
|
10
|
+
// gated next Next is disabled while any field on the CURRENT step is
|
|
11
|
+
// invalid; earlier steps are already valid by construction,
|
|
12
|
+
// later ones aren't the user's problem yet.
|
|
13
|
+
// resumable the current step lives in view state, so a reload lands
|
|
14
|
+
// on the step the user was on, holding their answers.
|
|
15
|
+
|
|
16
|
+
import { createElement as h } from "react";
|
|
17
|
+
import { Field, validateField, formatValue } from "./blocks.js";
|
|
18
|
+
|
|
19
|
+
function stepErrors(step, data) {
|
|
20
|
+
return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ResultScreen({ step, ctx }) {
|
|
24
|
+
const items = step.result(ctx.data) || [];
|
|
25
|
+
return h(
|
|
26
|
+
"div",
|
|
27
|
+
{ className: "caf-block caf-output" },
|
|
28
|
+
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
29
|
+
h(
|
|
30
|
+
"div",
|
|
31
|
+
{ className: "caf-output-grid" },
|
|
32
|
+
items.map((item, i) =>
|
|
33
|
+
h(
|
|
34
|
+
"div",
|
|
35
|
+
{ key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
36
|
+
h("span", { className: "caf-stat-label" }, item.label),
|
|
37
|
+
h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
38
|
+
item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
),
|
|
42
|
+
h(
|
|
43
|
+
"div",
|
|
44
|
+
{ className: "caf-step-nav" },
|
|
45
|
+
h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(0) }, "Start over")
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function StepsLayout({ spec, ctx }) {
|
|
51
|
+
const steps = spec.steps;
|
|
52
|
+
const i = Math.min(Math.max(ctx.view.step || 0, 0), steps.length - 1);
|
|
53
|
+
const step = steps[i];
|
|
54
|
+
const isResult = typeof step.result === "function";
|
|
55
|
+
const isLast = i === steps.length - 1;
|
|
56
|
+
|
|
57
|
+
return h(
|
|
58
|
+
"div",
|
|
59
|
+
{ className: "caf-panel caf-panel-single" },
|
|
60
|
+
h(
|
|
61
|
+
"div",
|
|
62
|
+
{ className: "caf-steps-progress" },
|
|
63
|
+
h("span", { className: "caf-hint" }, isResult ? "Result" : `Step ${i + 1} of ${steps.length}`),
|
|
64
|
+
h(
|
|
65
|
+
"div",
|
|
66
|
+
{ className: "caf-bar-track" },
|
|
67
|
+
h("div", { className: "caf-bar-fill caf-steps-fill", style: { width: `${((i + 1) / steps.length) * 100}%` } })
|
|
68
|
+
)
|
|
69
|
+
),
|
|
70
|
+
isResult
|
|
71
|
+
? h(ResultScreen, { step, ctx })
|
|
72
|
+
: h(
|
|
73
|
+
"div",
|
|
74
|
+
{ className: "caf-block caf-fields" },
|
|
75
|
+
step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
|
|
76
|
+
step.text ? h("p", { className: "caf-step-text" }, typeof step.text === "function" ? step.text(ctx.data) : step.text) : null,
|
|
77
|
+
(step.fields || []).map((field) =>
|
|
78
|
+
h(Field, {
|
|
79
|
+
key: field.key,
|
|
80
|
+
field,
|
|
81
|
+
value: ctx.data[field.key],
|
|
82
|
+
onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
|
|
83
|
+
})
|
|
84
|
+
),
|
|
85
|
+
h(
|
|
86
|
+
"div",
|
|
87
|
+
{ className: "caf-step-nav" },
|
|
88
|
+
i > 0
|
|
89
|
+
? h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(i - 1) }, "‹ Back")
|
|
90
|
+
: h("span"),
|
|
91
|
+
!isLast
|
|
92
|
+
? h(
|
|
93
|
+
"button",
|
|
94
|
+
{
|
|
95
|
+
type: "button",
|
|
96
|
+
className: "caf-btn caf-btn-primary",
|
|
97
|
+
disabled: stepErrors(step, ctx.data),
|
|
98
|
+
onClick: () => ctx.setStep(i + 1),
|
|
99
|
+
},
|
|
100
|
+
"Next ›"
|
|
101
|
+
)
|
|
102
|
+
: null
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
}
|
package/src/theme.js
CHANGED
|
@@ -146,3 +146,12 @@ export function themeCss(seed) {
|
|
|
146
146
|
:root[data-theme="light"] { ${vars(p.light)} }
|
|
147
147
|
`;
|
|
148
148
|
}
|
|
149
|
+
|
|
150
|
+
// Categorical series colors for charts, derived from the seed by golden-angle
|
|
151
|
+
// hue rotation, so any number of segments stays distinguishable. Mid-tone
|
|
152
|
+
// lightness reads on both the light and dark surface.
|
|
153
|
+
export function seriesColors(seed, n) {
|
|
154
|
+
const [h0, s0] = rgbToHsl(hexToRgb(seed || "#3b6ef6"));
|
|
155
|
+
const s = clamp(s0, 45, 70);
|
|
156
|
+
return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
|
|
157
|
+
}
|