diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..8b311a3f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "allow": [] + } +} diff --git a/.claude/skills/generate-design-md/SKILL.md b/.claude/skills/generate-design-md/SKILL.md new file mode 100644 index 00000000..2829f722 --- /dev/null +++ b/.claude/skills/generate-design-md/SKILL.md @@ -0,0 +1,207 @@ +--- +name: generate-design-md +description: Regenerate the Optics DESIGN.md design token file and enrich it with CSS usage documentation. Use this skill when asked to update design tokens, regenerate DESIGN.md, run `yarn design-tokens`, sync token docs, or add context and usage examples to the token file. Always use this skill before suggesting CSS variable changes to a consumer of the Optics design system. +allowed-tools: Bash(yarn design-tokens) Bash(yarn design-tokens:lint) +compatibility: Designed for Claude Code (or similar products) +metadata: + author: rolemodel + version: "1.0" + triggers: "Generate DESIGN.md, build a design file, build a DESIGN.md, generate a design file" +license: MIT +--- + +# Design Tokens Skill + +Regenerate `dist/DESIGN.md` from CSS source, verify it passes the linter, then append human-readable usage documentation to help developers — and AI coding agents — work with the Optics design system correctly. + +## Step 1: Regenerate + +```bash +yarn design-tokens +``` + +If this fails, report the error and stop — do not proceed to lint or append. + +## Step 2: Lint + +```bash +yarn design-tokens:lint +``` + +If lint fails, report the errors and stop. The DESIGN.md is not valid until lint passes. + +## Step 3: Read CSS source for context + +Read these files before writing any documentation: + +- `src/core/tokens/base_tokens.css` — all primitive token declarations with inline comments +- `src/core/tokens/scale_color_tokens.css` — the color scale structure and light/dark mode patterns +- Every `.css` file in `src/components/` except `index.css` — real component usage patterns + +As you read, note: +- Every `var(--op-...)` usage — these are the tokens in practice +- The box-shadow border technique (borders avoid `border:` property; they use `box-shadow` so borders don't affect layout) +- How components expose a public customization API with `--_op-` prefixed vars at the top of their rule +- How components use `--__op-` vars internally (private, derived from the public `--_op-` vars) +- Color naming — the `plus`/`minus` luminosity scale and how `on-` variants pair with backgrounds +- Any inline comments that explain why a pattern exists (especially workarounds or surprises) + +## Step 4: Add usage documentation to generated-docs/DESIGN.md + +Read `generated-docs/DESIGN.md`. The file structure is: +1. YAML frontmatter (between the opening and closing `---`) +2. Markdown body (after the closing `---`) — includes a `## Shapes` section with content + +**Never modify the YAML block or any existing markdown content.** + +The [DESIGN.md spec](https://github.com/google-labs-code/design.md) defines a required section order: + +1. Overview +2. Colors +3. Typography +4. Layout +5. Elevation & Depth +6. Shapes ← already present in generated file +7. Components +8. Do's and Don'ts + +Sections 1–5 must be **inserted before `## Shapes`** to maintain spec order. Sections 7–8 and any Optics-specific sections must be **appended after the very last line of the file**. + +After all edits, ensure the file ends with a single trailing newline. + +--- + +### Sections to insert BEFORE `## Shapes` + +Insert these immediately before the `## Shapes` heading, drawing on what you found in the CSS: + +#### Section: Overview + +A concise description of the Optics design system's personality and purpose: a CSS-only, utility-forward system built on semantic color scales, logical spacing, and component-scoped customization. Mention light/dark mode is automatic via `light-dark()`. + +#### Section: Colors + +Explain the semantic luminosity scale used across all palettes: + +- Scale runs from `plus-max` (lightest) → `plus-eight` through `plus-one` → `base` → `minus-one` through `minus-eight` → `minus-max` (darkest) +- `plus-*` steps are lighter; `minus-*` steps are darker +- Every step has a paired `-on-*` color guaranteed readable as text on that background +- An `-alt` variant of each `-on-*` color provides a secondary/muted text option +- Palettes: `primary`, `neutral`, `alerts-warning`, `alerts-danger`, `alerts-info`, `alerts-notice` +- Light/dark mode is automatic — all scale colors use `light-dark()`, no class toggling needed + +Include a concrete example: + +```css +.my-card { + background-color: var(--op-color-primary-plus-eight); + color: var(--op-color-primary-on-plus-eight); +} +.my-card:hover { + background-color: var(--op-color-primary-plus-seven); + color: var(--op-color-primary-on-plus-seven); +} +``` + +#### Section: Typography + +Document every non-color token in this group with a reference table. Include: +- Font size scale (`--op-font-2x-small` through `--op-font-6x-large`) — pixel values and common use +- Font weight tokens (`--op-font-weight-thin` through `--op-font-weight-black`) — numeric values +- Font family tokens (`--op-font-family`, `--op-font-family-alt`) — family names +- Line height tokens (`--op-line-height-none` through `--op-line-height-loosest`) — numeric values +- Letter spacing tokens (`--op-letter-spacing-navigation`, `--op-letter-spacing-label`) — values and use + +Show a composition example demonstrating how the tokens work together. + +#### Section: Layout + +Document every non-color token in this group with a reference table. Include: +- Full spacing scale (`--op-space-3x-small` through `--op-space-4x-large`) — pixel values +- `--op-size-unit` (4px) — for icon sizing and fine-grained layout +- Breakpoint tokens (`--op-breakpoint-x-small` through `--op-breakpoint-x-large`) — pixel values and viewport descriptions + +Note that breakpoints are reference values only — CSS does not support custom properties in `@media` queries. + +Show a usage example for padding and gap. + +#### Section: Elevation & Depth + +Document every non-color token in this group with a reference table. Include: +- Shadow scale (`--op-shadow-x-small` through `--op-shadow-x-large`) — use cases +- Opacity tokens (`--op-opacity-none` through `--op-opacity-full`) — values and use cases +- Input height tokens (`--op-input-height-small` through `--op-input-height-x-large`) — pixel values +- Input focus ring tokens (`--op-input-focus-primary` through `--op-input-focus-notice`) — note these are pre-composed `box-shadow` values applied directly on `:focus-visible` +- Z-index layers (`--op-z-index-header` through `--op-z-index-tooltip`) — values and layer descriptions + +Note that elevation in Optics is expressed through shadows rather than color shifts. + +--- + +### Sections to append AFTER `## Shapes` (after its content, at end of file) + +#### Section: Components + +Show the pattern for customizing a component instance using its public API vars (`--_op-*`), and explain that these overrides are scoped — they only affect components inside the selector: + +```css +/* Make buttons taller in a specific form */ +.my-form .btn { + --_op-btn-height-medium: 44px; +} +``` + +Remind the reader that `--__op-` vars (double underscore) are internal and should never be set directly from outside the component. To know what a component exposes, check its CSS file for `--_op-` declarations at the top of the root rule. + +#### Section: Do's and Don'ts + +Practical guardrails drawn from real component patterns in the CSS source: + +- Do pair every background color step with its matching `-on-*` text color +- Do use `box-shadow` for borders — never the `border` property — to preserve layout dimensions +- Do use the named transition tokens (`--op-transition-input`, `--op-transition-modal`, etc.) rather than writing raw durations +- Don't set `--__op-*` (double underscore) vars from outside a component +- Don't hardcode pixel values for spacing or font sizes — use the token scale +- Don't toggle a class to switch color schemes; `light-dark()` handles it automatically + +#### Section: CSS Custom Property Conventions + +Explain the three-tier naming system (Optics-specific, not in the spec): + +- `--op-*` — Public design tokens. Use in any CSS to tap into the system. +- `--_op-*` — Component public API. Override on a parent selector to customize an instance. +- `--__op-*` — Component private implementation. Derived from `--_op-*`; never set externally. + +#### Section: Borders + +Explain that Optics renders borders via `box-shadow` instead of `border`. This preserves layout — box-shadow does not affect element dimensions or document flow. + +Show usage examples (outline, inset, multiple borders). + +Document every non-color token in this group with a reference table. Include: +- Border direction tokens (`--op-border-all`, `--op-border-top`, etc.) — directions, noting that `--op-border-x` and `--op-border-y` are pre-composed with `var(--op-color-border)` +- Border width tokens (`--op-border-width`, `--op-border-width-large`, `--op-border-width-x-large`) — pixel values and typical use (default, focus inner ring, focus outer ring) +- Border radius tokens (`--op-radius-small` through `--op-radius-pill`) — pixel values and use cases + +#### Section: Transitions and Animation + +List named transitions and their intended contexts: + +- `--op-transition-input` — hover/focus changes on interactive controls (120ms, fast/snappy) +- `--op-transition-accordion` — rotation of disclosure markers (120ms) +- `--op-transition-accordion-content` — accordion panel open/close (300ms, height + content-visibility) +- `--op-transition-modal` — modal appear/disappear (300ms) +- `--op-transition-sidebar` — sidebar slide in/out (200ms) +- `--op-transition-panel` — side panel entry from right (400ms) +- `--op-transition-tooltip` — tooltip delayed reveal (300ms + 300ms delay) + +--- + +## Format and tone + +- Write for a developer who knows CSS but is new to Optics +- Use `##` for top-level sections, `###` for subsections +- Code examples for every pattern — prefer one-liners where sufficient, multi-line when structure matters +- Keep prose tight — let examples carry the explanation +- Do not duplicate values already in the YAML frontmatter (the token values are there; this section is about _how to use_ them) +- If the CSS source reveals any surprising patterns or workarounds not covered above, document them too diff --git a/.claude/skills/wrap-up/SKILL.md b/.claude/skills/wrap-up/SKILL.md new file mode 100644 index 00000000..bca56dd5 --- /dev/null +++ b/.claude/skills/wrap-up/SKILL.md @@ -0,0 +1,42 @@ +--- +name: wrap-up +description: End-of-session skill that updates project instructions (AGENTS.md) and captures learnings. Use when finishing a work session or when the user says they're done, want to wrap up, close out, save progress, end the session, or "that's it for today." +allowed-tools: Read, Write, Edit, Glob +compatibility: Designed for Claude Code (or similar products) +metadata: + author: rolemodel + version: "1.0" + triggers: "wrap up, wrap-up, wrap-up session, end session, save progress, that's it for today, close out" +license: MIT +--- + +# Wrap Up Session + +Perform the following steps to close out the current session. Do each step in order. + +## Step 1: Update AGENTS.md + +Review the full conversation for any learnings, decisions, or context that should persist in the project instructions. Look for: +- New conventions or patterns established +- Workflow preferences expressed by the user +- Technical decisions made +- Any corrections to existing instructions + +**What NOT to add to AGENTS.md:** +- Individual file paths or file inventories -- files move, and the agent can find them with Glob/Grep. Describe directory purposes and conventions instead. +- Information the agent can discover from the code, git history, or config files +- Anything that duplicates what linters, formatters, or config files already enforce +- Granular per-file descriptions -- if a directory's purpose is clear, individual files don't need entries + +Keep AGENTS.md focused on actionable guidance: conventions, decisions, workflows, and gotchas. Aim for under 150 lines. + +If `AGENTS.md` already exists, read it and edit it with any updates. If no updates are needed, say so and move on. + +If `AGENTS.md` does not exist yet, create it. Draw the content from whatever is available: an existing `CLAUDE.md`, anything the user has described about the project structure and purpose during the conversation, or both. The goal is a complete, accurate set of project instructions an AI assistant would need to work effectively in this codebase. + +After updating `AGENTS.md`, ensure `CLAUDE.md` exists and contains `@AGENTS.md` as its only content (so Claude Code sources the shared file). If `CLAUDE.md` already contains `@AGENTS.md`, leave it alone. Otherwise create or replace it — this is intentional migration of the instructions to `AGENTS.md` as the single source of truth. + +## Step 2: Confirm completion + +Summarize what was updated: +- What changed in `AGENTS.md` (or that no changes were needed) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f9b6f407 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,114 @@ +# Optics — Agent Instructions + +Optics is a CSS-only design system by RoleModel Software. It ships as an npm package consumed by Rails and other web apps. There is no JavaScript, no build framework for components — just CSS custom properties, BEM-style component classes, and Storybook for documentation. + +## Project layout + +``` +src/ + core/ + tokens/ + base_tokens.css # Primitive tokens: spacing, typography, borders, transitions + scale_color_tokens.css # Color scale (plus/minus luminosity steps, light-dark()) + base.css + layout.css + utilities.css + components/ # One file per component (BEM classes) + addons/ # Optional add-on stylesheets +dist/ # Build output (do not edit by hand) +generated-docs/ # Generated design token docs (DESIGN.md, design-tokens.json) +plans/ # Planning and reference docs (not enforced in code) +tools/ # Node scripts for code generation +src/stories/ # Storybook stories and documentation +``` + +## CSS conventions + +### Token naming + +- `--op-*` — Public design tokens. Available globally. Use these in component CSS and consumer apps. +- `--_op-*` — Component public API. Defined at the top of a component rule with defaults. Consumers can override these on a parent selector to customize an instance. +- `--__op-*` — Component private implementation. Derived from `--_op-*`; used internally. Never set from outside the component. + +### Color system + +Colors follow a luminosity scale borrowed from photography f-stops: + +``` +plus-max · plus-eight → plus-one · base · minus-one → minus-eight · minus-max +``` + +- `plus-*` = lighter, `minus-*` = darker +- Every step has a paired `-on-*` color guaranteed readable as text on that background +- An `-alt` variant exists for secondary/muted text +- Light/dark mode is handled automatically via CSS `light-dark()` — no class toggling needed +- Palettes: `primary`, `neutral`, `alerts-warning`, `alerts-danger`, `alerts-info`, `alerts-notice` + +### Borders + +Borders are rendered with `box-shadow` (not `border:`) so they don't affect layout or element dimensions: + +```css +box-shadow: var(--op-border-all) var(--op-color-border); +box-shadow: inset var(--op-border-all) var(--op-color-neutral-plus-four); +``` + +Direction tokens: `--op-border-all`, `--op-border-top`, `--op-border-right`, `--op-border-bottom`, `--op-border-left`, `--op-border-x`, `--op-border-y`, `--op-border-none` + +### Component structure (BEM) + +```css +.component-name { + /* --_op- public API vars first */ + /* --__op- private resolved vars next */ + /* base styles */ + + .component-name__element { } + + &.component-name--modifier { } + &.component-name--variant { } +} +``` + +Modifiers nest inside the root rule using `&.component-name--variant`. See `src/components/button.css` or `src/components/badge.css` for reference. + +## Common tasks + +### Adding a component + +Follow `NEW_COMPONENT.md`. Create `src/components/{name}.css` and add it to `src/components/index.css`. Add a Storybook story under `src/stories/Components/`. + +### Updating design tokens + +Run `/generate_design_md` (Claude Code skill) or manually: + +```sh +yarn design-tokens # regenerates generated-docs/DESIGN.md +yarn design-tokens:lint # validates the output +``` + +### Building + +```sh +yarn build # full build to dist/ +yarn lint # JS + CSS lint +yarn storybook # local Storybook dev server +``` + +### Storybook token docs + +Tag CSS token groups for Storybook with: + +```css +/** + * @tokens Group Name + * @presenter Color /* or: Spacing, FontSize, FontWeight, Shadow, etc. */ + */ +``` + +## Key constraints + +- No JavaScript in components. Interactivity is handled by consumers. +- Do not edit files in `dist/` or `generated-docs/` by hand — they are generated. +- CSS is written in modern CSS (nesting, `light-dark()`, logical properties like `padding-block`). +- `src/addons/` contains optional stylesheets that consumers opt into separately; keep them independent of core. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/generated-docs/DESIGN.md b/generated-docs/DESIGN.md new file mode 100644 index 00000000..6fd64e09 --- /dev/null +++ b/generated-docs/DESIGN.md @@ -0,0 +1,859 @@ +--- +version: alpha +name: Optics +description: RoleModel Software's Optics design system. +colors: + white: "#ffffff" + black: "#000000" + primary: "#336cc1" + primary-plus-max: "#ffffff" + primary-plus-eight: "#f7f9fd" + primary-plus-seven: "#eff4fb" + primary-plus-six: "#e7eef9" + primary-plus-five: "#d7e3f4" + primary-plus-four: "#bfd1ee" + primary-plus-three: "#86aadf" + primary-plus-two: "#6e99d8" + primary-plus-one: "#3065b5" + primary-base: "#2b5aa1" + primary-minus-one: "#275191" + primary-minus-two: "#224881" + primary-minus-three: "#1e3f71" + primary-minus-four: "#1a3661" + primary-minus-five: "#152d51" + primary-minus-six: "#112440" + primary-minus-seven: "#091220" + primary-minus-eight: "#040910" + primary-minus-max: "#000000" + primary-on-plus-max: "#000000" + primary-on-plus-max-alt: "#152d51" + primary-on-plus-eight: "#040910" + primary-on-plus-eight-alt: "#1a3661" + primary-on-plus-seven: "#091220" + primary-on-plus-seven-alt: "#1e3f71" + primary-on-plus-six: "#112440" + primary-on-plus-six-alt: "#1c3b69" + primary-on-plus-five: "#152d51" + primary-on-plus-five-alt: "#2b5aa1" + primary-on-plus-four: "#1a3661" + primary-on-plus-four-alt: "#040910" + primary-on-plus-three: "#152d51" + primary-on-plus-three-alt: "#0b1728" + primary-on-plus-two: "#112440" + primary-on-plus-two-alt: "#060e18" + primary-on-plus-one: "#ffffff" + primary-on-plus-one-alt: "#ebf1fa" + primary-on-base: "#ffffff" + primary-on-base-alt: "#cfddf2" + primary-on-minus-one: "#e7eef9" + primary-on-minus-one-alt: "#b6ccec" + primary-on-minus-two: "#d7e3f4" + primary-on-minus-two-alt: "#a6c0e7" + primary-on-minus-three: "#c7d7f0" + primary-on-minus-three-alt: "#96b5e3" + primary-on-minus-four: "#bfd1ee" + primary-on-minus-four-alt: "#8eafe1" + primary-on-minus-five: "#cfddf2" + primary-on-minus-five-alt: "#a6c0e7" + primary-on-minus-six: "#e7eef9" + primary-on-minus-six-alt: "#b6ccec" + primary-on-minus-seven: "#eff4fb" + primary-on-minus-seven-alt: "#bfd1ee" + primary-on-minus-eight: "#f7f9fd" + primary-on-minus-eight-alt: "#c7d7f0" + primary-on-minus-max: "#ffffff" + primary-on-minus-max-alt: "#cfddf2" + neutral: "#76797f" + neutral-plus-max: "#ffffff" + neutral-plus-eight: "#fafafa" + neutral-plus-seven: "#f4f5f5" + neutral-plus-six: "#eff0f0" + neutral-plus-five: "#e4e5e7" + neutral-plus-four: "#d5d6d8" + neutral-plus-three: "#afb2b6" + neutral-plus-two: "#a0a2a7" + neutral-plus-one: "#6c6f75" + neutral-base: "#62656a" + neutral-minus-one: "#585b5f" + neutral-minus-two: "#4e5155" + neutral-minus-three: "#45474a" + neutral-minus-four: "#3b3d40" + neutral-minus-five: "#313335" + neutral-minus-six: "#27282a" + neutral-minus-seven: "#141415" + neutral-minus-eight: "#0a0a0b" + neutral-minus-max: "#000000" + neutral-on-plus-max: "#000000" + neutral-on-plus-max-alt: "#62656a" + neutral-on-plus-eight: "#0a0a0b" + neutral-on-plus-eight-alt: "#53565a" + neutral-on-plus-seven: "#141415" + neutral-on-plus-seven-alt: "#45474a" + neutral-on-plus-six: "#27282a" + neutral-on-plus-six-alt: "#585b5f" + neutral-on-plus-five: "#313335" + neutral-on-plus-five-alt: "#62656a" + neutral-on-plus-four: "#3b3d40" + neutral-on-plus-four-alt: "#0a0a0b" + neutral-on-plus-three: "#313335" + neutral-on-plus-three-alt: "#18191b" + neutral-on-plus-two: "#27282a" + neutral-on-plus-two-alt: "#0f0f10" + neutral-on-plus-one: "#ffffff" + neutral-on-plus-one-alt: "#f2f2f3" + neutral-on-base: "#ffffff" + neutral-on-base-alt: "#e4e5e7" + neutral-on-minus-one: "#eff0f0" + neutral-on-minus-one-alt: "#d5d6d8" + neutral-on-minus-two: "#e4e5e7" + neutral-on-minus-two-alt: "#c5c6c9" + neutral-on-minus-three: "#dadbdd" + neutral-on-minus-three-alt: "#babcbf" + neutral-on-minus-four: "#d5d6d8" + neutral-on-minus-four-alt: "#bfc1c4" + neutral-on-minus-five: "#dfe0e2" + neutral-on-minus-five-alt: "#bfc1c4" + neutral-on-minus-six: "#eff0f0" + neutral-on-minus-six-alt: "#cfd1d3" + neutral-on-minus-seven: "#f4f5f5" + neutral-on-minus-seven-alt: "#d5d6d8" + neutral-on-minus-eight: "#fafafa" + neutral-on-minus-eight-alt: "#dadbdd" + neutral-on-minus-max: "#ffffff" + neutral-on-minus-max-alt: "#dfe0e2" + alerts-warning: "#ffd438" + alerts-warning-plus-max: "#ffffff" + alerts-warning-plus-eight: "#fffdf5" + alerts-warning-plus-seven: "#fffbeb" + alerts-warning-plus-six: "#fff8e0" + alerts-warning-plus-five: "#fff4cc" + alerts-warning-plus-four: "#ffe999" + alerts-warning-plus-three: "#ffde66" + alerts-warning-plus-two: "#ffd747" + alerts-warning-plus-one: "#ffc800" + alerts-warning-base: "#cca000" + alerts-warning-minus-one: "#856800" + alerts-warning-minus-two: "#705800" + alerts-warning-minus-three: "#5c4800" + alerts-warning-minus-four: "#473800" + alerts-warning-minus-five: "#3d3000" + alerts-warning-minus-six: "#332800" + alerts-warning-minus-seven: "#141000" + alerts-warning-minus-eight: "#0a0800" + alerts-warning-minus-max: "#000000" + alerts-warning-on-plus-max: "#000000" + alerts-warning-on-plus-max-alt: "#665000" + alerts-warning-on-plus-eight: "#141000" + alerts-warning-on-plus-eight-alt: "#7a6000" + alerts-warning-on-plus-seven: "#292000" + alerts-warning-on-plus-seven-alt: "#8f7000" + alerts-warning-on-plus-six: "#524000" + alerts-warning-on-plus-six-alt: "#856800" + alerts-warning-on-plus-five: "#665000" + alerts-warning-on-plus-five-alt: "#806400" + alerts-warning-on-plus-four: "#665000" + alerts-warning-on-plus-four-alt: "#000000" + alerts-warning-on-plus-three: "#665000" + alerts-warning-on-plus-three-alt: "#332800" + alerts-warning-on-plus-two: "#524000" + alerts-warning-on-plus-two-alt: "#1f1800" + alerts-warning-on-plus-one: "#665000" + alerts-warning-on-plus-one-alt: "#4d3c00" + alerts-warning-on-base: "#3d3000" + alerts-warning-on-base-alt: "#000000" + alerts-warning-on-minus-one: "#ffffff" + alerts-warning-on-minus-one-alt: "#fff2c2" + alerts-warning-on-minus-two: "#fffbeb" + alerts-warning-on-minus-two-alt: "#ffedad" + alerts-warning-on-minus-three: "#fff6d6" + alerts-warning-on-minus-three-alt: "#ffe999" + alerts-warning-on-minus-four: "#fff2c2" + alerts-warning-on-minus-four-alt: "#ffe485" + alerts-warning-on-minus-five: "#fff2c2" + alerts-warning-on-minus-five-alt: "#ffe485" + alerts-warning-on-minus-six: "#fff8e0" + alerts-warning-on-minus-six-alt: "#ffeba3" + alerts-warning-on-minus-seven: "#fffbeb" + alerts-warning-on-minus-seven-alt: "#ffedad" + alerts-warning-on-minus-eight: "#fffdf5" + alerts-warning-on-minus-eight-alt: "#fff0b8" + alerts-warning-on-minus-max: "#ffffff" + alerts-warning-on-minus-max-alt: "#fff2c2" + alerts-danger: "#fe8585" + alerts-danger-plus-max: "#ffffff" + alerts-danger-plus-eight: "#fff5f5" + alerts-danger-plus-seven: "#ffebeb" + alerts-danger-plus-six: "#ffe1e1" + alerts-danger-plus-five: "#ffcccc" + alerts-danger-plus-four: "#ffaeae" + alerts-danger-plus-three: "#fe6767" + alerts-danger-plus-two: "#fe4848" + alerts-danger-plus-one: "#fe0101" + alerts-danger-base: "#cb0101" + alerts-danger-minus-one: "#b70101" + alerts-danger-minus-two: "#a20101" + alerts-danger-minus-three: "#8e0101" + alerts-danger-minus-four: "#7a0101" + alerts-danger-minus-five: "#650101" + alerts-danger-minus-six: "#510000" + alerts-danger-minus-seven: "#290000" + alerts-danger-minus-eight: "#140000" + alerts-danger-minus-max: "#000000" + alerts-danger-on-plus-max: "#000000" + alerts-danger-on-plus-max-alt: "#650101" + alerts-danger-on-plus-eight: "#140000" + alerts-danger-on-plus-eight-alt: "#7a0101" + alerts-danger-on-plus-seven: "#290000" + alerts-danger-on-plus-seven-alt: "#8e0101" + alerts-danger-on-plus-six: "#510000" + alerts-danger-on-plus-six-alt: "#b70101" + alerts-danger-on-plus-five: "#650101" + alerts-danger-on-plus-five-alt: "#980101" + alerts-danger-on-plus-four: "#7a0101" + alerts-danger-on-plus-four-alt: "#140000" + alerts-danger-on-plus-three: "#650101" + alerts-danger-on-plus-three-alt: "#330000" + alerts-danger-on-plus-two: "#510000" + alerts-danger-on-plus-two-alt: "#1e0000" + alerts-danger-on-plus-one: "#290000" + alerts-danger-on-plus-one-alt: "#0f0000" + alerts-danger-on-base: "#ffffff" + alerts-danger-on-base-alt: "#ffdbdb" + alerts-danger-on-minus-one: "#ffe1e1" + alerts-danger-on-minus-one-alt: "#ffc7c7" + alerts-danger-on-minus-two: "#ffcccc" + alerts-danger-on-minus-two-alt: "#ffa9a9" + alerts-danger-on-minus-three: "#ffb8b8" + alerts-danger-on-minus-three-alt: "#fe9494" + alerts-danger-on-minus-four: "#ffaeae" + alerts-danger-on-minus-four-alt: "#fe8a8a" + alerts-danger-on-minus-five: "#ffc2c2" + alerts-danger-on-minus-five-alt: "#ff9f9f" + alerts-danger-on-minus-six: "#ffe1e1" + alerts-danger-on-minus-six-alt: "#ffbdbd" + alerts-danger-on-minus-seven: "#ffebeb" + alerts-danger-on-minus-seven-alt: "#ffc7c7" + alerts-danger-on-minus-eight: "#fff5f5" + alerts-danger-on-minus-eight-alt: "#ffd1d1" + alerts-danger-on-minus-max: "#ffffff" + alerts-danger-on-minus-max-alt: "#ffdbdb" + alerts-info: "#336cc1" + alerts-info-plus-max: "#ffffff" + alerts-info-plus-eight: "#f7f9fd" + alerts-info-plus-seven: "#eff4fb" + alerts-info-plus-six: "#e7eef9" + alerts-info-plus-five: "#d7e3f4" + alerts-info-plus-four: "#bfd1ee" + alerts-info-plus-three: "#86aadf" + alerts-info-plus-two: "#6e99d8" + alerts-info-plus-one: "#4e82d0" + alerts-info-base: "#2b5aa1" + alerts-info-minus-one: "#275191" + alerts-info-minus-two: "#224881" + alerts-info-minus-three: "#1e3f71" + alerts-info-minus-four: "#1a3661" + alerts-info-minus-five: "#152d51" + alerts-info-minus-six: "#112440" + alerts-info-minus-seven: "#091220" + alerts-info-minus-eight: "#040910" + alerts-info-minus-max: "#000000" + alerts-info-on-plus-max: "#000000" + alerts-info-on-plus-max-alt: "#152d51" + alerts-info-on-plus-eight: "#040910" + alerts-info-on-plus-eight-alt: "#1a3661" + alerts-info-on-plus-seven: "#091220" + alerts-info-on-plus-seven-alt: "#1e3f71" + alerts-info-on-plus-six: "#112440" + alerts-info-on-plus-six-alt: "#275191" + alerts-info-on-plus-five: "#152d51" + alerts-info-on-plus-five-alt: "#2b5aa1" + alerts-info-on-plus-four: "#1a3661" + alerts-info-on-plus-four-alt: "#040910" + alerts-info-on-plus-three: "#152d51" + alerts-info-on-plus-three-alt: "#0b1728" + alerts-info-on-plus-two: "#112440" + alerts-info-on-plus-two-alt: "#060e18" + alerts-info-on-plus-one: "#091220" + alerts-info-on-plus-one-alt: "#03070c" + alerts-info-on-base: "#ffffff" + alerts-info-on-base-alt: "#cfddf2" + alerts-info-on-minus-one: "#e7eef9" + alerts-info-on-minus-one-alt: "#b6ccec" + alerts-info-on-minus-two: "#d7e3f4" + alerts-info-on-minus-two-alt: "#a6c0e7" + alerts-info-on-minus-three: "#c7d7f0" + alerts-info-on-minus-three-alt: "#96b5e3" + alerts-info-on-minus-four: "#bfd1ee" + alerts-info-on-minus-four-alt: "#8eafe1" + alerts-info-on-minus-five: "#cfddf2" + alerts-info-on-minus-five-alt: "#9ebbe5" + alerts-info-on-minus-six: "#e7eef9" + alerts-info-on-minus-six-alt: "#b6ccec" + alerts-info-on-minus-seven: "#eff4fb" + alerts-info-on-minus-seven-alt: "#bfd1ee" + alerts-info-on-minus-eight: "#f7f9fd" + alerts-info-on-minus-eight-alt: "#c7d7f0" + alerts-info-on-minus-max: "#ffffff" + alerts-info-on-minus-max-alt: "#cfddf2" + alerts-notice: "#6bdb7e" + alerts-notice-plus-max: "#ffffff" + alerts-notice-plus-eight: "#f7fdf8" + alerts-notice-plus-seven: "#effbf1" + alerts-notice-plus-six: "#e6f9e9" + alerts-notice-plus-five: "#d6f5db" + alerts-notice-plus-four: "#bdefc6" + alerts-notice-plus-three: "#84e193" + alerts-notice-plus-two: "#6bdb7e" + alerts-notice-plus-one: "#2fc147" + alerts-notice-base: "#28a43d" + alerts-notice-minus-one: "#208330" + alerts-notice-minus-two: "#1c732a" + alerts-notice-minus-three: "#1a6b27" + alerts-notice-minus-four: "#165a21" + alerts-notice-minus-five: "#124a1b" + alerts-notice-minus-six: "#104218" + alerts-notice-minus-seven: "#08210c" + alerts-notice-minus-eight: "#041006" + alerts-notice-minus-max: "#000000" + alerts-notice-on-plus-max: "#000000" + alerts-notice-on-plus-max-alt: "#14521e" + alerts-notice-on-plus-eight: "#041006" + alerts-notice-on-plus-eight-alt: "#186324" + alerts-notice-on-plus-seven: "#08210c" + alerts-notice-on-plus-seven-alt: "#1c732a" + alerts-notice-on-plus-six: "#104218" + alerts-notice-on-plus-six-alt: "#1a6b27" + alerts-notice-on-plus-five: "#14521e" + alerts-notice-on-plus-five-alt: "#1e7b2d" + alerts-notice-on-plus-four: "#186324" + alerts-notice-on-plus-four-alt: "#041006" + alerts-notice-on-plus-three: "#14521e" + alerts-notice-on-plus-three-alt: "#0a290f" + alerts-notice-on-plus-two: "#104218" + alerts-notice-on-plus-two-alt: "#061909" + alerts-notice-on-plus-one: "#08210c" + alerts-notice-on-plus-one-alt: "#030c05" + alerts-notice-on-base: "#08210c" + alerts-notice-on-base-alt: "#000000" + alerts-notice-on-minus-one: "#ffffff" + alerts-notice-on-minus-one-alt: "#effbf1" + alerts-notice-on-minus-two: "#effbf1" + alerts-notice-on-minus-two-alt: "#d2f4d8" + alerts-notice-on-minus-three: "#def7e2" + alerts-notice-on-minus-three-alt: "#c1f0c9" + alerts-notice-on-minus-four: "#cef3d4" + alerts-notice-on-minus-four-alt: "#b1ecbb" + alerts-notice-on-minus-five: "#cef3d4" + alerts-notice-on-minus-five-alt: "#9ce7a9" + alerts-notice-on-minus-six: "#e6f9e9" + alerts-notice-on-minus-six-alt: "#b5edbe" + alerts-notice-on-minus-seven: "#effbf1" + alerts-notice-on-minus-seven-alt: "#bdefc6" + alerts-notice-on-minus-eight: "#f7fdf8" + alerts-notice-on-minus-eight-alt: "#c6f1cd" + alerts-notice-on-minus-max: "#ffffff" + alerts-notice-on-minus-max-alt: "#cef3d4" +opacity: + none: 0 + overlay: 0.2 + disabled: 0.4 + half: 0.5 + full: 1 +layout: + breakpoints: + x-small: 512px + small: 768px + medium: 1024px + large: 1280px + x-large: 1440px + border-width: + default: 1px + large: 2px + x-large: 4px + border: + none: 0 0 0 0 + all: 0 0 0 1px + top: 0 calc(-1 * 1px) 0 0 + right: 1px 0 0 0 + bottom: 0 1px 0 0 + left: calc(-1 * 1px) 0 0 0 + y: "0 calc(-1 * {layout.border.width}) 0 0 var(--op-color-border), 0 {layout.border.width} 0 0 var(--op-color-border)" + x: "calc(-1 * {layout.border.width}) 0 0 0 var(--op-color-border), {layout.border.width} 0 0 0 var(--op-color-border)" +rounded: + small: 2px + medium: 4px + large: 8px + x-large: 12px + 2x-large: 16px + pill: 9999px +typography: + font-size: + 2x-small: 10px + x-small: 12px + small: 14px + medium: 16px + large: 18px + x-large: 20px + 2x-large: 24px + 3x-large: 28px + 4x-large: 32px + 5x-large: 36px + 6x-large: 48px + font-weight: + thin: 100 + extra-light: 200 + light: 300 + normal: 400 + medium: 500 + semi-bold: 600 + bold: 700 + extra-bold: 800 + black: 900 + font-family: + default: "'Noto Sans', sans-serif" + alt: "'Noto Serif', serif" + line-height: + none: 0 + densest: 1 + denser: 1.15 + dense: 1.3 + base: 1.5 + loose: 1.6 + looser: 1.7 + loosest: 1.8 + letter-spacing: + navigation: 0.1px + label: 0.4px +animation: + accordion: rotate 120ms ease-in + accordion-content: "height 300ms ease, content-visibility 300ms ease allow-discrete" + input: all 120ms ease-in + sidebar: all 200ms ease-in-out + modal-time: 300ms + modal: all 300ms ease-in + panel: right 400ms ease-in + tooltip: all 300ms ease-in 300ms +assets: + dropdown-arrow: "url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik02IDguMzc1MDFMMCAyLjM3NTAxTDEuNCAwLjk3NTAwNkw2IDUuNTc1MDFMMTAuNiAwLjk3NTAwNkwxMiAyLjM3NTAxTDYgOC4zNzUwMVoiIGZpbGw9IiMwQTBBMEIiLz4KPC9zdmc+Cg==')" +spacing: + 3x-small: 2px + 2x-small: 4px + x-small: 8px + small: 12px + medium: 16px + large: 20px + x-large: 24px + 2x-large: 28px + 3x-large: 40px + 4x-large: 80px +elevation: + x-small: "0 1px 2px hsl(0deg 0% 0% / 3%), 0 1px 3px hsl(0deg 0% 0% / 15%)" + small: "0 1px 2px hsl(0deg 0% 0% / 3%), 0 2px 6px hsl(0deg 0% 0% / 15%)" + medium: "0 4px 8px hsl(0deg 0% 0% / 15%), 0 1px 3px hsl(0deg 0% 0% / 3%)" + large: "0 6px 10px hsl(0deg 0% 0% / 15%), 0 2px 3px hsl(0deg 0% 0% / 3%)" + x-large: "0 8px 12px hsl(0deg 0% 0% / 15%), 0 4px 4px hsl(0deg 0% 0% / 3%)" + z-index: + header: 500 + footer: 500 + sidebar: 700 + dialog: 800 + dialog-backdrop: 801 + dialog-content: 802 + dropdown: 900 + alert-group: 950 + tooltip: 1000 +inputs: + height: + small: 28px + medium: 36px + large: 40px + x-large: 84px +components: + alert: + rounded: "{rounded.medium}" + alert-warning: + backgroundColor: "{colors.alerts-warning-plus-eight}" + textColor: "{colors.alerts-warning-on-plus-eight}" + alert-danger: + backgroundColor: "{colors.alerts-danger-plus-eight}" + textColor: "{colors.alerts-danger-on-plus-eight}" + alert-info: + backgroundColor: "{colors.alerts-info-plus-eight}" + textColor: "{colors.alerts-info-on-plus-eight}" + alert-notice: + backgroundColor: "{colors.alerts-notice-plus-eight}" + textColor: "{colors.alerts-notice-on-plus-eight}" + badge: + rounded: "{rounded.medium}" + backgroundColor: "{colors.neutral-base}" + textColor: "{colors.neutral-on-base}" + padding: "{spacing.2x-small}" + badge-primary: + backgroundColor: "{colors.primary-base}" + textColor: "{colors.primary-on-base}" + badge-warning: + backgroundColor: "{colors.alerts-warning-base}" + textColor: "{colors.alerts-warning-on-base}" + badge-danger: + backgroundColor: "{colors.alerts-danger-base}" + textColor: "{colors.alerts-danger-on-base}" + badge-info: + backgroundColor: "{colors.alerts-info-base}" + textColor: "{colors.alerts-info-on-base}" + badge-notice: + backgroundColor: "{colors.alerts-notice-base}" + textColor: "{colors.alerts-notice-on-base}" + badge-pill: + rounded: "{rounded.pill}" + button: + rounded: "{rounded.medium}" + backgroundColor: "{colors.neutral-plus-eight}" + textColor: "{colors.neutral-on-plus-eight}" + button-active: + backgroundColor: "{colors.primary-plus-five}" + textColor: "{colors.primary-on-plus-five}" + button-no-border: + textColor: "{colors.primary-on-plus-max}" + button-pill: + rounded: "{rounded.pill}" + button-icon-with-label: + padding: "{spacing.small}" + button-primary: + backgroundColor: "{colors.primary-base}" + textColor: "{colors.primary-on-base}" + button-destructive: + backgroundColor: "{colors.alerts-danger-base}" + textColor: "{colors.alerts-danger-on-base}" + button-warning: + backgroundColor: "{colors.alerts-warning-base}" + textColor: "{colors.alerts-warning-on-base}" + content-header: + padding: "{spacing.medium}" + segmented-control: + padding: "{spacing.2x-small}" + rounded: "{rounded.medium}" + backgroundColor: "{colors.neutral-plus-eight}" + textColor: "{colors.neutral-on-plus-eight}" + table: + rounded: "{rounded.medium}" + tag: + padding: "{spacing.2x-small}" + rounded: "{rounded.pill}" + backgroundColor: "{colors.neutral-plus-four}" + textColor: "{colors.neutral-on-plus-four}" + tag-primary: + backgroundColor: "{colors.primary-base}" + textColor: "{colors.primary-on-base}" + tag-danger: + backgroundColor: "{colors.alerts-danger-base}" + textColor: "{colors.alerts-danger-on-base}" + tag-warning: + backgroundColor: "{colors.alerts-warning-base}" + textColor: "{colors.alerts-warning-on-base}" + tag-info: + backgroundColor: "{colors.alerts-info-base}" + textColor: "{colors.alerts-info-on-base}" + tag-notice: + backgroundColor: "{colors.alerts-notice-base}" + textColor: "{colors.alerts-notice-on-base}" + color-border: + color: "{colors.neutral-plus-five}" +--- + +## Overview + +Optics is a CSS-only design system by RoleModel Software. It provides a semantic color scale, logical spacing and typography, and BEM component classes — with no JavaScript. Light and dark mode are handled automatically via CSS `light-dark()`, requiring no class toggling from the consuming application. Components expose a public customization API through scoped CSS custom properties, keeping overrides predictable and contained. + +## Colors + +Colors are organized in a luminosity scale borrowed from photography f-stops. Each palette defines a full range of lightness steps while preserving its hue and saturation. + +**Scale:** `plus-max` (lightest) → `plus-eight` through `plus-one` → `base` → `minus-one` through `minus-eight` → `minus-max` (darkest) + +- `plus-*` steps are lighter; `minus-*` steps are darker +- Every step has a paired `-on-*` color guaranteed readable as text on that background +- An `-alt` variant of each `-on-*` color provides a secondary/muted text option +- Palettes: `primary`, `neutral`, `alerts-warning`, `alerts-danger`, `alerts-info`, `alerts-notice` +- Light and dark mode values are embedded directly in each token via `light-dark()` — no theme class needed + +```css +.my-card { + background-color: var(--op-color-primary-plus-eight); + color: var(--op-color-primary-on-plus-eight); +} +.my-card:hover { + background-color: var(--op-color-primary-plus-seven); + color: var(--op-color-primary-on-plus-seven); +} +``` + +## Typography + +Font sizes use `--op-font-scale-unit: 1rem`, which equals **10px** when the root `font-size` is `62.5%` (the Optics baseline). + +**Font sizes:** + +| Token | Value | Common use | +|---|---|---| +| `--op-font-2x-small` | 10px | Fine print, legal | +| `--op-font-x-small` | 12px | Captions, timestamps | +| `--op-font-small` | 14px | Labels, secondary text, buttons | +| `--op-font-medium` | 16px | Body text (default) | +| `--op-font-large` | 18px | Large body, lead text | +| `--op-font-x-large` | 20px | Small headings | +| `--op-font-2x-large` | 24px | Section headings | +| `--op-font-3x-large` | 28px | Page headings | +| `--op-font-4x-large` | 32px | Large headings | +| `--op-font-5x-large` | 36px | Display headings | +| `--op-font-6x-large` | 48px | Hero / display | + +**Font weights:** + +| Token | Value | +|---|---| +| `--op-font-weight-thin` | 100 | +| `--op-font-weight-extra-light` | 200 | +| `--op-font-weight-light` | 300 | +| `--op-font-weight-normal` | 400 | +| `--op-font-weight-medium` | 500 | +| `--op-font-weight-semi-bold` | 600 | +| `--op-font-weight-bold` | 700 | +| `--op-font-weight-extra-bold` | 800 | +| `--op-font-weight-black` | 900 | + +**Font families:** + +| Token | Value | +|---|---| +| `--op-font-family` | Noto Sans, sans-serif (default) | +| `--op-font-family-alt` | Noto Serif, serif | + +**Line heights:** + +| Token | Value | +|---|---| +| `--op-line-height-none` | 0 | +| `--op-line-height-densest` | 1 | +| `--op-line-height-denser` | 1.15 | +| `--op-line-height-dense` | 1.3 | +| `--op-line-height-base` | 1.5 | +| `--op-line-height-loose` | 1.6 | +| `--op-line-height-looser` | 1.7 | +| `--op-line-height-loosest` | 1.8 | + +**Letter spacing:** + +| Token | Value | Use for | +|---|---|---| +| `--op-letter-spacing-navigation` | 0.01rem | Nav items | +| `--op-letter-spacing-label` | 0.04rem | Labels, badges, caps text | + +```css +.my-label { + font-size: var(--op-font-small); /* 14px */ + font-weight: var(--op-font-weight-medium); + letter-spacing: var(--op-letter-spacing-label); + line-height: var(--op-line-height-dense); +} +``` + +## Layout + +The spacing scale is built on `--op-space-scale-unit: 1rem` (**10px** at `font-size: 62.5%`). A separate `--op-size-unit: 0.4rem` (4px) is available for icon sizing and fine-grained layout. + +**Spacing:** + +| Token | Value | +|---|---| +| `--op-space-3x-small` | 2px | +| `--op-space-2x-small` | 4px | +| `--op-space-x-small` | 8px | +| `--op-space-small` | 12px | +| `--op-space-medium` | 16px | +| `--op-space-large` | 20px | +| `--op-space-x-large` | 24px | +| `--op-space-2x-large` | 28px | +| `--op-space-3x-large` | 40px | +| `--op-space-4x-large` | 80px | + +```css +.my-panel { + padding: var(--op-space-medium); + gap: var(--op-space-x-small); +} +``` + +**Breakpoints** — reference values only. CSS does not support custom properties inside `@media` queries, so these cannot be used directly in breakpoint expressions; they document intended breakpoints for preprocessors or JavaScript. + +| Token | Value | Viewport | +|---|---|---| +| `--op-breakpoint-x-small` | 512px | Vertical phone | +| `--op-breakpoint-small` | 768px | Vertical iPad | +| `--op-breakpoint-medium` | 1024px | Landscape iPad | +| `--op-breakpoint-large` | 1280px | Small laptop | +| `--op-breakpoint-x-large` | 1440px | Medium laptop | + +## Elevation & Depth + +Elevation in Optics is expressed through box shadows, not color shifts. + +**Shadows:** + +| Token | Use for | +|---|---| +| `--op-shadow-x-small` | Subtle lift — inline elements, hover states | +| `--op-shadow-small` | Cards, list items | +| `--op-shadow-medium` | Floating panels, popovers | +| `--op-shadow-large` | Dropdowns, sidebars | +| `--op-shadow-x-large` | Modals, dialogs | + +**Opacities:** + +| Token | Value | Use for | +|---|---|---| +| `--op-opacity-none` | 0 | Fully invisible | +| `--op-opacity-overlay` | 0.2 | Modal backdrop tint | +| `--op-opacity-disabled` | 0.4 | Disabled elements | +| `--op-opacity-half` | 0.5 | Dimmed states | +| `--op-opacity-full` | 1 | Fully visible | + +**Input heights** — standardized heights for form controls and buttons: + +| Token | Value | +|---|---| +| `--op-input-height-small` | 28px | +| `--op-input-height-medium` | 36px | +| `--op-input-height-large` | 40px | +| `--op-input-height-x-large` | 84px (textarea) | + +**Input focus rings** — pre-composed `box-shadow` values. Apply directly as `box-shadow` on `:focus-visible`: + +| Token | Palette | +|---|---| +| `--op-input-focus-primary` | Primary | +| `--op-input-focus-neutral` | Neutral | +| `--op-input-focus-danger` | Danger | +| `--op-input-focus-warning` | Warning | +| `--op-input-focus-info` | Info | +| `--op-input-focus-notice` | Notice | + +**Z-index layers:** + +| Token | Value | Layer | +|---|---|---| +| `--op-z-index-header` | 500 | Page header | +| `--op-z-index-footer` | 500 | Page footer | +| `--op-z-index-sidebar` | 700 | Navigation sidebar | +| `--op-z-index-dialog` | 800 | Modal dialog | +| `--op-z-index-dialog-backdrop` | 801 | Dialog backdrop overlay | +| `--op-z-index-dialog-content` | 802 | Dialog content (above backdrop) | +| `--op-z-index-dropdown` | 900 | Dropdowns and select menus | +| `--op-z-index-alert-group` | 950 | Flash/toast alert group | +| `--op-z-index-tooltip` | 1000 | Tooltips (always on top) | + +## Shapes + +To render a fully circular element, apply `border-radius: 50%` directly. This is a layout instruction rather than a fixed dimension, so it is not included in the `rounded` token scale above. +## Components + +Components expose a public customization API through `--_op-*` CSS custom properties declared at the top of each component rule. Override these on a containing selector to adjust the component without touching its internals. Overrides are scoped — they only affect components inside the selector. + +```css +/* Make buttons taller in a specific toolbar */ +.my-toolbar .btn { + --_op-btn-height-medium: 44px; +} + +/* Adjust card padding in a compact sidebar */ +.my-sidebar .card { + --_op-card-padding: var(--op-space-x-small); +} +``` + +To discover what a component exposes, check its CSS file for `--_op-` declarations at the top of the root rule. Never set `--__op-*` (double-underscore) vars from outside a component — these are private resolved values derived from the public API. + +## Do's and Don'ts + +- Do pair every background color step with its matching `-on-*` text color (e.g. `--op-color-primary-plus-five` + `--op-color-primary-on-plus-five`) +- Do use `box-shadow` for borders — never the `border` property — to preserve element dimensions and document flow +- Do use named transition tokens (`--op-transition-input`, `--op-transition-modal`, etc.) rather than raw durations +- Do use `--op-opacity-disabled` on disabled elements rather than inventing a value +- Don't set `--__op-*` (double-underscore) vars from outside a component +- Don't hardcode pixel values for spacing or font sizes — use the token scale +- Don't toggle a class to switch color schemes; `light-dark()` handles it automatically + +## CSS Custom Property Conventions + +Optics uses a three-tier naming system: + +- `--op-*` — **Public design tokens.** Available globally on `:root`. Use in any CSS to tap into the system. +- `--_op-*` — **Component public API.** Declared at the top of a component rule with defaults. Override on a parent selector to customize an instance. +- `--__op-*` — **Component private implementation.** Derived from `--_op-*`; used only within the component. Never set externally. + +## Borders + +Optics renders borders via `box-shadow` instead of the `border` CSS property. This means borders never affect element dimensions or document flow — adding or removing a border causes no reflow. + +```css +/* Outline border */ +box-shadow: var(--op-border-all) var(--op-color-border); + +/* Inset border (common inside components) */ +box-shadow: inset var(--op-border-all) var(--op-color-neutral-plus-four); + +/* Multiple borders */ +box-shadow: + var(--op-border-top) var(--op-color-border), + var(--op-border-bottom) var(--op-color-border); +``` + +**Border direction tokens** — shadow offsets for use with `box-shadow: `: + +| Token | Direction | +|---|---| +| `--op-border-all` | All sides (spread) | +| `--op-border-top` | Top only | +| `--op-border-right` | Right only | +| `--op-border-bottom` | Bottom only | +| `--op-border-left` | Left only | +| `--op-border-x` | Left + right (pre-composed with `--op-color-border`) | +| `--op-border-y` | Top + bottom (pre-composed with `--op-color-border`) | +| `--op-border-none` | No border | + +Note: `--op-border-x` and `--op-border-y` are already composed with `var(--op-color-border)` — use them as the complete `box-shadow` value without appending a color. + +**Border widths:** + +| Token | Value | Use for | +|---|---|---| +| `--op-border-width` | 1px | Default border | +| `--op-border-width-large` | 2px | Focus inner ring | +| `--op-border-width-x-large` | 4px | Focus outer ring | + +**Border radius:** + +| Token | Value | Use for | +|---|---|---| +| `--op-radius-small` | 2px | Subtle rounding | +| `--op-radius-medium` | 4px | Buttons, inputs, cards (default) | +| `--op-radius-large` | 8px | Modals, larger panels | +| `--op-radius-x-large` | 12px | Cards with prominent rounding | +| `--op-radius-2x-large` | 16px | Featured/hero elements | +| `--op-radius-circle` | 50% | Avatars, icon buttons | +| `--op-radius-pill` | 9999px | Pill badges, pill buttons | + +## Transitions and Animation + +Use named transition tokens to keep motion consistent across the system: + +| Token | Duration | Use for | +|---|---|---| +| `--op-transition-input` | 120ms | Hover/focus on buttons, inputs, interactive controls | +| `--op-transition-accordion` | 120ms | Rotation of disclosure chevron/marker | +| `--op-transition-accordion-content` | 300ms | Accordion panel open/close (height + content-visibility) | +| `--op-transition-modal` | 300ms | Modal appear/disappear | +| `--op-transition-sidebar` | 200ms | Sidebar slide in/out | +| `--op-transition-panel` | 400ms | Side panel entry from the right | +| `--op-transition-tooltip` | 300ms + 300ms delay | Tooltip delayed reveal | + +A single animation token also exists for flash/toast alerts: `--op-animation-flash` runs a 5s slide-in-hold-slide-out sequence. diff --git a/generated-docs/design-tokens.json b/generated-docs/design-tokens.json new file mode 100644 index 00000000..7adc0c3b --- /dev/null +++ b/generated-docs/design-tokens.json @@ -0,0 +1,870 @@ +{ + "op-space": { + "$type": "dimension", + "scale-unit": { + "$value": "1rem", + "$description": "10px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-scale-unit)" + } + } + }, + "3x-small": { + "$value": "0.2rem", + "$description": "2px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-3x-small)" + } + } + }, + "2x-small": { + "$value": "0.4rem", + "$description": "4px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-2x-small)" + } + } + }, + "x-small": { + "$value": "0.8rem", + "$description": "8px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-x-small)" + } + } + }, + "small": { + "$value": "1.2rem", + "$description": "12px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-small)" + } + } + }, + "medium": { + "$value": "1.6rem", + "$description": "16px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-medium)" + } + } + }, + "large": { + "$value": "2rem", + "$description": "20px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-large)" + } + } + }, + "x-large": { + "$value": "2.4rem", + "$description": "24px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-x-large)" + } + } + }, + "2x-large": { + "$value": "2.8rem", + "$description": "28px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-2x-large)" + } + } + }, + "3x-large": { + "$value": "4rem", + "$description": "40px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-3x-large)" + } + } + }, + "4x-large": { + "$value": "8rem", + "$description": "80px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT", + "GAP", + "PARAGRAPH_SPACING", + "PARAGRAPH_INDENT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-space-4x-large)" + } + } + } + }, + "op-radius": { + "$type": "dimension", + "small": { + "$value": "2px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-small)" + } + } + }, + "medium": { + "$value": "4px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-medium)" + } + } + }, + "large": { + "$value": "8px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-large)" + } + } + }, + "x-large": { + "$value": "12px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-x-large)" + } + } + }, + "2x-large": { + "$value": "16px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-2x-large)" + } + } + }, + "circle": { + "$value": "50%", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-circle)" + } + } + }, + "pill": { + "$value": "9999px", + "$extensions": { + "com.figma.scopes": [ + "CORNER_RADIUS" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-radius-pill)" + } + } + } + }, + "op-breakpoint": { + "$type": "dimension", + "x-small": { + "$value": "512px", + "$description": "vertical phone", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-breakpoint-x-small)" + } + } + }, + "small": { + "$value": "768px", + "$description": "vertical ipad", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-breakpoint-small)" + } + } + }, + "medium": { + "$value": "1024px", + "$description": "landscape ipad", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-breakpoint-medium)" + } + } + }, + "large": { + "$value": "1280px", + "$description": "small laptop", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-breakpoint-large)" + } + } + }, + "x-large": { + "$value": "1440px", + "$description": "medium laptop", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-breakpoint-x-large)" + } + } + } + }, + "op-input-height": { + "$type": "dimension", + "small": { + "$value": "2.8rem", + "$description": "28px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-input-height-small)" + } + } + }, + "medium": { + "$value": "3.6rem", + "$description": "36px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-input-height-medium)" + } + } + }, + "large": { + "$value": "4rem", + "$description": "40px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-input-height-large)" + } + } + }, + "x-large": { + "$value": "8.4rem", + "$description": "84px", + "$extensions": { + "com.figma.scopes": [ + "WIDTH_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-input-height-x-large)" + } + } + } + }, + "op-border": { + "width": { + "$type": "dimension", + "default": { + "$value": "1px", + "$extensions": { + "com.figma.scopes": [ + "STROKE_FLOAT", + "EFFECT_FLOAT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-border-width)" + } + } + }, + "large": { + "$value": "2px", + "$extensions": { + "com.figma.scopes": [ + "STROKE_FLOAT", + "EFFECT_FLOAT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-border-width-large)" + } + } + }, + "x-large": { + "$value": "4px", + "$extensions": { + "com.figma.scopes": [ + "STROKE_FLOAT", + "EFFECT_FLOAT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-border-width-x-large)" + } + } + } + } + }, + "op-font": { + "$type": "dimension", + "scale-unit": { + "$value": "1rem", + "$description": "10px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-scale-unit)" + } + } + }, + "2x-small": { + "$value": "1rem", + "$description": "10px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-2x-small)" + } + } + }, + "x-small": { + "$value": "1.2rem", + "$description": "12px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-x-small)" + } + } + }, + "small": { + "$value": "1.4rem", + "$description": "14px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-small)" + } + } + }, + "medium": { + "$value": "1.6rem", + "$description": "16px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-medium)" + } + } + }, + "large": { + "$value": "1.8rem", + "$description": "18px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-large)" + } + } + }, + "x-large": { + "$value": "2rem", + "$description": "20px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-x-large)" + } + } + }, + "2x-large": { + "$value": "2.4rem", + "$description": "24px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-2x-large)" + } + } + }, + "3x-large": { + "$value": "2.8rem", + "$description": "28px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-3x-large)" + } + } + }, + "4x-large": { + "$value": "3.2rem", + "$description": "32px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-4x-large)" + } + } + }, + "5x-large": { + "$value": "3.6rem", + "$description": "36px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-5x-large)" + } + } + }, + "6x-large": { + "$value": "4.8rem", + "$description": "48px", + "$extensions": { + "com.figma.scopes": [ + "FONT_SIZE" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-6x-large)" + } + } + }, + "weight": { + "$type": "number", + "thin": { + "$value": 100, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-thin)" + } + } + }, + "extra-light": { + "$value": 200, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-extra-light)" + } + } + }, + "light": { + "$value": 300, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-light)" + } + } + }, + "normal": { + "$value": 400, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-normal)" + } + } + }, + "medium": { + "$value": 500, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-medium)" + } + } + }, + "semi-bold": { + "$value": 600, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-semi-bold)" + } + } + }, + "bold": { + "$value": 700, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-bold)" + } + } + }, + "extra-bold": { + "$value": 800, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-extra-bold)" + } + } + }, + "black": { + "$value": 900, + "$extensions": { + "com.figma.scopes": [ + "FONT_WEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-weight-black)" + } + } + } + }, + "family": { + "$type": "fontFamily", + "default": { + "$value": "'Noto Sans', sans-serif", + "$extensions": { + "com.figma.scopes": [ + "FONT_FAMILY" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-family)" + } + } + }, + "alt": { + "$value": "'Noto Serif', serif", + "$extensions": { + "com.figma.scopes": [ + "FONT_FAMILY" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-font-family-alt)" + } + } + } + } + }, + "op-line-height": { + "$type": "number", + "none": { + "$value": 0, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-none)" + } + } + }, + "densest": { + "$value": 1, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-densest)" + } + } + }, + "denser": { + "$value": 1.15, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-denser)" + } + } + }, + "dense": { + "$value": 1.3, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-dense)" + } + } + }, + "base": { + "$value": 1.5, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-base)" + } + } + }, + "loose": { + "$value": 1.6, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-loose)" + } + } + }, + "looser": { + "$value": 1.7, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-looser)" + } + } + }, + "loosest": { + "$value": 1.8, + "$extensions": { + "com.figma.scopes": [ + "LINE_HEIGHT" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-line-height-loosest)" + } + } + } + }, + "op-letter-spacing": { + "$type": "dimension", + "navigation": { + "$value": "0.01rem", + "$extensions": { + "com.figma.scopes": [ + "LETTER_SPACING" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-letter-spacing-navigation)" + } + } + }, + "label": { + "$value": "0.04rem", + "$extensions": { + "com.figma.scopes": [ + "LETTER_SPACING" + ], + "com.figma.codeSyntax": { + "WEB": "var(--op-letter-spacing-label)" + } + } + } + }, + "op-z-index": { + "$type": "number", + "header": { + "$value": 500, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-header)" + } + } + }, + "footer": { + "$value": 500, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-footer)" + } + } + }, + "sidebar": { + "$value": 700, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-sidebar)" + } + } + }, + "dialog": { + "$value": 800, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-dialog)" + } + } + }, + "dialog-backdrop": { + "$value": 801, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-dialog-backdrop)" + } + } + }, + "dialog-content": { + "$value": 802, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-dialog-content)" + } + } + }, + "dropdown": { + "$value": 900, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-dropdown)" + } + } + }, + "alert-group": { + "$value": 950, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-alert-group)" + } + } + }, + "tooltip": { + "$value": 1000, + "$extensions": { + "com.figma.codeSyntax": { + "WEB": "var(--op-z-index-tooltip)" + } + } + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index a7394866..c2475d35 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ "prettier": "prettier -w .", "prettier-check": "prettier -c .", "sanity-check": "yarn lint && yarn prettier && yarn build && yarn build-storybook && rm -rf ./dist && rm -rf ./storybook-static", - "generate": "node ./tools/generate.js" + "generate": "node ./tools/generate.js", + "design-tokens": "node ./tools/generate-design-tokens.js", + "dtcg-tokens": "node ./tools/generate-dtcg-tokens.js", + "design-tokens:lint": "npx @google/design.md lint generated-docs/DESIGN.md", + "design-tokens:export": "npx @google/design.md export --format dtcg generated-docs/DESIGN.md > generated-docs/design-tokens.json" }, "repository": { "type": "git", diff --git a/plans/DESIGN_MD_NOTES.md b/plans/DESIGN_MD_NOTES.md new file mode 100644 index 00000000..7031fe85 --- /dev/null +++ b/plans/DESIGN_MD_NOTES.md @@ -0,0 +1,15 @@ +# design.md Notes + +**Repo:** https://github.com/google-labs-code/design.md +**Spec docs:** https://stitch.withgoogle.com/docs/design-md/specification +**Summary:** A format specification for describing a visual identity to coding agents. A `DESIGN.md` file gives agents a persistent, structured understanding of a design system. + +--- + +## Notes + +* Colors are limited to Hex in srgb. This means that hsl is not supported and colors have to be converted when generating a DESIGN.md document. +* CSS variables that are partial values like `0 0 0 var(--op-border-width)` which represent just the number values of a box shadow don't seem to be supported. +* CSS functions like `hsl(10deg 10% 10%)` are not supported, especially when each value is a reference to other values. +* Neither DESIGN.md nor https://www.designtokens.org/tr/2025.10/format/ seem to support calculated values like how we do our spacing scales. This leads to having to generate the token as the calculated value. +* Due to the calculated values and color limitations, this forces the DESIGN.md file to only have the end values which may be helpful for reference but won't allow tools to write correct CSS by referencing this. diff --git a/plans/DTCG_TOKENS_PLAN.md b/plans/DTCG_TOKENS_PLAN.md new file mode 100644 index 00000000..978dfe2f --- /dev/null +++ b/plans/DTCG_TOKENS_PLAN.md @@ -0,0 +1,131 @@ +# DTCG Token Generation Plan + +## Goal + +Generate a `dist/design-tokens.json` file from the Optics CSS token source files that conforms to the [Design Tokens Community Group (DTCG) spec](https://tr.designtokens.org/format/). + +Run with: +``` +yarn dtcg-tokens +``` + +Script lives at `tools/generate-dtcg-tokens.js`. + +--- + +## Source Files + +| File | Contains | +|------|----------| +| `src/core/tokens/base_tokens.css` | Space, radius, font, border-width, breakpoint, input-height, z-index, letter-spacing, line-height, and HSL component variables for colors | +| `src/core/tokens/scale_color_tokens.css` | All color scale tokens using `light-dark()` | + +--- + +## Output Structure + +``` +dist/design-tokens.json +└── op-space (dimension) — WIDTH_HEIGHT, GAP, PARAGRAPH_SPACING, PARAGRAPH_INDENT +└── op-radius (dimension) — CORNER_RADIUS +└── op-breakpoint (dimension) — WIDTH_HEIGHT +└── op-input-height (dimension) — WIDTH_HEIGHT +└── op-border +│ └── width (dimension) — STROKE_FLOAT, EFFECT_FLOAT +└── op-font +│ ├── scale-unit / size tokens (dimension) — FONT_SIZE +│ ├── weight (number) — FONT_WEIGHT +│ └── family (fontFamily) — FONT_FAMILY +└── op-line-height (number) — LINE_HEIGHT +└── op-letter-spacing (dimension) — LETTER_SPACING +└── op-z-index (number) — (no scopes) +└── op-color + ├── white / black (color) + ├── primary (color scale) + ├── neutral (color scale) + ├── alerts-warning (color scale) + ├── alerts-danger (color scale) + ├── alerts-info (color scale) + ├── alerts-notice (color scale) + ├── border (color alias) + ├── background (color alias) + ├── on-background (color alias) + └── on-background-alt (color alias) +``` + +--- + +## Key Design Decisions + +### Generic token groups (`GROUPS` array) + +Each group entry in the `GROUPS` array supports: + +| Option | Purpose | +|--------|---------| +| `prefix` | CSS variable prefix to match (e.g. `op-space`) | +| `type` | DTCG type: `dimension`, `number`, `fontFamily` | +| `group` | Top-level output key (if different from prefix) | +| `subgroup` | Nesting under the group key | +| `scaleUnitVar` | Enables `calc()` resolution for scaled tokens | +| `defaultName` | Token name when the variable has no suffix (e.g. `--op-border-width` → `default`) | +| `exclude` | Array of name prefixes to skip (used to prevent `op-font` matching `weight`/`family`) | +| `scopes` | Figma scopes array — omitted from output when empty | + +### `com.figma.scopes` omission + +When `scopes` is an empty array, `com.figma.scopes` is omitted from `$extensions` entirely (see `op-z-index`). + +### Font sizes live directly under `op-font` + +Font sizes (`--op-font-small`, etc.) are siblings to `weight` and `family` subgroups rather than nested under a `size` subgroup, because `size` doesn't appear in the CSS variable names. + +### `op-border` nesting + +`--op-border-width`, `--op-border-width-large`, `--op-border-width-x-large` are nested as: +``` +op-border.width.default +op-border.width.large +op-border.width.x-large +``` +`defaultName: 'default'` handles the no-suffix base token. + +### Color parsing (dedicated `parseColors()` function) + +Colors are not handled by the generic `GROUPS` mechanism because: +- Values are multiline `light-dark(lightVal, darkVal)` — only light mode is used +- HSL values use `var()` references to component variables (`-h`, `-s`, `-l`) that need resolving +- Alias tokens (e.g. `var(--op-color-neutral-plus-eight)`) become DTCG references (`{op-color.neutral.plus-eight}`) +- Output format uses `colorSpace: "srgb"` with `components: {r, g, b}` (0–1 floats) rather than hex + +Color scales are segmented by `COLOR_SUBGROUPS` (longest-first to avoid partial matches): +`alerts-warning`, `alerts-danger`, `alerts-info`, `alerts-notice`, `neutral`, `primary` + +--- + +## Not Yet Covered + +The following CSS tokens exist in the source but are not yet included in the output: + +- **Shadows** (`--op-shadow-*`) — composite values, not a standard DTCG type +- **Transitions** (`--op-transition-*`) — check if these exist +- **Opacity** (`--op-opacity-*`) — check if these exist +- **Encoded images** (`--op-encoded-images-*`) — likely out of scope + +--- + +## Figma Scopes Reference + +| Scope | Used for | +|-------|----------| +| `WIDTH_HEIGHT` | Space, breakpoints, input heights | +| `GAP` | Space | +| `PARAGRAPH_SPACING`, `PARAGRAPH_INDENT` | Space | +| `CORNER_RADIUS` | Radius | +| `STROKE_FLOAT`, `EFFECT_FLOAT` | Border widths | +| `FONT_SIZE` | Font size scale | +| `FONT_WEIGHT` | Font weights | +| `FONT_FAMILY` | Font families | +| `LINE_HEIGHT` | Line heights | +| `LETTER_SPACING` | Letter spacing | +| `ALL_SCOPES` | Colors | diff --git a/tools/generate-design-tokens.js b/tools/generate-design-tokens.js new file mode 100644 index 00000000..4d5db948 --- /dev/null +++ b/tools/generate-design-tokens.js @@ -0,0 +1,472 @@ +#!/usr/bin/env node + +const fs = require('fs') +const path = require('path') + +// --- Configuration --- + +const SRC_DIR = path.resolve(__dirname, '../src') +const SRC_COMPONENTS_DIR = path.resolve(__dirname, '../src/components') +const OUTPUT_FILE = path.resolve(__dirname, '../generated-docs/DESIGN.md') + +const EXCLUDED_DIRS = ['addons'] + +// Active token groups — comment out any group to exclude it from output. +// `subsection` is optional: when set, tokens nest one level deeper in the YAML +// (e.g. section: 'layout', subsection: 'spacing' → layout.spacing.*) +// `filter` is optional: a function (key) => boolean to include only matching tokens +const REM_TO_PX = 10 + +const METADATA = { + version: 'alpha', + name: 'Optics', + description: "RoleModel Software's Optics design system.", +} + +const COMPONENTS = { + 'color-border': { color: '{colors.neutral-plus-five}' }, +} + +const TOKEN_GROUPS = [ + { prefix: '--op-space-', section: 'spacing', filter: (key) => key !== 'scale-unit' }, + { + prefix: '--op-font-', + section: 'typography', + subsection: 'font-size', + filter: (key) => !key.startsWith('weight-') && !key.startsWith('family') && key !== 'scale-unit', + }, + { + prefix: '--op-font-', + section: 'typography', + subsection: 'font-weight', + filter: (key) => key.startsWith('weight-'), + stripKeyPrefix: 'weight-', + }, + { + prefix: '--op-font-', + section: 'typography', + subsection: 'font-family', + filter: (key) => key === 'family' || key.startsWith('family-'), + keyTransform: (key) => (key === 'family' ? 'default' : key.replace(/^family-/, '')), + }, + { prefix: '--op-line-height-', section: 'typography', subsection: 'line-height' }, + { prefix: '--op-letter-spacing-', section: 'typography', subsection: 'letter-spacing' }, + { + prefix: '--op-color-', + section: 'colors', + useColorTransform: true, + filter: (key) => + !key.endsWith('-h') && !key.endsWith('-s') && !key.endsWith('-l') && key !== 'border', + keyTransform: (key) => key.replace(/-original$/, ''), + }, + { prefix: '--op-shadow-', section: 'elevation' }, + { prefix: '--op-z-index-', section: 'elevation', subsection: 'z-index' }, + { prefix: '--op-opacity-', section: 'opacity' }, + { prefix: '--op-radius-', section: 'rounded', filter: (key) => key !== 'circle' }, + { prefix: '--op-breakpoint-', section: 'layout', subsection: 'breakpoints' }, + { + prefix: '--op-border-', + section: 'layout', + subsection: 'border-width', + filter: (key) => key === 'width' || key.startsWith('width-'), + stripKeyPrefix: 'width-', + keyTransform: (key) => key === 'width' ? 'default' : key, + }, + { + prefix: '--op-border-', + section: 'layout', + subsection: 'border', + filter: (key) => key !== 'width' && !key.startsWith('width-'), + }, + { prefix: '--op-input-height-', section: 'inputs', subsection: 'height' }, + // { prefix: '--op-input-', section: 'inputs', subsection: 'focus' }, + { prefix: '--op-transition-', section: 'animation' }, + { prefix: '--op-encoded-images-', section: 'assets', filter: (key) => key !== 'dropdown-arrow-width' }, +] + +// --- File Discovery --- + +function findCssFiles(dir) { + const results = [] + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (EXCLUDED_DIRS.includes(entry.name)) continue + results.push(...findCssFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.css')) { + results.push(fullPath) + } + } + return results.sort() +} + +// --- CSS Parsing --- + +function extractVariableDeclarations(cssText) { + const declarations = [] + const declarationPattern = /(--[\w-]+)\s*:\s*/g + let match + + while ((match = declarationPattern.exec(cssText)) !== null) { + const name = match[1] + const valueStart = match.index + match[0].length + const rawValue = extractValueAtPosition(cssText, valueStart) + if (rawValue === null) continue + + const value = normalizeWhitespace(rawValue) + declarations.push({ name, value }) + + declarationPattern.lastIndex = valueStart + rawValue.length + } + + return declarations +} + +function extractValueAtPosition(cssText, startIndex) { + let depth = 0 + let i = startIndex + + while (i < cssText.length) { + const ch = cssText[i] + if (ch === '(') depth++ + else if (ch === ')') depth-- + else if (ch === ';' && depth === 0) return cssText.slice(startIndex, i) + else if (ch === '{' && depth === 0) return null + i++ + } + return null +} + +function normalizeWhitespace(value) { + return value.replace(/\/\*.*?\*\//gs, '').trim().replace(/\s+/g, ' ') +} + +// --- Token Processing --- + +function matchTokenGroups(name) { + return TOKEN_GROUPS.filter(({ prefix }) => name.startsWith(prefix)) +} + +function tokenKey(name, prefix, stripKeyPrefix, keyTransform) { + const key = name.slice(prefix.length) + const stripped = stripKeyPrefix && key.startsWith(stripKeyPrefix) ? key.slice(stripKeyPrefix.length) : key + return keyTransform ? keyTransform(stripped) : stripped +} + +function tokenPath(section, subsection, key) { + return subsection ? `${section}.${subsection}.${key}` : `${section}.${key}` +} + +function convertReferences(value, prefix, section, subsection) { + const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return value.replace(new RegExp(`var\\((${escaped}([\\w-]+))\\)`, 'g'), (_, _full, key) => { + return `{${tokenPath(section, subsection, key)}}` + }) +} + +function resolveCalc(value, resolvedTokens) { + // Substitute known token values into {section.key} references, then + // simplify calc(Nunit * M) → (N*M)unit so the exporter sees plain dimensions. + const substituted = value.replace(/\{([\w.-]+)\}/g, (match, path) => { + const key = path.split('.').pop() + return resolvedTokens.get(key) ?? match + }) + + return substituted.replace( + /calc\(\s*([\d.]+)(rem|em|px)\s*\*\s*([\d.]+)\s*\)/g, + (_, n, unit, multiplier) => { + const result = parseFloat(n) * parseFloat(multiplier) + if (unit === 'rem') return `${parseFloat((result * REM_TO_PX).toFixed(4))}px` + return `${parseFloat(result.toFixed(4))}${unit}` + } + ).replace(/^([\d.]+)rem$/, (_, n) => `${parseFloat((parseFloat(n) * REM_TO_PX).toFixed(4))}px`) +} + +function formatValue(value, prefix, section, subsection) { + return convertReferences(normalizeWhitespace(value), prefix, section, subsection) +} + +// --- Color Processing --- + +function buildBaseColorVars(declarations) { + const raw = new Map() + for (const { name, value } of declarations) { + if (/^--op-color-[\w-]+-(h|s|l)$/.test(name)) raw.set(name, value.trim()) + } + // Resolve one level of var() references (e.g. neutral-h → primary-h) + const resolved = new Map() + for (const [name, value] of raw) { + resolved.set(name, value.replace(/var\((--[\w-]+)\)/g, (_, ref) => raw.get(ref) ?? ref)) + } + return resolved +} + +function hslToHex(h, s, l) { + s /= 100 + l /= 100 + const a = s * Math.min(l, 1 - l) + const f = (n) => { + const k = (n + h / 30) % 12 + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color).toString(16).padStart(2, '0') + } + return `#${f(0)}${f(8)}${f(4)}` +} + +function colorValueToHex(rawValue, baseColorVars) { + // Extract light-mode value from light-dark(light, dark) + const ldMatch = rawValue.match(/light-dark\(\s*(hsl\([^)]+\))\s*,/) + const hslStr = ldMatch ? ldMatch[1] : rawValue + + // Resolve var() references to their numeric values + const resolved = hslStr.replace(/var\((--[\w-]+)\)/g, (_, ref) => baseColorVars.get(ref) ?? ref) + + // Parse hsl(H S% L%) — modern space-separated syntax, optional "deg" suffix + const m = resolved.match(/hsl\(\s*([\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%\s*\)/) + if (!m) return null + + return hslToHex(parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3])) +} + +// --- Component Parsing --- + +const COMPONENT_PROP_MAP = { + 'background-color': 'backgroundColor', + 'color': 'textColor', + 'border-radius': 'rounded', + 'padding': 'padding', + 'padding-block': 'padding', + 'padding-inline': 'padding', + 'min-block-size': 'height', + 'block-size': 'height', + 'inline-size': 'width', +} + +function buildVarToRef(groupMap) { + const map = new Map() + for (const { section, subsection, tokens } of groupMap.values()) { + for (const [key, , originalName] of tokens) { + if (!originalName || map.has(originalName)) continue + const p = subsection ? `${section}.${subsection}.${key}` : `${section}.${key}` + map.set(originalName, `{${p}}`) + } + } + return map +} + +function extractDirectDeclarations(blockText) { + const result = {} + let depth = 0 + let buf = '' + for (const ch of blockText) { + if (ch === '{') { depth++; buf = '' } + else if (ch === '}') depth-- + else if (ch === ';' && depth === 0) { + const cleaned = buf.trim() + const idx = cleaned.indexOf(':') + if (idx > 0) result[cleaned.slice(0, idx).trim()] = cleaned.slice(idx + 1).trim() + buf = '' + } else if (depth === 0) buf += ch + } + return result +} + +function extractNestedBlocks(blockText) { + const blocks = [] + let depth = 0 + let i = 0 + let buf = '' + while (i < blockText.length) { + const ch = blockText[i] + if (ch === '{' && depth === 0) { + const contentStart = i + 1 + depth = 1 + i++ + while (i < blockText.length && depth > 0) { + if (blockText[i] === '{') depth++ + else if (blockText[i] === '}') depth-- + i++ + } + if (buf.trim()) blocks.push({ selector: buf.trim(), content: blockText.slice(contentStart, i - 1) }) + buf = '' + continue + } + if (depth === 0) buf += ch + i++ + } + return blocks +} + +function declarationsToDesignTokens(declarations, varToRef) { + const result = {} + for (const [prop, value] of Object.entries(declarations)) { + const designProp = COMPONENT_PROP_MAP[prop] + if (!designProp || result[designProp]) continue + const m = value.match(/^var\((--op-[\w-]+)\)$/) + if (!m) continue + const ref = varToRef.get(m[1]) + if (ref) result[designProp] = ref + } + return result +} + +function parseComponentFiles(varToRef) { + const components = {} + const files = fs + .readdirSync(SRC_COMPONENTS_DIR) + .filter((f) => f.endsWith('.css') && f !== 'index.css') + .map((f) => path.join(SRC_COMPONENTS_DIR, f)) + + for (const filePath of files) { + const raw = fs.readFileSync(filePath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') + const componentName = path.basename(filePath, '.css').replace(/_/g, '-') + + const firstBrace = raw.indexOf('{') + if (firstBrace === -1) continue + + let depth = 1 + let i = firstBrace + 1 + while (i < raw.length && depth > 0) { + if (raw[i] === '{') depth++ + else if (raw[i] === '}') depth-- + i++ + } + const rootContent = raw.slice(firstBrace + 1, i - 1) + + const rootTokens = declarationsToDesignTokens(extractDirectDeclarations(rootContent), varToRef) + if (Object.keys(rootTokens).length > 0) components[componentName] = rootTokens + + for (const { selector, content } of extractNestedBlocks(rootContent)) { + const m = selector.match(/&\.[a-z][\w-]*--([a-z][\w-]*)/) + if (!m) continue + const variantTokens = declarationsToDesignTokens(extractDirectDeclarations(content), varToRef) + if (Object.keys(variantTokens).length > 0) components[`${componentName}-${m[1]}`] = variantTokens + } + } + + return components +} + +// --- DESIGN.md Generation --- + +function buildDesignMd(tokensBySectionAndKey, allComponents) { + const lines = ['---'] + + for (const [key, value] of Object.entries(METADATA)) { + lines.push(`${key}: ${value}`) + } + + // Group by top-level section so multiple groups can share one YAML section block + const bySection = new Map() + for (const group of tokensBySectionAndKey) { + if (!bySection.has(group.section)) bySection.set(group.section, []) + bySection.get(group.section).push(group) + } + + for (const [section, groups] of bySection) { + lines.push(`${section}:`) + for (const { subsection, tokens } of groups) { + if (subsection) lines.push(` ${subsection}:`) + for (const [key, value] of tokens) { + const indent = subsection ? ' ' : ' ' + const needsQuotes = value.includes(',') || value.startsWith('#') || value.includes("'") + const formatted = needsQuotes ? `"${value}"` : value + lines.push(`${indent}${key}: ${formatted}`) + } + } + } + + if (Object.keys(allComponents).length > 0) { + lines.push('components:') + for (const [component, props] of Object.entries(allComponents)) { + lines.push(` ${component}:`) + for (const [prop, val] of Object.entries(props)) { + const formatted = val.startsWith('{') ? `"${val}"` : val + lines.push(` ${prop}: ${formatted}`) + } + } + } + + lines.push('---') + + lines.push('') + lines.push('## Shapes') + lines.push('') + lines.push( + 'To render a fully circular element, apply `border-radius: 50%` directly. ' + + 'This is a layout instruction rather than a fixed dimension, so it is not included in the `rounded` token scale above.' + ) + + return lines.join('\n') +} + +// --- Main --- + +function generateDesignTokens() { + const cssFiles = findCssFiles(SRC_DIR) + console.log(`Found ${cssFiles.length} CSS files in ${SRC_DIR}`) + + const seen = new Set() + const rawDeclarations = [] + + for (const filePath of cssFiles) { + const cssText = fs.readFileSync(filePath, 'utf8') + for (const decl of extractVariableDeclarations(cssText)) { + if (!seen.has(decl.name)) { + seen.add(decl.name) + rawDeclarations.push(decl) + } + } + } + + const baseColorVars = buildBaseColorVars(rawDeclarations) + + // First pass: collect all matching tokens (no filter) for calc() resolution + const resolvedTokens = new Map() + for (const { name, value } of rawDeclarations) { + for (const group of matchTokenGroups(name)) { + const key = tokenKey(name, group.prefix, group.stripKeyPrefix, group.keyTransform) + const formattedValue = formatValue(value, group.prefix, group.section, group.subsection) + resolvedTokens.set(key, formattedValue) + } + } + + // Second pass: build output groups, applying filters and resolving calc() + const groupMap = new Map() + for (const { name, value } of rawDeclarations) { + for (const group of matchTokenGroups(name)) { + const rawKey = tokenKey(name, group.prefix) + if (group.filter && !group.filter(rawKey)) continue + + const key = tokenKey(name, group.prefix, group.stripKeyPrefix, group.keyTransform) + const groupKey = `${group.section}::${group.subsection ?? ''}` + if (!groupMap.has(groupKey)) groupMap.set(groupKey, { ...group, tokens: [] }) + let finalValue + if (group.useColorTransform) { + finalValue = colorValueToHex(value, baseColorVars) + if (!finalValue) continue + } else { + finalValue = resolveCalc(formatValue(value, group.prefix, group.section, group.subsection), resolvedTokens) + } + groupMap.get(groupKey).tokens.push([key, finalValue, name]) + } + } + + const tokensBySectionAndKey = Array.from(groupMap.values()) + + console.log( + `Extracted ${tokensBySectionAndKey.reduce((n, g) => n + g.tokens.length, 0)} tokens across ${tokensBySectionAndKey.length} section(s)` + ) + + const varToRef = buildVarToRef(groupMap) + const parsedComponents = parseComponentFiles(varToRef) + const allComponents = { ...parsedComponents, ...COMPONENTS } + console.log(`Parsed ${Object.keys(allComponents).length} components`) + + const output = buildDesignMd(tokensBySectionAndKey, allComponents) + fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }) + fs.writeFileSync(OUTPUT_FILE, output, 'utf8') + console.log(`Written to ${OUTPUT_FILE}`) +} + +generateDesignTokens() diff --git a/tools/generate-dtcg-tokens.js b/tools/generate-dtcg-tokens.js new file mode 100644 index 00000000..2e1f85fe --- /dev/null +++ b/tools/generate-dtcg-tokens.js @@ -0,0 +1,252 @@ +const fs = require('fs') +const path = require('node:path') + +const SOURCE = 'src/core/tokens/base_tokens.css' +const OUTPUT = 'generated-docs/design-tokens.json' + +const css = fs.readFileSync(path.resolve(SOURCE), 'utf8') + +const GROUPS = [ + { + prefix: 'op-space', + type: 'dimension', + scaleUnitVar: '--op-space-scale-unit', + scopes: ['WIDTH_HEIGHT', 'GAP', 'PARAGRAPH_SPACING', 'PARAGRAPH_INDENT'], + }, + { + prefix: 'op-radius', + type: 'dimension', + scopes: ['CORNER_RADIUS'], + }, + { + prefix: 'op-breakpoint', + type: 'dimension', + scopes: ['WIDTH_HEIGHT'], + }, + { + prefix: 'op-input-height', + type: 'dimension', + scopes: ['WIDTH_HEIGHT'], + }, + { + prefix: 'op-border-width', + group: 'op-border', + subgroup: 'width', + type: 'dimension', + defaultName: 'default', + scopes: ['STROKE_FLOAT', 'EFFECT_FLOAT'], + }, + { + prefix: 'op-font', + group: 'op-font', + type: 'dimension', + scaleUnitVar: '--op-font-scale-unit', + exclude: ['weight', 'family'], + scopes: ['FONT_SIZE'], + }, + { + prefix: 'op-font-weight', + group: 'op-font', + subgroup: 'weight', + type: 'number', + scopes: ['FONT_WEIGHT'], + }, + { + prefix: 'op-font-family', + group: 'op-font', + subgroup: 'family', + type: 'fontFamily', + defaultName: 'default', + scopes: ['FONT_FAMILY'], + }, + { + prefix: 'op-line-height', + type: 'number', + scopes: ['LINE_HEIGHT'], + }, + { + prefix: 'op-letter-spacing', + type: 'dimension', + scopes: ['LETTER_SPACING'], + }, + { + prefix: 'op-z-index', + type: 'number', + scopes: [], + }, +] + +function coerceValue(value, type) { + if (type === 'number') return parseFloat(value) + return value +} + +function parseGroup({ prefix, group, subgroup, type, scaleUnitVar, exclude, defaultName, scopes }) { + const pattern = new RegExp(`--${prefix}(?:-([\\w-]+))?:\\s*([^;]+);(?:[^\\S\\n]*\\/\\*\\s*([^*]+?)\\s*\\*\\/)?`, 'g') + let scaleUnit = null + const tokens = {} + let match + + while ((match = pattern.exec(css)) !== null) { + const [, rawName, rawValue, comment] = match + const name = rawName ?? defaultName + if (!name) continue + if (exclude && exclude.some((ex) => name.startsWith(ex))) continue + + const cssVar = rawName ? `var(--${prefix}-${name})` : `var(--${prefix})` + const value = rawValue.trim() + const description = comment ? comment.trim().replace(/;$/, '') : undefined + + const extensions = { + ...(scopes.length > 0 && { 'com.figma.scopes': scopes }), + 'com.figma.codeSyntax': { WEB: cssVar }, + } + + if (scaleUnitVar && name === 'scale-unit') { + scaleUnit = value + tokens[name] = { $value: coerceValue(value, type), ...(description && { $description: description }), $extensions: extensions } + continue + } + + const calcMatch = scaleUnitVar && value.match(new RegExp(`calc\\(var\\(${scaleUnitVar}\\)\\s*\\*\\s*([\\d.]+)\\)`)) + if (calcMatch && scaleUnit) { + const multiplier = parseFloat(calcMatch[1]) + const unitMatch = scaleUnit.match(/^([\d.]+)(.+)$/) + const resolved = `${parseFloat((parseFloat(unitMatch[1]) * multiplier).toPrecision(10))}${unitMatch[2]}` + tokens[name] = { $value: resolved, ...(description && { $description: description }), $extensions: extensions } + } else { + tokens[name] = { $value: coerceValue(value, type), ...(description && { $description: description }), $extensions: extensions } + } + } + + const tokenGroup = { $type: type, ...tokens } + if (group && subgroup) return { [group]: { [subgroup]: tokenGroup } } + if (group) return { [group]: tokenGroup } + return { [prefix]: tokenGroup } +} + +function deepMerge(target, source) { + for (const key of Object.keys(source)) { + if (key in target && typeof target[key] === 'object' && !Array.isArray(target[key])) { + deepMerge(target[key], source[key]) + } else { + target[key] = source[key] + } + } + return target +} + +// ─── Color parsing ─────────────────────────────────────────────────────────── + +const COLOR_SOURCES = ['src/core/tokens/base_tokens.css', 'src/core/tokens/scale_color_tokens.css'] +const COLOR_SUBGROUPS = ['alerts-warning', 'alerts-danger', 'alerts-info', 'alerts-notice', 'neutral', 'primary'] + +function hslToSrgb(h, s, l) { + h = ((h % 360) + 360) % 360 + s /= 100 + l /= 100 + const a = s * Math.min(l, 1 - l) + const channel = (n) => { + const k = (n + h / 30) % 12 + return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + } + const round = (v) => Math.round(v * 10000) / 10000 + return { r: round(channel(0)), g: round(channel(8)), b: round(channel(4)) } +} + +function resolveHslVars(combinedCss) { + const vars = {} + const pattern = /--op-color-([\w-]+)-(h|s|l):\s*([^;]+);/g + let match + while ((match = pattern.exec(combinedCss)) !== null) { + vars[`--op-color-${match[1]}-${match[2]}`] = match[3].trim() + } + function resolve(value, seen = new Set()) { + const m = value.match(/^var\((--[^)]+)\)$/) + if (m && vars[m[1]] && !seen.has(m[1])) return resolve(vars[m[1]], new Set([...seen, m[1]])) + return value + } + return Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, resolve(v)])) +} + +function extractLightValue(value) { + const m = value.match(/light-dark\(([\s\S]+)\)\s*$/) + if (!m) return value.trim() + const inner = m[1] + let depth = 0 + for (let i = 0; i < inner.length; i++) { + if (inner[i] === '(') depth++ + else if (inner[i] === ')') depth-- + else if (inner[i] === ',' && depth === 0) return inner.slice(0, i).trim() + } + return inner.trim() +} + +function hslStringToColorValue(hslStr, hslVars) { + const inner = hslStr.match(/hsl\(([\s\S]+)\)/)?.[1]?.trim() + if (!inner) return null + const parts = inner.split(/\s+/) + const rv = (v) => { const m = v.match(/^var\((--[^)]+)\)$/); return m && hslVars[m[1]] ? hslVars[m[1]] : v } + const h = parseFloat(rv(parts[0])) + const s = parseFloat(rv(parts[1])) + const l = parseFloat(rv(parts[2])) + if (isNaN(h) || isNaN(s) || isNaN(l)) return null + const { r, g, b } = hslToSrgb(h, s, l) + return { colorSpace: 'srgb', components: { r, g, b }, alpha: 1 } +} + +function cssVarToRef(cssVar) { + const after = cssVar.replace('--op-color-', '') + const sg = COLOR_SUBGROUPS.find((s) => after.startsWith(s + '-')) + return sg ? `{op-color.${sg}.${after.slice(sg.length + 1)}}` : `{op-color.${after}}` +} + +function parseColors() { + const combinedCss = COLOR_SOURCES.map((s) => fs.readFileSync(path.resolve(s), 'utf8')).join('\n') + const hslVars = resolveHslVars(combinedCss) + const pattern = /--op-color-([\w-]+):\s*([\s\S]+?)(?=;)/g + const result = {} + let match + + while ((match = pattern.exec(combinedCss)) !== null) { + const name = match[1] + const rawValue = match[2].trim() + + if (/-(h|s|l)$/.test(name)) continue + + const aliasMatch = rawValue.match(/^var\((--op-color-[\w-]+)\)$/) + let dtcgValue + if (aliasMatch) { + dtcgValue = cssVarToRef(aliasMatch[1]) + } else { + dtcgValue = hslStringToColorValue(extractLightValue(rawValue), hslVars) + if (!dtcgValue) continue + } + + const sg = COLOR_SUBGROUPS.find((s) => name.startsWith(s + '-')) + const tokenName = sg ? name.slice(sg.length + 1) : name + const token = { + $type: 'color', + $value: dtcgValue, + $extensions: { + 'com.figma.scopes': ['ALL_SCOPES'], + 'com.figma.codeSyntax': { WEB: `var(--op-color-${name})` }, + }, + } + + if (sg) { + result[sg] = result[sg] ?? {} + result[sg][tokenName] = token + } else { + result[tokenName] = token + } + } + + return { 'op-color': result } +} + +// const output = deepMerge(GROUPS.map(parseGroup).reduce(deepMerge, {}), parseColors()) +const output = deepMerge(GROUPS.map(parseGroup).reduce(deepMerge, {}), {}) +fs.mkdirSync(path.dirname(path.resolve(OUTPUT)), { recursive: true }) +fs.writeFileSync(path.resolve(OUTPUT), JSON.stringify(output, null, 2)) +console.log(`Written to ${OUTPUT}`)