@typecad/ui 1.0.0-alpha.13 → 1.0.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1457 -1469
  2. package/dist/cli.js +22 -19
  3. package/dist/preview/host-ui-runtime.js +0 -1
  4. package/dist/ui-engine/model.js +7 -1
  5. package/dist/ui-engine/runtime-header/canvas-scrollbar.js +115 -115
  6. package/dist/ui-engine/runtime-header/dirty-scroll-mutators.js +182 -182
  7. package/dist/ui-engine/runtime-header/forward-decls.js +227 -216
  8. package/dist/ui-engine/runtime-header/init-press-input.js +138 -138
  9. package/dist/ui-engine/runtime-header/keyboard.js +676 -676
  10. package/dist/ui-engine/runtime-header/node-draw-body.js +1658 -1656
  11. package/dist/ui-engine/runtime-header/paint-order-coords.js +259 -259
  12. package/dist/ui-engine/runtime-header/paint-rects-repair.js +736 -736
  13. package/dist/ui-engine/runtime-header/scroll-physics.js +4 -4
  14. package/dist/ui-engine/runtime-header/structs.js +228 -228
  15. package/dist/ui-engine/runtime-header/text-rendering.js +888 -888
  16. package/dist/ui-engine/runtime-header/tick/bindings-phase.js +118 -118
  17. package/dist/ui-engine/runtime-header/tick/dirty-draw-phase.js +896 -896
  18. package/dist/ui-engine/runtime-header/tick/scroll-canvas-phase.js +24 -24
  19. package/dist/ui-engine/runtime-header/touch-keyboard-fwd.js +11 -11
  20. package/dist/ui-engine/runtime-header/types-defines.js +110 -110
  21. package/dist/ui-engine/shadcn-kit.d.ts +1 -1
  22. package/dist/ui-engine/shadcn-kit.js +4 -0
  23. package/dist/ui-engine/ui-lowering.d.ts +1 -1
  24. package/dist/ui-engine/ui-lowering.js +14 -5
  25. package/dist/ui-engine/ui-registry.js +9 -1
  26. package/dist/wizard/display-catalog.d.ts +0 -3
  27. package/dist/wizard/display-catalog.js +3 -12
  28. package/dist/wizard/index.d.ts +2 -0
  29. package/dist/wizard/index.js +1 -0
  30. package/dist/wizard/integration-wizard.d.ts +3 -0
  31. package/dist/wizard/integration-wizard.js +69 -20
  32. package/dist/wizard/package-writer.d.ts +20 -0
  33. package/dist/wizard/package-writer.js +79 -0
  34. package/package.json +4 -4
  35. package/src/cli.ts +90 -87
  36. package/src/preview/host-ui-runtime.ts +0 -1
  37. package/src/ui-engine/model.ts +6 -1
  38. package/src/ui-engine/runtime-header/canvas-scrollbar.ts +121 -121
  39. package/src/ui-engine/runtime-header/dirty-scroll-mutators.ts +188 -188
  40. package/src/ui-engine/runtime-header/forward-decls.ts +238 -227
  41. package/src/ui-engine/runtime-header/init-press-input.ts +144 -144
  42. package/src/ui-engine/runtime-header/keyboard.ts +689 -689
  43. package/src/ui-engine/runtime-header/node-draw-body.ts +1683 -1681
  44. package/src/ui-engine/runtime-header/paint-order-coords.ts +265 -265
  45. package/src/ui-engine/runtime-header/paint-rects-repair.ts +742 -742
  46. package/src/ui-engine/runtime-header/scroll-physics.ts +4 -4
  47. package/src/ui-engine/runtime-header/structs.ts +234 -234
  48. package/src/ui-engine/runtime-header/text-rendering.ts +894 -894
  49. package/src/ui-engine/runtime-header/tick/bindings-phase.ts +124 -124
  50. package/src/ui-engine/runtime-header/tick/dirty-draw-phase.ts +902 -902
  51. package/src/ui-engine/runtime-header/tick/scroll-canvas-phase.ts +30 -30
  52. package/src/ui-engine/runtime-header/touch-keyboard-fwd.ts +11 -11
  53. package/src/ui-engine/runtime-header/types-defines.ts +116 -116
  54. package/src/ui-engine/shadcn-kit.ts +4 -0
  55. package/src/ui-engine/ui-lowering.ts +12 -4
  56. package/src/ui-engine/ui-registry.ts +9 -1
  57. package/src/wizard/display-catalog.ts +261 -273
  58. package/src/wizard/index.ts +46 -38
  59. package/src/wizard/integration-wizard.ts +679 -619
  60. package/src/wizard/package-writer.ts +98 -0
