claude-artifact-framework 0.16.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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-artifact-framework",
3
- "version": "0.16.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
@@ -1093,6 +1132,7 @@ export function createApp(spec = {}) {
1093
1132
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
1094
1133
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
1095
1134
  viewport: narrow ? "mobile" : "desktop",
1135
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
1096
1136
  machine: spec.machine || null,
1097
1137
  send: spec.machine
1098
1138
  ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload)
@@ -1130,7 +1170,7 @@ export function createApp(spec = {}) {
1130
1170
  "div",
1131
1171
  // The layout class lets shared CSS follow the active container width —
1132
1172
  // e.g. the header must share the workspace's wider axis, not panel's.
1133
- { className: `caf-root caf-layout-${layout}` },
1173
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
1134
1174
  h("style", { dangerouslySetInnerHTML: { __html: css } }),
1135
1175
  h(
1136
1176
  "header",
@@ -1405,6 +1445,27 @@ const BASE_CSS = `
1405
1445
  /* Never-clip guarantee: content inside a custom block that is wider than the
1406
1446
  phone (column grids, week views) must scroll, not disappear off-screen. */
1407
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; }
1408
1469
  /* Touch devices get tappable framework controls regardless of the spec. */
1409
1470
  @media (pointer: coarse) {
1410
1471
  .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }