Files
sure/design/tokens
Guillem Arias Fauste e67ff3e3dc refactor(design-system): migrate single-color tokens to @theme + lint @utility /N footgun (#1849)
* refactor(design-system): migrate single-color semantic tokens to @theme + lint @utility /N footgun

Closes #1653. Tailwind v4 auto-generates the `/N` opacity-modifier
pipeline (`color-mix(in oklab, var(--color-X) N%, transparent)`)
only for colors declared in `@theme`. Tokens emitted as
`@utility name { @apply ... }` bypass that pipeline entirely, so
`text-link/70`, `bg-surface/50`, etc. silently compile to nothing —
the workaround from #1626 was `text-inverse opacity-70`.

Migrate the 11 single-color semantic tokens whose class names match
Tailwind's color-utility convention (`bg-X`, `text-X`, `border-X`)
and have no cross-prefix collision:

  bg-surface, bg-surface-hover, bg-surface-inset, bg-surface-inset-hover
  bg-container, bg-container-hover, bg-container-inset, bg-container-inset-hover
  bg-nav-indicator
  text-link
  border-tertiary

After migration, `--color-surface`, `--color-container`, etc. live
in `@theme` and Tailwind auto-generates every prefix variant
(`bg-surface`, `text-surface`, `border-surface`, plus
`/10`..`/100`). The original utility class names are preserved
(now via auto-generation instead of `@utility` blocks), so every
existing callsite continues to work.

NOT migrated, by design:

- **inverse family** (`bg-inverse`, `text-inverse`, `bg-inverse-hover`,
  `border-inverse`): bg- and text- variants have *different* colors,
  cannot share one `--color-inverse`. Renaming the family
  (`bg-strong-surface` + `text-on-strong-surface`) would touch
  ~61 view files and trade one footgun for semantic loss; deferred
  until a concrete `bg-inverse/N` use case appears.
- **primary/secondary/subdued/destructive** (cross-prefix collision):
  `text-primary` (gray.900) and `border-primary` (alpha-black.300)
  carry deliberately distinct values, can't share `--color-primary`.
  Same for the secondary/subdued pairs. Migrating either alone
  would force a rename of the other.
- **button-bg-*, tab-item-*, tab-bg-group**: class names don't
  follow Tailwind's `<prefix>-<name>` convention, so
  auto-generation would emit `bg-button-bg-primary` not
  `button-bg-primary`.
- **composites** (`bg-loader`, `bg-overlay`, `shadow-border-*`,
  `border-divider`): compile to multiple properties or
  alias-reference other utilities — must stay as @utility.

Add an `erb_lint` DeprecatedClasses rule covering the
@utility-only tokens with `\d+` regex modifiers so any future
`text-inverse/70` etc. fails CI with the explanation that
`opacity-N` is the workaround and #1653 is the tracking issue.
Verified the rule fires on synthetic input; verified zero new
violations on the existing app.

Stats: `@utility` blocks dropped from 45 → 34; @theme primitives
grew from 183 → 194.

* fix(review): cover remaining @utility /N footgun tokens in erb_lint

CodeRabbit flagged that the new DeprecatedClasses /N rule missed
seven still-defined @utility color tokens: border-destructive,
border-solid, button-bg-secondary-strong, button-bg-secondary-strong-hover,
button-bg-disabled, button-bg-ghost-hover, button-bg-outline-hover.
Without them, classes like button-bg-disabled/50 pass lint while
Tailwind silently drops the class.

Adding the patterns surfaced two pre-existing offenders
(border-destructive/30, border-destructive/20). Swap both to solid
border-destructive — the @utility override defines red-500 (light)
while --color-destructive in @theme is red-600, so the /N modifier
was rendering an off-shade rather than the intended faded variant.

Verified the rule fires on synthetic input for all seven new
patterns, then verified zero remaining violations on the new
patterns across app/**/*.erb.

* chore(erb_lint): add trailing newline to .erb_lint.yml

Per review feedback on #1849. Some editors flag the missing newline;
keeps style consistent with the rest of the codebase.
2026-05-20 18:20:38 +02:00
..

Sure design tokens

This is where the design system actually lives. Tailwind reads from here, and any external tooling (Figma Tokens Studio, AI design tools, anything that shows up later) is meant to read the same JSON.

Files

  • design/tokens/sure.tokens.json: every token, hand-edited.
  • bin/tokens.mjs: plain Node script. Compiles the JSON into Tailwind v4 CSS.
  • app/assets/tailwind/sure-design-system/_generated.css: the build output. Generated, do not edit by hand.

Workflow

# Edit a token:
$EDITOR design/tokens/sure.tokens.json

# Regenerate the CSS:
npm run tokens:build

# Commit both files together:
git add design/tokens/sure.tokens.json app/assets/tailwind/sure-design-system/_generated.css

bin/setup runs the build automatically on a fresh checkout.

Versioning

The root $version field follows semver, scoped to the token contract:

  • Major (X.0.0): breaking changes — token removed or renamed, value type changed, dark variant removed, semantic meaning changed.
  • Minor (1.X.0): additive changes — new tokens, new $extensions.sure.* keys, new top-level groups.
  • Patch (1.0.X): cosmetic / value tweaks that consumers don't need to know about — a hex shifts a few points without changing intent.

Bump it when you commit. External consumers (Tokens Studio, future Figma sync, etc.) read this to decide whether their cached snapshot is stale.

Schema

The file uses the W3C DTCG token format: $value, $type, $description, $extensions. Tokens cross-reference via {path.to.token} placeholders.

{
  "color": {
    "white": { "$value": "#ffffff", "$type": "color" },
    "gray": {
      "500": { "$value": "#737373", "$type": "color" }
    },
    "success": {
      "$value": "{color.green.600}",
      "$type": "color",
      "$extensions": { "sure.dark": "{color.green.500}" }
    }
  }
}

Top-level groups

Key Purpose
font font-family stacks
color base colors, semantic aliases (success, warning, destructive, shadow), full-scale ladders, alpha ladders
budget budget-chart fills (need their own dark variants because Stimulus controllers reference them)
border.radius corner radii
shadow drop shadows, both light and dark variants
animate named animations
utility Tailwind @utility blocks: semantic surfaces, foregrounds, borders, button backgrounds, etc.

Custom $extensions.sure.*

Extension Where What it does
sure.dark any token Dark-mode override value. Same template syntax as $value.
sure.alpha reserved Currently unused; alpha is expressed inline via {ref|N%}. Reserved for structured alpha if it's ever needed.
sure.utility.prefix utility.* only The Tailwind utility family (bg, text, border). Tells the build which @apply class to emit.
sure.utility.raw utility.* only A CSS property name (background-color, box-shadow, etc.) when the utility emits raw CSS instead of @apply.
sure.compose utility.* only Array of class names to @apply. For example, bg-loader is ["bg-surface-inset", "animate-pulse"].

Template strings

Anywhere a $value is a string:

  • {path.to.token} resolves to var(--path-to-token) in the generated CSS.
  • {path.to.token|N%} resolves to --alpha(var(--path-to-token) / N%) (Tailwind v4 alpha syntax).

The same syntax appears inside composite values like shadow.xs.$value: "0px 1px 2px 0px {color.black|6%}".

Alpha modifiers in views (/N syntax)

Tailwind v4's class/N modifier (bg-warning/10, text-link/70, etc.) only resolves on theme colors (anything declared under the top-level color.* group, which becomes --color-* in the generated CSS). It does not resolve on classes from this file's utility.* group, because those compile to static @apply blocks with no modifier-aware definition.

The mismatch is silent — Tailwind drops the unrecognized class and the element renders with no CSS for that property. Recently caught examples on text-inverse/70, border-secondary/30, and bg-surface-inset/40 (all of which produced no class output).

Until the build script teaches custom utilities to be modifier-aware, the convention is:

  • For alpha on a custom utility: pair the base class with opacity-N, e.g. text-inverse opacity-70 instead of text-inverse/70.
  • For alpha on a theme color: the /N modifier works as expected, e.g. bg-warning/10, border-destructive/30.

The pre-resolved alpha tints (color.gray.tint-5, color.gray.tint-10, color.red.tint-5, etc.) are theme colors, so bg-gray-tint-5 and similar work as straight utilities and accept further /N modifiers.

Adding a new token

  1. Pick the right top-level group.
  2. Add the $value (raw or {ref}) and $type.
  3. If it should change in dark mode, add $extensions.sure.dark.
  4. If it's a utility, add $extensions.sure.utility.prefix (or raw, or compose).
  5. Run npm run tokens:build.
  6. Look at the diff in _generated.css and confirm it's what you expected.
  7. Commit both files.

Edge cases the build script handles

  • color.gray.DEFAULT: the DEFAULT segment is dropped in the CSS variable name (--color-gray, not --color-gray-DEFAULT). DTCG convention; matches Tailwind.
  • utility.border-divider: the value is a plain class string (border-tertiary) instead of a {ref}. The build treats values without {} as raw @apply arguments.
  • utility.bg-overlay: uses sure.utility.raw: "background-color" because it needs alpha rendering instead of @apply.
  • utility.bg-loader: uses sure.compose to apply two utilities together (bg-surface-inset animate-pulse).
  • utility.button-bg-ghost-hover: its dark value is a multi-class string (bg-gray-800 text-inverse), not a single ref. The build accepts both forms.

Consumers

  • Rails / Tailwind: via the generated CSS, automatically.
  • Lookbook reference page: /design-system/inspect/design_tokens/* reads sure.tokens.json at request time.
  • External tools (Figma Tokens Studio, AI design tools, etc.): point them at this file.

If a consumer wants a different shape, transform the JSON in their tooling rather than editing the source here.