issuance-config panel
Pre-flight configuration panel for issuance. Replaces the AskUserQuestion chain
with a single interactive panel in the Claude Desktop side panel. The panel is a
repeater of one full key-value block per stakeholder — every field (name,
email, stakeholder type, relationship, quantity, and the whole type-specific
field set) lives inside that person's own block, so one batch can issue
genuinely different terms to different people. A "+ Add stakeholder" button
appends another block, pre-filled by copying the most-recently-added block's
non-personal terms forward. One template serves both security types: it
carries every field set, and the {{SECURITY_TYPE}} switch (option_grant |
certificate | piu) hides the rows whose data-sectype doesn't match and
selects the matching submit() payload. Issue date and Board approval are shared field
rows (not type-gated) inside every block.
Two footer buttons. Review posts action: "config_submit" — the parent
skill saves and validates (save_drafts + validate_drafts) before ever
rendering the review surface (carta-issuance SKILL.md's Phase
1.5); any
server error re-renders this same panel with a banner (see Server-error
banners, below), never silently moves on. Save
posts action: "save_only" — a lighter escape hatch, save_drafts only, no
validation, no panel re-render. Both gate on the exact same per-block
readiness check (missingFields()) — Save isn't a weaker bar.
Do not invoke this skill directly. Dispatched by carta-issuance Phase 0.5.
References
| File |
Purpose |
../../../lib/issuance_fields.py |
Every field builder, shared by both surfaces. The Cowork form collects the identical set, so one copy serves both and the two cannot drift. Also emits the so_type gate sets as JS (so_type_js_constants()) so the browser's idea of which types report to HMRC always matches Python's. |
scripts/build_config.py |
Builds every dynamic block for the Code panel (toggle groups, grantee/holder rows, autocomplete roster) from the fetched data + prompt-derived knowns. The model never hand-authors panel HTML — doing so once shipped dead btn-card buttons and stamped a plan id where a document-set id belonged. |
scripts/build_cowork_form.py |
Builds the whole Cowork form as one self-contained show_widget document. See Cowork form. |
scripts/preview_config.py |
Design-iteration harness. Renders the panel to a standalone HTML file with committed sample data — no MCP, no skill run. See Iterating on the UI. |
references/artifact.yaml |
Shared required + per-type optional substitutions; save + submit-watcher capabilities |
references/template.html |
Config panel — both field sets gated by data-sectype, shared sticky Review/Save footer |
references/styles.css |
Ink-compliant panel styles (toggles, date/price inputs, legend attestation box) |
references/cowork-template.html |
Cowork form — same class contract, sendPrompt() submit, per-row and batch layouts |
references/cowork-styles.css |
Cowork styles — design-system tokens only (see Cowork form for why the panel sheet can't be reused) |
references/Inter-roman.var.woff2 |
Inter variable font (panel only) |
references/SangBleuVersailles-Regular-WebS.ttf |
SangBleu Versailles for corp name (panel only) |
Cowork form
build_cowork_form.py emits one self-contained document for show_widget. Same fields,
same rows payload as the panel — only the submit path and the styling differ.
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-issuance/issuance-config/scripts/build_cowork_form.py" \
--security-type <option_grant|certificate|piu> \
--data "$WORK/_data.json" --knowns "$WORK/_knowns.json" \
--corp-name "<legal name>" --corp-id "<corporation_id>" \
--out "$WORK/form.html"
# → prints FORM=<path>. Pass the file's contents VERBATIM as show_widget's widget_code.
_data.json and _knowns.json are the same two files build_config.py takes — the
knowns table in code-adapter.md
is the contract for both. Add --no-minify while iterating on the markup.
Why the panel's styles.css is not reused. The widget host forbids four things it
does: hardcoded hex (invisible in dark mode), a background on the outer container (the
host paints it), 100vh/sticky positioning (the iframe sizes to content), and
@font-face (the CSP blocks the plugin origin). position: fixed is out too — it
collapses the iframe — so the submit state is an in-flow block, not the panel's modal
overlay. cowork-styles.css uses design-system tokens throughout and keys its
responsive rule off @container, not @media: a viewport query would track the user's
window rather than the form's own width.
Batch mode (cowork-adapter.md § Batch mode) activates on >10 rows whose terms are
all identical or unset, collapsing to shared-terms-once plus a name/email/quantity
table. knowns.batch_mode forces it either way. It is a rendering choice only: the
shared terms are expanded onto every row at submit, so the payload is indistinguishable
from the per-row layout's — including one row_key per person.
read_me is not needed. show_widget renders this document as-is, and the
interactive module carries no repeater guidance that would express this form — the
call is ~5k tokens and a round trip for nothing.
Substitutions
required (both types provide): CORP_NAME, CORP_ID, FLOW_TITLE
(Issue Option Grants | Issue Certificates | Issue Profits Interest Units —
verb-first, since this is a write operation), HEADER_SUB (7 grantees |
2 holders), SECURITY_TYPE (option_grant | certificate | piu).
optional (default ""; built by build_config.py):
| Key |
Value |
STAKEHOLDER_ROWS |
one full .stake-block per person from knowns.rows — every field (name, email, stakeholder type, relationship, quantity, and the whole type-specific field set) lives inside that block; one blank block when the prompt named no one. TODAY_ISO/CURRENCY/EXERCISE_PRICE_DEFAULT/PRICE_PER_SHARE_DEFAULT are no longer separate template substitutions — they're knowns scalar inputs build_config.py stamps into each block directly (see payload delivered on submit below for why: a token inside a generated fragment isn't re-substituted by render-panel's single text-replace pass) |
STAKEHOLDER_LIST_JSON |
JSON array of the corp roster ([{name,email,id,kind,event_relationship},…]) for name autocomplete + email/stakeholder-type/relationship auto-fill |
BATCH_ERRORS_HTML |
Panel-level red banner (above the Grantees/Holders list) for corp-/batch-level server errors from a Phase 1.5 validation round — built from knowns.batch_errors; "" (collapsed via CSS :empty) when clean. See Server-error banners. |
build_config.py normalizes the real MCP shape. The live cap_table:get:stakeholders
result uses full_name, never name — reading only name silently dropped every real
record (each one looked "nameless"), which emptied STAKEHOLDER_LIST_JSON against live
data even though every test fixture (built with a name key) kept passing. build_stakeholder_list()
reads s.get("name") or s.get("full_name") and always normalizes the output to a name key.
Every per-type option list (option type, vesting, documents, share class, legend, Rule 144)
that used to be its own top-level substitution now lives inside each block within
STAKEHOLDER_ROWS — there's no separate OPTION_TYPE_OPTIONS/VESTING_OPTIONS/etc. key
anymore, since each stakeholder's block needs its own independently-selected set.
{{SAVE_PORT}} is filled by render-panel.
Per-block field contracts
build_config.py emits all of these — this is the spec it implements, not a
hand-authoring guide. The parent skill writes the fetched data + a knowns
object to disk and runs the script; the model never emits panel HTML. The rows
below document what the script produces (and what a reviewer should expect) for
each stakeholder block. Each button carries the attributes the template's JS
reads, and the row's own default is marked selected (falling back to the
batch-level knowns default when the row didn't specify its own value — see
carta-issuance SKILL.md). The
No vesting (grant) option is part of the script's output too.
| Group |
Per-button HTML |
| Stakeholder type |
<button class="toggle[ selected]" data-group="kind" data-value="INDIVIDUAL|NON-INDIVIDUAL"> — two buttons; INDIVIDUAL selected by default. Auto-selected (but still clickable/editable) by template JS on an exact roster-name match. |
Type (so_type) |
<div class="toggle-row"> of the corp's own resolved jurisdiction's 3 so_type buttons only, each <button class="toggle[ selected]" data-group="type" data-value="<so_type>"> — US ISO/NSO/INTL, UK EMI/CSOP/Unapproved, or AU Startup Concessions/Non-Concessional/ZEPO, gated by knowns.jurisdiction (design feedback reversed an earlier "show all 9 across all 3 jurisdictions, grouped by jurisdiction" layout — a corp only ever issues one jurisdiction's types, so the other 6 read as clutter, not a genuine affordance). Mark this row's resolved type selected when it has one. pickType() (not the generic pick()) additionally re-syncs the HMRC/ATO conditional rows below for the newly-selected type. |
| Vesting |
<select class="select-input block-vesting-select"> with one <option data-label="<name>"> per template plus the No vesting sentinel (value="__none__"). Grants: always shown, defaults to the 4yr/1yr cliff (or this row's own prior value) — vesting_template is always server-side (payload-reference.md). Certificates: also shown (opt-in server-side), but defaults to No vesting unless the row or the batch knowns default already names a real template — the opposite default from grants. |
| Documents |
<button class="toggle[ selected]" data-group="docset" data-value="<set id>" data-label="<set name>"> — one per set; mark selected when only one set exists or this row already named one. |
| HMRC notified |
Grant-only. A checkbox (.block-hmrc-notified, bound to is_hmrc_notified) + date input (.block-hmrc-notified-date, bound to hmrc_notified), tagged data-conditional="so_type_emi" — shown only when the row's so_type is EMI, hidden (and omitted from the submit payload) otherwise. pickType() toggles this row when the type selection changes. |
| ATO notified |
Grant-only. A checkbox (.block-ato-notified, bound to is_ato_notified), tagged data-conditional="so_type_au" — shown only when so_type is Startup Concessions/Non-Concessional/ZEPO, hidden (and omitted) otherwise. |
| Employment related |
Grant-only, required. A Yes/No toggle pair (data-group="employment-related", bound to employment_related), tagged data-conditional="so_type_employment_related" — shown only when so_type is Unapproved, hidden (and omitted) otherwise. A tri-state, unlike the two checkboxes above: neither button starts selected, and collectBlocks() sends null while none is picked, so an unanswered designation stays distinguishable from an explicit "No". This is what validate_drafts rejects — collecting it here is the whole point, since the failure would otherwise land after the draft set already exists. |
| Share class |
<button class="toggle[ selected]" data-group="shareclass" data-value="<prefix>" data-label="<class name>" <class name></button> — one per share class (button text carries the prefix, e.g. (CS) Common, so the user can tell classes apart without decoding it themselves; data-label stays the bare name). selected on the prompt-named/row's-own class; else the only class when there's just one; else the last class in the fetched list (proxy for "most recently created" — no creation timestamp in the response, see certificate-fields.md's Share-class reconciliation). |
| Legend |
<button class="toggle[ selected]" data-group="legend" data-value="<legend id>" data-label="<legend name>" data-body="<full legal body, HTML-escaped>" name></button> — one per legend; mark the default/only/row's-own legend selected. Selecting one reveals its data-body in that block's attestation box. |
| Rule 144 reason |
<select class="select-input block-rule144-reason"> with the 5-value rule_144_difference_reason enum (payload-reference.md); pre-selected with this row's own value. Lives inside .block-rule144-reason-wrap, shown/hidden by pickRule144() in lockstep with the Rule 144 date input — visible only when "Use a different date" is picked. Collected here, in the panel, instead of a separate post-submit AskUserQuestion (the prior design) — the reason is required at the same moment the date is, so there's no reason to make it a second round-trip. The Rule 144 date field itself carries the required=True marker (*) — design feedback that it read as optional without one, even though it's always collected (defaulting to the issue date). |
| Advanced fields (grant) |
A collapsed <details class="advanced-fields"><summary>More fields (optional)</summary>, in order: custom_label, grant_reason (<select> — carta-web's own picklist, carta-modify-issuables/references/field-contract.md: New Hire, Merit, Promotion, Refresh, Corporate transaction, Relationship change, Retention, Advisor, Consultant, Board, Performance bonus, Boxcar grant — was free text, which silently invited server-rejected values), acceleration_template (moved in from its own top-level row; still tagged data-conditional="vesting", hidden when the block's own vesting is "No vesting"), early_exercise, auto_exercise_at_vest, is_flexible_issue_date, notes (moved in from the shared section). Collapsed is presentation only: collectBlocks() reads every one of these fields regardless of the accordion's open/closed state. (state_exemption/employee_id/cost_center/job_title/salary were dropped from the panel entirely — design feedback.) |
| Advanced fields (certificate) |
Same accordion pattern, in order: acceleration_template (moved in, same conditional-on-vesting behavior), prefix_number, cash_paid, debt_canceled, notes (moved in). (convertible_note was dropped from the panel entirely — design feedback. returned_invested_capital was dropped too — it's LLC-only and no MCP command can confirm LLC status.) |
| PIU field rows |
prefix (labelled Unit class), option_plan (optional, and never defaulted — an empty plan issues off the unit class), threshold_value and threshold_value_type (Unit / Overall only, labelled with the issuer's own noun from knowns.threshold_noun), issue_date, board_approval_date (optional, no pending state), vesting schedule + start, document_set_id, and corresponding_interest — rendered only when the selected unit class reports has_corresponding_interest. See piu-fields.md. |
| Advanced fields (PIU) |
acceleration_template (conditional on vesting), prefix_number (Security number), cash_paid (Consideration price — UK growth shares only), notes. |
data-label is required on vesting / acceleration / documents / share-class / legend buttons
(read for the submit payload). data-body is required on legend buttons (the
attestation box and your record of what the user attested to).
Server-error banners
Two additive, display-only inputs support carta-issuance SKILL.md's Phase
1.5 — neither is ever sent
to a mutate:
row.row_key — stamped onto every block's data-row-key (build_stakeholder_blocks()
assigns a positional r<index> fallback only when a row doesn't already carry its own).
Read by collectBlocks() on every submit so the parent skill can re-match a resubmitted row
to its previously-saved draft_pk across a validation-error retry — never array
position, which desyncs the moment a block is added or removed mid-retry (an ordinary thing
to do while fixing an error on an otherwise-still-open panel). addStakeBlock() stamps a
fresh, non-colliding key ('new-' + Date.now() + …) on a clone — a clone is a new person,
not an edit to the source block's already-saved row.
row.server_errors — a list of already-translated, already-human-readable message
strings for that specific stakeholder (e.g. "Quantity: Not enough shares in the option
plan"). build_stakeholder_block() renders them as a .block-error-banner (role="alert")
between the block head and the kv-table when present and non-empty — absent or empty renders
nothing (never an empty box). Messages are HTML-escaped but otherwise shown verbatim —
the parent skill translates payload keys before ever writing to this field (Voice &
defaults), this script never reinterprets a server message.
knowns.batch_errors — the panel-level counterpart, for corp-/batch-level errors not
tied to any one stakeholder (missing signatory, a whole-issuance-level error). Renders as
.panel-error-banner into BATCH_ERRORS_HTML, above the Grantees/Holders list.
Stakeholder auto-populate
The Name field is a text input with a custom typeahead dropdown against the
full roster (STAKEHOLDER_LIST_JSON) — select an existing stakeholder or type a
new name. This is template.html's own JS (renderSuggestions() /
selectSuggestion()), not a native HTML <datalist>: a <datalist> was tried
first and dropped — its suggestion popover doesn't render reliably inside Claude
Desktop's embedded webview, which made the "Search…" placeholder a lie (nothing
ever appeared). The dropdown filters the roster by substring match as the user
types, caps at 8 results, and closes on blur/Escape/outside-click. Focusing an empty Name
field also shows the first 8 roster entries (renderSuggestions()'s empty-query branch) —
design feedback that clicking into a blank field showed nothing until the user typed a
character, even when a roster clearly existed to pick from.
On an exact (case-insensitive) match — whether typed or picked from the dropdown
— the block's Email, Stakeholder type, and Relationship fields auto-populate from
the roster record. Fields stay editable, never locked — a prior version
locked them (read-only / disabled) on the theory that an edit would be "ignored
server-side," but that read as broken UI with no offsetting data-safety benefit:
Phase 1 always uses the real cap-table record for an existing stakeholder
regardless of what this panel shows, so locking the field doesn't protect
anything the field's own value could threaten — it just looks like a bug. This
mirrors the real Carta product's typeahead behavior in the drafts-v2 spreadsheet
and simple-issuance forms.
Payload delivered on submit
This is the Code adapter's JSON action-request (machine-to-machine) — see
artifact-flow §3. On the Cowork path the
parent skill collects the same fields via a show_widget form, which returns the same
rows shape through sendPrompt() — see
cowork-adapter.md §1.
The payload carries action ("config_submit" for Review, "save_only" for Save —
identical rows shape either way; only action tells the parent skill which Phase 1.5
branch to run), security_type, and rows — every field lives inside each row now,
since each stakeholder's block is independently configured; there are no separate
batch-wide scalars alongside rows anymore. The parent skill takes rows as its working
set and looks each name up on the cap table; it does not re-ask for
quantity/stakeholder/terms in chat.
Every row also carries row_key (each block's stable identity — see Server-error
banners), notes, and acceleration_template (null when "No
acceleration" is selected) regardless of type — omitted from the examples below for
brevity, same as the other empty-string-default optional fields. Grant rows additionally carry custom_label,
early_exercise, auto_exercise_at_vest, is_flexible_issue_date,
grant_reason (all from the "More fields" accordion — grant_reason is a picklist value, not
free text), plus is_hmrc_notified/hmrc_notified
(only present when option_type is EMI), is_ato_notified (only present when
option_type is one of the AU types), and employment_related (only present when
option_type is Unapproved) — the template's collectBlocks() omits those keys
entirely for any other so_type, mirroring the panel data-conditional visibility, rather
than sending a stale value for a type that can't carry it. employment_related is the one
tri-state among them: it arrives as true, false, or null when still unanswered. Certificate rows additionally
carry vesting_template_id/vesting_start_date (same shape as grants, null when "No
vesting"), prefix_number, cash_paid, debt_canceled (accordion fields).
Option grant (two rows shown with genuinely different terms — the second demonstrates a
block that diverged from the first via per-row edits or a copy-forward-then-changed value):
{
"action": "config_submit",
"security_type": "option_grant",
"corp_id": "2776",
"rows": [
{"name": "Jane Doe", "email": "", "quantity": "1000", "relationship": "Employee",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"option_type": "ISO", "exercise_price": "1.45",
"vesting_template_id": "94", "vesting_label": "4yr / 1yr cliff",
"vesting_start_date": "2026-06-11",
"document_set_id": "12", "document_set_label": "Standard option grant docs"},
{"name": "John Smith", "email": "", "quantity": "250", "relationship": "Consultant",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"option_type": "NSO", "exercise_price": "2.00",
"vesting_template_id": null, "vesting_label": "No vesting",
"vesting_start_date": null,
"document_set_id": "12", "document_set_label": "Standard option grant docs"}
]
}
option_type is that row's selected so_type. exercise_price is a bare number; the parent skill hard-sets 0 for ZEPO regardless.
vesting_template_id / vesting_start_date are null when No vesting is selected on that row (vesting_label is then "No vesting").
stakeholder_kind (INDIVIDUAL | NON-INDIVIDUAL) is the row's Stakeholder-type toggle — auto-populated (but still editable) when the name matched an existing roster record; the parent skill only trusts it for a genuinely new stakeholder (an existing record's kind always wins regardless of what this toggle shows).
Certificate:
{
"action": "config_submit",
"security_type": "certificate",
"corp_id": "2776",
"rows": [
{"name": "Jane Doe", "email": "", "quantity": "500", "relationship": "Employee",
"stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",
"board_approval": "approved_other", "board_approval_date": "2026-06-11",
"share_class_prefix": "CS", "share_class_label": "Common",
"price_per_share": "1.50",
"legend_id": "7", "legend_label": "Standard restrictive legend",
"rule_144_mode": "issue_date", "rule_144_date": null, "rule_144_reason": null}
]
}
share_class_prefix is that row's selected class prefix (share_class_label its display name) — different rows can carry different classes.
board_approval is approved_other (the panel doesn't distinguish "today" from "another date" — both are just a board-approval date; only pending is a distinct state) or pending (option-grant only — hidden for certificates, which always require a board approval date, and for PIUs, whose date is optional and simply cleared instead); board_approval_date is the chosen date, omitted when pending.
rule_144_mode is issue_date (the default — rule_144_date and rule_144_reason are both null, and the parent skill stamps the issue date as the Rule 144 date) or other (rule_144_date carries the chosen YYYY-MM-DD; rule_144_reason carries the enum value picked from the panel's own reason <select> — the parent reformats the date to MM/DD/YYYY and stamps rule_144_reason as rule_144_difference_reason, no separate collection step needed).
relationship in a row is the value the user selected in that block — the full
issue_date_relationship picklist (payload-reference.md),
always required for a new stakeholder (the panel's Review button won't enable until it's
set — the template's missingFields() checks it). It can still arrive as "" for a
roster-matched row whose own record has no relationship on file — missingFields()
detects a roster match by re-running the same name lookup onStakeNameInput() uses (fields
are never locked/disabled — Stakeholder auto-populate, above —
so this can't be read off a field's disabled state), so an empty value there reflects the
existing record, not a skipped required field. The parent
skill stamps relationship as issue_date_relationship only for new stakeholders not found
on the cap table; an empty-string row still falls through to the roster lookup, then
AskUserQuestion as before.
Back-to-edit round-trip
When the review panel's Back to edit returns the user here, the parent skill
reconstructs knowns.rows from $OUT_DIR/_review_rows.json (the Phase-1-resolved rows,
written before the review rendered) rather than re-deriving defaults — see carta-issuance
code-adapter.md's Back to edit. Every per-row key in the payload above
has a same-named or documented-mapping counterpart in a resolved row, so this is a
mechanical 1:1 copy, not a re-resolution.
Iterating on the UI
For design changes to the config panel (colors, spacing, layout, copy), you only
touch three files — no Carta MCP and no full skill run:
| File |
What lives here |
references/styles.css |
All panel styling (Ink tokens, toggles, inputs, legend box) |
references/template.html |
Structure + the inline behavior JS |
scripts/build_config.py |
The dynamic blocks (buttons, grantee rows, roster) |
Preview loop — edit a file, then:
uv run scripts/preview_config.py --open # renders both types, opens in browser
uv run scripts/preview_config.py --security-type certificate --open
It reproduces what render-panel does at runtime (runs build_config.py on the
committed sample fixtures, inlines styles.css, substitutes every {{TOKEN}}) and
writes preview_config_<type>.html. The Review and Save buttons are both inert
in preview (they POST to a dead port), so you can click through the form freely. Edit the
inline SAMPLE_* fixtures in preview_config.py to preview a different shape (more rows, a
longer legend, a UK jurisdiction, sample server_errors/batch_errors, etc.).
1---2name: carta-cap-table-issuance-config3description: Internal config panel sub-skill for carta-issuance. Renders a pre-flight configuration panel with one full key-value block per stakeholder — name, email, stakeholder type, relationship, quantity, and the whole type-specific field set (option type / exercise price / vesting / documents for option grants, or share class / price per share / legend / Rule 144 for certificates, plus issue date and board approval) — so a single batch can carry genuinely different terms per person. Not invocable directly — dispatched by carta-issuance Phase 0.5.4---56<!-- carta:instrumentation-fallback -->7<IMPORTANT>8`_instrumentation_v2` is anonymous Carta skill-usage telemetry. ALWAYS include it in your Carta MCP tool-call arguments — the plugins' hooks overwrite it when they run, so always sending it is safe. Shape:9`_instrumentation_v2: {"skills": ["<plugin>:<skill>", ...], "model": "<model-id>", "surface": "<value>"}`10`skills` is cumulative for the session: list every Carta skill loaded so far, not only the one driving this call. Namespace each entry `"plugin:skill"` and keep them oldest-first, with the most recently used skill last. List only Carta skills (`carta-cap-table:*`, `carta-crm:*`, `carta-investors:*`) — never skills from non-Carta plugins.11`surface` is the Claude surface you are running in: `"chat"` (claude.ai or the Claude app, i.e. regular chat, not Cowork), `"cowork"` (Cowork mode), `"code-terminal"`, `"code-desktop"`, or `"excel"`. Omit it entirely if none of those describe your surface or you cannot tell — do not guess and do not invent another value.12</IMPORTANT>1314# issuance-config panel1516Pre-flight configuration panel for issuance. Replaces the `AskUserQuestion` chain17with a single interactive panel in the Claude Desktop side panel. The panel is a18repeater of **one full key-value block per stakeholder** — every field (name,19email, stakeholder type, relationship, quantity, and the whole type-specific20field set) lives inside that person's own block, so one batch can issue21genuinely different terms to different people. A "+ Add stakeholder" button22appends another block, pre-filled by copying the most-recently-added block's23non-personal terms forward. **One template serves both security types**: it24carries every field set, and the `{{SECURITY_TYPE}}` switch (`option_grant` |25`certificate` | `piu`) hides the rows whose `data-sectype` doesn't match and26selects the matching `submit()` payload. Issue date and Board approval are shared field27rows (not type-gated) inside every block.2829**Two footer buttons.** **Review** posts `action: "config_submit"` — the parent30skill saves *and* validates (`save_drafts` + `validate_drafts`) before ever31rendering the review surface ([carta-issuance SKILL.md's Phase321.5](../SKILL.md#phase-15--save--validate-before-review-or-save-only)); any33server error re-renders this same panel with a banner (see [Server-error34banners](#server-error-banners), below), never silently moves on. **Save**35posts `action: "save_only"` — a lighter escape hatch, `save_drafts` only, no36validation, no panel re-render. Both gate on the exact same per-block37readiness check (`missingFields()`) — Save isn't a weaker bar.3839**Do not invoke this skill directly.** Dispatched by `carta-issuance` Phase 0.5.4041## References4243| File | Purpose |44|---|---|45| `../../../lib/issuance_fields.py` | **Every field builder, shared by both surfaces.** The Cowork form collects the identical set, so one copy serves both and the two cannot drift. Also emits the `so_type` gate sets as JS (`so_type_js_constants()`) so the browser's idea of which types report to HMRC always matches Python's. |46| `scripts/build_config.py` | **Builds every dynamic block for the Code panel** (toggle groups, grantee/holder rows, autocomplete roster) from the fetched data + prompt-derived `knowns`. The model never hand-authors panel HTML — doing so once shipped dead `btn-card` buttons and stamped a plan id where a document-set id belonged. |47| `scripts/build_cowork_form.py` | **Builds the whole Cowork form** as one self-contained `show_widget` document. See [Cowork form](#cowork-form). |48| `scripts/preview_config.py` | **Design-iteration harness.** Renders the panel to a standalone HTML file with committed sample data — no MCP, no skill run. See [Iterating on the UI](#iterating-on-the-ui). |49| `references/artifact.yaml` | Shared `required` + per-type `optional` substitutions; `save` + `submit-watcher` capabilities |50| `references/template.html` | Config panel — both field sets gated by `data-sectype`, shared sticky Review/Save footer |51| `references/styles.css` | Ink-compliant panel styles (toggles, date/price inputs, legend attestation box) |52| `references/cowork-template.html` | Cowork form — same class contract, `sendPrompt()` submit, per-row and batch layouts |53| `references/cowork-styles.css` | Cowork styles — design-system tokens only (see [Cowork form](#cowork-form) for why the panel sheet can't be reused) |54| `references/Inter-roman.var.woff2` | Inter variable font (panel only) |55| `references/SangBleuVersailles-Regular-WebS.ttf` | SangBleu Versailles for corp name (panel only) |5657## Cowork form5859`build_cowork_form.py` emits one self-contained document for `show_widget`. Same fields,60same `rows` payload as the panel — only the submit path and the styling differ.6162```bash63uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-issuance/issuance-config/scripts/build_cowork_form.py" \64 --security-type <option_grant|certificate|piu> \65 --data "$WORK/_data.json" --knowns "$WORK/_knowns.json" \66 --corp-name "<legal name>" --corp-id "<corporation_id>" \67 --out "$WORK/form.html"68# → prints FORM=<path>. Pass the file's contents VERBATIM as show_widget's widget_code.69```7071`_data.json` and `_knowns.json` are the same two files `build_config.py` takes — the72[`knowns` table in code-adapter.md](../references/code-adapter.md#1-config-panel-build_configpy-builds-every-block)73is the contract for both. Add `--no-minify` while iterating on the markup.7475**Why the panel's `styles.css` is not reused.** The widget host forbids four things it76does: hardcoded hex (invisible in dark mode), a background on the outer container (the77host paints it), `100vh`/sticky positioning (the iframe sizes to content), and78`@font-face` (the CSP blocks the plugin origin). `position: fixed` is out too — it79collapses the iframe — so the submit state is an in-flow block, not the panel's modal80overlay. `cowork-styles.css` uses design-system tokens throughout and keys its81responsive rule off `@container`, not `@media`: a viewport query would track the user's82window rather than the form's own width.8384**Batch mode** (cowork-adapter.md § Batch mode) activates on >10 rows whose terms are85all identical or unset, collapsing to shared-terms-once plus a name/email/quantity86table. `knowns.batch_mode` forces it either way. It is a **rendering** choice only: the87shared terms are expanded onto every row at submit, so the payload is indistinguishable88from the per-row layout's — including one `row_key` per person.8990**`read_me` is not needed.** `show_widget` renders this document as-is, and the91`interactive` module carries no repeater guidance that would express this form — the92call is ~5k tokens and a round trip for nothing.9394## Substitutions9596`required` (both types provide): `CORP_NAME`, `CORP_ID`, `FLOW_TITLE`97(`Issue Option Grants` | `Issue Certificates` | `Issue Profits Interest Units` —98verb-first, since this is a write operation), `HEADER_SUB` (`7 grantees` |99`2 holders`), `SECURITY_TYPE` (`option_grant` | `certificate` | `piu`).100101`optional` (default `""`; built by `build_config.py`):102103| Key | Value |104|---|---|105| `STAKEHOLDER_ROWS` | one full `.stake-block` per person from `knowns.rows` — every field (name, email, stakeholder type, relationship, quantity, and the whole type-specific field set) lives inside that block; one blank block when the prompt named no one. `TODAY_ISO`/`CURRENCY`/`EXERCISE_PRICE_DEFAULT`/`PRICE_PER_SHARE_DEFAULT` are no longer separate template substitutions — they're `knowns` scalar inputs `build_config.py` stamps into each block directly (see [payload delivered on submit](#payload-delivered-on-submit) below for why: a token inside a generated fragment isn't re-substituted by render-panel's single text-replace pass) |106| `STAKEHOLDER_LIST_JSON` | JSON array of the corp roster (`[{name,email,id,kind,event_relationship},…]`) for name autocomplete + email/stakeholder-type/relationship auto-fill |107| `BATCH_ERRORS_HTML` | Panel-level red banner (above the Grantees/Holders list) for corp-/batch-level server errors from a Phase 1.5 validation round — built from `knowns.batch_errors`; `""` (collapsed via CSS `:empty`) when clean. See [Server-error banners](#server-error-banners). |108109**`build_config.py` normalizes the real MCP shape.** The live `cap_table:get:stakeholders`110result uses `full_name`, never `name` — reading only `name` silently dropped every real111record (each one looked "nameless"), which emptied `STAKEHOLDER_LIST_JSON` against live112data even though every test fixture (built with a `name` key) kept passing. `build_stakeholder_list()`113reads `s.get("name") or s.get("full_name")` and always normalizes the output to a `name` key.114115Every per-type option list (option type, vesting, documents, share class, legend, Rule 144)116that used to be its own top-level substitution now lives **inside each block** within117`STAKEHOLDER_ROWS` — there's no separate `OPTION_TYPE_OPTIONS`/`VESTING_OPTIONS`/etc. key118anymore, since each stakeholder's block needs its own independently-selected set.119120`{{SAVE_PORT}}` is filled by render-panel.121122### Per-block field contracts123124**`build_config.py` emits all of these — this is the spec it implements, not a125hand-authoring guide.** The parent skill writes the fetched data + a `knowns`126object to disk and runs the script; the model never emits panel HTML. The rows127below document what the script produces (and what a reviewer should expect) for128**each** stakeholder block. Each button carries the attributes the template's JS129reads, and the row's own default is marked `selected` (falling back to the130batch-level `knowns` default when the row didn't specify its own value — see131[carta-issuance SKILL.md](../SKILL.md#phase-05--configure-the-issuance)). The132**No vesting** (grant) option is part of the script's output too.133134| Group | Per-button HTML |135|---|---|136| Stakeholder type | `<button class="toggle[ selected]" data-group="kind" data-value="INDIVIDUAL\|NON-INDIVIDUAL" onclick="pick(this)">Individual\|Non-individual</button>` — two buttons; `INDIVIDUAL` selected by default. Auto-selected (but still clickable/editable) by template JS on an exact roster-name match. |137| Type (`so_type`) | `<div class="toggle-row">` of the corp's own resolved jurisdiction's 3 `so_type` buttons only, each `<button class="toggle[ selected]" data-group="type" data-value="<so_type>" onclick="pickType(this)"><so_type></button>` — US `ISO`/`NSO`/`INTL`, UK `EMI`/`CSOP`/`Unapproved`, or AU `Startup Concessions`/`Non-Concessional`/`ZEPO`, gated by `knowns.jurisdiction` (design feedback reversed an earlier "show all 9 across all 3 jurisdictions, grouped by jurisdiction" layout — a corp only ever issues one jurisdiction's types, so the other 6 read as clutter, not a genuine affordance). Mark this row's resolved type `selected` when it has one. `pickType()` (not the generic `pick()`) additionally re-syncs the HMRC/ATO conditional rows below for the newly-selected type. |138| Vesting | `<select class="select-input block-vesting-select">` with one `<option data-label="<name>">` per template plus the **No vesting** sentinel (`value="__none__"`). **Grants**: always shown, defaults to the 4yr/1yr cliff (or this row's own prior value) — `vesting_template` is `always` server-side (payload-reference.md). **Certificates**: also shown (opt-in server-side), but defaults to **No vesting** unless the row or the batch `knowns` default already names a real template — the opposite default from grants. |139| Documents | `<button class="toggle[ selected]" data-group="docset" data-value="<set id>" data-label="<set name>" onclick="pick(this)"><name></button>` — one per set; mark `selected` when only one set exists or this row already named one. |140| HMRC notified | Grant-only. A checkbox (`.block-hmrc-notified`, bound to `is_hmrc_notified`) + date input (`.block-hmrc-notified-date`, bound to `hmrc_notified`), tagged `data-conditional="so_type_emi"` — shown only when the row's `so_type` is `EMI`, hidden (and omitted from the submit payload) otherwise. `pickType()` toggles this row when the type selection changes. |141| ATO notified | Grant-only. A checkbox (`.block-ato-notified`, bound to `is_ato_notified`), tagged `data-conditional="so_type_au"` — shown only when `so_type` is `Startup Concessions`/`Non-Concessional`/`ZEPO`, hidden (and omitted) otherwise. |142| Employment related | Grant-only, `required`. A Yes/No toggle pair (`data-group="employment-related"`, bound to `employment_related`), tagged `data-conditional="so_type_employment_related"` — shown only when `so_type` is `Unapproved`, hidden (and omitted) otherwise. A **tri-state**, unlike the two checkboxes above: neither button starts selected, and `collectBlocks()` sends `null` while none is picked, so an unanswered designation stays distinguishable from an explicit "No". This is what `validate_drafts` rejects — collecting it here is the whole point, since the failure would otherwise land after the draft set already exists. |143| Share class | `<button class="toggle[ selected]" data-group="shareclass" data-value="<prefix>" data-label="<class name>" onclick="pick(this)">(<prefix>) <class name></button>` — one per share class (button text carries the prefix, e.g. `(CS) Common`, so the user can tell classes apart without decoding it themselves; `data-label` stays the bare name). `selected` on the prompt-named/row's-own class; else the only class when there's just one; else the **last** class in the fetched list (proxy for "most recently created" — no creation timestamp in the response, see [certificate-fields.md's Share-class reconciliation](../references/certificate-fields.md#share-class-reconciliation-certificate)). |144| Legend | `<button class="toggle[ selected]" data-group="legend" data-value="<legend id>" data-label="<legend name>" data-body="<full legal body, HTML-escaped>" onclick="pickLegend(this)"><legend name></button>` — one per legend; mark the default/only/row's-own legend `selected`. Selecting one reveals its `data-body` in that block's attestation box. |145| Rule 144 reason | `<select class="select-input block-rule144-reason">` with the 5-value `rule_144_difference_reason` enum (payload-reference.md); pre-selected with this row's own value. Lives inside `.block-rule144-reason-wrap`, shown/hidden by `pickRule144()` in lockstep with the Rule 144 date input — visible only when "Use a different date" is picked. Collected here, in the panel, instead of a separate post-submit `AskUserQuestion` (the prior design) — the reason is required at the same moment the date is, so there's no reason to make it a second round-trip. The Rule 144 date field itself carries the `required=True` marker (`*`) — design feedback that it read as optional without one, even though it's always collected (defaulting to the issue date). |146| Advanced fields (grant) | A collapsed `<details class="advanced-fields"><summary>More fields (optional)</summary>`, in order: `custom_label`, `grant_reason` (`<select>` — carta-web's own picklist, [carta-modify-issuables/references/field-contract.md](../../carta-modify-issuables/references/field-contract.md): New Hire, Merit, Promotion, Refresh, Corporate transaction, Relationship change, Retention, Advisor, Consultant, Board, Performance bonus, Boxcar grant — was free text, which silently invited server-rejected values), `acceleration_template` (moved in from its own top-level row; still tagged `data-conditional="vesting"`, hidden when the block's own vesting is "No vesting"), `early_exercise`, `auto_exercise_at_vest`, `is_flexible_issue_date`, `notes` (moved in from the shared section). Collapsed is presentation only: `collectBlocks()` reads every one of these fields regardless of the accordion's open/closed state. (`state_exemption`/`employee_id`/`cost_center`/`job_title`/`salary` were dropped from the panel entirely — design feedback.) |147| Advanced fields (certificate) | Same accordion pattern, in order: `acceleration_template` (moved in, same conditional-on-vesting behavior), `prefix_number`, `cash_paid`, `debt_canceled`, `notes` (moved in). (`convertible_note` was dropped from the panel entirely — design feedback. `returned_invested_capital` was dropped too — it's LLC-only and no MCP command can confirm LLC status.) |148| PIU field rows | `prefix` (labelled **Unit class**), `option_plan` (optional, and never defaulted — an empty plan issues off the unit class), `threshold_value` and `threshold_value_type` (`Unit` / `Overall` only, labelled with the issuer's own noun from `knowns.threshold_noun`), `issue_date`, `board_approval_date` (optional, no pending state), vesting schedule + start, `document_set_id`, and `corresponding_interest` — rendered only when the selected unit class reports `has_corresponding_interest`. See [piu-fields.md](../references/piu-fields.md). |149| Advanced fields (PIU) | `acceleration_template` (conditional on vesting), `prefix_number` (**Security number**), `cash_paid` (**Consideration price** — UK growth shares only), `notes`. |150151`data-label` is required on vesting / acceleration / documents / share-class / legend buttons152(read for the submit payload). `data-body` is required on legend buttons (the153attestation box and your record of what the user attested to).154155### Server-error banners156157Two additive, display-only inputs support [carta-issuance SKILL.md's Phase1581.5](../SKILL.md#phase-15--save--validate-before-review-or-save-only) — neither is ever sent159to a mutate:160161- **`row.row_key`** — stamped onto every block's `data-row-key` (`build_stakeholder_blocks()`162 assigns a positional `r<index>` fallback only when a row doesn't already carry its own).163 Read by `collectBlocks()` on every submit so the parent skill can re-match a resubmitted row164 to its previously-saved `draft_pk` across a validation-error retry — **never** array165 position, which desyncs the moment a block is added or removed mid-retry (an ordinary thing166 to do while fixing an error on an otherwise-still-open panel). `addStakeBlock()` stamps a167 fresh, non-colliding key (`'new-' + Date.now() + …`) on a clone — a clone is a new person,168 not an edit to the source block's already-saved row.169- **`row.server_errors`** — a list of already-translated, already-human-readable message170 strings for that specific stakeholder (e.g. *"Quantity: Not enough shares in the option171 plan"*). `build_stakeholder_block()` renders them as a `.block-error-banner` (`role="alert"`)172 between the block head and the kv-table when present and non-empty — absent or empty renders173 nothing (never an empty box). Messages are HTML-escaped but otherwise shown **verbatim** —174 the parent skill translates payload keys before ever writing to this field (Voice &175 defaults), this script never reinterprets a server message.176- **`knowns.batch_errors`** — the panel-level counterpart, for corp-/batch-level errors not177 tied to any one stakeholder (missing signatory, a whole-issuance-level error). Renders as178 `.panel-error-banner` into `BATCH_ERRORS_HTML`, above the Grantees/Holders list.179180### Stakeholder auto-populate181182The Name field is a text input with a **custom** typeahead dropdown against the183full roster (`STAKEHOLDER_LIST_JSON`) — select an existing stakeholder or type a184new name. This is template.html's own JS (`renderSuggestions()` /185`selectSuggestion()`), not a native HTML `<datalist>`: a `<datalist>` was tried186first and dropped — its suggestion popover doesn't render reliably inside Claude187Desktop's embedded webview, which made the "Search…" placeholder a lie (nothing188ever appeared). The dropdown filters the roster by substring match as the user189types, caps at 8 results, and closes on blur/Escape/outside-click. **Focusing an empty Name190field also shows the first 8 roster entries** (`renderSuggestions()`'s empty-query branch) —191design feedback that clicking into a blank field showed nothing until the user typed a192character, even when a roster clearly existed to pick from.193194On an exact (case-insensitive) match — whether typed or picked from the dropdown195— the block's Email, Stakeholder type, and Relationship fields auto-populate from196the roster record. **Fields stay editable, never locked** — a prior version197locked them (read-only / disabled) on the theory that an edit would be "ignored198server-side," but that read as broken UI with no offsetting data-safety benefit:199Phase 1 always uses the real cap-table record for an existing stakeholder200regardless of what this panel shows, so locking the field doesn't protect201anything the field's own value could threaten — it just looks like a bug. This202mirrors the real Carta product's typeahead behavior in the drafts-v2 spreadsheet203and simple-issuance forms.204205## Payload delivered on submit206207This is the **Code adapter's** JSON action-request (machine-to-machine) — see208[artifact-flow §3](../references/artifact-flow.md#3-wake-on-submit). On the Cowork path the209parent skill collects the same fields via a `show_widget` form, which returns the **same210`rows` shape** through `sendPrompt()` — see211[cowork-adapter.md §1](../references/cowork-adapter.md#1-collectconfig--the-show_widget-form).212213The payload carries `action` (`"config_submit"` for **Review**, `"save_only"` for **Save** —214identical `rows` shape either way; only `action` tells the parent skill which Phase 1.5215branch to run), `security_type`, and `rows` — **every field lives inside each row now**,216since each stakeholder's block is independently configured; there are no separate217batch-wide scalars alongside `rows` anymore. The parent skill takes `rows` as its working218set and looks each `name` up on the cap table; it does **not** re-ask for219quantity/stakeholder/terms in chat.220221Every row also carries `row_key` (each block's stable identity — see [Server-error222banners](#server-error-banners)), `notes`, and `acceleration_template` (`null` when "No223acceleration" is selected) regardless of type — omitted from the examples below for224brevity, same as the other empty-string-default optional fields. **Grant** rows additionally carry `custom_label`,225`early_exercise`, `auto_exercise_at_vest`, `is_flexible_issue_date`,226`grant_reason` (all from the "More fields" accordion — `grant_reason` is a picklist value, not227free text), plus `is_hmrc_notified`/`hmrc_notified`228(only present when `option_type` is `EMI`), `is_ato_notified` (only present when229`option_type` is one of the AU types), and `employment_related` (only present when230`option_type` is `Unapproved`) — the template's `collectBlocks()` omits those keys231entirely for any other `so_type`, mirroring the panel `data-conditional` visibility, rather232than sending a stale value for a type that can't carry it. `employment_related` is the one233tri-state among them: it arrives as `true`, `false`, or `null` when still unanswered. **Certificate** rows additionally234carry `vesting_template_id`/`vesting_start_date` (same shape as grants, `null` when "No235vesting"), `prefix_number`, `cash_paid`, `debt_canceled` (accordion fields).236237**Option grant** (two rows shown with genuinely different terms — the second demonstrates a238block that diverged from the first via per-row edits or a copy-forward-then-changed value):239240```json241{242 "action": "config_submit",243 "security_type": "option_grant",244 "corp_id": "2776",245 "rows": [246 {"name": "Jane Doe", "email": "", "quantity": "1000", "relationship": "Employee",247 "stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",248 "board_approval": "approved_other", "board_approval_date": "2026-06-11",249 "option_type": "ISO", "exercise_price": "1.45",250 "vesting_template_id": "94", "vesting_label": "4yr / 1yr cliff",251 "vesting_start_date": "2026-06-11",252 "document_set_id": "12", "document_set_label": "Standard option grant docs"},253 {"name": "John Smith", "email": "", "quantity": "250", "relationship": "Consultant",254 "stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",255 "board_approval": "approved_other", "board_approval_date": "2026-06-11",256 "option_type": "NSO", "exercise_price": "2.00",257 "vesting_template_id": null, "vesting_label": "No vesting",258 "vesting_start_date": null,259 "document_set_id": "12", "document_set_label": "Standard option grant docs"}260 ]261}262```263264- `option_type` is that row's selected `so_type`. `exercise_price` is a bare number; the parent skill hard-sets `0` for ZEPO regardless.265- `vesting_template_id` / `vesting_start_date` are `null` when **No vesting** is selected on that row (`vesting_label` is then `"No vesting"`).266- `stakeholder_kind` (`INDIVIDUAL` | `NON-INDIVIDUAL`) is the row's Stakeholder-type toggle — auto-populated (but still editable) when the name matched an existing roster record; the parent skill only trusts it for a genuinely new stakeholder (an existing record's `kind` always wins regardless of what this toggle shows).267268**Certificate:**269270```json271{272 "action": "config_submit",273 "security_type": "certificate",274 "corp_id": "2776",275 "rows": [276 {"name": "Jane Doe", "email": "", "quantity": "500", "relationship": "Employee",277 "stakeholder_kind": "INDIVIDUAL", "issue_date": "2026-06-11",278 "board_approval": "approved_other", "board_approval_date": "2026-06-11",279 "share_class_prefix": "CS", "share_class_label": "Common",280 "price_per_share": "1.50",281 "legend_id": "7", "legend_label": "Standard restrictive legend",282 "rule_144_mode": "issue_date", "rule_144_date": null, "rule_144_reason": null}283 ]284}285```286287- `share_class_prefix` is that row's selected class prefix (`share_class_label` its display name) — different rows can carry different classes.288- `board_approval` is `approved_other` (the panel doesn't distinguish "today" from "another date" — both are just a board-approval date; only `pending` is a distinct state) or `pending` (option-grant only — hidden for certificates, which always require a board approval date, and for PIUs, whose date is optional and simply cleared instead); `board_approval_date` is the chosen date, omitted when `pending`.289- `rule_144_mode` is `issue_date` (the default — `rule_144_date` and `rule_144_reason` are both `null`, and the parent skill stamps the issue date as the Rule 144 date) or `other` (`rule_144_date` carries the chosen `YYYY-MM-DD`; `rule_144_reason` carries the enum value picked from the panel's own reason `<select>` — the parent reformats the date to `MM/DD/YYYY` and stamps `rule_144_reason` as `rule_144_difference_reason`, no separate collection step needed).290- `relationship` in a row is the value the user selected in that block — the full291 `issue_date_relationship` picklist ([payload-reference.md](../references/payload-reference.md#picklists)),292 **always required** for a new stakeholder (the panel's Review button won't enable until it's293 set — the template's `missingFields()` checks it). It can still arrive as `""` for a294 **roster-matched** row whose own record has no relationship on file — `missingFields()`295 detects a roster match by re-running the same name lookup `onStakeNameInput()` uses (fields296 are never locked/disabled — [Stakeholder auto-populate](#stakeholder-auto-populate), above —297 so this can't be read off a field's disabled state), so an empty value there reflects the298 existing record, not a skipped required field. The parent299 skill stamps `relationship` as `issue_date_relationship` only for new stakeholders not found300 on the cap table; an empty-string row still falls through to the roster lookup, then301 `AskUserQuestion` as before.302303### Back-to-edit round-trip304305When the review panel's **Back to edit** returns the user here, the parent skill306reconstructs `knowns.rows` from `$OUT_DIR/_review_rows.json` (the Phase-1-resolved rows,307written before the review rendered) rather than re-deriving defaults — see [carta-issuance308code-adapter.md's Back to edit](../references/code-adapter.md#back-to-edit). Every per-row key in the payload above309has a same-named or documented-mapping counterpart in a resolved row, so this is a310mechanical 1:1 copy, not a re-resolution.311312## Iterating on the UI313314For design changes to the config panel (colors, spacing, layout, copy), you only315touch three files — no Carta MCP and no full skill run:316317| File | What lives here |318|---|---|319| `references/styles.css` | All panel styling (Ink tokens, toggles, inputs, legend box) |320| `references/template.html` | Structure + the inline behavior JS |321| `scripts/build_config.py` | The dynamic blocks (buttons, grantee rows, roster) |322323**Preview loop** — edit a file, then:324325```bash326uv run scripts/preview_config.py --open # renders both types, opens in browser327uv run scripts/preview_config.py --security-type certificate --open328```329330It reproduces what `render-panel` does at runtime (runs `build_config.py` on the331committed sample fixtures, inlines `styles.css`, substitutes every `{{TOKEN}}`) and332writes `preview_config_<type>.html`. The **Review** and **Save** buttons are both inert333in preview (they POST to a dead port), so you can click through the form freely. Edit the334inline `SAMPLE_*` fixtures in `preview_config.py` to preview a different shape (more rows, a335longer legend, a UK jurisdiction, sample `server_errors`/`batch_errors`, etc.).