Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/surface-gaps-newsroom.md
Original file line number Diff line number Diff line change
@@ -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 `<legend>`, 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.
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ New names in `SURFACE_FUNCTIONS`, in the same shape as today: `{"call": "<name>"
- 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.

Expand Down Expand Up @@ -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 `<table>` with `<caption>` and `<th scope>`.
- **Small screens:** each row becomes a stacked card below 480 px; there is no sideways scroll. `render_page`'s `overflow-x` check must pass.
Expand Down Expand Up @@ -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 `<fieldset>` with a visible `<legend>`, 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.
Expand Down
2 changes: 1 addition & 1 deletion packages/astro-renderer/src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}" +
Expand Down
13 changes: 7 additions & 6 deletions packages/astro-renderer/src/surface-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand All @@ -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:op==="gte"?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;
Expand All @@ -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);}};
Expand All @@ -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
Expand All @@ -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 `<script type="application/json">`: nothing in it can end the element. */
Expand Down
16 changes: 9 additions & 7 deletions packages/astro-renderer/src/surfaces/primitives/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,25 +53,27 @@ function cellText(v: unknown, c: Record<string, unknown>, locale: string): strin
return str(v);
}

/** RFC-0022 §4: a real table; `data-label` lets the theme stack rows into cards on phones. */
/**
* RFC-0022 §4: a real table; `data-label` lets the theme stack rows into cards on phones, and
* `data-phone="hide"` leaves a column out of those cards (never the first, which names the row).
*/
export const tableHtml: PrimitiveRenderer = ({ props: p }, { media }) => {
const locale = media?.locale ?? "en";
const rows = (Array.isArray(p.rows) ? p.rows : []).filter(isRow).slice(0, 200);
const columns = (Array.isArray(p.columns) ? p.columns : []).filter(isRow).slice(0, 8);
const hl = isRow(p.highlight) ? p.highlight : undefined;
const extra = (c: Record<string, unknown>, i: number) =>
`${c.align === "end" ? ' data-align="end"' : ""}${i > 0 && c.hideOnPhone === true ? ' data-phone="hide"' : ""}`;
const head = columns
.map(
(c) =>
`<th scope="col"${c.align === "end" ? ' data-align="end"' : ""}>${escapeHtml(str(c.label))}</th>`,
)
.map((c, i) => `<th scope="col"${extra(c, i)}>${escapeHtml(str(c.label))}</th>`)
.join("");
const body = rows
.map((r) => {
const marked = hl && str(r[str(hl.field)]) === str(hl.equals);
return `<tr${marked ? ' data-highlight="yes"' : ""}>${columns
.map(
(c) =>
`<td data-label="${escapeHtml(str(c.label))}"${c.align === "end" ? ' data-align="end"' : ""}>${escapeHtml(cellText(r[str(c.field)], c, locale))}</td>`,
(c, i) =>
`<td data-label="${escapeHtml(str(c.label))}"${extra(c, i)}>${escapeHtml(cellText(r[str(c.field)], c, locale))}</td>`,
)
.join("")}</tr>`;
})
Expand Down
4 changes: 3 additions & 1 deletion packages/astro-renderer/src/surfaces/primitives/inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const toggleHtml: PrimitiveRenderer = (node, { id }) => {
`<label class="ch-toggle__option"><input type="radio" name="${id}" value="${escapeHtml(o)}"${model(node)}${o === str(p.value) ? " checked" : ""}><span>${escapeHtml(o)}</span></label>`,
)
.join("");
return `<div class="ch-toggle" role="radiogroup"${p.label ? ` aria-label="${escapeHtml(str(p.label))}"` : ""}>${radios}</div>`;
// A label shows as a legend, as RadioGroup's does (the fieldset names the group).
if (!str(p.label)) return `<div class="ch-toggle" role="radiogroup">${radios}</div>`;
return `<fieldset class="ch-radio ch-toggle-field"><legend class="ch-field__label">${escapeHtml(str(p.label))}</legend><div class="ch-toggle">${radios}</div></fieldset>`;
};

export const selectHtml: PrimitiveRenderer = (node) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro-renderer/src/surfaces/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function surfaceTree(section: SurfaceSection, ctx: SurfaceContext = {}):
const index = section.components.indexOf(c);
if (invalidProps.has(String(index))) return undefined;
// A live surface keeps hidden components (the runtime shows them when their condition holds).
const shown = evaluateCondition(c.visible, data);
const shown = evaluateCondition(c.visible, data, { locale });
if (!shown && !live) return undefined;
const entry = catalog.get(c.component);
if (!entry) return undefined;
Expand Down
Loading
Loading