npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@typecad/ui

v1.0.0-alpha.11

Published

TypeCAD UI authoring library — HTML/CSS-driven graphics for microcontrollers

Readme

@typecad/ui — HTML/CSS-Driven Graphics for Microcontrollers

Write UIs in HTML and CSS. TypeCAD's cuttlefish transpiler lowers them to a retained-mode C++ runtime that draws on ILI9341 (and future) displays over hardware SPI. No browser, no DOM, no CSS engine on the device — everything is resolved at transpile time.

Install

@typecad/ui is the authoring API you import in source; @typecad/cuttlefish is the transpiler that lowers those imports to firmware at build time. You need both, plus a board package:

npm install @typecad/ui @typecad/board-esp32-devkit
npm install --save-dev @typecad/cuttlefish

@typecad/ui is compile-time only — none of its code is shipped to the device. The transpiler intercepts ui.mount / ui.signal / ui.bind / ... calls and lowers them to device variables and binding-table entries, so the package can be safely kept in dependencies.

Project layout

A TypeCAD UI project has one entry — the file you point cuttlefish.config.ts at. The entry can be either a .ui single-file component or a plain .ts module. Both intermix freely with regular cuttlefish TypeScript (HAL pin reads, setInterval, console.log, your own .ts modules) — the <script> block of a .ui file and a standalone .ts file are lowered by the same pipeline.

The transpiler accepts three entry extensions: .ts, .tsx, and .ui.

Pattern A — .ui single-file component (display + logic together)

A .ui file is a Svelte-style single-file component with three sections — <script>, <style>, and the HTML template — in one file. The blocks may appear in any order; only one <script> is supported, and multiple <style> blocks are concatenated.

The transpiler injects an implicit import { screen } from './app.ui.html' into the script, so the in-file template is referenceable as screen.* without an explicit import.

src/app.ui — markup, styling, and behavior for one screen in one file:

<script>
  import { ui } from '@typecad/ui';
  import { A0 } from '@typecad/board-esp32-devkit';

  ui.mount(screen);

  // Plain TypeScript — same lowering as a standalone .ts. HAL pin reads,
  // timers, and UI writes coexist as regular statements.
  const sensor = A0.asInput();

  setInterval(() => {
    screen.reading.value = sensor.readAnalog();
  }, 500);

  ui.bind(screen.lamp, 'background', () =>
    screen.reading.value > 512 ? 'limegreen' : '#333'
  );
</script>

<style>
  screen { background: #1a1a2e; display: flex; flex-direction: column; padding: 10px; gap: 8px; }
  #reading { color: dodgerblue; font-size: 24px; }
  #lamp { width: 16px; height: 16px; border-radius: 4px; }
</style>

<screen>
  <text id="reading">0</text>
  <view id="lamp"></view>
</screen>

cuttlefish.config.ts — point the entry at the .ui file:

import type { CuttlefishConfig } from '@typecad/cuttlefish/api';

const config: CuttlefishConfig = {
  entry: './src/app.ui',
  target: 'esp32',
  mcu: '@typecad/mcu-esp32',
  board: '@typecad/board-esp32-devkit',
  framework: '@typecad/framework-arduino',
  frameworkData: { buildTarget: 'esp32:esp32:esp32' },
  output: { framework: 'arduino', optimize: 'size', outDir: './out' },
  toolchain: { type: 'arduino-cli' },
  console: { baudRate: 115200 },
  display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
};
export default config;

Use this layout when a screen's markup and behavior are tightly coupled and you want them in one place.

Pattern B — main.ts entry + .ui.html for display (logic factored out)

When the sensor/IO logic is substantial, factor it into plain .ts modules and keep the UI file purely declarative. main.ts is the entry; it imports screen from a .ui.html file and the sensor functions from a sibling .ts, then marries them.

The import graph resolves both directions: main.ts → app.ui.html (for screen) and main.ts → sensors.ts (for the reading). Cross-module function calls survive lowering into the emitted C++.

src/main.ts — the entry, wires display and sensor logic:

import { ui } from '@typecad/ui';
import { screen } from './app.ui.html';
import { readVolts } from './sensors.js';   // .js extension required (Node16 resolution)

ui.mount(screen, { display: 'ili9341', bus: 'SPI', cs: 10, dc: 9, rst: 8 });

setInterval(() => {
  screen.meter.value = Math.round(readVolts() * 100);
}, 500);

export function main(): void { while (true) {} }

src/sensors.ts — plain cuttlefish TS, owns device I/O, no UI imports:

import { A0 } from '@typecad/board-arduino-uno';
const adc = A0.asInput();
export function readVolts(): number {
  return adc.readVoltage();
}

src/app.ui.html — display only:

<screen>
  <range id="meter" min="0" max="330"></range>
</screen>

cuttlefish.config.ts — point the entry at main.ts:

const config: CuttlefishConfig = {
  entry: './src/main.ts',     // ← .ts entry instead of .ui
  // ...rest identical
};

The transpiler lowers the setInterval callback to a timer that writes the node table and marks it dirty, preserving the cross-module readVolts() call verbatim:

static void __tc_timer_cb_0() {
  __ui_nodes[1].value = round(readVolts() * 100);   // sensor call preserved
  ui_mark_dirty(1);                                  // screen.meter.value = ... lowered
}

Use this layout when sensor or IO logic is large enough to deserve its own module, or when you want the UI file to stay purely declarative.

Choosing between the two

| Layout | Entry | Use when | |---|---|---| | .ui single-file | entry: './src/app.ui' | Display and behavior are tightly coupled; one screen's markup + logic belong together | | main.ts + .ui.html split | entry: './src/main.ts' | Sensor/IO logic is substantial; UI file should stay declarative |

Both patterns can import the same things (@typecad/ui, the board package, the HAL, your own .ts modules), and both lower through the same pipeline. You can also mix them within a project — a .ui entry can import helper functions from a sibling .ts, and a main.ts entry can import screen from multiple .ui.html files.

A note on dynamic text bindings

Most bindings lower general expressions and work in either layout — color, background, border, value, visibility. The one exception is ui.bind(node, 'text', ...), whose body in v1 only lowers three shapes: String(<numeric>), a bare string literal, or a template literal with numeric interpolations. Anything else (including a cross-module function call) lowers to an empty body and emits a ui-bind-text-unlowered warning.

For dynamic text that doesn't fit those shapes, write to the element from setInterval instead:

// Avoid (body won't lower in v1):
ui.bind(screen.reading, 'text', () => readVolts().toFixed(2) + 'V');

// Prefer — drive from a timer:
setInterval(() => {
  // write to .value on numeric elements, or use String(<expr>) in the bind
  screen.meter.value = Math.round(readVolts() * 100);
}, 500);

Quick start

The fastest path is a single .ui file with markup, styling, and behavior together (see Project layout for the alternative main.ts + .ui.html split).

1. Create src/app.ui

<script>
  import { ui } from '@typecad/ui';

  ui.mount(screen);

  // A signal carries the count; the binding recomputes the label each frame.
  // Text updates via ui.bind(..., 'text', ...) — <text> elements don't expose
  // a writable .text. String(<number>) is one of the supported bind shapes.
  const taps = ui.signal(0);
  ui.bind(screen.count, 'text', () => String(taps()));

  screen.action.onClick(() => {
    taps.set(taps() + 1);
  });
</script>

<style>
  screen {
    background: #1a1a2e;
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 10px;
  }
  #count { color: dodgerblue; font-size: 16px; }
  #action {
    background: darkgreen;
    color: white;
    border: 2px solid limegreen;
    border-radius: 4px;
    padding: 8px 20px;
    text-align: center;
    transition: background 300ms;
  }
  #action:pressed { background: limegreen; }
