claude-artifact-framework 0.14.0 → 0.15.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 +73 -0
- package/dist/index.esm.js +137 -19
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +40 -11
- package/dist/index.global.js.map +3 -3
- package/llms.txt +98 -0
- package/package.json +9 -4
- package/src/app.js +112 -16
- package/src/blocks.js +38 -0
- package/src/records.js +24 -3
package/llms.txt
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# claude-artifact-framework
|
|
2
|
+
|
|
3
|
+
> Declarative framework for building apps inside Claude Artifacts. Declare what
|
|
4
|
+
> the app IS (fields, records, steps, chat with tools, workspace, documents);
|
|
5
|
+
> the framework owns persistence, hydration, validation, empty/loading/error
|
|
6
|
+
> states, light+dark theming, navigation, and the Claude tool loop.
|
|
7
|
+
> Full documentation: README.md (this file is the compact digest).
|
|
8
|
+
|
|
9
|
+
Load (classic script, global `ArtifactKit`; call `ArtifactKit.useReact(React)` once):
|
|
10
|
+
https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
11
|
+
|
|
12
|
+
## Registries (any other name throws, naming the valid set)
|
|
13
|
+
|
|
14
|
+
- Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
|
|
15
|
+
steps, chat, main, sidebar, documents, data, shared, libs, machine,
|
|
16
|
+
brand:{logo, footer} (logo: emoji | inline <svg> | https URL | omit for an
|
|
17
|
+
automatic monogram)
|
|
18
|
+
- Layouts: panel (default), records, steps, chat, workspace, documents
|
|
19
|
+
- Block types: fields, output, text, list, chart, timer, actions, banner
|
|
20
|
+
({image?, title, subtitle} — no image paints a seed-derived gradient),
|
|
21
|
+
image ({src: url|inline-svg, caption, fit, height}), chat, tabs, records,
|
|
22
|
+
custom — plus per-block title, icon (an emoji, prefixes the title),
|
|
23
|
+
when:(d)=>bool, state:"fase", collapsible, wide, fill (custom only)
|
|
24
|
+
- Field types: text, textarea, number, money, percent, check, date, select,
|
|
25
|
+
file — attrs: key, label, value, required, min, max, step, placeholder,
|
|
26
|
+
hint, rows, options (array or (d)=>array), accept
|
|
27
|
+
- records spec: label, fields, title, subtitle, sort, summary, empty, search,
|
|
28
|
+
compute, items, actions, avatar (true = initials circles per row)
|
|
29
|
+
(+ key when used as a block)
|
|
30
|
+
- documents: label, title, blocks OR types, empty; machine: key, initial,
|
|
31
|
+
states, events (event: from, do, then); chat: system, greeting, placeholder,
|
|
32
|
+
tools, model, maxTokens; steps step: title, text, fields, result
|
|
33
|
+
- Formats: money, percent (0-100), number, date. Output/chart/compute items:
|
|
34
|
+
{ label, value, format, big }
|
|
35
|
+
|
|
36
|
+
## Hard rules
|
|
37
|
+
|
|
38
|
+
1. Write the spec INDENTED, never minified (measured: minified output drops
|
|
39
|
+
closing braces).
|
|
40
|
+
2. Theme variables, never hardcoded hex: var(--caf-text), var(--caf-surface),
|
|
41
|
+
var(--caf-accent), var(--caf-border), var(--caf-sunken), var(--caf-muted),
|
|
42
|
+
var(--caf-danger); ctx.colors(n) for series. Hardcoded #fff breaks dark mode.
|
|
43
|
+
3. Never render a <form> (sandbox lacks allow-forms) — inputs + button onClick.
|
|
44
|
+
4. Never write big strings (data URLs, file contents) into data — one ~5MB
|
|
45
|
+
storage key holds the whole pool; big things live in React.useState or
|
|
46
|
+
ctx.files.
|
|
47
|
+
5. Phases (games, turns, processes) belong to `machine` — ctx.send(event,
|
|
48
|
+
payload) is the only door; events are no-ops outside their `from` states;
|
|
49
|
+
`after: {seconds, then, do}` timers are framework-owned.
|
|
50
|
+
6. Row actions receive (record, data, viewerId); block actions (data,
|
|
51
|
+
viewerId). Stop early if you like, but never skip a middle argument.
|
|
52
|
+
7. One big:true per screen; the constructive action takes kind:"primary"; a
|
|
53
|
+
counter changed by pressing is an action, not an editable number field.
|
|
54
|
+
8. Parts of a record are `items` — never JSON in a textarea.
|
|
55
|
+
9. Don't hand-build empty/loading/error states — the framework owns them.
|
|
56
|
+
10. CDN libs via libs:[urls]: call the method, not the global (marked.parse);
|
|
57
|
+
pin versions; unsure of the file path → bare package URL
|
|
58
|
+
(https://cdn.jsdelivr.net/npm/<pkg>@<version>).
|
|
59
|
+
11. Framework record ids are strings — never Number(record.id).
|
|
60
|
+
12. In custom blocks: return strings/React elements (never DOM elements — use
|
|
61
|
+
ref + React.useEffect for libs that write into an element); hooks are
|
|
62
|
+
allowed; persistent state via ctx.update, ephemeral via React.useState,
|
|
63
|
+
never createStore for UI state.
|
|
64
|
+
13. Brand is zero-asset-first: monogram/gradient/emoji defaults before URLs;
|
|
65
|
+
inline <svg> over remote images; one icon style per app (emoji-first);
|
|
66
|
+
avatar: true instead of hand-built initials circles.
|
|
67
|
+
|
|
68
|
+
## Canonical example
|
|
69
|
+
|
|
70
|
+
ArtifactKit.createApp({
|
|
71
|
+
title: "Gastos",
|
|
72
|
+
theme: { seed: "#c2410c" },
|
|
73
|
+
layout: "records",
|
|
74
|
+
shared: false,
|
|
75
|
+
records: {
|
|
76
|
+
label: "gasto",
|
|
77
|
+
fields: [
|
|
78
|
+
{ key: "concepto", label: "Concepto", type: "text", required: true },
|
|
79
|
+
{ key: "monto", label: "Monto", type: "money", value: 0, min: 0 },
|
|
80
|
+
{ key: "categoria", label: "Categoría", type: "select", options: ["comida", "transporte", "otros"] },
|
|
81
|
+
],
|
|
82
|
+
subtitle: (r) => r.categoria,
|
|
83
|
+
summary: (all) => [
|
|
84
|
+
{ label: "Total", value: all.reduce((s, r) => s + (Number(r.monto) || 0), 0), format: "money", big: true },
|
|
85
|
+
],
|
|
86
|
+
actions: [{ label: "Duplicar", run: (r, d) => { d.records = [...d.records, { ...r, id: undefined }]; } }],
|
|
87
|
+
},
|
|
88
|
+
blocks: [
|
|
89
|
+
{ chart: (d) => ({ type: "donut", format: "money", items: porCategoria(d.records) }), title: "Por categoría" },
|
|
90
|
+
],
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
Chat with tools (workspace copilot): sidebar block
|
|
94
|
+
{ title: "Asistente", chat: { system: (d) => "...", tools: { nombre: {
|
|
95
|
+
description: "...", input: { campo: "string" }, run: (input, ctx) => {
|
|
96
|
+
ctx.update((d) => { /* mutate */ }); return { ok: true }; } } } } }
|
|
97
|
+
— the artifact runtime proxies api.anthropic.com with no API key; default
|
|
98
|
+
model claude-sonnet-4-6; conversation persists under data.chat.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Utilities for building apps inside Claude Artifacts: platform storage, chat tool-calling loops, and other primitives verified against the real runtime.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.esm.js",
|
|
@@ -12,16 +12,21 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
-
"src"
|
|
15
|
+
"src",
|
|
16
|
+
"llms.txt"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
19
|
"build": "node scripts/build.mjs",
|
|
19
20
|
"build:watch": "node scripts/build.mjs --watch",
|
|
21
|
+
"test": "npm run build && node test/e2e.mjs",
|
|
20
22
|
"prepublishOnly": "npm run build",
|
|
21
|
-
"release": "node scripts/check-docs.mjs && bash scripts/publish.sh"
|
|
23
|
+
"release": "node scripts/check-docs.mjs && npm test && bash scripts/publish.sh"
|
|
22
24
|
},
|
|
23
25
|
"devDependencies": {
|
|
24
|
-
"esbuild": "^0.25.0"
|
|
26
|
+
"esbuild": "^0.25.0",
|
|
27
|
+
"playwright-core": "^1.45.0",
|
|
28
|
+
"react": "^18.3.0",
|
|
29
|
+
"react-dom": "^18.3.0"
|
|
25
30
|
},
|
|
26
31
|
"license": "MIT",
|
|
27
32
|
"author": "Alejandro Cuartas",
|
package/src/app.js
CHANGED
|
@@ -18,12 +18,15 @@ import { StepsLayout } from "./steps.js";
|
|
|
18
18
|
import { ChatLayout, CHAT_KEYS } from "./chat.js";
|
|
19
19
|
|
|
20
20
|
const LAYOUTS = ["panel", "records", "steps", "chat", "workspace", "documents"];
|
|
21
|
-
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state"];
|
|
21
|
+
const RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "collapsible", "fill", "state", "icon"];
|
|
22
|
+
const BRAND_KEYS = ["logo", "footer"];
|
|
23
|
+
const BANNER_KEYS = ["image", "title", "subtitle"];
|
|
24
|
+
const IMAGE_KEYS = ["src", "caption", "fit", "height"];
|
|
22
25
|
|
|
23
26
|
// Frontier models still invent identifiers a few percent of the time, so an
|
|
24
27
|
// unknown name has to fail loudly and name the alternatives rather than
|
|
25
28
|
// silently rendering nothing — a blank pane is indistinguishable from a bug.
|
|
26
|
-
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine"];
|
|
29
|
+
const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
|
|
27
30
|
|
|
28
31
|
const MACHINE_KEYS = ["key", "initial", "states", "events"];
|
|
29
32
|
const MACHINE_STATE_KEYS = ["after"];
|
|
@@ -31,7 +34,7 @@ const MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
|
|
|
31
34
|
const MACHINE_EVENT_KEYS = ["from", "do", "then"];
|
|
32
35
|
|
|
33
36
|
const DOCUMENTS_KEYS = ["label", "title", "blocks", "empty", "types"];
|
|
34
|
-
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions"];
|
|
37
|
+
const RECORDS_KEYS = ["label", "fields", "title", "subtitle", "sort", "summary", "empty", "search", "compute", "items", "actions", "avatar"];
|
|
35
38
|
const ITEMS_KEYS = ["label", "fields", "empty"];
|
|
36
39
|
|
|
37
40
|
// An unknown field type used to fall through silently to a text input — the
|
|
@@ -121,6 +124,25 @@ function checkBlocks(blocks) {
|
|
|
121
124
|
if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
|
|
122
125
|
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
123
126
|
if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
|
|
127
|
+
if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
|
|
128
|
+
const unknownBn = Object.keys(block.banner).filter((k) => !BANNER_KEYS.includes(k));
|
|
129
|
+
if (unknownBn.length) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`claude-artifact-framework: blocks[${i}].banner has unknown keys (${unknownBn.join(", ")}). Valid keys: ${BANNER_KEYS.join(", ")}.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (known[0] === "image" && typeof block.image === "object" && block.image !== null) {
|
|
136
|
+
const unknownIm = Object.keys(block.image).filter((k) => !IMAGE_KEYS.includes(k));
|
|
137
|
+
if (unknownIm.length) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`claude-artifact-framework: blocks[${i}].image has unknown keys (${unknownIm.join(", ")}). Valid keys: ${IMAGE_KEYS.join(", ")}.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (block.icon !== undefined && typeof block.icon !== "string") {
|
|
144
|
+
throw new Error(`claude-artifact-framework: blocks[${i}].icon must be a string (an emoji works best).`);
|
|
145
|
+
}
|
|
124
146
|
if (known[0] === "tabs") {
|
|
125
147
|
const tabs = block.tabs;
|
|
126
148
|
if (!Array.isArray(tabs) || tabs.length === 0) {
|
|
@@ -234,6 +256,17 @@ function validate(spec) {
|
|
|
234
256
|
);
|
|
235
257
|
}
|
|
236
258
|
if (spec.machine !== undefined) checkMachine(spec.machine);
|
|
259
|
+
if (spec.brand !== undefined) {
|
|
260
|
+
const unknownB = Object.keys(spec.brand || {}).filter((k) => !BRAND_KEYS.includes(k));
|
|
261
|
+
if (unknownB.length) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`claude-artifact-framework: brand has unknown keys (${unknownB.join(", ")}). Valid keys: ${BRAND_KEYS.join(", ")}.`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (spec.brand.logo !== undefined && typeof spec.brand.logo !== "string") {
|
|
267
|
+
throw new Error("claude-artifact-framework: brand.logo must be a string — an emoji, an inline <svg>...</svg>, or an https URL. Omit it for an automatic monogram.");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
237
270
|
if (spec.libs !== undefined) {
|
|
238
271
|
if (!Array.isArray(spec.libs) || spec.libs.some((u) => typeof u !== "string" || !/^https?:\/\//.test(u))) {
|
|
239
272
|
throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
|
|
@@ -350,7 +383,7 @@ function validate(spec) {
|
|
|
350
383
|
}
|
|
351
384
|
for (const name of names) {
|
|
352
385
|
const t = d.types[name];
|
|
353
|
-
const TYPE_KEYS = ["label", "title", "blocks", "empty"];
|
|
386
|
+
const TYPE_KEYS = ["label", "title", "blocks", "empty", "icon"];
|
|
354
387
|
const unknownT = Object.keys(t || {}).filter((k) => !TYPE_KEYS.includes(k));
|
|
355
388
|
if (unknownT.length) {
|
|
356
389
|
throw new Error(
|
|
@@ -565,7 +598,7 @@ function TabsBlock({ spec, ctx }) {
|
|
|
565
598
|
className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
|
|
566
599
|
onClick: () => ctx.setTab(key, i),
|
|
567
600
|
},
|
|
568
|
-
t.label
|
|
601
|
+
t.icon ? `${t.icon} ${t.label}` : t.label
|
|
569
602
|
)
|
|
570
603
|
)
|
|
571
604
|
),
|
|
@@ -585,6 +618,8 @@ function Block({ block, ctx }) {
|
|
|
585
618
|
if (typeof block.when === "function" && !block.when(ctx.data)) return null;
|
|
586
619
|
// `state: "x"` — machine sugar: the block exists only in that phase.
|
|
587
620
|
if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
|
|
621
|
+
// `icon` is sugar: it prefixes the title everywhere titles render.
|
|
622
|
+
if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
|
|
588
623
|
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
589
624
|
const Component = ALL_BLOCKS[name];
|
|
590
625
|
const inner = h(
|
|
@@ -623,9 +658,9 @@ function Panel({ spec, ctx }) {
|
|
|
623
658
|
(spec.blocks || []).map((block, i) =>
|
|
624
659
|
h(
|
|
625
660
|
"section",
|
|
626
|
-
//
|
|
627
|
-
// orphaned right edge on desktop and change
|
|
628
|
-
{ key: i, className: block.wide || block.collapsible ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
661
|
+
// Collapsibles and banners are always full-width: half-width versions
|
|
662
|
+
// leave an orphaned right edge on desktop and change across breakpoints.
|
|
663
|
+
{ key: i, className: block.wide || block.collapsible || block.banner ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
629
664
|
h(Block, { block, ctx })
|
|
630
665
|
)
|
|
631
666
|
)
|
|
@@ -650,9 +685,9 @@ function RecordsWithBlocks({ spec, ctx }) {
|
|
|
650
685
|
spec.blocks.map((block, i) =>
|
|
651
686
|
h(
|
|
652
687
|
"section",
|
|
653
|
-
//
|
|
654
|
-
// orphaned right edge on desktop and change
|
|
655
|
-
{ key: i, className: block.wide || block.collapsible ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
688
|
+
// Collapsibles and banners are always full-width: half-width versions
|
|
689
|
+
// leave an orphaned right edge on desktop and change across breakpoints.
|
|
690
|
+
{ key: i, className: block.wide || block.collapsible || block.banner ? "caf-slot caf-slot-wide" : "caf-slot" },
|
|
656
691
|
h(Block, { block, ctx })
|
|
657
692
|
)
|
|
658
693
|
)
|
|
@@ -734,7 +769,10 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
734
769
|
const types = dspec.types || null;
|
|
735
770
|
const typeNames = types ? Object.keys(types) : [];
|
|
736
771
|
const specFor = (doc) => (types ? types[doc.type] : dspec);
|
|
737
|
-
const typeLabel = (name) =>
|
|
772
|
+
const typeLabel = (name) => {
|
|
773
|
+
const t = types[name] || {};
|
|
774
|
+
return (t.icon ? t.icon + " " : "") + (t.label || name);
|
|
775
|
+
};
|
|
738
776
|
const [picking, setPicking] = useState(false);
|
|
739
777
|
|
|
740
778
|
const storedId = ctx.view.tabs ? ctx.view.tabs[DOC_TAB_KEY] : undefined;
|
|
@@ -1053,6 +1091,29 @@ export function createApp(spec = {}) {
|
|
|
1053
1091
|
|
|
1054
1092
|
const Layout = LAYOUT_COMPONENTS[layout];
|
|
1055
1093
|
|
|
1094
|
+
// Zero-asset-first logo ladder: emoji → automatic monogram (initials over
|
|
1095
|
+
// the accent) → inline SVG → URL. The brand never NEEDS an uploaded file.
|
|
1096
|
+
const logo = spec.brand && spec.brand.logo;
|
|
1097
|
+
const logoEl = !spec.brand
|
|
1098
|
+
? null
|
|
1099
|
+
: logo && logo.trim().startsWith("<svg")
|
|
1100
|
+
? h("span", { className: "caf-logo caf-logo-svg", dangerouslySetInnerHTML: { __html: logo } })
|
|
1101
|
+
: logo && /^https?:\/\//.test(logo)
|
|
1102
|
+
? h("img", { className: "caf-logo", src: logo, alt: "" })
|
|
1103
|
+
: logo
|
|
1104
|
+
? h("span", { className: "caf-logo caf-logo-emoji", "aria-hidden": true }, logo)
|
|
1105
|
+
: h(
|
|
1106
|
+
"span",
|
|
1107
|
+
{ className: "caf-logo caf-logo-mono", "aria-hidden": true },
|
|
1108
|
+
(spec.title || "?")
|
|
1109
|
+
.split(/\s+/)
|
|
1110
|
+
.map((w) => w[0])
|
|
1111
|
+
.filter(Boolean)
|
|
1112
|
+
.slice(0, 2)
|
|
1113
|
+
.join("")
|
|
1114
|
+
.toUpperCase()
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1056
1117
|
return h(
|
|
1057
1118
|
"div",
|
|
1058
1119
|
// The layout class lets shared CSS follow the active container width —
|
|
@@ -1061,9 +1122,14 @@ export function createApp(spec = {}) {
|
|
|
1061
1122
|
h("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
1062
1123
|
h(
|
|
1063
1124
|
"header",
|
|
1064
|
-
{ className: "caf-header" },
|
|
1065
|
-
|
|
1066
|
-
|
|
1125
|
+
{ className: spec.brand ? "caf-header caf-header-brand" : "caf-header" },
|
|
1126
|
+
logoEl,
|
|
1127
|
+
h(
|
|
1128
|
+
"div",
|
|
1129
|
+
{ className: "caf-header-text" },
|
|
1130
|
+
spec.title ? h("h1", null, spec.title) : null,
|
|
1131
|
+
spec.subtitle ? h("p", null, spec.subtitle) : null
|
|
1132
|
+
)
|
|
1067
1133
|
),
|
|
1068
1134
|
libsState === "error"
|
|
1069
1135
|
? h(
|
|
@@ -1074,7 +1140,8 @@ export function createApp(spec = {}) {
|
|
|
1074
1140
|
)
|
|
1075
1141
|
: !state.isHydrated() || libsState === "loading"
|
|
1076
1142
|
? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
|
|
1077
|
-
: h(Layout, { spec, ctx })
|
|
1143
|
+
: h(Layout, { spec, ctx }),
|
|
1144
|
+
spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
|
|
1078
1145
|
);
|
|
1079
1146
|
};
|
|
1080
1147
|
}
|
|
@@ -1288,6 +1355,35 @@ const BASE_CSS = `
|
|
|
1288
1355
|
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
1289
1356
|
.caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
1290
1357
|
.caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
1358
|
+
.caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
|
|
1359
|
+
.caf-header-text { min-width: 0; }
|
|
1360
|
+
.caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
|
|
1361
|
+
.caf-logo-emoji { display: flex; align-items: center; justify-content: center; font-size: 1.7rem; background: var(--caf-surface); border: 1px solid var(--caf-border); }
|
|
1362
|
+
.caf-logo-mono {
|
|
1363
|
+
display: flex; align-items: center; justify-content: center;
|
|
1364
|
+
background: var(--caf-accent); color: var(--caf-accentText);
|
|
1365
|
+
font-size: 0.95rem; font-weight: 700; letter-spacing: 0.02em;
|
|
1366
|
+
}
|
|
1367
|
+
.caf-logo-svg { display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
|
1368
|
+
.caf-logo-svg svg { width: 100%; height: 100%; }
|
|
1369
|
+
.caf-banner {
|
|
1370
|
+
border-radius: var(--caf-radius); padding: 1.6rem 1.4rem; min-height: 110px;
|
|
1371
|
+
display: flex; flex-direction: column; justify-content: flex-end; gap: 0.2rem;
|
|
1372
|
+
}
|
|
1373
|
+
.caf-banner-title { margin: 0; color: #fff; font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; text-shadow: 0 1px 3px rgba(0,0,0,0.35); }
|
|
1374
|
+
.caf-banner-sub { margin: 0; color: rgba(255,255,255,0.92); font-size: 0.9rem; text-shadow: 0 1px 2px rgba(0,0,0,0.35); }
|
|
1375
|
+
.caf-image { margin: 0; }
|
|
1376
|
+
.caf-image img { width: 100%; border-radius: 7px; display: block; }
|
|
1377
|
+
.caf-image-svg svg { width: 100%; height: auto; display: block; }
|
|
1378
|
+
.caf-footer { max-width: 760px; margin: 2rem auto 0; padding-top: 0.8rem; border-top: 1px solid var(--caf-border); color: var(--caf-muted); font-size: 0.78rem; text-align: center; }
|
|
1379
|
+
.caf-layout-workspace .caf-footer { max-width: 1200px; }
|
|
1380
|
+
.caf-avatar {
|
|
1381
|
+
flex: none; width: 30px; height: 30px; border-radius: 50%;
|
|
1382
|
+
display: flex; align-items: center; justify-content: center;
|
|
1383
|
+
color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em;
|
|
1384
|
+
}
|
|
1385
|
+
.caf-row-avatar { flex-direction: row; align-items: center; gap: 0.6rem; }
|
|
1386
|
+
.caf-row-main { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
|
1291
1387
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
1292
1388
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
1293
1389
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
package/src/blocks.js
CHANGED
|
@@ -198,6 +198,42 @@ export function OutputBlock({ spec, ctx }) {
|
|
|
198
198
|
);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// Brand elements follow the zero-asset-first principle: every form works
|
|
202
|
+
// with nothing uploaded (artifacts can't persist real images — the ~5MB
|
|
203
|
+
// pool). banner's default face is a gradient derived from the theme seed;
|
|
204
|
+
// image accepts a URL or an inline SVG string (LLMs generate SVG well).
|
|
205
|
+
export function BannerBlock({ spec, ctx }) {
|
|
206
|
+
const b = typeof spec.banner === "string" ? { title: spec.banner } : spec.banner || {};
|
|
207
|
+
const [c1, c2] = ctx.colors(2);
|
|
208
|
+
const background = b.image
|
|
209
|
+
? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${JSON.stringify(b.image)}) center / cover`
|
|
210
|
+
: `linear-gradient(135deg, ${c1}, ${c2})`;
|
|
211
|
+
return h(
|
|
212
|
+
"div",
|
|
213
|
+
{ className: "caf-banner", style: { background } },
|
|
214
|
+
b.title ? h("h2", { className: "caf-banner-title" }, b.title) : null,
|
|
215
|
+
b.subtitle ? h("p", { className: "caf-banner-sub" }, b.subtitle) : null
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function ImageBlock({ spec }) {
|
|
220
|
+
const im = typeof spec.image === "string" ? { src: spec.image } : spec.image || {};
|
|
221
|
+
const isSvg = typeof im.src === "string" && im.src.trim().startsWith("<svg");
|
|
222
|
+
return h(
|
|
223
|
+
"figure",
|
|
224
|
+
{ className: "caf-block caf-image" },
|
|
225
|
+
spec.title ? h("h2", { className: "caf-block-title" }, spec.title) : null,
|
|
226
|
+
isSvg
|
|
227
|
+
? h("div", { className: "caf-image-svg", dangerouslySetInnerHTML: { __html: im.src } })
|
|
228
|
+
: h("img", {
|
|
229
|
+
src: im.src,
|
|
230
|
+
alt: im.caption || spec.title || "",
|
|
231
|
+
style: { objectFit: im.fit || "cover", maxHeight: im.height ? `${im.height}px` : undefined },
|
|
232
|
+
}),
|
|
233
|
+
im.caption ? h("figcaption", { className: "caf-hint" }, im.caption) : null
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
export function TextBlock({ spec, ctx }) {
|
|
202
238
|
const body = typeof spec.text === "function" ? spec.text(ctx.data) : spec.text;
|
|
203
239
|
return h(
|
|
@@ -464,6 +500,8 @@ export const BLOCKS = {
|
|
|
464
500
|
chart: ChartBlock,
|
|
465
501
|
timer: TimerBlock,
|
|
466
502
|
actions: ActionsBlock,
|
|
503
|
+
banner: BannerBlock,
|
|
504
|
+
image: ImageBlock,
|
|
467
505
|
chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx, title: spec.title }),
|
|
468
506
|
custom: CustomBlock,
|
|
469
507
|
};
|
package/src/records.js
CHANGED
|
@@ -54,6 +54,22 @@ function makeId() {
|
|
|
54
54
|
return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// avatar: true — an initials circle per row, colored deterministically from
|
|
58
|
+
// the row title through the theme's series palette. Identity without assets.
|
|
59
|
+
function Avatar({ title, ctx }) {
|
|
60
|
+
const initials = String(title)
|
|
61
|
+
.split(/\s+/)
|
|
62
|
+
.map((w) => w[0])
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.slice(0, 2)
|
|
65
|
+
.join("")
|
|
66
|
+
.toUpperCase() || "?";
|
|
67
|
+
let hash = 0;
|
|
68
|
+
for (const ch of String(title)) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
|
|
69
|
+
const palette = ctx.colors(8);
|
|
70
|
+
return h("span", { className: "caf-avatar", style: { background: palette[hash % palette.length] } }, initials);
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function rowTitle(spec, rec) {
|
|
58
74
|
if (spec.title) return spec.title(rec);
|
|
59
75
|
// Default: the first text-ish field, which is what a human would label by.
|
|
@@ -152,11 +168,16 @@ function ListScreen({ spec, ctx }) {
|
|
|
152
168
|
"button",
|
|
153
169
|
{
|
|
154
170
|
type: "button",
|
|
155
|
-
className: "caf-row",
|
|
171
|
+
className: spec.avatar ? "caf-row caf-row-avatar" : "caf-row",
|
|
156
172
|
onClick: () => ctx.go("record", rec.id),
|
|
157
173
|
},
|
|
158
|
-
h(
|
|
159
|
-
|
|
174
|
+
spec.avatar ? h(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
|
|
175
|
+
h(
|
|
176
|
+
"span",
|
|
177
|
+
{ className: "caf-row-main" },
|
|
178
|
+
h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
|
|
179
|
+
spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
|
|
180
|
+
)
|
|
160
181
|
),
|
|
161
182
|
spec.actions
|
|
162
183
|
? h(
|