claude-artifact-framework 0.2.1 → 0.3.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.
@@ -0,0 +1,80 @@
1
+ // react-runtime.js — how the framework gets hold of React.
2
+ //
3
+ // It must use the SAME React the host already loaded: a second copy on the
4
+ // page makes every hook throw. But it must not depend on it with a static
5
+ // `import React from "react"` either — a specifier the host can't resolve
6
+ // kills the whole module at load time, before anything is left running to
7
+ // report why.
8
+ //
9
+ // So React is resolved at runtime, from whichever of these is available:
10
+ //
11
+ // 1. globalThis.React — present when the host exposes it
12
+ // 2. useReact(React) — the explicit hand-off
13
+ //
14
+ // The hand-off is one line and always available, because an artifact already
15
+ // imports React for its own code:
16
+ //
17
+ // import React from "react";
18
+ // ArtifactKit.useReact(React);
19
+ //
20
+ // Deliberately no dynamic import("react") fallback: it would need top-level
21
+ // await, which the IIFE build can't have, and it only pays off in a runtime
22
+ // with a native import map — which is not the way artifacts load code.
23
+
24
+ let React = typeof globalThis !== "undefined" ? globalThis.React : undefined;
25
+
26
+ export function useReact(instance) {
27
+ React = instance;
28
+ return instance;
29
+ }
30
+
31
+ export function hasReact() {
32
+ return Boolean(React);
33
+ }
34
+
35
+ // The live instance, for the few places that need React itself rather than one
36
+ // of its functions — `class extends React.Component`, most of all.
37
+ export function getReact() {
38
+ return required();
39
+ }
40
+
41
+ function required() {
42
+ if (!React) {
43
+ throw new Error(
44
+ "claude-artifact-framework: React not found. Hand it over once, before createApp:\n\n" +
45
+ ' import React from "react";\n' +
46
+ " ArtifactKit.useReact(React);\n"
47
+ );
48
+ }
49
+ return React;
50
+ }
51
+
52
+ // Bound lazily, because modules import these at load time — which is before
53
+ // useReact() can possibly have run.
54
+ export const createElement = (...args) => required().createElement(...args);
55
+ export const useState = (...args) => required().useState(...args);
56
+ export const useEffect = (...args) => required().useEffect(...args);
57
+ export const useMemo = (...args) => required().useMemo(...args);
58
+ export const useRef = (...args) => required().useRef(...args);
59
+
60
+ // A class component is read as a value at module scope, so it can't be
61
+ // deferred behind a call. This stand-in is only ever constructed if React was
62
+ // still missing at render time, and it reports the same error then.
63
+ export const Component =
64
+ (React && React.Component) ||
65
+ class ReactMissing {
66
+ render() {
67
+ return required();
68
+ }
69
+ };
70
+
71
+ // The default export mirrors `import React from "react"`, so the source files
72
+ // can keep using React.createElement / React.useState unchanged.
73
+ export default {
74
+ get createElement() { return required().createElement; },
75
+ get Component() { return required().Component; },
76
+ get useState() { return required().useState; },
77
+ get useEffect() { return required().useEffect; },
78
+ get useMemo() { return required().useMemo; },
79
+ get useRef() { return required().useRef; },
80
+ };
package/src/records.js ADDED
@@ -0,0 +1,158 @@
1
+ // records.js — the "list of things" layout: expenses, habits, notes, todos.
2
+ //
3
+ // The author declares what a record IS (the same field declarations the
4
+ // fields block uses) and optionally what the collection summarises to. The
5
+ // whole CRUD lifecycle — list, add, edit, delete, empty state, selection,
6
+ // navigation — is framework-owned, so none of it can be half-built.
7
+ //
8
+ // Two deliberate choices keep the state model tiny:
9
+ //
10
+ // add-then-edit "Add" creates the record from field defaults and navigates
11
+ // straight into it. There is no separate draft object, so a
12
+ // half-typed record can't be lost — it already exists.
13
+ // edit-in-place Detail fields write into the record directly through
14
+ // update(), so persistence and re-render are the same
15
+ // debounced path every other block uses.
16
+ //
17
+ // Which record is open lives in view state (screen "record", arg = id), never
18
+ // in data — restoring it on reload is correct, and deleting the open record
19
+ // simply falls back to the list.
20
+
21
+ import { createElement as h } from "react";
22
+ import { Field, formatValue } from "./blocks.js";
23
+
24
+ export function recordDefaults(fields) {
25
+ const rec = {};
26
+ for (const f of fields) {
27
+ rec[f.key] = f.value !== undefined ? f.value : f.type === "check" ? false : "";
28
+ }
29
+ return rec;
30
+ }
31
+
32
+ // Collision-proof enough for a per-user list; no coordination needed.
33
+ function makeId() {
34
+ return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
35
+ }
36
+
37
+ function rowTitle(spec, rec) {
38
+ if (spec.title) return spec.title(rec);
39
+ // Default: the first text-ish field, which is what a human would label by.
40
+ const f = spec.fields.find((f) => !f.type || f.type === "text" || f.type === "textarea" || f.type === "select");
41
+ const v = f && rec[f.key];
42
+ return v === "" || v === undefined || v === null ? "(untitled)" : String(v);
43
+ }
44
+
45
+ function Summary({ spec, records }) {
46
+ if (!spec.summary) return null;
47
+ const items = spec.summary(records) || [];
48
+ if (!items.length) return null;
49
+ return h(
50
+ "div",
51
+ { className: "caf-block caf-output" },
52
+ h(
53
+ "div",
54
+ { className: "caf-output-grid" },
55
+ items.map((item, i) =>
56
+ h(
57
+ "div",
58
+ { key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
59
+ h("span", { className: "caf-stat-label" }, item.label),
60
+ h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format))
61
+ )
62
+ )
63
+ )
64
+ );
65
+ }
66
+
67
+ function ListScreen({ spec, ctx }) {
68
+ const label = spec.label || "item";
69
+ let records = ctx.data.records;
70
+ if (spec.sort) records = records.slice().sort(spec.sort);
71
+
72
+ function add() {
73
+ const rec = { id: makeId(), ...recordDefaults(spec.fields) };
74
+ ctx.update((d) => {
75
+ d.records = [...d.records, rec];
76
+ });
77
+ ctx.go("record", rec.id);
78
+ }
79
+
80
+ return h(
81
+ "div",
82
+ { className: "caf-block caf-list" },
83
+ h(
84
+ "div",
85
+ { className: "caf-toolbar" },
86
+ h("span", { className: "caf-count" }, `${records.length} ${label}${records.length === 1 ? "" : "s"}`),
87
+ h("button", { type: "button", className: "caf-btn caf-btn-primary", onClick: add }, `Add ${label}`)
88
+ ),
89
+ records.length === 0
90
+ ? h("div", { className: "caf-empty" }, spec.empty || `No ${label}s yet. Add the first one.`)
91
+ : records.map((rec) =>
92
+ h(
93
+ "button",
94
+ {
95
+ key: rec.id,
96
+ type: "button",
97
+ className: "caf-row",
98
+ onClick: () => ctx.go("record", rec.id),
99
+ },
100
+ h("span", { className: "caf-row-title" }, rowTitle(spec, rec)),
101
+ spec.subtitle ? h("span", { className: "caf-row-sub" }, spec.subtitle(rec)) : null
102
+ )
103
+ )
104
+ );
105
+ }
106
+
107
+ function DetailScreen({ spec, ctx, record }) {
108
+ const label = spec.label || "item";
109
+
110
+ function remove() {
111
+ ctx.update((d) => {
112
+ d.records = d.records.filter((r) => r.id !== record.id);
113
+ });
114
+ ctx.back();
115
+ }
116
+
117
+ return h(
118
+ "div",
119
+ { className: "caf-block caf-fields" },
120
+ h(
121
+ "div",
122
+ { className: "caf-toolbar" },
123
+ h("button", { type: "button", className: "caf-btn", onClick: ctx.back }, "‹ Back"),
124
+ h("button", { type: "button", className: "caf-btn caf-btn-danger", onClick: remove }, `Delete ${label}`)
125
+ ),
126
+ spec.fields.map((field) =>
127
+ h(Field, {
128
+ key: field.key,
129
+ field,
130
+ value: record[field.key],
131
+ onChange: (v) =>
132
+ ctx.update((d) => {
133
+ const rec = d.records.find((r) => r.id === record.id);
134
+ if (rec) rec[field.key] = v;
135
+ }),
136
+ })
137
+ )
138
+ );
139
+ }
140
+
141
+ export function RecordsLayout({ spec, ctx }) {
142
+ const rspec = spec.records;
143
+
144
+ if (ctx.view.screen === "record") {
145
+ const record = ctx.data.records.find((r) => r.id === ctx.view.arg);
146
+ // The open record can vanish (deleted, or stale view state from an earlier
147
+ // version of the data) — fall back to the list rather than a blank pane.
148
+ if (record) {
149
+ return h("div", { className: "caf-panel caf-panel-single" }, h(DetailScreen, { spec: rspec, ctx, record }));
150
+ }
151
+ }
152
+ return h(
153
+ "div",
154
+ { className: "caf-panel caf-panel-single" },
155
+ h(Summary, { spec: rspec, records: ctx.data.records }),
156
+ h(ListScreen, { spec: rspec, ctx })
157
+ );
158
+ }
package/src/theme.js ADDED
@@ -0,0 +1,148 @@
1
+ // theme.js — derives a complete palette from a single seed colour.
2
+ //
3
+ // One token in, a full light+dark palette out, with contrast checked rather
4
+ // than assumed. The point is that an app gets its own visual identity from
5
+ // one line while structure stays shared, so apps built with this framework
6
+ // don't all look alike.
7
+ //
8
+ // Neutrals carry a slight hue bias toward the seed instead of being pure
9
+ // grey — a pure mid-grey reads as unconsidered.
10
+
11
+ function clamp(n, lo, hi) {
12
+ return Math.min(hi, Math.max(lo, n));
13
+ }
14
+
15
+ function hexToRgb(hex) {
16
+ let h = String(hex).replace("#", "").trim();
17
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
18
+ const n = parseInt(h, 16);
19
+ if (!Number.isFinite(n)) return [59, 110, 246];
20
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
21
+ }
22
+
23
+ function rgbToHex(rgb) {
24
+ return "#" + rgb.map((v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0")).join("");
25
+ }
26
+
27
+ function rgbToHsl([r, g, b]) {
28
+ r /= 255; g /= 255; b /= 255;
29
+ const max = Math.max(r, g, b);
30
+ const min = Math.min(r, g, b);
31
+ const l = (max + min) / 2;
32
+ const d = max - min;
33
+ let h = 0;
34
+ let s = 0;
35
+ if (d) {
36
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
37
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
38
+ else if (max === g) h = (b - r) / d + 2;
39
+ else h = (r - g) / d + 4;
40
+ h /= 6;
41
+ }
42
+ return [h * 360, s * 100, l * 100];
43
+ }
44
+
45
+ function hslToRgb(h, s, l) {
46
+ h = (((h % 360) + 360) % 360) / 360;
47
+ s = clamp(s, 0, 100) / 100;
48
+ l = clamp(l, 0, 100) / 100;
49
+ if (!s) return [l * 255, l * 255, l * 255];
50
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
51
+ const p = 2 * l - q;
52
+ const channel = (t) => {
53
+ if (t < 0) t += 1;
54
+ if (t > 1) t -= 1;
55
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
56
+ if (t < 1 / 2) return q;
57
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
58
+ return p;
59
+ };
60
+ return [channel(h + 1 / 3) * 255, channel(h) * 255, channel(h - 1 / 3) * 255];
61
+ }
62
+
63
+ function hsl(h, s, l) {
64
+ return rgbToHex(hslToRgb(h, s, l));
65
+ }
66
+
67
+ function luminance(rgb) {
68
+ const a = rgb.map((v) => {
69
+ v /= 255;
70
+ return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
71
+ });
72
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
73
+ }
74
+
75
+ // WCAG contrast ratio between two hex colours, 1 (identical) to 21 (max).
76
+ export function contrast(a, b) {
77
+ const la = luminance(hexToRgb(a));
78
+ const lb = luminance(hexToRgb(b));
79
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
80
+ }
81
+
82
+ // Walks lightness until the colour clears `min` contrast against `against`,
83
+ // so the accent is guaranteed legible instead of merely hoped to be.
84
+ function accentAt(h, s, against, min, from, step) {
85
+ for (let l = from; l >= 4 && l <= 96; l += step) {
86
+ const candidate = hsl(h, s, l);
87
+ if (contrast(candidate, against) >= min) return candidate;
88
+ }
89
+ return hsl(h, s, step < 0 ? 24 : 82);
90
+ }
91
+
92
+ export function palette(seed = "#3b6ef6") {
93
+ const [h, rawSat] = rgbToHsl(hexToRgb(seed));
94
+ const sat = clamp(rawSat, 30, 88);
95
+ // Neutrals keep a trace of the seed's hue: chosen, not inherited.
96
+ const tint = clamp(sat * 0.1, 3, 9);
97
+
98
+ const lightSurface = hsl(h, tint * 0.4, 100);
99
+ const darkSurface = hsl(h, tint * 1.4, 13);
100
+
101
+ return {
102
+ light: {
103
+ bg: hsl(h, tint, 96.5),
104
+ surface: lightSurface,
105
+ sunken: hsl(h, tint, 93),
106
+ border: hsl(h, tint, 86),
107
+ text: hsl(h, tint * 1.6, 11),
108
+ muted: hsl(h, tint * 1.2, 41),
109
+ accent: accentAt(h, sat, "#ffffff", 4.5, 54, -2),
110
+ accentText: "#ffffff",
111
+ danger: accentAt(2, 68, "#ffffff", 4.5, 54, -2),
112
+ ok: accentAt(148, 62, "#ffffff", 4.5, 44, -2),
113
+ warn: accentAt(38, 88, "#ffffff", 4.5, 44, -2),
114
+ },
115
+ dark: {
116
+ bg: hsl(h, tint * 1.5, 9),
117
+ surface: darkSurface,
118
+ sunken: hsl(h, tint * 1.5, 7),
119
+ border: hsl(h, tint * 1.3, 24),
120
+ text: hsl(h, tint * 0.6, 95),
121
+ muted: hsl(h, tint * 0.8, 66),
122
+ accent: accentAt(h, clamp(sat, 40, 80), darkSurface, 4.5, 62, 2),
123
+ accentText: hsl(h, tint * 2, 8),
124
+ danger: accentAt(2, 70, darkSurface, 4.5, 62, 2),
125
+ ok: accentAt(148, 55, darkSurface, 4.5, 62, 2),
126
+ warn: accentAt(38, 80, darkSurface, 4.5, 62, 2),
127
+ },
128
+ };
129
+ }
130
+
131
+ function vars(scheme) {
132
+ return Object.entries(scheme)
133
+ .map(([k, v]) => `--caf-${k}: ${v};`)
134
+ .join("");
135
+ }
136
+
137
+ // Both themes are styled through the same tokens, and the viewer's explicit
138
+ // toggle (data-theme on the root) must beat the OS preference in both
139
+ // directions — hence the two attribute blocks after the media query.
140
+ export function themeCss(seed) {
141
+ const p = palette(seed);
142
+ return `
143
+ :root { ${vars(p.light)} }
144
+ @media (prefers-color-scheme: dark) { :root { ${vars(p.dark)} } }
145
+ :root[data-theme="dark"] { ${vars(p.dark)} }
146
+ :root[data-theme="light"] { ${vars(p.light)} }
147
+ `;
148
+ }