feat(limitless-mcp): multi-surface expansion + design-system skills layer & structure linter#34
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
write_variables(rename/update/delete across variables, collections, modes)get_slots/reset_slot/append_to_slotmanifest.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../inmain/uipaths.2. Offline design-system skills (knowledge layer)
server/skills/(zero network)list_skills/read_skill/get_build_recipe(server-local, path-traversal-guarded)3. Structure linter (
lint_design_system) — 16 rules livelint_rungathers a snapshot (variable graph + styles + components, afterloadAllPagesAsync); the server runs the detectors (testable TS, not the plugin bundle)error; every opinionated structural rule is awarn. Intra-collection granular scales are respected, not flagged.Validation
tsc+ plugin typecheck +vitebuild cleanHonesty notes
lint_design_systemlists them undernot_yet_implemented.dist/artifacts are gitignored and rebuilt viapnpm buildin each package.🤖 Generated with Claude Code