</style>

<screen>
  <text id="count">0</text>
  <button id="action">Start</button>
</screen>

Display hardware and wiring live in cuttlefish.config.ts under display, so the UI source stays focused on UI behavior.

2. Point the entry at the .ui file

// cuttlefish.config.ts
import type { CuttlefishConfig } from '@typecad/cuttlefish/api';

const config: CuttlefishConfig = {
  entry: './src/app.ui',
  target: 'esp32',
  mcu: '@typecad/mcu-esp32',
  board: '@typecad/board-esp32-devkit',
  framework: '@typecad/framework-arduino',
  frameworkData: { buildTarget: 'esp32:esp32:esp32' },
  output: { framework: 'arduino', optimize: 'size', outDir: './out' },
  toolchain: { type: 'arduino-cli' },
  console: { baudRate: 115200 },
  display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
};
export default config;

3. Build

npx @typecad/cuttlefish build --compile

Elements

<screen>

The root container. Required (exactly one). Its box fills the display viewport (320×240 landscape for ILI9341).

<view>

A generic container. Supports display: flex for layout. Has a .value property and onToggle/onChange for interaction.

<view id="row">
  <text id="label">Status</text>
  <text id="value">OK</text>
</view>

<text>

Static or dynamic text. Has a .value property for numeric state.

<text id="counter">0</text>

<button>

A clickable button with :pressed pseudo-state support and transition animations.

<button id="start">Start</button>

Interactive elements

| Element | Purpose | .value | |---|---|---| | <check> | Checkbox (tap to toggle) | 0 / 1 | | <radio name="g"> | Radio (mutually exclusive within a name group) | 0 / 1 | | <select> | Tap to cycle options; text auto-shows the current option | 0..N-1 | | <progress> | Progress bar (0-100) | fill percentage | | <range> | Draggable slider | between min and max | | <input> | Text input (tap opens the on-screen keyboard) | — (use .text) | | <list> | Virtualized, data-bound list (renders only visible items) | — | | <canvas> | User-drawn graphics (sparklines, gauges, custom shapes) via ui.drawCanvas | — |

<range id="brightness" min="0" max="100"></range>
<progress id="load" value="40"></progress>
<check id="enable">Enable feature</check>
<input id="ssid" type="text" placeholder="Network name" maxlength="32"></input>
<select id="mode"><option>Auto</option><option>Manual</option></select>

Slider and progress values can be driven live from loop() via bindings (see Reactive bindings below).

Images

<img> embeds a raw RGB565 .img file as a static const uint16_t[] array:

<img id="logo" src="assets/logo.img" width="64" height="64"></img>

The .img file is a flat row-major RGB565 dump (width × height × 2 bytes). Use object-fit (contain, cover, fill) to control scaling.

HTML tag aliases

Common HTML tags are accepted and remapped to the internal primitives, so you can write familiar HTML:

| HTML tag | Maps to | Notes | |---|---|---| | body, div, header, footer, nav, main, section, article, aside | <view> | Block container | | span, p, h1h6 | <text> | Inline/heading text |

<header><h1 id="title">Settings</h1></header>
<main><p id="desc">Adjust preferences.</p></main>

Global attributes

All UI elements support the hidden attribute. Hidden elements and their descendants stay in the generated node table, but they do not take space in layout and are skipped for drawing and hit testing.

<view id="advancedPanel" hidden>
  <text>Advanced settings</text>
</view>

CSS reference

Supported properties

Box model

| Property | Values | Notes | |---|---|---| | padding | 8px, 8px 16px | Shorthand supported | | margin | 8px, 8px 16px | Shorthand supported | | width | 100px | Explicit size | | height | 50px | Explicit size | | min-width / max-width | 100px | Yoga constraints | | min-height / max-height | 50px | Yoga constraints | | aspect-ratio | 16 / 9, 1 / 1, 1.5 | Infers the missing width or height | | box-sizing | border-box | Yoga border-box | | overflow | hidden, scroll | Clips children; scroll enables touch-drag scrolling |

Length units

All length values accept px, bare numbers, and rem/em (× 16 root font size). 0.625rem resolves to 10px. Percentages are used as-is in the contexts that honor them (flex/position).

calc() evaluates simple arithmetic (+ - * /) on lengths after var() substitution, with proper operator precedence:

:root { --radius: 10px; }
.card { border-radius: calc(var(--radius) - 4px); }   /* 6px */
.a { padding: calc(0.625rem * 2); }                   /* 20px */

Flexbox (via Yoga)

| Property | Values | |---|---| | display | flex, none | | flex-direction | row, row-reverse, column, column-reverse | | gap | 8px (sets both row and column gap) | | row-gap / column-gap | 8px (per-axis; overrides uniform gap) | | flex-grow | 1 | | flex-shrink | 0 | | flex (shorthand) | 1, 1 0 auto, none | | align-items | flex-start, center, flex-end, stretch | | align-self | flex-start, center, flex-end, stretch, baseline | | align-content | flex-start, center, flex-end, stretch, space-between, space-around, space-evenly | | justify-content | flex-start, center, flex-end, space-between, space-around, space-evenly | | flex-wrap | wrap, nowrap, wrap-reverse | | order | 1, 2, ... | | position | relative, absolute, static | | top / right / bottom / left | 10px | | z-index | numeric layers; inherited by descendants |

display: none removes the element subtree from layout, drawing, and hit testing while preserving generated node indices.

Colors

All standard CSS color formats are supported:

  • #rrggbb#ff0000
  • #rgb#f00
  • #rrggbbaa#ff0000ff (alpha ignored)
  • rgb(r,g,b)rgb(255, 0, 0)
  • rgba(r,g,b,a)rgba(255, 0, 0, 0.5) (alpha ignored)
  • hsl(h, s%, l%) / hsla(...)hsl(240, 100%, 50%), hsla(0 0% 0% / 0.05)
  • Named colors — red, dodgerblue, limegreen, transparent, ... (147 CSS named colors)

Both comma (hsl(0, 0%, 0%)) and CSS4 space (hsl(0 0% 0%)) syntaxes work. The slash-alpha form (hsl(0 0% 0% / 0.05)) is honored in box-shadow alpha; elsewhere alpha is ignored (no runtime blending on bare metal).

Typography

| Property | Values | Notes | |---|---|---| | color | any color | Text foreground color | | font-family | "MyFont" | Uses a generated font when matched by @font-face; otherwise the built-in bitmap font | | font-size | 16px | Generated fonts are rasterized at this pixel size; bitmap text maps to GFX text size | | font | italic bold 18px DeviceSans | Shorthand support for style, weight, size, and family | | text-align | left, center, right | Horizontal alignment within the box | | text-decoration | underline, line-through, none | Both may combine: underline line-through | | text-overflow | ellipsis, clip | Truncates overflowing single-line text with ... | | text-transform | uppercase, lowercase, capitalize, none | Applied at transpile time | | line-height | 1.5, 150%, 24px | Line advance for wrapped text; normal = font default | | letter-spacing | 2px, -1px | Per-character advance adjustment | | white-space | normal, nowrap, pre, pre-line | Controls word-wrap behavior | | font-weight | normal, bold, 400, 700 | Selects the matching @font-face variant when available | | font-style | normal, italic, oblique | Selects the matching @font-face variant when available | | font-smoothing | antialiased, none | Overrides display-level text antialiasing | | font-subset | exact, fallback | Controls generated-font glyph selection |

Fonts

Local TTF/OTF fonts can be referenced with @font-face. The transpiler does not copy the whole font to the board. It reads the font at build time, rasterizes only the glyphs needed by the UI, packs them as 4-bit alpha bitmap data, and emits those tables into the firmware. On ESP32-class targets those generated tables are static const data in flash/rodata; the original TTF/OTF file is not held in RAM on the hardware.

Install a font in a project

Put font files somewhere inside the project, usually next to the .ui.css file or under a local fonts/ folder:

src/
  app.ui.html
  app.ui.css
  fonts/
    DeviceSans-Regular.ttf
    DeviceSans-Bold.ttf

Reference them from CSS with paths relative to the .ui.css file:

@font-face {
  font-family: "DeviceSans";
  src: url("fonts/DeviceSans.ttf");
}

#title {
  font-family: "DeviceSans";
  font-size: 24px;
  font-smoothing: antialiased;
}

Remote font URLs are not supported for embedded builds. Use local files so the build is reproducible and does not depend on network access.

Declare variants

Declare each weight/style variant as its own @font-face. The UI compiler chooses the closest matching variant for each node based on font-family, font-weight, font-style, and font-size.

@font-face {
  font-family: "DeviceSans";
  src: url("fonts/DeviceSans-Regular.ttf");
  font-weight: 400;
  font-style: normal;
}

@font-face {
  font-family: "DeviceSans";
  src: url("fonts/DeviceSans-Bold.ttf");
  font-weight: 700;
  font-style: normal;
}

@font-face {
  font-family: "DeviceSans";
  src: url("fonts/DeviceSans-Italic.ttf");
  font-weight: 400;
  font-style: italic;
}

#title {
  font-family: "DeviceSans";
  font-size: 24px;
  font-weight: bold;
}

Every distinct font-family + resolved font file + font-size + variant becomes one generated font asset. Reusing the same face and size across many nodes shares one asset. Using the same face at 16px and 24px creates two assets because each size is rasterized separately.

Exact subsetting and icon fonts

By default, generated fonts use font-subset: exact. Only the literal characters found in static UI text, placeholders, and option labels are encoded. This is useful for icon fonts and symbol fonts:

<text id="wifiIcon">✓</text>
@font-face {
  font-family: "DeviceIcons";
  src: url("fonts/device-icons.ttf");
}

#wifiIcon {
  font-family: "DeviceIcons";
  font-size: 20px;
  font-subset: exact;
}

In this case, only the checkmark glyph is emitted for that font/size, not the whole icon font and not the common ASCII set.

