Skip to content

feat(diffs-highlight): Add a Figma plugin that highlights code with Pierre variables - #1053

Draft
mdo wants to merge 2 commits into
mdo/theme-figma-variablesfrom
mdo/diffs-highlight-figma-plugin
Draft

feat(diffs-highlight): Add a Figma plugin that highlights code with Pierre variables#1053
mdo wants to merge 2 commits into
mdo/theme-figma-variablesfrom
mdo/diffs-highlight-figma-plugin

Conversation

@mdo

@mdo mdo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Note

Stacked on #1049. Base is mdo/theme-figma-variables, so review that one
first — this plugin reads the token files it generates.

Summary

A Figma plugin that syntax highlights code in a text layer by binding the Pierre
syntax/* variables to each token's characters. Paste code into a text layer,
pick the language, hit Highlight.

Because the colors come from variables rather than hex values, switching the
collection's mode re-colors the code — one layer covers Light, Dark, Soft, and
both CVD variants, which is the payoff for #1049 landing the variables as modes
in the first place.

Local development only: it loads from a manifest on disk and is not published to
the Figma Community.

How it works

Two realms, because Figma requires it. src/sandbox/code.ts owns the figma
API and does no tokenizing: it reports the selection, lists local collections, and
binds the character ranges it is handed. src/ui owns Shiki. The build is two
Vite passes because the realms need different shapes — a bare IIFE for the
sandbox, and one HTML file with its script and styles inlined for the iframe,
which loads no external resources.

Colors are turned back into role names by probing. Binding a variable needs a
name like syntax/keyword, but Shiki hands back a resolved color. All eight
Pierre variants share one scope table and differ only in resolved color, so the UI
always tokenizes with pierre-dark as a probe, looks the role up by color, and
binds by name; Figma resolves the actual color for whichever mode is active. That
is why there is no theme picker in the UI.

pierre-dark is the probe specifically because its syntax colors are all
distinct — the tritanopia variants collapse five of them into one, which would
make the reverse lookup ambiguous. The lookup table is built from the committed
figma/semantic/dark.json, so it regenerates alongside the variables it
describes; that is the one new export on @pierre/theme.

Roles do collide even in dark mode (fg/fg3 and syntax/comment are both
#737373), so src/shared/roleIndex.ts resolves in a fixed order and excludes
bg and border entirely — those are surface colors that never apply to glyphs.

Notes for reviewers

Every bundled language is offered, all 235. Grammars are registered on demand,
so the count costs nothing at startup (0.1 ms to create the highlighter, 0.5 ms
per grammar). Aliases resolve too, so bash works and not just shellscript
safe because Shiki's 97 aliases never collide with an id and are never claimed by
two languages.

The WebAssembly constraint is the real risk, and it is tested. The sandbox
cannot load WASM, so Shiki runs on the JavaScript regex engine, and a grammar that
needed Oniguruma would fail in Figma with no other warning. test/highlight.test.ts
runs all 235 through the engine; all of them pass, so no forgiving mode or
error-path special-casing was needed. That sweep is most of the suite's ~12s.

There is a warm-up call that looks pointless and is not. A fresh highlighter's
very first tokenization comes back coarser than every call after it — const a = 1;
yields 1; as one token instead of separating the number from the semicolon. One
throwaway pass settles it, and it is genuinely one-time: languages registered later
tokenize correctly on their first real call. There is a test pinning that offset so
nobody deletes it.

dist/ui.html is about 8 MB. Inherent — Figma fetches nothing at runtime, so
every grammar has to be in the file. It costs little in practice: the panel loads
in ~200 ms, a first highlight takes ~350 ms, later ones under 100 ms. Minifying was
measured at a 10% saving and skipped, since the bulk is grammar data rather than
code and readable output is worth more on a dev plugin.

Whitespace-only ranges are skipped and touching same-role ranges are merged, to
keep the sandbox's setRangeFills calls down. Merging only happens when ranges are
literally adjacent, so a skipped token or a line break breaks the run and it cannot
silently swallow a range.

Test plan

  • moonx diffs-highlight:test — 32 pass, including the 235-grammar sweep
  • moonx diffs-highlight:typecheck, moonx theme:test (376 pass)
  • moon run root:format root:lint root:check-licenses
  • moonx diffs-highlight:build — emits exactly the two files the manifest names
  • Ran the built 8 MB ui.html in a browser: 235 options render, and TypeScript,
    Rust, ABAP, and Clojure each produced sensible role bindings, confirming
    on-demand grammar registration works in the real bundle
  • Needs a Figma desktop check. Import the variables from feat(theme): Export palette and theme colors as Figma variables #1049 first
    (primitives collection, then semantic as modes), then
    Plugins > Development > Import plugin from manifest. Worth trying an
    uncommon language to confirm on-demand registration behaves in the sandbox, and
    switching modes on the layer afterwards to see it re-color.

…ierre variables

Designers can paste code into a Figma text layer and have it syntax
highlighted from the Pierre variables imported by `@pierre/theme`.
Because the colors come from variables rather than hex values,
switching the collection's mode re-colors the sample, so one layer
covers light, dark, soft, and the CVD variants.

The plugin is split across Figma's two realms. `src/sandbox` owns
the `figma` API and does no tokenizing: it reports the selection,
lists the local collections, and binds the character ranges it is
handed. `src/ui` owns Shiki. Shiki runs on the JavaScript regex
engine, which matters more here than elsewhere in the repo: the
plugin sandbox cannot load WebAssembly.

Binding needs a role name, but Shiki returns a resolved color. All
eight variants share one scope table and differ only in color, so
the UI always tokenizes with `pierre-dark` as a probe, looks the
role up by color, and binds by name; Figma resolves the color for
whichever mode is active. `pierre-dark` is the probe because its
syntax colors are all distinct, where the tritanopia variants
collapse five into one. The lookup table is built from the
committed `figma/semantic/dark.json`, which is why `@pierre/theme`
grows a `./figma/*` export.

Every language Shiki bundles is offered. Grammars are registered on
demand, so the count does not affect startup, and their aliases
resolve too. A one-time warm-up pass is needed because a fresh
highlighter's first tokenization comes back coarser than every call
after it.

`test/highlight.test.ts` runs all 235 grammars through the
JavaScript engine, since one that needed Oniguruma would fail in
Figma with no other warning. All of them pass today.

Local development only: the plugin is loaded from a manifest on
disk and is not published to the Figma Community.
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pierre-docs-diffshub Ready Ready Preview Aug 4, 2026 7:11pm
pierre-docs-trees Ready Ready Preview Aug 4, 2026 7:11pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
pierre-docs-diffs Skipped Skipped Aug 4, 2026 7:11pm
pierrejs-diff-demo Skipped Skipped Aug 4, 2026 7:11pm

Request Review

@socket-security

socket-security Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedvite-plugin-singlefile@​2.3.310010010082100
Added@​figma/​plugin-typings@​1.131.01001008598100

View full report

A selection can now hold any number of text layers, so a frame of code
samples is one run instead of one run each. Non-text and empty layers
in the selection are filtered out rather than rejected, so sweeping up
a stray rectangle alongside the code still works.

Each layer is tokenized separately, since each has its own text, but
all of them use the language the picker names — the point being
several samples of the same language at once. Binding is per layer too,
and a layer whose text changed between tokenizing and binding is
skipped and named in the summary rather than failing the whole run.

The panel drops the line that echoed the selected layer's name back;
the layer count now rides on the Highlight button, which is where it
is about to be acted on. What is left of that line is the prompt shown
when there is nothing to work on, now in secondary gray, since asking
for a selection is guidance rather than an error.

The selects draw their own chevron. Chrome pins the platform one hard
against the right edge, which read as cramped; this one is inset, with
room reserved so a long option label cannot run underneath it, and it
is masked with `currentColor` so it follows both Figma themes. Hover
and focus states come along with it.

Collection labels list a mode count instead of every mode name. The
old format was redundant for a single-mode collection ("Light (light)")
and would have overflowed the control for an eight-mode one.
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