claude-artifact-framework 0.15.0 → 0.17.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/llms.txt CHANGED
@@ -14,7 +14,10 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
14
14
  - Top-level keys: title, subtitle, theme:{seed}, layout, blocks, records,
15
15
  steps, chat, main, sidebar, documents, data, shared, libs, machine,
16
16
  brand:{logo, footer} (logo: emoji | inline <svg> | https URL | omit for an
17
- automatic monogram)
17
+ automatic monogram), mobile:{nav:"bottom"} (phone: pins the first
18
+ top-level tabs block's bar to the bottom — declare it when one tabs
19
+ block with 4+ sections IS the app; a top bar overflows sideways at
20
+ phone width and hides the last sections)
18
21
  - Layouts: panel (default), records, steps, chat, workspace, documents
19
22
  - Block types: fields, output, text, list, chart, timer, actions, banner
20
23
  ({image?, title, subtitle} — no image paints a seed-derived gradient),
@@ -64,6 +67,10 @@ https://cdn.jsdelivr.net/npm/claude-artifact-framework/dist/index.global.js
64
67
  13. Brand is zero-asset-first: monogram/gradient/emoji defaults before URLs;
65
68
  inline <svg> over remote images; one icon style per app (emoji-first);
66
69
  avatar: true instead of hand-built initials circles.
70
+ 14. Column grids in custom (kanban, week views) branch on ctx.viewport
71
+ ("mobile" < 720px | "desktop", reactive) — stack columns on mobile; half
72
+ the viewers are on a phone. The framework never clips (wide custom
73
+ content scrolls), but scrolling is the fallback, not the design.
67
74
 
68
75
  ## Canonical example
69
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Utilities for building apps inside Claude Artifacts: platform storage, chat tool-calling loops, and other primitives verified against the real runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.esm.js",
package/src/app.js CHANGED
@@ -26,7 +26,8 @@ const IMAGE_KEYS = ["src", "caption", "fit", "height"];
26
26
  // Frontier models still invent identifiers a few percent of the time, so an
27
27
  // unknown name has to fail loudly and name the alternatives rather than
28
28
  // silently rendering nothing — a blank pane is indistinguishable from a bug.
29
- const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
29
+ const TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
30
+ const MOBILE_KEYS = ["nav"];
30
31
 
31
32
  const MACHINE_KEYS = ["key", "initial", "states", "events"];
32
33
  const MACHINE_STATE_KEYS = ["after"];
@@ -248,6 +249,18 @@ function checkMachine(m) {
248
249
  }
249
250
  }
250
251
 
