---
name: block-create
description: Make a main component, or a whole screen component and everything inside it, AI ready by turning it into reactive Block components. The design becomes a small app driven by data and pictures, with totals worked out, states that follow the content, and lists that grow and shrink. Works children first and reuses components that are already Blocks. Use when the user asks to make a component or screen smart, functional, data-driven, AI ready, or to "turn it into a Block". To change the data or pictures a copy (instance) shows, use block-update instead.
---

# Block: make component (or screen)

A Block turns a design into a **small app**. You look at a screen the way a developer would, as **data and logic, not layers**:

- **Data:** the few things someone really enters, such as the items in a bag, a product's name and price, or the selected category.
- **Logic:** everything that follows from that data and is worked out by the Block itself:
  - totals, line totals, tax and counts ("3 Results")
  - which button is disabled and which badge, empty state or discount row shows
  - which chip is selected, and a title that follows the selection

This reactivity is what makes a design AI ready. Afterwards `block-update` (or a person in the Block plugin) changes only the data, and the whole screen stays correct and consistent. The look always stays as the designer made it: logic drives only what the design already has, such as its texts, variants, on/off properties and showing or hiding parts.

Technically, a Block is the component plus a **Block script**: `block.define({ inputs, bind })`. **Inputs** are the data, and each input's `write` puts its value on the canvas. **`bind(inputs, node)`** runs after every change and does all the logic. Everything is stored as shared plugin data under the namespace `block`, the same data the Block plugin reads and writes. The script goes on the component (`block/script`) and the values on each instance (`block/values`). The checks and test copies need the Block engine in the file, which the Block plugin saves the first time it's opened in the file. Without it, scripts are saved but not checked.

## Hard rules: follow exactly

- Think in data first (steps 2–3), code second. Never turn a total, count or state into an input.
- **Every input that shows on the canvas reads itself back.** Give it an `initial(node)` that reads the same layer its `write` sets and returns a clean value. Designers edit copies by hand; when the plugin or `block-update` opens a copy, it reads the inputs back, then `bind` works out the totals again. An input without `initial` loses those edits. Calculated parts are never read back.
- Tool calls: **one SCAN** (plus one per component SCAN marks `cut`), then **one SAVE per wave**, or one per 4 components when a wave has more. Retry a SAVE at most once, only to fix the error, `errors` or `readBack` it returned.
- Copy SCAN and SAVE **character for character**. Change only the lines marked `// EDIT`: `ROOT_ID` and `LIMIT` in SCAN, and `REQUEST` and the `WAVE` block in SAVE. Never write your own Figma code for this task.
- The only layer edits allowed are the renames you list in `WAVE`. Never move, restyle, detach or delete layers.
- Save only with `setSharedPluginData("block", …)`. **Never** call `getPluginData`, `setPluginData` or `getPluginDataKeys`; they throw here.
- Components made with the old Block plugin: their old script is private and unreadable. Treat them as not converted yet and write a fresh script.
- If a script returns `error`, quote it to the user. Never just say something "can't be done".

## Steps

### 1. SCAN (read-only)

Put the id of the node the user attached or selected (a main component, or a screen that is a component) in `ROOT_ID`, e.g. `"2003:3785"`. Take the id from the attached node in this chat; don't rely on the canvas selection, which your code may not see.

**Copies are checked first.** Setup always happens on the main component, so every copy gets it. If the attached node is a copy (an instance), SCAN changes nothing and returns `stop` with a message instead. Tell the user that message in one or two lines, then:
- `mainId` with `aiReady: false`: ask "Want me to set up the main component instead? Every copy of it gets the change." On **yes**, run SCAN again with `mainId` in `ROOT_ID`.
- `aiReady: true`: it's already AI ready. Offer to change its data (the block-update skill), or to redo the setup if they attach the main component.
- No `mainId`: the main component is in another library or missing, so it can't be set up from this file. Stop there.

**Frames, groups and sections are stopped too.** Only components can be Blocks. SCAN returns `stop` with `inside`, the components in it (and the main components of copies in it, from this file). Tell the user the message, then:
- `inside` has components that aren't AI ready: name up to three and ask "Want me to make these AI ready instead?" On **yes**, run SCAN with each one's `id` in `ROOT_ID`, one at a time.
- Everything inside is already AI ready, or `inside` is empty: say the screen must become a component first (Create component, ⌥⌘K) and stop. Never save a script on a frame.

Otherwise it returns a screenshot plus every local component inside, ordered **children first** (`wave` 0 = no nested components). For each component it lists its numbered text items, its nested components (`children`), and its repeated copies (`count` > 1, which means a list).

A component with `cut` has more texts, hidden parts or pictures than SCAN listed (e.g. `cut: { texts: 38 }`). Never plan from a partial list: run SCAN once more with `LIMIT` at `120`. If it's still cut, the component is too big to be one Block; see "Big or deeply nested components".

### 2. Work out the screen's data model

