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/README.md CHANGED
@@ -422,6 +422,30 @@ The active tab and each accordion's open/closed state are framework-owned
422
422
  view state — they survive reload, per viewer. Tabs cannot nest inside tabs;
423
423
  `when` must be a function; both throw otherwise.
424
424
 
425
+ **When tabs ARE the app** — one tabs block as the whole UI, 4-5 sections
426
+ the user hops between on a phone — declare bottom navigation:
427
+
428
+ ```js
429
+ ArtifactKit.createApp({
430
+ title: "Mi Gym",
431
+ mobile: { nav: "bottom" },
432
+ blocks: [
433
+ { tabs: [
434
+ { label: "Hoy", icon: "💪", blocks: [...] },
435
+ { label: "Progreso", icon: "📈", blocks: [...] },
436
+ // ...up to ~5 sections
437
+ ], wide: true },
438
+ ],
439
+ })
440
+ ```
441
+
442
+ On a phone the first top-level tabs block's bar pins to the bottom of the
443
+ screen (icons above labels, every section visible and thumb-reachable);
444
+ on desktop nothing changes. Measured reason to bother: a top tab bar with
445
+ 5 sections overflows sideways at phone width and silently hides the last
446
+ sections behind a scroll nobody sees. Declaring `mobile: { nav: "bottom" }`
447
+ without a top-level tabs block throws.
448
+
425
449
  ### The `machine` — declared phases instead of hand-rolled state logic
426
450
 
427
451
  If your app has **phases** — a game (choosing → comparing → won), a turn
@@ -832,7 +856,8 @@ Everything an app can declare, in one place.
832
856
 
833
857
  **Top-level keys**: `title`, `subtitle`, `theme: { seed }`, `layout`, `blocks`,
834
858
  `records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `documents`,
835
- `data`, `shared`, `libs`. Anything else throws.
859
+ `data`, `shared`, `libs`, `machine`, `brand`, `mobile: { nav: "bottom" }`.
860
+ Anything else throws.
836
861
 
837
862
  **Layouts** (`layout:`): `panel` (default — grid of blocks), `records` (CRUD
838
863
  list), `steps` (wizard), `chat` (conversation with Claude), `workspace`
package/dist/index.esm.js CHANGED
@@ -1438,7 +1438,8 @@ var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "colla
1438
1438
  var BRAND_KEYS = ["logo", "footer"];
1439
1439
  var BANNER_KEYS = ["image", "title", "subtitle"];
1440
1440
  var IMAGE_KEYS = ["src", "caption", "fit", "height"];
1441
- var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand"];
1441
+ var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
1442
+ var MOBILE_KEYS = ["nav"];
1442
1443
  var MACHINE_KEYS = ["key", "initial", "states", "events"];
1443
1444
  var MACHINE_STATE_KEYS = ["after"];
1444
1445
  var MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
@@ -1641,6 +1642,14 @@ function checkMachine(m) {
1641
1642
  }
1642
1643
  }
1643
1644
  }
1645
+ function findNavTabs(spec) {
1646
+ for (const list of [spec.blocks, spec.main]) {
1647
+ if (!Array.isArray(list)) continue;
1648
+ const found = list.find((b) => b && Array.isArray(b.tabs));
1649
+ if (found) return found;
1650
+ }
1651
+ return null;
1652
+ }
1644
1653
  function validate(spec) {
1645
1654
  const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
1646
1655
  if (unknownTop.length) {
@@ -1665,6 +1674,22 @@ function validate(spec) {
1665
1674
  throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
1666
1675
  }
1667
1676
  }
1677
+ if (spec.mobile !== void 0) {
1678
+ const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
1679
+ if (unknownM.length) {
1680
+ throw new Error(
1681
+ `claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
1682
+ );
1683
+ }
1684
+ if (spec.mobile.nav !== void 0 && spec.mobile.nav !== "bottom") {
1685
+ throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
1686
+ }
1687
+ if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
1688
+ throw new Error(
1689
+ 'claude-artifact-framework: mobile.nav: "bottom" moves a top-level `tabs` block\'s bar to the bottom of the phone screen \u2014 this spec has no top-level tabs block (in `blocks` or `main`) to move.'
1690
+ );
1691
+ }
1692
+ }
1668
1693
  const layout = spec.layout || "panel";
