@ui-organized/ui-inspect-plugin
v0.1.1
Published
Bundler-agnostic dev-server plugin for UI.Inspect — discovers design tokens from disk, injects the inspector in dev only, and writes copy edits back to source.
Maintainers
Readme
@ui-organized/ui-inspect-plugin
The bundler-agnostic half of plugin mode (SPEC §5). @ui-organized/ui-inspect-vite is a
ten-line wrapper around this; Webpack, Rspack, Rollup and esbuild wrappers hang
off the same factory when their targets are proven.
What it does
discover ──▶ read ──▶ push ──────────ws──────────▶ parse ──▶ Engine
config.json disk uiinspect:tokens core setRawTokens()
↑ │
└── watch the file, re-push on change uiinspect:tokens-loaded
(count for the log line)
locate ◀── write ◀──────────────ws──────────────── edit copy in the panel
src/App.tsx:42 uiinspect:apply │
│ │
└──▶ uiinspect:applied ────────────────────▶ overlay dropped; HMR
(one result per change) renders from the fileapply: "serve"— dev only. The plugin does not participate in a production build at all (§0.7 #1).- Discovers config —
uiinspect.config.jsonat the project root; every key optional, the file itself optional. - Discovers tokens — an explicit
tokens.path, else sniffing in §5.2's order:tailwind.config.*→tokens.json/*.tokens.json→theme.ts→ a stylesheet that actually declares custom properties. Nothing found degrades to the page's own:rootproperties, the same thing the bookmarklet does. - Reads from disk — the capability that separates plugin mode from the
bookmarklet. DTCG
$description/$extensions, alias references, and SCSS variables the compiler folds away all exist in the source and never survive into the runtime DOM..ts/.jsthemes are evaluated through the bundler's own module loader, so atailwind.config.tsworks. - Injects the panel via a virtual module appended in
transformIndexHtml. The app imports nothing (§0.7 corollary). - Watches the token file and re-pushes on change, so editing a token updates drift and the pickers live, without a reload.
Division of labour
The plugin locates and reads; core parses. Every adapter therefore has exactly one implementation — the one core already tests — instead of a second copy living in a node process. What crosses the wire is the file's content plus enough context to parse it:
interface ProjectTokens {
path: string; // project-relative, for display
kind: "css" | "dtcg" | "json" | "tailwind";
text?: string; // stylesheet or JSON, verbatim
data?: unknown; // an evaluated theme module
root?: string; // which selector (css only)
}The client calls parseProjectTokens(payload) and hands the result to
engine.setRawTokens(tokens, origin). The Engine's source becomes project, and
the panel names the real file.
Tailwind
The theme surface is theme with theme.extend merged over it — deliberately
not resolveConfig. Resolving would pull in Tailwind's entire default
palette, making nearly every colour on the page read as "on-token" and inflating
the drift score (§8). The project's own declared theme is the honest answer.
Writing copy back to source
Edit an element's text in the panel, then Apply to source in the Changes tab. The plugin finds the string in your project and rewrites it, the dev server reloads, and the overlay disappears — the edit stops being a preview and becomes the file.
Finding the string is the whole problem, and it is solved in three steps:
- Source location, free. React's dev JSX transform already records
__source, which React hangs off the fiber as_debugSource. §5.3 says to check for that before writing a Babel plugin, and it is there — so a text edit carriessrc: "src/App.tsx:42:6"with no extra tooling. Vue's__fileworks the same way. React ≥19 dropped_debugSource; those projects fall through to step 3. - Whitespace-tolerant matching. JSX collapses a wrapped text child into one line, so the string the browser reports is often nowhere in the file literally. Matching happens on a whitespace-collapsed copy with an index map back to the original offsets, so copy written across three indented lines is found and replaced as one span.
- Widening search. A
<h2>{title}</h2>reports the h2's line while the copy lives in atitle="Counter"prop in a different file, so a miss at the hint widens to the whole file, then to the project.
It writes only when exactly one place could have produced the string. Two matches is a refusal, not a coin flip:
✕ "Save" appears in more than one file — too ambiguous to write
✕ "Clicks: 42" is not in any source file — it may be interpolated or come from data
✕ new text contains < or >, which would break the JSX it sits inRefused changes stay staged and still export, so nothing is lost. Give ambiguous copy a unique edit, or apply it by hand.
Replacements are validated against the syntax they land in: JSX text rejects
<, >, {, }; a quoted attribute rejects its own delimiter and newlines; a
template literal rejects ${. The writer refuses rather than escaping, because
escaping means generating code you did not write in a file you own.
Safety
Never reaching production:
apply: "serve", so the plugin never runs in a production build.- The injected client sits inside
if (process.env.NODE_ENV !== "production"), which a bundler folds away — defence in depth (§0.7 #2). - The panel import is dynamic and inside that guard, so there is never a static edge from app code into the tool.
npm run check:prodbuildsexamples/vite-reactfor production and fails on any occurrence of__uii/uiinspect/mountInspectorin the output.
Writing to your repo (§5.6). Every write must satisfy all of:
- the target resolves inside the project root after symlinks — a traversal, an absolute path elsewhere, and a symlink escaping the root are all refused;
- the file is an editable source type, and outside
node_modules; - it matches
write.allowPaths, when you configure one; - the old text matches exactly one place in the searched region;
- the replacement is legal in the syntax it lands in;
- the file is replaced via temp file + rename, so a crash cannot truncate it;
- every write is printed to the dev server console:
[uiinspect] wrote src/App.tsx:69 — "Plugin mode" → "Plugin mode works".
Use dryRun: true to locate and validate without writing anything.
Copy is all this writes. Token writers, staging and the PR flow are Phase 4b;
write.format / tokensPath and the github keys are parsed and carried,
unused.
Options
uiInspect({
enabled: true, // false turns the panel off without removing the plugin
expanded: false, // start open instead of collapsed to the launcher
tokens: { path: "./src/tokens.css", root: ":root", include: "", exclude: "" },
allowPaths: ["src/**"], // restrict what copy edits may touch
dryRun: false, // locate + validate, write nothing
clientEntry: "@ui-organized/ui-inspect", // advanced
});Anything set in uiinspect.config.json is the default; these options override it.
