Skip to content

Publish To npm Through Trusted Publishing Instead Of A Stored Token - #396

Merged
tommybarvaag merged 1 commit into
mainfrom
ci/npm-trusted-publishing
Oct 23, 2026
Merged

tommybarvaag merged 1 commit into
mainfrom
ci/npm-trusted-publishing

Conversation

@tommybarvaag

Copy link
Copy Markdown
Contributor

Problem

The publish job authenticated to npm with NPM_TOKEN, a repository secret. Every workflow run on every branch of this repository can read a repository secret, including merge.yml runs for PRs from branches inside the repo. The token itself can publish from anywhere until it expires. The runbook kept the token only because npm dist-tag add couldn't use OIDC (npm/cli#8547), and npm fixed that in 11.21.0 (npm/cli#10038).

Scope

This is release infrastructure for the one published package. The releases page still says publishing is "not active yet" and calls the channels "designed", although RELEASE_ENABLED has been true since 2026-09-29. That is a separate problem, so this PR changes only the sentences about the npm credential. Two follow-ups need engine changes in elmeragroup/internal, and the runbook records both. One splits build and publish into separate jobs. The other keeps provenance on a retry.

Change

  • The publish job runs in the GitHub environment npm, which only main can deploy to. The release job in merge.yml and publish-release.yml both grant id-token: write, and no secret crosses the workflow_call.
  • The job installs npm 11.21.1 before the engine runs. Node 24.13.0 bundles npm 11.6.2, which can't authenticate npm dist-tag add over OIDC.
  • A retry dispatch sets NPM_CONFIG_PROVENANCE=false. npm signs provenance for GITHUB_SHA, and a retry runs at the head of main, not at the commit its recorded archive came from.
  • release-credentials.ts no longer strips the npm token or restores setup-node's placeholder, because the step holds no npm token now. It still strips the GitHub token and the OIDC request pair during the build and gates.
  • scripts/RELEASE.md, README.md and the releases page describe the trusted-publisher entries, npm's 2-day expiry for unused entries, the retry entry and the remaining gaps.

The PR carries no-changeset and leaves out no-canary on purpose. Its merge publishes a canary through OIDC, and that publication validates the new merge.yml trusted-publisher entry inside npm's 2-day window.

Before this merges, a package owner adds a trusted publisher to @elmeragroup/fuse on npmjs.com, with these settings:

  • repository elmeragroup/fuse
  • workflow merge.yml
  • environment npm
  • direct npm publish allowed
  • "Allow npm dist-tag" enabled

Without that entry, the canary publication after the merge fails.

Verification

  • pnpm exec vitest run --config test/vitest.config.mjs test/release-workflow.test.mjs test/merge-workflow.test.mjs passes, 29 of 29. Run against main's workflow files, the same tests fail 6, one per new expectation.
  • vitest related scripts/release-credentials.ts scripts/release-pack.ts --run --project unit in packages/fuse passes, 3 of 3.
  • oxfmt --check passes on every changed file.
  • npm's behaviour was checked in its v11.21.1 source. lib/utils/oidc.js stores the exchanged token in the cli config layer, which outranks setup-node's .npmrc placeholder. It turns on provenance only when provenance keeps its default. libnpmpublish/lib/provenance.js takes the commit from GITHUB_SHA.
  • Two fresh-context Codex reviews ran, one for bugs and security and one for code quality. Round 1 found that a retry signed provenance for the wrong commit, and this PR fixes it. Round 2 reported no findings.
  • Not run locally: lint, pnpm check and the docs e2e readiness-copy.test.ts. The machine's load stayed well above the repo's throttle limit. Lint in an unbuilt worktree also reports type-aware errors in files this PR doesn't touch. CI runs all three.
  • Not checkable before merge: a real OIDC publication. The first canary after the merge is the test. It should carry provenance on npm and move the canary tag.

Replace the NPM_TOKEN repository secret with npm trusted publishing
(OIDC). The publish job runs in the main-only `npm` environment, grants
id-token: write on both sides of the workflow_call, and pins npm 11.21.1,
the first patch with OIDC for `npm dist-tag add`.

A retry dispatch publishes without provenance, since npm would sign
main's head instead of the recorded archive's commit. The credential
strip list drops the npm token and setup-node placeholder handling.
The runbook, README and releases page describe the new setup.
@tommybarvaag
tommybarvaag requested a review from oddhus as a code owner October 23, 2026 09:40
@tommybarvaag tommybarvaag added the no-changeset Skip the changeset presence check for CI/tooling-only PRs label Oct 23, 2026
@tommybarvaag

Copy link
Copy Markdown
Contributor Author

Reopening so the merge workflow's event payload carries the no-changeset label.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Docs preview: removed when this PR closed.

@tommybarvaag
tommybarvaag merged commit 8e3bd83 into main Oct 23, 2026
21 of 30 checks passed
@tommybarvaag
tommybarvaag deleted the ci/npm-trusted-publishing branch October 23, 2026 09:54
tommybarvaag added a commit that referenced this pull request Oct 23, 2026
)

## Problem

`scripts/RELEASE.md` told maintainers to set the package's publishing
access to "Require two-factor authentication and disallow tokens" once
the token was gone. npm's settings page labels that option "Require
two-factor authentication and disallow bypass 2fa tokens (recommended)".
The owner applied it on 2026-10-23, after #396 moved publishing to
trusted publishing, so the runbook also described a finished step as
pending.

## Scope

This is a one-paragraph correction to the release runbook. The releases
page's stale "not active yet" copy is a separate problem and stays out
of this PR.

## Change

- The authentication section now says to keep publishing access at npm's
current label. It also says the setting stops every npm token from
publishing and leaves trusted publishing working, which is what npm's
settings page states.
- The pending instruction at the end of the npm pin paragraph is
removed.

## Verification

- `pnpm exec oxfmt --check scripts/RELEASE.md` passes.
- `git grep` finds no test that reads `RELEASE.md`.
- The label was checked against the package's Publishing access settings
on npmjs.com, where the option is now selected.
tommybarvaag added a commit that referenced this pull request Oct 26, 2026
## Problem

Designers have no Fuse Input, Textarea, Avatar or Meter in Figma, and
none of the leaf and row components: Select trigger, Pagination,
Breadcrumb, the Field text parts, Empty, Link, the Calendar day cell,
Item, Alert, Accordion, the Sidebar rows and the Navigation menu bar.
The kit cannot draw them yet. Every box is a horizontal auto layout that
hugs or has a fixed size. Text never fills, wraps or truncates. Nothing
can fill its parent, stack vertically, sit at an absolute position,
carry an image, rotate, dash its stroke or hide behind a toggle.

## Scope

Mostly tooling in the private `@elmeragroup/fuse-figma` package. The
`@elmeragroup/fuse` changes are recipe moves out of TSX into
runtime-free recipe files that render identical classes, plus one
consumer fix: a lone Checkbox or Switch in a vertical `Field.Root` keeps
its size in a container that sizes to its content (U7, patch changeset).

The owner chose one PR for this batch instead of a stack of about 27, as
an exception to one problem per PR for the Figma kit work. Each unit
below is one commit, reviewed before it is committed, so the PR can be
read commit by commit. Units land in order and this body is updated as
they do.

