claude-artifact-framework 0.2.1 → 0.4.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/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/steps.js ADDED
@@ -0,0 +1,106 @@
1
+ // steps.js — the wizard layout: quizzes, assessments, staged calculators,
2
+ // long forms, onboarding.
3
+ //
4
+ // The author declares the sequence; next/back/progress/result are
5
+ // framework-owned. Three rules keep it un-breakable:
6
+ //
7
+ // one data pool step fields write into the same flat `data` every other
8
+ // block uses, so a result step is just a function of data —
9
+ // no "collect answers" plumbing to half-build.
10
+ // gated next Next is disabled while any field on the CURRENT step is
11
+ // invalid; earlier steps are already valid by construction,
12
+ // later ones aren't the user's problem yet.
13
+ // resumable the current step lives in view state, so a reload lands
14
+ // on the step the user was on, holding their answers.
15
+
16
+ import { createElement as h } from "react";
17
+ import { Field, validateField, formatValue } from "./blocks.js";
18
+
19
+ function stepErrors(step, data) {
20
+ return (step.fields || []).some((f) => validateField(f, data[f.key]) !== null);
21
+ }
22
+
23
+ function ResultScreen({ step, ctx }) {
24
+ const items = step.result(ctx.data) || [];
25
+ return h(
26
+ "div",
27
+ { className: "caf-block caf-output" },
28
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
29
+ h(
30
+ "div",
31
+ { className: "caf-output-grid" },
32
+ items.map((item, i) =>
33
+ h(
34
+ "div",
35
+ { key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
36
+ h("span", { className: "caf-stat-label" }, item.label),
37
+ h("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
38
+ item.hint ? h("small", { className: "caf-hint" }, item.hint) : null
39
+ )
40
+ )
41
+ ),
42
+ h(
43
+ "div",
44
+ { className: "caf-step-nav" },
45
+ h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(0) }, "Start over")
46
+ )
47
+ );
48
+ }
49
+
50
+ export function StepsLayout({ spec, ctx }) {
51
+ const steps = spec.steps;
52
+ const i = Math.min(Math.max(ctx.view.step || 0, 0), steps.length - 1);
53
+ const step = steps[i];
54
+ const isResult = typeof step.result === "function";
55
+ const isLast = i === steps.length - 1;
56
+
57
+ return h(
58
+ "div",
59
+ { className: "caf-panel caf-panel-single" },
60
+ h(
61
+ "div",
62
+ { className: "caf-steps-progress" },
63
+ h("span", { className: "caf-hint" }, isResult ? "Result" : `Step ${i + 1} of ${steps.length}`),
64
+ h(
65
+ "div",
66
+ { className: "caf-bar-track" },
67
+ h("div", { className: "caf-bar-fill caf-steps-fill", style: { width: `${((i + 1) / steps.length) * 100}%` } })
68
+ )
69
+ ),
70
+ isResult
71
+ ? h(ResultScreen, { step, ctx })
72
+ : h(
73
+ "div",
74
+ { className: "caf-block caf-fields" },
75
+ step.title ? h("h2", { className: "caf-block-title" }, step.title) : null,
76
+ step.text ? h("p", { className: "caf-step-text" }, typeof step.text === "function" ? step.text(ctx.data) : step.text) : null,
77
+ (step.fields || []).map((field) =>
78
+ h(Field, {
79
+ key: field.key,
80
+ field,
81
+ value: ctx.data[field.key],
82
+ onChange: (v) => ctx.update((d) => { d[field.key] = v; }),
83
+ })
84
+ ),
85
+ h(
86
+ "div",
87
+ { className: "caf-step-nav" },
88
+ i > 0
89
+ ? h("button", { type: "button", className: "caf-btn", onClick: () => ctx.setStep(i - 1) }, "‹ Back")
90
+ : h("span"),
91
+ !isLast
92
+ ? h(
93
+ "button",
94
+ {
95
+ type: "button",
96
+ className: "caf-btn caf-btn-primary",
97
+ disabled: stepErrors(step, ctx.data),
98
+ onClick: () => ctx.setStep(i + 1),
99
+ },
100
+ "Next ›"
101
+ )
102
+ : null
103
+ )
104
+ )
105
+ );
106
+ }
package/src/theme.js ADDED
@@ -0,0 +1,157 @@
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
+ }
149
+
150
+ // Categorical series colors for charts, derived from the seed by golden-angle
151
+ // hue rotation, so any number of segments stays distinguishable. Mid-tone
152
+ // lightness reads on both the light and dark surface.
153
+ export function seriesColors(seed, n) {
154
+ const [h0, s0] = rgbToHsl(hexToRgb(seed || "#3b6ef6"));
155
+ const s = clamp(s0, 45, 70);
156
+ return Array.from({ length: n }, (_, i) => hsl((h0 + i * 137.5) % 360, s, 52));
157
+ }