claude-artifact-framework 0.19.1 → 0.20.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 +32 -10
- package/dist/index.esm.js +154 -89
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +56 -16
- package/dist/index.global.js.map +3 -3
- package/llms.txt +9 -2
- package/package.json +1 -1
- package/src/app.js +63 -9
- package/src/appState.js +12 -1
- package/src/blocks.js +60 -13
- package/src/chat.js +31 -23
- package/src/records.js +25 -23
- package/src/steps.js +2 -20
package/README.md
CHANGED
|
@@ -908,25 +908,34 @@ keys must be unique across steps.
|
|
|
908
908
|
`{ description, input, run(input, ctx) }`.
|
|
909
909
|
|
|
910
910
|
**Formats** (`format:` on output/chart/compute items): `money`, `percent`,
|
|
911
|
-
`number`, `date
|
|
912
|
-
|
|
911
|
+
`number`, `date`, `text` (explicit no-op). Anything else throws, contained
|
|
912
|
+
to the block. `percent` expects 0–100 (pass `ratio * 100`, not the raw
|
|
913
|
+
ratio); a computed `NaN`/`Infinity` renders as "—", never as "NaN".
|
|
914
|
+
`big: true` renders an item at hero size AND promotes its block to
|
|
913
915
|
the screen's one feature surface (accent-tinted card) — reserve it for the
|
|
914
916
|
ONE number that is the screen's main result (a total, a final score), not
|
|
915
917
|
for every stat; more than one hero per screen means no hero at all.
|
|
916
918
|
|
|
917
919
|
**Quick-add pattern** — a lightweight alternative to `records` when you just
|
|
918
|
-
need "type something, press a button, it lands in a list"
|
|
920
|
+
need "type something, press a button, it lands in a list". Put the action
|
|
921
|
+
IN the fields block — it renders as the card's footer, visually owned by
|
|
922
|
+
the inputs it submits (a submit button in its own separate block is an
|
|
923
|
+
orphan card):
|
|
919
924
|
|
|
920
925
|
```js
|
|
921
926
|
blocks: [
|
|
922
|
-
{
|
|
923
|
-
|
|
927
|
+
{ title: "Agregar persona",
|
|
928
|
+
fields: [{ key: "nueva", label: "Persona", type: "text" }],
|
|
929
|
+
actions: [{ label: "Agregar", kind: "primary", run: (d) => {
|
|
924
930
|
if (d.nueva && d.nueva.trim()) { d.personas = [...(d.personas || []), d.nueva.trim()]; d.nueva = ""; }
|
|
925
|
-
|
|
931
|
+
}}] },
|
|
926
932
|
{ text: (d) => (d.personas || []).join(", ") || "Sin personas todavía" },
|
|
927
933
|
]
|
|
928
934
|
```
|
|
929
935
|
|
|
936
|
+
`{ fields, actions }` is the ONE legal block-type pair; any other
|
|
937
|
+
combination still throws.
|
|
938
|
+
|
|
930
939
|
## `storage`
|
|
931
940
|
|
|
932
941
|
Thin wrapper over `window.storage`, the persistence API Claude injects into a
|
|
@@ -988,6 +997,19 @@ Conflict handling is last-write-wins: if two viewers change the same key
|
|
|
988
997
|
within one poll window, whichever write reaches storage last overwrites the
|
|
989
998
|
other. There is no merge/CRDT logic.
|
|
990
999
|
|
|
1000
|
+
**Teammates' changes are visible by construction.** When another viewer's
|
|
1001
|
+
change arrives, rows they added or edited in any records list flash the
|
|
1002
|
+
accent once — nothing to declare. For custom blocks, `ctx.remote` is
|
|
1003
|
+
`{ keys, at }` for the last remote change (`keys` = the top-level data
|
|
1004
|
+
keys it touched, `at` = a `Date.now()` timestamp), or `null` before any:
|
|
1005
|
+
|
|
1006
|
+
```js
|
|
1007
|
+
custom: (ctx) => {
|
|
1008
|
+
const fresh = ctx.remote && Date.now() - ctx.remote.at < 4000 && ctx.remote.keys.includes("tarjetas");
|
|
1009
|
+
return React.createElement("div", { className: fresh ? "mi-tablero pulso" : "mi-tablero" }, /* ... */);
|
|
1010
|
+
}
|
|
1011
|
+
```
|
|
1012
|
+
|
|
991
1013
|
## Design principles — composing a screen that reads
|
|
992
1014
|
|
|
993
1015
|
The framework owns the CSS, but whether a screen has visual hierarchy is
|
|
@@ -1013,10 +1035,10 @@ these; every generic-looking one breaks at least two:
|
|
|
1013
1035
|
repeating the app's own title (the header shows it), no hand-built
|
|
1014
1036
|
totals when `summary`/`output` computes them, no "empty list" messages.
|
|
1015
1037
|
6. **Group; don't multiply cards.** Related inputs belong in ONE `fields`
|
|
1016
|
-
block with one title
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1038
|
+
block with one title, and their submit button belongs INSIDE it —
|
|
1039
|
+
`{ fields: [...], actions: [...] }` renders the actions as the card's
|
|
1040
|
+
footer. Secondary content goes into `tabs` or a collapsible. A first
|
|
1041
|
+
screen with more than ~5 cards has no hierarchy left.
|
|
1020
1042
|
7. **Say state with `when`, not with more blocks.** Hide what doesn't
|
|
1021
1043
|
apply right now instead of stacking every possibility on screen.
|
|
1022
1044
|
8. **Charts are support, not wallpaper.** At most two per screen; `wide:
|
package/dist/index.esm.js
CHANGED
|
@@ -182,13 +182,19 @@ function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2
|
|
|
182
182
|
});
|
|
183
183
|
}, debounceMs);
|
|
184
184
|
const persistView = debounce2(() => storage.set(VIEW_KEY, view), debounceMs);
|
|
185
|
+
let lastRemote = null;
|
|
185
186
|
if (shared) {
|
|
186
187
|
setInterval(async () => {
|
|
187
188
|
if (!hydrated || writes > persistedWrites) return;
|
|
188
189
|
try {
|
|
189
190
|
const remote = await dataStore.get(DATA_KEY, void 0);
|
|
190
191
|
if (remote !== void 0 && JSON.stringify(remote) !== JSON.stringify(data)) {
|
|
191
|
-
|
|
192
|
+
const merged = mergeDefaults(defaults, remote);
|
|
193
|
+
const keys = [.../* @__PURE__ */ new Set([...Object.keys(data || {}), ...Object.keys(merged || {})])].filter(
|
|
194
|
+
(k) => JSON.stringify(data[k]) !== JSON.stringify(merged[k])
|
|
195
|
+
);
|
|
196
|
+
data = merged;
|
|
197
|
+
lastRemote = { keys, at: Date.now() };
|
|
192
198
|
notify();
|
|
193
199
|
}
|
|
194
200
|
} catch {
|
|
@@ -230,6 +236,7 @@ function createAppState(defaults, { debounceMs = 400, shared = false, pollMs = 2
|
|
|
230
236
|
ready,
|
|
231
237
|
isHydrated: () => hydrated,
|
|
232
238
|
getViewerId: () => viewerId,
|
|
239
|
+
getRemote: () => lastRemote,
|
|
233
240
|
getData: () => data,
|
|
234
241
|
getView: () => view,
|
|
235
242
|
update,
|
|
@@ -577,42 +584,46 @@ Continue with this information. If you have everything you need, answer in natur
|
|
|
577
584
|
{ className: "caf-chat-panel" },
|
|
578
585
|
createElement(
|
|
579
586
|
"div",
|
|
580
|
-
{ className: "caf-block caf-chat-
|
|
587
|
+
{ className: "caf-block caf-chat-card" },
|
|
581
588
|
// Every other block opens with its title label — a chat card that
|
|
582
589
|
// starts straight at a bubble was the one heading-less block on screen.
|
|
583
590
|
title ? createElement("h2", { className: "caf-block-title" }, title) : null,
|
|
584
|
-
c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
|
|
585
|
-
log.map((m, i) => createElement(Bubble, { key: i, m })),
|
|
586
|
-
live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
|
|
587
|
-
busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
|
|
588
|
-
createElement("div", { ref: endRef })
|
|
589
|
-
),
|
|
590
|
-
// Deliberately NOT a <form>: the artifact iframe's sandbox lacks
|
|
591
|
-
// allow-forms, so native form submission is blocked there — and on mobile
|
|
592
|
-
// the keyboard's send key triggers exactly that implicit submission. A
|
|
593
|
-
// plain div with onClick + Enter via onKeyDown never touches the native
|
|
594
|
-
// form machinery, so it can't be sandboxed away.
|
|
595
|
-
createElement(
|
|
596
|
-
"div",
|
|
597
|
-
{ className: "caf-chat-input" },
|
|
598
|
-
createElement("input", {
|
|
599
|
-
value: draft,
|
|
600
|
-
placeholder: c.placeholder || "Message\u2026",
|
|
601
|
-
disabled: busy,
|
|
602
|
-
enterKeyHint: "send",
|
|
603
|
-
onChange: (e) => setDraft(e.target.value),
|
|
604
|
-
onKeyDown: (e) => {
|
|
605
|
-
if (e.key === "Enter") {
|
|
606
|
-
e.preventDefault();
|
|
607
|
-
send();
|
|
608
|
-
}
|
|
609
|
-
},
|
|
610
|
-
"aria-label": "Message"
|
|
611
|
-
}),
|
|
612
591
|
createElement(
|
|
613
|
-
"
|
|
614
|
-
{
|
|
615
|
-
"
|
|
592
|
+
"div",
|
|
593
|
+
{ className: "caf-chat-log" },
|
|
594
|
+
c.greeting ? createElement("div", { className: "caf-msg caf-msg-assistant", dangerouslySetInnerHTML: { __html: renderMarkdown(c.greeting) } }) : null,
|
|
595
|
+
log.map((m, i) => createElement(Bubble, { key: i, m })),
|
|
596
|
+
live !== null ? createElement("div", { className: "caf-msg caf-msg-assistant caf-msg-live", dangerouslySetInnerHTML: { __html: renderMarkdown(live || "\u2026") } }) : null,
|
|
597
|
+
busy && live === null ? createElement("div", { className: "caf-msg-tools" }, "thinking\u2026") : null,
|
|
598
|
+
createElement("div", { ref: endRef })
|
|
599
|
+
),
|
|
600
|
+
// Deliberately NOT a <form>: the artifact iframe's sandbox lacks
|
|
601
|
+
// allow-forms, so native form submission is blocked there — and on mobile
|
|
602
|
+
// the keyboard's send key triggers exactly that implicit submission. A
|
|
603
|
+
// plain div with onClick + Enter via onKeyDown never touches the native
|
|
604
|
+
// form machinery, so it can't be sandboxed away.
|
|
605
|
+
createElement(
|
|
606
|
+
"div",
|
|
607
|
+
{ className: "caf-chat-input" },
|
|
608
|
+
createElement("input", {
|
|
609
|
+
value: draft,
|
|
610
|
+
placeholder: c.placeholder || "Message\u2026",
|
|
611
|
+
disabled: busy,
|
|
612
|
+
enterKeyHint: "send",
|
|
613
|
+
onChange: (e) => setDraft(e.target.value),
|
|
614
|
+
onKeyDown: (e) => {
|
|
615
|
+
if (e.key === "Enter") {
|
|
616
|
+
e.preventDefault();
|
|
617
|
+
send();
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
"aria-label": "Message"
|
|
621
|
+
}),
|
|
622
|
+
createElement(
|
|
623
|
+
"button",
|
|
624
|
+
{ type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
|
|
625
|
+
"Send"
|
|
626
|
+
)
|
|
616
627
|
)
|
|
617
628
|
)
|
|
618
629
|
);
|
|
@@ -625,6 +636,7 @@ function ChatLayout({ spec, ctx }) {
|
|
|
625
636
|
var FIELD_TYPES = ["text", "textarea", "number", "money", "percent", "check", "date", "select", "file"];
|
|
626
637
|
function formatValue(value, format) {
|
|
627
638
|
if (value === null || value === void 0 || value === "") return "\u2014";
|
|
639
|
+
if (typeof value === "number" && !Number.isFinite(value)) return "\u2014";
|
|
628
640
|
const n = Number(value);
|
|
629
641
|
switch (format) {
|
|
630
642
|
case "money":
|
|
@@ -635,8 +647,15 @@ function formatValue(value, format) {
|
|
|
635
647
|
return Number.isFinite(n) ? n.toLocaleString(void 0, { maximumFractionDigits: 2 }) : String(value);
|
|
636
648
|
case "date":
|
|
637
649
|
return value instanceof Date ? value.toLocaleDateString() : String(value);
|
|
638
|
-
|
|
650
|
+
case "text":
|
|
651
|
+
// observed natural usage (round 21) — an explicit no-op
|
|
652
|
+
case void 0:
|
|
653
|
+
case null:
|
|
639
654
|
return String(value);
|
|
655
|
+
default:
|
|
656
|
+
throw new Error(
|
|
657
|
+
`claude-artifact-framework: unknown format "${format}". Valid formats: money, percent, number, date, text.`
|
|
658
|
+
);
|
|
640
659
|
}
|
|
641
660
|
}
|
|
642
661
|
function validateField(field, value) {
|
|
@@ -696,7 +715,9 @@ function Field({ field, value, onChange, data }) {
|
|
|
696
715
|
const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
|
|
697
716
|
control = createElement(
|
|
698
717
|
"select",
|
|
699
|
-
|
|
718
|
+
// Unchosen reads muted, like a text placeholder — "has a value" and
|
|
719
|
+
// "is empty" must differ at a glance (audited on the Select… state).
|
|
720
|
+
{ ...common, className: value ? void 0 : "caf-select-empty" },
|
|
700
721
|
createElement("option", { value: "" }, field.placeholder || "Select\u2026"),
|
|
701
722
|
options.map((opt) => {
|
|
702
723
|
const val = typeof opt === "string" ? opt : opt.value;
|
|
@@ -739,6 +760,7 @@ function writeField(ctx, key, field, v, setter) {
|
|
|
739
760
|
}
|
|
740
761
|
function FieldsBlock({ spec, ctx }) {
|
|
741
762
|
const fields = spec.fields || [];
|
|
763
|
+
const footer = (spec.actions || []).filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
|
|
742
764
|
return createElement(
|
|
743
765
|
"div",
|
|
744
766
|
{ className: "caf-block caf-fields" },
|
|
@@ -753,19 +775,31 @@ function FieldsBlock({ spec, ctx }) {
|
|
|
753
775
|
d[field.key] = val;
|
|
754
776
|
})
|
|
755
777
|
})
|
|
756
|
-
)
|
|
778
|
+
),
|
|
779
|
+
footer.length ? createElement(
|
|
780
|
+
"div",
|
|
781
|
+
{ className: "caf-actions-row caf-fields-footer" },
|
|
782
|
+
footer.map(
|
|
783
|
+
(a) => createElement(
|
|
784
|
+
"button",
|
|
785
|
+
{
|
|
786
|
+
key: a.label,
|
|
787
|
+
type: "button",
|
|
788
|
+
className: a.kind === "danger" ? "caf-btn caf-btn-danger" : a.kind === "primary" ? "caf-btn caf-btn-primary" : "caf-btn",
|
|
789
|
+
onClick: () => ctx.update((d) => a.run(d, ctx.viewerId))
|
|
790
|
+
},
|
|
791
|
+
a.label
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
) : null
|
|
757
795
|
);
|
|
758
796
|
}
|
|
759
|
-
function
|
|
760
|
-
const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
|
|
761
|
-
if (!items.length) {
|
|
762
|
-
return createElement("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
|
|
763
|
-
}
|
|
797
|
+
function StatGrid({ items, title }) {
|
|
764
798
|
const hero = items.some((it) => it && it.big);
|
|
765
799
|
return createElement(
|
|
766
800
|
"div",
|
|
767
801
|
{ className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
|
|
768
|
-
|
|
802
|
+
title ? createElement("h2", { className: "caf-block-title" }, title) : null,
|
|
769
803
|
createElement(
|
|
770
804
|
"div",
|
|
771
805
|
{ className: "caf-output-grid" },
|
|
@@ -781,6 +815,13 @@ function OutputBlock({ spec, ctx }) {
|
|
|
781
815
|
)
|
|
782
816
|
);
|
|
783
817
|
}
|
|
818
|
+
function OutputBlock({ spec, ctx }) {
|
|
819
|
+
const items = typeof spec.output === "function" ? spec.output(ctx.data) : spec.output || [];
|
|
820
|
+
if (!items.length) {
|
|
821
|
+
return createElement("div", { className: "caf-block caf-empty" }, spec.empty || "Nothing to show yet.");
|
|
822
|
+
}
|
|
823
|
+
return createElement(StatGrid, { items, title: spec.title });
|
|
824
|
+
}
|
|
784
825
|
function BannerBlock({ spec }) {
|
|
785
826
|
const b = typeof spec.banner === "string" ? { title: spec.banner } : spec.banner || {};
|
|
786
827
|
const background = b.image ? `linear-gradient(rgba(0,0,0,0.35), rgba(0,0,0,0.35)), url(${JSON.stringify(b.image)}) center / cover` : `linear-gradient(135deg, color-mix(in srgb, var(--caf-accent) 82%, #000), var(--caf-accent) 55%, color-mix(in srgb, var(--caf-accent) 72%, #fff))`;
|
|
@@ -1100,23 +1141,7 @@ function Summary({ spec, records }) {
|
|
|
1100
1141
|
if (!spec.summary) return null;
|
|
1101
1142
|
const items = spec.summary(records) || [];
|
|
1102
1143
|
if (!items.length) return null;
|
|
1103
|
-
|
|
1104
|
-
return createElement(
|
|
1105
|
-
"div",
|
|
1106
|
-
{ className: hero ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
|
|
1107
|
-
createElement(
|
|
1108
|
-
"div",
|
|
1109
|
-
{ className: "caf-output-grid" },
|
|
1110
|
-
items.map(
|
|
1111
|
-
(item, i) => createElement(
|
|
1112
|
-
"div",
|
|
1113
|
-
{ key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
1114
|
-
createElement("span", { className: "caf-stat-label" }, item.label),
|
|
1115
|
-
createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format))
|
|
1116
|
-
)
|
|
1117
|
-
)
|
|
1118
|
-
)
|
|
1119
|
-
);
|
|
1144
|
+
return createElement(StatGrid, { items });
|
|
1120
1145
|
}
|
|
1121
1146
|
function ListScreen({ spec, ctx }) {
|
|
1122
1147
|
const label = spec.label || "item";
|
|
@@ -1145,6 +1170,17 @@ function ListScreen({ spec, ctx }) {
|
|
|
1145
1170
|
});
|
|
1146
1171
|
ctx.go("record", rec.id);
|
|
1147
1172
|
}
|
|
1173
|
+
const prevRef = useRef(null);
|
|
1174
|
+
const snapshot = {};
|
|
1175
|
+
for (const r of ctx.data.records || []) if (r && r.id) snapshot[r.id] = JSON.stringify(r);
|
|
1176
|
+
const remoteFresh = ctx.remote && Date.now() - ctx.remote.at < 4e3 && ctx.remote.keys.includes(ctx._recordsKey || "records");
|
|
1177
|
+
const pulseIds = /* @__PURE__ */ new Set();
|
|
1178
|
+
if (remoteFresh && prevRef.current) {
|
|
1179
|
+
for (const id in snapshot) if (prevRef.current[id] !== snapshot[id]) pulseIds.add(id);
|
|
1180
|
+
}
|
|
1181
|
+
useEffect(() => {
|
|
1182
|
+
prevRef.current = snapshot;
|
|
1183
|
+
});
|
|
1148
1184
|
return createElement(
|
|
1149
1185
|
"div",
|
|
1150
1186
|
{ className: "caf-block caf-list" },
|
|
@@ -1174,7 +1210,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
1174
1210
|
"button",
|
|
1175
1211
|
{
|
|
1176
1212
|
type: "button",
|
|
1177
|
-
className: spec.avatar ? "caf-row caf-row-avatar" : "caf-row",
|
|
1213
|
+
className: (spec.avatar ? "caf-row caf-row-avatar" : "caf-row") + (pulseIds.has(rec.id) ? " caf-row-pulse" : ""),
|
|
1178
1214
|
onClick: () => ctx.go("record", rec.id)
|
|
1179
1215
|
},
|
|
1180
1216
|
spec.avatar ? createElement(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
|
|
@@ -1333,7 +1369,9 @@ function RecordsBlock({ spec, ctx }) {
|
|
|
1333
1369
|
data: { ...ctx.data, records: arr },
|
|
1334
1370
|
update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
|
|
1335
1371
|
go: (_screen, id) => ctx.setTab(stateKey, id),
|
|
1336
|
-
back: () => ctx.setTab(stateKey, void 0)
|
|
1372
|
+
back: () => ctx.setTab(stateKey, void 0),
|
|
1373
|
+
_recordsKey: key
|
|
1374
|
+
// so remote-change detection matches the REAL pool key
|
|
1337
1375
|
};
|
|
1338
1376
|
const record = openId ? arr.find((r) => r.id === openId) : null;
|
|
1339
1377
|
return createElement(
|
|
@@ -1372,25 +1410,7 @@ function ResultScreen({ step, ctx }) {
|
|
|
1372
1410
|
return createElement(
|
|
1373
1411
|
"div",
|
|
1374
1412
|
{ className: "caf-steps-result" },
|
|
1375
|
-
createElement(
|
|
1376
|
-
"div",
|
|
1377
|
-
// Same rule as OutputBlock: the hero (big) result gets the feature card.
|
|
1378
|
-
{ className: items.some((it) => it && it.big) ? "caf-block caf-output caf-block-feature" : "caf-block caf-output" },
|
|
1379
|
-
step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
|
|
1380
|
-
createElement(
|
|
1381
|
-
"div",
|
|
1382
|
-
{ className: "caf-output-grid" },
|
|
1383
|
-
items.map(
|
|
1384
|
-
(item, i) => createElement(
|
|
1385
|
-
"div",
|
|
1386
|
-
{ key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
1387
|
-
createElement("span", { className: "caf-stat-label" }, item.label),
|
|
1388
|
-
createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
1389
|
-
item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
|
|
1390
|
-
)
|
|
1391
|
-
)
|
|
1392
|
-
)
|
|
1393
|
-
),
|
|
1413
|
+
createElement(StatGrid, { items, title: step.title }),
|
|
1394
1414
|
// A result can end on more than stats: any declared blocks (charts,
|
|
1395
1415
|
// text, lists) render under the grid with full block services.
|
|
1396
1416
|
(step.blocks || []).map((b, i) => createElement(Block2, { key: "rb" + i, block: b, ctx })),
|
|
@@ -1537,15 +1557,16 @@ function checkBlocks(blocks) {
|
|
|
1537
1557
|
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
|
|
1538
1558
|
);
|
|
1539
1559
|
}
|
|
1540
|
-
|
|
1560
|
+
const fieldsWithActions = known.length === 2 && known.includes("fields") && known.includes("actions");
|
|
1561
|
+
if (known.length > 1 && !fieldsWithActions) {
|
|
1541
1562
|
throw new Error(
|
|
1542
1563
|
`claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
|
|
1543
1564
|
", "
|
|
1544
|
-
)}). Split them into separate entries of \`blocks
|
|
1565
|
+
)}). Split them into separate entries of \`blocks\` \u2014 the one exception is { fields, actions }, which renders the actions as the fields card's footer.`
|
|
1545
1566
|
);
|
|
1546
1567
|
}
|
|
1547
1568
|
checkFields(block.fields, `blocks[${i}]`);
|
|
1548
|
-
if (known
|
|
1569
|
+
if (known.includes("actions")) checkActions(block.actions, `blocks[${i}].actions`);
|
|
1549
1570
|
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
1550
1571
|
if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
|
|
1551
1572
|
if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
|
|
@@ -2041,7 +2062,7 @@ function Block({ block, ctx }) {
|
|
|
2041
2062
|
if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
|
|
2042
2063
|
const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
|
|
2043
2064
|
if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
|
|
2044
|
-
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
2065
|
+
const name = block.fields && block.actions ? "fields" : Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
2045
2066
|
const Component2 = ALL_BLOCKS[name];
|
|
2046
2067
|
const inner = createElement(
|
|
2047
2068
|
boundary(ctx.React),
|
|
@@ -2106,11 +2127,12 @@ function RecordsWithBlocks({ spec, ctx }) {
|
|
|
2106
2127
|
}
|
|
2107
2128
|
function WorkspaceLayout({ spec, ctx }) {
|
|
2108
2129
|
const render = (blocks) => (blocks || []).map((block, i) => createElement("section", { key: i, className: "caf-ws-slot" }, createElement(Block, { block, ctx })));
|
|
2130
|
+
const mainStartsWithTabs = Array.isArray(spec.main) && spec.main[0] && Array.isArray(spec.main[0].tabs);
|
|
2109
2131
|
return createElement(
|
|
2110
2132
|
"div",
|
|
2111
2133
|
{ className: "caf-workspace" },
|
|
2112
2134
|
createElement("div", { className: "caf-ws-main" }, render(spec.main)),
|
|
2113
|
-
spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
|
|
2135
|
+
spec.sidebar && spec.sidebar.length ? createElement("div", { className: mainStartsWithTabs ? "caf-ws-side caf-ws-side-tabs" : "caf-ws-side" }, render(spec.sidebar)) : null
|
|
2114
2136
|
);
|
|
2115
2137
|
}
|
|
2116
2138
|
function docDefaults(dspec) {
|
|
@@ -2435,6 +2457,9 @@ function createApp(spec = {}) {
|
|
|
2435
2457
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
2436
2458
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
2437
2459
|
viewport: narrow ? "mobile" : "desktop",
|
|
2460
|
+
// { keys, at } of the last change another viewer made (shared mode),
|
|
2461
|
+
// null before any. The door to "notice teammates' changes".
|
|
2462
|
+
remote: state.getRemote(),
|
|
2438
2463
|
_navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
|
|
2439
2464
|
_Block: Block,
|
|
2440
2465
|
// for layouts in other modules (steps) that render blocks
|
|
@@ -2523,6 +2548,10 @@ var BASE_CSS = `
|
|
|
2523
2548
|
flex-direction: column;
|
|
2524
2549
|
gap: 0.8rem;
|
|
2525
2550
|
height: 100%;
|
|
2551
|
+
/* The one depth cue cards get \u2014 same as collapsible heads, so every
|
|
2552
|
+
raised surface floats exactly the same amount (audited: buttons had
|
|
2553
|
+
depth, cards didn't). */
|
|
2554
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
2526
2555
|
}
|
|
2527
2556
|
.caf-block-title { margin: 0; font-size: 0.78rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--caf-muted); }
|
|
2528
2557
|
.caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
@@ -2534,6 +2563,11 @@ var BASE_CSS = `
|
|
|
2534
2563
|
border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border);
|
|
2535
2564
|
background: var(--caf-sunken); color: var(--caf-text); width: 100%;
|
|
2536
2565
|
}
|
|
2566
|
+
/* "Has a value" and "is empty" must read differently at a glance \u2014
|
|
2567
|
+
audited: a filled "0" and the neighboring placeholder shared one gray. */
|
|
2568
|
+
.caf-field input::placeholder, .caf-field textarea::placeholder,
|
|
2569
|
+
.caf-chat-input input::placeholder { color: var(--caf-muted); opacity: 0.75; }
|
|
2570
|
+
.caf-field select.caf-select-empty { color: var(--caf-muted); }
|
|
2537
2571
|
.caf-field input:focus-visible, .caf-field select:focus-visible, .caf-field textarea:focus-visible,
|
|
2538
2572
|
.caf-row:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
2539
2573
|
.caf-field input[aria-invalid="true"] { border-color: var(--caf-danger); }
|
|
@@ -2613,7 +2647,8 @@ var BASE_CSS = `
|
|
|
2613
2647
|
.caf-steps-fill { transition: width 0.25s ease; }
|
|
2614
2648
|
.caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
|
|
2615
2649
|
.caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
|
|
2616
|
-
.caf-chat-
|
|
2650
|
+
.caf-chat-card { min-width: 0; }
|
|
2651
|
+
.caf-chat-log { display: flex; flex-direction: column; min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
|
|
2617
2652
|
.caf-msg { max-width: 85%; padding: 0.55rem 0.75rem; border-radius: 12px; font-size: 0.92rem; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
|
|
2618
2653
|
.caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
|
|
2619
2654
|
.caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
|
|
@@ -2621,9 +2656,12 @@ var BASE_CSS = `
|
|
|
2621
2656
|
.caf-msg-tools { align-self: flex-start; font-size: 0.74rem; color: var(--caf-muted); padding: 0.15rem 0.55rem; border: 1px solid var(--caf-border); border-radius: 999px; }
|
|
2622
2657
|
.caf-msg-error { align-self: stretch; max-width: none; display: flex; flex-direction: column; gap: 0.2rem; background: transparent; border: 1px solid var(--caf-danger); }
|
|
2623
2658
|
.caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
|
|
2659
|
+
/* The composer belongs to the conversation: a hairline continuation of the
|
|
2660
|
+
same card, not a second bordered box floating 1rem below (audited: log
|
|
2661
|
+
and input read as unrelated siblings). */
|
|
2624
2662
|
.caf-chat-input {
|
|
2625
|
-
display: flex; gap: 0.6rem; margin-top:
|
|
2626
|
-
background:
|
|
2663
|
+
display: flex; gap: 0.6rem; margin-top: 0.2rem; padding: 0.7rem 0 0;
|
|
2664
|
+
background: transparent; border: none; border-top: 1px solid var(--caf-border); border-radius: 0;
|
|
2627
2665
|
}
|
|
2628
2666
|
.caf-chat-input input { flex: 1; font: inherit; font-size: 0.95rem; padding: 0.55rem 0.7rem; border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border); background: var(--caf-sunken); color: var(--caf-text); }
|
|
2629
2667
|
.caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
@@ -2637,6 +2675,14 @@ var BASE_CSS = `
|
|
|
2637
2675
|
.caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
|
|
2638
2676
|
.caf-records-blocks { margin-top: 1rem; }
|
|
2639
2677
|
.caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
|
|
2678
|
+
/* A teammate's change announces itself: rows another viewer just added or
|
|
2679
|
+
edited flash the accent tint once (shared mode \u2014 round 20's dropped
|
|
2680
|
+
"notice teammates' changes" requirement, framework-owned). */
|
|
2681
|
+
@keyframes caf-remote-pulse {
|
|
2682
|
+
0% { background: var(--caf-accent-tint); box-shadow: 0 0 0 2px var(--caf-accent); }
|
|
2683
|
+
100% { background: transparent; box-shadow: none; }
|
|
2684
|
+
}
|
|
2685
|
+
.caf-row-pulse { animation: caf-remote-pulse 2.4s ease-out; }
|
|
2640
2686
|
.caf-row-wrap .caf-row { flex: 1; min-width: 0; }
|
|
2641
2687
|
.caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
|
|
2642
2688
|
/* min-width keeps row actions' left edges aligned across rows even when
|
|
@@ -2649,6 +2695,9 @@ var BASE_CSS = `
|
|
|
2649
2695
|
.caf-item-remove { flex: none; margin-bottom: 0.15rem; }
|
|
2650
2696
|
.caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
|
|
2651
2697
|
.caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
|
|
2698
|
+
/* Footer actions inside a fields card: a hairline marks "this submits the
|
|
2699
|
+
inputs above", the same attachment grammar as the chat composer. */
|
|
2700
|
+
.caf-fields-footer { border-top: 1px solid var(--caf-border); padding-top: 0.8rem; margin-top: 0.2rem; }
|
|
2652
2701
|
.caf-code { background: var(--caf-sunken); border-radius: 6px; padding: 0.55rem 0.7rem; font-size: 0.8rem; overflow-x: auto; margin: 0.3rem 0; font-family: ui-monospace, monospace; }
|
|
2653
2702
|
.caf-msg code { background: color-mix(in srgb, currentColor 12%, transparent); border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.85em; font-family: ui-monospace, monospace; }
|
|
2654
2703
|
.caf-md-h { display: block; margin-top: 0.3rem; }
|
|
@@ -2657,6 +2706,12 @@ var BASE_CSS = `
|
|
|
2657
2706
|
.caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
|
|
2658
2707
|
.caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
|
|
2659
2708
|
.caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: var(--caf-block-gap); position: sticky; top: 1rem; }
|
|
2709
|
+
/* Tab bar height (2\xD70.45rem pad + ~1.02rem line + 1px border) plus the
|
|
2710
|
+
.caf-tabs gap \u2014 so the sidebar's first card tops out level with main's
|
|
2711
|
+
first card, not with the tab bar. Phones stack, no offset needed. */
|
|
2712
|
+
@media (min-width: 901px) {
|
|
2713
|
+
.caf-ws-side-tabs { margin-top: calc(1.92rem + 1px + 0.8rem); }
|
|
2714
|
+
}
|
|
2660
2715
|
.caf-ws-slot { min-width: 0; }
|
|
2661
2716
|
.caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
|
|
2662
2717
|
.caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
|
|
@@ -2704,6 +2759,14 @@ var BASE_CSS = `
|
|
|
2704
2759
|
.caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
2705
2760
|
.caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
2706
2761
|
.caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
|
|
2762
|
+
/* Where the page has a gutter, the logo lives in it: the title keeps the
|
|
2763
|
+
exact left axis of tabs and cards below (audited twice \u2014 a logo pushed
|
|
2764
|
+
every heading onto a third axis). On phones there is no gutter, so the
|
|
2765
|
+
logo stays inline. */
|
|
2766
|
+
@media (min-width: 720px) {
|
|
2767
|
+
.caf-header-brand { position: relative; }
|
|
2768
|
+
.caf-header-brand .caf-logo { position: absolute; right: 100%; margin-right: 0.85rem; top: 0.1rem; }
|
|
2769
|
+
}
|
|
2707
2770
|
.caf-header-text { min-width: 0; }
|
|
2708
2771
|
.caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
|
|
2709
2772
|
.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); }
|
|
@@ -2723,7 +2786,9 @@ var BASE_CSS = `
|
|
|
2723
2786
|
.caf-image { margin: 0; }
|
|
2724
2787
|
.caf-image img { width: 100%; border-radius: 7px; display: block; }
|
|
2725
2788
|
.caf-image-svg svg { width: 100%; height: auto; display: block; }
|
|
2726
|
-
|
|
2789
|
+
/* Left, like every other axis on the page \u2014 a centered footer was the one
|
|
2790
|
+
element breaking the single left axis (audited). */
|
|
2791
|
+
.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; }
|
|
2727
2792
|
.caf-layout-workspace .caf-footer { max-width: 1200px; }
|
|
2728
2793
|
.caf-avatar {
|
|
2729
2794
|
flex: none; width: 30px; height: 30px; border-radius: 50%;
|