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 %> +
+
+