Before any code, read SCAN and the screenshot the way a developer reads a spec. Number nothing new: use SCAN's numbered `texts`, `hidden` parts, `children`, `properties` (this component's own variants and on/offs) and `props` (each nested instance's current state and options). For the root and for every component you'll make, give each part exactly one **role**:

| Role | What it is | Becomes |
|---|---|---|
| **input** | Real content entered per use: a name, price, quantity, date, status, the items of a list, a selected value | an input with `write`, and `initial` that reads it back |
| **calculated** | Anything that can be worked out from other parts: subtotal, total, tax, line total (price × quantity), "3 items", "24 Results", a badge count, an average rating. **Prefer this whenever it can be worked out.** | a line in `bind`, never an input |
| **setting** | A number or choice a calculation needs that isn't typed per row: shipping fee, tax rate, free-shipping threshold, currency | a plain input with a sensible default and no `write`, used in `bind` |
| **optional** | A small extra shown or not per use: "SALE", "New", a discount row | a boolean input. Use the design's own on/off from `properties`/`props` if it has one, else `setVisible` |
| **show when** | A part that should only show sometimes, often one of SCAN's `hidden` parts: an empty message, a discount line, a "free shipping" note | `setVisible(condition)` in `bind` |
| **driven** | A variant or on/off that should follow the data: checkout `State=Disable` when the bag is empty, a chip `Selected` when it matches the chosen category, a bag badge `Item Added` when there are items | `setProperty(name, value)` in `bind` for parts of this component (buttons, badges). For **list copies** (chips, rows), keep the state in the rows (`selected: true`) so the child's own script sets its variant (see "State in the rows"). Use only the option names SCAN lists, and on/off keys exactly as listed (e.g. `"Item Added#2014:7"`) |
| **picture** | A picture that shows the content (one of SCAN's `pictures`): a product photo, a person's avatar, a brand logo, a hotel | an `image` input whose `write` calls `setImage(value)` on that layer. A decorative picture that's always the same (a background, an illustration, the app's own logo) is **fixed** |
| **fixed** | The same every time: page titles, section headings, field labels, placeholders, button words, units, and status-bar texts (clock, battery) | nothing |
| **design** | How it looks: style, size, theme, icons, "Property 1" | nothing, it stays as designed |

Some screens have little to work out (a browse page with lists and a title). Don't invent logic. Say "nothing to work out" and keep only the real show-when and driven parts.

Also look for **relationships across parts**, which are what make a screen feel alive:
- a title that follows a selection (category "Electronics" → title "Electronics")
- a choice kept in a list's rows (the selected chip) that other parts follow
- an amount summed over list rows (`sum(price × quantity)`)
- a count from a list's length
- a button that only makes sense with data

When the design shows two related values that differ (e.g. title "Shoes" while the selected chip is "Sneakers"), keep them as separate inputs. Don't bind one to the other.

And list **needs**: states the data needs that the design doesn't have, such as a bag with no empty state or a button with no disabled variant. Never fake them by restyling or adding layers. Report them instead.

### 3. Tell the user what you understand, before changing anything

Post the data model in plain words. Then:

- **Ask before saving** when the root has never been set up (`root.script` is empty), or when you're unsure about any part (two texts that could each be the price, a grey line that might be the brand or the size). End with: "Is this right? Reply **yes** to save, or tell me what's different." Wait for the answer. If the user corrects something, update the data model and the map's notes and statuses, say what you changed in one line, and continue without asking again.
- **Otherwise continue straight away**, e.g. when you're updating a component that's already AI ready and nothing is unclear.

A correction is the most useful thing a user can give: it becomes the map's reading of that part and the next agent request relies on it.

> **What this screen does:** A shopping bag: the items someone is buying, and what they'll pay.
> **Data you enter:** the items (each: name, brand, price, quantity).
> **Worked out by the Block:** Subtotal = Σ price × quantity · Shipping = shipping fee, or 0 when the bag is empty · Total = Subtotal + Shipping.
> **Reacts to the data:** Checkout is disabled while the bag is empty · the bag icon's badge shows when there are items.
> **Settings:** shipping fee (5.12), currency (USD).
> **Missing in the design:** there's no empty-bag message to show.
> **Limits:** the items list is a slot, so it can grow · the screen has a fixed height, so long lists run under the bottom bar.
> **Plan, smallest parts first:** Cart Single: reuse (`cartItem`). Summary rows and buttons: driven by Cart, not Blocks of their own. Cart: upgrade.

### 4. Decide each component

Go through the components in SCAN order and pick one decision for each:

- **Reuse:** `existingKey` is set, `exposed` is true and `notCovered` is empty. Don't touch it.
- **Already AI ready:** if the attached component's current script (`root.script`) already matches the data model you worked out, don't SAVE it again. Tell the user it's already AI ready, show its model and needs, and suggest requests to try.
- **Upgrade:**
  - the component the user attached (the root)
  - any component with `needsUpgrade` or `scriptError`
  - any Block whose `notCovered` lists child Blocks that carry this screen's data (cards, rows, chips) but that its script neither has an input for nor drives. A `notCovered` child without screen data, like a bottom nav, can stay as it is.
  - any Block whose script writes a list from `bind`, or sets list copies' variants from `bind`. Move the state into the rows (see "State in the rows").
  - any Block whose `exposed` is false

  Rewrite its script with the same `key`. `existingInputs` shows each existing input with its type, and for lists the child Block and its row fields (e.g. `collection of productCard {title, brand, price, rating}`); `root.script` is the attached component's current script. Keep the inputs that are still real data under the same names, and turn any that are really calculated into `bind` logic. Removing an input is fine when its job moves elsewhere (e.g. `activeCategory` replaced by `selected` in the rows), but say so in the plan: "Removed: activeCategory, now the chip's `selected`". Instances lose the saved value for a removed input. A screen is only AI ready when its lists, cards and rows are data, and its totals and states follow them.
- **Leave as design:** a library component, a status bar, an icon, or anything without content a person would want to swap. A parent can still drive its label or state from `bind`.
- **Make a Block:** it holds its own data (names, prices, counts, ratings, dates, status), or it is repeated (`count` > 1). A component whose parts are all calculated from the parent (e.g. a summary row) doesn't need its own Block; the parent drives it.

How children connect to the parent:
- **Repeated child Block** (`count` > 1), **or any child Block inside a slot** (`hostType` is `SLOT`, even with one copy): a `collection` input with the child's `blockKey`, written to the container (`hostLayer`).
- **Single child Block that carries this screen's data:** a `block` input with its `blockKey`.
- **Child whose values follow from the parent's data** (summary rows, a counter badge, a button): drive it from `bind` by layer name instead of making it an input.
- **Grid** (the same child repeated in side-by-side containers): one `collection` dealt across the containers.
- **List container that isn't a slot** (`hostType` is not `SLOT`): instances can only show or hide the copies already there, so the list can't grow beyond `count` per container. Mention this; the designer can make it a slot.
- **Variant sets** (e.g. a chip with `Selected=True/False`) count as one component; SCAN reports the set's id. The child's own script drives its variant from a row field (see "State in the rows").
- `designOnly` children (icons, shapes without text) never need a decision.
- **Child Blocks that don't carry this screen's data** (a bottom nav, a tab bar): leave them as they are. They need no input in the parent.
- **`variantNotes`** list design limits of a variant set. Mention each under needs:
  - a variant lacks a text layer the default has (e.g. `Selected=False has no Category Label`, an icon-only chip), so that state can't show its text
  - variants have different widths but don't hug their content. Inside a screen, a copy that switches variant keeps its old width (a selected chip squeezed to icon size, an unselected one left wide). The designer fixes it by making the variants auto layout with Hug width. No script can resize it.

### Big or deeply nested components

Large screens with many nested copies go wrong when one script tries to do everything. Split them up:

- **One owner per text.** Each component's `texts` are its own: the texts of a nested local component are listed under that component, not its parent. Its own Block handles them, and the parent never writes into a child Block's layers. The parent passes data down (a `block` or `collection` input) and drives only what follows from its own data.
- **About 12 inputs per Block at most.** More than that usually means the component holds repeated or separable parts: a header, a list of legs, a fare box. Make those child Blocks, smallest first, and give the parent a `block` or `collection` input for each. Keep the leftovers in the parent, grouped (see "The Edit panel").
- **Library copies** (`library: true` in `parts`) can't become Blocks here. Drive their `props` from the parent when they follow the data (a status badge), and treat their texts as **fixed** unless they're clearly content.
- **Repeated names.** Big designs reuse layer names ("Title", "Label") in many places. Use mirror ids (`findByMirror`) for layers inside nested copies, and `renames` for the rest. SAVE rejects a name that matches more than one layer.
- **Renamed layers in copies are fine.** Names are looked up as they are in the main component, so write scripts with the main component's names; a designer renaming a layer in one copy doesn't break it.
- **Duplicated components.** A duplicate starts with the original's script and key. Give it a key of its own when you make it AI ready; SAVE rejects a key another component already uses. (The Block plugin also gives an untouched duplicate its own key, like `card2`, when it opens.)
- **Plan as a table first.** For a screen with more than 3 components, post the plan in step 3 as one line per component: `Flight Leg: make a Block · from, to, time, date` / `Fare Box: driven by Flight Card` / `Airline Logo: leave as design`. Then save smallest first.
- **Save in small batches.** At most 4 components per SAVE call, so a script error points at one place. Check each result's `testText`, `errors` and `readBack` before the next batch.

### 5. SAVE, one wave at a time

Save wave 0 first, then wave 1, and so on, so every child exists before its parent. One SAVE call handles every component in a wave. For each component:

- **renames:** give every layer the script touches a clear, unique name within that component (`Title`, `Price`, `Items`, `Subtotal`). Scripts find layers by name, never by id, because ids differ in every instance.
- **script:** follow the patterns below.
- **description:** one sentence saying what the component is. SAVE writes it into the component's description in Figma, together with the input list, and into Block's "AI ready" panel, so designers can see what the AI understood.
- **map:** what AI understands about this component, part by part. The Block plugin draws it as a heat map over the component. Required; see "Readiness map" below.
- **REQUEST:** the user's request in their own words (e.g. "Make this cart AI ready"). SAVE logs it with the change, and the Block plugin shows it under "Asked for".
- **testValues:** realistic sample data for every input; 2–3 rows for lists. The test copy shows the logic working, so check both:
  - `testText`: calculated values should add up, and states and selections should follow.
  - the screenshot: look for parts that are clipped, squeezed or the wrong size. Rows in a scrolling carousel may be cut off; that's fine.

  A screen with design needs is still AI ready: its data and logic work, and the needs are for the designer. Say both.

  If a **reused child** misbehaves in the test (e.g. a chip shows the wrong state), you may make one extra SAVE to upgrade that child, then SAVE the parent again. If the cause is the design (see `variantNotes`), don't retry; list it under needs.
- **Defaults:** give each list input a `default` holding the rows the design shows now, built from that child's `copies` in SCAN (each copy's visible texts, in order), so an instance without values still looks like the design. When a copy shows no text (e.g. an icon-only chip), keep the existing default's row if there is one, otherwise use a plain placeholder ("Option 2"), and say so. `root.instanceValues` shows what the attached instance holds now.

