From a7e964f8be2d85494770f6ae31ec5326cd70a9f7 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Fri, 1 May 2026 16:06:25 +0200 Subject: [PATCH] feat(design-system): live tokens reference page in Lookbook (#1618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- app/assets/tailwind/application.css | 5 - .../previews/design_tokens_preview.rb | 222 ++++++++++++++++++ .../design_tokens_preview/borders.html.erb | 25 ++ .../design_tokens_preview/controls.html.erb | 25 ++ .../design_tokens_preview/effects.html.erb | 42 ++++ .../design_tokens_preview/palette.html.erb | 50 ++++ .../design_tokens_preview/surfaces.html.erb | 29 +++ .../design_tokens_preview/text.html.erb | 61 +++++ .../design_tokens_preview/typography.html.erb | 60 +++++ 9 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 test/components/previews/design_tokens_preview.rb create mode 100644 test/components/previews/design_tokens_preview/borders.html.erb create mode 100644 test/components/previews/design_tokens_preview/controls.html.erb create mode 100644 test/components/previews/design_tokens_preview/effects.html.erb create mode 100644 test/components/previews/design_tokens_preview/palette.html.erb create mode 100644 test/components/previews/design_tokens_preview/surfaces.html.erb create mode 100644 test/components/previews/design_tokens_preview/text.html.erb create mode 100644 test/components/previews/design_tokens_preview/typography.html.erb diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index ad728cdb3..72fa70258 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -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"; diff --git a/test/components/previews/design_tokens_preview.rb b/test/components/previews/design_tokens_preview.rb new file mode 100644 index 000000000..13bf588a3 --- /dev/null +++ b/test/components/previews/design_tokens_preview.rb @@ -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 diff --git a/test/components/previews/design_tokens_preview/borders.html.erb b/test/components/previews/design_tokens_preview/borders.html.erb new file mode 100644 index 000000000..23514cc99 --- /dev/null +++ b/test/components/previews/design_tokens_preview/borders.html.erb @@ -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" +%> +
+
+

Borders

+

border-* and shadow-border-* utilities. Boxes show the rendered effect.

+
+ +
+ <% utilities.each do |u| %> + <% is_shadow_border = u[:name].start_with?("shadow-border-") %> +
+
">
+

<%= u[:name] %>

+

<%= u[:light_resolved] || u[:light_value] %>

+ <% if u[:dark_value] %> +

dark: <%= u[:dark_resolved] || u[:dark_value] %>

+ <% end %> +
+ <% end %> +
+
diff --git a/test/components/previews/design_tokens_preview/controls.html.erb b/test/components/previews/design_tokens_preview/controls.html.erb new file mode 100644 index 000000000..39d34b293 --- /dev/null +++ b/test/components/previews/design_tokens_preview/controls.html.erb @@ -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" +%> +
+
+

Controls

+

button-bg-*, tab-*, and the nav indicator.

+
+ +
+ <% utilities.each do |u| %> +
+
+

<%= u[:name] %>

+

<%= u[:light_resolved] || u[:light_value] %>

+ <% if u[:dark_value] %> +

dark: <%= u[:dark_resolved] || u[:dark_value] %>

+ <% end %> +
+ <% end %> +
+
diff --git a/test/components/previews/design_tokens_preview/effects.html.erb b/test/components/previews/design_tokens_preview/effects.html.erb new file mode 100644 index 000000000..483d0ce7f --- /dev/null +++ b/test/components/previews/design_tokens_preview/effects.html.erb @@ -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" +%> +
+
+

Effects

+

Shadow and border-radius tokens.

+
+ +
+

Shadows

+
+ <% shadows.each do |s| %> +
+
+

<%= s[:var] %>

+

<%= s[:light_raw] %>

+ <% if s[:dark_raw] %> +

dark: <%= s[:dark_raw] %>

+ <% end %> +
+ <% end %> +
+
+ +
+

Border radius

+
+ <% radii.each do |r| %> +
+
+
+
+

<%= r[:var] %>

+

<%= r[:value] %>

+
+ <% end %> +
+
+
diff --git a/test/components/previews/design_tokens_preview/palette.html.erb b/test/components/previews/design_tokens_preview/palette.html.erb new file mode 100644 index 000000000..1f9455f97 --- /dev/null +++ b/test/components/previews/design_tokens_preview/palette.html.erb @@ -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" +%> +
+
+

Palette

+

Base colors, semantic aliases, and the full scale ladders. Aliases that have a dark variant show both values.

+
+ +
+

Base & semantic colors

+
+ <% (base_colors + semantic_colors + budget_colors).each do |entry| %> +
+
+
+ <%= entry[:var] %> + <% if entry[:dark_resolved] %> + light/dark + <% end %> +
+

<%= entry[:light_resolved] %>

+ <% if entry[:dark_resolved] %> +

dark: <%= entry[:dark_resolved] %>

+ <% end %> +
+ <% end %> +
+
+ +
+

Color scales

+ <% scales.each do |scale, entries| %> +
+

<%= scale %>

+
+ <% entries.each do |entry| %> +
+
+

<%= entry[:name] %>

+
+ <% end %> +
+
+ <% end %> +
+
diff --git a/test/components/previews/design_tokens_preview/surfaces.html.erb b/test/components/previews/design_tokens_preview/surfaces.html.erb new file mode 100644 index 000000000..725ac18d1 --- /dev/null +++ b/test/components/previews/design_tokens_preview/surfaces.html.erb @@ -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" +%> +
+
+

Surfaces & backgrounds

+

bg-* utilities. Light/dark values shown when defined.

+
+ +
+ <% utilities.each do |u| %> +
+
+

<%= u[:name] %>

+ <% if u[:compose] %> +

@apply <%= u[:compose].join(" ") %>

+ <% else %> +

<%= u[:light_resolved] || u[:light_value] %>

+ <% if u[:dark_value] %> +

dark: <%= u[:dark_resolved] || u[:dark_value] %>

+ <% end %> + <% end %> +
+ <% end %> +
+
diff --git a/test/components/previews/design_tokens_preview/text.html.erb b/test/components/previews/design_tokens_preview/text.html.erb new file mode 100644 index 000000000..e46d27a2e --- /dev/null +++ b/test/components/previews/design_tokens_preview/text.html.erb @@ -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 + } +%> +
+
+