| Unit | What | State |
| --- | --- | --- |
| U1 | Shared test helpers | done |
| U2 | One label source, README facts, `.env.example` | done |
| U3–U6 | Box sizing and direction in the plugin and the build, text
layout, the field corner and `input/50` variables | done |
| U7 | Field keeps a lone Checkbox and Switch at their size | done |
| U8–U11 | Input, Textarea, Avatar with image fills, Meter with absolute
positioning | done |
| U12–U13 | Shared facts, slotless parts, BOOLEAN properties, rotation |
done |
| U14–U18 | Confirm button, Pagination, Breadcrumb, Select trigger, the
Field page and its text parts | done |
| U19–U22 | Empty with dashed strokes, Calendar day cell, Link, and the
variables for Empty and the W4 rows | done |
| U23–U27 | Item row, Alert, Accordion item, Sidebar rows, Navigation
menu bar parts | done |
| U28 | Fixes from the owner's real-file runs | done |
| U29–U32 | Instances inside sets (plugin and build), Kbd group,
Breadcrumb trail, Pagination row, Alert action, Empty actions | done |
| U34–U37 | Menu and popover recipe moves, client-rendered compare, menu
variables and rules, Select's open menu | done |
| U38–U42 | Popover, Dropdown menu, Input group, Combobox, Command |
done |
| U43 | Navigation menu popup | done |
| U44–U47 | Sidebar panel recipe moves, Sidebar factory parts, Sidebar
sections, Sidebar panel | done |
| U33 | Avatar group | done |
| U48–U49 | Fixes from the owner's real-file Applies: a swapped instance
sublayer, side stroke weights | done |
| Compare fixes | Fixes from the owner's real-file compares: the compare
mount, Select's label, the Combobox popup width, shortcut glyphs,
preview instance lengths | done |

## Change

