Skip to content

YAML-driven Frontends

Habit frontends are authored as a single declarative file — frontend/index.yaml — and compiled to a self-contained HTML document on the fly. This guide explains the runtime, the schema, the workflow for converting an existing HTML page, and how to test the result locally.

If you already have a hand-written frontend/index.html, the Cortex server keeps serving it. You only opt into the YAML pipeline by adding frontend/index.yaml. When both files exist, the YAML wins.

Why YAML?

A habit's UI is almost always a thin shell over its workflows: a form, a fetch, a result panel, sometimes a tab strip or a history grid. Hand-written HTML/CSS/JS for every habit means duplicated boilerplate, drifting design tokens, and a long tail of one-off bugs.

The YAML engine inverts that:

  • One schema (schemas/ui-spec.schema.yaml) describes every pattern we use.
  • One renderer (@ha-bits/cortex-corecompileUiSpec) produces consistent HTML, CSS, and a small client-side runtime.
  • One source of truth per habit. No more <style> blobs to maintain.

File layout

showcase/<habit-id>/
├── stack.yaml            # workflow + server config
├── habits/               # individual workflow YAMLs
└── frontend/
    ├── index.yaml        # ← the UiSpec the server compiles
    └── index.html        # optional fallback, ignored when index.yaml exists

The stack.yaml doesn't change — it still points at the folder:

yaml
server:
  port: 13000
  frontend: ./frontend

The server resolves the directory and picks the first match from index.yaml, index.yml, ui.yaml, ui.yml, then index.html.

How compilation works

The pipeline lives in packages/cortex/core/src/ui/:

text
frontend/index.yaml


   parseUiSpec()                  // yaml → typed UiSpec


   compileUiSpec()                // UiSpec → { html, css, js }
       │      │      │
       │      │      └── runtime.ts (state, dispatch, templating, streaming)
       │      └──────── theme.ts   (CSS variables from ThemeSpec)
       └─────────────── layouts.ts + widgets.ts (server-rendered HTML)


   <!DOCTYPE html><html><head>…<style>…</style></head>
                     <body>…compiled markup…
                          <script>…RUNTIME_JS + boot data…</script>
                     </body></html>

What the engine handles natively (no per-habit JS needed):

  • Forms (text, email, number, date, textarea, select, chip-group, radio-cards, tag-input, file/image upload with base64).
  • Layouts: single, tabs, sidebar, wizard, mobile-shell, split, chat, showcase.
  • Templated text: {{state.foo}}, {{state.user.name | truncate:30}}, {{now | iso}}.
  • Conditional rendering via showWhen / hideWhen.
  • Action dispatch: HTTP (GET/POST/etc.), OAuth gating, NDJSON streaming, polling.
  • Output widgets: result panels, metric grids, kv-grids, score rings, progress bars, badge lists, data tables, history grids, code/markdown blocks with copy buttons.
  • Drag-and-drop file zones, downloads from base64 payloads, print-friendly views.

The canonical list of widget kinds lives in packages/cortex/core/src/ui/types.ts.

Server integration

The Cortex server (packages/cortex/server/src/server.ts) handles compilation transparently:

text
GET /                       (habit root URL)
  └── if frontend/index.yaml exists:
        compileUiYaml(file) → HTML response
      else if frontend/index.html exists:
        sendFile(file)

For .habit zip bundles the loader applies the same rule against the bundled frontend/ directory.

Authoring a new YAML frontend

A minimal example:

yaml
# yaml-language-server: $schema=../../../schemas/ui-spec.schema.yaml
version: 1

meta:
  id: hello-world
  title: Hello World Demo
  icon: "lucide:Hand"

theme:
  preset: neural
  mode: dark

state:
  name: ""
  result: null

actions:
  greet:
    method: POST
    endpoint: /api/hello-world
    body: { name: "{{state.name}}" }
    responsePath: output
    onSuccess: { set: { result: "$response" }, toast: "Greeted" }