Text & foregrounds

+

+ Each example renders the literal class on a light and a dark surface so utilities meant for inverse contexts show their intended state. +

+
+ +
+
+

text-* (canonical, ~2,000 uses across the codebase)

+
+ <%= render_group.call(text_utilities) %> +
+ +
+
+

fg-* (legacy — prefer text-*)

+

+ Older namespace, ~40 uses left in the codebase. Most are 1:1 duplicates of a text-* equivalent. Tracked for migration; no new code should reference these. +

+
+ <%= render_group.call(fg_utilities) %> +
+
diff --git a/test/components/previews/design_tokens_preview/typography.html.erb b/test/components/previews/design_tokens_preview/typography.html.erb new file mode 100644 index 000000000..5297aa668 --- /dev/null +++ b/test/components/previews/design_tokens_preview/typography.html.erb @@ -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" +%> +
+
+

Typography

+

Font family tokens defined in design/tokens/sure.tokens.json. Sizes, weights, and line heights are not yet curated as Sure tokens — the codebase uses Tailwind defaults, listed below for reference.

+
+ +
+

Font families (Sure tokens)

+
+ <% fonts.each do |entry| %> +
+
+ <%= entry[:var] %> + <%= entry[:name] %> +
+

+ The quick brown fox jumps over the lazy dog. 0123456789 +

+

<%= entry[:value] %>

+
+ <% end %> +
+
+ +
+
+

Size scale (Tailwind defaults — not Sure tokens)

+

Used heavily across the codebase. Listed here as reference until a Sure-specific hierarchy is curated.

+
+
+ <% text_sizes.each do |class_name, value| %> +
+ <%= class_name %> + <%= value %> + The quick brown fox +
+ <% end %> +
+
+ +
+
+

Weight scale (Tailwind defaults — not Sure tokens)

+
+
+ <% font_weights.each do |class_name, value| %> +
+ <%= class_name %> + <%= value %> + The quick brown fox +
+ <% end %> +
+
+