View components API
Custom views can use the same components the artifuncs app is built from — the ones
showcased on the kitchen-sink page — through the af-* elements. They're registered by the
host page, so there's nothing to import or install: write the tag, and the real component
renders, fully styled and theme-aware in light/dark.
import { useState } from "react"
export default function View() {
const [range, setRange] = useState([40, 50])
return (
<div className="grid gap-4 p-4">
<af-slider label="Height range" value={range} onChange={(e: any) => setRange(e.detail[0])} />
<af-button label="Run" variant="primary" onClick={go} />
</div>
)
}
The value / change contract
Every input element takes its state through the value property and reports changes via
a change event with the new value in e.detail[0]:
<af-text-field label="Name" value={name} onChange={(e: any) => setName(e.detail[0])} />
Text controls (af-text-field, af-textarea) additionally emit input on every keystroke
with the same payload. af-button needs no bridge — its click is a normal DOM click
(onClick). Complex values (arrays, objects) work because JSX sets DOM properties:
value={[40, 50]} passes the tuple as-is.
Elements
af-button
<af-button label="Run" variant="primary" onClick={go} />
<af-button label="Cancel" variant="outline" size="sm" />
<af-button label="Delete" variant="danger" disabled={busy} />
<af-button label="Working…" variant="primary" loading={busy} />
| Prop | Values |
|---|---|
label | button text |
variant | primary (default), secondary, outline, ghost, link, danger, danger-outline, success, warn |
size | xs, sm, md (default), lg |
loading, disabled, icon-only | booleans |
af-slider
<af-slider label="Volume" value={vol} min={0} max={100} step={1} unit="%"
onChange={(e: any) => setVol(e.detail[0])} />
Range mode: pass a tuple — value={[40, 50]} — and you get two draggable thumbs; the
change payload stays an ordered [lo, hi] pair (thumbs can't cross).
Props: label, min (0), max (100), step (1, floats OK), unit (readout suffix),
hint, disabled.
af-text-field
<af-text-field label="API key" placeholder="sk-…" value={key}
onInput={(e: any) => setKey(e.detail[0])} />
Props: label, placeholder, hint, type (e.g. "password"), disabled, readonly,
error. Events: input (per keystroke), change.
af-textarea
<af-textarea label="Prompt" value={prompt} onInput={(e: any) => setPrompt(e.detail[0])} />
Props: label, placeholder, hint, disabled, readonly, error.
af-select
<af-select label="Mode" items={["fast", "thorough"]} value={mode}
onChange={(e: any) => setMode(e.detail[0])} />
items accepts an array property (objects too — set item-title / item-value for the
display/value keys) or a JSON string attribute. Props: label, placeholder, hint,
disabled.
af-checkbox
<af-checkbox label="Enable feature" value={on} onChange={(e: any) => setOn(e.detail[0])} />
Props: label, hint, disabled, readonly. Value: boolean.
af-date
<af-date label="Due date" value={date} onChange={(e: any) => setDate(e.detail[0])} />
Value in and out is a YYYY-MM-DD string — JSON-friendly for run(). Props: label,
hint, disabled, readonly, clearable.
af-color / af-color-palette
<af-color label="Accent" value={hex} onChange={(e: any) => setHex(e.detail[0])} />
<af-color-palette label="Palette" value={colors} onChange={(e: any) => setColors(e.detail[0])} />
af-color holds one hex string (show-swatches for preset swatches; show-text-field
defaults on; variant="minimal" renders just the rounded-square swatch — no hex text).
af-color-palette holds a hex string[].
af-multi-select
<af-multi-select label="Formats" items={["png", "jpg", "webp"]} value={formats}
onChange={(e: any) => setFormats(e.detail[0])} />
Like af-select but the value is an array. Same items handling (item-title/item-value
for object items).
af-tags-input
<af-tags-input label="Tags" value={tags} onChange={(e: any) => setTags(e.detail[0])} />
Free-text tag entry; value is a string[].
af-file-field
const [file, setFile] = useState<File | null>(null)
<af-file-field label="Upload" accept=".pdf,.png" onChange={(e: any) => setFile(e.detail[0])} />
<af-button label="Process" onClick={async () => setOut(await run({ upload: file }))} />
The value is a browser File (or File[] with multiple). Two ways to get it to
process():
- Just pass it to
run()— anyFilein the input is uploaded automatically and replaced with its filename, soprocess()receivesinput["upload"]as a filename string (identical to declaredfilefields). - Eager upload —
const { name } = await files.upload(file, { onProgress })uploads immediately (e.g. on select, with a progress bar) and gives you the filename to pass in a laterrun().
Props: label, hint, accept, multiple, max-size (bytes), max-files, disabled.
Reading files process() wrote (see custom views for the
full files API): files.text(name), files.blob(name), files.url(name) (object URL —
drop into <img src>), files.save(name) (browser download).
af-image
const [img, setImg] = useState<string | null>(null)
const go = async () => {
const out = await run({}) // process() returns { picture: {path, size} }
setImg(await files.url(out.picture.path)) // object URL for <af-image src>
}
<af-image label="Result" src={img} file-name="result.png" editable
onChange={(e: any) => console.log('edited', e.detail[0].blob)} />
Renders an image with a download button, and — with editable — an edit button that
opens the full artifuncs image editor. src is anything an <img> accepts: an object URL
from files.url(name), a data: URI, or an http(s) URL your manifest's
network.allow permits.
Props: src, label, alt, file-name (download / export name), editable,
max-height (px, default 420).
When the user applies edits, change fires with { blob, fileName } in e.detail[0] — the
edited bytes, ready for files.upload(new File([blob], fileName)) if you want to send them
back to process(). The element also shows an edited · revert badge; src itself is never
rewritten, so your view stays the source of truth.
The editor is a full-screen modal, so it opens in the host page, not inside the view's frame — nothing for you to wire up, but it means the editor is unavailable in contexts with no host (the button hides itself rather than doing nothing).
Display elements — af-code-block, af-tag, af-timeline, af-divider
<af-code-block code={result.json} language="json" filename="result.json" />
<af-tag label="nlp" />
<af-timeline steps={[
{ state: "done", title: "Fetched" },
{ state: "active", title: "Processing" },
{ state: "pending", title: "Render" },
]} />
<af-divider text="output" />
<af-divider />
af-code-block: code, language, filename, show-copy, show-header, plus
marks / segments / wrap / line-numbers / max-height (see below). af-tag:
label, hash. af-timeline: steps array of
{ state: 'done'|'active'|'pending'|'failed', title, body?, substeps?, collapsible?, defaultOpen? }.
af-divider: the same labelled rule INPUT→OUTPUT funcs use to split input from output fields.
One prop, text — the label sits centered between two hairlines; omit it (or pass an empty
string) for a plain hairline.
Coloring parts of a value — marks and segments
af-code-block can color arbitrary parts of the text it displays. Two props, both plain
data (so they work identically from a view, from a Vue template, and as JSON returned by
process()):
// segments — split the value and color each piece, cycling the palette.
// A JWT's three parts in one declaration:
<af-code-block code={jwt} wrap
segments={{ split: ".", colors: ["warn", "info", "success"], delimiterColor: "ink-4" }} />
// marks — a list of ranges, each selected one of four ways:
<af-code-block code={log} lineNumbers marks={[
{ regex: "^ERROR.*$", flags: "gm", color: "danger", bold: true }, // pattern
{ text: "timeout", occurrence: "all", bg: "brand-glow" }, // literal
{ start: 120, end: 128, underline: "wavy", title: "invalid port" },// offsets
{ line: [3, 5], bg: "surface-2" }, // whole lines
]} />
Selectors — one per mark: { start, end } (0-based character offsets, what a parser
gives you) · { text, occurrence? } (occurrence is 1-based or "all", default first
only) · { regex, flags?, group? } (g is always applied; group marks a capture group
instead of the whole match) · { line } (1-based, a number or an array).
Styles — combine freely on any mark: color, bg, bold, italic,
underline (true | "solid" | "wavy" | "dotted"), strike, title (hover tooltip —
useful for explaining why something is flagged).
Colors accept a theme token name (brand, accent, success, warn, danger, info,
ink-3, surface-2, brand-glow, … — these follow the viewer's light/dark theme, so prefer
them) or any CSS color: #8b5cf6, rgb(255 60 0), oklch(0.72 0.19 40), tomato.
Values that aren't colors are ignored.
Marks compose. Overlapping marks are split at their boundaries and their styles merged,
with later marks winning per property — so a red ERROR word inside a warm-backgrounded
line gives you both, not one or the other. segments and marks can be used together.
Other display props: wrap breaks long unbroken tokens (JWTs, base64, URLs) instead of
scrolling sideways · line-numbers adds a gutter · max-height caps the body in px and
scrolls past it.
Syntax highlighting — language
Set language and the block is highlighted with the real VS Code grammar for it:
<af-code-block code={result.json} language="json" filename="result.json" />
<af-code-block code={src} language="py" lineNumbers />
Supported: json, python, javascript, typescript, jsx, tsx, bash, yaml,
markdown, html, css, sql, diff, xml, toml, ini. Common aliases resolve too
(js, ts, py, sh/shell, yml, md, patch, htm, …). An unknown language, or
plaintext, simply renders as plain text — never an error.
Three things worth knowing:
- It loads on demand. The highlighter and each grammar are fetched only when a block
actually declares a
language, so a func that doesn't highlight anything downloads nothing extra. The block paints your text immediately and upgrades a moment later. - It follows the theme. One render carries both light and dark colors, so a viewer switching theme sees the right palette with no reload.
- Marks compose with it.
marks/segmentsstill apply on top — acolormark overrides the token color for that range, whilebg,underline,boldandtitlelayer over the syntax colors. Handy for pointing at one line of an otherwise syntax-coloured payload:
<af-code-block code={payload} language="json" marks={[
{ start: err.offset, end: err.offset + err.length,
underline: "wavy", color: "danger", title: err.message },
]} />
Two notes on regex marks: an invalid pattern silently marks nothing rather than breaking the
view, and matches are capped per mark. Since your regex runs in the visitor's browser, prefer
segments, text or offsets when they'll do — they can't backtrack.
Editing — readonly={false}
af-code-block is display-only by default. Pass readonly={false} and it becomes an
editable box that stays coloured as the user types — the jwt.io shape, where you paste a
token and see its parts:
const [token, setToken] = useState("")
<af-code-block
code={token}
readonly={false}
wrap
placeholder="paste a JWT…"
segments={{ split: ".", colors: ["warn", "info", "success"] }}
onChange={(e: any) => setToken(e.detail[0])}
/>
Same element, same marks / segments / language — highlighting works while editing too.
The new value arrives on change (and input) in e.detail[0], like every other editable
af-* element. placeholder and disabled apply in this mode.
It is a real <textarea> underneath, so caret, selection, undo, IME and mobile keyboards all
behave normally; Tab indents by two spaces instead of leaving the field.
TypeScript note
The af-* tags aren't in preact's JSX types — either type the event handlers' arg as any
(as above) or add a one-liner to your view:
declare module "react" { namespace JSX { interface IntrinsicElements { [tag: `af-${string}`]: any } } }
Layout & custom styling
Use Tailwind utilities (grid, gap-4, p-4,
bg-surface, text-ink, …) for layout and anything bespoke, and theme tokens
(var(--brand), var(--surface), …) in raw CSS. The af-* elements render in the page's
light DOM, so they compose with both.
The live reference for how each component looks in every state is the app's kitchen-sink
page (/kitchen-sink when signed in).