widgets:
  - kind: card
    title: Say hello
    children:
      - kind: form
        bindTo: state
        fields:
          - { name: name, type: text, label: Your name, required: true }
        submit: { label: Say hello, action: greet, loadingLabel: "Greeting..." }
  - kind: result-panel
    source: state.result
    title: Greeting
    sections:
      - { kind: json-dump, source: state.result, copy: true }

That single file gives you a themed page, a validated form, a POST to /api/hello-world, a toast on success, a conditional result panel, and a JSON viewer with a copy button. No CSS, no JS.

Icons

Icon fields (meta.icon, layout.header.icon, layout.nav[].icon, widget icon props) accept:

FormatExampleNotes
Lucide name (recommended)lucide:Zap47 curated icons; inherits theme primary color
Image URL/assets/logo.svgRendered as <img>
Inline SVG<svg viewBox="0 0 24 24">…</svg>Sanitized at compile time
Plain text or HEscaped text fallback
Omit(no field)Header/nav show labels only

Lucide icons are generated from lucide-static via scripts/sync-lucide-icons.mjs into packages/cortex/core/assets/icons/lucide/ (not committed; run automatically before @ha-bits/cortex-core builds). The Cortex runtime serves them at /ha-assets/icons/lucide/ (Node server or Tauri www/ha-assets/). Reference any icon as lucide:IconName (PascalCase, e.g. lucide:Hand, lucide:TriangleAlert). See lucideIcons.ts for lookup helpers.

No emoji required. Prefer lucide:Name or omit the icon entirely.

Alternatives without icons:

  • Labels only — drop icon from nav items; tabs and sidebar use text alone.
  • Hero / image widgets — use kind: hero with imageSource for a logo instead of meta.icon.
  • Hide header icon slottheme.customCss: ".ha-header__icon { display: none; }"

