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 +52 -4
- package/dist/index.esm.js +74 -7
- package/dist/index.esm.js.map +2 -2
- package/dist/index.global.js +38 -9
- package/dist/index.global.js.map +3 -3
- package/llms.txt +8 -1
- package/package.json +1 -1
- package/src/app.js +87 -6
- package/src/blocks.js +4 -1
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`,
|
|
806
|
-
`back`) and lives inside the block error boundary — a
|
|
807
|
-
code shows a named error in its slot, never a blank
|
|
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
|
|
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,
|