1669
1694
  if (!LAYOUTS.includes(layout)) {
1670
1695
  throw new Error(
@@ -1946,17 +1971,18 @@ function boundary(React2) {
1946
1971
  }
1947
1972
  return BlockBoundary;
1948
1973
  }
1949
- function TabsBlock({ spec, ctx }) {
1974
+ function TabsBlock({ spec, ctx, bottomNav }) {
1950
1975
  const tabs = spec.tabs;
1951
1976
  const key = tabs.map((t) => t.label).join("|");
1952
1977
  const stored = ctx.view.tabs ? ctx.view.tabs[key] : void 0;
1953
1978
  const active = Math.min(stored === void 0 ? 0 : stored, tabs.length - 1);
1979
+ const bottom = !!bottomNav && ctx.viewport === "mobile";
1954
1980
  return createElement(
1955
1981
  "div",
1956
1982
  { className: "caf-tabs" },
1957
1983
  createElement(
1958
1984
  "div",
1959
- { className: "caf-tabbar", role: "tablist" },
1985
+ { className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
1960
1986
  tabs.map(
1961
1987
  (t, i) => createElement(
1962
1988
  "button",
@@ -1968,7 +1994,7 @@ function TabsBlock({ spec, ctx }) {
1968
1994
  className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
1969
1995
  onClick: () => ctx.setTab(key, i)
1970
1996
  },
1971
- t.icon ? `${t.icon} ${t.label}` : t.label
1997
+ bottom && t.icon ? [createElement("span", { key: "i", className: "caf-tab-ico", "aria-hidden": true }, t.icon), createElement("span", { key: "l", className: "caf-tab-lbl" }, t.label)] : t.icon ? `${t.icon} ${t.label}` : t.label
1972
1998
  )
1973
1999
  )
1974
2000
  ),
@@ -1984,13 +2010,14 @@ var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
1984
2010
  function Block({ block, ctx }) {
1985
2011
  if (typeof block.when === "function" && !block.when(ctx.data)) return null;
1986
2012
  if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
2013
+ const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
1987
2014
  if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
1988
2015
  const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
1989
2016
  const Component2 = ALL_BLOCKS[name];
1990
2017
  const inner = createElement(
1991
2018
  boundary(ctx.React),
1992
2019
  null,
1993
- createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx })
2020
+ createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx, bottomNav })
1994
2021
  );
1995
2022
  if (!block.collapsible) return inner;
1996
2023
  const key = typeof block.title === "string" && block.title ? block.title : name;
@@ -2376,6 +2403,7 @@ function createApp(spec = {}) {
2376
2403
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
2377
2404
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2378
2405
  viewport: narrow ? "mobile" : "desktop",
2406
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
2379
2407
  machine: spec.machine || null,
2380
2408
  send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
2381
2409
  throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
@@ -2392,7 +2420,7 @@ function createApp(spec = {}) {
2392
2420
  "div",
2393
2421
  // The layout class lets shared CSS follow the active container width —
2394
2422
  // e.g. the header must share the workspace's wider axis, not panel's.
2395
- { className: `caf-root caf-layout-${layout}` },
2423
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
2396
2424
  createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
2397
2425
  createElement(
2398
2426
  "header",
@@ -2662,6 +2690,27 @@ var BASE_CSS = `
2662
2690
  /* Never-clip guarantee: content inside a custom block that is wider than the
2663
2691
  phone (column grids, week views) must scroll, not disappear off-screen. */
2664
2692
  .caf-custom { overflow-x: auto; }
2693
+ /* mobile.nav: "bottom" \u2014 the app-shell tab bar pinned to the thumb zone.
2694
+ Tabs share the width evenly so every section stays visible; the root
2695
+ reserves space so the bar never covers the end of the content. */
2696
+ .caf-has-bottomnav { padding-bottom: 76px; }
2697
+ .caf-tabbar-bottom {
2698
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
2699
+ gap: 0; margin: 0; overflow-x: visible;
2700
+ background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
2701
+ padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
2702
+ box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
2703
+ }
2704
+ .caf-tabbar-bottom .caf-tab {
2705
+ flex: 1 1 0; min-width: 0; min-height: 48px;
2706
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
2707
+ gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
2708
+ font-size: 0.66rem; border-bottom: none;
2709
+ overflow: hidden; text-overflow: ellipsis;
2710
+ }
2711
+ .caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
2712
+ .caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2713
+ .caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
2665
2714
  /* Touch devices get tappable framework controls regardless of the spec. */
2666
2715
  @media (pointer: coarse) {
2667
2716
  .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }