From 82fa917f76ca61e696bc907c27c9a4533eb57116 Mon Sep 17 00:00:00 2001 From: Madraka Date: Tue, 20 Oct 2026 15:34:17 +0300 Subject: [PATCH] feat(surfaces): computed comparisons, Toggle label, Table hideOnPhone, minDigits Four surface gaps found while building interactive articles on the newsroom (RFC-0022, after 22e): - a `visible` condition or `if`'s `cond` takes a call as an operand, within the same nesting and call limits; `evaluateCondition` takes an optional ResolveContext - a labelled Toggle renders a fieldset with a visible legend - Table columns take `hideOnPhone` (never the first column); the blog theme and the preview hide them in the phone card - formatNumber takes `minDigits` for trailing zeros (9.60) The runtime is now surface.11.js; surface.10.js joins the aliases. Ported from an uncommitted worktree based on the pre-public history. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka --- .changeset/surface-gaps-newsroom.md | 11 ++ ...ls-computed-values-inputs-charts-tables.md | 12 +- packages/astro-renderer/src/preview.ts | 2 +- packages/astro-renderer/src/surface-client.ts | 13 ++- .../src/surfaces/primitives/data.ts | 16 +-- .../src/surfaces/primitives/inputs.ts | 4 +- packages/astro-renderer/src/surfaces/tree.ts | 2 +- .../test/surface-runtime.test.ts | 36 +++++- packages/astro-renderer/test/surfaces.test.ts | 17 +++ packages/astro-renderer/test/table.test.ts | 14 +++ .../src/surfaces/bindings/functions.ts | 7 +- .../core-ts/src/surfaces/bindings/resolve.ts | 26 +++-- .../core-ts/src/surfaces/bindings/values.ts | 28 ++++- .../src/surfaces/catalog/primitives-inputs.ts | 10 +- packages/core-ts/src/surfaces/prompt.ts | 8 +- packages/core-ts/test/surfaces.test.ts | 104 ++++++++++++++++++ .../site-design/references/page-recipes.md | 4 +- .../src/styles/features/live-surfaces.css | 3 + 18 files changed, 272 insertions(+), 45 deletions(-) create mode 100644 .changeset/surface-gaps-newsroom.md diff --git a/.changeset/surface-gaps-newsroom.md b/.changeset/surface-gaps-newsroom.md new file mode 100644 index 00000000..07ad30cb --- /dev/null +++ b/.changeset/surface-gaps-newsroom.md @@ -0,0 +1,11 @@ +--- +"@cloudhal/core-ts": minor +"@cloudhal/astro-renderer": minor +--- + +Four surface gaps found while building interactive articles (RFC-0022, after 22e). +- **Computed comparisons:** a `visible` condition or `if`'s `cond` takes a call as an operand, e.g. `{"lt": [{"call": "calculate", …}, {"call": "calculate", …}]}`, within the same nesting and call limits. `evaluateCondition` takes an optional `ResolveContext`. +- **`Toggle` label:** a labelled Toggle shows its label as a ``, as RadioGroup does. +- **`Table` columns:** `hideOnPhone: true` keeps a column (never the first) out of the phone card. +- **`formatNumber`:** `minDigits` keeps trailing zeros (9.60). +- **Runtime:** it is now `surface.11.js`; the earlier names keep working. diff --git a/docs/rfcs/0022-surface-tools-computed-values-inputs-charts-tables.md b/docs/rfcs/0022-surface-tools-computed-values-inputs-charts-tables.md index d8c95f9c..17a16952 100644 --- a/docs/rfcs/0022-surface-tools-computed-values-inputs-charts-tables.md +++ b/docs/rfcs/0022-surface-tools-computed-values-inputs-charts-tables.md @@ -63,7 +63,7 @@ New names in `SURFACE_FUNCTIONS`, in the same shape as today: `{"call": "" - nesting at most 8 deep; - at most 500 calls per evaluation of a surface; - lists of at most 1,000 items for aggregates. -- **Conditions** gain `gte` and `lte`, next to `eq`, `ne`, `gt` and `lt`. +- **Conditions** gain `gte` and `lte`, next to `eq`, `ne`, `gt` and `lt`. A comparison operand may also be a call (added after 22e), so computed values compare directly. **Where it runs.** The same function table runs in `resolveValue` (server render and previews) and in the interaction runtime. A test keeps the two equal, as it does today. The runtime file gets a new version (`/_cloudhal/surface.2.js`). Pages already cached keep `surface.1.js`, which stays served. @@ -145,7 +145,8 @@ Inputs write to the surface's own data model (`value: {"path": …}`). That mode - `caption` (required; the table's accessible name); - `rows`: an array of objects, as a binding or a literal, at most 200; - `columns`: `[{field, label, align: start|end, format?}]`, 1–8; `format` is a call applied to each cell, e.g. `formatCurrency`; -- optional: `striped`, `highlight` (a `visible`-style condition per row, for "the recommended plan"). +- optional: `striped`, `highlight` (a `visible`-style condition per row, for "the recommended plan"); +- per column, optional `hideOnPhone` (added after 22e): the column stays in the table but not in the phone card. The first column always shows, because it names the row. - **HTML:** a real `` with `'); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html.match(/data-phone="hide"/g)).toHaveLength(6); + }); + it("formats in the page locale", () => { const html = renderSurface(table(base), { media: { locale: "tr" } as never }); expect(html).toMatch(/
` and `
`. - **Small screens:** each row becomes a stacked card below 480 px; there is no sideways scroll. `render_page`'s `overflow-x` check must pass. @@ -261,6 +262,13 @@ Inputs write to the surface's own data model (`value: {"path": …}`). That mode - **The fix.** `calculate` takes `vars`, names bound to values, paths or calls, so a result is one formula: `(10*w + 6.25*h - 5*a + s) * f`. The grammar adds `^` (exponent at most ±1000, binding tighter than unary minus), and whitespace no longer runs names together. - **Checks:** names are letters, digits and `_`, at most 20 of them. An unknown name or a blank variable gives `""`. - **The catalog prompt and the skill** now lead with formulas, with a two-input calculator (BMI) as the worked example. The runtime is `surface.7.js`; 1–6 are aliases. +- **After 22e: gaps from the newsroom's explainers.** Four interactive articles built through MCP on the live newsroom needed workarounds; each is now part of the vocabulary. All four are additive within `blog@1` (§5): new optional props and wider operands, so stored surfaces are unchanged and no new RFC is needed. + - **Computed comparisons.** A `visible` condition, or `if`'s `cond`, takes a call as an operand: `{"lt": [{"call": "calculate", …}, {"call": "calculate", …}]}`. Before, operands were only `{path}` or scalars, and "A is cheaper" needed a `clamp` + `choose` sign trick. Operand calls run on the same function table on the server and in the runtime. They count towards the 8-deep nesting (inside `if`, from the `if`'s own depth) and the 500 calls per surface. A blank or non-finite result is `""`, so no ordering comparison holds until the inputs are filled. The parity test adds 7 conditions and 6 values. + - **`Toggle` label.** A labelled `Toggle` renders as a `
` with a visible ``, as `RadioGroup` does (`class="ch-radio ch-toggle-field"`, so themes that style `RadioGroup` already lay it out). Before, the label was only an `aria-label`. An unlabelled `Toggle` keeps its old markup. + - **`Table` on phones.** A column with `hideOnPhone: true` gets `data-phone="hide"`, and the theme leaves it out of the phone card below 480 px. An 8-column league table becomes a short card instead of 8 lines a row. Themes need the one CSS rule: the blog theme and the preview have it; a theme without it shows every column, as before. + - **Trailing zeros.** `formatNumber` takes `minDigits` (0–6; it raises `digits` when larger), so a price shows 9.60, not 9.6. + - **Prompt and skill.** The catalog prompt shows a computed comparison, a `minDigits` price and `hideOnPhone`. The site-design recipes use them for pricing and comparisons. + - The runtime is `surface.11.js`; 1–10 are aliases. - **Deviations (22e).** - **The chart brief builds a page section, not an article:** Build chat builds pages. - **No `computed-empty` check:** "Tools working" in the eval covers it, using sample inputs. diff --git a/packages/astro-renderer/src/preview.ts b/packages/astro-renderer/src/preview.ts index 94cb121c..3d282bb2 100644 --- a/packages/astro-renderer/src/preview.ts +++ b/packages/astro-renderer/src/preview.ts @@ -508,7 +508,7 @@ const PREVIEW_CSS = ".ch-slider{display:flex;align-items:center;gap:.75rem}.ch-slider input{flex:1;max-width:24rem}.ch-slider__value{font-weight:600;min-width:3rem}" + // RFC-0022 §4: tables (cards below 480 px). ".ch-table{width:100%;border-collapse:collapse;font-size:.9rem}.ch-table caption{text-align:left;font-weight:600;padding-bottom:.5rem}.ch-table th,.ch-table td{border-bottom:1px solid var(--line);padding:.4rem .6rem;text-align:left}.ch-table [data-align=end]{text-align:right}.ch-table[data-striped=yes] tbody tr:nth-child(even){background:color-mix(in srgb,var(--fg) 4%,transparent)}.ch-table tr[data-highlight=yes]{background:color-mix(in srgb,var(--fg) 8%,transparent)}" + - "@media (max-width:480px){.ch-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.ch-table caption{display:block;width:100%}.ch-table,.ch-table tbody,.ch-table tr,.ch-table td{display:block;width:100%}.ch-table tr{margin-bottom:.75rem;border:1px solid var(--line);border-radius:8px;padding:.25rem .75rem}.ch-table td{display:flex;justify-content:space-between;gap:1rem;text-align:right}.ch-table td::before{content:attr(data-label);font-weight:500;color:var(--muted);text-align:left}}" + + "@media (max-width:480px){.ch-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.ch-table caption{display:block;width:100%}.ch-table,.ch-table tbody,.ch-table tr,.ch-table td{display:block;width:100%}.ch-table tr{margin-bottom:.75rem;border:1px solid var(--line);border-radius:8px;padding:.25rem .75rem}.ch-table td{display:flex;justify-content:space-between;gap:1rem;text-align:right}.ch-table td::before{content:attr(data-label);font-weight:500;color:var(--muted);text-align:left}.ch-table td[data-phone=hide]{display:none}}" + // RFC-0022 §3: charts. ":root{--ch-chart-1:#18181b;--ch-chart-2:#2563eb;--ch-chart-3:#d97706;--ch-chart-4:#059669;--ch-chart-5:#db2777;--ch-chart-6:#7c3aed}.ch-chart{margin:0;display:grid;gap:.75rem}.ch-chart__title{font-weight:600}.ch-chart__svg{display:block;width:100%;height:auto}.ch-chart__grid{stroke:var(--line)}.ch-chart__tick{fill:var(--muted);font-size:12px}.ch-chart__total{fill:currentColor;font-size:22px;font-weight:600}" + ".ch-chart__legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;margin:0;padding:0;list-style:none;font-size:.9rem}.ch-chart__legend li{display:flex;align-items:center;gap:.4rem}.ch-chart__swatch{display:inline-block;width:.75rem;height:.75rem;border-radius:2px}.ch-chart__caption,.ch-chart__empty,.ch-chart__data summary{color:var(--muted);font-size:.9rem}.ch-chart__data table{width:100%;border-collapse:collapse;font-size:.9rem}.ch-chart__data th,.ch-chart__data td{border-bottom:1px solid var(--line);padding:.25rem .5rem;text-align:left}" + diff --git a/packages/astro-renderer/src/surface-client.ts b/packages/astro-renderer/src/surface-client.ts index 9749047b..d62751f8 100644 --- a/packages/astro-renderer/src/surface-client.ts +++ b/packages/astro-renderer/src/surface-client.ts @@ -52,7 +52,7 @@ const ex=()=>{let v=te();for(let c=pk();c==="+"||c==="-";c=pk()){i++;const r=te( try{if(!pk())return"";const v=ex();return pk()===undefined?fin(v):"";}catch{return"";}}; const fn=(name,a,loc)=>{const v=a.value;switch(name){ case"formatDate":{const d=new Date(txt(v));if(Number.isNaN(d.getTime()))return"";const st=["short","medium","long","full"].includes(txt(a.style))?txt(a.style):"medium";return new Intl.DateTimeFormat(loc,{dateStyle:st,timeZone:"UTC"}).format(d);} -case"formatNumber":{if(blank(v))return"";const n=num(v);if(!Number.isFinite(n))return"";const g=Math.min(Math.max(0,Math.trunc(num(a.digits===undefined?2:a.digits))||0),6);return new Intl.NumberFormat(loc,{maximumFractionDigits:g}).format(n);} +case"formatNumber":{if(blank(v))return"";const n=num(v);if(!Number.isFinite(n))return"";const g=Math.min(Math.max(0,Math.trunc(num(a.digits===undefined?2:a.digits))||0),6);const m=Math.min(Math.max(0,Math.trunc(num(a.minDigits===undefined?0:a.minDigits))||0),6);return new Intl.NumberFormat(loc,{minimumFractionDigits:m,maximumFractionDigits:Math.max(g,m)}).format(n);} case"formatCurrency":{if(blank(v))return"";const n=num(v);const c=txt(a.currency).toUpperCase();if(!Number.isFinite(n)||!/^[A-Z]{3}$/.test(c))return"";try{return new Intl.NumberFormat(loc,{style:"currency",currency:c}).format(n);}catch{return"";}} case"pluralize":{const n=num(a.count);return(n===1?txt(a.one):txt(a.other)).replace(/#/g,Number.isFinite(n)?String(n):"");} case"concat":return(Array.isArray(a.parts)?a.parts:[]).map(txt).join(""); @@ -76,14 +76,14 @@ case"calculate":return calc(txt(a.expression),a.vars);} return"";}; const nil=(o)=>o===undefined||o===null?"":o; const val=(v,d,loc,z=0)=>{if(isPath(v))return get(d,v.path);if(isCall(v)){if(z>=8)return"";const r=obj(v.args)?v.args:{};const at=(x)=>val(x,d,loc,z+1); -if(v.call==="if")return nil(cond(r.cond,d)?at(r.ifTrue):at(r.ifFalse)); +if(v.call==="if")return nil(cond(r.cond,d,loc,z+1)?at(r.ifTrue):at(r.ifFalse)); if(v.call==="calculate"&&obj(r.vars)){const vs={};for(const[k,x]of Object.entries(r.vars))vs[k]=at(x);return calc(txt(at(r.expression)),vs);} if(v.call==="choose"){const k=txt(at(r.value));const c=obj(r.cases)?r.cases:{};return nil(own(c,k)?at(c[k]):at(r.default));} const a={};for(const[k,x]of Object.entries(r))a[k]=Array.isArray(x)?x.map(at):at(x);return fn(v.call,a,loc);}return v;}; const truthy=(v)=>!(v===undefined||v===null||v===false||v===0||v===""||(Array.isArray(v)&&v.length===0)); -const cond=(c,d)=>{if(c===undefined)return true;if(isPath(c))return truthy(get(d,c.path));if(!obj(c))return false;const[op,a]=Object.entries(c)[0]||[];const o=(x)=>isPath(x)?get(d,x.path):x; +const cond=(c,d,loc,z=0)=>{if(c===undefined)return true;if(isPath(c))return truthy(get(d,c.path));if(!obj(c))return false;const[op,a]=Object.entries(c)[0]||[];const o=(x)=>isPath(x)||isCall(x)?val(x,d,loc,z):x;const sub=(x)=>cond(x,d,loc,z); if(["eq","ne","gt","lt","gte","lte"].includes(op)&&Array.isArray(a)){const x=o(a[0]);const y=o(a[1]);if(op==="eq")return x===y;if(op==="ne")return x!==y;if(typeof x!==typeof y||(typeof x!=="number"&&typeof x!=="string"))return false;return op==="gt"?x>y:op==="lt"?x=y:x<=y;} -if(op==="and"&&Array.isArray(a))return a.every((x)=>cond(x,d));if(op==="or"&&Array.isArray(a))return a.some((x)=>cond(x,d));if(op==="not")return!cond(a,d);return false;}; +if(op==="and"&&Array.isArray(a))return a.every(sub);if(op==="or"&&Array.isArray(a))return a.some(sub);if(op==="not")return!sub(a);return false;}; const safe=(h)=>typeof h==="string"&&/^(\/(?!\/)|#|https:\/\/)/.test(h)&&!/[\s<>"']/.test(h); const json=(el,a)=>{try{return JSON.parse(el.getAttribute(a)||"null");}catch{return undefined;}}; let chartMod; @@ -93,7 +93,7 @@ if(chartMod)draw(chartMod);else import("${CHART_SCRIPT.path}").then((m)=>{chartM const boot=(root)=>{const s=root.querySelector("script[data-ch-state]");if(!s||root.hasAttribute("data-ch-live"))return;root.setAttribute("data-ch-live",""); let init;try{init=JSON.parse(s.textContent||"{}");}catch{return;}let d=structuredClone(init);const loc=document.documentElement.lang||"en"; const render=()=>{for(const el of root.querySelectorAll("[data-ch-text]"))el.textContent=txt(val(json(el,"data-ch-text"),d,loc)); -for(const el of root.querySelectorAll("[data-ch-visible]"))el.hidden=!cond(json(el,"data-ch-visible"),d); +for(const el of root.querySelectorAll("[data-ch-visible]"))el.hidden=!cond(json(el,"data-ch-visible"),d,loc); charts(root,d,loc); for(const el of root.querySelectorAll("[data-ch-invalid]"))el.setAttribute("aria-invalid",truthy(val(json(el,"data-ch-invalid"),d,loc))?"true":"false"); for(const el of root.querySelectorAll("[data-ch-model]")){const v=get(d,el.getAttribute("data-ch-model"));if(el.type==="radio")el.checked=el.value===txt(v);else if(el.type==="checkbox")el.checked=truthy(v);else if(v!==undefined&&document.activeElement!==el)el.value=txt(v);}}; @@ -117,7 +117,7 @@ render();}; for(const root of document.querySelectorAll("[data-ch-surface]"))boot(root); `; -export const SURFACE_SCRIPT = { path: "/_cloudhal/surface.10.js", code } as const; +export const SURFACE_SCRIPT = { path: "/_cloudhal/surface.11.js", code } as const; /** * Earlier names of the runtime, served with the current code: pages cached with them keep @@ -133,6 +133,7 @@ export const SURFACE_SCRIPT_ALIASES = [ "/_cloudhal/surface.7.js", "/_cloudhal/surface.8.js", "/_cloudhal/surface.9.js", + "/_cloudhal/surface.10.js", ] as const; /** JSON for a `'); + expect(html).toContain(''); // The data cannot end its script element. expect(html).not.toContain("${values .map((v, i) => ``) @@ -410,7 +435,9 @@ describe("interaction runtime", () => { ); }); conditions.forEach((c, i) => { - expect(page.querySelector(`#c${i}`)?.hidden).toBe(!evaluateCondition(c, data)); + expect(page.querySelector(`#c${i}`)?.hidden).toBe( + !evaluateCondition(c, data, { locale: "en" }), + ); }); }); @@ -469,9 +496,9 @@ describe("interaction runtime", () => { it("pins the module's code to its versioned name", () => { // Bump `surface..js` with any change to the code (`/_cloudhal/*` is cached as immutable). expect(createHash("sha256").update(SURFACE_SCRIPT.code).digest("hex").slice(0, 12)).toBe( - "fc0f2c39714e", + "c304c0f46e4e", ); - expect(SURFACE_SCRIPT.path).toBe("/_cloudhal/surface.10.js"); + expect(SURFACE_SCRIPT.path).toBe("/_cloudhal/surface.11.js"); // RFC-0022: pages cached with the first name get the same (compatible) code expect(SURFACE_SCRIPT_ALIASES).toEqual([ "/_cloudhal/surface.1.js", @@ -483,6 +510,7 @@ describe("interaction runtime", () => { "/_cloudhal/surface.7.js", "/_cloudhal/surface.8.js", "/_cloudhal/surface.9.js", + "/_cloudhal/surface.10.js", ]); // RFC-0025 25d: a copy says so in the page's toast region. expect(SURFACE_SCRIPT.code).toContain('.then(()=>toast("copied"))'); diff --git a/packages/astro-renderer/test/surfaces.test.ts b/packages/astro-renderer/test/surfaces.test.ts index 718688ab..0b6665a5 100644 --- a/packages/astro-renderer/test/surfaces.test.ts +++ b/packages/astro-renderer/test/surfaces.test.ts @@ -76,6 +76,23 @@ describe("renderSurface (RFC-0020)", () => { expect(html).not.toContain("Yearly"); }); + it("shows a Toggle's label as a legend, as RadioGroup does", () => { + const toggle = (props: Record) => + renderSurface(surface([{ id: "root", component: "Toggle", props }], { billing: "monthly" })); + const labelled = toggle({ + label: "Billing", + options: ["monthly", "yearly"], + value: { path: "/billing" }, + }); + expect(labelled).toContain( + '
Billing
', + ); + expect(labelled).not.toContain("aria-label"); + expect(toggle({ options: ["monthly", "yearly"], value: { path: "/billing" } })).toContain( + '
', + ); + }); + it("draws tabs and disclosures that work without JavaScript", () => { const html = renderSurface( surface([ diff --git a/packages/astro-renderer/test/table.test.ts b/packages/astro-renderer/test/table.test.ts index c8af467e..2fcb64cd 100644 --- a/packages/astro-renderer/test/table.test.ts +++ b/packages/astro-renderer/test/table.test.ts @@ -46,6 +46,20 @@ describe("Table", () => { expect(html).not.toContain(" { + const columns = base.columns.map((c, i) => + i === 0 || i >= 3 ? { ...c, hideOnPhone: true } : c, + ); + expect(checkSurface(table({ ...base, columns }), createCatalog()).issues).toEqual([]); + const html = renderSurface(table({ ...base, columns }), { media: { locale: "en" } as never }); + expect(html).toContain('
PlanSincePro$19.50Mar 1, 2026\$19,50<\/td>/); diff --git a/packages/core-ts/src/surfaces/bindings/functions.ts b/packages/core-ts/src/surfaces/bindings/functions.ts index 18d9a269..7b0e1a47 100644 --- a/packages/core-ts/src/surfaces/bindings/functions.ts +++ b/packages/core-ts/src/surfaces/bindings/functions.ts @@ -23,7 +23,12 @@ export function runFunction( const n = num(value); if (!Number.isFinite(n)) return ""; const digits = Math.min(Math.max(0, Math.trunc(num(args.digits ?? 2)) || 0), 6); - return new Intl.NumberFormat(ctx.locale, { maximumFractionDigits: digits }).format(n); + // `minDigits` keeps trailing zeros (9.6 → 9.60); it raises `digits` when larger. + const min = Math.min(Math.max(0, Math.trunc(num(args.minDigits ?? 0)) || 0), 6); + return new Intl.NumberFormat(ctx.locale, { + minimumFractionDigits: min, + maximumFractionDigits: Math.max(digits, min), + }).format(n); } case "formatCurrency": { if (blank(value)) return ""; diff --git a/packages/core-ts/src/surfaces/bindings/resolve.ts b/packages/core-ts/src/surfaces/bindings/resolve.ts index 83cf2635..86476797 100644 --- a/packages/core-ts/src/surfaces/bindings/resolve.ts +++ b/packages/core-ts/src/surfaces/bindings/resolve.ts @@ -6,6 +6,7 @@ import { FUNCTIONS, getPointer, isCallValue, + isDynamic, isPathValue, type ResolveContext, text, @@ -19,7 +20,9 @@ export function resolveValue(v: unknown, data: unknown, ctx: ResolveContext, dep const raw = isObj(v.args) ? v.args : {}; const at = (x: unknown) => resolveValue(x, data, ctx, depth + 1); if (v.call === "if") - return (evaluateCondition(raw.cond, data) ? at(raw.ifTrue) : at(raw.ifFalse)) ?? ""; + return ( + (evaluateCondition(raw.cond, data, ctx, depth + 1) ? at(raw.ifTrue) : at(raw.ifFalse)) ?? "" + ); // RFC-0022: a formula's variables are values, bindings or calls, resolved first. if (v.call === "calculate" && isObj(raw.vars)) { const vars: Record = {}; @@ -58,13 +61,21 @@ export const truthy = (v: unknown) => (Array.isArray(v) && v.length === 0) ); -/** Whether a visibility condition holds for `data`; no condition is visible. */ -export function evaluateCondition(c: unknown, data: unknown): boolean { +/** + * Whether a visibility condition holds for `data`; no condition is visible. A call operand + * (RFC-0022) resolves at `depth`, the depth of a call argument in its place. + */ +export function evaluateCondition( + c: unknown, + data: unknown, + ctx: ResolveContext = { locale: "en" }, + depth = 0, +): boolean { if (c === undefined) return true; if (isPathValue(c)) return truthy(getPointer(data, c.path)); if (!isObj(c)) return false; const [op, arg] = (Object.entries(c)[0] ?? []) as [string, unknown]; - const operand = (x: unknown) => (isPathValue(x) ? getPointer(data, x.path) : x); + const operand = (x: unknown) => (isDynamic(x) ? resolveValue(x, data, ctx, depth) : x); if ((COMPARE as readonly string[]).includes(op) && Array.isArray(arg)) { const a = operand(arg[0]); const b = operand(arg[1]); @@ -75,9 +86,10 @@ export function evaluateCondition(c: unknown, data: unknown): boolean { const y = b as number; return op === "gt" ? x > y : op === "lt" ? x < y : op === "gte" ? x >= y : x <= y; } - if (op === "and" && Array.isArray(arg)) return arg.every((x) => evaluateCondition(x, data)); - if (op === "or" && Array.isArray(arg)) return arg.some((x) => evaluateCondition(x, data)); - if (op === "not") return !evaluateCondition(arg, data); + const sub = (x: unknown) => evaluateCondition(x, data, ctx, depth); + if (op === "and" && Array.isArray(arg)) return arg.every(sub); + if (op === "or" && Array.isArray(arg)) return arg.some(sub); + if (op === "not") return !sub(arg); return false; } diff --git a/packages/core-ts/src/surfaces/bindings/values.ts b/packages/core-ts/src/surfaces/bindings/values.ts index aa2e799f..7df7f2ad 100644 --- a/packages/core-ts/src/surfaces/bindings/values.ts +++ b/packages/core-ts/src/surfaces/bindings/values.ts @@ -111,7 +111,8 @@ export function checkCall(v: CallValue, path: string, out: PropsIssue[], depth = for (const [k, a] of Object.entries(v.args)) { const at = `${path}/args/${k}`; // `if` takes a condition; `choose` a map from keys to values. - if (v.call === "if" && k === "cond") checkCondition(a, at, out); + if (v.call === "if" && k === "cond") + checkConditionAt(a, at, out, { depth: 0, callDepth: depth }); else if (v.call === "choose" && k === "cases") { if (!isObj(a) || Object.keys(a).length > SURFACE_LIMITS.chooseCases) out.push({ @@ -170,8 +171,24 @@ export function checkDynamic(v: unknown, path: string, out: PropsIssue[]): void export const COMPARE = ["eq", "ne", "gt", "lt", "gte", "lte"] as const; -/** A visibility condition (RFC-0020 §3): `{path}`, `eq|ne|gt|lt: [a, b]`, `and|or: [...]`, `not`. */ +/** + * A visibility condition (RFC-0020 §3): `{path}`, `eq|ne|gt|lt: [a, b]`, `and|or: [...]`, `not`. + * A comparison operand may be a call (RFC-0022). + */ export function checkCondition(c: unknown, path: string, out: PropsIssue[], depth = 0): void { + checkConditionAt(c, path, out, { depth, callDepth: 0 }); +} + +/** + * `callDepth` is the enclosing call's depth (0 for a `visible`), so `if` counts its operand calls + * towards the same limit as its other arguments. + */ +function checkConditionAt( + c: unknown, + path: string, + out: PropsIssue[], + { depth, callDepth }: { depth: number; callDepth: number }, +): void { if (depth > SURFACE_LIMITS.conditionDepth) { out.push({ path, message: `nested at most ${SURFACE_LIMITS.conditionDepth} deep` }); return; @@ -190,7 +207,7 @@ export function checkCondition(c: unknown, path: string, out: PropsIssue[], dept out.push({ path: `${path}/${op}`, message: "two operands" }); else arg.forEach((x, i) => { - checkOperand(x, `${path}/${op}/${i}`, out); + checkArg(x, `${path}/${op}/${i}`, out, callDepth); }); } else if (op === "and" || op === "or") { if (!Array.isArray(arg) || arg.length === 0 || arg.length > SURFACE_LIMITS.conditionItems) @@ -200,9 +217,10 @@ export function checkCondition(c: unknown, path: string, out: PropsIssue[], dept }); else arg.forEach((x, i) => { - checkCondition(x, `${path}/${op}/${i}`, out, depth + 1); + checkConditionAt(x, `${path}/${op}/${i}`, out, { depth: depth + 1, callDepth }); }); - } else if (op === "not") checkCondition(arg, `${path}/not`, out, depth + 1); + } else if (op === "not") + checkConditionAt(arg, `${path}/not`, out, { depth: depth + 1, callDepth }); else out.push({ path, message: "one of {path}, eq, ne, gt, lt, gte, lte, and, or, not" }); } diff --git a/packages/core-ts/src/surfaces/catalog/primitives-inputs.ts b/packages/core-ts/src/surfaces/catalog/primitives-inputs.ts index 5f3d9dec..a2b4dcb5 100644 --- a/packages/core-ts/src/surfaces/catalog/primitives-inputs.ts +++ b/packages/core-ts/src/surfaces/catalog/primitives-inputs.ts @@ -6,8 +6,8 @@ export const INPUT_PRIMITIVES: readonly PrimitiveType[] = [ name: "Toggle", title: { en: "Toggle", tr: "Seçici" }, description: { - en: "A row of choices; the chosen one is written to `value`'s path (bind it: {path}).", - tr: "Seçenekler; seçilen, value'nun yoluna yazılır.", + en: "A row of choices; the chosen one is written to `value`'s path (bind it: {path}). The label shows above it.", + tr: "Seçenekler; seçilen, value'nun yoluna yazılır. Etiket üstünde görünür.", }, category: "interactive", props: schema( @@ -115,8 +115,8 @@ export const INPUT_PRIMITIVES: readonly PrimitiveType[] = [ name: "Table", title: { en: "Table", tr: "Tablo" }, description: { - en: "Rows of the surface's data as a table: rows {path} to a list of objects, columns the fields to show. On phones each row becomes a card.", - tr: "Yüzey verisindeki satırlar tablo olarak; telefonda her satır bir karta dönüşür.", + en: "Rows of the surface's data as a table: rows {path} to a list of objects, columns the fields to show. On phones each row becomes a card; hideOnPhone keeps a column out of it.", + tr: "Yüzey verisindeki satırlar tablo olarak; telefonda her satır bir karta dönüşür, hideOnPhone bir sütunu karttan çıkarır.", }, category: "content", props: schema( @@ -138,6 +138,8 @@ export const INPUT_PRIMITIVES: readonly PrimitiveType[] = [ format: { type: "string", enum: ["text", "number", "currency", "percent", "date"] }, currency: { type: "string", pattern: "^[A-Z]{3}$" }, digits: { type: "integer", minimum: 0, maximum: 6 }, + // Left out of the phone card (the first column always shows: it names the row). + hideOnPhone: { type: "boolean" }, }, }, }, diff --git a/packages/core-ts/src/surfaces/prompt.ts b/packages/core-ts/src/surfaces/prompt.ts index db87c230..67c5b477 100644 --- a/packages/core-ts/src/surfaces/prompt.ts +++ b/packages/core-ts/src/surfaces/prompt.ts @@ -68,16 +68,16 @@ export function catalogPrompt(catalog: Catalog, locale = "en"): string { `- Functions: ${SURFACE_FUNCTIONS.join(", ")}.`, '- Inputs (NumberField, TextField, Checkbox, RadioGroup) write only to the surface\'s data in the reader\'s browser: bind value (checked for Checkbox) with {"path"}, start it in `data` ("" for an empty number), give every input a label, and show the computed result next to it. Buttons may `append` text to a value (keypads) or `set` a computed value, e.g. {"action":"set","path":"/display","value":{"call":"calculate","args":{"expression":{"path":"/display"}}}}.', '- Charts: a Chart draws rows from the surface\'s data, e.g. {"id":"c","component":"Chart","props":{"kind":"bar","title":"Readers per month","data":{"path":"/months"},"x":"month","series":[{"field":"readers","label":"Readers"}]}} with "data":{"months":[{"month":"Jan","readers":1200},…]}. Use one only when the numbers are the point; bar for comparing, line or area for change over time, donut for parts of a whole (one series, at most six parts).', - '- Tables: a Table shows rows from the surface\'s data, e.g. {"id":"t","component":"Table","props":{"caption":"Plans compared","rows":{"path":"/plans"},"columns":[{"field":"name","label":"Plan"},{"field":"price","label":"Price","align":"end","format":"currency","currency":"USD"}],"highlight":{"field":"name","equals":"Pro"}}}. Formats: text, number, currency, percent, date. At most 8 columns; on phones each row becomes a card.', + '- Tables: a Table shows rows from the surface\'s data, e.g. {"id":"t","component":"Table","props":{"caption":"Plans compared","rows":{"path":"/plans"},"columns":[{"field":"name","label":"Plan"},{"field":"price","label":"Price","align":"end","format":"currency","currency":"USD"}],"highlight":{"field":"name","equals":"Pro"}}}. Formats: text, number, currency, percent, date. At most 8 columns; on phones each row becomes a card of every column, so with more than four give the ones a phone reader can skip "hideOnPhone":true (the first column always shows: it names the row).', "- Compute, don't promise: a total, a price for N seats or a result from the reader's choices is a call, never text such as \"calculated at checkout\". " + "For any result with more than one step, write the formula with `calculate` and name its inputs in `vars` (paths into the data, numbers, or a `choose`): " + '{"call":"calculate","args":{"expression":"seats * price * (1 + vat)","vars":{"seats":{"path":"/seats"},"price":{"call":"choose","args":{"value":{"path":"/plan"},"cases":{"basic":9,"pro":19}}},"vat":0.2}}}. ' + "Formulas use + - * / % ^ and parentheses; names are letters, digits and _. Wrap the result in formatNumber or formatCurrency to show it: " + - '{"call":"formatCurrency","args":{"currency":"USD","value":{"call":"calculate","args":{…}}}}. A blank input leaves the result empty until it is filled. ' + + '{"call":"formatCurrency","args":{"currency":"USD","value":{"call":"calculate","args":{…}}}}. A price without a currency keeps its cents with minDigits: {"call":"formatNumber","args":{"digits":2,"minDigits":2,"value":{"path":"/price"}}} shows 9.60, not 9.6. A blank input leaves the result empty until it is filled. ' + 'A whole calculator: data {"kg":"","cm":""}; NumberFields {"label":"Weight","value":{"path":"/kg"},"unit":"kg"} and {"label":"Height","value":{"path":"/cm"},"unit":"cm"}; ' + 'a Stat {"label":"BMI","value":{"call":"formatNumber","args":{"digits":1,"value":{"call":"calculate","args":{"expression":"kg / (cm/100)^2","vars":{"kg":{"path":"/kg"},"cm":{"path":"/cm"}}}}}}}. ' + - "Other functions: add/multiply/min/max {values}, subtract/divide {a,b}, round/floor/ceil {value,digits}, power {base,exponent}, if {cond,ifTrue,ifFalse}, sum/average/count {list,field}.", - '- `visible` (optional) shows a component only when a condition holds: {"path":"/x"}, {"eq":[a,b]}, {"ne":[a,b]}, {"gt":[a,b]}, {"lt":[a,b]}, {"gte":[a,b]}, {"lte":[a,b]}, {"and":[…]}, {"or":[…]}, {"not":…}.', + "Other functions: formatNumber {value,digits,minDigits}, add/multiply/min/max {values}, subtract/divide {a,b}, round/floor/ceil {value,digits}, power {base,exponent}, if {cond,ifTrue,ifFalse}, sum/average/count {list,field}.", + '- `visible` (optional) shows a component only when a condition holds: {"path":"/x"}, {"eq":[a,b]}, {"ne":[a,b]}, {"gt":[a,b]}, {"lt":[a,b]}, {"gte":[a,b]}, {"lte":[a,b]}, {"and":[…]}, {"or":[…]}, {"not":…}. An operand may be a value, a {path} or a call, so computed results compare directly: a Text "Paying monthly is cheaper" with visible {"lt":[{"call":"calculate","args":{"expression":"a * 12","vars":{"a":{"path":"/monthly"}}}},{"path":"/yearly"}]}. if takes the same conditions.', // RFC-0024 §2: motion as presets, next to `visible`. `- \`motion\` (optional, next to \`visible\`) animates a component with presets, never CSS: {"reveal":"rise","speed":"normal","delay":1}. "reveal" plays once as it scrolls into view: ${MOTION_REVEALS.join(", ")}. "hover": lift, scale, glow; underline (Link, Button, Card, Heading); zoom-media (Card, Image); tilt (Card, Image); magnetic (Button, Link); spotlight (Card, Band). "stagger": true on ${MOTION_STAGGER.join("/")} reveals its children one after another. "delay" 0–4 stagger steps; "speed" fast|normal|slow; "replay": true reveals again on re-entry. "scroll" moves it with the page instead (not with "reveal"): parallax or pan with "depth" -3…3 (negative moves the other way), fade-through, scale-through, rotate-through — for images, cards and decorative parts, never for text people read. split-words, split-chars and split-lines reveal a Heading or a short Text piece by piece (once, as kinetic type). "loop" (alone, decorative only): float (Image, Badge, Stat, Card), pulse (Badge, Button, Stat), gradient-shift (Card, Band), spin-slow (Image, Badge).`, 'Kinetic headings: Heading "effect" highlight (marks the words *between asterisks*, else the last word), gradient-text, or rotate-words with "words" (2–6) taking turns where {words} stands in the text ("Invoices that {words}" with ["chase themselves","never get lost"]). Marquee scrolls a row of 3+ children by itself (logos, quotes); Counter counts up to "value" once as it comes into view. Anything that loops gets a pause control from the renderer; use at most two per page.', diff --git a/packages/core-ts/test/surfaces.test.ts b/packages/core-ts/test/surfaces.test.ts index e030c0e6..ca7abca8 100644 --- a/packages/core-ts/test/surfaces.test.ts +++ b/packages/core-ts/test/surfaces.test.ts @@ -1345,3 +1345,107 @@ describe("image fit (RFC-0028 28b)", () => { expect(messages(sec({ fit: "stretch" }) as never).length).toBeGreaterThan(0); }); }); + +describe("computed operands, labels and digits (RFC-0022, after 22e)", () => { + const ctx = { locale: "en" }; + const call = (name: string, args: Record) => ({ call: name, args }); + const calc = (expression: string, vars: Record) => + call("calculate", { expression, vars }); + // two offers: 12 months at 9.60 with 20 % off, or 10 months at 11 + const data = { a: 9.6, b: 11, months: 12 }; + const a = calc("a * months * 0.8", { a: { path: "/a" }, months: { path: "/months" } }); + const b = calc("b * 10", { b: { path: "/b" } }); + + it("compares computed values in conditions and in if", () => { + expect(evaluateCondition({ lt: [a, b] }, data, ctx)).toBe(true); + expect(evaluateCondition({ gt: [a, b] }, data, ctx)).toBe(false); + expect(evaluateCondition({ not: { gte: [a, b] } }, data, ctx)).toBe(true); + expect(evaluateCondition({ eq: [call("count", { list: [1, 2] }), 2] }, data, ctx)).toBe(true); + // a blank input gives "" and no ordering comparison holds + expect(evaluateCondition({ lt: [a, b] }, { ...data, a: "" }, ctx)).toBe(false); + expect(evaluateCondition({ gt: [a, b] }, { ...data, a: "" }, ctx)).toBe(false); + expect( + resolveValue( + call("if", { cond: { lt: [a, b] }, ifTrue: "A is cheaper", ifFalse: "B is cheaper" }), + data, + ctx, + ), + ).toBe("A is cheaper"); + }); + + it("checks call operands with the same limits as arguments", () => { + const visible = (cond: unknown) => + messages( + surface([ + { id: "root", component: "Stack", children: ["t"] }, + { id: "t", component: "Text", props: { text: "A is cheaper" }, visible: cond }, + ] as SurfaceComponent[]), + ); + expect(visible({ lt: [a, b] })).toEqual([]); + expect(visible({ and: [{ lt: [a, b] }, { not: { eq: [a, 0] } }] })).toEqual([]); + expect(visible({ lt: [call("explode", {}), 1] })).toEqual([ + `/components/1/visible/lt/0/call one of ${SURFACE_FUNCTIONS.join(", ")}`, + ]); + expect(visible({ lt: [{ list: [1] }, 1] })).toEqual([ + "/components/1/visible/lt/0 a text, number, true/false or {path}", + ]); + // eight calls deep is the limit in a condition too, counted from the enclosing call + let deep: unknown = 1; + for (let i = 0; i < 8; i++) deep = call("abs", { value: deep }); + expect(visible({ gt: [deep, 0] })).toEqual([]); + expect(evaluateCondition({ gt: [deep, 0] }, {}, ctx)).toBe(true); + expect(visible({ gt: [call("abs", { value: deep }), 0] })).toHaveLength(1); + expect(evaluateCondition({ gt: [call("abs", { value: deep }), 0] }, {}, ctx)).toBe(false); + const inIf = (n: number) => { + let x: unknown = 1; + for (let i = 0; i < n; i++) x = call("abs", { value: x }); + return call("if", { cond: { gt: [x, 0] }, ifTrue: "yes", ifFalse: "no" }); + }; + expect( + messages(surface([{ id: "root", component: "Text", props: { text: inIf(7) } }])), + ).toEqual([]); + expect(resolveValue(inIf(7), {}, ctx)).toBe("yes"); + expect( + messages(surface([{ id: "root", component: "Text", props: { text: inIf(8) } }])), + ).toHaveLength(1); + expect(resolveValue(inIf(8), {}, ctx)).toBe("no"); + // operand calls count towards the surface's calls + const many = Array.from({ length: 26 }, (_, i) => ({ + id: `t${i}`, + component: "Text", + props: { text: "x" }, + visible: { + lt: [ + call("add", { values: Array.from({ length: 20 }, () => call("abs", { value: 1 })) }), + 1, + ], + }, + })); + expect( + messages( + surface([{ id: "root", component: "Stack", children: many.map((c) => c.id) }, ...many]), + ), + ).toContain("/components at most 500 function calls (this surface has 546)"); + }); + + it("keeps trailing zeros with minDigits", () => { + const fmt = (args: Record, locale = "en") => + resolveValue(call("formatNumber", args), {}, { locale }); + expect(fmt({ value: 9.6 })).toBe("9.6"); + expect(fmt({ value: 9.6, minDigits: 2 })).toBe("9.60"); + expect(fmt({ value: 9.6, minDigits: 2 }, "tr")).toBe("9,60"); + expect(fmt({ value: 9, digits: 2, minDigits: 2 })).toBe("9.00"); + expect(fmt({ value: 9.125, digits: 1, minDigits: 3 })).toBe("9.125"); + expect(fmt({ value: 1234.5, digits: 0, minDigits: -4 })).toBe("1,235"); + expect(fmt({ value: "", minDigits: 2 })).toBe(""); + }); + + it("describes the new props in the catalog and the prompt", () => { + const table = catalog.get("Table"); + expect(JSON.stringify(table?.schema)).toContain('"hideOnPhone":{"type":"boolean"}'); + const prompt = catalogPrompt(catalog); + expect(prompt).toContain('"minDigits":2'); + expect(prompt).toContain('"hideOnPhone":true'); + expect(prompt).toContain('{"lt":[{"call":"calculate"'); + }); +}); diff --git a/skills/official/site-design/references/page-recipes.md b/skills/official/site-design/references/page-recipes.md index e290a20c..77a02e74 100644 --- a/skills/official/site-design/references/page-recipes.md +++ b/skills/official/site-design/references/page-recipes.md @@ -6,7 +6,7 @@ Starting points, not templates: adapt them to the site's rules and real content. 1. **Hero:** `Band` (surface `gradient`, `mesh`, `inverse` or `brand`, width `full`, spacing `xl`) → `Heading` level 1 (the promise, ≤ 8 words) → `Text` size `l` (one sentence) → `Row` with one primary and one secondary `Button`. 2. **Features:** plain section → `Heading` level 2 → `Grid` (columns 3 or 4) of `Card`s with a short `Heading` level 3 and one sentence each. 3. **Proof:** `Band` surface `muted` or `dots` → `Grid` of `Stat`s, or `Columns 2-1` with a `Quote` / `testimonial` next to an `Image`. -4. **Pricing:** `Tabs` (monthly / yearly) or a `Toggle` bound to data and `Text` bound with `formatCurrency`; one `Card` per real plan. +4. **Pricing:** `Tabs` (monthly / yearly) or a `Toggle` with a `label` (shown above it) bound to data and `Text` bound with `formatCurrency`; one `Card` per real plan. A price with no currency symbol is `formatNumber` with `digits` 2 and `minDigits` 2 (9.60, not 9.6). 5. **Questions:** `faq` block or `Accordion` of `Disclosure`s (the real questions, as many as there are). 6. **Call to action:** `Band` surface `brand` or `accent` (anchor `start`) → `Heading` level 2 → one primary `Button`. @@ -30,5 +30,5 @@ Compute, don't promise: a result is a function call over the inputs, never text - **Calculator** (calories, loan, savings, price × seats): a `Card` holding a `Heading`, the inputs (`NumberField` with `unit`, `min`, `max`; `RadioGroup` `variant: buttons` for two to four choices; `Checkbox` for yes/no), two or three to a `Grid` row, then the result as a `Stat` right below. Start every number input as `""` in `data`; bind each input's `value` with `{path}`; write the result as one formula with `calculate` and `vars` (each input's path, or a `choose` for a choice), and show it with `formatNumber` or `formatCurrency`. Calories: `(10*w + 6.25*h - 5*a + s) * f` with `s` and `f` from `choose`; a loan payment: `p * r / (1 - (1 + r)^(-12*y))` with `r` the yearly percent ÷ 1200. - **Keypad calculator:** a `Text` bound to `/display`, a `Grid` of `Button`s with `append` (digits and operators), `C` as `set` "" and `=` as `set` with `calculate` over `/display`. - **Chart:** a `Chart` bound to rows in `data` (`x` the label field, `series` the number fields), titled with what it shows, with a `caption` naming the source. Bar to compare, line over time, donut for parts of a whole. -- **Comparison:** a `Table` bound to rows, the first column naming the row, numbers aligned `end` with a `format`. +- **Comparison:** a `Table` bound to rows, the first column naming the row, numbers aligned `end` with a `format`. With more than four columns, mark the ones a phone reader can skip `hideOnPhone: true`, so each phone card keeps three or four lines. To say which option wins, give a `Text` a `visible` that compares the computed values: `{"lt": [{"call": "calculate", …}, {"call": "calculate", …}]}`; no sign tricks with `clamp` and `choose`. Check with `render_page`: no `input-label`, `chart-empty` or `overflow-x`, and the result shows a number with the sample values. diff --git a/starters/blog/src/styles/features/live-surfaces.css b/starters/blog/src/styles/features/live-surfaces.css index 9689f9a4..2c3a995d 100644 --- a/starters/blog/src/styles/features/live-surfaces.css +++ b/starters/blog/src/styles/features/live-surfaces.css @@ -216,6 +216,9 @@ button.ch-button--secondary { @apply text-left font-medium text-zinc-600; content: attr(data-label); } + .ch-table td[data-phone="hide"] { + @apply hidden; + } .ch-table tr[data-highlight="yes"] td:first-child { box-shadow: none; }