# Syntari > A design system for people and agents. 137 components, a machine-readable registry, and a renderer that validates what agents compose. 59 components have render contracts an agent can use from Screen IR. Syntari ships editable HTML, CSS, and JavaScript components. An agent describes a screen as Screen IR (JSON), validates it against the published contracts, and renders it with the same components people use. Actions in a rendered screen are requests: the host application decides whether anything runs. Install: `npm install syntari-ui`. MCP (read-only, stdio): `npx -y syntari-ui mcp`. Version 0.4.0. ## Agent integration Use Syntari as a bounded interface renderer. Retrieve its current contracts before composing a screen. 1. Discover components and patterns. From a terminal, run `syntari list --json`, `syntari patterns --json`, and `syntari info --json`. From an MCP client, connect `npx -y syntari-ui mcp` and use `search_components`, `list_components`, `get_component`, and `get_pattern`. Models without tools can read [llms.txt](https://syntariui.giovanitier.com/llms.txt) and [llms-full.txt](https://syntariui.giovanitier.com/llms-full.txt). Read `syntari registry policy --json` or the MCP tool `get_agent_policy` for the machine-readable rendering and action boundary. 2. Choose a pattern and components that fit the user's intent. Use only component ids declared by the registry. Prefer components marked `renderable`: they declare `props`, `ir`, `actions`, and a valid `sample`. Other components are documented but cannot render from Screen IR yet. 3. Produce JSON that matches `registry/schema/sui.schema.json`. Keep values inside each manifest's prop types, enum values, ranges, and list limits. Give every node that a person can act on a unique `id`. Structure an answer in sections (`{ "type": "section", "heading", "layout"?, "children" }`): the finding, the evidence, then the decision. Put skippable evidence and provenance in labelled regions. Explain numbers with Text block and Breakdown, let people explore with Calculator, whose formulas are arithmetic only, and never write an image URL: Media cards name image ids the host supplies. 4. Run `syntari validate --spec screen.json --json`, call `validateScreen(spec)` from `syntari-ui`, or use the MCP tool `validate_screen`. Repair every error before presenting the screen. Preserve warnings in the decision trace. 5. Render with `render(spec, target, { onActionRequest, assets })` from the installed browser runtime. Show the intent, selected pattern, candidate components, decision, Screen IR, validation results, and rendered interface so a person can inspect why the screen looks as it does. ## Screen data A screen that shows numbers states them once, in `data`, and binds every figure to it. The runtime computes each measure and group in code, so a headline, a chart, and a table cannot disagree: ```json "data": { "mode": "demo", "label": "Illustrative portfolio", "currency": "EUR", "locale": "es-ES", "tables": { "holdings": { "key": "asset", "fields": { "asset": { "type": "text", "label": "Asset" }, "weight": { "type": "percent", "label": "Weight", "total": 100, "role": "allocation" }, "liquidityDays": { "type": "days", "label": "Liquidity", "role": "liquidity" } }, "rows": [ ] } }, "measures": { "liquid30": { "label": "Liquid within 30 days", "sum": "holdings.weight", "where": { "liquidityDays": { "max": 30 } }, "format": "percent" } }, "groups": { "ladder": { "table": "holdings", "by": "liquidityDays", "value": "weight", "buckets": [{ "label": "D0", "max": 0 }, { "label": "D1–30", "max": 30 }, { "label": ">D30" }] } } } ``` - A table declares each field's `type` (`text`, `category`, `period`, `date`, `number`, `percent`, `currency`, `days`), and may declare a `total` the column must add up to and a `role` (`allocation`, `exposure`, `risk-contribution`, `volatility`, `return`, `benchmark`, `value`, `liquidity`, `count`). Allocation, exposure, volatility, and risk contribution are different things: a field holds one of them. - A measure is `sum`, `avg`, `min`, `max`, `count`, `first`, or `last` over `table.field`, with an optional `where`; a `formula` over other measures; or a stated `value` with its `source`. A group splits a table `by` a field, with `value` or `values`, ordered `buckets` whose `max` is inclusive, `order`, and `top`. Bucket boundaries must cover every row. - A prop binds with `{ "$data": "measures.liquid30" }`, `{ "$data": "groups.ladder", "map": { "label": "label", "a": "value" } }`, or `{ "$data": "tables.holdings", "cells": ["asset", { "field": "weight", "format": "percent" }], "sort": { "by": "weight", "dir": "desc" } }`. Formats are `percent`, `signed-percent`, `points`, `currency`, `compact-currency`, `number`, `integer`, `days`, and `text`, written in the screen's locale. - A literal figure whose label names data must agree with it: “Liquid within 30 days: 38 %” beside rows that add up to 48 % is a `data-conflict` error, and that part is not painted. Totals (`data-total`), bucket labels that do not match their rows (`bucket-mismatch`), uncovered rows (`bucket-gap`), and unknown references are errors too. A screen without data is checked against its own tables where it can be, as warnings, because the runtime cannot know which figure is canonical. - `mode` is `provided` or `demo`. Demonstration data is labelled under the title. ## Composition `registry/composition-policy.json` (MCP `get_composition_policy`, `getCompositionPolicy()` in Node) says which visualization fits which kind of data, and the order each kind of screen follows: a report, a dashboard, a transaction workflow, a workspace. Time series take a line or area chart; parts of a whole take a breakdown; ordered buckets take ordered bars; risk contribution is compared with weight, not shown as a second allocation; records take a sortable table. - Declare the decision in `meta.plan`: `{ pattern, purpose, questions, groups, alternatives, rationale, limits }`. It is shown to the person as the decision trace; it is not private reasoning. - Validation separates hard errors from advice. A visualization that misrepresents its data (a pie chart of a time series, a risk chart that repeats the allocation, `misleading-visualization`, `relabeled-data`) is an error. Order, repetition, density, and weak fits (`summary-not-first`, `repeated-metric`, `redundant-visualization`, `weak-visualization`) are advice: they are inspectable, and they are not presented as objective. - `assessScreen(spec)` or the MCP tool `assess_composition` returns the pattern, each part's data role and fit, seven criteria with a verdict, and a profile of the screen data that lists the visualizations each table, measure, and group supports. - When a request names a visualization Syntari does not have, such as a scatter plot or a map, answer `unsupported: [{ visualization, reason, closest }]` instead of imitating it with another component. ## Changing a screen Change a screen that exists with operations; never regenerate it to add one part. Give every node an id with `assignIds(spec)` (or the MCP tool `prepare_screen`), show the model the screen with its `versionOf(spec)`, and ask it for a change: ```json { "version": "syntari-ops-1", "base": "sv-5884dcb6", "summary": "Add a table of employees under the chart", "operations": [{ "op": "insert", "parent": "section-trend", "after": "area-chart", "node": { "id": "employee-table", "component": "syntari.table", "props": { } } }] } ``` `applyScreenOperations(spec, change, options)` in Node, `applyOperations(spec, change, options)` from the browser runtime, or the MCP tool `apply_operations` applies it. The model proposes; the runtime applies: - Each operation names its scope by id. `insert { parent?, after? | before?, node }` adds one node at that place. `replace { target, set: { "props.caption": … } }` changes only the listed fields; `replace { target, node }` replaces one node and keeps its id and kind; the screen title is `replace { target: "screen", set: { "title": … } }`. `move { target, parent?, after? | before? }` moves one node, unchanged; it must name where. `remove { target }` removes one node and what it holds. - `replace { target: "data", set: { "measures.excess": { … } } }` defines a measure or group; definitions are computed from the rows, so they add no figure. Tables, sources, and settings are protected data. - A broad request, such as “improve the hierarchy of the whole screen”, is one `recompose { screen }`, alone in its change: the whole new screen, with the same data. It may add measures and groups; it may not change tables or settings (`data-changed`), drop a record its tables showed (`dropped-records`), or quote a figure the data does not hold. A screen without data states its rows in `data` first, using only figures already on the screen. `diffScreens()` reports the moves and the data entries a recomposition touched. - A change written for another version is refused (`stale-base`). The result is compared with the base node by node, and any difference no operation declared is refused (`scope-violation`). `diffScreens(before, after)` returns the same comparison, for showing exactly what a change touched. - Existing data is protected: a figure or a record already on the screen cannot change unless the host passes `allowDataChanges` after a person confirmed it (`protected-data`; the refused result lists what would change in `authorization`). Titles, captions, labels, and notes are copy and may be reworded. - New figures need a source. With `data: "strict"`, the default, a number that appears neither on the screen nor in `options.sources` is refused (`unsourced-data`). With `data: "demo"` it is reported (`demo-data`), and the screen must say so: `meta.data = { "mode": "demo" }` renders a Demonstration data label under the title. - The whole result is validated, including its data and composition. Errors the base already had do not block the change; new ones do. On any error, `ok` is false and `spec` is null: keep the last valid screen. - Ask for the change in a form the model cannot break. Where the provider supports it, offer a tool whose input is the change and validate it inside the tool, handing problems back once; otherwise read exactly one JSON value from the answer and treat a second value or a cut-off answer as a failed attempt. Allow one automatic correction, then show a recovery state with the screen unchanged. Never ask a person to repair JSON or Screen IR. - Instead of operations, a model may answer `clarify` (`{ "kind": "new-screen" | "recompose" | "question", "message" }`), `needs` (`[{ "data", "reason" }]`), or `unsupported` (`[{ "visualization", "reason", "closest" }]`). The host asks the person. The model never switches to a new screen on its own: the interface decides whether a request edits this screen or starts another. ## Unverified blocks Some explanations need a visual no contract covers, such as a diagram. A host may allow unverified blocks: `{ "type": "sandbox", "label", "html", "height"? }`. They are rejected by default (`sandbox-not-allowed`). A host that passes `{ sandbox: true }` to `render()`, `validateScreen()`, or the MCP tool `validate_screen` gets them in a sandboxed frame with no scripts, no network, no forms, and no navigation, labelled as unverified, with a warning in the diagnostics. They can never request actions. Prefer registry components; use a block only for what no contract can show. ## Actions A contract declares what a person can ask for, such as `approve` and `reject` on Tool approval, `submit` on Prompt input, or `open` on Citations. When a person acts, the renderer calls `onActionRequest` and dispatches a bubbling `syntari:action` event with: ```json { "component": "tool-approval", "action": "approve", "id": "deploy", "path": "children[0]", "consequential": true } ``` Form actions add `values` with what the person entered. List actions add `index`. Declared actions never run demo behaviour and never perform anything. Re-render with the outcome once the host has decided. View controls, such as the period on Filter bar, Compare on Chart toolbar, or the chosen measure on Metric tabs, are `local` actions: the control updates itself, then the request reports its new state in `values` (`value`, `checked`, or `pressed`) so the host can load the matching data. Set an instance's consequence with its props where the contract allows it, such as `consequential: true` on a Delete button. Leave an optional prop out and its slot stays empty: the renderer never shows the template's example copy. Required text and labels cannot be empty strings, and every component that declares actions needs an `id`. Icon props take a name from `icons` in the registry index (also returned by `get_component`): a malformed name is an `invalid-icon` error, and a well-formed name the runtime does not ship is an `unknown-icon` warning that keeps the component's own icon. ## Forms A section with a `submit` label is a form: `{ "type": "section", "heading": "Invite a teammate", "submit": "Send invite", "children": [...] }`. Its fields are the form controls in the registry: Text input, Text area, Checkbox, Radio, Switch, Slider, Range slider, Number input, Searchable select, Multi-select, Date range picker, and Selectable cards. Each field is sent under its `name` prop; left out, the name is its label in camelCase, so "Work email" is sent as `workEmail`. Submitting reports one request with every value, typed: ```json { "component": "form", "action": "submit", "id": "invite", "path": "children[1]", "values": { "workEmail": "ana@example.com", "role": "editor", "notify": true, "seats": 3, "labels": ["Design"], "period": { "start": "2026-09-01", "end": "2026-09-30" } }, "consequential": false } ``` A number or a slider sends a number, a range slider a list of its low and high ends, a switch `true` or `false`, a checkbox group the list of checked values, a radio group the chosen value or `null`, and a searchable select the chosen option or `null`; text that is not one of the options is refused before anything is sent. Set `"consequential": true` on the section when sending it is hard to undo. Validation reports a field outside any form (`field-outside-form`, a warning: nothing it holds reaches the host), two fields sent under one name (`duplicate-field-name`), a form inside a form or a component that is a form of its own, such as Prompt input, inside one (`nested-form`), and a form with no fields (`form-without-fields`). ## Action boundary Screen validation controls UI composition. A button or approval card is a visual affordance; it does not grant permission to run a tool. The host application must keep authentication, tool allowlists, data scope, and human approval in trusted code outside the model's Screen IR. A model-provided `approved: true` value is not evidence of human consent, and an `approve` request is consent to ask for, not authorization. For a consequential action, the host should validate its arguments and scope, obtain approval through its own trusted state, then execute the action. Show the outcome and any failure in a new validated Screen IR result. Never infer permission from a rendered component or a model-authored provenance field. ## Example ```js import { getComponent, getPattern, validateScreen } from 'syntari-ui'; const approval = await getComponent('tool-approval'); const screen = { type: 'screen', children: [{ id: 'deploy', component: 'syntari.tool-approval', props: approval.sample.props }] }; const { ok, diagnostics } = await validateScreen(screen); const next = ok ? { status: 'ready', screen, diagnostics } : { status: 'repair', diagnostics }; ``` ## Agent policy ```json { "$id": "https://syntariui.giovanitier.com/registry/agent-policy.json", "version": "0.4.0", "screenIR": { "schema": "schema/sui.schema.json", "componentIndex": "index.json", "patternIndex": "patterns/index.json", "requiresValidation": true, "onError": "reject", "onWarning": "show-diagnostic", "allowedComponentStatus": "authored-with-props-and-ir", "unverifiedBlocks": { "default": "reject", "enabledBy": "host-render-option", "isolation": "sandboxed-frame-without-scripts-network-forms-or-navigation", "canRequestActions": false, "label": "always-shown" } }, "actions": { "authorizationOwner": "host-application", "default": "deny", "modelAuthoredApprovalIsAuthorization": false, "requiresTrustedScopeCheck": true } } ``` ## Screen IR in brief A screen is `{ "type": "screen", "layout": "stack" | "row" | "grid" | "two-column", "title"?, "children": [...] }`. A child is a component node `{ "id"?, "component": "syntari.", "props": {...}, "provenance"? }` a section `{ "type": "section", "heading", "layout"?, "children": [...] }` that groups nodes under a heading and never collapses, or a labelled region `{ "type": "region", "label", "open"?, "children": [...] }` for detail a person may skip. Components do not take children; group them in a section or a region. Calculator formulas are arithmetic only: numbers, input and earlier result names, + - * / ^, parentheses, min, max, round, floor, ceil, abs, and fv(rate, periods, payment). Media card images name an id the host supplies, never a URL. An unverified block `{ "type": "sandbox", "label", "html", "height"? }` holds static HTML; it is rejected unless the host allows unverified blocks, and is then shown isolated, labelled, and without scripts, network, or actions. Budgets: 120 nodes, depth 8, 40 top-level children, 40 items in any list prop. Errors stop a node and show a labelled fallback; warnings repair it. An optional prop left out leaves its slot empty. Icon props take one of these names: `grid`, `palette`, `chevron`, `down`, `external`, `sun`, `moon`, `download`, `search`, `x`, `copy`, `plus`, `code`, `cursor`, `type`, `check`, `circlecheck`, `sliders`, `chart`, `layers`, `bell`, `layout`, `list`, `heart`, `star`, `link`, `more`, `mail`, `lock`, `arrow`, `smile`, `gauge`, `funnel`, `settings`, `globe`, `info`, `folder`, `trash`, `edit`, `upload`, `chat`, `send`, `calendar`, `minus`, `eye`, `eye-off`, `clock`, `file`, `list-todo`, `terminal`, `bot`, `rotate-ccw`, `square`, `paperclip`, `image`, `external-link`, `thumbs-up`, `circle`, `chevron-left`, `arrow-up-down`, `arrow-up`. Complete compositions you can fetch with `syntari info --json` or the MCP tool `get_pattern`: - `release-review`: A report someone has to trust before they act on it. - `analytics-overview`: The north-star screen: a report a product team opens every morning. - `approval-review`: A consequential change needs a clear review before a person authorizes it. ## Render contracts ### Button (`syntari.button`) Trigger an immediate user action. Use when: A person triggers something that changes data, submits a form, or opens a dialog, drawer, or menu. One action is the main way forward in a region, with alternatives beside it. An action deletes or sends something that cannot be taken back, shown as danger and confirmed before it runs. Do not use when: The action takes the person to another page or address. Use a link, so it can be opened in a new tab and its address copied. The control is only an icon. Use `syntari.icon-button`. The action reports its progress while it runs, such as Saving… then Saved. Use `syntari.action-swap`. A person switches between views or values rather than starting an action. Use `syntari.segmented-control`. Props: - `label` (string): required; max 32 characters - `variant` ("primary" | "secondary" | "ghost" | "danger" | "outline"): optional; default "primary"; One primary per region. Danger only for destructive actions. - `disabled` (boolean): optional; default false - `consequential` (boolean): optional; default false; Set true when pressing it changes something outside the screen, such as Delete. Its requests then report consequential: true. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `press` — consequential when `consequential` is true: The person pressed the button. Valid sample: ```json { "component": "syntari.button", "props": { "label": "Save changes", "variant": "primary" } } ``` ### Text input (`syntari.text-input`) Ask for one line of text, such as a name, an email address, or a link. Use when: The answer is short and open: a name, a title, an email address, a link, or a phone number. It sits in a form, with its label above it, and is sent when the form is submitted. Do not use when: There are two to seven options a person should see at once. Use `syntari.radio`. The answer is one of a long, known list of options. Use `syntari.searchable-select`. The answer runs to several sentences. Use `syntari.text-area`. The text is a password or another secret. Use `syntari.password-field`. The value is a small number a person nudges up or down. Use `syntari.number-stepper`. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `type` ("text" | "email" | "url" | "tel" | "search"): optional; default "text"; What kind of text: an email or a link gets the matching keyboard and autofill. - `placeholder` (string): optional; max 80 characters; An example of an answer, shown until a person types. It never replaces the label. - `value` (string): optional; max 200 characters; Text the field starts with. - `icon` (string): optional; max 24 characters; Icon at the start of the field, such as "mail" or "search". - `required` (boolean): optional; default false; The form cannot be sent until a person fills this in. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.text-input", "props": { "label": "Work email", "type": "email", "placeholder": "you@company.com", "icon": "mail", "required": true, "hint": "The invite is sent here." } } ``` ### Switch (`syntari.switch`) Show settings a person turns on or off, each with what it does. Avoid when: the-choice-needs-a-confirmation, options-are-mutually-exclusive. Props: - `label` (string): optional; max 60 characters; Heading for the group, such as "Notifications". - `items` (array, at most 12 items): required; One switch per setting, in display order. - `label` (string): required; max 60 characters; The setting, such as "Weekly digest". With no name, it is also the key the switch is sent under. - `description` (string): optional; max 100 characters; What turning it on does. - `name` (string): optional; max 40 characters; The key this switch is sent under. Left out, it is the label in camelCase. - `checked` (boolean): optional; default false; The switch starts on. - `disabled` (boolean): optional; default false; The switch is shown but cannot be changed. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. Valid sample: ```json { "component": "syntari.switch", "props": { "label": "Notifications", "items": [ { "label": "Email notifications", "description": "The important things, in your inbox.", "checked": true }, { "label": "Weekly digest", "description": "A short recap every Monday." } ] } } ``` ### Checkbox (`syntari.checkbox`) Let a person pick any number of independent choices from a short list. Avoid when: only-one-choice-may-be-picked, a-choice-applies-at-once. Props: - `label` (string): required; max 60 characters; What the choices are for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `options` (array, at most 12 items): required; The choices, in display order. - `label` (string): required; max 60 characters; What the choice says. - `value` (string): optional; max 60 characters; What is sent when it is chosen. Defaults to the label. - `checked` (boolean): optional; default false; The choice starts selected. - `disabled` (boolean): optional; default false; The choice is shown but cannot be changed. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.checkbox", "props": { "label": "Notify me about", "options": [ { "label": "Mentions", "checked": true }, { "label": "Comments on my work" }, { "label": "Weekly summary" } ] } } ``` ### Radio (`syntari.radio`) Let a person pick exactly one of a few choices they can see at once. Avoid when: more-than-seven-options, several-choices-may-apply. Props: - `label` (string): required; max 60 characters; What is being chosen. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `options` (array, at most 12 items): required; The choices, in display order. One may start checked. - `label` (string): required; max 60 characters; What the choice says. - `value` (string): optional; max 60 characters; What is sent when it is chosen. Defaults to the label. - `checked` (boolean): optional; default false; The choice starts selected. - `disabled` (boolean): optional; default false; The choice is shown but cannot be changed. - `required` (boolean): optional; default false; The form cannot be sent until a person fills this in. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.radio", "props": { "label": "Who can see this project", "options": [ { "label": "Everyone in the workspace", "value": "workspace" }, { "label": "Only me", "value": "private", "checked": true }, { "label": "People I invite", "value": "invited" } ] } } ``` ### Badge (`syntari.badge-and-status`) Label a status or a category in a few words, with a tone that says what it means. Avoid when: the-status-needs-an-explanation, the-badges-would-be-actions. Props: - `badges` (array, at most 8 items): required; Badges in reading order. - `label` (string): required; max 24 characters - `tone` ("neutral" | "positive" | "warning" | "critical" | "accent"): optional; default "neutral" - `dot` (boolean): optional; default false; A status dot before the label, in the badge's colour. Valid sample: ```json { "component": "syntari.badge-and-status", "props": { "badges": [ { "label": "Active", "tone": "positive", "dot": true }, { "label": "3 pending", "tone": "warning" }, { "label": "Pro plan", "tone": "accent" } ] } } ``` ### Metric summary (`syntari.metric-and-sparkline`) Show the numbers that matter now and how they moved. Avoid when: the-value-needs-a-full-chart. Props: - `stats` (array, at most 4 items): required; Metrics, in display order. - `label` (string): required; max 30 characters - `value` (string): required; max 12 characters - `badge` (string): optional; max 12 characters - `tone` ("neutral" | "success" | "warning" | "danger"): optional; default "success" - `series` (array, at most 12 items): required; Bars, oldest to newest. - `label` (string): required; max 6 characters - `value` (number): required; 0 to 100 - `chartLabel` (string): optional; default "Values by period"; max 90 characters; Accessible name for the chart group. Valid sample: ```json { "component": "syntari.metric-and-sparkline", "props": { "chartLabel": "Runs by day", "stats": [ { "label": "Runs", "value": "1,284", "badge": "+18.6%", "tone": "danger" } ], "series": [ { "label": "Mon", "value": 33 }, { "label": "Tue", "value": 61 } ] } } ``` ### Avatar (`syntari.avatar-and-group`) Show who is involved, such as owners or reviewers, as a group of avatars with a caption. Avoid when: the-people-need-to-be-read-one-by-one, more-detail-than-a-name-matters. Props: - `people` (array, at most 6 items): required; The people shown, in order. - `name` (string): required; max 40 characters; Read by screen readers and shown on hover. - `initials` (string): optional; max 2 characters; One or two letters shown in the avatar. - `others` (number): optional; 1 to 9999; How many more people there are than are shown. - `caption` (string): optional; max 60 characters; Who they are, such as 'Reviewers on this change'. It also names the group for screen readers. Valid sample: ```json { "component": "syntari.avatar-and-group", "props": { "people": [ { "name": "Ada Okafor", "initials": "AO" }, { "name": "Jamie Chen", "initials": "JC" }, { "name": "Sam Rivera", "initials": "SR" } ], "others": 4, "caption": "Reviewers on this change" } } ``` ### Progress ring (`syntari.score`) Show one score out of 100 as a ring, with what it measures and what it means. Avoid when: the-value-is-not-out-of-100, several-scores-compare-to-each-other. Props: - `title` (string): required; max 40 characters; What is scored, such as 'Checks passed'. - `value` (number): required; 0 to 100; The score, out of 100. - `detail` (string): optional; max 80 characters; What the score means, such as '46 of 50 checks passed'. - `tone` ("neutral" | "positive" | "warning" | "critical"): optional; default "neutral"; What the score means: positive when it is good, warning or critical when it needs attention. Valid sample: ```json { "component": "syntari.score", "props": { "title": "Checks passed", "value": 92, "detail": "46 of 50 checks passed on the last run.", "tone": "positive" } } ``` ### Progress bar (`syntari.progress`) Show how far each of a few measures is toward its whole, as a bar and a written percentage. Avoid when: there-is-no-whole-to-measure-against, values-change-over-time. Props: - `bars` (array, at most 6 items): required; One bar per measure, in display order. - `label` (string): required; max 40 characters; What is measured and against what, such as 'Storage used'. - `value` (number): required; 0 to 100; Percent of the whole. Valid sample: ```json { "component": "syntari.progress", "props": { "bars": [ { "label": "Storage used", "value": 68 }, { "label": "Seats assigned", "value": 42 } ] } } ``` ### Banner (`syntari.banner`) Report an outcome or a needed action inside the current context. Avoid when: the-message-needs-a-decision-before-the-user-continues. Props: - `message` (string): required; max 90 characters; The outcome, in one line. - `detail` (string): optional; max 160 characters; Optional supporting context. - `tone` ("success" | "info"): optional; default "success"; Sentiment colour. The text still carries the meaning. - `icon` (string): optional; default "circlecheck"; max 24 characters; Syntari icon name. The registry index lists every name as icons. Valid sample: ```json { "component": "syntari.banner", "props": { "message": "Build 4281 passed", "detail": "128 tests in 42 seconds.", "tone": "success", "icon": "circlecheck" } } ``` ### Table (`syntari.table`) Compare records across the same few columns. Use when: People compare the same few attributes across many records, such as traffic sources and their visitors. Values must be read exactly, not estimated from a shape. People look for a particular record among many; make the table sortable past six rows. Do not use when: The point is a trend over time, not exact values. Use `syntari.line-chart`. The point is how a whole splits into parts. Use `syntari.breakdown`. Two or three options are compared on the same measures to pick one. Use `syntari.comparison-table`. People need to search, filter, select, edit, or page through many records. Use `syntari.data-table`. There are no rows yet. Use `syntari.empty-state`. Props: - `caption` (string): optional; default "Records"; max 90 characters; Screen-reader caption for the table. - `columns` (array, at most 6 items): required; Column headings, in order. - `label` (string): required; max 30 characters - `rows` (array, at most 20 items): required; One array of cell strings per row, one cell for each column. Columns of figures align on the right. - each item: array - `sortable` (boolean): optional; default false; Let people sort by any column: figures largest first, text A to Z. Valid sample: ```json { "component": "syntari.table", "props": { "caption": "Tool calls this week", "columns": [ { "label": "Tool" }, { "label": "Calls" } ], "rows": [ [ "read_files", "412" ], [ "search_docs", "238" ], [ "run_tests", "96" ] ] } } ``` ### Card (`syntari.card`) Hold one record or one idea in a surface that can stand on its own. Avoid when: the-detail-needs-a-full-screen. Props: - `title` (string): required; max 60 characters - `body` (string): optional; max 120 characters - `badge` (string): optional; max 24 characters; Short status word. - `badgeTone` ("neutral" | "accent" | "success" | "warning" | "danger"): optional; default "accent" - `action` (string): optional; max 28 characters; Label for the card action. - `mark` (string): optional; default "file"; max 24 characters; Syntari icon shown in the card's leading mark, so the card says what kind of thing it holds. The registry index lists every name as icons. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `open`: The person asked to open the item. Omit action to show no button. Valid sample: ```json { "component": "syntari.card", "props": { "title": "Q3 board pack", "body": "Revenue, retention, and hiring updates for the October meeting.", "badge": "Needs review", "badgeTone": "danger", "action": "Open pack" } } ``` ### Slider (`syntari.slider`) Let a person pick one value from a range when the exact number matters less than where it sits. Avoid when: the-exact-number-matters, the-range-has-more-than-a-few-hundred-steps. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `min` (number): required; Lowest value a person can choose. - `max` (number): required; Highest value a person can choose. - `step` (number): optional; default 1; Distance between values a person can choose. - `value` (number): optional; Where the slider starts. Defaults to min. - `unit` (string): optional; max 12 characters; Unit written with every figure: "$" leads it; "%" and words such as "seats" follow it. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. Valid sample: ```json { "component": "syntari.slider", "props": { "label": "Monthly events", "min": 1000, "max": 50000, "step": 1000, "value": 10000 } } ``` ### Empty state (`syntari.empty-state`) Explain what belongs in an empty region and offer the next useful step. Avoid when: the-region-has-content. Props: - `title` (string): required; max 60 characters - `body` (string): optional; max 140 characters - `action` (string): optional; max 28 characters - `icon` (string): optional; default "folder"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `create`: The person asked for the next step. Omit action to show no button. Valid sample: ```json { "component": "syntari.empty-state", "props": { "title": "No reports yet", "body": "Reports you generate appear here.", "action": "Create report", "icon": "plus" } } ``` ### Activity list (`syntari.activity-list`) Show what happened, in order, with the context needed to trust it. Avoid when: the-list-needs-sorting-or-filtering. Props: - `items` (array, at most 8 items): required; Events, newest first. - `icon` (string): optional; default "check"; max 24 characters - `title` (string): required; max 60 characters - `detail` (string): optional; max 120 characters - `time` (string): optional; max 24 characters Valid sample: ```json { "component": "syntari.activity-list", "props": { "items": [ { "icon": "plus", "title": "Run started", "detail": "3 tasks queued", "time": "2m ago" }, { "icon": "check", "title": "Tests passed", "detail": "128 tests in 42 s", "time": "1m ago" } ] } } ``` ### Chat composer (`syntari.chat-composer`) Show a short conversation and let the person send the next message. Avoid when: long-form-prompt, single-response. Props: - `messages` (array, at most 20 items): optional - `text` (string): required; max 300 characters - `from` ("assistant" | "person"): optional; default "assistant" - `placeholder` (string): optional; default "What would you like to make?"; max 80 characters - `label` (string): optional; default "Write a message"; max 60 characters - `hint` (string): optional; max 40 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `send` (form submit, sends values): The person sent a message. values.message holds the text; append it and re-render. Valid sample: ```json { "component": "syntari.chat-composer", "props": { "messages": [ { "text": "Which quarter should I compare?" }, { "text": "Q2 against Q3, please.", "from": "person" } ], "placeholder": "Reply to the assistant" } } ``` ### Text area (`syntari.text-area`) Ask for a few sentences, such as a description, a reason, or a note. Avoid when: the-answer-fits-on-one-line. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `placeholder` (string): optional; max 120 characters; An example of an answer, shown until a person types. - `value` (string): optional; max 2000 characters; Text the field starts with. - `rows` (number): optional; default 3; 2 to 12; Visible lines. - `maxLength` (number): optional; 1 to 5000; Most characters a person can enter. Set, it is counted under the field. - `required` (boolean): optional; default false; The form cannot be sent until a person fills this in. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.text-area", "props": { "label": "What should the reviewer know?", "placeholder": "Context, links, or open questions", "maxLength": 280, "rows": 4 } } ``` ### Searchable select (`syntari.searchable-select`) Let a person pick one option from a list too long to show at once, by typing to narrow it. Avoid when: fewer-than-six-options, several-choices-may-apply. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `options` (array, at most 40 items): required; The choices a person searches, in display order. - each item: string; max 60 characters - `value` (string): optional; max 60 characters; The option chosen at the start. It must be one of the options. - `placeholder` (string): optional; max 60 characters; A prompt to type, shown until a person does. - `required` (boolean): optional; default false; The form cannot be sent until a person fills this in. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.searchable-select", "props": { "label": "Assign to", "options": [ "Alex Morgan", "Jamie Chen", "Sam Rivera", "Robin Park", "Casey Lewis" ], "placeholder": "Search people…" } } ``` ### Multi-select (`syntari.multiselect`) Let a person pick several options from a searchable list and see what they picked. Avoid when: fewer-than-six-options, only-one-choice-may-be-picked. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `options` (array, at most 40 items): required; The choices a person searches, in display order. - each item: string; max 60 characters - `values` (array, at most 40 items): optional; Options chosen at the start. Each must be one of the options. - each item: string; max 60 characters - `placeholder` (string): optional; max 60 characters; A prompt to type, shown until a person does. - `required` (boolean): optional; default false; The form cannot be sent until a person chooses at least one option. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.multiselect", "props": { "label": "Labels", "options": [ "Design", "Research", "Engineering", "Marketing", "Operations" ], "values": [ "Design" ], "placeholder": "Find a label…" } } ``` ### Number input (`syntari.number-stepper`) Ask for a small whole number, such as seats or quantity, that a person nudges up or down. Avoid when: the-range-is-large, the-value-is-not-a-count. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `min` (number): required; Lowest value a person can choose. - `max` (number): required; Highest value a person can choose. - `step` (number): optional; default 1; Distance between values a person can choose. - `value` (number): optional; Where the count starts. Defaults to min. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.number-stepper", "props": { "label": "Seats", "min": 1, "max": 50, "value": 5, "hint": "Between 1 and 50 seats." } } ``` ### Date range picker (`syntari.date-range-picker`) Let a person choose a start and an end date, such as a reporting period or a trip. Avoid when: only-one-date-is-needed. Props: - `label` (string): required; max 60 characters; What the field asks for. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `start` (string): required; max 10 characters; First day of the range, written YYYY-MM-DD. - `end` (string): required; max 10 characters; Last day of the range, written YYYY-MM-DD. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. - `error` (string): optional; max 140 characters; A problem with the value, shown under the field and announced with it. It marks the field invalid. Valid sample: ```json { "component": "syntari.date-range-picker", "props": { "label": "Reporting period", "start": "2026-09-01", "end": "2026-09-30" } } ``` ### Selectable cards (`syntari.selectable-cards`) Let a person choose one option when each needs a sentence to explain it. Avoid when: more-than-four-options, choosing-commits-a-consequential-change. Props: - `question` (string): required; max 80 characters; What the person is choosing. - `name` (string): optional; max 40 characters; The key the choice is sent under inside a form section. Left out, it is the question in camelCase. - `options` (array, at most 4 items): required - `title` (string): required; max 40 characters - `description` (string): optional; max 100 characters - `icon` (string): optional; default "circle"; max 24 characters - `selected` (boolean): optional; default false Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `select` (the control updates itself and sends its new state): The person chose an option. index names it; the choice is shown at once and the host decides what it means. Valid sample: ```json { "component": "syntari.selectable-cards", "props": { "question": "Which plan fits your team?", "options": [ { "title": "Business", "description": "Unlimited projects and SSO.", "icon": "layers", "selected": true }, { "title": "Enterprise", "description": "Audit logs and a dedicated contact.", "icon": "lock" } ] } } ``` ### Error state (`syntari.error-and-retry`) Say what failed, what is kept, and offer one way forward. Avoid when: nothing-failed, the-region-is-empty-by-design. Props: - `title` (string): required; max 60 characters; What failed, such as 'Couldn’t load the invoices'. - `message` (string): optional; max 160 characters; Why, if known, and what is kept. - `action` (string): optional; max 28 characters; The retry button's label. Omit it to show no button. - `icon` (string): optional; default "info"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `retry`: The person asked to try again. Re-render with the result; omit action to show no button. Valid sample: ```json { "component": "syntari.error-and-retry", "props": { "title": "Couldn’t load the invoices", "message": "The billing service did not answer. Your filters are kept.", "action": "Try again", "icon": "info" } } ``` ### Metadata list (`syntari.metadata-list`) Show the label and value pairs that describe one record. Avoid when: the-record-has-more-than-eight-pairs. Props: - `items` (array, at most 8 items): required; Label and value pairs. - `label` (string): required; max 40 characters - `value` (string): required; max 80 characters Valid sample: ```json { "component": "syntari.metadata-list", "props": { "items": [ { "label": "Owner", "value": "Ada Okafor" }, { "label": "Updated", "value": "Oct 14, 2026" }, { "label": "Status", "value": "In review" } ] } } ``` ### Agent todo list (`syntari.agent-todo-list`) Show an agent's plan as tasks with their state, and how many are done. Avoid when: the-steps-are-instructions-for-a-person, the-person-ticks-the-steps. Props: - `title` (string): required; max 60 characters; What the plan is for. - `tasks` (array, at most 12 items): required; The tasks in order. - `label` (string): required; max 80 characters - `state` ("pending" | "active" | "done" | "failed" | "skipped"): optional; default "pending" - `detail` (string): optional; max 60 characters; A word on the task's state, such as '1.2M of 3.4M rows'. Left out, it says the state. - `mark` (string): optional; max 24 characters; An icon name. Left out, it follows the state. - `open` (boolean): optional; default true; Show the tasks, or only the summary. Valid sample: ```json { "component": "syntari.agent-todo-list", "props": { "title": "Migrate the billing tables", "tasks": [ { "label": "Back up the current tables", "state": "done" }, { "label": "Copy rows to the new schema", "state": "active", "detail": "1.2M of 3.4M rows" }, { "label": "Compare row counts", "state": "pending" }, { "label": "Switch reads to the new tables", "state": "pending" } ] } } ``` ### Agent message (`syntari.agent-message`) Show one settled agent response with its author and the follow-ups a person can ask for. Avoid when: response-still-streaming, system-notice. Props: - `author` (string): required; max 40 characters - `text` (string): required; max 600 characters; The settled response. - `time` (string): optional; max 24 characters; Relative or absolute time, as written. - `icon` (string): optional; default "bot"; max 24 characters - `helpful` (boolean): optional; default false; Whether the person already marked the response as helpful. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `helpful`: The person toggled 'helpful'. Re-render with the new helpful value. - `regenerate`: The person asked for a new response. Valid sample: ```json { "component": "syntari.agent-message", "props": { "author": "Research assistant", "text": "Revenue grew 12% in Q3, led by renewals in the enterprise tier.", "time": "just now" } } ``` ### Prompt input (`syntari.prompt-input`) Let a person write the next request to an agent, optionally with one attachment. Avoid when: structured-input-with-known-fields, single-choice-question. Props: - `label` (string): optional; default "Message"; max 60 characters; Accessible name of the field. - `placeholder` (string): optional; default "What would you like to make?"; max 80 characters - `hint` (string): optional; max 40 characters; Text beside the attachment button. - `status` (string): optional; max 80 characters; Status line under the field. Empty by default; the host fills it after a submission. - `submitLabel` (string): optional; default "Send message"; max 24 characters; Accessible name of the send button. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `submit` (form submit, sends values): The person sent a prompt. values.prompt holds the text; values.attachment holds a File when one was chosen. Valid sample: ```json { "component": "syntari.prompt-input", "props": { "label": "Ask the research assistant", "placeholder": "Ask about the Q3 report", "hint": "PDF or CSV" } } ``` ### Code block (`syntari.code-block`) Show code or a command exactly as written, ready to copy. Avoid when: the-code-changes-a-file, the-text-is-prose. Props: - `title` (string): required; max 60 characters; What the code is or does, such as a file name or 'Install the button'. - `language` (string): optional; max 16 characters; Its language, such as 'Shell' or 'CSS'. - `code` (string): required; max 4000 characters; The code, with its line breaks. - `note` (string): optional; max 80 characters; Small print under the code, such as where to run it. Valid sample: ```json { "component": "syntari.code-block", "props": { "title": "Install the button", "language": "Shell", "code": "npx syntari-ui add button", "note": "Run it in your project root." } } ``` ### Approval card (`syntari.approval-card`) Summarize a completed piece of work and ask for a decision on it. Avoid when: nothing-has-been-produced-yet. Props: - `title` (string): required; max 60 characters - `summary` (string): required; max 160 characters; What was produced. - `detail` (string): required; max 120 characters; Where the change lands. - `hint` (string): optional; max 120 characters; Small print under the request. Empty by default. - `icon` (string): optional; default "circlecheck"; max 24 characters - `approveLabel` (string): optional; default "Approve"; max 24 characters - `rejectLabel` (string): optional; default "Decline"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `approve` — consequential: The person approved the result. The host decides what approval allows. - `reject`: The person declined the result. Valid sample: ```json { "component": "syntari.approval-card", "props": { "title": "Review the release notes", "summary": "Release notes for 4.2 are drafted from 18 merged changes.", "detail": "docs/releases/4.2.md" } } ``` ### File diff (`syntari.file-diff`) Show a proposed change to one file and ask the person to keep or discard it. Avoid when: many-files-at-once, binary-files. Props: - `file` (string): required; max 80 characters - `summary` (string): optional; max 60 characters; Such as '2 additions · 2 deletions'. - `icon` (string): optional; default "file"; max 24 characters - `lines` (array, at most 40 items): required - `kind` ("add" | "remove" | "context"): required - `text` (string): required; max 160 characters; The line as shown, including its + or − marker. - `open` (boolean): optional; default true - `approveLabel` (string): optional; default "Keep changes"; max 24 characters - `rejectLabel` (string): optional; default "Discard"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `approve` — consequential: The person asked to keep the change. Apply it only after the host's own checks. - `reject`: The person asked to discard the change. Valid sample: ```json { "component": "syntari.file-diff", "props": { "file": "pricing.css", "summary": "1 addition · 1 deletion", "lines": [ { "kind": "context", "text": " .plan {" }, { "kind": "remove", "text": "− padding: 12px;" }, { "kind": "add", "text": "+ padding: var(--space-4);" }, { "kind": "context", "text": " }" } ] } } ``` ### Tool result (`syntari.tool-result`) Report what a tool run produced, with its outcome and key figures, and let a person ask for another run. Avoid when: long-form-output, streaming-output. Props: - `title` (string): required; max 60 characters - `summary` (string): optional; max 80 characters; One line under the title. - `statusLabel` (string): optional; default "Complete"; max 24 characters - `tone` ("success" | "warning" | "danger" | "neutral"): optional; default "success" - `icon` (string): optional; default "terminal"; max 24 characters - `fields` (array, at most 8 items): optional - `label` (string): required; max 32 characters - `value` (string): required; max 48 characters - `note` (string): optional; max 120 characters; Small print under the values. - `open` (boolean): optional; default false; Start expanded. - `rerunLabel` (string): optional; default "Run again"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `rerun` — consequential: The person asked to run the tool again. Treat it like any tool call: check scope and consent first. Valid sample: ```json { "component": "syntari.tool-result", "props": { "title": "Lint check", "summary": "12 files checked · 1 warning", "statusLabel": "Warning", "tone": "warning", "fields": [ { "label": "Files", "value": "12" }, { "label": "Warnings", "value": "1" }, { "label": "Duration", "value": "1.4 s" } ] } } ``` ### Streaming response (`syntari.streaming-response`) Show an agent response as it arrives, with an explicit way to stop it. Avoid when: the-answer-is-a-single-fixed-value. Props: - `author` (string): required; max 40 characters - `text` (string): required; max 400 characters; The settled response text. - `status` (string): optional; max 40 characters; Short status shown beside the author. - `action` (string): optional; default "Generate response"; max 32 characters - `icon` (string): optional; default "bot"; max 24 characters - `streaming` (boolean): optional; default false; True while text is arriving: Stop is available and Generate is not. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `generate`: The person asked for a response. Stream it by re-rendering with new text. - `stop`: The person asked to stop the response. Valid sample: ```json { "component": "syntari.streaming-response", "props": { "author": "Research assistant", "text": "Renewals grew 18% quarter over quarter, mostly in the enterprise tier.", "status": "Complete" } } ``` ### Tool approval (`syntari.tool-approval`) Require explicit human consent before an agent runs a consequential or external operation. Avoid when: low-risk-reversible-action, background-local-read. Props: - `title` (string): required; max 60 characters - `question` (string): required; max 120 characters; What the agent intends to do. - `scope` (string): required; max 90 characters; What the action touches. - `hint` (string): optional; max 120 characters; Small print under the request. Empty by default. - `icon` (string): optional; default "terminal"; max 24 characters - `approveLabel` (string): optional; default "Approve"; max 24 characters - `rejectLabel` (string): optional; default "Decline"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `approve` — consequential: The person approved the request. This is consent to ask for, not authorisation: the host still checks scope and policy before running anything. - `reject`: The person declined. Do not run the tool. Valid sample: ```json { "component": "syntari.tool-approval", "props": { "title": "Run the billing migration?", "question": "Apply three schema changes to the billing database in production.", "scope": "billing-production · write access" } } ``` ### Citations (`syntari.citations`) Disclose the sources behind an answer next to the answer, numbered and expandable. Avoid when: no-sources, ranking-sources-by-share. Props: - `title` (string): optional; default "Sources"; max 40 characters - `meta` (string): optional; max 40 characters; Count or scope, such as '2 references'. - `text` (string): optional; max 240 characters; The claim the sources support. - `icon` (string): optional; default "link"; max 24 characters - `sources` (array, at most 8 items): required - `number` (number): required; 1 to 99 - `title` (string): required; max 60 characters - `excerpt` (string): optional; max 200 characters; The passage the claim rests on. - `linkLabel` (string): optional; default "Open source"; max 32 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `open`: The person asked to open a source. detail.index is its position in sources. Valid sample: ```json { "component": "syntari.citations", "props": { "meta": "2 references", "text": "Renewals drove most of the growth.", "sources": [ { "number": 1, "title": "Q3 board report", "excerpt": "Renewal revenue rose 18% quarter over quarter." }, { "number": 2, "title": "Sales dashboard", "excerpt": "Enterprise renewals closed at 94%." } ] } } ``` ### Agent activity (`syntari.agent-activity`) Keep a chronological record of what an agent did, separate from its plan. Avoid when: future-steps, single-event. Props: - `title` (string): optional; default "Recent activity"; max 40 characters - `meta` (string): optional; max 40 characters; Scope, such as 'This run'. - `icon` (string): optional; default "list"; max 24 characters - `events` (array, at most 12 items): required - `title` (string): required; max 60 characters - `detail` (string): optional; max 80 characters - `time` (string): optional; max 24 characters - `icon` (string): optional; default "circlecheck"; max 24 characters Valid sample: ```json { "component": "syntari.agent-activity", "props": { "meta": "This run", "events": [ { "title": "Workspace opened", "detail": "3 files in scope", "time": "2m ago" }, { "title": "Files read", "detail": "pricing.css, plans.json, README.md", "time": "1m ago" }, { "title": "Change proposed", "detail": "pricing.css", "time": "now", "icon": "file" } ] } } ``` ### Range slider (`syntari.range-slider`) Let a person set a lowest and a highest value, such as a budget or a price range. Avoid when: only-one-value-is-needed. Props: - `label` (string): required; max 60 characters; What the interval measures. It names the control and, with no name prop, the key its value is sent under. - `name` (string): optional; max 40 characters; The key the value is sent under, such as "email". Left out, it is the label in camelCase: "Work email" is sent as workEmail. - `min` (number): required; Lowest value a person can choose. - `max` (number): required; Highest value a person can choose. - `step` (number): optional; default 1; Distance between values a person can choose. - `low` (number): optional; Where the low end starts. Defaults to min. - `high` (number): optional; Where the high end starts. Defaults to max. - `unit` (string): optional; max 12 characters; Unit written with every figure: "$" leads it; "%" and words such as "seats" follow it. - `hint` (string): optional; max 140 characters; Help that stays under the field and is read with it, such as a format or a limit. Valid sample: ```json { "component": "syntari.range-slider", "props": { "label": "Monthly budget", "min": 0, "max": 1000, "step": 10, "low": 200, "high": 800, "unit": "$" } } ``` ### Agent questions (`syntari.agent-questions`) Ask the person one focused question with a small set of answers, plus an optional note. Avoid when: open-ended-request, consequential-approval. Props: - `question` (string): required; max 80 characters - `options` (array, at most 6 items): required - `value` (string): required; max 40 characters - `selected` (boolean): optional; default false - `noteLabel` (string): optional; default "Anything to preserve?"; max 40 characters - `notePlaceholder` (string): optional; default "Optional details"; max 60 characters - `status` (string): optional; max 60 characters - `submitLabel` (string): optional; default "Send answer"; max 24 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `submit` (form submit, sends values): The person answered. values.choice holds the chosen option; values.notes holds the note. Valid sample: ```json { "component": "syntari.agent-questions", "props": { "question": "Who is this summary for?", "options": [ { "value": "Board", "selected": true }, { "value": "Team" }, { "value": "Customers" } ] } } ``` ### Bar chart (`syntari.chart-bars`) Compare two series across the same checkpoints. Avoid when: the-series-need-different-scales, a-trend-over-many-periods, parts-of-one-whole-that-sum-to-100. Props: - `title` (string): required; max 60 characters - `note` (string): optional; max 60 characters - `orientation` ("horizontal" | "vertical"): optional; default "horizontal"; vertical draws columns, for ordered buckets such as a liquidity or maturity ladder; horizontal draws bars, for ranked or compared categories. - `unit` ("" | "%" | "pp"): optional; Written after each value. - `seriesA` (string): optional; max 24 characters - `seriesB` (string): optional; max 24 characters; Name of the second series. Leave it and every b out for one series. - `chartLabel` (string): required; max 160 characters; What a screen reader hears instead of the bars, so include the values, such as 'Runs by day, this week against last: Mon 41 vs 30'. - `columns` (array, at most 12 items): required; Categories in display order: buckets in their natural order, ranked categories largest first. Bars are drawn against the largest value, and each label shows its values. - `label` (string): required; max 40 characters; Up to 6 characters reads best in vertical columns; horizontal bars take a full name, such as a product or an asset class. - `a` (number): required; 0 to 100; The first series, 0 to 100: a share or an index. - `b` (number): optional; 0 to 100; The second series on the same scale. Leave it out for one series. Valid sample: ```json { "component": "syntari.chart-bars", "props": { "title": "Runs by period", "note": "Indexed", "seriesA": "This week", "seriesB": "Last", "chartLabel": "Runs by period, this week against last: Mon 41 vs 30", "columns": [ { "label": "Mon", "a": 41, "b": 30 } ] } } ``` ### Donut chart (`syntari.chart-donut`) Show how one whole splits into two to four parts, with the whole in the middle. Avoid when: the-parts-are-not-shares-of-one-total, more-than-four-parts, values-change-over-time, parts-need-a-precise-comparison. Props: - `title` (string): required; max 60 characters; What the whole is. - `note` (string): optional; max 40 characters; Small print beside the title, such as the period or the source. - `total` (string): optional; max 10 characters; The whole, as written, in the centre of the ring, such as '96' or '$4.2M'. - `unit` (string): optional; max 16 characters; What the total counts, under it, such as 'decisions'. - `chartLabel` (string): optional; max 160 characters; What the ring shows, for screen readers. Defaults to the title. - `segments` (array, at most 4 items): required; Two to four parts in display order, largest first. Shares add up to 100. A fourth part is drawn neutral, so make it the rest, such as 'Other'. - `label` (string): required; max 24 characters - `share` (number): required; 0 to 100; Percent of the whole. Valid sample: ```json { "component": "syntari.chart-donut", "props": { "title": "Decisions this week", "note": "96 reviewed", "total": "96", "unit": "decisions", "segments": [ { "label": "Approved", "share": 62 }, { "label": "Waiting", "share": 23 }, { "label": "Declined", "share": 15 } ] } } ``` ### Heatmap (`syntari.heatmap`) Show one measure for every pair of two categories, so the pattern is visible before any number is read. Avoid when: the-cells-hold-different-measures, one-of-the-categories-has-only-one-value, exact-values-matter-more-than-the-pattern. Props: - `title` (string): required; max 60 characters; What every cell measures, such as 'Failed runs by day and time'. - `note` (string): optional; max 40 characters; Small print beside the title, such as the period. - `columns` (array, at most 24 items): required; Column labels, in order. - each item: string; max 12 characters - `rows` (array, at most 12 items): required; One row per category, each with one number per column, in column order. - `label` (string): required; max 20 characters - `values` (array): required - `unit` (string): optional; max 12 characters; What the figures count, written with each one, such as 'runs', '%', or '$'. - `showValues` (boolean): optional; default false; Write the figures in the cells. Leave off for large grids; screen readers and tooltips read them either way. Valid sample: ```json { "component": "syntari.heatmap", "props": { "title": "Failed runs by day and time", "note": "Last four weeks", "columns": [ "Night", "Morning", "Afternoon", "Evening" ], "rows": [ { "label": "Mon", "values": [ 2, 14, 9, 4 ] }, { "label": "Tue", "values": [ 1, 11, 8, 3 ] }, { "label": "Wed", "values": [ 3, 18, 12, 5 ] }, { "label": "Thu", "values": [ 2, 9, 7, 2 ] }, { "label": "Fri", "values": [ 1, 6, 5, 1 ] } ], "unit": "runs", "showValues": true } } ``` ### Treemap (`syntari.treemap`) Show the parts of a whole by size, nested in up to three groups, when there are too many parts for a bar. Avoid when: the-values-are-not-parts-of-one-whole, values-change-over-time, fewer-than-four-parts, parts-need-a-precise-comparison. Props: - `title` (string): required; max 60 characters; What the whole is. - `note` (string): optional; max 40 characters; Small print beside the title, such as the period. - `tiles` (array, at most 16 items): required; The parts, each with a value above 0 that sets its area. Give tiles a group to nest them; three groups get a colour. - `label` (string): required; max 24 characters - `value` (number): required; at least 0; The part's size, in any one unit. Its area is its share of the sum. - `display` (string): optional; max 12 characters; The value as written, such as '$41.2k'. Left out, the tile shows its share. - `group` (string): optional; max 20 characters; The group the tile belongs to, such as 'Compute'. Valid sample: ```json { "component": "syntari.treemap", "props": { "title": "Cloud spend by service", "note": "September", "tiles": [ { "label": "EC2", "value": 41.2, "display": "$41.2k", "group": "Compute" }, { "label": "Lambda", "value": 6.1, "display": "$6.1k", "group": "Compute" }, { "label": "RDS", "value": 18.3, "display": "$18.3k", "group": "Data" }, { "label": "Redshift", "value": 9.6, "display": "$9.6k", "group": "Data" }, { "label": "S3", "value": 12.4, "display": "$12.4k", "group": "Storage" }, { "label": "EBS", "value": 7.9, "display": "$7.9k", "group": "Storage" }, { "label": "Other", "value": 4.5, "display": "$4.5k" } ] } } ``` ### Comparison table (`syntari.comparison-table`) Measure the same thing across options and name the advantage. Avoid when: more-than-two-options. Props: - `caption` (string): optional; max 90 characters - `optionA` (string): required; max 24 characters - `optionB` (string): required; max 24 characters - `rows` (array, at most 8 items): required - `metric` (string): required; max 40 characters - `a` (string): required; max 40 characters - `b` (string): required; max 40 characters - `advantage` (string): required; max 24 characters Valid sample: ```json { "component": "syntari.comparison-table", "props": { "caption": "Plans compared", "optionA": "Free", "optionB": "Growth", "rows": [ { "metric": "Projects", "a": "1", "b": "Unlimited", "advantage": "Growth" } ] } } ``` ### Stat row (`syntari.stat-row`) Put the few measures that matter side by side. Avoid when: the-values-need-a-shared-scale. Props: - `stats` (array, at most 6 items): required - `label` (string): required; max 24 characters - `value` (string): required; max 12 characters - `badge` (string): optional; max 12 characters - `note` (string): optional; max 40 characters - `tone` ("neutral" | "success" | "warning" | "danger"): optional; default "success" Valid sample: ```json { "component": "syntari.stat-row", "props": { "stats": [ { "label": "Runs", "value": "1,284", "badge": "+18.6%", "note": "this week" } ] } } ``` ### Pipeline stages (`syntari.process-ledger`) Show the stages of a pipeline and what each one cost. Avoid when: the-work-is-a-single-step. Props: - `stages` (array, at most 8 items): required - `title` (string): required; max 60 characters - `detail` (string): optional; max 80 characters - `time` (string): optional; max 12 characters - `mark` (string): optional; max 24 characters; An icon name. Left out, it follows state: check when done, clock when current, circle when pending. - `state` ("done" | "current" | "pending"): optional; default "pending" - `total` (string): optional; max 12 characters - `budget` (string): optional; max 40 characters Valid sample: ```json { "component": "syntari.process-ledger", "props": { "total": "3.1ms", "budget": "under budget", "stages": [ { "title": "Resolve", "detail": "Match the intent", "time": "0.3ms", "mark": "check", "state": "done" } ] } } ``` ### Playback controls (`syntari.transport-controls`) Drive something that runs: play, pause, step, and a speed. Avoid when: the-output-is-static. Props: - `speed` (number): optional; default 8; 1 to 30 - `steps` (number): optional; default 40; 4 to 200 - `label` (string): optional; max 60 characters - `playing` (boolean): optional; default false; Whether the host is running it. Sets the Play button's pressed state. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `play`: The person asked to start or pause. The host runs or pauses the work, then renders playing and label. - `step`: The person asked to advance one step. - `reset`: The person asked to return to the first step. - `speed` (the control updates itself and sends its new state): The person set a new speed. values.value is frames per second. Valid sample: ```json { "component": "syntari.transport-controls", "props": { "speed": 12, "steps": 60, "playing": false, "label": "Paused at step 12 of 60" } } ``` ### Live stats (`syntari.live-readout`) Show values that change while someone watches. Avoid when: the-value-never-changes. Props: - `title` (string): required; max 40 characters - `state` (string): required; max 24 characters - `tone` ("neutral" | "success" | "warning" | "danger"): optional; default "success" - `note` (string): optional; max 60 characters - `values` (array, at most 6 items): required - `label` (string): required; max 24 characters - `value` (string): required; max 16 characters Valid sample: ```json { "component": "syntari.live-readout", "props": { "title": "Render run", "state": "Running", "values": [ { "label": "Generation", "value": "27" }, { "label": "Nodes", "value": "1,204" } ] } } ``` ### Area chart (`syntari.area-chart`) Show one measure over time, with its scale stated. Avoid when: more-than-one-measure-needs-comparing. Props: - `title` (string): required; max 60 characters - `note` (string): optional; max 40 characters - `chartLabel` (string): optional; max 160 characters; Accessible name for the plot. Defaults to the title. - `points` (array, at most 40 items): required; Percentages over time, oldest first. Each value is shown with a % sign. - `label` (string): required; max 6 characters - `value` (number): required; 0 to 100; A percentage from 0 to 100. Valid sample: ```json { "component": "syntari.area-chart", "props": { "title": "Mention rate", "note": "Last 30 days", "chartLabel": "Mention rate", "points": [ { "label": "D1", "value": 40 }, { "label": "D2", "value": 55 } ] } } ``` ### Line chart (`syntari.line-chart`) Show how one measure moves over time, optionally against a benchmark on the same scale. Avoid when: parts-of-one-whole, categories-without-an-order, more-than-two-series. Props: - `title` (string): required; max 60 characters - `note` (string): optional; max 40 characters; The basis or the period, such as "Cumulative, this year". - `chartLabel` (string): required; max 200 characters; What a screen reader hears instead of the lines: the range and where each series ends. - `unit` ("%" | "pp" | "x" | "€" | "$" | "£" | ""): optional; default "%"; Written after every value on the axis and on each point. - `seriesA` (string): optional; default "Actual"; max 24 characters; Name of the main series. - `seriesB` (string): optional; max 24 characters; Name of the benchmark. Give it whenever points carry compare. - `points` (array, at most 40 items): required; Periods, oldest first. Values may be negative; both series share one scale. - `label` (string): required; max 8 characters; The period, such as "Mar" or "Q2". - `value` (number): required - `compare` (number): optional; The benchmark for the same period. Valid sample: ```json { "component": "syntari.line-chart", "props": { "title": "Return against benchmark", "note": "Cumulative, this year", "unit": "%", "seriesA": "Portfolio", "seriesB": "Benchmark", "chartLabel": "Portfolio return this year, up to 3.4% in September against 2.5% for the benchmark, below zero in April.", "points": [ { "label": "Jan", "value": 0, "compare": 0 }, { "label": "Feb", "value": 1.2, "compare": 0.9 }, { "label": "Mar", "value": 0.6, "compare": 0.8 }, { "label": "Apr", "value": -0.8, "compare": -0.3 }, { "label": "May", "value": 0.4, "compare": 0.2 }, { "label": "Jun", "value": 1.9, "compare": 1.1 }, { "label": "Jul", "value": 2.6, "compare": 1.8 }, { "label": "Aug", "value": 2.1, "compare": 1.6 }, { "label": "Sep", "value": 3.4, "compare": 2.5 } ] } } ``` ### Chart toolbar (`syntari.chart-toolbar`) Scope a chart before reading it. Avoid when: the-chart-has-nothing-to-scope. Props: - `compareLabel` (string): optional; max 60 characters - `compare` (boolean): optional; default true - `viewLabel` (string): optional; max 30 characters - `viewActive` (boolean): optional; default false - `legend` (string): optional; max 40 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `compare` (the control updates itself and sends its new state): The person turned the comparison on or off. values.checked is the new state; the host redraws the chart. - `view` (the control updates itself and sends its new state): The person switched the view. values.pressed is the new state; the host changes the data it shows. Valid sample: ```json { "component": "syntari.chart-toolbar", "props": { "compareLabel": "Compare with the prior period", "compare": false, "viewLabel": "View numbers", "legend": "Mention rate" } } ``` ### Metric tabs (`syntari.metric-strip`) Let one measure be the focus of a report. Avoid when: the-measures-are-not-alternatives. Props: - `items` (array, at most 6 items): required - `label` (string): required; max 24 characters - `icon` (string): optional; max 24 characters - `value` (string): required; max 12 characters - `delta` (string): optional; max 12 characters - `direction` ("up" | "down" | "none"): optional; default "up" - `meaning` (string): optional; max 40 characters - `active` (boolean): optional; default false Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `select` (the control updates itself and sends its new state): The person chose the measure to chart. index says which; the host charts it. Valid sample: ```json { "component": "syntari.metric-strip", "props": { "items": [ { "label": "Mention rate", "icon": "eye", "value": "45.4%", "delta": "3.3 pp", "direction": "up", "meaning": "of answers" }, { "label": "Leads", "icon": "star", "value": "2", "direction": "down" } ] } } ``` ### Headline metric (`syntari.headline-metric`) State the one number a screen is about. Avoid when: the-number-needs-no-emphasis. Props: - `metrics` (array, at most 4 items): required - `label` (string): required; max 30 characters - `value` (string): required; max 12 characters - `total` (string): optional; max 8 characters - `delta` (string): optional; max 12 characters - `direction` ("up" | "down" | "none"): optional; default "up" Valid sample: ```json { "component": "syntari.headline-metric", "props": { "metrics": [ { "label": "Lost questions", "value": "12", "total": "/ 125", "delta": "50.0%", "direction": "down" } ] } } ``` ### Filter bar (`syntari.filter-bar`) Say what a view is scoped to. Avoid when: the-view-has-nothing-to-scope. Props: - `period` (string): optional; max 24 characters; The label of the period the view shows now. The matching period is marked selected. - `periods` (array, at most 6 items): optional - `label` (string): required; max 24 characters - `value` (string): required; max 24 characters - `filtersLabel` (string): optional; default "Filters"; max 30 characters - `settingsLabel` (string): optional; default "Chart settings"; max 30 characters Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `period` (the control updates itself and sends its new state): The person chose a reporting period. values.value is that period's value; the host loads the data for it. - `filters`: The person asked for filters. The host opens its own filter controls. - `settings`: The person asked for chart settings. The host opens them. Valid sample: ```json { "component": "syntari.filter-bar", "props": { "period": "Last 90 days", "periods": [ { "label": "Last 7 days", "value": "Last 7 days" }, { "label": "Last 90 days", "value": "Last 90 days" } ], "filtersLabel": "Filters" } } ``` ### Bar list (`syntari.source-list`) Show which sources make up a number. Avoid when: the-rows-are-not-a-share-of-one-total. Props: - `sources` (array, at most 8 items): required - `name` (string): required; max 24 characters - `share` (number): required; 0 to 100 Valid sample: ```json { "component": "syntari.source-list", "props": { "sources": [ { "name": "ChatGPT", "share": 42 }, { "name": "Perplexity", "share": 26 } ] } } ``` ### Text block (`syntari.text-block`) Explain in words what the components around it show. Avoid when: the-text-repeats-a-component-beside-it, the-content-is-a-table-of-records. Props: - `body` (string): required; max 600 characters; The explanation. Plain text. - `items` (array, at most 8 items): optional; Steps or points under the paragraph, in order. - each item: string; max 160 characters - `listStyle` ("numbered" | "bulleted"): optional; default "numbered"; Numbered when order matters, such as steps. - `note` (string): optional; max 200 characters; Small print, such as an assumption or a source. Valid sample: ```json { "component": "syntari.text-block", "props": { "body": "Economists usually measure GDP by adding up four kinds of spending in a year.", "items": [ "Consumption: what households buy.", "Investment: what businesses spend on equipment and buildings." ] } } ``` ### Breakdown bar (`syntari.breakdown`) Show how one whole splits into parts. Avoid when: the-parts-are-not-shares-of-one-total, more-than-six-parts, values-change-over-time. Props: - `title` (string): required; max 60 characters; What the whole is. - `total` (string): optional; max 16 characters; The whole, as written, such as '$1T'. - `segments` (array, at most 6 items): required; Parts in display order. Shares add up to 100. A value is the part's amount, such as '$600B'; leave it out rather than repeat the share. - `label` (string): required; max 32 characters - `value` (string): optional; max 16 characters; The part's amount as written, such as '$600B'. - `share` (number): required; 0 to 100; Percent of the whole. - `note` (string): optional; max 160 characters; Small print, such as a source or an assumption. Valid sample: ```json { "component": "syntari.breakdown", "props": { "title": "Where the $48,200 invoice comes from", "total": "$48,200", "segments": [ { "label": "Seats", "value": "$31,000", "share": 64 }, { "label": "API overage", "value": "$14,400", "share": 30 }, { "label": "Storage", "value": "$2,800", "share": 6 } ] } } ``` ### Checklist (`syntari.checklist`) Give a person steps to tick off, with progress they can trust. Avoid when: the-agent-runs-the-steps, ticking-a-box-should-change-something-outside-the-screen. Props: - `title` (string): required; max 60 characters - `items` (array, at most 12 items): required - `title` (string): required; max 80 characters - `detail` (string): optional; max 160 characters - `time` (string): optional; max 24 characters; When, as written, such as 'Monday' or '12:30'. - `done` (boolean): optional; default false Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `check` (the control updates itself and sends its new state): The person ticked or cleared a step. index names the step and values.checked its new state; the host may save it. Valid sample: ```json { "component": "syntari.checklist", "props": { "title": "Security review before launch", "items": [ { "title": "Rotate production keys", "time": "Monday", "done": true }, { "title": "Review admin access", "detail": "Remove anyone who left this quarter.", "time": "Tuesday" } ] } } ``` ### Media cards (`syntari.media-cards`) Introduce a few kinds of thing side by side, each with a picture and a caption. Avoid when: the-cards-need-actions, more-than-four-kinds. Props: - `items` (array, at most 4 items): required - `title` (string): required; max 40 characters - `description` (string): optional; max 140 characters - `icon` (string): optional; default "image"; max 24 characters; Syntari icon shown when there is no image. - `image` (string): optional; max 64 characters; Id of an image the host provides through the assets option. Never a URL. - `alt` (string): optional; max 120 characters; What the image shows, for people who cannot see it. Valid sample: ```json { "component": "syntari.media-cards", "props": { "items": [ { "title": "Starter", "description": "For teams trying the product.", "icon": "smile" }, { "title": "Business", "description": "For teams that run on it every day.", "icon": "layers" }, { "title": "Enterprise", "description": "For companies that need SSO and audit logs.", "icon": "lock" } ] } } ``` ### Calculator (`syntari.calculator`) Let a person explore how a result depends on a few inputs. Avoid when: the-result-needs-data-the-screen-does-not-have, a-change-should-be-saved-or-charged. Props: - `title` (string): required; max 60 characters; What the calculator estimates. Say if it is illustrative. - `currency` ("USD" | "EUR" | "GBP"): optional; default "USD" - `inputs` (array, at most 4 items): required; Sliders. Each name is usable in formulas. - `name` (string): required; max 24 characters; Formula name: a lowercase letter, then letters, digits, or underscores. - `label` (string): required; max 40 characters - `min` (number): required - `max` (number): required - `step` (number): optional; Defaults to any value. - `value` (number): required; Starting value, between min and max. - `format` ("number" | "integer" | "currency" | "percent"): optional; default "number" - `unit` (string): optional; max 12 characters; Word after the value, such as 'years' or 'seats'. - `outputs` (array, at most 4 items): required; Results in display order; the first is the answer. Each formula may use input names and earlier output names. - `name` (string): required; max 24 characters - `label` (string): required; max 40 characters - `formula` (string): required; max 200 characters; Arithmetic: numbers, names, + - * / ^, parentheses, min, max, round, floor, ceil, abs, and fv(rate per period, periods, payment). - `format` ("number" | "integer" | "currency" | "percent"): optional; default "number" - `unit` (string): optional; max 12 characters - `parts` (array, at most 4 items): optional; Parts of the first result, shown as a bar with shares, such as contributions and growth. - `label` (string): required; max 32 characters - `formula` (string): required; max 200 characters - `format` ("number" | "integer" | "currency" | "percent"): optional - `note` (string): optional; max 240 characters; The assumptions, in plain words. Actions (fire `syntari:action` and `onActionRequest`; nothing runs until the host decides): - `adjust` (the control updates itself and sends its new state): The person moved an input. index names the input and values.value its new value. Results are already recomputed on screen; the host can use the value, for example to quote a charge. Valid sample: ```json { "component": "syntari.calculator", "props": { "title": "Annual cost for your team", "inputs": [ { "name": "seats", "label": "Seats", "min": 5, "max": 200, "step": 5, "value": 40, "format": "integer" }, { "name": "price", "label": "Price per seat a month", "min": 10, "max": 80, "step": 5, "value": 40, "format": "currency" } ], "outputs": [ { "name": "annual", "label": "Annual cost", "formula": "seats * price * 12", "format": "currency" } ] } } ``` ## Component guidance When to use each component, when not and what to use instead, its states, and what to do and not do. 5 of 137 components are documented this way so far. ### Button (`syntari.button`) Starts an action on the current page, such as saving, sending, confirming, or opening a dialog. A button does something; a link goes somewhere. Use when: - A person triggers something that changes data, submits a form, or opens a dialog, drawer, or menu. - One action is the main way forward in a region, with alternatives beside it. - An action deletes or sends something that cannot be taken back, shown as danger and confirmed before it runs. Do not use when: - The action takes the person to another page or address. Use a link, so it can be opened in a new tab and its address copied. - The control is only an icon. Use Icon button (`syntari.icon-button`). - The action reports its progress while it runs, such as Saving… then Saved. Use Loading button (`syntari.action-swap`). - A person switches between views or values rather than starting an action. Use Segmented control (`syntari.segmented-control`). States: - Default: Ready to press. Primary is filled, secondary is outlined, ghost has no surface until hovered, and danger is red for actions that destroy something. (variant: primary, secondary, ghost, or danger) - Hover: The surface shifts and lifts slightly under a pointer. It is never the only sign that the button can be pressed. (A pointer over the button.) - Focus: A focus ring shows which button the keyboard is on. (Tab to the button.) - Pressed: The button sinks slightly while it is held, so the press is felt before anything happens. (Pointer down, or Enter or Space held.) - Disabled: Visible but unavailable: it cannot be focused or pressed, so the reason must be shown nearby. (disabled: true) Do: - One primary per region: A single filled button shows the way forward; two compete. - Verb-first labels: Save changes, Send invite: people know the result before they press. - Confirm the irreversible: Mark delete, send, and pay as consequential, and confirm them. - Primary goes last: Cancel first, then the action, in the same order on every screen. Don’t: - Buttons that navigate: Going to another page is a link, so it can open in a new tab. - Disabled with no reason: It can’t be focused, so people never learn what is missing. - Vague labels: OK, Submit, or Click here don’t say what will happen. - Danger for emphasis: Red that marks everything stops warning about what is permanent. Writing: - Sentence case, one to three words, no end punctuation. - Match the label to what the title promised: under Delete project, the button says Delete project, not Confirm. Accessibility: - Tab: Moves focus to the button. - Enter or Space: Presses the button. - A native button element, so its role, focus, and keys come from the browser. - A button that only shows an icon needs an accessible name; use Icon button. - A disabled button leaves the tab order, so its reason must be written where people can read it. ### Text input (`syntari.text-input`) Collects one line of free text that a person types, such as a name, an email address, or a link. Use when: - The answer is short and open: a name, a title, an email address, a link, or a phone number. - It sits in a form, with its label above it, and is sent when the form is submitted. Do not use when: - There are two to seven options a person should see at once. Use Radio (`syntari.radio`). - The answer is one of a long, known list of options. Use Searchable select (`syntari.searchable-select`). - The answer runs to several sentences. Use Text area (`syntari.text-area`). - The text is a password or another secret. Use Password input (`syntari.password-field`). - The value is a small number a person nudges up or down. Use Number input (`syntari.number-stepper`). States: - Empty: Shows its label and, when it helps, an example as a placeholder. (label and placeholder) - Focus: A focus ring shows where typing goes. (Tab to the field, or click it.) - Filled: Holds what the person typed, or the value the form started with. (value) - With a hint: Help stays under the field, such as a format or why it is asked, and is read with it. (hint) - Invalid: The value cannot be used: the field is outlined, and the error under it says how to fix it. (error, set by the host after checking the value) - Required: The form cannot be sent until the field is filled; the browser names the field that is missing. (required: true) Do: - Visible label above: A placeholder vanishes as people type, and the question with it. - Matching type: Email, url, and tel bring the right keyboard and autofill. - Errors that say how: Say how to fix the value, right under the field. - Ask only what you need: Every extra field is one more reason to give up on the form. Don’t: - Placeholder as label: It disappears, is often too faint, and isn’t reliably announced. - Errors while typing: Check when people leave the field or submit, not mid-word. - Red as the only signal: One man in twelve can’t tell red from green; words carry it. - Asking twice: Confirm-email fields double the work and the typos. Writing: - Labels are short nouns in sentence case: Work email, not Please enter your email. - Placeholders are examples, not instructions: name@company.com. - Errors say what to do: Enter an email address with an @, such as name@company.com. Accessibility: - Tab: Moves focus to the field. - Enter: Submits the form the field belongs to. - The label names the field; the hint and the error are tied to it with aria-describedby, so they are read with it. - An error sets aria-invalid, so assistive technology reports the field as invalid. - Set autocomplete to email, url, or tel so browsers and password managers can fill the value; a field rendered from Screen IR sets it from its type. ### Table (`syntari.table`) Shows records in rows and their attributes in columns, so people can scan, compare, and sort exact values. Use when: - People compare the same few attributes across many records, such as traffic sources and their visitors. - Values must be read exactly, not estimated from a shape. - People look for a particular record among many; make the table sortable past six rows. Do not use when: - The point is a trend over time, not exact values. Use Line chart (`syntari.line-chart`). - The point is how a whole splits into parts. Use Breakdown bar (`syntari.breakdown`). - Two or three options are compared on the same measures to pick one. Use Comparison table (`syntari.comparison-table`). - People need to search, filter, select, edit, or page through many records. Use Data table (`syntari.data-table`). - There are no rows yet. Use Empty state (`syntari.empty-state`). States: - Default: Rows in the order given, under column headers. (columns and rows) - Sortable: Column headers become buttons with a sort mark. (sortable: true) - Sorted: Pressing a header sorts by it: figures largest first, text A to Z. A second press reverses the order, and a third restores the original. (Press a column header.) Do: - A caption that names it: Screen reader users hear it first; it says what the rows are. - Units in the header: Cells hold only values, so they stay short and line up. - Sortable past six rows: Beyond a screenful, people look for a value instead of reading. - Most important first: When it isn’t sortable, the first rows get most of the attention. Don’t: - Tables for layout: Screen readers announce rows and columns that aren’t data. - Sentences in cells: Rows turn uneven; move the detail to a region or detail view. - Colour as the verdict: Write the sign or a word too: +24%, Down 6%. - Columns that never change: A value shared by every row belongs in the caption. Writing: - Column headers are short nouns: Source, People, Change. - Write a column’s numbers the same way all the way down: the same decimals and the same unit. Accessibility: - Tab: Moves between sortable column headers. - Enter or Space: Sorts by the focused column. - Headers are th elements with scope="col", so every cell is read with its column. - The caption names the table; it may be visually hidden, but it must be there. - A sorted column sets aria-sort, so the order is announced. ### Dialog (`syntari.dialog`) A modal window for one short, focused task, such as naming a new project, that a person finishes or cancels before returning to the page. Use when: - A short task needs the person’s full attention and only a few fields. - The page behind should stay exactly where it was while the task is done. Do not use when: - Confirming an action that deletes, sends, or pays. Use Confirmation dialog (`syntari.confirmation-dialog`). - The task is long, or people need to look at the page while doing it. Use Side drawer (`syntari.side-drawer`). - Telling people that something happened. Use Toast (`syntari.toast`). - Showing a few options next to the control that opened them. Use Popover (`syntari.popover`). - A warning or information people can read where they are. Use Banner (`syntari.banner`). States: - Closed: Only the button that opens it is visible. (Before the trigger is pressed.) - Open: The dialog sits over a dimmed page. Focus moves into it and stays there. (Press the trigger; the dialog opens with showModal().) - Invalid: Submitting a missing or wrong value keeps the dialog open and names the problem at the field. (Submit with a required field empty.) - Dismissed: Escape, Cancel, or the close button closes it without saving, and focus returns to the trigger. (Escape, Cancel, or the close button.) Do: - Title and button agree: Create a project, then Create project: people confirm what they started. - One short task: The longer a dialog gets, the more people lose their place. - Always a way out: Cancel, the close button, and Escape all leave without saving. - Focus goes back: On close, focus returns to the button that opened it. Don’t: - Unrequested dialogs: They interrupt work and get dismissed unread. - Dialogs on dialogs: With two layers, it is unclear what Cancel will close. - Closing on outside click: One stray click shouldn’t throw away what someone typed. - Page content in a dialog: It can’t be linked to, bookmarked, or found again. Writing: - Title: the task in sentence case, such as Create a project. - Primary button: the same verb and its object, such as Create project. The other button is Cancel. - Add one short sentence under the title only when the task needs context. Accessibility: - Tab and Shift+Tab: Move between the controls inside the dialog. - Escape: Closes the dialog without saving. - Enter: Submits the dialog’s form. - A native dialog element opened with showModal(): the page behind becomes inert and focus stays inside. - aria-labelledby points to the title, so the dialog is announced by its name. - The close button has its own accessible name, Close dialog. ### Toast (`syntari.toast`) A brief message confirming that an action finished, such as Changes saved, which disappears on its own. Use when: - Confirming that an action the person just took succeeded, when the result is not visible otherwise. - The message is a few words and needs no response. Do not use when: - An error the person has to fix. Use Banner (`syntari.banner`). - A decision the person must make, or an action to confirm. Use Confirmation dialog (`syntari.confirmation-dialog`). - Messages that must stay until read, or several that arrive together. Use Notification stack (`syntari.notification-stack`). - The result is already visible where the person is looking. Show nothing extra. States: - Hidden: No message; the live region waits, empty. (Before any action.) - Visible: The message appears at the bottom of the screen, is announced without moving focus, and fades after about four seconds. (An action that succeeded calls the toast with its message.) - Replaced: A new message replaces the current one and restarts the timer; toasts never pile up. (A second action within four seconds.) Do: - Past tense, few words: Changes saved: read in under two seconds. - Only after success: Confirming something that then fails breaks trust. - Repeat it on the page: It disappears, so the result must be findable later. Don’t: - Errors in a toast: It vanishes before people can act; errors belong in place. - Actions in a toast: It times out before keyboard users can reach the button. - A toast for everything: Constant toasts become noise people learn to ignore. Writing: - Past tense, sentence case, no end punctuation: Project created. - Name the object when it helps: Invite sent to ana@example.com. Accessibility: - The message is written into a role="status" live region, so screen readers announce it politely without moving focus. - It stays about four seconds. Anything people need later must be on the page too, because they cannot pause or recall a toast.