claude-artifact-framework 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -8
- package/dist/index.esm.js +417 -260
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +40 -9
- package/dist/index.global.js.map +4 -4
- package/package.json +1 -1
- package/src/app.js +192 -23
- package/src/appState.js +1 -1
- package/src/blocks.js +2 -0
- package/src/chat.js +10 -3
package/dist/index.esm.js
CHANGED
|
@@ -134,7 +134,7 @@ var Component = React && React.Component || class ReactMissing {
|
|
|
134
134
|
// src/appState.js
|
|
135
135
|
var DATA_KEY = "caf:data";
|
|
136
136
|
var VIEW_KEY = "caf:view";
|
|
137
|
-
var EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null };
|
|
137
|
+
var EMPTY_VIEW = { screen: null, arg: null, stack: [], step: 0, selected: null, tabs: {}, collapsed: {} };
|
|
138
138
|
function isPlainObject(v) {
|
|
139
139
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
140
140
|
}
|
|
@@ -372,6 +372,241 @@ function seriesColors(seed, n) {
|
|
|
372
372
|
return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
// src/chat.js
|
|
376
|
+
var CHAT_KEYS = ["system", "greeting", "placeholder", "tools", "model", "maxTokens"];
|
|
377
|
+
var DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
378
|
+
var MAX_ROUNDS = 8;
|
|
379
|
+
async function streamCall({ model, maxTokens, system, messages, onText }) {
|
|
380
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: { "Content-Type": "application/json" },
|
|
383
|
+
body: JSON.stringify({ model, max_tokens: maxTokens, system, messages, stream: true })
|
|
384
|
+
});
|
|
385
|
+
if (!res.ok || !res.body) {
|
|
386
|
+
let detail = "";
|
|
387
|
+
try {
|
|
388
|
+
const b = await res.json();
|
|
389
|
+
detail = b && b.error && b.error.message || JSON.stringify(b);
|
|
390
|
+
} catch {
|
|
391
|
+
detail = await res.text().catch(() => "");
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`HTTP ${res.status}${detail ? ": " + detail : ""}`);
|
|
394
|
+
}
|
|
395
|
+
const reader = res.body.getReader();
|
|
396
|
+
const decoder = new TextDecoder();
|
|
397
|
+
let full = "";
|
|
398
|
+
let buf = "";
|
|
399
|
+
for (; ; ) {
|
|
400
|
+
const { done, value } = await reader.read();
|
|
401
|
+
if (done) break;
|
|
402
|
+
buf += decoder.decode(value, { stream: true });
|
|
403
|
+
const lines = buf.split("\n");
|
|
404
|
+
buf = lines.pop();
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
if (!line.startsWith("data: ")) continue;
|
|
407
|
+
try {
|
|
408
|
+
const j = JSON.parse(line.slice(6));
|
|
409
|
+
if (j.type === "content_block_delta" && j.delta && j.delta.text) {
|
|
410
|
+
full += j.delta.text;
|
|
411
|
+
if (onText) onText(full);
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return full;
|
|
418
|
+
}
|
|
419
|
+
function parseToolCalls(text) {
|
|
420
|
+
const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
|
|
421
|
+
if (!match) return null;
|
|
422
|
+
try {
|
|
423
|
+
const calls = JSON.parse(match[1]);
|
|
424
|
+
return Array.isArray(calls) && calls.length ? calls : null;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function stripToolCallBlock(text) {
|
|
430
|
+
return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
|
|
431
|
+
}
|
|
432
|
+
function toolProtocol(tools) {
|
|
433
|
+
const list = Object.entries(tools).map(([name, t]) => `- ${name}: ${t.description}
|
|
434
|
+
Input: ${JSON.stringify(t.input || {})}`).join("\n");
|
|
435
|
+
return `
|
|
436
|
+
|
|
437
|
+
You have tools available:
|
|
438
|
+
${list}
|
|
439
|
+
|
|
440
|
+
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:
|
|
441
|
+
|
|
442
|
+
\`\`\`tool_call
|
|
443
|
+
[{ "name": "tool_name", "input": { "param": "value" } }]
|
|
444
|
+
\`\`\`
|
|
445
|
+
|
|
446
|
+
You may include several calls in one array. NEVER invent a tool's result \u2014 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.`;
|
|
447
|
+
}
|
|
448
|
+
function runTools(calls, tools, ctx) {
|
|
449
|
+
return calls.map((call) => {
|
|
450
|
+
const tool = tools[call.name];
|
|
451
|
+
if (!tool) {
|
|
452
|
+
return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
|
|
453
|
+
}
|
|
454
|
+
try {
|
|
455
|
+
const result = tool.run(call.input || {}, ctx);
|
|
456
|
+
return { name: call.name, result: result === void 0 ? { ok: true } : result };
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return { name: call.name, error: String(e && e.message || e) };
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
var escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
463
|
+
function renderMarkdown(text) {
|
|
464
|
+
const codeBlocks = [];
|
|
465
|
+
let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
|
|
466
|
+
codeBlocks.push(code.replace(/\n$/, ""));
|
|
467
|
+
return "\0" + (codeBlocks.length - 1) + "\0";
|
|
468
|
+
});
|
|
469
|
+
t = escHtml(t);
|
|
470
|
+
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
471
|
+
t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
472
|
+
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
473
|
+
t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
|
|
474
|
+
t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
|
|
475
|
+
t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
|
|
476
|
+
t = t.replace(/\n/g, "<br>");
|
|
477
|
+
t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
|
|
478
|
+
return t;
|
|
479
|
+
}
|
|
480
|
+
function Bubble({ m }) {
|
|
481
|
+
if (m.role === "tools") {
|
|
482
|
+
return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
|
|
483
|
+
}
|
|
484
|
+
if (m.role === "error") {
|
|
485
|
+
return createElement(
|
|
486
|
+
"div",
|
|
487
|
+
{ className: "caf-msg caf-msg-error" },
|
|
488
|
+
createElement("strong", null, "The call failed"),
|
|
489
|
+
createElement("span", null, m.content),
|
|
490
|
+
createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (m.role === "user") return createElement("div", { className: "caf-msg caf-msg-user" }, m.content);
|
|
494
|
+
return createElement("div", {
|
|
495
|
+
className: "caf-msg caf-msg-assistant",
|
|
496
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(m.content) }
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
function ChatPanel({ cfg, ctx }) {
|
|
500
|
+
const c = cfg;
|
|
501
|
+
const [draft, setDraft] = useState("");
|
|
502
|
+
const [busy, setBusy] = useState(false);
|
|
503
|
+
const [live, setLive] = useState(null);
|
|
504
|
+
const endRef = useRef(null);
|
|
505
|
+
const chat = ctx.data.chat || { api: [], log: [] };
|
|
506
|
+
const log = chat.log || [];
|
|
507
|
+
useEffect(() => {
|
|
508
|
+
if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
|
|
509
|
+
}, [log.length, live]);
|
|
510
|
+
async function send() {
|
|
511
|
+
const text = draft.trim();
|
|
512
|
+
if (!text || busy) return;
|
|
513
|
+
setDraft("");
|
|
514
|
+
setBusy(true);
|
|
515
|
+
let api = [...chat.api || [], { role: "user", content: text }];
|
|
516
|
+
let logNow = [...log, { role: "user", content: text }];
|
|
517
|
+
const commit = () => ctx.update((d) => {
|
|
518
|
+
d.chat = { api, log: logNow };
|
|
519
|
+
});
|
|
520
|
+
commit();
|
|
521
|
+
try {
|
|
522
|
+
const system = (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") + (c.tools ? toolProtocol(c.tools) : "");
|
|
523
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
524
|
+
const raw = await streamCall({
|
|
525
|
+
model: c.model || DEFAULT_MODEL,
|
|
526
|
+
maxTokens: c.maxTokens || 2e3,
|
|
527
|
+
system,
|
|
528
|
+
messages: api,
|
|
529
|
+
onText: (t) => setLive(stripToolCallBlock(t))
|
|
530
|
+
});
|
|
531
|
+
api = [...api, { role: "assistant", content: raw }];
|
|
532
|
+
const shown = stripToolCallBlock(raw);
|
|
533
|
+
const calls = c.tools ? parseToolCalls(raw) : null;
|
|
534
|
+
if (!calls) {
|
|
535
|
+
logNow = [...logNow, { role: "assistant", content: shown || raw }];
|
|
536
|
+
commit();
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
const results = runTools(calls, c.tools, ctx);
|
|
540
|
+
if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
|
|
541
|
+
logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
|
|
542
|
+
api = [
|
|
543
|
+
...api,
|
|
544
|
+
{
|
|
545
|
+
role: "user",
|
|
546
|
+
content: `[TOOL_RESULT]
|
|
547
|
+
${JSON.stringify(results)}
|
|
548
|
+
[/TOOL_RESULT]
|
|
549
|
+
|
|
550
|
+
Continue with this information. If you have everything you need, answer in natural language without further tool_call.`
|
|
551
|
+
}
|
|
552
|
+
];
|
|
553
|
+
commit();
|
|
554
|
+
setLive(null);
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {
|
|
557
|
+
logNow = [...logNow, { role: "error", content: String(e && e.message || e) }];
|
|
558
|
+
commit();
|
|
559
|
+
} finally {
|
|
560
|
+
setLive(null);
|
|
561
|
+
setBusy(false);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return createElement(
|
|
565
|
+
"div",
|
|
566
|
+
{ className: "caf-chat-panel" },
|
|
567
|
+
createElement(
|
|
568
|
+
"div",
|
|
569
|
+
{ className: "caf-block caf-chat-log" },
|
|
570
|
+
c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
|
|
571
|
+
log.map((m, i) => createElement(Bubble, { key: i, m })),
|
|
572
|
+
live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
|
|
573
|
+
busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
|
|
574
|
+
createElement("div", { ref: endRef })
|
|
575
|
+
),
|
|
576
|
+
// Deliberately NOT a <form>: the artifact iframe's sandbox lacks
|
|
577
|
+
// allow-forms, so native form submission is blocked there — and on mobile
|
|
578
|
+
// the keyboard's send key triggers exactly that implicit submission. A
|
|
579
|
+
// plain div with onClick + Enter via onKeyDown never touches the native
|
|
580
|
+
// form machinery, so it can't be sandboxed away.
|
|
581
|
+
createElement(
|
|
582
|
+
"div",
|
|
583
|
+
{ className: "caf-chat-input" },
|
|
584
|
+
createElement("input", {
|
|
585
|
+
value: draft,
|
|
586
|
+
placeholder: c.placeholder || "Message\u2026",
|
|
587
|
+
disabled: busy,
|
|
588
|
+
enterKeyHint: "send",
|
|
589
|
+
onChange: (e) => setDraft(e.target.value),
|
|
590
|
+
onKeyDown: (e) => {
|
|
591
|
+
if (e.key === "Enter") {
|
|
592
|
+
e.preventDefault();
|
|
593
|
+
send();
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
"aria-label": "Message"
|
|
597
|
+
}),
|
|
598
|
+
createElement(
|
|
599
|
+
"button",
|
|
600
|
+
{ type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
|
|
601
|
+
"Send"
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
function ChatLayout({ spec, ctx }) {
|
|
607
|
+
return createElement("div", { className: "caf-panel caf-panel-single" }, createElement(ChatPanel, { cfg: spec.chat, ctx }));
|
|
608
|
+
}
|
|
609
|
+
|
|
375
610
|
// src/blocks.js
|
|
376
611
|
var FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select"];
|
|
377
612
|
function formatValue(value, format) {
|
|
@@ -738,6 +973,7 @@ var BLOCKS = {
|
|
|
738
973
|
chart: ChartBlock,
|
|
739
974
|
timer: TimerBlock,
|
|
740
975
|
actions: ActionsBlock,
|
|
976
|
+
chat: ({ spec, ctx }) => createElement(ChatPanel, { cfg: spec.chat, ctx }),
|
|
741
977
|
custom: CustomBlock
|
|
742
978
|
};
|
|
743
979
|
var BLOCK_NAMES = Object.keys(BLOCKS);
|
|
@@ -1079,253 +1315,41 @@ function StepsLayout({ spec, ctx }) {
|
|
|
1079
1315
|
);
|
|
1080
1316
|
}
|
|
1081
1317
|
|
|
1082
|
-
// src/
|
|
1083
|
-
var
|
|
1084
|
-
var
|
|
1085
|
-
var
|
|
1086
|
-
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
let detail = "";
|
|
1094
|
-
try {
|
|
1095
|
-
const b = await res.json();
|
|
1096
|
-
detail = b && b.error && b.error.message || JSON.stringify(b);
|
|
1097
|
-
} catch {
|
|
1098
|
-
detail = await res.text().catch(() => "");
|
|
1318
|
+
// src/app.js
|
|
1319
|
+
var LAYOUTS = ["panel", "records", "steps", "chat", "workspace"];
|
|
1320
|
+
var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible"];
|
|
1321
|
+
var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar"];
|
|
1322
|
+
function checkFields(fields, where) {
|
|
1323
|
+
for (const f of fields || []) {
|
|
1324
|
+
if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
|
|
1325
|
+
if (f.type !== void 0 && !FIELD_TYPES.includes(f.type)) {
|
|
1326
|
+
throw new Error(
|
|
1327
|
+
`claude-artifact-framework: field "${f.key}" in ${where} has unknown type "${f.type}". Valid types: ${FIELD_TYPES.join(", ")}.`
|
|
1328
|
+
);
|
|
1099
1329
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
let full = "";
|
|
1105
|
-
let buf = "";
|
|
1106
|
-
for (; ; ) {
|
|
1107
|
-
const { done, value } = await reader.read();
|
|
1108
|
-
if (done) break;
|
|
1109
|
-
buf += decoder.decode(value, { stream: true });
|
|
1110
|
-
const lines = buf.split("\n");
|
|
1111
|
-
buf = lines.pop();
|
|
1112
|
-
for (const line of lines) {
|
|
1113
|
-
if (!line.startsWith("data: ")) continue;
|
|
1114
|
-
try {
|
|
1115
|
-
const j = JSON.parse(line.slice(6));
|
|
1116
|
-
if (j.type === "content_block_delta" && j.delta && j.delta.text) {
|
|
1117
|
-
full += j.delta.text;
|
|
1118
|
-
if (onText) onText(full);
|
|
1119
|
-
}
|
|
1120
|
-
} catch {
|
|
1121
|
-
}
|
|
1330
|
+
if (f.type === "select" && f.options !== void 0 && !Array.isArray(f.options) && typeof f.options !== "function") {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`claude-artifact-framework: field "${f.key}" in ${where}: \`options\` must be an array or a function of data returning one.`
|
|
1333
|
+
);
|
|
1122
1334
|
}
|
|
1123
1335
|
}
|
|
1124
|
-
return full;
|
|
1125
|
-
}
|
|
1126
|
-
function parseToolCalls(text) {
|
|
1127
|
-
const match = text.match(/```tool_call\s*\n?([\s\S]*?)```/);
|
|
1128
|
-
if (!match) return null;
|
|
1129
|
-
try {
|
|
1130
|
-
const calls = JSON.parse(match[1]);
|
|
1131
|
-
return Array.isArray(calls) && calls.length ? calls : null;
|
|
1132
|
-
} catch {
|
|
1133
|
-
return null;
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
function stripToolCallBlock(text) {
|
|
1137
|
-
return text.replace(/```tool_call\s*\n?[\s\S]*?```/, "").trim();
|
|
1138
|
-
}
|
|
1139
|
-
function toolProtocol(tools) {
|
|
1140
|
-
const list = Object.entries(tools).map(([name, t]) => `- ${name}: ${t.description}
|
|
1141
|
-
Input: ${JSON.stringify(t.input || {})}`).join("\n");
|
|
1142
|
-
return `
|
|
1143
|
-
|
|
1144
|
-
You have tools available:
|
|
1145
|
-
${list}
|
|
1146
|
-
|
|
1147
|
-
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:
|
|
1148
|
-
|
|
1149
|
-
\`\`\`tool_call
|
|
1150
|
-
[{ "name": "tool_name", "input": { "param": "value" } }]
|
|
1151
|
-
\`\`\`
|
|
1152
|
-
|
|
1153
|
-
You may include several calls in one array. NEVER invent a tool's result \u2014 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.`;
|
|
1154
1336
|
}
|
|
1155
|
-
function
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
return { name: call.name, error: `unknown tool "${call.name}". Valid tools: ${Object.keys(tools).join(", ")}` };
|
|
1160
|
-
}
|
|
1161
|
-
try {
|
|
1162
|
-
const result = tool.run(call.input || {}, ctx);
|
|
1163
|
-
return { name: call.name, result: result === void 0 ? { ok: true } : result };
|
|
1164
|
-
} catch (e) {
|
|
1165
|
-
return { name: call.name, error: String(e && e.message || e) };
|
|
1166
|
-
}
|
|
1167
|
-
});
|
|
1168
|
-
}
|
|
1169
|
-
var escHtml = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
1170
|
-
function renderMarkdown(text) {
|
|
1171
|
-
const codeBlocks = [];
|
|
1172
|
-
let t = String(text).replace(/```[a-z]*\n?([\s\S]*?)```/g, (m, code) => {
|
|
1173
|
-
codeBlocks.push(code.replace(/\n$/, ""));
|
|
1174
|
-
return "\0" + (codeBlocks.length - 1) + "\0";
|
|
1175
|
-
});
|
|
1176
|
-
t = escHtml(t);
|
|
1177
|
-
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
1178
|
-
t = t.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
1179
|
-
t = t.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
1180
|
-
t = t.replace(/^#{1,3} (.+)$/gm, '<strong class="caf-md-h">$1</strong>');
|
|
1181
|
-
t = t.replace(/^[-•*] (.+)$/gm, '<span class="caf-md-li">$1</span>');
|
|
1182
|
-
t = t.replace(/^\d+\. (.+)$/gm, '<span class="caf-md-li caf-md-num">$1</span>');
|
|
1183
|
-
t = t.replace(/\n/g, "<br>");
|
|
1184
|
-
t = t.replace(/\u0000(\d+)\u0000/g, (m, i) => '<pre class="caf-code">' + escHtml(codeBlocks[Number(i)]) + "</pre>");
|
|
1185
|
-
return t;
|
|
1186
|
-
}
|
|
1187
|
-
function Bubble({ m }) {
|
|
1188
|
-
if (m.role === "tools") {
|
|
1189
|
-
return createElement("div", { className: "caf-msg-tools" }, `used: ${m.tools.join(", ")}`);
|
|
1190
|
-
}
|
|
1191
|
-
if (m.role === "error") {
|
|
1192
|
-
return createElement(
|
|
1193
|
-
"div",
|
|
1194
|
-
{ className: "caf-msg caf-msg-error" },
|
|
1195
|
-
createElement("strong", null, "The call failed"),
|
|
1196
|
-
createElement("span", null, m.content),
|
|
1197
|
-
createElement("span", { className: "caf-hint" }, "Your message is kept \u2014 send again to retry.")
|
|
1337
|
+
function checkChatConfig(c, where) {
|
|
1338
|
+
if (!c || typeof c.system !== "string" && typeof c.system !== "function") {
|
|
1339
|
+
throw new Error(
|
|
1340
|
+
`claude-artifact-framework: ${where} needs \`{ system: "..." }\` (a string or a function of data).`
|
|
1198
1341
|
);
|
|
1199
1342
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}
|
|
1206
|
-
function ChatLayout({ spec, ctx }) {
|
|
1207
|
-
const c = spec.chat;
|
|
1208
|
-
const [draft, setDraft] = useState("");
|
|
1209
|
-
const [busy, setBusy] = useState(false);
|
|
1210
|
-
const [live, setLive] = useState(null);
|
|
1211
|
-
const endRef = useRef(null);
|
|
1212
|
-
const chat = ctx.data.chat || { api: [], log: [] };
|
|
1213
|
-
const log = chat.log || [];
|
|
1214
|
-
useEffect(() => {
|
|
1215
|
-
if (endRef.current) endRef.current.scrollIntoView({ block: "end" });
|
|
1216
|
-
}, [log.length, live]);
|
|
1217
|
-
async function send() {
|
|
1218
|
-
const text = draft.trim();
|
|
1219
|
-
if (!text || busy) return;
|
|
1220
|
-
setDraft("");
|
|
1221
|
-
setBusy(true);
|
|
1222
|
-
let api = [...chat.api || [], { role: "user", content: text }];
|
|
1223
|
-
let logNow = [...log, { role: "user", content: text }];
|
|
1224
|
-
const commit = () => ctx.update((d) => {
|
|
1225
|
-
d.chat = { api, log: logNow };
|
|
1226
|
-
});
|
|
1227
|
-
commit();
|
|
1228
|
-
try {
|
|
1229
|
-
const system = (typeof c.system === "function" ? c.system(ctx.data) : c.system || "") + (c.tools ? toolProtocol(c.tools) : "");
|
|
1230
|
-
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
1231
|
-
const raw = await streamCall({
|
|
1232
|
-
model: c.model || DEFAULT_MODEL,
|
|
1233
|
-
maxTokens: c.maxTokens || 2e3,
|
|
1234
|
-
system,
|
|
1235
|
-
messages: api,
|
|
1236
|
-
onText: (t) => setLive(stripToolCallBlock(t))
|
|
1237
|
-
});
|
|
1238
|
-
api = [...api, { role: "assistant", content: raw }];
|
|
1239
|
-
const shown = stripToolCallBlock(raw);
|
|
1240
|
-
const calls = c.tools ? parseToolCalls(raw) : null;
|
|
1241
|
-
if (!calls) {
|
|
1242
|
-
logNow = [...logNow, { role: "assistant", content: shown || raw }];
|
|
1243
|
-
commit();
|
|
1244
|
-
break;
|
|
1245
|
-
}
|
|
1246
|
-
const results = runTools(calls, c.tools, ctx);
|
|
1247
|
-
if (shown) logNow = [...logNow, { role: "assistant", content: shown }];
|
|
1248
|
-
logNow = [...logNow, { role: "tools", tools: calls.map((x) => x.name) }];
|
|
1249
|
-
api = [
|
|
1250
|
-
...api,
|
|
1251
|
-
{
|
|
1252
|
-
role: "user",
|
|
1253
|
-
content: `[TOOL_RESULT]
|
|
1254
|
-
${JSON.stringify(results)}
|
|
1255
|
-
[/TOOL_RESULT]
|
|
1256
|
-
|
|
1257
|
-
Continue with this information. If you have everything you need, answer in natural language without further tool_call.`
|
|
1258
|
-
}
|
|
1259
|
-
];
|
|
1260
|
-
commit();
|
|
1261
|
-
setLive(null);
|
|
1262
|
-
}
|
|
1263
|
-
} catch (e) {
|
|
1264
|
-
logNow = [...logNow, { role: "error", content: String(e && e.message || e) }];
|
|
1265
|
-
commit();
|
|
1266
|
-
} finally {
|
|
1267
|
-
setLive(null);
|
|
1268
|
-
setBusy(false);
|
|
1269
|
-
}
|
|
1343
|
+
const unknown = Object.keys(c).filter((k) => !CHAT_KEYS.includes(k));
|
|
1344
|
+
if (unknown.length) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
`claude-artifact-framework: ${where} has unknown keys (${unknown.join(", ")}). Valid keys: ${CHAT_KEYS.join(", ")}.`
|
|
1347
|
+
);
|
|
1270
1348
|
}
|
|
1271
|
-
|
|
1272
|
-
"
|
|
1273
|
-
{ className: "caf-panel caf-panel-single" },
|
|
1274
|
-
createElement(
|
|
1275
|
-
"div",
|
|
1276
|
-
{ className: "caf-block caf-chat-log" },
|
|
1277
|
-
c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
|
|
1278
|
-
log.map((m, i) => createElement(Bubble, { key: i, m })),
|
|
1279
|
-
live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
|
|
1280
|
-
busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
|
|
1281
|
-
createElement("div", { ref: endRef })
|
|
1282
|
-
),
|
|
1283
|
-
// Deliberately NOT a <form>: the artifact iframe's sandbox lacks
|
|
1284
|
-
// allow-forms, so native form submission is blocked there — and on mobile
|
|
1285
|
-
// the keyboard's send key triggers exactly that implicit submission. A
|
|
1286
|
-
// plain div with onClick + Enter via onKeyDown never touches the native
|
|
1287
|
-
// form machinery, so it can't be sandboxed away.
|
|
1288
|
-
createElement(
|
|
1289
|
-
"div",
|
|
1290
|
-
{ className: "caf-chat-input" },
|
|
1291
|
-
createElement("input", {
|
|
1292
|
-
value: draft,
|
|
1293
|
-
placeholder: c.placeholder || "Message\u2026",
|
|
1294
|
-
disabled: busy,
|
|
1295
|
-
enterKeyHint: "send",
|
|
1296
|
-
onChange: (e) => setDraft(e.target.value),
|
|
1297
|
-
onKeyDown: (e) => {
|
|
1298
|
-
if (e.key === "Enter") {
|
|
1299
|
-
e.preventDefault();
|
|
1300
|
-
send();
|
|
1301
|
-
}
|
|
1302
|
-
},
|
|
1303
|
-
"aria-label": "Message"
|
|
1304
|
-
}),
|
|
1305
|
-
createElement(
|
|
1306
|
-
"button",
|
|
1307
|
-
{ type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
|
|
1308
|
-
"Send"
|
|
1309
|
-
)
|
|
1310
|
-
)
|
|
1311
|
-
);
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// src/app.js
|
|
1315
|
-
var LAYOUTS = ["panel", "records", "steps", "chat"];
|
|
1316
|
-
var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle"];
|
|
1317
|
-
var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared"];
|
|
1318
|
-
function checkFields(fields, where) {
|
|
1319
|
-
for (const f of fields || []) {
|
|
1320
|
-
if (!f || !f.key) throw new Error(`claude-artifact-framework: every field in ${where} needs a \`key\`.`);
|
|
1321
|
-
if (f.type !== void 0 && !FIELD_TYPES.includes(f.type)) {
|
|
1322
|
-
throw new Error(
|
|
1323
|
-
`claude-artifact-framework: field "${f.key}" in ${where} has unknown type "${f.type}". Valid types: ${FIELD_TYPES.join(", ")}.`
|
|
1324
|
-
);
|
|
1325
|
-
}
|
|
1326
|
-
if (f.type === "select" && f.options !== void 0 && !Array.isArray(f.options) && typeof f.options !== "function") {
|
|
1349
|
+
for (const [name, tool] of Object.entries(c.tools || {})) {
|
|
1350
|
+
if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
|
|
1327
1351
|
throw new Error(
|
|
1328
|
-
`claude-artifact-framework:
|
|
1352
|
+
`claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
|
|
1329
1353
|
);
|
|
1330
1354
|
}
|
|
1331
1355
|
}
|
|
@@ -1356,7 +1380,7 @@ function checkBlocks(blocks) {
|
|
|
1356
1380
|
throw new Error(`claude-artifact-framework: blocks[${i}] must be an object like { fields: [...] }.`);
|
|
1357
1381
|
}
|
|
1358
1382
|
const keys = Object.keys(block).filter((k) => !RESERVED.includes(k));
|
|
1359
|
-
const known = keys.filter((k) => BLOCK_NAMES.includes(k));
|
|
1383
|
+
const known = keys.filter((k) => BLOCK_NAMES.includes(k) || k === "tabs");
|
|
1360
1384
|
if (known.length === 0) {
|
|
1361
1385
|
throw new Error(
|
|
1362
1386
|
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${BLOCK_NAMES.join(", ")}.`
|
|
@@ -1371,6 +1395,25 @@ function checkBlocks(blocks) {
|
|
|
1371
1395
|
}
|
|
1372
1396
|
checkFields(block.fields, `blocks[${i}]`);
|
|
1373
1397
|
if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
|
|
1398
|
+
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
1399
|
+
if (known[0] === "tabs") {
|
|
1400
|
+
const tabs = block.tabs;
|
|
1401
|
+
if (!Array.isArray(tabs) || tabs.length === 0) {
|
|
1402
|
+
throw new Error(`claude-artifact-framework: blocks[${i}].tabs needs an array of { label, blocks: [...] }.`);
|
|
1403
|
+
}
|
|
1404
|
+
tabs.forEach((t, j) => {
|
|
1405
|
+
if (!t || typeof t.label !== "string" || !Array.isArray(t.blocks) || t.blocks.length === 0) {
|
|
1406
|
+
throw new Error(`claude-artifact-framework: tabs[${j}] needs a \`label\` string and a non-empty \`blocks\` array.`);
|
|
1407
|
+
}
|
|
1408
|
+
if (t.blocks.some((b) => b && b.tabs)) {
|
|
1409
|
+
throw new Error("claude-artifact-framework: tabs cannot nest inside tabs.");
|
|
1410
|
+
}
|
|
1411
|
+
checkBlocks(t.blocks);
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
if (block.when !== void 0 && typeof block.when !== "function") {
|
|
1415
|
+
throw new Error(`claude-artifact-framework: blocks[${i}].when must be a function of data returning true/false.`);
|
|
1416
|
+
}
|
|
1374
1417
|
});
|
|
1375
1418
|
}
|
|
1376
1419
|
function validate(spec) {
|
|
@@ -1419,25 +1462,28 @@ function validate(spec) {
|
|
|
1419
1462
|
return layout;
|
|
1420
1463
|
}
|
|
1421
1464
|
if (layout === "chat") {
|
|
1422
|
-
|
|
1423
|
-
|
|
1465
|
+
checkChatConfig(spec.chat, "chat");
|
|
1466
|
+
return layout;
|
|
1467
|
+
}
|
|
1468
|
+
if (layout === "workspace") {
|
|
1469
|
+
if (!Array.isArray(spec.main) || spec.main.length === 0) {
|
|
1424
1470
|
throw new Error(
|
|
1425
|
-
'claude-artifact-framework: layout "
|
|
1471
|
+
'claude-artifact-framework: layout "workspace" needs `main: [...]` (an array of blocks). `sidebar: [...]` is optional.'
|
|
1426
1472
|
);
|
|
1427
1473
|
}
|
|
1428
|
-
|
|
1429
|
-
if (
|
|
1474
|
+
checkBlocks(spec.main);
|
|
1475
|
+
if (spec.sidebar !== void 0) {
|
|
1476
|
+
if (!Array.isArray(spec.sidebar)) {
|
|
1477
|
+
throw new Error("claude-artifact-framework: workspace `sidebar` must be an array of blocks.");
|
|
1478
|
+
}
|
|
1479
|
+
checkBlocks(spec.sidebar);
|
|
1480
|
+
}
|
|
1481
|
+
const chats = flattenBlocks([...spec.main, ...spec.sidebar || []]).filter((b) => b && b.chat);
|
|
1482
|
+
if (chats.length > 1) {
|
|
1430
1483
|
throw new Error(
|
|
1431
|
-
|
|
1484
|
+
"claude-artifact-framework: only one chat block per app \u2014 the conversation persists under data.chat and there is exactly one."
|
|
1432
1485
|
);
|
|
1433
1486
|
}
|
|
1434
|
-
for (const [name, tool] of Object.entries(c.tools || {})) {
|
|
1435
|
-
if (!tool || typeof tool.description !== "string" || typeof tool.run !== "function") {
|
|
1436
|
-
throw new Error(
|
|
1437
|
-
`claude-artifact-framework: chat tool "${name}" needs a \`description\` string and a \`run(input, ctx)\` function.`
|
|
1438
|
-
);
|
|
1439
|
-
}
|
|
1440
|
-
}
|
|
1441
1487
|
return layout;
|
|
1442
1488
|
}
|
|
1443
1489
|
if (layout === "records") {
|
|
@@ -1499,15 +1545,25 @@ function validate(spec) {
|
|
|
1499
1545
|
checkBlocks(spec.blocks || []);
|
|
1500
1546
|
return layout;
|
|
1501
1547
|
}
|
|
1548
|
+
function flattenBlocks(blocks) {
|
|
1549
|
+
const out = [];
|
|
1550
|
+
for (const b of blocks || []) {
|
|
1551
|
+
out.push(b);
|
|
1552
|
+
if (b && Array.isArray(b.tabs)) for (const t of b.tabs) out.push(...flattenBlocks(t.blocks));
|
|
1553
|
+
}
|
|
1554
|
+
return out;
|
|
1555
|
+
}
|
|
1502
1556
|
function defaultsFrom(spec) {
|
|
1503
1557
|
const data = { ...spec.data || {} };
|
|
1504
1558
|
if ((spec.layout || "panel") === "records" && !Array.isArray(data.records)) {
|
|
1505
1559
|
data.records = [];
|
|
1506
1560
|
}
|
|
1507
|
-
|
|
1561
|
+
const allBlocks = flattenBlocks([...spec.blocks || [], ...spec.main || [], ...spec.sidebar || []]);
|
|
1562
|
+
const hasChat = (spec.layout || "panel") === "chat" || allBlocks.some((b) => b && b.chat);
|
|
1563
|
+
if (hasChat && !data.chat) {
|
|
1508
1564
|
data.chat = { api: [], log: [] };
|
|
1509
1565
|
}
|
|
1510
|
-
for (const block of [...
|
|
1566
|
+
for (const block of [...allBlocks, ...spec.steps || []]) {
|
|
1511
1567
|
for (const field of block.fields || []) {
|
|
1512
1568
|
if (field && field.key !== void 0 && !(field.key in data)) {
|
|
1513
1569
|
data[field.key] = field.value !== void 0 ? field.value : field.type === "check" ? false : "";
|
|
@@ -1542,10 +1598,69 @@ function boundary(React2) {
|
|
|
1542
1598
|
}
|
|
1543
1599
|
return BlockBoundary;
|
|
1544
1600
|
}
|
|
1601
|
+
function TabsBlock({ spec, ctx }) {
|
|
1602
|
+
const tabs = spec.tabs;
|
|
1603
|
+
const key = tabs.map((t) => t.label).join("|");
|
|
1604
|
+
const stored = ctx.view.tabs ? ctx.view.tabs[key] : void 0;
|
|
1605
|
+
const active = Math.min(stored === void 0 ? 0 : stored, tabs.length - 1);
|
|
1606
|
+
return createElement(
|
|
1607
|
+
"div",
|
|
1608
|
+
{ className: "caf-tabs" },
|
|
1609
|
+
createElement(
|
|
1610
|
+
"div",
|
|
1611
|
+
{ className: "caf-tabbar", role: "tablist" },
|
|
1612
|
+
tabs.map(
|
|
1613
|
+
(t, i) => createElement(
|
|
1614
|
+
"button",
|
|
1615
|
+
{
|
|
1616
|
+
key: t.label,
|
|
1617
|
+
type: "button",
|
|
1618
|
+
role: "tab",
|
|
1619
|
+
"aria-selected": i === active,
|
|
1620
|
+
className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
|
|
1621
|
+
onClick: () => ctx.setTab(key, i)
|
|
1622
|
+
},
|
|
1623
|
+
t.label
|
|
1624
|
+
)
|
|
1625
|
+
)
|
|
1626
|
+
),
|
|
1627
|
+
createElement(
|
|
1628
|
+
"div",
|
|
1629
|
+
{ className: "caf-tabs-body" },
|
|
1630
|
+
tabs[active].blocks.map((b, i) => createElement(Block, { key: active + ":" + i, block: b, ctx }))
|
|
1631
|
+
)
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
var ALL_BLOCKS = { ...BLOCKS, tabs: TabsBlock };
|
|
1635
|
+
var ALL_NAMES = [...BLOCK_NAMES, "tabs"];
|
|
1545
1636
|
function Block({ block, ctx }) {
|
|
1546
|
-
|
|
1547
|
-
const
|
|
1548
|
-
|
|
1637
|
+
if (typeof block.when === "function" && !block.when(ctx.data)) return null;
|
|
1638
|
+
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
1639
|
+
const Component2 = ALL_BLOCKS[name];
|
|
1640
|
+
const inner = createElement(
|
|
1641
|
+
boundary(ctx.React),
|
|
1642
|
+
null,
|
|
1643
|
+
createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx })
|
|
1644
|
+
);
|
|
1645
|
+
if (!block.collapsible) return inner;
|
|
1646
|
+
const key = typeof block.title === "string" && block.title ? block.title : name;
|
|
1647
|
+
const collapsed = ctx.view.collapsed && key in ctx.view.collapsed ? ctx.view.collapsed[key] : block.collapsible === "collapsed";
|
|
1648
|
+
return createElement(
|
|
1649
|
+
"div",
|
|
1650
|
+
{ className: "caf-collapse" },
|
|
1651
|
+
createElement(
|
|
1652
|
+
"button",
|
|
1653
|
+
{
|
|
1654
|
+
type: "button",
|
|
1655
|
+
className: "caf-collapse-head",
|
|
1656
|
+
"aria-expanded": !collapsed,
|
|
1657
|
+
onClick: () => ctx.toggleCollapsed(key, !collapsed)
|
|
1658
|
+
},
|
|
1659
|
+
createElement("span", null, block.title || name),
|
|
1660
|
+
createElement("span", { className: collapsed ? "caf-chevron caf-chevron-closed" : "caf-chevron" }, "\u25BE")
|
|
1661
|
+
),
|
|
1662
|
+
collapsed ? null : createElement("div", { className: "caf-collapse-body" }, inner)
|
|
1663
|
+
);
|
|
1549
1664
|
}
|
|
1550
1665
|
function Panel({ spec, ctx }) {
|
|
1551
1666
|
return createElement(
|
|
@@ -1579,7 +1694,16 @@ function RecordsWithBlocks({ spec, ctx }) {
|
|
|
1579
1694
|
) : null
|
|
1580
1695
|
);
|
|
1581
1696
|
}
|
|
1582
|
-
|
|
1697
|
+
function WorkspaceLayout({ spec, ctx }) {
|
|
1698
|
+
const render = (blocks) => (blocks || []).map((block, i) => createElement("section", { key: i, className: "caf-ws-slot" }, createElement(Block, { block, ctx })));
|
|
1699
|
+
return createElement(
|
|
1700
|
+
"div",
|
|
1701
|
+
{ className: "caf-workspace" },
|
|
1702
|
+
createElement("div", { className: "caf-ws-main" }, render(spec.main)),
|
|
1703
|
+
spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
|
|
1704
|
+
);
|
|
1705
|
+
}
|
|
1706
|
+
var LAYOUT_COMPONENTS = { panel: Panel, records: RecordsWithBlocks, steps: StepsLayout, chat: ChatLayout, workspace: WorkspaceLayout };
|
|
1583
1707
|
function createApp(spec = {}) {
|
|
1584
1708
|
const layout = validate(spec);
|
|
1585
1709
|
const state = createAppState(defaultsFrom(spec), { shared: Boolean(spec.shared) });
|
|
@@ -1604,6 +1728,8 @@ function createApp(spec = {}) {
|
|
|
1604
1728
|
go: state.go,
|
|
1605
1729
|
back: state.back,
|
|
1606
1730
|
setStep: (n) => state.setView({ step: n }),
|
|
1731
|
+
setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
|
|
1732
|
+
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
1607
1733
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n)
|
|
1608
1734
|
};
|
|
1609
1735
|
const Layout = LAYOUT_COMPONENTS[layout];
|
|
@@ -1759,6 +1885,37 @@ var BASE_CSS = `
|
|
|
1759
1885
|
.caf-md-h { display: block; margin-top: 0.3rem; }
|
|
1760
1886
|
.caf-md-li { display: block; padding-left: 1rem; position: relative; }
|
|
1761
1887
|
.caf-md-li::before { content: "\u2022"; position: absolute; left: 0.2rem; opacity: 0.6; }
|
|
1888
|
+
.caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
|
|
1889
|
+
.caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1rem; }
|
|
1890
|
+
.caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: 1rem; position: sticky; top: 1rem; }
|
|
1891
|
+
.caf-ws-slot { min-width: 0; }
|
|
1892
|
+
.caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
|
|
1893
|
+
.caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
|
|
1894
|
+
@media (max-width: 900px) {
|
|
1895
|
+
.caf-workspace { flex-direction: column; align-items: stretch; }
|
|
1896
|
+
.caf-ws-side { width: 100%; position: static; }
|
|
1897
|
+
}
|
|
1898
|
+
.caf-tabs { display: flex; flex-direction: column; gap: 0.8rem; min-width: 0; }
|
|
1899
|
+
.caf-tabbar { display: flex; gap: 0.2rem; border-bottom: 1px solid var(--caf-border); overflow-x: auto; }
|
|
1900
|
+
.caf-tab {
|
|
1901
|
+
font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer;
|
|
1902
|
+
padding: 0.45rem 0.8rem; border: none; background: none; color: var(--caf-muted);
|
|
1903
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap;
|
|
1904
|
+
}
|
|
1905
|
+
.caf-tab:hover { color: var(--caf-text); }
|
|
1906
|
+
.caf-tab:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: -2px; }
|
|
1907
|
+
.caf-tab-active { color: var(--caf-accent); border-bottom-color: var(--caf-accent); }
|
|
1908
|
+
.caf-tabs-body { display: flex; flex-direction: column; gap: 0.8rem; }
|
|
1909
|
+
.caf-collapse { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
|
|
1910
|
+
.caf-collapse-head {
|
|
1911
|
+
font: inherit; font-size: 0.95rem; font-weight: 650; cursor: pointer;
|
|
1912
|
+
display: flex; align-items: center; justify-content: space-between; gap: 0.6rem;
|
|
1913
|
+
padding: 0.7rem 1rem; border: 1px solid var(--caf-border); border-radius: var(--caf-radius);
|
|
1914
|
+
background: var(--caf-surface); color: var(--caf-text); width: 100%; text-align: left;
|
|
1915
|
+
}
|
|
1916
|
+
.caf-collapse-head:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
1917
|
+
.caf-chevron { transition: transform 0.15s ease; color: var(--caf-muted); }
|
|
1918
|
+
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
1762
1919
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
1763
1920
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
1764
1921
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|