feat(design-system): live tokens reference page in Lookbook (#1618)

* feat(design-system): live tokens reference page in Lookbook

Adds `DesignTokensPreview` at `/design-system/inspect/design_tokens/*`,
split into seven sub-pages (typography, palette, surfaces, text,
borders, controls, effects). Each reads `design/tokens/sure.tokens.json`
at request time and renders the corresponding slice with values
pre-resolved to literal hex / rgba in Ruby — Tailwind doesn't need to
keep every CSS variable alive for the swatches to render.

Also drops the `@source not "../../../design/tokens"` directive added
in #1604. Excluding the JSON tree-shook ten or so design system
utilities that aren't yet used in app views (`shadow-border-md/sm/xl`,
`button-bg-ghost-hover`, etc.). The preview references each utility
through dynamic ERB, which Tailwind's scanner can't follow, so those
swatches were rendering blank. Letting Tailwind scan the JSON keeps
every declared utility available, which matches the intent of a design
system. Compiled CSS grows by about 3 KB.

Stacked previously on the `refactor/design-system-tokens` branch behind
#1604; rebased onto `main` once that landed.

* style(design-system): apply rubocop indented_internal_methods to preview

CI lint flagged the private helpers in DesignTokensPreview because the
project's RuboCop config uses `indented_internal_methods` style (methods
after `private`/`protected` get an extra 2-space indent). Auto-fixed
with `bin/rubocop -A`.

* fix(design-system): pre-resolve utility token values for the preview

CodeRabbit caught: collect_utilities was passing raw `{ref}` strings
(e.g. `{color.gray.50}`) as light_value/dark_value, while the rest of
the class pre-resolves to literal hex / rgba. The four templates that
display them (surfaces, text, borders, controls) showed the unresolved
template strings to users.

Adds `light_resolved` / `dark_resolved` fields to each utility entry,
populated via the same `resolve_template` helper the other collectors
use. Templates display `:light_resolved || :light_value` so plain class
strings (e.g. `border-tertiary`, `bg-gray-800 fg-inverse`) and compose
cases still fall through correctly.
This commit is contained in:
Guillem Arias Fauste
2026-05-01 16:06:25 +02:00
committed by GitHub
parent 05b5dba445
commit a7e964f8be
9 changed files with 514 additions and 5 deletions

View File

@@ -1,10 +1,5 @@
@import 'tailwindcss';
/* Exclude design/tokens/sure.tokens.json from Tailwind's content scanner: its utility
keys (e.g. `bg-surface`) would otherwise be treated as used classes and skip
tree-shaking. Path is relative to this file. */
@source not "../../../design/tokens";
@import "./sure-design-system.css";
@import "./geist-font.css";

View File