SAVE checks every script before saving anything. It then builds a temporary copy of each component off to the side, fills it with `testValues`, screenshots it and deletes it. The user's own instances are never changed.

After each wave, post one line, e.g. `Wave 1 done: Summary Row ✓, Cart ✓`. Skip a wave where every component is reused or left as design: no SAVE call, just `Wave 0: nothing to change (Product Card reused)`.

### Readiness map

`map` tells a design system manager, in plain words, what AI understands about the component and where. The Block plugin colours each part on the component: blue for `ok`, orange for `partly`, red for `cant`, a dashed box for `missing`.

```js
map: {
  summary: "A shopping bag screen: the items someone is buying, an order summary and a checkout button. AI can change the items, quantities and prices, and works out the totals itself.",
  parts: [
    { label: "Product photo", status: "ok", note: "The product photo. AI swaps it for each item.", layers: ["2002:2128"], inputs: ["photo"] },
    { label: "Grey text under the name", status: "partly", note: "Probably the brand, but it might be the size.", layers: ["2002:2130"], inputs: ["brand"] },
    { label: "Items", status: "ok", note: "The list of products. AI adds or removes rows, from an empty bag to a long one.", layers: ["<hostId of the list>"], inputs: ["items"] },
    { label: "Subtotal", status: "ok", note: "Worked out from the items: price × quantity, added up.", layers: ["2002:2161"], inputs: ["items"] },
    { label: "Promo code", status: "cant", note: "Looks like a field to fill in, but nothing drives it, so AI can't add a code.", layers: ["2002:2150"] },
    { label: "Sale price", status: "missing", note: "Bags usually show the original price next to a sale price. This design has none.", near: "2002:2131", place: "right" },
  ],
}
```

Pick each part's status from the roles you gave in step 2:

| Status | Use for |
|---|---|
| `ok` | an **input**, **picture**, **calculated**, **driven**, **show when** or **optional** part your script handles, and each list (one part for the whole list) |
| `partly` | a part you handle but had to guess about (an ambiguous text you assigned a role to), or one that only partly works, e.g. a fee typed by hand that doesn't follow a rule |
| `cant` | something that looks like data a person would change, but your script doesn't drive: a child without screen data you left as it is, a library component with content, a field with nothing behind it |
| `missing` | one of the **needs** from step 2: a state or field the data needs that the design doesn't have. Give `near` (the layer it would sit beside) and `place` (`"right"` or `"below"`) instead of `layers` |

- Only parts that hold data. Leave out **fixed** and **design** parts (titles, labels, icons, the status bar).
- `layers` are ids from SCAN for **this** component: a text's or picture's `id`, a hidden layer's `id`, a child's `layerId`, or a list's `hostId`. One part can cover several layers (e.g. a summary row's label and amount). A list is one part on its `hostId`, never one part per copy.
- `inputs`: the input keys that drive the part (its own input, or the inputs a calculated part is worked out from). The plugin uses them to show which parts a change touched. Leave it out for `cant` and `missing` parts.
- `note`: one or two short sentences for a designer, with no code words (no input keys, `bind`, `write` or ids). Say what the part is, and for `partly`, `cant` and `missing`, why.
- `summary`: the same plain-words reading of the whole component that you posted in step 3, in a sentence or two.
- A child Block gets its own map in its own wave, covering its own parts.

### 6. Finish

Show the test screenshots. For each Block you made, list its data inputs, what it works out and what reacts. Then repeat any **needs**. Say that opening the Block plugin on the component shows the same reading as a heat map. End with ready-to-try requests that exercise the logic through `block-update`, e.g. "empty the bag" (watch checkout disable) or "4 coffees in euros" (watch the totals and currency).

## Script patterns

A reactive screen: data inputs write only their own layers, and `bind` does all the logic.

```js
function amount(n) {                                 // 1234.5 → "1,234.50"
  var parts = (Math.round(Number(n || 0) * 100) / 100).toFixed(2).split(".");
  return parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",") + "." + parts[1];
}

block.define({
  key: "cartScreen",                                   // camelCase, unique in the file
  expose: true,                                        // always: lets parents and the Block plugin use it
  inputs: {
    items: {                                           // data: a list of child Blocks
      type: "collection", label: "Items in the bag", blockKey: "cartItem", default: [],
      summary: ["title", "price"],                     // each row reads "Running Shoe · 120", not "Item 1"
      write(node, value) { node.findOne("Items").setBlockValues(Array.isArray(value) ? value : []) },
    },
    shippingFee: { type: "number", label: "Shipping fee", default: 5.12, min: 0 },              // setting
    currency: { type: "select", label: "Currency", default: "USD", options: ["USD", "EUR", "GBP"] },
    onSale: { type: "boolean", label: "On sale", default: false,                                // optional
      initial(node) { return node.findOne("Sale Badge").getVisible() },
      write(node, value) { node.findOne("Sale Badge").setVisible(value) } },
  },
  bind(inputs, node) {                                 // logic: runs after every change
    var rows = Array.isArray(inputs.items) ? inputs.items : [];
    var subtotal = 0;
    for (var i = 0; i < rows.length; i++) subtotal += Number(rows[i].price || 0) * Number(rows[i].quantity || 1);
    var shipping = rows.length ? Number(inputs.shippingFee) : 0;                                 // calculated
    node.findOne("Subtotal Amount").setText(amount(subtotal) + " " + inputs.currency);
    node.findOne("Total Amount").setText(amount(subtotal + shipping) + " " + inputs.currency);
    node.findOne("Item Count").setText(rows.length + (rows.length === 1 ? " item" : " items"));
    node.findOne("Empty Message").setVisible(rows.length === 0);                                 // show when
    node.findOne("Checkout Button").setProperty("State", rows.length ? "Default" : "Disable");  // driven variant
    node.findOne("Bag").setProperty("Item Added#2014:7", rows.length > 0);                      // driven on/off
  },
});
```

**Finding layers.** Prefer a layer's **mirror id**: its id in the main component, which is the same in every copy and survives renames. SCAN's `id` for a text or picture is its mirror id once any leading `I` is dropped (`I102:18265;1:758;10:1233` → `102:18265;1:758;10:1233`). Use the mirror with the name as a fallback when the name is unique, or the mirror alone when the name is used twice:

```js
function layer(node, mirror, name) { var m = node.findByMirror(mirror); return m.id || !name ? m : node.findOne(name) }
// …
write(node, value) { layer(node, "2003:3037", "Price").setText("$" + amount(value)) }
```

Mirror ids come from the main component's default variant. For another variant's layer, pass all of them: `node.findByMirror(["45:67", "46:12"])`.

A small Block with its own data and formatting. Every input that shows reads itself back:

```js
function num(t) { var n = Number(String(t || "").replace(/[^0-9.\-]/g, "")); return isNaN(n) ? undefined : n }

block.define({
  key: "productCard",
  expose: true,
  inputs: {
    title: {
      type: "text", label: "Product", default: "<current text>",
      initial(node) { return node.findOne("Title").getText() },
      write(node, value) { node.findOne("Title").setText(value) },
    },
    price: { type: "number", label: "Price", default: 120, min: 0,                   // keep data clean, format in write
      initial(node) { return num(node.findOne("Price").getText()) },                 // "$1,234.50" → 1234.5
      write(node, value) { node.findOne("Price").setText("$" + Number(value).toFixed(2)) } },
    rating: { type: "slider", label: "Rating", default: 5, min: 0, max: 5, step: 0.1,
      initial(node) { return num(node.findOne("Rating").getText()) },                // "(4.5)" → 4.5
      write(node, value) { node.findOne("Rating").setText("(" + Number(value).toFixed(1) + ")") } },
    size: { type: "select", label: "Size", default: "M", options: ["S", "M", "L"],
      initial(node) { return node.findOne("Size").getText() },
      write(node, value) { node.findOne("Size").setText(value) } },
    selected: { type: "boolean", label: "Selected", default: false,                  // this component's own variant
      initial(node) { return node.getProperty("Selected") === "True" },
      write(node, value) { node.setProperty("Selected", value ? "True" : "False") } },
    photo: { type: "image", label: "Photo", format: "bitmap", default: "",            // a picture: a Figma image hash, "" = as designed
      write(node, value) { node.findOne("Image").setImage(value) } },                // pictures can't be read back; the saved one is kept
  },
});
```

