Skip to content

feat(limitless-mcp): multi-surface expansion + design-system skills layer & structure linter#34

Merged
tknatwork merged 31 commits into
mainfrom
feat/limitless-expansion
Jul 14, 2026
Merged

feat(limitless-mcp): multi-surface expansion + design-system skills layer & structure linter#34
tknatwork merged 31 commits into
mainfrom
feat/limitless-expansion

Conversation

@tknatwork

Copy link
Copy Markdown
Owner

Extends figma-limitless-mcp from 72 → 98 tools, adds an offline design-system knowledge layer, and a structure linter — all local-first (no REST, no token, no telemetry). 17 commits, +5.1k lines.

What's in it

1. Multi-surface plugin (one codebase, all editor surfaces)

  • Variable/token CRUD completed in write_variables (rename/update/delete across variables, collections, modes)
  • Slots suiteget_slots / reset_slot / append_to_slot
  • FigJam adapter (7 tools) · Slides adapter (7) · Buzz adapter (5)
  • Manifests: flagship manifest.json = ["figma","dev"] (canvas + Dev Mode); extras/manifest.json = ["figma","figjam","slides","buzz"]. Learned the hard way (all live-verified in Figma): Figma requires "figma" in every editorType, forbids ["figjam","dev"] together, and rejects ../ in main/ui paths.

2. Offline design-system skills (knowledge layer)

  • 6 opinionated skills + the canonical Primitive→Semantic→Component build order + a 57-rule catalog, bundled as Markdown in server/skills/ (zero network)
  • Served by list_skills / read_skill / get_build_recipe (server-local, path-traversal-guarded)

3. Structure linter (lint_design_system) — 16 rules live

  • Plugin lint_run gathers a snapshot (variable graph + styles + components, after loadAllPagesAsync); the server runs the detectors (testable TS, not the plugin bundle)
  • 9 token-graph + 7 scope detectors, with a shared tier-classification + alias-resolution engine
  • Calibrated to advise, not dictate: only objectively-broken defects (dangling aliases, Figma-illegal scopes) are error; every opinionated structural rule is a warn. Intra-collection granular scales are respected, not flagged.

Validation

  • Every commit: server tsc + plugin typecheck + vite build clean
  • Plugins live-verified in Figma (all four surfaces load)
  • Linter live-run against a real 1,121-variable design system (48 pages, 376 components): caught a real crash bug (variant components, fixed), and after calibration surfaces 7 genuine dangling-alias defects — trustworthy signal, zero structural-preference noise

Honesty notes

  • Detectors implemented so far: 16 / 57 (token + scope tiers). Remaining tiers (node-binding, modes/naming, codegen, components, a11y) are queued — lint_design_system lists them under not_yet_implemented.
  • dist/ artifacts are gitignored and rebuilt via pnpm build in each package.

🤖 Generated with Claude Code

tknatwork and others added 30 commits July 14, 2026 16:48
…ite_variables

Wave 1 of the surface + tool expansion. The write_variables batch tool
covered create/set/alias/bind/delete but had no way to rename or update
existing tokens, rename/remove modes, or delete a whole collection. Adds
six verbs to the action union (schema + plugin handler), mirroring the
existing action pattern exactly:

- rename_variable        (variableId, name)
- update_variable        (scopes / description / hiddenFromPublishing / codeSyntax)
- rename_collection      (collectionId, name)
- delete_collection      (collectionId)
- rename_mode            (collectionId, modeId, name)
- remove_mode            (collectionId, modeId)

All are local Plugin-API ops (no REST, no token). No new top-level tools,
so no toolInputSchemas/rpcToArgs/registration churn; write_variables is
already in EDIT_REQUEST_TYPES + JOURNALED_TOOLS. Tool description updated.

