* refactor(design-system): migrate fg-* utilities to text-* and remove namespace
The design system carried two parallel namespaces for foreground colors:
text-* (canonical, ~2,000 uses) and fg-* (32 uses). Most fg-* tokens
were 1:1 duplicates of a text-* counterpart. fg-gray was nearly
identical to text-secondary, with a one-step shade difference in dark
mode.
This PR migrates all 32 usages to their text-* equivalents and removes
the fg-* block from the design tokens. Closes #1606.
Mapping:
- fg-inverse -> text-inverse (20 usages, identical light/dark values)
- fg-gray -> text-secondary (7 usages; light values match, dark is
one step lighter: gray-300 vs gray-400)
- fg-primary -> text-primary (3 usages, identical values)
- fg-subdued -> text-subdued (2 usages, identical values)
The four other fg-* tokens (fg-contrast, fg-primary-variant,
fg-secondary, fg-secondary-variant) had zero usages despite being
defined; they are removed without replacement.
JSON / build:
- design/tokens/sure.tokens.json: $version 1.0.0 -> 2.0.0 (breaking
schema change per the policy added in #1620). 8 fg-* token
definitions removed.
- button-bg-ghost-hover's dark value still references "fg-inverse"
internally; rewritten to "bg-gray-800 text-inverse" so the cleanup
doesn't break that utility.
- _generated.css regenerated. 42 utility blocks now (was 50).
Lookbook tokens preview:
- The Text & foregrounds section dropped its split between text-*
(canonical) and fg-* (legacy). Now a single section listing the
five text-* utilities. The "(legacy)" framing is gone since there's
no legacy left.
README:
- design/tokens/README.md's button-bg-ghost-hover edge-case example
updated to reflect the new "bg-gray-800 text-inverse" dark value.
Visual review needed in dark mode:
- Anywhere icons use the application_helper#icon helper with
color: "default" (most icons in the app). The default class moved
from fg-gray (gray-400 dark) to text-secondary (gray-300 dark), so
default-color icons render slightly lighter in dark mode.
- DS::Buttonish icons in secondary buttons (same shade shift).
- DS::Link icons (same).
- Time series chart axes (same).
- All tooltips, account add flow, settings hostings buttons,
invitations, AI consent, family export, danger-zone buttons --
these used fg-inverse, which is identical to text-inverse, so no
visual change expected.
* fix(design-system): use inverse pair on tooltips for readable dark mode
* fix(lookbook): use semantic tokens in menu preview header text
* fix(lookbook): set text-primary on layout body so previews inherit theme
* fix(design-system): keep shadows dark-toned in dark mode
Inverting shadows to white|8% on dark surfaces produces a halo
effect rather than an elevation cue, and stacks redundantly with
the alpha-white 1px ring already in shadow-border-*.
Switch dark-mode shadows to black at progressively higher alpha
(25%/30%/35%/40%/50% for xs..xl) so they read as actual cast
shadows on near-black surfaces. Surface-tint differences and the
existing alpha-white border ring continue to handle elevation
hierarchy and edge definition.
Approach matches Material 3, Apple HIG, IBM Carbon, Refactoring UI,
and the dark-mode shadows used in Linear/Vercel/Stripe.
* fix(design-system): set text-primary on DS::Dialog element
Browser UA stylesheets apply color: black directly to <dialog>,
which overrides ancestor inheritance even when a body or html
ancestor sets a theme-aware color. Unstyled child content then
renders black regardless of theme.
Setting text-primary on the dialog element itself defeats the UA
override and lets descendants inherit the semantic token.
* fix(lookbook): use shadow css vars in effects preview so dark theme renders
* Revert "fix(design-system): keep shadows dark-toned in dark mode"
This reverts commit 3e9d76ed0b.
* fix(design-system): use opacity-70 instead of text-inverse/70 in value tooltip
The custom @utility text-inverse expands to @apply text-white and
isn't modifier-aware, so text-inverse/70 produced no CSS at all and
the muted labels fell through to inherited color (invisible on the
white pill in dark mode).
Replace with text-inverse + opacity-70. Same visual effect, works
with the existing utility definition.
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 tovar(--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%}".
Adding a new token
- Pick the right top-level group.
- Add the
$value(raw or{ref}) and$type. - If it should change in dark mode, add
$extensions.sure.dark. - If it's a utility, add
$extensions.sure.utility.prefix(orraw, orcompose). - Run
npm run tokens:build. - Look at the diff in
_generated.cssand confirm it's what you expected. - Commit both files.
Edge cases the build script handles
color.gray.DEFAULT: theDEFAULTsegment 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@applyarguments.utility.bg-overlay: usessure.utility.raw: "background-color"because it needs alpha rendering instead of@apply.utility.bg-loader: usessure.composeto 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/*readssure.tokens.jsonat 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.