With a profile imported and bound to your app's origin (panel → DS tab), every developer on your team gets:
- Element Tokens view (free) — inspect any element and see which token each property resolves to, with raw values, primitives, and deprecated aliases flagged.
- Component inventory (free,
O) — every design-system component on the page, plus nativebutton/input/select/textareacontrols that aren't from the design system. - Lint audit (Pro,
L) — page-wide violations of your token rules, with fix suggestions.
The profile is a file you distribute (internal URL, repo file, chat) — Loupe never bundles or uploads it. Developers import it once per origin in the DS panel tab.
1. The file at a glance
{
"schemaVersion": 1,
"id": "acme", // short stable id — the storage key
"name": "Acme Design System", // display name shown in the panel
"generatedAt": "2026-07-01T00:00:00.000Z",
"detect": { // optional "this page uses Acme" heuristics
"cssVars": ["--acme-bg-surface"],
"slotAttr": "data-part"
},
"components": {
"slotAttr": "data-part", // the attribute your components render on every part
"variantAttrs": ["data-size", "data-active"],
"runtimeAttrs": ["data-state"] // attrs added by underlying libs (Radix, base-ui…)
},
"themeAxes": [
{ "id": "dark", "label": "Dark mode", "kind": "class", "name": "dark", "darkDiff": true },
{ "id": "brand", "label": "Brand", "kind": "attr", "name": "data-brand",
"values": ["consumer", "enterprise"] }
],
"varNamespaces": {
"semantic": ["--acme-bg-", "--acme-text-", "--acme-stroke-"],
"primitive": ["--acme-grey-", "--acme-blue-", "--acme-space-"]
},
"typography": {
"utilities": ["acme-label-", "acme-body-", "acme-heading-"],
"discouragedTextSizes": true // flag raw text-sm/text-lg where your scale exists
},
"ignoreSelectors": ["#third-party-chat"], // subtrees the lint audit skips
"tokens": [ /* see §3 — the bulk of the file */ ],
"lint": [ /* see §4 — rule configs */ ]
}
Hard limits enforced at import: 512 KB file size, 2,000 tokens, schemaVersion: 1. Validation errors are shown verbatim in the import UI, so a broken file fails loudly, not silently.
2. Field reference
id, name, generatedAt
id is the storage key — keep it stable across regenerations or users will accumulate stale copies (^[a-z0-9][a-z0-9-_]{0,63}$, case-insensitive). name appears throughout the panel. generatedAt should come from your token pipeline so staleness is diagnosable.
detect (optional)
Presence heuristics. If any listed CSS var resolves on document.documentElement, or any element matches [slotAttr], Loupe shows a "this page looks like it uses <name>" banner on unbound pages. Pick one or two vars that exist on every page of every consumer app.
components
slotAttr— the data attribute every component part renders (e.g.data-part="button"). This must be a documented contract of your library, not an implementation detail: the component inventory, the lookalike detector, and the lint audit's "inside the design system" exemption all rely on it. If your library doesn't render such an attribute, add one before authoring a profile — without it only the token features work.variantAttrs— attributes your own components write (data-size,data-active, …); shown as variant histograms in the inventory.runtimeAttrs— attributes added at runtime by your headless-UI layer (data-state,data-side, …); listed so tooling can display them, never required.
themeAxes
One entry per independent theme mechanism on <html>:
| kind | name | matches |
|---|---|---|
class | dark | <html class="dark"> |
attr | data-brand | <html data-brand="enterprise"> |
Loupe uses these to classify per-theme token values when reading your stylesheets. Mark exactly one axis darkDiff: true — the one the dark-mode diff should toggle.
varNamespaces
Prefix lists that classify any CSS custom property found on the page:
semantic— theme-aware tokens; the ones you want people using.primitive— raw ramps/scales with no dark-mode override; usage is flagged as an error.
Everything else is unknown (reported as raw). Prefixes match with startsWith, so include the leading -- and the trailing - where applicable.
typography (optional)
utilities are your type-scale class prefixes. discouragedTextSizes: true enables the raw-text-size lint rule (flags text-xs…text-9xl).
ignoreSelectors (optional)
CSS selectors whose subtrees the lint audit and the component scan skip — third-party embeds, chat widgets, cookie banners. Invalid selectors are ignored safely.
"ignoreSelectors": ["[data-ds-exception]"] and have developers mark consciously accepted exceptions in their markup (<button data-ds-exception="palette-trigger">…). The exception is then visible in the DOM, reviewable in code review, and excluded from audits — instead of being re-triaged on every scan.3. tokens[]
One entry per design token. This drives the Tokens view's reverse lookup and the deprecated-alias lint rule.
{
"cssVar": "--acme-bg-surface", // the custom property; null for class-only aliases
"utility": "bg-surface", // the utility class consumers write; null if none
"category": "bg", // free-form grouping: bg | text | stroke | icon | …
"role": "surface", // optional; mirrors your token naming levels
"state": "default", // optional
"light": "#ffffff", // resolved hex per theme — REQUIRED for reverse lookup
"dark": "#1d1d1d",
"description": "Default page surface" // optional; shown as context
}
Deprecated aliases (old names you're migrating away from) additionally carry:
{
"cssVar": null, // class-only alias — no custom property of its own
"utility": "bg-canvas", // the OLD class name to flag
"category": "legacy",
"light": null, "dark": null,
"deprecated": true,
"replacement": { "utility": "bg-surface" } // what the lint suggestion names
}
Rules of thumb:
- Every token needs
cssVarorutility(both is best). - Ship both hex values. The page's own CSS is always ground truth for current values, but the profile hexes are the fallback when stylesheets are cross-origin, and they power exact colour → token matching.
- Generate, don't hand-write. Derive this array from the same pipeline that builds your CSS (style-dictionary output, a
tokens.jsonmanifest, etc.): keep a hand-authored base file holding everything in §2, and merge your generated token list into it on every release.
4. lint[]
Rule configs — the implementations live in Loupe, so a profile can only select, parameterise, and set severity, never inject code:
[
{ "id": "raw-color", "severity": "error", "enabled": true },
{ "id": "primitive-token", "severity": "error", "enabled": true },
{ "id": "raw-spacing", "severity": "error", "enabled": true },
{ "id": "density-arbitrary", "severity": "error", "enabled": true },
{ "id": "hsl-var-wrap", "severity": "error", "enabled": true },
{ "id": "deprecated-alias", "severity": "warning", "enabled": true },
{ "id": "raw-text-size", "severity": "warning", "enabled": true }
]
| id | flags | notes |
|---|---|---|
raw-color | hex / rgb() / hsl() / oklch() literals in class arbitrary values or inline styles | var(--…) usages are exempt |
primitive-token | var(--<primitive prefix>…) in arbitrary values or inline styles | driven by varNamespaces.primitive |
raw-spacing | Tailwind numeric spacing scale (px-3.5, gap-2, m-4) | exempt inside [slotAttr] subtrees — your own components may use raw scale internally |
density-arbitrary | the literal [var(--density-…)] class form | for systems whose build drops that form silently |
hsl-var-wrap | hsl(var(--…)) | invalid once tokens resolve to hex |
deprecated-alias | any tokens[] entry with deprecated: true, matched by utility (variant prefixes like hover: are handled) | suggestion names replacement.utility |
raw-text-size | text-xs … text-9xl | only when typography.discouragedTextSizes |
Disable anything that doesn't apply to your system ("enabled": false) rather than deleting the entry — it documents the decision.
4b. Team features (schema v2)
Setting "schemaVersion": 2 unlocks four optional fields for organisation-wide rollouts:
{
"origins": ["https://app.acme.com", "https://admin.acme.com"],
"updateUrl": "https://ds.acme.internal/loupe-profile.json",
"reportWebhook": "https://ds.acme.internal/loupe-reports",
"license": { /* signed licence blob issued with your commercial licence */ }
}
origins— auto-bound when the profile is imported or refreshed; developers import once and every listed app origin is covered (their manual bindings always win).updateUrl(https only) — Loupe re-fetches the profile from here about once a day and on browser start, replacing the stored copy only when it validates, keeps the sameid, and has a newergeneratedAt. Publish the profile at this URL from your token pipeline and every install stays current with token releases.reportWebhook(https only) — adds an explicit "Send to team" button after lint and dark-mode-diff runs. The result JSON (findings, page URL, profile id, extension version — never tokens or credentials) is POSTed to your endpoint, only on the user's click. Point it at anything that accepts JSON: a lambda, Splunk HEC, a spreadsheet hook.license— a signed org licence that unlocks Loupe Pro for everyone using this profile on your origins, with no individual accounts. Licences are issued as part of a commercial agreement — contact the developer for team licensing. Embed the blob verbatim; it survives profile regeneration because the signature covers only the licence fields.
5. Distributing and updating
- Host it on an internal URL (any static host your developers can reach; CORS headers are not required — Loupe fetches from the extension background). Developers use Import → Fetch. Re-fetching after a token release picks up the new values; the same
idoverwrites in place. - Or commit it to your design-system repo and share the file; Import → From file / paste.
- Regenerate on every token release and bump
generatedAt. Keep the profile out of public hosting if your token names/values are internal — the profile is exactly as sensitive as your design-system source.
6. Pre-release checklist
On a page of a real consumer app, after binding the profile:
- Inspect a known component → Tokens view names your semantic vars for its colour properties.
O→ the inventory lists your components with plausible counts; inject a raw<button>in DevTools → it appears under "Not from the design system".- Seed one violation per enabled lint rule (e.g.
class="bg-[#ff0000] px-3.5 old-alias") → each fires with the right severity and a useful suggestion; the same classes inside one of your components do not fireraw-spacing. - Toggle your dark axis → re-inspect: resolved values change, token names stay.
- Check the banner: open an app page without binding → the detect heuristics show the "looks like it uses …" hint.