Generated font glyph lookup supports UTF-8 text for codepoints in the Basic Multilingual Plane (U+0000 to U+FFFF). Many icon fonts use Private Use Area codepoints such as U+E000; those are supported. Emoji and other characters above U+FFFF are not currently supported by the generated-font runtime.

Wingdings-style fonts can work, but be careful: some older symbol fonts use legacy character mappings rather than standard Unicode symbols. Copy the exact character/codepoint that the font maps to the glyph you want, or prefer a Unicode icon font when possible.

Dynamic text and fallback glyphs

Exact subsetting can only see text known at build time. If a generated font is used on a node whose text changes at runtime, include a fallback character set:

#counter {
  font-family: "DeviceSans";
  font-size: 18px;
  font-subset: fallback;
}

font-subset: fallback includes the static text plus a small common ASCII set containing digits, letters, spaces, and punctuation. Use it for counters, formatted numeric values, input fields, or any generated-font text binding that can produce characters not present in the initial HTML.

If the node uses the built-in bitmap font, font-subset has no effect.

Smoothing

Generated TTF/OTF glyphs are rasterized as alpha masks. Use font-smoothing: antialiased to blend edge pixels for smoother text on RGB displays. Use font-smoothing: none to threshold the same glyph masks for a sharper, more pixel-like look. On monochrome displays smoothing is disabled.

.smooth {
  font-family: "DeviceSans";
  font-size: 18px;
  font-smoothing: antialiased;
}

.sharp {
  font-family: "DeviceSans";
  font-size: 18px;
  font-smoothing: none;
}

Converting fonts

Use TTF or OTF files when possible. WOFF/WOFF2 web fonts should be converted to TTF/OTF before use.

Common conversion options:

  • FontForge GUI: open the source font, then use File -> Generate Fonts... and choose TrueType (.ttf) or OpenType (.otf).
  • FontForge CLI:
fontforge -lang=ff -c 'Open($1); Generate($2)' input.otf output.ttf
  • WOFF2 tools: use woff2_decompress input.woff2 to produce a TTF-flavored font when the source is a WOFF2 web font.
  • fonttools can inspect and subset fonts:
python -m pip install fonttools brotli
pyftsubset DeviceSans.ttf --text="ABC123" --unicodes=U+2713 --output-file=DeviceSans-subset.ttf

Manual external subsetting is optional. TypeCAD already subsets the emitted hardware glyphs. External subsetting is mainly useful when you need to distribute a smaller source font file, remove unused font tables for licensing reasons, or speed up build-time parsing of a very large font.

Licensing

Do not assume system fonts are redistributable. Fonts such as commercial OS fonts may be licensed for local use but not for checking into a repository or shipping in a firmware project. Prefer open-licensed fonts, or keep proprietary fonts outside shared source control if your license requires it.

Visual

| Property | Values | Notes | |---|---|---| | background / background-color | any color | Fill color | | border (shorthand) | 2px solid #808080 | Splits into width/style/color | | border-width | 2px | | | border-color | any color | | | border-style | solid, dashed, none | Dashed approximated with segments | | border-radius | 4px | Rounded fill/border on hardware; preview approximates | | outline | 1px solid #fff, 2px dashed red | Drawn outside the element box | | visibility | visible, hidden | Hidden elements are not drawn | | box-shadow | inset 0 1px 0 #fff, 0 10px 0 #333 | Up to 4 rect shadows; approximated for TFT drawing | | transform | translateY(10px), translate(0, 10px) | Draw-time translate offset; no flex relayout | | opacity | parsed | (Blending not supported without PSRAM framebuffer) |

Transitions

| Property | Values | Notes | |---|---|---| | transition | background 300ms, color 120ms | Lerps the property over the duration | | :pressed | pseudo-class | Applied when .value is 1 (button press) |

Pressed rules may also include top / left / right / bottom or transform: translate(...). These are applied as draw-time offsets so the element face/content can move visually without recomputing the flex layout; outset shadows stay anchored, which is useful for raised button effects.

Small dirty paint regions for text, backgrounds, borders, outlines, shadows, and draw-time translate offsets are composed in an offscreen RGB565 canvas and pushed as one rectangle when memory allows. Larger regions fall back to direct drawing.

Selectors

  • Element: screen { ... }
  • ID: #title { ... }
  • Class: .card { ... }
  • Compound: .card.active { ... }, button.primary { ... }
  • Descendant: view text { ... }
  • Child: view > text { ... } (direct children only)
  • Adjacent sibling: .first + .second { ... } (immediate next sibling)
  • General sibling: .first ~ .later { ... } (any following sibling)
  • Attribute: [disabled], [type="number"] (presence and exact-value match)
  • Negation: button:not(.disabled), .a:not(.b.c) (compound :not() supported)
  • Pseudo-state: #btn:pressed, input:disabled, check:checked, *:focus
  • Inline style: <text style="color: red">hi</text>
  • <style> blocks embedded in the .ui.html

Pseudo-states match runtime element state: :pressed (button held), :disabled (disabled attribute), :checked (<check>/<radio> with .value 1), and :focus (the node currently receiving input).

CSS variables

Define variables in :root and reference them with var():

:root {
  --bg: #0a0a0a;
  --fg: #fafafa;
  --primary: #7c3aed;
}
screen { background: var(--bg); }
#title { color: var(--fg); }

Variables resolve at transpile time — no runtime cost.

Class-scoped variables (themes)

Variables can also be defined under a class selector (e.g. .dark) and selected at build time via the themeClass config option. This is how shadcn-style light/dark themes work:

