# Zul Writer

> Generates ZK Framework ZUL pages (.zul) through a structured 5-step workflow: requirements clarification, ZUL generation, validation, controller generation, and a rendered-image self-review. Every step also stands alone, so use this skill for a single step too — validating an existing .zul, writing the Composer/ViewModel for a page that already exists, or just rendering a .zul to a preview PNG without touching it. Supports both MVC (Composer-based) and MVVM (ViewModel-based) patterns, ZK 9/10, and visual analysis for screenshot-to-ZUL conversion. Use when the user asks to create a ZUL page, build ZK UI components (forms, grids, dashboards, borderlayouts), convert an image/mockup to ZUL code, edit or extend an existing ZUL page, move a page's hard-coded data into a Composer/ViewModel, validate/fix a .zul that errors, preview/screenshot/see what a ZUL page looks like, or work out why a rendered page looks wrong — a blank icon, a clipped label, an element that is not there, a colour or width nobody asked for.

- Skill: `zkoss-demo/zul-writer` (Agent Skill, multi-file: 28 files)
- Install (CLI): `npx skillmds@latest add zkoss-demo/zul-writer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zkoss-demo/zul-writer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- License: MIT
- Author: zkoss-demo (https://skillmd.com/u/zkoss-demo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zkoss-demo/zul-writer

---

# ZUL Writer

## Workflow Overview

This skill creates well-structured zul pages through a 5-step process:

1. **Clarify Requirements** - Gather page purpose, pattern, and layout needs
2. **Generate ZUL** - Create the ZUL file based on requirements
3. **Validate ZUL** - Verify correctness of the generated ZUL
4. **Generate Controller Class** - Create the corresponding Java class (ViewModel or Composer)
5. **Preview & Self-Review** - Render the page to an image and check it against the requirements

**Alternative entry**: When user provides a UI image (screenshot/mockup), perform the **Visual Analysis** below first, then proceed to the 5-step process.

### Run only the steps the request needs

The five steps are entry points, not a chain. A page built from nothing needs all of them; most
other requests need one or two. Someone who hands you a finished `.zul` and asks what it looks like
wants Step 5 and nothing else — interviewing them about MVC vs MVVM, or "improving" their markup on
the way past, answers a question they did not ask and costs them a page they were happy with.

| What the user asks | Steps to run |
|---|---|
| "Build me a page that…", "turn this mockup into ZUL" | 1 → 5, the whole workflow |
| "Preview / screenshot / show me what `foo.zul` looks like" | 5 |
| "Why is this icon a blank box / this label clipped / this section missing?" | 5, then `--probe` the element |
| "Is this ZUL valid?", "why won't this page parse?" | 3 |
| "Does ZK 10 have a `<togglebutton>`?", "can `<charts>` take `sclass`?" | neither — answer from the schema with `--describe` (Step 2, guideline 3) |
| "Write the ViewModel for this page" | 4 |
| "Move this page's data into a ViewModel/Composer" | the extraction pass in Step 2 → 4 → 5 with `--run-controllers` |
| "Add a column to this grid", "make the sidebar narrower" | 2 on the existing file → 3 → 5 if the change is visible |
| "Build a page for this ViewModel/Composer we already have" | 1 → 5, but there is nothing to extract — see *Model-driven pages* in Step 2 |

**A model-driven page runs Step 5 twice, and the second time is not a fix round.** The numbered
steps are the order for a page whose data is still in the markup; once Step 5 has settled the
layout, the extraction pass in Step 2 moves that data into the controller and one more
`--run-controllers` render checks it. So the shape is 1 → 5, then back to 2 → 4 → 5 once. That last
loop is easy to forget precisely because it runs backwards through the numbers.

**"Why does this look wrong?" is a diagnosis request, not a validation one.** The two rows sit
together because they are so easy to confuse: *why won't this page parse* and *why is this icon a
blank box* read alike, and Step 3 answers only the first. `<label sclass="z-icon-bell"/>` is valid
ZUL — the validator passes it and reports the page clean, which is precisely how a real defect
gets closed as "no problem found". A page that renders wrong while validating clean is the case
Step 3 cannot see. The markup is already written; the question is what the browser did to it, and
only a render can answer that. Go to Step 5 and probe the element, so the measurement names the
cause before you touch the markup — opening with a guess costs a round and can "fix" the wrong
line convincingly. Naming the cause is the whole answer to a "why"; the edit is a separate
question, and it is the user's to ask.

The steps you skip still feed the ones you run — Step 4 needs to know whether the page is MVC or
MVVM, Step 5 needs something to judge the render against. Read those out of the file and the user's
message rather than restarting Step 1: an existing `.zul` already states its pattern, its layout and
its ZK version far more reliably than an interview would. Ask only what you genuinely cannot infer.

**Called in for one step, report rather than rewrite.** Validating or rendering a page you did not
write surfaces things nobody asked you to change. Say what you found, then make only the edit that
was requested — or wait to be told to fix the rest. The exception is the defect that blocks the step
itself: a page that will not parse cannot be rendered, so name it and offer the fix.

---

## Visual Analysis (for Images/Mockups)

When a UI screenshot or mockup image is provided, perform this analysis **before** starting the 5-step workflow:

1. **Visual Breakdown**: Identify all UI elements (layout, inputs, buttons, tables, navigation).
2. **Component & Layout Strategy**: Plan the ZK component mapping (refer to [references/ui-to-component-mapping.md](references/ui-to-component-mapping.md)) and determine the overall layout (e.g., `<borderlayout>`, nested `<vlayout>`).
3. **Tab Content Scope**: If tabs are present, determine content boundaries. Items switching with tabs must go INSIDE `<tabpanel>`. See [assets/content-tabbox.zul](assets/content-tabbox.zul).
4. **Identify Custom Styling**: Mark areas that require fallback HTML elements or custom CSS.

**Transition**: Use these findings to inform **Step 1: Clarify User Requirements** and eventually **Step 2: Generate ZUL File**.


---

## Step 1: Clarify User Requirements

Ask targeted questions to understand needs. If starting from an image, use the results of the **Visual Analysis** to inform these questions.

Ask only what is still open. The request, the project and any existing file answer several of these
already — a version in `pom.xml`, a pattern visible in the markup you were pointed at, a layout
described in the prompt — and re-asking those reads as though you did not look.

Sometimes there is nobody to ask — a scripted run, a batch job, a user who has gone quiet. That is
not a reason to stop, and it is not a licence to invent an answer either: see *When there is no one
to ask* after the questions.

### Questions to Ask

#### 1. ZK Version
Detect from user's project (check `pom.xml`, `ivy.xml`, or `build.gradle` for ZK dependency version). If not found, ask:
- 9 or before
- 10.x

#### 2. Page Purpose
- Data entry form
- Data list/grid display
- Dashboard with multiple sections
- Dialog/popup window
- Master-detail view
- Search and results page
- Other: [specify]

#### 3. MVC or MVVM Pattern
Present both options with equal weight — do NOT mark either as "(Recommended)":
- **MVVM**: ViewModel-based with `@bind`/`@command` data binding — testable, requires more ZK familiarity
- **MVC**: Composer-based with `apply` and wired components — straightforward, beginner-friendly

The line between them is exact, so there is never a page you cannot classify: **MVC applies a
Composer** (`apply="com.foo.MyComposer"`), **MVVM sets a ViewModel and uses binding syntax**
(`viewModel="@id('vm') @init('com.foo.MyVM')"` plus `@load`/`@bind`/`@command`). Either one is a
controller, and it governs the component it is set on **and every descendant of it**.

**Write one pattern per page.** ZK permits mixing them and some projects do, but a mixed page has no
single answer to "where does this value come from", which is the question both the extraction pass
and the Step 5 self-review depend on. If the user asks for both, ask which one the page's data
should follow.

If this question goes unanswered, see *When there is no one to ask* below — it is the one question
where the project's existing pages get a say before the default does.

#### 4. Static Data or Model-Driven

Ask this as its own question. It is independent of MVC/MVVM — that choice decides where *behaviour*
lives, this one decides where *data* lives — and it changes the order the work happens in:

- **Static data**: the text and rows are written in the ZUL — `<label value="Acme Corp"/>`, and rows
  spelled out as `<row>` in a grid, `<listitem>` in a listbox, `<treeitem>` in a tree. Right for a
  layout, a mockup, a demo, anything whose content is fixed.
- **Model-driven**: the controller supplies them — a Composer calling `setModel()` on a component it
  wired, or a ViewModel the page reads through `@load`/`@bind` and a bound `model`. Right for
  anything backed by real data.

**A component gets its rows from one of these, never both.** Setting a model discards the rows
written in the markup, silently, so literal rows left beside a model are markup that displays
nothing at all.

Either answer still gets a controller in Step 4. Model-driven pages are additionally built in two
passes — see *Model-driven pages* in Step 2.

#### 5. Layout Requirements
- Borderlayout (north/south/east/west/center)
- Vertical layout (vlayout)
- Horizontal layout (hlayout)
- Grid-based layout
- Tabbed layout (tabbox)
- Combined layouts

#### 6. ZK Charts (only when charts are needed)

If the ZUL page requires a `<charts>` component, follow [references/charts-guidelines.md](references/charts-guidelines.md) before generating any chart code.

#### 7. Theme and Data Density

If a page is designed to show a high density of data, suggest to the user to use another free theme called `iceblue_c`, a compact theme that has smaller padding, margin, and font-size.

### When there is no one to ask

Every question above has an answer to fall back on, so an unanswered question costs a stated choice
rather than the whole run:

| Question | Unanswered → use | Why this one |
|---|---|---|
| 1. ZK version | **10.x** | Not a new decision — `validate-zul.py` already defaults to `10` and `preview-zul.py` to 10.2.1. A different default here would set the skill writing against its own validators. |
| 2. Page purpose | **No default — read it out of the request** | The user always described the page they wanted. There is nothing to guess, and a fixed default would overwrite something already said. |
| 3. MVC or MVVM | **MVC**, unless the project is unanimous — see below | Composer-and-`@Wire` is the smaller thing to get right when nobody is available to correct it. |
| 4. Static or model-driven | **Static (literal rows)** | This is where a page starts anyway: Pass 1 of a model-driven page is literal rows. Choose model-driven only when the request names a real data source. |
| 5. Layout | **No default — derive it from the purpose, and say how you derived it** | Layout is the most concrete part of any request or mockup. A fixed default here means ignoring what you were given. |
| 6. ZK Charts | Not applicable — asked only when the page needs a chart | — |
| 7. Theme and density | **Keep the project's theme**, and note in the report that `iceblue_c` exists | Switching themes repaints every page in the app. That is not something to do while nobody is looking. |

**Name every default you used, in the final report.** A default is worth having because it is
predictable and can be checked afterwards; a default nobody is told about is neither.

#### What the project already does (question 3 only)

A project where every existing page is MVVM is evidence, not a guess — adding one MVC page to it
makes the codebase worse in a way the default cannot see. So for question 3, and only question 3,
look before falling back:

```bash
uv run <skill-base-dir>/scripts/detect-pattern.py <project-root>
```

It reads the ZUL side only, because that is the side where both patterns declare themselves in an
attribute. The Java side cannot be counted honestly: a ViewModel is a plain class, `@Init` is
optional so counting it misses ViewModels that do without one, and counting `Composer` subclasses
sweeps in every base class and helper that was never applied to a page.

```
PATTERN: mixed (MVC 6, MVVM 2)
USE: mvc -- the project uses both, so it has no single answer to give
```

Follow the project **only when it is unanimous** — `PATTERN: mvc` or `PATTERN: mvvm`. Anything else,
including a lopsided 6-to-2, leaves the MVC default standing, because a project that already mixes
patterns is not telling you which one it wants. Either way, say in the report what the scan found
and which answer you used.

The other six questions do not work this way. A pattern is one architectural decision that holds
across a codebase; a page's purpose and layout are different on every page, and the ZK version comes
from the build file rather than from a survey.

---

## Step 2: Generate a ZUL File

### Generation Guidelines

When generating the ZUL file, follow these technical guidelines:

1. **Map UI Elements**: Consult [references/ui-to-component-mapping.md](references/ui-to-component-mapping.md) to choose the correct ZK components. 
   - Prioritize ZK components over native HTML.
   - Use layout components like `<borderlayout>`, `<vlayout>`, and `<hlayout>` effectively.
2. **Style through classes, not through `style` attributes**:
   - Put the page's CSS in one `<style>` element near the top of the file — **not** the `<?style ?>`
     processing instruction — and attach it with `sclass` on ZK components (`class` on native `n:`
     elements). Name each class for what the thing *is* (`sclass="stat-card"`), and give the page's
     classes a short prefix of their own so they cannot collide with ZK's `z-` classes or with
     another page's CSS.
   - **Why this is not just taste.** A `style` attribute is rendered onto the widget's own element,
     where it outranks every rule any stylesheet can write: the page stops being themeable, CSS
     written later silently loses to it, and no `:hover`, `:focus` or `@media` rule can ever reach
     it. Declarations pasted onto a dozen components also drift into a dozen near-identical values,
     where one class is edited once and every instance follows.
   - **When a class looks like it "doesn't work", the theme is out-specifying it** — `.z-button` is
     more specific than `.my-btn`, so the theme wins. Qualify the selector
     (`.my-page button.my-btn { … }`) rather than reaching for `style`; that reach is exactly how a
     page ends up inline-styled all the way down.
   - **Size and spacing belong to the component, not to the CSS.** `hflex`/`vflex`, `width`,
     `height`, `spacing` and `valign` are the component's own API and cooperate with ZK's layout
     engine; recreating them in CSS is how flex layouts break. Let the component own the box and the
     class own the appearance.
   - **One honest exception: a value that only exists at runtime.** A colour or width that comes from
     data (`style="@load('background-color:'.concat(tag.color))"`) cannot be a static class. Keep
     that one declaration inline and leave the rest in the class.
   - If fallback native HTML elements (e.g. `<n:div>`) are used, include the CSS they need in that
     same `<style>` block.
3. **Ask the schema before you write — it is bundled, exact, and free**:

   Before writing a component you have not used before, or putting an attribute on a component you
   have not put it on before, ask:

   ```bash
   uv run <skill-base-dir>/scripts/validate-zul.py --describe <component> [--attr <name>] --zk-version <detected-version>
   ```

   ```bash
   # Does <charts> take sclass?  -> no: className or zclass
   uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --describe charts --attr sclass
   # Does <togglebutton> exist in ZK 10?  -> no such component
   uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --describe togglebutton
   ```

   **Do this instead of guessing between two spellings you both half-remember.** The usual failure
   here is not ignorance of how to build a grid — it is knowing three plausible spellings and
   picking a wrong one. Validation catches that afterwards, at the cost of a whole round; the same
   schema asked first answers immediately and locally. An attribute absent from the printed list is
   not accepted, however plausible it looks.

   One limit to respect: the bundled schema is a 10.x document. For a ZK 9 target its absences are
   suggestive, not conclusive, and it says so when that applies.

4. **ZK Documentation**:
   - Query the `zk-doc` MCP server (tool: `search_zk_docs`) for detailed component info if available.
   - Use [ZK Javadoc](https://www.zkoss.org/javadoc/latest/zk/) for properties and event details.
5. **Best Practices**:
   - Prefer `hflex`/`vflex` over fixed pixel widths for responsive layouts. `hflex="min"` sizes a component to fit its content — useful for a `<button>` sitting beside an `hflex="1"` field (see [assets/flexible-sizing.zul](assets/flexible-sizing.zul)).
   - Use meaningful IDs and follow the [assets/template.zul](assets/template.zul) structure.
   - **Never put `--` inside an XML comment.** XML forbids it anywhere between `<!--` and `-->`, so
     the `<!-- ---------- Left column ---------- -->` separator that is perfectly good Java style is
     a hard parse error in a ZUL. Use `=`: `<!-- ===== Left column ===== -->`.


### Model-driven pages: write the data in, then take it out

A page whose values come from a controller cannot show you itself until that controller runs. Until
then `@load(vm.customer)` renders as dimmed expression text and a bound `model` renders as a couple
of placeholder rows — so column widths, wrapping, card heights and whether a row of stats fits are
all being judged against text that is not the text the page will hold. That is how a page passes
Step 5 and still comes out wrong the first time it is run for real.

**Which of the two paths you are on is a lookup, not a question.** Do not ask the user; the answer
is in front of you. A page you are writing from nothing needs a controller you have not written yet
— in ZK a Composer or ViewModel belongs to a page, not to a shared service layer, so a new page
means a new controller and the two-pass path below. Only an existing `.zul` can already name a class,
and then you open it: if it exists and compiles, there is nothing to extract — render with
`--run-controllers` from the start and judge the real data.

**What counts as data.** A value that changes when the data changes is data. A word that stays put
no matter what the data says is chrome, and chrome belongs in the ZUL permanently — column headers,
button captions, section titles, units, empty-state messages, validation hints. Cell values, list
items, counts, amounts and status labels are data. The test is not "is it text": it is *would this
word still be right tomorrow, against tomorrow's rows*.

#### Pass 1 — literal data, real shape

Write the first version with literal values, shaped like the data that will replace them: a name of
realistic length, a price with its real digits, enough rows to fill the region. Rows go in as
markup — `<row>` in a grid, `<listitem>` in a listbox, `<treeitem>` in a tree. Literals render as
themselves, so the Step 5 screenshot is the page the user will actually get, and this pass is
*cleaner* than a bound one: with no `@load` anywhere there is not a placeholder on the page.

**The controller you write in Step 4 for this pass holds behaviour, not data** — the event handler,
the `@Command`, the wiring. Putting the data in it now cancels the whole point of the pass: Step 5
would render real rows through `--run-controllers` and you would be back to judging the layout
against text you never checked.

Iterate here until the layout and the styling are right.

**A chart has no Pass 1.** `<charts>` has no literal form — a series cannot be spelled out in the
markup the way a `<row>` can — so the two-pass shape does not exist for it. Write the chart's data
into the controller from the start and render that region with `--run-controllers` from its first
render, while the rest of the page still goes through the literal pass. The cost is that this page's
first render now depends on the controller compiling, so read the `CONTROLLERS:` line before
concluding anything about a chart that is missing: a stale `.class` looks exactly like a chart that
was never drawn.

#### Pass 2 — extraction, after Step 5 has settled the layout

This is the one action in the workflow that runs **after** Step 5 rather than before it, so it is
easy to skip. Its trigger is "the layout is settled", which is a Step 5 outcome. Four actions, in
order:

1. **Move each literal into the controller** as a field, getter or list.
2. **Point the ZUL at it.** MVVM replaces the value with the binding that reads it
   (`@load(vm.customer.name)`, `model="@load(vm.items)"`). MVC has no ZUL-side expression at all:
   the Composer wires the component by its `id` and calls `setModel(...)`, so the ZUL keeps the
   `id` and gains nothing else.

   **A `<template name="model">` is the exception, and the two patterns read it differently.** A
   template repeats markup per item, so it needs an expression whichever pattern the page is:

   | Page | Inside the template | The template variable |
   |---|---|---|
   | MVVM | `@load(node.data.name)` | the binder honours `var="node"` |
   | MVC | `${each.data.name}` — plain EL | always `each`; a custom `var` is **silently ignored** |

   Get that the wrong way round and it costs a round, because an unresolvable variable renders as
   **empty text rather than an error**: `${node.data.name}` on an MVC tree produced a tree with
   correct structure, indentation, open state and selection, and every label blank. Only a probe
   with `${each.name}` gave a real message — `Property 'name' not found on type DefaultTreeNode` —
   which is also what names the second half of the rule: for a **grid or listbox** template `each`
   is the item itself (`${each.action}`), but for a **tree** it is the `TreeNode`, so the data is
   one hop further in (`${each.data.action}`).
3. **Delete the literal rows.** They are not harmless leftovers. Setting a model — bound or through
   `setModel()`, full or empty — **discards the rows written in the markup**, silently and with no
   warning, so the page renders correctly while the markup keeps rows that display nothing. Nobody
   looking at the image can see this, which is why `literal-rows-discarded` in Step 5's `LAYOUT`
   block measures it for you.
4. **Change nothing else** — same components, same `sclass`, same `hflex`. Extraction moves values,
   not structure, so if the page shifts afterwards the extraction is what to look at.

**Two schema complaints to expect here**, both false positives of the bundled XSD rather than
anything wrong with the page — Layer 2 is stricter than ZK is:

- A `<tree>` whose only child is the `<template>` fails: `treeType` demands a `treecols` or
  `treechildren` the moment the tree has any child at all. Add an empty `<treechildren/>` and it
  passes. The model still fills the tree, and an empty element is not a literal row.
- A `<listbox>` carrying literal `<listitem>`s without a `<listhead>` fails the same way. A
  `<listhead>` is usually wanted anyway.

A `<grid>` with `<columns>` plus a template is accepted as written. See the `B1` group in
`test/known-failures.txt` for the rest of this class.

Then re-render once with `--run-controllers` to confirm it did not. That render checks the
extraction, not the layout, so it is not a fix round — and anything it does find is fixed on Pass
2's own budget, not on what is left of Pass 1's. See *How many rounds* in Step 5.

### Layout & Component Patterns

#### XML & Pattern Structures
- **Base Template**: [assets/template.zul](assets/template.zul)
- **MVC Structure**: [assets/mvc-sample.zul](assets/mvc-sample.zul)
- **MVVM Structure**: [assets/mvvm-pattern-structure.zul](assets/mvvm-pattern-structure.zul)

#### Sizing & Layouts
- **Flexible Sizing (hflex/vflex)**: [assets/flexible-sizing.zul](assets/flexible-sizing.zul)
- **Borderlayout Example**: [assets/borderlayout-example.zul](assets/borderlayout-example.zul)

#### Common MVVM Patterns
- [Form with Validation](assets/form-validation-mvvm.zul)
- [Data Grid with Selection](assets/data-grid-selection-mvvm.zul)
- [Master-Detail Pattern](assets/master-detail-mvvm.zul)
- [Dialog/Popup](assets/dialog-popup-mvvm.zul)

---

## Step 3: Validate Generated ZUL

Run validation using the script from this skill's base directory (provided as "Base directory for this skill:" in the skill context header). Pass the ZK version detected in Step 1 via `--zk-version` so Layer 4 checks match the target:

```bash
uv run <skill-base-dir>/scripts/validate-zul.py --zk-version <detected-version> <path-to-zul-file>
```

Example: if the skill base directory is `~/.claude/skills/zul-writer` and the project targets ZK 10.3.0, run:
```bash
uv run ~/.claude/skills/zul-writer/scripts/validate-zul.py --zk-version 10.3.0 path/to/file.zul
```
- Layer 1: XML well-formedness (no dependencies). Multi-root fragments are auto-wrapped in `<zk>` before validating.
- Layer 2: XSD schema validation (requires `lxml`)
- Layer 3: Attribute placement check (requires `lxml`) - catches misplaced attributes (e.g. `iconSclass` on `textbox`)
- Layer 5: inline-style advisory — lists static `style="..."` attributes that belong in a `<style>`
  class attached with `sclass`. It reports and never fails the run, so treat each line as a defect
  to fix rather than noise to pass over; a data-driven `style="@load(...)"` is skipped on purpose.
- Layer 4: version compatibility checks for the target ZK version — removed/deprecated API for all targets, plus ZK-10-only API (e.g. dropped `<fragment>`, or new `accept`/`responsive` attributes) gated by `--zk-version`. Defaults to `10` if omitted.
- Layer 6: runtime semantics — markup that is legal by every static measure and still throws while
  the page is being built. Today that is a literal `selectedIndex`: `<combobox selectedIndex="0"/>`
  passes Layers 1-5 and dies with `Out of bound: 0 while size=0`. The index is applied before the
  component's own children are attached, before any controller runs and before the binder sets a
  model — so on `combobox`, `listbox`, `radiogroup` and `tabbox` it throws **whatever the markup
  says**. Writing the items out does not help, and neither does `model="..."`: a model-driven
  `<listbox model="@load(vm.items)" selectedIndex="0">` throws identically. Express the selection
  instead — `value="..."` on a readonly combobox, `selected="true"` on the `<listitem>` / `<radio>`
  / `<tab>`, or a controller call after the model is in place.
  **`selectedIndex="-1"` is not the universal escape hatch it reads as.** Measured identically on
  ZK 9.6.6 and ZK 10.3.0.1: `<tabbox selectedIndex="-1">` still dies with `No tab at all`, because
  the setter wants a `<tabs>` child that does not exist yet — no literal value is safe on a tabbox.
  `<cardlayout selectedIndex="-1">` dies with `Out of bound: -1 while size=2`: its cards *are*
  attached first, so it bounds-checks the value and has no "nothing selected" state; a card index
  inside `0..cards-1` is fine. `selectbox` tolerates every literal value, with a model and without
  one, so the layer says nothing about it at all.
- Layer 7: controller cross-check — **only runs when you pass `--controller`** (see Step 4).

### Prerequisites
Layer 2 and 3 require `lxml`. **`uv run` handles this automatically** via the script's PEP 723 inline metadata — it provisions `lxml` in an ephemeral environment, so no manual setup is needed. If `uv` is unavailable, run with a plain interpreter instead and the script self-installs `lxml` as a fallback:

```bash
python3 <skill-base-dir>/scripts/validate-zul.py --zk-version <detected-version> <path-to-zul-file>
```
(On Windows, use `python` instead of `python3`.)

### Usage Tracking
Running this script also fires an anonymous, aggregate usage ping (skill name + version only, no identifier) on a background thread — it never delays or blocks validation. Opt out with `DO_NOT_TRACK=1` or `TRACK_URL=""`, or per-run with `--dev` — which is what runs made while developing or testing the skill itself should pass, so they are not counted as usage.

### Asked only to validate

Report the layers that failed, quote the lines the script names, and say what each one means — then
stop. A validation request is a request to be told what is wrong; edit the file when the user asks
for the fix, or when fixing it is the task you were already on.

### Post-Validation Checklist

#### Pattern Consistency
- **MVC**: Uses `apply` attribute, no MVVM binding expressions
- **MVVM**: Uses `viewModel` attribute, proper binding syntax
- One pattern for the whole page, not one per component
- No component carries both a `model` and literal rows — see *Model-driven pages* in Step 2

#### Best Practices
- IDs are unique within each ID space owner (`<window>`, `<idspace>`)
- Appearance lives in `<style>` classes attached with `sclass`; the only `style` attribute left is
  one whose value comes from data. Layer 5 lists any others it found
- Prefer `hflex`/`vflex` over fixed dimensions
- Include meaningful labels and tooltips for accessibility


## Step 4: Generate Controller Class

Generate the corresponding Java controller class (ViewModel or Composer) for the ZUL page. 

**Generate it for a static-data page too.** The values may be fixed, but the page will still have to
*do* something, and what a developer needs from you is the shape of that attachment: how a Composer
wires a component and listens for its event, how a ViewModel declares a `@Command` and what the ZUL
writes to invoke it. Leaving it out withholds the one part that is ZK-specific and hard to guess. So
include at least one working handler on something the page really has — the Save button, the row
selection, the search box — acting on the values already in the markup.

**Behaviour now, data later.** Whether the page is static or model-driven, what you write here is
the page's *behaviour*. On a model-driven page the data is still in the markup at this point and
belongs there until the layout is settled — so no `setModel()`, no getter backing a bound `model`,
not yet. Both arrive in the extraction pass (see *Model-driven pages* in Step 2), and this step runs
a second time to receive them. Writing the data in now is the single easiest way to lose the literal
pass without noticing: Step 5 would render real rows and the layout would never be judged against
anything you checked.

**Except a chart.** `<charts>` has no literal form, so its model belongs here from the first render
while everything else on the page stays literal — see *A chart has no Pass 1* in Step 2. Stripping a
chart's data back out to obey the paragraph above leaves an empty chart that reports no error.

### Controller Generation Guidelines

1. **Pattern Consistency**: 
   - Use **ViewModel** for MVVM patterns.
   - Use **Composer** for MVC patterns.
2. **Implementation Details**: Follow the technical requirements in [references/controller-guidelines.md](references/controller-guidelines.md).
3. **Cross-check the wiring against the ZUL before you finish.** Re-run the validator with
   `--controller`, which turns on Layer 7:

   ```bash
   uv run <skill-base-dir>/scripts/validate-zul.py <path-to-zul> --controller <path-to-controller>
   ```

   It reports two defects that **nothing else in this workflow can see** — not the other layers, not
   the Step 5 render:
   - a `@Wire` field whose type is a different component than the id it names. This compiles, passes
     every validation layer, renders correctly, and then throws `ClassCastException` the first time
     the field is used. An earlier run shipped `@Wire Label` on an `<a>` and only caught it by
     re-reading its own composer.
   - a `@Wire` field naming an id no component in the ZUL declares. The field stays null and the
     first use throws `NullPointerException`.

   Layer 7 stays silent wherever it cannot be certain — component families where one class inherits
   from another (`Textbox`/`Combobox`, `Checkbox`/`Radio`, `Box`/`Hbox`, `Row`/`Group`,
   `Listitem`/`Listgroup`, `Button`/`Combobutton`), base-class field types like `Component`,
   collections, non-`#id` selectors, and pages containing an `<include>`. **A silent Layer 7 is not
   a guarantee of correct wiring**; it means nothing it can prove is wrong.

