The sandbox is the source of truth for a component; the plugin gets a near-verbatim copy. The risk is drift — someone hand-edits the plugin copy, or a port silently diverges. This makes components content-addressed: a canonical hash of the component's portable body must match on both sides, the same idea as "never hand-edit generated artifacts."
Copy utilities.css with the port
src/utilities.css is a set of oas--prefixed layout, spacing, typography, and
border utilities built on Obsidian's CSS custom property scale. It covers two
spacing families: the 4-px step scale (steps 1–6) and the 2-px sub-scale
(oas-gap-2-1 = 2px, oas-gap-2-3 = 6px — class names mirror Obsidian token names).
Components may use these classes — they are portable by design. When porting
a component, copy this file into the plugin's styles directory and import it:
// In the plugin's entry (or the view file's companion CSS import):
import "./utilities.css"; // or however the plugin loads CSS
The oas- prefix means it won't conflict with any Obsidian selector. Copy it
once per plugin — all ported components share it. If the sandbox adds new utility
classes, re-copy the file.
Write portable components
So the body is byte-identical across sandbox and plugin and only the mount site differs:
- Take data via props / getters, not hard-wired sources. The sandbox passes a
stub (
loadStatus()); the plugin passes the real source (rpc) — but that wiring lives at the call site (main.ts/ItemView.onOpen()), not in the component body. - Use Obsidian classes +
var(--…)tokens (already the rule). Keepobsidian-API calls (setIcon, …) out of the component body where possible.
The tool: scripts/component-hash.mjs
Dependency-free Node; canonicalizer in scripts/lib/canonical-source.mjs.
node scripts/component-hash.mjs <file> # print canonical hash
node scripts/component-hash.mjs --verify <sandbox> <plugin> # PARITY OK | DRIFT (exit 0/1)
node scripts/component-hash.mjs --check port-parity.json # check all; exit 1 on drift
node scripts/component-hash.mjs --check port-parity.json --update # re-bless baselines
Canonical form (define it so both sides agree by construction):
import …;statements are stripped (single- and multi-line) — imports are the main legit delta (stub vs real data, theobsidianAPI).- Line endings →
\n, trailing whitespace trimmed, blank-line runs collapsed, leading/trailing blanks trimmed. - Comments are kept (they're part of the component; the sandbox owns them too).
- SHA-256 of the result.
So two copies that differ only in imports/formatting hash equal; any change to the body/template/comments shows as drift.
Wiring the check in the plugin
The hook lives in the plugin repo (that's where ported copies drift). Copy the
two dependency-free files in (scripts/lib/canonical-source.mjs,
scripts/component-hash.mjs) and add a manifest:
// port-parity.json — paths resolve relative to THIS file
{
"entries": [
{
"plugin": "src/chat/arrow/SettingsPanel.ts",
"sandbox": "../../arrow-ui/obsidian-arrow-sandbox/src/components/SettingsPanel.ts", // optional
"hash": "<sha256>" // fallback when sandbox isn't checked out
}
]
}
- If the
sandboxpath exists (sibling checkout), the check compares plugin copy ↔ live sandbox source — catches drift in both directions. - If it doesn't, it falls back to the recorded
hash(catches hand-edits to the plugin copy). Record/refresh it with--updateafter an intentional re-port.
husky pre-commit (and CI):
node scripts/component-hash.mjs --check port-parity.json
On drift the commit fails: edit the sandbox component and re-port (don't
hand-edit the copy); use --update only to intentionally re-bless.
CSS porting — pnpm port:css
Components use oas-* utility classes and scoped custom CSS during development.
When porting to the real plugin, class names need a project-specific prefix so
they don't collide with other plugins. The port:css command handles this at
port time — it never modifies source files.
Setup: porting.config.json
Create porting.config.json at the project root (copy from porting.config.example.json):
{
"cssPrefix": "vault-mind-", // prefix applied to every class selector
"viewSubScope": true, // view CSS gains ancestor selector (see below)
"include": [ // CSS files to process (glob patterns)
"src/components/**/*.css",
"src/views/**/*.css"
],
"outDir": "port-output/css" // where prefixed output is written
}
This file is user-owned — it is never touched by create-obsidian-arrow refresh.
Running it
pnpm port:css
Produces a prefixed copy of every matched CSS file under outDir/, plus an
outDir/index.css that @imports them all. Copy those into your plugin's styles
directory and import index.css.
What it does to class names
Every class selector in the output gets the prefix prepended:
/* source */
.composer { display: flex; }
/* output (cssPrefix: "vault-mind-") */
.vault-mind-composer { display: flex; }
viewSubScope: true — ancestor scoping for view CSS
When enabled, CSS files inside src/views/<ViewName>/ get an additional ancestor
selector derived from the view folder name and the prefix:
/* source: src/views/ChatView/Composer.css */
.composer { display: flex; }
/* output (cssPrefix: "vault-mind-", viewSubScope: true) */
.vault-mind-chat-view .vault-mind-composer { display: flex; }
This means the view's CSS only activates inside its own workspace leaf — no
cross-view bleed. The ancestor class (.vault-mind-chat-view) must be applied to
the view's root element in the plugin's ItemView.onOpen() or equivalent.
Disable viewSubScope if your views don't need that isolation (e.g., a settings
view that mounts as a modal rather than a leaf).
When to run
Run pnpm port:css as the final step before copying files into the plugin. It is
not part of pnpm ci — it's a one-shot port operation, not a development check.
What this does NOT do (by design)
- No runtime component-DOM hashing in Obsidian. Source parity already
guarantees same code → same Arrow DOM, and the sandbox vs real Obsidian DOM
intentionally differ (icon stub vs
setIcon,MarkdownRenderernodes, live theme), so a runtime markup hash would be tautological and full of false mismatches. The real runtime risk is visual/CSS — verify that by loading in Obsidian and looking, not with a hash. - Planned (not built): a styling-freshness check. A small helper that hashes
the live Obsidian token set and compares it to the sandbox's pulled
app.csssnapshot, to flag "re-runpnpm pull-css." That's the one runtime hash worth having — environment parity, not component parity. Add it when needed.