:root { --bg: #ffffff; --fg: #0a0a0a; }
.dark { --bg: #0a0a0a; --fg: #fafafa; }
screen { background: var(--bg); color: var(--fg); }
// cuttlefish.config.ts — or the display config in ui.mount
display: {
  themeClass: 'dark',   // resolves var(--x) using the .dark overrides
}

When themeClass is set, var() substitution prefers that class's variables over :root. This is transpile-time selection — one theme per firmware build (there is no runtime theme switch on a fixed-screen device).

@media (compile-time variant selection)

@media rules are evaluated against the resolved display profile at transpile time. Since each build targets one fixed screen size, this acts as a compile-time variant selector, not responsive design:

/* Applied only when the display is ≤ 240px wide */
@media (max-width: 240px) {
  #title { font-size: 12px; }
}

Supported conditions: min-width, max-width, min-height, max-height (in px). Unsupported conditions (e.g. orientation) emit a warning and the rule is skipped. @import and @supports are not supported (warned + skipped).

Theming

Themes are compile-time. There are two complementary mechanisms:

1. Theme file (themeCss) — swap the entire CSS file:

// cuttlefish.config.ts
display: {
  themeCss: './src/hello.dark.css',  // relative to .ui.html dir
  // or: themeCss: '/absolute/path/to/theme.css',
}

When themeCss is set, that file replaces the default sibling .ui.css. Use CSS variables to define a palette once, then swap the variable file for different themes:

src/
  hello.ui.html       ← layout (shared)
  hello.ui.css         ← default theme (no themeCss set)
  hello.dark.css       ← dark theme
  hello.shadcn.css     ← shadcn palette

2. Theme class (themeClass) — select a class-scoped variable block within a single CSS file (see Class-scoped variables above):

display: {
  themeClass: 'dark',  // resolves var(--x) from .dark { ... } overrides
}

The two can be combined: themeCss picks the file, themeClass picks the variable scope within it.

The .ui.html file defines the structure (elements, IDs, layout); the CSS file defines the appearance (colors, fonts, borders, shadows). Swap either in config without touching the HTML.

Unsupported (and why)

  • display: grid — needs a GridLayoutEngine
  • Full inline rich text — basic wrapping, line-height, white-space, and <br> are supported; mixed inline spans are not
  • background-image / sprites — use <img> for embedded images; CSS background: url(...) is unsupported (only solid colors and linear-gradient)
  • position: fixed — viewport-fixed positioning is not implemented
  • :after / :before pseudo-elements — no generated content
  • text-shadow on built-in font — needs sub-pixel font data (works with custom fonts)
  • Per-corner border-radius — only a uniform radius is supported (Adafruit_GFX draws one corner value)
  • Runtime theme switching — themes are compile-time only (one themeClass per build; swap in config and rebuild)

State and interaction

The .value property

Every interactive element has a .value property — a number that is both readable and writable:

// Read
const count = screen.counter.value;

// Write (updates the display immediately)
screen.counter.value = 42;

Pin input

// Watch a pin for falling edges — runs in the frame loop (no ISR)
ui.watchPin(4, () => {
  screen.counter.value = screen.counter.value + 1;
});

// Toggle an element's .value on pin press (0 ↔ 1)
screen.ledBox.onToggle(5);

// Cycle through options (0 → 1 → 2 → 0 → ...)
screen.modeSelect.onChange(15, 3);

Reactive bindings

Bindings compute a display property from .value or signals each frame:

// Color binding
ui.bind(screen.counter, 'color', () =>
  (screen.counter.value % 2 === 0 ? 'limegreen' : 'orange')
);

// Background binding
ui.bind(screen.btn, 'background', () =>
  (screen.btn.value > 0 ? 'limegreen' : 'darkgreen')
);

// Border color binding
ui.bind(screen.ledBox, 'borderColor', () =>
  (screen.ledBox.value ? 'limegreen' : '#808080')
);

// Value binding — drive a progress/range node live from loop()
ui.bind(screen.progress, 'value', () => sensorPercent);

// Visibility binding - preallocate both branches and toggle which one draws
ui.bind(screen.enteredBranch, 'visible', () => screen.input.value > 0);
ui.bind(screen.emptyBranch, 'visible', () => screen.input.value === 0);

// Text binding — number to string
ui.bind(screen.counter, 'text', () => String(screen.counter.value));

// Text binding — ternary chain (for selectors)
ui.bind(screen.modeValue, 'text', () => (
  screen.modeValue.value === 0 ? 'Auto' :
  screen.modeValue.value === 1 ? 'Manual' : 'Off'
));

visible bindings are for fixed-layout conditional rendering. The nodes stay in the retained UI tree; hidden branches are skipped for drawing and hit testing, and shown branches repaint their subtree.

Two-way input binding

When the user types into an <input> via the on-screen keyboard, push the text back into app state with ui.bindInput:

let ssid = '';
ui.bindInput(screen.ssid, (text) => { ssid = text; });

The callback fires whenever the input's text changes (after the keyboard commits).

Slider change callbacks

A <range> fires onChange on every value change while dragging — read .value inside the callback for the new value:

screen.brightness.onChange(() => {
  // Fires continuously during the drag.
  ledPwm = screen.brightness.value;
});

Data-bound lists

<list> is a virtualized, callback-driven list — it renders only the visible items to a dedicated scroll canvas, so a thousand-item list has the same memory footprint as a ten-item one. Bind it with ui.bindList:

<list id="networks" item-height="28px"></list>
ui.bindList(
  screen.networks,
  () => scanResults.length,                          // count
  (i) => `${scanResults[i].ssid} (${scanResults[i].rssi} dBm)`,  // item text
  (i) => { connectTo(scanResults[i].ssid); },        // optional tap handler
);

The count function re-evaluates each frame; if it changes, the list recomputes its content height and repaints. Drag to scroll; tap an item to fire the optional third callback.

User-drawn canvas (<canvas>)

<canvas> is an element whose contents you draw yourself, every frame, using the display graphics primitives. It follows all CSS rules (layout, borders, transforms, z-index) like any other element, but its pixels come from your callback. Use it for sparkline graphs, analog gauges, or custom-shaped controls.

<canvas id="spark" width="120" height="40"></canvas>

width/height set the drawing buffer size (px). The CSS box is the layout size — size them to match unless you want clipping.

ui.drawCanvas(screen.spark, (ctx) => {
  ctx.fillScreen('black');
  ctx.line(0, 30, ctx.width, 30, 'limegreen');      // baseline
  ctx.rect(2, 2, ctx.width - 4, ctx.height - 4, '#333');
  ctx.fillCircle(needleX, 30, 3, 'red');
  ctx.text(4, 12, `${temp}°`, 'white');             // optional color arg
});

Coordinates are canvas-relative ((0,0) = element top-left) and drawing is auto-clipped to the buffer — you cannot accidentally paint over neighbors. Color arguments are CSS color strings resolved to RGB565 at build time.

The callback runs every frame; to animate, mutate state in a setInterval or signal and the canvas picks it up next frame. Taps hit-test as the full CSS box, so screen.spark.onClick(...) works for interactive canvases.

ctx methods (the display graphics primitives)

| Method | Notes | |---|---| | ctx.fillRect(x,y,w,h,color) / ctx.rect(...) | Filled / outline rectangle | | ctx.fillRoundRect(x,y,w,h,r,color) / ctx.roundRect(...) | Rounded variant | | ctx.line(x0,y0,x1,y1,color) | Arbitrary line | | ctx.hline(x,y,w,color) / ctx.vline(x,y,h,color) | Fast horizontal / vertical line | | ctx.fillCircle(x,y,r,color) / ctx.circle(...) | Filled / outline circle | | ctx.drawPixel(x,y,color) | Single pixel | | ctx.text(x,y,str,color?) | Bitmap text (built-in font) | | ctx.fillScreen(color) | Clear the whole buffer | | ctx.width / ctx.height | Read-only buffer dimensions |

Signals

For reactive state not tied to an element:

const temperature = ui.signal(22);

// Read
const t = temperature();

// Write
temperature.set(25);

ui.signal() accepts number, string, or boolean literals — these lower to int/double, const char*, and bool on the device. Other initializers (objects, arrays, null, identifiers) are rejected at type-check time (Signal<T extends SignalValue>) and at build time with a ui-signal-initializer warning that defaults the signal to 0 (int).

Element id errors

If a binding or event handler references an element id that doesn't exist in the screen, the build fails with a ui-unknown-element error rather than silently re-targeting the wrong node. This applies to every call that takes a screen element:

ui.bind(screen.typo, 'color', ...)      // ✗ error: element "typo" not found
ui.bindInput(screen.typo, ...)          // ✗ error
ui.bindList(screen.typo, ...)           // ✗ error
ui.drawCanvas(screen.typo, ...)         // ✗ error
await ui.onTap(screen.typo)             // ✗ error (no silent fallback to any-tap)
screen.typo.onClick(...)                // ✗ error
screen.typo.onToggle(...)               // ✗ error

Fix the typo in your .ui.html / .ui file's id attribute and rebuild.

Composing custom elements

Don't see the element you need? Build it from <view> + <text> + bindings:

Custom checkbox

<view id="ledRow">
  <view id="ledBox"></view>
  <text id="ledLabel">Enable LED</text>
</view>
#ledBox {
  width: 16px;
  height: 16px;
  border: 2px solid #808080;
}
screen.ledBox.onToggle(5);  // toggles .value 0↔1

ui.bind(screen.ledBox, 'background', () =>
  (screen.ledBox.value ? 'limegreen' : 'transparent')
);
ui.bind(screen.ledBox, 'borderColor', () =>
  (screen.ledBox.value ? 'limegreen' : '#808080')
);

Custom selector

<view id="modeRow">
  <text id="modeLabel">Mode:</text>
  <text id="modeValue">Auto</text>
</view>
screen.modeValue.onChange(15, 3);  // cycles 0→1→2→0

ui.bind(screen.modeValue, 'text', () => (
  screen.modeValue.value === 0 ? 'Auto' :
  screen.modeValue.value === 1 ? 'Manual' : 'Off'
));

Custom progress bar

<view id="barContainer">
  <view id="barFill"></view>
</view>
#barContainer { width: 200px; height: 20px; border: 1px solid #808080; }
#barFill { background: limegreen; height: 100%; }
ui.bind(screen.barFill, 'background', () =>
  (screen.barFill.value > 50 ? 'limegreen' : 'orange')
);

Timers

// Auto-update every 2 seconds
setInterval(() => {
  screen.counter.value = screen.counter.value + 1;
}, 2000);

Architecture

.ui.html / .ui.css  →  parse (linkedom + css-tree)  →  resolve styles
                         ↓
                    layout (Yoga flexbox)
                         ↓
                    lower to C++ UINode[] table
                         ↓
              ui_mount → display.init (Adafruit_ILI9341)
              ui_tick  → poll inputs → eval bindings → transitions → draw
              ui_init  → mark all dirty for first frame

The runtime is a retained-mode tree: the HTML/CSS is fully resolved at transpile time. The device only sees static tables + a tiny draw loop. No DOM, no CSS engine, no HTML parser on the MCU.

Rendering & performance

Each frame, ui_tick re-evaluates bindings, advances transitions/animations, and redraws only the nodes marked dirty (most frames touch a handful of nodes, not the whole screen). Dirty paint regions are composed in an offscreen RGB565 canvas and pushed as one rectangle when memory allows.

Framebuffer (PSRAM-gated). When the board has PSRAM (BOARD_HAS_PSRAM defined + psramFound()), the runtime allocates a full-screen GFXcanvas16 framebuffer and renders the entire dirty-node pass into it, then pushes once via a single SPI transaction. This eliminates the per-primitive transaction storm that otherwise limits redraw rate on ILI9341 over SPI. Without PSRAM the runtime falls back to direct per-node drawing (no behavior change). The framebuffer activates automatically — no config needed beyond enabling PSRAM in the Arduino build flags.

Diagnostics (warnings)

Unknown HTML tags and unknown CSS properties are reported as warnings, not silently dropped. They appear in the build output (yellow, to stderr) and do not abort the build:

⚠ Unknown CSS property "bogus-prop" — ignored.
⚠ Unknown HTML tag <marquee> — ignored.
⚠ Unsupported @media (orientation: portrait) has an unsupported condition — rule ignored.

This surfaces typos and unsupported features early instead of leaving styles mysteriously unapplied.

Display profiles

The display hardware is described in cuttlefish.config.ts under the display field. This drives all transpile-time decisions: dimensions, color format, rotation, SPI pins, backlight, and touch.

Config reference

// cuttlefish.config.ts
display: {
  // Either reference a built-in profile by name:
  profile: 'ili9341-spi',

  // Or inline everything:
  // driver: 'ili9341',
  // width: 320, height: 240,
  // colorFormat: 'rgb565',
  // rotation: 1,

  // Wiring (always project-specific)
  cs: 5,
  dc: 21,
  rst: 22,
  backlight: 17,       // optional — pin number for backlight

  // Experimental and opt-in: use only after verifying the controller's
  // GET_SCANLINE (0x45) readback and wiring SDO/MISO in spiPins. Some ST7796S
  // modules stop scanning when this command is read, so the default is off.
  // scanlineSync: true,
  // spiPins: { mosi: 11, sck: 12, miso: 13 },

  // Antialiasing (optional)
  antialias: true,       // smooths shapes and text; text can opt out with font-smoothing:none

  // Theming (optional — compile-time)
  themeCss: './src/hello.dark.css',  // override the sibling .ui.css file
  themeClass: 'dark',                 // select a class-scoped variable block (.dark { ... })

  // Touch (optional)
  touch: {
    library: 'XPT2046_Touchscreen',
    cs: 14,             // touch controller CS pin
    irq: 2,             // optional — interrupt pin
    calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
    minPressure: 10,
  },
}

Profile fields

| Field | Type | Description | |---|---|---| | profile | string | Built-in profile name (e.g. "ili9341-spi") | | driver | string | Display driver id (e.g. "ili9341") | | width | number | Display width in pixels (after rotation) | | height | number | Display height in pixels (after rotation) | | colorFormat | "rgb565" | "mono" | Color depth | | rotation | number | 0=portrait, 1=landscape, 2-3=inverted | | backlight | number | Backlight pin (optional) | | cs / dc / rst | number | Display wiring pins |

Built-in profiles

| Name | Display | Dimensions | Color | Touch | |---|---|---|---|---| | ili9341-spi | ILI9341 (SPI) | 320×240 | RGB565 | Add via touch config | | ssd1309-i2c | SSD1309 OLED (I2C) | 128×64 | Mono | None |

Adding a new display

Adding a new display driver requires two parts: a display profile (the hardware config) and a display adapter (the generated C++ code that drives it).

1. Register a display adapter

A display adapter is a TypeScript function that generates C++ code for a specific driver. Register it in a module that runs before the build:

// my-project/display-adapters.ts
import { registerDisplayAdapter } from '@typecad/cuttlefish/api/shared/display-adapter';

registerDisplayAdapter('ssd1306', (display) => {
  return {
    includes: `#include <Adafruit_GFX.h>\n#include <Adafruit_SSD1306.h>\n#include <Wire.h>`,
    declaration: `Adafruit_SSD1306 __tc_display(128, 64, &Wire, -1);`,
    functions: [
      'static int16_t __addrX = 0, __addrY = 0, __addrW = 0, __addrH = 0;',
      'static uint32_t __addrCursor = 0;',
      'static inline void display_init() {',
      '  __tc_display.begin(SSD1306_SWITCHCAPVCC, 0x3C);',
      '  __tc_display.clearDisplay();',
      '  __tc_display.display();',
      '}',
      'static inline void display_fillScreen(uint16_t color) {',
      '  __tc_display.fillScreen(color ? 1 : 0);',
      '}',
      'static inline void display_startWrite() { }',
      'static inline void display_endWrite() { }',
      'static inline void display_setAddrWindow(int16_t x, int16_t y, int16_t w, int16_t h) {',
      '  __addrX = x; __addrY = y; __addrW = w; __addrH = h; __addrCursor = 0;',
      '}',
      'static inline void display_writePixels(uint16_t* pixels, uint32_t count) {',
      '  if (__addrW <= 0 || __addrH <= 0) return;',
      '  uint32_t total = (uint32_t)__addrW * (uint32_t)__addrH;',
      '  for (uint32_t i = 0; i < count; i++) {',
      '    if (__addrCursor >= total) break;',
      '    uint32_t pos = __addrCursor++;',
      '    __tc_display.drawPixel(__addrX + (pos % __addrW), __addrY + (pos / __addrW), pixels[i] ? 1 : 0);',
      '  }',
      '}',
      'static inline void display_partial_refresh(int16_t x, int16_t y, int16_t w, int16_t h) {',
      '  (void)x; (void)y; (void)w; (void)h;',
      '  __tc_display.display();',
      '}',
    ].join('\\n'),
  };
});

The runtime calls these core display functions:

| Function | Purpose | |----------|---------| | display_init() | Initialize the display (begin, rotation, clear) | | display_fillScreen(color) | Fill the entire screen with a color | | display_startWrite() | Begin an SPI transaction (no-op for I2C) | | display_endWrite() | End a transaction (often a no-op for page-buffered I2C) | | display_setAddrWindow(x, y, w, h) | Set the active write region | | display_writePixels(pixels, count) | Write pixels into the active region | | display_partial_refresh(x, y, w, h) | Publish the dirty region on deferred displays |

For monochrome displays, the adapter wraps each color argument with a conversion function. For deferred displays, the current runtime publishes from display_partial_refresh().

2. Create a display profile

// displays/my-display.ts
import type { DisplayProfile } from '@typecad/cuttlefish/api/shared';

export const MY_DISPLAY: DisplayProfile = {
  driver: 'ssd1306',      // must match the adapter name
  width: 128,
  height: 64,
  colorFormat: 'mono',    // 'rgb565' or 'mono'
  rotation: 0,
};

3. Reference it in config

display: {
  driver: 'ssd1306',
  width: 128, height: 64,
  colorFormat: 'mono',
  rotation: 0,
}

4. Import the adapter module before building

Make sure your adapter module is imported (side-effect import) so the registration runs:

// cuttlefish.config.ts or main.ts
import './display-adapters';  // registers the 'ssd1306' adapter

Built-in adapters

| Driver | Display | Color | Notes | |--------|---------|-------|-------| | ili9341 | ILI9341 (320×240) | RGB565 | Default, hardware SPI |

To add more built-in adapters, contribute a file to packages/framework-arduino/src/graphics/ and register it.

Touch input

Touch is configured via the touch field in the display profile. The system uses an adapter pattern: built-in libraries generate C++ automatically; custom libraries use a TypeScript adapter file.

Built-in touch libraries

| Library | Controllers | Interface | Config | |---|---|---|---| | XPT2046_Touchscreen | XPT2046 (common ILI9341 shields) | SPI (shared with display) | { library, cs, irq? } | | Adafruit_TouchScreen | Resistive 4-wire | Analog (no SPI) | { library, analogPins: { xp, yp, xm, ym, rx } } | | Adafruit_STMPE610 | STMPE610 (capacitive) | SPI or I2C | { library, cs } |

Config examples

XPT2046 (most common with ILI9341 TFT shields):

display: {
  profile: 'ili9341-spi',
  cs: 5, dc: 21, rst: 22,
  touch: {
    library: 'XPT2046_Touchscreen',
    cs: 14,           // touch CS pin (separate from display CS)
    irq: 2,           // optional
    calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
    minPressure: 10,
  },
}

Adafruit resistive 4-wire:

touch: {
  library: 'Adafruit_TouchScreen',
  analogPins: { xp: 'A3', yp: 'A2', xm: 8, ym: 9, rx: 300 },
  calibration: { xMin: 100, xMax: 900, yMin: 100, yMax: 900 },
  minPressure: 10,
}

Calibration

Calibration maps the touch controller's raw ADC values to display pixel coordinates. To calibrate your panel:

  1. Add Serial.printf("raw=(%d,%d,%d)\n", p.x, p.y, p.z) to the touch poll
  2. Touch the four corners of the screen and note the raw values
  3. Set xMin/xMax from the left/right edges, yMin/yMax from the top/bottom

The transpiler handles rotation (axis swap + inversion) automatically based on the rotation field in the display profile.

Touch events (onClick, onHold, onRelease)

// Short tap (finger down + up within 600ms)
screen.btn.onClick(() => {
  console.log("tapped");
  screen.counter.value = screen.counter.value + 1;
});

// Long press (finger held ≥600ms)
screen.btn.onHold(() => {
  console.log("held");
});

// Finger lift (always fires after click or hold)
screen.btn.onRelease(() => {
  console.log("released");
});

The touch system implements a state machine:

  • 50ms debounce — prevents rapid re-triggering
  • Click — touch down + up within 600ms
  • Hold — touch held ≥600ms (fires once)
  • Release — finger lifts (clears .value to 0)
  • Visual feedback.value set to 1 on touch down, 0 on release

Hit-testing walks nodes topmost-first and skips containers without click handlers.

Awaitable tap notifications (ui.onTap)

ui.onTap() is an awaitable tap signal — use it inside an async function to suspend until the next tap. It's the building block for display-sleep / screensaver behavior and custom flow control ("tap to continue"):

// Display-sleep: wake on ANY touch, dim again after 10s of inactivity.
async function screensaver() {
  while (true) {
    backlightOff();
    await ui.onTap();        // resume on the next tap, anywhere on the screen
    backlightOn();
    await delay(10000);      // keep the display awake for 10 seconds
  }
}

With no argument it resumes on the next tap anywhere — including empty space, which is what makes "wake on any touch" work even when the finger lands on no element. Pass an element to resume only when that element is tapped:

// Tap-to-continue wizard: wait for the Start button specifically.
async function setupWizard() {
  await ui.onTap(screen.start);
  beginSetup();
}

A tap fires both the tapped element's onClick handler and resumes any await ui.onTap() awaiter — they don't compete. ui.onTap() is a resume signal, not a value: there is nothing to read from it (it returns Promise<void>).

| Call | Resumes on | |---|---| | await ui.onTap() | the next tap anywhere (including empty space) | | await ui.onTap(screen.elem) | the next tap on that specific element |

Under the hood this lowers to a cooperative state-machine state that polls a tap counter incremented by the touch driver each frame — no ISRs, natural debounce from the ~16ms tick, same model as await delay().

Custom touch adapters

For libraries not in the built-in list, write a TypeScript adapter:

// my-touch-adapter.ts
import { SomeTouchLib } from '../lib/SomeTouchLib/SomeTouchLib';

const ts = new SomeTouchLib(14, 2);
ts.begin();

export const touch = {
  isTouched: () => ts.touched(),
  read: () => {
    const p = ts.getPoint();
    return { x: p.x, y: p.y, z: p.z };
  },
};

Reference it in config:

touch: {
  adapter: './my-touch-adapter',
  calibration: { xMin: 100, xMax: 4000, yMin: 100, yMax: 4000 },
  minPressure: 10,
}

The adapter only provides raw {x, y, z} — the transpiler handles calibration, rotation, and coordinate mapping.

GPIO input (buttons without touch)

For physical buttons on GPIO pins (no touchscreen required):

// Watch a pin for falling edges — runs in the frame loop
ui.watchPin(4, () => {
  screen.counter.value = screen.counter.value + 1;
});

// Toggle an element's .value on pin press
screen.ledBox.onToggle(5);

// Cycle through options
screen.modeValue.onChange(15, 3);  // 3 options: 0→1→2→0

Natural debounce from the ~16ms frame rate — no ISR, no volatile.