#### MVC Pattern - Composer Class
[assets/MyComposer.java](assets/MyComposer.java)

#### MVVM Pattern - ViewModel Class
[assets/MyViewModel.java](assets/MyViewModel.java)

### Complete Examples & Patterns

For complex UI patterns like Kanban Boards or Dashboards, and for complete template examples, refer to [references/use-case-guidelines.md](references/use-case-guidelines.md).

---

## Step 5: Preview & Self-Review

Render the finished page to an image, **look at it**, and check it against the requirements gathered in Step 1. Steps 2–4 only ever see markup; this is the only step that sees what the page actually looks like.

**Asked only for a preview**, there are no Step 1 answers to judge against — so judge the render
against the page's own markup and whatever the user said they expected, describe what you see, and
report the image path. The *What to fix* list below still tells you what counts as a defect worth
mentioning; it does not license editing a file the user asked you to look at rather than change.

Run the preview script from this skill's base directory (same convention as Step 3):

```bash
uv run <skill-base-dir>/scripts/preview-zul.py <path-to-zul-file>
```

Example: if the skill base directory is `~/.claude/skills/zul-writer/` and the page lives in a Maven webapp, run:
```bash
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py src/main/webapp/index.zul
```

**Where the image goes: the current working directory.** With no `--out`, the PNG is written to the
directory you are working in, named after the page — `index.zul` gives `./index-preview.png` — and
the `SCREENSHOT:` line reports the exact path. Leave it there. This image is not a scratch file: it
is the one visual artifact of the whole workflow, the thing the user opens to see whether the page
matches what they asked for, so it belongs beside their work where they can click it, keep it, or
delete it. Sending it to a temp or scratchpad directory instead hides it behind a path they would
have to be told about and cannot find again later. Re-renders overwrite the same file, so the path
stays valid across fix rounds and is still the right one to report at the end. Pass `--out` only
when the user names a destination, or when a corpus/CI job needs the images collected somewhere.

