claude-artifact-framework 0.19.2 → 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 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
- { fields: [{ key: "nueva", label: "Persona", type: "text" }] },
925
- { actions: [{ label: "Agregar", kind: "primary", run: (d) => {
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. Secondary content goes into `tabs` or a
1019
- collapsible. A first screen with more than ~5 cards has no hierarchy
1020
- left and a block whose only content is one lonely button probably
1021
- belongs next to the fields it acts on.
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
- data = mergeDefaults(defaults, remote);
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-log" },
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
- "button",
614
- { type: "button", className: "caf-btn caf-btn-primary", disabled: busy || !draft.trim(), onClick: send },
615
- "Send"
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
  );
@@ -704,7 +715,9 @@ function Field({ field, value, onChange, data }) {
704
715
  const options = typeof field.options === "function" ? field.options(data) || [] : field.options || [];
705
716
  control = createElement(
706
717
  "select",
707
- common,
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" },
708
721
  createElement("option", { value: "" }, field.placeholder || "Select\u2026"),
709
722
  options.map((opt) => {
710
723
  const val = typeof opt === "string" ? opt : opt.value;
@@ -747,6 +760,7 @@ function writeField(ctx, key, field, v, setter) {
747
760
  }
748
761
  function FieldsBlock({ spec, ctx }) {
749
762
  const fields = spec.fields || [];
763
+ const footer = (spec.actions || []).filter((a) => !a.when || a.when(ctx.data, ctx.viewerId));
750
764
  return createElement(
751
765
  "div",
752
766
  { className: "caf-block caf-fields" },
@@ -761,7 +775,23 @@ function FieldsBlock({ spec, ctx }) {
761
775
  d[field.key] = val;
762
776
  })
763
777
  })
764
- )
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
765
795
  );
766
796
  }
767
797
  function StatGrid({ items, title }) {
@@ -1140,6 +1170,17 @@ function ListScreen({ spec, ctx }) {
1140
1170
  });
1141
1171
  ctx.go("record", rec.id);
1142
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
+ });
1143
1184
  return createElement(
1144
1185
  "div",
1145
1186
  { className: "caf-block caf-list" },
@@ -1169,7 +1210,7 @@ function ListScreen({ spec, ctx }) {
1169
1210
  "button",
1170
1211
  {
1171
1212
  type: "button",
1172
- 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" : ""),
1173
1214
  onClick: () => ctx.go("record", rec.id)
1174
1215
  },
1175
1216
  spec.avatar ? createElement(Avatar, { title: rowTitle(spec, rec), ctx }) : null,
@@ -1328,7 +1369,9 @@ function RecordsBlock({ spec, ctx }) {
1328
1369
  data: { ...ctx.data, records: arr },
1329
1370
  update: (fn) => ctx.update((d) => fn(aliasRecords(d, key))),
1330
1371
  go: (_screen, id) => ctx.setTab(stateKey, id),
1331
- 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
1332
1375
  };
1333
1376
  const record = openId ? arr.find((r) => r.id === openId) : null;
1334
1377
  return createElement(
@@ -1514,15 +1557,16 @@ function checkBlocks(blocks) {
1514
1557
  `claude-artifact-framework: blocks[${i}] has no recognised block type (found: ${keys.join(", ") || "nothing"}). Valid block types: ${[...BLOCK_NAMES, "tabs", "records"].join(", ")}.`
1515
1558
  );
1516
1559
  }
1517
- if (known.length > 1) {
1560
+ const fieldsWithActions = known.length === 2 && known.includes("fields") && known.includes("actions");
1561
+ if (known.length > 1 && !fieldsWithActions) {
1518
1562
  throw new Error(
1519
1563
  `claude-artifact-framework: blocks[${i}] declares more than one block type (${known.join(
1520
1564
  ", "
1521
- )}). 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.`
1522
1566
  );
1523
1567
  }
1524
1568
  checkFields(block.fields, `blocks[${i}]`);
1525
- if (known[0] === "actions") checkActions(block.actions, `blocks[${i}].actions`);
1569
+ if (known.includes("actions")) checkActions(block.actions, `blocks[${i}].actions`);
1526
1570
  if (known[0] === "chat") checkChatConfig(block.chat, `blocks[${i}].chat`);
1527
1571
  if (known[0] === "records") checkRecordsSpec(block.records, `blocks[${i}].records`, true);
1528
1572
  if (known[0] === "banner" && typeof block.banner === "object" && block.banner !== null) {
@@ -2018,7 +2062,7 @@ function Block({ block, ctx }) {
2018
2062
  if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
2019
2063
  const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
2020
2064
  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));
2065
+ const name = block.fields && block.actions ? "fields" : Object.keys(block).find((k) => ALL_NAMES.includes(k));
2022
2066
  const Component2 = ALL_BLOCKS[name];
2023
2067
  const inner = createElement(
2024
2068
  boundary(ctx.React),
@@ -2083,11 +2127,12 @@ function RecordsWithBlocks({ spec, ctx }) {
2083
2127
  }
2084
2128
  function WorkspaceLayout({ spec, ctx }) {
2085
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);
2086
2131
  return createElement(
2087
2132
  "div",
2088
2133
  { className: "caf-workspace" },
2089
2134
  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
2135
+ spec.sidebar && spec.sidebar.length ? createElement("div", { className: mainStartsWithTabs ? "caf-ws-side caf-ws-side-tabs" : "caf-ws-side" }, render(spec.sidebar)) : null
2091
2136
  );
2092
2137
  }
2093
2138
  function docDefaults(dspec) {
@@ -2412,6 +2457,9 @@ function createApp(spec = {}) {
2412
2457
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
2413
2458
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2414
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(),
2415
2463
  _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
2416
2464
  _Block: Block,
2417
2465
  // for layouts in other modules (steps) that render blocks
@@ -2500,6 +2548,10 @@ var BASE_CSS = `
2500
2548
  flex-direction: column;
2501
2549
  gap: 0.8rem;
2502
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);
2503
2555
  }
2504
2556
  .caf-block-title { margin: 0; font-size: 0.78rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--caf-muted); }
2505
2557
  .caf-field { display: flex; flex-direction: column; gap: 0.3rem; }
@@ -2511,6 +2563,11 @@ var BASE_CSS = `
2511
2563
  border-radius: var(--caf-control-radius); border: 1px solid var(--caf-border);
2512
2564
  background: var(--caf-sunken); color: var(--caf-text); width: 100%;
2513
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); }
2514
2571
  .caf-field input:focus-visible, .caf-field select:focus-visible, .caf-field textarea:focus-visible,
2515
2572
  .caf-row:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
2516
2573
  .caf-field input[aria-invalid="true"] { border-color: var(--caf-danger); }
@@ -2590,7 +2647,8 @@ var BASE_CSS = `
2590
2647
  .caf-steps-fill { transition: width 0.25s ease; }
2591
2648
  .caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
2592
2649
  .caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
2593
- .caf-chat-log { min-height: 320px; max-height: 62vh; overflow-y: auto; gap: 0.55rem; }
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; }
2594
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; }
2595
2653
  .caf-msg-user { align-self: flex-end; background: var(--caf-accent); color: var(--caf-accentText); border-bottom-right-radius: 4px; }
2596
2654
  .caf-msg-assistant { align-self: flex-start; background: var(--caf-sunken); border-bottom-left-radius: 4px; }
@@ -2598,9 +2656,12 @@ var BASE_CSS = `
2598
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; }
2599
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); }
2600
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). */
2601
2662
  .caf-chat-input {
2602
- display: flex; gap: 0.6rem; margin-top: 1rem; padding: 0.6rem;
2603
- background: var(--caf-surface); border: 1px solid var(--caf-border); border-radius: var(--caf-radius);
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;
2604
2665
  }
