claude-artifact-framework 0.16.0 → 0.18.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 +48 -9
- package/dist/index.esm.js +109 -21
- package/dist/index.esm.js.map +3 -3
- package/dist/index.global.js +34 -12
- package/dist/index.global.js.map +3 -3
- package/llms.txt +9 -4
- package/package.json +1 -1
- package/src/app.js +88 -9
- package/src/records.js +23 -2
- package/src/steps.js +18 -10
package/README.md
CHANGED
|
@@ -168,6 +168,18 @@ ArtifactKit.createApp({
|
|
|
168
168
|
Field keys must be unique across all steps (they share one data pool); a
|
|
169
169
|
duplicate throws at creation, as does an unknown step key.
|
|
170
170
|
|
|
171
|
+
A result step can end on more than stats: give it `blocks` and any block
|
|
172
|
+
set (a chart of the answers, explanatory text, a list) renders under the
|
|
173
|
+
result grid:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
{ title: "Resultado",
|
|
177
|
+
result: (d) => [{ label: "Libre por mes", value: libre(d), format: "money", big: true }],
|
|
178
|
+
blocks: [{ title: "Distribución", chart: (d) => ({ type: "donut", format: "money", items: porRubro(d) }) }] }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`blocks` on a non-result step throws.
|
|
182
|
+
|
|
171
183
|
### The `chart` block — bar, line, donut
|
|
172
184
|
|
|
173
185
|
Same vocabulary as `output` (items of `{ label, value }` plus an optional
|
|
@@ -343,12 +355,13 @@ blocks: [
|
|
|
343
355
|
```
|
|
344
356
|
|
|
345
357
|
Action keys: `label`, `run`, optional `when` (hides the button when false)
|
|
346
|
-
and `kind` (`"primary"` / `"danger"`). Row actions
|
|
347
|
-
`(record, data, viewerId)
|
|
348
|
-
early is fine (`run: (r) => ...`),
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
358
|
+
and `kind` (`"primary"` / `"danger"`). Row actions — `when` and `run`
|
|
359
|
+
alike — receive `(record, data, viewerId)`; block actions
|
|
360
|
+
`(data, viewerId)`. Stopping early is fine (`run: (r) => ...`), and the
|
|
361
|
+
framework matches the viewer id **by name**: a second parameter literally
|
|
362
|
+
named `viewerId` receives the viewer id even though `data` was skipped —
|
|
363
|
+
`run: (r, viewerId) => ...` works as written. Still, prefer the full
|
|
364
|
+
order: `run: (r, d, viewerId)`. Changes persist like any other update.
|
|
352
365
|
Give the constructive action `kind: "primary"` — a "Vote"/"Add"/"Done" left
|
|
353
366
|
as the neutral default reads weaker than a `"danger"` next to it. And a
|
|
354
367
|
smell to avoid: **a number that changes by pressing is an `action`, not an
|
|
@@ -422,6 +435,30 @@ The active tab and each accordion's open/closed state are framework-owned
|
|
|
422
435
|
view state — they survive reload, per viewer. Tabs cannot nest inside tabs;
|
|
423
436
|
`when` must be a function; both throw otherwise.
|
|
424
437
|
|
|
438
|
+
**When tabs ARE the app** — one tabs block as the whole UI, 4-5 sections
|
|
439
|
+
the user hops between on a phone — declare bottom navigation:
|
|
440
|
+
|
|
441
|
+
```js
|
|
442
|
+
ArtifactKit.createApp({
|
|
443
|
+
title: "Mi Gym",
|
|
444
|
+
mobile: { nav: "bottom" },
|
|
445
|
+
blocks: [
|
|
446
|
+
{ tabs: [
|
|
447
|
+
{ label: "Hoy", icon: "💪", blocks: [...] },
|
|
448
|
+
{ label: "Progreso", icon: "📈", blocks: [...] },
|
|
449
|
+
// ...up to ~5 sections
|
|
450
|
+
], wide: true },
|
|
451
|
+
],
|
|
452
|
+
})
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
On a phone the first top-level tabs block's bar pins to the bottom of the
|
|
456
|
+
screen (icons above labels, every section visible and thumb-reachable);
|
|
457
|
+
on desktop nothing changes. Measured reason to bother: a top tab bar with
|
|
458
|
+
5 sections overflows sideways at phone width and silently hides the last
|
|
459
|
+
sections behind a scroll nobody sees. Declaring `mobile: { nav: "bottom" }`
|
|
460
|
+
without a top-level tabs block throws.
|
|
461
|
+
|
|
425
462
|
### The `machine` — declared phases instead of hand-rolled state logic
|
|
426
463
|
|
|
427
464
|
If your app has **phases** — a game (choosing → comparing → won), a turn
|
|
@@ -832,7 +869,8 @@ Everything an app can declare, in one place.
|
|
|
832
869
|
|
|
833
870
|
**Top-level keys**: `title`, `subtitle`, `theme: { seed }`, `layout`, `blocks`,
|
|
834
871
|
`records`, `steps`, `chat`, `main`, `sidebar` (workspace panes), `documents`,
|
|
835
|
-
`data`, `shared`, `libs
|
|
872
|
+
`data`, `shared`, `libs`, `machine`, `brand`, `mobile: { nav: "bottom" }`.
|
|
873
|
+
Anything else throws.
|
|
836
874
|
|
|
837
875
|
**Layouts** (`layout:`): `panel` (default — grid of blocks), `records` (CRUD
|
|
838
876
|
list), `steps` (wizard), `chat` (conversation with Claude), `workspace`
|
|
@@ -968,8 +1006,9 @@ The rules that decide whether a generated app works, all in one place
|
|
|
968
1006
|
or `ctx.files`.
|
|
969
1007
|
5. **Phases belong to the `machine`** — never track game/process phases
|
|
970
1008
|
with hand-rolled flags and `setTimeout`; `ctx.send` is the only door.
|
|
971
|
-
6. **
|
|
972
|
-
|
|
1009
|
+
6. **Row callbacks are `(record, data, viewerId)`** — `when` and `run`
|
|
1010
|
+
alike. Prefer the full order; a second parameter literally named
|
|
1011
|
+
`viewerId` is matched by name and still gets the viewer id.
|
|
973
1012
|
7. **One `big: true` per screen** — it marks THE result, not every stat.
|
|
974
1013
|
8. **The constructive action takes `kind: "primary"`**; a counter changed
|
|
975
1014
|
by pressing is an `action`, not an editable `number` field.
|
package/dist/index.esm.js
CHANGED
|
@@ -1070,6 +1070,19 @@ function recordDefaults(fields) {
|
|
|
1070
1070
|
function makeId() {
|
|
1071
1071
|
return "r" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
1072
1072
|
}
|
|
1073
|
+
var rowCallLegacy = /* @__PURE__ */ new WeakMap();
|
|
1074
|
+
function rowCall(fn, rec, data, viewerId) {
|
|
1075
|
+
let legacy = rowCallLegacy.get(fn);
|
|
1076
|
+
if (legacy === void 0) {
|
|
1077
|
+
const src = String(fn);
|
|
1078
|
+
const arrow = src.match(/^\s*(?:async\s+)?([\w$]+)\s*=>/);
|
|
1079
|
+
const parens = src.match(/^[^(]*\(([^)]*)\)/);
|
|
1080
|
+
const params = arrow ? [arrow[1]] : parens ? parens[1].split(",").map((s) => s.trim().split(/[=\s:]/)[0]) : [];
|
|
1081
|
+
legacy = /viewer|voter|usuario|user/i.test(params[1] || "");
|
|
1082
|
+
rowCallLegacy.set(fn, legacy);
|
|
1083
|
+
}
|
|
1084
|
+
return legacy ? fn(rec, viewerId) : fn(rec, data, viewerId);
|
|
1085
|
+
}
|
|
1073
1086
|
function Avatar({ title, ctx }) {
|
|
1074
1087
|
const initials = String(title).split(/\s+/).map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase() || "?";
|
|
1075
1088
|
let hash = 0;
|
|
@@ -1174,7 +1187,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
1174
1187
|
spec.actions ? createElement(
|
|
1175
1188
|
"div",
|
|
1176
1189
|
{ className: "caf-row-actions" },
|
|
1177
|
-
spec.actions.filter((a) => !a.when || a.when
|
|
1190
|
+
spec.actions.filter((a) => !a.when || rowCall(a.when, rec, ctx.data, ctx.viewerId)).map(
|
|
1178
1191
|
(a) => createElement(
|
|
1179
1192
|
"button",
|
|
1180
1193
|
{
|
|
@@ -1183,7 +1196,7 @@ function ListScreen({ spec, ctx }) {
|
|
|
1183
1196
|
className: a.kind === "danger" ? "caf-btn caf-btn-danger caf-btn-sm" : "caf-btn caf-btn-sm",
|
|
1184
1197
|
onClick: () => ctx.update((d) => {
|
|
1185
1198
|
const live = d.records.find((x) => x.id === rec.id);
|
|
1186
|
-
if (live) a.run
|
|
1199
|
+
if (live) rowCall(a.run, live, d, ctx.viewerId);
|
|
1187
1200
|
})
|
|
1188
1201
|
},
|
|
1189
1202
|
a.label
|
|
@@ -1354,23 +1367,31 @@ function stepErrors(step, data) {
|
|
|
1354
1367
|
}
|
|
1355
1368
|
function ResultScreen({ step, ctx }) {
|
|
1356
1369
|
const items = step.result(ctx.data) || [];
|
|
1370
|
+
const Block2 = ctx._Block;
|
|
1357
1371
|
return createElement(
|
|
1358
1372
|
"div",
|
|
1359
|
-
{ className: "caf-
|
|
1360
|
-
step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
|
|
1373
|
+
{ className: "caf-steps-result" },
|
|
1361
1374
|
createElement(
|
|
1362
1375
|
"div",
|
|
1363
|
-
{ className: "caf-output
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1376
|
+
{ className: "caf-block caf-output" },
|
|
1377
|
+
step.title ? createElement("h2", { className: "caf-block-title" }, step.title) : null,
|
|
1378
|
+
createElement(
|
|
1379
|
+
"div",
|
|
1380
|
+
{ className: "caf-output-grid" },
|
|
1381
|
+
items.map(
|
|
1382
|
+
(item, i) => createElement(
|
|
1383
|
+
"div",
|
|
1384
|
+
{ key: item.label ?? i, className: item.big ? "caf-stat caf-stat-big" : "caf-stat" },
|
|
1385
|
+
createElement("span", { className: "caf-stat-label" }, item.label),
|
|
1386
|
+
createElement("span", { className: "caf-stat-value" }, formatValue(item.value, item.format)),
|
|
1387
|
+
item.hint ? createElement("small", { className: "caf-hint" }, item.hint) : null
|
|
1388
|
+
)
|
|
1371
1389
|
)
|
|
1372
1390
|
)
|
|
1373
1391
|
),
|
|
1392
|
+
// A result can end on more than stats: any declared blocks (charts,
|
|
1393
|
+
// text, lists) render under the grid with full block services.
|
|
1394
|
+
(step.blocks || []).map((b, i) => createElement(Block2, { key: "rb" + i, block: b, ctx })),
|
|
1374
1395
|
createElement(
|
|
1375
1396
|
"div",
|
|
1376
1397
|
{ className: "caf-step-nav" },
|
|
@@ -1438,7 +1459,8 @@ var RESERVED = ["title", "empty", "wide", "onSelect", "subtitle", "when", "colla
|
|
|
1438
1459
|
var BRAND_KEYS = ["logo", "footer"];
|
|
1439
1460
|
var BANNER_KEYS = ["image", "title", "subtitle"];
|
|
1440
1461
|
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"];
|
|
1462
|
+
var TOP_KEYS = ["title", "subtitle", "theme", "layout", "blocks", "records", "steps", "chat", "data", "shared", "main", "sidebar", "libs", "documents", "machine", "brand", "mobile"];
|
|
1463
|
+
var MOBILE_KEYS = ["nav"];
|
|
1442
1464
|
var MACHINE_KEYS = ["key", "initial", "states", "events"];
|
|
1443
1465
|
var MACHINE_STATE_KEYS = ["after"];
|
|
1444
1466
|
var MACHINE_AFTER_KEYS = ["seconds", "then", "do"];
|
|
@@ -1641,6 +1663,14 @@ function checkMachine(m) {
|
|
|
1641
1663
|
}
|
|
1642
1664
|
}
|
|
1643
1665
|
}
|
|
1666
|
+
function findNavTabs(spec) {
|
|
1667
|
+
for (const list of [spec.blocks, spec.main]) {
|
|
1668
|
+
if (!Array.isArray(list)) continue;
|
|
1669
|
+
const found = list.find((b) => b && Array.isArray(b.tabs));
|
|
1670
|
+
if (found) return found;
|
|
1671
|
+
}
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1644
1674
|
function validate(spec) {
|
|
1645
1675
|
const unknownTop = Object.keys(spec).filter((k) => !TOP_KEYS.includes(k));
|
|
1646
1676
|
if (unknownTop.length) {
|
|
@@ -1665,6 +1695,22 @@ function validate(spec) {
|
|
|
1665
1695
|
throw new Error("claude-artifact-framework: `libs` must be an array of https:// script URLs.");
|
|
1666
1696
|
}
|
|
1667
1697
|
}
|
|
1698
|
+
if (spec.mobile !== void 0) {
|
|
1699
|
+
const unknownM = Object.keys(spec.mobile || {}).filter((k) => !MOBILE_KEYS.includes(k));
|
|
1700
|
+
if (unknownM.length) {
|
|
1701
|
+
throw new Error(
|
|
1702
|
+
`claude-artifact-framework: mobile has unknown keys (${unknownM.join(", ")}). Valid keys: ${MOBILE_KEYS.join(", ")}.`
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
if (spec.mobile.nav !== void 0 && spec.mobile.nav !== "bottom") {
|
|
1706
|
+
throw new Error('claude-artifact-framework: mobile.nav only accepts "bottom" (omit `mobile` for the default top tab bar).');
|
|
1707
|
+
}
|
|
1708
|
+
if (spec.mobile.nav === "bottom" && !findNavTabs(spec)) {
|
|
1709
|
+
throw new Error(
|
|
1710
|
+
'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.'
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1668
1714
|
const layout = spec.layout || "panel";
|
|
1669
1715
|
if (!LAYOUTS.includes(layout)) {
|
|
1670
1716
|
throw new Error(
|
|
@@ -1676,7 +1722,7 @@ function validate(spec) {
|
|
|
1676
1722
|
if (!Array.isArray(steps) || steps.length === 0) {
|
|
1677
1723
|
throw new Error('claude-artifact-framework: layout "steps" needs `steps: [...]` with at least one step.');
|
|
1678
1724
|
}
|
|
1679
|
-
const STEP_KEYS = ["title", "text", "fields", "result"];
|
|
1725
|
+
const STEP_KEYS = ["title", "text", "fields", "result", "blocks"];
|
|
1680
1726
|
const seen = /* @__PURE__ */ new Set();
|
|
1681
1727
|
steps.forEach((st, i) => {
|
|
1682
1728
|
if (!st || typeof st !== "object") {
|
|
@@ -1691,6 +1737,12 @@ function validate(spec) {
|
|
|
1691
1737
|
if (!st.fields && !st.text && !st.result) {
|
|
1692
1738
|
throw new Error(`claude-artifact-framework: steps[${i}] needs \`fields\`, \`text\`, or \`result\`.`);
|
|
1693
1739
|
}
|
|
1740
|
+
if (st.blocks !== void 0) {
|
|
1741
|
+
if (typeof st.result !== "function") {
|
|
1742
|
+
throw new Error(`claude-artifact-framework: steps[${i}].blocks only renders on a result step \u2014 add \`result\` or move the blocks to the step's fields.`);
|
|
1743
|
+
}
|
|
1744
|
+
checkBlocks(st.blocks);
|
|
1745
|
+
}
|
|
1694
1746
|
checkFields(st.fields, `steps[${i}]`);
|
|
1695
1747
|
for (const f of st.fields || []) {
|
|
1696
1748
|
if (seen.has(f.key)) {
|
|
@@ -1946,17 +1998,18 @@ function boundary(React2) {
|
|
|
1946
1998
|
}
|
|
1947
1999
|
return BlockBoundary;
|
|
1948
2000
|
}
|
|
1949
|
-
function TabsBlock({ spec, ctx }) {
|
|
2001
|
+
function TabsBlock({ spec, ctx, bottomNav }) {
|
|
1950
2002
|
const tabs = spec.tabs;
|
|
1951
2003
|
const key = tabs.map((t) => t.label).join("|");
|
|
1952
2004
|
const stored = ctx.view.tabs ? ctx.view.tabs[key] : void 0;
|
|
1953
2005
|
const active = Math.min(stored === void 0 ? 0 : stored, tabs.length - 1);
|
|
2006
|
+
const bottom = !!bottomNav && ctx.viewport === "mobile";
|
|
1954
2007
|
return createElement(
|
|
1955
2008
|
"div",
|
|
1956
2009
|
{ className: "caf-tabs" },
|
|
1957
2010
|
createElement(
|
|
1958
2011
|
"div",
|
|
1959
|
-
{ className: "caf-tabbar", role: "tablist" },
|
|
2012
|
+
{ className: bottom ? "caf-tabbar caf-tabbar-bottom" : "caf-tabbar", role: "tablist" },
|
|
1960
2013
|
tabs.map(
|
|
1961
2014
|
(t, i) => createElement(
|
|
1962
2015
|
"button",
|
|
@@ -1968,7 +2021,7 @@ function TabsBlock({ spec, ctx }) {
|
|
|
1968
2021
|
className: i === active ? "caf-tab caf-tab-active" : "caf-tab",
|
|
1969
2022
|
onClick: () => ctx.setTab(key, i)
|
|
1970
2023
|
},
|
|
1971
|
-
t.icon ? `${t.icon} ${t.label}` : t.label
|
|
2024
|
+
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
2025
|
)
|
|
1973
2026
|
)
|
|
1974
2027
|
),
|
|
@@ -1984,13 +2037,14 @@ var ALL_NAMES = [...BLOCK_NAMES, "tabs", "records"];
|
|
|
1984
2037
|
function Block({ block, ctx }) {
|
|
1985
2038
|
if (typeof block.when === "function" && !block.when(ctx.data)) return null;
|
|
1986
2039
|
if (block.state !== void 0 && ctx.machine && ctx.data[ctx.machine.key || "fase"] !== block.state) return null;
|
|
2040
|
+
const bottomNav = ctx._navTabs !== void 0 && ctx._navTabs === block;
|
|
1987
2041
|
if (block.icon && typeof block.title === "string") block = { ...block, title: `${block.icon} ${block.title}` };
|
|
1988
2042
|
const name = Object.keys(block).find((k) => ALL_NAMES.includes(k));
|
|
1989
2043
|
const Component2 = ALL_BLOCKS[name];
|
|
1990
2044
|
const inner = createElement(
|
|
1991
2045
|
boundary(ctx.React),
|
|
1992
2046
|
null,
|
|
1993
|
-
createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx })
|
|
2047
|
+
createElement(Component2, { spec: block.collapsible ? { ...block, title: void 0 } : block, ctx, bottomNav })
|
|
1994
2048
|
);
|
|
1995
2049
|
if (!block.collapsible) return inner;
|
|
1996
2050
|
const key = typeof block.title === "string" && block.title ? block.title : name;
|
|
@@ -2194,7 +2248,10 @@ function DocumentsLayout({ spec, ctx }) {
|
|
|
2194
2248
|
{ className: "caf-documents" },
|
|
2195
2249
|
createElement(DocTabbar, { docs, active, dspec, types, close, onAdd, label, ctx }),
|
|
2196
2250
|
typePicker,
|
|
2197
|
-
|
|
2251
|
+
// Keyed by document id: switching tabs REMOUNTS the pane, so DOM-held
|
|
2252
|
+
// state can't leak across documents. Measured: the native file input
|
|
2253
|
+
// kept showing the previous document's filename.
|
|
2254
|
+
createElement(Panel, { key: active.id, spec: { blocks: aspec.blocks }, ctx: scoped })
|
|
2198
2255
|
);
|
|
2199
2256
|
}
|
|
2200
2257
|
function DocTabbar({ docs, active, dspec, types, close, onAdd, label, ctx }) {
|
|
@@ -2376,6 +2433,9 @@ function createApp(spec = {}) {
|
|
|
2376
2433
|
toggleCollapsed: (key, val) => state.setView({ collapsed: { ...state.getView().collapsed, [key]: val } }),
|
|
2377
2434
|
colors: (n) => seriesColors(spec.theme && spec.theme.seed, n),
|
|
2378
2435
|
viewport: narrow ? "mobile" : "desktop",
|
|
2436
|
+
_navTabs: narrow && spec.mobile && spec.mobile.nav === "bottom" ? findNavTabs(spec) : null,
|
|
2437
|
+
_Block: Block,
|
|
2438
|
+
// for layouts in other modules (steps) that render blocks
|
|
2379
2439
|
machine: spec.machine || null,
|
|
2380
2440
|
send: spec.machine ? (eventName, payload) => runMachineEvent(spec.machine, state.update, eventName, payload) : () => {
|
|
2381
2441
|
throw new Error("claude-artifact-framework: ctx.send() needs a `machine` declared at the top level.");
|
|
@@ -2392,7 +2452,7 @@ function createApp(spec = {}) {
|
|
|
2392
2452
|
"div",
|
|
2393
2453
|
// The layout class lets shared CSS follow the active container width —
|
|
2394
2454
|
// e.g. the header must share the workspace's wider axis, not panel's.
|
|
2395
|
-
{ className: `caf-root caf-layout-${layout}` },
|
|
2455
|
+
{ className: `caf-root caf-layout-${layout}${ctx._navTabs ? " caf-has-bottomnav" : ""}` },
|
|
2396
2456
|
createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
2397
2457
|
createElement(
|
|
2398
2458
|
"header",
|
|
@@ -2410,7 +2470,13 @@ function createApp(spec = {}) {
|
|
|
2410
2470
|
{ className: "caf-block caf-block-error" },
|
|
2411
2471
|
createElement("strong", null, "A library failed to load"),
|
|
2412
2472
|
createElement("code", null, libsError)
|
|
2413
|
-
) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") :
|
|
2473
|
+
) : !state.isHydrated() || libsState === "loading" ? createElement("div", { className: "caf-block caf-loading" }, libsState === "loading" ? "Loading libraries\u2026" : "Loading\u2026") : (
|
|
2474
|
+
// The same containment blocks get, one level up: a crash inside a
|
|
2475
|
+
// layout (a throwing sort, a bad row callback) shows a named error
|
|
2476
|
+
// under the header instead of killing the whole app. Measured: an
|
|
2477
|
+
// unhandled row-action throw once blanked an entire blind app.
|
|
2478
|
+
createElement(boundary(ctx.React), null, createElement(Layout, { spec, ctx }))
|
|
2479
|
+
),
|
|
2414
2480
|
spec.brand && spec.brand.footer ? createElement("footer", { className: "caf-footer" }, spec.brand.footer) : null
|
|
2415
2481
|
);
|
|
2416
2482
|
};
|
|
@@ -2533,6 +2599,7 @@ var BASE_CSS = `
|
|
|
2533
2599
|
.caf-timer-controls { display: flex; gap: 0.6rem; }
|
|
2534
2600
|
.caf-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
2535
2601
|
.caf-steps-progress { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.2rem; }
|
|
2602
|
+
.caf-steps-result { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
|
|
2536
2603
|
.caf-steps-fill { transition: width 0.25s ease; }
|
|
2537
2604
|
.caf-step-text { margin: 0; color: var(--caf-muted); font-size: 0.92rem; line-height: 1.55; }
|
|
2538
2605
|
.caf-step-nav { display: flex; justify-content: space-between; gap: 0.6rem; margin-top: 0.4rem; }
|
|
@@ -2662,6 +2729,27 @@ var BASE_CSS = `
|
|
|
2662
2729
|
/* Never-clip guarantee: content inside a custom block that is wider than the
|
|
2663
2730
|
phone (column grids, week views) must scroll, not disappear off-screen. */
|
|
2664
2731
|
.caf-custom { overflow-x: auto; }
|
|
2732
|
+
/* mobile.nav: "bottom" \u2014 the app-shell tab bar pinned to the thumb zone.
|
|
2733
|
+
Tabs share the width evenly so every section stays visible; the root
|
|
2734
|
+
reserves space so the bar never covers the end of the content. */
|
|
2735
|
+
.caf-has-bottomnav { padding-bottom: 76px; }
|
|
2736
|
+
.caf-tabbar-bottom {
|
|
2737
|
+
position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
|
2738
|
+
gap: 0; margin: 0; overflow-x: visible;
|
|
2739
|
+
background: var(--caf-surface); border-top: 1px solid var(--caf-border); border-bottom: none;
|
|
2740
|
+
padding: 0.25rem 0.4rem calc(0.3rem + env(safe-area-inset-bottom, 0px));
|
|
2741
|
+
box-shadow: 0 -1px 6px rgba(0, 0, 0, 0.07);
|
|
2742
|
+
}
|
|
2743
|
+
.caf-tabbar-bottom .caf-tab {
|
|
2744
|
+
flex: 1 1 0; min-width: 0; min-height: 48px;
|
|
2745
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
2746
|
+
gap: 0.15rem; padding: 0.25rem 0.2rem; margin-bottom: 0;
|
|
2747
|
+
font-size: 0.66rem; border-bottom: none;
|
|
2748
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
2749
|
+
}
|
|
2750
|
+
.caf-tabbar-bottom .caf-tab-ico { font-size: 1.25rem; line-height: 1; }
|
|
2751
|
+
.caf-tabbar-bottom .caf-tab-lbl { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2752
|
+
.caf-tabbar-bottom .caf-tab-active, .caf-tabbar-bottom .caf-tab-active:hover { border-bottom-color: transparent; }
|
|
2665
2753
|
/* Touch devices get tappable framework controls regardless of the spec. */
|
|
2666
2754
|
@media (pointer: coarse) {
|
|
2667
2755
|
.caf-btn, .caf-tab, .caf-field input, .caf-field select { min-height: 44px; }
|