If this session wrote the page's controller, append `--run-controllers` (read the next paragraph
before you do — it executes project code):
```bash
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py --run-controllers src/main/webapp/index.zul
```

**When to pass `--run-controllers`.** Pass it when this session wrote the page's controller
(Step 4's composer or ViewModel): the sample data in it is yours, running it is what turns a
skeleton screenshot into a judgeable one, and the flag makes bound values, model-bound rows and
composer-filled labels real. It is also the render that checks an *extraction*: after moving a
page's literals into its controller (Step 2), this is what shows the page still looks like the one
you approved. Do **not** pass it for a page whose controller you did not write —
the flag **executes arbitrary project code** from the project's classpath (constructors, service
calls, whatever `doAfterCompose` does), so it is opt-in per render and never a default. If the
controller has not been compiled yet, build first (`mvn compile` / `gradle classes`); the script
warns when no compiled classes are on the classpath. Add `--controller-timeout <seconds>` only if
a legitimately slow page keeps degrading (the default budget is 10 s for the whole render).

**Viewport: `--width`, `--height`, `--full-page`.** The default viewport is 1280x900; the `SIZE:`
line in the output always says which viewport the render actually used.

- **Match the mockup's width.** When the user supplied a screenshot or mockup, pass `--width` at
  approximately that image's pixel width, so the two images compare like for like — a 1600 px
  mockup means `--width 1600`. Rendering the 1280 default against a wider mockup adds differences
  that are yours, not the page's, and you will spend fix rounds on them. Match the *layout* width,
  not the file's pixel count: halve a high-DPI export, and do not follow a thumbnail far below 1024
  unless you mean to test a narrow viewport — a very narrow render manufactures `clipped-text`
  findings that the same markup does not produce at desktop width.
