---
name: eezstudio
description: Help the user build EEZ Studio LVGL projects by editing the `.eez-project` JSON directly via Python scripts in the project's `tmp/` directory. The agent makes the changes; the user opens EEZ Studio to **visually validate** them before flashing, and retains the option to make additional changes themselves. The skill's central concern is canvas-device divergence — the bug where deployed firmware shows graphical elements that don't appear in EEZ Studio's canvas. Two mechanisms cause it: (1) C code in `main/*.c` calling `lv_obj_set_pos` / `_size` / `_align` etc. on `objects.<widget>` (Mode A), and (2) JSON shapes the C generator accepts but EEZ's canvas renderer silently drops (Mode B). Every edit runs the verifier gates that catch both. Use this skill for any task involving screens, widgets, styles, fonts, themes, or actions in an EEZ Studio LVGL project. Includes Trap reference, schema crib sheet (with pointers into the EEZ Studio source at github.com/eez-open/studio), and C-side wiring conventions for `actions.c` / `vars.c` / `main/main.c`.
---

# EEZ Studio (programmatic `.eez-project` authoring)

EEZ Studio is a low-code GUI designer for LVGL (and EEZ-Flow). The project file is `<name>.eez-project` — a single JSON file that EEZ Studio reads on open and writes on save. C output (`screens.c`, `screens.h`, `actions.h`, `vars.h`, `ui.c`, `ui.h`, `styles.c`, `fonts.c`, `images.c`) is generated **only by EEZ Studio's export step** — never hand-write those files; they get overwritten.

The clean separation is:
- **`.eez-project`** — source of truth for the GUI. Author this.
- **`main/ui/*.c|*.h`** — auto-generated by EEZ Studio. Treat as read-only.
- **`main/main.c`, `main/actions.c`, `main/vars.c`** — your C code that wires actions and variables to firmware.

> **Paths in this skill are placeholders, not literal paths.**
>
> | Variable | What it points at |
> |---|---|
> | `$PROJECT_ROOT` | root of the product/asset repository |
>
> Resolve each from the environment; if unset, ask the user and offer to save
> the answer to a gitignored local config file. Never hardcode a
> machine-specific path into a tracked file.

## When to use

- The user asks to add / change screens, widgets, styles, fonts, themes, or actions in an EEZ Studio LVGL project.
- The user has an empty / silently-failing `.eez-project` file and you need to figure out why.
- You're scaffolding an EEZ Studio project for an ESP32 / LVGL firmware target.
- The user wants tab-style navigation across many screens.

## When NOT to use

- The user only wants to edit firmware C code (not the GUI definition) — work in `main/` directly.
- The project uses raw LVGL without EEZ Studio — author `lv_*` calls in C.
- The user wants you to edit `main/ui/screens.c` etc. — refuse and explain that EEZ Studio overwrites it.

## The non-negotiable rule: canvas must match device

**What runs on the device must match what EEZ Studio's canvas renders.** This is THE failure mode the user has been bitten by repeatedly, and it's the only thing the rest of this skill exists to prevent. When an agent makes a GUI change and the user opens EEZ Studio, the canvas must show exactly what's going to render on the hardware. If the deployed firmware has a widget the canvas doesn't show, the agent broke the rule.

The user has stated this directly, multiple times:

> "I don't care if you call it a patch, a script, an EEZ Studio change — that terminology means nothing to me. The question is when an agent makes a change and I deploy it to hardware, then open EEZ Studio do they match."

Two distinct mechanisms produce canvas-device divergence — both are banned:

**Mode A — C code overrides on EEZ-authored widgets.** Calling `lv_obj_set_pos`, `lv_obj_set_size`, `lv_obj_set_style_text_font`, `lv_obj_set_align`, `lv_obj_align`, `lv_obj_move_foreground`, `lv_obj_center`, `lv_obj_set_style_min_width`, etc. on any `objects.<widget>` in `main/*.c`. EEZ Studio's canvas does NOT run your C — it renders from the JSON. The device runs both. Whatever your C overrides, the canvas can't see. Gate 5 (`~/.claude/skills/eezstudio/verify_no_canvas_divergence.py`) detects every variant of this and must pass before any work is reported done.

C's legitimate jobs on EEZ widgets are: state (`lv_obj_add_state(LV_STATE_CHECKED)`), flags (`lv_obj_add_flag(LV_OBJ_FLAG_HIDDEN)`), content (`lv_label_set_text`, `lv_slider_set_value`, `lv_textarea_set_password_mode`), events (`lv_obj_add_event_cb`), and screen loads (`lv_scr_load`). Anything else — geometry, alignment, fonts, sizes — belongs in the JSON.

**Mode B — JSON shapes EEZ Studio's canvas silently drops.** The C generator that produces `main/ui/screens.c` is more forgiving than EEZ Studio's own canvas renderer. A widget that's missing a required field, uses an unknown field, or has a malformed nested object will compile through to the device and render there — but EEZ Studio's loader either silently drops the widget, omits it from the Pages list, or refuses to draw it on the canvas. The user opens EEZ Studio, doesn't see the widget that's running on the device, and asks why they don't match.

The hardest version: `LVGLScreenWidget` (page root) and `LVGLUserWidgetWidget` (user-widget instance on a page) have strict schemas. Adding `useStyle` to a LVGLScreenWidget makes the page disappear from the Pages list. Omitting `userPropertyValues` from a LVGLUserWidgetWidget makes EEZ Studio refuse to load it. Both fail silently. See "CRITICAL — LVGLScreenWidget shape" and "CRITICAL — LVGLUserWidgetWidget shape" below for the exact canonical key sets.

**When the user says "I opened EEZ Studio and I don't see your change", that's the bug.** Don't argue. Don't paper over with another C override (Mode A). Don't try a different JSON shape and hope (Mode B). Read what you wrote against the canonical shape in this skill, fix the JSON to the canonical shape, re-run the gates, and ask the user to reload EEZ Studio.

**Colors in styles must reference named tokens from the project palette. Hex values in styles are forbidden — no exceptions.** This is the rule the user has restated multiple times. Earlier versions of this skill said "hex requires user approval"; that was wrong — approval is not the bar, **named tokens are the bar**. If the palette doesn't carry the color the agent wants, the agent does NOT ask permission to drop a hex value. The agent asks the user to add a named color to the palette, then references that name in the spec.

**The full rule:**

1. **No `bg_color: "#xxxxxx"`. No `text_color: "#xxxxxx"`. No `border_color: "#xxxxxx"`. No hex in any style key, ever.** Every color in every style — named project styles AND per-widget `localStyles` overrides — references a token from the project's `colors[]` array.

2. **If the existing palette doesn't have the color you need, propose a new named token to the user.** The prompt to the user must include:
   - The token name you propose (e.g. `BackgroundOverlay`, `WizardAccent`, `DangerBg`).
   - Where in the GUI it would be used and why existing tokens don't fit (e.g. "`BackgroundPanel` is too opaque for a 70%-overlay; `BackgroundContent` doesn't have enough contrast against `PrimaryTextColor`").
   - A suggested hex value for the **Default (light) theme**.
   - A suggested hex value for the **Dark theme** — and any other themes the project carries. Tokens must have a value defined for every theme, in the same array index, or the project breaks. Theme-invariant tokens (`ForegroundWhite`, brand `AccentColor`) repeat the same hex; theme-variant tokens have different hex per theme.
   - Whether the token is **theme-invariant** (same hex in both themes — for brand colors, status colors that mean the same thing regardless of background, text-on-colored-buttons) or **theme-variant** (different hex per theme — for backgrounds, soft tints, primary text). This matters for Trap 9 (text-on-colored-button contrast) — get it wrong and the text becomes illegible in one theme.

3. **The user adds the token** via EEZ Studio's Colors panel (`+` button, set name, set hex for each theme), then confirms back. The agent then references the new token by name in the spec it produces. The agent does NOT add the color via JSON patch.

4. **Never** invoke this exception for a color that's almost-the-same as an existing token. If you find yourself proposing `BrandGreen2` because `AccentColor` is "close but a hair darker," use `AccentColor`. Token proliferation is itself a smell — every new token has to be justified per-theme and the project gets harder to re-skin.

Concrete phrasing when you need a new token:

> "I'm specifying a modal-overlay panel that needs to sit at 70% opacity over the page content. Existing tokens that don't fit: `BackgroundPanel` (opaque, no transparency intent), `BackgroundContent` (designed for full-opacity content). Proposing a new token:
>
>   Name: `BackgroundOverlay`
>   Used for: full-screen modal scrims (e.g. alarm overlay, confirm dialogs)
>   Theme-variant: yes (different hex per theme to maintain contrast)
>   Default theme hex: `#1a1a1a` at 70% alpha
>   Dark theme hex:    `#000000` at 70% alpha
>
> Open EEZ Studio → Colors panel → add the token with these per-theme values, then I'll reference `BackgroundOverlay` in the spec."

If the user says no or proposes a different name/hex, take that and move on. The user owns the palette.

**Agents make the edits to `.eez-project`. The user opens EEZ Studio to visually validate them.** This is the working pattern, stated by the user verbatim (2026-06-22):

> "It's not Agent produces a spec. The agent is supposed to make changes and the user uses EEZ Studio to validate those prior to flashing visually. The user retains the option to make other changes themselves but agents should be able to make changes."

The previous skill iteration said "agents don't write JSON, produce a spec the user executes." That was an over-correction. Days of working with this user proved it's "useless" — agents that just produce checklists block the user behind their own click-through. The right division of labor is: **agent edits the JSON, user verifies the canvas matches the intent, then flashes**. The visual reload-and-check IS the safety net — there's no need to outsource the authoring to the user as well.