For richer examples, browse showcase/*/frontend/index.yaml — every habit in this repo now has one. Notable patterns:

  • Tabs + history: showcase/ai-quiz/frontend/index.yaml
  • Multi-platform output tabs: showcase/social-media-manager/frontend/index.yaml
  • NDJSON streaming: showcase/marketing-campaign/frontend/index.yaml
  • Chat layout: showcase/openclaw-clone/frontend/index.yaml
  • OAuth gating: showcase/cloud-file-upload/frontend/index.yaml
  • Showcase dashboard: showcase/ecommerce-retail-order-management/frontend/index.yaml

Schema

The full schema is schemas/ui-spec.schema.yaml:

yaml
# UiSpec Schema — YAML-driven UI for Habits frontends
#
# Every habit ships a single `frontend/index.yaml` (a "UiSpec"). The Cortex
# server compiles it to a self-contained HTML document at request time via
# `compileUiSpec` / `compileUiYaml` in `@ha-bits/cortex-core`.
#
# This schema describes that YAML. Authoring tools (and humans) can lint
# against it. Keep it in sync with
# `packages/cortex/core/src/ui/types.ts`.

$schema: http://json-schema.org/draft-07/schema#
$id: https://habits.codenteam.com/schemas/ui-spec.schema.json
title: Habits UI Spec
description: |
  A declarative spec for a habit's frontend. Compiled to HTML by
  `@ha-bits/cortex-core`. Supports forms, tabs, sidebars, wizards, mobile
  shells, chat threads, history grids, NDJSON streaming, OAuth gating,
  polling, and static showcase dashboards.

type: object
additionalProperties: false

required: []

properties:
  version:
    description: Schema version. Currently always `1`.
    type: integer
    enum: [1]

  meta:
    $ref: "#/definitions/MetaSpec"

  theme:
    $ref: "#/definitions/ThemeSpec"

  layout:
    $ref: "#/definitions/LayoutSpec"

  state:
    description: |
      Initial in-memory state. The runtime exposes this as `state.*` to
      every template expression and binding (e.g. `{{state.foo}}`,
      `bindTo: state`, `showWhen: state.result`).
    type: object
    additionalProperties: true

  actions:
    description: |
      Map of named actions. Reference them from buttons/forms/onMount via
      action id. Default endpoint is `/api/{meta.id}` if you omit
      `endpoint`.
    type: object
    additionalProperties:
      $ref: "#/definitions/ActionSpec"

  onMount:
    description: |
      Action ids (or inline `{ set: {...} }` updates) dispatched when the
      page first loads. May be a single id, an array of ids, or an array
      mixing ids and inline ActionSpec fragments.
    oneOf:
      - type: string
      - type: array
        items:
          oneOf:
            - type: string
            - $ref: "#/definitions/ActionSpec"

  defaultView:
    description: |
      View id rendered first for `tabs`, `sidebar`, `mobile-shell`,
      `wizard`, and `chat` layouts.
    type: string

  views:
    description: |
      Named views keyed by id (matches `layout.nav[].id`). For `single`
      layout, omit `views` and use top-level `widgets` instead.
    type: object
    additionalProperties:
      $ref: "#/definitions/ViewSpec"

  widgets:
    description: |
      Convenience for `single` layout. Alternative to `views.main.widgets`.
    type: array
    items:
      $ref: "#/definitions/WidgetSpec"

definitions:
  # ---------------------------------------------------------------------
  # Meta + theme
  # ---------------------------------------------------------------------

  MetaSpec:
    type: object
    additionalProperties: false
    properties:
      id:
        type: string
        description: |
          Workflow id used to build the default `/api/{id}` endpoint for
          actions. Should match the directory name (e.g. `ai-quiz`).
      title:
        type: string
        description: Header title and document `<title>` fallback.
      subtitle:
        type: string
      description:
        type: string
      icon:
        type: string
        description: |
          Icon for the header. Prefer `lucide:Name` (e.g. `lucide:Zap`), or an
          image URL, inline SVG string, or plain text. Omit to hide the icon.
      documentTitle:
        type: string
        description: Overrides the `<title>`; defaults to `title`.

  ThemeSpec:
    type: object
    additionalProperties: false
    properties:
      preset:
        description: One of the bundled palette presets.
        type: string
        enum:
          - neural
          - ha-bits-blue
          - ha-bits-cyan
          - ha-bits-purple
          - ha-bits-red
          - ha-bits-emerald
          - ha-bits-warn
          - aurora
          - cyberpunk
          - mobile-blue
          - tailwind-dark
          - showcase-flat
      mode:
        type: string
        enum: [dark, light]
      primary:    { type: string, description: Hex override }
      secondary:  { type: string, description: Hex override }
      accent:     { type: string, description: Hex override }
      background: { type: string, description: Hex override }
      font:
        type: object
        additionalProperties: false
        properties:
          body:    { type: string }
          mono:    { type: string }
          display: { type: string }
      radius:
        type: integer
        minimum: 0
        maximum: 32
      density:
        type: string
        enum: [comfortable, compact, mobile]
      customCss:
        type: string

  # ---------------------------------------------------------------------
  # Layout
  # ---------------------------------------------------------------------

  LayoutSpec:
    type: object
    additionalProperties: false
    properties:
      type:
        description: |
          Top-level page shell. `showcase` renders a static dashboard with
          metric grid + habit cards.
        type: string
        enum: [single, tabs, sidebar, wizard, mobile-shell, split, chat, showcase]
        default: single
      header:
        $ref: "#/definitions/HeaderSpec"
      footerStatus:
        $ref: "#/definitions/FooterStatusSpec"
      nav:
        type: array
        items:
          $ref: "#/definitions/NavItemSpec"
      navPosition:
        type: string
        enum: [top, bottom, left]
      split:
        type: object
        required: [left, right]
        properties:
          left:  { type: string }
          right: { type: string }
          ratio: { type: string }

  HeaderSpec:
    type: object
    additionalProperties: false
    properties:
      title:    { type: string }
      subtitle: { type: string }
      icon:     { type: string }
      sticky:   { type: boolean }
      badge:
        type: object
        properties:
          text: { type: string }
          tone: { $ref: "#/definitions/Tone" }
      actions:
        type: array
        items:
          $ref: "#/definitions/WidgetSpec"

  FooterStatusSpec:
    type: object
    additionalProperties: false
    properties:
      dot:
        type: string
        enum: [live, idle, warn, error]
      text:    { type: string }
      port:    { oneOf: [{ type: integer }, { type: string }] }
      version: { type: string }

  NavItemSpec:
    type: object
    required: [id, label]
    additionalProperties: false
    properties:
      id:    { type: string }
      label: { type: string }
      icon:  { type: string }
      badge: { oneOf: [{ type: string }, { type: integer }] }

  # ---------------------------------------------------------------------
  # Actions
  # ---------------------------------------------------------------------

  ActionSpec:
    type: object
    additionalProperties: false
    properties:
      type:
        description: |
          `http` (default), `oauth`, `navigate`, `reset`, `logout`.
        type: string
        enum: [http, oauth, navigate, reset, logout]
      open:
        description: |
          Shorthand: open a URL (e.g. an OAuth init endpoint or external
          page). Equivalent to `type: navigate` with a URL.
        type: string
      reset:
        description: |
          State keys to reset to their initial values. Useful for "Start
          over" buttons.
        type: array
        items: { type: string }
      geolocate:
        description: |
          Request the browser's geolocation and write the result into the
          provided state paths.
        type: object
        additionalProperties: false
        properties:
          latitudeTo:  { type: string }
          longitudeTo: { type: string }
      set:
        description: |
          Shorthand: synchronously update state without making an HTTP
          call. Equivalent to `type: reset` with `onSuccess.set`.
        type: object
        additionalProperties: true
      method:
        type: string
        enum: [GET, POST, PUT, PATCH, DELETE]
      endpoint:
        description: Templated URL. Defaults to `/api/{meta.id}`.
        type: string
      body:
        description: |
          Templated body. Object whose leaf values are template strings.
        oneOf:
          - type: object
            additionalProperties: true
          - type: string
      query:
        type: object
        additionalProperties: true
      headers:
        type: object
        additionalProperties: { type: string }
      responsePath:
        description: |
          Where in the JSON response the payload lives. Default `output`
          with a fallback to root. Use `$` for the whole response.
        type: string
      stream:
        description: |
          Set to `ndjson` to parse a stream of newline-delimited JSON
          events and dispatch them through `events[]`.
        oneOf:
          - { type: string, enum: [ndjson, tokens] }
          - { type: boolean, enum: [false] }
      events:
        type: array
        items:
          $ref: "#/definitions/ActionEventHandler"
      poll:
        type: object
        required: [intervalMs]
        properties:
          intervalMs: { type: integer, minimum: 100 }
          auto:       { type: boolean }
      oauth:
        type: object
        required: [statusUrl, initUrl]
        properties:
          statusUrl: { type: string }
          initUrl:   { type: string }
      confirm:
        description: |
          If set, prompts the user with this text before dispatching.
        type: string
      onSuccess:
        $ref: "#/definitions/ActionSuccessSpec"
      onError:
        $ref: "#/definitions/ActionErrorSpec"

  ActionEventHandler:
    type: object
    additionalProperties: false
    properties:
      match:
        description: Partial-match object against the streamed event.
        type: object
        additionalProperties: true
      append:    { type: string, description: State path to push the event onto }
      set:
        type: object
        additionalProperties: true
      increment:
        type: string
        description: State path of a counter to increment.

  ActionSuccessSpec:
    type: object
    additionalProperties: false
    properties:
      goto:
        type: string
        description: Navigate to this view id after success.
      set:
        description: |
          State updates. Each value is a template string (e.g.
          `"$response"`, `"{{state.foo}}"`), a literal, or null to clear.
        type: object
        additionalProperties: true
      append:
        description: |
          Either a state path (string) to push the response into, or a
          map of `{ statePath: itemTemplate }` for multi-list appends.
        oneOf:
          - type: string
          - type: object
            additionalProperties: true
      toast:
        oneOf:
          - { type: string }
          - type: object
            additionalProperties: true
            properties:
              message: { type: string }
              level:   { type: string, enum: [info, success, warn, error] }
      reload:
        description: Re-dispatch one or more action ids after this action succeeds.
        oneOf:
          - { type: string }
          - { type: array, items: { type: string } }
      dispatch:
        description: Alias for `reload`. Either spelling works at runtime.
        oneOf:
          - { type: string }
          - { type: array, items: { type: string } }
      resetForm:
        type: string
      download:
        type: object
        required: [dataPath]
        properties:
          dataPath:     { type: string }
          fileNamePath: { type: string }
          mimeType:     { type: string }

  ActionErrorSpec:
    type: object
    additionalProperties: false
    properties:
      toast:
        oneOf:
          - { type: string }
          - type: object
            additionalProperties: true
            properties:
              message: { type: string }
              level:   { type: string, enum: [info, success, warn, error] }
      set:
        type: object
        additionalProperties: true

  # ---------------------------------------------------------------------
  # Views + widgets
  # ---------------------------------------------------------------------

  ViewSpec:
    type: object
    additionalProperties: false
    required: [widgets]
    properties:
      title: { type: string }
      widgets:
        type: array
        items:
          $ref: "#/definitions/WidgetSpec"
      onEnter:
        oneOf:
          - type: string
          - type: array
            items: { type: string }

  Tone:
    type: string
    enum: [primary, secondary, accent, success, warn, danger, info, muted]

  # `WidgetSpec` is an open object — every widget shape has a `kind` plus
  # widget-specific keys. The runtime ignores unknown keys, so the
  # safest schema is "object with a string `kind` discriminator".
  WidgetSpec:
    type: object
    required: [kind]
    properties:
      kind:
        type: string
        description: |
          Widget discriminator. Common values include:
          - layout: `section`, `card`, `row`, `column`, `tabs`,
            `accordion`, `modal`, `bottom-sheet`, `split`
          - input: `form`, `button`, `action-button`, `submit-button`,
            `copy-button`, `download-button`, `print-button`,
            `link-button`, `button-row`, `toggle`, `chip-group`,
            `radio-cards`, `tag-input`
          - output: `result-panel`, `pre`, `code-block`, `code`,
            `json-dump`, `markdown`, `text`, `heading`, `html-preview`,
            `image`, `hero`, `score-ring`, `bar-chart`, `progress-bar`,
            `metric-grid`, `stat-row`, `badge`, `badge-list`,
            `numbered-list`, `bullet-list`, `data-table`, `kv-grid`,
            `list`, `checklist`, `trait-bars`, `step-list`,
            `filter-bar`, `repeatable`
          - feedback: `status-banner`, `alert`, `empty-state`, `spinner`,
            `loading-steps`, `pipeline-indicator`
          - realtime: `chat-panel`, `streaming-panel`, `streaming-text`
          - app: `habit-grid`, `history-grid`, `history-list`,
            `mode-selector`, `oauth-status-card`
      id:        { type: string }
      className: { type: string }
      showWhen:
        type: string
        description: |
          Template expression. The widget is rendered only when this
          evaluates truthy at runtime (e.g. `state.result`,
          `state.queue.length > 0`).
      hideWhen:
        type: string
        description: Inverse of `showWhen`. Either may be used.
    additionalProperties: true
    # The full per-widget shape is documented in
    # `packages/cortex/core/src/ui/types.ts`. A short reference of the
    # most common widgets follows so editors can offer reasonable
    # completions even without dependent-schema support.
    examples:
      - kind: card
        title: A grouped section
        children:
          - { kind: text, value: "Hello" }
      - kind: form
        bindTo: state
        fields:
          - { name: title, type: text, label: Title, required: true }
        submit: { label: Save, action: save }
      - kind: metric-grid
        columns: 4
        metrics:
          - { value: "1,842", label: "Orders today" }
      - kind: result-panel
        source: state.result
        title: Result
        sections:
          - { kind: json-dump, source: state.result, copy: true }
      - kind: alert
        level: error
        showWhen: state.error
        text: "{{state.error}}"

Wire it into your editor via .vscode/settings.json:

json
{
  "yaml.schemas": {
    "./schemas/ui-spec.schema.yaml": [
      "showcase/*/frontend/index.yaml",
      "showcase/*/frontend/ui.yaml"
    ]
  }
}