2605
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); }
2606
2667
  .caf-chat-input input:focus-visible { outline: 2px solid var(--caf-accent); outline-offset: 1px; }
@@ -2614,6 +2675,14 @@ var BASE_CSS = `
2614
2675
  .caf-computed { border-top: 1px solid var(--caf-border); padding-top: 0.7rem; }
2615
2676
  .caf-records-blocks { margin-top: 1rem; }
2616
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; }
2617
2686
  .caf-row-wrap .caf-row { flex: 1; min-width: 0; }
2618
2687
  .caf-row-actions { display: flex; gap: 0.35rem; flex: none; padding-right: 0.3rem; }
2619
2688
  /* min-width keeps row actions' left edges aligned across rows even when
@@ -2626,6 +2695,9 @@ var BASE_CSS = `
2626
2695
  .caf-item-remove { flex: none; margin-bottom: 0.15rem; }
2627
2696
  .caf-empty-sm { padding: 0.6rem; font-size: 0.82rem; }
2628
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; }
2629
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; }
2630
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; }
2631
2703
  .caf-md-h { display: block; margin-top: 0.3rem; }
@@ -2634,6 +2706,12 @@ var BASE_CSS = `
2634
2706
  .caf-workspace { max-width: 1200px; margin: 0 auto; display: flex; gap: 1rem; align-items: flex-start; }
2635
2707
  .caf-ws-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--caf-block-gap); }
2636
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
+ }
2637
2715
  .caf-ws-slot { min-width: 0; }
2638
2716
  .caf-chat-panel { display: flex; flex-direction: column; min-height: 0; }
2639
2717
  .caf-ws-side .caf-chat-log { max-height: calc(100vh - 220px); }
@@ -2681,6 +2759,14 @@ var BASE_CSS = `
2681
2759
  .caf-records-block { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
2682
2760
  .caf-doc-typemenu { display: flex; gap: 0.5rem; flex-wrap: wrap; }
2683
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
+ }
2684
2770
  .caf-header-text { min-width: 0; }
2685
2771
  .caf-logo { flex: none; width: 40px; height: 40px; border-radius: 9px; object-fit: contain; }
2686
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); }
@@ -2700,7 +2786,9 @@ var BASE_CSS = `
2700
2786
  .caf-image { margin: 0; }
2701
2787
  .caf-image img { width: 100%; border-radius: 7px; display: block; }
2702
2788
  .caf-image-svg svg { width: 100%; height: auto; display: block; }
2703
- .caf-footer { max-width: 760px; margin: 2rem auto 0; padding-top: 0.8rem; border-top: 1px solid var(--caf-border); color: var(--caf-muted); font-size: 0.78rem; text-align: center; }
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; }
2704
2792
  .caf-layout-workspace .caf-footer { max-width: 1200px; }
2705
2793
  .caf-avatar {
2706
2794
  flex: none; width: 30px; height: 30px; border-radius: 50%;