diff --git a/.gitignore b/.gitignore index 654d4ca..d245541 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ # Promo & blog (local only) showcase/PROMO*.md showcase/blog-*.md +__pycache__/ diff --git a/README.md b/README.md index 3fee945..338289d 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/demo-pricing/.engine/components/patterns/chart-card.tsx b/demo-pricing/.engine/components/patterns/chart-card.tsx index e5a7091..c1f28a2 100644 --- a/demo-pricing/.engine/components/patterns/chart-card.tsx +++ b/demo-pricing/.engine/components/patterns/chart-card.tsx @@ -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", )} > diff --git a/demo-pricing/.engine/components/patterns/donut-chart-card.tsx b/demo-pricing/.engine/components/patterns/donut-chart-card.tsx index 0487b98..2a3d3ab 100644 --- a/demo-pricing/.engine/components/patterns/donut-chart-card.tsx +++ b/demo-pricing/.engine/components/patterns/donut-chart-card.tsx @@ -56,7 +56,7 @@ function DonutChartCard({ {title} -
+
{chartElement} {(centerValue !== undefined || centerLabel) && ( diff --git a/demo-pricing/.engine/components/patterns/hero-card.tsx b/demo-pricing/.engine/components/patterns/hero-card.tsx index e8063f0..79bd2a7 100644 --- a/demo-pricing/.engine/components/patterns/hero-card.tsx +++ b/demo-pricing/.engine/components/patterns/hero-card.tsx @@ -31,7 +31,7 @@ function HeroCard({
@@ -67,7 +67,7 @@ function RankedList({ {item.name}

{item.badge && ( - + {item.badge} )} diff --git a/demo-pricing/.engine/components/ui/button.tsx b/demo-pricing/.engine/components/ui/button.tsx index 736c83f..56c8217 100644 --- a/demo-pricing/.engine/components/ui/button.tsx +++ b/demo-pricing/.engine/components/ui/button.tsx @@ -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]", @@ -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", diff --git a/demo-pricing/.engine/components/ui/confirm-modal.tsx b/demo-pricing/.engine/components/ui/confirm-modal.tsx index 551ad3c..b2b7c7a 100644 --- a/demo-pricing/.engine/components/ui/confirm-modal.tsx +++ b/demo-pricing/.engine/components/ui/confirm-modal.tsx @@ -66,8 +66,10 @@ function ConfirmModal({ diff --git a/engine/.claude/skills/ss-lint/SKILL.md b/engine/.claude/skills/ss-lint/SKILL.md index 659e2fc..659af14 100644 --- a/engine/.claude/skills/ss-lint/SKILL.md +++ b/engine/.claude/skills/ss-lint/SKILL.md @@ -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 ``` diff --git a/engine/.claude/skills/ss-lint/scripts/contrast_check.py b/engine/.claude/skills/ss-lint/scripts/contrast_check.py new file mode 100644 index 0000000..37a88db --- /dev/null +++ b/engine/.claude/skills/ss-lint/scripts/contrast_check.py @@ -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])) diff --git a/engine/.claude/skills/ss-page/SKILL.md b/engine/.claude/skills/ss-page/SKILL.md index 37e9ee3..edb43a0 100644 --- a/engine/.claude/skills/ss-page/SKILL.md +++ b/engine/.claude/skills/ss-page/SKILL.md @@ -20,6 +20,8 @@ Description: $ARGUMENTS ## Instructions 1. Read the design system reference: + - `STYLESEED.md` (project root) FIRST — the Design Lock incl. **Brand intent**; obey its + "Never" constraints in every section and keep the reference's implied traits visible - `CLAUDE.md` for file structure and conventions - `components/patterns/page-shell.tsx` for page layout - `components/patterns/top-bar.tsx` for header pattern @@ -72,4 +74,5 @@ export default function PageName() { - [ ] Spacing uses 6px multiples (p-1.5, p-3, p-6) - [ ] `mx-6` for single cards, `px-6` for grids/carousels - [ ] Touch targets ≥ 44px on all interactive elements + - [ ] Brand intent respected — no "Never" violation, implied traits present (skip if no lock) If any violation is found, fix it before presenting the page to the user. diff --git a/engine/.claude/skills/ss-setup/SKILL.md b/engine/.claude/skills/ss-setup/SKILL.md index afbb10c..956fe00 100644 --- a/engine/.claude/skills/ss-setup/SKILL.md +++ b/engine/.claude/skills/ss-setup/SKILL.md @@ -42,6 +42,13 @@ What type of app are you building? Remember the answer — it determines which page composition recipe to use (DESIGN-LANGUAGE.md Section 63). +Then ask the **surface** (same message is fine): +``` +Is this primarily a mobile app or a desktop/web (B2B) product? +``` +The surface decides the type scale (Golden Rule 16: desktop/web B2B body ≥16px, +mobile-tight allows 14px) — record it for the lock. + ### Step 2: Brand Color Ask: @@ -100,6 +107,21 @@ If they pick a brand (options 1-7 or 9): If they pick 8 (No thanks): skip, keep current brand color from Step 2. +**Then, whatever they chose, distill a Brand intent** (goes into the lock in Step 5): +- **Reference** — ONE concrete reference (the brand they picked, or a product/era/artifact + they name). Never bare adjectives like "modern, clean". +- **Implied traits** — 3 concrete attributes that reference implies (e.g. Stripe → + "restrained color · dense type · precise borders"). Spelling these out anchors the + reference so every model/agent reads it the same way. +- **Never** — 3 negative constraints the reference rules out (e.g. "no gradients · + no glassmorphism · no decorative serif"). +- **Feeling** — one line: what the user should feel in 3 seconds. +In the SAME confirmation message, also propose the **Mood** (4 axes with defaults from +the chosen skin: Edges sharp/soft/pill · Feel minimal/expressive · Density airy/compact · +Tone calm/playful) and the **Motion seed** derived from Tone (calm → Silk or Spring, +playful → Pulse; skin default wins: Toss → Spring, Stripe/Notion → Silk, Linear → Snap). +Confirm everything in ONE message, then move on. + ### Step 4: Font Ask: @@ -156,11 +178,19 @@ Then: # StyleSeed — Design Lock - App domain: [Step 1 app type] + - Surface: [mobile-app | desktop-web (B2B) — from Step 1] + - Mood: [edges · feel · density · tone — from Step 3, e.g. soft · minimal · airy · calm] - Skin: [Step 3 concept, or "custom"] - Key color (accent): [Step 2 hex] # the ONLY accent — everything else greyscale - Radius personality: [sharp | soft | pill — one everywhere] - - Motion seed: [Spring | Silk | Snap | Float | Pulse] - - Type: [Step 4 font] + - Motion seed: [Spring | Silk | Snap | Float | Pulse — from Step 3 Tone] + - Font: [Step 4 font] + - Type scale: [mobile-tight | desktop (body 16-18px) — from Surface] + - Brand intent: + - Reference: [Step 3 reference — ONE concrete product/era/artifact] + - Implied traits: [3 attributes the reference implies] + - Never: [3 negative constraints] + - Feeling: [one line] - Locked: [today] ``` Tell the user this file is the source of truth — editing a value changes it project-wide, diff --git a/engine/.claude/skills/ss-update/SKILL.md b/engine/.claude/skills/ss-update/SKILL.md index e7a7046..e182bdc 100644 --- a/engine/.claude/skills/ss-update/SKILL.md +++ b/engine/.claude/skills/ss-update/SKILL.md @@ -21,9 +21,10 @@ Automatically detect and update StyleSeed files in the current project. Updating is **safe and reversible**. Updates are additive — new rules, components, skins, and skills get added; your `theme.css`, your components, and -your app code are never overwritten, and design rules only ever get added (never -changed in a breaking way). A big version jump looks like a lot changed, but -it's almost all additions. **Do NOT warn the user that the build will break** +your app code are never overwritten, and design rules almost only ever get added. +Rarely an existing rule VALUE is corrected (e.g. the 2026-07 grid-unit fix, 8px → 6px); +those are surfaced one-by-one in the Step 4 changed-rule gate — never applied silently. +A big version jump looks like a lot changed, but it's almost all additions. **Do NOT warn the user that the build will break** unless you actually find a changed component/import API. Tell them: commit first, copy the new rules + skills, run a build, and `git reset --hard` if anything is off — they can't permanently break their project. @@ -115,16 +116,31 @@ For each update, in order: For DESIGN-LANGUAGE.md: - Show diff summary: how many new rules, what sections added +- **Changed-rule gate (design-diff):** diff the Golden Rules and enforcement values + (grid unit, radius scale, type minimums, accent policy) specifically. Any rule that + CHANGED (not merely added) gets its own ⚠️ line — old value → new value → what it + re-flags in this project — and a per-item OK before applying. A changed enforcement + value can flip existing passing screens to failing, so it is never bundled into a + blanket "Y". - Ask: "Update DESIGN-LANGUAGE.md? (Y/N)" - If yes: copy to the detected location +For theme.css — `--brand-foreground` (v2.9 components reference it): +- If the updated components use `text-brand-foreground` but the project's theme.css + has no `--brand-foreground`, offer to APPEND it (one line per scope + the + `--color-brand-foreground: var(--brand-foreground);` mapping in `@theme inline`). + Pick the value by contrast: white if it reaches ≥ 4.5:1 on the project's `--brand` + (run the `/ss-lint` contrast script to verify), otherwise the skin's darkest ink. + This is ADDITIVE only — never modify existing token values. + For CLAUDE.md (Golden Rules): - Check if Golden Rules section already exists - If not: ask "Add Golden Rules section to your CLAUDE.md? This adds 10 lines at the top. Your existing content stays untouched." - If yes: insert Golden Rules after the first heading **Never touch:** -- theme.css — say "Your theme.css (skin) is untouched." +- theme.css — say "Your theme.css (skin) is untouched." (Sole exception: the additive + `--brand-foreground` append above, and only with the user's OK.) - components/ — say "Your components are untouched. Run `/ss-lint` to check compliance." ### Step 5: Summary diff --git a/engine/.cursorrules b/engine/.cursorrules index e10f599..2b7eec9 100644 --- a/engine/.cursorrules +++ b/engine/.cursorrules @@ -17,7 +17,7 @@ Follow the StyleSeed design language strictly. Full reference: https://github.co # Layout - Mobile-first, max-w-[430px], bg-surface-page. - Page structure: PageShell > TopBar > PageContent (space-y-6) > BottomNav. -- 4 section types: Full Card (mx-6), Grid (px-6 grid-cols-2 gap-4), Carousel (px-6 flex gap-3 overflow-x-auto), Hero (mx-6 p-8). +- 4 section types: Full Card (mx-6), Grid (px-6 grid-cols-2 gap-4), Carousel (px-6 flex gap-3 overflow-x-auto), Hero (mx-6 p-9). - Section spacing: space-y-6 between all sections. No section dividers. - Bottom padding: pb-24 for BottomNav clearance. diff --git a/engine/AGENTS.md b/engine/AGENTS.md index 21f0d5c..6379918 100644 --- a/engine/AGENTS.md +++ b/engine/AGENTS.md @@ -16,7 +16,7 @@ build any UI, dashboard, page, or component in this project, follow the rules be 2. Single accent color (--brand) — everything else grayscale 3. No pure black (#000) — darkest text is defined by the skin (~#2A2A2A) 4. Numbers 2:1 with units — 48px number + 24px unit, always - 5. One spatial rhythm on the 8px grid — mobile: space-y-6 · mx-6 · px-6; desktop: container + gap-6/gap-8 + 5. One spatial rhythm on the 6px grid (all spacing = multiples of 6px) — mobile: space-y-6 · mx-6 · px-6; desktop: container + gap-6/gap-9 6. Never repeat the same section type consecutively — create visual rhythm 7. Elevation, one language: LIGHT = layered shadows ≤ 8%; DARK = tonal surface ramp + hairline borders (shadows don't read on dark) 8. Touch targets ≥ 44×44px on touch surfaces; pointer-first desktop controls may be 36–40px (keep focus rings) @@ -62,11 +62,22 @@ live only in chat memory and drift. Fix with a project design-lock file: - Radius personality: soft (12px) # sharp 0-4 | soft 8-12 | pill — one everywhere - Motion seed: Spring # Spring | Silk | Snap | Float | Pulse - Type scale: desktop (body 16-18px) # mobile-tight | desktop-larger +- Brand intent: # prose carries intent — tokens are context, not instructions + - Reference: Toss home tab # ONE concrete reference (product/era/artifact) — never bare adjectives + - Implied traits: flat surfaces · single hue + grey · generous whitespace # 3 traits the reference implies — spell them out so every model tier reads it the same + - Never: gradients · glassmorphism · decorative serif # 3 negative constraints — what this design is NOT + - Feeling: calm, in-control, scannable in 3 seconds ``` When the user later says "make it more X," update the lock *and* the UI together. The lock is what keeps the result consistent across prompts — without it, even perfect rules drift. +**Why Brand intent works (concept ported from google-labs-code/design.md):** a specific +reference carries more constraint than a dozen adjectives, and a strong reference implies its +own "Never" list for free. But reference interpretation varies by model — so the lock always +pairs the reference with its 3 implied traits as an explicit anchor. A reference alone is +FORBIDDEN in the lock; adjectives alone ("modern, clean") are equally forbidden. + ## Quick Setup — MANDATORY before building (consistency comes from constraints) **Not optional.** If there's no `STYLESEED.md` lock and you're about to build UI, this is the @@ -89,6 +100,9 @@ regulation → **Toss `#3182F6`**; premium SaaS → **Stripe**; dev/dark → **L compact) · **Tone** → motion+saturation (calm · playful). Default from skin (Toss → soft· minimal·airy·calm; Linear → sharp·minimal·compact·calm), let the user tweak ("sharper corners"), lock all four. This is what makes it feel *chosen*, not defaulted. + Then capture **Brand intent**: ONE concrete reference (a product, era, or artifact — not + adjectives), the 3 traits it implies, 3 "Never" constraints, and a one-line feeling — + confirm with the user and write all of it into the lock (see the Brand intent template). 3. **Accent (key color)** — a domain-fit color or skin (see Smart defaults), or the user's brand hex. **One accent only; everything else greyscale.** 4. **Font** — recommend by skin/language, don't leave the default: Korean/CJK → Pretendard · @@ -115,7 +129,7 @@ between "looks generated" and "looks designed." Never show UI that hasn't passed □ Color=meaning — normal/OK/"보통" rows GREY; color only the minority needing attention; no rainbow list; same value → same color □ Hierarchy — one clear primary; numbers 2:1 with unit -□ Layout — content in cards; 8px rhythm; gap-around-group > gap-inside +□ Layout — content in cards; 6px rhythm; gap-around-group > gap-inside □ States — empty + loading + error on every data surface (static mockup / landing with no data surface → N/A, don't fail) □ Copy — buttons name the action; errors help, not blame diff --git a/engine/CLAUDE.md b/engine/CLAUDE.md index ea1276c..950fa84 100644 --- a/engine/CLAUDE.md +++ b/engine/CLAUDE.md @@ -10,8 +10,8 @@ The engine provides layout rules, components, and skills. The skin provides colo 2. Single accent color (--brand) — everything else grayscale 3. No pure black (#000) — darkest text is defined by skin (~#2A2A2A) 4. Numbers 2:1 with units — 48px number + 24px unit, always - 5. One spatial rhythm on the 8px grid — mobile: space-y-6 · mx-6 · px-6; desktop: same - principle via container + gap-6/gap-8 (don't mix off-grid one-offs) + 5. One spatial rhythm on the 6px grid (all spacing = multiples of 6px) — mobile: space-y-6 · + mx-6 · px-6; desktop: same principle via container + gap-6/gap-9 (don't mix off-grid one-offs) 6. Never repeat same section type consecutively — create visual rhythm 7. Elevation, one language: LIGHT = layered shadows ≤ 8% opacity (if visible, too strong); DARK = shadows don't read — tonal surface ramp (page < card < raised) + hairline borders @@ -68,6 +68,11 @@ design-lock file.** Before building any UI: - Imagery palette: (optional) sand #E5CBAA · oak #D9B084 · charcoal #3A2E27 # locked content tones, not accents - Semantic resolve: (if accent ≈ green/red) positive-progress uses accent; success reserved for confirmation moments - Signature move: (optional) oversized serif index on the hero step ONLY # one treatment, not a uniform (CC-9c) +- Brand intent: # prose carries intent — tokens are context, not instructions + - Reference: Toss home tab # ONE concrete reference (product/era/artifact) — never bare adjectives + - Implied traits: flat surfaces · single hue + grey · generous whitespace # 3 traits the reference implies — spell them out so every model tier reads it the same + - Never: gradients · glassmorphism · decorative serif # 3 negative constraints — what this design is NOT + - Feeling: calm, in-control, scannable in 3 seconds - Locked: 2026-06-23 ``` @@ -75,6 +80,12 @@ Keep it short and human-editable. When the user later says "make it more X," upd *and* the UI so they never diverge. **The lock is what makes the result consistent across prompts** — without it, even perfect rules drift. +**Why Brand intent works (concept ported from google-labs-code/design.md):** a specific +reference carries more constraint than a dozen adjectives, and a strong reference implies its +own "Never" list for free. But reference interpretation varies by model — so the lock always +pairs the reference with its 3 implied traits as an explicit anchor. A reference alone is +FORBIDDEN in the lock; adjectives alone ("modern, clean") are equally forbidden. + ## Quick Setup — MANDATORY before building (consistency comes from constraints) **This is not optional.** If there is no `STYLESEED.md` lock in the project and you are about @@ -117,6 +128,9 @@ Run this setup with the user (in plan mode), then build: compact·calm · Arc → soft·expressive·airy·playful), let the user tweak in their words ("make the corners sharper", "more playful"), then **lock all four**. One mood → one radius, one shadow language, one density, one motion — applied everywhere. + Then capture **Brand intent**: ONE concrete reference (a product, era, or artifact — not + adjectives), the 3 traits it implies, 3 "Never" constraints, and a one-line feeling — + confirm with the user and write all of it into the lock (see the Brand intent template). 3. **Accent (key color)** — recommend a domain-fit color or skin (see Smart defaults). If the user has a brand hex, use it. **One accent only; everything else greyscale.** Skins: Toss/Stripe/Linear/Notion/Raycast/Arc/Vercel. @@ -163,7 +177,7 @@ demo was reviewed and fixed, not a first draft. **Never show the user UI that ha □ Color=meaning — normal/OK/"보통" rows are GREY; color marks only the minority that needs attention; no rainbow list; same value → same color (§65, CL-2a) □ Hierarchy — one clear primary per screen; numbers 2:1 with unit; sizes from the table -□ Layout — content in cards (not bare bg); 8px rhythm; gap-around-group > gap-inside +□ Layout — content in cards (not bare bg); 6px rhythm; gap-around-group > gap-inside □ States — every data surface has empty + loading + error (not just the full state). Static mockup / marketing landing with no data surface → mark N/A, don't fail □ Copy — buttons name the action ("Send $2,400" not "Submit"); errors help, not blame @@ -214,6 +228,7 @@ Modify in `:root` of `src/styles/theme.css`: | Variable | Purpose | Default | |----------|---------|---------| | `--brand` | Brand accent color | Defined by skin (e.g. `#721FE5` for toss) | +| `--brand-foreground` | Label on brand surfaces (buttons/chips) — components use `text-brand-foreground`, never hardcoded white | `#FFFFFF` (skins with a light brand use dark ink) | | `--primary` | Buttons, links, primary UI | `#030213` | | `--destructive` | Error/danger | `#d4183d` | | `--success` | Success indicator | `#6B9B7A` | @@ -365,8 +380,8 @@ escape the "default sans everything" look. Set both in the lock and `css/fonts.c ### Spacing - Uses Tailwind default utilities -- **One base grid: 8px** (`p-2`/`p-4`/`p-6`/`p-8` — 4px allowed as a half-step for icon↔label gaps). - This matches VISUAL-CRAFT CR-1; don't mix in 6/10/14px one-offs (`p-1.5`, `gap-2.5`, `py-3.5`). +- **One base grid: 6px** (`p-1.5`/`p-3`/`p-6`/`p-9` — all margin/padding/gap in multiples of 6px). + This matches VISUAL-CRAFT CR-1; don't mix in 8/10/14px one-offs (`p-2`, `gap-2.5`, `py-3.5`). - Page horizontal padding: `px-6` (24px) - Between sections: `space-y-6` (24px) @@ -762,6 +777,7 @@ Exact contrast ratios depend on your skin's color values. Verify your skin meets | `--foreground` | 7:1+ | Body text | | `--muted-foreground` | 4.5:1+ | Secondary text | | `--brand` | 4.5:1+ | Accent (verify with your brand color) | +| `--brand-foreground` | 4.5:1+ vs `--brand` | Button/chip labels on brand | | `--destructive` | 4.5:1+ | Error | | `--warning` | 4.5:1+ | Warning text | | `--success` | 3:1+ | Large text/icons only | diff --git a/engine/DESIGN-LANGUAGE.md b/engine/DESIGN-LANGUAGE.md index 8a4436c..1319835 100644 --- a/engine/DESIGN-LANGUAGE.md +++ b/engine/DESIGN-LANGUAGE.md @@ -400,7 +400,7 @@ Rules: Normal row: bg-surface-subtle (#FAFAF9) My row: bg-brand-tint (#F0E8FF) + border-2 border-brand Normal rank: bg-surface-muted (#E8E6E1) + text-text-tertiary -My rank: bg-brand + text-white +My rank: bg-brand + text-brand-foreground Normal name: text-text-primary (#3C3C3C) My name: text-brand ``` @@ -427,14 +427,14 @@ If there are 2-4 options, use a **Pill toggle**. If 5 or more, **separate into a ### Pattern 1: Capsule Pill Toggle ``` Container: bg-surface-muted rounded-full p-1 -Active button: bg-brand text-white rounded-full shadow +Active button: bg-brand text-brand-foreground rounded-full shadow Inactive: text-text-disabled (#9B9B9B) ``` | Property | Active | Inactive | |----------|--------|----------| | Background | `bg-brand` | transparent | -| Text | `text-white` | `text-text-disabled` | +| Text | `text-brand-foreground` | `text-text-disabled` | | Corners | `rounded-full` | `rounded-full` | | Shadow | `shadow-sm` | none | | Size | 11px bold | 11px bold | @@ -442,7 +442,7 @@ Inactive: text-text-disabled (#9B9B9B) ```tsx
-