Or pin a single file with a comment at the top:

yaml
# yaml-language-server: $schema=../../../schemas/ui-spec.schema.yaml

Testing locally

1. Compile every YAML in one pass

The repo ships a smoke-test script that walks showcase/*/frontend/index.yaml, compiles each one, and writes the rendered HTML to .compiled-frontends/<habit-id>.html:

bash
pnpm nx build @ha-bits/cortex-core       # build the engine (once)
node scripts/smoke-test-ui-engine.mjs    # compile everything

Sample output:

text
✓ showcase/ai-quiz/frontend/index.yaml → .compiled-frontends/ai-quiz.html (72.8 KB)
✓ showcase/hello-world/frontend/index.yaml → .compiled-frontends/hello-world.html (68.6 KB)
...
78 ok, 0 failed

To compile a single file:

bash
node scripts/smoke-test-ui-engine.mjs showcase/ai-quiz/frontend/index.yaml

A non-zero exit code means at least one compile failed — useful for CI.

2. Preview the compiled HTML in a browser

bash
cd .compiled-frontends
python3 -m http.server 7531
# open http://localhost:7531/ai-quiz.html

This shows the rendered page without booting Cortex — handy for checking layout, theme, and visibility logic.

3. Run end-to-end against Cortex

For a realistic test (real API calls, real state, real polling/streaming), boot the habit normally:

bash
pnpm habits dev showcase/ai-quiz/stack.yaml
# open http://localhost:13000/

The server logs which file it served:

text
[cortex] serving frontend YAML (index.yaml)

Edit frontend/index.yaml, refresh the browser, and you'll see the change immediately.

4. Authoring loop tips

  • Compile-then-diff. When porting an HTML page, keep the original index.html in place. After compiling, open both side by side. Fields, copy strings, and field order should match.
  • Show conditionals. Use showWhen: state.foo / hideWhen: state.error instead of duplicating views. The runtime evaluates them with full JS expressions (!state.loading, state.queue.length > 0).
  • Template strings. Anything inside {{ ... }} is evaluated against the live state. Common filters: truncate:N, date, iso, currency, splitCsv, length, filterBy:foo=bar, sum:total, join:', '.
  • Default endpoint. If you omit endpoint, the engine uses /api/{meta.id}. That covers most habits because the workflow id matches the directory.
  • Use the showcase converter. scripts/convert-showcase-to-yaml.mjs parses any HTML page that follows the static showcase template (header + 4 stat cards + 3x2 habit grid) and emits an index.yaml. Use it as a starting point for new industry showcases.

Adding new widgets or themes

Both live in @ha-bits/cortex-core:

ConcernFile
Type definitionspackages/cortex/core/src/ui/types.ts
Themes / CSSpackages/cortex/core/src/ui/theme.ts
Server-side render of widgetspackages/cortex/core/src/ui/widgets.ts
Layout shellspackages/cortex/core/src/ui/layouts.ts
Client runtimepackages/cortex/core/src/ui/runtime.ts
Icon renderingpackages/cortex/core/src/ui/icons.ts
Lucide iconsscripts/sync-lucide-icons.mjsassets/icons/lucide/*.svg + lucideIcons.ts
Bundled assetspackages/cortex/core/assets/ (icons + fonts), served once at /ha-assets/ by the runtime
Entry pointpackages/cortex/core/src/ui/index.ts

After changing the engine, rebuild and re-run the smoke test:

bash
pnpm nx build @ha-bits/cortex-core --skip-nx-cache
node scripts/smoke-test-ui-engine.mjs

Remember to update schemas/ui-spec.schema.yaml and this doc whenever you add a new widget kind, layout, theme preset, or top-level UiSpec property.

Released under the AGPL-3.0 License.