@@ -0,0 +1,222 @@
class DesignTokensPreview < ViewComponent::Preview
# Each section is its own preview so the Lookbook nav groups them.
# Source of truth: design/tokens/sure.tokens.json.
#
# All values are pre-resolved in this class (refs and {ref|N%} expanded to
# final hex / rgba strings) so templates iterate over plain data with no
# Tailwind-runtime dependency.
TAILWIND_TEXT_SIZES = [
[ "text-xs", "12px / 16px" ],
[ "text-sm", "14px / 20px" ],
[ "text-base", "16px / 24px" ],
[ "text-lg", "18px / 28px" ],
[ "text-xl", "20px / 28px" ],
[ "text-2xl", "24px / 32px" ],
[ "text-3xl", "30px / 36px" ],
[ "text-4xl", "36px / 40px" ],
[ "text-5xl", "48px" ]
].freeze
TAILWIND_FONT_WEIGHTS = [
[ "font-light", 300 ],
[ "font-normal", 400 ],
[ "font-medium", 500 ],
[ "font-semibold", 600 ],
[ "font-bold", 700 ]
].freeze
def typography
render_with_template(locals: {
fonts: collect_fonts,
text_sizes: TAILWIND_TEXT_SIZES,
font_weights: TAILWIND_FONT_WEIGHTS
})
end
def palette
render_with_template(locals: {
base_colors: collect_named_colors(%w[white black]),
semantic_colors: collect_named_colors(%w[success warning destructive shadow]),
budget_colors: collect_budget,
scales: collect_scales
})
end
def surfaces
render_with_template(locals: { utilities: collect_utilities { |name| name.start_with?("bg-") } })
end
def text
render_with_template(locals: {
text_utilities: collect_utilities { |name| name.start_with?("text-") },
fg_utilities: collect_utilities { |name| name.start_with?("fg-") }
})
end
def borders
render_with_template(locals: { utilities: collect_utilities { |name| name.start_with?("border-") || name.start_with?("shadow-border-") } })
end
def controls
render_with_template(locals: { utilities: collect_utilities { |name| name.start_with?("button-bg-") || name.start_with?("tab-") || name == "bg-nav-indicator" } })
end
def effects
render_with_template(locals: {
shadows: collect_shadows,
radii: collect_radii
})
end
private
# ─── Data builders ──────────────────────────────────────────────────────
def collect_fonts
walked.select { |path, _| path.first == "font" }.map do |path, node|
{ var: var_name(path), name: path.last, value: node["$value"] }
end
end
def collect_named_colors(names)
walked.filter_map do |path, node|
next unless path.first == "color" && path.length == 2 && names.include?(path[1])
build_color_entry(path, node)
end
end
def collect_budget
walked.filter_map do |path, node|
next unless path.first == "budget"
build_color_entry(path, node)
end
end
def collect_scales
scales = {}
walked.each do |path, node|
next unless path.first == "color" && path.length > 2
scales[path[1]] ||= []
scales[path[1]] << build_color_entry(path, node)
end
scales
end
def collect_utilities
walked.filter_map do |path, node|
next unless path.first == "utility"
name = path[1]
next unless yield(name)
ext = node["$extensions"] || {}
light_raw = node["$value"]
dark_raw = ext["sure.dark"]
{
name: path[1..].join("-"),
light_value: light_raw,
dark_value: dark_raw,
light_resolved: light_raw.is_a?(String) ? resolve_template(light_raw) : nil,
dark_resolved: dark_raw.is_a?(String) ? resolve_template(dark_raw) : nil,
compose: ext["sure.compose"]
}
end
end
def collect_shadows
walked.filter_map do |path, node|
next unless path.first == "shadow"
{
var: var_name(path),
name: path.last,
light_resolved: resolve_value(node),
light_raw: node["$value"],
dark_raw: node.dig("$extensions", "sure.dark")
}
end
end
def collect_radii
walked.filter_map do |path, node|
next unless path.first == "border" && path.length == 3
{ var: var_name(path), name: path.last, value: resolve_value(node) || node["$value"] }
end
end
def build_color_entry(path, node)
{
var: var_name(path),
name: path.last,
light_resolved: resolve_value(node) || node["$value"],
light_raw: node["$value"],
dark_resolved: resolve_dark(node),
dark_raw: node.dig("$extensions", "sure.dark")
}
end
# ─── Token walker ───────────────────────────────────────────────────────
def tokens
@tokens ||= JSON.parse(Rails.root.join("design/tokens/sure.tokens.json").read)
end
def walked
@walked ||= begin
result = []
walker = lambda do |node, path|
return unless node.is_a?(Hash)
if node.key?("$value") || node["$type"] == "utility"
result << [ path, node ]
return unless node["$value"].is_a?(Hash)
end
node.each do |k, v|
next if k.start_with?("$")
walker.call(v, path + [ k ])
end
end
walker.call(tokens, [])
result
end
end
# ─── Reference resolution ───────────────────────────────────────────────
def var_name(path)
cleaned = path.last == "DEFAULT" ? path[0..-2] : path
"--#{cleaned.join('-')}"
end
def resolve_value(node)
return nil unless node.is_a?(Hash)
v = node["$value"]
return nil unless v.is_a?(String)
resolve_template(v)
end
def resolve_dark(node)
raw = node.dig("$extensions", "sure.dark")
raw ? resolve_template(raw) : nil
end
def resolve_template(str)
str.gsub(/\{([^|}]+)(?:\|([^}]+))?\}/) do
ref_path = Regexp.last_match(1).split(".")
alpha = Regexp.last_match(2)
target = lookup(ref_path)
resolved = target ? (resolve_value(target) || target["$value"]) : Regexp.last_match(0)
alpha ? hex_to_rgba(resolved, alpha) : resolved
end
end
def lookup(path)
path.inject(tokens) { |h, k| h.is_a?(Hash) ? h[k] : nil }
end
def hex_to_rgba(hex, percent_str)
return hex unless hex.is_a?(String) && hex.start_with?("#")
h = hex.delete_prefix("#")
h = h.chars.map { |c| c * 2 }.join if h.length == 3
r, g, b = h[0, 2].to_i(16), h[2, 2].to_i(16), h[4, 2].to_i(16)
pct = percent_str.to_s.delete("%").to_f / 100.0
"rgba(#{r}, #{g}, #{b}, #{pct.round(3)})"
end
end