- **U1, shared test helpers.** `component-spec.test.ts` had 22 local
build helpers, and four compare cell tests each copied a root lookup and
a state-attribute list. They move into `test/fixture-sets.ts` and
`test/static-markup.ts`, so the next sets reuse them instead of copying
them again.
- **U2, loose ends.** The Badge, Button, Toggle and Kbd renderers read
their label from the kit definition instead of restating it, and the
markup check now compares each box's own text with its label, decoding
the entities React writes. The README records that Figma refuses to
remove the page it shows, fixes a stale code-map row, and points to a
new `.env.example` for the sync's token and file key.
- **U3, box sizing and direction in the plugin.** A kit box now carries
a direction, a fixed, hug or fill sizing on each axis, min and max
limits, and whether it clips. The plugin writes them through
`layoutSizingHorizontal` and `layoutSizingVertical`, in the order the
probes showed a real file accepts: the direction, then the fixed
lengths, then the sizing, then the limits. A direction change rewrites
the box's fixed lengths and its layers' sizing, since a turn resizes the
box and moves a fill to the other axis. The kit check refuses a variant
root that fills, a layer that fills a parent that hugs, and a limit on a
fixed axis. The build still emits the same 14 sets, in the new shape.
- **U4, box sizing and direction in the build.** The build reads them
from the compiled CSS: `flex-col` as a column, `justify-between` as
space between, a column's default stretch as a fill across it, `100%` as
a fill against a definite parent (along a row only for its sole layer,
since CSS sizes a `w-full` beside siblings differently from Figma's
fill), a root's `100%` against a preview width its definition names,
`min-*` and `max-*` as limits (folded into a fixed length as CSS
resolves them), `field-sizing: content`, and `overflow: hidden`.
`rounded-full` on a box with one fixed side writes half that side, which
the corner-clamp probe showed Figma draws as CSS does, so that `TODO.md`
item goes. The sizing reads live in a new `box-sizing.ts`.
- **U5, text layout.** A text layer now carries its sizing, alignment
and truncation. The plugin derives `textAutoResize` from the sizing and
writes the auto resize, the sizing, any fixed length, the truncation and
`maxLines` in the order the probes showed, and a new box writes its
sizing whatever its stored modes predict. The build reads `truncate` (an
ellipsis only in a block box, since a flex box clips its text without
one), `line-clamp-*`, wrapping in a definite width, a flex box's text as
an item that keeps its own width where the box places it, a native
control's placement through its box's alignment, per-cell characters,
and one text property that two labels share. A label's mapping lives in
a new `label-layout.ts`. A read-only probe showed the published labels
already hold the left, top, hugging, untruncated layout the plugin now
writes.
- **U6, two variables.** `radius-field-box` syncs the field box corner
per theme (`radius` in internal themes, `radius-field` in external ones)
and binds by its code syntax, as the checkbox corner does, and
`input/50` is the disabled field box's fill. Input and Textarea bind
both. The owner runs `figma:sync` before the next Apply.
- **U7, Field keeps a lone Checkbox and Switch at their size.** A
vertical `Field.Root` stretched every direct child with `*:w-full`,
which beat Checkbox's `size-4` and Switch's `w-[32px]`. In a container
that sizes to its content, a label-less checkbox drew 2px wide and a
switch 18px. The stretch now skips Checkbox and Switch, as it already
skipped `.sr-only`, and stays at one class's specificity. The responsive
md row resets the stretch through the same child selector, so Tailwind
emits the reset after the stretch. With a `*:` reset, Tailwind's sort
order let the stretch win, and the md row shrank a direct checkbox to 0
and a switch to 16px. A patch changeset says what consumers see.
- **U8, Input.** The first set on the text-layout factory: placeholder
and value in five states, 10 variants at the docs' 384px width. An
`<input>` sets its value on one line whatever its classes say, so a
native control's label now fills the box and ends in an ellipsis after
one line. An invalid Field's `text-error` reaches the input through
inheritance from an ancestor layer the kit does not draw. A custom
property that only passes a value to parts the box does not have drops
instead of staying unmapped, unless it overrides a synced variable.
`inputClass` moves into a runtime-free `input-class.ts`. The markup
check reads an `<input>`'s label from its `placeholder` or `value`
attribute, decoded. One hand-written kit inventory replaces the page,
set and preview lists that three end-to-end tests repeated, so a new set
adds one row.
- **U9, Textarea.** Placeholder and value in five states, 10 variants at
384px, sharing Input's axes, states and invalid text. The box hugs its
height above a 64px minimum, and the text wraps from the top left, since
a `<textarea>` wraps its value across its content box whatever its
`display` says. `textareaClass` moves into a runtime-free
`textarea-class.ts`. The compare renders it through `Field.Control`, as
the docs do; Chromium's resize grip is a listed expected difference.
- **U10, Avatar with image fills.** Initials or the docs portrait in a
circle at four px sizes, 8 variants. The kit can now paint an image: the
build carries a PNG's bytes and its SHA-1, which a probe showed is
Figma's image hash, so Plan matches a held image without storing
anything, and Apply stores it and checks the hash. The kit check accepts
a PNG with a whole header of at most 4096px a side. The portrait is
rasterised once from the docs' SVG to a checked-in 96px PNG that Figma
and the compare share. Avatar's inline classes move into four
runtime-free `cn` constants (`avatar-class.ts`); a first `tv` recipe
brought tailwind-variants into the `avatar` entry and failed
`size-limit` (25.4 kB against an 18.7 kB ceiling), so a follow-up commit
returns it to 13.2 kB. A flex item whose text is ASCII letters and
digits alone, which a browser never wraps, hugs where its box places it.
- **U11, Meter with absolute positioning.** Every mode and level the
docs show, 16 variants at 256px. The fill is an absolute layer at the
bar's start, SCALE across for its share of the bar and STRETCH down, so
it keeps its share when a designer stretches the Meter. The plugin
writes the positioning before the size, as Figma refuses FILL on an
absolute layer, and rewrites an absolute layer's place and size when a
box above it resizes in the same run, so one Apply converges. The build
measures an absolute part against its containing block after that
block's limits, and leaves a share that a min or max bounds unmapped. A
part can carry an inline style that cascades above its classes (Base UI
writes the fill's width), a block-level root takes the preview width,
and the space before the value becomes the box's gap. The paint readers
move into `box-paint.ts`. `meterVariants` gains label and value slots.
- **U12, shared facts and slotless parts.** W3's leaf sets read plain
`hover:` and style a nested link, so the shared state table holds
`hover:` at the hover state and omits `[&>a]` and `[&>a:hover]`. React
Aria's Calendar cell and Link render no `data-slot`, so a definition can
name its root, or a part, by an element selector, which compare measures
by and the markup check resolves below the part's parent. The kit's
output is unchanged.
- **U13, optional parts and rotation.** A part can sit behind a BOOLEAN
property linked to its visibility, so a designer shows or hides it from
the properties panel. The build refuses an optional part when any
compiled rule reads a `data-slot` in its subtree through `:has()`, since
hiding a layer cannot restyle its parent; one selector scanner now
serves specificity and these reads. A part or icon can carry a rotation
read from `rotate`, so an open caret turns 180°. A quarter turn is taken
only on a square layer, since Figma lays a turned layer out by its
turned box, which CSS does not.
- **U14, Confirm button.** Its own set on the Button page: destructive
and success, four sizes, four states, armed off and on, 64 variants,
with Button's classes merged with `confirmButtonVariants` as the
component passes them. A disabled cell draws disarmed, since disabling
disarms the component. Button's set is unchanged.
- **U15, Pagination.** The page link (current off and on), the previous
and next edges, and the ellipsis, 13 variants on a Pagination page. They
draw Button's recipe as the component passes it. The link's number takes
the host's type, since Button's `icon` size sets none, so it draws at
16px where the edges' copy uses the control type, as Fuse renders it
today.
- **U16, Breadcrumb.** The link, the current page, the ellipsis and the
separator, 6 variants on a Breadcrumb page. Breadcrumb's inline classes
move into a `breadcrumbVariants` slot recipe with identical markup. A
set can now name its ancestors by their recipe classes: each gets a
layer around the root, cascaded with the cell's states, and the root
inherits from the nearest. So the parts take the list's text, and Input
and Textarea take an invalid Field root's colour, from Fuse's own
classes rather than hand copies. A shared fact holds `sm:` and `md:` at
the kit's desktop width.
- **U17, Select trigger.** Input's field box with a placeholder or value
and a caret, closed or open, at both sizes and four states, 32 variants
on a Select page; an open cell turns the caret 180°. The trigger, value
and caret classes move into a `selectTriggerVariants` slot recipe with
identical markup. The builder learns `*:data-[slot=x]:` classes on a
child part, `flex: 1` in a hugging box with no main-axis limit, and an
inert line clamp. The box mapper moves into `box-mapping.ts`.
- **U18, the Field page.** One Field page holds Label and four new sets,
6 variants: the description, the error, the legend and the title. The
plugin moves the published Label set onto it with its ids in the
existing two-phase Apply and retires the empty Label page; on a file
that already holds the kit, the Field page lands after Icons, since the
plugin never reorders pages. Field's part classes move into
`fieldVariants` slots with identical markup, and `Field.Root`'s props
keep only the orientation axis. The markup check now holds each renderer
to the ancestors its definition names.
- **U19, first half: dashed strokes.** A stroke can carry a dash
pattern, read from `border-style: dashed` at the stroke's width with the
lengths Chromium draws, and scaled with a part's scale; a solid stroke
plans `[]`, so published sets plan nothing. Empty's inline classes move
into `emptyVariants` slots with identical markup. Empty's set waits for
its density padding variable (U22) and lands after it.
- **U22, variables for Empty and the W4 rows.** Three role tints and ten
formula variables. Empty's desktop padding, Accordion's panel padding
and Alert's media line read density metrics, so they are computed per
density mode. The inner corners of Accordion items and the floating
Sidebar's rows read a theme radius and a density padding; each is a
`Fuse density` variable whose modes alias hidden per-density values in
`Fuse tokens`, so one binding follows both. The owner runs `figma:sync`
before the next Apply.
- **U19, second half: Empty.** Empty's three frames, default, outline
and outline-dashed, 3 variants at 512px, with optional icon media and
description behind BOOLEAN properties and the desktop padding bound to
U22's density variable. The builder learns letter spacing in `em` as a
percent, a wrapping label's max width under a literal cap (taken through
the box's limits, siblings, gaps and margins), a part's margins drawn as
the padding of a frame around it that hides with the part, and
`text-wrap` inherited.
- **U20, Calendar day cell.** React Aria's day cell, selected off and on
in five states, 10 variants on a Calendar page, from `cellVariants` of
each state's render props. A `react-aria-element` root kind reads React
Aria's disabled and invalid attributes, the root is found by a selector
with an `aria-label` prefix, and compare writes onto the static page
what React Aria writes from pointer and focus events.
`@internationalized/date` joins the private kit package's
devDependencies through the catalog.
- **U21, Link.** React Aria's Link in six colour variants at normal and
bold weight, in rest, hover, focus and disabled, 48 variants on a Link
page, composed as `link.tsx` composes it. The root is any `data-rac`
element, since a disabled Link renders a `<span>`. `truncate` is left
out: a Figma frame cannot draw CSS's narrower-of-text-and-160px box. The
host page's type moves into a shared `host-type.ts`.
- **A test timeout fix.** The variable-set tests rebuilt the whole set
on every lookup, so after U22 grew the set one test hit Vitest's 30s
limit on a CI runner. The file now builds the set once.
- **U23, Item and Item link.** Item in three variants at three sizes,
with and without a description, with icon or image media (36 variants),
and the `[a]:` link row in four states (8), on an Item page. A single
set with link states would have run to 216 variants. Media and actions
are optional behind BOOLEAN properties; the image media's outline is an
inside stroke that takes no layout space. Beside a description the
actions fill the row's height and centre their text while the media and
content top, as CSS draws them. Item's media, footer and inline classes
move into runtime-free recipes with identical markup. The builder learns
`item-described:` rules, tag rules at any depth, bounded stretching, a
`w-fit` text box that fills its column, and colour mixes toward
`transparent` through `@elmeragroup/color`.
- **U24, Alert.** Five tones with and without a description, 10 variants
at 512px on an Alert page, on Item's parts; the action waits for
instances. Alert's text pairs the density font size with a unitless line
height, so a unitless line height over a bound font size now draws as a
Figma percent line height, which a probe showed follows the bound size
in every density mode; a literal size keeps pixels. Alert's title type
and media line move into `alertVariants` slots with identical markup.
- **U25, Accordion item.** Default and card at three radii, closed and
open, in the trigger's four states, 48 variants on an Accordion page. An
open cell turns the caret 180° and shows the panel, whose top padding
and card corner bind the W4 variables. The cascade now resolves a custom
property where it is declared, before inheritance, as CSS does, so a
box's override of a synced token paints where the box reads it, and a
child's override never changes a value it inherits.
- **U26, Sidebar rows.** The menu button (96 variants, expanded and
collapsed to the icon rail), sub button (16), group label (1) and badge
(3) on a Sidebar page. The group label's text adds a
`sidebar-foreground/70` role tint to the synced variables. The
has-action rows wait for the menu action (W6), and the floating rows for
a floating demo. The builder learns `!important`, `[:where(&)]`, a
trailing truncating span as the row's label, an absolute root's position
and a zero-width border. Compare forces a peer's state, and counts a
forced face as unchanged by design only where the preview's theme
resolves both faces to the same colours.
- **U27, Navigation menu.** The bar trigger and vertical trigger, closed
and open in their states, and the bar link and row link, inactive and
active, 28 variants on a Navigation menu page. An open bar trigger turns
its caret 180°; the vertical trigger's caret points right, as the Root's
`side` sets it in the docs. The popup and the row inside it wait for W7.
The builder reads Tailwind's `**:` descendant variant, a root's
`inherit` with no host, `max-content`, and limits that bind the fixed
length's own variable. A label that comes first in a `justify-between`
row fills the room after the gap, so it wraps where CSS wraps it.
`packages/fuse` only re-exports `navigationMenuVariants` from the
catalog.
- **U28, fixes from the first real-file run.** On the real file every
text the kit fills across drew 0 wide, its characters stacked one per
line: the plugin wrote a new text's layout before its characters, an
empty text under auto resize HEIGHT is 0 wide, and Figma's FILL never
widens a 0-wide text. A new text now gets its characters first, and Plan
gives a 0-wide filling text a width before its fill, so one Apply
repairs the published layers. The Select trigger, which hugs its width,
now packs its value and caret at the start with its gap, which Figma's
space-between dropped. A part that draws a focus ring as a spread shadow
gets the near-clear fill and clipping a root gets, so the Accordion
trigger's ring draws. Compare checks fonts only inside each measured
cell, so the Calendar's month heading no longer stops a run on the
external themes.
- **Fixes from the second real-file run.** The page layout reads every
preview instance before it stacks the previews, since an instance takes
its main's new size only when read within one plugin run; the placement
then converges in one Apply. The spec lists a layer's shadows in the
order Figma paints them (the last on top), so a focus ring draws above
an outline border or `shadow-xs`; two-shadow layers plan that change
once. The open card Accordion's stack draws its trigger on top, as CSS
paints a flex item after its block siblings' backgrounds, so the focus
ring stays whole above the panel.
- **U29, instances inside sets (second batch begins).** The owner asked
for open menus, the Popover and the whole Sidebar, which all need a set
to hold other sets' instances. A layer can now be an instance of another
set's variant, with TEXT, BOOLEAN and swap properties by name, paint,
corner and shadow overrides, sizing, exposure and a visibility link. The
plugin plans an instance in stages that read what the previous stage
left, and waits for the run's operations when the instance is new,
swaps, or shows an inner set or icon the run changes. Sizes settle
deepest first, since an instance takes its main's new size only when
read. No kit set uses instances yet.
- **U30, instance parts in the build.** A definition's part can name
another kit component and the cell it shows. The build cascades the
inner root in place and diffs it against the inner set's variant: text,
BOOLEAN and swap differences become property values, paint, corner and
shadow differences overrides, the outer layout's sizing the instance's
sizing, and anything else stays unmapped, so a composite cannot drift
from its parts silently. Presence checks, stacking and alignment see
through instances at any depth; the markup check holds each instance to
the inner cell it names. The coverage logs, stacking and flow policies
move into their own modules.
- **U31, Kbd group, Breadcrumb trail, Pagination row.** The first sets
built from other sets' instances, each instance's in-place diff empty.
`flex-wrap`, a whole-parent `max-width` and `overflow-wrap` drop only
where the build proves nothing bounds the box, tracking available width
apart from Figma sizing; `space-y` drops where the markup renders one
element child, which the definition declares and the markup check
confirms.
- **U32, Alert's action and Empty's actions.** Button instances behind
BOOLEANs that start false (`Action`, `Actions`), so the published
variants keep their look. Alert's tones paint the action through fill
and label-colour overrides; Empty's centred text reaches its Button
labels as a text-alignment override. Height bounds read a variable's
range across modes, capped then floored as CSS resolves limits; the
cascade expands `white-space` and `text-wrap` into longhands, so the
nearer declaration decides wrapping.
- **A hidden layer's fill.** On the real file a hidden auto-layout child
reads its sizing as FIXED on an axis it fills, keeping the fill in its
grow and align, so the hidden action frames planned FILL again after
every Apply. The plugin reads a hidden layer's fill from those, and the
next Plan is empty.
- **U34, menu and popover recipe moves.** Select, Combobox, Dropdown
menu and Popover read their popup, row, label, separator and header
classes from runtime-free `tv()` recipes, and the recipe catalog
re-exports them with the shared menu-row, overlay and Command list shell
classes. Rendered classes are unchanged.
- **U35, client-rendered compare cells.** Fuse popups portal through an
effect, so static markup draws no popup. A renderer can now mount its
cells in Chromium from a browser bundle, open them, portal the popup
into the cell and settle with no stray focus or highlight; compare
measures the popup, or the trigger and popup together for an open
composite, forces highlight by `:focus` or `data-highlighted`, and
checks a client set's markup on the mounted page.
- **U36, menu variables and build rules.** Synced `foreground/10`,
`radius-inner/md-pad-sm`, `radius-inner/xl-gap-sm` and
`row-indicator-room`. Build rules for what menu popups declare: an
absolute end indicator drawn in flow, an auto start margin, full-bleed
separators as section frames, a maximum height that clips, `overflow:
auto` as clipping, and drops for motion and the available height. Length
identities compare exactly as affine expressions.
- **U37, Select's open menu.** The Select page gains the menu group
label, menu separator, Select item, Select popup and the open Select,
with the popup below its trigger. Rows, labels and separators are
instances, a filling part's vertical margins draw in a wrapper frame,
and the open Select's root does not clip, so the popup's shadow shows.
The popup draws at 144px, the width CSS gives it for every docs trigger;
a trigger wider than that is a follow-up.
- **U38, Popover.** A Popover page with `Popover content` (the surface,
the header, and the basic demo's two Fields, each a frame label beside
an `Input` instance) and `Popover (open)` (an outline `Button` trigger
instance above the content). An instance part can declare facts that
hold for its inner root in place, such as the open trigger's
`aria-expanded` and `data-popup-open`, which the diff carries as
overrides; a fact may only name a state the inner set omits or leaves
unmodelled.
- **U39, Dropdown menu.** A Dropdown menu page with the item, checkbox
item, radio item, sub trigger, popup (basic, checkboxes, radio group,
destructive, submenu), sub content and the open menu, and `menu` and
`menu inset` kinds on the shared group label. New rules: an optional
icon linked to a BOOLEAN beside its swap property; a part after the
row's text; an auto margin before the last of several layers drawn as a
filling label; a checkbox or radio indicator first in the DOM drawn last
in the row; and popups that hug their rows, where the widest row hugs
with the popup's content minimum and every other row fills. The open
menu draws the basic menu; the submenu form is left out, since its
offset follows the row height and no variable holds it.
- **U40, Input group.** The forms Combobox and Command render (a caret
button, a clear button, a leading search icon) × rest, focus, disabled
and invalid, on the Input page. In Fuse the Input group's control
classes move to a recipe module and Combobox's trigger classes into its
recipe, with the rendered markup unchanged. New build rules: a one-child
instance ignores its gap; a growing control drops its width;
`max-height: 100%` fills down; `order` sets the layer order; a
direct-child tag selector; and a negative margin on a box that paints
nothing folds into its padding, through two new synced density
variables. A shadow that reaches past a clipping root now gets a
coverage row.
- **U41, Combobox.** A Combobox page with the item, the empty message,
the popup (flat, grouped, empty) and the open Combobox, an Input group
instance above its popup; the Input group gains a `caret open` form.
Figma cannot turn a layer inside an instance, so the open caret swaps to
CaretUp, and the diff accepts that swap only for a glyph pair whose
paths a test proves are a half turn apart. An empty container that
paints nothing draws in no cell, since Figma keeps an empty hugging
frame at its last height.
- **U42, Command.** A Command page with the item (none, shortcut or
caret trailing × state, its shortcut a Kbd group instance) and the
Groups demo's list box; the dialog stays out. A composite can now set
TEXT values on instances exposed below the instance it holds, so each
list row shows the demo's Alt shortcut; the plugin sets them through
exposure and finds the sublayers again after the row's own writes. In
place, a one-word label keeps the row set's filling layout where both
draw the same.
- **U43, Navigation menu popup.** The Basic demo's open popup for
Customers and Business, 264 and 296 px wide, each row a Navigation menu
row link instance with the popup's inner corner and text colour. New
build rules: a grid that puts every item in one column draws as a
vertical auto layout spaced by `row-gap`; a list item stacks blocks as a
block box does; and a percentage height in a parent that hugs its height
hugs (CSS 2.1 §10.5). An instance fact may now name a variant its inner
set omits as a composition, and still refuses a pseudo-element, media
condition or transition frame.
- **U44, Sidebar panel recipe moves.** In Fuse, the desktop Sidebar
panel reads its root, gap, container and inner classes from a
`sidebarPanelVariants` recipe, and its other parts from `cn` constants
in `sidebar-class.ts`. The rail widths move to a runtime-free module
that the `@elmeragroup/fuse/sidebar` facade re-exports, and the recipe
catalog re-exports all of them for the kit. The mobile Sheet branch,
GroupAction and MenuAction stay inline. Rendered classes are unchanged.
- **U45, Sidebar factory parts.** Build rules the Sidebar sets need. A
border on some sides draws per-side stroke weights that take room on
their side, as a CSS border does. A root's `h-full` takes a preview
height. A fixed root drops its position as an absolute one does. A part
at opacity 0 whose negative margin cancels its height draws in no layer.
An instance may sit absolute at the end and centred down its parent, as
the menu badge does: the build proves the centre holds at both
densities, and the plugin places the instance once and compares its end
inset and centre offset on later Plans, which Figma keeps as the
instance's text or its parent changes.
- **U46, Sidebar sections.** The Sidebar page gains the group (five
menu-button rows, expanded and collapsed), the header (the search
input), the footer (a large menu-button row), the menu item with its
badge, and the sub menu. In the collapsed rail the group label leaves no
trace. The sub menu's rows each move 1px left; where every row of a
column moves by the same literal amount, the column's paddings take it
(9 and 11 instead of 10 and 10), which matches the browser pixel for
pixel.
- **U47, Sidebar panel.** The whole Sidebar, `sidebar` and `inset`,
expanded and collapsed: the rail at 256 × 448 (48 or 66 collapsed)
holding the header, a group, the separator and the footer as section
instances. Three build rules:
- **Clipped overshoot.** Fuse's separator draws full width from an 8px
start inset and its column clips the overshoot, so a 100%-wide part with
a start margin in a clipping column draws in a wrapper padded at the
start.
- **Backdrop.** A root with no fill of its own takes the background of
the ancestor that paints behind it, as the inset band shows the Provider
wrapper's `bg-sidebar`.
- **Part facts.** A part can carry facts only its own element holds, as
the separator's orientation does.

  The compare page's slots grow to 464px for the panel.
- **U33, Avatar group.** The docs demo's group as three Avatar instances
in a row. A negative `space-x` in a flex row maps to a negative item
spacing with later avatars on top, as CSS paints them. A weightless
every-child class gives each instance its background-coloured ring as an
effects override.
- **U48, a swapped instance sublayer.** The owner's Apply on the real
file stopped at the Input group's caret: "holds no layer icon-only to
override". In Figma, a sublayer swapped inside an instance reports the
swapped-in component's key, not the key of the layer it stands for, so
the plugin lost the caret's CaretDown icon. The plugin now keys each
instance sublayer by the main component's layer at the same place. The
in-memory fake now behaves as the file did, which fails the old lookup
on the Input group and on an existing Alert test that had been passing
only through the fake's gap.
- **U49, side stroke weights.** The owner's next Apply wrote 297
changes, but the second Plan still listed the Sidebar sub menu's left
stroke and the Sidebar's right stroke. In Figma, a 0 written to one side
of a node whose stroke weight was never written sets every side to 0.
The plugin wrote only the sides that differed, so the side at 1 was
lost. A side stroke now writes `strokeWeight` and then all four sides as
one change, and the fake spills as the file did.
- **Merge of `main`.** `main` moved during the batch (#339, #374, #394),
and again before the review (#395, #396, #397, #398, #399, #404). The
branch merges it rather than rebasing, so no unit commit is rewritten.
Both merges were conflict-free. The variable counts take #339's
`card-shadow` token and U6's two variables: 113 in `Fuse tokens`.
- **Review before merge.** A whole-PR review (bugs, quality, simplify)
found no bug. It did find duplication the batch had introduced, now
removed with the kit's spec byte-equal:
- the open-popup facts, the menu group, row and check builders, the
user-agent display defaults, the affine helpers and two restated lengths
each move to one shared module;
- the compare self-check's overlay fixtures go, since the real popup
sets now prove the same paths;
  - one `BLOCK_LEVEL` set counts `flow-root`;
  - the recipe catalog stops re-exporting 15 names nothing reads.

The deferred items, including the per-state instance copies that wait
for a real-file run, are in `TODO.md` under "Figma component kit".
- **Fixes from the owner's real-file compare (2026-10-26).** The owner's
export of all 120 previews (3,508 instances) matched the spec's counts.
The compare found three kit defects and one compare defect:
- **Compare mount.** Base UI 1.9 appends U+2060 to a live region's text
on mount and removes it 200 ms later. The compare read `Combobox.Empty`
inside that window under load and stopped. The mount now waits until no
client slot holds the marker.
- **Select's label.** A flex row with no `justify-content` packs at its
start (CSS Box Alignment 3 §5.3). The kit kept such a row centered, so
"Apple" sat in the middle of Select's 144px row. A fixed or filling row
that holds a hugging layer and no filling one now maps to MIN. Only
`select-item` changes in the spec.
- **Combobox popup width.** Base UI anchors the popup to the `<input>`
inside the Input group, not to the group. So the browser draws it at
that input's width plus 28px: 374px dense, 366px comfortable. The kit
drew 412px. It now draws the dense widths (popup 374, rows 366), derived
from Fuse's density metrics. A Figma width can't follow density without
a synced variable that bakes in the kit's 384px sample, so the
comfortable popup stays 8px wider in Figma, and the README records it.
- **Shortcut glyphs.** Roboto has no glyph for ⇧ or ⌘, and Figma draws
each as a blank advance, so the Dropdown menu showed `P` for `⇧⌘P`. The
kit names the glyphs Inter draws (`glyphFont`). The plugin sets them as
Inter ranges on main components and on instances whose TEXT override
shows one, and Plan compares the text's segments. Probes G1 to G12 on
the real file settled how range writes, property links, overrides and
family bindings change a text's fonts, and the fake's rules 50 to 55
follow them. A shortcut a designer types resets to `font-sans`, and the
README says to set ⇧ and ⌘ to Inter.
- **Preview instance lengths (second real-file run).** After the owner
applied the fixes, the second Plan read clean. Still, the comfortable
Combobox previews kept the old 404/412 widths. Probes G13 to G19 on the
real file showed the cause. An instance laid out in a mode that changes
its size holds its literal width and height, and stops following its
main's. `resetOverrides` doesn't release it, a swap releases it only
where it equals the old main's, and a bound length keeps following its
variable unless the binding is lost. Plan now gives every preview
instance the instance parts' sizing sequence, moved to
`plugin/src/instance-sizing.ts`. Each fixed axis turns FIXED, then takes
its length or binding, and the other axis takes its spec sizing. The
fake's rule 56 follows the probes.
- **Merge of `main` (#408).** Type checks now run `tsrs` on TypeScript
7.1. The merge was conflict-free, and `tsrs` reports nothing in this
branch's code.
- **Merge of `main` (#403, #405, #406).** These set text line heights
and the heading scale from theme roles. They add 12 synced variables and
leave the kit's spec byte-identical. Two conflicts, in the README's
collection table and the CLI test's counts, were resolved with both
sides' counts.

## Verification

- U1: the `fuse-figma` tests and type-check, `oxfmt --check`, oxlint on
both packages, the full Fuse unit project, `figma:plugin` and
`figma:compare --self-check` pass. The touched files run the same 148
cases with the same names, and `spec.json` and `coverage.md` are
identical to `main`'s. One review round by two independent reviewers
(bugs, and code quality) found nothing.
- U2: the same checks pass, and `spec.json` is unchanged. The new label
check went red when a definition's label and its markup disagreed, and
when a definition showed its label in a cell that has none. Review: the
code-quality reviewer found the check would compare HTML-escaped text
with plain characters, so a label with `&` or an apostrophe would fail
falsely. The text is now decoded, with a test that also catches decoding
twice.
- U3: the same checks pass. A script mapped `main`'s `spec.json` into
the new field names and found all 14 sets equal, and `coverage.md` is
unchanged. A test seeds the in-memory fake with the nodes the real file
held (probe P0) and a whole-kit Plan writes nothing to them. Seven new
fake rules, each tested and dated with its probe, model the sizing
projection, the real error strings, resizes and bindings clearing a
fill, direction changes and the limits. Review: both reviewers found
that an existing layer was planned against its parent's old direction,
that a direction change resizes the box under a fixed length, and that a
fixed length beside a limit never converges. All three are fixed, each
with a test that failed first.
- U4: the same checks pass, the 14 sets build byte-identical and
`coverage.md` is unchanged. Each new read has a test on a hand-written
fixture whose oracle is the CSS rule it follows. Review: the bugs
reviewer found that a main-axis `100%` beside siblings is not Figma's
fill and that a zero maximum would reach Figma, and the code-quality
reviewer asked for the sizing reads in their own module and for limits
that cannot be compared to stay unmapped. All four are fixed.
- U5: the same checks pass, the 14 sets build equal once the new text
fields are mapped, and `coverage.md` is unchanged. Three new fake rules,
from the text probes, are tested. Review: the bugs reviewer found that a
flex box's `truncate` takes no ellipsis in CSS (a web platform test
covers it), that a wrapping label a row centres sits at its own width,
that a hugging box with a `max-width` does wrap its text, and that a
fixed text length was compared before a new auto resize. The
code-quality reviewer asked for a label module with one line-clamp
decision, and found that a native control's vertical placement cannot
move a label that hugs its height. All six are fixed, each with a test
that failed first.
- U6: per-theme values computed by hand from `themes.css`, and a fixture
whose corner turned unbound when the formula was mutated. The merged
tree passes the same checks, and `spec.json` and `coverage.md` are
unchanged by the merge. Review found nothing.
- U7: two new browser tests failed on the old classes (checkbox 2px,
switches 18 and 14px; the md row's checkbox 0px) and pass now. One
measures a lone control against the size it draws outside a Field. The
other measures a responsive root in a wide field group against a
horizontal one. The Field, Checkbox, Switch, data-table-filter and
field-frame browser suites pass, as do `package:check` (the standalone
`styles.css` at 33,278 of 36,890 gzip bytes) and the checks above. The
kit's `spec.json` is unchanged. Review found nothing.
- U8: 15 sets and 982 variants build with 0 unmapped, and the 14 earlier
sets are unchanged. Static markup of 11 Input and Input group renders
has identical classes before and after the move. The Input and Input
group browser suites and `package:check` pass. Review: the bugs reviewer
found that the custom property drop would hide a local override of a
synced variable, and both reviewers found that ancestor declarations
skipped inheritance, so an ancestor's `var()` resolved against the root.
The code-quality reviewer asked for a label read from own text in some
cells (Textarea's value), decoded attribute values, and one kit
inventory for the end-to-end tests. All are fixed, each with a test that
failed first.
- U9: 16 sets and 992 variants build with 0 unmapped, and the 15 earlier
sets are unchanged. Static markup of 11 Textarea renders, consumer
classes and Input group included, has identical classes before and after
the move. The Textarea and Field browser suites and `package:check`
pass. Review found nothing.
- U10: 17 sets and 1000 variants build with 0 unmapped, and the 16
earlier sets are unchanged. Static markup of 11 Avatar renders has
identical classes before and after the move. The Avatar browser suites
and `package:check` pass. Review, round 1: both reviewers found that
Plan stored an image to learn its hash, so the dry run wrote to the
file; a probe then showed the hash is the SHA-1 of the bytes, and the
build now carries it. Both also found the image check weaker than what
Figma accepts, and the no-wrap rule admitting fullwidth letters, which
can wrap. Round 2 found nothing.
- U11: 18 sets and 1016 variants build with 0 unmapped, and the 17
earlier sets and their coverage rows are unchanged. Static markup of 102
Meter renders has identical classes before and after. The Meter browser
suite and `package:check` pass. Review: both reviewers found that an
absolute share was measured against a containing block before its
limits, and that SCALE was given to a share a limit capped. The
code-quality reviewer found that a parent resize in the same run left
the fill for a second Apply, and that `component-spec.ts` kept child
lists that disagreed on which layers flow. All are fixed, each with a
test that failed first; the build's output is byte-equal before and
after the fixes.
- U12: `spec.json` and `coverage.md` are byte-identical to U11's.
Review: both reviewers found the markup check matched a slotless part's
selector anywhere in the tree instead of below its parent, and the
code-quality reviewer found icon owners named by key rather than path;
the bugs reviewer found the measured selector did not escape a newline.
All are fixed, each with a test that failed first.
- U13: `spec.json` and `coverage.md` are byte-identical to U12's.
Review: the bugs reviewer found that the presence guard checked only the
optional part's own slot, not its subtree, and that the fake hid an
instance's hidden layer from `children` although the plugin never turns
on `skipInvisibleInstanceChildren` (the probe environment does, so the
probe record was corrected). The code-quality reviewer asked for one
selector scanner, presence read from every compiled rule rather than
only rules that start with the class, and the two files that crossed
1,000 lines split. All are fixed, each with a test that failed first. A
further probe settled quarter turns, the rotation sign and a BOOLEAN
whose default is false.
- U14: 19 sets and 1080 variants build with 0 unmapped, and the 18
earlier sets are unchanged. Each of the 64 rendered cells' classes
equals the definition's. `size-limit` and `package:check` pass. Review
found nothing.
- U15: 22 sets and 1093 variants build with 0 unmapped, and the 19
earlier sets are unchanged. Each of the 13 rendered cells' classes
equals the definition's. Review: the code-quality reviewer found a test
that restated Button's expectations, which is deleted.
- U16: 26 sets and 1099 variants build with 0 unmapped, the 22 earlier
sets are unchanged, and Breadcrumb's static markup is identical before
and after the move. The Breadcrumb browser suite, `size-limit` and
`package:check` pass. Review: the code-quality reviewer found the list's
text hand-copied into the kit, where a change to Fuse's list would leave
it stale; ancestors are now read from their recipes, with the drawn spec
byte-identical.
- U17: 27 sets and 1131 variants build with 0 unmapped, and the 26
earlier sets and every other coverage row are unchanged. 14 static
Select renders are class-identical before and after the move. The Select
and SelectField browser suites, `size-limit` and `package:check` pass.
Review: both reviewers found that `flex: 1` was dropped in a hugging box
whose `min-width` leaves free space; it now stays unmapped there. The
code-quality reviewer asked for the box mapper in its own module. A
probe showed Figma's PNG export rounds fractional render bounds up,
which the compare self-check now matches.
- U18: 31 sets and 1137 variants build with 0 unmapped; the earlier sets
are unchanged except Label's page. 24 static Field renders are identical
before and after the move. A fake file laid out with Label on its own
page moves it with its set, variant and component ids in one Apply, and
the second Plan is empty. The Field browser suite, the public type
tests, `size-limit` and `package:check` pass. Review: both reviewers
found that a horizontal root margin, which narrows an auto-width block,
was dropped; the bugs reviewer found a block's line centred where CSS
puts it at the top. Both are fixed, each with a test that failed first.
- U19 first half: `spec.json` and `coverage.md` are byte-identical to
U18's; 11 static Empty renders are identical before and after the move;
a fixture with a dashed stroke converges in one Apply and the second
Plan is empty. Review: both reviewers found that a scaled part kept its
dash lengths unscaled; fixed with a test that failed first.
- U22: per-theme and per-density values computed by hand from the theme
catalog and the density metrics; a fixture binding per new variable;
`spec.json` and `coverage.md` unchanged. Review found nothing.
- U19 second half: 32 sets and 1140 variants build with 0 unmapped, and
the 31 earlier sets are unchanged. Review: the bugs reviewer found a cap
that could reach zero, margins not taken off a framed part's cap, and
inherited `em` tracking re-read at the child's size; the code-quality
reviewer found the cap ignoring a winning `min-width` and siblings'
shares, and a variable-backed text max width the kit check did not
validate. All are fixed, each with a test that failed first; Empty's
spec is unchanged by the fixes.
- U20: 33 sets and 1150 variants build with 0 unmapped, and the 32
earlier sets' spec entries are unchanged (14 coverage rows that were
missing for Confirm button, Pagination edge and Icon toggle now appear
as dropped). `size-limit` and `package:check` pass. Review found
nothing.
- U21: 34 sets and 1198 variants build with 0 unmapped, and the 33
earlier sets are unchanged. A renderer test holds the kit's classes to
the real Link's markup. Review: the code-quality reviewer found Link
reading its host type from Pagination's definition; it now has its own
module.
- U23: 36 sets and 1,242 variants build with 0 unmapped, and the 34
earlier sets and their coverage rows are unchanged. 13 static Item
renders are identical before and after the move. The Item browser suite,
`size-limit` and `package:check` pass. Two probes on the real file
settled the actions' stretch (PF) and an outline that takes no layout
space (PS). Review: the code-quality reviewer found the new stretch rule
ignored a part's own height, tag rules applied only to direct children,
margins read outside the cascade, and a colour mix parsed ad hoc; the
bugs reviewer found the outline taking layout space, an uncapped title,
and a growing part's room including its siblings. All are fixed, each
with a test that failed first.
- U24: 37 sets and 1,252 variants build with 0 unmapped, and the 36
earlier sets are unchanged. 27 static Alert renders are identical before
and after the move. The Alert browser suite, `size-limit` and
`package:check` pass. Review found nothing.
- U25: 38 sets and 1,300 variants build with 0 unmapped, and the 37
earlier sets are unchanged. Review, round 1: both reviewers found that
inherited values were resolved again against a descendant's overrides
(blocking); the code-quality reviewer found a second, cell-wide override
check and the markup checker past 1,000 lines. Round 2 confirmed those
closed and found two more edges of the same rule, the page's inherited
values and an invalid substitution; all are fixed, each with a test that
failed first, and the drawn spec did not change.
- U26: 42 sets and 1,416 variants build with 0 unmapped, and the 38
earlier sets are unchanged. Static Sidebar markup is identical before
and after the move; `size-limit` and `package:check` pass. Review, round
1: both reviewers found that compare's new fallback could hide a missing
state (blocking); a negative control now flags 48 focus cells when their
ring rule is removed. Both also found edges of the new layout rules
(opposing insets, label alignment and caps) and `!important` crossing
inheritance. Round 2 confirmed them closed and found one more importance
edge, fixed with a test that failed first. A probe on the real file
showed that a collapsed row draws only its icon, as the browser does; at
`lg` it draws a clipped "…" where Chromium draws a clipped "D".
- U27: 46 sets and 1,444 variants build with 0 unmapped, and the 42
earlier sets and their coverage rows are unchanged. `size-limit` and
`package:check` pass. A probe on the real file showed the vertical
trigger's row lays out as CSS does, except that a filling label in a
space-between row lost the 8px gap. Review: the bugs reviewer found the
same gap, now drawn by packing the row at its start with the gap. The
code-quality reviewer found an invalid inherited weight drawn as
Regular, which now stays unmapped. Both fixes have tests that failed
first. A third finding, that the builder measures no text and so cannot
prove a word fits its room, is a limit of every filling label and is
noted in the README.
- U28: the owner's sync, Plan and Apply at U27 and `figma:compare` on
the export (1,520 internal cells, then each external page) found the
four defects; two probes on the real file settled the cause of the first
and third. The in-memory fake now models the 0-wide text, and a gate
checks that no filling text ends 0 wide after Apply (red for all 252
under the old order). A seeded file with 0-wide texts, holding their
characters or empty and unlinked, is repaired in one Apply, and the next
Plan is empty. Only the Select trigger's 32 alignments and the
Accordion's 12 focused triggers change in the spec. Review: both
reviewers found the repair first read the characters a text held before
the run, so an empty, unlinked text needed a second Apply; fixed with a
test that failed first.
- Second real-file run: after U28 the owner's Apply made 332 changes;
compare on all four themes (3,040 cells) shows the repaired texts, the
Select gap and the closed Accordion ring matching. Three probes settled
what remained (instances settle when read, effect order, stacking). The
fake models each; a reconcile test reproduces the stale placement first;
the spec diff touches only the two-shadow layers' order and three
Accordion roots. Review found the lift could fire past a later stacking
context and the kit check missed nested fills; the build now decides the
lift with the CSS facts and leaves anything it cannot prove unmapped.
- U29: probes N1 to N5 and NS on the real file settled nested
properties, swaps, overrides and their survival, exposure, instance
sizing, and that a write equal to the main drops an override; the fake
models each as a numbered rule, and a fixture kit converges through
Plan, Apply and an empty Plan, including an in-place edit of the inner
set and an inner set shrinking. The spec is unchanged. Review, round 1:
both reviewers found properties and overrides planned against the same
held state, so an icon swap removed a layer an override held (blocking);
round 2 confirmed it closed and found icon recolouring, held fonts and a
FIXED length write; all are fixed, each with a test that failed first.
- U30: the spec and coverage are unchanged (no set uses instance parts
yet); fixture definitions prove each outcome of the in-place diff, with
a negative control per mechanism. Review ran three rounds, each finding
edges of the same two problems (presence checks and stacking through
instances) plus coverage ownership, multi-field reads and sibling
identity; all are fixed with a failing-first test per reviewer scenario.
With the three-round cap spent, the last fixes were vetted without a
fourth round; the units that follow are the first real consumers.
- U31: 49 sets and 1,448 variants build with 0 unmapped; the 46 earlier
sets are unchanged. Review, round 1: both reviewers found the new drops
trusted a Figma hug as proof that no ancestor bounds the row, and that
`overflow-wrap` inherits (blocking); round 2 found grids and unread
widths, now bounded too. Each fix has a test that failed first.
- U32: 49 sets and 1,448 variants with 0 unmapped; only Alert and Empty
change, and each equals its U31 spec once the added hidden layer and
property are removed. A probe on the real file showed a text-alignment
override on an instance's label is kept through the main's edits. Review
found a first alignment rule relied on today's label text, limits
applied in the wrong order, and wrapping decided by layer order; all are
fixed with tests that failed first.
- U34: 10 files; the five components' docs demos, rendered open in the
browser, give identical `data-slot` and class pairs before and after (a
negative control fails), so no changeset. `size-limit` (select +20,
combobox +51, dropdown-menu −5, command 0, popover +17 gzip bytes),
`package:check` and the five components' browser suites pass; the kit's
spec is unchanged. Review found that the catalog lacked the Command list
shell, now re-exported.
- U35: the self-check renders three overlay fixtures twice, identically,
and a negative control flags a missing highlight; every static cell
stays at 0.000% and the spec is unchanged. Review: the mount left Base
UI's focus and `data-highlighted` on an open Select (blocking), and the
self-check's fake export clipped a popup's shadow; both are fixed with a
selected-Select fixture and a threshold-0 run that failed first.
- U36: every earlier set and `coverage.md` are unchanged; each rule has
a builder test that failed first. A probe on the real file showed a
hugging column whose children all fill hugs only to its `minWidth`,
which the popup sets follow. Review found the length identity check
inexact, section frames not carrying their coverage feeds, and a
clipping test that could not fail; all are fixed with tests that failed
first.
- U37: 54 sets and 1,460 variants with 0 unmapped; the 49 earlier sets
are unchanged and only the Select previews change. Probes on the real
file showed an instance of a root that does not clip exports the popup's
whole shadow, the separator wrapper keeps its height in every density,
and a corner binding two instance levels deep survives an edit of the
main. Review asked for the margin wrapper in its own module, done with
no change to the spec.
- U38: 56 sets and 1,462 variants with 0 unmapped; the earlier sets are
unchanged. A probe on the real file showed an empty TEXT value keeps a
filling text at its width and one line's height, which the Popover's
inputs rely on. Review found that a fact could replace a state the inner
set models, such as Input's `invalid`; that now throws, with tests that
failed first.
- U39: 63 sets and 1,508 variants with 0 unmapped; the 55 earlier sets
are unchanged, and the shared group label's existing variant is
byte-equal. Probes on the real file showed one icon instance holds a
visibility link and a swap link together, a swap keeps the layer's name,
a filling text in an instance set to hug draws at its own width, and an
instance that hugs with a `minWidth` fills up to it and hugs past it.
Review found the submenu's `Messages` row would wrap at comfortable
density (a probe confirmed Figma wraps it 0.2px short), fixed by the
minimum, and the label-fill rule also catching a real `justify-between`
row, fixed with a test that failed first.
- U40: 64 sets and 1,520 variants with 0 unmapped; the earlier sets are
unchanged. `size-limit`, `package:check` and the Input group and
Combobox browser suites pass, with the markup byte-identical before and
after the moves. A probe on the real file showed a bound end padding
follows density, a control filling down beside a 1px inside stroke is
the row's height less 2, and an instance's stroke and ring overrides
survive an edit of the main. Review found the invalid caret's ring
clipped 1px at dense; the build now records such a clip in coverage.
- U41: 68 sets and 1,535 variants with 0 unmapped; the earlier sets are
unchanged, and the Input group's existing 12 variants are byte-equal.
Probes on the real file showed Figma keeps an empty hugging frame's last
height, refuses a rotation inside an instance, and keeps a popup wider
than its open column with the overflow in the instance's export. Review
found the swapped caret's classes missing from coverage and the mirror
proof reading only one path per icon; both are fixed with tests that
failed first.
- U42: 70 sets and 1,545 variants with 0 unmapped; the earlier sets are
unchanged. Probes on the real file showed TEXT values set through two
levels of exposure survive the outer instance's property writes, a
variant switch and edits to the mains, and that same-named caps keep
their values by place. Review found the list measured narrower in the
compare page than the kit draws it, the fake restoring same-named caps
onto the first, and font checks for nested values the plan never writes;
all are fixed with tests that failed first.
- U43: 71 sets and 1,547 variants with 0 unmapped; the earlier sets are
unchanged. Each new rule has a negative control: a grid with a template,
column flow or a spanning item stays unmapped, and so do a percentage
width in a hugging column and a percentage height in a hugging row. The
popup draws no plugin node state that earlier probes do not cover.
Review found nothing.
- U44: 8 files. The static markup of 77 Sidebar renders, the five docs
demos plus variant × collapsible × side × open × consumer classes, is
byte-identical before and after, and a negative control differs, so
there is no changeset. The sidebar entry grows by 249 B, well under its
`size-limit` ceiling. `package:check` and the six Sidebar browser suites
(73 tests) pass, and the kit's spec is unchanged. Review found nothing.
- U45: the spec is byte-equal to U44's, since no set uses the new rules
yet. Probes on the real file showed per-side stroke weights in the
layout take room on their own side, and an absolute instance at the end
and centre keeps its end inset as its own text widens it and its centre
offset through density switches, in the main and in outer instances,
with no `x`, `y` or constraint overrides. Review found an anchored
instance missing from the paint record when nested, and two places where
the in-memory Figma fake stopped following its main; all are fixed with
tests that failed first.
- U46: 76 sets and 1,556 variants with 0 unmapped; the earlier sets are
unchanged. The translate fold has six negative controls that stay
unmapped (rows that move apart, one row of two, a move down, a bound
padding, a padding that would go below 0, a wrapper that paints). The
sets write no plugin node state that earlier probes do not cover. Review
found nothing.
- U47: 77 sets and 1,560 variants with 0 unmapped; the earlier sets are
unchanged. Each panel cell's box and pixels match the browser in the
self-check. The clipped overshoot has five negative controls and the
backdrop one, all staying unmapped or keeping their own fill. Review
found nothing.
- U33: 78 sets and 1,561 variants with 0 unmapped; the earlier sets are
unchanged. A probe on the real file showed ringed instances overlapping
under a −8 spacing in a clipping row, each later ring drawn over the
earlier avatar. Review found nothing.
- U48: the spec is unchanged. Reads of the owner's file showed which key
a swapped sublayer reports in each case. A swap made inside an instance
reports the swapped-in component's key. A swap made on the main
component's own layer keeps that layer's key, and the instance follows
it. A reconcile test reproduced the owner's error before the fix and
converges after it. Review found one duplicated test, now folded.
- U49: the spec is unchanged. Probes on the real file isolated the
spill. It happens only on a node whose stroke weight was never written,
and an explicit `strokeWeight` write disarms it. Reconcile tests for a
new left-edge variant, a new right-edge variant and the owner's file as
it stood each failed before the fix and converge in one Apply after it.
Review found no bug. The suggested split of `reconcile.test.ts` is a
follow-up.
- Real-file probes on 2026-10-22 and 2026-10-23, net zero on a scratch
page of the test file that was then deleted, ahead of the units that use
each Plugin API member: FILL, HUG and their error cases, resize and
bindings against FILL, text auto-resize under FILL, truncation and
`maxLines`, SPACE_BETWEEN, direction changes, min and max limits,
absolute children with SCALE constraints, image fills and their hash,
BOOLEAN properties linked to visibility, rotation in auto layout, dashed
strokes, the corner clamp, PNG export rounding, letter spacing, a
counter-axis fill, strokes outside layout, percent line heights, a
clipped label in a collapsed row, the vertical trigger's row, a 0-wide
filling text, and a ring on a part. Each result becomes a dated rule in
the plugin's in-memory Figma fake.
- Review before merge: three review rounds, the first with simplify.
After the fixes, `pnpm ci:checks`, `figma:plugin` (78 sets, 1,561
variants, 0 unmapped, spec byte-equal) and `figma:compare --self-check`
pass locally on the merged tree. The moved Select-item fill check goes
red when the highlight rule is removed from the built CSS.
- The owner's real-file compare on 2026-10-26, at 14a624ff: 3,508 cells
in 120 previews, none skipped. 1,981 match to the pixel, and 898 more
differ by under 0.5%. Expected differences: Figma rounds auto-width
labels up to a whole pixel (444 cells, 0.5 to 1.4px) and the Item
title's 19.25px line up to 20px (104 cells). Switch differs only in edge
anti-aliasing, and the Sidebar sub-button in its 1px translate, which
the kit doesn't draw. The four defects are fixed in the commits above.
- Compare fixes: each new or changed test went red without its fix.
After the mount fix, the full compare on the owner's export ran to the
end. Select's spec diff is `select-item` only, and Combobox's is its
four sets' widths. The glyph fix changes only `glyphFont`. Its Plan →
Apply → Plan tests on the fake end with an empty second Plan for a main
component, an overridden instance, the kit applied before and after the
rule, and four designer edits. One review round (bugs and code quality)
found that a glyph range bound to an Inter-valued variable never
converged (both reviewers), and that two size-reset tests had no probe
behind them. Both are fixed, after probes G11 and G12. On the merged
tree, `figma:plugin` (78 sets, 1,561 variants, 0 unmapped, spec
byte-identical to the pre-merge commit) and `figma:compare --self-check`
pass locally, and CI passes every check. Locally on macOS, `pnpm
ci:checks` failed one docs studio test, "opens a new target with no menu
open" in `test/studio/theme-toolbar.browser.test.ts`: an open Type menu
covers the Approve button it clicks. It passed locally before the merge,
and CI passes it on this tree and on #406. #406 added heading knobs to
that Type menu, so the menu probably now reaches the button with macOS's
font widths.
- The owner's second real-file run, at 56bd97ae: after sync, Plan, Apply
and Plan, the second Plan read "The file matches the kit." The compare
on that export (3,508 cells, none skipped) shows:
  - Select's label matches (0–0.01%).
- The Dropdown menu draws ⇧⌘P, ⌘B and ⌘K. Its popup is 175px wide
against the browser's 174.42.
  - The dense Combobox matches exactly.
- The comfortable Combobox previews stayed at their old widths, which
the preview-length fix addresses.
- Preview lengths: three reconcile tests reproduce the owner's stale 404
first, plus a bound length the instance lost and an instance that hugs
on a fixed axis (N4). Each converges in one Apply with an empty second
Plan, and each failed before its fix. One review round (bugs and code
quality) found the sizing order and the hugging case (both reviewers), a
fake rule that contradicted G13, an unprobed unbind (now G19), and a
test file past 1,000 lines. All are fixed. The spec is byte-identical.
- On the merge with #408, `pnpm ci:checks` (31 of 31 tasks, `tsrs` in
every type check), `figma:plugin` and `figma:compare --self-check` pass
locally.
- The owner's third real-file run, at cfaccd22 (rebuild, Plan, Apply,
Plan, export), with the compare on that export:
- The file's 20 comfortable Combobox previews now read 366 and 374 wide,
FIXED/HUG, at their comfortabl…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changeset Skip the changeset presence check for CI/tooling-only PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant