From df19fa94d910608be1f7d8eb705c5f8c304b17a2 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 4 Aug 2026 11:55:36 -0700 Subject: [PATCH 1/2] feat(diffs-highlight): Add a Figma plugin that highlights code with Pierre 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. --- packages/diffs-highlight/LICENSE.md | 189 ++++++++++++++ packages/diffs-highlight/README.md | 95 +++++++ packages/diffs-highlight/manifest.json | 11 + packages/diffs-highlight/moon.yml | 37 +++ packages/diffs-highlight/package.json | 28 ++ packages/diffs-highlight/src/sandbox/code.ts | 209 +++++++++++++++ .../diffs-highlight/src/shared/mapTokens.ts | 93 +++++++ .../diffs-highlight/src/shared/messages.ts | 66 +++++ .../diffs-highlight/src/shared/roleIndex.ts | 96 +++++++ packages/diffs-highlight/src/ui/highlight.ts | 120 +++++++++ packages/diffs-highlight/src/ui/main.ts | 241 ++++++++++++++++++ packages/diffs-highlight/src/ui/ui.css | 113 ++++++++ .../diffs-highlight/test/highlight.test.ts | 174 +++++++++++++ .../diffs-highlight/test/mapTokens.test.ts | 165 ++++++++++++ packages/diffs-highlight/tsconfig.json | 17 ++ packages/diffs-highlight/ui.html | 34 +++ packages/diffs-highlight/vite.config.code.ts | 25 ++ packages/diffs-highlight/vite.config.ui.ts | 27 ++ packages/theme/CONTRIBUTING.md | 7 + packages/theme/README.md | 3 + packages/theme/package.json | 4 +- pnpm-lock.yaml | 61 +++++ pnpm-workspace.yaml | 4 + tsconfig.json | 3 + 24 files changed, 1821 insertions(+), 1 deletion(-) create mode 100644 packages/diffs-highlight/LICENSE.md create mode 100644 packages/diffs-highlight/README.md create mode 100644 packages/diffs-highlight/manifest.json create mode 100644 packages/diffs-highlight/moon.yml create mode 100644 packages/diffs-highlight/package.json create mode 100644 packages/diffs-highlight/src/sandbox/code.ts create mode 100644 packages/diffs-highlight/src/shared/mapTokens.ts create mode 100644 packages/diffs-highlight/src/shared/messages.ts create mode 100644 packages/diffs-highlight/src/shared/roleIndex.ts create mode 100644 packages/diffs-highlight/src/ui/highlight.ts create mode 100644 packages/diffs-highlight/src/ui/main.ts create mode 100644 packages/diffs-highlight/src/ui/ui.css create mode 100644 packages/diffs-highlight/test/highlight.test.ts create mode 100644 packages/diffs-highlight/test/mapTokens.test.ts create mode 100644 packages/diffs-highlight/tsconfig.json create mode 100644 packages/diffs-highlight/ui.html create mode 100644 packages/diffs-highlight/vite.config.code.ts create mode 100644 packages/diffs-highlight/vite.config.ui.ts diff --git a/packages/diffs-highlight/LICENSE.md b/packages/diffs-highlight/LICENSE.md new file mode 100644 index 000000000..e2c2b0606 --- /dev/null +++ b/packages/diffs-highlight/LICENSE.md @@ -0,0 +1,189 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty percent + (50%) or more of the outstanding shares, or (iii) beneficial ownership of + such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, and + configuration files. + + "Object" form shall mean any form resulting from mechanical transformation + or translation of a Source form, including but not limited to compiled + object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that is + included in or attached to the work (an example is provided in the Appendix + below). + + "Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as a + whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof. + + "Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its + representatives, including but not limited to communication on electronic + mailing lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, the Licensor for the purpose of discussing + and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by the copyright owner as "Not a + Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and subsequently + incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable copyright license to + reproduce, prepare Derivative Works of, publicly display, publicly perform, + sublicense, and distribute the Work and such Derivative Works in Source or + Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this + License, each Contributor hereby grants to You a perpetual, worldwide, + non-exclusive, no-charge, royalty-free, irrevocable (except as stated in + this section) patent license to make, have made, use, offer to sell, sell, + import, and otherwise transfer the Work, where such license applies only to + those patent claims licensable by such Contributor that are necessarily + infringed by their Contribution(s) alone or by combination of their + Contribution(s) with the Work to which such Contribution(s) was submitted. + If You institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work or a + Contribution incorporated within the Work constitutes direct or contributory + patent infringement, then any patent licenses granted to You under this + License for that Work shall terminate as of the date such litigation is + filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or + Derivative Works thereof in any medium, with or without modifications, and + in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a + copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating + that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy + of the attribution notices contained within such NOTICE file, excluding + those notices that do not pertain to any part of the Derivative Works, in at + least one of the following places: within a NOTICE text file distributed as + part of the Derivative Works; within the Source form or documentation, if + provided along with the Derivative Works; or, within a display generated by + the Derivative Works, if and wherever such third-party notices normally + appear. The contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional attribution + notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any + Contribution intentionally submitted for inclusion in the Work by You to the + Licensor shall be under the terms and conditions of this License, without + any additional terms or conditions. Notwithstanding the above, nothing + herein shall supersede or modify the terms of any separate license agreement + you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, except as + required for reasonable and customary use in describing the origin of the + Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in + writing, Licensor provides the Work (and each Contributor provides its + Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied, including, without limitation, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or + FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining + the appropriateness of using or redistributing the Work and assume any risks + associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in + tort (including negligence), contract, or otherwise, unless required by + applicable law (such as deliberate and grossly negligent acts) or agreed to + in writing, shall any Contributor be liable to You for damages, including + any direct, indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or inability + to use the Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses), even if such Contributor has been advised of + the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or + Derivative Works thereof, You may choose to offer, and charge a fee for, + acceptance of support, warranty, indemnity, or other liability obligations + and/or rights consistent with this License. However, in accepting such + obligations, You may act only on Your own behalf and on Your sole + responsibility, not on behalf of any other Contributor, and only if You + agree to indemnify, defend, and hold each Contributor harmless for any + liability incurred by, or claims asserted against, such Contributor by + reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Pierre Computer Company + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/packages/diffs-highlight/README.md b/packages/diffs-highlight/README.md new file mode 100644 index 000000000..a5f113431 --- /dev/null +++ b/packages/diffs-highlight/README.md @@ -0,0 +1,95 @@ +PIERRE COMPUTER COMPANY █ PROJECT: DIFFS HIGHLIGHT + +``` + +CONTACT: SUPPORT@PIERRE.CO +LOCATION: USA +STATUS: ONLINE +OPEN POSITIONS: [Systems Engineer](https://pierre.computer/careers/systems-engineer) + +``` + +Overview: + +- A Figma plugin that syntax highlights code in a text layer by binding the + Pierre `syntax/*` variables to each token's characters. +- Because the colors come from variables rather than hex values, switching the + collection's mode re-colors the code: light, dark, soft, and both CVD variants + from one sample. +- Local development only. This is not published to the Figma Community. + +## Setup + +The plugin is loaded from disk, so it has to be built first: + +```bash +moonx diffs-highlight:build +``` + +Then in Figma desktop: **Plugins > Development > Import plugin from manifest**, +and select `packages/diffs-highlight/manifest.json`. + +The variables have to exist in the file you run it on. Import them from +[`@pierre/theme`](../theme/README.md) first — the primitives collection, then +the semantic collection with one mode per variant. + +## Usage + +1. Select a single text layer containing code. +2. Run **Plugins > Development > Diffs Highlight**. +3. Pick the language, confirm the variable collection, and press **Highlight**. +4. Switch that collection's mode on the layer (or its parent frame) to move the + sample between Light, Dark, Soft, and the CVD variants. + +Every language Shiki bundles is offered — 235 of them, listed alphabetically by +display name, so the select relies on type-ahead. The picker opens on +TypeScript. + +The status line reports what happened: how many ranges were bound, any colors +that matched no Pierre role, and any variable names missing from the collection +you picked (usually the sign of a partial import, or the wrong collection). + +## How a color becomes a variable + +Shiki returns a resolved color per token, but binding a variable needs a role +name like `syntax/keyword`. All eight Pierre variants share one scope table and +differ only in resolved color, so the plugin always tokenizes with `pierre-dark` +as a probe — its 18 `syntax/*` colors are all distinct, where the tritanopia +variants collapse five of them into one — looks the role up by that color, and +binds by name. Figma then resolves the color for whichever mode is active, which +is why there is no theme picker in the UI. + +The lookup table is built from `@pierre/theme`'s committed +`figma/semantic/dark.json`, so it regenerates with the variables it describes. +See [`src/shared/roleIndex.ts`](src/shared/roleIndex.ts) for the resolution +order and the collisions it has to break. + +## Development + +```bash +moonx diffs-highlight:dev --ignore-ci-checks # rebuild both realms on change +moonx diffs-highlight:test +moonx diffs-highlight:typecheck +``` + +Figma reloads the plugin from disk each run, so a rebuild takes effect the next +time you run it — no re-import needed. + +The two realms build separately because Figma requires different shapes: +`dist/code.js` is a single IIFE for the sandbox that owns the `figma` API, and +`dist/ui.html` is one document with its script and styles inlined, since the +iframe loads no external resources. Shiki lives in the UI realm; the sandbox +only applies bindings. + +Carrying all 235 grammars makes `dist/ui.html` about 8 MB. That is inherent — +nothing can be fetched at runtime, so every grammar has to be in the file — and +it costs little in practice, since a grammar is only parsed into a registry when +a language is picked: the panel loads in roughly 200 ms and a first highlight +takes about 350 ms, later ones well under 100 ms. Minifying was measured at a +10% saving, not worth the unreadable output, because the bulk is grammar data +rather than code. + +`test/highlight.test.ts` runs every one of those grammars through the JavaScript +regex engine, which is the check that matters here: the sandbox cannot load +WebAssembly, so a grammar that needs Oniguruma would fail in Figma with no other +warning. All 235 pass today, and the sweep is most of the suite's ~12s runtime. diff --git a/packages/diffs-highlight/manifest.json b/packages/diffs-highlight/manifest.json new file mode 100644 index 000000000..84b253d8d --- /dev/null +++ b/packages/diffs-highlight/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Diffs Highlight", + "api": "1.0.0", + "main": "dist/code.js", + "ui": "dist/ui.html", + "editorType": ["figma"], + "documentAccess": "dynamic-page", + "networkAccess": { + "allowedDomains": ["none"] + } +} diff --git a/packages/diffs-highlight/moon.yml b/packages/diffs-highlight/moon.yml new file mode 100644 index 000000000..22a878b56 --- /dev/null +++ b/packages/diffs-highlight/moon.yml @@ -0,0 +1,37 @@ +$schema: 'https://moonrepo.dev/schemas/project.json' + +# A Figma plugin, not an npm package, so there is no `publishable` tag and no +# tsdown build: the two Vite passes emit exactly the two files manifest.json +# points at (dist/code.js for the sandbox, dist/ui.html for the iframe). +language: 'typescript' +layer: 'application' + +tasks: + # Two passes because the realms need different output shapes: the sandbox is a + # bare IIFE with no DOM, the UI is one HTML file with its JS and CSS inlined. + build: + script: + 'vite build --config vite.config.code.ts && vite build --config + vite.config.ui.ts' + deps: + - '^:build' + inputs: + - 'src/**/*' + - 'ui.html' + - 'vite.config.*.ts' + - 'tsconfig.json' + outputs: + - 'dist' + + # Both realms watched in parallel; `wait` keeps the task alive until both + # exit. Figma still needs the plugin re-run to pick up a rebuild. + dev: + script: + 'vite build --config vite.config.code.ts --watch & vite build --config + vite.config.ui.ts --watch & wait' + deps: + - '^:build' + options: + cache: false + persistent: true + runInCI: 'skip' diff --git a/packages/diffs-highlight/package.json b/packages/diffs-highlight/package.json new file mode 100644 index 000000000..bde41a109 --- /dev/null +++ b/packages/diffs-highlight/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pierre/diffs-highlight", + "version": "0.0.0", + "private": true, + "description": "Diffs Highlight, a Figma plugin that binds Pierre syntax variables to code in text layers", + "license": "apache-2.0", + "author": "Pierre Computer ", + "repository": { + "type": "git", + "url": "https://github.com/pierrecomputer/pierre.git", + "directory": "packages/diffs-highlight" + }, + "type": "module", + "dependencies": { + "@pierre/theme": "workspace:*", + "@shikijs/core": "catalog:", + "@shikijs/engine-javascript": "catalog:", + "@shikijs/langs": "catalog:", + "shiki": "catalog:" + }, + "devDependencies": { + "@figma/plugin-typings": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-singlefile": "catalog:" + } +} diff --git a/packages/diffs-highlight/src/sandbox/code.ts b/packages/diffs-highlight/src/sandbox/code.ts new file mode 100644 index 000000000..e338d1e7a --- /dev/null +++ b/packages/diffs-highlight/src/sandbox/code.ts @@ -0,0 +1,209 @@ +/** + * The sandbox realm. It owns the `figma` API — selection, variables, fonts — and + * does no tokenizing: it hands the selected layer's text to the UI and applies + * the character ranges the UI sends back. + */ +import type { + CollectionSummary, + SandboxMessage, + SelectionSummary, + TokenBinding, + UiMessage, +} from '../shared/messages'; + +/** Variable-name prefix used to recognize a collection holding Pierre roles. */ +const SYNTAX_PREFIX = 'syntax/'; + +figma.showUI(__html__, { width: 320, height: 320, themeColors: true }); + +function post(message: SandboxMessage): void { + figma.ui.postMessage(message); +} + +function postError(error: unknown): void { + post({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); +} + +/** + * Describes the current selection for the UI, or explains why it cannot be + * used. Only a single non-empty text layer is workable. + */ +function readSelection(): { + selection: SelectionSummary | null; + issue: string | null; +} { + const nodes = figma.currentPage.selection; + + if (nodes.length === 0) { + return { selection: null, issue: 'Select a text layer containing code.' }; + } + if (nodes.length > 1) { + return { selection: null, issue: 'Select a single text layer.' }; + } + + const node = nodes[0]; + if (node === undefined || node.type !== 'TEXT') { + return { + selection: null, + issue: 'The selected layer is not a text layer.', + }; + } + if (node.characters.length === 0) { + return { selection: null, issue: 'The selected text layer is empty.' }; + } + + return { + selection: { + nodeId: node.id, + nodeName: node.name, + characters: node.characters, + }, + issue: null, + }; +} + +/** + * Lists every local collection with its modes, plus how many `syntax/*` + * variables it holds. The semantic collection's name is chosen by whoever + * imported the tokens, so that count is what lets the UI preselect the right + * one instead of guessing from the name. + */ +async function readCollections(): Promise { + const [collections, variables] = await Promise.all([ + figma.variables.getLocalVariableCollectionsAsync(), + figma.variables.getLocalVariablesAsync('COLOR'), + ]); + + const syntaxCounts = new Map(); + for (const variable of variables) { + if (!variable.name.startsWith(SYNTAX_PREFIX)) continue; + const collectionId = variable.variableCollectionId; + syntaxCounts.set(collectionId, (syntaxCounts.get(collectionId) ?? 0) + 1); + } + + return collections.map((collection) => ({ + id: collection.id, + name: collection.name, + modeNames: collection.modes.map((mode) => mode.name), + syntaxVariableCount: syntaxCounts.get(collection.id) ?? 0, + })); +} + +async function sendState(): Promise { + const { selection, issue } = readSelection(); + post({ + type: 'state', + collections: await readCollections(), + selection, + issue, + }); +} + +/** Color variables of one collection, keyed by their full name. */ +async function readVariablesByName( + collectionId: string +): Promise> { + const variables = await figma.variables.getLocalVariablesAsync('COLOR'); + const byName = new Map(); + for (const variable of variables) { + if (variable.variableCollectionId === collectionId) { + byName.set(variable.name, variable); + } + } + return byName; +} + +/** `setRangeFills` throws unless every font in the layer is loaded first. */ +async function loadFonts(node: TextNode): Promise { + const fonts = node.getRangeAllFontNames(0, node.characters.length); + await Promise.all(fonts.map((font) => figma.loadFontAsync(font))); +} + +/** + * Rewrites one character range's fill so its color comes from `variable`. + * + * The range's current solid paint is reused when there is one, which keeps + * properties like opacity; a range with mixed or non-solid fills falls back to + * an opaque paint, since the variable supplies the color either way. + */ +function bindRange( + node: TextNode, + binding: TokenBinding, + variable: Variable +): void { + const fills = node.getRangeFills(binding.start, binding.end); + const existing = + fills === figma.mixed + ? undefined + : fills.find((paint): paint is SolidPaint => paint.type === 'SOLID'); + + const paint: SolidPaint = existing ?? { + type: 'SOLID', + color: { r: 0, g: 0, b: 0 }, + }; + + node.setRangeFills(binding.start, binding.end, [ + figma.variables.setBoundVariableForPaint(paint, 'color', variable), + ]); +} + +async function applyBindings( + request: Extract +): Promise { + const node = await figma.getNodeByIdAsync(request.nodeId); + if (node === null || node.type !== 'TEXT') { + post({ type: 'error', message: 'That text layer no longer exists.' }); + return; + } + + // Bindings are ordered, so the last one's end is the highest offset needed. + // Checking it once catches a layer edited between tokenizing and applying, + // which would otherwise make every out-of-bounds range throw. + const lastBinding = request.bindings[request.bindings.length - 1]; + if (lastBinding !== undefined && lastBinding.end > node.characters.length) { + post({ + type: 'error', + message: 'The text changed since it was tokenized. Run it again.', + }); + return; + } + + const variables = await readVariablesByName(request.collectionId); + await loadFonts(node); + + const missingVariableNames = new Set(); + let boundRanges = 0; + + for (const binding of request.bindings) { + const variable = variables.get(binding.variableName); + if (variable === undefined) { + missingVariableNames.add(binding.variableName); + continue; + } + bindRange(node, binding, variable); + boundRanges += 1; + } + + post({ + type: 'applied', + boundRanges, + missingVariableNames: [...missingVariableNames].sort(), + }); +} + +figma.ui.onmessage = (message: UiMessage) => { + if (message.type === 'cancel') { + figma.closePlugin(); + return; + } + applyBindings(message).catch(postError); +}; + +figma.on('selectionchange', () => { + sendState().catch(postError); +}); + +sendState().catch(postError); diff --git a/packages/diffs-highlight/src/shared/mapTokens.ts b/packages/diffs-highlight/src/shared/mapTokens.ts new file mode 100644 index 000000000..f7a7926ab --- /dev/null +++ b/packages/diffs-highlight/src/shared/mapTokens.ts @@ -0,0 +1,93 @@ +import type { TokenBinding } from './messages'; +import type { RoleIndex } from './roleIndex'; + +/** + * The part of Shiki's `ThemedToken` this module needs. Declared structurally so + * the mapping stays a pure function that tests can call with plain objects. + */ +export interface MappableToken { + /** Start offset relative to the whole input, 0-indexed. */ + offset: number; + content: string; + color?: string; +} + +export interface MapTokensResult { + bindings: TokenBinding[]; + /** Ranges left untouched because no role claimed their color. */ + unmatchedRanges: number; + /** The distinct unmatched colors, sorted, for the UI summary. */ + unmatchedColors: string[]; +} + +const WHITESPACE_ONLY = /^\s*$/; + +/** + * Normalizes a Shiki color to the lowercase `#rrggbb` form the role index is + * keyed by, dropping a fully opaque `ff` alpha suffix if the theme carries one. + */ +function normalizeColor(color: string): string { + const lower = color.toLowerCase(); + return lower.length === 9 && lower.endsWith('ff') ? lower.slice(0, 7) : lower; +} + +/** + * Turns tokenized lines into the character ranges the sandbox should bind. + * + * Two reductions keep the sandbox's per-range `setRangeFills` calls down, since + * that is the expensive side of the plugin: + * + * - Whitespace-only tokens are skipped. They draw no glyph, so a fill on them + * is invisible. + * - Neighbouring ranges that resolve to the same variable are merged, but only + * when they actually touch (`previous.end === token.offset`). A skipped token + * or the newline between two lines breaks that adjacency, so merging can + * never silently swallow a range it should have left alone. + */ +export function mapTokens( + lines: readonly (readonly MappableToken[])[], + index: RoleIndex +): MapTokensResult { + const bindings: TokenBinding[] = []; + const unmatchedColors = new Set(); + let unmatchedRanges = 0; + + for (const line of lines) { + for (const token of line) { + if (token.content.length === 0) continue; + if (WHITESPACE_ONLY.test(token.content)) continue; + + if (token.color === undefined) { + unmatchedRanges += 1; + continue; + } + + const color = normalizeColor(token.color); + const variableName = index.get(color); + if (variableName === undefined) { + unmatchedRanges += 1; + unmatchedColors.add(color); + continue; + } + + const end = token.offset + token.content.length; + const previous = bindings[bindings.length - 1]; + if ( + previous !== undefined && + previous.variableName === variableName && + previous.end === token.offset + ) { + previous.end = end; + continue; + } + + bindings.push({ start: token.offset, end, variableName }); + } + } + + return { + bindings, + unmatchedRanges, + unmatchedColors: [...unmatchedColors].sort(), + }; +} diff --git a/packages/diffs-highlight/src/shared/messages.ts b/packages/diffs-highlight/src/shared/messages.ts new file mode 100644 index 000000000..44348291c --- /dev/null +++ b/packages/diffs-highlight/src/shared/messages.ts @@ -0,0 +1,66 @@ +/** + * The message contract between the two Figma realms. The sandbox + * (`src/sandbox/code.ts`) owns the document and the variables; the UI iframe + * (`src/ui/main.ts`) owns Shiki and the controls. Neither can call into the + * other directly, so every exchange goes through `postMessage` with one of the + * shapes below. + */ + +/** One character range of a text layer, to be bound to one Figma variable. */ +export interface TokenBinding { + /** Character index of the first character, 0-indexed. */ + start: number; + /** Character index one past the last character. */ + end: number; + /** Figma variable name inside the chosen collection, e.g. `syntax/keyword`. */ + variableName: string; +} + +/** A local variable collection the user can target, as offered in the picker. */ +export interface CollectionSummary { + id: string; + name: string; + /** Mode names, shown so the user can tell the semantic collection apart. */ + modeNames: string[]; + /** How many of the collection's variables are named `syntax/*`. */ + syntaxVariableCount: number; +} + +/** The selected text layer, if the current selection is usable. */ +export interface SelectionSummary { + nodeId: string; + nodeName: string; + characters: string; +} + +export type SandboxMessage = + /** + * Sent on startup and again whenever the selection changes, so the UI never + * has to ask. `issue` explains why `selection` is null when it is. + */ + | { + type: 'state'; + collections: CollectionSummary[]; + selection: SelectionSummary | null; + issue: string | null; + } + | { + type: 'applied'; + /** Character ranges that received a bound variable. */ + boundRanges: number; + /** + * Variable names the mapping asked for that the chosen collection does + * not contain — the sign of a wrong collection or a partial import. + */ + missingVariableNames: string[]; + } + | { type: 'error'; message: string }; + +export type UiMessage = + | { + type: 'apply'; + nodeId: string; + collectionId: string; + bindings: TokenBinding[]; + } + | { type: 'cancel' }; diff --git a/packages/diffs-highlight/src/shared/roleIndex.ts b/packages/diffs-highlight/src/shared/roleIndex.ts new file mode 100644 index 000000000..c1a270572 --- /dev/null +++ b/packages/diffs-highlight/src/shared/roleIndex.ts @@ -0,0 +1,96 @@ +import darkTokens from '@pierre/theme/figma/semantic/dark.json'; + +/** + * Maps a resolved token color back to the Figma variable that produced it. + * + * Shiki hands us colors, but binding a variable needs a name (`syntax/keyword`). + * All eight Pierre variants share one scope table and differ only in resolved + * color, so the role a color stands for can be recovered from any single + * variant and then bound by name — Figma resolves the actual color per mode. + * `pierre-dark` is the variant used for that lookup (its 18 `syntax/*` colors + * are all distinct, unlike the tritanopia variants where five collapse into + * one), which is why the index is built from the committed `dark.json`. + */ +export type RoleIndex = ReadonlyMap; + +/** Shiki theme name whose colors the index is keyed by. */ +export const PROBE_THEME_NAME = 'pierre-dark'; + +/** + * Groups scanned when a color is not a `syntax/*` color, in the order they are + * scanned. `bg` and `border` are left out entirely: they are surface colors + * that never apply to glyphs, and including them would let a background color + * win over the foreground role that shares its hex. + */ +const FALLBACK_GROUPS = ['states', 'accent', 'ansi', 'fg'] as const; + +const SYNTAX_GROUP = 'syntax'; + +interface ColorToken { + $value: { hex: string }; +} + +interface TokenGroup { + [key: string]: ColorToken | TokenGroup; +} + +function isColorToken(node: ColorToken | TokenGroup): node is ColorToken { + return '$value' in node; +} + +/** + * Walks one group of the token document and records `hex -> variableName` for + * every color token it contains, without overwriting a hex already claimed by + * an earlier (higher priority) group. + */ +function indexGroup( + into: Map, + group: TokenGroup, + path: string[] +): void { + for (const [key, node] of Object.entries(group)) { + const nextPath = [...path, key]; + if (isColorToken(node)) { + const hex = node.$value.hex.toLowerCase(); + if (!into.has(hex)) into.set(hex, nextPath.join('/')); + continue; + } + indexGroup(into, node, nextPath); + } +} + +/** + * Builds the color-to-variable-name index from a semantic token document. + * + * `baseForegroundHex` is the probe theme's `editor.foreground`, which Shiki + * emits for any text no grammar scope claims. That color is also `syntax/invalid` + * in every Pierre variant, so it is resolved last and unconditionally to + * `fg/base`: plain code is common, invalid code is not, and mapping every + * unscoped character onto `syntax/invalid` would tint whole samples wrong. + */ +export function buildRoleIndex( + tokens: TokenGroup, + baseForegroundHex: string +): RoleIndex { + const index = new Map(); + + const syntaxGroup = tokens[SYNTAX_GROUP]; + if (syntaxGroup !== undefined && !isColorToken(syntaxGroup)) { + indexGroup(index, syntaxGroup, [SYNTAX_GROUP]); + } + + for (const groupName of FALLBACK_GROUPS) { + const group = tokens[groupName]; + if (group === undefined || isColorToken(group)) continue; + indexGroup(index, group, [groupName]); + } + + index.set(baseForegroundHex.toLowerCase(), 'fg/base'); + + return index; +} + +/** The index for `pierre-dark`, built from the committed token export. */ +export function createRoleIndex(baseForegroundHex: string): RoleIndex { + return buildRoleIndex(darkTokens as unknown as TokenGroup, baseForegroundHex); +} diff --git a/packages/diffs-highlight/src/ui/highlight.ts b/packages/diffs-highlight/src/ui/highlight.ts new file mode 100644 index 000000000..166c3b094 --- /dev/null +++ b/packages/diffs-highlight/src/ui/highlight.ts @@ -0,0 +1,120 @@ +/** + * Shiki setup for the UI realm: the language list, the probe theme, and the step + * from source text to the character ranges the sandbox binds. Kept free of DOM + * access so it can be exercised directly by tests. + */ +import probeTheme from '@pierre/theme/pierre-dark'; +import { + createHighlighterCore, + type HighlighterCore, + type ThemeRegistrationRaw, +} from '@shikijs/core'; +import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'; +import { bundledLanguagesInfo } from 'shiki/langs'; + +import { mapTokens, type MapTokensResult } from '../shared/mapTokens'; +import { createRoleIndex, PROBE_THEME_NAME } from '../shared/roleIndex'; + +export interface Language { + /** Shiki language id, e.g. `typescript`. */ + id: string; + /** Display name from Shiki's bundle, e.g. `TypeScript`. */ + label: string; +} + +/** + * Every language Shiki bundles, sorted by display name for the picker. + * + * The grammars behind them are reached through `bundledLanguagesInfo`'s + * `import()` getters. Those are still statically bundled — the UI is one + * self-contained HTML file and Figma loads no external resources, so there is + * nothing to fetch at runtime — but going through the getters means a grammar is + * only *parsed into a registry* when a user picks it, which keeps startup flat + * regardless of how many languages exist. + */ +export const LANGUAGES: Language[] = bundledLanguagesInfo + .map((info) => ({ id: info.id, label: info.name })) + .sort((a, b) => a.label.localeCompare(b.label)); + +/** + * Grammar loaders keyed by id *and* by alias, so `bash` resolves as readily as + * `shellscript`. Shiki's aliases are unambiguous — none collides with an id and + * none is claimed by two languages — so flattening them into one map is safe. + */ +const LANGUAGE_IMPORTS = new Map( + bundledLanguagesInfo.flatMap((info) => + [info.id, ...(info.aliases ?? [])].map((key) => [key, info.import] as const) + ) +); + +/** + * Shiki's theme type is mutable while `@pierre/theme` exports a frozen readonly + * object; the shapes are otherwise identical, so one cast at the boundary keeps + * the rest of the module honest. + */ +const theme = probeTheme as unknown as ThemeRegistrationRaw; + +// Shiki paints text that no grammar scope claims with `editor.foreground`, which +// is why the index needs it; roleIndex.ts explains what it resolves to. +const roleIndex = createRoleIndex(probeTheme.colors['editor.foreground']); + +/** Highlighters that have had the one-time warm-up described below. */ +const warmed = new WeakSet(); + +export function createProbeHighlighter(): Promise { + return createHighlighterCore({ + themes: [theme], + langs: [], + // The JavaScript engine avoids WebAssembly, which the plugin sandbox cannot + // load. It is the same engine @pierre/diffs defaults to. + engine: createJavaScriptRegexEngine(), + }); +} + +/** + * Registers `lang`'s grammar if it is not registered yet, then makes sure the + * highlighter has been warmed up. + * + * The warm-up exists because a 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, so this does not repeat per language. + */ +async function ensureLanguage( + highlighter: HighlighterCore, + lang: string +): Promise { + if (!highlighter.getLoadedLanguages().includes(lang)) { + const load = LANGUAGE_IMPORTS.get(lang); + if (load === undefined) throw new Error(`Unknown language: ${lang}`); + await highlighter.loadLanguage(load); + } + + if (!warmed.has(highlighter)) { + highlighter.codeToTokens('x', { lang, theme: PROBE_THEME_NAME }); + warmed.add(highlighter); + } +} + +/** + * Tokenizes `characters` from a Figma text layer and resolves each token to the + * Figma variable that should color it. + * + * Figma stores a soft line break as U+2028. Swapping in a newline is + * character-for-character, so Shiki's token offsets still line up with the + * layer's own character indices and the ranges can be applied as-is. + */ +export async function highlightToBindings( + highlighter: HighlighterCore, + characters: string, + lang: string +): Promise { + await ensureLanguage(highlighter, lang); + + const { tokens } = highlighter.codeToTokens( + characters.replaceAll('\u2028', '\n'), + { lang, theme: PROBE_THEME_NAME } + ); + return mapTokens(tokens, roleIndex); +} diff --git a/packages/diffs-highlight/src/ui/main.ts b/packages/diffs-highlight/src/ui/main.ts new file mode 100644 index 000000000..ada1af08d --- /dev/null +++ b/packages/diffs-highlight/src/ui/main.ts @@ -0,0 +1,241 @@ +/** + * The UI realm's controls. It never touches the document: it receives the + * selected layer's text from the sandbox, hands it to Shiki (see highlight.ts), + * and sends the resulting character ranges back for binding. + */ +import type { HighlighterCore } from '@shikijs/core'; + +import type { MapTokensResult } from '../shared/mapTokens'; +import type { + CollectionSummary, + SandboxMessage, + SelectionSummary, + UiMessage, +} from '../shared/messages'; +import { + createProbeHighlighter, + highlightToBindings, + LANGUAGES, +} from './highlight'; + +function requireElement(id: string): T { + const element = document.getElementById(id); + if (element === null) throw new Error(`Missing #${id} in the plugin UI`); + return element as T; +} + +const elements = { + target: requireElement('target'), + language: requireElement('language'), + collection: requireElement('collection'), + status: requireElement('status'), + apply: requireElement('apply'), + cancel: requireElement('cancel'), +}; + +/** What the picker opens on, since the full list is alphabetical. */ +const DEFAULT_LANGUAGE = 'typescript'; + +let collections: CollectionSummary[] = []; +let selection: SelectionSummary | null = null; +/** Kept from the last run so the sandbox's report can be summarized with it. */ +let lastMapping: MapTokensResult | null = null; + +/** + * Created at startup, with no grammars registered yet — the picked language's + * grammar is registered on demand inside `highlightToBindings`. + */ +const highlighterReady: Promise = createProbeHighlighter(); + +function post(message: UiMessage): void { + parent.postMessage({ pluginMessage: message }, '*'); +} + +function setStatus(text: string, tone: 'info' | 'error' = 'info'): void { + elements.status.textContent = text; + elements.status.dataset.tone = tone; +} + +/** + * Fills the language picker with every language Shiki bundles, alphabetically. + * The list is long, so the select relies on the browser's built-in type-ahead; + * it opens on TypeScript rather than whatever sorts first. + */ +function fillLanguages(): void { + for (const language of LANGUAGES) { + const option = document.createElement('option'); + option.value = language.id; + option.textContent = language.label; + elements.language.append(option); + } + + const preferred = LANGUAGES.some( + (language) => language.id === DEFAULT_LANGUAGE + ) + ? DEFAULT_LANGUAGE + : LANGUAGES[0]?.id; + if (preferred !== undefined) elements.language.value = preferred; +} + +/** + * Rebuilds the collection picker, keeping the user's choice if that collection + * still exists and otherwise defaulting to the collection holding the most + * `syntax/*` variables — the semantic collection, whatever it was named at + * import time. + */ +function fillCollections(): void { + const previous = elements.collection.value; + elements.collection.replaceChildren(); + + for (const collection of collections) { + const option = document.createElement('option'); + option.value = collection.id; + option.textContent = `${collection.name} (${collection.modeNames.join(', ')})`; + elements.collection.append(option); + } + + const stillPresent = collections.some( + (collection) => collection.id === previous + ); + if (stillPresent) { + elements.collection.value = previous; + return; + } + + let best = collections[0]; + for (const collection of collections) { + if ( + best !== undefined && + collection.syntaxVariableCount > best.syntaxVariableCount + ) { + best = collection; + } + } + if (best !== undefined) elements.collection.value = best.id; +} + +function render(issue: string | null): void { + const hasSyntaxVariables = collections.some( + (collection) => collection.syntaxVariableCount > 0 + ); + + elements.collection.disabled = collections.length === 0; + elements.apply.disabled = selection === null || collections.length === 0; + + if (selection !== null) { + elements.target.textContent = `Selected: ${selection.nodeName}`; + elements.target.dataset.issue = 'false'; + } else { + elements.target.textContent = + issue ?? 'Select a text layer containing code.'; + elements.target.dataset.issue = 'true'; + } + + if (collections.length === 0) { + setStatus( + 'This file has no local variable collections. Import the Pierre tokens first.', + 'error' + ); + } else if (!hasSyntaxVariables) { + setStatus( + 'No collection has syntax/* variables. Import packages/theme/figma/semantic into this file.', + 'error' + ); + } else if (lastMapping === null) { + // Clears an environment warning that no longer holds, without wiping the + // summary from a run that already happened. + setStatus(''); + } +} + +/** Tokenizes the selected layer and asks the sandbox to bind the result. */ +async function apply(): Promise { + const target = selection; + const collectionId = elements.collection.value; + if (target === null || collectionId === '') return; + + elements.apply.disabled = true; + setStatus('Tokenizing…'); + + try { + const highlighter = await highlighterReady; + const mapping = await highlightToBindings( + highlighter, + target.characters, + elements.language.value + ); + lastMapping = mapping; + + if (mapping.bindings.length === 0) { + setStatus('Nothing to bind: no token matched a Pierre role.', 'error'); + elements.apply.disabled = false; + return; + } + + setStatus(`Binding ${String(mapping.bindings.length)} ranges…`); + post({ + type: 'apply', + nodeId: target.nodeId, + collectionId, + bindings: mapping.bindings, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setStatus(`Highlighting failed: ${message}`, 'error'); + elements.apply.disabled = false; + } +} + +function summarize(boundRanges: number, missingVariableNames: string[]): void { + const lines = [`Bound ${String(boundRanges)} ranges.`]; + + if (lastMapping !== null && lastMapping.unmatchedRanges > 0) { + lines.push( + `Left ${String(lastMapping.unmatchedRanges)} ranges alone (colors with no Pierre role: ${lastMapping.unmatchedColors.join(', ')}).` + ); + } + if (missingVariableNames.length > 0) { + lines.push( + `Missing from the chosen collection: ${missingVariableNames.join(', ')}.` + ); + } + + setStatus( + lines.join('\n'), + missingVariableNames.length > 0 ? 'error' : 'info' + ); +} + +function handleMessage(message: SandboxMessage): void { + switch (message.type) { + case 'state': + collections = message.collections; + selection = message.selection; + fillCollections(); + render(message.issue); + return; + case 'applied': + elements.apply.disabled = false; + summarize(message.boundRanges, message.missingVariableNames); + return; + case 'error': + elements.apply.disabled = false; + setStatus(message.message, 'error'); + return; + } +} + +fillLanguages(); + +elements.apply.addEventListener('click', () => { + void apply(); +}); +elements.cancel.addEventListener('click', () => { + post({ type: 'cancel' }); +}); + +window.addEventListener('message', (event: MessageEvent) => { + const message = (event.data as { pluginMessage?: SandboxMessage }) + .pluginMessage; + if (message !== undefined) handleMessage(message); +}); diff --git a/packages/diffs-highlight/src/ui/ui.css b/packages/diffs-highlight/src/ui/ui.css new file mode 100644 index 000000000..df9c85dc3 --- /dev/null +++ b/packages/diffs-highlight/src/ui/ui.css @@ -0,0 +1,113 @@ +/* + * Figma injects --figma-color-* custom properties into every plugin iframe and + * updates them when the editor's own theme changes, so the panel follows light + * and dark Figma without a media query. Fallbacks cover the case of an older + * client that does not inject them. + */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: + Inter, + -apple-system, + blinkmacsystemfont, + 'Segoe UI', + roboto, + sans-serif; + font-size: 11px; + line-height: 16px; + color: var(--figma-color-text, #1e1e1e); + background: var(--figma-color-bg, #fff); +} + +.panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; +} + +.target { + margin: 0; + color: var(--figma-color-text-secondary, #5b5b5b); +} + +.target[data-issue='true'] { + color: var(--figma-color-text-danger, #b3261e); +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +label { + font-weight: 600; +} + +select { + width: 100%; + height: 32px; + padding: 0 8px; + font-family: inherit; + font-size: inherit; + color: var(--figma-color-text, #1e1e1e); + background: var(--figma-color-bg-secondary, #f5f5f5); + border: 1px solid var(--figma-color-border, #e6e6e6); + border-radius: 6px; +} + +select:disabled { + color: var(--figma-color-text-disabled, #b3b3b3); +} + +.status { + min-height: 16px; + margin: 0; + color: var(--figma-color-text-secondary, #5b5b5b); + white-space: pre-line; +} + +.status[data-tone='error'] { + color: var(--figma-color-text-danger, #b3261e); +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +button { + height: 32px; + padding: 0 12px; + font-family: inherit; + font-size: inherit; + font-weight: 600; + border: 1px solid transparent; + border-radius: 6px; +} + +button:not(:disabled) { + cursor: pointer; +} + +button:disabled { + opacity: 0.4; +} + +.primary { + color: var(--figma-color-text-onbrand, #fff); + background: var(--figma-color-bg-brand, #0d99ff); +} + +.secondary { + color: var(--figma-color-text, #1e1e1e); + background: var(--figma-color-bg, #fff); + border-color: var(--figma-color-border, #e6e6e6); +} diff --git a/packages/diffs-highlight/test/highlight.test.ts b/packages/diffs-highlight/test/highlight.test.ts new file mode 100644 index 000000000..5d9ddebbe --- /dev/null +++ b/packages/diffs-highlight/test/highlight.test.ts @@ -0,0 +1,174 @@ +import type { HighlighterCore } from '@shikijs/core'; +/** + * Guards the tokenizing side of the plugin, where the standing risk is Shiki's + * JavaScript regex engine: the plugin sandbox cannot load WebAssembly, so a + * grammar that only works under Oniguruma would fail at runtime in Figma with no + * other warning. Since the picker offers every bundled language, that has to be + * checked across all of them, not just the popular ones. + */ +import { beforeAll, describe, expect, test } from 'bun:test'; + +import { + createProbeHighlighter, + highlightToBindings, + LANGUAGES, +} from '../src/ui/highlight'; + +/** + * Languages spot-checked for real role variety, with a snippet each. These are + * the ones the plugin is most likely used on; the sweep below covers the rest. + */ +const SAMPLES: Record = { + typescript: 'const enabled: boolean = true; // note\nexport function go() {}', + tsx: 'const App = () =>
{name}
;', + javascript: 'let total = 0;\nfor (const x of xs) total += x; // sum', + jsx: 'export default () =>

{count}

;', + python: 'def go(name: str) -> int:\n return len(name) # count', + go: 'package main\n\nfunc main() { println("hi") }', + rust: 'fn main() {\n let x: u32 = 1; // one\n}', + bash: 'set -euo pipefail\necho "$HOME" # home', + json: '{ "name": "pierre", "count": 2 }', + html: '
hi
', + css: '.a { color: #fff; /* note */ }', + markdown: '# Title\n\nSome `code` and a [link](https://pierre.co).', +}; + +/** Generic enough to exercise comments, strings, numbers, and punctuation. */ +const SWEEP_SAMPLE = `# comment line +const x = "string" /* 1.5 */ +function f(a, b) { return a + b; } +text +[section] +key: value`; + +let highlighter: HighlighterCore; + +beforeAll(async () => { + highlighter = await createProbeHighlighter(); +}); + +describe('the language list', () => { + test('offers Shiki’s full bundle, sorted by display name', () => { + expect(LANGUAGES.length).toBeGreaterThan(200); + + const labels = LANGUAGES.map((language) => language.label); + expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b))); + + const ids = LANGUAGES.map((language) => language.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test('lists canonical ids only, leaving aliases out of the picker', () => { + const ids = LANGUAGES.map((language) => language.id); + // `bash` is an alias of `shellscript`; only the latter belongs in the list. + expect(ids).toContain('shellscript'); + expect(ids).not.toContain('bash'); + }); +}); + +describe('spot-checked languages', () => { + for (const [id, sample] of Object.entries(SAMPLES)) { + test(`${id} resolves to several distinct roles`, async () => { + const result = await highlightToBindings(highlighter, sample, id); + + expect(result.bindings.length).toBeGreaterThan(0); + // A grammar the engine cannot run yields one flat unscoped range, so + // several distinct roles is the signal that it really tokenized. + const roles = new Set( + result.bindings.map((binding) => binding.variableName) + ); + expect(roles.size).toBeGreaterThan(1); + // Every color the probe theme produces must be a known Pierre role. + expect(result.unmatchedColors).toEqual([]); + }); + } +}); + +describe('every bundled language', () => { + test('loads and tokenizes under the JavaScript regex engine', async () => { + const failures: string[] = []; + + for (const language of LANGUAGES) { + try { + await highlightToBindings(highlighter, SWEEP_SAMPLE, language.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(`${language.id}: ${message.split('\n')[0] ?? ''}`); + } + } + + expect(failures).toEqual([]); + }, 120_000); +}); + +describe('highlightToBindings', () => { + test('accepts an alias as readily as its canonical id', async () => { + const code = 'echo "$HOME" # home'; + const viaAlias = await highlightToBindings(highlighter, code, 'bash'); + const viaId = await highlightToBindings(highlighter, code, 'shellscript'); + + expect(viaAlias.bindings.length).toBeGreaterThan(0); + expect(viaAlias.bindings).toEqual(viaId.bindings); + }); + + test('rejects a language that is not in the bundle', async () => { + let message = ''; + try { + await highlightToBindings(highlighter, 'x = 1', 'not-a-language'); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toBe('Unknown language: not-a-language'); + }); + + test('keeps offsets aligned with Figma soft line breaks', async () => { + // U+2028 is how Figma stores a soft line break. Swapping in a newline has to + // leave every offset where it was, so the two identical lines below must + // produce the same role at the same distance into each line. + const code = 'const a = 1;\u2028const b = 2;'; + const secondLineStart = code.indexOf('const', 1); + const result = await highlightToBindings(highlighter, code, 'typescript'); + + const firstDeclaration = result.bindings.find( + (binding) => binding.start === 0 + ); + expect(firstDeclaration?.end).toBe('const'.length); + expect( + result.bindings.find((binding) => binding.start === secondLineStart) + ).toEqual({ + start: secondLineStart, + end: secondLineStart + 'const'.length, + variableName: firstDeclaration?.variableName ?? '', + }); + }); + + test('separates the number from the semicolon on its very first call', async () => { + // The warm-up in ensureLanguage exists for exactly this: without it, a fresh + // highlighter's first tokenization returns `1;` as a single token. + const fresh = await createProbeHighlighter(); + const result = await highlightToBindings( + fresh, + 'const a = 1;', + 'typescript' + ); + const last = result.bindings[result.bindings.length - 1]; + + expect(last).toEqual({ + start: 11, + end: 12, + variableName: 'syntax/punctuation', + }); + }); + + test('produces ranges inside the layer text', async () => { + const code = SAMPLES.typescript ?? ''; + const result = await highlightToBindings(highlighter, code, 'typescript'); + + for (const binding of result.bindings) { + expect(binding.start).toBeGreaterThanOrEqual(0); + expect(binding.end).toBeLessThanOrEqual(code.length); + expect(binding.end).toBeGreaterThan(binding.start); + } + }); +}); diff --git a/packages/diffs-highlight/test/mapTokens.test.ts b/packages/diffs-highlight/test/mapTokens.test.ts new file mode 100644 index 000000000..f4c89037a --- /dev/null +++ b/packages/diffs-highlight/test/mapTokens.test.ts @@ -0,0 +1,165 @@ +/** + * Covers the two pure pieces the plugin's correctness rests on: recovering a + * role name from a resolved color (`roleIndex`) and turning tokens into the + * character ranges the sandbox binds (`mapTokens`). + */ +import { describe, expect, test } from 'bun:test'; + +import { type MappableToken, mapTokens } from '../src/shared/mapTokens'; +import { createRoleIndex } from '../src/shared/roleIndex'; + +/** `pierre-dark`'s `editor.foreground`, which is also `fg/base`. */ +const BASE_FOREGROUND = '#fafafa'; + +const index = createRoleIndex(BASE_FOREGROUND); + +/** Builds a single line of tokens with offsets derived from the contents. */ +function line( + tokens: { content: string; color?: string }[] +): MappableToken[][] { + let offset = 0; + return [ + tokens.map((token) => { + const mapped = { ...token, offset }; + offset += token.content.length; + return mapped; + }), + ]; +} + +describe('createRoleIndex', () => { + test('every syntax role resolves back to its own variable name', () => { + // The probe theme is only usable if its syntax colors are distinct, so this + // asserts the property the whole design depends on rather than a sample. + const syntaxNames = [...index.values()].filter((name) => + name.startsWith('syntax/') + ); + expect(syntaxNames.length).toBe(17); + expect(new Set(syntaxNames).size).toBe(syntaxNames.length); + }); + + test('syntax roles win over fg roles that share their color', () => { + // #737373 is both fg/fg3 and syntax/comment in pierre-dark. + expect(index.get('#737373')).toBe('syntax/comment'); + // #636363 is both fg/fg4 and syntax/punctuation. + expect(index.get('#636363')).toBe('syntax/punctuation'); + // #a3a3a3 is both fg/fg2 and syntax/parameter. + expect(index.get('#a3a3a3')).toBe('syntax/parameter'); + }); + + test('editor.foreground maps to fg/base, not the syntax/invalid it shares', () => { + expect(index.get(BASE_FOREGROUND)).toBe('fg/base'); + }); + + test('states are preferred over the ansi colors they share', () => { + // #08c0ef is states/info, syntax/operator, and four ansi cyans; syntax wins. + expect(index.get('#08c0ef')).toBe('syntax/operator'); + // #ff2e3f is states/danger and the ansi reds, with no syntax claim. + expect(index.get('#ff2e3f')).toBe('states/danger'); + }); + + test('background and border colors are not indexed as text roles', () => { + // #1d1d1d is only ever bg/inset and border/*, so nothing should claim it. + expect(index.get('#1d1d1d')).toBeUndefined(); + }); +}); + +describe('mapTokens', () => { + test('maps token colors to variable-bound ranges', () => { + const result = mapTokens( + line([ + { content: 'const', color: '#ff678d' }, + { content: ' ', color: '#fafafa' }, + { content: 'x', color: '#fafafa' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 5, variableName: 'syntax/keyword' }, + { start: 6, end: 7, variableName: 'fg/base' }, + ]); + expect(result.unmatchedRanges).toBe(0); + }); + + test('merges touching ranges that resolve to the same variable', () => { + const result = mapTokens( + line([ + { content: '(', color: '#636363' }, + { content: ')', color: '#636363' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 2, variableName: 'syntax/punctuation' }, + ]); + }); + + test('does not merge across a skipped whitespace token', () => { + const result = mapTokens( + line([ + { content: 'a', color: '#636363' }, + { content: ' ', color: '#636363' }, + { content: 'b', color: '#636363' }, + ]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 1, variableName: 'syntax/punctuation' }, + { start: 3, end: 4, variableName: 'syntax/punctuation' }, + ]); + }); + + test('does not merge across the newline between two lines', () => { + // Offsets skip the newline, so line-final and line-initial ranges of the + // same role must stay separate. + const result = mapTokens( + [ + [{ offset: 0, content: 'a', color: '#636363' }], + [{ offset: 2, content: 'b', color: '#636363' }], + ], + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 1, variableName: 'syntax/punctuation' }, + { start: 2, end: 3, variableName: 'syntax/punctuation' }, + ]); + }); + + test('counts unmatched colors and leaves their ranges out', () => { + const result = mapTokens( + line([ + { content: 'x', color: '#123456' }, + { content: 'y', color: '#123456' }, + { content: 'z', color: '#abcdef' }, + ]), + index + ); + + expect(result.bindings).toEqual([]); + expect(result.unmatchedRanges).toBe(3); + expect(result.unmatchedColors).toEqual(['#123456', '#abcdef']); + }); + + test('treats a token with no color as unmatched', () => { + const result = mapTokens(line([{ content: 'x' }]), index); + + expect(result.bindings).toEqual([]); + expect(result.unmatchedRanges).toBe(1); + expect(result.unmatchedColors).toEqual([]); + }); + + test('ignores case and a fully opaque alpha suffix on token colors', () => { + const result = mapTokens( + line([{ content: 'const', color: '#FF678DFF' }]), + index + ); + + expect(result.bindings).toEqual([ + { start: 0, end: 5, variableName: 'syntax/keyword' }, + ]); + }); +}); diff --git a/packages/diffs-highlight/tsconfig.json b/packages/diffs-highlight/tsconfig.json new file mode 100644 index 000000000..23d16d387 --- /dev/null +++ b/packages/diffs-highlight/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.options.json", + "include": ["src/**/*.ts", "test/**/*.ts", "vite.config.*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "composite": false, + "noEmit": true, + "emitDeclarationOnly": false, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["bun", "@figma/plugin-typings"], + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true + } +} diff --git a/packages/diffs-highlight/ui.html b/packages/diffs-highlight/ui.html new file mode 100644 index 000000000..57be221e0 --- /dev/null +++ b/packages/diffs-highlight/ui.html @@ -0,0 +1,34 @@ + + + + + Diffs Highlight + + + +
+

Reading selection…

+ +
+ + +
+ +
+ + +
+ +

+ +
+ + +
+
+ + + + diff --git a/packages/diffs-highlight/vite.config.code.ts b/packages/diffs-highlight/vite.config.code.ts new file mode 100644 index 000000000..ca7d7ab57 --- /dev/null +++ b/packages/diffs-highlight/vite.config.code.ts @@ -0,0 +1,25 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +/** + * Builds the sandbox realm into a single `dist/code.js`. + * + * Figma evaluates that file in a plain JavaScript scope with no module loader + * and no DOM, so the output has to be one IIFE with nothing split out and no + * imports left in it (`lib` mode disables code splitting). This pass runs first + * and owns clearing `dist`; the UI pass writes into the same directory after it. + */ +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2017', + minify: false, + lib: { + entry: resolve(import.meta.dirname, 'src/sandbox/code.ts'), + formats: ['iife'], + name: 'diffsHighlight', + fileName: () => 'code.js', + }, + }, +}); diff --git a/packages/diffs-highlight/vite.config.ui.ts b/packages/diffs-highlight/vite.config.ui.ts new file mode 100644 index 000000000..68aabf8a3 --- /dev/null +++ b/packages/diffs-highlight/vite.config.ui.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import { viteSingleFile } from 'vite-plugin-singlefile'; + +/** + * Builds the UI realm into a single `dist/ui.html`. + * + * Figma hands the UI document to the iframe as a string and loads nothing else, + * so every script and stylesheet has to be inlined, which is what + * `viteSingleFile` does. Vite names an HTML output after its input, so the entry + * is `ui.html` rather than the conventional `index.html` — that is what makes the + * output land at the `dist/ui.html` manifest.json points at. + * + * This pass runs after the sandbox pass and must not wipe its output, hence + * `emptyOutDir: false`. + */ +export default defineConfig({ + plugins: [viteSingleFile()], + build: { + outDir: 'dist', + emptyOutDir: false, + target: 'es2017', + minify: false, + rollupOptions: { + input: 'ui.html', + }, + }, +}); diff --git a/packages/theme/CONTRIBUTING.md b/packages/theme/CONTRIBUTING.md index a32b4abe7..18bcb50ac 100644 --- a/packages/theme/CONTRIBUTING.md +++ b/packages/theme/CONTRIBUTING.md @@ -107,6 +107,13 @@ not in any palette scale fails the build. That is deliberate: it catches roles drifting away from the palette. If a role genuinely needs a standalone literal, add its hex to `UNALIASED_ROLE_COLORS`. +One consumer reads these files directly rather than importing them into Figma: +the [Diffs Highlight](../diffs-highlight/README.md) plugin builds its +color-to-role lookup from `figma/semantic/dark.json`, using the hexes and +variable names side by side. Renaming a role group or changing a `dark` value +changes what that plugin binds, so run `moonx diffs-highlight:test` when either +moves. + ## Scripts | Script | Description | diff --git a/packages/theme/README.md b/packages/theme/README.md index 81489d905..b5cb70bf1 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -56,6 +56,9 @@ Usage: The Vibrant variants are not exported: Figma imports sRGB and HSL only. + To syntax highlight code in a Figma text layer with these variables, see + the Diffs Highlight plugin in packages/diffs-highlight + ``` ## Agent skill diff --git a/packages/theme/package.json b/packages/theme/package.json index 4c91033f5..daca91782 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -26,6 +26,7 @@ "publisher": "pierrecomputer", "files": [ "dist", + "figma", "themes", "icon.png", "LICENSE", @@ -89,7 +90,8 @@ "import": "./dist/pierre-dark-vibrant.mjs", "default": "./dist/pierre-dark-vibrant.mjs" }, - "./themes/*": "./themes/*" + "./themes/*": "./themes/*", + "./figma/*": "./figma/*" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9f6ceda5..b0298828f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@arethetypeswrong/core': specifier: 0.18.2 version: 0.18.2 + '@figma/plugin-typings': + specifier: 1.131.0 + version: 1.131.0 '@icons-pack/react-simple-icons': specifier: 13.8.0 version: 13.8.0 @@ -222,6 +225,9 @@ catalogs: vite: specifier: 8.1.0 version: 8.1.0 + vite-plugin-singlefile: + specifier: 2.3.3 + version: 2.3.3 zod: specifier: 4.1.11 version: 4.1.11 @@ -229,6 +235,7 @@ catalogs: overrides: '@shikijs/core': 4.2.0 '@shikijs/engine-javascript': 4.2.0 + '@shikijs/langs': 4.2.0 '@shikijs/themes': 4.2.0 '@shikijs/transformers': 4.2.0 '@types/react': 19.2.7 @@ -663,6 +670,40 @@ importers: specifier: 'catalog:' version: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + packages/diffs-highlight: + dependencies: + '@pierre/theme': + specifier: workspace:* + version: link:../theme + '@shikijs/core': + specifier: 4.2.0 + version: 4.2.0 + '@shikijs/engine-javascript': + specifier: 4.2.0 + version: 4.2.0 + '@shikijs/langs': + specifier: 4.2.0 + version: 4.2.0 + shiki: + specifier: 4.2.0 + version: 4.2.0 + devDependencies: + '@figma/plugin-typings': + specifier: 'catalog:' + version: 1.131.0 + '@types/bun': + specifier: 'catalog:' + version: 1.3.14 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 'catalog:' + version: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite-plugin-singlefile: + specifier: 'catalog:' + version: 2.3.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)) + packages/path-store: devDependencies: '@pierre/tree-test-data': @@ -1145,6 +1186,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@figma/plugin-typings@1.131.0': + resolution: {integrity: sha512-XBTPNGc65myupitMaeemyiF/ir1jn/EXXTdoGUCEVCfVB6KPQUy+gbfHbnC+QQiHxdw/Naikdx+9v/u4d49gfA==} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -6067,6 +6111,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6697,6 +6751,8 @@ snapshots: tslib: 2.8.1 optional: true + '@figma/plugin-typings@1.131.0': {} + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -11827,6 +11883,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-singlefile@2.3.3(vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + micromatch: 4.0.8 + vite: 8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0) + vite@8.1.0(@types/node@25.9.3)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 75097528a..abbe6f0f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,6 +44,7 @@ minimumReleaseAgeExclude: catalog: '@arethetypeswrong/core': '0.18.2' + '@figma/plugin-typings': '1.131.0' '@icons-pack/react-simple-icons': '13.8.0' '@moonrepo/cli': '2.3.3' '@octokit/app': '16.1.1' @@ -70,6 +71,7 @@ catalog: '@radix-ui/react-use-controllable-state': '1.2.2' '@shikijs/core': '4.2.0' '@shikijs/engine-javascript': '4.2.0' + '@shikijs/langs': '4.2.0' '@shikijs/themes': '4.2.0' '@shikijs/transformers': '4.2.0' '@tailwindcss/postcss': '4.1.13' @@ -133,11 +135,13 @@ catalog: 'typescript': '6.0.3' 'unist-util-visit': '5.0.0' 'vite': '8.1.0' + 'vite-plugin-singlefile': '2.3.3' 'zod': '4.1.11' overrides: '@shikijs/core': 'catalog:' '@shikijs/engine-javascript': 'catalog:' + '@shikijs/langs': 'catalog:' '@shikijs/themes': 'catalog:' '@shikijs/transformers': 'catalog:' '@types/react': 'catalog:' diff --git a/tsconfig.json b/tsconfig.json index 628982f12..0a554da33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,9 @@ { "path": "packages/diffs/tsconfig.json" }, + { + "path": "packages/diffs-highlight/tsconfig.json" + }, { "path": "packages/path-store/tsconfig.json" }, From d87b9021d927f1dfa18de34e44090f368c8aae25 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Tue, 4 Aug 2026 12:10:05 -0700 Subject: [PATCH 2/2] feat(diffs-highlight): Highlight several text layers in one run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/diffs-highlight/README.md | 13 +- packages/diffs-highlight/src/sandbox/code.ts | 118 +++++++++----- .../diffs-highlight/src/shared/messages.ts | 30 ++-- packages/diffs-highlight/src/ui/main.ts | 153 ++++++++++++------ packages/diffs-highlight/src/ui/ui.css | 62 ++++++- packages/diffs-highlight/ui.html | 10 +- 6 files changed, 269 insertions(+), 117 deletions(-) diff --git a/packages/diffs-highlight/README.md b/packages/diffs-highlight/README.md index a5f113431..7e4a4bfeb 100644 --- a/packages/diffs-highlight/README.md +++ b/packages/diffs-highlight/README.md @@ -35,9 +35,12 @@ the semantic collection with one mode per variant. ## Usage -1. Select a single text layer containing code. +1. Select one or more text layers containing code. Non-text layers and empty + text layers in the selection are ignored rather than rejected. 2. Run **Plugins > Development > Diffs Highlight**. 3. Pick the language, confirm the variable collection, and press **Highlight**. + Every selected layer is tokenized as that one language, so group a + multi-layer run by language. 4. Switch that collection's mode on the layer (or its parent frame) to move the sample between Light, Dark, Soft, and the CVD variants. @@ -45,9 +48,11 @@ Every language Shiki bundles is offered — 235 of them, listed alphabetically b display name, so the select relies on type-ahead. The picker opens on TypeScript. -The status line reports what happened: how many ranges were bound, any colors -that matched no Pierre role, and any variable names missing from the collection -you picked (usually the sign of a partial import, or the wrong collection). +The status line reports what happened: how many ranges were bound and across how +many layers, any colors that matched no Pierre role, and any variable names +missing from the collection you picked (usually the sign of a partial import, or +the wrong collection). A layer edited between tokenizing and binding is skipped +and named, rather than costing you the rest of the selection. ## How a color becomes a variable diff --git a/packages/diffs-highlight/src/sandbox/code.ts b/packages/diffs-highlight/src/sandbox/code.ts index e338d1e7a..f01857ad0 100644 --- a/packages/diffs-highlight/src/sandbox/code.ts +++ b/packages/diffs-highlight/src/sandbox/code.ts @@ -1,12 +1,13 @@ /** * The sandbox realm. It owns the `figma` API — selection, variables, fonts — and - * does no tokenizing: it hands the selected layer's text to the UI and applies + * does no tokenizing: it hands the selected layers' text to the UI and applies * the character ranges the UI sends back. */ import type { CollectionSummary, + LayerBindings, SandboxMessage, - SelectionSummary, + SelectionLayer, TokenBinding, UiMessage, } from '../shared/messages'; @@ -28,41 +29,48 @@ function postError(error: unknown): void { } /** - * Describes the current selection for the UI, or explains why it cannot be - * used. Only a single non-empty text layer is workable. + * Describes the selected text layers for the UI, or explains why there is + * nothing to work on. + * + * Any number of text layers can be selected at once. Non-text layers and empty + * text layers are filtered out rather than rejected, so a selection that sweeps + * up a frame or a stray rectangle alongside the code still works. */ function readSelection(): { - selection: SelectionSummary | null; + layers: SelectionLayer[]; issue: string | null; } { const nodes = figma.currentPage.selection; - if (nodes.length === 0) { - return { selection: null, issue: 'Select a text layer containing code.' }; - } - if (nodes.length > 1) { - return { selection: null, issue: 'Select a single text layer.' }; + return { layers: [], issue: 'Select one or more text layers.' }; } - const node = nodes[0]; - if (node === undefined || node.type !== 'TEXT') { - return { - selection: null, - issue: 'The selected layer is not a text layer.', - }; - } - if (node.characters.length === 0) { - return { selection: null, issue: 'The selected text layer is empty.' }; + const textNodes = nodes.filter( + (node): node is TextNode => node.type === 'TEXT' + ); + if (textNodes.length === 0) { + return { layers: [], issue: 'The selection has no text layers.' }; } - return { - selection: { + const layers = textNodes + .filter((node) => node.characters.length > 0) + .map((node) => ({ nodeId: node.id, nodeName: node.name, characters: node.characters, - }, - issue: null, - }; + })); + + if (layers.length === 0) { + return { + layers: [], + issue: + textNodes.length === 1 + ? 'The selected text layer is empty.' + : 'The selected text layers are empty.', + }; + } + + return { layers, issue: null }; } /** @@ -93,11 +101,11 @@ async function readCollections(): Promise { } async function sendState(): Promise { - const { selection, issue } = readSelection(); + const { layers, issue } = readSelection(); post({ type: 'state', collections: await readCollections(), - selection, + layers, issue, }); } @@ -150,34 +158,36 @@ function bindRange( ]); } -async function applyBindings( - request: Extract -): Promise { - const node = await figma.getNodeByIdAsync(request.nodeId); +/** + * Applies one layer's ranges, or returns why it was skipped. + * + * A layer is skipped rather than failing the whole run, so one stale layer in a + * multi-layer selection does not cost the user the others. + */ +async function applyToLayer( + layer: LayerBindings, + variables: Map, + missingVariableNames: Set +): Promise<{ boundRanges: number } | { skipped: string }> { + const node = await figma.getNodeByIdAsync(layer.nodeId); if (node === null || node.type !== 'TEXT') { - post({ type: 'error', message: 'That text layer no longer exists.' }); - return; + return { skipped: `${layer.nodeName}: no longer exists` }; } // Bindings are ordered, so the last one's end is the highest offset needed. // Checking it once catches a layer edited between tokenizing and applying, // which would otherwise make every out-of-bounds range throw. - const lastBinding = request.bindings[request.bindings.length - 1]; + const lastBinding = layer.bindings[layer.bindings.length - 1]; if (lastBinding !== undefined && lastBinding.end > node.characters.length) { - post({ - type: 'error', - message: 'The text changed since it was tokenized. Run it again.', - }); - return; + return { + skipped: `${layer.nodeName}: text changed since it was tokenized`, + }; } - const variables = await readVariablesByName(request.collectionId); await loadFonts(node); - const missingVariableNames = new Set(); let boundRanges = 0; - - for (const binding of request.bindings) { + for (const binding of layer.bindings) { const variable = variables.get(binding.variableName); if (variable === undefined) { missingVariableNames.add(binding.variableName); @@ -187,10 +197,34 @@ async function applyBindings( boundRanges += 1; } + return { boundRanges }; +} + +async function applyBindings( + request: Extract +): Promise { + const variables = await readVariablesByName(request.collectionId); + const missingVariableNames = new Set(); + const skippedLayers: string[] = []; + let boundRanges = 0; + let boundLayers = 0; + + for (const layer of request.layers) { + const result = await applyToLayer(layer, variables, missingVariableNames); + if ('skipped' in result) { + skippedLayers.push(result.skipped); + continue; + } + boundRanges += result.boundRanges; + boundLayers += 1; + } + post({ type: 'applied', boundRanges, + boundLayers, missingVariableNames: [...missingVariableNames].sort(), + skippedLayers, }); } diff --git a/packages/diffs-highlight/src/shared/messages.ts b/packages/diffs-highlight/src/shared/messages.ts index 44348291c..2062e70a6 100644 --- a/packages/diffs-highlight/src/shared/messages.ts +++ b/packages/diffs-highlight/src/shared/messages.ts @@ -20,47 +20,55 @@ export interface TokenBinding { export interface CollectionSummary { id: string; name: string; - /** Mode names, shown so the user can tell the semantic collection apart. */ + /** Mode names, so the UI can tell a multi-mode collection from a single one. */ modeNames: string[]; /** How many of the collection's variables are named `syntax/*`. */ syntaxVariableCount: number; } -/** The selected text layer, if the current selection is usable. */ -export interface SelectionSummary { +/** One selected text layer and the text to tokenize for it. */ +export interface SelectionLayer { nodeId: string; nodeName: string; characters: string; } +/** The ranges to bind on one layer, the result of tokenizing it. */ +export interface LayerBindings { + nodeId: string; + /** Carried along so the sandbox can name the layer if it has to skip it. */ + nodeName: string; + bindings: TokenBinding[]; +} + export type SandboxMessage = /** * Sent on startup and again whenever the selection changes, so the UI never - * has to ask. `issue` explains why `selection` is null when it is. + * has to ask. `layers` is empty when nothing usable is selected, and `issue` + * explains why. */ | { type: 'state'; collections: CollectionSummary[]; - selection: SelectionSummary | null; + layers: SelectionLayer[]; issue: string | null; } | { type: 'applied'; /** Character ranges that received a bound variable. */ boundRanges: number; + /** How many layers those ranges are spread across. */ + boundLayers: number; /** * Variable names the mapping asked for that the chosen collection does * not contain — the sign of a wrong collection or a partial import. */ missingVariableNames: string[]; + /** Layers left untouched, each as `name: reason`. */ + skippedLayers: string[]; } | { type: 'error'; message: string }; export type UiMessage = - | { - type: 'apply'; - nodeId: string; - collectionId: string; - bindings: TokenBinding[]; - } + | { type: 'apply'; collectionId: string; layers: LayerBindings[] } | { type: 'cancel' }; diff --git a/packages/diffs-highlight/src/ui/main.ts b/packages/diffs-highlight/src/ui/main.ts index ada1af08d..93facc80d 100644 --- a/packages/diffs-highlight/src/ui/main.ts +++ b/packages/diffs-highlight/src/ui/main.ts @@ -1,15 +1,15 @@ /** * The UI realm's controls. It never touches the document: it receives the - * selected layer's text from the sandbox, hands it to Shiki (see highlight.ts), - * and sends the resulting character ranges back for binding. + * selected layers' text from the sandbox, hands each one to Shiki (see + * highlight.ts), and sends the resulting character ranges back for binding. */ import type { HighlighterCore } from '@shikijs/core'; -import type { MapTokensResult } from '../shared/mapTokens'; import type { CollectionSummary, + LayerBindings, SandboxMessage, - SelectionSummary, + SelectionLayer, UiMessage, } from '../shared/messages'; import { @@ -25,7 +25,7 @@ function requireElement(id: string): T { } const elements = { - target: requireElement('target'), + notice: requireElement('notice'), language: requireElement('language'), collection: requireElement('collection'), status: requireElement('status'), @@ -37,9 +37,11 @@ const elements = { const DEFAULT_LANGUAGE = 'typescript'; let collections: CollectionSummary[] = []; -let selection: SelectionSummary | null = null; -/** Kept from the last run so the sandbox's report can be summarized with it. */ -let lastMapping: MapTokensResult | null = null; +let layers: SelectionLayer[] = []; + +/** Totals from the last run, kept so the sandbox's report can cite them. */ +let lastRun: { unmatchedRanges: number; unmatchedColors: string[] } | null = + null; /** * Created at startup, with no grammars registered yet — the picked language's @@ -77,6 +79,17 @@ function fillLanguages(): void { if (preferred !== undefined) elements.language.value = preferred; } +/** + * How a collection reads in the picker. The mode count is only worth showing + * when there is more than one: a single-mode collection is fully described by + * its name, and listing every mode of an eight-mode collection would overflow + * the control. + */ +function collectionLabel(collection: CollectionSummary): string { + if (collection.modeNames.length < 2) return collection.name; + return `${collection.name} (${String(collection.modeNames.length)} modes)`; +} + /** * Rebuilds the collection picker, keeping the user's choice if that collection * still exists and otherwise defaulting to the collection holding the most @@ -90,7 +103,7 @@ function fillCollections(): void { for (const collection of collections) { const option = document.createElement('option'); option.value = collection.id; - option.textContent = `${collection.name} (${collection.modeNames.join(', ')})`; + option.textContent = collectionLabel(collection); elements.collection.append(option); } @@ -120,16 +133,20 @@ function render(issue: string | null): void { ); elements.collection.disabled = collections.length === 0; - elements.apply.disabled = selection === null || collections.length === 0; - - if (selection !== null) { - elements.target.textContent = `Selected: ${selection.nodeName}`; - elements.target.dataset.issue = 'false'; - } else { - elements.target.textContent = - issue ?? 'Select a text layer containing code.'; - elements.target.dataset.issue = 'true'; - } + elements.apply.disabled = layers.length === 0 || collections.length === 0; + // The count lives on the button rather than in a separate line of text, so a + // multi-layer run is legible right where it is about to be triggered. + elements.apply.textContent = + layers.length > 1 + ? `Highlight ${String(layers.length)} layers` + : 'Highlight'; + + // Shown only when there is nothing to work on; a usable selection needs no + // narration. + const notice = + layers.length > 0 ? null : (issue ?? 'Select one or more text layers.'); + elements.notice.textContent = notice ?? ''; + elements.notice.hidden = notice === null; if (collections.length === 0) { setStatus( @@ -141,44 +158,71 @@ function render(issue: string | null): void { 'No collection has syntax/* variables. Import packages/theme/figma/semantic into this file.', 'error' ); - } else if (lastMapping === null) { + } else if (lastRun === null) { // Clears an environment warning that no longer holds, without wiping the // summary from a run that already happened. setStatus(''); } } -/** Tokenizes the selected layer and asks the sandbox to bind the result. */ +/** + * Tokenizes every selected layer and asks the sandbox to bind the results. + * + * All layers are tokenized as the one language the picker names, which is the + * point of allowing a multi-layer selection: several samples of the same + * language get highlighted in one go. Layers are handled one after another + * because they share a highlighter, and only the first pass has to register the + * grammar. + */ async function apply(): Promise { - const target = selection; + const targets = layers; const collectionId = elements.collection.value; - if (target === null || collectionId === '') return; + if (targets.length === 0 || collectionId === '') return; elements.apply.disabled = true; setStatus('Tokenizing…'); try { const highlighter = await highlighterReady; - const mapping = await highlightToBindings( - highlighter, - target.characters, - elements.language.value - ); - lastMapping = mapping; + const lang = elements.language.value; - if (mapping.bindings.length === 0) { + const payload: LayerBindings[] = []; + const unmatchedColors = new Set(); + let unmatchedRanges = 0; + let totalRanges = 0; + + for (const layer of targets) { + const mapping = await highlightToBindings( + highlighter, + layer.characters, + lang + ); + + unmatchedRanges += mapping.unmatchedRanges; + for (const color of mapping.unmatchedColors) unmatchedColors.add(color); + if (mapping.bindings.length === 0) continue; + + totalRanges += mapping.bindings.length; + payload.push({ + nodeId: layer.nodeId, + nodeName: layer.nodeName, + bindings: mapping.bindings, + }); + } + + lastRun = { + unmatchedRanges, + unmatchedColors: [...unmatchedColors].sort(), + }; + + if (payload.length === 0) { setStatus('Nothing to bind: no token matched a Pierre role.', 'error'); elements.apply.disabled = false; return; } - setStatus(`Binding ${String(mapping.bindings.length)} ranges…`); - post({ - type: 'apply', - nodeId: target.nodeId, - collectionId, - bindings: mapping.bindings, - }); + setStatus(`Binding ${String(totalRanges)} ranges…`); + post({ type: 'apply', collectionId, layers: payload }); } catch (error) { const message = error instanceof Error ? error.message : String(error); setStatus(`Highlighting failed: ${message}`, 'error'); @@ -186,37 +230,48 @@ async function apply(): Promise { } } -function summarize(boundRanges: number, missingVariableNames: string[]): void { - const lines = [`Bound ${String(boundRanges)} ranges.`]; +function summarize( + applied: Extract +): void { + const lines = [ + applied.boundLayers > 1 + ? `Bound ${String(applied.boundRanges)} ranges across ${String(applied.boundLayers)} layers.` + : `Bound ${String(applied.boundRanges)} ranges.`, + ]; - if (lastMapping !== null && lastMapping.unmatchedRanges > 0) { + if (lastRun !== null && lastRun.unmatchedRanges > 0) { lines.push( - `Left ${String(lastMapping.unmatchedRanges)} ranges alone (colors with no Pierre role: ${lastMapping.unmatchedColors.join(', ')}).` + `Left ${String(lastRun.unmatchedRanges)} ranges alone (colors with no Pierre role: ${lastRun.unmatchedColors.join(', ')}).` ); } - if (missingVariableNames.length > 0) { + if (applied.missingVariableNames.length > 0) { lines.push( - `Missing from the chosen collection: ${missingVariableNames.join(', ')}.` + `Missing from the chosen collection: ${applied.missingVariableNames.join(', ')}.` + ); + } + if (applied.skippedLayers.length > 0) { + const noun = applied.skippedLayers.length === 1 ? 'layer' : 'layers'; + lines.push( + `Skipped ${String(applied.skippedLayers.length)} ${noun} (${applied.skippedLayers.join('; ')}).` ); } - setStatus( - lines.join('\n'), - missingVariableNames.length > 0 ? 'error' : 'info' - ); + const wentWrong = + applied.missingVariableNames.length > 0 || applied.skippedLayers.length > 0; + setStatus(lines.join('\n'), wentWrong ? 'error' : 'info'); } function handleMessage(message: SandboxMessage): void { switch (message.type) { case 'state': collections = message.collections; - selection = message.selection; + layers = message.layers; fillCollections(); render(message.issue); return; case 'applied': elements.apply.disabled = false; - summarize(message.boundRanges, message.missingVariableNames); + summarize(message); return; case 'error': elements.apply.disabled = false; diff --git a/packages/diffs-highlight/src/ui/ui.css b/packages/diffs-highlight/src/ui/ui.css index df9c85dc3..783686155 100644 --- a/packages/diffs-highlight/src/ui/ui.css +++ b/packages/diffs-highlight/src/ui/ui.css @@ -31,15 +31,13 @@ body { padding: 16px; } -.target { +/* Only rendered when there is nothing to highlight, so it reads as guidance + rather than an error. */ +.notice { margin: 0; color: var(--figma-color-text-secondary, #5b5b5b); } -.target[data-issue='true'] { - color: var(--figma-color-text-danger, #b3261e); -} - .field { display: flex; flex-direction: column; @@ -50,22 +48,70 @@ label { font-weight: 600; } -select { +.select { + position: relative; + display: flex; +} + +/* + * `appearance: none` drops the platform chevron, which Chrome pins hard against + * the control's right edge, in favor of the one drawn in `.select::after` at a + * deliberate inset. The right padding is what keeps a long option label from + * running underneath that chevron. + */ +.select select { width: 100%; height: 32px; - padding: 0 8px; + padding: 0 34px 0 10px; font-family: inherit; font-size: inherit; color: var(--figma-color-text, #1e1e1e); + appearance: none; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e6e6e6); border-radius: 6px; } -select:disabled { +.select select:hover:not(:disabled) { + border-color: var(--figma-color-border-strong, #b3b3b3); +} + +.select select:focus-visible { + border-color: var(--figma-color-border-selected, #0d99ff); + outline: 1px solid var(--figma-color-border-selected, #0d99ff); + outline-offset: -2px; +} + +.select select:disabled { color: var(--figma-color-text-disabled, #b3b3b3); } +/* + * Drawn as a mask filled with `currentColor` rather than a background image, so + * the chevron follows the panel's text color in both Figma themes instead of + * being baked to one value. + */ +.select::after { + position: absolute; + top: 50%; + right: 12px; + width: 10px; + height: 6px; + content: ''; + background-color: currentcolor; + opacity: 0.65; + transform: translateY(-50%); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23000' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 10px 6px; + pointer-events: none; +} + +.select:has(select:disabled)::after { + opacity: 0.3; +} + .status { min-height: 16px; margin: 0; diff --git a/packages/diffs-highlight/ui.html b/packages/diffs-highlight/ui.html index 57be221e0..0eb5bdbc6 100644 --- a/packages/diffs-highlight/ui.html +++ b/packages/diffs-highlight/ui.html @@ -7,16 +7,20 @@
-

Reading selection…

+
- +
+ +
- +
+ +