**Reading back.** `initial(node)` undoes what `write` did, from the same layer:

| `write` does | `initial` returns |
|---|---|
| `setText("$" + amount(value))` | `num(getText())`: the number without currency, separators or units |
| `setText(value)` (text, select) | `getText()`. For a select, it must be one of the options |
| `setVisible(value)` | `getVisible()` |
| `setProperty("State", value ? "On" : "Off")` | `getProperty("State") === "On"` |
| a date formatted for the design ("Sun, 5 Apr 2026") | the date as `"2026-04-05"`. If the format drops part of it (no year), return `undefined` to keep the saved value |
| `setBlockValues(rows)` (a list) | nothing: leave `initial` out. Each row is read from its copy by the child Block's own `initial`s |
| `setImage(value)` | nothing: pictures can't be read back |

Inputs without `write` (settings like a shipping fee) have nothing to read and need no `initial`. `bind` never needs one either: calculated parts are worked out again after the inputs are read.

State in the rows: a choice inside a list (the selected chip, the current tab, the default address) is a field of its rows. The child's own script shows it (`selected` driving its `Selected` variant), and the parent's `bind` reads the rows to work out what follows from the choice. The list keeps its normal `write`, and `bind` never writes the list.

```js
    filters: {                                         // each row: { label, selected }
      type: "collection", label: "Category filters", blockKey: "productCategoryFilter",
      default: [{ label: "Sneakers", selected: true }, { label: "Boots", selected: false }],
      write(node, value) { node.findOne("Category Filters").setBlockValues(Array.isArray(value) ? value : []) },
    },
  // …
  bind(inputs, node) {
    var chips = Array.isArray(inputs.filters) ? inputs.filters : [], active = "";
    for (var i = 0; i < chips.length; i++) if (chips[i].selected) { active = chips[i].label; break; }
    node.findOne("Selected Summary").setText(active ? "Showing " + active : "All products");  // follows the choice
  },
```

The child Block needs the row field (here a `selected` input driving its own `Selected` variant, as in the productCard example). Add it when you upgrade the child, in an earlier wave. `block-update` then selects a chip by changing `selected` in the rows.

Picking one row: when the design shows one chosen item from a list (the chosen flight, the delivery address, the plan on a pricing screen) and the chosen **copy doesn't change its own look**, make the choice a `select` on the parent whose options are the rows. People then see "Pick a flight: Emirates · EK 202" instead of a toggle hidden inside "Item 2". The options are a function of the inputs, so the dropdown always lists the current rows. Use a field that names the row as the value (a flight number, an id), never the row's position, so the pick survives rows being added or reordered. The parent's `bind` finds the picked row and drives what follows from it.

```js
    flights: {
      type: "collection", label: "Flights", blockKey: "flightRow", default: [],
      summary: ["airline", "flightNo"],
      write(node, value) { node.findOne("Flights").setBlockValues(Array.isArray(value) ? value : []) },
    },
    pickedFlight: {
      type: "select", label: "Pick a flight", default: "",
      options(inputs) {                                // one option per row, labelled the way people say it
        var rows = Array.isArray(inputs.flights) ? inputs.flights : [], out = [];
        for (var i = 0; i < rows.length; i++) {
          if (rows[i].flightNo) out.push({ value: String(rows[i].flightNo), label: rows[i].airline + " · " + rows[i].flightNo });
        }
        return out.length ? out : [{ value: "", label: "No flights yet" }];
      },
    },
  // …
  bind(inputs, node) {
    var rows = Array.isArray(inputs.flights) ? inputs.flights : [], picked = null;
    for (var i = 0; i < rows.length; i++) if (String(rows[i].flightNo) === inputs.pickedFlight) { picked = rows[i]; break; }
    node.findOne("Selected Flight").setText(picked ? picked.airline + " " + picked.flightNo : "Choose a flight");
    node.findOne("Total Amount").setText(picked ? amount(picked.price) : "");
  },
```

When the chosen copy itself changes look (a highlighted chip, a ticked row), keep the choice in the rows as above: only the child's own script can change its variant.

Grid: one list dealt across two columns.

```js
    results: {
      type: "collection", label: "Results", blockKey: "productCard", default: [],
      write(node, value) {
        var rows = Array.isArray(value) ? value : [], left = [], right = [];
        for (var i = 0; i < rows.length; i++) (i % 2 === 0 ? left : right).push(rows[i]);
        node.findOne("Results Left").setBlockValues(left);
        node.findOne("Results Right").setBlockValues(right);
      },
    },
  // and in bind: node.findOne("Result Count").setText(n + " Results")
```

- Every script needs a unique `key` and `expose: true`. Without `expose: true`, the Block plugin can't use the component as a child Block, and any screen that lists it shows "No Block connected".
- Each input needs `type`, `label` and `default`. Types: `text`, `date`, `time`, `image`, `number`, `slider`, `boolean`, `select`, `block`, `collection`.
- A `date` is stored as `"2026-04-05"` and a `time` as `"09:30"`. Use them for real dates and times (not text), and format them for the design in `write`.
- An `image` input's value is a **Figma image hash**. Use `""` as the default and in testValues unless you're reusing a hash from SCAN's `fileImages`, which lists images already in the file. `setImage` keeps the layer's crop and scaling; `""` leaves the design's picture.
- **Every name you pass to `findOne` must match exactly one layer** in that component. A missing name silently does nothing, and a name used twice (e.g. a frame and an instance both called "Bottom Nav") hits the first one. Give the layer a unique name in `renames`. SAVE refuses scripts that break this.
- **Don't set a list copy's variant from the parent's `bind`** (e.g. `chip.setProperty("Selected", …)`). Figma keeps a copy's text only when the variant it switches to has the same text layer; otherwise the label is lost, and the parent can't tell which copy is which. Instead, pass the state as a row field (`selected: true`) and let the child's own script set its variant. See "State in the rows". If a variant has no label at all (e.g. an icon-only chip), list that under needs.
- **Every `collection` gets a `summary`**: 1–3 row fields (`text`, `number`, `slider` or `select`) that tell rows apart the way a person would, e.g. `["airline", "flightNo"]` or `["title", "price"]`. Without it, the Edit panel labels rows "Item 1", "Item 2".
- **Never ask people for an index or position.** A choice among the rows of a list is a `select` whose `options(inputs)` lists those rows by name (see "Picking one row"), and a choice among fixed values is a `select` with those values. No `number` input for "which one".
- **Don't search for list copies with `findAll("INSTANCE")`.** It also returns the icons inside each copy, so indexes don't line up. Work with rows through `setBlockValues`.
- **Each layer has exactly one owner:** one input's `write`, or `bind`, never both. Otherwise the engine rejects the script with `Duplicate write target`. Calculated, shown-when and driven parts always belong to `bind`.
- `bind` must cope with any data: empty lists, missing fields, zero. Use `Number(x || 0)` and `Array.isArray`.
- Keep values clean (numbers, not "$12.00"), and format them where they're written, matching the design's format (currency, decimals, thousands separators, units). Use a helper like `amount()` above; `toLocaleString` options aren't reliable here.
- A label or state inside a nested design component (e.g. a button) is driven through the instance: `node.findOne("Checkout Button").setProperty("State", "Disable")`, or `node.findOne("Checkout Button").findAll("TEXT")[0].setText(…)`.
- Write scripts in plain older JavaScript (`var`, `function`, `for`). No `??`, no `?.`, no arrow functions.

### The Edit panel: how inputs are laid out

People fill a Block in the Block plugin's Edit panel. You decide its layout with a few hints on the inputs; the plugin draws them. Hints never change values or the design, and SAVE rejects hints that break these rules. Order inputs the way people think: the content first, settings last.

For each input, ask in order and use the first that fits:

