Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ dist/
# Promo & blog (local only)
showcase/PROMO*.md
showcase/blog-*.md
__pycache__/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ applies them every time:
- **Tabular numbers** for anything that updates — no width jitter as values change
- **Status color = severity only** — a "normal" row is grey; color marks the exception, never a rainbow list
- **No emoji icons, and no Lucide-in-a-pale-chip on every card** (§CC-9b) — the two opposite AI icon tells
- **8px spatial grid; gap-around-a-group > gap-inside it** — proximity that reads as structure
- **6px spatial grid; gap-around-a-group > gap-inside it** — proximity that reads as structure
- **Optical, not pixel, alignment** — nudge arrows/play glyphs; center type by cap-height
- **Desktop body ≥16px, one focal point per screen** — the tight mobile scale and an all-even grid both read "machine-made"
- **One radius personality · one icon set · one shadow language** — the coherence laws (§C0), the #1 fix for "looks AI-generated"
Expand Down
2 changes: 1 addition & 1 deletion demo-pricing/.engine/components/patterns/chart-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function ChartCard({
className={cn(
"px-4 py-1.5 text-[11px] font-semibold rounded-full transition-all",
activePeriod === period
? "bg-brand text-white shadow-sm"
? "bg-brand text-brand-foreground shadow-sm"
: "text-text-tertiary",
)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function DonutChartCard({
{title}
</h3>

<div className="flex items-center gap-8">
<div className="flex items-center gap-9">
<div className="relative size-32 flex-shrink-0">
{chartElement}
{(centerValue !== undefined || centerLabel) && (
Expand Down
2 changes: 1 addition & 1 deletion demo-pricing/.engine/components/patterns/hero-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function HeroCard({
<div
data-slot="hero-card"
className={cn(
"mx-6 rounded-2xl bg-card p-8 shadow-[var(--shadow-card)] relative overflow-hidden",
"mx-6 rounded-2xl bg-card p-9 shadow-[var(--shadow-card)] relative overflow-hidden",
className,
)}
{...props}
Expand Down
4 changes: 2 additions & 2 deletions demo-pricing/.engine/components/patterns/ranked-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function RankedList({
className={cn(
"size-8 rounded-lg flex items-center justify-center text-[12px] font-bold",
item.isHighlighted
? "bg-brand text-white"
? "bg-brand text-brand-foreground"
: "bg-surface-muted text-text-tertiary",
)}
>
Expand All @@ -67,7 +67,7 @@ function RankedList({
{item.name}
</p>
{item.badge && (
<span className="px-2 py-0.5 bg-brand text-white text-[9px] font-bold rounded uppercase tracking-wider">
<span className="px-2 py-0.5 bg-brand text-brand-foreground text-[9px] font-bold rounded uppercase tracking-wider">
{item.badge}
</span>
)}
Expand Down
6 changes: 3 additions & 3 deletions demo-pricing/.engine/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ const buttonVariants = cva(
{
variants: {
variant: {
// 토스 스타일: brandSolid — 키컬러 배경, 흰 텍스트
// 토스 스타일: brandSolid — 키컬러 배경, 스킨이 정한 라벨색(--brand-foreground)
default:
"bg-brand text-white active:bg-brand/85 disabled:bg-surface-muted disabled:text-text-disabled",
"bg-brand text-brand-foreground active:bg-brand/85 disabled:bg-surface-muted disabled:text-text-disabled",
// neutralSolid — 어두운 배경, 흰 텍스트
neutral:
"bg-[#2A2A2A] text-white active:bg-[#3C3C3C] disabled:bg-surface-muted disabled:text-text-disabled dark:bg-[#E0E0E0] dark:text-[#121212] dark:active:bg-[#C0C0C0]",
Expand All @@ -20,7 +20,7 @@ const buttonVariants = cva(
"bg-[#F3F4F5] text-text-primary active:bg-[#EAEBEC] disabled:bg-surface-muted disabled:text-text-disabled dark:bg-[#2B2E35] dark:text-[#E0E0E0] dark:active:bg-[#393D46]",
// criticalSolid — 위험 액션
destructive:
"bg-destructive text-white active:bg-destructive/85 focus-visible:ring-destructive/20 disabled:bg-surface-muted disabled:text-text-disabled",
"bg-destructive text-destructive-foreground active:bg-destructive/85 focus-visible:ring-destructive/20 disabled:bg-surface-muted disabled:text-text-disabled",
// outline — 테두리만
outline:
"border border-border bg-transparent text-text-primary active:bg-surface-muted/50 disabled:border-surface-muted disabled:text-text-disabled dark:border-white/8",
Expand Down
6 changes: 4 additions & 2 deletions demo-pricing/.engine/components/ui/confirm-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ function ConfirmModal({
<button
onClick={onConfirm}
className={cn(
"flex-1 h-11 rounded-full text-[16px] font-semibold text-white active:opacity-85 transition-colors duration-150",
variant === "destructive" ? "bg-destructive" : "bg-brand",
"flex-1 h-11 rounded-full text-[16px] font-semibold active:opacity-85 transition-colors duration-150",
variant === "destructive"
? "bg-destructive text-destructive-foreground"
: "bg-brand text-brand-foreground",
)}
>
{confirmText}
Expand Down
2 changes: 2 additions & 0 deletions engine/.claude/skills/ss-component/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Description: $ARGUMENTS
## Instructions

1. First, read the design system seed for context:
- Read `STYLESEED.md` (project root) if it exists — the Design Lock incl. **Brand intent**;
obey its "Never" constraints and radius/motion personality
- Read `CLAUDE.md` for component conventions
- Read `css/theme.css` for available design tokens
- Read `components/ui/button.tsx` as a reference pattern
Expand Down
12 changes: 6 additions & 6 deletions engine/.claude/skills/ss-dial/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: ss-dial
description: Turn ONE design axis up or down as a coordinated, deterministic transform — "denser", "sharper corners", "more muted", "bolder", "flatter", "livelier". Not a vibe the model reinterprets each time; a defined ramp that moves many tokens together, respects the guardrails (8px grid, a11y floors, single accent, nested-radius), updates the lock, and re-runs the gate. Use this when a human saying "more X" would otherwise get an inconsistent one-off.
description: Turn ONE design axis up or down as a coordinated, deterministic transform — "denser", "sharper corners", "more muted", "bolder", "flatter", "livelier". Not a vibe the model reinterprets each time; a defined ramp that moves many tokens together, respects the guardrails (6px grid, a11y floors, single accent, nested-radius), updates the lock, and re-runs the gate. Use this when a human saying "more X" would otherwise get an inconsistent one-off.
argument-hint: "<axis> <direction> — e.g. \"density denser\", \"radius sharper\", \"color more-muted\", \"weight bolder\""
allowed-tools: Read, Write, Edit, Grep, Glob, Bash
---
Expand Down Expand Up @@ -52,13 +52,13 @@ Ramp: `airy → comfortable → compact → dense`. The page gutter stays `px-6`

| Position | Section `space-y` | Card padding | Grid `gap` | Body line-height | Type scale |
|---|---|---|---|---|---|
| **airy** | `space-y-10` | `p-8` | `gap-8` | `leading-relaxed` | one step up (desktop-larger) |
| **airy** | `space-y-9` | `p-9` | `gap-9` | `leading-relaxed` | one step up (desktop-larger) |
| **comfortable** | `space-y-6` | `p-6` | `gap-6` | `leading-normal` | surface default |
| **compact** | `space-y-4` | `p-4` | `gap-4` | `leading-normal` | surface default, tighter headings |
| **dense** | `space-y-4` | `p-4` | `gap-3` (12px half-step) | `leading-snug` on data | data-table scale |
| **compact** | `space-y-4.5` | `p-4.5` | `gap-4.5` | `leading-normal` | surface default, tighter headings |
| **dense** | `space-y-3` | `p-3` | `gap-3` | `leading-snug` on data | data-table scale |

**Guardrails:** stay on the 8px grid (only `p-2/4/6/8`, `gap-*` on grid or the 4px half-step —
never invent `p-5`/`gap-2.5`); **touch controls stay ≥ 44px even at `dense`** (shrink padding,
**Guardrails:** stay on the 6px grid — a clean 36/24/18/12px ladder (`p-1.5/3/4.5/6/9`,
`gap-*` in 6px multiples — never invent `p-5`/`gap-2.5`); **touch controls stay ≥ 44px even at `dense`** (shrink padding,
not tap targets); body never drops below the surface floor (desktop 16px). Dense is for
data-heavy surfaces; don't dense-ify a marketing landing.

Expand Down
2 changes: 1 addition & 1 deletion engine/.claude/skills/ss-feedback/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Rules:

### State 3: Error (Load Failed)
```tsx
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="flex flex-col items-center justify-center py-9 text-center">
<AlertCircle className="size-8 text-destructive mb-3" />
<p className="text-[14px] text-text-secondary mb-4">Couldn't load the data</p>
<Button variant="brandGhost" size="sm" onClick={retry}>Try again</Button>
Expand Down
22 changes: 22 additions & 0 deletions engine/.claude/skills/ss-lint/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,28 @@ grep -n 'className={`' [file]
**Violation:** Template literal className
**Fix:** Use `cn()` for all className composition

---

## Token contrast — WCAG (check 9, runs when a theme file exists)

If the project has `css/theme.css` (or the detected theme file), run the bundled
deterministic checker — NEVER compute WCAG luminance by mental arithmetic, the
gamma math is exactly where LLMs slip:
```bash
python3 "$(dirname "$SKILL_MD")/scripts/contrast_check.py" css/theme.css
# self-check if in doubt: ... contrast_check.py --self-test → must print OK
```
(`$SKILL_MD` = this SKILL.md's directory; use the literal path of the skill folder.)
It parses `:root` + `.dark` custom properties (hex / rgb() / hsl() / oklch()),
resolves `var()` chains, and checks the standard pairs in BOTH scopes:
body & muted text on background/card ≥ 4.5:1 · brand UI on page ≥ 3:1 ·
label on brand/primary/destructive controls ≥ 4.5:1 (brand-foreground —
or the hardcoded white that pre-2.9 components used — on brand, etc.).
**Violation:** any `🔴 FAIL` line (exit code 1).
**Fix:** darken/lighten the failing token in `theme.css` — adjust the token, never
the component. `🟡 SKIP` lines (alpha compositing, `color-mix()`) are NOT passes:
report them and eyeball those pairs manually.

## Output Format

```
Expand Down
226 changes: 226 additions & 0 deletions engine/.claude/skills/ss-lint/scripts/contrast_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""WCAG token-contrast checker for StyleSeed theme.css (ss-lint check 9).

stdlib only. Parses CSS custom properties in `:root` and `.dark` blocks,
resolves var() chains, converts hex / rgb() / hsl() / oklch() to WCAG relative
luminance, and checks the standard StyleSeed token pairs in BOTH scopes.

Exit codes: 0 = all checked pairs pass, 1 = at least one FAIL, 2 = usage/parse error.
Unparseable values are reported as skipped (never silently passed).

Usage:
python3 contrast_check.py path/to/theme.css
python3 contrast_check.py --self-test
"""
import math
import re
import sys

# ---------- color parsing → linear-sRGB (0..1 per channel) ----------

def _hex_to_linear(s):
s = s.lstrip("#")
if len(s) == 3:
s = "".join(c * 2 for c in s)
if len(s) == 8: # RRGGBBAA
if int(s[6:8], 16) != 255:
raise ValueError("alpha < 1 needs backdrop compositing; skipped")
s = s[:6]
if len(s) != 6:
raise ValueError("bad hex length")
srgb = [int(s[i:i + 2], 16) / 255.0 for i in (0, 2, 4)]
return [_srgb_decode(c) for c in srgb]


def _srgb_decode(c):
# WCAG 2.x published constant (0.03928)
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4


def _num(tok, scale=1.0, pct_base=None):
tok = tok.strip()
if tok.endswith("%"):
base = pct_base if pct_base is not None else 100.0
return float(tok[:-1]) / 100.0 * base
return float(tok) * scale


def _rgb_func_to_linear(args):
# rgb(255 0 0) | rgb(255, 0, 0) | rgba(255,0,0,1) | rgb(100% 0% 0%)
if len(args) == 4 and float(_num(args[3])) < 1.0:
raise ValueError("alpha < 1; skipped")
srgb = []
for a in args[:3]:
v = _num(a, pct_base=255.0) if a.strip().endswith("%") else float(a)
srgb.append(max(0.0, min(1.0, v / 255.0)))
return [_srgb_decode(c) for c in srgb]


def _hsl_to_linear(args):
if len(args) == 4 and float(_num(args[3])) < 1.0:
raise ValueError("alpha < 1; skipped")
h = float(re.sub(r"deg$", "", args[0].strip())) % 360.0
s = _num(args[1], pct_base=1.0) if args[1].strip().endswith("%") else float(args[1])
l = _num(args[2], pct_base=1.0) if args[2].strip().endswith("%") else float(args[2])
c = (1 - abs(2 * l - 1)) * s
x = c * (1 - abs((h / 60.0) % 2 - 1))
m = l - c / 2
rgb1 = [(c, x, 0), (x, c, 0), (0, c, x), (0, x, c), (x, 0, c), (c, 0, x)][int(h // 60) % 6]
return [_srgb_decode(max(0.0, min(1.0, v + m))) for v in rgb1]


def _oklch_to_linear(args):
# oklch(L C H [/ alpha]) — L may be 0..1 or a percentage
if len(args) == 4 and float(_num(args[3])) < 1.0:
raise ValueError("alpha < 1; skipped")
L = _num(args[0], pct_base=1.0) if args[0].strip().endswith("%") else float(args[0])
C = float(args[1])
H = math.radians(float(re.sub(r"deg$", "", args[2].strip())))
a, b = C * math.cos(H), C * math.sin(H)
l_ = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
m_ = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
s_ = (L - 0.0894841775 * a - 1.2914855480 * b) ** 3
r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
bb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_
return [max(0.0, min(1.0, v)) for v in (r, g, bb)]


_FUNC = {"rgb": _rgb_func_to_linear, "rgba": _rgb_func_to_linear,
"hsl": _hsl_to_linear, "hsla": _hsl_to_linear, "oklch": _oklch_to_linear}


def luminance(value):
"""CSS color string → WCAG relative luminance. Raises ValueError if unsupported."""
v = value.strip()
if v.startswith("#"):
lin = _hex_to_linear(v)
else:
m = re.match(r"^([a-zA-Z]+)\((.*)\)$", v)
if not m or m.group(1).lower() not in _FUNC:
raise ValueError("unsupported color syntax: %s" % v)
raw = m.group(2).replace("/", " ").replace(",", " ")
args = [t for t in raw.split() if t]
lin = _FUNC[m.group(1).lower()](args)
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]


def contrast(l1, l2):
hi, lo = max(l1, l2), min(l1, l2)
return (hi + 0.05) / (lo + 0.05)

# ---------- theme.css parsing ----------

def _extract_block_vars(css, selector):
"""Collect --var: value pairs from every `selector { ... }` block (flat braces).

ponytail: naive brace scan — no nested @media handling; token blocks are flat
in StyleSeed themes. Upgrade path: a real CSS tokenizer if themes grow nesting.
"""
out = {}
for m in re.finditer(re.escape(selector) + r"\s*\{", css):
depth, i, start = 1, m.end(), m.end()
while i < len(css) and depth:
depth += {"{": 1, "}": -1}.get(css[i], 0)
i += 1
body = css[start:i - 1]
for vm in re.finditer(r"--([\w-]+)\s*:\s*([^;]+);", body):
out[vm.group(1)] = vm.group(2).strip()
return out


def _resolve(name, scope, depth=0):
if depth > 8 or name not in scope:
return None
val = scope[name]
vm = re.match(r"^var\(\s*--([\w-]+)\s*(?:,[^)]*)?\)$", val)
return _resolve(vm.group(1), scope, depth + 1) if vm else val

# ---------- pair table ----------

# (role_fg candidates — token names or '#literal', role_bg candidates, min ratio, label)
# The fg candidates mirror what StyleSeed components actually render: buttons/chips put
# brand-foreground (older skins: hardcoded white) on brand/destructive, and badge/tooltip/
# checkbox put primary-foreground on primary. Don't pair primary-foreground with brand —
# no component renders that combination.
PAIRS = [
(["foreground", "text-primary"], ["background"], 4.5, "body text on page"),
(["foreground", "text-primary"], ["card"], 4.5, "body text on card"),
(["muted-foreground", "text-secondary"], ["background"], 4.5, "muted text on page"),
(["muted-foreground", "text-secondary"], ["card"], 4.5, "muted text on card"),
(["brand", "primary"], ["background"], 3.0, "brand UI on page (3:1 non-text)"),
(["brand-foreground", "#ffffff"], ["brand"], 4.5, "label on brand button"),
(["primary-foreground"], ["primary"], 4.5, "label on primary (badge/tooltip)"),
(["destructive-foreground", "#ffffff"], ["destructive"], 4.5, "label on destructive"),
]


def check_file(path):
try:
css = open(path, encoding="utf-8").read()
except OSError as e:
print("🔴 ERROR cannot read %s: %s" % (path, e))
return 2
root = _extract_block_vars(css, ":root")
dark_only = _extract_block_vars(css, ".dark")
if not root and not dark_only:
print("🔴 ERROR no :root/.dark custom properties found in %s" % path)
return 2
scopes = [("light", root)]
if dark_only:
merged = dict(root)
merged.update(dark_only)
scopes.append(("dark", merged))

fails, skipped, checked = 0, 0, 0
for scope_name, scope in scopes:
for fg_names, bg_names, minimum, label in PAIRS:
fg_name = next((n for n in fg_names if n.startswith("#") or n in scope), None)
bg_name = next((n for n in bg_names if n in scope), None)
if not fg_name or not bg_name:
continue # pair not present in this theme — not an error
fg_val = fg_name if fg_name.startswith("#") else _resolve(fg_name, scope)
bg_val = _resolve(bg_name, scope)
fg_disp = fg_name if fg_name.startswith("#") else "--" + fg_name
try:
ratio = contrast(luminance(fg_val), luminance(bg_val))
except (ValueError, TypeError) as e:
skipped += 1
print("🟡 SKIP [%s] %s (%s on --%s): %s" % (scope_name, label, fg_disp, bg_name, e))
continue
checked += 1
mark = "🟢 PASS" if ratio >= minimum else "🔴 FAIL"
fails += 0 if ratio >= minimum else 1
print("%s [%s] %s: %s on --%s = %.2f:1 (min %.1f)"
% (mark, scope_name, label, fg_disp, bg_name, ratio, minimum))
print("Contrast: %d checked, %d failed, %d skipped" % (checked, fails, skipped))
return 1 if fails else 0

# ---------- self-test ----------

def self_test():
assert abs(contrast(luminance("#ffffff"), luminance("#000000")) - 21.0) < 1e-6
assert abs(contrast(luminance("#767676"), luminance("#ffffff")) - 4.54) < 0.02
assert abs(luminance("hsl(0 100% 50%)") - luminance("#ff0000")) < 1e-6
assert abs(luminance("oklch(0.627955 0.257683 29.2338)") - luminance("#ff0000")) < 0.005
assert abs(luminance("rgb(255 0 0)") - luminance("#ff0000")) < 1e-9
assert abs(luminance("oklch(100% 0 0)") - 1.0) < 0.001
scope = {"a": "var(--b)", "b": "#fff"}
assert _resolve("a", scope) == "#fff"
for bad in ("#ffffff80", "rgba(0,0,0,0.5)", "color-mix(in srgb, red, blue)"):
try:
luminance(bad)
raise AssertionError("should have raised: %s" % bad)
except ValueError:
pass
print("OK — self-test passed (7 groups)")
return 0


if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "--self-test":
sys.exit(self_test())
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
sys.exit(check_file(sys.argv[1]))
Loading