View File

@@ -0,0 +1,25 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
%>
<div class="bg-surface min-h-screen p-6 space-y-6">
<header>
<h2 class="text-2xl font-semibold text-primary">Borders</h2>
<p class="text-sm text-secondary"><code class="font-mono">border-*</code> and <code class="font-mono">shadow-border-*</code> utilities. Boxes show the rendered effect.</p>
</header>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<% utilities.each do |u| %>
<% is_shadow_border = u[:name].start_with?("shadow-border-") %>
<div class="<%= card %>">
<div class="h-12 w-full rounded-md bg-container <%= is_shadow_border ? u[:name] : "border-2 #{u[:name]}" %>"></div>
<p class="<%= label %>"><%= u[:name] %></p>
<p class="<%= meta %> truncate"><%= u[:light_resolved] || u[:light_value] %></p>
<% if u[:dark_value] %>
<p class="<%= meta %> truncate text-subdued">dark: <%= u[:dark_resolved] || u[:dark_value] %></p>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,25 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
swatch = "h-12 w-full rounded-md border border-secondary"
%>
<div class="bg-surface min-h-screen p-6 space-y-6">
<header>
<h2 class="text-2xl font-semibold text-primary">Controls</h2>
<p class="text-sm text-secondary"><code class="font-mono">button-bg-*</code>, <code class="font-mono">tab-*</code>, and the nav indicator.</p>
</header>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<% utilities.each do |u| %>
<div class="<%= card %>">
<div class="<%= swatch %> <%= u[:name] %>"></div>
<p class="<%= label %>"><%= u[:name] %></p>
<p class="<%= meta %> truncate"><%= u[:light_resolved] || u[:light_value] %></p>
<% if u[:dark_value] %>
<p class="<%= meta %> truncate text-subdued">dark: <%= u[:dark_resolved] || u[:dark_value] %></p>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,42 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
%>
<div class="bg-surface min-h-screen p-6 space-y-10">
<header>
<h2 class="text-2xl font-semibold text-primary">Effects</h2>
<p class="text-sm text-secondary">Shadow and border-radius tokens.</p>
</header>
<section class="space-y-3">
<h3 class="text-lg font-medium text-primary">Shadows</h3>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
<% shadows.each do |s| %>
<div class="<%= card %>">
<div class="h-16 w-full rounded-md bg-container" style="box-shadow: <%= s[:light_resolved] %>"></div>
<p class="<%= label %>"><%= s[:var] %></p>
<p class="<%= meta %> truncate"><%= s[:light_raw] %></p>
<% if s[:dark_raw] %>
<p class="<%= meta %> truncate text-subdued">dark: <%= s[:dark_raw] %></p>
<% end %>
</div>
<% end %>
</div>
</section>
<section class="space-y-3">
<h3 class="text-lg font-medium text-primary">Border radius</h3>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<% radii.each do |r| %>
<div class="<%= card %>">
<div class="flex items-center justify-center bg-surface rounded p-4">
<div class="size-12 bg-inverse" style="border-radius: <%= r[:value] %>"></div>
</div>
<p class="<%= label %>"><%= r[:var] %></p>
<p class="<%= meta %>"><%= r[:value] %></p>
</div>
<% end %>
</div>
</section>
</div>

View File