package/README.md CHANGED
@@ -1,1469 +1,1457 @@
1
- # @typecad/ui — HTML/CSS-Driven Graphics for Microcontrollers
2
-
3
- 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.
4
-
5
- ## Install
6
-
7
- `@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:
8
-
9
- ```bash
10
- npm install @typecad/ui @typecad/board-esp32-devkit
11
- npm install --save-dev @typecad/cuttlefish
12
- ```
13
-
14
- `@typecad/ui` is compile-time onlynone 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`.
15
-
16
- ### Integration wizard
17
-
18
- Integrating a display is the one step with real hardware decisions — which panel, which bus, which pins, whether there's touch and how it connects. Run the wizard from your project directory:
19
-
20
- ```bash
21
- npx @typecad/ui --config
22
- ```
23
-
24
- It asks:
25
-
26
- 1. **Display** — a built-in profile (ILI9341 320×240 SPI TFT, ST7796S 320×480 SPI TFT, SSD1309 128×64 I2C OLED), the desktop SDL simulator, or a fully custom driver.
27
- 2. **Bus wiring** — SPI pins (CS/DC/RST/backlight, frequency in MHz, optional SCK/MOSI/MISO override) or the I2C address. Defaults match the framework profiles and the repo's demo wiring (ESP32 VSPI), and any existing `display` section in your config pre-fills the answers.
28
- 3. **Orientation & rendering** — rotation and antialiasing, with advanced color-order/inversion options behind a confirm.
29
- 4. **Touch** — none, resistive (XPT2046, STMPE610, 4-wire analog), capacitive (FT6336U, GT911, CST816S), or a custom adapter file each with its pins, address, and a sensible default calibration.
30
- 5. **Theme** — optional `themeCss` / `themeClass`.
31
-
32
- After a summary preview and confirmation, the wizard splices only the `display` section into `cuttlefish.config.ts` every other section (and its comments) is preserved byte-for-byte, unmanaged display keys like `scroll` are carried over, and the result is syntax-checked before anything is written. If your config's `entry` points at a `.ui` file that doesn't exist yet, it offers to generate a starter screen, and it prints the exact `arduino-cli lib install ...`, preview, compile, and flash commands as next steps.
33
-
34
- If there is no `cuttlefish.config.ts` yet, create the project first with `npx @typecad/cuttlefish init`, then re-run the wizard.
35
-
36
- ## Project layout
37
-
38
- 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.
39
-
40
- The transpiler accepts three entry extensions: `.ts`, `.tsx`, and `.ui`.
41
-
42
- ### Pattern A `.ui` single-file component (display + logic together)
43
-
44
- 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.
45
-
46
- 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.
47
-
48
- **`src/app.ui`** markup, styling, and behavior for one screen in one file:
49
-
50
- ```html
51
- <script>
52
- import { ui } from '@typecad/ui';
53
- import { A0 } from '@typecad/board-esp32-devkit';
54
-
55
- ui.mount(screen);
56
-
57
- // Plain TypeScript — same lowering as a standalone .ts. HAL pin reads,
58
- // timers, and UI writes coexist as regular statements.
59
- const sensor = A0.asInput();
60
-
61
- setInterval(() => {
62
- screen.reading.value = sensor.readAnalog();
63
- }, 500);
64
-
65
- ui.bind(screen.lamp, 'background', () =>
66
- screen.reading.value > 512 ? 'limegreen' : '#333'
67
- );
68
- </script>
69
-
70
- <style>
71
- screen { background: #1a1a2e; display: flex; flex-direction: column; padding: 10px; gap: 8px; }
72
- #reading { color: dodgerblue; font-size: 24px; }
73
- #lamp { width: 16px; height: 16px; border-radius: 4px; }
74
- </style>
75
-
76
- <screen>
77
- <text id="reading">0</text>
78
- <view id="lamp"></view>
79
- </screen>
80
- ```
81
-
82
- **`cuttlefish.config.ts`** — point the entry at the `.ui` file:
83
-
84
- ```typescript
85
- import type { CuttlefishConfig } from '@typecad/cuttlefish/api';
86
-
87
- const config: CuttlefishConfig = {
88
- entry: './src/app.ui',
89
- target: 'esp32',
90
- mcu: '@typecad/mcu-esp32',
91
- board: '@typecad/board-esp32-devkit',
92
- framework: '@typecad/framework-arduino',
93
- frameworkData: { buildTarget: 'esp32:esp32:esp32' },
94
- output: { framework: 'arduino', optimize: 'size', outDir: './out' },
95
- toolchain: { type: 'arduino-cli' },
96
- console: { baudRate: 115200 },
97
- display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
98
- };
99
- export default config;
100
- ```
101
-
102
- Use this layout when a screen's markup and behavior are tightly coupled and you want them in one place.
103
-
104
- ### Pattern B `main.ts` entry + `.ui.html` for display (logic factored out)
105
-
106
- 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.
107
-
108
- 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++.
109
-
110
- **`src/main.ts`** — the entry, wires display and sensor logic:
111
-
112
- ```typescript
113
- import { ui } from '@typecad/ui';
114
- import { screen } from './app.ui.html';
115
- import { readVolts } from './sensors.js'; // .js extension required (Node16 resolution)
116
-
117
- ui.mount(screen, { display: 'ili9341', bus: 'SPI', cs: 10, dc: 9, rst: 8 });
118
-
119
- setInterval(() => {
120
- screen.meter.value = Math.round(readVolts() * 100);
121
- }, 500);
122
-
123
- export function main(): void { while (true) {} }
124
- ```
125
-
126
- **`src/sensors.ts`** — plain cuttlefish TS, owns device I/O, no UI imports:
127
-
128
- ```typescript
129
- import { A0 } from '@typecad/board-arduino-uno';
130
- const adc = A0.asInput();
131
- export function readVolts(): number {
132
- return adc.readVoltage();
133
- }
134
- ```
135
-
136
- **`src/app.ui.html`** — display only:
137
-
138
- ```html
139
- <screen>
140
- <range id="meter" min="0" max="330"></range>
141
- </screen>
142
- ```
143
-
144
- **`cuttlefish.config.ts`** — point the entry at `main.ts`:
145
-
146
- ```typescript
147
- const config: CuttlefishConfig = {
148
- entry: './src/main.ts', // ← .ts entry instead of .ui
149
- // ...rest identical
150
- };
151
- ```
152
-
153
- 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:
154
-
155
- ```c
156
- static void __tc_timer_cb_0() {
157
- __ui_nodes[1].value = round(readVolts() * 100); // sensor call preserved
158
- ui_mark_dirty(1); // screen.meter.value = ... lowered
159
- }
160
- ```
161
-
162
- 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.
163
-
164
- ### Choosing between the two
165
-
166
- | Layout | Entry | Use when |
167
- |---|---|---|
168
- | `.ui` single-file | `entry: './src/app.ui'` | Display and behavior are tightly coupled; one screen's markup + logic belong together |
169
- | `main.ts` + `.ui.html` split | `entry: './src/main.ts'` | Sensor/IO logic is substantial; UI file should stay declarative |
170
-
171
- 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.
172
-
173
- ### A note on dynamic text bindings
174
-
175
- 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.
176
-
177
- For dynamic text that doesn't fit those shapes, write to the element from `setInterval` instead:
178
-
179
- ```typescript
180
- // Avoid (body won't lower in v1):
181
- ui.bind(screen.reading, 'text', () => readVolts().toFixed(2) + 'V');
182
-
183
- // Prefer drive from a timer:
184
- setInterval(() => {
185
- // write to .value on numeric elements, or use String(<expr>) in the bind
186
- screen.meter.value = Math.round(readVolts() * 100);
187
- }, 500);
188
- ```
189
-
190
- ## Quick start
191
-
192
- The fastest path is a single `.ui` file with markup, styling, and behavior together (see [Project layout](#project-layout) for the alternative `main.ts` + `.ui.html` split).
193
-
194
- ### 1. Create `src/app.ui`
195
-
196
- ```html
197
- <script>
198
- import { ui } from '@typecad/ui';
199
-
200
- ui.mount(screen);
201
-
202
- // A signal carries the count; the binding recomputes the label each frame.
203
- // Text updates via ui.bind(..., 'text', ...) — <text> elements don't expose
204
- // a writable .text. String(<number>) is one of the supported bind shapes.
205
- const taps = ui.signal(0);
206
- ui.bind(screen.count, 'text', () => String(taps()));
207
-
208
- screen.action.onClick(() => {
209
- taps.set(taps() + 1);
210
- });
211
- </script>
212
-
213
- <style>
214
- screen {
215
- background: #1a1a2e;
216
- display: flex;
217
- flex-direction: column;
218
- gap: 8px;
219
- padding: 10px;
220
- }
221
- #count { color: dodgerblue; font-size: 16px; }
222
- #action {
223
- background: darkgreen;
224
- color: white;
225
- border: 2px solid limegreen;
226
- border-radius: 4px;
227
- padding: 8px 20px;
228
- text-align: center;
229
- transition: background 300ms;
230
- }
231
- #action:pressed { background: limegreen; }
232
- </style>
233
-
234
- <screen>
235
- <text id="count">0</text>
236
- <button id="action">Start</button>
237
- </screen>
238
- ```
239
-
240
- Display hardware and wiring live in `cuttlefish.config.ts` under `display`, so the UI source stays focused on UI behavior. Run `npx @typecad/ui --config` to generate that section interactively (see [Integration wizard](#integration-wizard)) — the result looks like the `display` line below.
241
-
242
- ### 2. Point the entry at the `.ui` file
243
-
244
- ```typescript
245
- // cuttlefish.config.ts
246
- import type { CuttlefishConfig } from '@typecad/cuttlefish/api';
247
-
248
- const config: CuttlefishConfig = {
249
- entry: './src/app.ui',
250
- target: 'esp32',
251
- mcu: '@typecad/mcu-esp32',
252
- board: '@typecad/board-esp32-devkit',
253
- framework: '@typecad/framework-arduino',
254
- frameworkData: { buildTarget: 'esp32:esp32:esp32' },
255
- output: { framework: 'arduino', optimize: 'size', outDir: './out' },
256
- toolchain: { type: 'arduino-cli' },
257
- console: { baudRate: 115200 },
258
- display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
259
- };
260
- export default config;
261
- ```
262
-
263
- ### 3. Build
264
-
265
- ```bash
266
- npx @typecad/cuttlefish build --compile
267
- ```
268
-
269
- ## Elements
270
-
271
- ### `<screen>`
272
-
273
- The root container. Required (exactly one). Its box fills the display viewport (320×240 landscape for ILI9341).
274
-
275
- ### `<view>`
276
-
277
- A generic container. Supports `display: flex` for layout. Has a `.value` property and `onToggle`/`onChange` for interaction.
278
-
279
- ```html
280
- <view id="row">
281
- <text id="label">Status</text>
282
- <text id="value">OK</text>
283
- </view>
284
- ```
285
-
286
- ### `<text>`
287
-
288
- Static or dynamic text. Has a `.value` property for numeric state.
289
-
290
- ```html
291
- <text id="counter">0</text>
292
- ```
293
-
294
- ### `<button>`
295
-
296
- A clickable button with `:pressed` pseudo-state support and transition animations.
297
-
298
- ```html
299
- <button id="start">Start</button>
300
- ```
301
-
302
- ### Interactive elements
303
-
304
- | Element | Purpose | `.value` |
305
- |---|---|---|
306
- | `<check>` | Checkbox (tap to toggle) | 0 / 1 |
307
- | `<radio name="g">` | Radio (mutually exclusive within a `name` group) | 0 / 1 |
308
- | `<select>` | Tap to cycle options; text auto-shows the current option | 0..N-1 |
309
- | `<progress>` | Progress bar (0-100) | fill percentage |
310
- | `<range>` | Draggable slider | between `min` and `max` |
311
- | `<input>` | Text input (tap opens the on-screen keyboard) | — (use `.text`) |
312
- | `<list>` | Virtualized, data-bound list (renders only visible items) | — |
313
- | `<canvas>` | User-drawn graphics (sparklines, gauges, custom shapes) via `ui.drawCanvas` | — |
314
-
315
- ```html
316
- <range id="brightness" min="0" max="100"></range>
317
- <progress id="load" value="40"></progress>
318
- <check id="enable">Enable feature</check>
319
- <input id="ssid" type="text" placeholder="Network name" maxlength="32"></input>
320
- <select id="mode"><option>Auto</option><option>Manual</option></select>
321
- ```
322
-
323
- Slider and progress values can be driven live from `loop()` via bindings (see
324
- [Reactive bindings](#reactive-bindings) below).
325
-
326
- ### Images
327
-
328
- `<img>` embeds a raw RGB565 `.img` file as a `static const uint16_t[]` array:
329
-
330
- ```html
331
- <img id="logo" src="assets/logo.img" width="64" height="64"></img>
332
- ```
333
-
334
- The `.img` file is a flat row-major RGB565 dump (width × height × 2 bytes). Use
335
- `object-fit` (`contain`, `cover`, `fill`) to control scaling.
336
-
337
- ### HTML tag aliases
338
-
339
- Common HTML tags are accepted and remapped to the internal primitives, so you
340
- can write familiar HTML:
341
-
342
- | HTML tag | Maps to | Notes |
343
- |---|---|---|
344
- | `body`, `div`, `header`, `footer`, `nav`, `main`, `section`, `article`, `aside` | `<view>` | Block container |
345
- | `span`, `p`, `h1`–`h6` | `<text>` | Inline/heading text |
346
-
347
- ```html
348
- <header><h1 id="title">Settings</h1></header>
349
- <main><p id="desc">Adjust preferences.</p></main>
350
- ```
351
-
352
- ### Global attributes
353
-
354
- All UI elements support the `hidden` attribute. Hidden elements and their
355
- descendants stay in the generated node table, but they do not take space in
356
- layout and are skipped for drawing and hit testing.
357
-
358
- ```html
359
- <view id="advancedPanel" hidden>
360
- <text>Advanced settings</text>
361
- </view>
362
- ```
363
-
364
- ## CSS reference
365
-
366
- ### Supported properties
367
-
368
- #### Box model
369
- | Property | Values | Notes |
370
- |---|---|---|
371
- | `padding` | `8px`, `8px 16px` | Shorthand supported |
372
- | `margin` | `8px`, `8px 16px` | Shorthand supported |
373
- | `width` | `100px` | Explicit size |
374
- | `height` | `50px` | Explicit size |
375
- | `min-width` / `max-width` | `100px` | Yoga constraints |
376
- | `min-height` / `max-height` | `50px` | Yoga constraints |
377
- | `aspect-ratio` | `16 / 9`, `1 / 1`, `1.5` | Infers the missing width or height |
378
- | `box-sizing` | `border-box` | Yoga border-box |
379
- | `overflow` | `hidden`, `scroll` | Clips children; `scroll` enables touch-drag scrolling |
380
-
381
- #### Length units
382
-
383
- All length values accept `px`, bare numbers, and `rem`/`em` (× 16 root font
384
- size). `0.625rem` resolves to `10px`. Percentages are used as-is in the contexts
385
- that honor them (flex/position).
386
-
387
- `calc()` evaluates simple arithmetic (`+ - * /`) on lengths after `var()`
388
- substitution, with proper operator precedence:
389
-
390
- ```css
391
- :root { --radius: 10px; }
392
- .card { border-radius: calc(var(--radius) - 4px); } /* 6px */
393
- .a { padding: calc(0.625rem * 2); } /* 20px */
394
- ```
395
-
396
- #### Flexbox (via Yoga)
397
- | Property | Values |
398
- |---|---|
399
- | `display` | `flex`, `none` |
400
- | `flex-direction` | `row`, `row-reverse`, `column`, `column-reverse` |
401
- | `gap` | `8px` (sets both row and column gap) |
402
- | `row-gap` / `column-gap` | `8px` (per-axis; overrides uniform `gap`) |
403
- | `flex-grow` | `1` |
404
- | `flex-shrink` | `0` |
405
- | `flex` (shorthand) | `1`, `1 0 auto`, `none` |
406
- | `align-items` | `flex-start`, `center`, `flex-end`, `stretch` |
407
- | `align-self` | `flex-start`, `center`, `flex-end`, `stretch`, `baseline` |
408
- | `align-content` | `flex-start`, `center`, `flex-end`, `stretch`, `space-between`, `space-around`, `space-evenly` |
409
- | `justify-content` | `flex-start`, `center`, `flex-end`, `space-between`, `space-around`, `space-evenly` |
410
- | `flex-wrap` | `wrap`, `nowrap`, `wrap-reverse` |
411
- | `order` | `1`, `2`, ... |
412
- | `position` | `relative`, `absolute`, `static` |
413
- | `top` / `right` / `bottom` / `left` | `10px` |
414
- | `z-index` | numeric layers; inherited by descendants |
415
-
416
- `display: none` removes the element subtree from layout, drawing, and hit
417
- testing while preserving generated node indices.
418
-
419
- #### Colors
420
- All standard CSS color formats are supported:
421
- - `#rrggbb``#ff0000`
422
- - `#rgb` — `#f00`
423
- - `#rrggbbaa` `#ff0000ff` (alpha ignored)
424
- - `rgb(r,g,b)` — `rgb(255, 0, 0)`
425
- - `rgba(r,g,b,a)` `rgba(255, 0, 0, 0.5)` (alpha ignored)
426
- - `hsl(h, s%, l%)` / `hsla(...)` — `hsl(240, 100%, 50%)`, `hsla(0 0% 0% / 0.05)`
427
- - Named colors — `red`, `dodgerblue`, `limegreen`, `transparent`, ... (147 CSS named colors)
428
-
429
- Both comma (`hsl(0, 0%, 0%)`) and CSS4 space (`hsl(0 0% 0%)`) syntaxes work.
430
- The slash-alpha form (`hsl(0 0% 0% / 0.05)`) is honored in `box-shadow` alpha;
431
- elsewhere alpha is ignored (no runtime blending on bare metal).
432
-
433
- #### Typography
434
- | Property | Values | Notes |
435
- |---|---|---|
436
- | `color` | any color | Text foreground color |
437
- | `font-family` | `"MyFont"` | Uses a generated font when matched by `@font-face`; otherwise the built-in bitmap font |
438
- | `font-size` | `16px` | Generated fonts are rasterized at this pixel size; bitmap text maps to GFX text size |
439
- | `font` | `italic bold 18px DeviceSans` | Shorthand support for style, weight, size, and family |
440
- | `text-align` | `left`, `center`, `right` | Horizontal alignment within the box |
441
- | `text-decoration` | `underline`, `line-through`, `none` | Both may combine: `underline line-through` |
442
- | `text-overflow` | `ellipsis`, `clip` | Truncates overflowing single-line text with `...` |
443
- | `text-transform` | `uppercase`, `lowercase`, `capitalize`, `none` | Applied at transpile time |
444
- | `line-height` | `1.5`, `150%`, `24px` | Line advance for wrapped text; `normal` = font default |
445
- | `letter-spacing` | `2px`, `-1px` | Per-character advance adjustment |
446
- | `white-space` | `normal`, `nowrap`, `pre`, `pre-line` | Controls word-wrap behavior |
447
- | `font-weight` | `normal`, `bold`, `400`, `700` | Selects the matching `@font-face` variant when available |
448
- | `font-style` | `normal`, `italic`, `oblique` | Selects the matching `@font-face` variant when available |
449
- | `font-smoothing` | `antialiased`, `none` | Overrides display-level text antialiasing |
450
- | `font-subset` | `exact`, `fallback` | Controls generated-font glyph selection |
451
-
452
- ### Fonts
453
-
454
- Local TTF/OTF fonts can be referenced with `@font-face`. The transpiler does
455
- not copy the whole font to the board. It reads the font at build time, rasterizes
456
- only the glyphs needed by the UI, packs them as 4-bit alpha bitmap data, and
457
- emits those tables into the firmware. On ESP32-class targets those generated
458
- tables are `static const` data in flash/rodata; the original TTF/OTF file is not
459
- held in RAM on the hardware.
460
-
461
- #### Install a font in a project
462
-
463
- Put font files somewhere inside the project, usually next to the `.ui.css` file
464
- or under a local `fonts/` folder:
465
-
466
- ```text
467
- src/
468
- app.ui.html
469
- app.ui.css
470
- fonts/
471
- DeviceSans-Regular.ttf
472
- DeviceSans-Bold.ttf
473
- ```
474
-
475
- Reference them from CSS with paths relative to the `.ui.css` file:
476
-
477
- ```css
478
- @font-face {
479
- font-family: "DeviceSans";
480
- src: url("fonts/DeviceSans.ttf");
481
- }
482
-
483
- #title {
484
- font-family: "DeviceSans";
485
- font-size: 24px;
486
- font-smoothing: antialiased;
487
- }
488
- ```
489
-
490
- Remote font URLs are not supported for embedded builds. Use local files so the
491
- build is reproducible and does not depend on network access.
492
-
493
- #### Declare variants
494
-
495
- Declare each weight/style variant as its own `@font-face`. The UI compiler
496
- chooses the closest matching variant for each node based on `font-family`,
497
- `font-weight`, `font-style`, and `font-size`.
498
-
499
- ```css
500
- @font-face {
501
- font-family: "DeviceSans";
502
- src: url("fonts/DeviceSans-Regular.ttf");
503
- font-weight: 400;
504
- font-style: normal;
505
- }
506
-
507
- @font-face {
508
- font-family: "DeviceSans";
509
- src: url("fonts/DeviceSans-Bold.ttf");
510
- font-weight: 700;
511
- font-style: normal;
512
- }
513
-
514
- @font-face {
515
- font-family: "DeviceSans";
516
- src: url("fonts/DeviceSans-Italic.ttf");
517
- font-weight: 400;
518
- font-style: italic;
519
- }
520
-
521
- #title {
522
- font-family: "DeviceSans";
523
- font-size: 24px;
524
- font-weight: bold;
525
- }
526
- ```
527
-
528
- Every distinct `font-family` + resolved font file + `font-size` + variant becomes
529
- one generated font asset. Reusing the same face and size across many nodes
530
- shares one asset. Using the same face at `16px` and `24px` creates two assets
531
- because each size is rasterized separately.
532
-
533
- #### Exact subsetting and icon fonts
534
-
535
- By default, generated fonts use `font-subset: exact`. Only the literal
536
- characters found in static UI text, placeholders, and option labels are encoded.
537
- This is useful for icon fonts and symbol fonts:
538
-
539
- ```html
540
- <text id="wifiIcon">✓</text>
541
- ```
542
-
543
- ```css
544
- @font-face {
545
- font-family: "DeviceIcons";
546
- src: url("fonts/device-icons.ttf");
547
- }
548
-
549
- #wifiIcon {
550
- font-family: "DeviceIcons";
551
- font-size: 20px;
552
- font-subset: exact;
553
- }
554
- ```
555
-
556
- In this case, only the checkmark glyph is emitted for that font/size, not the
557
- whole icon font and not the common ASCII set.
558
-
559
- Generated font glyph lookup supports UTF-8 text for codepoints in the Basic
560
- Multilingual Plane (`U+0000` to `U+FFFF`). Many icon fonts use Private Use Area
561
- codepoints such as `U+E000`; those are supported. Emoji and other characters
562
- above `U+FFFF` are not currently supported by the generated-font runtime.
563
-
564
- Wingdings-style fonts can work, but be careful: some older symbol fonts use
565
- legacy character mappings rather than standard Unicode symbols. Copy the exact
566
- character/codepoint that the font maps to the glyph you want, or prefer a
567
- Unicode icon font when possible.
568
-
569
- #### Dynamic text and fallback glyphs
570
-
571
- Exact subsetting can only see text known at build time. If a generated font is
572
- used on a node whose text changes at runtime, include a fallback character set:
573
-
574
- ```css
575
- #counter {
576
- font-family: "DeviceSans";
577
- font-size: 18px;
578
- font-subset: fallback;
579
- }
580
- ```
581
-
582
- `font-subset: fallback` includes the static text plus a small common ASCII set
583
- containing digits, letters, spaces, and punctuation. Use it for counters,
584
- formatted numeric values, input fields, or any generated-font text binding that
585
- can produce characters not present in the initial HTML.
586
-
587
- If the node uses the built-in bitmap font, `font-subset` has no effect.
588
-
589
- #### Smoothing
590
-
591
- Generated TTF/OTF glyphs are rasterized as alpha masks. Use
592
- `font-smoothing: antialiased` to blend edge pixels for smoother text on RGB
593
- displays. Use `font-smoothing: none` to threshold the same glyph masks for a
594
- sharper, more pixel-like look. On monochrome displays smoothing is disabled.
595
-
596
- ```css
597
- .smooth {
598
- font-family: "DeviceSans";
599
- font-size: 18px;
600
- font-smoothing: antialiased;
601
- }
602
-
603
- .sharp {
604
- font-family: "DeviceSans";
605
- font-size: 18px;
606
- font-smoothing: none;
607
- }
608
- ```
609
-
610
- #### Converting fonts
611
-
612
- Use TTF or OTF files when possible. WOFF/WOFF2 web fonts should be converted to
613
- TTF/OTF before use.
614
-
615
- Common conversion options:
616
-
617
- - FontForge GUI: open the source font, then use `File -> Generate Fonts...` and
618
- choose TrueType (`.ttf`) or OpenType (`.otf`).
619
- - FontForge CLI:
620
-
621
- ```bash
622
- fontforge -lang=ff -c 'Open($1); Generate($2)' input.otf output.ttf
623
- ```
624
-
625
- - WOFF2 tools: use `woff2_decompress input.woff2` to produce a TTF-flavored
626
- font when the source is a WOFF2 web font.
627
- - fonttools can inspect and subset fonts:
628
-
629
- ```bash
630
- python -m pip install fonttools brotli
631
- pyftsubset DeviceSans.ttf --text="ABC123" --unicodes=U+2713 --output-file=DeviceSans-subset.ttf
632
- ```
633
-
634
- Manual external subsetting is optional. TypeCAD already subsets the emitted
635
- hardware glyphs. External subsetting is mainly useful when you need to distribute
636
- a smaller source font file, remove unused font tables for licensing reasons, or
637
- speed up build-time parsing of a very large font.
638
-
639
- #### Licensing
640
-
641
- Do not assume system fonts are redistributable. Fonts such as commercial OS
642
- fonts may be licensed for local use but not for checking into a repository or
643
- shipping in a firmware project. Prefer open-licensed fonts, or keep proprietary
644
- fonts outside shared source control if your license requires it.
645
-
646
- #### Visual
647
- | Property | Values | Notes |
648
- |---|---|---|
649
- | `background` / `background-color` | any color | Fill color |
650
- | `border` (shorthand) | `2px solid #808080` | Splits into width/style/color |
651
- | `border-width` | `2px` | |
652
- | `border-color` | any color | |
653
- | `border-style` | `solid`, `dashed`, `none` | Dashed approximated with segments |
654
- | `border-radius` | `4px` | Rounded fill/border on hardware; preview approximates |
655
- | `outline` | `1px solid #fff`, `2px dashed red` | Drawn outside the element box |
656
- | `visibility` | `visible`, `hidden` | Hidden elements are not drawn |
657
- | `box-shadow` | `inset 0 1px 0 #fff`, `0 10px 0 #333` | Up to 4 rect shadows; approximated for TFT drawing |
658
- | `transform` | `translateY(10px)`, `translate(0, 10px)` | Draw-time translate offset; no flex relayout |
659
- | `opacity` | parsed | (Blending not supported without PSRAM framebuffer) |
660
-
661
- #### Transitions
662
- | Property | Values | Notes |
663
- |---|---|---|
664
- | `transition` | `background 300ms`, `color 120ms` | Lerps the property over the duration |
665
- | `:pressed` | pseudo-class | Applied when `.value` is 1 (button press) |
666
-
667
- Pressed rules may also include `top` / `left` / `right` / `bottom` or
668
- `transform: translate(...)`. These are applied as draw-time offsets so the
669
- element face/content can move visually without recomputing the flex layout;
670
- outset shadows stay anchored, which is useful for raised button effects.
671
-
672
- Small dirty paint regions for text, backgrounds, borders, outlines, shadows,
673
- and draw-time translate offsets are composed in an offscreen RGB565 canvas and
674
- pushed as one rectangle when memory allows. Larger regions fall back to direct
675
- drawing.
676
-
677
- ### Selectors
678
- - Element: `screen { ... }`
679
- - ID: `#title { ... }`
680
- - Class: `.card { ... }`
681
- - Compound: `.card.active { ... }`, `button.primary { ... }`
682
- - Descendant: `view text { ... }`
683
- - Child: `view > text { ... }` (direct children only)
684
- - Adjacent sibling: `.first + .second { ... }` (immediate next sibling)
685
- - General sibling: `.first ~ .later { ... }` (any following sibling)
686
- - Attribute: `[disabled]`, `[type="number"]` (presence and exact-value match)
687
- - Negation: `button:not(.disabled)`, `.a:not(.b.c)` (compound `:not()` supported)
688
- - Pseudo-state: `#btn:pressed`, `input:disabled`, `check:checked`, `*:focus`
689
- - Inline style: `<text style="color: red">hi</text>`
690
- - `<style>` blocks embedded in the `.ui.html`
691
-
692
- Pseudo-states match runtime element state: `:pressed` (button held), `:disabled`
693
- (`disabled` attribute), `:checked` (`<check>`/`<radio>` with `.value` 1), and
694
- `:focus` (the node currently receiving input).
695
-
696
- ### CSS variables
697
-
698
- Define variables in `:root` and reference them with `var()`:
699
-
700
- ```css
701
- :root {
702
- --bg: #0a0a0a;
703
- --fg: #fafafa;
704
- --primary: #7c3aed;
705
- }
706
- screen { background: var(--bg); }
707
- #title { color: var(--fg); }
708
- ```
709
-
710
- Variables resolve at transpile time — no runtime cost.
711
-
712
- #### Class-scoped variables (themes)
713
-
714
- Variables can also be defined under a class selector (e.g. `.dark`) and selected
715
- at build time via the `themeClass` config option. This is how shadcn-style
716
- light/dark themes work:
717
-
718
- ```css
719
- :root { --bg: #ffffff; --fg: #0a0a0a; }
720
- .dark { --bg: #0a0a0a; --fg: #fafafa; }
721
- screen { background: var(--bg); color: var(--fg); }
722
- ```
723
-
724
- ```typescript
725
- // cuttlefish.config.ts or the display config in ui.mount
726
- display: {
727
- themeClass: 'dark', // resolves var(--x) using the .dark overrides
728
- }
729
- ```
730
-
731
- When `themeClass` is set, `var()` substitution prefers that class's variables
732
- over `:root`. This is **transpile-time selection** one theme per firmware
733
- build (there is no runtime theme switch on a fixed-screen device).
734
-
735
- #### `@media` (compile-time variant selection)
736
-
737
- `@media` rules are evaluated against the resolved display profile at transpile
738
- time. Since each build targets one fixed screen size, this acts as a
739
- compile-time variant selector, not responsive design:
740
-
741
- ```css
742
- /* Applied only when the display is ≤ 240px wide */
743
- @media (max-width: 240px) {
744
- #title { font-size: 12px; }
745
- }
746
- ```
747
-
748
- Supported conditions: `min-width`, `max-width`, `min-height`, `max-height`
749
- (in `px`). Unsupported conditions (e.g. `orientation`) emit a warning and the
750
- rule is skipped. `@import` and `@supports` are not supported (warned + skipped).
751
-
752
- ### Theming
753
-
754
- Themes are **compile-time**. There are two complementary mechanisms:
755
-
756
- **1. Theme file (`themeCss`)** — swap the entire CSS file:
757
-
758
- ```typescript
759
- // cuttlefish.config.ts
760
- display: {
761
- themeCss: './src/hello.dark.css', // relative to .ui.html dir
762
- // or: themeCss: '/absolute/path/to/theme.css',
763
- }
764
- ```
765
-
766
- 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:
767
-
768
- ```
769
- src/
770
- hello.ui.html ← layout (shared)
771
- hello.ui.css ← default theme (no themeCss set)
772
- hello.dark.css ← dark theme
773
- hello.shadcn.css ← shadcn palette
774
- ```
775
-
776
- **2. Theme class (`themeClass`)** — select a class-scoped variable block
777
- within a single CSS file (see [Class-scoped variables](#class-scoped-variables-themes) above):
778
-
779
- ```typescript
780
- display: {
781
- themeClass: 'dark', // resolves var(--x) from .dark { ... } overrides
782
- }
783
- ```
784
-
785
- The two can be combined: `themeCss` picks the file, `themeClass` picks the
786
- variable scope within it.
787
-
788
- 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.
789
-
790
- ### Unsupported (and why)
791
- - `display: grid` — needs a GridLayoutEngine
792
- - Full inline rich text basic wrapping, `line-height`, `white-space`, and `<br>` are supported; mixed inline spans are not
793
- - `background-image` / sprites — use `<img>` for embedded images; CSS `background: url(...)` is unsupported (only solid colors and `linear-gradient`)
794
- - `position: fixed` — viewport-fixed positioning is not implemented
795
- - `:after` / `:before` pseudo-elements — no generated content
796
- - `text-shadow` on built-in font — needs sub-pixel font data (works with custom fonts)
797
- - Per-corner `border-radius` — only a uniform radius is supported (Adafruit_GFX draws one corner value)
798
- - Runtime theme switching themes are compile-time only (one `themeClass` per build; swap in config and rebuild)
799
-
800
- ## State and interaction
801
-
802
- ### The `.value` property
803
-
804
- Every interactive element has a `.value` property — a number that is both readable and writable:
805
-
806
- ```typescript
807
- // Read
808
- const count = screen.counter.value;
809
-
810
- // Write (updates the display immediately)
811
- screen.counter.value = 42;
812
- ```
813
-
814
- ### Pin input
815
-
816
- ```typescript
817
- // Watch a pin for falling edges — runs in the frame loop (no ISR)
818
- ui.watchPin(4, () => {
819
- screen.counter.value = screen.counter.value + 1;
820
- });
821
-
822
- // Toggle an element's .value on pin press (0 ↔ 1)
823
- screen.ledBox.onToggle(5);
824
-
825
- // Cycle through options (0 1 2 0 → ...)
826
- screen.modeSelect.onChange(15, 3);
827
- ```
828
-
829
- ### Reactive bindings
830
-
831
- Bindings compute a display property from `.value` or signals each frame:
832
-
833
- ```typescript
834
- // Color binding
835
- ui.bind(screen.counter, 'color', () =>
836
- (screen.counter.value % 2 === 0 ? 'limegreen' : 'orange')
837
- );
838
-
839
- // Background binding
840
- ui.bind(screen.btn, 'background', () =>
841
- (screen.btn.value > 0 ? 'limegreen' : 'darkgreen')
842
- );
843
-
844
- // Border color binding
845
- ui.bind(screen.ledBox, 'borderColor', () =>
846
- (screen.ledBox.value ? 'limegreen' : '#808080')
847
- );
848
-
849
- // Value binding — drive a progress/range node live from loop()
850
- ui.bind(screen.progress, 'value', () => sensorPercent);
851
-
852
- // Visibility binding - preallocate both branches and toggle which one draws
853
- ui.bind(screen.enteredBranch, 'visible', () => screen.input.value > 0);
854
- ui.bind(screen.emptyBranch, 'visible', () => screen.input.value === 0);
855
-
856
- // Text binding number to string
857
- ui.bind(screen.counter, 'text', () => String(screen.counter.value));
858
-
859
- // Text binding — ternary chain (for selectors)
860
- ui.bind(screen.modeValue, 'text', () => (
861
- screen.modeValue.value === 0 ? 'Auto' :
862
- screen.modeValue.value === 1 ? 'Manual' : 'Off'
863
- ));
864
- ```
865
-
866
- `visible` bindings are for fixed-layout conditional rendering. The nodes stay in
867
- the retained UI tree; hidden branches are skipped for drawing and hit testing,
868
- and shown branches repaint their subtree.
869
-
870
- ### Two-way input binding
871
-
872
- When the user types into an `<input>` via the on-screen keyboard, push the text
873
- back into app state with `ui.bindInput`:
874
-
875
- ```typescript
876
- let ssid = '';
877
- ui.bindInput(screen.ssid, (text) => { ssid = text; });
878
- ```
879
-
880
- The callback fires whenever the input's text changes (after the keyboard commits).
881
-
882
- ### Slider change callbacks
883
-
884
- A `<range>` fires `onChange` on every value change while dragging — read
885
- `.value` inside the callback for the new value:
886
-
887
- ```typescript
888
- screen.brightness.onChange(() => {
889
- // Fires continuously during the drag.
890
- ledPwm = screen.brightness.value;
891
- });
892
- ```
893
-
894
- ### Data-bound lists
895
-
896
- `<list>` is a virtualized, callback-driven list — it renders only the visible
897
- items to a dedicated scroll canvas, so a thousand-item list has the same memory
898
- footprint as a ten-item one. Bind it with `ui.bindList`:
899
-
900
- ```html
901
- <list id="networks" item-height="28px"></list>
902
- ```
903
-
904
- ```typescript
905
- ui.bindList(
906
- screen.networks,
907
- () => scanResults.length, // count
908
- (i) => `${scanResults[i].ssid} (${scanResults[i].rssi} dBm)`, // item text
909
- (i) => { connectTo(scanResults[i].ssid); }, // optional tap handler
910
- );
911
- ```
912
-
913
- The count function re-evaluates each frame; if it changes, the list recomputes
914
- its content height and repaints. Drag to scroll; tap an item to fire the
915
- optional third callback.
916
-
917
- ### User-drawn canvas (`<canvas>`)
918
-
919
- `<canvas>` is an element whose contents you draw yourself, every frame, using
920
- the display graphics primitives. It follows all CSS rules (layout, borders,
921
- transforms, z-index) like any other element, but its pixels come from your
922
- callback. Use it for sparkline graphs, analog gauges, or custom-shaped controls.
923
-
924
- ```html
925
- <canvas id="spark" width="120" height="40"></canvas>
926
- ```
927
-
928
- `width`/`height` set the **drawing buffer** size (px). The CSS box is the
929
- **layout** size size them to match unless you want clipping.
930
-
931
- ```typescript
932
- ui.drawCanvas(screen.spark, (ctx) => {
933
- ctx.fillScreen('black');
934
- ctx.line(0, 30, ctx.width, 30, 'limegreen'); // baseline
935
- ctx.rect(2, 2, ctx.width - 4, ctx.height - 4, '#333');
936
- ctx.fillCircle(needleX, 30, 3, 'red');
937
- ctx.text(4, 12, `${temp}°`, 'white'); // optional color arg
938
- });
939
- ```
940
-
941
- Coordinates are **canvas-relative** (`(0,0)` = element top-left) and drawing is
942
- **auto-clipped** to the buffer — you cannot accidentally paint over neighbors.
943
- Color arguments are CSS color strings resolved to RGB565 at build time.
944
-
945
- The callback runs **every frame**; to animate, mutate state in a `setInterval`
946
- or signal and the canvas picks it up next frame. Taps hit-test as the full CSS
947
- box, so `screen.spark.onClick(...)` works for interactive canvases.
948
-
949
- #### `ctx` methods (the display graphics primitives)
950
-
951
- | Method | Notes |
952
- |---|---|
953
- | `ctx.fillRect(x,y,w,h,color)` / `ctx.rect(...)` | Filled / outline rectangle |
954
- | `ctx.fillRoundRect(x,y,w,h,r,color)` / `ctx.roundRect(...)` | Rounded variant |
955
- | `ctx.line(x0,y0,x1,y1,color)` | Arbitrary line |
956
- | `ctx.hline(x,y,w,color)` / `ctx.vline(x,y,h,color)` | Fast horizontal / vertical line |
957
- | `ctx.fillCircle(x,y,r,color)` / `ctx.circle(...)` | Filled / outline circle |
958
- | `ctx.drawPixel(x,y,color)` | Single pixel |
959
- | `ctx.text(x,y,str,color?)` | Bitmap text (built-in font) |
960
- | `ctx.fillScreen(color)` | Clear the whole buffer |
961
- | `ctx.width` / `ctx.height` | Read-only buffer dimensions |
962
-
963
- ### Signals
964
-
965
- For reactive state not tied to an element:
966
-
967
- ```typescript
968
- const temperature = ui.signal(22);
969
-
970
- // Read
971
- const t = temperature();
972
-
973
- // Write
974
- temperature.set(25);
975
- ```
976
-
977
- `ui.signal()` accepts `number`, `string`, or `boolean` literals — these lower to
978
- `int`/`double`, `const char*`, and `bool` on the device. Other initializers
979
- (objects, arrays, `null`, identifiers) are rejected at type-check time
980
- (`Signal<T extends SignalValue>`) and at build time with a `ui-signal-initializer`
981
- warning that defaults the signal to `0` (`int`).
982
-
983
- ## Element id errors
984
-
985
- If a binding or event handler references an element id that doesn't exist in the
986
- screen, the build fails with a `ui-unknown-element` error rather than silently
987
- re-targeting the wrong node. This applies to every call that takes a screen
988
- element:
989
-
990
- ```typescript
991
- ui.bind(screen.typo, 'color', ...) // ✗ error: element "typo" not found
992
- ui.bindInput(screen.typo, ...) // ✗ error
993
- ui.bindList(screen.typo, ...) // ✗ error
994
- ui.drawCanvas(screen.typo, ...) // error
995
- await ui.onTap(screen.typo) // ✗ error (no silent fallback to any-tap)
996
- screen.typo.onClick(...) // error
997
- screen.typo.onToggle(...) // ✗ error
998
- ```
999
-
1000
- Fix the typo in your `.ui.html` / `.ui` file's `id` attribute and rebuild.
1001
-
1002
- ## Composing custom elements
1003
-
1004
- Don't see the element you need? Build it from `<view>` + `<text>` + bindings:
1005
-
1006
- ### Custom checkbox
1007
-
1008
- ```html
1009
- <view id="ledRow">
1010
- <view id="ledBox"></view>
1011
- <text id="ledLabel">Enable LED</text>
1012
- </view>
1013
- ```
1014
- ```css
1015
- #ledBox {
1016
- width: 16px;
1017
- height: 16px;
1018
- border: 2px solid #808080;
1019
- }
1020
- ```
1021
- ```typescript
1022
- screen.ledBox.onToggle(5); // toggles .value 0↔1
1023
-
1024
- ui.bind(screen.ledBox, 'background', () =>
1025
- (screen.ledBox.value ? 'limegreen' : 'transparent')
1026
- );
1027
- ui.bind(screen.ledBox, 'borderColor', () =>
1028
- (screen.ledBox.value ? 'limegreen' : '#808080')
1029
- );
1030
- ```
1031
-
1032
- ### Custom selector
1033
-
1034
- ```html
1035
- <view id="modeRow">
1036
- <text id="modeLabel">Mode:</text>
1037
- <text id="modeValue">Auto</text>
1038
- </view>
1039
- ```
1040
- ```typescript
1041
- screen.modeValue.onChange(15, 3); // cycles 0→1→2→0
1042
-
1043
- ui.bind(screen.modeValue, 'text', () => (
1044
- screen.modeValue.value === 0 ? 'Auto' :
1045
- screen.modeValue.value === 1 ? 'Manual' : 'Off'
1046
- ));
1047
- ```
1048
-
1049
- ### Custom progress bar
1050
-
1051
- ```html
1052
- <view id="barContainer">
1053
- <view id="barFill"></view>
1054
- </view>
1055
- ```
1056
- ```css
1057
- #barContainer { width: 200px; height: 20px; border: 1px solid #808080; }
1058
- #barFill { background: limegreen; height: 100%; }
1059
- ```
1060
- ```typescript
1061
- ui.bind(screen.barFill, 'background', () =>
1062
- (screen.barFill.value > 50 ? 'limegreen' : 'orange')
1063
- );
1064
- ```
1065
-
1066
- ## Timers
1067
-
1068
- ```typescript
1069
- // Auto-update every 2 seconds
1070
- setInterval(() => {
1071
- screen.counter.value = screen.counter.value + 1;
1072
- }, 2000);
1073
- ```
1074
-
1075
- ## Architecture
1076
-
1077
- ```
1078
- .ui.html / .ui.css → parse (linkedom + css-tree) → resolve styles
1079
-
1080
- layout (Yoga flexbox)
1081
-
1082
- lower to C++ UINode[] table
1083
-
1084
- ui_mount → display.init (Adafruit_ILI9341)
1085
- ui_tick → poll inputs → eval bindings → transitions → draw
1086
- ui_init → mark all dirty for first frame
1087
- ```
1088
-
1089
- 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.
1090
-
1091
- ### Rendering & performance
1092
-
1093
- Each frame, `ui_tick` re-evaluates bindings, advances transitions/animations,
1094
- and redraws only the nodes marked dirty (most frames touch a handful of nodes,
1095
- not the whole screen). Dirty paint regions are composed in an offscreen RGB565
1096
- canvas and pushed as one rectangle when memory allows.
1097
-
1098
- **Framebuffer (PSRAM-gated).** When the board has PSRAM (`BOARD_HAS_PSRAM`
1099
- defined + `psramFound()`), the runtime allocates a full-screen `GFXcanvas16`
1100
- framebuffer and renders the entire dirty-node pass into it, then pushes once
1101
- via a single SPI transaction. This eliminates the per-primitive transaction
1102
- storm that otherwise limits redraw rate on ILI9341 over SPI. Without PSRAM the
1103
- runtime falls back to direct per-node drawing (no behavior change). The
1104
- framebuffer activates automatically — no config needed beyond enabling PSRAM in
1105
- the Arduino build flags.
1106
-
1107
- ### Diagnostics (warnings)
1108
-
1109
- Unknown HTML tags and unknown CSS properties are **reported as warnings**, not
1110
- silently dropped. They appear in the build output (yellow, to stderr) and do
1111
- not abort the build:
1112
-
1113
- ```
1114
- ⚠ Unknown CSS property "bogus-prop" — ignored.
1115
- ⚠ Unknown HTML tag <marquee> — ignored.
1116
- Unsupported @media (orientation: portrait) has an unsupported condition — rule ignored.
1117
- ```
1118
-
1119
- This surfaces typos and unsupported features early instead of leaving styles
1120
- mysteriously unapplied.
1121
-
1122
- ## Display profiles
1123
-
1124
- 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.
1125
-
1126
- ### Config reference
1127
-
1128
- ```typescript
1129
- // cuttlefish.config.ts
1130
- display: {
1131
- // Either reference a built-in profile by name:
1132
- profile: 'ili9341-spi',
1133
-
1134
- // Or inline everything:
1135
- // driver: 'ili9341',
1136
- // width: 320, height: 240,
1137
- // colorFormat: 'rgb565',
1138
- // rotation: 1,
1139
-
1140
- // Wiring (always project-specific)
1141
- cs: 5,
1142
- dc: 21,
1143
- rst: 22,
1144
- backlight: 17, // optional pin number for backlight
1145
-
1146
- // Experimental and opt-in: use only after verifying the controller's
1147
- // GET_SCANLINE (0x45) readback and wiring SDO/MISO in spiPins. Some ST7796S
1148
- // modules stop scanning when this command is read, so the default is off.
1149
- // scanlineSync: true,
1150
- // spiPins: { mosi: 11, sck: 12, miso: 13 },
1151
-
1152
- // Antialiasing (optional)
1153
- antialias: true, // smooths shapes and text; text can opt out with font-smoothing:none
1154
-
1155
- // Theming (optional — compile-time)
1156
- themeCss: './src/hello.dark.css', // override the sibling .ui.css file
1157
- themeClass: 'dark', // select a class-scoped variable block (.dark { ... })
1158
-
1159
- // Touch (optional)
1160
- touch: {
1161
- library: 'XPT2046_Touchscreen',
1162
- cs: 14, // touch controller CS pin
1163
- irq: 2, // optional interrupt pin
1164
- calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
1165
- minPressure: 10,
1166
- },
1167
- }
1168
- ```
1169
-
1170
- ### Profile fields
1171
-
1172
- | Field | Type | Description |
1173
- |---|---|---|
1174
- | `profile` | string | Built-in profile name (e.g. `"ili9341-spi"`) |
1175
- | `driver` | string | Display driver id (e.g. `"ili9341"`) |
1176
- | `width` | number | Display width in pixels (after rotation) |
1177
- | `height` | number | Display height in pixels (after rotation) |
1178
- | `colorFormat` | `"rgb565"` \| `"mono"` | Color depth |
1179
- | `rotation` | number | 0=portrait, 1=landscape, 2-3=inverted |
1180
- | `backlight` | number | Backlight pin (optional) |
1181
- | `cs` / `dc` / `rst` | number | Display wiring pins |
1182
-
1183
- ### Built-in profiles
1184
-
1185
- | Name | Display | Dimensions | Color | Touch |
1186
- |---|---|---|---|---|
1187
- | `ili9341-spi` | ILI9341 (SPI) | 320×240 | RGB565 | Add via `touch` config |
1188
- | `ssd1309-i2c` | SSD1309 OLED (I2C) | 128×64 | Mono | None |
1189
-
1190
- ### Adding a new display
1191
-
1192
- 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).
1193
-
1194
- #### 1. Register a display adapter
1195
-
1196
- 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:
1197
-
1198
- ```typescript
1199
- // my-project/display-adapters.ts
1200
- import { registerDisplayAdapter } from '@typecad/cuttlefish/api/shared/display-adapter';
1201
-
1202
- registerDisplayAdapter('ssd1306', (display) => {
1203
- return {
1204
- includes: `#include <Adafruit_GFX.h>\n#include <Adafruit_SSD1306.h>\n#include <Wire.h>`,
1205
- declaration: `Adafruit_SSD1306 __tc_display(128, 64, &Wire, -1);`,
1206
- functions: [
1207
- 'static int16_t __addrX = 0, __addrY = 0, __addrW = 0, __addrH = 0;',
1208
- 'static uint32_t __addrCursor = 0;',
1209
- 'static inline void display_init() {',
1210
- ' __tc_display.begin(SSD1306_SWITCHCAPVCC, 0x3C);',
1211
- ' __tc_display.clearDisplay();',
1212
- ' __tc_display.display();',
1213
- '}',
1214
- 'static inline void display_fillScreen(uint16_t color) {',
1215
- ' __tc_display.fillScreen(color ? 1 : 0);',
1216
- '}',
1217
- 'static inline void display_startWrite() { }',
1218
- 'static inline void display_endWrite() { }',
1219
- 'static inline void display_setAddrWindow(int16_t x, int16_t y, int16_t w, int16_t h) {',
1220
- ' __addrX = x; __addrY = y; __addrW = w; __addrH = h; __addrCursor = 0;',
1221
- '}',
1222
- 'static inline void display_writePixels(uint16_t* pixels, uint32_t count) {',
1223
- ' if (__addrW <= 0 || __addrH <= 0) return;',
1224
- ' uint32_t total = (uint32_t)__addrW * (uint32_t)__addrH;',
1225
- ' for (uint32_t i = 0; i < count; i++) {',
1226
- ' if (__addrCursor >= total) break;',
1227
- ' uint32_t pos = __addrCursor++;',
1228
- ' __tc_display.drawPixel(__addrX + (pos % __addrW), __addrY + (pos / __addrW), pixels[i] ? 1 : 0);',
1229
- ' }',
1230
- '}',
1231
- 'static inline void display_partial_refresh(int16_t x, int16_t y, int16_t w, int16_t h) {',
1232
- ' (void)x; (void)y; (void)w; (void)h;',
1233
- ' __tc_display.display();',
1234
- '}',
1235
- ].join('\\n'),
1236
- };
1237
- });
1238
- ```
1239
-
1240
- The runtime calls these core display functions:
1241
-
1242
- | Function | Purpose |
1243
- |----------|---------|
1244
- | `display_init()` | Initialize the display (begin, rotation, clear) |
1245
- | `display_fillScreen(color)` | Fill the entire screen with a color |
1246
- | `display_startWrite()` | Begin an SPI transaction (no-op for I2C) |
1247
- | `display_endWrite()` | End a transaction (often a no-op for page-buffered I2C) |
1248
- | `display_setAddrWindow(x, y, w, h)` | Set the active write region |
1249
- | `display_writePixels(pixels, count)` | Write pixels into the active region |
1250
- | `display_partial_refresh(x, y, w, h)` | Publish the dirty region on deferred displays |
1251
-
1252
- For monochrome displays, the adapter wraps each color argument with a conversion function. For deferred displays, the current runtime publishes from `display_partial_refresh()`.
1253
-
1254
- #### 2. Create a display profile
1255
-
1256
- ```typescript
1257
- // displays/my-display.ts
1258
- import type { DisplayProfile } from '@typecad/cuttlefish/api/shared';
1259
-
1260
- export const MY_DISPLAY: DisplayProfile = {
1261
- driver: 'ssd1306', // must match the adapter name
1262
- width: 128,
1263
- height: 64,
1264
- colorFormat: 'mono', // 'rgb565' or 'mono'
1265
- rotation: 0,
1266
- };
1267
- ```
1268
-
1269
- #### 3. Reference it in config
1270
-
1271
- ```typescript
1272
- display: {
1273
- driver: 'ssd1306',
1274
- width: 128, height: 64,
1275
- colorFormat: 'mono',
1276
- rotation: 0,
1277
- }
1278
- ```
1279
-
1280
- #### 4. Import the adapter module before building
1281
-
1282
- Make sure your adapter module is imported (side-effect import) so the registration runs:
1283
-
1284
- ```typescript
1285
- // cuttlefish.config.ts or main.ts
1286
- import './display-adapters'; // registers the 'ssd1306' adapter
1287
- ```
1288
-
1289
- #### Built-in adapters
1290
-
1291
- | Driver | Display | Color | Notes |
1292
- |--------|---------|-------|-------|
1293
- | `ili9341` | ILI9341 (320×240) | RGB565 | Default, hardware SPI |
1294
-
1295
- To add more built-in adapters, contribute a file to `packages/framework-arduino/src/graphics/` and register it.
1296
-
1297
- ## Touch input
1298
-
1299
- 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.
1300
-
1301
- ### Built-in touch libraries
1302
-
1303
- | Library | Controllers | Interface | Config |
1304
- |---|---|---|---|
1305
- | `XPT2046_Touchscreen` | XPT2046 (common ILI9341 shields) | SPI (shared with display) | `{ library, cs, irq? }` |
1306
- | `Adafruit_TouchScreen` | Resistive 4-wire | Analog (no SPI) | `{ library, analogPins: { xp, yp, xm, ym, rx } }` |
1307
- | `Adafruit_STMPE610` | STMPE610 (capacitive) | SPI or I2C | `{ library, cs }` |
1308
-
1309
- ### Config examples
1310
-
1311
- **XPT2046 (most common with ILI9341 TFT shields):**
1312
-
1313
- ```typescript
1314
- display: {
1315
- profile: 'ili9341-spi',
1316
- cs: 5, dc: 21, rst: 22,
1317
- touch: {
1318
- library: 'XPT2046_Touchscreen',
1319
- cs: 14, // touch CS pin (separate from display CS)
1320
- irq: 2, // optional
1321
- calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
1322
- minPressure: 10,
1323
- },
1324
- }
1325
- ```
1326
-
1327
- **Adafruit resistive 4-wire:**
1328
-
1329
- ```typescript
1330
- touch: {
1331
- library: 'Adafruit_TouchScreen',
1332
- analogPins: { xp: 'A3', yp: 'A2', xm: 8, ym: 9, rx: 300 },
1333
- calibration: { xMin: 100, xMax: 900, yMin: 100, yMax: 900 },
1334
- minPressure: 10,
1335
- }
1336
- ```
1337
-
1338
- ### Calibration
1339
-
1340
- Calibration maps the touch controller's raw ADC values to display pixel coordinates. To calibrate your panel:
1341
-
1342
- 1. Add `Serial.printf("raw=(%d,%d,%d)\n", p.x, p.y, p.z)` to the touch poll
1343
- 2. Touch the four corners of the screen and note the raw values
1344
- 3. Set `xMin`/`xMax` from the left/right edges, `yMin`/`yMax` from the top/bottom
1345
-
1346
- The transpiler handles rotation (axis swap + inversion) automatically based on the `rotation` field in the display profile.
1347
-
1348
- ### Touch events (onClick, onHold, onRelease)
1349
-
1350
- ```typescript
1351
- // Short tap (finger down + up within 600ms)
1352
- screen.btn.onClick(() => {
1353
- console.log("tapped");
1354
- screen.counter.value = screen.counter.value + 1;
1355
- });
1356
-
1357
- // Long press (finger held ≥600ms)
1358
- screen.btn.onHold(() => {
1359
- console.log("held");
1360
- });
1361
-
1362
- // Finger lift (always fires after click or hold)
1363
- screen.btn.onRelease(() => {
1364
- console.log("released");
1365
- });
1366
- ```
1367
-
1368
- The touch system implements a state machine:
1369
- - **50ms debounce** prevents rapid re-triggering
1370
- - **Click** — touch down + up within 600ms
1371
- - **Hold** — touch held ≥600ms (fires once)
1372
- - **Release** finger lifts (clears `.value` to 0)
1373
- - **Visual feedback** — `.value` set to 1 on touch down, 0 on release
1374
-
1375
- Hit-testing walks nodes topmost-first and skips containers without click handlers.
1376
-
1377
- ### Awaitable tap notifications (`ui.onTap`)
1378
-
1379
- `ui.onTap()` is an **awaitable** tap signal — use it inside an `async` function
1380
- to suspend until the next tap. It's the building block for display-sleep /
1381
- screensaver behavior and custom flow control ("tap to continue"):
1382
-
1383
- ```typescript
1384
- // Display-sleep: wake on ANY touch, dim again after 10s of inactivity.
1385
- async function screensaver() {
1386
- while (true) {
1387
- backlightOff();
1388
- await ui.onTap(); // resume on the next tap, anywhere on the screen
1389
- backlightOn();
1390
- await delay(10000); // keep the display awake for 10 seconds
1391
- }
1392
- }
1393
- ```
1394
-
1395
- With no argument it resumes on the next tap **anywhere** including empty
1396
- space, which is what makes "wake on any touch" work even when the finger lands
1397
- on no element. Pass an element to resume only when that element is tapped:
1398
-
1399
- ```typescript
1400
- // Tap-to-continue wizard: wait for the Start button specifically.
1401
- async function setupWizard() {
1402
- await ui.onTap(screen.start);
1403
- beginSetup();
1404
- }
1405
- ```
1406
-
1407
- A tap fires **both** the tapped element's `onClick` handler **and** resumes any
1408
- `await ui.onTap()` awaiter — they don't compete. `ui.onTap()` is a resume signal,
1409
- not a value: there is nothing to read from it (it returns `Promise<void>`).
1410
-
1411
- | Call | Resumes on |
1412
- |---|---|
1413
- | `await ui.onTap()` | the next tap anywhere (including empty space) |
1414
- | `await ui.onTap(screen.elem)` | the next tap on that specific element |
1415
-
1416
- Under the hood this lowers to a cooperative state-machine state that polls a
1417
- tap counter incremented by the touch driver each frame — no ISRs, natural
1418
- debounce from the ~16ms tick, same model as `await delay()`.
1419
-
1420
- ### Custom touch adapters
1421
-
1422
- For libraries not in the built-in list, write a TypeScript adapter:
1423
-
1424
- ```typescript
1425
- // my-touch-adapter.ts
1426
- import { SomeTouchLib } from '../lib/SomeTouchLib/SomeTouchLib';
1427
-
1428
- const ts = new SomeTouchLib(14, 2);
1429
- ts.begin();
1430
-
1431
- export const touch = {
1432
- isTouched: () => ts.touched(),
1433
- read: () => {
1434
- const p = ts.getPoint();
1435
- return { x: p.x, y: p.y, z: p.z };
1436
- },
1437
- };
1438
- ```
1439
-
1440
- Reference it in config:
1441
-
1442
- ```typescript
1443
- touch: {
1444
- adapter: './my-touch-adapter',
1445
- calibration: { xMin: 100, xMax: 4000, yMin: 100, yMax: 4000 },
1446
- minPressure: 10,
1447
- }
1448
- ```
1449
-
1450
- The adapter only provides raw `{x, y, z}` — the transpiler handles calibration, rotation, and coordinate mapping.
1451
-
1452
- ## GPIO input (buttons without touch)
1453
-
1454
- For physical buttons on GPIO pins (no touchscreen required):
1455
-
1456
- ```typescript
1457
- // Watch a pin for falling edgesruns in the frame loop
1458
- ui.watchPin(4, () => {
1459
- screen.counter.value = screen.counter.value + 1;
1460
- });
1461
-
1462
- // Toggle an element's .value on pin press
1463
- screen.ledBox.onToggle(5);
1464
-
1465
- // Cycle through options
1466
- screen.modeValue.onChange(15, 3); // 3 options: 0→1→2→0
1467
- ```
1468
-
1469
- Natural debounce from the ~16ms frame rate — no ISR, no `volatile`.
1
+ # @typecad/ui — HTML/CSS-Driven Graphics for Microcontrollers
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ `@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 framework that lowers the display ops:
8
+
9
+ ```bash
10
+ npm install @typecad/ui @typecad/framework-zephyr
11
+ npm install --save-dev @typecad/cuttlefish
12
+ ```
13
+
14
+ Hardware pins/classes come from **`@typecad/board`**the module cuttlefish generates per project from the board catalog; the scaffold creates it for you.
15
+
16
+ `@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`.
17
+
18
+ ### Integration wizard
19
+
20
+ Integrating a display is the one step with real hardware decisions — which panel, which bus, which pins, whether there's touch and how it connects. Run the wizard from your project directory:
21
+
22
+ ```bash
23
+ npx @typecad/ui --config
24
+ ```
25
+
26
+ It asks:
27
+
28
+ 1. **Display** — a built-in profile (ILI9341 320×240 SPI TFT, ST7796S 320×480 SPI TFT, SSD1309 128×64 I2C OLED), the desktop SDL simulator, or a fully custom driver.
29
+ 2. **Bus wiring** — SPI pins (CS/DC/RST/backlight, frequency in MHz, optional SCK/MOSI/MISO override) or the I2C address. Defaults match the framework profiles and the repo's demo wiring (ESP32 VSPI), and any existing `display` section in your config pre-fills the answers.
30
+ 3. **Orientation & rendering** — rotation and antialiasing, with advanced color-order/inversion options behind a confirm.
31
+ 4. **Touch** — none, resistive (XPT2046, STMPE610, 4-wire analog), capacitive (FT6336U, GT911, CST816S), or a custom adapter file — each with its pins, address, and a sensible default calibration.
32
+ 5. **Theme**optional `themeCss` / `themeClass`.
33
+
34
+ After a summary preview and confirmation, the wizard splices only the `display` section into `cuttlefish.config.ts` — every other section (and its comments) is preserved byte-for-byte, unmanaged display keys like `scroll` are carried over, and the result is syntax-checked before anything is written. If your config's `entry` points at a `.ui` file that doesn't exist yet, it offers to generate a starter screen, and it prints the exact preview, compile, and flash commands as next steps.
35
+
36
+ If there is no `cuttlefish.config.ts` yet, create the project first with `npx @typecad/cuttlefish create`, then re-run the wizard.
37
+
38
+ ## Project layout
39
+
40
+ 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.
41
+
42
+ The transpiler accepts three entry extensions: `.ts`, `.tsx`, and `.ui`.
43
+
44
+ ### Pattern A `.ui` single-file component (display + logic together)
45
+
46
+ 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.
47
+
48
+ 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.
49
+
50
+ **`src/app.ui`** — markup, styling, and behavior for one screen in one file:
51
+
52
+ ```html
53
+ <script>
54
+ import { ui } from '@typecad/ui';
55
+ import { GPIO34, ADC } from '@typecad/board';
56
+
57
+ ui.mount(screen);
58
+
59
+ // Plain TypeScript — same lowering as a standalone .ts. HAL pin reads,
60
+ // timers, and UI writes coexist as regular statements.
61
+ const sensor = new ADC(GPIO34);
62
+
63
+ setInterval(() => {
64
+ screen.reading.value = sensor.read();
65
+ }, 500);
66
+
67
+ ui.bind(screen.lamp, 'background', () =>
68
+ screen.reading.value > 512 ? 'limegreen' : '#333'
69
+ );
70
+ </script>
71
+
72
+ <style>
73
+ screen { background: #1a1a2e; display: flex; flex-direction: column; padding: 10px; gap: 8px; }
74
+ #reading { color: dodgerblue; font-size: 24px; }
75
+ #lamp { width: 16px; height: 16px; border-radius: 4px; }
76
+ </style>
77
+
78
+ <screen>
79
+ <text id="reading">0</text>
80
+ <view id="lamp"></view>
81
+ </screen>
82
+ ```
83
+
84
+ **`cuttlefish.config.ts`** — point the entry at the `.ui` file:
85
+
86
+ ```typescript
87
+ import type { CuttlefishConfig } from '@typecad/cuttlefish/api';
88
+
89
+ const config: CuttlefishConfig = {
90
+ entry: './src/app.ui',
91
+ board: 'esp32s3_devkitc/esp32s3/procpu',
92
+ framework: '@typecad/framework-zephyr',
93
+ output: { outDir: './out' },
94
+ console: { baudRate: 115200 },
95
+ display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
96
+ };
97
+ export default config;
98
+ ```
99
+
100
+ Use this layout when a screen's markup and behavior are tightly coupled and you want them in one place.
101
+
102
+ ### Pattern B `main.ts` entry + `.ui.html` for display (logic factored out)
103
+
104
+ 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.
105
+
106
+ 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++.
107
+
108
+ **`src/main.ts`** the entry, wires display and sensor logic:
109
+
110
+ ```typescript
111
+ import { ui } from '@typecad/ui';
112
+ import { screen } from './app.ui.html';
113
+ import { readVolts } from './sensors.js'; // .js extension required (Node16 resolution)
114
+
115
+ ui.mount(screen, { display: 'ili9341', bus: 'SPI', cs: 10, dc: 9, rst: 8 });
116
+
117
+ setInterval(() => {
118
+ screen.meter.value = Math.round(readVolts() * 100);
119
+ }, 500);
120
+
121
+ export function main(): void { while (true) {} }
122
+ ```
123
+
124
+ **`src/sensors.ts`** — plain cuttlefish TS, owns device I/O, no UI imports:
125
+
126
+ ```typescript
127
+ import { GPIO1, ADC } from '@typecad/board'; // an ADC-capable pin on this board
128
+ const adc = new ADC(GPIO1);
129
+ export function readVolts(): number {
130
+ return adc.readMillivolts() / 1000;
131
+ }
132
+ ```
133
+
134
+ **`src/app.ui.html`** — display only:
135
+
136
+ ```html
137
+ <screen>
138
+ <range id="meter" min="0" max="330"></range>
139
+ </screen>
140
+ ```
141
+
142
+ **`cuttlefish.config.ts`** — point the entry at `main.ts`:
143
+
144
+ ```typescript
145
+ const config: CuttlefishConfig = {
146
+ entry: './src/main.ts', // ← .ts entry instead of .ui
147
+ // ...rest identical
148
+ };
149
+ ```
150
+
151
+ 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:
152
+
153
+ ```c
154
+ static void __tc_timer_cb_0() {
155
+ __ui_nodes[1].value = round(readVolts() * 100); // sensor call preserved
156
+ ui_mark_dirty(1); // screen.meter.value = ... lowered
157
+ }
158
+ ```
159
+
160
+ 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.
161
+
162
+ ### Choosing between the two
163
+
164
+ | Layout | Entry | Use when |
165
+ |---|---|---|
166
+ | `.ui` single-file | `entry: './src/app.ui'` | Display and behavior are tightly coupled; one screen's markup + logic belong together |
167
+ | `main.ts` + `.ui.html` split | `entry: './src/main.ts'` | Sensor/IO logic is substantial; UI file should stay declarative |
168
+
169
+ 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.
170
+
171
+ ### A note on dynamic text bindings
172
+
173
+ 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.
174
+
175
+ For dynamic text that doesn't fit those shapes, write to the element from `setInterval` instead:
176
+
177
+ ```typescript
178
+ // Avoid (body won't lower in v1):
179
+ ui.bind(screen.reading, 'text', () => readVolts().toFixed(2) + 'V');
180
+
181
+ // Prefer drive from a timer:
182
+ setInterval(() => {
183
+ // write to .value on numeric elements, or use String(<expr>) in the bind
184
+ screen.meter.value = Math.round(readVolts() * 100);
185
+ }, 500);
186
+ ```
187
+
188
+ ## Quick start
189
+
190
+ The fastest path is a single `.ui` file with markup, styling, and behavior together (see [Project layout](#project-layout) for the alternative `main.ts` + `.ui.html` split).
191
+
192
+ ### 1. Create `src/app.ui`
193
+
194
+ ```html
195
+ <script>
196
+ import { ui } from '@typecad/ui';
197
+
198
+ ui.mount(screen);
199
+
200
+ // A signal carries the count; the binding recomputes the label each frame.
201
+ // Text updates via ui.bind(..., 'text', ...) — <text> elements don't expose
202
+ // a writable .text. String(<number>) is one of the supported bind shapes.
203
+ const taps = ui.signal(0);
204
+ ui.bind(screen.count, 'text', () => String(taps()));
205
+
206
+ screen.action.onClick(() => {
207
+ taps.set(taps() + 1);
208
+ });
209
+ </script>
210
+
211
+ <style>
212
+ screen {
213
+ background: #1a1a2e;
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 8px;
217
+ padding: 10px;
218
+ }
219
+ #count { color: dodgerblue; font-size: 16px; }
220
+ #action {
221
+ background: darkgreen;
222
+ color: white;
223
+ border: 2px solid limegreen;
224
+ border-radius: 4px;
225
+ padding: 8px 20px;
226
+ text-align: center;
227
+ transition: background 300ms;
228
+ }
229
+ #action:pressed { background: limegreen; }
230
+ </style>
231
+
232
+ <screen>
233
+ <text id="count">0</text>
234
+ <button id="action">Start</button>
235
+ </screen>
236
+ ```
237
+
238
+ Display hardware and wiring live in `cuttlefish.config.ts` under `display`, so the UI source stays focused on UI behavior. Run `npx @typecad/ui --config` to generate that section interactively (see [Integration wizard](#integration-wizard)) — the result looks like the `display` line below.
239
+
240
+ ### 2. Point the entry at the `.ui` file
241
+
242
+ ```typescript
243
+ // cuttlefish.config.ts
244
+ import type { CuttlefishConfig } from '@typecad/cuttlefish/api';
245
+
246
+ const config: CuttlefishConfig = {
247
+ entry: './src/app.ui',
248
+ board: 'esp32s3_devkitc/esp32s3/procpu',
249
+ framework: '@typecad/framework-zephyr',
250
+ output: { outDir: './out' },
251
+ console: { baudRate: 115200 },
252
+ display: { profile: 'ili9341-spi', cs: 5, dc: 21, rst: 22, backlight: 33 },
253
+ };
254
+ export default config;
255
+ ```
256
+
257
+ ### 3. Build
258
+
259
+ ```bash
260
+ npx @typecad/cuttlefish build --compile
261
+ ```
262
+
263
+ ## Elements
264
+
265
+ ### `<screen>`
266
+
267
+ The root container. Required (exactly one). Its box fills the display viewport (320×240 landscape for ILI9341).
268
+
269
+ ### `<view>`
270
+
271
+ A generic container. Supports `display: flex` for layout. Has a `.value` property and `onToggle`/`onChange` for interaction.
272
+
273
+ ```html
274
+ <view id="row">
275
+ <text id="label">Status</text>
276
+ <text id="value">OK</text>
277
+ </view>
278
+ ```
279
+
280
+ ### `<text>`
281
+
282
+ Static or dynamic text. Has a `.value` property for numeric state.
283
+
284
+ ```html
285
+ <text id="counter">0</text>
286
+ ```
287
+
288
+ ### `<button>`
289
+
290
+ A clickable button with `:pressed` pseudo-state support and transition animations.
291
+
292
+ ```html
293
+ <button id="start">Start</button>
294
+ ```
295
+
296
+ ### Interactive elements
297
+
298
+ | Element | Purpose | `.value` |
299
+ |---|---|---|
300
+ | `<check>` | Checkbox (tap to toggle) | 0 / 1 |
301
+ | `<radio name="g">` | Radio (mutually exclusive within a `name` group) | 0 / 1 |
302
+ | `<select>` | Tap to cycle options; text auto-shows the current option | 0..N-1 |
303
+ | `<progress>` | Progress bar (0-100) | fill percentage |
304
+ | `<range>` | Draggable slider | between `min` and `max` |
305
+ | `<input>` | Text input (tap opens the on-screen keyboard) | — (use `.text`) |
306
+ | `<list>` | Virtualized, data-bound list (renders only visible items) | |
307
+ | `<canvas>` | User-drawn graphics (sparklines, gauges, custom shapes) via `ui.drawCanvas` | |
308
+
309
+ ```html
310
+ <range id="brightness" min="0" max="100"></range>
311
+ <progress id="load" value="40"></progress>
312
+ <check id="enable">Enable feature</check>
313
+ <input id="ssid" type="text" placeholder="Network name" maxlength="32"></input>
314
+ <select id="mode"><option>Auto</option><option>Manual</option></select>
315
+ ```
316
+
317
+ Slider and progress values can be driven live from `loop()` via bindings (see
318
+ [Reactive bindings](#reactive-bindings) below).
319
+
320
+ ### Images
321
+
322
+ `<img>` embeds a raw RGB565 `.img` file as a `static const uint16_t[]` array:
323
+
324
+ ```html
325
+ <img id="logo" src="assets/logo.img" width="64" height="64"></img>
326
+ ```
327
+
328
+ The `.img` file is a flat row-major RGB565 dump (width × height × 2 bytes). Use
329
+ `object-fit` (`contain`, `cover`, `fill`) to control scaling.
330
+
331
+ ### HTML tag aliases
332
+
333
+ Common HTML tags are accepted and remapped to the internal primitives, so you
334
+ can write familiar HTML:
335
+
336
+ | HTML tag | Maps to | Notes |
337
+ |---|---|---|
338
+ | `body`, `div`, `header`, `footer`, `nav`, `main`, `section`, `article`, `aside` | `<view>` | Block container |
339
+ | `span`, `p`, `h1`–`h6` | `<text>` | Inline/heading text |
340
+
341
+ ```html
342
+ <header><h1 id="title">Settings</h1></header>
343
+ <main><p id="desc">Adjust preferences.</p></main>
344
+ ```
345
+
346
+ ### Global attributes
347
+
348
+ All UI elements support the `hidden` attribute. Hidden elements and their
349
+ descendants stay in the generated node table, but they do not take space in
350
+ layout and are skipped for drawing and hit testing.
351
+
352
+ ```html
353
+ <view id="advancedPanel" hidden>
354
+ <text>Advanced settings</text>
355
+ </view>
356
+ ```
357
+
358
+ ## CSS reference
359
+
360
+ ### Supported properties
361
+
362
+ #### Box model
363
+ | Property | Values | Notes |
364
+ |---|---|---|
365
+ | `padding` | `8px`, `8px 16px` | Shorthand supported |
366
+ | `margin` | `8px`, `8px 16px` | Shorthand supported |
367
+ | `width` | `100px` | Explicit size |
368
+ | `height` | `50px` | Explicit size |
369
+ | `min-width` / `max-width` | `100px` | Yoga constraints |
370
+ | `min-height` / `max-height` | `50px` | Yoga constraints |
371
+ | `aspect-ratio` | `16 / 9`, `1 / 1`, `1.5` | Infers the missing width or height |
372
+ | `box-sizing` | `border-box` | Yoga border-box |
373
+ | `overflow` | `hidden`, `scroll` | Clips children; `scroll` enables touch-drag scrolling |
374
+
375
+ #### Length units
376
+
377
+ All length values accept `px`, bare numbers, and `rem`/`em` 16 root font
378
+ size). `0.625rem` resolves to `10px`. Percentages are used as-is in the contexts
379
+ that honor them (flex/position).
380
+
381
+ `calc()` evaluates simple arithmetic (`+ - * /`) on lengths after `var()`
382
+ substitution, with proper operator precedence:
383
+
384
+ ```css
385
+ :root { --radius: 10px; }
386
+ .card { border-radius: calc(var(--radius) - 4px); } /* 6px */
387
+ .a { padding: calc(0.625rem * 2); } /* 20px */
388
+ ```
389
+
390
+ #### Flexbox (via Yoga)
391
+ | Property | Values |
392
+ |---|---|
393
+ | `display` | `flex`, `none` |
394
+ | `flex-direction` | `row`, `row-reverse`, `column`, `column-reverse` |
395
+ | `gap` | `8px` (sets both row and column gap) |
396
+ | `row-gap` / `column-gap` | `8px` (per-axis; overrides uniform `gap`) |
397
+ | `flex-grow` | `1` |
398
+ | `flex-shrink` | `0` |
399
+ | `flex` (shorthand) | `1`, `1 0 auto`, `none` |
400
+ | `align-items` | `flex-start`, `center`, `flex-end`, `stretch` |
401
+ | `align-self` | `flex-start`, `center`, `flex-end`, `stretch`, `baseline` |
402
+ | `align-content` | `flex-start`, `center`, `flex-end`, `stretch`, `space-between`, `space-around`, `space-evenly` |
403
+ | `justify-content` | `flex-start`, `center`, `flex-end`, `space-between`, `space-around`, `space-evenly` |
404
+ | `flex-wrap` | `wrap`, `nowrap`, `wrap-reverse` |
405
+ | `order` | `1`, `2`, ... |
406
+ | `position` | `relative`, `absolute`, `static` |
407
+ | `top` / `right` / `bottom` / `left` | `10px` |
408
+ | `z-index` | numeric layers; inherited by descendants |
409
+
410
+ `display: none` removes the element subtree from layout, drawing, and hit
411
+ testing while preserving generated node indices.
412
+
413
+ #### Colors
414
+ All standard CSS color formats are supported:
415
+ - `#rrggbb` — `#ff0000`
416
+ - `#rgb` `#f00`
417
+ - `#rrggbbaa` `#ff0000ff` (alpha ignored)
418
+ - `rgb(r,g,b)` — `rgb(255, 0, 0)`
419
+ - `rgba(r,g,b,a)` — `rgba(255, 0, 0, 0.5)` (alpha ignored)
420
+ - `hsl(h, s%, l%)` / `hsla(...)` — `hsl(240, 100%, 50%)`, `hsla(0 0% 0% / 0.05)`
421
+ - Named colors — `red`, `dodgerblue`, `limegreen`, `transparent`, ... (147 CSS named colors)
422
+
423
+ Both comma (`hsl(0, 0%, 0%)`) and CSS4 space (`hsl(0 0% 0%)`) syntaxes work.
424
+ The slash-alpha form (`hsl(0 0% 0% / 0.05)`) is honored in `box-shadow` alpha;
425
+ elsewhere alpha is ignored (no runtime blending on bare metal).
426
+
427
+ #### Typography
428
+ | Property | Values | Notes |
429
+ |---|---|---|
430
+ | `color` | any color | Text foreground color |
431
+ | `font-family` | `"MyFont"` | Uses a generated font when matched by `@font-face`; otherwise the built-in bitmap font |
432
+ | `font-size` | `16px` | Generated fonts are rasterized at this pixel size; bitmap text maps to GFX text size |
433
+ | `font` | `italic bold 18px DeviceSans` | Shorthand support for style, weight, size, and family |
434
+ | `text-align` | `left`, `center`, `right` | Horizontal alignment within the box |
435
+ | `text-decoration` | `underline`, `line-through`, `none` | Both may combine: `underline line-through` |
436
+ | `text-overflow` | `ellipsis`, `clip` | Truncates overflowing single-line text with `...` |
437
+ | `text-transform` | `uppercase`, `lowercase`, `capitalize`, `none` | Applied at transpile time |
438
+ | `line-height` | `1.5`, `150%`, `24px` | Line advance for wrapped text; `normal` = font default |
439
+ | `letter-spacing` | `2px`, `-1px` | Per-character advance adjustment |
440
+ | `white-space` | `normal`, `nowrap`, `pre`, `pre-line` | Controls word-wrap behavior |
441
+ | `font-weight` | `normal`, `bold`, `400`, `700` | Selects the matching `@font-face` variant when available |
442
+ | `font-style` | `normal`, `italic`, `oblique` | Selects the matching `@font-face` variant when available |
443
+ | `font-smoothing` | `antialiased`, `none` | Overrides display-level text antialiasing |
444
+ | `font-subset` | `exact`, `fallback` | Controls generated-font glyph selection |
445
+
446
+ ### Fonts
447
+
448
+ Local TTF/OTF fonts can be referenced with `@font-face`. The transpiler does
449
+ not copy the whole font to the board. It reads the font at build time, rasterizes
450
+ only the glyphs needed by the UI, packs them as 4-bit alpha bitmap data, and
451
+ emits those tables into the firmware. On ESP32-class targets those generated
452
+ tables are `static const` data in flash/rodata; the original TTF/OTF file is not
453
+ held in RAM on the hardware.
454
+
455
+ #### Install a font in a project
456
+
457
+ Put font files somewhere inside the project, usually next to the `.ui.css` file
458
+ or under a local `fonts/` folder:
459
+
460
+ ```text
461
+ src/
462
+ app.ui.html
463
+ app.ui.css
464
+ fonts/
465
+ DeviceSans-Regular.ttf
466
+ DeviceSans-Bold.ttf
467
+ ```
468
+
469
+ Reference them from CSS with paths relative to the `.ui.css` file:
470
+
471
+ ```css
472
+ @font-face {
473
+ font-family: "DeviceSans";
474
+ src: url("fonts/DeviceSans.ttf");
475
+ }
476
+
477
+ #title {
478
+ font-family: "DeviceSans";
479
+ font-size: 24px;
480
+ font-smoothing: antialiased;
481
+ }
482
+ ```
483
+
484
+ Remote font URLs are not supported for embedded builds. Use local files so the
485
+ build is reproducible and does not depend on network access.
486
+
487
+ #### Declare variants
488
+
489
+ Declare each weight/style variant as its own `@font-face`. The UI compiler
490
+ chooses the closest matching variant for each node based on `font-family`,
491
+ `font-weight`, `font-style`, and `font-size`.
492
+
493
+ ```css
494
+ @font-face {
495
+ font-family: "DeviceSans";
496
+ src: url("fonts/DeviceSans-Regular.ttf");
497
+ font-weight: 400;
498
+ font-style: normal;
499
+ }
500
+
501
+ @font-face {
502
+ font-family: "DeviceSans";
503
+ src: url("fonts/DeviceSans-Bold.ttf");
504
+ font-weight: 700;
505
+ font-style: normal;
506
+ }
507
+
508
+ @font-face {
509
+ font-family: "DeviceSans";
510
+ src: url("fonts/DeviceSans-Italic.ttf");
511
+ font-weight: 400;
512
+ font-style: italic;
513
+ }
514
+
515
+ #title {
516
+ font-family: "DeviceSans";
517
+ font-size: 24px;
518
+ font-weight: bold;
519
+ }
520
+ ```
521
+
522
+ Every distinct `font-family` + resolved font file + `font-size` + variant becomes
523
+ one generated font asset. Reusing the same face and size across many nodes
524
+ shares one asset. Using the same face at `16px` and `24px` creates two assets
525
+ because each size is rasterized separately.
526
+
527
+ #### Exact subsetting and icon fonts
528
+
529
+ By default, generated fonts use `font-subset: exact`. Only the literal
530
+ characters found in static UI text, placeholders, and option labels are encoded.
531
+ This is useful for icon fonts and symbol fonts:
532
+
533
+ ```html
534
+ <text id="wifiIcon">✓</text>
535
+ ```
536
+
537
+ ```css
538
+ @font-face {
539
+ font-family: "DeviceIcons";
540
+ src: url("fonts/device-icons.ttf");
541
+ }
542
+
543
+ #wifiIcon {
544
+ font-family: "DeviceIcons";
545
+ font-size: 20px;
546
+ font-subset: exact;
547
+ }
548
+ ```
549
+
550
+ In this case, only the checkmark glyph is emitted for that font/size, not the
551
+ whole icon font and not the common ASCII set.
552
+
553
+ Generated font glyph lookup supports UTF-8 text for codepoints in the Basic
554
+ Multilingual Plane (`U+0000` to `U+FFFF`). Many icon fonts use Private Use Area
555
+ codepoints such as `U+E000`; those are supported. Emoji and other characters
556
+ above `U+FFFF` are not currently supported by the generated-font runtime.
557
+
558
+ Wingdings-style fonts can work, but be careful: some older symbol fonts use
559
+ legacy character mappings rather than standard Unicode symbols. Copy the exact
560
+ character/codepoint that the font maps to the glyph you want, or prefer a
561
+ Unicode icon font when possible.
562
+
563
+ #### Dynamic text and fallback glyphs
564
+
565
+ Exact subsetting can only see text known at build time. If a generated font is
566
+ used on a node whose text changes at runtime, include a fallback character set:
567
+
568
+ ```css
569
+ #counter {
570
+ font-family: "DeviceSans";
571
+ font-size: 18px;
572
+ font-subset: fallback;
573
+ }
574
+ ```
575
+
576
+ `font-subset: fallback` includes the static text plus a small common ASCII set
577
+ containing digits, letters, spaces, and punctuation. Use it for counters,
578
+ formatted numeric values, input fields, or any generated-font text binding that
579
+ can produce characters not present in the initial HTML.
580
+
581
+ If the node uses the built-in bitmap font, `font-subset` has no effect.
582
+
583
+ #### Smoothing
584
+
585
+ Generated TTF/OTF glyphs are rasterized as alpha masks. Use
586
+ `font-smoothing: antialiased` to blend edge pixels for smoother text on RGB
587
+ displays. Use `font-smoothing: none` to threshold the same glyph masks for a
588
+ sharper, more pixel-like look. On monochrome displays smoothing is disabled.
589
+
590
+ ```css
591
+ .smooth {
592
+ font-family: "DeviceSans";
593
+ font-size: 18px;
594
+ font-smoothing: antialiased;
595
+ }
596
+
597
+ .sharp {
598
+ font-family: "DeviceSans";
599
+ font-size: 18px;
600
+ font-smoothing: none;
601
+ }
602
+ ```
603
+
604
+ #### Converting fonts
605
+
606
+ Use TTF or OTF files when possible. WOFF/WOFF2 web fonts should be converted to
607
+ TTF/OTF before use.
608
+
609
+ Common conversion options:
610
+
611
+ - FontForge GUI: open the source font, then use `File -> Generate Fonts...` and
612
+ choose TrueType (`.ttf`) or OpenType (`.otf`).
613
+ - FontForge CLI:
614
+
615
+ ```bash
616
+ fontforge -lang=ff -c 'Open($1); Generate($2)' input.otf output.ttf
617
+ ```
618
+
619
+ - WOFF2 tools: use `woff2_decompress input.woff2` to produce a TTF-flavored
620
+ font when the source is a WOFF2 web font.
621
+ - fonttools can inspect and subset fonts:
622
+
623
+ ```bash
624
+ python -m pip install fonttools brotli
625
+ pyftsubset DeviceSans.ttf --text="ABC123" --unicodes=U+2713 --output-file=DeviceSans-subset.ttf
626
+ ```
627
+
628
+ Manual external subsetting is optional. TypeCAD already subsets the emitted
629
+ hardware glyphs. External subsetting is mainly useful when you need to distribute
630
+ a smaller source font file, remove unused font tables for licensing reasons, or
631
+ speed up build-time parsing of a very large font.
632
+
633
+ #### Licensing
634
+
635
+ Do not assume system fonts are redistributable. Fonts such as commercial OS
636
+ fonts may be licensed for local use but not for checking into a repository or
637
+ shipping in a firmware project. Prefer open-licensed fonts, or keep proprietary
638
+ fonts outside shared source control if your license requires it.
639
+
640
+ #### Visual
641
+ | Property | Values | Notes |
642
+ |---|---|---|
643
+ | `background` / `background-color` | any color | Fill color |
644
+ | `border` (shorthand) | `2px solid #808080` | Splits into width/style/color |
645
+ | `border-width` | `2px` | |
646
+ | `border-color` | any color | |
647
+ | `border-style` | `solid`, `dashed`, `none` | Dashed approximated with segments |
648
+ | `border-radius` | `4px` | Rounded fill/border on hardware; preview approximates |
649
+ | `outline` | `1px solid #fff`, `2px dashed red` | Drawn outside the element box |
650
+ | `visibility` | `visible`, `hidden` | Hidden elements are not drawn |
651
+ | `box-shadow` | `inset 0 1px 0 #fff`, `0 10px 0 #333` | Up to 4 rect shadows; approximated for TFT drawing |
652
+ | `transform` | `translateY(10px)`, `translate(0, 10px)` | Draw-time translate offset; no flex relayout |
653
+ | `opacity` | parsed | (Blending not supported without PSRAM framebuffer) |
654
+
655
+ #### Transitions
656
+ | Property | Values | Notes |
657
+ |---|---|---|
658
+ | `transition` | `background 300ms`, `color 120ms` | Lerps the property over the duration |
659
+ | `:pressed` | pseudo-class | Applied when `.value` is 1 (button press) |
660
+
661
+ Pressed rules may also include `top` / `left` / `right` / `bottom` or
662
+ `transform: translate(...)`. These are applied as draw-time offsets so the
663
+ element face/content can move visually without recomputing the flex layout;
664
+ outset shadows stay anchored, which is useful for raised button effects.
665
+
666
+ Small dirty paint regions for text, backgrounds, borders, outlines, shadows,
667
+ and draw-time translate offsets are composed in an offscreen RGB565 canvas and
668
+ pushed as one rectangle when memory allows. Larger regions fall back to direct
669
+ drawing.
670
+
671
+ ### Selectors
672
+ - Element: `screen { ... }`
673
+ - ID: `#title { ... }`
674
+ - Class: `.card { ... }`
675
+ - Compound: `.card.active { ... }`, `button.primary { ... }`
676
+ - Descendant: `view text { ... }`
677
+ - Child: `view > text { ... }` (direct children only)
678
+ - Adjacent sibling: `.first + .second { ... }` (immediate next sibling)
679
+ - General sibling: `.first ~ .later { ... }` (any following sibling)
680
+ - Attribute: `[disabled]`, `[type="number"]` (presence and exact-value match)
681
+ - Negation: `button:not(.disabled)`, `.a:not(.b.c)` (compound `:not()` supported)
682
+ - Pseudo-state: `#btn:pressed`, `input:disabled`, `check:checked`, `*:focus`
683
+ - Inline style: `<text style="color: red">hi</text>`
684
+ - `<style>` blocks embedded in the `.ui.html`
685
+
686
+ Pseudo-states match runtime element state: `:pressed` (button held), `:disabled`
687
+ (`disabled` attribute), `:checked` (`<check>`/`<radio>` with `.value` 1), and
688
+ `:focus` (the node currently receiving input).
689
+
690
+ ### CSS variables
691
+
692
+ Define variables in `:root` and reference them with `var()`:
693
+
694
+ ```css
695
+ :root {
696
+ --bg: #0a0a0a;
697
+ --fg: #fafafa;
698
+ --primary: #7c3aed;
699
+ }
700
+ screen { background: var(--bg); }
701
+ #title { color: var(--fg); }
702
+ ```
703
+
704
+ Variables resolve at transpile time — no runtime cost.
705
+
706
+ #### Class-scoped variables (themes)
707
+
708
+ Variables can also be defined under a class selector (e.g. `.dark`) and selected
709
+ at build time via the `themeClass` config option. This is how shadcn-style
710
+ light/dark themes work:
711
+
712
+ ```css
713
+ :root { --bg: #ffffff; --fg: #0a0a0a; }
714
+ .dark { --bg: #0a0a0a; --fg: #fafafa; }
715
+ screen { background: var(--bg); color: var(--fg); }
716
+ ```
717
+
718
+ ```typescript
719
+ // cuttlefish.config.ts or the display config in ui.mount
720
+ display: {
721
+ themeClass: 'dark', // resolves var(--x) using the .dark overrides
722
+ }
723
+ ```
724
+
725
+ When `themeClass` is set, `var()` substitution prefers that class's variables
726
+ over `:root`. This is **transpile-time selection** — one theme per firmware
727
+ build (there is no runtime theme switch on a fixed-screen device).
728
+
729
+ #### `@media` (compile-time variant selection)
730
+
731
+ `@media` rules are evaluated against the resolved display profile at transpile
732
+ time. Since each build targets one fixed screen size, this acts as a
733
+ compile-time variant selector, not responsive design:
734
+
735
+ ```css
736
+ /* Applied only when the display is ≤ 240px wide */
737
+ @media (max-width: 240px) {
738
+ #title { font-size: 12px; }
739
+ }
740
+ ```
741
+
742
+ Supported conditions: `min-width`, `max-width`, `min-height`, `max-height`
743
+ (in `px`). Unsupported conditions (e.g. `orientation`) emit a warning and the
744
+ rule is skipped. `@import` and `@supports` are not supported (warned + skipped).
745
+
746
+ ### Theming
747
+
748
+ Themes are **compile-time**. There are two complementary mechanisms:
749
+
750
+ **1. Theme file (`themeCss`)** swap the entire CSS file:
751
+
752
+ ```typescript
753
+ // cuttlefish.config.ts
754
+ display: {
755
+ themeCss: './src/hello.dark.css', // relative to .ui.html dir
756
+ // or: themeCss: '/absolute/path/to/theme.css',
757
+ }
758
+ ```
759
+
760
+ 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:
761
+
762
+ ```
763
+ src/
764
+ hello.ui.html ← layout (shared)
765
+ hello.ui.css ← default theme (no themeCss set)
766
+ hello.dark.css dark theme
767
+ hello.shadcn.css ← shadcn palette
768
+ ```
769
+
770
+ **2. Theme class (`themeClass`)** — select a class-scoped variable block
771
+ within a single CSS file (see [Class-scoped variables](#class-scoped-variables-themes) above):
772
+
773
+ ```typescript
774
+ display: {
775
+ themeClass: 'dark', // resolves var(--x) from .dark { ... } overrides
776
+ }
777
+ ```
778
+
779
+ The two can be combined: `themeCss` picks the file, `themeClass` picks the
780
+ variable scope within it.
781
+
782
+ 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.
783
+
784
+ ### Unsupported (and why)
785
+ - `display: grid` needs a GridLayoutEngine
786
+ - Full inline rich text — basic wrapping, `line-height`, `white-space`, and `<br>` are supported; mixed inline spans are not
787
+ - `background-image` / sprites — use `<img>` for embedded images; CSS `background: url(...)` is unsupported (only solid colors and `linear-gradient`)
788
+ - `position: fixed` viewport-fixed positioning is not implemented
789
+ - `:after` / `:before` pseudo-elements — no generated content
790
+ - `text-shadow` on built-in font — needs sub-pixel font data (works with custom fonts)
791
+ - Per-corner `border-radius`only a uniform radius is supported (Adafruit_GFX draws one corner value)
792
+ - Runtime theme switchingthemes are compile-time only (one `themeClass` per build; swap in config and rebuild)
793
+
794
+ ## State and interaction
795
+
796
+ ### The `.value` property
797
+
798
+ Every interactive element has a `.value` property a number that is both readable and writable:
799
+
800
+ ```typescript
801
+ // Read
802
+ const count = screen.counter.value;
803
+
804
+ // Write (updates the display immediately)
805
+ screen.counter.value = 42;
806
+ ```
807
+
808
+ ### Pin input
809
+
810
+ ```typescript
811
+ // Watch a pin for falling edges — runs in the frame loop (no ISR)
812
+ ui.watchPin(4, () => {
813
+ screen.counter.value = screen.counter.value + 1;
814
+ });
815
+
816
+ // Toggle an element's .value on pin press (0 ↔ 1)
817
+ screen.ledBox.onToggle(5);
818
+
819
+ // Cycle through options (0 → 1 → 2 → 0 → ...)
820
+ screen.modeSelect.onChange(15, 3);
821
+ ```
822
+
823
+ ### Reactive bindings
824
+
825
+ Bindings compute a display property from `.value` or signals each frame:
826
+
827
+ ```typescript
828
+ // Color binding
829
+ ui.bind(screen.counter, 'color', () =>
830
+ (screen.counter.value % 2 === 0 ? 'limegreen' : 'orange')
831
+ );
832
+
833
+ // Background binding
834
+ ui.bind(screen.btn, 'background', () =>
835
+ (screen.btn.value > 0 ? 'limegreen' : 'darkgreen')
836
+ );
837
+
838
+ // Border color binding
839
+ ui.bind(screen.ledBox, 'borderColor', () =>
840
+ (screen.ledBox.value ? 'limegreen' : '#808080')
841
+ );
842
+
843
+ // Value binding — drive a progress/range node live from loop()
844
+ ui.bind(screen.progress, 'value', () => sensorPercent);
845
+
846
+ // Visibility binding - preallocate both branches and toggle which one draws
847
+ ui.bind(screen.enteredBranch, 'visible', () => screen.input.value > 0);
848
+ ui.bind(screen.emptyBranch, 'visible', () => screen.input.value === 0);
849
+
850
+ // Text binding number to string
851
+ ui.bind(screen.counter, 'text', () => String(screen.counter.value));
852
+
853
+ // Text binding ternary chain (for selectors)
854
+ ui.bind(screen.modeValue, 'text', () => (
855
+ screen.modeValue.value === 0 ? 'Auto' :
856
+ screen.modeValue.value === 1 ? 'Manual' : 'Off'
857
+ ));
858
+ ```
859
+
860
+ `visible` bindings are for fixed-layout conditional rendering. The nodes stay in
861
+ the retained UI tree; hidden branches are skipped for drawing and hit testing,
862
+ and shown branches repaint their subtree.
863
+
864
+ ### Two-way input binding
865
+
866
+ When the user types into an `<input>` via the on-screen keyboard, push the text
867
+ back into app state with `ui.bindInput`:
868
+
869
+ ```typescript
870
+ let ssid = '';
871
+ ui.bindInput(screen.ssid, (text) => { ssid = text; });
872
+ ```
873
+
874
+ The callback fires whenever the input's text changes (after the keyboard commits).
875
+
876
+ ### Slider change callbacks
877
+
878
+ A `<range>` fires `onChange` on every value change while dragging — read
879
+ `.value` inside the callback for the new value:
880
+
881
+ ```typescript
882
+ screen.brightness.onChange(() => {
883
+ // Fires continuously during the drag.
884
+ ledPwm = screen.brightness.value;
885
+ });
886
+ ```
887
+
888
+ ### Data-bound lists
889
+
890
+ `<list>` is a virtualized, callback-driven list — it renders only the visible
891
+ items to a dedicated scroll canvas, so a thousand-item list has the same memory
892
+ footprint as a ten-item one. Bind it with `ui.bindList`:
893
+
894
+ ```html
895
+ <list id="networks" item-height="28px"></list>
896
+ ```
897
+
898
+ ```typescript
899
+ ui.bindList(
900
+ screen.networks,
901
+ () => scanResults.length, // count
902
+ (i) => `${scanResults[i].ssid} (${scanResults[i].rssi} dBm)`, // item text
903
+ (i) => { connectTo(scanResults[i].ssid); }, // optional tap handler
904
+ );
905
+ ```
906
+
907
+ The count function re-evaluates each frame; if it changes, the list recomputes
908
+ its content height and repaints. Drag to scroll; tap an item to fire the
909
+ optional third callback.
910
+
911
+ ### User-drawn canvas (`<canvas>`)
912
+
913
+ `<canvas>` is an element whose contents you draw yourself, every frame, using
914
+ the display graphics primitives. It follows all CSS rules (layout, borders,
915
+ transforms, z-index) like any other element, but its pixels come from your
916
+ callback. Use it for sparkline graphs, analog gauges, or custom-shaped controls.
917
+
918
+ ```html
919
+ <canvas id="spark" width="120" height="40"></canvas>
920
+ ```
921
+
922
+ `width`/`height` set the **drawing buffer** size (px). The CSS box is the
923
+ **layout** size — size them to match unless you want clipping.
924
+
925
+ ```typescript
926
+ ui.drawCanvas(screen.spark, (ctx) => {
927
+ ctx.fillScreen('black');
928
+ ctx.line(0, 30, ctx.width, 30, 'limegreen'); // baseline
929
+ ctx.rect(2, 2, ctx.width - 4, ctx.height - 4, '#333');
930
+ ctx.fillCircle(needleX, 30, 3, 'red');
931
+ ctx.text(4, 12, `${temp}°`, 'white'); // optional color arg
932
+ });
933
+ ```
934
+
935
+ Coordinates are **canvas-relative** (`(0,0)` = element top-left) and drawing is
936
+ **auto-clipped** to the buffer — you cannot accidentally paint over neighbors.
937
+ Color arguments are CSS color strings resolved to RGB565 at build time.
938
+
939
+ The callback runs **every frame**; to animate, mutate state in a `setInterval`
940
+ or signal and the canvas picks it up next frame. Taps hit-test as the full CSS
941
+ box, so `screen.spark.onClick(...)` works for interactive canvases.
942
+
943
+ #### `ctx` methods (the display graphics primitives)
944
+
945
+ | Method | Notes |
946
+ |---|---|
947
+ | `ctx.fillRect(x,y,w,h,color)` / `ctx.rect(...)` | Filled / outline rectangle |
948
+ | `ctx.fillRoundRect(x,y,w,h,r,color)` / `ctx.roundRect(...)` | Rounded variant |
949
+ | `ctx.line(x0,y0,x1,y1,color)` | Arbitrary line |
950
+ | `ctx.hline(x,y,w,color)` / `ctx.vline(x,y,h,color)` | Fast horizontal / vertical line |
951
+ | `ctx.fillCircle(x,y,r,color)` / `ctx.circle(...)` | Filled / outline circle |
952
+ | `ctx.drawPixel(x,y,color)` | Single pixel |
953
+ | `ctx.text(x,y,str,color?)` | Bitmap text (built-in font) |
954
+ | `ctx.fillScreen(color)` | Clear the whole buffer |
955
+ | `ctx.width` / `ctx.height` | Read-only buffer dimensions |
956
+
957
+ ### Signals
958
+
959
+ For reactive state not tied to an element:
960
+
961
+ ```typescript
962
+ const temperature = ui.signal(22);
963
+
964
+ // Read
965
+ const t = temperature();
966
+
967
+ // Write
968
+ temperature.set(25);
969
+ ```
970
+
971
+ `ui.signal()` accepts `number`, `string`, or `boolean` literals — these lower to
972
+ `int`/`double`, `const char*`, and `bool` on the device. Other initializers
973
+ (objects, arrays, `null`, identifiers) are rejected at type-check time
974
+ (`Signal<T extends SignalValue>`) and at build time with a `ui-signal-initializer`
975
+ warning that defaults the signal to `0` (`int`).
976
+
977
+ ## Element id errors
978
+
979
+ If a binding or event handler references an element id that doesn't exist in the
980
+ screen, the build fails with a `ui-unknown-element` error rather than silently
981
+ re-targeting the wrong node. This applies to every call that takes a screen
982
+ element:
983
+
984
+ ```typescript
985
+ ui.bind(screen.typo, 'color', ...) // error: element "typo" not found
986
+ ui.bindInput(screen.typo, ...) // error
987
+ ui.bindList(screen.typo, ...) // error
988
+ ui.drawCanvas(screen.typo, ...) // ✗ error
989
+ await ui.onTap(screen.typo) // ✗ error (no silent fallback to any-tap)
990
+ screen.typo.onClick(...) // ✗ error
991
+ screen.typo.onToggle(...) // ✗ error
992
+ ```
993
+
994
+ Fix the typo in your `.ui.html` / `.ui` file's `id` attribute and rebuild.
995
+
996
+ ## Composing custom elements
997
+
998
+ Don't see the element you need? Build it from `<view>` + `<text>` + bindings:
999
+
1000
+ ### Custom checkbox
1001
+
1002
+ ```html
1003
+ <view id="ledRow">
1004
+ <view id="ledBox"></view>
1005
+ <text id="ledLabel">Enable LED</text>
1006
+ </view>
1007
+ ```
1008
+ ```css
1009
+ #ledBox {
1010
+ width: 16px;
1011
+ height: 16px;
1012
+ border: 2px solid #808080;
1013
+ }
1014
+ ```
1015
+ ```typescript
1016
+ screen.ledBox.onToggle(5); // toggles .value 0↔1
1017
+
1018
+ ui.bind(screen.ledBox, 'background', () =>
1019
+ (screen.ledBox.value ? 'limegreen' : 'transparent')
1020
+ );
1021
+ ui.bind(screen.ledBox, 'borderColor', () =>
1022
+ (screen.ledBox.value ? 'limegreen' : '#808080')
1023
+ );
1024
+ ```
1025
+
1026
+ ### Custom selector
1027
+
1028
+ ```html
1029
+ <view id="modeRow">
1030
+ <text id="modeLabel">Mode:</text>
1031
+ <text id="modeValue">Auto</text>
1032
+ </view>
1033
+ ```
1034
+ ```typescript
1035
+ screen.modeValue.onChange(15, 3); // cycles 0→1→2→0
1036
+
1037
+ ui.bind(screen.modeValue, 'text', () => (
1038
+ screen.modeValue.value === 0 ? 'Auto' :
1039
+ screen.modeValue.value === 1 ? 'Manual' : 'Off'
1040
+ ));
1041
+ ```
1042
+
1043
+ ### Custom progress bar
1044
+
1045
+ ```html
1046
+ <view id="barContainer">
1047
+ <view id="barFill"></view>
1048
+ </view>
1049
+ ```
1050
+ ```css
1051
+ #barContainer { width: 200px; height: 20px; border: 1px solid #808080; }
1052
+ #barFill { background: limegreen; height: 100%; }
1053
+ ```
1054
+ ```typescript
1055
+ ui.bind(screen.barFill, 'background', () =>
1056
+ (screen.barFill.value > 50 ? 'limegreen' : 'orange')
1057
+ );
1058
+ ```
1059
+
1060
+ ## Timers
1061
+
1062
+ ```typescript
1063
+ // Auto-update every 2 seconds
1064
+ setInterval(() => {
1065
+ screen.counter.value = screen.counter.value + 1;
1066
+ }, 2000);
1067
+ ```
1068
+
1069
+ ## Architecture
1070
+
1071
+ ```
1072
+ .ui.html / .ui.css → parse (linkedom + css-tree) → resolve styles
1073
+
1074
+ layout (Yoga flexbox)
1075
+
1076
+ lower to C++ UINode[] table
1077
+
1078
+ ui_mount display.init (the active display adapter)
1079
+ ui_tick → poll inputs → eval bindings → transitions → draw
1080
+ ui_init → mark all dirty for first frame
1081
+ ```
1082
+
1083
+ 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.
1084
+
1085
+ ### Rendering & performance
1086
+
1087
+ Each frame, `ui_tick` re-evaluates bindings, advances transitions/animations,
1088
+ and redraws only the nodes marked dirty (most frames touch a handful of nodes,
1089
+ not the whole screen). Dirty paint regions are composed in an offscreen RGB565
1090
+ canvas and pushed as one rectangle when memory allows.
1091
+
1092
+ **Framebuffer (PSRAM-gated).** When the board has PSRAM (`psram: 'opi'` or `'quad'` in `cuttlefish.config.ts`), the runtime allocates a full-screen RGB565 canvas framebuffer and renders the entire dirty-node pass into it, then pushes once via a single 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). Setting `psram` makes the framework emit the PSRAM-enabling Kconfig and define `BOARD_HAS_PSRAM`, which activates the runtime's PSRAM canvas paths automatically.
1093
+
1094
+ ### Diagnostics (warnings)
1095
+
1096
+ Unknown HTML tags and unknown CSS properties are **reported as warnings**, not
1097
+ silently dropped. They appear in the build output (yellow, to stderr) and do
1098
+ not abort the build:
1099
+
1100
+ ```
1101
+ Unknown CSS property "bogus-prop" ignored.
1102
+ Unknown HTML tag <marquee> ignored.
1103
+ Unsupported @media (orientation: portrait) has an unsupported condition — rule ignored.
1104
+ ```
1105
+
1106
+ This surfaces typos and unsupported features early instead of leaving styles
1107
+ mysteriously unapplied.
1108
+
1109
+ ## Display profiles
1110
+
1111
+ 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.
1112
+
1113
+ ### Config reference
1114
+
1115
+ ```typescript
1116
+ // cuttlefish.config.ts
1117
+ display: {
1118
+ // Either reference a built-in profile by name:
1119
+ profile: 'ili9341-spi',
1120
+
1121
+ // Or inline everything:
1122
+ // driver: 'ili9341',
1123
+ // width: 320, height: 240,
1124
+ // colorFormat: 'rgb565',
1125
+ // rotation: 1,
1126
+
1127
+ // Wiring (always project-specific)
1128
+ cs: 5,
1129
+ dc: 21,
1130
+ rst: 22,
1131
+ backlight: 17, // optional pin number for backlight
1132
+
1133
+ // Experimental and opt-in: use only after verifying the controller's
1134
+ // GET_SCANLINE (0x45) readback and wiring SDO/MISO in spiPins. Some ST7796S
1135
+ // modules stop scanning when this command is read, so the default is off.
1136
+ // scanlineSync: true,
1137
+ // spiPins: { mosi: 11, sck: 12, miso: 13 },
1138
+
1139
+ // Antialiasing (optional)
1140
+ antialias: true, // smooths shapes and text; text can opt out with font-smoothing:none
1141
+
1142
+ // Theming (optional — compile-time)
1143
+ themeCss: './src/hello.dark.css', // override the sibling .ui.css file
1144
+ themeClass: 'dark', // select a class-scoped variable block (.dark { ... })
1145
+
1146
+ // Touch (optional)
1147
+ touch: {
1148
+ library: 'XPT2046_Touchscreen',
1149
+ cs: 14, // touch controller CS pin
1150
+ irq: 2, // optional interrupt pin
1151
+ calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
1152
+ minPressure: 10,
1153
+ },
1154
+ }
1155
+ ```
1156
+
1157
+ ### Profile fields
1158
+
1159
+ | Field | Type | Description |
1160
+ |---|---|---|
1161
+ | `profile` | string | Built-in profile name (e.g. `"ili9341-spi"`) |
1162
+ | `driver` | string | Display driver id (e.g. `"ili9341"`) |
1163
+ | `width` | number | Display width in pixels (after rotation) |
1164
+ | `height` | number | Display height in pixels (after rotation) |
1165
+ | `colorFormat` | `"rgb565"` \| `"mono"` | Color depth |
1166
+ | `rotation` | number | 0=portrait, 1=landscape, 2-3=inverted |
1167
+ | `backlight` | number | Backlight pin (optional) |
1168
+ | `cs` / `dc` / `rst` | number | Display wiring pins |
1169
+
1170
+ ### Built-in profiles
1171
+
1172
+ | Name | Display | Dimensions | Color | Touch |
1173
+ |---|---|---|---|---|
1174
+ | `ili9341-spi` | ILI9341 (SPI) | 320×240 | RGB565 | Add via `touch` config |
1175
+ | `st7796-spi` | ST7796S (SPI) | 480×320 | RGB565 | Add via `touch` config |
1176
+ | `ssd1309-i2c` | SSD1309 OLED (I2C) | 128×64 | Mono | None |
1177
+
1178
+ ### Adding a new display
1179
+
1180
+ 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).
1181
+
1182
+ #### 1. Register a display adapter
1183
+
1184
+ 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:
1185
+
1186
+ ```typescript
1187
+ // my-project/display-adapters.ts
1188
+ import { registerDisplayAdapter } from '@typecad/cuttlefish/api/shared/display-adapter';
1189
+
1190
+ registerDisplayAdapter('ssd1306', (display) => {
1191
+ return {
1192
+ includes: `#include <Adafruit_GFX.h>\n#include <Adafruit_SSD1306.h>\n#include <Wire.h>`,
1193
+ declaration: `Adafruit_SSD1306 __tc_display(128, 64, &Wire, -1);`,
1194
+ functions: [
1195
+ 'static int16_t __addrX = 0, __addrY = 0, __addrW = 0, __addrH = 0;',
1196
+ 'static uint32_t __addrCursor = 0;',
1197
+ 'static inline void display_init() {',
1198
+ ' __tc_display.begin(SSD1306_SWITCHCAPVCC, 0x3C);',
1199
+ ' __tc_display.clearDisplay();',
1200
+ ' __tc_display.display();',
1201
+ '}',
1202
+ 'static inline void display_fillScreen(uint16_t color) {',
1203
+ ' __tc_display.fillScreen(color ? 1 : 0);',
1204
+ '}',
1205
+ 'static inline void display_startWrite() { }',
1206
+ 'static inline void display_endWrite() { }',
1207
+ 'static inline void display_setAddrWindow(int16_t x, int16_t y, int16_t w, int16_t h) {',
1208
+ ' __addrX = x; __addrY = y; __addrW = w; __addrH = h; __addrCursor = 0;',
1209
+ '}',
1210
+ 'static inline void display_writePixels(uint16_t* pixels, uint32_t count) {',
1211
+ ' if (__addrW <= 0 || __addrH <= 0) return;',
1212
+ ' uint32_t total = (uint32_t)__addrW * (uint32_t)__addrH;',
1213
+ ' for (uint32_t i = 0; i < count; i++) {',
1214
+ ' if (__addrCursor >= total) break;',
1215
+ ' uint32_t pos = __addrCursor++;',
1216
+ ' __tc_display.drawPixel(__addrX + (pos % __addrW), __addrY + (pos / __addrW), pixels[i] ? 1 : 0);',
1217
+ ' }',
1218
+ '}',
1219
+ 'static inline void display_partial_refresh(int16_t x, int16_t y, int16_t w, int16_t h) {',
1220
+ ' (void)x; (void)y; (void)w; (void)h;',
1221
+ ' __tc_display.display();',
1222
+ '}',
1223
+ ].join('\\n'),
1224
+ };
1225
+ });
1226
+ ```
1227
+
1228
+ The runtime calls these core display functions:
1229
+
1230
+ | Function | Purpose |
1231
+ |----------|---------|
1232
+ | `display_init()` | Initialize the display (begin, rotation, clear) |
1233
+ | `display_fillScreen(color)` | Fill the entire screen with a color |
1234
+ | `display_startWrite()` | Begin an SPI transaction (no-op for I2C) |
1235
+ | `display_endWrite()` | End a transaction (often a no-op for page-buffered I2C) |
1236
+ | `display_setAddrWindow(x, y, w, h)` | Set the active write region |
1237
+ | `display_writePixels(pixels, count)` | Write pixels into the active region |
1238
+ | `display_partial_refresh(x, y, w, h)` | Publish the dirty region on deferred displays |
1239
+
1240
+ For monochrome displays, the adapter wraps each color argument with a conversion function. For deferred displays, the current runtime publishes from `display_partial_refresh()`.
1241
+
1242
+ #### 2. Create a display profile
1243
+
1244
+ ```typescript
1245
+ // displays/my-display.ts
1246
+ import type { DisplayProfile } from '@typecad/cuttlefish/api/shared';
1247
+
1248
+ export const MY_DISPLAY: DisplayProfile = {
1249
+ driver: 'ssd1306', // must match the adapter name
1250
+ width: 128,
1251
+ height: 64,
1252
+ colorFormat: 'mono', // 'rgb565' or 'mono'
1253
+ rotation: 0,
1254
+ };
1255
+ ```
1256
+
1257
+ #### 3. Reference it in config
1258
+
1259
+ ```typescript
1260
+ display: {
1261
+ driver: 'ssd1306',
1262
+ width: 128, height: 64,
1263
+ colorFormat: 'mono',
1264
+ rotation: 0,
1265
+ }
1266
+ ```
1267
+
1268
+ #### 4. Import the adapter module before building
1269
+
1270
+ Make sure your adapter module is imported (side-effect import) so the registration runs:
1271
+
1272
+ ```typescript
1273
+ // cuttlefish.config.ts or main.ts
1274
+ import './display-adapters'; // registers the 'ssd1306' adapter
1275
+ ```
1276
+
1277
+ #### Built-in adapters
1278
+
1279
+ | Driver | Display | Color | Notes |
1280
+ |--------|---------|-------|-------|
1281
+ | `ili9341` | ILI9341 (320×240) | RGB565 | Default, hardware SPI |
1282
+
1283
+ To add more built-in adapters, contribute a file to `packages/cuttlefish/src/api/shared/display-adapters/` (the SDL simulator adapter lives there) or to the Zephyr framework's display adapter in `packages/framework-zephyr/src/display/`, and register it.
1284
+
1285
+ ## Touch input
1286
+
1287
+ 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.
1288
+
1289
+ ### Built-in touch libraries
1290
+
1291
+ | Library | Controllers | Interface | Config |
1292
+ |---|---|---|---|
1293
+ | `XPT2046_Touchscreen` | XPT2046 (common ILI9341 shields) | SPI (shared with display) | `{ library, cs, irq? }` |
1294
+ | `Adafruit_TouchScreen` | Resistive 4-wire | Analog (no SPI) | `{ library, analogPins: { xp, yp, xm, ym, rx } }` |
1295
+ | `Adafruit_STMPE610` | STMPE610 (capacitive) | SPI or I2C | `{ library, cs }` |
1296
+
1297
+ ### Config examples
1298
+
1299
+ **XPT2046 (most common with ILI9341 TFT shields):**
1300
+
1301
+ ```typescript
1302
+ display: {
1303
+ profile: 'ili9341-spi',
1304
+ cs: 5, dc: 21, rst: 22,
1305
+ touch: {
1306
+ library: 'XPT2046_Touchscreen',
1307
+ cs: 14, // touch CS pin (separate from display CS)
1308
+ irq: 2, // optional
1309
+ calibration: { xMin: 375, xMax: 3950, yMin: 200, yMax: 3750 },
1310
+ minPressure: 10,
1311
+ },
1312
+ }
1313
+ ```
1314
+
1315
+ **Adafruit resistive 4-wire:**
1316
+
1317
+ ```typescript
1318
+ touch: {
1319
+ library: 'Adafruit_TouchScreen',
1320
+ analogPins: { xp: 'A3', yp: 'A2', xm: 8, ym: 9, rx: 300 },
1321
+ calibration: { xMin: 100, xMax: 900, yMin: 100, yMax: 900 },
1322
+ minPressure: 10,
1323
+ }
1324
+ ```
1325
+
1326
+ ### Calibration
1327
+
1328
+ Calibration maps the touch controller's raw ADC values to display pixel coordinates. To calibrate your panel:
1329
+
1330
+ 1. Add `Serial.printf("raw=(%d,%d,%d)\n", p.x, p.y, p.z)` to the touch poll
1331
+ 2. Touch the four corners of the screen and note the raw values
1332
+ 3. Set `xMin`/`xMax` from the left/right edges, `yMin`/`yMax` from the top/bottom
1333
+
1334
+ The transpiler handles rotation (axis swap + inversion) automatically based on the `rotation` field in the display profile.
1335
+
1336
+ ### Touch events (onClick, onHold, onRelease)
1337
+
1338
+ ```typescript
1339
+ // Short tap (finger down + up within 600ms)
1340
+ screen.btn.onClick(() => {
1341
+ console.log("tapped");
1342
+ screen.counter.value = screen.counter.value + 1;
1343
+ });
1344
+
1345
+ // Long press (finger held ≥600ms)
1346
+ screen.btn.onHold(() => {
1347
+ console.log("held");
1348
+ });
1349
+
1350
+ // Finger lift (always fires after click or hold)
1351
+ screen.btn.onRelease(() => {
1352
+ console.log("released");
1353
+ });
1354
+ ```
1355
+
1356
+ The touch system implements a state machine:
1357
+ - **50ms debounce** prevents rapid re-triggering
1358
+ - **Click** — touch down + up within 600ms
1359
+ - **Hold** — touch held ≥600ms (fires once)
1360
+ - **Release** — finger lifts (clears `.value` to 0)
1361
+ - **Visual feedback** — `.value` set to 1 on touch down, 0 on release
1362
+
1363
+ Hit-testing walks nodes topmost-first and skips containers without click handlers.
1364
+
1365
+ ### Awaitable tap notifications (`ui.onTap`)
1366
+
1367
+ `ui.onTap()` is an **awaitable** tap signal — use it inside an `async` function
1368
+ to suspend until the next tap. It's the building block for display-sleep /
1369
+ screensaver behavior and custom flow control ("tap to continue"):
1370
+
1371
+ ```typescript
1372
+ // Display-sleep: wake on ANY touch, dim again after 10s of inactivity.
1373
+ async function screensaver() {
1374
+ while (true) {
1375
+ backlightOff();
1376
+ await ui.onTap(); // resume on the next tap, anywhere on the screen
1377
+ backlightOn();
1378
+ await Time.sleep(10000); // keep the display awake for 10 seconds
1379
+ }
1380
+ }
1381
+ ```
1382
+
1383
+ With no argument it resumes on the next tap **anywhere** — including empty
1384
+ space, which is what makes "wake on any touch" work even when the finger lands
1385
+ on no element. Pass an element to resume only when that element is tapped:
1386
+
1387
+ ```typescript
1388
+ // Tap-to-continue wizard: wait for the Start button specifically.
1389
+ async function setupWizard() {
1390
+ await ui.onTap(screen.start);
1391
+ beginSetup();
1392
+ }
1393
+ ```
1394
+
1395
+ A tap fires **both** the tapped element's `onClick` handler **and** resumes any
1396
+ `await ui.onTap()` awaiter they don't compete. `ui.onTap()` is a resume signal,
1397
+ not a value: there is nothing to read from it (it returns `Promise<void>`).
1398
+
1399
+ | Call | Resumes on |
1400
+ |---|---|
1401
+ | `await ui.onTap()` | the next tap anywhere (including empty space) |
1402
+ | `await ui.onTap(screen.elem)` | the next tap on that specific element |
1403
+
1404
+ Under the hood this lowers to a cooperative state-machine state that polls a
1405
+ tap counter incremented by the touch driver each frame — no ISRs, natural
1406
+ debounce from the ~16ms tick, same model as `await Time.sleep()`.
1407
+
1408
+ ### Custom touch adapters
1409
+
1410
+ For libraries not in the built-in list, write a TypeScript adapter:
1411
+
1412
+ ```typescript
1413
+ // my-touch-adapter.ts
1414
+ import { SomeTouchLib } from '../lib/SomeTouchLib/SomeTouchLib';
1415
+
1416
+ const ts = new SomeTouchLib(14, 2);
1417
+ ts.begin();
1418
+
1419
+ export const touch = {
1420
+ isTouched: () => ts.touched(),
1421
+ read: () => {
1422
+ const p = ts.getPoint();
1423
+ return { x: p.x, y: p.y, z: p.z };
1424
+ },
1425
+ };
1426
+ ```
1427
+
1428
+ Reference it in config:
1429
+
1430
+ ```typescript
1431
+ touch: {
1432
+ adapter: './my-touch-adapter',
1433
+ calibration: { xMin: 100, xMax: 4000, yMin: 100, yMax: 4000 },
1434
+ minPressure: 10,
1435
+ }
1436
+ ```
1437
+
1438
+ The adapter only provides raw `{x, y, z}` — the transpiler handles calibration, rotation, and coordinate mapping.
1439
+
1440
+ ## GPIO input (buttons without touch)
1441
+
1442
+ For physical buttons on GPIO pins (no touchscreen required):
1443
+
1444
+ ```typescript
1445
+ // Watch a pin for falling edges runs in the frame loop
1446
+ ui.watchPin(4, () => {
1447
+ screen.counter.value = screen.counter.value + 1;
1448
+ });
1449
+
1450
+ // Toggle an element's .value on pin press
1451
+ screen.ledBox.onToggle(5);
1452
+
1453
+ // Cycle through options
1454
+ screen.modeValue.onChange(15, 3); // 3 options: 0→1→2→0
1455
+ ```
1456
+
1457
+ Natural debounce from the ~16ms frame rateno ISR, no `volatile`.