issuance-review artifact
This skill holds the references/ assets for the issuance review panel rendered
by carta-issuance Phase 2. The panel is read-only — every field was
already decided per-stakeholder in the config panel (issuance-config), so this
surface is a confirmation summary, not another editing pass; corrections happen
via Back to edit, which re-opens the config panel with every block restored.
One template serves both security types — the shared chrome (top bar,
modals, footer, save/submit JS) is fixed, and everything that differs between
option grants and certificates is injected as a per-type block substitution.
Those blocks (DETAIL_TABLE, KPI_STRIP, PLAN_CARD) are produced by
scripts/build_review.py from the resolved rows — carta-issuance runs the
script and passes the outputs via --substitute-file; the model never
hand-authors the row markup. The block contracts below are the spec
build_review.py implements (and what a reviewer should expect), not a
hand-authoring guide.
Do not invoke this skill directly. It is dispatched internally by
carta-issuance via artifact-manager:render-panel.
References
| File | Purpose |
|---|---|
scripts/build_review.py |
Builds the dynamic blocks (read-only DETAIL_TABLE rows, KPI_STRIP stat tiles, PLAN_CARD equity-plan summary) deterministically from the resolved rows. The model never hand-authors row HTML — same discipline as build_config.py. |
scripts/preview_review.py |
Design-iteration harness — renders the panel to a standalone HTML file with sample data, no MCP. See Iterating on the UI. |
references/artifact.yaml |
Declares the shared substitutions and capabilities (save, submit-watcher) |
references/template.html |
Thin shared frame — per-type regions injected via {{BLOCK}} tokens |
references/styles.css |
Ink-compliant CSS (typography, layout, modals, legend rows) |
references/Inter-roman.var.woff2 |
Inter variable font — served from the preview server at render time |
references/SangBleuVersailles-Regular-WebS.ttf |
SangBleu Versailles for the heading-1 company name |
Substitutions
All keys in artifact.yaml's required list are shared — both security
types provide every one. Type-specific numbers live inside the block values
(KPI_STRIP, DETAIL_TABLE, PLAN_CARD), never as top-level placeholders, so
the frame stays type-agnostic. {{SAVE_PORT}} is filled by render-panel, not
here. PLAN_CARD is the one optional key (default "") — see Block:
PLAN_CARD below.
Scalars (per type)
| Key | Option grant | Certificate | PIU |
|---|---|---|---|
FLOW_TITLE |
Issue Option Grants |
Issue Certificates |
Issue Profits Interest Units |
SUBHEADING |
<plan name> · <issue date> |
<issue date> (no share-class names — a batch's classes are already shown per-row in DETAIL_TABLE, so repeating them here is redundant, and it read oddly with a single class name floating alone next to a date) |
<plan name> · <issue date> only when every row shares one plan; otherwise <issue date> alone |
DETAIL_TITLE |
Grant Detail |
Certificate Detail |
Profits Interest Detail |
DETAIL_INTRO |
Review before issuing. Use Back to edit to change anything. |
same | same |
VIEW_URL_PATH |
options/list/<CORP_ID>/ |
certificates/list/<CORP_ID>/ |
options/piu/list/<CORP_ID>/ |
SECURITY_NOUN_PLURAL |
option grants |
certificates |
profits interest units |
ISSUE_MODAL_DISCLAIMER |
grant signature-flow sentence (below) | certificate legend-restriction sentence (below) | PIU signature-flow sentence (below) |
- Grant
ISSUE_MODAL_DISCLAIMER: "Confirming will save these grants to Carta and send them to the signatory for signature." - Cert
ISSUE_MODAL_DISCLAIMER: "Confirming will save these certificates to Carta and issue them to the cap table." - PIU
ISSUE_MODAL_DISCLAIMER: "Confirming will save these profits interest units to Carta and send them to the signatory for signature."
Never append draft-set status to SUBHEADING (e.g. "· new draft set") — this has been
ad-libbed in production for both types even though it's not part of either pattern above.
{{DRAFT_SET_ID}} is its own separate substitution for exactly this kind of state; the
subheading's job is just "what is this, and when" (plan/nothing, then the date).
Block: PLAN_CARD
Rendered for option grants always, and for PIUs only when every row carries the same
option_plan — a PIU's plan is per row and optional, so a mixed batch has no batch-wide
plan to name and claiming one would misstate which pool the other rows draw from. Empty
string for certificates (no equity plan concept there) and for a plan-less or mixed PIU
batch; the template then renders nothing, and the per-row Equity plan column carries it. A highlighted <div class="card section plan-card"> (blue left-accent bar, tinted background) naming the resolved
equity plan and its exercise periods — elevated out of SUBHEADING's plain
text into its own card (design feedback: the plan name used to be buried in a
subheading line with no visual weight). Built once by build_review.py's
build_plan_card() from the first resolved row's plan_name /
exercise_periods_text (every row in one draft set shares the same
equity_plan_id — Phase 1's Option-plan reconciliation — so there's nothing to
reconcile across rows). Omits the periods paragraph entirely when
exercise_periods_text is blank, rather than rendering an empty line.
Block: KPI_STRIP
A <div class="kpi-grid"> of .kpi-cell stat tiles, computed once in Python from the
resolved rows (nothing on this panel is editable, so nothing needs to live-recompute):
Recipients (row count); Total options / Total shares (sum of every row's
quantity — safe to sum regardless of currency, since a share/option count isn't money) with
a sub-line breakdown (ISO/NSO split for grants, per-share-class breakdown for certs);
Pending board approval (count of rows with no board_approval_date — a pending row omits
the key entirely per the Row templates, never an empty string) — option grant only;
certificates always carry a board date (Row templates: always required, no pending state),
so this tile would forever read 0 there — dropped instead of showing a count that can never
be anything else; Currency/Currencies (the distinct set of currencies across rows — a
batch can genuinely mix currencies now that option type/currency are chosen per-row in the
config panel, so this tile surfaces that plainly instead of a combined dollar total, per the
repo-wide "never sum across currencies" rule).
Grants render 4 tiles, certificates render 3 — .kpi-grid's CSS is repeat(auto-fit, minmax(160px, 1fr)), not a fixed repeat(4,1fr), so it adapts to whichever count
build_kpi_strip() actually emits instead of leaving an empty 4th slot for certs.
Block: DETAIL_TABLE
This block is a read-only table — one <tr data-stake> per grantee/holder — wrapped in
<div class="grantee-table-wrap"><table class="grantee-table">. Every cell is plain text (or,
for the legend column, a read-only expand/collapse toggle) — no data-field, no inputs, no
per-row remove button. Corrections happen via the Back to edit button in the action bar,
which returns to the config panel with every stakeholder block restored, not by editing a cell
here.
- Each row root still carries
data-stake— kept as a stable hook for future tooling (e.g. per-row highlighting); nothing selects on it to collect a payload anymore, and the legend button no longer needs it either (showLegendModal()readsdata-legend-bodystraight off the clicked button, not an ancestor lookup). - Name →
.stake-name, email →.stake-emailin the first<td>(display only).
Option grant — a deliberately trimmed recap (design feedback), not carta-issuance's
full 16-column chat-review list (see
references/chat-review.md for why the
panel and the chat spec now differ):
Stakeholder · Email · Type (so_type — labeled "Type", not "Option type"; there is no
separate stakeholder-kind column on this table to collide with) · Quantity · Exercise price ·
Board approval · Issue date · Vesting schedule · Vesting start · Grant expiration. No Remove
column, no Flags column. Plan and Currency are stated once in SUBHEADING / KPI_STRIP
instead of repeating per row (see issuance-config/SKILL.md for
where Relationship, Stakeholder type, Exemption, Documents, and Exercise periods were already
shown and confirmed one screen earlier).
Grant table template:
<div class="grantee-table-wrap">
<table class="grantee-table">
<thead><tr>
<th>Stakeholder</th><th>Email</th><th>Type</th><th>Quantity</th><th>Exercise price</th><th>Board approval</th><th>Issue date</th><th>Vesting schedule</th><th>Vesting start</th><th>Grant expiration</th>
</tr></thead>
<tbody>
ROW_PER_GRANTEE
</tbody>
</table>
</div>
Each ROW_PER_GRANTEE (every cell plain text):
<tr data-stake>
<td><div class="stake-name">FULL_NAME</div><div class="stake-email">EMAIL</div></td>
<td>EMAIL</td>
<td>SO_TYPE</td>
<td>QUANTITY (comma-formatted)</td>
<td>EXERCISE_PRICE_OR_DASH</td>
<td>BOARD_DATE_OR_DASH</td>
<td>ISSUE_DATE_OR_DASH</td>
<td>VESTING_SCHEDULE_NAME</td>
<td>VESTING_START_OR_DASH</td>
<td>GRANT_EXPIRATION_OR_DASH</td>
</tr>
- Every
*_OR_DASH= the resolved value, or—when the row doesn't carry it. Source field names are the Row-template keys (../../SKILL.md#row-templates) —BOARD_DATE_OR_DASHreadsboard_approval_date(never present on a pending row — that's how the KPI strip's Pending-board-approval count works),VESTING_START_OR_DASHreadsvesting_start_date. Neither is the shortboard_date/vesting_startname a stale prior version of this table used — don't reintroduce it. - Every
*_DATE_OR_DASHcell is masked toMM/DD/YYYYby_fmt_date(), not emitted raw — resolved rows mix DateFieldYYYY-MM-DD(issue_date,board_approval_date) with CharFieldMM/DD/YYYY(vesting_start_date,grant_expiration_date,rule_144_date);_fmt_date()converts an ISO value and passes an already-masked one through unchanged, so every date column lands on the one format the user actually sees (Voice & defaults). Don't reintroduce a barer.get("issue_date")— that was the pre-fix bug (raw2026-06-11shown next to already-masked columns). VESTING_SCHEDULE_NAMEis resolved from the row'svesting_templateid against the fetched vesting-templates list (--vesting-templates), the same way the certificate table's share-class name is resolved fromprefix—vesting_templatenever holds a display label directly;null→"No vesting".build_review.py'smain()refuses to run (exits 2 with a stderr message) if any row carries avesting_templateid and--vesting-templatescame back empty — this used to silently render"Custom"for a perfectly real selection whenever the reference-data file wasn't threaded through, which is actively misleading (this skill can never set genuinely custom vesting — Hard rule 7). An id that's still unresolved despite a non-empty list (e.g. a template deleted after being fetched) renders"Selected — details unavailable", never"Custom".- Pass the raw fetched result to
--vesting-templates/--share-classes— don't hand-flatten it.build_review.pyunwraps the standard{count, results}envelope (and nested{result: ...}/{text: ...}shapes) itself, same asbuild_config.py. By recorded incident: a caller that instead wrote its own flattened array to a separately-named file (because it didn't know the script would unwrap the envelope) once found a same-named leftover from an unrelated prior run already sitting in the corp-keyed$OUT_DIRand reused it rather than regenerating it. Writing the raw fetch straight to the documented--vesting-templates/--share-classespath, fresh, every run, removes both the ad hoc step and the staleness risk.
- Pass the raw fetched result to
Certificate — columns (carta-issuance SKILL.md's 13 "always" columns, minus Currency — redundant with the KPI strip's Currency/Currencies tile — and minus Exemption, design feedback dropped it from this trimmed recap): Stakeholder · Type · Email · Relationship · Share class · Quantity · Price/share · Board approval · Issue date · Rule 144 date · Build legend · Flags (when applicable). No Remove column, no Currency column, no Exemption column.
Cert table template:
<div class="grantee-table-wrap">
<table class="grantee-table">
<thead><tr>
<th>Stakeholder</th><th>Type</th><th>Email</th><th>Relationship</th><th>Share class</th><th>Quantity</th><th>Price / share</th><th>Board approval</th><th>Issue date</th><th>Rule 144 date</th><th>Build legend</th>FLAGS_TH
</tr></thead>
<tbody>
ROW_PER_HOLDER
</tbody>
</table>
</div>
Each ROW_PER_HOLDER:
<tr data-stake>
<td><div class="stake-name">FULL_NAME</div><div class="stake-email">EMAIL</div></td>
<td>STAKEHOLDER_KIND_LABEL</td>
<td>EMAIL</td>
<td>RELATIONSHIP_OR_DASH</td>
<td>SHARE_CLASS_NAME (PREFIX)</td>
<td>QUANTITY (comma-formatted)</td>
<td>PRICE_OR_DASH</td>
<td>BOARD_DATE_OR_DASH</td>
<td>ISSUE_DATE_OR_DASH</td>
<td>RULE144_OR_DASH</td>
<td><button class="legend-view-btn" data-legend-body="LEGEND_BODY">View legend</button></td>
FLAGS_TD
</tr>
STAKEHOLDER_KIND_LABEL/RELATIONSHIP_OR_DASH— same display rules as the grant table above.SHARE_CLASS_NAME (PREFIX)= the matching class's full name from the fetched share-class list plus its prefix, or just the prefix (—if blank) when no match is found.FLAGS_TH/FLAGS_TD— same logic as grant: include column only when ≥1 row has a flag.FLAGS_HTML=<span class="tag tag-intl">Non-individual</span>/<span class="tag tag-warn">Non-cash dividend</span>/<span class="tag tag-first">LLC $0 OK</span>.RULE144_OR_DASHappends the difference reason as a parenthetical (_rule_144_cell(),RULE_144_REASON_LABELS) whenever the row carriesrule_144_difference_reason— set only whenrule_144_date≠issue_date(Row templates), so its mere presence is the trigger; no separate date comparison needed.LEGEND_BODY= full legal body text (HTML-escaped), read fromdata-legend-bodyand shown in#modal-legendon click (showLegendModal()) — not an inline expand/collapse anymore. The inline version lived inside.grantee-table-wrap, which scrolls horizontally; a revealed body inside a narrow<td>could be clipped or invisible depending on scroll position. A modal sidesteps the table-layout constraint entirely.
Button wiring
| Button | Action |
|---|---|
| Back to edit | No confirmation modal — immediately POSTs action: "back_to_edit" → carta-issuance re-renders the config panel with every block restored |
| Confirm & Issue | Opens confirmation modal → on confirm, POSTs action: "submit" → carta-issuance runs issue_securities only (the rows were already saved by the parent skill's Phase 1.5 save+validate step, before this panel ever rendered) |
| View on Carta ↗ | target="_blank" anchor to https://<ENV_HOST>/<VIEW_URL_PATH> → opens in a new browser tab |
There is no "Save draft" button on this panel — it moved to the config panel's own Save
button (parent SKILL.md's Phase 1.5), since by the time this review panel renders, the rows
are already saved. Both remaining action-bar buttons disable via setActionsDisabled(true)
the instant their POST fires (kept disabled through the progress-view spinner), re-enabling
only if the POST itself fails (onError) — this prevents a double-click from re-sending
back_to_edit / submit while the first request is still in flight.
On either action, the panel POSTs { action, corp_id, corp_name, draft_set_id } to
the save-server (the side-panel JSON — see
../references/artifact-flow.md; on the
Cowork path the parent skill confirms with one AskUserQuestion instead of this JSON
contract — see cowork-adapter.md §3). No rows — the panel is read-only, so there is nothing on the surface
to collect; carta-issuance builds the mutate payload straight from its own Phase-1-resolved
rows (see carta-issuance SKILL.md).
The save-server write wakes carta-issuance via the submit-watcher; the generic wake /
panel-close / no-poll mechanics live once in
../references/artifact-flow.md §3, §5. On Confirm &
Issue, showSubmitted() swaps the modal's progress message to an honest hand-off line —
there is no "Done" button: nothing is left to click, and the modal's dimmed backdrop
stays clickable to dismiss it manually. Deliberately does not call window.close():
unlike a true host-opened webview (where it's a guaranteed no-op), some automation-controlled
test hosts actually honor it, which would hide the hand-off message before the user ever
reads it — a regression caught in testing. Back to edit shows no modal at all — the rows
were already saved by Phase 1.5, re-editing and saving again just updates those same rows in
place, and the panel is about to be replaced by the re-rendered config panel anyway.
Grace-timer fallback (armGraceTimer()/START_GRACE_MS), same 30s contract as the config
panel's _startTimer (issuance-config/references/template.html) — by recorded incident,
a prior version of this panel had no recovery path at all once a POST succeeded: the modal
showed "Sent to Claude…" and every action-bar button stayed disabled forever, with no signal
to the user if the submit-watcher stalled or never woke (indistinguishable, from the panel's
own perspective, from Claude simply taking a while). Every success path that leaves the
actions disabled (showSubmitted() for Confirm & Issue, and backToEdit()'s own toast) arms
the timer; if it elapses with no visible progress, the modal resets to its confirm view, the
action bar re-enables, and a toast tells the user to type "continue" in chat or click the
button again. The panel can't observe whether Claude actually processed the request in time —
this is the same accepted limitation the config panel already has, not a new one.
Iterating on the UI
For design changes to the review panel (colors, spacing, table layout, copy), you touch three files — no Carta MCP, no full skill run:
| File | What lives here |
|---|---|
references/styles.css |
All panel styling (Ink tokens, table, modals, legend rows, KPI tiles) |
references/template.html |
Shared chrome + save/submit JS |
scripts/build_review.py |
The per-row detail table and the KPI stat tiles |
Preview loop — edit a file, then:
uv run scripts/preview_review.py --open # renders both types, opens in browser
uv run scripts/preview_review.py --security-type certificate --open
It runs build_review.py on committed sample rows, inlines styles.css, and
substitutes every {{TOKEN}}, writing preview_review_<type>.html. The Back to
edit / Confirm & Issue buttons are inert in preview (they POST to a dead port),
so you can open the modals and click through freely. Edit the inline SAMPLE_ROWS
in preview_review.py to preview more rows, an international employee, non-cash
dividend flags, or a long legend body.