- **`--full-page` when the page flows past the fold** — long forms, stacked reports, anything meant
  to scroll. It stitches the whole scrollable page into the PNG.
- **`--height` when the page is a vertical flex shell.** A page whose root region is `vflex` is
  exactly viewport-tall by construction, so `--full-page` cannot show more of it: raise `--height`
  instead. `hflex` is a width and does not do this — an `hflex` page flows past the fold like any
  other, and there `--full-page` is the right flag. A `--full-page` capture that comes back exactly
  as tall as the `SIZE:` viewport is telling you the page is flex-sized, not truncated.

```bash
uv run ~/.claude/skills/zul-writer/scripts/preview-zul.py --width 1600 --full-page --run-controllers src/main/webapp/index.zul
```

The script resolves the project's ZK jars (Maven, Gradle, or stock ZK when the file belongs to no project), renders the page through ZK's own engine, and writes a PNG. **Requires Java 17+ and Google Chrome or Microsoft Edge.** On first use it downloads the render helper (`zk-preview-launcher.jar`, ~500 KB), verifies its SHA-256, and caches it under `~/.cache/zul-writer/`; later runs need no network.

Then **read the PNG** with your image-reading tool and compare it against the Step 1 answers. If the user started from a screenshot or mockup, re-read that image too and compare the two side by side.