@@ -0,0 +1,50 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
swatch = "h-12 w-full rounded-md border border-secondary"
%>
<div class="bg-surface min-h-screen p-6 space-y-10">
<header>
<h2 class="text-2xl font-semibold text-primary">Palette</h2>
<p class="text-sm text-secondary">Base colors, semantic aliases, and the full scale ladders. Aliases that have a dark variant show both values.</p>
</header>
<section class="space-y-3">
<h3 class="text-lg font-medium text-primary">Base &amp; semantic colors</h3>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<% (base_colors + semantic_colors + budget_colors).each do |entry| %>
<div class="<%= card %>">
<div class="<%= swatch %>" style="background-color: <%= entry[:light_resolved] %>"></div>
<div class="flex items-center justify-between gap-1">
<span class="<%= label %>"><%= entry[:var] %></span>
<% if entry[:dark_resolved] %>
<span class="text-[10px] uppercase tracking-wide text-secondary">light/dark</span>
<% end %>
</div>
<p class="<%= meta %> truncate"><%= entry[:light_resolved] %></p>
<% if entry[:dark_resolved] %>
<p class="<%= meta %> truncate text-subdued">dark: <%= entry[:dark_resolved] %></p>
<% end %>
</div>
<% end %>
</div>
</section>
<section class="space-y-4">
<h3 class="text-lg font-medium text-primary">Color scales</h3>
<% scales.each do |scale, entries| %>
<div class="space-y-2">
<h4 class="text-sm font-medium text-primary"><%= scale %></h4>
<div class="grid grid-cols-6 sm:grid-cols-9 lg:grid-cols-13 gap-1">
<% entries.each do |entry| %>
<div class="space-y-1">
<div class="h-10 rounded border border-subdued" style="background-color: <%= entry[:light_resolved] %>" title="<%= entry[:var] %>: <%= entry[:light_resolved] %>"></div>
<p class="text-[10px] text-secondary text-center"><%= entry[:name] %></p>
</div>
<% end %>
</div>
</div>
<% end %>
</section>
</div>

View File

@@ -0,0 +1,29 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
swatch = "h-12 w-full rounded-md border border-secondary"
%>
<div class="bg-surface min-h-screen p-6 space-y-6">
<header>
<h2 class="text-2xl font-semibold text-primary">Surfaces &amp; backgrounds</h2>
<p class="text-sm text-secondary"><code class="font-mono">bg-*</code> utilities. Light/dark values shown when defined.</p>
</header>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<% utilities.each do |u| %>
<div class="<%= card %>">
<div class="<%= swatch %> <%= u[:name] %>"></div>
<p class="<%= label %>"><%= u[:name] %></p>
<% if u[:compose] %>
<p class="<%= meta %> truncate">@apply <%= u[:compose].join(" ") %></p>
<% else %>
<p class="<%= meta %> truncate"><%= u[:light_resolved] || u[:light_value] %></p>
<% if u[:dark_value] %>
<p class="<%= meta %> truncate text-subdued">dark: <%= u[:dark_resolved] || u[:dark_value] %></p>
<% end %>
<% end %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,61 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-2"
meta = "text-xs text-secondary font-mono"
light_ctx = "rounded p-3 bg-surface"
dark_ctx = "rounded p-3 bg-gray-900"
render_group = ->(items) {
capture do
content_tag(:div, class: "grid gap-3 grid-cols-1 lg:grid-cols-2") do
safe_join(items.map do |u|
content_tag(:div, class: card) do
safe_join([
content_tag(:div, class: "grid grid-cols-2 gap-2") do
safe_join([
content_tag(:div, class: light_ctx) do
content_tag(:p, "Quick brown fox", class: "text-sm font-medium #{u[:name]}")
end,
content_tag(:div, class: dark_ctx) do
content_tag(:p, "Quick brown fox", class: "text-sm font-medium #{u[:name]}")
end
])
end,
content_tag(:div, class: "space-y-0.5") do
safe_join([
content_tag(:p, u[:name], class: "text-xs font-medium text-primary"),
content_tag(:p, u[:light_resolved] || u[:light_value], class: "#{meta} truncate"),
(u[:dark_value] ? content_tag(:p, "dark: #{u[:dark_resolved] || u[:dark_value]}", class: "#{meta} truncate text-subdued") : nil)
].compact)
end
])
end
end)
end
end
}
%>
<div class="bg-surface min-h-screen p-6 space-y-10">
<header>
<h2 class="text-2xl font-semibold text-primary">Text &amp; foregrounds</h2>
<p class="text-sm text-secondary">
Each example renders the literal class on a light <em>and</em> a dark surface so utilities meant for inverse contexts show their intended state.
</p>
</header>
<section class="space-y-3">
<header class="space-y-1">
<h3 class="text-lg font-medium text-primary"><code class="font-mono">text-*</code> <span class="text-xs text-secondary font-normal">(canonical, ~2,000 uses across the codebase)</span></h3>
</header>
<%= render_group.call(text_utilities) %>
</section>
<section class="space-y-3">
<header class="space-y-1">
<h3 class="text-lg font-medium text-primary"><code class="font-mono">fg-*</code> <span class="text-xs text-warning font-normal">(legacy — prefer <code class="font-mono">text-*</code>)</span></h3>
<p class="text-sm text-secondary">
Older namespace, ~40 uses left in the codebase. Most are 1:1 duplicates of a <code class="font-mono">text-*</code> equivalent. Tracked for migration; no new code should reference these.
</p>
</header>
<%= render_group.call(fg_utilities) %>
</section>
</div>