| Question | Hint |
|---|---|
| A list of repeated items? | `collection` with a `summary` naming each row |
| One item chosen from a list (the picked flight)? | a `select` whose `options(inputs)` are the rows, labelled by name |
| A child Block? | `block` input: it opens on its own page |
| A part of the design that can be absent, with its own inputs (a checked bag, a sale badge)? | `groups: { bag: { label: "Checked bag", as: "optional", input: "hasBag" } }`, the `hasBag` boolean drives the layer, and its inputs get `group: "bag"` |
| Only means something for one value of another input (a return date on a return trip)? | `when: { tripType: "Return" }`, naming a select (an option value) or a boolean |
| An optional detail the design **doesn't show by default** and most people never change (promo code, seat)? | `more: true` |
| A start and an end of the same type (dates, times, min/max)? | `to: "returnDate"` on the start |
| A number or text that needs one short choice to read right (currency, unit, country code)? | `with: "currency"`, naming a select of up to 8 options of up to 8 characters. Units in lower case (`kg`) show after the value, codes (`EUR`) before. |
| Two short inputs people fill together (from/to, adults/children)? | the same `row: "route"` on both (2 at most) |
| 2–4 inputs people think of as one thing (passengers, an address)? | a group with `as: "summary"` |

Then group what's left: `as: "card"` for clear groups when there are 8 inputs or fewer, `as: "section"` (closed ones show what's set) when there are more, `as: "tab"` only for 2–4 equal groups edited one at a time. Inputs without a group come first.

- About 5–7 inputs should show when the panel opens; the rest wait in `more`, `when`, optional groups or closed sections.
- Anything the design always shows (a flight's time, a product's price) is a normal input, never `more`.
- Never make an input for a raw colour, size, spacing or font. Looks change only through the design's own variants, properties or styles, offered as a `select` or `boolean`.

```js
block.define({
  key: "flightCard", expose: true,
  groups: {
    pax: { label: "Passengers", as: "summary" },
    bag: { label: "Checked bag", as: "optional", input: "hasBag" },
  },
  inputs: {
    tripType:   { type: "select", label: "Trip", options: ["One way", "Return"], default: "Return" },
    from:       { type: "text", label: "From", default: "DXB", row: "route" },
    to:         { type: "text", label: "To", default: "LIS", row: "route" },
    departDate: { type: "date", label: "Departure", default: "2026-04-05", to: "returnDate" },
    returnDate: { type: "date", label: "Return", default: "2026-04-12", when: { tripType: "Return" } },
    departTime: { type: "time", label: "Time", default: "09:30" },
    adults:     { type: "number", label: "Adults", default: 2, min: 1, group: "pax" },
    cabin:      { type: "select", label: "Cabin", options: ["Economy", "Business"], default: "Economy", group: "pax" },
    price:      { type: "number", label: "Price", default: 389, with: "currency" },
    currency:   { type: "select", label: "Currency", options: ["EUR", "USD"], default: "EUR" },
    hasBag:     { type: "boolean", label: "Checked bag", default: false },
    bagWeight:  { type: "number", label: "Weight", default: 23, group: "bag", with: "bagUnit" },
    bagUnit:    { type: "select", label: "Unit", options: ["kg", "lb"], default: "kg" },
    promo:      { type: "text", label: "Promo code", default: "", more: true },
  },
  // write() on each input and bind() as usual
});
```

## SCAN

