claude-artifact-framework 0.14.1 → 0.16.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 +67 -3
- package/dist/index.esm.js +156 -20
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +48 -11
- package/dist/index.global.js.map +3 -3
- package/llms.txt +17 -5
- package/package.json +1 -1
- package/src/app.js +132 -16
- package/src/blocks.js +42 -1
- package/src/records.js +24 -3
package/llms.txt
CHANGED
|
@@ -12,16 +12,21 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
|
12
12
|
## Registries (any other name throws, naming the valid set)
|
|
13
13
|
|
|
14
14
|
- Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
|
|
15
|
-
steps, chat, main, sidebar, documents, data, shared, libs, machine
|
|
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)
|
|
16
18
|
- Layouts: panel (default), records, steps, chat, workspace, documents
|
|
17
|
-
- Block types: fields, output, text, list, chart, timer, actions,
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
20
24
|
- Field types: text, textarea, number, money, percent, check, date, select,
|
|
21
25
|
file — attrs: key, label, value, required, min, max, step, placeholder,
|
|
22
26
|
hint, rows, options (array or (d)=>array), accept
|
|
23
27
|
- records spec: label, fields, title, subtitle, sort, summary, empty, search,
|
|
24
|
-
compute, items, actions (
|
|
28
|
+
compute, items, actions, avatar (true = initials circles per row)
|
|
29
|
+
(+ key when used as a block)
|
|
25
30
|
- documents: label, title, blocks OR types, empty; machine: key, initial,
|
|
26
31
|
states, events (event: from, do, then); chat: system, greeting, placeholder,
|
|
27
32
|
tools, model, maxTokens; steps step: title, text, fields, result
|
|
@@ -56,6 +61,13 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
|
|
|
56
61
|
ref + React.useEffect for libs that write into an element); hooks are
|
|
57
62
|
allowed; persistent state via ctx.update, ephemeral via React.useState,
|
|
58
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
|
+
14. Column grids in custom (kanban, week views) branch on ctx.viewport
|
|
68
|
+
("mobile" < 720px | "desktop", reactive) — stack columns on mobile; half
|
|
69
|
+
the viewers are on a phone. The framework never clips (wide custom
|
|
70
|
+
content scrolls), but scrolling is the fallback, not the design.
|
|
59
71
|
|
|
60
72
|
## Canonical example
|
|
61
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-artifact-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
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;
|
|
@@ -991,6 +1029,17 @@ export function createApp(spec = {}) {
|
|
|
991
1029
|
);
|
|
992
1030
|
}
|
|
993
1031
|
const [, force] = useState(0);
|
|
1032
|
+
// ctx.viewport shares the 720px breakpoint the block grid already uses,
|
|
1033
|
+
// so a custom block that branches on it flips in sync with the layout.
|
|
1034
|
+
const [narrow, setNarrow] = useState(
|
|
1035
|
+
() => typeof window !== "undefined" && window.matchMedia("(max-width: 719px)").matches
|
|
1036
|
+
);
|
|
1037
|
+
useEffect(() => {
|
|
1038
|
+
const mq = window.matchMedia("(max-width: 719px)");
|
|
1039
|
+
const onChange = (e) => setNarrow(e.matches);
|
|
1040
|
+
mq.addEventListener("change", onChange);
|
|
1041
|
+
return () => mq.removeEventListener("change", onChange);
|
|
1042
|
+
}, []);
|
|
994
1043
|
const [libsState, setLibsState] = useState(spec.libs && spec.libs.length ? "loading" : "ready");
|
|
995
1044
|
const [libsError, setLibsError] = useState(null);
|
|
996
1045
|
useEffect(() => {
|
|
@@ -1043,6 +1092,7 @@ export function createApp(spec = {}) {
|
|
|
1043
1092
|
setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
|
|
1044
1093
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
1045
1094
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
1095
|
+
viewport: narrow ? "mobile" : "desktop",
|
|
1046
1096
|
machine: spec.machine || null,
|
|
1047
1097
|
send: spec.machine
|
|
1048
1098
|
? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
|
|
@@ -1053,6 +1103,29 @@ export function createApp(spec = {}) {
|
|
|
1053
1103
|
|
|
1054
1104
|
const Layout = LAYOUT_COMPONENTS[layout];
|
|
1055
1105
|
|
|
1106
|
+
// Zero-asset-first logo ladder: emoji → automatic monogram (initials over
|
|
1107
|
+
// the accent) → inline SVG → URL. The brand never NEEDS an uploaded file.
|
|
1108
|
+
const logo = spec.brand && spec.brand.logo;
|
|
1109
|
+
const logoEl = !spec.brand
|
|
1110
|
+
? null
|
|
1111
|
+
: logo && logo.trim().startsWith("<svg")
|
|
1112
|
+
? h("span", { className: "caf-logo caf-logo-svg", dangerouslySetInnerHTML: { __html: logo } })
|
|
1113
|
+
: logo && /^https?:\/\//.test(logo)
|
|
1114
|
+
? h("img", { className: "caf-logo", src: logo, alt: "" })
|
|
1115
|
+
: logo
|
|
1116
|
+
? h("span", { className: "caf-logo caf-logo-emoji", "aria-hidden": true }, logo)
|
|
1117
|
+
: h(
|
|
1118
|
+
"span",
|
|
1119
|
+
{ className: "caf-logo caf-logo-mono", "aria-hidden": true },
|
|
1120
|
+
(spec.title || "?")
|
|
1121
|
+
.split(/\s+/)
|
|
1122
|
+
.map((w) => w[0])
|
|
1123
|
+
.filter(Boolean)
|
|
1124
|
+
.slice(0, 2)
|
|
1125
|
+
.join("")
|
|
1126
|
+
.toUpperCase()
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1056
1129
|
return h(
|
|
1057
1130
|
"div",
|
|
1058
1131
|
// The layout class lets shared CSS follow the active container width —
|
|
@@ -1061,9 +1134,14 @@ export function createApp(spec = {}) {
|
|
|
1061
1134
|
h("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
1062
1135
|
h(
|
|
1063
1136
|
"header",
|
|
1064
|
-
{ className: "caf-header" },
|
|
1065
|
-
|
|
1066
|
-
|
|
1137
|
+
{ className: spec.brand ? "caf-header caf-header-brand" : "caf-header" },
|
|
1138
|
+
logoEl,
|
|
1139
|
+
h(
|
|
1140
|
+
"div",
|
|
1141
|
+
{ className: "caf-header-text" },
|
|
1142
|
+
spec.title ? h("h1", null, spec.title) : null,
|
|
1143
|
+
spec.subtitle ? h("p", null, spec.subtitle) : null
|
|
1144
|
+
)
|
|
1067
1145
|
),
|
|
1068
1146
|
libsState === "error"
|
|
1069
1147
|
? h(
|
|
@@ -1074,7 +1152,8 @@ export function createApp(spec = {}) {
|
|
|
1074
1152
|
)
|
|
1075
1153
|
: !state.isHydrated() || libsState === "loading"
|
|
1076
1154
|
? h("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries…" : "Loading…")
|
|
1077
|
-
: h(Layout, { spec, ctx })
|
|
1155
|
+
: h(Layout, { spec, ctx }),
|
|
1156
|
+
spec.brand && spec.brand.footer ? h("footer", { className: "caf-footer" }, spec.brand.footer) : null
|
|
1078
1157
|
);
|
|
1079
1158
|
};
|
|
1080
1159
|
}
|
|
@@ -1288,10 +1367,47 @@ const BASE_CSS = `
|
|
|
1288
1367
|
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
1289
1368
|
.caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
1290
1369
|
.caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
1370
|
+
.caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
|
|
1371
|
+
.caf-header-text { min-width: 0; }
|
|
1372
|
+
.caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
|
|
1373
|
+
.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); }
|
|
1374
|
+
.caf-logo-mono {
|
|
1375
|
+
display: flex; align-items: center; justify-content: center;
|
|
1376
|
+
background: var(--caf-accent); color: var(--caf-accentText);
|
|
1377
|
+
font-size: 0.95rem; font-weight: 700; letter-spacing: 0.02em;
|
|
1378
|
+
}
|
|
1379
|
+
.caf-logo-svg { display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
|
1380
|
+
.caf-logo-svg svg { width: 100%; height: 100%; }
|
|
1381
|
+
.caf-banner {
|
|
1382
|
+
border-radius: var(--caf-radius); padding: 1.6rem 1.4rem; min-height: 110px;
|
|
1383
|
+
display: flex; flex-direction: column; justify-content: flex-end; gap: 0.2rem;
|
|
1384
|
+
}
|
|
1385
|
+
.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); }
|
|
1386
|
+
.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); }
|
|
1387
|
+
.caf-image { margin: 0; }
|
|
1388
|
+
.caf-image img { width: 100%; border-radius: 7px; display: block; }
|
|
1389
|
+
.caf-image-svg svg { width: 100%; height: auto; display: block; }
|
|
1390
|
+
.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; }
|
|
1391
|
+
.caf-layout-workspace .caf-footer { max-width: 1200px; }
|
|
1392
|
+
.caf-avatar {
|
|
1393
|
+
flex: none; width: 30px; height: 30px; border-radius: 50%;
|
|
1394
|
+
display: flex; align-items: center; justify-content: center;
|
|
1395
|
+
color: #fff; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.02em;
|
|
1396
|
+
}
|
|
1397
|
+
.caf-row-avatar { flex-direction: row; align-items: center; gap: 0.6rem; }
|
|
1398
|
+
.caf-row-main { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
|
|
1291
1399
|
.caf-chevron-closed { transform: rotate(-90deg); }
|
|
1292
1400
|
.caf-block-fill { border: none; background: transparent; padding: 0; overflow: auto; min-height: 320px; }
|
|
1293
1401
|
.caf-empty, .caf-loading { color: var(--caf-muted); font-size: 0.9rem; text-align: center; padding: 1.6rem 1rem; }
|
|
1294
1402
|
.caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
|
|
1295
1403
|
.caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
|
|
1296
1404
|
.caf-block-error code { font-size: 0.78rem; color: var(--caf-muted); word-break: break-word; }
|
|
1405
|
+
/* Never-clip guarantee: content inside a custom block that is wider than the
|
|
1406
|
+
phone (column grids, week views) must scroll, not disappear off-screen. */
|
|
1407
|
+
.caf-custom { overflow-x: auto; }
|
|
1408
|
+
/* Touch devices get tappable framework controls regardless of the spec. */
|
|
1409
|
+
@media (pointer: coarse) {
|
|
1410
|
+
.caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
|
|
1411
|
+
.caf-btn-sm, .caf-doc-close, .caf-item-remove { min-height: 40px; }
|
|
1412
|
+
}
|
|
1297
1413
|
`;
|
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(
|
|
@@ -449,7 +485,10 @@ export function CustomBlock({ spec, ctx }) {
|
|
|
449
485
|
const rendered = spec.custom(ctx);
|
|
450
486
|
// `fill: true` frees the block from the card chrome so it can own a large
|
|
451
487
|
// scrollable surface (a viewer, a canvas) — the workspace main-area case.
|
|
452
|
-
|
|
488
|
+
// caf-custom guarantees a custom block can never CLIP its content on a
|
|
489
|
+
// narrow screen: wide hand-built grids become scrollable, not amputated
|
|
490
|
+
// (measured: a week grid cut off its last column at 390px with no way in).
|
|
491
|
+
const cls = spec.fill ? "caf-block caf-block-fill caf-custom" : "caf-block caf-custom";
|
|
453
492
|
if (typeof rendered === "string") {
|
|
454
493
|
return h("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
|
|
455
494
|
}
|
|
@@ -464,6 +503,8 @@ export const BLOCKS = {
|
|
|
464
503
|
chart: ChartBlock,
|
|
465
504
|
timer: TimerBlock,
|
|
466
505
|
actions: ActionsBlock,
|
|
506
|
+
banner: BannerBlock,
|
|
507
|
+
image: ImageBlock,
|
|
467
508
|
chat: ({ spec, ctx }) => h(ChatPanel, { cfg: spec.chat, ctx, title: spec.title }),
|
|
468
509
|
custom: CustomBlock,
|
|
469
510
|
};
|
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(
|