mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 13:45:01 +00:00
* 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.
220 lines
6.7 KiB
Ruby
220 lines
6.7 KiB
Ruby
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: { utilities: collect_utilities { |name| name.start_with?("text-") } })
|
|
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
|