```js
figma.skipInvisibleInstanceChildren = false; // hidden layers and hidden list copies must be visible to the scripts
const ROOT_ID = "<node id>"; // EDIT: id of the main component the user attached or selected
const LIMIT = 40; // EDIT: texts listed per component; 120 when an earlier SCAN said `cut`
let root = /^[\dI][\d:;I]*$/.test(ROOT_ID) ? await figma.getNodeByIdAsync(ROOT_ID) : figma.currentPage.selection[0];
if (!root) return { error: "No node found for ROOT_ID " + ROOT_ID + ". Put the attached node's id in ROOT_ID." };
if (root.type === "INSTANCE") {
  // Setup happens on the main component, never on a copy.
  const m = await root.getMainComponentAsync();
  const o = m && m.parent && m.parent.type === "COMPONENT_SET" ? m.parent : m;
  if (!o) return { stop: "“" + root.name + "” is a copy whose main component is missing, so it can't be set up." };
  const ready = !!(o.getSharedPluginData("block", "script") || m.getSharedPluginData("block", "script"));
  if (m.remote || o.remote) return { stop: "“" + root.name + "” is a copy of “" + o.name + "” from another library" + (ready ? ", which is already AI ready. Ask me to update its data instead." : ". It can only be made AI ready in that library's file. Ask your design system manager."), aiReady: ready };
  return { stop: ready
    ? "“" + root.name + "” is a copy of “" + o.name + "”, which is already AI ready. To change what it shows, ask me to update its data. To change the setup, attach the main component."
    : "“" + root.name + "” is a copy of “" + o.name + "”. AI setup happens on the main component, so every copy gets it.", mainId: o.id, mainName: o.name, aiReady: ready };
}
{ let pg = root; while (pg.parent && pg.type !== "PAGE") pg = pg.parent; if (pg.type === "PAGE" && figma.currentPage !== pg) await figma.setCurrentPageAsync(pg); }
if (root.parent && root.parent.type === "COMPONENT_SET") root = root.parent;
if (root.type !== "COMPONENT" && root.type !== "COMPONENT_SET") {
  // Only components can be Blocks: their copies carry the setup. A frame, group or section has no copies.
  const inside = [], seen = {};
  if ("findAllWithCriteria" in root) {
    for (const c of root.findAllWithCriteria({ types: ["COMPONENT", "COMPONENT_SET"] })) {
      const o = c.parent && c.parent.type === "COMPONENT_SET" ? c.parent : c;
      if (inside.length < 12 && !seen[o.id]) { seen[o.id] = true; inside.push({ id: o.id, name: o.name, aiReady: !!o.getSharedPluginData("block", "script") }); }
    }
    for (const ch of root.findAllWithCriteria({ types: ["INSTANCE"] })) {
      if (inside.length >= 12) break;
      const m = await ch.getMainComponentAsync(); if (!m) continue;
      const o = m.parent && m.parent.type === "COMPONENT_SET" ? m.parent : m;
      if (seen[o.id] || o.remote) continue; seen[o.id] = true;
      inside.push({ id: o.id, name: o.name, aiReady: !!(o.getSharedPluginData("block", "script") || m.getSharedPluginData("block", "script")) });
    }
  }
  const kind = { FRAME: "frame", GROUP: "group", SECTION: "section" }[root.type] || "layer";
  return { stop: "“" + root.name + "” is a " + kind + ", not a component, so it can't be made AI ready. Only components can: their copies get the setup. Make it a component first (select it, then Create component, ⌥⌘K), or pick one of the components inside it.", inside };
}
const comps = new Map();
const clock = /^\s*\d{1,2}:\d{2}\s*$/;
const label = c => c.parent && c.parent.type === "COMPONENT_SET" ? c.parent.name + " / " + c.name : c.name;
const owner = m => m.parent && m.parent.type === "COMPONENT_SET" ? m.parent : m;
function propsOf(inst, o) {
  try {
    const defs = o.componentPropertyDefinitions, cp = inst.componentProperties, out = {};
    for (const k in cp) {
      const d = defs[k]; if (!d) continue;
      if (d.type === "VARIANT") out[k] = { now: cp[k].value, options: d.variantOptions };
      else if (d.type === "BOOLEAN") out[k] = { now: cp[k].value };
    }
    return Object.keys(out).length ? out : undefined;
  } catch (err) { return undefined; }
}
async function analyse(node) {
  if (comps.has(node.id)) return comps.get(node.id);
  const isSet = node.type === "COMPONENT_SET";
  const e = { id: node.id, name: label(node), isComponent: isSet || node.type === "COMPONENT", existingKey: null, wave: 0, texts: [], children: [] };
  comps.set(node.id, e);
  let s = e.isComponent ? node.getSharedPluginData("block", "script") : "";
  if (isSet) {
    const withScript = node.children.filter(v => v.getSharedPluginData("block", "script"));
    if (withScript.length) e.needsUpgrade = (s ? "old scripts are left on " + withScript.length + " variants and override the set's" : "script is saved on " + withScript.length + " of " + node.children.length + " variants only") + "; save it on the set (SAVE clears the variant copies)";
  }
  if (isSet) {
    const textNames = v => v.findAll(x => x.type === "TEXT").map(t => t.name);
    const base = textNames(node.defaultVariant), notes = [];
    for (const v of node.children) {
      if (v.id === node.defaultVariant.id) continue;
      const have = textNames(v), missing = base.filter(nm => have.indexOf(nm) < 0);
      if (missing.length) notes.push(v.name + " has no " + missing.join(", "));
    }
    const widths = node.children.map(v => Math.round(v.width));
    if (Math.max.apply(null, widths) !== Math.min.apply(null, widths) && node.children.some(v => v.layoutMode === "NONE" || v.primaryAxisSizingMode === "FIXED"))
      notes.push("variants have different widths (" + widths.join(" / ") + ") but don't hug their content, so a copy that switches variant keeps its old width");
    if (notes.length) e.variantNotes = notes;
  }
  if (s) {
    const m = s.match(/key:\s*["']([^"']+)["']/); e.existingKey = m ? m[1] : "?";
    e.exposed = /expose:\s*true/.test(s);
    e._script = s;
    e.existingInputs = []; const re = /["']?(\w+)["']?\s*:\s*\{\s*type\s*:/g; let x; while ((x = re.exec(s))) e.existingInputs.push(x[1]);
  }
  const kids = [];
  e.hidden = []; e.parts = [];
  const cut = k => { e.cut = e.cut || {}; e.cut[k] = (e.cut[k] || 0) + 1; };
  if (e.isComponent) {
    try {
      const defs = node.componentPropertyDefinitions, props = {};
      for (const k in defs) { if (defs[k].type === "VARIANT") props[k] = defs[k].variantOptions; else if (defs[k].type === "BOOLEAN") props[k] = "on/off"; }
      if (Object.keys(props).length) e.properties = props;
    } catch (err) {}
  }
  async function walk(n, path) {
    let last = null;
    for (const ch of n.children) {
      if (!ch.visible) {
        if (e.hidden.length < Math.round(LIMIT * 0.4)) {
          const t = ch.type === "TEXT" ? ch : ("findOne" in ch ? ch.findOne(x => x.type === "TEXT") : null);
          e.hidden.push({ id: ch.id, name: ch.name, type: ch.type, text: t ? t.characters.slice(0, 40) : undefined, inside: path || undefined });
        } else cut("hidden");
        last = null; continue;
      }
      if ("fills" in ch && ch.fills !== figma.mixed && ch.fills.some(f => f.type === "IMAGE" && f.visible !== false)) {
        if ((e.pictures = e.pictures || []).length < Math.round(LIMIT * 0.3)) e.pictures.push({ id: ch.id, name: ch.name, size: Math.round(ch.width) + " × " + Math.round(ch.height), inside: path || undefined });
        else cut("pictures");
      }
      if (ch.type === "TEXT") {
        if (!clock.test(ch.characters)) {
          if (e.texts.length < LIMIT) e.texts.push({ n: e.texts.length + 1, id: ch.id, name: ch.name, text: ch.characters.slice(0, 60), inside: path || undefined });
          else cut("texts");
        }
        last = null; continue;
      }
      if (ch.type === "INSTANCE") {
        const m = await ch.getMainComponentAsync();
        if (!m || m.remote) {
          if (/status\s*bar/i.test(ch.name)) { last = null; continue; }
          const lp = m ? propsOf(ch, owner(m)) : undefined;
          if (lp && e.parts.length < Math.round(LIMIT / 4)) e.parts.push({ layer: ch.name, id: ch.id, library: true, props: lp });
          await walk(ch, path ? path + " > " + ch.name : ch.name); last = null; continue;
        }
        const o = owner(m);
        const copyText = ch.findAll(x => x.type === "TEXT" && x.visible).map(x => x.characters.slice(0, 40)).slice(0, 6);
        if (last && last.componentId === o.id && last.hostId === n.id) { last.count++; if (last.copies.length < 8) last.copies.push(copyText); continue; }
        last = { layer: ch.name, layerId: ch.id, hostLayer: n.name, hostId: n.id, hostType: n.type, componentId: o.id, component: o.name, count: 1, props: propsOf(ch, o), copies: [copyText] };
        kids.push({ entry: last, main: o });
        continue;
      }
      last = null;
      if ("children" in ch) await walk(ch, path);
    }
  }
  await walk(isSet ? node.defaultVariant : node, "");
  if (!e.hidden.length) delete e.hidden;
  if (!e.parts.length) delete e.parts;
  for (const k of kids) {
    const child = await analyse(k.main);
    k.entry.existingKey = child.existingKey;
    if (!child.texts.length && !child.children.length) { k.entry.designOnly = true; delete k.entry.existingKey; delete k.entry.props; delete k.entry.copies; }
    else e.wave = Math.max(e.wave, child.wave + 1);
    e.children.push(k.entry);
  }
  if (s) {
    const mentions = t => s.indexOf('"' + t + '"') >= 0 || s.indexOf("'" + t + "'") >= 0;
    const missed = e.children.filter(c => c.existingKey && !mentions(c.existingKey) && !mentions(c.layer));
    if (missed.length) e.notCovered = missed.map(c => c.component + " ×" + c.count + " in " + c.hostLayer);
  }
  return e;
}
await analyse(root);
// With the engine in the file: each existing Block's inputs with types, and each list's row fields.
const engineSrc = figma.root.getSharedPluginData("block", "engine");
if (engineSrc) {
  new Function(engineSrc)();
  const engine = globalThis.__blockEngine, reg = {};
  for (const c of figma.currentPage.findAllWithCriteria({ types: ["COMPONENT", "COMPONENT_SET"] })) {
    const sc = c.getSharedPluginData("block", "script"); if (!sc) continue;
    if (c.type === "COMPONENT" && c.parent && c.parent.type === "COMPONENT_SET" && c.parent.getSharedPluginData("block", "script")) continue;
    const km = sc.match(/key:\s*["']([^"']+)["']/); if (km) reg[km[1]] = sc;
  }
  const ectx = { resolveBlock: k => reg[k] ? { script: reg[k] } : null };
  for (const e of comps.values()) {
    if (!e._script) continue;
    const r = engine.evaluateScript(e._script, ectx);
    if (!r.ok) { e.scriptError = r.error; continue; }
    e.existingInputs = {};
    for (const k in r.definition.inputs) {
      const d = r.definition.inputs[k];
      e.existingInputs[k] = d.type + (d.options && d.options.length ? " (" + d.options.map(o => o.value).join("/") + ")" : "") + (d.blockKey ? " of " + d.blockKey + " {" + Object.keys(d.inputs || {}).join(", ") + "}" : "");
    }
  }
}
const rootEntry = comps.get(root.id);
const rootScript = rootEntry && rootEntry._script;
for (const e of comps.values()) delete e._script;
await root.screenshot({ scale: root.height > 1200 ? 0.4 : 0.6 });
const all = [...comps.values()];
const list = all.filter(c => c.id === root.id || c.texts.length || c.children.length).sort((a, b) => a.wave - b.wave);
const designOnly = all.filter(c => list.indexOf(c) < 0).map(c => c.name);
const fileImages = {};
for (const n of figma.currentPage.findAll(x => "fills" in x && x.fills !== figma.mixed && x.fills.some(f => f.type === "IMAGE"))) {
  for (const f of n.fills) if (f.type === "IMAGE" && f.imageHash && !fileImages[f.imageHash] && Object.keys(fileImages).length < 20) fileImages[f.imageHash] = n.name + " (" + Math.round(n.width) + " × " + Math.round(n.height) + ")";
}
return { root: { id: root.id, name: label(root), isComponent: root.type === "COMPONENT" || root.type === "COMPONENT_SET", script: rootScript || undefined }, components: list, designOnly, fileImages };
```

Only components become Blocks. A frame, group or section returns `stop` (see step 1), so `root.isComponent` is always true here.

## SAVE

```js
figma.skipInvisibleInstanceChildren = false; // hidden layers and hidden list copies must be visible to the scripts
const REQUEST = "<what the user asked, in their words>"; // EDIT
const WAVE = [ // EDIT
  { componentId: "<id>", renames: { "<layer id>": "Title" }, description: "<one sentence>", script: `block.define({ ... })`, testValues: { },
    map: { summary: "<what AI understands, 1–2 sentences>", parts: [{ label: "Price", status: "ok", note: "<plain words>", layers: ["<layer id>"], inputs: ["price"] }] } },
];
// Each change is logged on the component, so the Block plugin can show what changed and when.
function addHistory(node, entry) {
  let list = [];
  try { list = JSON.parse(node.getSharedPluginData("block", "history") || "[]"); } catch (err) {}
  if (!Array.isArray(list)) list = [];
  list.push(entry);
  node.setSharedPluginData("block", "history", JSON.stringify(list.slice(-50)));
}

const src = figma.root.getSharedPluginData("block", "engine");
let engine = null;
if (src) { new Function(src)(); engine = globalThis.__blockEngine; }
const registry = {};
// A duplicated component carries the original's key; the original says it owns it ("block" / "owner").
const owns = n => !!n && n.getSharedPluginData("block", "owner") === n.id;
function addScript(node, s) {
  const m = s && s.match(/key:\s*["']([^"']+)["']/); if (!m) return;
  const prev = registry[m[1]];
  if (prev && prev.node.type === "COMPONENT_SET" && node.type !== "COMPONENT_SET") return;
  if (prev && owns(prev.node) && !owns(node)) return;
  registry[m[1]] = { node, script: s };
}
const nodes = [];
for (const w of WAVE) {
  const c = await figma.getNodeByIdAsync(w.componentId);
  if (!c || (c.type !== "COMPONENT" && c.type !== "COMPONENT_SET")) return { error: w.componentId + " is not a component" };
  nodes.push(c);
  for (const inst of c.findAllWithCriteria({ types: ["INSTANCE"] })) {
    const m = await inst.getMainComponentAsync();
    if (!m) continue;
    if (m.parent && m.parent.type === "COMPONENT_SET") addScript(m.parent, m.parent.getSharedPluginData("block", "script"));
    addScript(m, m.getSharedPluginData("block", "script"));
  }
}
const keyOf = t => ((t || "").match(/key:\s*["']([^"']+)["']/) || [])[1];
WAVE.forEach((w, i) => { const k = keyOf(w.script); if (k) registry[k] = { node: nodes[i], script: w.script }; });
// Each Block needs a key of its own. A duplicated component still has the original's.
{
  const clashes = [], seen = {}, own = {};
  WAVE.forEach((w, i) => { const k = keyOf(w.script); if (own[k]) clashes.push('key "' + k + '" is in WAVE twice'); own[k] = nodes[i].id; });
  let listed = [];
  try { const p = JSON.parse(figma.root.getSharedPluginData("block", "index") || "null"); listed = p && Array.isArray(p.items) ? p.items : Array.isArray(p) ? p : []; } catch (err) {}
  const others = figma.currentPage.findAllWithCriteria({ types: ["COMPONENT", "COMPONENT_SET"] }).filter(c => !(c.type === "COMPONENT" && c.parent && c.parent.type === "COMPONENT_SET"));
  for (const x of listed) { const c = x && x.id ? await figma.getNodeByIdAsync(x.id) : null; if (c) others.push(c); }
  for (const c of others) {
    if (seen[c.id] || nodes.some(n => n.id === c.id)) continue; seen[c.id] = true;
    const k = keyOf(c.getSharedPluginData("block", "script"));
    if (k && own[k]) clashes.push('key "' + k + '" is already used by "' + c.name + '" (' + c.id + ")");
  }
  if (clashes.length) return { error: "Nothing saved. Each Block needs its own key (a duplicated component still has the original's), so pick another key: " + clashes.join(" | ") };
}
const evalCtx = { resolveBlock: k => registry[k] ? { script: registry[k].script } : null };
function evalFor(k) { const e = registry[k]; if (!e.r) e.r = engine.evaluateScript(e.script, evalCtx); return e.r; }

const checked = [];
if (engine) {
  const bad = [];
  WAVE.forEach((w, i) => { const r = engine.evaluateScript(w.script, evalCtx); if (!r.ok) bad.push(nodes[i].name + ": " + r.error); checked.push(r); });
  if (bad.length) return { error: "Nothing saved. Fix: " + bad.join(" | ") };
}

// Every name a script finds must match exactly one layer, or findOne silently does nothing (or hits the wrong layer).
const nameProblems = [];
WAVE.forEach((w, i) => {
  const c = nodes[i], used = [], re = /find(?:One|Child)\(\s*["']([^"']+)["']\s*\)/g;
  let m; while ((m = re.exec(w.script))) if (used.indexOf(m[1]) < 0) used.push(m[1]);
  const layers = (c.type === "COMPONENT_SET" ? c.defaultVariant : c).findAll(() => true);
  const nameOf = n => w.renames[n.id] || n.name;
  const issues = [];
  for (const nm of used) {
    const hits = layers.filter(n => nameOf(n) === nm).length;
    if (hits !== 1) issues.push('"' + nm + '" matches ' + (hits ? hits + " layers" : "no layer"));
  }
  const mirrors = [], mre = /findByMirror\(\s*\[?\s*["']([^"']+)["']/g;
  while ((m = mre.exec(w.script))) if (mirrors.indexOf(m[1]) < 0) mirrors.push(m[1]);
  for (const mi of mirrors) if (!layers.some(n => n.id === mi || n.id === "I" + mi || n.id.slice(-(mi.length + 1)) === ";" + mi)) issues.push('mirror "' + mi + '" matches no layer');
  // The readiness map: every part needs a label, a status and layers that are in this component.
  if (!w.map || !Array.isArray(w.map.parts) || !w.map.parts.length) issues.push("map is missing: list its data parts and what AI understands about each");
  else w.map.parts.forEach((p, j) => {
    const where = "map part " + (j + 1) + (p && p.label ? ' "' + p.label + '"' : "");
    if (!p || !p.label) { issues.push(where + " needs a label"); return; }
    if (["ok", "partly", "cant", "missing"].indexOf(p.status) < 0) issues.push(where + ' has status "' + p.status + '"; use ok, partly, cant or missing');
    const ids = (Array.isArray(p.layers) ? p.layers : []).concat(p.near ? [p.near] : []);
    if (!ids.length) issues.push(where + (p.status === "missing" ? " needs near: the layer it would sit beside" : " needs layers"));
    for (const id of ids) if (!layers.some(n => n.id === id)) issues.push(where + ': layer "' + id + '" is not in this component');
    if (checked[i] && checked[i].ok) for (const k of (Array.isArray(p.inputs) ? p.inputs : [])) if (!(k in checked[i].definition.inputs)) issues.push(where + ': input "' + k + '" is not in the script');
  });
  if (issues.length) nameProblems.push(c.name + ": " + issues.join(", "));
});
if (nameProblems.length) return { error: "Nothing saved. Each name a script finds must match exactly one layer, each mirror id must be a layer of the component, and each map part needs a label, a status and layers from SCAN for this component. Fix: " + nameProblems.join(" | ") };

let logs = [];
const log = (l, a) => { if (l === "error") logs.push(JSON.stringify(a)); };
const ctx = {
  getBlockValue(t) { const raw = t.getSharedPluginData("block", "values"); return raw ? JSON.parse(raw) : null; },
  getBlockValues(t) { const raw = t.getSharedPluginData("block", "values"); return raw ? JSON.parse(raw) : null; },
  async setBlockValue(t, value, def) {
    const k = def && def.blockKey; if (!k || !registry[k]) { logs.push("unknown block " + k); return; }
    await engine.applyKnobs(t, evalFor(k), value, log, ctx);
    t.setSharedPluginData("block", "values", JSON.stringify(value));
  },
  async setBlockValues(host, rows, def) {
    let k = (def && def.blockKey) || host.getSharedPluginData("block", "key");
    if (!k) for (const ch of host.children) {
      if (ch.type !== "INSTANCE") continue;
      const m = await ch.getMainComponentAsync(); if (!m) continue;
      const o = m.parent && m.parent.type === "COMPONENT_SET" ? m.parent : m;
      const km = (o.getSharedPluginData("block", "script") || m.getSharedPluginData("block", "script")).match(/key:\s*["']([^"']+)["']/);
      if (km) { k = km[1]; break; }
    }
    const e = registry[k]; if (!e) { logs.push("no Block found for the list in " + host.name); return; }
    host.setSharedPluginData("block", "key", k);
    const comp = e.node.type === "COMPONENT_SET" ? e.node.defaultVariant : e.node;
    const existing = [];
    for (const ch of host.children) {
      if (ch.type !== "INSTANCE") continue;
      const m = await ch.getMainComponentAsync();
      if (m && (m.id === comp.id || (m.parent && m.parent.id === e.node.id))) existing.push(ch);
    }
    for (let i = 0; i < rows.length; i++) {
      let inst = existing[i];
      if (!inst) {
        inst = comp.createInstance();
        try { host.appendChild(inst); } catch (err) { inst.remove(); logs.push("only " + existing.length + " copies fit in " + host.name + " (not a slot), so " + (rows.length - existing.length) + " rows were not shown"); break; }
      }
      inst.visible = true;
      try { if (host.children[i].id !== inst.id) host.insertChild(i, inst); } catch (err) {}
      await engine.applyKnobs(inst, evalFor(k), rows[i], log, ctx);
      inst.setSharedPluginData("block", "values", JSON.stringify(rows[i]));
    }
    for (let i = rows.length; i < existing.length; i++) { try { existing[i].remove(); } catch (err) { existing[i].visible = false; } }
    host.setSharedPluginData("block", "values", JSON.stringify(rows));
  },
};

// Only the keys the test set are compared, so a row's unset fields don't count.
function differs(want, got) {
  if (Array.isArray(want)) return !Array.isArray(got) || want.length !== got.length || want.some((x, j) => differs(x, got[j]));
  if (want && typeof want === "object") return !got || typeof got !== "object" || Object.keys(want).some(k => differs(want[k], got[k]));
  return JSON.stringify(want) !== JSON.stringify(got);
}

const results = [];
for (let i = 0; i < WAVE.length; i++) {
  const w = WAVE[i], c = nodes[i];
  for (const id in w.renames) { const n = await figma.getNodeByIdAsync(id); if (n) n.name = w.renames[id]; }
  const prevScript = c.getSharedPluginData("block", "script");
  const prevInputs = [], pre = /["']?(\w+)["']?\s*:\s*\{\s*type\s*:/g; let pm; while ((pm = pre.exec(prevScript))) prevInputs.push(pm[1]);
  let prevParts = 0; try { prevParts = (JSON.parse(c.getSharedPluginData("block", "map") || "{}").parts || []).length; } catch (err) {}
  c.setSharedPluginData("block", "script", w.script);
  c.setSharedPluginData("block", "owner", c.id); // this component owns its key, even after someone duplicates it
  if (c.type === "COMPONENT_SET") for (const v of c.children) if (v.getSharedPluginData("block", "script")) v.setSharedPluginData("block", "script", "");
  const key = (w.script.match(/key:\s*["']([^"']+)["']/) || [])[1];
  const inputs = engine ? Object.keys(checked[i].definition.inputs).map(k => k + " (" + checked[i].definition.inputs[k].type + ")").join(", ") : "";
  const keep = (c.description || "").split("\n").filter(l => l && l.indexOf("Block · ") !== 0);
  c.description = ["Block · " + key + " · " + w.description, "Block · inputs: " + inputs].concat(keep).join("\n");
  const savedAt = new Date().toISOString();
  c.setSharedPluginData("block", "about", JSON.stringify({ by: "ai", summary: w.description, updatedAt: savedAt }));
  c.setSharedPluginData("block", "map", JSON.stringify({
    v: 1, summary: w.map.summary || w.description, updatedAt: savedAt,
    parts: w.map.parts.map((p, j) => ({ id: p.id || "part-" + (j + 1), label: p.label, status: p.status, note: p.note || "", layers: p.layers, near: p.near, place: p.place, inputs: p.inputs })),
  }));
  const tally = {};
  for (const p of w.map.parts) tally[p.status] = (tally[p.status] || 0) + 1;
  const defs = engine ? checked[i].definition.inputs : {};
  const newLabels = Object.keys(defs).map(k => defs[k].label || k);
  addHistory(c, {
    at: savedAt, by: "agent", kind: "setup", title: prevScript ? "AI setup updated" : "Set up for AI", request: REQUEST || undefined,
    changes: [
      { label: "Data you can change", before: prevInputs.length ? prevInputs.length + " fields" : "—", after: newLabels.join(", ") || "—" },
      { label: "Parts AI understands", before: prevParts ? String(prevParts) : "—", after: String(w.map.parts.length) },
    ],
  });
  const res = { component: c.name, key, inputs, map: tally };
  if (engine) {
    const temp = (c.type === "COMPONENT_SET" ? c.defaultVariant : c).createInstance();
    temp.x = -100000; temp.y = -100000;
    logs = [];
    try {
      await engine.applyKnobs(temp, checked[i], w.testValues, log, ctx);
      res.testText = temp.findAll(n => n.type === "TEXT").map(t => t.characters).slice(0, 30);
      if (engine.readValues) {
        // Read the test copy back: each input should return what was written, so edits made on the canvas survive.
        const back = engine.readValues(temp, checked[i], ctx), hooks = checked[i].inputHooks, defs = checked[i].definition.inputs, rb = [];
        for (const k in defs) {
          if (!hooks[k] || !hooks[k].write || defs[k].type === "image" || defs[k].type === "block" || !(k in w.testValues)) continue;
          if (back.notRead.indexOf(k) >= 0) { if (defs[k].type !== "collection") rb.push(k + ": can't be read back (no initial, or it returned nothing)"); continue; }
          if (differs(w.testValues[k], back.values[k])) rb.push(k + ": wrote " + JSON.stringify(w.testValues[k]).slice(0, 80) + ", read back " + JSON.stringify(back.values[k]).slice(0, 80));
        }
        if (back.errors.length || rb.length) res.readBack = back.errors.concat(rb);
      }
      if (logs.length) res.errors = logs;
      await temp.screenshot({ scale: temp.height > 1200 ? 0.4 : 0.6 });
    } finally { temp.remove(); }
  }
  results.push(res);
}
// Adds them to the file's list of AI-ready components, so the Blocks dashboard opens without scanning every page.
// The list only points to components; their data stays on them. An older list (a plain array) keeps its form.
{
  let list = null, complete = false;
  try {
    const p = JSON.parse(figma.root.getSharedPluginData("block", "index") || "null");
    if (p && p.v === 2 && Array.isArray(p.items)) { list = p.items; complete = true; } else if (Array.isArray(p)) list = p;
  } catch (err) {}
  const at = new Date().toISOString();
  list = (list || []).filter(x => x && !nodes.some(n => n.id === x.id)).concat(nodes.map(n => ({ id: n.id, name: n.name, type: n.type, updatedAt: at })));
  figma.root.setSharedPluginData("block", "index", JSON.stringify(complete ? { v: 2, items: list } : list));
}
return { saved: results, mutatedNodeIds: nodes.map(n => n.id).concat(...WAVE.map(w => Object.keys(w.renames))) };
```

If `errors` shows up in a result, the script saved but part of it failed on the test copy. Fix the script, and re-run SAVE for that one component only.

If `readBack` shows up, the script saved, but some inputs don't come back as written, so a designer's edits to a copy would be lost when it's opened. Fix each input's `initial` (see "Reading back") and re-run SAVE for that component. The one exception is a format that drops information (a date shown without its year): leave it, and say so.