### When there is no preview

The script exits **2** and prints one line beginning `PREVIEW_SKIPPED:` — no ZK jars resolvable, no Java 17+, no browser, or the helper could not be downloaded. **This is not a defect in the ZUL.** Report it in one line — *"Skipped the rendered preview: &lt;reason&gt;"* — and finish the task normally.

Never describe a screenshot you did not see, and never let a skipped preview stand in for a passed one.

The `NEXT:` line says what would enable a preview. If that looks fixable from here, spend **one** retry on it — appending `--debug` prints the resolved classpath, every helper command line and the renderer's own output to stderr (stdout is unchanged), which is usually enough to see why. Then stop and report the skip either way.

### Read the `CONTROLLERS:` line first

It is one line in the output, and the judging rules below **invert** on it:

| Line | What the image is | How to read it |
|---|---|---|
| `CONTROLLERS: executed` | controllers ran: real bound values, real model rows, real composer output | *What you cannot judge* shrinks — a blank bound field **is** a defect |
| `CONTROLLERS: skipped (isolated)` | the default: no Composer, no ViewModel | dimmed expression text and placeholder rows are correct behaviour |
| `CONTROLLERS: failed → isolated` | you asked for controllers; they failed and the isolated render was served instead | read it under the isolated rules, and see the new *What to fix* bullet below |

### Read the `LAYOUT:` block first

A browser *measured* these, so they are facts, not opinions — read them before you open the PNG.
Each line is `rule | locator | measurement`:

```
LAYOUT: 3 findings
  - zero-size         | a[label="Settings"] | 0x0 with text but no box
  - clipped-text      | a[label="Documents"] | text needs 77px, box is 48px
  - viewport-overflow | grid.gp-wide | page scrollWidth 2005 > viewport 1280; widest offender 2000px
```

The locator is the ZUL id when the component has one (`label#breadcrumbCurrent`), otherwise the
component plus a distinguishing attribute (`a[label="Settings"]`) or its style class
(`grid.gp-wide`). It never names a generated id, so it is always something you can find in your own
markup.

| Rule | What it means | What fixes it |
|---|---|---|
| `clipped-text` | the text does not fit the box that clips it, so part of i

…(truncated)