View File

@@ -0,0 +1,60 @@
<%
card = "rounded-lg border border-secondary bg-container p-3 space-y-1.5"
label = "text-xs font-medium text-primary"
meta = "text-xs text-secondary font-mono"
%>
<div class="bg-surface min-h-screen p-6 space-y-10">
<header>
<h2 class="text-2xl font-semibold text-primary">Typography</h2>
<p class="text-sm text-secondary">Font family tokens defined in <code class="font-mono">design/tokens/sure.tokens.json</code>. Sizes, weights, and line heights are not yet curated as Sure tokens — the codebase uses Tailwind defaults, listed below for reference.</p>
</header>
<section class="space-y-3">
<h3 class="text-lg font-medium text-primary">Font families <span class="text-xs text-secondary font-normal">(Sure tokens)</span></h3>
<div class="grid gap-3 grid-cols-1 lg:grid-cols-2">
<% fonts.each do |entry| %>
<div class="<%= card %>">
<div class="flex items-baseline justify-between">
<span class="<%= label %>"><%= entry[:var] %></span>
<span class="<%= meta %>"><%= entry[:name] %></span>
</div>
<p class="text-2xl text-primary" style="font-family: var(<%= entry[:var] %>)">
The quick brown fox jumps over the lazy dog. 0123456789
</p>
<p class="<%= meta %> truncate"><%= entry[:value] %></p>
</div>
<% end %>
</div>
</section>
<section class="space-y-3">
<header class="space-y-1">
<h3 class="text-lg font-medium text-primary">Size scale <span class="text-xs text-secondary font-normal">(Tailwind defaults — not Sure tokens)</span></h3>
<p class="text-sm text-secondary">Used heavily across the codebase. Listed here as reference until a Sure-specific hierarchy is curated.</p>
</header>
<div class="<%= card %>">
<% text_sizes.each do |class_name, value| %>
<div class="flex items-baseline gap-4 py-1.5 border-b border-subdued last:border-0">
<code class="<%= meta %> w-20 shrink-0"><%= class_name %></code>
<span class="<%= meta %> w-24 shrink-0 text-subdued"><%= value %></span>
<span class="<%= class_name %> text-primary truncate">The quick brown fox</span>
</div>
<% end %>
</div>
</section>
<section class="space-y-3">
<header class="space-y-1">
<h3 class="text-lg font-medium text-primary">Weight scale <span class="text-xs text-secondary font-normal">(Tailwind defaults — not Sure tokens)</span></h3>
</header>
<div class="<%= card %>">
<% font_weights.each do |class_name, value| %>
<div class="flex items-baseline gap-4 py-1.5 border-b border-subdued last:border-0">
<code class="<%= meta %> w-28 shrink-0"><%= class_name %></code>
<span class="<%= meta %> w-12 shrink-0 text-subdued"><%= value %></span>
<span class="text-lg <%= class_name %> text-primary">The quick brown fox</span>
</div>
<% end %>
</div>
</section>
</div>