/docs/Development/View Components
back to app →

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.

tsx
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]:

tsx
<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

tsx
<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} />
PropValues
labelbutton text
variantprimary (default), secondary, outline, ghost, link, danger, danger-outline, success, warn
sizexs, sm, md (default), lg
loading, disabled, icon-onlybooleans

af-slider

tsx
<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

tsx
<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

tsx
<af-textarea label="Prompt" value={prompt} onInput={(e: any) => setPrompt(e.detail[0])} />

Props: label, placeholder, hint, disabled, readonly, error.

af-select

tsx
<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

tsx
<af-checkbox label="Enable feature" value={on} onChange={(e: any) => setOn(e.detail[0])} />

Props: label, hint, disabled, readonly. Value: boolean.

af-date

tsx
<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

tsx
<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

tsx
<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

tsx
<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

tsx
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():

  1. Just pass it to run() — any File in the input is uploaded automatically and replaced with its filename, so process() receives input["upload"] as a filename string (identical to declared file fields).
  2. Eager uploadconst { 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 later run().

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

tsx
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

tsx
<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()):

tsx
// 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:

tsx
<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/segments still apply on top — a color mark overrides the token color for that range, while bg, underline, bold and title layer over the syntax colors. Handy for pointing at one line of an otherwise syntax-coloured payload:
tsx
<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:

tsx
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:

ts
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).