← Loupe

Authoring a Design System Profile

A profile is a single JSON file that teaches Loupe your design system — tokens, components, theme axes, and lint rules. This is the complete reference for building one.

With a profile imported and bound to your app's origin (panel → DS tab), every developer on your team gets:

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

themeAxes

One entry per independent theme mechanism on <html>:

kindnamematches
classdark<html class="dark">
attrdata-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:

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-xstext-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.

A useful team convention: declare "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:

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 }
]
idflagsnotes
raw-colorhex / rgb() / hsl() / oklch() literals in class arbitrary values or inline stylesvar(--…) usages are exempt
primitive-tokenvar(--<primitive prefix>…) in arbitrary values or inline stylesdriven by varNamespaces.primitive
raw-spacingTailwind numeric spacing scale (px-3.5, gap-2, m-4)exempt inside [slotAttr] subtrees — your own components may use raw scale internally
density-arbitrarythe literal [var(--density-…)] class formfor systems whose build drops that form silently
hsl-var-wraphsl(var(--…))invalid once tokens resolve to hex
deprecated-aliasany tokens[] entry with deprecated: true, matched by utility (variant prefixes like hover: are handled)suggestion names replacement.utility
raw-text-sizetext-xstext-9xlonly 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 */ }
}

5. Distributing and updating

6. Pre-release checklist

On a page of a real consumer app, after binding the profile:

  1. Inspect a known component → Tokens view names your semantic vars for its colour properties.
  2. O → the inventory lists your components with plausible counts; inject a raw <button> in DevTools → it appears under "Not from the design system".
  3. 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 fire raw-spacing.
  4. Toggle your dark axis → re-inspect: resolved values change, token names stay.
  5. Check the banner: open an app page without binding → the detect heuristics show the "looks like it uses …" hint.