Build gate: server tsc clean, plugin typecheck + vite build clean.
Live in-Figma verification pending (manual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nd_to_slot

Wave 2 of the surface + tool expansion. The plugin could create a slot
(create_slot) but couldn't read, reset, or populate one. Adds three
first-class tools over the GA'd (June 2026) Figma Slots Plugin API, wired
through all four seams (schema + toolInputSchemas + rpcToArgs, tools.ts
registration, plugin RequestType + handler, orchestration journaling):

- get_slots       (read)  — enumerate SLOT frames in a node (id, name,
                            childCount, limitViolations) + the owning
                            COMPONENT/COMPONENT_SET's SLOT property defs
                            (key, description, slotSettings)
- reset_slot      (write) — SlotNode.resetSlot()
- append_to_slot  (write) — move a scene node into a slot (insertChild/
                            appendChild); surfaces the Figma platform limit
                            that instance-slot population is blocked

All local Plugin-API ops (no REST, no token). Reads/writes guarded the
same way as create_slot; writes added to EDIT_REQUEST_TYPES + JOURNALED_TOOLS.
Tool count 72 -> 75.

Build gate: server tsc clean, plugin typecheck + vite build clean.
Live in-Figma verification pending (manual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… companion manifest

Wave 3 — multi-surface foundation. Figma's manifest validator forbids only
one editorType combination: ["figjam","dev"]. So a single manifest can't
carry both FigJam and Dev Mode. Solution: one codebase, two manifests.

- manifest.json      -> editorType ["figma","figjam","slides","buzz"]
                        (drops "dev" + the dev-only capabilities:["inspect"])
- manifest.dev.json  -> editorType ["figma","dev"] + capabilities:["inspect"],
                        NEW optional companion pointing at the same dist/ +
                        the same server. Import it too for Dev-Mode inspection.

Both entries share one build and one MCP server on :1994; each activates only
in its declared surfaces and the plugin branches on figma.editorType.

requireEditorMode generalized: design-canvas write tools now give a clear,
editor-specific error in FigJam (pointing to execute_code / FigJam-native
creation) instead of a cryptic Figma runtime error; Dev-Mode message clarified.
execute_code + reads + screenshots already work in every enabled surface today;
dedicated FigJam/Slides/Buzz tool adapters land in the next waves.

Build gate: plugin typecheck + vite build clean; both manifests valid JSON.
Live in-Figma verification pending (manual) — incl. documentAccess:dynamic-page
across figjam/slides/buzz at import time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the FigJam surface (enabled in Wave 3) genuinely useful with native
FigJam creation tools, wired through all four seams:

- create_sticky           (text / fill / wide / xy)
- create_shape_with_text  (30 flowchart shapes + text/fill/size)
- create_connector        (node-attached via magnets or free points;
                           label, ELBOWED/STRAIGHT/CURVED, stroke caps)
- create_section          (titled grouping + size)
- create_table            (rows/cols + row-major cell text)
- create_code_block       (17 languages)
- create_gif              (by existing media hash; local-only, no URL import)

Mechanism:
- New FIGJAM_REQUEST_TYPES set + requireFigJam guard: these tools throw a
  clear "FigJam only" error outside FigJam, and the design-canvas guard
  (requireEditorMode) already blocks design tools inside FigJam — so each
  surface advertises the full tool list but only its valid tools execute.
- FigJam text lives in TextSublayerNode; setSublayerText loads the sublayer
  font (Inter Medium default) before writing characters.
- Dispatch serializes FigJam writes via the mutationChain; all 7 journaled.

Honest scope: stamp/washi-tape/highlight have NO Plugin-API factory
(user-only) and createLinkPreviewAsync needs network (blocked by our
localhost-only manifest), so they're intentionally omitted.

Tool count 75 -> 82. Build gate: server tsc + plugin typecheck + vite build
all clean (first pass). Live in-Figma verification pending (manual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e 5)

Native Slides tools for the slides surface (enabled Wave 3), wired through
all four seams and guarded by requireSlides (editorType === "slides"):

- create_slide          (grid row/col + optional background color)
- create_slide_row      (optional insert index)
- set_slide_transition  (23 styles, duration, 8 easing curves, on-click/
                         after-delay timing; only passed fields change)
- set_slide_skip        (isSkippedSlide)
- focus_slide           (currentPage.focusedSlide)
- get_slide_grid        (read the deck order as a 2D id/name grid)
- set_slide_grid        (reorder by passing the full grid back)

Mechanism mirrors the FigJam adapter: SLIDES_REQUEST_TYPES + requireSlides;
writes serialize via the mutationChain; the 5 content mutations
(create_slide/create_slide_row/set_slide_transition/set_slide_skip/
set_slide_grid) are journaled, focus_slide + get_slide_grid are not.

Honest scope: figma.viewport.slidesMode is NOT in the pinned
@figma/plugin-typings 1.130, so a set-view-mode tool is intentionally
omitted rather than shipped against an unverified API.

Tool count 82 -> 89. Build gate: server tsc + plugin typecheck + vite build
all clean (first pass). Live in-Figma verification pending (manual).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the multi-surface expansion with native figma.buzz.* tools,
guarded by requireBuzz (editorType === "buzz"):

- create_buzz_frame     (canvas-grid frame + platform asset type + bg)
- set_buzz_asset_type   (43 platform types: LINKEDIN_POST, INSTA_STORY, …)
- get_buzz_content      (read dynamic text + media fields; read-only)
- set_buzz_text         (positional bulk-fill of text fields — the core
                         data-driven marketing-at-scale workflow)
- buzz_smart_resize     (layout-aware reflow across platform sizes)

Mechanism mirrors the FigJam/Slides adapters: BUZZ_REQUEST_TYPES +
requireBuzz; writes serialize via the mutationChain; the 4 mutations are
journaled, get_buzz_content is not. set_buzz_text uses BuzzTextField
setValueAsync.

Honest scope: setting media (BuzzMediaField.setMediaAsync) needs an
ImagePaint/VideoPaint hash, which requires a media pipeline unavailable in
the local-only sandbox, so only media READ is exposed.

All 4 authoring surfaces (figma+draw, figjam, slides, buzz) now have native
tool adapters. Tool count 89 -> 94. Build gate: server tsc + plugin
typecheck + vite build all clean (first pass). Live verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Figma's "Import from manifest" only recognizes a file named exactly
manifest.json, so the top-level manifest.dev.json was never selectable in
the picker. Move it to plugin/dev-mode/manifest.json (a real manifest.json)
pointing at the shared build via ../dist/code.js + ../dist/index.html — no
duplicated bundle. Import plugin/manifest.json for the 4 authoring surfaces
and plugin/dev-mode/manifest.json for the Dev-Mode companion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…path rule)

Figma rejects a manifest whose "main"/"ui" points at a parent dir:
  Manifest "main" file must be located in same directory, or a subdirectory,
  as manifest
So ../dist/code.js failed to load. Fix: dev-mode/manifest.json now references
code.js + index.html in its OWN directory, and a post-build step
(plugin/scripts/sync-dev-mode.mjs, wired into `pnpm build`) copies the built
bundle from dist/ into dev-mode/ on every build so the two stay in lockstep.
The copies are gitignored; only dev-mode/manifest.json is tracked.

Import plugin/dev-mode/manifest.json for the Dev-Mode companion; it shares
the exact same build as plugin/manifest.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reprioritized per real usage: most people work in the Figma canvas and use
Dev Mode for handoff, so that pairing is now the MAIN plugin; the niche
surfaces move to one optional companion.

- plugin/manifest.json         -> editorType ["figma","dev"] + capabilities
                                  ["inspect"]  (flagship: canvas + Dev Mode)
- plugin/extras/manifest.json  -> editorType ["figjam","slides","buzz"]
                                  (one optional companion; self-contained,
                                  synced from dist/ by scripts/sync-extras.mjs)

Replaces the previous dev-mode companion. ["figjam","dev"] remains the only
illegal combo, and neither manifest contains it. Import plugin/manifest.json
for canvas + Dev Mode; import plugin/extras/manifest.json when working in
FigJam / Slides / Buzz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live import surfaced a hard Figma constraint: EVERY plugin manifest's
editorType must contain "figma" — a figjam/slides/buzz-only manifest is
rejected with "The manifest editorType does not include 'figma'." So the
extras companion is ["figma","figjam","slides","buzz"] (only ["figjam","dev"]
together remains illegal). Both manifests now include figma; dev lives only
in the flagship manifest, the niche surfaces in extras.

Verified live: stale dev-plugin entry removed, both manifests imported and
valid in Figma's Development menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The KNOWLEDGE half of the design-system skills layer, authored + verified by
a 9-agent design workflow and bundled as offline Markdown (no network):

- server/skills/canonical-structure.md   the flawless build order (crown jewel)
- server/skills/token-architecture.md     3-tier Primitive->Semantic->Component
- server/skills/variable-scopes-binding-tokens.md
- server/skills/theming-with-modes.md
- server/skills/component-variant-structure-for-codegen.md
- server/skills/design-to-code-correctness.md
- server/skills/accessibility-naming-conventions.md
- server/skills/lint-rules.md              the 57-rule catalog (rule -> detection)
- server/skills/index.json                 metadata for list_skills

Encodes ONE opinionated structure (one collection per tier, theme as Semantic
modes, aliases strictly one tier down, nodes bind Semantic/Component never
Primitive) so design->code is deterministic. Every one of the 57 linter rules
in the catalog was verified detectable via Plugin API 1.130 locally (0 REST).

Next: Wave 1b wires the serving tools (list_skills / read_skill /
get_build_recipe) + skill:// MCP resources + the copy-to-dist build step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cipe (Wave 1b)

Wires the offline knowledge bundle to the AI. Three server-local tools
(server/src/skills.ts) read the bundled Markdown from dist/skills/ via
import.meta.url — no plugin round-trip, no network, resolved identically on
leader or follower:

- list_skills(query?)        catalog + start_here (canonical) + rule count
- read_skill(slug)           full Markdown of one skill; slug whitelisted +
                             charset-guarded (no path traversal)
- get_build_recipe(step?)    canonical build order + the lint rule_ids that
                             gate each Primitive->Semantic->Component step

Resolved locally via a new renderLocal() helper (BridgeResponse needs
type/requestId, which these don't have). Schemas are exported but NOT added to
toolInputSchemas/rpcToArgs since they never hit the follower->leader RPC path.
Build step copies server/skills/ -> dist/skills/ after tsc (scripts/copy-skills.mjs).

Verified at runtime from dist: list=6 skills/57 rules, read=13K md,
get_build_recipe(semantic) returns the right gate, traversal blocked.
Tool count 94 -> 97. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (Wave 2)

The STRUCTURE-LINTER scaffold. Architecture: the plugin gathers a serializable
snapshot; the SERVER runs the detectors — 57 rules of logic stay in testable TS,
not the plugin bundle.

- plugin lint_run (read): prepends figma.loadAllPagesAsync() (dynamic-page),
  then serializes the variable graph (collections+modes, variables with
  resolvedType/scopes/valuesByMode with aliases as {alias:id}/codeSyntax/
  hiddenFromPublishing/description), paint/text/effect styles, and
  COMPONENT/COMPONENT_SET property definitions. Not journaled (read-only).
- server/src/lint/registry.ts: the 57 rules' metadata (id/title/category/
  severity/skillUri/fixHint), auto-generated from the verified catalog.
- server/src/lint/runner.ts: LintSnapshot types + Detector type + a (currently
  empty) DETECTORS map + runLint() — filters by only/categories/severity,
  returns {summary, findings, not_yet_implemented, scope}. Detectors land
  tier-by-tier in Waves 3-7.
- lint_design_system tool: calls lint_run, runs runLint over the snapshot,
  returns the report. Resolved via renderLocal (server-side post-processing).

Verified: server tsc + plugin typecheck + vite build clean; runLint over an
empty snapshot returns 57 pending / 0 findings, category filter (tokens=12) works.
Tool count 97 -> 98.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The linter now catches real structural defects. Server-side detectors over the
lint_run snapshot (no plugin change):

- shared.ts   tier classification (name-hint primary so a mis-built but
              correctly-NAMED collection fails instead of being re-labelled) +
              alias resolution with cycle/depth guard
- tokens.ts   9 detectors: three-tier-collections-exist, primitive-raw-values-
              only, semantic-alias-in-every-mode, component-token-must-alias-
              semantic, alias-one-tier-down, alias-target-resolves,
              alias-graph-acyclic-max-depth-2, primitive-hidden-from-publishing,
              duplicate-primitive-value
- register.ts / index.ts   barrel that populates DETECTORS as a side-effect
              (import lint/index.js, not runner.js) — new tiers append here

The 3 remaining token rules (no-node-binds-primitive, unused-variable-orphan,
single-use-component-passthrough) need a node-binding scan and land next.

Verified: clean 3-tier snapshot -> 0 findings (no false positives); each rule
fires on its defect incl. >2-hop chains and cycles; category filter tokens=12,
run=9, pending=3. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scope IS the code-gen property hint, so wrong scopes make the generator
emit the wrong CSS property. Seven detectors over the same lint_run snapshot,
reusing shared tier classification:

- no-all-scopes-on-typed-token   semantic/component tokens must not use ALL_SCOPES
- scope-legal-for-resolved-type  scope must be legal for COLOR/FLOAT/STRING/BOOLEAN
- non-redundant-scope-set        no ALL_SCOPES alongside specific scopes
- color-role-scope-match         bg->fill, fg->TEXT_FILL, border->STROKE_COLOR
- dimension-role-scope-match     radius->CORNER_RADIUS, space->GAP, size->WIDTH_HEIGHT
- type-role-scope-match          font-size->FONT_SIZE, line-height->LINE_HEIGHT, ...
- no-text-content-scope-on-token TEXT_CONTENT is for content vars, not design tokens

Role-matching is conservative (only clear, unambiguous roles fire; ALL_SCOPES-only
cases are left to no-all-scopes-on-typed-token) to avoid false positives.

Verified: clean semantic tokens -> 0 findings; all 7 fire on their defects.
Linter now runs 16 active rules (tokens 9 + scopes 7); scopes category 8, run 7,
pending 1 (binding-on-scope-for-property needs the node-binding scan). Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-linting the real Nectar DS crashed lint_run: findAllWithCriteria(["COMPONENT"])
returns variant components (children of a COMPONENT_SET), and reading
componentPropertyDefinitions on a variant throws "Can only get component
property definitions of a component set or non-variant component". Guard it:
variants (parent is COMPONENT_SET) get propertyDefinitions: undefined + an
isVariant flag, so one variant no longer aborts the whole scan.

Verified: lint_design_system now completes on the live file (48 pages, 1121
variables, 376 components) with no crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Live-linting the Nectar DS (1121 vars) surfaced 611 "errors", almost all
false positives from treating one opinionated structure as law. A design
system's exact shape is a CHOICE, and Nectar is a valid 3-tier system with
granular in-tier controls. Recalibrated so the linter advises:

- Severity: only objectively-broken defects stay `error` (alias-target-resolves,
  scope-legal-for-resolved-type); every structural-preference rule -> `warn`.
  severity:'error' = "is anything actually broken?", severity:'all' = full review.
- alias-one-tier-down: cross-collection edges only — intra-collection granular
  scales (duration/50->/100) are the team's controls, not a tier violation.
- semantic-alias-in-every-mode: flag only mode INCONSISTENCY (aliases some
  modes, raw in others), not pure-raw granular values.
- Tier name-hints narrowed: dropped over-broad "token/role/theme" that
  mislabeled "Motion Tokens"-style collections as the semantic tier.

Result on the live Nectar file: 611 -> 7 errors, and all 7 are genuine
dangling aliases (icon/link color tokens pointing at deleted variables) —
trustworthy, actionable signal. Nectar DS itself untouched (lint is read-only).
Clean unit still 0 findings. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flagship "missing semantic layer" check. lint_run now scans all pages for
nodes with boundVariables and serializes node->variable edges (recursing into
scalar fields, fills[] arrays, and nested paint boundVariables to pull
VARIABLE_ALIAS ids), bounded at 10k edges with a bindingsTruncated flag.

New detector no-node-binds-primitive: a node bound directly to a primitive-tier
variable has no semantic indirection, so it can't be themed — flagged (warn),
deduped to one finding per primitive with an example node.

Verified: build clean; a node binding a semantic -> 0 findings, binding a
primitive -> 1 deduped warning. Live node-scan validation on the real file next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Theming (4): primitive-component-single-mode (only Semantic is multi-mode),
every-mode-populated, mode-count-ceiling (>8 = folded axes), semantic-default-
mode-is-base (default should be light, not dark).

Naming (3), kept deliberately tolerant — a team's case convention and category
vocabulary are their choice: name-kebab-segments flags only malformed segments
(spaces/empty/stray slashes), NOT camelCase; name-slash-structure-depth flags
flat (no group) or >5-deep names; hue-ramp-words-primitives-only flags chromatic
hue words on semantic/component tokens (they should be role-named). Neutrals
excluded to avoid false positives.

All advisory (warn). The 7 remaining theming/naming rules are opinionated /
config-dependent (vocab allowlists, cross-collection axis, fg-bg pairing) and
stay pending rather than risk noise on a real DS.

Verified: clean structure -> 0 findings; each rule fires on its defect; all warn.
Linter now runs 24 active rules (tokens 9 + scopes 7 + binding 1 + theming 4 +
naming 3). Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design->code contract, variable-level:
- published-variable-has-codesyntax-web  (conditional: only once some tokens
                                          have WEB codeSyntax — respects a team
                                          that hasn't adopted it)
- codesyntax-web-unique                  (duplicate WEB names collide in code)
- published-variable-has-description      (conditional; info)

The 4 remaining code-output rules (no-raw-value-on-component-node,
text-layer-uses-style-or-bound-type, component-set-has-code-mapping,
codesyntax-web-matches-name) need node-level data / the code-mappings store, or
conflict with why codeSyntax exists — deferred rather than guess.

Verified: not-adopted -> 0 findings; partial adoption -> all 3 fire (collision,
missing codeSyntax, missing description). Linter now runs 27 active rules.
Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From componentPropertyDefinitions (already in the snapshot):
- property-name-convention-unique          duplicate property base names in a set
- boolean-vocab-variant-should-be-boolean  VARIANT {true,false}/{on,off}/... -> BOOLEAN
- instance-swap-preferred-values           INSTANCE_SWAP without a curated list
- no-asset-enumeration-variant             VARIANT with >12 options (asset dump) -> INSTANCE_SWAP

The 8 remaining component rules need variant counts, instance/override data,
usage, or cross-component/config heuristics (variant-matrix-complete,
variant-count-ceiling-60, no-dead-component-property, no-instance-restyle-
override, detached-component-frame-signal, shared-property-value-consistency,
default-variant-is-base-tuple, boolean-prop-no-sizing-intent) — deferred.

Verified: clean components -> 0 findings; each rule fires on its defect (all warn).
Linter now runs 31 active rules. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Real WCAG contrast, done carefully (the one rule where a subtle bug ships
silently). Resolves semantic fg/bg (and border/icon) token PAIRS down to
concrete RGB per mode — following aliases to the primitive raw value — then
computes the WCAG relative-luminance contrast ratio:

- fg-bg-pair-contrast              < 4.5:1 (WCAG AA text)
- border-icon-graphical-contrast   < 3.0:1 (WCAG 1.4.11 non-text)

Pairing is conservative (exact name-suffix match + the on-<X> convention +
fallback to bg/default) so it only flags clear, real pairs — advisory (warn).

Deferred: min-font-size (needs text-node data) and the export-pixel-sampling
contrast fallback.

Verified against known values: black/white = 21.00:1 (0 findings), gray(0.6)/
white = 2.85:1 (both rules fire). All 7 rule tiers now have detectors; the
linter runs 33 active rules. Server tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n tests

Live-linting the real Nectar DS surfaced 53 contrast findings, but the
math was right and the PAIRING was wrong: loose name-suffix matching
paired foreground/X with surface/X even when they were never intended to
sit together (the tell: `foreground/neutral/default` on
`surface/neutral/default` resolved to the SAME colour = 1.00:1).

Pair a foreground with a surface ONLY through the explicit on-<X> naming
convention (foreground/on-primary -> surface/primary) — the one signal
where the designer declared the intended pair. A DS without that
convention (like Nectar's 3-tier system) now gets 0 a11y findings instead
of 53 fabricated ones; convention-free auditing is deferred to node-level
pixel sampling. Belt-and-braces: skip pairs that resolve to the same
primitive.

This keeps the linter advisory and precise — it only asserts contrast
failures it can prove are real pairs, consistent with the calibration
philosophy (advise, don't dictate; everyone's DS structure is their own).

Adds the project's first test harness: `pnpm test` (build + node:test,
zero new deps) with 5 regression tests pinning on-<X> pairing, the
same-colour skip, a passing high-contrast pair, and the suffix-only
non-pairing that caused the false positives. Verified: WCAG math intact
(gray0.6/white = 2.85:1), all 5 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hand-crafted, correctly-built 3-tier DS (primitives hidden + raw,
semantics role-named + role-scoped + every-mode, components aliasing one
tier down) that MUST pass all 33 active detectors with zero findings.

This is the linter's anti-noise ratchet: proof it stays silent on a
clean DS instead of crying wolf, and proof the 33 detectors are mutually
satisfiable (a passing DS provably exists). If a future detector fires
here, either the fixture must encode the new "correct" criterion or the
rule is over-strict — both worth catching at test time.

Converging the fixture also exercised the detectors as their own tests:
`scope-legal-for-resolved-type` correctly rejected a bogus ALL_STROKES
scope (Figma's enum is STROKE_COLOR), and color-role-scope-match pinned
fg->TEXT_FILL / border->STROKE_COLOR. Second assertion guards that every
implemented detector actually ran (no silent skips). All 7 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…AGENTS docs

The build recipe's lint gate was a flat rule_id list forward-declared
before lint_design_system existed. Now the tool is live, so wire the
closed loop: buildGate() splits each step's gate into enforced_now vs
forward_declared computed from the live registry at runtime (no manual
drift), and returns the exact `run` call — lint_design_system {only:
[<enforced ids>]} — plus a fix-errors-then-advance instruction. A gate
never asks the lint tool for a rule whose detector hasn't landed
(min-font-size, variant-matrix-complete, no-raw-value-on-component-node
stay forward-declared). Also adds the now-implemented
border-icon-graphical-contrast to the a11y gate.

Documents the whole skills + linter closed loop in AGENTS.md: the two
offline halves, the get_build_recipe → build → lint → fix loop, the
advise-don't-dictate philosophy (read-only, error=objectively-broken
only, contrast pairs only via on-<X>), the 3-place add-a-detector recipe,
and the new `pnpm test` unit layer (incl. the can't-hot-reload note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ive tests

Implements four pending rules that are pure functions over the existing
LintSnapshot (no new plugin data), lifting coverage 33 -> 37 / 57:

- binding-on-scope-for-property (warn): a token bound to a node property
  its scopes don't cover (TEXT_FILL colour on a stroke, GAP token on
  cornerRadius). Field->scope map keyed off the plugin's real
  boundVariables field names; `fills` disambiguated by nodeType
  (text-fill vs frame/shape-fill). ALL_SCOPES/empty never flag; unknown
  fields skipped (never guessed).
- surface-on-pair-completeness (warn): once a DS adopts the on-<X>
  convention, every chromatic surface should have its pair so text on it
  has a contrast-checkable colour. Fires on INCONSISTENCY only — a DS
  that doesn't use on-<X> gets nothing (advise, don't dictate).
- unused-variable-orphan (info): hidden token never aliased and never
  bound = dead weight. Guarded: silent unless the binding scan ran in
  full (undefined/truncated -> can't prove unused -> silent); published
  tokens are the public API and exempt.
- variant-count-ceiling-60 (warn): variant matrix product (from
  propertyDefinitions) over ~60 — the explosion is slow/unwieldy.

Adds dirty-fixture.test.mjs — the true-positive complement to the golden
fixture: proves the objectively-broken ERROR rules (dangling alias,
illegal scope) surface as severity:error so the build gate genuinely
blocks, and that each new detector fires on a violation while staying
silent on its control cases (ALL_SCOPES binding, un-adopted on-<X>,
truncated scan, sub-ceiling matrix). Golden fixture still 0 findings —
the ratchet held. All 12 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an opt-in / parameterized-rule surface so the linter can enforce
house style WITHOUT making the default lint noisy — advise, don't dictate.

Mechanism:
- registry: RuleMeta gains optional `defaultOn` (omitted=true). Opinionated
  rules ship `defaultOn:false` and run only when explicitly enabled.
- runLint: LintOptions gains enable[]/disable[]/config{}. Selection =
  default-on minus disabled, plus opt-ins named in enable[]/only[].
  Detector signature is now `(snap, config?)` — the 30+ existing detectors
  ignore the arg (narrower fn is assignable). Per-rule config resolved +
  validated (lint/config.ts, LintConfigError); bad/missing config →
  report.config_errors and the rule is skipped, never a crash. Each
  detector also runs inside try/catch → report.rule_failures, so one buggy
  rule can't abort the whole lint.
- report: `available_optin[]` advertises every off-by-default rule with its
  config_shape + an enable_hint so the AI can discover & turn them on;
  summary.rules_opt_in_available; ruleInventory() now reports defaultOn.
- perf: analyze(snap) is WeakMap-memoized — the alias-DAG classification is
  computed once per lint instead of ~30× (1,121 vars × 48 pages).
- tool: lint_design_system schema gains enable/disable/config.

Newly-implemented config-driven rules (all pure, opt-in unless noted):
- semantic-role-allowlist: semantic role not in the team's allowlist (req cfg)
- top-segment-in-tier-vocabulary: top segment not in the per-tier vocab (req cfg)
- numeric-scale-zero-padded: numeric step not padded to `width` (default 3)
- codesyntax-web-matches-name: WEB codeSyntax shares < N name segments (default 1)
- variant-count-ceiling-60: ceiling now configurable (default 60, still default-on)

+13 tests (config-surface.test.mjs): backward-compat (opt-ins off by
default), enable/disable/only selection, config defaults+overrides,
required-config validation → config_errors (no crash), available_optin
surfacing, and each detector firing when configured. Golden fixture still
0 findings; "all detectors ran" invariant now counts default-on rules. All
25 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 6-lens adversarial review of cbe42b0 (each finding independently
refute-verified) surfaced 3 real defects; security/pollution,
memoization, and backward-compat lenses found nothing.

1. codesyntax-web-matches-name (medium, the real bug): segTokens() split
   only on non-alphanumerics, so a faithfully-derived camelCase codeSyntax
   like 'buttonBackground' or 'theme.spacingMd' collapsed to one blob and
   read as "unrelated" to the slash-grouped name -> near-100% false
   positives for any team on a camelCase codeSyntax convention (the
   opposite of the rule's intent). Now split camelCase/PascalCase
   boundaries too before tokenizing.
2. passed miscount (low): rulesRun++ runs before the detector try/catch, so
   a crashed detector (new rule_failures path) was still counted in
   `passed`. Now passed = rulesRun - withFindings - ruleFailures, so
   passed + withFindings + failures reconciles to rules_run.
3. rule_failures untested (low): added a test that injects a throwing
   detector via the DETECTORS map and asserts it surfaces under
   rule_failures AND is excluded from passed (the invariant above). Plus a
   regression test that camelCase codeSyntax no longer false-positives.

All 27 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure functions over the existing LintSnapshot:
- consistent-mode-names-across-axis (theming, warn): same mode spelled
  inconsistently across collections (Light vs light) — objective.
- one-theme-axis-per-collection (theming, info): modes form an A×B
  cross-product (>=4 modes, every mode two tokens, count==|A|*|B|) — two
  axes folded into one collection. Conservative.
- boolean-prop-no-sizing-intent (components, info): a BOOLEAN property
  named for a size/layout concept (booleans are show/hide).
- shared-property-value-consistency (components, warn, OPT-IN): the same
  variant property name uses different option sets across components. A
  shared name isn't always the same axis, so defaultOn:false.
- single-use-component-passthrough (tokens, warn): a component token that
  aliases one semantic in every mode with no override and is bound <=1
  time — a tier for nothing. Guarded on a full binding scan.

+5 tests (pending-rules.test.mjs), each fires on a violation and is silent
on its control; golden fixture still 0 findings. 32 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…6→51/57)

Extends the plugin's lint_run to gather bounded per-component AGGREGATES
(booleans/counts/min/key-union — never raw node trees, so the payload
stays small) via one DFS per non-variant root with a shared 20k-node
budget + componentScanTruncated flag; TEXT styles now carry fontSize/
fontFamily; COMPONENT_SETs carry realized variant tuples (capped 400 +
variantTuplesTruncated) and the default-variant tuple.

Server detectors consuming it (all degrade to SILENT when the enrichment
is absent — old plugin build — so nothing false-flags):
- min-font-size (a11y, warn, config {floor?:number} default 12): TEXT
  style or component TEXT layer below the floor. Positive evidence, so no
  scan guard needed.
- no-raw-value-on-component-node (code-output, warn): a component
  descendant carries an unbound, unstyled SOLID/GRADIENT paint.
- text-layer-uses-style-or-bound-type (code-output, warn): a TEXT
  descendant has neither a text style nor bound type tokens.
- no-dead-component-property (components, warn): a non-VARIANT property no
  descendant references — NEGATIVE evidence, so it requires enriched
  (full scan); VARIANT axes are exempt (structural, not layer-referenced).
- variant-matrix-complete (components, warn): realized variant tuples <
  the product of variant options; skipped when the tuple list truncated.

Truncation discipline (per the design panel): positive-evidence rules are
truncation-safe (a found defect is real); the one negative-evidence rule
guards on a complete scan. Plugin gather is fully defensive (per-node and
per-component try/catch — one unreadable node can't abort lint_run).

+6 tests (enrichment-rules.test.mjs): each fires on enriched data and is
silent without it / when truncated. Golden fixture still 0. 38 tests pass.
Deferred (data cost / ambiguity): default-variant-is-base-tuple,
no-instance-restyle-override, component-set-has-code-mapping,
detached-component-frame-signal, contrast-fallback-export-sampling,
multi-brand-alias-discipline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…finding)

The Wave 10 adversarial review (6 lenses, refute-by-default) confirmed 3
of 13 findings; 10 refuted (noise, truncation race, backward-compat all
held). The real bug (flagged independently by two lenses):

walkComponent's DFS descended into nested INSTANCE subtrees. Since
InstanceNode.children exposes the main component's cloned layers, a clean
component that merely USES an instance inherited that instance's
raw-paint / untyped-text / low-font defects — mis-attributed to the outer
component (wrong nodeId), duplicated across every consumer, and burning
the shared 20k budget (prematurely truncating later components). The
happy-path live-verify missed it because Nectar's flagged components had
own-layer defects.

Fix: treat nested instances (and nested non-variant components/sets) as
boundaries — process a boundary node's own componentPropertyReferences
(it may wire an exposed property of THIS component) but do NOT descend
into it or attribute its paint/text/font here; its layers are linted when
that component is walked as its own root. A COMPONENT_SET root's direct
variant children stay in-scope. Also dropped INSTANCE from RAW_PAINT_TYPES
(an instance's effective fills can be inherited, not authored here).

Also (low, test-coverage finding): added the single-use-passthrough
uses===1 firing case and the per-mode-variation silent case. AGENTS.md
updated for the enrichment layer + 51/57 coverage. Plugin + server build
clean; 38 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tknatwork
tknatwork merged commit 9cc6d7e into main Jul 14, 2026
2 checks passed
@tknatwork
tknatwork deleted the feat/limitless-expansion branch July 14, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant