claude-artifact-framework 0.19.2 → 0.20.1
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 +28 -8
- package/dist/index.esm.js +150 -54
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +56 -13
- package/dist/index.global.js.map +3 -3
- package/llms.txt +7 -1
- package/package.json +1 -1
- package/src/app.js +68 -11
- package/src/appState.js +12 -1
- package/src/blocks.js +49 -8
- package/src/chat.js +31 -23
- package/src/records.js +21 -2
package/README.md
CHANGED
|
@@ -917,18 +917,25 @@ ONE number that is the screen's main result (a total, a final score), not
|
|
|
917
917
|
for every stat; more than one hero per screen means no hero at all.
|
|
918
918
|
|
|
919
919
|
**Quick-add pattern** — a lightweight alternative to `records` when you just
|
|
920
|
-
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):
|
|
921
924
|
|
|
922
925
|
```js
|
|
923
926
|
blocks: [
|
|
924
|
-
{
|
|
925
|
-
|
|
927
|
+
{ title: "Agregar persona",
|
|
928
|
+
fields: [{ key: "nueva", label: "Persona", type: "text" }],
|
|
929
|
+
actions: [{ label: "Agregar", kind: "primary", run: (d) => {
|
|
926
930
|
if (d.nueva && d.nueva.trim()) { d.personas = [...(d.personas || []), d.nueva.trim()]; d.nueva = ""; }
|
|
927
|
-
|
|
931
|
+
}}] },
|
|
928
932
|
{ text: (d) => (d.personas || []).join(", ") || "Sin personas todavía" },
|
|
929
933
|
]
|
|
930
934
|
```
|
|
931
935
|
|
|
936
|
+
`{ fields, actions }` is the ONE legal block-type pair; any other
|
|
937
|
+
combination still throws.
|
|
938
|
+
|
|
932
939
|
## `storage`
|
|
933
940
|
|
|
934
941
|
Thin wrapper over `window.storage`, the persistence API Claude injects into a
|
|
@@ -990,6 +997,19 @@ Conflict handling is last-write-wins: if two viewers change the same key
|
|
|
990
997
|
within one poll window, whichever write reaches storage last overwrites the
|
|
991
998
|
other. There is no merge/CRDT logic.
|
|
992
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
|
+
|
|
993
1013
|
## Design principles — composing a screen that reads
|
|
994
1014
|
|
|
995
1015
|
The framework owns the CSS, but whether a screen has visual hierarchy is
|
|
@@ -1015,10 +1035,10 @@ these; every generic-looking one breaks at least two:
|
|
|
1015
1035
|
repeating the app's own title (the header shows it), no hand-built
|
|
1016
1036
|
totals when `summary`/`output` computes them, no "empty list" messages.
|
|
1017
1037
|
6. **Group; don't multiply cards.** Related inputs belong in ONE `fields`
|
|
1018
|
-
block with one title
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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.
|
|
1022
1042
|
7. **Say state with `when`, not with more blocks.** Hide what doesn't
|
|
1023
1043
|
apply right now instead of stacking every possibility on screen.
|
|
1024
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
|
);
|
|
@@ -661,7 +672,10 @@ function validateField(field, value) {
|
|
|
661
672
|
return null;
|
|
662
673
|
}
|
|
663
674
|
function Field({ field, value, onChange, data }) {
|
|
664
|
-
const
|
|
675
|
+
const [touched, setTouched] = useState(false);
|
|
676
|
+
const rawError = validateField(field, value);
|
|
677
|
+
const pristineEmpty = !touched && (value === "" || value === null || value === void 0);
|
|
678
|
+
const error = pristineEmpty ? null : rawError;
|
|
665
679
|
const id = `caf-f-${field.key}`;
|
|
666
680
|
const numeric = field.type === "number" || field.type === "money" || field.type === "percent";
|
|
667
681
|
if (field.type === "file") {
|
|
@@ -695,6 +709,7 @@ function Field({ field, value, onChange, data }) {
|
|
|
695
709
|
id,
|
|
696
710
|
value: value === null || value === void 0 ? "" : value,
|
|
697
711
|
"aria-invalid": error ? "true" : void 0,
|
|
712
|
+
onBlur: () => setTouched(true),
|
|
698
713
|
onChange: (e) => onChange(numeric ? toNumber(e.target.value) : e.target.value)
|
|
699
714
|
};
|
|
700
715
|
let control;
|
|
@@ -704,7 +719,9 @@ function Field({ field, value, onChange, data }) {
|
|
|
704
719
|
const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
|
|
705
720
|
control = createElement(
|
|
706
721
|
"select",
|
|
707
|
-
|
|
722
|
+
// Unchosen reads muted, like a text placeholder — "has a value" and
|
|
723
|
+
// "is empty" must differ at a glance (audited on the Select… state).
|
|
724
|
+
{ ...common, className: value ? void 0 : "caf-select-empty" },
|
|
708
725
|
createElement("option", { value: "" }, field.placeholder || "Select\u2026"),
|
|
709
726
|
options.map((opt) => {
|
|
710
727
|
const val = typeof opt === "string" ? opt : opt.value;
|
|
@@ -747,6 +764,7 @@ function writeField(ctx, key, field, v, setter) {
|
|
|
747
764
|
}
|
|
748
765
|
function FieldsBlock({ spec, ctx }) {
|
|
749
766
|
const fields = spec.fields || [];
|
|
767
|
+
const footer = (spec.actions || []).filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
|
|
750
768
|
return createElement(
|
|
751
769
|
"div",
|
|
752
770
|
{ className: "caf-block caf-fields" },
|
|
@@ -761,7 +779,23 @@ function FieldsBlock({ spec, ctx }) {
|
|
|
761
779
|
d[field.key] = val;
|
|
762
780
|
})
|
|
763
781
|
})
|
|
764
|
-
)
|
|
782
|
+
),
|
|
783
|
+
footer.length ? createElement(
|
|
784
|
+
"div",
|
|
785
|
+
{ className: "caf-actions-row caf-fields-footer" },
|
|
786
|
+
footer.map(
|
|
787
|
+
(a) => createElement(
|
|
788
|
+
"button",
|
|
789
|
+
{
|
|
790
|
+
key: a.label,
|
|
791
|
+
type: "button",
|
|
792
|
+
className: a.kind === "danger" ? "caf-btn caf-btn-danger" : a.kind === "primary" ? "caf-btn caf-btn-primary" : "caf-btn",
|
|
793
|
+
onClick: () => ctx.update((d) => a.run(d, ctx.viewerId))
|
|
794
|
+
},
|
|
795
|
+
a.label
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
) : null
|
|
765
799
|
);
|
|
766
800
|
}
|
|
767
801
|
function StatGrid({ items, title }) {
|
|
@@ -773,15 +807,16 @@ function StatGrid({ items, title }) {
|
|
|
773
807
|
createElement(
|
|
774
808
|
"div",
|
|
775
809
|
{ className: "caf-output-grid" },
|
|
776
|
-
items.map(
|
|
777
|
-
(item
|
|
810
|
+
items.map((item, i) => {
|
|
811
|
+
const neg = (item.format === "money" || item.format === "number") && Number(item.value) < 0;
|
|
812
|
+
return createElement(
|
|
778
813
|
"div",
|
|
779
814
|
{ key: item.label || i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
780
815
|
createElement("span", { className: "caf-stat-label" }, item.label),
|
|
781
|
-
createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
816
|
+
createElement("span", { className: neg ? "caf-stat-value caf-stat-neg" : "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
782
817
|
item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
|
|
783
|
-
)
|
|
784
|
-
)
|
|
818
|
+
);
|
|
819
|
+
})
|
|
785
820
|
)
|
|
786
821
|
);
|
|
787
822
|
}
|
|
@@ -1140,6 +1175,17 @@ function ListScreen({ spec, ctx }) {
|
|
|
1140
1175
|
});
|
|
1141
1176
|
ctx.go("record", rec.id);
|
|
1142
1177
|
}
|
|
1178
|
+
const prevRef = useRef(null);
|
|
1179
|
+
const snapshot = {};
|
|
1180
|
+
for (const r of ctx.data.records || []) if (r && r.id) snapshot[r.id] = JSON.stringify(r);
|
|
1181
|
+
const remoteFresh = ctx.remote && Date.now() - ctx.remote.at < 4e3 && ctx.remote.keys.includes(ctx._recordsKey || "records");
|
|
1182
|
+
const pulseIds = /* @__PURE__ */ new Set();
|
|
1183
|
+
if (remoteFresh && prevRef.current) {
|
|
1184
|
+
for (const id in snapshot) if (prevRef.current[id] !== snapshot[id]) pulseIds.add(id);
|
|
1185
|
+
}
|
|
1186
|
+
useEffect(() => {
|
|
1187
|
+
prevRef.current = snapshot;
|
|
1188
|
+
});
|
|
1143
1189
|
return createElement(
|
|
1144
1190
|
"div",
|
|
1145
1191
|
{ className: "caf-block caf-list" },
|
|
@@ -1169,7 +1215,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
1169
1215
|
"button",
|
|
1170
1216
|
{
|
|
1171
1217
|
type: "button",
|
|
1172
|
-
className: spec.avatar ? "caf-row caf-row-avatar" : "caf-row",
|
|
1218
|
+
className: (spec.avatar ? "caf-row caf-row-avatar" : "caf-row") + (pulseIds.has(rec.id) ? " caf-row-pulse" : ""),
|
|
1173
1219
|
onClick: () => ctx.go("record", rec.id)
|
|
1174
1220
|
},
|
|
1175
1221
|
spec.avatar ? createElement(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
|
|
@@ -1328,7 +1374,9 @@ function RecordsBlock({ spec, ctx }) {
|
|
|
1328
1374
|
data: { ...ctx.data, records: arr },
|
|
1329
1375
|
update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
|
|
1330
1376
|
go: (_screen, id) => ctx.setTab(stateKey, id),
|
|
1331
|
-
back: () => ctx.setTab(stateKey, void 0)
|
|
1377
|
+
back: () => ctx.setTab(stateKey, void 0),
|
|
1378
|
+
_recordsKey: key
|
|
1379
|
+
// so remote-change detection matches the REAL pool key
|
|
1332
1380
|
};
|
|
1333
1381
|
const record = openId ? arr.find((r) => r.id === openId) : null;
|
|
1334
1382
|
return createElement(
|
|
@@ -1514,15 +1562,16 @@ function checkBlocks(blocks) {
|
|
|
1514
1562
|
`claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
|
|
1515
1563
|
);
|
|
1516
1564
|
}
|
|
1517
|
-
|
|
1565
|
+
const fieldsWithActions = known.length === 2 && known.includes("fields") && known.includes("actions");
|
|
1566
|
+
if (known.length > 1 && !fieldsWithActions) {
|
|
1518
1567
|
throw new Error(
|
|
1519
1568
|
`claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
|
|
1520
1569
|
", "
|
|
1521
|
-
)}). Split them into separate entries of \`blocks
|
|
1570
|
+
)}). Split them into separate entries of \`blocks\` \u2014 the one exception is { fields, actions }, which renders the actions as the fields card's footer.`
|
|
1522
1571
|
);
|
|
1523
1572
|
}
|
|
1524
1573
|
checkFields(block.fields, `blocks[${i}]`);
|
|
1525
|
-
if (known
|
|
1574
|
+
if (known.includes("actions")) checkActions(block.actions, `blocks[${i}].actions`);
|
|
1526
1575
|
if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
|
|
1527
1576
|
if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
|
|
1528
1577
|
if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
|
|
@@ -2018,7 +2067,7 @@ function Block({ block, ctx }) {
|
|
|
2018
2067
|
if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
|
|
2019
2068
|
const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
|
|
2020
2069
|
if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
|
|
2021
|
-
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
2070
|
+
const name = block.fields && block.actions ? "fields" : Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
2022
2071
|
const Component2 = ALL_BLOCKS[name];
|
|
2023
2072
|
const inner = createElement(
|
|
2024
2073
|
boundary(ctx.React),
|
|
@@ -2083,11 +2132,12 @@ function RecordsWithBlocks({ spec, ctx }) {
|
|
|
2083
2132
|
}
|
|
2084
2133
|
function WorkspaceLayout({ spec, ctx }) {
|
|
2085
2134
|
const render = (blocks) => (blocks || []).map((block, i) => createElement("section", { key: i, className: "caf-ws-slot" }, createElement(Block, { block, ctx })));
|
|
2135
|
+
const mainStartsWithTabs = Array.isArray(spec.main) && spec.main[0] && Array.isArray(spec.main[0].tabs);
|
|
2086
2136
|
return createElement(
|
|
2087
2137
|
"div",
|
|
2088
2138
|
{ className: "caf-workspace" },
|
|
2089
2139
|
createElement("div", { className: "caf-ws-main" }, render(spec.main)),
|
|
2090
|
-
spec.sidebar && spec.sidebar.length ? createElement("div", { className: "caf-ws-side" }, render(spec.sidebar)) : null
|
|
2140
|
+
spec.sidebar && spec.sidebar.length ? createElement("div", { className: mainStartsWithTabs ? "caf-ws-side caf-ws-side-tabs" : "caf-ws-side" }, render(spec.sidebar)) : null
|
|
2091
2141
|
);
|
|
2092
2142
|
}
|
|
2093
2143
|
function docDefaults(dspec) {
|
|
@@ -2412,6 +2462,9 @@ function createApp(spec = {}) {
|
|
|
2412
2462
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
2413
2463
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
2414
2464
|
viewport: narrow ? "mobile" : "desktop",
|
|
2465
|
+
// { keys, at } of the last change another viewer made (shared mode),
|
|
2466
|
+
// null before any. The door to "notice teammates' changes".
|
|
2467
|
+
remote: state.getRemote(),
|
|
2415
2468
|
_navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
|
|
2416
2469
|
_Block: Block,
|
|
2417
2470
|
// for layouts in other modules (steps) that render blocks
|
|
@@ -2500,6 +2553,10 @@ var BASE_CSS = `
|
|
|
2500
2553
|
flex-direction: column;
|
|
2501
2554
|
gap: 0.8rem;
|
|
2502
2555
|
height: 100%;
|
|
2556
|
+
/* The one depth cue cards get \u2014 same as collapsible heads, so every
|
|
2557
|
+
raised surface floats exactly the same amount (audited: buttons had
|
|
2558
|
+
depth, cards didn't). */
|
|
2559
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
2503
2560
|
}
|
|
2504
2561
|
.caf-block-title { margin: 0; font-size: 0.78rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--caf-muted); }
|
|
2505
2562
|
.caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
|
@@ -2511,6 +2568,11 @@ var BASE_CSS = `
|
|
|
2511
2568
|
border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border);
|
|
2512
2569
|
background: var(--caf-sunken); color: var(--caf-text); width: 100%;
|
|
2513
2570
|
}
|
|
2571
|
+
/* "Has a value" and "is empty" must read differently at a glance \u2014
|
|
2572
|
+
audited: a filled "0" and the neighboring placeholder shared one gray. */
|
|
2573
|
+
.caf-field input::placeholder, .caf-field textarea::placeholder,
|
|
2574
|
+
.caf-chat-input input::placeholder { color: var(--caf-muted); opacity: 0.75; }
|
|
2575
|
+
.caf-field select.caf-select-empty { color: var(--caf-muted); }
|
|
2514
2576
|
.caf-field input:focus-visible, .caf-field select:focus-visible, .caf-field textarea:focus-visible,
|
|
2515
2577
|
.caf-row:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
2516
2578
|
.caf-field input[aria-invalid="true"] { border-color: var(--caf-danger); }
|
|
@@ -2590,7 +2652,8 @@ var BASE_CSS = `
|
|
|
2590
2652
|
.caf-steps-fill { transition: width 0.25s ease; }
|
|
2591
2653
|
.caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
|
|
2592
2654
|
.caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
|
|
2593
|
-
.caf-chat-
|
|
2655
|
+
.caf-chat-card { min-width: 0; }
|
|
2656
|
+
.caf-chat-log { display: flex; flex-direction: column; min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
|
|
2594
2657
|
.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; }
|
|
2595
2658
|
.caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
|
|
2596
2659
|
.caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
|
|
@@ -2598,9 +2661,12 @@ var BASE_CSS = `
|
|
|
2598
2661
|
.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; }
|
|
2599
2662
|
.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); }
|
|
2600
2663
|
.caf-msg-error strong { color: var(--caf-danger); font-size: 0.85rem; }
|
|
2664
|
+
/* The composer belongs to the conversation: a hairline continuation of the
|
|
2665
|
+
same card, not a second bordered box floating 1rem below (audited: log
|
|
2666
|
+
and input read as unrelated siblings). */
|
|
2601
2667
|
.caf-chat-input {
|
|
2602
|
-
display: flex; gap: 0.6rem; margin-top:
|
|
2603
|
-
background:
|
|
2668
|
+
display: flex; gap: 0.6rem; margin-top: 0.2rem; padding: 0.7rem 0 0;
|
|
2669
|
+
background: transparent; border: none; border-top: 1px solid var(--caf-border); border-radius: 0;
|
|
2604
2670
|
}
|
|
2605
2671
|
.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); }
|
|
2606
2672
|
.caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
|
|
@@ -2614,6 +2680,14 @@ var BASE_CSS = `
|
|
|
2614
2680
|
.caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
|
|
2615
2681
|
.caf-records-blocks { margin-top: 1rem; }
|
|
2616
2682
|
.caf-row-wrap { display: flex; align-items: center; gap: 0.4rem; }
|
|
2683
|
+
/* A teammate's change announces itself: rows another viewer just added or
|
|
2684
|
+
edited flash the accent tint once (shared mode \u2014 round 20's dropped
|
|
2685
|
+
"notice teammates' changes" requirement, framework-owned). */
|
|
2686
|
+
@keyframes caf-remote-pulse {
|
|
2687
|
+
0% { background: var(--caf-accent-tint); box-shadow: 0 0 0 2px var(--caf-accent); }
|
|
2688
|
+
100% { background: transparent; box-shadow: none; }
|
|
2689
|
+
}
|
|
2690
|
+
.caf-row-pulse { animation: caf-remote-pulse 2.4s ease-out; }
|
|
2617
2691
|
.caf-row-wrap .caf-row { flex: 1; min-width: 0; }
|
|
2618
2692
|
.caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
|
|
2619
2693
|
/* min-width keeps row actions' left edges aligned across rows even when
|
|
@@ -2626,6 +2700,9 @@ var BASE_CSS = `
|
|
|
2626
2700
|
.caf-item-remove { flex: none; margin-bottom: 0.15rem; }
|
|
2627
2701
|
.caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
|
|
2628
2702
|
.caf-actions-row { display: flex; flex-wrap: wrap; gap: 0.6rem; }
|
|
2703
|
+
/* Footer actions inside a fields card: a hairline marks "this submits the
|
|
2704
|
+
inputs above", the same attachment grammar as the chat composer. */
|
|
2705
|
+
.caf-fields-footer { border-top: 1px solid var(--caf-border); padding-top: 0.8rem; margin-top: 0.2rem; }
|
|
2629
2706
|
.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; }
|
|
2630
2707
|
.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; }
|
|
2631
2708
|
.caf-md-h { display: block; margin-top: 0.3rem; }
|
|
@@ -2634,6 +2711,12 @@ var BASE_CSS = `
|
|
|
2634
2711
|
.caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
|
|
2635
2712
|
.caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
|
|
2636
2713
|
.caf-ws-side { width: 340px; flex: none; display: flex; flex-direction: column; gap: var(--caf-block-gap); position: sticky; top: 1rem; }
|
|
2714
|
+
/* Tab bar height (2\xD70.45rem pad + ~1.02rem line + 1px border) plus the
|
|
2715
|
+
.caf-tabs gap \u2014 so the sidebar's first card tops out level with main's
|
|
2716
|
+
first card, not with the tab bar. Phones stack, no offset needed. */
|
|
2717
|
+
@media (min-width: 901px) {
|
|
2718
|
+
.caf-ws-side-tabs { margin-top: calc(1.92rem + 1px + 0.8rem); }
|
|
2719
|
+
}
|
|
2637
2720
|
.caf-ws-slot { min-width: 0; }
|
|
2638
2721
|
.caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
|
|
2639
2722
|
.caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
|
|
@@ -2678,11 +2761,22 @@ var BASE_CSS = `
|
|
|
2678
2761
|
.caf-doc-close:hover { color: var(--caf-danger); }
|
|
2679
2762
|
.caf-doc-add { font-size: 1rem; font-weight: 650; }
|
|
2680
2763
|
.caf-empty .caf-btn { margin-top: 0.6rem; }
|
|
2681
|
-
|
|
2764
|
+
/* Tight gap: the block title is a SECTION head for the cards below \u2014 it
|
|
2765
|
+
must hug its group, not float equidistant (audited on the retro board). */
|
|
2766
|
+
.caf-records-block { display: flex; flex-direction: column; gap: 0.6rem; min-width: 0; }
|
|
2767
|
+
.caf-stat-neg, .caf-stat-big .caf-stat-value.caf-stat-neg { color: var(--caf-danger); }
|
|
2682
2768
|
.caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
|
2683
2769
|
.caf-header-brand { display: flex; align-items: center; gap: 0.8rem; }
|
|
2770
|
+
/* Where the page has a gutter, the logo lives in it: the title keeps the
|
|
2771
|
+
exact left axis of tabs and cards below (audited twice \u2014 a logo pushed
|
|
2772
|
+
every heading onto a third axis). On phones there is no gutter, so the
|
|
2773
|
+
logo stays inline. */
|
|
2774
|
+
@media (min-width: 720px) {
|
|
2775
|
+
.caf-header-brand { position: relative; }
|
|
2776
|
+
.caf-header-brand .caf-logo { position: absolute; right: 100%; margin-right: 0.85rem; top: 0.1rem; }
|
|
2777
|
+
}
|
|
2684
2778
|
.caf-header-text { min-width: 0; }
|
|
2685
|
-
.caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
|
|
2779
|
+
.caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; overflow: hidden; }
|
|
2686
2780
|
.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); }
|
|
2687
2781
|
.caf-logo-mono {
|
|
2688
2782
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -2700,7 +2794,9 @@ var BASE_CSS = `
|
|
|
2700
2794
|
.caf-image { margin: 0; }
|
|
2701
2795
|
.caf-image img { width: 100%; border-radius: 7px; display: block; }
|
|
2702
2796
|
.caf-image-svg svg { width: 100%; height: auto; display: block; }
|
|
2703
|
-
|
|
2797
|
+
/* Left, like every other axis on the page \u2014 a centered footer was the one
|
|
2798
|
+
element breaking the single left axis (audited). */
|
|
2799
|
+
.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; }
|
|
2704
2800
|
.caf-layout-workspace .caf-footer { max-width: 1200px; }
|
|
2705
2801
|
.caf-avatar {
|
|
2706
2802
|
flex: none; width: 30px; height: 30px; border-radius: 50%;
|