252
+ // The tabs block mobile.nav moves: the first one at the top level of the
253
+ // panel's `blocks` or the workspace's `main`. Nested tabs never qualify —
254
+ // a bottom bar is app-shell navigation, not in-page structure.
255
+ function findNavTabs(spec) {
256
+ for (const list of [spec.blocks, spec.main]) {
257
+ if (!Array.isArray(list)) continue;
258
+ const found = list.find((b) => b && Array.isArray(b.tabs));
259
+ if (found) return found;
260
+ }
261
+ return null;
262
+ }
263
+
251
264
  function validate(spec) {
252
265
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
253
266
  if (unknownTop.length) {
@@ -272,6 +285,22 @@ function validate(spec) {
272
285
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
273
286
  }
274
287
  }
288
+ if (spec.mobile !== undefined) {
289
+ const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
290
+ if (unknownM.length) {
291
+ throw new Error(
292
+ `claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
293
+ );
294
+ }
295
+ if (spec.mobile.nav !== undefined && spec.mobile.nav !== "bottom") {
296
+ throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
297
+ }
298
+ if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
299
+ throw new Error(
300
+ 'claude-artifact-framework: mobile.nav: "bottom" moves a top-level `tabs` block\'s bar to the bottom of the phone screen — this spec has no top-level tabs block (in `blocks` or `main`) to move.'
301
+ );
302
+ }
303
+ }
275
304
  const layout = spec.layout || "panel";
276
305
  if (!LAYOUTS.includes(layout)) {
277
306
  throw new Error(
@@ -576,17 +605,21 @@ function boundary(React) {
576
605
 
577
606
  // Tabs are defined here (not blocks.js) because they render other blocks —
578
607
  // keeping the recursion in one file avoids an import cycle.
579
- function TabsBlock({ spec, ctx }) {
608
+ function TabsBlock({ spec, ctx, bottomNav }) {
580
609
  const tabs = spec.tabs;
581
610
  const key = tabs.map((t) => t.label).join("|");
582
611
  const stored = ctx.view.tabs ? ctx.view.tabs[key] : undefined;
583
612
  const active = Math.min(stored === undefined ? 0 : stored, tabs.length - 1);
613
+ // mobile.nav: "bottom" — on a phone, this block's bar becomes a fixed
614
+ // bottom nav: every section stays visible and inside the thumb zone
615
+ // instead of hiding behind the tab bar's sideways scroll.
616
+ const bottom = !!bottomNav && ctx.viewport === "mobile";
584
617
  return h(
585
618
  "div",
586
619
  { className: "caf-tabs" },
587
620
  h(
588
621
  "div",
589
- { className: "caf-tabbar", role: "tablist" },
622
+ { className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
590
623
  tabs.map((t, i) =>
591
624
  h(
592
625
  "button",
@@ -598,7 +631,11 @@ function TabsBlock({ spec, ctx }) {
598
631
  className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
599
632
  onClick: () => ctx.setTab(key, i),
600
633
  },
601
- t.icon ? `${t.icon} ${t.label}` : t.label
634
+ bottom && t.icon
635
+ ? [h("span", { key: "i", className: "caf-tab-ico", "aria-hidden": true }, t.icon), h("span", { key: "l", className: "caf-tab-lbl" }, t.label)]
636
+ : t.icon
637
+ ? `${t.icon} ${t.label}`
638
+ : t.label
602
639
  )
603
640
  )
604
641
  ),
@@ -618,6 +655,8 @@ function Block({ block, ctx }) {
618
655
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
619
656
  // `state: "x"` — machine sugar: the block exists only in that phase.
620
657
  if (block.state !== undefined && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
658
+ // Identity against the original spec object — before any sugar copies it.
659
+ const bottomNav = ctx._navTabs !== undefined && ctx._navTabs === block;
621
660
  // `icon` is sugar: it prefixes the title everywhere titles render.
622
661
  if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
623
662
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
@@ -625,7 +664,7 @@ function Block({ block, ctx }) {
625
664
  const inner = h(
626
665
  boundary(ctx.React),
627
666
  null,
628
- h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx })
667
+ h(Component, { spec: block.collapsible ? { ...block, title: undefined } : block, ctx, bottomNav })
629
668
  );
630
669
  if (!block.collapsible) return inner;
631
670
  // Collapsed state is framework-owned view state, keyed by the block's title
@@ -1029,6 +1068,17 @@ export function createApp(spec = {}) {
1029
1068
  );
1030
1069
  }
1031
1070
  const [, force] = useState(0);
1071
+ // ctx.viewport shares the 720px breakpoint the block grid already uses,
1072
+ // so a custom block that branches on it flips in sync with the layout.
1073
+ const [narrow, setNarrow] = useState(
1074
+ () => typeof window !== "undefined" && window.matchMedia("(max-width: 719px)").matches
1075
+ );
1076
+ useEffect(() => {
1077
+ const mq = window.matchMedia("(max-width: 719px)");
1078
+ const onChange = (e) => setNarrow(e.matches);
1079
+ mq.addEventListener("change", onChange);
1080
+ return () => mq.removeEventListener("change", onChange);
1081
+ }, []);
1032
1082
  const [libsState, setLibsState] = useState(spec.libs && spec.libs.length ? "loading" : "ready");
1033
1083
  const [libsError, setLibsError] = useState(null);
1034
1084
  useEffect(() => {
@@ -1081,6 +1131,8 @@ export function createApp(spec = {}) {
1081
1131
  setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
1082
1132
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
1083
1133
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1134
+ viewport: narrow ? "mobile" : "desktop",
1135
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
1084
1136
  machine: spec.machine || null,
1085
1137
  send: spec.machine
1086
1138
  ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
@@ -1118,7 +1170,7 @@ export function createApp(spec = {}) {
1118
1170
  "div",
1119
1171
  // The layout class lets shared CSS follow the active container width —
1120
1172
  // e.g. the header must share the workspace's wider axis, not panel's.
1121
- { className: `caf-root caf-layout-${layout}` },
1173
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
1122
1174
  h("style", { dangerouslySetInnerHTML: { __html: css } }),
1123
1175
  h(
1124
1176
  "header",
@@ -1390,4 +1442,33 @@ const BASE_CSS = `
1390
1442
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
1391
1443
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
1392
1444
  .caf-block-error code { font-size: 0.78rem; color: var(--caf-muted); word-break: break-word; }
1445
+ /* Never-clip guarantee: content inside a custom block that is wider than the
1446
+ phone (column grids, week views) must scroll, not disappear off-screen. */
1447
+ .caf-custom { overflow-x: auto; }
1448
+ /* mobile.nav: "bottom" — the app-shell tab bar pinned to the thumb zone.
1449
+ Tabs share the width evenly so every section stays visible; the root
1450
+ reserves space so the bar never covers the end of the content. */
1451
+ .caf-has-bottomnav { padding-bottom: 76px; }
1452
+ .caf-tabbar-bottom {
1453
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
1454
+ gap: 0; margin: 0; overflow-x: visible;
1455
+ background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
1456
+ padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
1457
+ box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
1458
+ }
1459
+ .caf-tabbar-bottom .caf-tab {
1460
+ flex: 1 1 0; min-width: 0; min-height: 48px;
1461
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1462
+ gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
1463
+ font-size: 0.66rem; border-bottom: none;
1464
+ overflow: hidden; text-overflow: ellipsis;
1465
+ }
1466
+ .caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
1467
+ .caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1468
+ .caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
1469
+ /* Touch devices get tappable framework controls regardless of the spec. */
1470
+ @media (pointer: coarse) {
1471
+ .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
1472
+ .caf-btn-sm, .caf-doc-close, .caf-item-remove { min-height: 40px; }
1473
+ }
1393
1474
  `;
package/src/blocks.js CHANGED
@@ -485,7 +485,10 @@ export function CustomBlock({ spec, ctx }) {
485
485
  const rendered = spec.custom(ctx);
486
486
  // `fill: true` frees the block from the card chrome so it can own a large
487
487
  // scrollable surface (a viewer, a canvas) — the workspace main-area case.
488
- const cls = spec.fill ? "caf-block caf-block-fill" : "caf-block";
488
+ // caf-custom guarantees a custom block can never CLIP its content on a
489
+ // narrow screen: wide hand-built grids become scrollable, not amputated
490
+ // (measured: a week grid cut off its last column at 390px with no way in).
491
+ const cls = spec.fill ? "caf-block caf-block-fill caf-custom" : "caf-block caf-custom";
489
492
  if (typeof rendered === "string") {
490
493
  return h("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
491
494
  }