Why this works (and the "produce a spec" approach didn't):

1. **The user is the visual verifier, not the typist.** EEZ Studio's canvas is the highest-fidelity check for whether the JSON renders correctly. The user opening the project + looking at the canvas is exactly the validation step the previous workflow misallocated. The agent's job is to make the JSON correct enough that the canvas matches; the user's job is to glance at the canvas and catch anything wrong.
2. **The agent has the schema knowledge concentrated; the user has the design intent.** Re-typing the schema by hand in EEZ Studio's UI surfaces all the agent's mistakes (wrong field names, wrong nesting) into the user's hands. Putting the agent in charge of the schema and the user in charge of "does this look right" lines up with where each has the knowledge.
3. **Reproducibility.** A Python script in `tmp/add_<feature>.py` documents exactly what was changed and is replayable. A spec-walkthrough is one-shot and the work isn't replayable without re-clicking.
4. **Canvas-device match is the only validation that matters.** Both the spec workflow and the agent-edits workflow converge on the same step: user opens EEZ Studio, looks at the canvas, confirms it matches intent before flashing. The agent-edits workflow gets to that point faster.

## How the agent makes the edits

A Python script in `<project>/tmp/`, named after what it adds (e.g. `add_brightness_slider.py`, `add_status_bar.py`, `move_dock_left.py`). The script:

1. **Backs up first.** `shutil.copyfile(PROJ, PROJ + ".bak." + ts)` before any mutation. EEZ Studio also writes `.bak`, `.bak2`, etc. on its own saves; the script's backup uses a timestamp suffix so it doesn't collide.
2. **Loads JSON, mutates a copy, writes back.** Use `json.load` / `json.dump(..., indent=2)` + trailing `\n` to match EEZ Studio's on-disk format.
3. **Is idempotent.** Check whether the new action/widget already exists before adding it again. Re-running the script after a partial failure should converge to the right state, not duplicate widgets.
4. **Mirrors a known-working widget shape.** Find an existing widget in the project that already renders correctly in the canvas; produce builder functions that emit the same field set. Do not invent shapes — the canonical shapes for each widget type are documented in this skill's "Schema crib sheet" and in `SCHEMA_REFERENCE.md`.
5. **Inserts via tree traversal.** `find_page(proj, "PageSetup")` then `find_node(page, "setup_body")` to locate the parent, then `parent["children"].append(build_new_widget())`. Don't rely on JSON file offsets — they shift between EEZ Studio saves.

Reference implementation: `tmp/patch_volume_slider.py` and `tmp/add_brightness_slider.py` in the Spotter project. Mirror their shape for any new widget-addition script.

## The gates the script must pass before reporting done

After running the script and writing the modified JSON back, run all five and roll back from the backup if any fail:

- **G1 — JSON parse:** `python3 -c "import json; json.load(open(PROJ))"` exits zero. Catches malformed writes.
- **G2 — Diff scope:** identifier additions / removals match what the script intended. Use a Python one-liner that builds the set of identifiers in the backup vs the new file and prints the diff. No widgets removed unintentionally.
- **G3 — Named-reference integrity:** `python3 ~/.claude/skills/eezstudio/verify_project.py <project>.eez-project` exits zero. No dangling `useStyle`, `text_font`, `text_color`, `action`, `userWidgetPageName`, `image` references.
- **G4 — Action set:** count of `actions[]` matches expected (existing + new). The script should print which actions it added.
- **G5 — No canvas-device divergence:** `python3 ~/.claude/skills/eezstudio/verify_no_canvas_divergence.py <project_root>` exits zero. No `lv_obj_set_pos`/`_size`/`_align`/etc. on `objects.<widget>` in any `main/*.c`. Critical because Mode-A overrides are the easy bug to introduce when wiring C after the JSON edit.

Gate failures roll back from the backup the script took at step 1. Don't try to "fix forward" a broken JSON — the backup is the rollback point.

## What the user does after the gates pass

1. **Close + reopen** the project in EEZ Studio (File → Close Project, then File → Open). EEZ Studio doesn't auto-reload a project file changed underneath it.
2. **Look at the canvas.** Navigate to the affected page. Confirm:
   - The new widget(s) render at the authored position and size.
   - Styles/colors/fonts resolve to the project's theme tokens (no LVGL-default blue, no missing-font fallback).
   - For runtime-overridden widgets (LVGLKeyboardWidget, LVGLListWidget, LVGLButtonMatrixWidget, LVGLDropdownWidget, LVGLRollerWidget, LVGLTabviewWidget): the widget renders at the authored coords, not LVGL's default. If it doesn't, the localStyles `align: TOP_LEFT` + `min_*` / `max_*` pinning is missing — see Trap 13.
3. **Ctrl+B to export.** Regenerates `main/ui/screens.c`, `screens.h`, `actions.h`, etc.
4. **Flash and verify.** The device should match what the canvas just showed. If the device shows something the canvas didn't, you've hit canvas-device divergence — re-check Mode A (grep for `lv_obj_set_pos` etc. on the divergent widget in `main/*.c`) and Mode B (the widget's JSON shape against the canonical shapes in this skill).

## When the agent should NOT make the edits itself

Two cases:

1. **The user explicitly says "I'll do this one in EEZ Studio myself, just tell me what to add."** Then produce a step-by-step spec instead. Some changes the user prefers to author by hand because they want the muscle-memory of working in EEZ Studio's GUI.
2. **The change requires palette additions.** Per the color rule below, new color tokens are added by the user via EEZ Studio's Colors panel — the agent proposes name + per-theme hex, the user adds, then the agent's edit script references the new token by name. Adding a color token via JSON edit is forbidden (the user owns the palette).

In every other case: the agent edits the JSON, runs the gates, hands off for visual validation.


**Never modify `main/ui/*.c|*.h`.** They are EEZ Studio's export output. Edit the source (generator script during build-out, EEZ Studio GUI or an approved one-shot patch script after handoff) and ask the user to re-export via EEZ Studio → Build (Ctrl+B).

**Always pause for the EEZ Studio export before attempting a firmware build that depends on `objects.<widget>`, `action_<name>`, or `get_var_<name>`.** EEZ Studio's export is a human step (open the project, hit Build/Ctrl+B). Skipping the pause sends you down a rabbit hole "fixing" missing UI symbols that only the export produces. See "Build gating — wait for the EEZ Studio export" below.

**When a project's CLAUDE.md says "agents must not edit the .eez-project" but the user asks you to add/move/change a widget — surface the conflict, name it in one sentence, and let the user resolve it.** This was a real failure mode: an agent received "add an image widget to the project" against a CLAUDE.md that said "agents must not edit the .eez-project", and the agent silently sat doing no work while the user wondered what was happening. The CLAUDE.md text is almost certainly outdated relative to this skill's current "agent edits, user validates" workflow — but the project owner's rule still trumps until you confirm. Concrete script:

> "The project CLAUDE.md still says agents shouldn't edit the .eez-project (this dates from an older version of the eezstudio skill that's since been corrected). The current skill has the agent edit the JSON and the user validate visually in EEZ Studio. Do you want me to (a) make the edit using the current workflow, or (b) treat the CLAUDE.md note as still in force and produce a checklist for you to apply in EEZ Studio?"

Don't lecture the user about the rule; just name the conflict and offer the choices. Doing nothing is the worst possible response — *interruption is part of conversation, not a directive to freeze*. If they pick (a), also offer to update the project CLAUDE.md to remove the stale note.

## Layout is math + verification, not authoring — three mandatory gates

Layout bugs are the most-frequent complaint about agent-authored EEZ projects, and the reason is structural: agents treat layout as authoring (write coordinates, ship) when it is actually math followed by verification (compute bounds, render, look, then ship). The traps below this section are useful reference, but warnings don't prevent bugs — gates do. **The following three gates are mandatory for every layout-touching change.** If you skip any of them you are guaranteed to ship the kind of bug the user has already pointed out three times. Don't.

### Gate 1 — Pixel-math worksheet before any `.eez-project` write

Before generating or patching any JSON that changes widget positions or sizes, produce a worksheet in your reply that lists, for every widget being added or moved:

- **Identifier**
- **Parent identifier** (or `<screen root>`)
- **Parent content area** — `(parent_w - 2*(pad + border), parent_h - 2*(pad + border))` after the parent style resolves. If `pad_all` and `border_width` are zero, parent_content = parent_w × parent_h.
- **Authored `(x, y, w, h)`**
- **Right edge** = x + w, must be ≤ parent_content_w
- **Bottom edge** = y + h, must be ≤ parent_content_h
- **Status** — ✓ in bounds, ⚠ within 8 px of edge (likely clipped by font measurement variance), or ✗ overflow

For a screen with N children, the worksheet is N rows. For nested hierarchies, recurse. The worksheet is the work-product — the JSON write is a transcription of an already-verified design. You cannot write JSON without it.

If the worksheet has any ✗ row, stop. Re-design the layout. If it has a ⚠ row, raise the buffer or move on with explicit acknowledgment that you accepted the risk.

The user doesn't need to read the worksheet — they need it to *exist*. Producing it forces the math to happen, which is the entire point. A four-line worksheet is fine. A one-line "looks right to me" is not.

Concrete example for a 800×480 screen with a textarea, two buttons, and a keyboard:

```
Screen 800×480 (pad=0, border=0 → content 800×480)
  rename_title         parent=<root>  (0, 12, 800, 28)    right=800 bot=40   ✓
  rename_subtitle      parent=<root>  (0, 44, 800, 20)    right=800 bot=64   ✓
  sensor_rename_input  parent=<root>  (40, 76, 720, 56)   right=760 bot=132  ✓
  rename_cancel_btn    parent=<root>  (40, 140, 240, 46)  right=280 bot=186  ✓
  rename_save_btn      parent=<root>  (520, 140, 240, 46) right=760 bot=186  ✓
  sensor_rename_kb     parent=<root>  (8, 200, 784, 270)  right=792 bot=470  ✓ (10 px to screen bottom)
```

If a widget you author has internal layout LVGL re-runs at runtime (keyboard, button matrix, list, dropdown), call that out in the worksheet — those need the C-side size pin from Trap 13 in addition to the JSON coords.

### Gate 2 — Pre-handoff render check (optional sanity, not a substitute for the user's canvas)

The user reloading EEZ Studio after the agent's edit is the authoritative visual validation. EEZ Studio's canvas renders the project's actual schema with the actual LVGL behavior; it catches everything PIL can't (style cascades, theme palette, runtime overrides, true font metrics with kerning).

The `render_pages.py` PIL preview lives at `~/.claude/skills/eezstudio/render_pages.py` and is useful in two specific cases:

1. **Pixel-math sanity** — for layouts with tight bounds (label widths within 8 px of widget edges, wizards with overlapping steps, anything where the math feels risky). Run it before declaring done so you catch overflow before the user does.
2. **Scaffolding** — when authoring a project from scratch and no canvas exists yet (rare).

In normal "user has a working project, agent adds X" flows, **skip the renderer** and rely on the user's EEZ Studio reload as the visual check. The renderer is approximate (it doesn't know LVGL's font shaping subtleties, theme palette, or runtime layout overrides) and re-introducing it as a mandatory gate burns time without catching anything the user wouldn't catch in 5 seconds of looking.

When you do use it:

```
cp ~/.claude/skills/eezstudio/render_pages.py <project>/tmp/render_pages.py
python3 <project>/tmp/render_pages.py <project>/GUI/<Project>.eez-project PageA PageB
```

Output goes to `<project>/tmp/preview/<PageName>.png` — Read each PNG with the Read tool. The renderer's stdout lists overflow / runtime-pin issues.

Things to look for in the PNG:
- Widgets overlapping each other
- Text clipped at widget edges
- Widgets extending past their parent
- For keyboards specifically: **all four key rows visible** at their authored height (or flagged RUNTIME-OVERRIDDEN with a note to confirm the style pin in JSON)
- For wizards / multi-step pages: every step actually visible (not hidden behind the keyboard)

If any of those fail, fix the JSON, re-render, re-read. Only then hand off to the user for canvas reload.

Historical record: skipping pixel-math has produced misaligned axle buttons (Spotter), keyboards rendering as a single row at the bottom (Spotter MQTT/rename screens — fixed by Trap 13's style pinning), labels clipped at right edges (FluidCNC pendant card), and overlapping wizard steps (Spotter WiFi). The renderer would have caught these. Use it when bounds are tight.

### Gate 4 — Project-wide named-reference verification before recommending styles, colors, fonts, or actions

When a spec says `useStyle: StyleButtonSucces`, the agent is making a claim about how the canvas will render. If `StyleButtonSucces` is empty, missing, or references a color that isn't in `colors[]`, the canvas falls through to LVGL's built-in defaults — usually a blue rounded button that's NEVER in the user's palette and NEVER what the spec intended. The agent has no idea what it just told the user to apply.

Same risk for any named reference: a style that references `bg_color: PhantomColor` (not in `colors[]`), a label that references `text_font: TweedyBold` (not in `fonts[]`), a button with `event → DoTheThing` (not declared in `actions[]`), a user-widget instance for `MyHeader` (not in `userWidgets[]`). In every case the project is silently underspecified and the canvas renders something the agent didn't authorize.

**Gate 4: run `~/.claude/skills/eezstudio/verify_project.py <project>.eez-project` before recommending any named reference in a spec.** Every issue it reports is an unknown — if you're about to recommend a style/color/font/action that the verifier flags, stop and resolve it first.

The verifier reports:

- Styles with `NO DEFINITION` or `EMPTY DEFINITION` — render as LVGL widget defaults (Trap 18). Never recommend these.
- Styles whose `MAIN/CHECKED/etc` blocks reference a color name not in `colors[]`, or a font name not in `fonts[]` (and not a built-in `MONTSERRAT_<n>`).
- Hex values in any style — forbidden by the color rule.
- Themes whose color arrays don't match `colors[]` length — token resolution off-by-one across the project.
- Widget `useStyle` pointing at a style that doesn't exist.
- Widget `localStyles` with the same color/font reference problems.
- Event handlers pointing at actions not declared in `actions[]`.
- `LVGLUserWidgetWidget` instances pointing at a `userWidgetPageName` not in `userWidgets[]`.
- `LVGLImageWidget` pointing at a bitmap not in `bitmaps[]`.

```
cp ~/.claude/skills/eezstudio/verify_project.py <project>/tmp/verify_project.py
python3 <project>/tmp/verify_project.py <project>/GUI/<Project>.eez-project
```

Exit code 0 = clean. Non-zero = at least one dangling/incomplete reference. Read every issue. Some will be debt that pre-dates the agent's work — those become spec items the agent surfaces to the user (e.g. "5 styles in the project are empty; agent recommends consolidating them or filling them in"). Some will be in references the agent is about to make — those must be resolved before the spec goes out.

**When the verifier reports issues in the user's pre-existing project, hand them to the user as findings — do not silently fix them.** The user owns the palette and the style library. The agent surfaces what's broken; the user decides whether to clean up now, defer, or accept the LVGL-default look on certain widgets.

### Gate 5 — No C-side geometry override on EEZ-exported widgets (THE rule that keeps slipping)

The user has restated this multiple times across multiple sessions and the skill keeps failing to enforce it. Past skill revisions handled it as text: "EEZ Studio is the single source of truth." That phrasing is correct but gets skimmed under cognitive load. **This gate replaces the aspiration with a mechanical halt pattern.**

**Halt pattern — treat exactly like `rm -rf /`:**

If you are about to write — or you encounter existing — C code that matches any of the following on an `objects.<widget>` symbol, **STOP and reconcile before continuing**. These calls *change the visible geometry of a widget that EEZ Studio also authors*. The result is that the canvas shows one thing and the device shows another. The skill calls this **canvas-device divergence** and it is a first-class failure mode.

```
lv_obj_set_pos        ─ position
lv_obj_set_x / set_y
lv_obj_align          ─ alignment
lv_obj_align_to
lv_obj_set_align
lv_obj_center
lv_obj_set_size       ─ size
lv_obj_set_width / set_height
lv_obj_set_content_width / set_content_height
lv_obj_move_foreground ─ z-order
lv_obj_move_background
lv_obj_move_to_index
lv_obj_swap
lv_obj_set_style_align               ─ style-API geometry overrides
lv_obj_set_style_min_width / max_width
lv_obj_set_style_min_height / max_height
lv_obj_set_style_width / height / x / y
lv_obj_set_style_translate_x / translate_y
lv_obj_set_style_transform_angle / transform_zoom
```

**The rule: every one of those, when applied to an `objects.<widget>` symbol, belongs in the `.eez-project`, not in C.** Either:

- Author the geometry on the widget itself (`left`/`top`/`width`/`height` fields), or
- Pin it via `localStyles.MAIN.DEFAULT` with `align: TOP_LEFT` + `min_width/max_width/min_height/max_height` for widgets whose internal LVGL layout overrides authored coordinates (keyboards, lists, button-matrices, dropdowns, rollers, tabviews — see Trap 13).

**What IS allowed from C on an `objects.<widget>` symbol** (the discriminator — these change *invisible* runtime properties or content):

- Content: `lv_label_set_text`, `lv_textarea_set_text`, `lv_textarea_set_placeholder_text`, `lv_textarea_set_password_mode`, `lv_textarea_get_text`, `lv_img_set_src`, `lv_keyboard_set_textarea`, `lv_keyboard_set_mode`, `lv_dropdown_set_options`, `lv_dropdown_set_selected`, `lv_slider_set_value`, `lv_bar_set_value`, `lv_arc_set_value`, etc.
- State (`LV_STATE_*`): `lv_obj_add_state`, `lv_obj_clear_state`, `lv_obj_has_state`.
- Flags (`LV_OBJ_FLAG_*`): `lv_obj_add_flag`, `lv_obj_clear_flag`, `lv_obj_has_flag`.
- Events: `lv_obj_add_event_cb`, `lv_obj_remove_event_cb`.
- Hit area (invisible — doesn't affect render): `lv_obj_set_ext_click_area`.
- Programmatic scroll: `lv_obj_scroll_to`, `lv_obj_scroll_by`, etc.
- Screen load: `lv_scr_load`, `lv_disp_load_scr`.

**The mechanical gate**: `~/.claude/skills/eezstudio/verify_no_canvas_divergence.py <project_root>`. It greps every `.c`/`.cpp` file under `main/` (excluding `main/ui/` — the EEZ export is by definition allowed to write geometry, because it's reconstructing the `.eez-project`) for the forbidden patterns above paired with `objects.`. Exit 0 = clean. Non-zero = at least one divergence — every entry must be resolved before declaring the work done.

Copy it into the project on first use:

```
cp ~/.claude/skills/eezstudio/verify_no_canvas_divergence.py <project>/tmp/verify_no_canvas_divergence.py
python3 <project>/tmp/verify_no_canvas_divergence.py <project>
```

**Run this gate**:

1. Before reporting any C work as complete.
2. Before each `idf.py build` cycle when you've touched a `.c` file that references `objects.`.
3. When inheriting a project — Spotter, Fireside, FluidCNC pendant — to find pre-existing divergence.

**When the gate reports an issue, the resolution path is always one of two:**

1. **Delete the C call and move the geometry to the JSON.** This is the default. For widgets that LVGL overrides at runtime (keyboards etc.), pin via `localStyles` (see Trap 13).
2. **Delete the C call entirely** because the JSON already authors the correct geometry. (This is exactly what `fix_keyboard_alignment` became in Spotter once the keyboards were style-pinned: removed without replacement.)

There is no third option. There is no "but this case is special" exception. If a previous skill revision documented a C-side fix and a later revision added a JSON-side fix that supersedes it, the C-side fix becomes immediately wrong — delete it. The user's repeated complaint about canvas drift is exactly this: half-fixes accumulating, with no mechanical sweep that retires the stale ones.

The audit was missing from previous skill versions. It's here now. Run it.

### Gate 3 — Helper-function audit before reuse

When you find a helper already in the project (`fix_keyboard_alignment`, `vcenter`, `measure_text`, `paint_row`, `disable_clickable`), **open it and verify it implements the full fix the skill prescribes**. Do not assume an existing helper is canonical.

The motivating case: Spotter has `fix_keyboard_alignment(kb, x, y)` — two args, no size. The skill's Trap 13 fix is `fix_keyboard(kb, x, y, w, h)` — four args, with size. The two-arg form happens to work for keyboards on otherwise-empty screens because LVGL's default fill-parent sizing coincidentally produces the right result. On a screen with siblings, the same call collapses the keyboard to one visible row. An agent calling the existing 2-arg helper "because the project already has it" reproduces the bug across every new keyboard.

The rule: **before calling an existing helper, read its body. If it's incomplete relative to the skill's documented fix, extend it in place — and update every existing call site to pass the new arguments**. Do not add a sibling helper that papers over the incomplete one. Do not silently call the incomplete one and hope. The user will spot the same bug in a screen you didn't even touch.

Same rule for measurement helpers: if `measure_text` exists but ignores `letter_space`, fix it. If `vcenter` rounds the wrong way, fix it. The helper is the implementation of the rule — if it's wrong, the rule isn't enforced.

## How to write an edit script

The agent's primary output for any GUI change is a Python script in the project's `tmp/` directory that opens the `.eez-project`, mutates it idempotently, and writes back. The user runs it (or the agent runs it inline with `python3 tmp/<name>.py`), then reloads EEZ Studio to validate visually.

### Script structure

```python
#!/usr/bin/env python3
"""One-line description: what this edit adds/moves/renames."""

import json, hashlib, shutil, sys, time

PROJ = "/abs/path/to/<project>.eez-project"

_seq = [0]
def oid(tag):
    _seq[0] += 1
    h = hashlib.md5(f"<feature>:{tag}:{_seq[0]}".encode()).hexdigest()
    return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"

def build_<new_widget>():
    return { /* full widget dict mirroring the working sibling */ }

def find_page(proj, name):
    for p in proj["userPages"]:
        if p.get("name") == name: return p
    raise KeyError(name)

def find_node(root, ident):
    if isinstance(root, dict):
        if root.get("identifier") == ident: return root
        for v in root.values():
            r = find_node(v, ident)
            if r is not None: return r
    elif isinstance(root, list):
        for x in root:
            r = find_node(x, ident)
            if r is not None: return r
    return None

def main():
    # 1. Backup
    ts = time.strftime("%Y%m%d-%H%M%S")
    shutil.copyfile(PROJ, PROJ + ".bak." + ts)

    # 2. Load
    with open(PROJ) as f: proj = json.load(f)

    # 3. Idempotent mutations
    if not any(a.get("name") == "<NewAction>" for a in proj["actions"]):
        proj["actions"].append({
            "objID": oid("act"), "components": [], "connectionLines": [],
            "localVariables": [], "userProperties": [], "name": "<NewAction>"
        })

    page = find_page(proj, "<PageName>")
    parent = find_node(page, "<parent_identifier>")
    if find_node(parent, "<new_widget_identifier>") is None:
        parent["children"].append(build_<new_widget>())

    # 4. Write back, 2-space indent + trailing LF (EEZ Studio's convention)
    with open(PROJ, "w") as f:
        json.dump(proj, f, indent=2)
        f.write("\n")

if __name__ == "__main__":
    main()
```

### What goes in `build_<new_widget>()`

Find a sibling widget in the project that already renders correctly in the canvas. Read its JSON and produce a builder that emits the same fields, changing only what needs to change (identifier, text, position, event handler action name). Do NOT invent shapes — the canonical key sets for each widget type are documented below in "Schema crib sheet" and in `SCHEMA_REFERENCE.md`.

The Spotter project's `tmp/patch_volume_slider.py` and `tmp/add_brightness_slider.py` are the canonical references for adding a label+value+slider row. Mirror that shape for similar additions.

### Required gates after the script runs

```bash
# G1 — JSON parse
python3 -c "import json; d=json.load(open('GUI/X.eez-project')); print(f'OK, {len(d[\"userPages\"])} pages')"

# G3 — Named-reference integrity
python3 ~/.claude/skills/eezstudio/verify_project.py GUI/X.eez-project

# G5 — No C-side geometry overrides on objects.*
python3 ~/.claude/skills/eezstudio/verify_no_canvas_divergence.py <project_root>

# G2/G4 — Diff scope (what identifiers and actions actually changed)
python3 -c "
import json
def idents(p):
    s=set()
    def walk(n):
        if isinstance(n,dict):
            i=n.get('identifier')
            if i: s.add(i)
            for v in n.values(): walk(v)
        elif isinstance(n,list):
            for x in n: walk(x)
    d=json.load(open(p))
    walk(d)
    return s
before=idents('GUI/X.eez-project.bak.<ts>')
after=idents('GUI/X.eez-project')
print('ADDED:', sorted(after-before))
print('REMOVED:', sorted(before-after))
"
```

All four must come out clean. If any fail, restore from the `.bak.<ts>` file and figure out why before re-running.

### Handing off to the user

Tell the user, in this order:

1. What the script added (action names + new widget identifiers).
2. Which gates passed and what their output was.
3. "Close + reopen the project in EEZ Studio, navigate to `<page>`, confirm the new widgets render at `(x, y, w, h)`, then Ctrl+B to export."
4. After their Ctrl+B, you'll wire the C side (`action_<name>`, NVS persistence, paint helpers).

The user's reload-and-look IS the canvas-side validation. The gates above catch the JSON shapes EEZ Studio's canvas drops; the user's eyes catch anything subtler.

## When to produce a checklist instead of running the script

The agent runs the script by default. Two exceptions, both initiated by the user explicitly:

1. **The user says "I'll do this one in EEZ Studio myself, just tell me what to add."** Then produce a step-by-step checklist (parent identifier, widget type, position/size, identifier, useStyle, localStyles overrides — including the `align: TOP_LEFT` + `min_*` / `max_*` pinning for runtime-overridden widget types per Trap 13, event handlers, action name). The user clicks through it in EEZ Studio's GUI. This is the path some users prefer for tightly-designed single-widget changes where they want the muscle-memory.

2. **The user says "produce a spec for me to review first, don't change the file yet."** Produce the same checklist, but the user reviews the design before authorizing the agent to make the change.

In both cases, the checklist's content is the same — the difference is whether the agent or the user will be the one performing the clicks.

## Build gating — wait for the EEZ Studio export

When you regenerate the `.eez-project` and the user (or you) intends to run a firmware build, **pause and ask the user to run EEZ Studio → Build (Ctrl+B)** before attempting any `idf.py build` that depends on the generated symbols. EEZ Studio's export is a manual step and produces:

- `main/ui/screens.h` — `objects.<widget>` struct, `SCREEN_ID_<NAME>` enum, `create_screen_<name>()` functions
- `main/ui/actions.h` — `extern void action_<name>(lv_event_t *e);` declarations
- `main/ui/vars.h` — `extern <type> get_var_<name>()` / `set_var_<name>()` declarations
- `main/ui/fonts.h`, `styles.h`, `images.h`, `ui.h`/`ui.c`

If you skip the pause and run `idf.py build` against stale or absent `main/ui/`, you'll chase "undefined reference to `objects`" errors that have nothing to do with the C code you wrote. Worse, you may "fix" them by writing stubs that the next EEZ Studio export then conflicts with.

**Compile-safe scaffolding pattern.** Wrap any C source that references EEZ-exported symbols in `#if __has_include("ui/screens.h")` (or `actions.h` / `vars.h` as appropriate). The translation unit collapses to a placeholder until the user exports, so the project builds at every stage:

```c
#if __has_include("ui/actions.h")
#include "ui/actions.h"
#include "ui/screens.h"
/* … action handlers that reference objects.<widget> live here … */
#else
typedef int actions_placeholder;     /* keeps the TU non-empty */
#endif
```

Use the same guard in `main.c` around `ui_init()` and show a "run EEZ Studio Build" placeholder label on the screen when the export hasn't happened yet.

## Schema crib sheet (LVGL projects, projectVersion v3, lvglVersion 8.4.x)

**⭐ FIRST STOP for any "is this valid?" question: [`SCHEMA_REFERENCE.md`](./SCHEMA_REFERENCE.md) in this skill directory.**

That file is the canonical schema reference — every widget type, every flag, every state, every part, every style property, every event, every code-gen placeholder, every cross-version difference between LVGL 8.x / 9.x / 9.5.x — all distilled directly from the EEZ Studio source at `github.com/eez-open/studio` with source file + line citations on every claim. It was generated specifically to stop the "agent re-discovers the same edge case every session" cycle the user has called out repeatedly.

**Workflow:**
1. Have a schema question? → Open `SCHEMA_REFERENCE.md` first. Use its table of contents to jump to widget X / flag Y / property Z.
2. Citations in that doc point you at the exact source file + line if you need to drill in further.
3. The crib sheet below remains a quick narrative summary of the common cases — use it for orientation, but `SCHEMA_REFERENCE.md` is the authority.
4. If `SCHEMA_REFERENCE.md` is silent on something, drop to the source at `https://github.com/eez-open/studio` (or your local clone of it) directly. Don't guess.

**EEZ Studio is open source.** When in doubt about how the canvas renders a widget, what fields a widget type carries, or how a style property is applied — read `SCHEMA_REFERENCE.md` first, then read the source if needed. Never guess.

The most useful files when reasoning about widget behavior (these are the SCHEMA_REFERENCE.md's primary sources):

- **`packages/project-editor/lvgl/widgets/Base.tsx`** (around line 616) — every widget property definition, default value, and the full property list. If you're not sure whether a widget has property X, this file has the answer.
- **`packages/project-editor/lvgl/widgets/Screen.tsx`** — special rules for the root LVGLScreenWidget (the strict-schema trap documented below).
- **`packages/project-editor/lvgl/widgets/Keyboard.tsx`** (and `ButtonMatrix.tsx`, `List.tsx`, `Dropdown.tsx`, `Roller.tsx`, `Tabview.tsx`) — the runtime-overridden widget types. These call `lv_obj_set_*` internally during canvas render, which is why JSON-authored coords get ignored unless the style explicitly pins via `align` + `min_*` / `max_*`. The source tells you exactly which overrides happen and how to defeat them.
- **`packages/project-editor/lvgl/style.tsx`** and **`packages/project-editor/lvgl/style-definition.tsx`** — LVGL Style and StylesDefinition shape; how `useStyle` cascades to `localStyles`.
- **`packages/project-editor/lvgl/style-catalog.tsx`** — the canonical `lvglPropertiesMap` of every legal style property name. If a property name silently does nothing, this file shows whether it's a typo or a property EEZ Studio doesn't recognize.
- **`packages/project-editor/features/page/page.tsx`** — Page (a "screen") schema and migration hook.
- **`packages/project-editor/features/font/font.tsx`** — Font load hook (relevant when fonts silently don't embed).
- **`packages/project-editor/project/project.tsx`** (around line 1419) — project-level property registration; useful when you need to know what a top-level `.eez-project` field is for.

Two side references for shape checking when you're not sure your JSON describes what you intended:

- **Working production reference**: `$PROJECT_ROOT/Product/TrailCurrentFireside/GUI/TrailCurrentFireside.eez-project` — Fireside is the canonical TrailCurrent EEZ Studio project. Inspect it for the canonical shape of every widget type you need.
- **Upstream simple example**: `https://github.com/eez-open/eez-project-examples` → `examples/LVGL/Smart Home Low Res.eez-project` is a minimal working file.

The pattern: form a hypothesis about how a widget will behave → check Base.tsx / the specific widget's .tsx file → confirm against a working example → only then write the spec. Skipping the source-check step is what produced the keyboard bug (Trap 13) — every agent assumed `align/min/max` weren't needed in the JSON because the JSON-authored width/height "obviously" should suffice. Reading `Keyboard.tsx` makes clear it doesn't.

### Top-level shape (LVGL project)

```jsonc
{
  "themesVersion": 1,
  "objID": "<uuid>",
  "settings": { /* see below */ },
  "variables": { "objID": "...", "globalVariables": [...], "structures": [], "enums": [] },
  "actions": [ /* action declarations — function names only when flowSupport=false */ ],
  "userPages": [ /* each is a "screen" — one EEZ Studio page per logical screen */ ],
  "userWidgets": [ /* reusable widget components, instanced on pages */ ],
  "lvglStyles": { "objID": "...", "styles": [...], "defaultStyles": { /* widgetType -> styleName */ } },
  "lvglGroups": { "objID": "...", "groups": [] },
  "fonts": [ /* font definitions — sourced from TTF/OTF on disk */ ],
  "bitmaps": [],
  "colors": [ { "objID": "...", "name": "<token>" } ],
  "themes": [ { "objID": "...", "name": "Default", "colors": ["#hex","#hex",...] } ]
}
```

### settings.general (LVGL 8.4)

```jsonc
{
  "objID": "...",
  "projectVersion": "v3",
  "projectType": "lvgl",
  "lvglVersion": "8.4.0",
  "extensions": [], "imports": [],
  "flowSupport": false,             // <-- false unless the user explicitly wants EEZ-Flow
  "displayWidth": 1024,
  "displayHeight": 600,
  "displayBorderRadius": 0,
  "darkTheme": true,
  "colorFormat": "BGR",              // ESP-IDF + Waveshare panel default
  "resourceFiles": [],
  "hiddenWidgetLines": "dimmed",
  "dimmedLinesOpacity": "20",
  "embedFonts": true
}
```

### settings.build

`destinationFolder` is **relative to the .eez-project file**, e.g. `"../main/ui"`. Every file entry must have a `template` containing the `${eez-studio ...}` placeholders that EEZ Studio fills in. Copy templates from Fireside verbatim.

### Pages (userPages and userWidgets)

A page is a screen. Each page has exactly one root component: an `LVGLScreenWidget` for `userPages`, an `LVGLPanelWidget` for `userWidgets`.

```jsonc
{
  "objID": "...",
  "components": [ /* single LVGLScreenWidget (page) OR LVGLPanelWidget (user widget) */ ],
  "connectionLines": [],
  "localVariables": [],
  "userProperties": [],
  "name": "PageDashboard",            // unique
  "left": 0, "top": 0, "width": 1024, "height": 600,
  "createAtStart": true,
  "deleteOnScreenUnload": false,
  // userWidgets ONLY:
  "isUsedAsUserWidget": true
}
```

### CRITICAL — LVGLScreenWidget shape (this is the trap that silently breaks the project)

EEZ Studio silently drops a page if its root LVGLScreenWidget has unknown fields. The screen widget has a **stricter** schema than a regular widget. Use this canonical key set, no more, no fewer:

```jsonc
{
  "objID": "...",
  "type": "LVGLScreenWidget",
  "left": 0, "top": 0, "width": 1024, "height": 600,
  "customInputs": [], "customOutputs": [],
  "style": { "objID": "...", "useStyle": "default", "conditionalStyles": [], "childStyles": [] },
  "timeline": [], "eventHandlers": [],
  "leftUnit": "px", "topUnit": "px", "widthUnit": "px", "heightUnit": "px",
  "widgetFlags": "CLICKABLE|PRESS_LOCK|CLICK_FOCUSABLE|GESTURE_BUBBLE|SNAPPABLE|SCROLLABLE|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER",
  "hiddenFlagType": "literal",
  "clickableFlag": true,
  "clickableFlagType": "literal",
  "checkedStateType": "literal",
  "disabledStateType": "literal",
  "states": "",
  "localStyles": { "objID": "..." },
  "groupIndex": 0,
  "children": [ /* the actual page content */ ]
}
```

Note `groupIndex` IS valid on the screen widget — it's present on every screen in Fireside and other working projects. Earlier skill versions called it forbidden; that was wrong.

**Do NOT include on a LVGLScreenWidget**: `useStyle`, `identifier`, `group`, `scrollSnapX`, `scrollSnapY`, `flagScrollbarMode`, `flagScrollDirection`. Adding any of these makes EEZ Studio silently fail to load the page (no error dialog — it just shows nothing in the Pages list).

### CRITICAL — LVGLUserWidgetWidget instances (referencing a user widget on a page)

Also stricter than a normal widget. Required field: `userPropertyValues`. Do NOT include `useStyle`, `context`, `inputsProperties`, `outputsProperties`.

**Per-instance object naming.** When EEZ Studio exports a user widget that's instanced on multiple pages (e.g. a StatusBar on every pendant page), each child widget gets a per-instance entry in the `objects` struct, prefixed by `<page>_<userwidget_identifier>__`. There is **no single** `objects.<child>` — referencing one will fail to compile with `'objects_t' has no member named '<child>'`.

Example: a `status_wifi_icon` label inside the `StatusBar` user widget, instanced as `status_bar` on 8 pendant pages, surfaces in the export as:

```c
objects.page_dashboard_status_bar__status_wifi_icon
objects.page_jog_status_bar__status_wifi_icon
objects.page_run_status_bar__status_wifi_icon
/* … one per host page … */
```

Any C code that toggles state on a user-widget child has to walk every instance:

```c
lv_obj_t *const icons[] = {
    objects.page_dashboard_status_bar__status_wifi_icon,
    objects.page_jog_status_bar__status_wifi_icon,
    /* … */
};
for (size_t i = 0; i < sizeof(icons)/sizeof(*icons); i++) {
    if (icons[i]) lv_obj_clear_flag(icons[i], LV_OBJ_FLAG_HIDDEN);
}
```

If state must stay consistent across all instances (status indicators, mode toggles, brand colors), this fan-out is unavoidable. For one-off content that only needs to render in one place, prefer putting the widget on the page directly, not inside a user widget.

```jsonc
{
  "objID": "...",
  "type": "LVGLUserWidgetWidget",
  "left": 0, "top": 0, "width": 1024, "height": 46,
  "customInputs": [], "customOutputs": [],
  "style": { "objID": "...", "useStyle": "default", "conditionalStyles": [], "childStyles": [] },
  "timeline": [], "eventHandlers": [],
  "identifier": "PageDashboard_status_bar",
  "leftUnit": "px", "topUnit": "px", "widthUnit": "px", "heightUnit": "px",
  "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE",
  "hiddenFlagType": "literal",
  "clickableFlag": true,
  "clickableFlagType": "literal",
  "flagScrollbarMode": "",
  "flagScrollDirection": "",
  "scrollSnapX": "", "scrollSnapY": "",
  "checkedStateType": "literal",
  "disabledStateType": "literal",
  "states": "",
  "localStyles": { "objID": "..." },
  "group": "", "groupIndex": 0,
  "userWidgetPageName": "StatusBar",       // matches userWidgets[].name
  "userPropertyValues": { "objID": "...", "values": {} }
}
```

### Regular widgets (Panel, Button, Label, Bar, Arc, Slider, Switch, etc.)

These have the **superset** of fields and EEZ Studio is permissive about which exist. A safe baseline for any non-screen widget:

```jsonc
{
  "objID": "...",
  "type": "LVGLButtonWidget",                 // or LVGLLabelWidget, LVGLPanelWidget, etc.
  "left": 10, "top": 10, "width": 100, "height": 30,
  "customInputs": [], "customOutputs": [],
  "style": { "objID": "...", "useStyle": "default", "conditionalStyles": [], "childStyles": [] },
  "timeline": [],
  "eventHandlers": [
    { "objID": "...", "eventName": "CLICKED",
      "handlerType": "action", "action": "<ActionName>", "userData": 0 }
  ],
  "identifier": "<unique snake_case name>",
  "leftUnit": "px", "topUnit": "px", "widthUnit": "px", "heightUnit": "px",
  "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE",
  "hiddenFlagType": "literal",
  "clickableFlag": true,
  "clickableFlagType": "literal",
  "flagScrollbarMode": "",
  "flagScrollDirection": "",
  "scrollSnapX": "", "scrollSnapY": "",
  "checkedState": false,                       // button/switch/etc — selector widgets
  "checkedStateType": "literal",
  "disabledStateType": "literal",
  "states": "",
  "useStyle": "MyNamedStyle",                  // references lvglStyles.styles[].name, OR "default"
  "localStyles": { "objID": "...", "definition": { "MAIN": { "DEFAULT": { /* overrides */ }}}},
  "group": "", "groupIndex": 0,
  "children": []
}
```

Type-specific extras:
- `LVGLLabelWidget`: `text` (string), `textType: "literal"`, `longMode: "WRAP"|"DOT"|"SCROLL"`, `recolor: false`
- `LVGLBarWidget`: `min`, `max`, `mode: "NORMAL"|"SYMMETRICAL"|"RANGE"`, `value`, `valueType`, `valueStart`, `valueStartType`, `enableAnimation`
- `LVGLArcWidget`: `rangeMin`, `rangeMinType`, `rangeMax`, `rangeMaxType`, `value`, `valueType`, `bgStartAngle`, `bgEndAngle`, `mode`, `rotation`
- `LVGLSliderWidget`: `min`, `minType`, `max`, `maxType`, `mode`, `value`, `valueType`, `previewValue`, `valueLeft`, `valueLeftType`, `previewValueLeft`, `enableAnimation`
- `LVGLSwitchWidget`: (just base widget — width 44, height 25 is typical)
- `LVGLImageWidget`: `image` (references bitmaps[].name), `setPivot`, `pivotX`, `pivotY`, `zoom: 256`, `angle: 0`, `innerAlign: "CENTER"`
- `LVGLKeyboardWidget`: `mode: "TEXT_LOWER"|"TEXT_UPPER"|"SPECIAL"|"NUMBER"`
- `LVGLTextareaWidget`: `text`, `textType`, `placeholder`, `placeholderType`, `oneLineMode`, `passwordMode`, `acceptedCharacters`, `maxTextLength`
- `LVGLDropdownWidget`: `options` (newline-separated), `optionsType`, `selected`, `selectedType`, `direction: "bottom"`

### Styles (named, reusable)

```jsonc
{
  "objID": "...",
  "name": "Card",
  "forWidgetType": "LVGLPanelWidget",       // must match registered widget class name
  "childStyles": [],
  "definition": {
    "objID": "...",
    "definition": {
      "MAIN": {
        "DEFAULT": {
          "bg_color": "BgCard",              // ColorToken name OR "#xxxxxx"
          "bg_opa": 255,
          "border_color": "BorderColor",
          "border_width": 1,
          "border_opa": 255,
          "radius": 12,
          "pad_all": 14,
          "text_color": "TextPrimary",
          "text_font": "MONTSERRAT_12"       // UPPERCASE = LVGL built-in (lv_font_*); lowercase = custom (ui_font_*) — see Fonts section
        },
        "CHECKED": { /* state overrides */ },
        "PRESSED": { /* state overrides */ }
      }
    }
  }
}
```

Valid LVGL style property names are listed in EEZ Studio's `lvglPropertiesMap` (style-catalog.tsx). Unknown property names are silently ignored at render but generate Check warnings. Common ones: `bg_color`, `bg_opa`, `bg_grad_color`, `bg_grad_dir`, `border_color`, `border_width`, `border_opa`, `border_side` (`TOP|BOTTOM|LEFT|RIGHT`), `radius`, `pad_all|pad_top|pad_bottom|pad_left|pad_right`, `shadow_color|shadow_opa|shadow_width|shadow_spread`, `text_color`, `text_font`, `text_align` (`LEFT|CENTER|RIGHT|AUTO`), `text_letter_space`, `text_line_space`, `arc_color`, `arc_width`, `arc_opa`, `arc_rounded`.

States: `DEFAULT|PRESSED|FOCUSED|CHECKED|DISABLED|EDITED|HOVERED|FOCUS_KEY`.
Parts (per widget): `MAIN`, `INDICATOR`, `KNOB`, `ITEMS`, `SCROLLBAR`.

### `defaultStyles` map

Bind a default style per widget class (so every widget of that type uses it unless overridden):

```jsonc
"defaultStyles": {
  "LVGLArcWidget": "ArcDefault",
  "LVGLBarWidget": "BarDefault",
  "LVGLButtonWidget": "StyleButtonDefault",
  "LVGLLabelWidget": "LabelDefault",
  "LVGLPanelWidget": "StylePanelDefault",
  "LVGLScreenWidget": "StyleScreenDefault",
  // ...
}
```

### Themes and colors

Define color **tokens** once (colors[]), then bind hex values per theme. Style definitions reference tokens by name; theme switching automatically resolves them. Both themes must list the same number of colors in the same order as `colors[]`.

```jsonc
"colors": [
  { "objID": "...", "name": "AccentPrimary" },
  { "objID": "...", "name": "BgCard" }
],
"themes": [
  { "objID": "...", "name": "Default", "colors": ["#52a441", "#1a1a1a"] },
  { "objID": "...", "name": "Dark",    "colors": ["#52a441", "#1a1a1a"] }
]
```

For a dark-only design, make both themes identical so EEZ Studio's theme dropdown still works.

### Fonts

```jsonc
{
  "objID": "...",
  "name": "fa_22",                                      // referenced by text_font (lowercase = custom; see below)
  "renderingEngine": "LVGL",
  "source": {
    "objID": "...",
    "filePath": "../assets/fonts/fontawesome/Font Awesome 7 Free-Solid-900.otf",   // relative to .eez-project
    "size": 22
  },
  "embeddedFontFile": "",     // see "Embed fonts toggle is a trap" below
  "bpp": 4,
  "threshold": 128,
  "height": 24, "ascent": 17, "descent": 4,
  "glyphs": [],
  "lvglRanges": "0xF04B,0xF04C,0xF04D,...",             // ASCII range "0x20-0x7F" or explicit FA codepoints
  "lvglSymbols": "",
  "lvglGlyphs": { "encodings": [] }
}
```

For Font Awesome subsetting, list explicit codepoints in `lvglRanges` (comma-separated `0xFxxx`) — the icon glyphs you'll reference with `\\uF04B` etc. in label `text` fields.

#### Built-in vs custom fonts: the naming convention determines the C symbol

EEZ Studio decides whether `text_font: "X"` becomes `&lv_font_X` or `&ui_font_X` based on **case and presence in the `fonts[]` array**:

| `text_font` value | What EEZ Studio emits | What you must provide |
|---|---|---|
| `"MONTSERRAT_14"` (UPPERCASE, **not** in `fonts[]`) | `&lv_font_montserrat_14` | `CONFIG_LV_FONT_MONTSERRAT_14=y` in `sdkconfig` |
| `"fa_22"` / `"mono_30"` (lowercase, **is** in `fonts[]`) | `&ui_font_fa_22` | `main/ui/ui_font_fa_22.c` containing the converted glyph data |

The LVGL built-ins available as `MONTSERRAT_<size>` cover sizes 8–48. There is **no** built-in for Font Awesome, DejaVu Mono, etc. — those must be custom.

Pattern (matches Fireside): in the generator's `FONTS` list, register only the truly custom families (Font Awesome, monospace, custom display fonts). For every Montserrat size, reference it as `"MONTSERRAT_<size>"` in `text_font` and ensure the matching `CONFIG_LV_FONT_MONTSERRAT_<size>=y` is in `sdkconfig.defaults`.

**Edit `sdkconfig.defaults` only — never `sdkconfig`.** ESP-IDF regenerates `sdkconfig` on every `idf.py build` (and during `menuconfig`, `reconfigure`, `set-target`, `fullclean`). Any direct edit to `sdkconfig` is silently overwritten by the next build, so it's worse than useless — it gives the false impression the change is live for one cycle and then vanishes. Put the Kconfig change in `sdkconfig.defaults` and the next `idf.py build` picks it up automatically.

When gcc emits `'ui_font_montserrat_14' undeclared (first use in this function); did you mean 'lv_font_montserrat_14'?` — that hint is gcc telling you you've referenced a custom font that has the same name as a built-in. The fix is almost always to switch the `text_font` to uppercase (and drop the custom-font entry, and enable the built-in's sdkconfig).

#### `styles.c` must `#include "fonts.h"` — the default template doesn't

The EEZ Studio default `styles.c` template emits:
```c
#include "styles.h"
//${eez-studio LVGL_STYLES_DEF}
```
That's broken the moment your project uses any custom (`ui_font_*`) font: `styles.c` references `&ui_font_<name>` but the extern declaration only lives in `fonts.h`, which is **not** transitively included anywhere. Symptom: `'ui_font_<name>' undeclared (first use in this function)` even though `ui_font_<name>.c` exists in `main/ui/` and is in `SRCS`.

Fix: override the template in `settings.build.files[].template` for `styles.c` to match Fireside's working version:
```jsonc
{
  "fileName": "styles.c",
  "template": "#include \"styles.h\"\n#include \"images.h\"\n#include \"fonts.h\"\n\n//${eez-studio LVGL_STYLES_DEF}\n"
}
```
For an existing already-broken `styles.c`, patch the includes directly as a one-time fix so the build clears without round-tripping through EEZ Studio.

#### `lv_font_conv` is a required external tool

EEZ Studio's font conversion (and any direct generation) calls `lv_font_conv` — a Node.js CLI. If it's not on PATH, EEZ Studio silently produces no `.c` files. Install:
```bash
sudo npm install -g lv_font_conv
which lv_font_conv && lv_font_conv --version
```
After install, **fully quit and relaunch EEZ Studio** — it caches the "tool not found" result from launch and won't re-probe until restarted.

#### The "Embed fonts inside eez-project" toggle and the empty-`embeddedFontFile` failure

**Absolute rule: EEZ Studio owns every file under `main/ui/`, including every `ui_font_*.c` and `ui_image_*.c`. The agent never runs `lv_font_conv` and never writes to `main/ui/` — not to unstick a build, not "just for fonts", not because the project's own comments suggest it's ok.** The moment you write a `ui_font_*.c` yourself, EEZ Studio stops being the source of truth: the file drifts from the JSON, the next EEZ Studio export overwrites it inconsistently, and canvas-device divergence returns through the back door. This has burned a user's real project — repeatedly — and the user has stated this rule verbatim: **"EEZ Studio remains the SINGLE SOURCE OF TRUTH."**

`embedFonts: true` in `settings.general` (the **Embed fonts inside eez-project** toggle) auto-converts every font's source TTF/OTF into `embeddedFontFile` (raw base64 of the file bytes). EEZ Studio then decodes that blob on Ctrl+B and emits `main/ui/ui_font_<name>.c`. A healthy font entry in a working project has:

```jsonc
{
  "name": "fa24",
  "source": { "filePath": "../ASSETS/fa-solid-900.otf", "size": 24 },
  "embeddedFontFile": "AAEAAAAKAIAAAwAgT1MvMmFrYlMAAAEo…"   // ~500KB base64
}
```

**The failure mode**: when a project has `"embeddedFontFile": ""` on every font — because whatever generated the `.eez-project` (an older `gen_eez_project.py`, a stripped export, a copy that dropped the blob) never populated it — EEZ Studio has nothing to decode on Ctrl+B and silently emits no `ui_font_*.c`. The build then links against `undefined reference to ui_font_<name>`. The user opens the Fonts panel, edits and re-saves each entry, and the field STAYS empty because the UI's auto-embed only re-reads the source file when the user changes `source.filePath` — not when they just click through the existing entry.

**The correct fix — populate `embeddedFontFile` from the source file, do NOT write `ui_font_*.c` yourself.** The one-time repair is a patch script that reads each `source.filePath`, base64-encodes it, and stuffs the result into `embeddedFontFile`. Once populated, EEZ Studio owns every future regeneration exactly like a normally-authored project:

```python
import base64, json, os
proj_path = "GUI/<name>.eez-project"
gui_dir   = os.path.dirname(proj_path)
proj = json.load(open(proj_path))
for f in proj["fonts"]:
    if f.get("embeddedFontFile"):
        continue                                  # already healthy
    src = os.path.join(gui_dir, f["source"]["filePath"])
    f["embeddedFontFile"] = base64.b64encode(open(src, "rb").read()).decode("ascii")
json.dump(proj, open(proj_path, "w"), indent=2)
open(proj_path, "a").write("\n")
```

Symptoms that mean you're hitting this: `undefined reference to ui_font_<name>` at link, `ui_font_*.c` missing from `main/ui/` after Ctrl+B, `embeddedFontFile: ""` on every font in the `.eez-project`, "I touched every font in EEZ Studio's UI and Ctrl+B still doesn't emit the file."

**Never do these things — they look like fixes but each breaks the source-of-truth invariant:**

1. **Never run `lv_font_conv` yourself and write to `main/ui/ui_font_<name>.c`.** The file's contents diverge from what EEZ Studio would produce, and the very next Ctrl+B (after any font change) will emit a different file, silently. Even if a link error is blocking the user, the answer is to populate `embeddedFontFile` and let Ctrl+B produce the file — never to write it yourself. The user has called this "hacking" verbatim.
2. **Never trust `gen_eez_project.py` comments or `main/CMakeLists.txt` comments that claim "font conversion is owned by the generator / by `tmp/gen_eez_project.py`, not EEZ Studio's auto-embed."** Such comments have appeared in real projects (Fireside CrowPanel, 2026-07) and are wrong regardless of who wrote them. If you find one, flag it to the user as project debt to clean up — but do NOT use it as justification for running `lv_font_conv`. The correct behavior is always: the `.eez-project` should carry embedded font data, and EEZ Studio should emit the `.c`.
3. **Never delete a `ui_font_*.c` to "let EEZ Studio regenerate it" without first confirming `embeddedFontFile` is populated.** If the field is empty, deleting the `.c` just leaves the user with a broken build until the field is fixed. Repair the JSON first, then the `.c` regenerates cleanly on next Ctrl+B.

**Legitimate reasons for the agent to invoke `lv_font_conv` directly: none, in normal use.** The one narrow exception is when a project's owner has explicitly opted into a generator-driven pipeline and told you so — even then, the correct move is to raise the trade-off (loss of EEZ-Studio-as-source-of-truth) and let them re-confirm before you write anything under `main/ui/`. Default posture: don't.

**When `embedFonts: false` in `settings.general`:** the project is opting out of `.eez-project`-embedded font blobs. In this mode, EEZ Studio expects the source TTF/OTF file to exist at `source.filePath` at Ctrl+B time and re-reads it every export. This is equally valid; the source of truth is still EEZ Studio's export step, not `lv_font_conv`. If the source file is missing, fix the path — do not write the `.c` yourself.

### Actions

When `flowSupport: false` (recommended), actions are just **named declarations** that EEZ Studio's C export turns into `extern void action_<Name>(lv_event_t *e);` prototypes. You implement them in `main/actions.c`.

```jsonc
{
  "objID": "...",
  "components": [], "connectionLines": [], "localVariables": [], "userProperties": [],
  "name": "ChangeScreen"
}
```

Disambiguate variants of one action with `userData` on the event handler — e.g. `ChangeScreen` with `userData: 0..7` for tab buttons, all calling one C handler that switches on userData.

### Variables

Document the surface area C code populates. Without flow support, label text doesn't bind to variables automatically — code calls `lv_label_set_text(objects.lbl_x, "...")`. Variables here are organizational.

```jsonc
{
  "objID": "...",
  "name": "machine_state",
  "type": "string",                          // integer|float|double|boolean|string
  "defaultValue": "\"IDLE\"",                // JSON-encoded literal
  "persistent": false,
  "native": false
}
```

## Recommended project architecture for ESP-IDF + LVGL

- **One EEZ Studio page per logical screen.** Tabbed UIs: one page per tab. Dock buttons fire `ChangeScreen(userData=N)`; C implements the dispatch with `lv_scr_load(...)`.
- **User widgets for reused chrome.** Top status bar, bottom navigation dock, alarm banner, etc. → one user widget each, instanced on every page.
- **No flow support** — keep all logic in C. Set `flowSupport: false` and write action handlers in `main/actions.c`, variable getters/setters in `main/vars.c`.
- **EEZ Studio controls APPEARANCE, code controls STATE.** Code uses `lv_obj_add_state` / `lv_obj_clear_state` with `LV_STATE_CHECKED` etc. — never `lv_obj_set_style_*` to override the EEZ Studio look.
- **`destinationFolder: "../main/ui"`** in settings.build so EEZ Studio drops C output into the project's main component.

## Sizing widgets so they fit the canvas

EEZ Studio doesn't auto-layout absolutely-positioned widgets — you do the math. You have **every input you need** to do it correctly:

- The canvas dimensions (`settings.general.displayWidth` × `displayHeight`).
- Every font's TTF file on disk and its rendered size.
- Every widget's `left/top/width/height` you're writing yourself.
- Every parent's `pad_all`, `border_width`, and own width/height.
- Every user widget's `width`/`height` in `project.userWidgets[*]`.

It is **simple arithmetic** from those inputs. Don't guess label widths from font size × character count, don't assume "this looks like it should fit", don't iterate via "tell me if it's still cut off". Calculate, validate, render, and ship.

### Measure first, place second

For every label you author, measure the rendered text with **the actual TTF that EEZ Studio will embed**, using PIL. Make this part of the generator. Reject any layout where any label's text is within ≥4 px of its widget's bounds.

```python
from PIL import ImageFont

FONTS = {
    "montserrat_14": ("/path/to/Montserrat-SemiBold.ttf", 14),
    "mono_13":       ("/path/to/DejaVuSansMono-Bold.ttf", 13),
    # ...
}
_cache = {}
def measure(text, font_name, letter_space=0):
    if font_name not in FONTS or not text: return 0
    if font_name not in _cache:
        path, size = FONTS[font_name]
        _cache[font_name] = ImageFont.truetype(path, size)
    w = int(_cache[font_name].getlength(text) + 0.5)
    if letter_space and len(text) > 1:
        w += letter_space * (len(text) - 1)
    return w

# In the generator, when authoring a label:
text = "START SPINDLE"
font = "montserrat_14"
tw   = measure(text, font, letter_space=1)
widget_w = max(tw + 16, 110)     # 8 px breathing room each side, min 110
```

Or, sweep the project after generation with an `audit_labels.py` that walks every `LVGLLabelWidget`, resolves its effective `text_font` + `text_letter_space` (via the style chain + `localStyles` overlay), and reports any label where `text_width > widget_width - 4`. Fail the build until the audit returns 0.

The PIL measurement and EEZ Studio's rendered width are not bit-identical (EEZ Studio's text engine adds a small amount for sub-pixel positioning and may round differently), so the rule is **never sit within 8 px of measured width**. Give yourself a buffer the math can't blow through.

### Look up user widget dimensions at the source — never guess

When a page instantiates a user widget (`LVGLUserWidgetWidget`), the instance's `left/top/width/height` is what you set, but the *content* the user widget draws is laid out using its own root panel's width/height. Those live in `project.userWidgets[*]` — read them from there, don't reproduce them as constants in the page builder.

```python
# Bad — magic number repeated in two places, drifts when StatusBar resizes:
STATUS_H = 46
page_children.append(user_widget_instance("status_bar", 0, 0, W, 46, "StatusBar"))

# Good — sizes derived from the user widget definition:
def widget_size(name):
    for w in project["userWidgets"]:
        if w["name"] == name: return (w["width"], w["height"])
    raise KeyError(name)

sb_w, sb_h = widget_size("StatusBar")
dock_w, dock_h = widget_size("BottomDock")
body_h = H - sb_h - dock_h          # never miscomputed
page_children.append(user_widget_instance("status_bar", 0, 0,        sb_w, sb_h, "StatusBar"))
page_children.append(user_widget_instance("body",       0, sb_h,     W, body_h, ...))
page_children.append(user_widget_instance("dock",       0, sb_h+body_h, dock_w, dock_h, "BottomDock"))
```

If the StatusBar's height changes, every page recomputes automatically. Same rule for any widget whose size affects neighbours: look up the source of truth.

### The full child-bound formula

For a child at `(cx, cy, cw, ch)` inside a parent of inner size `(pw, ph)` with style padding `pad` and border `bw`:

```
content_w = pw - 2*(pad + bw)
content_h = ph - 2*(pad + bw)
visible    if cx + cw <= content_w  and  cy + ch <= content_h
```

For LVGL, `pad_all` and `border_width` come from the parent's effective style (`useStyle` merged with `localStyles`). If `pad_all = 0` and `border_width = 0`, then `content_w == pw` and life is simple — which is the strong reason to keep `pad_all = 0` on every container that holds absolutely-positioned children (see Trap 1 below).

Seven traps cost real time if you don't know about them. **Apply the StatusBar recipe** (Traps 1–7 below) to every user widget you author. We discovered every single one by iterating on one widget until it was right, and they generalise.

### Trap 0 — Widget `width`/`height` is a *suggestion*; the *style* is what pins the rendered size

This is the single biggest LVGL surprise. The `width` and `height` you write on a panel/widget in the JSON are not enforced by LVGL at render time. LVGL falls back to `LV_SIZE_CONTENT` for many widget types, sizing the widget to fit its children — which can be smaller than you declared. The widget then "loses" the rest of its bounding box and any absolutely-positioned children that lived in that lost area get clipped or float in the wrong place.

To pin the rendered size, the widget's **style** must declare:

```python
"min_width":  W,   "max_width":  W,
"min_height": H,   "max_height": H,
```

This is exactly how Fireside's `StylePanelNavBarBottom` pins itself to 1024×60 — without those four keys the bottom nav doesn't render at full width.

**Symptom of the trap**: the widget appears smaller than you authored; children clip; text labels appear with only the bottom half visible.

**Rule**: every user widget root panel — and every body container that must be a known size — needs `min_*` / `max_*` style entries equal to its intended dimensions. Author this in the named style, not in `localStyles`, so every instance gets the same constraint.

### Trap 1 — `pad_all` doesn't reliably override LVGL's per-side defaults

Setting `pad_all: 0` in a style is *not* equivalent to setting all four per-side pad keys to 0. LVGL's button (and panel) default styles cascade in per-side `pad_top`/`pad_bottom`/`pad_left`/`pad_right` values, and `pad_all: 0` in your style does not always defeat them. Children of a button styled with only `pad_all: 0` still get pushed inward by LVGL's defaults, so a label flush to the right edge clips.

**Rule**: in every interactive widget style (button, panel, pill, dock button — anything with children laid out at absolute coordinates), set all four per-side keys explicitly:

```python
"pad_top": 0, "pad_bottom": 0, "pad_left": 0, "pad_right": 0,
```

Don't write `pad_all: 0` and assume it's the same — it isn't.

### Trap 2 — Icon labels need to be at least as tall as the font

A label widget at `h=14` cannot fully contain a `fa_16` glyph (font height ≈ 18 px). The glyph either clips at top/bottom or renders offset within the box. Symptom: icons look "low" inside a button or have their top/bottom shaved.

**Rule**: icon labels should be sized `≥ font_size + 4 px` in both width and height, ideally square. For a `fa_16` font use a `20×20` label. For `fa_22` use `26×26`. Author the icon's `text_align: CENTER` so the glyph centres in its box.

### Trap 3 — Centre icon + text *as a group* inside a button

A button containing an icon and a text label is one of the most common UI primitives. To centre them correctly:

```
content_w = icon_w + inner_gap + label_w
icon_x    = (button_w - content_w) // 2
label_x   = icon_x + icon_w + inner_gap
```

Both children get `vcenter(button_h, child_h)` independently — they have *different* heights (icon ≈ 20, label = 14) so their `y` values will differ, but they share the same vertical midline (`y + h/2` equals `button_h/2` for both).

**Rule**: never left-anchor an icon to (8, ?) and hope the label "happens" to look centred. Compute the group's total width, derive the left offset from the slack, and chain the label off the icon's right edge.

### Trap 4 — `pad_all: 0` and `align: TOP_LEFT` belong in the style explicitly

For user widget root panels in particular, EEZ Studio's expansion behaviour depends on the style declaring an explicit `align`. Without it, the panel may anchor differently than expected when instanced on a page, and you'll end up wondering why the bar appears shifted.

**Rule**: `align: TOP_LEFT` for top-anchored bars (StatusBar), `align: BOTTOM_LEFT` for bottom-anchored bars (BottomDock). Always declared in the named style.

### Trap 5 — Measure text with PIL on the actual TTF, every time

`PIL.ImageFont.truetype(path, size).getlength(text)` returns the exact advance width LVGL will use. There is no excuse for guessing a label width based on "looks about right" or character count × font size. Always measure.

```python
from PIL import ImageFont

_font_cache = {}
def measure(text, font_path, size, letter_space=0):
    key = (font_path, size)
    if key not in _font_cache:
        _font_cache[key] = ImageFont.truetype(font_path, size)
    w = int(_font_cache[key].getlength(text) + 0.5)
    if letter_space and len(text) > 1:
        w += letter_space * (len(text) - 1)
    return w
```

Then size every label `width >= measure(text, ...) + buffer` where buffer is **at minimum 8 px**. EEZ Studio's renderer measures slightly wider than PIL — buffer covers the discrepancy.

### Trap 6 — Size a label widget for its WIDEST runtime string, not its initial value

A label that flips between `"HOLD"` and `"RESUME"` at runtime needs to fit `"RESUME"`. A pill that flips between `IDLE`, `RUN`, `HOLD`, `JOG`, `HOMING`, `ALARM` needs to fit `HOMING`. A button whose text is set via `lv_label_set_text` from C must be sized for the worst case in the entire firmware, not just the placeholder you put in the `.eez-project`.

**Rule**: when authoring a label whose text varies, enumerate every possible runtime value in a comment next to the widget, measure each one, and size for the longest. The widget is wrong if it works for the initial value but the runtime can change to something it doesn't fit.

### Trap 8 — Themes are useless unless the palette actually flips

EEZ Studio supports `Default` and `Dark` themes side-by-side. If both themes carry the **same hex values for the same tokens**, switching themes in the preview does nothing visible — and the team treats your project as "dark only" when it could have been theme-aware.

Split your colour palette into two classes:

- **Theme-invariant** — same hex in both themes. Use for:
  - Semantic accent / status colours that mean the same thing regardless of background: brand `AccentPrimary`, `AxisX/Y/Z`, etc.
  - `ForegroundWhite` (`#FFFFFF` in both) and `ForegroundBlack` (`#000000` in both) — text on coloured buttons (see below).
- **Theme-dependent** — different hex per theme. Use for backgrounds, borders, text, soft accent tints. `BgPrimary` `#FFFFFF` in Default, `#000000` in Dark. `TextPrimary` `#000000` in Default, `#FFFFFF` in Dark. Etc.

Then the test: regenerate, open in EEZ Studio, switch themes from the preview, and watch the canvas actually invert. If it doesn't, both themes are still carrying identical hex.

### Trap 9 — Text on a coloured button must use a theme-invariant text token

This is the most common bug after Trap 8. A red E-STOP button keeps its red background regardless of theme. If you set its text to `TextPrimary`, then:

- Dark theme: `TextPrimary = white` → white text on red ✓
- Default (light): `TextPrimary = black` → black text on red — illegible, blends into the red.

The button's background is theme-invariant (red); the text must be too. Reference `ForegroundWhite` (always white) or `ForegroundBlack` (always black) — whichever contrasts with the button colour — instead of `TextPrimary`.

Same applies to:
- Green accent-fill buttons (START, LOAD & RUN): `ForegroundBlack` (better contrast on this shade).
- Coloured axis chips (X/Y/Z): chip background is invariant, chip letter uses `ForegroundBlack`.
- Any pill / badge whose background colour is also theme-invariant.

For buttons whose background flips with the theme (`BgCard`, `BgCardHover`, etc.), text *should* use `TextPrimary` so it flips alongside. The rule is **the text token's theme-behaviour must match its background's theme-behaviour**.

A quick generator audit:

```python
THEME_INVARIANT = {"ForegroundWhite","ForegroundBlack","AccentPrimary","AccentHover",
                   "AxisX","AxisY","AxisZ"}
def audit_text_on_invariant_bg(styles):
    for s in styles:
        defn = s["definition"]["definition"]
        for part in defn.values():
            for state in part.values():
                bg = state.get("bg_color")
                tc = state.get("text_color")
                if bg in THEME_INVARIANT and tc and tc not in THEME_INVARIANT:
                    print(f"  {s['name']}: bg={bg} (invariant) but text={tc} (flips)")
```

### Trap 10 — Every glyph referenced in label text must be in the font's `lvglRanges`

A Font Awesome label like `text: ""` only renders if `0xF024` is included in the `lvglRanges` of whichever font the label resolves to (after the style cascade + local-style overrides). If the codepoint isn't in the subset, the label shows an empty rectangle. There is no warning at build time, no error in EEZ Studio's check panel — the glyph just silently doesn't render.

Easy to miss because the symptom (empty box) looks identical to a missing font or a hidden widget.

**Rule**: run a generator-side audit that, for every `LVGLLabelWidget` containing `\uXXXX`, resolves the effective `text_font` and asserts the codepoint is in that font's `lvglRanges`. Add the missing codepoints to the source range list and regenerate. The user will need to re-Build the font in EEZ Studio for the new subset to be embedded.

```python
import re
def audit_fa_coverage(project):
    ranges = {}
    for f in project["fonts"]:
        if not f["name"].startswith("fa_"): continue
        cps = set()
        for tok in f.get("lvglRanges","").split(","):
            try: cps.add(int(tok.strip(), 16))
            except ValueError: pass
        ranges[f["name"]] = cps
    styles = {s["name"]: s for s in project["lvglStyles"]["styles"]}

    def effective_font(node, parent_font):
        f = parent_font
        if node.get("useStyle") in styles:
            defn = styles[node["useStyle"]]["definition"]["definition"] \
                  .get("MAIN", {}).get("DEFAULT", {})
            if "text_font" in defn: f = defn["text_font"]
        ls = (node.get("localStyles") or {}).get("definition", {})
        if isinstance(ls, dict):
            defn = ls.get("MAIN", {}).get("DEFAULT", {})
            if "text_font" in defn: f = defn["text_font"]
        return f

    missing = []
    def walk(node, parent_font):
        f = effective_font(node, parent_font)
        if node.get("type") == "LVGLLabelWidget":
            for hc in re.findall(r'\\u([0-9a-fA-F]{4})', node.get("text", "")):
                cp = int(hc, 16)
                if f in ranges and cp not in ranges[f]:
                    missing.append((node.get("identifier","?"), f, hex(cp)))
        for c in node.get("children", []) or []:
            walk(c, f)
    for p in project["userPages"]: walk(p["components"][0], "montserrat_12")
    for w in project["userWidgets"]: walk(w["components"][0], "montserrat_12")
    return missing
```

Common omissions when seeding a Font Awesome subset:
- `F024` flag, `F05A` info-circle, `F126` code-branch, `F545` ruler-horizontal, `F5FD` layer-group, `F6E3` screwdriver-wrench.
- The full FA codepoint reference: <https://fontawesome.com/v6/icons>.

After adding codepoints, the `ui_font_<name>.c` has to be regenerated or the embedded glyph table won't include the new codepoints. For a generator-driven project, just re-run `python3 tmp/gen_eez_project.py` — the `build_font_sources()` step re-invokes `lv_font_conv` for every font. For a hand-authored project, edit each font's `lvglRanges` in EEZ Studio's UI (the in-UI edit triggers auto-embed), save, and Ctrl+B. (Do **not** look for a per-font Build action in the Fonts panel — there isn't one in modern EEZ Studio; older skill notes lied about this.)

### Trap 11 — Em dashes, ellipses, and other typographic glyphs aren't in LVGL's built-in Montserrat subset

LVGL's bundled `lv_font_montserrat_*` fonts ship with a Latin-1-ish subset that does **not** include:

- `—` (em dash, U+2014) — renders as `[]`
- `–` (en dash, U+2013) — same
- `…` (ellipsis, U+2026) — same
- `‘ ’ “ ”` (curly quotes, U+2018–U+201D) — same
- `·` (middle dot, U+00B7) — same
- Most non-ASCII punctuation

Symptom: a label authored as `"Scanning…"` or `"Connect to TrailCurrent — pick a network"` shows as `Scanning[]` or `Connect to TrailCurrent [] pick a network` on-device (and in EEZ Studio's canvas if it uses the same subset for preview).

**Rule**: every label text emitted by a generator or one-shot patch should pass through an ASCII normalizer before it lands in the JSON:

```python
def ascii_only(s):
    if not isinstance(s, str): return s
    return (s.replace("—", "-").replace("–", "-")
              .replace("…", "...").replace("·", "*")
              .replace("‘", "'").replace("’", "'")
              .replace("“", '"').replace("”", '"'))

def walk_label_text(node):
    if isinstance(node, dict):
        for k, v in list(node.items()):
            if k == "text" and isinstance(v, str): node[k] = ascii_only(v)
            else: walk_label_text(v)
    elif isinstance(node, list):
        for x in node: walk_label_text(x)
```

Run it across the whole project tree, not just freshly-added widgets — paste-from-design-doc text drifts in over time. Pair with the `audit_fa_coverage` from Trap 10 so glyph-range coverage and typographic-character coverage are both checked.

If you need a real em dash or ellipsis for design reasons, switch the label's `text_font` to a custom font whose `lvglRanges` includes the codepoint (e.g. a Montserrat custom font with `0x20-0x7F,0x2013,0x2014,0x2026`), and accept the binary-size cost. Don't sprinkle typographic chars into labels that resolve to LVGL's built-in subset.

### Trap 12 — `text_align: CENTER` only centers HORIZONTALLY; vertical centering needs label sizing + position math

This bit me hard on a wizard with many buttons. I authored each button label as `(left=0, top=0, width=btn_w, height=btn_h)` with `text_align: CENTER` and assumed the text would sit dead-center. It sits CENTERED HORIZONTALLY but ANCHORED TO THE TOP vertically. A 22-px-tall glyph inside a 70-px-tall label shows up hugging the top of the button.

**Why**: `text_align` controls glyph positioning inside the label's text rectangle, but the label widget itself is 70 px tall and the text rectangle's baseline is at the top of that 70-px box. There is no `text_v_align` property on LVGL labels.

**Rule**: for any label that needs to look centered inside a parent (button, panel, badge), size the label to the text and position it manually:

```python
# In the generator / patch script:
text = "Connect"
font_size = 22                 # MONTSERRAT_22
tw = measure_text(text, font_size)   # PIL on Montserrat-SemiBold.ttf
th = FONT_HEIGHT[font_size]    # e.g. {14:18, 16:20, 18:22, 20:24, 22:26, 24:28, 32:36, 48:56}
bw, bh = btn["width"], btn["height"]
label["width"]  = tw + 2       # 1 px slack each side for measurement variance
label["height"] = th
label["left"]   = max(0, (bw - label["width"]) // 2)
label["top"]    = max(0, (bh - th) // 2)
```

Same pattern for icon labels (FontAwesome glyphs inside a button) — size the label to the glyph's font height, center it in the button. The icon ends up perfectly placed instead of hugging the top edge.

**Do NOT** "fix" this in C with `lv_obj_set_size(label, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_center(label);` after `ui_init()`. It works on the device but violates the source-of-truth rule (see top): the user opens EEZ Studio, sees the label uncentered, knows something is wrong. Always fix the JSON.

### Trap 13 — runtime-overridden widgets (`lv_keyboard`, `lv_list`, `lv_buttonmatrix`, `lv_dropdown`, `lv_roller`, `lv_tabview`)

Several LVGL widgets have internal layout logic that re-sets `align`, `width`, and `height` regardless of what the `.eez-project` JSON says. The keyboard is the most-visible offender — it always anchors itself bottom-mid and sizes itself to half the parent's height unless you stop it. The same pattern bites lists, button matrices, dropdowns, rollers, and tabviews.

Symptom: widget renders at LVGL's default location (often bottom of parent) at LVGL's default size (often parent-fill), even though your JSON authored a different `(left, top, width, height)`. **You see it in the EEZ Studio canvas, not just on device** — because EEZ Studio simulates LVGL's behavior at render time. The earlier version of this skill told you to fix this in C with `lv_obj_set_pos` / `set_size` / `set_align`. That was wrong: C only affects runtime, not the canvas, so the user still saw broken layouts when they opened EEZ Studio.

**Correct fix: override in the widget's `localStyles`.** LVGL's internal layout pass honors the style's `align`, `min_width`/`max_width`, and `min_height`/`max_height` even when it overrides the widget's `left`/`top`/`width`/`height`. EEZ Studio's canvas also honors them. So pinning via style fixes canvas and device at the same time, with no C wrapper.

For every runtime-overridden widget, the spec MUST instruct the user to set, in the widget's `localStyles` → `MAIN` → `DEFAULT`:

```
align:      TOP_LEFT
min_width:  <same as authored width>
max_width:  <same as authored width>
min_height: <same as authored height>
max_height: <same as authored height>
```

Example spec entry for the rename-sensor keyboard:

```
LVGLKeyboardWidget identifier=sensor_rename_kb at (8, 200, 784, 270)
  mode: TEXT_LOWER
  useStyle: KeyboardDefault
  localStyles MAIN.DEFAULT (REQUIRED — runtime-overridden widget):
    align: TOP_LEFT
    min_width: 784
    max_width: 784
    min_height: 270
    max_height: 270
```

Canvas verification: open the page in EEZ Studio. The keyboard must show **all four key rows** in the authored y-range (here y=200..470). If only 1–2 rows are visible or the keyboard sits at the bottom, the style pin is missing or has the wrong values.

**Source-of-truth check** (per the rule at the top of "Schema crib sheet"): `packages/project-editor/lvgl/widgets/Keyboard.tsx` in the EEZ Studio source documents exactly which `lv_obj_set_*` calls Keyboard makes during canvas render. Read it when in doubt about which style overrides apply.

**When (and only when) the C-side pin is also appropriate.** If you have a project where the keyboard widget is created at runtime (not by EEZ Studio's export) — e.g. an ad-hoc `lv_keyboard_create()` in C — then `lv_obj_set_align` + `lv_obj_set_pos` + `lv_obj_set_size` is the right fix, since there's no `.eez-project` widget to pin via style. For widgets defined in EEZ Studio, style pinning is always preferred because it makes the canvas correct.

**Legacy C-pin helpers in older projects.** If a project still has `fix_keyboard_alignment(kb, x, y, w, h)` from before this skill version, it does no harm — it pins the same values as the style override, just at a different time. Removing the C call once style pinning is in place is OK but not required. Adding new C-pin calls for new keyboards is NOT OK — use style pinning instead, so the canvas shows what the device shows.

### Trap 14 — Authored placeholder text shows up in EEZ Studio canvas even when C hides the widget

When generating a list/grid of widgets that the C side will populate at runtime (scan-result rows, dynamic menu items, etc.), the temptation is to fill them with placeholder text like `"Network 1"`, `"Network 2"`, …, `"Network 8"` so the layout looks reasonable while authoring. Then C hides the unused ones (`lv_obj_add_flag(LV_OBJ_FLAG_HIDDEN)`) and overwrites visible ones with real data (`lv_label_set_text(row, ssid)`).

Problem: when the user opens EEZ Studio's canvas they see "Network 1..8" — but on the device they see real SSIDs or nothing. The canvas lies. The user reasonably asks why the GUI doesn't match reality.

**Rule**: for any label whose runtime content comes from C, set the authored `text` to an empty string `""` so the canvas shows the row blank — matching what the device shows pre-population. If the label needs SOME placeholder for layout-debug reasons, use a single dash `"-"` (universally available glyph) or visibility-toggle the parent row with `widgetFlags` containing `HIDDEN` so the canvas hides it the same way C will.

This is the same principle as Trap 11: the EEZ canvas IS the device preview. Don't author text that misrepresents the device.

### Trap 15 — Decorative children inside buttons capture touches and swallow the click

When you author a button with a label inside (`Cancel`, `Next`, etc.) or a card-row panel with decorative children (SSID label + lock icon + signal bars), the default `widgetFlags` string on each child includes `CLICK_FOCUSABLE` and EEZ Studio sets `clickableFlag: true` for every widget by default — so the children inherit `LV_OBJ_FLAG_CLICKABLE` even though they have no event handler.

LVGL's hit-testing walks the widget tree depth-first and stops at the **topmost CLICKABLE descendant** under the touch point. It does NOT bubble the event to the parent automatically. So:

- The user taps the visible "Next" text on a button → the label captures the click → it has no event handler → click vanishes.
- The user taps the SSID text on a scan-result row → the SSID label captures it → the row's tap-target button never fires.

Symptom: "this button is hard to tap" / "I have to tap the corner" / "selecting a network from the list is finicky" — the only zones that work are the bare pixels between decorative children, where hit-testing falls through to the parent.

**Rule**: every widget that exists for **display purposes** (LVGLLabelWidget, LVGLImageWidget, decorative LVGLPanelWidget inside a clickable row) must have `clickableFlag: false` and `CLICKABLE` removed from `widgetFlags`. Only the actual interactive widget (the button or tap-target) keeps CLICKABLE.

Patch script pattern:

```python
DECORATIVE = {"LVGLLabelWidget", "LVGLPanelWidget", "LVGLImageWidget"}

def disable_clickable(node):
    node["clickableFlag"] = False
    wf = node.get("widgetFlags", "")
    if "CLICKABLE" in wf:
        node["widgetFlags"] = "|".join(p for p in wf.split("|") if p and p != "CLICKABLE")

def walk_buttons(node):
    if isinstance(node, dict):
        if node.get("type") == "LVGLButtonWidget":
            def mark_kids(n):
                for c in n.get("children", []) or []:
                    if c.get("type") in DECORATIVE: disable_clickable(c)
                    mark_kids(c)
            mark_kids(node)
        for v in node.values(): walk_buttons(v)
    elif isinstance(node, list):
        for x in node: walk_buttons(x)
```

Apply to every button-bearing project after authoring. Combine with Trap 16 (z-order) for tap-target overlay rows.

### Trap 16 — Hit-testing follows z-order; full-row tap targets must be the LAST child of the row

If you author a "tappable row" by laying out content panels (SSID label, icon, bars) and then dropping a full-row invisible button on top to capture taps anywhere in the row — the button must be the **last** child of the row's panel. LVGL z-orders later children on top of earlier ones, and hit-testing checks the topmost first.

Authoring the tap-target as the **first** child (which is the natural reading order — "the row is a button containing labels and icons") puts every decorative child ON TOP of it in z-order. Combined with Trap 15 (those children are still CLICKABLE), the user can only tap the gaps between visible elements.

**Rule**: when building a "clickable composite row", emit children in this order:

```python
row_children = [
    label(... ssid ...),    # decorative, clickableFlag=False (Trap 15)
    label(... lock ...),    # decorative, clickableFlag=False
    panel(... bars ...),    # decorative, clickableFlag=False
    button(... tap_target, full-row size, bg_opa=0, action=Select),  # LAST = top z-order
]
```

The tap target needs `bg_opa: 0` so its (otherwise opaque) background doesn't visually obscure the decorative content underneath. Don't rely on matching the row's bg color — bg_opa=0 is the only correct answer because it works in both light and dark themes and survives palette changes.

### Trap 17 — `sdkconfig` is sticky; new flags in `sdkconfig.defaults` don't retroactively apply

This bit Fireside hard. The skill already says "edit `sdkconfig.defaults`, never `sdkconfig`." That's correct but incomplete — it covers FUTURE builds. Here's the missing piece:

**ESP-IDF only applies `sdkconfig.defaults` to keys that are NOT already present in `sdkconfig`.** Once `sdkconfig` has been generated, adding a new `CONFIG_FOO=y` to `sdkconfig.defaults` does NOT silently appear in `sdkconfig` on the next build. The new key has to be applied by either:
- Deleting `sdkconfig` and letting it regenerate, OR
- Running `idf.py reconfigure` (regenerates from defaults), OR
- Running `idf.py fullclean` then a fresh build.

Symptom in the Fireside case: `sdkconfig.defaults` had `CONFIG_ESP_TLS_INSECURE=y` and `CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY=y` for many builds, but `sdkconfig` showed both as `# … is not set`. The MQTT TLS handshake failed with mbedtls error 0x8017 ("No server verification option set in esp_tls_cfg_t structure") because the kconfig flags that would have made the C code's no-cert path acceptable were never compiled in. Hours of "why doesn't this work, the flags are in defaults" debugging.

**Rule**: when adding any new `CONFIG_*` to `sdkconfig.defaults`, immediately `rm sdkconfig && idf.py reconfigure` (or `idf.py fullclean`) so the key actually lands in `sdkconfig`. Verify with `grep CONFIG_NEWLY_ADDED_FLAG sdkconfig`. If the line shows `# … is not set` after defaults says `=y`, the regeneration didn't happen.

This is also true in reverse: changing a value in `sdkconfig.defaults` (`CONFIG_FOO=y` → `CONFIG_FOO=n`) doesn't update an existing `sdkconfig` that has the old value. Same fix.

### The full StatusBar recipe (apply universally)

Before writing the JSON for any user widget, work through this checklist:

1. **Pin its size**: style includes `min_width: W, max_width: W, min_height: H, max_height: H`.
2. **Zero its padding**: style includes all four `pad_top: 0, pad_bottom: 0, pad_left: 0, pad_right: 0`.
3. **Set its anchor**: style includes `align: TOP_LEFT` (or whichever you want explicitly).
4. **Apply same to every interactive child style** (buttons, pill panels, etc.) — they need the same `pad_*: 0` per-side.
5. **Build every label width by measurement** — PIL on the actual TTF, +8 px buffer minimum.
6. **Buffer for the longest runtime string** — never the placeholder.
7. **Icon labels: square, ≥ font_size + 4** — and `text_align: CENTER`.
8. **Centre icon + text as a group** — `(button_w - content_w) // 2` for the left offset, label follows the icon's right edge + inner_gap.
9. **`vcenter(parent_h, child_h)` for every child y** — both icon and text get it, and they share a midline.
10. **Verify in code** — print `(child.y, child.h, top_gap, bottom_gap, midline)` for every child and assert top_gap == bottom_gap (within 1 px) before handing back to the user.
11. **Colours**: backgrounds reference theme-dependent tokens (`BgCard`, etc.); text on coloured buttons references theme-invariant tokens (`ForegroundWhite` / `ForegroundBlack`). Run `audit_text_on_invariant_bg` before regenerating.
12. **FA glyph coverage**: every `\uXXXX` codepoint in a label must be in the resolved font's `lvglRanges`. Run `audit_fa_coverage()` — if it returns anything, add those codepoints to the subset list and ask the user to re-Build the font in EEZ Studio.

### Trap 18 — Empty or incomplete styles fall through to LVGL's built-in defaults (the blue rounded button surprise)

A named style in the project's `lvglStyles.styles[]` array can exist with **no usable definition**. Two failure modes look identical to the agent grepping for the style's name:

```jsonc
// Mode A — definition block entirely missing
{
  "objID": "...", "name": "StyleButtonSucces",
  "forWidgetType": "LVGLButtonWidget",
  "childStyles": []
  // NO "definition" key
}

// Mode B — definition wrapper exists but has no MAIN/CHECKED/etc inside
{
  "objID": "...", "name": "StyleButtonSucces",
  "forWidgetType": "LVGLButtonWidget",
  "childStyles": [],
  "definition": { "objID": "..." }   // only objID — no MAIN.DEFAULT block
}
```

Both modes pass a naive "style exists" check (`style_name in {s["name"] for s in styles}`) but render the widget at LVGL's built-in defaults: blue background (`#0080FF`-ish), 10–12 px corner radius, white text, internal per-side padding that shifts label children off-center. **That blue is never in the user's palette and is never what the spec intended.** The first time you see it is when the user opens EEZ Studio and the button looks wrong.

Symptoms in the canvas (and on device):
- A button rendered in **blue** when no `colors[]` token is blue. The blue is LVGL's default accent.
- **Corner radius mismatch** with sibling buttons that use a properly-defined style.
- **Label inside the button is offset** (usually pushed right and down) because LVGL's default button padding wasn't overridden via the (missing) style.
- **Other widget types** show the same pattern: an empty `TabDefault` produces a default tab strip; an empty `DropdownDefault` shows the LVGL default dropdown look; etc.

Why this happens:
- Style was created in EEZ Studio's Styles panel, then never edited — leaves the `objID` shell with no body.
- Style was renamed and the rename left a dangling reference.
- Style was copied from a project where it had content and the copy dropped the definition.

The fix:
1. **Run `verify_project.py`** (Gate 4 above) — it flags both modes as `EMPTY DEFINITION` / `NO DEFINITION`.
2. **For every empty style the project has**, surface it to the user as a finding: "this style is empty; widgets using it currently render as LVGL defaults; either delete the style and update referrers, or fill it in with bg_color/radius/pad_*/etc." Let the user decide.
3. **In any spec the agent writes**, never recommend `useStyle: <empty style>`. If the only matching style is empty, recommend `useStyle: <similar style that does have a definition>` and override differences via `localStyles`.

Concrete case (Spotter, this conversation): `StyleButtonSucces` had no definition. My spec said `useStyle: StyleButtonSucces` for the Save button. Canvas rendered a blue button with the wrong radius and a mis-positioned label. The fix was: use `StyleButtonDefault` (which has `radius: 5`, `bg_color: BackgroundNotSelected`, defined) and override `bg_color: AccentColor` + `pad_*: 0` in `localStyles` to get the brand-green primary button.

### Trap 19 — PIL `getbbox()` lies for fonts you generated with `lv_font_conv` from a glyph subset

The single biggest source of "I positioned the label using PIL.getbbox() but it lands in the wrong place on device" bugs. The mechanism:

- `PIL.ImageFont.truetype(path, size).getbbox(text)` measures within the TTF's **natural** line metrics. For Roboto Mono, the natural line includes space for tall ascenders (`Q`'s tail) and deep descenders (`g`, `p`, `y`) even when your text is "12.6". PIL reports `y0 ≈ 16, y1 ≈ 51` for `"85"` at Roboto Mono 48 — within a natural ~60 px line.
- `lv_font_conv` with `-r 0x20,0x2D,0x2E,0x30-0x39` (digits + space + `-` + `.`) **recomputes `line_height` from the subset's max glyph extent**. The output declares `line_height = 36`, not 60. The digit glyphs are placed at `ofs_y=0, box_h=35` — ink y=1..35 within a 36 px line.
- The same digit "8" appears at PIL ink_mid ≈ 34, LVGL ink_mid ≈ 18. **A 16-px error**, in opposite directions of where you think centered means.

Symptom: every value label lands too low (PIL-measured `top = row_center - 34` puts the label so its visible digits sit 16 px below where they should). The user sees gauges with all the text shifted down.

**Rule for any font in the project's `fonts[]` array**: parse its `ui_font_*.c` glyph descriptors directly. Don't use PIL.

Use the skill helper:

```python
import os, sys
sys.path.insert(0, os.path.expanduser("~/.claude/skills/eezstudio"))
from font_metrics import LvglFont, text_ink_range, text_adv_w, mont_ink_range

rm48 = LvglFont("main/ui/ui_font_roboto_mono_48.c")
ink_top, ink_bot = text_ink_range(rm48, "85")    # authoritative LVGL ink
ink_mid = (ink_top + ink_bot) / 2
label_top = row_y_center - ink_mid                # ink-centered placement

# Advance width — also authoritative, sums per-glyph adv_w / 16
hero_w = text_adv_w(rm48, "85")
```

**When PIL IS safe**: LVGL built-in `MONTSERRAT_<size>` fonts use the same TTF and full charset — PIL matches. Use `mont_ink_range(size, text)` for unit labels.

`font_metrics.py` lives in this skill directory. It parses `line_height`, `base_line`, every glyph descriptor (`adv_w`, `box_w`, `box_h`, `ofs_x`, `ofs_y`), and the cmap (`range_start`, `unicode_list`, `glyph_id_start`) — enough to look up any codepoint's true ink position. Per-icon resolution: each FontAwesome glyph has its own ink range (a horizontal battery, a round sun, and a vertical bell each have different `box_h` and `ofs_y`), so you get correct per-icon centering automatically, not a uniform icon-font fallback.

### Trap 20 — Monospaced value boxes need `ceil`-rounded width AND `longMode: "CLIP"`

When you size a value box to exactly the text's pixel advance for a monospaced custom font, you'll hit subpixel rounding from `adv_w/16`. Roboto Mono 18's `adv_w/16 = 10.8125 px/char`. `"12.6"` advances `4 × 10.8125 = 43.25 px`. Set the box `width = 43` and you're 0.25 px too narrow.

LVGL's default `longMode = "WRAP"` then breaks the text at the rightmost character boundary onto a second line. With the label `height = line_height = 14`, only the first line renders → user sees `"13."` with the `4` wrapped + clipped. Looks like a font bug; it's a width-rounding bug.

**Rule for every monospaced value label**:

```python
import math
value_w = math.ceil(len(text) * adv_w_per_char) + 1   # +1 px belt-and-braces
value_node["width"]     = value_w
value_node["longMode"]  = "CLIP"                       # no wrap, no surprises
```

`+1 px` covers any future rounding behaviour from LVGL or a future EEZ Studio export. `longMode: "CLIP"` is the second backstop — even if width math drifts, the user sees a clean truncation instead of a wrap onto a hidden second row.

This applies to every label that displays formatted numeric data via `lv_label_set_text` in C: gauge values, monospaced status readouts, anything where width is "exactly the worst-case text".

### Trap 21 — LVGLSwitchWidget / LVGLCheckboxWidget authored in EEZ Studio silently omit CHECKABLE

The most confusing symptom is: **the switch renders correctly, the tap is felt (you can see the ripple/press-down animation), but the toggle never flips and no VALUE_CHANGED event fires.** All the C-side code — the handler, the NVS persistence, the `lv_obj_has_state(sw, LV_STATE_CHECKED)` read — is fine. The widget just never enters the CHECKED state.

The root cause is an asymmetry between EEZ Studio's export and LVGL's own C constructors:

- `lv_switch_create()` in C sets a default flag set that **includes `CHECKABLE`** (that's what makes a switch toggle its state on click — the base `lv_obj` behaviour for a widget with CHECKABLE is: PRESS_LOCK + CHECKABLE ⇒ on the RELEASED edge, flip LV_STATE_CHECKED, then dispatch VALUE_CHANGED).
- EEZ Studio's LVGLSwitchWidget default `widgetFlags` is **`CLICKABLE|PRESS_LOCK`** — no CHECKABLE. The `.eez-project` UI shows a "Checkable" checkbox in the Flags panel that's UNCHECKED by default. If nobody ticks it, the exported widget can be pressed but never toggled.

Same trap on `LVGLCheckboxWidget` and any other widget whose whole purpose is "flip state on tap." If you author it in EEZ Studio and the Checkable checkbox in the Flags panel isn't ticked, tap-to-toggle silently doesn't work.

**Confirming the trap when you see it**: the discriminator is whether other toggles on the same screen work. If a checkbox/switch created dynamically in C (`lv_switch_create` in an event handler or list-builder) toggles fine but an EEZ-authored one on the same screen doesn't, this is what you're hitting.

**How this hides in production**:

- The C generator emits the switch with `LV_OBJ_FLAG_CLICKABLE | LV_OBJ_FLAG_PRESS_LOCK` — no compile warning because `objects.<switch>` is valid, `action_toggle_*` compiles fine, the event handler is registered.
- On the device: tap the switch → ripple → nothing else. No log, no assert.
- User reports "the toggle doesn't work" and the natural first place to look is the event handler, which is correct code that would fire if the state ever changed.
- The bug is one field of the widget's JSON, buried behind a Flags checkbox that most agents (and users) don't check by default because the widget is named "Switch" and the Checkable flag sounds redundant.

**Fix — one edit per widget**: append `|CHECKABLE` to `widgetFlags` on every EEZ-authored switch or checkbox that the user is meant to toggle.

Idempotent patch script pattern:

```python
def add_checkable(node, target_ident):
    changed = 0
    if isinstance(node, dict):
        if node.get("identifier") == target_ident:
            parts = [p for p in (node.get("widgetFlags") or "").split("|") if p]
            if "CHECKABLE" not in parts:
                parts.append("CHECKABLE")
                node["widgetFlags"] = "|".join(parts)
                changed += 1
        for v in node.values():
            changed += add_checkable(v, target_ident)
    elif isinstance(node, list):
        for x in node:
            changed += add_checkable(x, target_ident)
    return changed
```

Or in EEZ Studio: select the widget, open Flags panel, tick **Checkable**, save.

**Prevention rule for new work**: any time you add an `LVGLSwitchWidget` or `LVGLCheckboxWidget` to a spec (or a patch script builder function), also add `CHECKABLE` to its widgetFlags in the builder. If you're producing a spec for the user to author by hand, list it explicitly: "widgetFlags: `CLICKABLE|PRESS_LOCK|CHECKABLE`" — not just "checkable switch" and hoping the user finds the Flags checkbox.

**Adjacent gotcha** for checkboxes specifically: LVGLCheckboxWidget defaults to a text label of "Check box". If you clear the label to empty string and rely on a sibling label for the description, the checkbox may render tiny (the sqaure with no adjacent text-anchor). That's a separate layout issue, not the same as CHECKABLE — but if you're already patching a checkbox to add CHECKABLE, verify the label + width at the same time.

## Mixed-font alignment: hero widths + ink-centered placement

The pattern that produces visually-correct dashboards where icons, hero values, and unit labels all sit on the same midline regardless of which font each uses. This is the recipe — the traps above are the things that break it.

### Two-axis split

- **Horizontal**: hero-aligned (the most-common runtime value width determines the value-box width; the value+unit pair is centered on the parent).
- **Vertical**: ink-centered (each label's `top` is computed so that `label.top + ink_mid == row_y_center`; works across mixed fonts because ink_mid is per-font, per-text, per-glyph).

The two axes are independent — fix horizontal once with a hero-recenter pass, fix vertical once with an ink-center pass, both pure JSON edits, no C overrides.

### Hero alignment (horizontal)

**Hero = the most common runtime value's display width, not the worst case.** The placeholder you see at boot is transient; what dominates user perception is the value during normal operation.

Picking the hero requires knowing the domain:

| Gauge | Worst case | Hero (most common) | Source |
|---|---|---|---|
| Battery percent | `100` (full charge, rare) | `85` (2 digits) | typical SOC range 20–95 % |
| Battery voltage | (fixed format `%.1f`) | `12.6` (4 chars) | always 4 chars |
| RV solar wattage | `1100` (large array peak) | `250` (3 digits) | Victron community readings, mid-day 200–400 W array |
| Speed (MPH) | `120` | `65` (2 digits) | typical highway speed |
| Tire pressure (PSI) | `120` (heavy-load tire) | `65` (2 digits) | typical trailer pressure |

If you don't know the domain, **research before committing to a hero width**. For gauge domains specifically (RV solar, automotive, marine), a one-shot web search of community displays gives concrete numeric ranges.

**Peer values must share font size — pick the lowest common denominator.** When two gauges carry equal logical weight (battery % alongside solar W; battery V alongside solar V; etc.), they must render at the same font size, or the larger one reads as more important than the other regardless of intent. The size to pick is the **smaller of the two** — whichever font fits the wider hero-text comfortably. Concretely: solar wattage needs 3 digits (`250`) and a `W` unit at Roboto Mono 36 to fit a 256-wide card with margins. Battery percent has a narrower hero (`85`, 2 digits) and would fit at Roboto Mono 48 — but rendering it at 48 while solar sits at 36 telegraphs that battery is more important. Both go to RM36. Apply this whenever you find yourself reaching for a bigger font "because we have the space" on one gauge — check the gauge's peers first; size to the LCD across all of them.

**Every element inside a gauge card must share one vertical center axis (`x = card_w / 2`).** This is the single most-important horizontal rule for dashboard cards, and it's not the same thing as per-pair hero centering. Hero centering puts each value+unit pair box-centered for the pair's own dimensions; the **card axis rule** says every element (arc, icon, hero pair, secondary pair, status text, anything stacked vertically within the card) must center on a single shared vertical line at `card_w / 2`. A vertical line dropped through the card's middle must pass through the visible-ink center of every child element.

This catches several traps that per-pair hero centering does NOT:

1. **Arc widget offsets.** Designer-placed arcs commonly aren't centered on their parent — e.g. an arc widget at `left=16, width=210` in a 256-wide card has its center at x=121, not x=128. Verify and fix as part of any layout pass.
2. **Icon `text_align: CENTER` lies when `adv_w ≠ box_w`.** A FontAwesome icon with `adv_w=32` in a label box of `width=36` and `text_align: CENTER` puts the glyph origin at `label.left + 2`. If `ofs_x` is nonzero in the glyph descriptor, the visible ink center drifts further. Use the per-glyph data from `LvglFont` to position the label so the actual ink centers on the axis, not the box.
3. **Per-row hero centering centers the PAIR BOX, not the visible content.** A pair with `value_box=44, unit_box=21` centered as a box pair has box-center at panel center, but visible "94" (43.25 px) + "%" (19 px) sit with their visible content center shifted ~1 px from panel center because the boxes contain slightly more slack than the text. For tight 1-row cards this is invisible; stacked rows in one card amplify the error.

The math for centering a value+unit pair's **visible content** on `axis_x`:

```python
# visible_left + visible_right = 2 * axis_x
# (value_box_right - value_text_w) + (unit_box_left + unit_text_w) = 2 * axis_x
# Substitute value_box_right = value_left + value_box_w, unit_box_left = value_left + value_box_w + gap:
value_left = (2 * axis_x - 2 * value_box_w - gap - unit_text_w + value_text_w) / 2
value_left = int(round(value_left))
unit_left  = int(round(value_left + value_box_w + gap))
```

For each row in a multi-row card, run this with the row's OWN hero text (volts uses `"13.7"`, percent uses `"94"`) — each row keeps its own hero width and font, but lands on the shared axis.

For icons, compute the ink-center position from the glyph descriptor:

```python
font = LvglFont("main/ui/ui_font_fa32.c")
gid  = font.glyph_id(0xf240)             # battery glyph
d    = font.descs[gid]
adv_w_px = d["adv_w"] / 16.0
# Want: glyph_origin_x + d["ofs_x"] + d["box_w"]/2 == axis_x
# glyph_origin_x = label.left + (label.width - adv_w_px)/2  (text_align=CENTER)
icon_label["left"] = int(round(
    axis_x - (icon_label["width"] - adv_w_px) / 2
           - d["ofs_x"]
           - d["box_w"] / 2
))
```

For the arc and any other rectangular widget that should sit symmetrically:

```python
arc["left"] = int(round(axis_x - arc["width"] / 2))
```

**Do NOT force a shared value→unit column across rows when their hero widths differ.** An earlier iteration of this skill recommended column-aligning the secondary row's value→unit boundary to the hero row's. That was wrong: the volts row's hero range (`12.0`–`13.8`, always 4 chars at RM18) is visually narrower than the percent row's hero (2 digits at RM36), and forcing them to share a column pushes the secondary pair off the card axis. The CORRECT rule is the card-axis rule above: each row keeps its own hero-width pair, centered on the shared vertical axis. The value→unit boundaries land at different x in each row, and that's fine — what unifies the stack is the axis, not the column.

Concrete reference: `GUI/tmp/center_card_axis.py` in Spotter centers arc, icon, hero `%` row, volts `V` row, and bottom status text all on `axis_x = 128` of a 256-wide card.

**Cross-card cards are independent.** Battery card and solar card each get their own axis at their own `card_w / 2`. The cards are positioned by the page layout — they don't share an axis with each other.

**Alignment is optimized for the hero range, not for every possible value.** This is intentional and the reason hero alignment exists. When an out-of-range value appears (`100%` instead of 2-digit `85`; `9.8 V` instead of 4-char `13.7`; `1100 W` instead of 3-digit `250`), the visible content shifts ±1 char width left or right of the card axis. That's correct — we cannot keep both 2-digit and 3-digit values perfectly axis-aligned simultaneously. We pick the case that runs 80%+ of the time and accept graceful drift on the edges.

**Monospace + hero-targeting is what makes this look polished.** Monospace fonts give every character the same advance width, so within the hero range every value is exactly the same pixel width — the axis math holds without per-string adjustment. As digits change inside the hero range, the visible content slides 0 px relative to the axis. Out of range, it slides by one whole `adv_w` per extra/missing character — a discrete, predictable shift, not a smooth jitter. Pair this with the card-axis rule and the dashboard reads as a clean column the vast majority of the time, with disciplined edge behaviour the rest.

### Per-data-field hero analysis — research per field, never inherit

The hero is a property of the **individual data field**, not the project, the screen, or any peer field. Two gauges next to each other (e.g. battery percent and solar wattage) come from different sensors and have completely different value distributions. Don't pick "the hero" once — pick it per field, every time.

**Process for each numeric field on the UI:**

1. **Identify what the underlying sensor or system actually reports** for this field. Battery SOC, solar wattage, axle load, coolant temp, RPM, fuel level, voltage, current, flow, pressure — each has its own operating envelope determined by the sensor's spec sheet, the calibration, and the real-world conditions of the deployed product.
2. **If you don't know the realistic value distribution, use the WebSearch tool to find it.** Search community forums, vendor docs, sensor calibration sheets, OEM specifications. Phrase the search for the **deployment context** that matters: "typical RV travel-trailer solar panel midday output watts", "OBD-II coolant temperature normal range", "marine bilge pump GPM realistic flow". Read the actual ranges users discuss, not the theoretical sensor range. The theoretical range almost always over-estimates the digit count.
3. **Pick the digit count that covers the bulk of normal operation** — the range the user will stare at for hours during normal use. Not the worst case; not the calibration case; not the cold-start value; not the diagnostic-screen value. The everyday operating reading.
4. **Consider duration as well as frequency when judging edge cases.** A value the system hits rarely AND only stays at briefly is the ideal edge case — even when it occurs, it shifts for moments. A value the system hits rarely but *lingers at for hours when it does* deserves a wider hero, even if time-weighted frequency looks low. The question is "how long will the user actually sit there reading a misaligned value?", not "how often does the value technically occur".
5. **Don't propagate one field's hero to another.** Field A's 2-digit hero says nothing about field B's hero — they're independent analyses. The "peer values share font size" rule (lowest common denominator across visually-equivalent gauges) constrains the **font size** the heroes render at, not the **digit count** of each hero.
6. **If you find yourself sizing for the worst case "just to be safe", stop.** The worst case is rare; optimize for the common case and let the edges drift. That's the dashboard aesthetic.

When this analysis lands well — per-field hero research + monospace fonts + card-axis centering — each numeric value sits in its expected axis position for the duration the user actually sees it. Out-of-range moments shift by discrete `adv_w` increments and resolve quickly. That's the polished dashboard look.

### Placeholders must match the hero advance width — not the hero meaning

The boot-time placeholder (`"--"`, `"--.-"`, `"---"`, etc.) written by C before the first MQTT/sensor reading lands MUST have the same character count as the hero text, not the same semantic meaning. The reason: `text_align: RIGHT` puts the placeholder against the right edge of the value box, sized for the hero advance. If the placeholder has fewer characters than the hero, it fills only the right portion of the box and the left portion sits empty — the placeholder ink-center lands off-axis by half the unused advance.

A row whose hero is 4 chars (`"13.7"` formatted as `%.1f`) needs a 4-character placeholder (`"----"`). A row whose hero is 3 digits (`"250"`) needs a 3-character placeholder (`"---"`). A row whose hero is 2 digits keeps `"--"`. This isn't aesthetic — the math requires it for the placeholder state to land on the card axis.

Concrete formula: `placeholder_char_count == hero_char_count`. Pick whatever filler character is in the font subset (dashes, spaces, dots, or even `"8888"` if you want a digit-shaped fill). The character matters less than the count.

This applies to every C placeholder written via `lv_label_set_text(objects.<value>, "...")` — both at boot and any "no data" / "sensor failed" / "stale" state your firmware might paint mid-session. Whenever you write a placeholder, count its characters against the hero count for that field.

**The geometry**:

```python
value_w = math.ceil(len(hero_text) * adv_w_per_char) + 1   # ceil + 1, never floor
unit_w  = mont_text_w(unit_size, unit_text) + 2            # +2 px Mont rendering variance
gap     = 4                                                # small visible space between
pair_w  = value_w + gap + unit_w
pair_x  = (parent_w - pair_w) // 2

value_node["left"]  = pair_x
value_node["width"] = value_w
value_node["longMode"] = "CLIP"
value_node["localStyles…text_align"] = "RIGHT"             # hero string fills box; edge cases overflow LEFT

unit_node["left"]  = pair_x + value_w + gap
unit_node["width"] = unit_w
unit_node["localStyles…text_align"] = "LEFT"               # unit pinned to the gap edge
```

When the hero value lands, the box fills exactly and the pair is dead-centered on `parent_w/2`. When an edge value lands (1 digit, 4 digits), it right-anchors and overflows **left** of the value box — into the icon-margin or other safe whitespace that's at a different y-band. Match the placeholder dash count to the hero digit count (`--` for 2-digit hero, `---` for 3-digit hero) so the boot preview also looks centered.

### Ink-centered placement (vertical)

For each row in each parent, pick a single `row_y_center`. Then every label whose ink belongs in that row gets:

```python
label_top = round(row_y_center - ink_mid(font, effective_text))
label["height"] = lvgl_line_height(font)
```

Where `effective_text` is the hero value (not the placeholder), so the digits' ink range — not the dash's tiny ink range — drives placement. Custom-font ink_mid comes from `LvglFont.ink_range()`; Montserrat ink_mid comes from `mont_ink_range(size, text)` (PIL is safe). The `font_metrics.py` helper in this skill directory has both.

Per-icon ink: don't average icons. The battery glyph is wider than tall (`box_h=24` in fa32); the round sun has `box_h=32`; the vertical bell has `box_h=33`. Each icon's ink range is different and each `label.top` is therefore different. `LvglFont.ink_range(icon_char)` per-icon handles this correctly without you having to think about it.

### Arc-aware icon placement

When an icon sits inside an `LVGLArcWidget` (gauge cards typically do):

```python
arc_outer_r  = arc_widget_h / 2
arc_inner_r  = arc_outer_r - arc_style.arc_width            # arc_width default 16, often 20
arc_center_y = arc_widget_top + arc_outer_r
arc_inner_top_y = arc_center_y - arc_inner_r                # topmost open-zone y

# Icon ink-center should sit inside the open zone, with clearance.
# 20 px below arc_inner_top is usually right — clear of the upper-corner
# arc stops at 135°/45° AND clear of the value text below.
icon_row_y = arc_inner_top_y + 20
```

The icon's per-glyph ink data (via `LvglFont`) ensures the icon's visible mass — not its label-box center — lands at `icon_row_y`. A horizontal battery and a round sun centered at the same `icon_row_y` will both visually sit at the same height, because each is positioned by its own ink range, not by box geometry.

### Two-pass edit-script pattern

The two axes are decoupled. Author them as two separate scripts in `<project>/tmp/`:

1. `align_ink_centers.py` — sets every label's `top` and `height` based on `row_y_center` + `ink_mid`. Run once after positions or fonts change.
2. `hero_recenter.py` — sets every pair's value/unit `left` and `width` based on hero digit count + adv_w. Run once after the hero values change (e.g. after gauge-domain research updates the expected display range).

Each script backs up the `.eez-project`, mutates idempotently, and is replayable. Gates 1/3/5 must pass after each.

Reference implementations are in the Spotter project's `GUI/tmp/`:
- `align_final.py` — ink-centered vertical pass (icons + text in mixed fonts)
- `hero_recenter.py` — hero-aligned horizontal pass (value+unit pairs)
- `fix_value_widths.py` — `ceil`-rounded widths + `longMode: CLIP` (Trap 20)
- `equalize_hero_weights.py` — peer-gauge font-size LCD enforcement

### `tmp/` scripts MUST be `.gitignore`d

Every edit script you write into a project's `tmp/` (or `GUI/tmp/`) is **local working state**, not source code. The user has flagged this multiple times across projects: agents keep committing single-use patch scripts and `.bak` backups into the repo, polluting history with files that have no shared meaning.

**First action on any new project** — verify `.gitignore` has:

```
# .eez-project backups left by one-shot patch scripts
GUI/*.eez-project.bak*
GUI/*.eez-project.bak.*

# All tmp/ directories are local working state — patch scripts, alignment
# scripts, generators, font conversion. Reusable tooling lives in the
# eezstudio skill at ~/.claude/skills/eezstudio/, never in the repo.
tmp/
GUI/tmp/
**/tmp/
```

If files are already tracked, `.gitignore` won't retroactively remove them. Run `git rm --cached -r GUI/tmp/ tmp/` to stage the un-tracking (files stay on disk; user commits when ready). Per the global rule, the agent does NOT run `git commit` — it stages and tells the user.

Reusable tooling — `font_metrics.py`, `verify_project.py`, `verify_no_canvas_divergence.py`, `render_pages.py` — lives in this skill directory (`~/.claude/skills/eezstudio/`). Copy into `tmp/` if a project needs an inline reference, but the canonical home is the skill. Never commit them into a project repo.

## Tier-by-tier propagation

When applying these rules to an existing project, work top-down through the hierarchy:

1. **STYLES** — fix here first; the cascade applies to every widget that references the style. A single style edit fixes dozens of widgets. Add `PAD0` per-side keys via a post-processor; pin user-widget root sizes with `min_*`/`max_*`/`align`.
2. **USER WIDGET ROOTS** (StatusBar, BottomDock, AlarmRibbon, …) — these need pinned sizes in the style. Each instance on a page inherits the rendered size from the style, not the instance's `width`/`height` attribute.
3. **PAGE-LEVEL CONTAINERS** (body panels, card panels) — already use the styles you just fixed, so most issues self-correct. Audit residuals.
4. **LEAF WIDGETS** (labels, icons, individual buttons) — measure with PIL; the `_resize_icon_labels` post-processor handles square icon containers; manual fixes only for the edges PIL/audit doesn't catch.

Apply rules 1–4 in that order and you eliminate 90 % of layout issues without page-by-page hand-editing. Hand-editing a page's coordinates should only be needed for genuine layout intent (column widths, card placement), not for fixing padding / clipping / theme problems — those belong upstream in the styles.



### Trap 1 — `pad_all` on a parent silently clips children at the right edge

LVGL applies the parent's `pad_all` value to the **content area** that its children render into. So a panel with `pad_all: 14` and `width: 1000` has a content area only **972 px wide**, and a child placed at `(x=836, w=150)` — which looks fine in the math — actually renders past the panel border by 14 px.

This is invisible to naive bounds-checking (the child's `x + w` is within the parent's outer width). The clipping only shows up in the EEZ Studio canvas or on-device.

**Rule:** any panel/button style whose children use absolute positioning must set `pad_all: 0`. Build whatever inner-margin look you want by leaving 12–14 px between the widget origin and the first child position — not by relying on `pad_all`. The most painful version of this trap was a `Card` style with `pad_all: 14`; every dashboard right-side button rendered with the right ~14 px of its label clipped.

Apply this discipline to: `Card`, `PanelDarkInset`, `StyleButtonDefault`, and any custom variants. LVGL's built-in button-style default also adds a few pixels of padding — explicitly override to zero in every interactive style.

### Trap 2 — `useStyle: "default"` is not a registered style

EEZ Studio's `defaultStyles` map (e.g. `"LVGLSwitchWidget": "SwitchDefault"`) is the proper fallback for unstyled widgets. The literal string `"default"` is not a style name — it's a sentinel valid only inside the inner `style` object (`widget.style.useStyle = "default"`), never on the top-level `widget.useStyle`. Setting `useStyle: "default"` produces a Check warning: *"Use style": "default" not found.*

**Rule:** in helper functions for `LVGLSwitchWidget`, `LVGLKeyboardWidget`, `LVGLTextareaWidget`, `LVGLDropdownWidget`, etc., default `use_style` to the registered style name (`"SwitchDefault"`, `"KeyboardDefault"`, …) rather than the sentinel. For widgets you don't have a named style for, omit `useStyle` from the JSON entirely — EEZ Studio falls back to `defaultStyles[<WidgetType>]`.

### Trap 3 — glyphs not present in the embedded font

Strings like `"Skip — Use Serial Only"` render with a `[]` square at the em-dash position when the font subset doesn't include U+2014. Same for non-ASCII separators like `·` (middle dot, U+00B7).

**Rule:** prefer ASCII characters in label text (`-`, `.`, `*`). If you need a typographic glyph, add its codepoint explicitly to that font's `lvglRanges`.

### Generation-time bounds + padding check

The naive bounds-checker that just compares `x + w > parent_w` is necessary but not sufficient. Carry the parent's `pad_all` and `border_width` into the recursion and validate against the **inner content rect**:

```python
def check_bounds(node, parent_inner_w, parent_inner_h, path):
    style = effective_style(node)
    pad = int(style.get("pad_all", 0))
    bw  = int(style.get("border_width", 0))
    inset = pad + bw
    for c in node.get("children", []):
        cw = c.get("width", 0); ch = c.get("height", 0)
        if c["left"] + cw > parent_inner_w - 2 * inset: ...   # overflow!
        check_bounds(c, cw, ch, path + " > " + ident(c))
```

Skip the check inside containers whose `widgetFlags` contains `SCROLLABLE` — scrolling list/grid content is supposed to overflow.

### Self-check by rendering the project to PNGs

Don't iterate by asking the user to open EEZ Studio after every change. Write a Python script (PIL / Pillow) that:

1. Loads the `.eez-project` JSON
2. Resolves the theme palette + style index
3. Walks each page's widget tree
4. Draws filled rectangles + text labels per widget, **applying the parent's `pad_all` when descending into children** (which is what EEZ Studio / LVGL will do at render time)
5. Emits one PNG per page under `tmp/preview/`

You read those PNGs back with the Read tool and spot overflow / overlap / wrong colors yourself. It's not pixel-perfect to LVGL (Font Awesome glyphs render as the literal `u+fXXX` string, no shadows/arcs/sliders) but it catches:

- Right-edge clipping from `pad_all` (the worst trap)
- Wrong column widths in mini-DRO / status-bar layouts
- Buttons drawn outside their parent card
- Hidden widgets that should be visible
- Wrong theme color applied to wrong widget

A working skeleton is at `tmp/render_pages.py` in the TrailCurrent FluidCNC Pendant project — copy and adapt.

**Workflow:** edit generator → run generator (`tmp/gen_eez_project.py`) → run renderer (`tmp/render_pages.py`) → Read the PNGs → iterate. Only ask the user to open EEZ Studio once the renderer says it looks right.

## Generator skeleton (Python)

Save this in the project's `tmp/gen_eez_project.py`. Iterate by editing the script and rerunning — never edit the JSON by hand.

```python
import hashlib, json, os

_seq = [0]
def oid(name):
    _seq[0] += 1
    h = hashlib.md5(f"proj:{name}:{_seq[0]}".encode()).hexdigest()
    return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}"

W, H = 1024, 600  # display size

# ---- helpers ----
def screen_widget(children, ls=None):
    """LVGLScreenWidget — STRICT schema, no extra fields."""
    return {
        "objID": oid("screen"), "type": "LVGLScreenWidget",
        "left": 0, "top": 0, "width": W, "height": H,
        "customInputs": [], "customOutputs": [],
        "style": {"objID": oid("ss"), "useStyle": "default",
                  "conditionalStyles": [], "childStyles": []},
        "timeline": [], "eventHandlers": [],
        "leftUnit": "px", "topUnit": "px", "widthUnit": "px", "heightUnit": "px",
        "widgetFlags": "CLICKABLE|PRESS_LOCK|CLICK_FOCUSABLE|GESTURE_BUBBLE|SNAPPABLE|SCROLLABLE|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER",
        "hiddenFlagType": "literal",
        "clickableFlag": True, "clickableFlagType": "literal",
        "checkedStateType": "literal", "disabledStateType": "literal",
        "states": "",
        "localStyles": {"objID": oid("sls")},
        "children": children,
    }

def user_widget_instance(ident, x, y, w, h, name):
    """LVGLUserWidgetWidget — has userPropertyValues, NO useStyle/context/inputsProperties/outputsProperties."""
    return {
        "objID": oid(f"uw:{ident}"), "type": "LVGLUserWidgetWidget",
        "left": x, "top": y, "width": w, "height": h,
        "customInputs": [], "customOutputs": [],
        "style": {"objID": oid("uws"), "useStyle": "default",
                  "conditionalStyles": [], "childStyles": []},
        "timeline": [], "eventHandlers": [],
        "identifier": ident,
        "leftUnit": "px", "topUnit": "px", "widthUnit": "px", "heightUnit": "px",
        "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE",
        "hiddenFlagType": "literal",
        "clickableFlag": True, "clickableFlagType": "literal",
        "flagScrollbarMode": "", "flagScrollDirection": "",
        "scrollSnapX": "", "scrollSnapY": "",
        "checkedStateType": "literal", "disabledStateType": "literal",
        "states": "",
        "localStyles": {"objID": oid("uwls")},
        "group": "", "groupIndex": 0,
        "userWidgetPageName": name,
        "userPropertyValues": {"objID": oid("upv"), "values": {}},
    }

# ---- assemble the project (colors, themes, styles, fonts, pages, widgets) ----
# ...
```

See `$PROJECT_ROOT/ExampleProjects/TrailCurrentFluidCNCPendant/tmp/gen_eez_project.py` for a fully worked example with 10 screens, 71 styles, 14 fonts, and 3 user widgets.

## Validation — always do before sending to the user

After generating, run these structural checks. Any failure is yours to fix, not the user's:

```python
def validate(d):
    # 1. Every useStyle / text_color / bg_color / text_font references a defined item
    known_styles = {s["name"] for s in d["lvglStyles"]["styles"]} | {"default"}
    known_colors = {c["name"] for c in d["colors"]}
    known_fonts = {f["name"] for f in d["fonts"]} | {f"MONTSERRAT_{n}" for n in
                   (8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48)}
    known_actions = {a["name"] for a in d["actions"]}
    known_widgets = {w["name"] for w in d["userWidgets"]}

    issues = []
    def visit(n):
        if isinstance(n, dict):
            if n.get("useStyle") and n["useStyle"] not in known_styles:
                issues.append(f"unknown style: {n['useStyle']}")
            if n.get("type") == "LVGLUserWidgetWidget":
                if n.get("userWidgetPageName") not in known_widgets:
                    issues.append(f"unknown user widget: {n.get('userWidgetPageName')}")
            for e in n.get("eventHandlers", []):
                if e.get("action") and e["action"] not in known_actions:
                    issues.append(f"unknown action: {e['action']}")
            for v in n.values():
                visit(v)
        elif isinstance(n, list):
            for x in n: visit(x)
    for p in d["userPages"] + d["userWidgets"]: visit(p["components"][0])
    return issues
```

Also confirm `set(LVGLScreenWidget keys) == {24-key canonical set}` (see the "CRITICAL" section above).

## When the file silently fails to load

Symptoms: EEZ Studio opens but Pages list is empty, Themes/Styles tabs empty, canvas blank. **No error dialog.**

This means EEZ Studio's loader rejected the project mid-parse. Don't guess — **bisect**. Take the user's file and produce a series of incremental files, each adding one feature layer on top of a known-good minimal baseline:

1. `minimal.eez-project` — 1 page, 1 label, no themes/styles/fonts/etc. Confirm this loads.
2. `step1` += themes + colors
3. `step2` += empty `lvglStyles` + `lvglGroups`
4. `step3` += named `lvglStyles` + `defaultStyles`
5. `step4` += fonts
6. `step5` += actions + variables
7. `step6` += user widgets (no page references)
8. `step7` += a full content page with user widget instances
9. `step8` == identical to the broken file

Have the user open each file and report which is the first to fail to show its Pages. That isolates the broken feature in one round-trip. The most common culprits are LVGLScreenWidget and LVGLUserWidgetWidget shape (see the CRITICAL sections above).

A "fails to load" file might still show **Check warnings** for missing fonts/colors/styles — those are validation messages, not load failures, and clear up as you add the referenced items.

## C-export workflow (what EEZ Studio produces and what you write)

EEZ Studio's Build (Ctrl+B) emits to `destinationFolder`:

| File | Content | Editable? |
|---|---|---|
| `screens.h` / `screens.c` | `objects` struct (one field per widget), `create_screen_<name>()` for each page | **NEVER** |
| `actions.h` | `extern void action_<Name>(lv_event_t *e);` for each declared action | **NEVER** |
| `vars.h` | `extern <type> get_var_<name>()` and `set_var_<name>(value)` | **NEVER** |
| `images.h` / `images.c` | converted bitmaps | **NEVER** |
| `fonts.h` | `extern lv_font_t <name>;` for each custom font | **NEVER** |
| `styles.h` / `styles.c` | named styles | **NEVER** |
| `ui.h` / `ui.c` | `ui_init()` and `ui_tick()` (calls create_screen_main, etc.) | **NEVER** |

You implement (in `main/`):

| File | Content |
|---|---|
| `main.c` | BSP init, NVS, optional WiFi, `bsp_display_start_with_config`, `ui_init()`, FreeRTOS event loop |
| `actions.c` | `void action_<Name>(lv_event_t *e) { ... }` for each declared action; dispatch on `lv_event_get_user_data(e)` for variant-encoding actions |
| `vars.c` | `<type> get_var_<name>() { return current; }` and `set_var_<name>(v) { current = v; }` |

The project's `CLAUDE.md` should call out the "never edit `main/ui/`" rule and the "edit in EEZ Studio, ask user to re-export" workflow — copy from Fireside's `CLAUDE.md` as a template.

## Project CLAUDE.md addition

When scaffolding a new EEZ Studio project, append this to the project root `CLAUDE.md`:

```markdown
# GUI Changes
**NEVER modify files in `main/ui/` directly** — they are auto-generated by EEZ Studio
and will be overwritten on export.

1. Edit `GUI/<project>.eez-project` (or regenerate it via `tmp/gen_eez_project.py`).
2. Prompt the user to open EEZ Studio and Build (Ctrl+B) to re-export.
3. Wire C logic in `main/actions.c`, `main/vars.c`.
4. `idf.py build` to verify.

EEZ Studio is the source of truth for the GUI. flowSupport is OFF; actions are
implemented in C and selected via `userData` on the event handler.
```

## Quick troubleshooting reference

| Symptom | Cause | Fix |
|---|---|---|
| Pages list empty, no error | Bad LVGLScreenWidget shape (extra fields) | Strip useStyle/identifier/group/scrollSnap*/flagScroll* from screen root |
| Pages list empty, no error | Bad LVGLUserWidgetWidget shape | Add `userPropertyValues`, remove useStyle/context/inputsProperties/outputsProperties |
| `Font not found for style property` warnings | Style references a font not defined in fonts[] | Add the font, or use built-in MONTSERRAT_<size> |
| `Color not found` warnings | Style references a color token not in colors[] | Add the color, or inline a hex literal |
| Black canvas with checkerboard | No bg_color on screen | Add StyleScreenDefault with `bg_color` and `bg_opa: 255` |
| `"Use style": "default" not found` warning | `useStyle: "default"` on the widget — not a real style name | Use a registered style name (`SwitchDefault`, etc.) or omit `useStyle` entirely |
| Buttons/labels clipped at the right edge inside a card | Parent style has `pad_all > 0`, LVGL shrinks the content area by that much on each side | Set `pad_all: 0` on the parent style; add visual margins via child x/y instead |
| `[]` squares in label text | Glyph not in the embedded font subset (em-dash, middle dot, etc.) | Use ASCII (`-`, `.`) or add the codepoint to `lvglRanges` |
| `undefined reference to objects` / `action_X` at link time | EEZ Studio Build hasn't run since the .eez-project changed | Open project in EEZ Studio → Build (Ctrl+B), then `idf.py build`. Guard C source with `#if __has_include("ui/screens.h")` to keep the project buildable in between |
| `undefined reference to objects` / `action_X` at link time, exports DO exist in `main/ui/` | `main/CMakeLists.txt` doesn't list `ui/*.c` in `SRCS` | Add `ui/screens.c`, `ui/styles.c`, `ui/images.c`, `ui/ui.c` (and any `ui/ui_font_*.c`, `ui/ui_image_*.c` per asset) to the SRCS block |
| `undefined reference to img_<name>` at link time | EEZ Studio is set to **source-mode** bitmap export (the default for LVGL projects). That emits `main/ui/ui_image_<name>.c` containing the bitmap data — but the export does NOT auto-register the new file in `main/CMakeLists.txt`. The next `idf.py build` then compiles `screens.c` (which references `img_<name>`) but never compiles the `.c` file that defines it. | Add `"ui/ui_image_<name>.c"` to the SRCS block, alongside the existing `ui_font_*.c` entries. This is the exact bitmap analog of the per-font SRCS entries — every bitmap registered in the .eez-project needs its matching `ui_image_*.c` listed in SRCS. **Whenever you add a bitmap to the project (whether via the GUI or a patch script), tell the user to add the corresponding `ui/ui_image_<name>.c` line to `main/CMakeLists.txt` before the next firmware build.** |
| `ui.h:NN: 'undefined' before 'void'` compile error inside the export | EEZ Studio could not substitute a template placeholder like `${eez-studio GUI_ASSETS_DECL}` and emitted the literal word `undefined` | Compare your `settings.build.files[].template` strings against Fireside's exactly — small wording differences (`LVGL_UI_DECL` vs `GUI_ASSETS_DECL`) prevent substitution. Update the generator and ask the user to re-export |
| `'ui_font_<name>' undeclared` in `styles.c`, or `undefined reference to ui_font_<name>` at link | **First check the `styles.c` template** — EEZ Studio's default only `#include`s `styles.h`, so the `ui_font_*` externs in `fonts.h` are invisible. **Then check `embeddedFontFile` on every font in the `.eez-project`** — if any is `""`, EEZ Studio's Ctrl+B silently emits no `ui_font_*.c` for that entry. | (1) Override the `styles.c` template to also `#include "images.h"` + `#include "fonts.h"` (see Fonts section). (2) Populate every empty `embeddedFontFile` with base64 of the source TTF/OTF (see Fonts section for the patch script), close and reopen the project in EEZ Studio, then Ctrl+B. **Do NOT run `lv_font_conv` yourself and do NOT write into `main/ui/` — that breaks the source-of-truth invariant.** |
| `'ui_font_montserrat_<n>' undeclared (first use in this function); did you mean 'lv_font_montserrat_<n>'?` | A Montserrat size is registered as a custom font when it should be the LVGL built-in | Switch the `text_font` reference from lowercase `"montserrat_<n>"` to UPPERCASE `"MONTSERRAT_<n>"`, remove the entry from the `fonts[]` array, and enable `CONFIG_LV_FONT_MONTSERRAT_<n>=y` in `sdkconfig.defaults` (never `sdkconfig` — the build regenerates it) |
| `fatal error: lvgl/lvgl.h: No such file or directory` in a `ui_font_*.c` | `lv_font_conv` was called with `--lv-include lvgl/lvgl.h`, but ESP-IDF's lvgl component exposes the header as plain `lvgl.h` | Use `--lv-include lvgl.h` and regenerate |
| C export produces unresolved `objects.btn_xyz` | Widget was added in code before EEZ Studio export ran | Edit the .eez-project, re-export from EEZ Studio, then `idf.py build` |
| User opens file but title shows a different name | The file failed to load — EEZ Studio shows the previously-open project or default | Bisect to find the offending feature |
| User reports visual problems you can't tell are real | Iterating blind | Run `tmp/render_pages.py` to generate per-page PNGs and Read them yourself before bothering the user |
| EEZ-authored switch or checkbox renders + accepts a tap (visible press animation) but never toggles LV_STATE_CHECKED / never fires VALUE_CHANGED — while a dynamically-created switch on the same screen works | EEZ Studio's default `widgetFlags` for LVGLSwitchWidget / LVGLCheckboxWidget is `CLICKABLE\|PRESS_LOCK` — no `CHECKABLE`. `lv_switch_create()` in C adds CHECKABLE for free; the EEZ export doesn't. Trap 21. | Append `\|CHECKABLE` to the widget's `widgetFlags` (or tick the **Checkable** checkbox in EEZ Studio's Flags panel), Ctrl+B, reflash |

## Don'ts

- Don't generate `screens.c`, `actions.h`, `vars.h`, etc. They are EEZ Studio's export output.
- **Don't write, edit, or `lv_font_conv` your way into ANY file under `main/ui/`** — not `screens.c`, not `styles.c`, not `ui_font_*.c`, not `ui_image_*.c`, not `fonts.h`, none of them. If a link error says `undefined reference to ui_font_<name>`, the fix is in the `.eez-project` (populate `embeddedFontFile`), not in `main/ui/`. See the Fonts section for the base64-populate pattern. Repo comments that claim otherwise are wrong — surface them to the user as debt, don't follow them.
- Don't reference styles, fonts, colors, actions, or user widgets that don't exist in the project — validate before saving.
- Don't use `flowSupport: true` unless the user wants EEZ-Flow (it changes everything).
- Don't hand-edit the `.eez-project` JSON in a text editor. Edit via a Python script in `tmp/` that takes a backup, mutates, and writes back — replayable, gate-able, rollback-able.
- Don't put `useStyle` on LVGLScreenWidget. Don't put it on LVGLUserWidgetWidget either.
- Don't set `useStyle: "default"` on any widget — it's not a valid style name.
- Don't leave `pad_all > 0` on a parent style whose children are absolutely positioned — the parent content area shrinks by `pad_all` on each side and clips children flush to its edges.
- Don't add fields you "think might be useful" — mirror a known-good sibling widget already rendering correctly in the project, or copy from a known-good reference (Fireside, Smart Home Low Res).
- Don't mix `flowSupport: false` with action implementations in EEZ Studio — actions are just declarations.
- Don't kick off `idf.py build` after editing the `.eez-project` without first asking the user to run EEZ Studio Build (Ctrl+B) — you'll chase phantom missing-symbol errors that only the export produces.
- Don't iterate blind on layout problems — for the rare cases when you need to check before the user reloads, render the pages to PNG (`tmp/render_pages.py`) and Read the PNGs yourself. Otherwise, the user reloading EEZ Studio is the visual check.
- Don't name a project utility script `bisect.py` (or any other stdlib name) — Python `import tempfile` pulls in `bisect`, and your file shadowing the stdlib silently executes at import time. Use a qualified name like `bisect_ladder.py`.
