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/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
@@ -801,10 +825,30 @@ custom: (ctx) => React.createElement("div", {
801
825
  `d.preview` can kill persistence for the entire app. Big things (data
802
826
  URLs, file contents, parse results) live in `React.useState` or come from
803
827
  `ctx.files`; `data` holds what is small and serializable.
828
+ - **Column grids ask `ctx.viewport`.** Half of artifact viewers are on a
829
+ phone. `ctx.viewport` is `"mobile"` (< 720px) or `"desktop"`, and the
830
+ block re-renders when it changes. A layout that lays columns side by side
831
+ (kanban boards, week calendars, dashboards of mini-cards) must stack or
832
+ reduce them on `"mobile"` — 3 columns squeezed into 390px are ~90px each
833
+ and unreadable:
834
+
835
+ ```js
836
+ custom: (ctx) => {
837
+ const cols = ctx.viewport === "mobile" ? "1fr" : "repeat(3, 1fr)";
838
+ return React.createElement("div",
839
+ { style: { display: "grid", gridTemplateColumns: cols, gap: "0.6rem" } },
840
+ /* ...columnas... */);
841
+ }
842
+ ```
843
+
844
+ As a safety net the framework never clips: custom content wider than the
845
+ screen scrolls horizontally instead of disappearing. But scrolling a
846
+ squeezed board is the fallback, not the design — branch on `ctx.viewport`.
804
847
  - Custom re-renders whenever `data` or `view` change; it receives the full
805
- `ctx` (`data`, `update`, `view`, `files`, `viewerId`, `colors`, `go`,
806
- `back`) and lives inside the block error boundary — a bug in your custom
807
- code shows a named error in its slot, never a blank app.
848
+ `ctx` (`data`, `update`, `view`, `files`, `viewerId`, `colors`,
849
+ `viewport`, `go`, `back`) and lives inside the block error boundary — a
850
+ bug in your custom code shows a named error in its slot, never a blank
851
+ app.
808
852
 
809
853
  ## API reference
810
854
 
@@ -812,7 +856,8 @@ Everything an app can declare, in one place.
812
856
 
813
857
  **Top-level keys**: `title`, `subtitle`, `theme: { seed }`, `layout`, `blocks`,
814
858
  `records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `documents`,
815
- `data`, `shared`, `libs`. Anything else throws.
859
+ `data`, `shared`, `libs`, `machine`, `brand`, `mobile: { nav: "bottom" }`.
860
+ Anything else throws.
816
861
 
817
862
  **Layouts** (`layout:`): `panel` (default — grid of blocks), `records` (CRUD
818
863
  list), `steps` (wizard), `chat` (conversation with Claude), `workspace`
@@ -963,3 +1008,6 @@ The rules that decide whether a generated app works, all in one place
963
1008
  13. **Brand is zero-asset-first** — monogram/gradient/emoji defaults before
964
1009
  URLs; inline `<svg>` over remote images; one icon style per app
965
1010
  (emoji-first); `avatar: true` instead of hand-built initials circles.
1011
+ 14. **Column grids in `custom` branch on `ctx.viewport`** — stack kanban
1012
+ columns / week grids when it says `"mobile"`; half the viewers are on
1013
+ a phone.
package/dist/index.esm.js CHANGED
@@ -1023,7 +1023,7 @@ function ActionsBlock({ spec, ctx }) {
1023
1023
  }
1024
1024
  function CustomBlock({ spec, ctx }) {
1025
1025
  const rendered = spec.custom(ctx);
1026
- const cls = spec.fill ? "caf-block caf-block-fill" : "caf-block";
1026
+ const cls = spec.fill ? "caf-block caf-block-fill caf-custom" : "caf-block caf-custom";
1027
1027
  if (typeof rendered === "string") {
1028
1028
  return createElement("div", { className: cls, dangerouslySetInnerHTML: { __html: rendered } });
1029
1029
  }
@@ -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;
@@ -2321,6 +2348,15 @@ function createApp(spec = {}) {
2321
2348
  );
2322
2349
  }
2323
2350
  const [, force] = useState(0);
2351
+ const [narrow, setNarrow] = useState(
2352
+ () => typeof window !== "undefined" && window.matchMedia("(max-width: 719px)").matches
2353
+ );
2354
+ useEffect(() => {
2355
+ const mq = window.matchMedia("(max-width: 719px)");
2356
+ const onChange = (e) => setNarrow(e.matches);
2357
+ mq.addEventListener("change", onChange);
2358
+ return () => mq.removeEventListener("change", onChange);
2359
+ }, []);
2324
2360
  const [libsState, setLibsState] = useState(spec.libs && spec.libs.length ? "loading" : "ready");
2325
2361
  const [libsError, setLibsError] = useState(null);
2326
2362
  useEffect(() => {
@@ -2366,6 +2402,8 @@ function createApp(spec = {}) {
2366
2402
  setTab: (key, i) => state.setView({ tabs: { ...state.getView().tabs, [key]: i } }),
2367
2403
  toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
2368
2404
  colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
2405
+ viewport: narrow ? "mobile" : "desktop",
2406
+ _navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
2369
2407
  machine: spec.machine || null,
2370
2408
  send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
2371
2409
  throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
@@ -2382,7 +2420,7 @@ function createApp(spec = {}) {
2382
2420
  "div",
2383
2421
  // The layout class lets shared CSS follow the active container width —
2384
2422
  // e.g. the header must share the workspace's wider axis, not panel's.
2385
- { className: `caf-root caf-layout-${layout}` },
2423
+ { className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
2386
2424
  createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
2387
2425
  createElement(
2388
2426
  "header",
@@ -2649,6 +2687,35 @@ var BASE_CSS = `
2649
2687
  .caf-block-error { border-color: var(--caf-danger); gap: 0.4rem; }
2650
2688
  .caf-block-error strong { font-size: 0.9rem; color: var(--caf-danger); }
2651
2689
  .caf-block-error code { font-size: 0.78rem; color: var(--caf-muted); word-break: break-word; }
2690
+ /* Never-clip guarantee: content inside a custom block that is wider than the
2691
+ phone (column grids, week views) must scroll, not disappear off-screen. */
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; }
2714
+ /* Touch devices get tappable framework controls regardless of the spec. */
2715
+ @media (pointer: coarse) {
2716
+ .caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
2717
+ .caf-btn-sm, .caf-doc-close, .caf-item-remove { min-height: 40px; }
2718
+ }
2652
2719
  `;
2653
2720
  export {
2654
2721
  contrast,