chore: import upstream snapshot with attribution
Test / npm-registry-smoke-windows (push) Waiting to run
Test / test (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:18:57 +08:00
commit e6ed586119
131 changed files with 17114 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
---
name: agent-dx-cli-scale
description: A scoring scale for evaluating how well a CLI is designed for AI agents, based on the "Rewrite Your CLI for AI Agents" principles.
---
# Agent DX CLI Scale
Use this skill to **evaluate any CLI** against the principles of agent-first design. Score each axis from 03, then sum for a total between 021.
> Human DX optimizes for discoverability and forgiveness.
> Agent DX optimizes for predictability and defense-in-depth.
> — [You Need to Rewrite Your CLI for AI Agents](/posts/rewrite-your-cli-for-ai-agents)
---
## Scoring Axes
### 1. Machine-Readable Output
Can an agent parse the CLI's output without heuristics?
| Score | Criteria |
| ----- | ----------------------------------------------------------------------------------------------------- |
| 0 | Human-only output (tables, color codes, prose). No structured format available. |
| 1 | `--output json` or equivalent exists but is incomplete or inconsistent across commands. |
| 2 | Consistent JSON output across all commands. Errors also return structured JSON. |
| 3 | NDJSON streaming for paginated results. Structured output is the default in non-TTY (piped) contexts. |
### 2. Raw Payload Input
Can an agent send the full API payload without translation through bespoke flags?
| Score | Criteria |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Only bespoke flags. No way to pass structured input. |
| 1 | Accepts `--json` or stdin JSON for some commands, but most require flags. |
| 2 | All mutating commands accept a raw JSON payload that maps directly to the underlying API schema. |
| 3 | Raw payload is first-class alongside convenience flags. The agent can use the API schema as documentation with zero translation loss. |
### 3. Schema Introspection
Can an agent discover what the CLI accepts at runtime without pre-stuffed documentation?
| Score | Criteria |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Only `--help` text. No machine-readable schema. |
| 1 | `--help --json` or a `describe` command for some surfaces, but incomplete. |
| 2 | Full schema introspection for all commands — params, types, required fields — as JSON. |
| 3 | Live, runtime-resolved schemas (e.g., from a discovery document) that always reflect the current API version. Includes scopes, enums, and nested types. |
### 4. Context Window Discipline
Does the CLI help agents control response size to protect their context window?
| Score | Criteria |
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Returns full API responses with no way to limit fields or paginate. |
| 1 | Supports `--fields` or field masks on some commands. |
| 2 | Field masks on all read commands. Pagination with `--page-all` or equivalent. |
| 3 | Streaming pagination (NDJSON per page). Explicit guidance in context/skill files on field mask usage. The CLI actively protects the agent from token waste. |
### 5. Input Hardening
Does the CLI defend against the specific ways agents fail (hallucinations, not typos)?
| Score | Criteria |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | No input validation beyond basic type checks. |
| 1 | Validates some inputs, but does not cover agent-specific hallucination patterns (path traversals, embedded query params, double encoding). |
| 2 | Rejects control characters, path traversals (`../`), percent-encoded segments (`%2e`), and embedded query params (`?`, `#`) in resource IDs. |
| 3 | Comprehensive hardening: all of the above, plus output path sandboxing to CWD, HTTP-layer percent-encoding, and an explicit security posture — _"The agent is not a trusted operator."_ |
### 6. Safety Rails
Can agents validate before acting, and are responses sanitized against prompt injection?
| Score | Criteria |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | No dry-run mode. No response sanitization. |
| 1 | `--dry-run` exists for some mutating commands. |
| 2 | `--dry-run` for all mutating commands. Agent can validate requests without side effects. |
| 3 | Dry-run plus response sanitization (e.g., via Model Armor) to defend against prompt injection embedded in API data. The full request→response loop is defended. |
### 7. Agent Knowledge Packaging
Does the CLI ship knowledge in formats agents can consume at conversation start?
| Score | Criteria |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0 | Only `--help` and a docs site. No agent-specific context files. |
| 1 | A `CONTEXT.md` or `AGENTS.md` with basic usage guidance. |
| 2 | Structured skill files (YAML frontmatter + Markdown) covering per-command or per-API-surface workflows and invariants. |
| 3 | Comprehensive skill library encoding agent-specific guardrails (_"always use --dry-run"_, _"always use --fields"_). Skills are versioned, discoverable, and follow a standard like OpenClaw. |
---
## Interpreting the Total
| Range | Rating | Description |
| ----- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| 05 | **Human-only** | Built for humans. Agents will struggle with parsing, hallucinate inputs, and lack safety rails. |
| 610 | **Agent-tolerant** | Agents can use it, but they'll waste tokens, make avoidable errors, and require heavy prompt engineering to compensate. |
| 1115 | **Agent-ready** | Solid agent support. Structured I/O, input validation, and some introspection. A few gaps remain. |
| 1621 | **Agent-first** | Purpose-built for agents. Full schema introspection, comprehensive input hardening, safety rails, and packaged agent knowledge. |
---
## Bonus: Multi-Surface Readiness
Not scored, but note whether the CLI exposes multiple agent surfaces from the same binary:
- [ ] **MCP (stdio JSON-RPC)** — typed tool invocation, no shell escaping
- [ ] **Extension / plugin install** — agent treats the CLI as a native capability
- [ ] **Headless auth** — env vars for tokens/credentials, no browser redirect required
+273
View File
@@ -0,0 +1,273 @@
---
name: ink
description: Ink terminal renderer for json-render that turns JSON specs into interactive terminal UIs. Use when working with @json-render/ink, building terminal UIs from JSON, creating terminal component catalogs, or rendering AI-generated specs in the terminal.
---
# @json-render/ink
Ink terminal renderer that converts JSON specs into interactive terminal component trees with standard components, data binding, visibility, actions, and dynamic props.
## Quick Start
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/ink/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/ink/catalog";
import { defineRegistry, Renderer, type Components } from "@json-render/ink";
import { z } from "zod";
// Create catalog with standard + custom components
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
CustomWidget: {
props: z.object({ title: z.string() }),
slots: [],
description: "Custom widget",
},
},
actions: standardActionDefinitions,
});
// Register only custom components (standard ones are built-in)
const { registry } = defineRegistry(catalog, {
components: {
CustomWidget: ({ props }) => <Text>{props.title}</Text>,
} as Components<typeof catalog>,
});
// Render
function App({ spec }) {
return (
<JSONUIProvider initialState={{}}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
);
}
```
## Spec Structure (Flat Element Map)
The Ink schema uses a flat element map with a root key:
```json
{
"root": "main",
"elements": {
"main": {
"type": "Box",
"props": { "flexDirection": "column", "padding": 1 },
"children": ["heading", "content"]
},
"heading": {
"type": "Heading",
"props": { "text": "Dashboard", "level": "h1" },
"children": []
},
"content": {
"type": "Text",
"props": { "text": "Hello from the terminal!" },
"children": []
}
}
}
```
## Standard Components
### Layout
- `Box` - Flexbox layout container (like a terminal `<div>`). Use for grouping, spacing, borders, alignment. Default flexDirection is row.
- `Text` - Text output with optional styling (color, bold, italic, etc.)
- `Newline` - Inserts blank lines. Must be inside a Box with flexDirection column.
- `Spacer` - Flexible empty space that expands along the main axis.
### Content
- `Heading` - Section heading (h1: bold+underlined, h2: bold, h3: bold+dimmed, h4: dimmed)
- `Divider` - Horizontal separator with optional centered title
- `Badge` - Colored inline label (variants: default, info, success, warning, error)
- `Spinner` - Animated loading spinner with optional label
- `ProgressBar` - Horizontal progress bar (0-1)
- `Sparkline` - Inline chart using Unicode block characters
- `BarChart` - Horizontal bar chart with labels and values
- `Table` - Tabular data with headers and rows
- `List` - Bulleted or numbered list
- `ListItem` - Structured list row with title, subtitle, leading/trailing text
- `Card` - Bordered container with optional title
- `KeyValue` - Key-value pair display
- `Link` - Clickable URL with optional label
- `StatusLine` - Status message with colored icon (info, success, warning, error)
- `Markdown` - Renders markdown text with terminal styling
### Interactive
- `TextInput` - Text input field (events: submit, change)
- `Select` - Selection menu with arrow key navigation (events: change)
- `MultiSelect` - Multi-selection with space to toggle (events: change, submit)
- `ConfirmInput` - Yes/No confirmation prompt (events: confirm, deny)
- `Tabs` - Tab bar navigation with left/right arrow keys (events: change)
## Visibility Conditions
Use `visible` on elements to show/hide based on state. Syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR.
## Dynamic Prop Expressions
Any prop value can be a data-driven expression resolved at render time:
- **`{ "$state": "/state/key" }`** - reads from state model (one-way read)
- **`{ "$bindState": "/path" }`** - two-way binding: use on the natural value prop of form components
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
Components do not use a `statePath` prop for two-way binding. Use `{ "$bindState": "/path" }` on the natural value prop instead.
## Event System
Components use `emit` to fire named events. The element's `on` field maps events to action bindings:
```tsx
CustomButton: ({ props, emit }) => (
<Box>
<Text>{props.label}</Text>
{/* emit("press") triggers the action bound in the spec's on.press */}
</Box>
),
```
```json
{
"type": "CustomButton",
"props": { "label": "Submit" },
"on": { "press": { "action": "submit" } },
"children": []
}
```
## Built-in Actions
`setState`, `pushState`, and `removeState` are built-in and handled automatically:
```json
{ "action": "setState", "params": { "statePath": "/activeTab", "value": "home" } }
{ "action": "pushState", "params": { "statePath": "/items", "value": { "text": "New" } } }
{ "action": "removeState", "params": { "statePath": "/items", "index": 0 } }
```
## Repeat (Dynamic Lists)
Use the `repeat` field on a container element to render items from a state array:
```json
{
"type": "Box",
"props": { "flexDirection": "column" },
"repeat": { "statePath": "/items", "key": "id" },
"children": ["item-row"]
}
```
Inside repeated children, use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current index.
## Streaming
Use `useUIStream` to progressively render specs from JSONL patch streams:
```tsx
import { useUIStream } from "@json-render/ink";
const { spec, send, isStreaming } = useUIStream({ api: "/api/generate" });
```
## Server-Side Prompt Generation
Use the `./server` export to generate AI system prompts from your catalog:
```typescript
import { catalog } from "./catalog";
const systemPrompt = catalog.prompt({ system: "You are a terminal assistant." });
```
## Providers
| Provider | Purpose |
|----------|---------|
| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
| `ActionProvider` | Handle actions dispatched via the event system |
| `VisibilityProvider` | Enable conditional rendering based on state |
| `ValidationProvider` | Form field validation |
| `FocusProvider` | Manage focus across interactive components |
| `JSONUIProvider` | Combined provider for all contexts |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider` (or `JSONUIProvider`) to use external state management:
```tsx
import { createStateStore, type StateStore } from "@json-render/ink";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>{children}</StateProvider>
store.set("/count", 1); // React re-renders automatically
```
When `store` is provided, `initialState` and `onStateChange` are ignored.
## createRenderer (Higher-Level API)
```tsx
import { createRenderer } from "@json-render/ink";
import { standardComponents } from "@json-render/ink";
import { catalog } from "./catalog";
const InkRenderer = createRenderer(catalog, {
...standardComponents,
// custom component overrides here
});
// InkRenderer includes all providers (state, visibility, actions, focus)
render(
<InkRenderer spec={spec} state={{ activeTab: "overview" }} />
);
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `createRenderer` | Higher-level: creates a component with built-in providers |
| `JSONUIProvider` | Combined provider for all contexts |
| `schema` | Ink flat element map schema (includes built-in state actions) |
| `standardComponentDefinitions` | Catalog definitions for all standard components |
| `standardActionDefinitions` | Catalog definitions for standard actions |
| `standardComponents` | Pre-built component implementations |
| `useStateStore` | Access state context |
| `useStateValue` | Get single value from state |
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
| `useActions` | Access actions context |
| `useAction` | Get a single action dispatch function |
| `useOptionalValidation` | Non-throwing variant of useValidation |
| `useUIStream` | Stream specs from an API endpoint |
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
| `StateStore` | Interface for plugging in external state management |
| `Components` | Typed component map (catalog-aware) |
| `Actions` | Typed action map (catalog-aware) |
| `ComponentContext` | Typed component context (catalog-aware) |
| `flatToTree` | Convert flat element map to tree structure |
## Terminal UI Design Guidelines
- Use Box for layout (flexDirection, padding, gap). Default flexDirection is row.
- Terminal width is ~80-120 columns. Prefer vertical layouts (flexDirection: column) for main structure.
- Use borderStyle on Box for visual grouping (single, double, round, bold).
- Use named terminal colors: red, green, yellow, blue, magenta, cyan, white, gray.
- Use Heading for section titles, Divider to separate sections, Badge for status, KeyValue for labeled data, Card for bordered groups.
- Use Tabs for multi-view UIs with visible conditions on child content.
- Use Sparkline for inline trends and BarChart for comparing values.
+81
View File
@@ -0,0 +1,81 @@
---
name: tdd-red-green-refactor
description: >
Enforces a disciplined Red-Green-Refactor (TDD) workflow in TypeScript/Node.js.
Use this whenever creating new features, fixing bugs, or migrating logic to ensure
high-quality, verifiable implementations.
---
# Red-Green-Refactor (TDD) Skill: TypeScript Edition
This skill implements a structural framework for AI-assisted programming to ensure every line of code is verifiable, typed, and purposeful.
## The Three-Phase Cycle
### Phase 1: Red (Establish Failure)
You must prove the feature does not exist and that your test is valid.
1. **Write One Test**: Create a single test case (e.g., in Vitest or Jest) for the next small piece of behavior.
2. **Execute & Fail**: Run the test. It must fail.
3. **Verify**: Ensure the failure is related to the missing logic (e.g., `ReferenceError: add is not defined`) and not a configuration error.
### Phase 2: Green (Minimal Pass)
Make the test pass as quickly and simply as possible.
1. **Minimal Implementation**: Write the simplest code that satisfies the test. Do not build for the future; focus strictly on the current "Red" test.
2. **Run Tests**: Execute the suite. All tests must be Green.
3. **Evidence**: The transition from Red to Green is the "Proof of Work" for the developer.
### Phase 3: Refactor (Clean Up)
Improve the code structure while maintaining the "Green" state.
1. **Clean Up**: Improve naming, remove duplication, and optimize the code written in Phase 2.
2. **Safety Net**: Rerun the tests after every change. If they turn Red, revert the change immediately.
---
## Core Operational Rules
### 1. No "Horizontal Splurging"
You are strictly forbidden from writing a large "splurge" of multiple tests at once. You must follow a strictly incremental loop:
- **Write 1 Test -> See it Fail -> Write 1 Fix -> See it Pass**.
- Repeat this loop for every sub-feature.
### 2. Impose Backpressure
Use automated assertions and strong typing (TypeScript) as backpressure to prevent the AI from "guessing" the solution or "playing in the mud" with low-quality code.
### 3. Verification of Integrity
Never modify an existing test to make a failing implementation pass. If a test must change, it must be because the requirement changed, not because the code is difficult to write.
---
## Example Workflow (TypeScript + Vitest)
**Step 1: Red**
```typescript
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add } from './math';
describe('add', () => {
it('should sum two numbers', () => {
expect(add(2, 2)).toBe(4); // Fails: ReferenceError: add is not defined
});
});
```
**Step 2: Green**
```typescript
// math.ts
export const add = (a: any, b: any) => {
return 4; // Passes: Minimal code to satisfy the test
};
```
**Step 3: Refactor**
```typescript
// math.ts
/**
* Sums two numbers with explicit type safety.
*/
export const add = (a: number, b: number): number => {
return a + b; // Passes: Proper implementation with safety net
};
```
@@ -0,0 +1,190 @@
---
name: typed-service-contracts
description: Architecture standard for building robust, type-safe TypeScript services using the "Spec and Handler" pattern. Use when building CLIs, libraries, or complex business logic.
---
# Typed Service Contracts (Spec & Handler Pattern)
This skill defines a **Vertical Slice Architecture** backed by **Design by Contract (DbC)** principles. It treats application logic as rigorously defined Units of Work where inputs are parsed (not just validated) and errors are treated as values (Result Pattern) rather than exceptions.
## When to use this skill
- **Building CLIs or Libraries:** When you need strict boundaries between user input and system logic.
- **Complex Validation:** When inputs require transformation (parsing) before being useful (e.g., ensuring a string is a valid file path).
- **High-Reliability Requirements:** When you cannot afford unhandled runtime exceptions and need exhaustive error handling.
- **Testing Focus:** When you want to separate data validation tests from business logic tests.
## Architecture Components
### 1. The Spec (`spec.ts`)
The "Contract" or "Port". It defines the *What*. It must contain:
- **Input Schema:** A Zod schema that parses raw input into a valid DTO.
- **Output Schema:** A Zod schema defining the successful data structure.
- **Error Schema:** A discriminated union of specific failure modes (not generic errors).
- **Result Type:** A `DiscriminatedUnion` of `Success | Failure`.
- **Interface:** The capability definition (e.g., `interface ConfigureSpec`).
### 2. The Handler (`handler.ts`)
The "Implementation" or "Adapter". It defines the *How*. It must:
- Implement the Interface defined in the Spec.
- Be an "Impure" class that handles side effects (File System, API calls).
- **NEVER throw** exceptions. It must catch internal errors and map them to the `Result` type.
---
## How to use it
### Step 1: Define the Contract (`spec.ts`)
Follow this template to define the boundaries.
```typescript
import { z } from 'zod';
// 1. VALIDATION HELPERS (Reusable Refinements)
export const SafePathSchema = z.string()
.min(1)
.refine(p => !p.includes('..'), "No traversal allowed");
// 2. INPUT (The Command) - "Parse, don't validate"
export const MyTaskInputSchema = z.object({
path: SafePathSchema,
force: z.boolean().default(false),
});
export type MyTaskInput = z.infer<typeof MyTaskInputSchema>;
// 3. ERROR CODES (Exhaustive)
export const MyTaskErrorCode = z.enum([
'FILE_NOT_FOUND',
'PERMISSION_DENIED',
'UNKNOWN_ERROR'
]);
// 4. RESULT (The Monad)
export const MyTaskSuccess = z.object({
success: z.literal(true),
data: z.string(), // The output payload
});
export const MyTaskFailure = z.object({
success: z.literal(false),
error: z.object({
code: MyTaskErrorCode,
message: z.string(),
suggestion: z.string().optional(),
recoverable: z.boolean(),
})
});
export type MyTaskResult =
| z.infer<typeof MyTaskSuccess>
| z.infer<typeof MyTaskFailure>;
// 5. INTERFACE (The Capability)
export interface MyTaskSpec {
execute(input: MyTaskInput): Promise<MyTaskResult>;
}
```
### Step 2: Implement the Handler (`handler.ts`)
Follow this template to implement the logic.
```typescript
import { MyTaskSpec, MyTaskInput, MyTaskResult } from './spec.js';
import * as fs from 'fs';
export class MyTaskHandler implements MyTaskSpec {
async execute(input: MyTaskInput): Promise<MyTaskResult> {
try {
// 1. Business Logic
if (!fs.existsSync(input.path)) {
// 2. Explicit Error Return (No Throwing)
return {
success: false,
error: {
code: 'FILE_NOT_FOUND',
message: `Path does not exist: ${input.path}`,
recoverable: true
}
};
}
// 3. Success Return
return {
success: true,
data: 'Operation complete'
};
} catch (error) {
// 4. Safety Net: Catch unknown runtime errors
return {
success: false,
error: {
code: 'UNKNOWN_ERROR',
message: error instanceof Error ? error.message : String(error),
recoverable: false
}
};
}
}
}
```
### Step 3: Testing Strategy
Do not write monolithic tests. Split them into **Contract Tests** and **Logic Tests**.
#### A. Contract Tests (Schema)
Test the *Bouncer*. Ensure invalid data is rejected before it reaches the handler.
* **Focus:** Edge cases, validation rules, Zod refinements.
* **Style:** Data-driven (Table tests).
```typescript
// spec.test.ts
import { MyTaskInputSchema } from './spec';
const invalidCases = [
{ val: '../etc/passwd', err: 'No traversal allowed' },
{ val: '', err: 'min(1)' },
];
test.each(invalidCases)('validates paths', ({ val, err }) => {
const result = MyTaskInputSchema.safeParse({ path: val });
expect(result.success).toBe(false);
});
```
#### B. Logic Tests (Handler)
Test the *Chef*. Mock external dependencies (fs, network) and assert the Result Object.
* **Focus:** Business logic flow, error mapping, success states.
* **Style:** Mocked unit tests or Scenario Runners.
```typescript
// handler.test.ts
import { MyTaskHandler } from './handler';
import { vi } from 'vitest'; // or jest
test('returns FILE_NOT_FOUND if path missing', async () => {
// MOCK
vi.mocked(fs.existsSync).mockReturnValue(false);
// EXECUTE
const handler = new MyTaskHandler();
const result = await handler.execute({ path: '/fake' });
// ASSERT (Check the Result Object)
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('FILE_NOT_FOUND');
}
});
```
+77
View File
@@ -0,0 +1,77 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Test
on:
push:
branches: ['**']
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9 # Must match root package.json "packageManager"
- name: Install dependencies
run: bun install
- name: Lint
run: bun run lint
- name: Run tests
run: bun run test
- name: Build CLI
run: bun run build
- name: Node.js Smoke Test
run: node packages/cli/dist/index.js lint examples/atmospheric-glass/DESIGN.md
- name: Tarball Smoke Test
run: |
cd packages/cli && npm pack
mkdir -p /tmp/designmd-smoke && cd /tmp/designmd-smoke
npm init -y
npm install $GITHUB_WORKSPACE/packages/cli/google-design.md-*.tgz
echo -e '---\ncolors:\n primary: "#0000ff"\n---' > DESIGN.md
npx design.md lint DESIGN.md
npx design.md spec > /dev/null
node -e "import('@google/design.md/linter').then(m => console.log('OK:', Object.keys(m).length, 'exports'))"
npm-registry-smoke-windows:
runs-on: windows-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Install @google/design.md from the public npm registry
shell: bash
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/npm-registry-smoke"
cd "$RUNNER_TEMP/npm-registry-smoke"
npm init -y
npm install "@google/design.md@latest"
node node_modules/@google/design.md/dist/index.js spec --rules-only --format json > /dev/null
node -e "import('@google/design.md/linter').then(m => console.log('OK:', Object.keys(m).length, 'exports'))"
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.env
*.local
bun.lockb
*.tgz
.turbo/
+33
View File
@@ -0,0 +1,33 @@
# How to contribute
We'd love to accept your patches and contributions to this project.
## Before you begin
### Sign our Contributor License Agreement
Contributions to this project must be accompanied by a
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
You (or your employer) retain the copyright to your contribution; this simply
gives us permission to use and redistribute your contributions as part of the
project.
If you or your current employer have already signed the Google CLA (even if it
was for a different project), you probably don't need to do it again.
Visit <https://cla.developers.google.com/> to see your current agreements or to
sign a new one.
### Review our community guidelines
This project follows
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
## Contribution process
### Code reviews
All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
information on using pull requests.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+110
View File
@@ -0,0 +1,110 @@
# DESIGN.md Philosophy
DESIGN.md captures how a design looks, feels, and behaves. The prose is where the design lives. Everything else in the document exists to support it.
```
---
name: Technical Handout
---
## Overview
A graduate-level computer science lecture handout in the tradition of an old established university. The audience is graduate students and research engineers reading a printed handout distributed at the beginning of a seminar.
The handout is austere, informationally dense, and proudly unconcerned with first impressions. The audience knows why they are there and the handout's job is to do work, not to seduce.
```
**The quality of a generated design is determined less by the precision of its values than by how clearly the intent is described.**
## Prose, not Tokens, is the focus of the specification
DESIGN.md contains two primary aspects: tokens and prose. The specification is focused on describing this design context to keep designs consistent between generations and to provide an area of creative exploration. The prose is the most vital part of the specification.
````
## Colors
```yaml
# Tokens
colors:
paper: '#F4F0E4'
ink: '#1E1A14'
vermilion: '#C3402A'
rule-gray: '#B8B0A2'
```
<!-- Prose -->
A single-ink-plus-accent system.
- **Paper** {colors.paper} is the canvas — warmed xerox stock, never pure white.
- **Ink** {colors.ink} is graphite-warm and carries all typography, all rules, all diagram strokes; never pure black.
- **Vermilion** {colors.vermilion} is the single accent and appears only inside diagrams and chart annotations — never on typography, never on page numerals, never on metadata of any kind.
- **Rule gray** {colors.rule-gray} is reserved for hairline rules inside content (chart baselines, table dividers); never used as page-frame chrome.
````
The token values serve as context and are not rendering instructions. Generally, we do not accept or recommend token requirements in the specification.
This keeps tokens as context that serves as a reference in the prose and focuses DESIGN.md on documenting the nature of the design and not trying to reinvent the decades long work established by languages and tools that came before us.
This document communicates the narrative and philosophy of DESIGN.md to clarify what problems it solves and how it currently attempts to solve them.
## A specific reference carries more than a list of adjectives
A design that references "A 1970s graduate lecture handout in the tradition of an old and established university" evokes a complete world: the one color of ink, the generous margins, the serif set at a reading size, and the absence of decoration. That single sentence carries more useful information than a dozen metric values. It carries the reasoning behind the values.
"Modern, clean, trustworthy, premium" evokes nothing specific. A model creates something in the center of what those words describe, creating an output that is typically generic. Adjectives describe a region. A specific reference describes a point.
## Negative Constraints: What you leave out defines the character
A clear design reference carries its restrictions automatically. A model knows what a lecture handout is, and it knows what a lecture handout is not. It does not glow or use a gradient. You don't have to list these. Naming the object names them, the same way naming a dog tells the model that dogs don't meow.
The negative constraints arrive for free when the reference is specific enough. An intentional list of "don'ts" is useful. A long rambling list is often a sign the description was too vague to carry them. A strong reference and an intentional list of do's and don'ts working together is the sweet spot.
```
## Do's and Don'ts
- **Don't** add a hero moment to the title page. A real handout title
page is the first page of content, not a magazine cover.
- **Don't** reach for an italic standfirst beneath a large title.
That is the Substack register.
- **Don't** add corner ornaments, chapter marks, or abstract glyphs
in the margins.
- **Don't** color the page numeral or any other piece of metadata.
Vermilion lives in diagrams only.
- **Don't** use a display-class serif. One family at four modest sizes.
- **Don't** use Bold. Anywhere.
- **Don't** use sans-serif for any role other than monospace metadata.
- **Don't** introduce dark mode, gradients, glows, glass surfaces,
drop shadows, or rounded corners.
- **Do** treat the handout as a printed object. The screen is the
substrate; the design is the page.
- **Do** keep vermilion inside diagrams. Its scarcity outside is what
makes its presence inside meaningful.
- **Do** trust modest size differences. The section title is only
~1.9× body, not 5× body.
- **Do** let pages have visible white space. A page that ends
two-thirds of the way down is correct, not under-filled.
```
## The format grows through its users, not its spec
The spec defines the structural minimum that every DESIGN.md shares: a name, and a small set of categories (colors, typography, spacing, rounded, components) that are universal enough to standardize. Everything beyond that minimum is yours to define. The format accepts any key, any section, any structure your design system needs.
The spec standardizes the categories where consistency helps. It leaves open the categories where flexibility helps more: motion, iconography, elevation, text casing, paragraph measure. One team's motion tokens are CSS animation curves. Another's are audio-domain time constants measured in buffer blocks. The right shape is specific to each system, and the format already lets you define it:
````
## Motion
```yaml
motion:
feedback: 120ms
content: 250ms
easing: 'cubic-bezier(0.2, 0, 0, 1)'
```
Transitions are quick and mechanical. Nothing bounces, nothing overshoots, nothing lingers. State changes should feel like a light switch, not a door closing.
- Interactive feedback (hover, press, toggle): {motion.feedback}, always {motion.easing}.
- Content transitions (page, panel, modal): {motion.content}, same curve.
- Nothing in the UI animates longer than 300ms. If something takes longer, cut it.
- Respect `prefers-reduced-motion`: all durations collapse to 0ms.
````
The linter accepts these values and agents read the prose. No spec change was needed because the tokens themselves are context rather than instruction.
+354
View File
@@ -0,0 +1,354 @@
# DESIGN.md
A format specification for describing a visual identity to coding agents. DESIGN.md gives agents a persistent, structured understanding of a design system.
## The Format
A DESIGN.md file combines machine-readable design tokens (YAML front matter) with human-readable design rationale (markdown prose). Tokens give agents exact values. Prose tells them *why* those values exist and how to apply them.
```md
---
name: Heritage
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
neutral: "#F7F5F2"
typography:
h1:
fontFamily: Public Sans
fontSize: 3rem
body-md:
fontFamily: Public Sans
fontSize: 1rem
label-caps:
fontFamily: Space Grotesk
fontSize: 0.75rem
rounded:
sm: 4px
md: 8px
spacing:
sm: 8px
md: 16px
---
## Overview
Architectural Minimalism meets Journalistic Gravitas. The UI evokes a
premium matte finish — a high-end broadsheet or contemporary gallery.
## Colors
The palette is rooted in high-contrast neutrals and a single accent color.
- **Primary (#1A1C1E):** Deep ink for headlines and core text.
- **Secondary (#6C7278):** Sophisticated slate for borders, captions, metadata.
- **Tertiary (#B8422E):** "Boston Clay" — the sole driver for interaction.
- **Neutral (#F7F5F2):** Warm limestone foundation, softer than pure white.
```
An agent that reads this file will produce a UI with deep ink headlines in Public Sans, a warm limestone background, and Boston Clay call-to-action buttons.
## Getting Started
Validate a DESIGN.md against the spec, catch broken token references, check WCAG contrast ratios, and surface structural findings — all as structured JSON that agents can act on.
```bash
npx @google/design.md lint DESIGN.md
```
```json
{
"findings": [
{
"severity": "warning",
"path": "components.button-primary",
"message": "textColor (#ffffff) on backgroundColor (#1A1C1E) has contrast ratio 15.42:1 — passes WCAG AA."
}
],
"summary": { "errors": 0, "warnings": 1, "info": 1 }
}
```
Compare two versions of a design system to detect token-level and prose regressions:
```bash
npx @google/design.md diff DESIGN.md DESIGN-v2.md
```
```json
{
"tokens": {
"colors": { "added": ["accent"], "removed": [], "modified": ["tertiary"] },
"typography": { "added": [], "removed": [], "modified": [] }
},
"regression": false
}
```
## The Specification
The full DESIGN.md spec lives at [`docs/spec.md`](docs/spec.md). What follows is a condensed reference.
### File Structure
A DESIGN.md file has two layers:
1. **YAML front matter** — Machine-readable design tokens, delimited by `---` fences at the top of the file.
2. **Markdown body** — Human-readable design rationale organized into `##` sections.
The tokens are the normative values. The prose provides context for how to apply them.
### Token Schema
```yaml
version: <string> # optional, current: "alpha"
name: <string>
description: <string> # optional
colors:
<token-name>: <Color>
typography:
<token-name>: <Typography>
rounded:
<scale-level>: <Dimension>
spacing:
<scale-level>: <Dimension | number>
components:
<component-name>:
<token-name>: <string | token reference>
```
### Token Types
| Type | Format | Example |
|:-----|:-------|:--------|
| Color | Any CSS color (hex, `rgb()`, `oklch()`, named, etc.) | `"#1A1C1E"`, `"oklch(62% 0.18 250)"` |
| Dimension | number + unit (`px`, `em`, `rem`) | `48px`, `-0.02em` |
| Token Reference | `{path.to.token}` | `{colors.primary}` |
| Typography | object with `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation` | See example above |
### Section Order
Sections use `##` headings. They can be omitted, but those present must appear in this order:
| # | Section | Aliases |
|:--|:--------|:--------|
| 1 | Overview | Brand & Style |
| 2 | Colors | |
| 3 | Typography | |
| 4 | Layout | Layout & Spacing |
| 5 | Elevation & Depth | Elevation |
| 6 | Shapes | |
| 7 | Components | |
| 8 | Do's and Don'ts | |
### Component Tokens
Components map a name to a group of sub-token properties:
```yaml
components:
button-primary:
backgroundColor: "{colors.tertiary}"
textColor: "{colors.on-tertiary}"
rounded: "{rounded.sm}"
padding: 12px
button-primary-hover:
backgroundColor: "{colors.tertiary-container}"
```
Valid component properties: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`.
Variants (hover, active, pressed) are expressed as separate component entries with a related key name.
### Consumer Behavior for Unknown Content
| Scenario | Behavior |
|:---------|:---------|
| Unknown section heading | Preserve; do not error |
| Unknown color token name | Accept if value is valid |
| Unknown typography token name | Accept as valid typography |
| Unknown component property | Accept with warning |
| Duplicate section heading | Error; reject the file |
## CLI Reference
### Installation
```bash
npm install @google/design.md
```
On **Windows**, quote the package name if your shell treats `@` specially (PowerShell, some terminals):
```bash
npm install "@google/design.md"
```
Or run directly (always resolves from the public npm registry):
```bash
npx @google/design.md lint DESIGN.md
```
On **Windows/PowerShell**, this direct form can produce no output (or open
`DESIGN.md` in your Markdown editor) because the `.md` suffix in the `design.md`
bin name collides with the Windows Markdown file association during command
resolution. Run the dot-free `designmd` alias instead — point `npx` at the
package with `-p`, then invoke `designmd`:
```bash
npx -p @google/design.md designmd lint DESIGN.md
```
The `designmd` shim resolves to the same entrypoint and works identically across
all platforms.
#### `npm error ENOVERSIONS` (“No versions available for @google/design.md”)
The CLI is published as [`@google/design.md` on npm](https://www.npmjs.com/package/@google/design.md). `ENOVERSIONS` almost always means npm is not querying the public registry (custom `registry=` in `.npmrc`, a corporate mirror that has not synced this package, or a misconfigured `@google:registry` for the `@google` scope).
Check your effective registry:
```bash
npm config get registry
```
For a normal install from the internet it should be `https://registry.npmjs.org/`. After fixing config, retry with `npm cache clean --force` if a stale 404 was cached.
All commands accept a file path or `-` for stdin. Output defaults to JSON.
> **Windows tip**: when invoking the CLI directly from a `package.json` script
> (rather than through `npx`), use the `designmd` alias instead of `design.md`.
> The `.md` suffix in the original bin name confuses Windows command resolution
> with the file association for Markdown files. The `designmd` shim resolves to
> the same entrypoint and works identically across all platforms.
>
> ```jsonc
> // package.json
> {
> "scripts": {
> "design:lint": "designmd lint DESIGN.md"
> }
> }
> ```
### `lint`
Validate a DESIGN.md file for structural correctness.
```bash
npx @google/design.md lint DESIGN.md
npx @google/design.md lint --format json DESIGN.md
cat DESIGN.md | npx @google/design.md lint -
```
| Option | Type | Default | Description |
|:-------|:-----|:--------|:------------|
| `file` | positional | required | Path to DESIGN.md (or `-` for stdin) |
| `--format` | `json` | `json` | Output format |
Exit code `1` if errors are found, `0` otherwise.
### `diff`
Compare two DESIGN.md files and report token-level changes.
```bash
npx @google/design.md diff DESIGN.md DESIGN-v2.md
```
| Option | Type | Default | Description |
|:-------|:-----|:--------|:------------|
| `before` | positional | required | Path to the "before" DESIGN.md |
| `after` | positional | required | Path to the "after" DESIGN.md |
| `--format` | `json` | `json` | Output format |
Exit code `1` if regressions are detected (more errors or warnings in the "after" file).
### `export`
Export DESIGN.md tokens to other formats.
```bash
npx @google/design.md export --format json-tailwind DESIGN.md > tailwind.theme.json
npx @google/design.md export --format css-tailwind DESIGN.md > theme.css
npx @google/design.md export --format dtcg DESIGN.md > tokens.json
```
| Option | Type | Default | Description |
|:-------|:-----|:--------|:------------|
| `file` | positional | required | Path to DESIGN.md (or `-` for stdin) |
| `--format` | `json-tailwind` \| `css-tailwind` \| `tailwind` \| `dtcg` | required | Output format |
| Format | Output | Description |
|:-------|:-------|:------------|
| `json-tailwind` | JSON | Tailwind v3 `theme.extend` config object |
| `css-tailwind` | CSS | Tailwind v4 `@theme { ... }` block with CSS custom properties |
| `tailwind` | JSON | Alias for `json-tailwind` |
| `dtcg` | JSON | W3C Design Tokens Format Module |
Exit code `0` on a successful export (regardless of any lint findings in the source — run `lint` to gate on those), `1` on an invalid `--format` or an emitter error, and `2` if the input file cannot be read.
### `spec`
Output the DESIGN.md format specification (useful for injecting spec context into agent prompts).
```bash
npx @google/design.md spec
npx @google/design.md spec --rules
npx @google/design.md spec --rules-only --format json
```
| Option | Type | Default | Description |
|:-------|:-----|:--------|:------------|
| `--rules` | boolean | `false` | Append the active linting rules table |
| `--rules-only` | boolean | `false` | Output only the linting rules table |
| `--format` | `markdown` \| `json` | `markdown` | Output format |
## Linting Rules
The linter runs nine rules against a parsed DESIGN.md. Each rule produces findings at a fixed severity level.
| Rule | Severity | What it checks |
|:-----|:---------|:---------------|
| `broken-ref` | error | Token references (`{colors.primary}`) that don't resolve to any defined token |
| `missing-primary` | warning | Colors are defined but no `primary` color exists — agents will auto-generate one |
| `contrast-ratio` | warning | Component `backgroundColor`/`textColor` pairs below WCAG AA minimum (4.5:1) |
| `orphaned-tokens` | warning | Color tokens defined but never referenced by any component |
| `token-summary` | info | Summary of how many tokens are defined in each section |
| `missing-sections` | info | Optional sections (spacing, rounded) absent when other tokens exist |
| `missing-typography` | warning | Colors are defined but no typography tokens exist — agents will use default fonts |
| `section-order` | warning | Sections appear out of the canonical order defined by the spec |
| `unknown-key` | warning | A top-level YAML key looks like a typo of a known schema key (e.g. `colours:``colors:`); custom extension keys stay silent |
### Programmatic API
The linter is also available as a library:
```typescript
import { lint } from '@google/design.md/linter';
const report = lint(markdownString);
console.log(report.findings); // Finding[]
console.log(report.summary); // { errors, warnings, info }
console.log(report.designSystem); // Parsed DesignSystemState
```
## Design Token Interoperability
DESIGN.md tokens are inspired by the [W3C Design Token Format](https://www.designtokens.org/). The `export` command converts tokens to other formats:
- **Tailwind v3 config (JSON)** — `npx @google/design.md export --format json-tailwind DESIGN.md` — emits a `theme.extend` JSON object for `tailwind.config.js`. `--format tailwind` is a backwards-compatible alias.
- **Tailwind v4 theme (CSS)** — `npx @google/design.md export --format css-tailwind DESIGN.md` — emits a CSS `@theme { ... }` block using Tailwind v4's CSS-variable token namespaces (`--color-*`, `--font-*`, `--text-*`, `--leading-*`, `--tracking-*`, `--font-weight-*`, `--radius-*`, `--spacing-*`).
- **DTCG tokens.json** ([W3C Design Tokens Format Module](https://tr.designtokens.org/format/)) — `npx @google/design.md export --format dtcg DESIGN.md`
## Status
The DESIGN.md format is at version `alpha`. The spec, token schema, and CLI are under active development. Expect changes to the format as it matures.
## Disclaimer
This project is not eligible for the [Google Open Source Software Vulnerability
Rewards Program](https://bughunters.google.com/open-source-security).
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`google-labs-code/design.md`
- 原始仓库:https://github.com/google-labs-code/design.md
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+411
View File
@@ -0,0 +1,411 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "design-monorepo",
"devDependencies": {
"@types/node": "^25.6.0",
"bun-types": "^1.3.12",
"turbo": "latest",
"typescript": "latest",
},
},
"packages/cli": {
"name": "@google/design.md",
"version": "0.2.0",
"bin": {
"design.md": "./dist/index.js",
"designmd": "./dist/index.js",
},
"dependencies": {
"citty": "^0.1.6",
"remark-frontmatter": "^5.0.0",
"remark-mdx": "^3.1.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.7.1",
"zod": "^3.24.0",
},
"devDependencies": {
"@types/bun": "latest",
"@types/mdast": "^4.0.4",
"@types/node": "^20.11.24",
"bun-types": "^1.3.12",
"tailwindcss": "3",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@google/design.md": ["@google/design.md@workspace:packages/cli"],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-X/56SnVXIQZBLKwniGTwEQTGmtE5brSACnKMBWpY3YafuxVYefrC2acamfjgxP7BG5w3I+6jf0UrLoSzgPcSJg=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aalBeSl4agT/QtYGDyf/XLajedWzUC9Vg/pm/YO6QQ93vkQ91Vz5uK1ta5RbVRDozQSz4njxUNqRNmOXDzW+qw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.9.6", "", { "os": "linux", "cpu": "x64" }, "sha512-YKi05jnNHaD7vevgYwahpzGwbsNNTwzU2c7VZdmdFm7+cGDP4oREUWSsainiMfRqjRuolQxBwRn8wf1jmu+YZA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-02o/ZS69cOYEDczXvOB2xmyrtzjQ2hVFtWZK1iqxXUfzMmTjZK4UumrfNnjckSg+gqeBfnPRHa0NstA173Ik3g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-wVdQjvnBI15wB6JrA+43CtUtagjIMmX6XYO758oZHAsCNSxqRlJtdyujih0D8OCnwCRWiGWGI63zAxR0hO6s9g=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-1XUUyWW0W6FTSqGEhU8RHVqb2wP1SPkr7hIvBlMEwH9jr+sJQK5kqeosLJ/QaUv4ecSAd1ZhIrLoW7qslAzT4A=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="],
"micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="],
"micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="],
"micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="],
"micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="],
"remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"turbo": ["turbo@2.9.6", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.6", "@turbo/darwin-arm64": "2.9.6", "@turbo/linux-64": "2.9.6", "@turbo/linux-arm64": "2.9.6", "@turbo/windows-64": "2.9.6", "@turbo/windows-arm64": "2.9.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-+v2QJey7ZUeUiuigkU+uFfklvNUyPI2VO2vBpMYJA+a1hKFLFiKtUYlRHdb3P9CrAvMzi0upbjI4WT+zKtqkBg=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@google/design.md/@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="],
"@google/design.md/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"@google/design.md/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"@google/design.md/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@google/design.md/bun-types/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
}
}
+365
View File
@@ -0,0 +1,365 @@
<!-- Generated from spec.mdx + spec-config.ts | version: alpha -->
<!-- Do not edit directly. Run `bun run spec:gen` to regenerate. -->
# DESIGN.md Format
DESIGN.md is a self-contained, plain-text representation of a design system. It defines the visual identity of a brand and product, thereby ensuring that these stylistic choices can be followed across design sessions and between different AI agents and tools. As a human-readable, open-format document, it serves as a living source of truth that both humans and AI can understand and refine.
A DESIGN.md file contains two parts: An optional YAML frontmatter, and a markdown body. The YAML front matter contains machine-readable design tokens. The markdown body sections provide human-readable design rationale and guidance. Prose may use descriptive color names (e.g., "Midnight Forest Green") that correspond to systematic token names (e.g., `primary`). The tokens are the normative values; the prose provides context for how to apply them.
# Design Tokens
DESIGN.md may embed design tokens in a structured format. The system that we use to describe design tokens is inspired by the
[Design Token JSON spec](https://www.designtokens.org/tr/2025.10/format/#abstract). Specifically, we adopt the concept of typed token groups (colors, typography, spacing) and the `{path.to.token}` reference syntax for cross-referencing values.
These tokens are easily converted from or to `tokens.json`, Figma variables, and Tailwind theme configs.
Design tokens are embedded as YAML front matter at the beginning of the file. The front matter block must begin with a line containing exactly `---` and end with a line containing exactly `---`. The YAML content between these delimiters is parsed according to the schema defined below.
Example:
```yaml
---
version: alpha
name: Daylight Prestige
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: 600
lineHeight: 1.1
letterSpacing: -0.02em
---
```
## Schema
Below is the schema for the design tokens defined in the front matter:
```yaml
version: <string> # optional, current version: "alpha"
name: <string>
description: <string> # optional
colors:
<token-name>: <Color>
typography:
<token-name>: <Typography>
rounded:
<scale-level>: <Dimension>
spacing:
<scale-level>: <Dimension | number>
components:
<component-name>:
<token-name>: <string|token reference>
```
The `<scale-level>` placeholder represents a named level in a sizing or spacing scale. Common level names include `xs`, `sm`, `md`, `lg`, `xl`, and `full`. Any descriptive string key is valid.
**Color**: A color value is any valid CSS color string. Supported formats include:
* Hex: `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`
* Named colors: `red`, `cornflowerblue`, `transparent`
* Functional: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`
* Wide-gamut: `oklch()`, `oklab()`, `lch()`, `lab()`
* Mixing: `color-mix(in srgb, ...)`
All color values are internally converted to sRGB for WCAG contrast checking. The original format is preserved for display and export.
Hex notation (`#RRGGBB`) remains the recommended default for simplicity and broad tooling support.
- `fontFamily` (string)
- `fontSize` (Dimension)
- `fontWeight` (number) - A numeric font weight value (e.g., `400`, `700`). In YAML, this may be expressed as either a bare number or a quoted string; both are equivalent.
- `lineHeight` (Dimension | number) - Accepts either a Dimension (e.g., `24px`, `1.5rem`) or a unitless number (e.g., `1.6`). A unitless number represents a multiplier of the element's `fontSize`, which is the recommended CSS practice.
- `letterSpacing` (Dimension)
- `fontFeature` (string) - configures
[`font-feature-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-feature-settings).
- `fontVariation` (string) - configures
[`font-variation-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variation-settings).
**Dimension**: A dimension value is a string with a unit suffix. Valid units are: px, em, rem.
**Token References**: A token reference must be wrapped in curly braces, and contain an object path to another value in the YAML tree. For most token groups, the reference must point to a primitive value (e.g., `colors.primary-60`), not a group (e.g., `colors`). Within the `components` section, references to composite values (e.g., `{typography.label-md}`) are permitted.
# Sections
Every `DESIGN.md` follows the same structure. Sections can be omitted if they're not relevant to your project, but those present should appear in the sequence listed below. All sections use `<h2>` (`##`) headings. An optional `<h1>` heading may appear for document titling purposes but is not parsed as a section.
### Section Order
1. **Overview** (also: "Brand & Style")
2. **Colors**
3. **Typography**
4. **Layout** (also: "Layout & Spacing")
5. **Elevation & Depth** (also: "Elevation")
6. **Shapes**
7. **Components**
8. **Do's and Don'ts**
### Prose and Tokens
## Overview
Also known as "Brand & Style".
This section is a holistic description of a product's look and feel. It defines the brand personality, target audience, and the emotional response the UI should evoke, such as whether it should feel playful or professional, dense or spacious. It serves as foundational context for guiding the agent's high-level stylistic decisions when a specific rule or token isn't explicitly defined.
## Colors
This section defines the color palettes for the design system.
At least the `primary` color palette must be defined, and additional color palettes may be defined as needed.
When there are multiple color palettes, the design system may assign a semantic role for each palette. A common convention is to name the palettes in this order: `primary`, `secondary`, `tertiary`, and `neutral`.
Example:
```markdown
## Colors
The palette is rooted in high-contrast neutrals and a single, evocative accent color.
- **Primary (#1A1C1E):** A deep ink used for headlines and core text to provide
maximum readability and a sense of permanence.
- **Secondary (#6C7278):** A sophisticated slate used primarily for utilitarian
elements like borders, captions, and metadata.
- **Tertiary (#B8422E):** A vibrant earthy red as the sole driver for
interaction, used exclusively for primary actions and critical highlights.
- **Neutral (#F7F5F2):** A warm limestone that serves as the foundation for all
pages, providing a softer, more organic feel than pure white.
```
### Design Tokens
The `colors` section defines all color design tokens. The color tokens should be derived from the key color palettes defined in the markdown prose. The exact mapping from color palettes to color tokens may follow any consistent naming convention.
It is a
map\<string, Color>, that maps the name of the color token to its value.
```yaml
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
tertiary: "#B8422E"
neutral: "#F7F5F2"
```
## Typography
This section defines typography levels.
Most design systems have 9 - 15 typography levels. The design system may prescribe a role for each typography level.
A common naming convention for typography levels is to use semantic categories such as `headline`, `display`, `body`, `label`, `caption`. Each category may further be divided into different sizes, such as `small`, `medium`, and `large`.
Example:
```markdown
## Typography
The typography strategy leverages two distinct weights of **Public Sans** for
the narrative and **Space Grotesk** for technical data.
- **Headlines:** Set in Public Sans Semi-Bold to establish an institutional
and trustworthy voice.
- **Body:** Public Sans Regular at 16px ensures contemporary professionalism
and long-form readability.
- **Labels:** Space Grotesk is used for all technical data, timestamps, and
metadata. Its geometric construction evokes the precision of a digital
stopwatch. Labels are strictly uppercase with generous letter spacing.
```
### Design Tokens
The `typography` section defines the precise font properties for the typography design tokens.
It is a
map\<string, Typography>
```yaml
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: 600
lineHeight: 1.1
letterSpacing: -0.02em
body-md:
fontFamily: Public Sans
fontSize: 16px
fontWeight: 400
lineHeight: 1.6
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: 500
lineHeight: 1
letterSpacing: 0.1em
```
## Layout
Also known as "Layout & Spacing".
This section describes the layout and spacing strategy.
Many design systems follow a grid-based layout. Others, like Liquid Glass, use margins, safe areas, and dynamic padding.
Example:
```markdown
## Layout
The layout follows a **Fluid Grid** model for mobile devices and a
**Fixed-Max-Width Grid** for desktop (max 1200px).
A strict 8px spacing scale (with a 4px half-step for micro-adjustments) is used to maintain a consistent rhythm. Components are grouped using "containment" principles, where related items are housed in cards with generous internal padding (24px) to emphasize the soft, approachable nature of the brand.
```
### Design Tokens
The spacing section defines the spacing design tokens. These may include spacing units that are useful for implementing the layout model. For example, a fixed grid layout may have spacing units for column spans, gutters, and margins.
It is a
map\<string, Dimension | number> that maps the spacing scale identifier to a dimension value or a unitless number (e.g., column counts or ratios).
```yaml
spacing:
base: 16px
xs: 4px
sm: 8px
md: 16px
lg: 32px
xl: 64px
gutter: 24px
margin: 32px
```
## Elevation & Depth
Also known as "Elevation".
This section describes how visual hierarchy is conveyed based on the design style. If elevation is used, it defines the required styling (spread, blur, color). For flat designs, this section explains the alternative methods used to convey visual hierarchy (e.g., borders, color contrast).
Example:
```markdown
## Elevation & Depth
Depth is achieved through **Tonal Layers** rather than heavy shadows. The
background uses a soft off-white or very light green, while primary content sits on pure white cards.
```
## Shapes
This section describes how visual elements are shaped.
Example:
```markdown
## Shapes
The shape language is defined by **Architectural Sharpness**. All interactive
elements, containers, and inputs utilize a minimal **4px corner radius**. This
provides just enough softness to feel modern while maintaining a rigid,
engineered aesthetic.
```
### Design Tokens
The `rounded` section defines the design tokens for rounded corners used in
buttons, cards, and other rectangular shapes.
It is a map\<string, Dimension>.
```yaml
rounded:
sm: 4px
md: 8px
lg: 12px
full: 9999px
```
## Components
This section provides style guidance for component atoms within the design system. The following are common component types. Design systems are encouraged to define additional components relevant to their domain.
* **Buttons**: Covers primary, secondary, and tertiary variants, including sizing, padding, and states.
* **Chips**: Covers selection chips, filter chips, and action chips.
* **Lists**: Covers styling for list items, dividers, and leading/trailing elements.
* **Tooltips**: Covers positioning, colors, and timing.
* **Checkboxes**: Covers checked, unchecked, and indeterminate states.
* **Radio buttons**: Covers selected and unselected states.
* **Input fields**: Covers text inputs, text areas, labels, helper text, and error states.
> **Note:** The components specification is actively evolving. The current structure provides intentional flexibility for domain-specific component definitions while the spec matures.
### Design Tokens
The components section defines a collection of design tokens used to ensure consistent styling of common components. It's a map\<string, map\<string, string>> that maps a component identifier to a group of sub token names and values. The design token values may be literal values, or references to previously defined design tokens.
**Variants**. A component may have a variant for different UI states such as active, hover, pressed, etc. Those variant components may be defined under a different but related key, for example, "button-primary", "button-primary-hover", "button-primary-active". The agent will consider all variants and make the appropriate styling decisions.
```yaml
components:
button-primary:
backgroundColor: "{colors.primary-60}"
textColor: "{colors.primary-20}"
rounded: "{rounded.md}"
padding: 12px
button-primary-hover:
backgroundColor: "{colors.primary-70}"
```
### Component Property Tokens
Each component has a set of properties that are themselves design tokens:
- backgroundColor: \<Color\>
- textColor: \<Color\>
- typography: \<Typography\>
- rounded: \<Dimension\>
- padding: \<Dimension\>
- size: \<Dimension\>
- height: \<Dimension\>
- width: \<Dimension\>
## Do's and Don'ts
This section provides practical guidelines and common pitfalls. These act as guardrails when creating designs.
```markdown
## Do's and Don'ts
- Do use the primary color only for the single most important action per screen
- Don't mix rounded and sharp corners in the same view
- Do maintain WCAG AA contrast ratios (4.5:1 for normal text)
- Don't use more than two font weights on a single screen
```
# Recommended Token Names (Non-Normative)
The following names are commonly used across design systems. They are not required but are provided as guidance for consistency.
**Colors:** `primary`, `secondary`, `tertiary`, `neutral`, `surface`, `on-surface`, `error`
**Typography:** `headline-display`, `headline-lg`, `headline-md`, `body-lg`, `body-md`, `body-sm`, `label-lg`, `label-md`, `label-sm`
**Rounded:** `none`, `sm`, `md`, `lg`, `xl`, `full`
# Consumer Behavior for Unknown Content
When a DESIGN.md consumer encounters content not defined by this spec:
| Scenario | Behavior | Example |
|---|---|---|
| Unknown section heading | Preserve; do not error | `## Iconography` |
| Unknown color token name | Accept if value is valid | `surface-container-high: '#ede7dd'` |
| Unknown typography token name | Accept as valid typography | `telemetry-data` |
| Unknown spacing value | Accept; store as string if not a valid dimension | `grid-columns: '5'` |
| Unknown component property | Accept with warning | `borderColor` |
| Duplicate section heading | Error; reject the file | Two `## Colors` headings |
+210
View File
@@ -0,0 +1,210 @@
---
name: Atmospheric Glass
colors:
surface: "#0b1326"
surface-dim: "#0b1326"
surface-bright: "#31394d"
surface-container-lowest: "#060e20"
surface-container-low: "#131b2e"
surface-container: "#171f33"
surface-container-high: "#222a3d"
surface-container-highest: "#2d3449"
on-surface: "#dae2fd"
on-surface-variant: "#c4c7c8"
inverse-surface: "#dae2fd"
inverse-on-surface: "#283044"
outline: "#8e9192"
outline-variant: "#444748"
surface-tint: "#c6c6c7"
primary: "#ffffff"
on-primary: "#2f3131"
primary-container: "#e2e2e2"
on-primary-container: "#636565"
inverse-primary: "#5d5f5f"
secondary: "#adc9eb"
on-secondary: "#14324e"
secondary-container: "#304b68"
on-secondary-container: "#9fbbdd"
tertiary: "#ffffff"
on-tertiary: "#620040"
tertiary-container: "#ffd8e7"
on-tertiary-container: "#ab3779"
error: "#ffb4ab"
on-error: "#690005"
error-container: "#93000a"
on-error-container: "#ffdad6"
primary-fixed: "#e2e2e2"
primary-fixed-dim: "#c6c6c7"
on-primary-fixed: "#1a1c1c"
on-primary-fixed-variant: "#454747"
secondary-fixed: "#d0e4ff"
secondary-fixed-dim: "#adc9eb"
on-secondary-fixed: "#001d35"
on-secondary-fixed-variant: "#2d4965"
tertiary-fixed: "#ffd8e7"
tertiary-fixed-dim: "#ffafd3"
on-tertiary-fixed: "#3d0026"
on-tertiary-fixed-variant: "#85145a"
background: "#0b1326"
on-background: "#dae2fd"
surface-variant: "#2d3449"
typography:
display-lg:
fontFamily: Inter
fontSize: 84px
fontWeight: "700"
lineHeight: 90px
letterSpacing: -0.04em
headline-lg:
fontFamily: Inter
fontSize: 32px
fontWeight: "600"
lineHeight: 40px
letterSpacing: -0.02em
headline-md:
fontFamily: Inter
fontSize: 24px
fontWeight: "500"
lineHeight: 32px
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: "400"
lineHeight: 28px
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: "400"
lineHeight: 24px
label-sm:
fontFamily: Inter
fontSize: 12px
fontWeight: "600"
lineHeight: 16px
letterSpacing: 0.05em
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
unit: 8px
container-padding: 24px
card-gap: 16px
section-margin: 40px
glass-padding: 20px
components:
glass-card-standard:
backgroundColor: rgba(255, 255, 255, 0.1)
textColor: "{colors.primary}"
rounded: "{rounded.lg}"
padding: "{spacing.glass-padding}"
glass-card-elevated:
backgroundColor: rgba(255, 255, 255, 0.2)
textColor: "{colors.primary}"
rounded: "{rounded.xl}"
padding: "{spacing.glass-padding}"
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.label-sm}"
rounded: "{rounded.xl}"
height: 48px
padding: 0 24px
button-primary-hover:
backgroundColor: "{colors.primary-fixed-dim}"
button-ghost:
backgroundColor: rgba(255, 255, 255, 0.05)
textColor: "{colors.primary}"
typography: "{typography.label-sm}"
rounded: "{rounded.xl}"
input-field:
backgroundColor: rgba(255, 255, 255, 0.1)
textColor: "{colors.primary}"
typography: "{typography.body-md}"
rounded: "{rounded.xl}"
padding: 20px
height: 48px
weather-display-large:
textColor: "{colors.primary}"
typography: "{typography.display-lg}"
metric-label:
textColor: "{colors.on-surface-variant}"
typography: "{typography.label-sm}"
list-item-interactive:
backgroundColor: transparent
rounded: "{rounded.md}"
padding: 12px
list-item-interactive-hover:
backgroundColor: rgba(255, 255, 255, 0.1)
---
## Brand & Style
This design system centers on a high-fidelity Glassmorphism aesthetic designed to evoke a sense of clarity, depth, and modern sophistication. The brand personality is ethereal yet functional, transforming complex meteorological data into a serene visual experience.
The UI relies on a "vibrant-minimalist" approach: the background provides the energy through multi-colored abstract gradients (pinks, purples, and blues), while the interface elements act as frosted crystalline lenses that focus the user's attention. The emotional response is intended to be calm and premium, utilizing transparency and blur to simulate physical layers of glass floating in a fluid, digital space.
## Colors
The color strategy prioritizes luminosity and contrast. Because the background is a vibrant, multi-colored abstract composition, the UI components utilize a monochromatic white palette with varying alpha channels to maintain legibility.
- **Primary Canvas:** A multi-stop linear or radial gradient background featuring Deep Blue (#1E3A8A), Vivid Purple (#7E22CE), and Soft Pink (#DB2777).
- **Surface Alpha:** Component backgrounds are never solid. They range from `rgba(255, 255, 255, 0.1)` for secondary depth to `0.2` for primary interaction areas.
- **Accents:** Semantic colors for weather conditions (e.g., Warning Yellow, Rain Blue) should be applied with high saturation but low opacity fills to maintain the glass effect.
- **Text:** Strictly white (#FFFFFF) or high-tint silver (#E2E8F0) to ensure WCAG compliance against the vibrant background.
## Typography
The design system utilizes **Inter** for its neutral, geometric clarity which balances the organic nature of the blurred backgrounds.
- **Hierarchy:** Large display sizes are used for temperature readings to create a clear focal point.
- **Legibility:** On frosted glass, font weight is increased by one tier (e.g., using Medium instead of Regular) to counteract the visual noise of the background blur.
- **Treatment:** Subtle text-shadows (`0px 2px 4px rgba(0,0,0,0.15)`) may be applied to small labels to ensure they "pop" against lighter areas of the background gradient.
## Layout & Spacing
The layout follows a fluid, contextual model. Elements are grouped into "Glass Containers" that float within the safe areas of the viewport.
- **Rhythm:** An 8px base grid governs all dimensions.
- **Grouping:** Related weather metrics (humidity, wind, UV index) are housed in a CSS grid or flex layout with 16px gaps.
- **Negative Space:** Generous outer margins (24px+) are maintained to ensure the vibrant background is visible, reinforcing the "floating" nature of the interface.
## Elevation & Depth
Depth in this design system is not achieved through darkness, but through the physics of light and refraction.
- **The Glass Stack:**
- **Level 1 (Base):** Dynamic background gradient with a slight grain texture.
- **Level 2 (Standard Card):** `backdrop-filter: blur(20px)`, `background: rgba(255, 255, 255, 0.1)`.
- **Level 3 (Elevated/Modals):** `backdrop-filter: blur(40px)`, `background: rgba(255, 255, 255, 0.2)`.
- **Edge Definition:** Every glass surface must have a 1px solid border at `rgba(255, 255, 255, 0.2)`. A secondary inner "shine" border (top and left only) can be used to simulate a light source.
- **Shadows:** Use extremely soft, spread-out shadows (`box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1)`) to separate the glass layers from the background without making the UI feel "heavy."
## Shapes
The shape language is organic and approachable. This design system uses "Rounded" (Level 2) settings to complement the fluid nature of the background.
- **Cards:** Use `1rem` (16px) for standard weather cards.
- **Action Elements:** Buttons and search bars use `rounded-xl` (1.5rem / 24px) to create a soft, tactile feel.
- **Icons:** Weather iconography should be line-based with rounded caps (2px stroke width) to match the border weights of the containers.
## Components
### Glass Containers
Standard cards use a 20px blur for general metrics, while elevated cards (modals or focal points) use 40px blur and higher opacity to sit physically higher in the stack. All glass elements must feature a 1px white border to simulate light refraction on the edges.
### Action Elements
Buttons use the `rounded-xl` setting to maintain a soft, organic feel. Primary buttons are solid white for maximum contrast, while ghost buttons utilize backdrop filters to remain integrated with the atmospheric background.
### Inputs & Interaction
Interactive list items and text inputs use subtle hover states and light blurs rather than solid color changes, preserving the "crystalline" transparency of the UI.
### Typography Application
Large weather displays utilize subtle text shadows to maintain legibility against the unpredictable colors of the background gradient. Metric labels should remain secondary in hierarchy using the silver-tinted `on-surface-variant` color.
+11
View File
@@ -0,0 +1,11 @@
# Atmospheric Glass
A glassmorphism-driven weather application design system. Frosted crystalline panels float over vibrant gradient backgrounds, transforming complex meteorological data into a serene, premium visual experience. Uses a monochromatic white palette with varying alpha channels for luminosity and depth.
## Files
| File | Description |
|------|-------------|
| `DESIGN.md` | The complete design system specification in DESIGN.md format, including both structured YAML design tokens (frontmatter) and human-readable style guidance (markdown body). |
| `tailwind.config.js` | A Tailwind CSS v3 theme configuration derived from the design tokens in the DESIGN.md frontmatter. Covers colors, typography, border-radius, and spacing. Component tokens are intentionally excluded — Tailwind's utility-first approach handles component styling through composition of these primitives. |
| `design_tokens.json` | A [Design Tokens Community Group](https://www.designtokens.org/) JSON file containing all design tokens from the DESIGN.md frontmatter, including component-level tokens. This format is interoperable with tools like Figma, Style Dictionary, and other token pipelines. |
@@ -0,0 +1,909 @@
{
"name": {
"$type": "string",
"$value": "Atmospheric Glass"
},
"colors": {
"surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.043,
0.075,
0.149
],
"hex": "#0b1326"
}
},
"surface-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.043,
0.075,
0.149
],
"hex": "#0b1326"
}
},
"surface-bright": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.192,
0.224,
0.302
],
"hex": "#31394d"
}
},
"surface-container-lowest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.024,
0.055,
0.125
],
"hex": "#060e20"
}
},
"surface-container-low": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.075,
0.106,
0.18
],
"hex": "#131b2e"
}
},
"surface-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.09,
0.122,
0.2
],
"hex": "#171f33"
}
},
"surface-container-high": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.133,
0.165,
0.239
],
"hex": "#222a3d"
}
},
"surface-container-highest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.176,
0.204,
0.286
],
"hex": "#2d3449"
}
},
"on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.855,
0.886,
0.992
],
"hex": "#dae2fd"
}
},
"on-surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.769,
0.78,
0.784
],
"hex": "#c4c7c8"
}
},
"inverse-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.855,
0.886,
0.992
],
"hex": "#dae2fd"
}
},
"inverse-on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.157,
0.188,
0.267
],
"hex": "#283044"
}
},
"outline": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.557,
0.569,
0.573
],
"hex": "#8e9192"
}
},
"outline-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.267,
0.278,
0.282
],
"hex": "#444748"
}
},
"surface-tint": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.776,
0.776,
0.78
],
"hex": "#c6c6c7"
}
},
"primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"on-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.184,
0.192,
0.192
],
"hex": "#2f3131"
}
},
"primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.886,
0.886,
0.886
],
"hex": "#e2e2e2"
}
},
"on-primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.388,
0.396,
0.396
],
"hex": "#636565"
}
},
"inverse-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.365,
0.373,
0.373
],
"hex": "#5d5f5f"
}
},
"secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.678,
0.788,
0.922
],
"hex": "#adc9eb"
}
},
"on-secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.078,
0.196,
0.306
],
"hex": "#14324e"
}
},
"secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.188,
0.294,
0.408
],
"hex": "#304b68"
}
},
"on-secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.624,
0.733,
0.867
],
"hex": "#9fbbdd"
}
},
"tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"on-tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.384,
0.0,
0.251
],
"hex": "#620040"
}
},
"tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.847,
0.906
],
"hex": "#ffd8e7"
}
},
"on-tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.671,
0.216,
0.475
],
"hex": "#ab3779"
}
},
"error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.706,
0.671
],
"hex": "#ffb4ab"
}
},
"on-error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.412,
0.0,
0.02
],
"hex": "#690005"
}
},
"error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.576,
0.0,
0.039
],
"hex": "#93000a"
}
},
"on-error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.855,
0.839
],
"hex": "#ffdad6"
}
},
"primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.886,
0.886,
0.886
],
"hex": "#e2e2e2"
}
},
"primary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.776,
0.776,
0.78
],
"hex": "#c6c6c7"
}
},
"on-primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.102,
0.11,
0.11
],
"hex": "#1a1c1c"
}
},
"on-primary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.271,
0.278,
0.278
],
"hex": "#454747"
}
},
"secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.816,
0.894,
1.0
],
"hex": "#d0e4ff"
}
},
"secondary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.678,
0.788,
0.922
],
"hex": "#adc9eb"
}
},
"on-secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.114,
0.208
],
"hex": "#001d35"
}
},
"on-secondary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.176,
0.286,
0.396
],
"hex": "#2d4965"
}
},
"tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.847,
0.906
],
"hex": "#ffd8e7"
}
},
"tertiary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.686,
0.827
],
"hex": "#ffafd3"
}
},
"on-tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.239,
0.0,
0.149
],
"hex": "#3d0026"
}
},
"on-tertiary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.522,
0.078,
0.353
],
"hex": "#85145a"
}
},
"background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.043,
0.075,
0.149
],
"hex": "#0b1326"
}
},
"on-background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.855,
0.886,
0.992
],
"hex": "#dae2fd"
}
},
"surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.176,
0.204,
0.286
],
"hex": "#2d3449"
}
}
},
"typography": {
"display-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 84,
"unit": "px"
},
"fontWeight": 700,
"lineHeight": {
"value": 90,
"unit": "px"
},
"letterSpacing": {
"value": -0.04,
"unit": "em"
}
}
},
"headline-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 32,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 40,
"unit": "px"
},
"letterSpacing": {
"value": -0.02,
"unit": "em"
}
}
},
"headline-md": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 24,
"unit": "px"
},
"fontWeight": 500,
"lineHeight": {
"value": 32,
"unit": "px"
}
}
},
"body-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 18,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 28,
"unit": "px"
}
}
},
"body-md": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 16,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 24,
"unit": "px"
}
}
},
"label-sm": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 12,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 16,
"unit": "px"
},
"letterSpacing": {
"value": 0.05,
"unit": "em"
}
}
}
},
"rounded": {
"sm": {
"$type": "dimension",
"$value": {
"value": 0.25,
"unit": "rem"
}
},
"DEFAULT": {
"$type": "dimension",
"$value": {
"value": 0.5,
"unit": "rem"
}
},
"md": {
"$type": "dimension",
"$value": {
"value": 0.75,
"unit": "rem"
}
},
"lg": {
"$type": "dimension",
"$value": {
"value": 1,
"unit": "rem"
}
},
"xl": {
"$type": "dimension",
"$value": {
"value": 1.5,
"unit": "rem"
}
},
"full": {
"$type": "dimension",
"$value": {
"value": 9999,
"unit": "px"
}
}
},
"spacing": {
"unit": {
"$type": "dimension",
"$value": {
"value": 8,
"unit": "px"
}
},
"container-padding": {
"$type": "dimension",
"$value": {
"value": 24,
"unit": "px"
}
},
"card-gap": {
"$type": "dimension",
"$value": {
"value": 16,
"unit": "px"
}
},
"section-margin": {
"$type": "dimension",
"$value": {
"value": 40,
"unit": "px"
}
},
"glass-padding": {
"$type": "dimension",
"$value": {
"value": 20,
"unit": "px"
}
}
},
"components": {
"glass-card-standard": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"alpha": 0.1
}
},
"textColor": "{colors.primary}",
"rounded": "{rounded.lg}",
"padding": "{spacing.glass-padding}"
},
"glass-card-elevated": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"alpha": 0.2
}
},
"textColor": "{colors.primary}",
"rounded": "{rounded.xl}",
"padding": "{spacing.glass-padding}"
},
"button-primary": {
"backgroundColor": "{colors.primary}",
"textColor": "{colors.on-primary}",
"typography": "{typography.label-sm}",
"rounded": "{rounded.xl}",
"height": {
"$type": "dimension",
"$value": {
"value": 48,
"unit": "px"
}
},
"padding": {
"$type": "dimension",
"$value": "0 24px"
}
},
"button-primary-hover": {
"backgroundColor": "{colors.primary-fixed-dim}"
},
"button-ghost": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"alpha": 0.05
}
},
"textColor": "{colors.primary}",
"typography": "{typography.label-sm}",
"rounded": "{rounded.xl}"
},
"input-field": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"alpha": 0.1
}
},
"textColor": "{colors.primary}",
"typography": "{typography.body-md}",
"rounded": "{rounded.xl}",
"padding": {
"$type": "dimension",
"$value": {
"value": 20,
"unit": "px"
}
},
"height": {
"$type": "dimension",
"$value": {
"value": 48,
"unit": "px"
}
}
},
"weather-display-large": {
"textColor": "{colors.primary}",
"typography": "{typography.display-lg}"
},
"metric-label": {
"textColor": "{colors.on-surface-variant}",
"typography": "{typography.label-sm}",
"textTransform": {
"$type": "string",
"$value": "uppercase"
}
},
"list-item-interactive": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0,
0,
0
],
"alpha": 0
}
},
"rounded": "{rounded.md}",
"padding": {
"$type": "dimension",
"$value": {
"value": 12,
"unit": "px"
}
}
},
"list-item-interactive-hover": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"alpha": 0.1
}
}
}
}
}
@@ -0,0 +1,141 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface": "#0b1326",
"surface-dim": "#0b1326",
"surface-bright": "#31394d",
"surface-container-lowest": "#060e20",
"surface-container-low": "#131b2e",
"surface-container": "#171f33",
"surface-container-high": "#222a3d",
"surface-container-highest": "#2d3449",
"on-surface": "#dae2fd",
"on-surface-variant": "#c4c7c8",
"inverse-surface": "#dae2fd",
"inverse-on-surface": "#283044",
"outline": "#8e9192",
"outline-variant": "#444748",
"surface-tint": "#c6c6c7",
"primary": "#ffffff",
"on-primary": "#2f3131",
"primary-container": "#e2e2e2",
"on-primary-container": "#636565",
"inverse-primary": "#5d5f5f",
"secondary": "#adc9eb",
"on-secondary": "#14324e",
"secondary-container": "#304b68",
"on-secondary-container": "#9fbbdd",
"tertiary": "#ffffff",
"on-tertiary": "#620040",
"tertiary-container": "#ffd8e7",
"on-tertiary-container": "#ab3779",
"error": "#ffb4ab",
"on-error": "#690005",
"error-container": "#93000a",
"on-error-container": "#ffdad6",
"primary-fixed": "#e2e2e2",
"primary-fixed-dim": "#c6c6c7",
"on-primary-fixed": "#1a1c1c",
"on-primary-fixed-variant": "#454747",
"secondary-fixed": "#d0e4ff",
"secondary-fixed-dim": "#adc9eb",
"on-secondary-fixed": "#001d35",
"on-secondary-fixed-variant": "#2d4965",
"tertiary-fixed": "#ffd8e7",
"tertiary-fixed-dim": "#ffafd3",
"on-tertiary-fixed": "#3d0026",
"on-tertiary-fixed-variant": "#85145a",
"background": "#0b1326",
"on-background": "#dae2fd",
"surface-variant": "#2d3449",
},
fontFamily: {
"display-lg": ["Inter"],
"headline-lg": ["Inter"],
"headline-md": ["Inter"],
"body-lg": ["Inter"],
"body-md": ["Inter"],
"label-sm": ["Inter"],
},
fontSize: {
"display-lg": [
"84px",
{
lineHeight: "90px",
letterSpacing: "-0.04em",
fontWeight: "700",
},
],
"headline-lg": [
"32px",
{
lineHeight: "40px",
letterSpacing: "-0.02em",
fontWeight: "600",
},
],
"headline-md": [
"24px",
{
lineHeight: "32px",
fontWeight: "500",
},
],
"body-lg": [
"18px",
{
lineHeight: "28px",
fontWeight: "400",
},
],
"body-md": [
"16px",
{
lineHeight: "24px",
fontWeight: "400",
},
],
"label-sm": [
"12px",
{
lineHeight: "16px",
letterSpacing: "0.05em",
fontWeight: "600",
},
],
},
borderRadius: {
"sm": "0.25rem",
"DEFAULT": "0.5rem",
"md": "0.75rem",
"lg": "1rem",
"xl": "1.5rem",
"full": "9999px",
},
spacing: {
"unit": "8px",
"container-padding": "24px",
"card-gap": "16px",
"section-margin": "40px",
"glass-padding": "20px",
},
},
},
};
+219
View File
@@ -0,0 +1,219 @@
---
name: Paws & Paths
colors:
surface: "#f9f9ff"
surface-dim: "#d3daea"
surface-bright: "#f9f9ff"
surface-container-lowest: "#ffffff"
surface-container-low: "#f0f3ff"
surface-container: "#e7eefe"
surface-container-high: "#e2e8f8"
surface-container-highest: "#dce2f3"
on-surface: "#151c27"
on-surface-variant: "#534434"
inverse-surface: "#2a313d"
inverse-on-surface: "#ebf1ff"
outline: "#867461"
outline-variant: "#d8c3ad"
surface-tint: "#855300"
primary: "#855300"
on-primary: "#ffffff"
primary-container: "#f59e0b"
on-primary-container: "#613b00"
inverse-primary: "#ffb95f"
secondary: "#0058be"
on-secondary: "#ffffff"
secondary-container: "#2170e4"
on-secondary-container: "#fefcff"
tertiary: "#00658b"
on-tertiary: "#ffffff"
tertiary-container: "#1abdff"
on-tertiary-container: "#004966"
error: "#ba1a1a"
on-error: "#ffffff"
error-container: "#ffdad6"
on-error-container: "#93000a"
primary-fixed: "#ffddb8"
primary-fixed-dim: "#ffb95f"
on-primary-fixed: "#2a1700"
on-primary-fixed-variant: "#653e00"
secondary-fixed: "#d8e2ff"
secondary-fixed-dim: "#adc6ff"
on-secondary-fixed: "#001a42"
on-secondary-fixed-variant: "#004395"
tertiary-fixed: "#c5e7ff"
tertiary-fixed-dim: "#7fd0ff"
on-tertiary-fixed: "#001e2d"
on-tertiary-fixed-variant: "#004c6a"
background: "#f9f9ff"
on-background: "#151c27"
surface-variant: "#dce2f3"
typography:
display:
fontFamily: Plus Jakarta Sans
fontSize: 44px
fontWeight: "800"
lineHeight: 52px
letterSpacing: -0.02em
headline-lg:
fontFamily: Plus Jakarta Sans
fontSize: 32px
fontWeight: "700"
lineHeight: 40px
letterSpacing: -0.01em
headline-md:
fontFamily: Plus Jakarta Sans
fontSize: 24px
fontWeight: "700"
lineHeight: 32px
title-lg:
fontFamily: Plus Jakarta Sans
fontSize: 20px
fontWeight: "600"
lineHeight: 28px
body-lg:
fontFamily: Plus Jakarta Sans
fontSize: 18px
fontWeight: "400"
lineHeight: 28px
body-md:
fontFamily: Plus Jakarta Sans
fontSize: 16px
fontWeight: "400"
lineHeight: 24px
label-md:
fontFamily: Plus Jakarta Sans
fontSize: 14px
fontWeight: "600"
lineHeight: 20px
letterSpacing: 0.01em
label-sm:
fontFamily: Plus Jakarta Sans
fontSize: 12px
fontWeight: "500"
lineHeight: 16px
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
base: 8px
xs: 4px
sm: 12px
md: 24px
lg: 40px
xl: 64px
gutter: 16px
margin: 24px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
padding: "{spacing.md}"
button-primary-hover:
backgroundColor: "{colors.primary-container}"
textColor: "{colors.on-primary-container}"
button-secondary:
backgroundColor: "{colors.secondary}"
textColor: "{colors.on-secondary}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
padding: "{spacing.md}"
button-secondary-hover:
backgroundColor: "{colors.secondary-container}"
textColor: "{colors.on-secondary-container}"
card-profile:
backgroundColor: "{colors.surface-container-lowest}"
rounded: "{rounded.xl}"
padding: "{spacing.md}"
card-walk-stat:
backgroundColor: "{colors.secondary-container}"
textColor: "{colors.on-secondary-container}"
rounded: "{rounded.md}"
padding: "{spacing.sm}"
input-field:
backgroundColor: "{colors.surface-container-low}"
textColor: "{colors.on-surface}"
typography: "{typography.body-md}"
rounded: "{rounded.DEFAULT}"
padding: "{spacing.sm}"
list-item-walker:
backgroundColor: transparent
padding: "{spacing.sm}"
rounded: "{rounded.md}"
list-item-walker-hover:
backgroundColor: "{colors.surface-container-high}"
badge-status:
backgroundColor: "{colors.tertiary-container}"
textColor: "{colors.on-tertiary-container}"
typography: "{typography.label-sm}"
rounded: "{rounded.full}"
padding: "{spacing.xs}"
---
## Brand & Style
The design system is built to evoke the joyful energy of a walk in the park balanced with the reliability of a premium professional service. The brand personality is optimistic, trustworthy, and active.
The chosen style is **Modern Corporate** with a friendly, human-centric twist. It utilizes clean layouts and significant whitespace to reduce cognitive load for busy pet owners. The interface feels light and airy, avoiding heavy borders in favor of soft shadows and tonal shifts to create a welcoming, "best-in-class" digital environment.
## Colors
The palette centers on "Golden Retriever" orange to drive action and signal energy. This is balanced by "Sky Walk" blue, which provides a calming counterpoint for administrative tasks and scheduling.
- **Primary:** Use for main actions, active states, and highlights.
- **Secondary:** Use for secondary information, trust indicators, and navigation accents.
- **Neutral:** A range of soft grays used for backgrounds and borders to keep the UI feeling "premium."
- **Deep Charcoal:** Used for all primary text to ensure high legibility and a grounded, professional feel.
## Typography
This design system utilizes **Plus Jakarta Sans** for its friendly, rounded terminals and exceptional legibility. It maintains a contemporary look while feeling more approachable than standard geometric sans-serifs.
- **Headlines:** Bold weights are used to create a clear hierarchy and guide the eye quickly to key information.
- **Body:** Generous line heights are applied to the body text to maintain the "premium and clean" feel.
- **Labels:** Used for buttons and small metadata, utilizing a medium or semi-bold weight to remain distinct even at small scales.
## Layout & Spacing
The layout follows a **Fixed Grid** model for mobile-first consistency, utilizing a 4-column system for handheld devices.
- **Whitespace:** A "generous" philosophy is applied. Never crowd elements; use `lg` and `xl` spacing for section vertical separation to maintain a high-end aesthetic.
- **Rhythm:** Spacing is strictly based on an 8px scale.
- **Containers:** Content should be centered with a maximum width on larger screens, ensuring the "Paths" (user journeys) feel focused and intentional.
## Elevation & Depth
This design system uses **Ambient Shadows** and **Tonal Layers** to define the interface's verticality.
- **Surfaces:** Main backgrounds use the lightest neutral tint. Interactive cards sit one level above on a pure white surface.
- **Shadows:** Shadows are highly diffused and soft (Blur: 20px-40px, Opacity: 4-8%) with a subtle hint of the primary orange or secondary blue mixed into the shadow color to prevent a "dirty" gray look.
- **Interactions:** Elements should subtly lift on hover or tap, increasing shadow spread to provide tactile feedback.
## Shapes
The shape language is defined by **Rounded** corners, mirroring the soft features of a pet and making the app feel safe and friendly.
- **Buttons:** Main CTA buttons use a `12px` (rounded-lg) radius to feel substantial and clickable.
- **Cards:** Dog profiles and walker cards use a `1.5rem` (rounded-xl) radius to create a soft, containerized look.
- **Inputs:** Form fields use a `0.5rem` radius to maintain a professional yet modern appearance.
- **Icons:** Icons should feature rounded caps and corners to harmonize with the UI's structural elements.
## Components
### Buttons & Inputs
Buttons use `rounded-lg` (12px) to feel substantial and friendly, while form fields use a smaller `DEFAULT` radius to maintain structural alignment. All interactive states should utilize a subtle 150ms ease-in-out transition for background color shifts.
### Cards & Elevation
The `card-profile` is the hero container, utilizing `rounded-xl` and a tinted ambient shadow to create a "lifted" appearance against the `surface` background. Use `card-walk-stat` for high-contrast data visualization within the blue secondary palette.
### Lists & Navigation
List items should maintain a wide touch target and use `surface-container-high` for hover states to provide clear feedback without visual clutter. Use the `badge-status` for pet availability or walk progress indicators, ensuring the typography remains legible at the smaller scale.
+11
View File
@@ -0,0 +1,11 @@
# Paws & Paths
A warm, friendly design system for a dog-walking and pet care platform. Built around "Golden Retriever" orange primary and "Sky Walk" blue secondary colors, with Plus Jakarta Sans typography for an approachable yet premium feel.
## Files
| File | Description |
|------|-------------|
| `DESIGN.md` | The complete design system specification in DESIGN.md format, including both structured YAML design tokens (frontmatter) and human-readable style guidance (markdown body). |
| `tailwind.config.js` | A Tailwind CSS v3 theme configuration derived from the design tokens in the DESIGN.md frontmatter. Covers colors, typography, border-radius, and spacing. Component tokens are intentionally excluded — Tailwind's utility-first approach handles component styling through composition of these primitives. |
| `design_tokens.json` | A [Design Tokens Community Group](https://www.designtokens.org/) JSON file containing all design tokens from the DESIGN.md frontmatter, including component-level tokens. This format is interoperable with tools like Figma, Style Dictionary, and other token pipelines. |
+876
View File
@@ -0,0 +1,876 @@
{
"name": {
"$type": "string",
"$value": "Paws & Paths"
},
"colors": {
"surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.976,
0.976,
1.0
],
"hex": "#f9f9ff"
}
},
"surface-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.827,
0.855,
0.918
],
"hex": "#d3daea"
}
},
"surface-bright": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.976,
0.976,
1.0
],
"hex": "#f9f9ff"
}
},
"surface-container-lowest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"surface-container-low": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.941,
0.953,
1.0
],
"hex": "#f0f3ff"
}
},
"surface-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.906,
0.933,
0.996
],
"hex": "#e7eefe"
}
},
"surface-container-high": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.886,
0.91,
0.973
],
"hex": "#e2e8f8"
}
},
"surface-container-highest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.863,
0.886,
0.953
],
"hex": "#dce2f3"
}
},
"on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.082,
0.11,
0.153
],
"hex": "#151c27"
}
},
"on-surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.325,
0.267,
0.204
],
"hex": "#534434"
}
},
"inverse-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.165,
0.192,
0.239
],
"hex": "#2a313d"
}
},
"inverse-on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.922,
0.945,
1.0
],
"hex": "#ebf1ff"
}
},
"outline": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.525,
0.455,
0.38
],
"hex": "#867461"
}
},
"outline-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.847,
0.765,
0.678
],
"hex": "#d8c3ad"
}
},
"surface-tint": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.522,
0.325,
0.0
],
"hex": "#855300"
}
},
"primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.522,
0.325,
0.0
],
"hex": "#855300"
}
},
"on-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.961,
0.62,
0.043
],
"hex": "#f59e0b"
}
},
"on-primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.38,
0.231,
0.0
],
"hex": "#613b00"
}
},
"inverse-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.725,
0.373
],
"hex": "#ffb95f"
}
},
"secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.345,
0.745
],
"hex": "#0058be"
}
},
"on-secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.129,
0.439,
0.894
],
"hex": "#2170e4"
}
},
"on-secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.996,
0.988,
1.0
],
"hex": "#fefcff"
}
},
"tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.396,
0.545
],
"hex": "#00658b"
}
},
"on-tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.102,
0.741,
1.0
],
"hex": "#1abdff"
}
},
"on-tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.286,
0.4
],
"hex": "#004966"
}
},
"error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.729,
0.102,
0.102
],
"hex": "#ba1a1a"
}
},
"on-error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
1.0,
1.0
],
"hex": "#ffffff"
}
},
"error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.855,
0.839
],
"hex": "#ffdad6"
}
},
"on-error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.576,
0.0,
0.039
],
"hex": "#93000a"
}
},
"primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.867,
0.722
],
"hex": "#ffddb8"
}
},
"primary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.725,
0.373
],
"hex": "#ffb95f"
}
},
"on-primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.165,
0.09,
0.0
],
"hex": "#2a1700"
}
},
"on-primary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.396,
0.243,
0.0
],
"hex": "#653e00"
}
},
"secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.847,
0.886,
1.0
],
"hex": "#d8e2ff"
}
},
"secondary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.678,
0.776,
1.0
],
"hex": "#adc6ff"
}
},
"on-secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.102,
0.259
],
"hex": "#001a42"
}
},
"on-secondary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.263,
0.584
],
"hex": "#004395"
}
},
"tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.773,
0.906,
1.0
],
"hex": "#c5e7ff"
}
},
"tertiary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.498,
0.816,
1.0
],
"hex": "#7fd0ff"
}
},
"on-tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.118,
0.176
],
"hex": "#001e2d"
}
},
"on-tertiary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.298,
0.416
],
"hex": "#004c6a"
}
},
"background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.976,
0.976,
1.0
],
"hex": "#f9f9ff"
}
},
"on-background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.082,
0.11,
0.153
],
"hex": "#151c27"
}
},
"surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.863,
0.886,
0.953
],
"hex": "#dce2f3"
}
}
},
"typography": {
"display": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 44,
"unit": "px"
},
"fontWeight": 800,
"lineHeight": {
"value": 52,
"unit": "px"
},
"letterSpacing": {
"value": -0.02,
"unit": "em"
}
}
},
"headline-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 32,
"unit": "px"
},
"fontWeight": 700,
"lineHeight": {
"value": 40,
"unit": "px"
},
"letterSpacing": {
"value": -0.01,
"unit": "em"
}
}
},
"headline-md": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 24,
"unit": "px"
},
"fontWeight": 700,
"lineHeight": {
"value": 32,
"unit": "px"
}
}
},
"title-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 20,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 28,
"unit": "px"
}
}
},
"body-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 18,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 28,
"unit": "px"
}
}
},
"body-md": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 16,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 24,
"unit": "px"
}
}
},
"label-md": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 14,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 20,
"unit": "px"
},
"letterSpacing": {
"value": 0.01,
"unit": "em"
}
}
},
"label-sm": {
"$type": "typography",
"$value": {
"fontFamily": "Plus Jakarta Sans",
"fontSize": {
"value": 12,
"unit": "px"
},
"fontWeight": 500,
"lineHeight": {
"value": 16,
"unit": "px"
}
}
}
},
"rounded": {
"sm": {
"$type": "dimension",
"$value": {
"value": 0.25,
"unit": "rem"
}
},
"DEFAULT": {
"$type": "dimension",
"$value": {
"value": 0.5,
"unit": "rem"
}
},
"md": {
"$type": "dimension",
"$value": {
"value": 0.75,
"unit": "rem"
}
},
"lg": {
"$type": "dimension",
"$value": {
"value": 1,
"unit": "rem"
}
},
"xl": {
"$type": "dimension",
"$value": {
"value": 1.5,
"unit": "rem"
}
},
"full": {
"$type": "dimension",
"$value": {
"value": 9999,
"unit": "px"
}
}
},
"spacing": {
"base": {
"$type": "dimension",
"$value": {
"value": 8,
"unit": "px"
}
},
"xs": {
"$type": "dimension",
"$value": {
"value": 4,
"unit": "px"
}
},
"sm": {
"$type": "dimension",
"$value": {
"value": 12,
"unit": "px"
}
},
"md": {
"$type": "dimension",
"$value": {
"value": 24,
"unit": "px"
}
},
"lg": {
"$type": "dimension",
"$value": {
"value": 40,
"unit": "px"
}
},
"xl": {
"$type": "dimension",
"$value": {
"value": 64,
"unit": "px"
}
},
"gutter": {
"$type": "dimension",
"$value": {
"value": 16,
"unit": "px"
}
},
"margin": {
"$type": "dimension",
"$value": {
"value": 24,
"unit": "px"
}
}
},
"components": {
"button-primary": {
"backgroundColor": "{colors.primary}",
"textColor": "{colors.on-primary}",
"typography": "{typography.label-md}",
"rounded": "{rounded.lg}",
"padding": "{spacing.md}"
},
"button-primary-hover": {
"backgroundColor": "{colors.primary-container}",
"textColor": "{colors.on-primary-container}"
},
"button-secondary": {
"backgroundColor": "{colors.secondary}",
"textColor": "{colors.on-secondary}",
"typography": "{typography.label-md}",
"rounded": "{rounded.lg}",
"padding": "{spacing.md}"
},
"button-secondary-hover": {
"backgroundColor": "{colors.secondary-container}",
"textColor": "{colors.on-secondary-container}"
},
"card-profile": {
"backgroundColor": "{colors.surface-container-lowest}",
"rounded": "{rounded.xl}",
"padding": "{spacing.md}"
},
"card-walk-stat": {
"backgroundColor": "{colors.secondary-container}",
"textColor": "{colors.on-secondary-container}",
"rounded": "{rounded.md}",
"padding": "{spacing.sm}"
},
"input-field": {
"backgroundColor": "{colors.surface-container-low}",
"textColor": "{colors.on-surface}",
"typography": "{typography.body-md}",
"rounded": "{rounded.DEFAULT}",
"padding": "{spacing.sm}"
},
"list-item-walker": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0,
0,
0
],
"alpha": 0
}
},
"padding": "{spacing.sm}",
"rounded": "{rounded.md}"
},
"list-item-walker-hover": {
"backgroundColor": "{colors.surface-container-high}"
},
"badge-status": {
"backgroundColor": "{colors.tertiary-container}",
"textColor": "{colors.on-tertiary-container}",
"typography": "{typography.label-sm}",
"rounded": "{rounded.full}",
"padding": "{spacing.xs}"
}
}
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface": "#f9f9ff",
"surface-dim": "#d3daea",
"surface-bright": "#f9f9ff",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f0f3ff",
"surface-container": "#e7eefe",
"surface-container-high": "#e2e8f8",
"surface-container-highest": "#dce2f3",
"on-surface": "#151c27",
"on-surface-variant": "#534434",
"inverse-surface": "#2a313d",
"inverse-on-surface": "#ebf1ff",
"outline": "#867461",
"outline-variant": "#d8c3ad",
"surface-tint": "#855300",
"primary": "#855300",
"on-primary": "#ffffff",
"primary-container": "#f59e0b",
"on-primary-container": "#613b00",
"inverse-primary": "#ffb95f",
"secondary": "#0058be",
"on-secondary": "#ffffff",
"secondary-container": "#2170e4",
"on-secondary-container": "#fefcff",
"tertiary": "#00658b",
"on-tertiary": "#ffffff",
"tertiary-container": "#1abdff",
"on-tertiary-container": "#004966",
"error": "#ba1a1a",
"on-error": "#ffffff",
"error-container": "#ffdad6",
"on-error-container": "#93000a",
"primary-fixed": "#ffddb8",
"primary-fixed-dim": "#ffb95f",
"on-primary-fixed": "#2a1700",
"on-primary-fixed-variant": "#653e00",
"secondary-fixed": "#d8e2ff",
"secondary-fixed-dim": "#adc6ff",
"on-secondary-fixed": "#001a42",
"on-secondary-fixed-variant": "#004395",
"tertiary-fixed": "#c5e7ff",
"tertiary-fixed-dim": "#7fd0ff",
"on-tertiary-fixed": "#001e2d",
"on-tertiary-fixed-variant": "#004c6a",
"background": "#f9f9ff",
"on-background": "#151c27",
"surface-variant": "#dce2f3",
},
fontFamily: {
"display": ["Plus Jakarta Sans"],
"headline-lg": ["Plus Jakarta Sans"],
"headline-md": ["Plus Jakarta Sans"],
"title-lg": ["Plus Jakarta Sans"],
"body-lg": ["Plus Jakarta Sans"],
"body-md": ["Plus Jakarta Sans"],
"label-md": ["Plus Jakarta Sans"],
"label-sm": ["Plus Jakarta Sans"],
},
fontSize: {
"display": [
"44px",
{
lineHeight: "52px",
letterSpacing: "-0.02em",
fontWeight: "800",
},
],
"headline-lg": [
"32px",
{
lineHeight: "40px",
letterSpacing: "-0.01em",
fontWeight: "700",
},
],
"headline-md": [
"24px",
{
lineHeight: "32px",
fontWeight: "700",
},
],
"title-lg": [
"20px",
{
lineHeight: "28px",
fontWeight: "600",
},
],
"body-lg": [
"18px",
{
lineHeight: "28px",
fontWeight: "400",
},
],
"body-md": [
"16px",
{
lineHeight: "24px",
fontWeight: "400",
},
],
"label-md": [
"14px",
{
lineHeight: "20px",
letterSpacing: "0.01em",
fontWeight: "600",
},
],
"label-sm": [
"12px",
{
lineHeight: "16px",
fontWeight: "500",
},
],
},
borderRadius: {
"sm": "0.25rem",
"DEFAULT": "0.5rem",
"md": "0.75rem",
"lg": "1rem",
"xl": "1.5rem",
"full": "9999px",
},
spacing: {
"base": "8px",
"xs": "4px",
"sm": "12px",
"md": "24px",
"lg": "40px",
"xl": "64px",
"gutter": "16px",
"margin": "24px",
},
},
},
};
+210
View File
@@ -0,0 +1,210 @@
---
name: Totality Festival Design System
colors:
surface: "#121318"
surface-dim: "#121318"
surface-bright: "#38393f"
surface-container-lowest: "#0d0e13"
surface-container-low: "#1a1b21"
surface-container: "#1e1f25"
surface-container-high: "#292a2f"
surface-container-highest: "#34343a"
on-surface: "#e3e1e9"
on-surface-variant: "#d0c6ab"
inverse-surface: "#e3e1e9"
inverse-on-surface: "#2f3036"
outline: "#999077"
outline-variant: "#4d4732"
surface-tint: "#e9c400"
primary: "#fff6df"
on-primary: "#3a3000"
primary-container: "#ffd700"
on-primary-container: "#705e00"
inverse-primary: "#705d00"
secondary: "#bdf4ff"
on-secondary: "#00363d"
secondary-container: "#00e3fd"
on-secondary-container: "#00616d"
tertiary: "#fcf3ff"
on-tertiary: "#3b2754"
tertiary-container: "#e7d1ff"
on-tertiary-container: "#6b5586"
error: "#ffb4ab"
on-error: "#690005"
error-container: "#93000a"
on-error-container: "#ffdad6"
primary-fixed: "#ffe16d"
primary-fixed-dim: "#e9c400"
on-primary-fixed: "#221b00"
on-primary-fixed-variant: "#544600"
secondary-fixed: "#9cf0ff"
secondary-fixed-dim: "#00daf3"
on-secondary-fixed: "#001f24"
on-secondary-fixed-variant: "#004f58"
tertiary-fixed: "#eedbff"
tertiary-fixed-dim: "#d6bcf4"
on-tertiary-fixed: "#25113e"
on-tertiary-fixed-variant: "#523d6c"
background: "#121318"
on-background: "#e3e1e9"
surface-variant: "#34343a"
typography:
headline-xl:
fontFamily: Space Grotesk
fontSize: 72px
fontWeight: "700"
lineHeight: 80px
letterSpacing: -0.04em
headline-lg:
fontFamily: Space Grotesk
fontSize: 48px
fontWeight: "600"
lineHeight: 56px
letterSpacing: -0.02em
headline-md:
fontFamily: Space Grotesk
fontSize: 32px
fontWeight: "600"
lineHeight: 40px
letterSpacing: 0em
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: "400"
lineHeight: 28px
letterSpacing: 0em
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: "400"
lineHeight: 24px
letterSpacing: 0em
label-md:
fontFamily: Space Grotesk
fontSize: 14px
fontWeight: "500"
lineHeight: 20px
letterSpacing: 0.1em
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
unit: 8px
container-max: 1280px
gutter: 24px
margin-mobile: 16px
margin-desktop: 64px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-primary}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
padding: 12px
height: 48px
button-primary-hover:
backgroundColor: "{colors.primary-fixed}"
button-secondary:
backgroundColor: transparent
textColor: "{colors.secondary}"
typography: "{typography.label-md}"
rounded: "{rounded.lg}"
padding: 12px
height: 48px
button-secondary-hover:
backgroundColor: rgba(0, 227, 253, 0.1)
card-glass-level-2:
backgroundColor: rgba(52, 52, 58, 0.2)
rounded: "{rounded.xl}"
padding: "{spacing.gutter}"
card-glass-interactive-hover:
backgroundColor: rgba(56, 57, 63, 0.4)
input-field:
backgroundColor: "{colors.surface-container-lowest}"
textColor: "{colors.on-surface}"
typography: "{typography.body-md}"
rounded: "{rounded.lg}"
padding: 12px
list-item-hover:
backgroundColor: "{colors.surface-container-high}"
textColor: "{colors.primary}"
rounded: "{rounded.md}"
padding: 8px
hero-headline:
textColor: "{colors.primary}"
typography: "{typography.headline-xl}"
badge-celestial:
backgroundColor: "{colors.tertiary-container}"
textColor: "{colors.on-tertiary-container}"
typography: "{typography.label-md}"
rounded: "{rounded.full}"
padding: 4px
---
## Brand & Style
The design system captures the visceral tension and awe of a solar eclipse. It targets an audience of celestial enthusiasts, music lovers, and seekers of rare experiences. The aesthetic is "Cosmic Premium," blending the stark mystery of deep space with the explosive brilliance of the solar corona.
To achieve this, the design system utilizes **Glassmorphism** and **High-Contrast** movements. Surfaces appear as translucent obsidian slabs floating over nebula-like gradients, creating a sense of immense depth. High-energy accents represent the "diamond ring" effect, ensuring that while the interface is dark, it feels luminous and alive rather than heavy or muted.
## Colors
The palette is anchored in the transition from shadow to light.
- **Primary (Amber/White-Gold):** Represents the solar corona and the "diamond ring" flash. Used for critical CTAs and high-importance highlights.
- **Secondary (Soft Cyan):** Represents the atmospheric thinning and the ethereal glow of the sky during totality. Used for interactive states and secondary information.
- **Tertiary (Deep Indigo):** Provides the "midnight" depth, used for subtle atmospheric gradients and deep backgrounds.
- **Neutral (Obsidian/Charcoal):** A near-black foundation that ensures the vibrant accents pop with maximum intensity.
Gradient usage is mandatory: use radial gradients for backgrounds to simulate the circular nature of the eclipse, moving from `Neutral` at the edges to `Tertiary` or `Secondary` in the focal centers.
## Typography
This design system uses a dual-font strategy to balance cinematic impact with utility.
- **Space Grotesk** is the voice of the festival. Its geometric, technical quirks suggest a futuristic and astronomical tone. It should be used for all headers and labels. Large headings should use tight letter spacing to feel "locked" and monumental.
- **Inter** provides a neutral, highly legible counterpoint for long-form content and descriptions, ensuring that even in low-light environments, the information remains accessible.
For a truly cinematic feel, apply a subtle text-shadow or "glow" to `headline-xl` elements when they appear on the darkest backgrounds, using a low-opacity version of the Primary color.
## Layout & Spacing
The layout philosophy follows a **Fixed Grid** model for desktop to maintain a prestigious, editorial feel, while transitioning to a fluid model for mobile.
A 12-column grid is used for desktop layouts, with generous outer margins to simulate the isolation of a celestial body in the void. Spacing is governed by an 8px base unit, but "negative space" is prioritized—elements should be allowed to breathe, echoing the vastness of space. Component groups should use tight internal spacing (e.g., 8px or 16px) but wide external margins (e.g., 64px or 80px) to create distinct "islands" of content.
## Elevation & Depth
Depth in this design system is achieved through **Glassmorphism** and light-based layering rather than traditional drop shadows.
- **Level 1 (Base):** Deep obsidian/neutral background.
- **Level 2 (Panels):** Semi-transparent surfaces (10-20% opacity) with a `20px` backdrop blur. These layers should have a `1px` inner stroke of white at 10% opacity to define the edge, simulating a glass refraction.
- **Level 3 (Interactive):** Elements that are hovered or active should emit an "Ambient Glow." This is a soft, diffused shadow tinted with the `Secondary` or `Primary` color, creating the effect of light bleeding from behind the object.
## Shapes
The shape language is "Soft-Technical." While the overall feel is geometric, a small corner radius is applied to all components to prevent the UI from feeling too aggressive or "sharp."
Buttons and input fields should utilize the `rounded-lg` (8px) setting for a modern feel. For specific decorative elements, such as image containers or featured cards, the `rounded-xl` (12px) setting can be used to soften the composition. Circles and perfect arcs are encouraged as supporting graphic elements to mirror the orbital theme of the festival.
## Components
### Action Elements
Buttons utilize `Space Grotesk` for a technical, high-impact feel; the Primary button mimics the "diamond ring" flash with a luminous amber glow on hover. Secondary buttons remain ethereal with a cyan outline, suggesting the sky's transition during totality.
### Containers & Surfaces
Cards implement level-2 glassmorphism using a semi-transparent `surface-variant` fill and a fine 1px inner stroke to simulate light refraction on glass edges. For interactive states, cards should expand their "Ambient Glow" using the Secondary Cyan color to indicate focus.
### Inputs & Selection
Input fields are anchored in the deepest `surface-container-lowest` to provide maximum contrast for entered text. Focus states use the Secondary Cyan border to maintain the cosmic color story without distracting from content.
### Typography Application
`headline-xl` should always be paired with a subtle primary-colored glow when placed on dark backgrounds to ensure it feels "radiant" rather than static. List items use a tighter `rounded-md` corner to differentiate them from larger, more prominent layout containers.
+11
View File
@@ -0,0 +1,11 @@
# Totality Festival
A dark, immersive design system for a solar eclipse music festival. The "Cosmic Premium" aesthetic blends deep-space obsidian with explosive corona gold and atmospheric cyan, using glassmorphism and ambient glow effects to capture the visceral drama of totality.
## Files
| File | Description |
|------|-------------|
| `DESIGN.md` | The complete design system specification in DESIGN.md format, including both structured YAML design tokens (frontmatter) and human-readable style guidance (markdown body). |
| `tailwind.config.js` | A Tailwind CSS v3 theme configuration derived from the design tokens in the DESIGN.md frontmatter. Covers colors, typography, border-radius, and spacing. Component tokens are intentionally excluded — Tailwind's utility-first approach handles component styling through composition of these primitives. |
| `design_tokens.json` | A [Design Tokens Community Group](https://www.designtokens.org/) JSON file containing all design tokens from the DESIGN.md frontmatter, including component-level tokens. This format is interoperable with tools like Figma, Style Dictionary, and other token pipelines. |
@@ -0,0 +1,911 @@
{
"name": {
"$type": "string",
"$value": "Totality Festival Design System"
},
"colors": {
"surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.071,
0.075,
0.094
],
"hex": "#121318"
}
},
"surface-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.071,
0.075,
0.094
],
"hex": "#121318"
}
},
"surface-bright": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.22,
0.224,
0.247
],
"hex": "#38393f"
}
},
"surface-container-lowest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.051,
0.055,
0.075
],
"hex": "#0d0e13"
}
},
"surface-container-low": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.102,
0.106,
0.129
],
"hex": "#1a1b21"
}
},
"surface-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.118,
0.122,
0.145
],
"hex": "#1e1f25"
}
},
"surface-container-high": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.161,
0.165,
0.184
],
"hex": "#292a2f"
}
},
"surface-container-highest": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.204,
0.204,
0.227
],
"hex": "#34343a"
}
},
"on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.89,
0.882,
0.914
],
"hex": "#e3e1e9"
}
},
"on-surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.816,
0.776,
0.671
],
"hex": "#d0c6ab"
}
},
"inverse-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.89,
0.882,
0.914
],
"hex": "#e3e1e9"
}
},
"inverse-on-surface": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.184,
0.188,
0.212
],
"hex": "#2f3036"
}
},
"outline": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.6,
0.565,
0.467
],
"hex": "#999077"
}
},
"outline-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.302,
0.278,
0.196
],
"hex": "#4d4732"
}
},
"surface-tint": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.914,
0.769,
0.0
],
"hex": "#e9c400"
}
},
"primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.965,
0.875
],
"hex": "#fff6df"
}
},
"on-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.227,
0.188,
0.0
],
"hex": "#3a3000"
}
},
"primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.843,
0.0
],
"hex": "#ffd700"
}
},
"on-primary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.439,
0.369,
0.0
],
"hex": "#705e00"
}
},
"inverse-primary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.439,
0.365,
0.0
],
"hex": "#705d00"
}
},
"secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.741,
0.957,
1.0
],
"hex": "#bdf4ff"
}
},
"on-secondary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.212,
0.239
],
"hex": "#00363d"
}
},
"secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.89,
0.992
],
"hex": "#00e3fd"
}
},
"on-secondary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.38,
0.427
],
"hex": "#00616d"
}
},
"tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.988,
0.953,
1.0
],
"hex": "#fcf3ff"
}
},
"on-tertiary": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.231,
0.153,
0.329
],
"hex": "#3b2754"
}
},
"tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.906,
0.82,
1.0
],
"hex": "#e7d1ff"
}
},
"on-tertiary-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.42,
0.333,
0.525
],
"hex": "#6b5586"
}
},
"error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.706,
0.671
],
"hex": "#ffb4ab"
}
},
"on-error": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.412,
0.0,
0.02
],
"hex": "#690005"
}
},
"error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.576,
0.0,
0.039
],
"hex": "#93000a"
}
},
"on-error-container": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.855,
0.839
],
"hex": "#ffdad6"
}
},
"primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
1.0,
0.882,
0.427
],
"hex": "#ffe16d"
}
},
"primary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.914,
0.769,
0.0
],
"hex": "#e9c400"
}
},
"on-primary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.133,
0.106,
0.0
],
"hex": "#221b00"
}
},
"on-primary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.329,
0.275,
0.0
],
"hex": "#544600"
}
},
"secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.612,
0.941,
1.0
],
"hex": "#9cf0ff"
}
},
"secondary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.855,
0.953
],
"hex": "#00daf3"
}
},
"on-secondary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.122,
0.141
],
"hex": "#001f24"
}
},
"on-secondary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.31,
0.345
],
"hex": "#004f58"
}
},
"tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.933,
0.859,
1.0
],
"hex": "#eedbff"
}
},
"tertiary-fixed-dim": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.839,
0.737,
0.957
],
"hex": "#d6bcf4"
}
},
"on-tertiary-fixed": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.145,
0.067,
0.243
],
"hex": "#25113e"
}
},
"on-tertiary-fixed-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.322,
0.239,
0.424
],
"hex": "#523d6c"
}
},
"background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.071,
0.075,
0.094
],
"hex": "#121318"
}
},
"on-background": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.89,
0.882,
0.914
],
"hex": "#e3e1e9"
}
},
"surface-variant": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.204,
0.204,
0.227
],
"hex": "#34343a"
}
}
},
"typography": {
"headline-xl": {
"$type": "typography",
"$value": {
"fontFamily": "Space Grotesk",
"fontSize": {
"value": 72,
"unit": "px"
},
"fontWeight": 700,
"lineHeight": {
"value": 80,
"unit": "px"
},
"letterSpacing": {
"value": -0.04,
"unit": "em"
}
}
},
"headline-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Space Grotesk",
"fontSize": {
"value": 48,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 56,
"unit": "px"
},
"letterSpacing": {
"value": -0.02,
"unit": "em"
}
}
},
"headline-md": {
"$type": "typography",
"$value": {
"fontFamily": "Space Grotesk",
"fontSize": {
"value": 32,
"unit": "px"
},
"fontWeight": 600,
"lineHeight": {
"value": 40,
"unit": "px"
},
"letterSpacing": {
"value": 0,
"unit": "em"
}
}
},
"body-lg": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 18,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 28,
"unit": "px"
},
"letterSpacing": {
"value": 0,
"unit": "em"
}
}
},
"body-md": {
"$type": "typography",
"$value": {
"fontFamily": "Inter",
"fontSize": {
"value": 16,
"unit": "px"
},
"fontWeight": 400,
"lineHeight": {
"value": 24,
"unit": "px"
},
"letterSpacing": {
"value": 0,
"unit": "em"
}
}
},
"label-md": {
"$type": "typography",
"$value": {
"fontFamily": "Space Grotesk",
"fontSize": {
"value": 14,
"unit": "px"
},
"fontWeight": 500,
"lineHeight": {
"value": 20,
"unit": "px"
},
"letterSpacing": {
"value": 0.1,
"unit": "em"
}
}
}
},
"rounded": {
"sm": {
"$type": "dimension",
"$value": {
"value": 0.125,
"unit": "rem"
}
},
"DEFAULT": {
"$type": "dimension",
"$value": {
"value": 0.25,
"unit": "rem"
}
},
"md": {
"$type": "dimension",
"$value": {
"value": 0.375,
"unit": "rem"
}
},
"lg": {
"$type": "dimension",
"$value": {
"value": 0.5,
"unit": "rem"
}
},
"xl": {
"$type": "dimension",
"$value": {
"value": 0.75,
"unit": "rem"
}
},
"full": {
"$type": "dimension",
"$value": {
"value": 9999,
"unit": "px"
}
}
},
"spacing": {
"unit": {
"$type": "dimension",
"$value": {
"value": 8,
"unit": "px"
}
},
"container-max": {
"$type": "dimension",
"$value": {
"value": 1280,
"unit": "px"
}
},
"gutter": {
"$type": "dimension",
"$value": {
"value": 24,
"unit": "px"
}
},
"margin-mobile": {
"$type": "dimension",
"$value": {
"value": 16,
"unit": "px"
}
},
"margin-desktop": {
"$type": "dimension",
"$value": {
"value": 64,
"unit": "px"
}
}
},
"components": {
"button-primary": {
"backgroundColor": "{colors.primary}",
"textColor": "{colors.on-primary}",
"typography": "{typography.label-md}",
"rounded": "{rounded.lg}",
"padding": {
"$type": "dimension",
"$value": {
"value": 12,
"unit": "px"
}
},
"height": {
"$type": "dimension",
"$value": {
"value": 48,
"unit": "px"
}
}
},
"button-primary-hover": {
"backgroundColor": "{colors.primary-fixed}"
},
"button-secondary": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0,
0,
0
],
"alpha": 0
}
},
"textColor": "{colors.secondary}",
"typography": "{typography.label-md}",
"rounded": "{rounded.lg}",
"padding": {
"$type": "dimension",
"$value": {
"value": 12,
"unit": "px"
}
},
"height": {
"$type": "dimension",
"$value": {
"value": 48,
"unit": "px"
}
}
},
"button-secondary-hover": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.0,
0.89,
0.992
],
"alpha": 0.1
}
}
},
"card-glass-level-2": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.204,
0.204,
0.227
],
"alpha": 0.2
}
},
"rounded": "{rounded.xl}",
"padding": "{spacing.gutter}"
},
"card-glass-interactive-hover": {
"backgroundColor": {
"$type": "color",
"$value": {
"colorSpace": "srgb",
"components": [
0.22,
0.224,
0.247
],
"alpha": 0.4
}
}
},
"input-field": {
"backgroundColor": "{colors.surface-container-lowest}",
"textColor": "{colors.on-surface}",
"typography": "{typography.body-md}",
"rounded": "{rounded.lg}",
"padding": {
"$type": "dimension",
"$value": {
"value": 12,
"unit": "px"
}
}
},
"list-item-hover": {
"backgroundColor": "{colors.surface-container-high}",
"textColor": "{colors.primary}",
"rounded": "{rounded.md}",
"padding": {
"$type": "dimension",
"$value": {
"value": 8,
"unit": "px"
}
}
},
"hero-headline": {
"textColor": "{colors.primary}",
"typography": "{typography.headline-xl}"
},
"badge-celestial": {
"backgroundColor": "{colors.tertiary-container}",
"textColor": "{colors.on-tertiary-container}",
"typography": "{typography.label-md}",
"rounded": "{rounded.full}",
"padding": {
"$type": "dimension",
"$value": {
"value": 4,
"unit": "px"
}
}
}
}
}
@@ -0,0 +1,144 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
theme: {
extend: {
colors: {
"surface": "#121318",
"surface-dim": "#121318",
"surface-bright": "#38393f",
"surface-container-lowest": "#0d0e13",
"surface-container-low": "#1a1b21",
"surface-container": "#1e1f25",
"surface-container-high": "#292a2f",
"surface-container-highest": "#34343a",
"on-surface": "#e3e1e9",
"on-surface-variant": "#d0c6ab",
"inverse-surface": "#e3e1e9",
"inverse-on-surface": "#2f3036",
"outline": "#999077",
"outline-variant": "#4d4732",
"surface-tint": "#e9c400",
"primary": "#fff6df",
"on-primary": "#3a3000",
"primary-container": "#ffd700",
"on-primary-container": "#705e00",
"inverse-primary": "#705d00",
"secondary": "#bdf4ff",
"on-secondary": "#00363d",
"secondary-container": "#00e3fd",
"on-secondary-container": "#00616d",
"tertiary": "#fcf3ff",
"on-tertiary": "#3b2754",
"tertiary-container": "#e7d1ff",
"on-tertiary-container": "#6b5586",
"error": "#ffb4ab",
"on-error": "#690005",
"error-container": "#93000a",
"on-error-container": "#ffdad6",
"primary-fixed": "#ffe16d",
"primary-fixed-dim": "#e9c400",
"on-primary-fixed": "#221b00",
"on-primary-fixed-variant": "#544600",
"secondary-fixed": "#9cf0ff",
"secondary-fixed-dim": "#00daf3",
"on-secondary-fixed": "#001f24",
"on-secondary-fixed-variant": "#004f58",
"tertiary-fixed": "#eedbff",
"tertiary-fixed-dim": "#d6bcf4",
"on-tertiary-fixed": "#25113e",
"on-tertiary-fixed-variant": "#523d6c",
"background": "#121318",
"on-background": "#e3e1e9",
"surface-variant": "#34343a",
},
fontFamily: {
"headline-xl": ["Space Grotesk"],
"headline-lg": ["Space Grotesk"],
"headline-md": ["Space Grotesk"],
"body-lg": ["Inter"],
"body-md": ["Inter"],
"label-md": ["Space Grotesk"],
},
fontSize: {
"headline-xl": [
"72px",
{
lineHeight: "80px",
letterSpacing: "-0.04em",
fontWeight: "700",
},
],
"headline-lg": [
"48px",
{
lineHeight: "56px",
letterSpacing: "-0.02em",
fontWeight: "600",
},
],
"headline-md": [
"32px",
{
lineHeight: "40px",
letterSpacing: "0em",
fontWeight: "600",
},
],
"body-lg": [
"18px",
{
lineHeight: "28px",
letterSpacing: "0em",
fontWeight: "400",
},
],
"body-md": [
"16px",
{
lineHeight: "24px",
letterSpacing: "0em",
fontWeight: "400",
},
],
"label-md": [
"14px",
{
lineHeight: "20px",
letterSpacing: "0.1em",
fontWeight: "500",
},
],
},
borderRadius: {
"sm": "0.125rem",
"DEFAULT": "0.25rem",
"md": "0.375rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px",
},
spacing: {
"unit": "8px",
"container-max": "1280px",
"gutter": "24px",
"margin-mobile": "16px",
"margin-desktop": "64px",
},
},
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"name": "design-monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"packageManager": "bun@1.3.9",
"scripts": {
"build": "turbo build",
"test": "turbo test",
"lint": "turbo lint",
"cli": "bun run packages/cli/src/index.ts"
},
"devDependencies": {
"@types/node": "^25.6.0",
"bun-types": "^1.3.12",
"turbo": "latest",
"typescript": "latest"
}
}
+4
View File
@@ -0,0 +1,4 @@
LICENSE
README.md
smoke-test/
*.tgz
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@google/design.md",
"version": "0.3.0",
"description": "Bridging design systems and code: a linter and exporter for the DESIGN.md format",
"keywords": [
"design.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/google-labs-code/design.md.git"
},
"bugs": {
"url": "https://github.com/google-labs-code/design.md/issues"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"private": false,
"type": "module",
"bin": {
"design.md": "./dist/index.js",
"designmd": "./dist/index.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./linter": {
"import": "./dist/linter/index.js",
"types": "./dist/linter/index.d.ts"
}
},
"publishConfig": {
"registry": "https://wombat-dressing-room.appspot.com",
"access": "public"
},
"scripts": {
"build": "bun build src/index.ts src/linter/index.ts --outdir dist --target node && npx tsc --project tsconfig.build.json --emitDeclarationOnly --skipLibCheck && cp src/linter/spec-config.yaml dist/linter/ && cp src/linter/spec-config.yaml dist/ && cp ../../docs/spec.md dist/linter/ && cp ../../docs/spec.md dist/",
"dev": "bun run src/index.ts",
"test": "bun test",
"lint": "tsc --noEmit --skipLibCheck",
"spec:gen": "bun run src/linter/spec-gen/generate.ts",
"check-package": "bun run scripts/check-package.ts"
},
"dependencies": {
"citty": "^0.1.6",
"remark-frontmatter": "^5.0.0",
"remark-mdx": "^3.1.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"yaml": "^2.7.1",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bun": "latest",
"@types/mdast": "^4.0.4",
"@types/node": "^20.11.24",
"bun-types": "^1.3.12",
"tailwindcss": "3",
"typescript": "^5.7.3"
}
}
+345
View File
@@ -0,0 +1,345 @@
#!/usr/bin/env bun
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Package verification script for @google/design.md
*
* Runs 17 checks across 4 phases to ensure the package is correctly
* structured for npm publication. Exit code 0 = all pass, 1 = failures.
*
* Usage: bun run scripts/check-package.ts
*/
import { readFileSync, existsSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join, resolve } from 'path';
import { execSync } from 'child_process';
import { Glob, $ } from 'bun';
import { tmpdir } from 'os';
// ── Helpers ────────────────────────────────────────────────────────
const ROOT = resolve(import.meta.dir, '..');
const PKG_PATH = join(ROOT, 'package.json');
let passed = 0;
let failed = 0;
function pass(label: string) {
console.log(`${label}`);
passed++;
}
function fail(label: string, detail?: string) {
console.error(`${label}`);
if (detail) console.error(`${detail}`);
failed++;
}
function check(label: string, ok: boolean, detail?: string) {
if (ok) pass(label);
else fail(label, detail);
}
function heading(title: string) {
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 56 - title.length))}`);
}
function readPkg(): Record<string, unknown> {
return JSON.parse(readFileSync(PKG_PATH, 'utf-8'));
}
function exec(cmd: string, opts?: { cwd?: string }): { ok: boolean; stdout: string; stderr: string } {
try {
const stdout = execSync(cmd, {
cwd: opts?.cwd ?? ROOT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PATH: `${process.env.HOME}/.bun/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${process.env.PATH ?? ''}` },
});
return { ok: true, stdout, stderr: '' };
} catch (e: unknown) {
const err = e as { stdout?: string; stderr?: string };
return { ok: false, stdout: err.stdout ?? '', stderr: err.stderr ?? '' };
}
}
// ── Phase 1: Pre-pack validation ───────────────────────────────────
function phase1_config() {
heading('Phase 1a: Package.json validation');
const pkg = readPkg();
// 1. files field exists
const files = pkg.files as string[] | undefined;
check('#1 `files` field exists', Array.isArray(files) && files.length > 0,
'Add a "files" array to package.json to control what ships');
// 2. exports structure
const exports = pkg.exports as Record<string, Record<string, string>> | undefined;
check('#2 `exports["."]` defined', !!exports?.['.'],
'Missing exports["."] in package.json');
if (exports?.['.']) {
check('#3 `exports["."].types` field exists', !!exports['.'].types,
'Missing types condition in exports["."]');
} else {
fail('#3 `exports["."].types` field exists', 'Skipped — no exports');
}
// 4. main field
const main = pkg.main as string | undefined;
check('#4 `main` field exists', !!main,
'Missing "main" field in package.json');
// 5. types field
const types = pkg.types as string | undefined;
check('#5 `types` field exists', !!types,
'Missing "types" field in package.json');
// 5c. bin map exposes a Windows-friendly alias (no dot in the name).
// The `design.md` bin file is unrunnable on Windows because the `.md`
// suffix collides with the Markdown file association, so PowerShell
// opens the shim in the user's Markdown editor instead of executing it.
// A dot-free alias such as `designmd` lets the npm CMD/PowerShell shims
// resolve cleanly via PATHEXT. See https://github.com/google-labs-code/design.md/issues/54.
const bin = pkg.bin as Record<string, string> | string | undefined;
const binEntries = typeof bin === 'object' && bin !== null ? Object.keys(bin) : [];
const hasDotFreeAlias = binEntries.some((name) => !name.includes('.'));
check('#5c bin map exposes a Windows-friendly alias (no dot in the name)',
hasDotFreeAlias,
`bin entries: ${binEntries.join(', ') || '(none)'} — add an alias without a dot for Windows compatibility`);
}
function phase1_paths() {
heading('Phase 1b: Path resolution (post-build)');
const pkg = readPkg();
const exports = pkg.exports as Record<string, Record<string, string>> | undefined;
if (exports?.['.']) {
const importPath = join(ROOT, exports['.'].import);
const typesPath = join(ROOT, exports['.'].types);
check('#2b `exports["."].import` resolves', existsSync(importPath),
`Missing: ${exports['.'].import}`);
check('#3b `exports["."].types` resolves', existsSync(typesPath),
`Missing: ${exports['.'].types}`);
}
const main = pkg.main as string | undefined;
check('#4b `main` resolves', !!main && existsSync(join(ROOT, main)),
`Missing: ${main}`);
const types = pkg.types as string | undefined;
check('#5b `types` resolves', !!types && existsSync(join(ROOT, types)),
`Missing: ${types}`);
}
// ── Phase 2: Clean build ───────────────────────────────────────────
function phase2() {
heading('Phase 2: Clean build');
// 6. Clean dist
const distPath = join(ROOT, 'dist');
if (existsSync(distPath)) {
rmSync(distPath, { recursive: true, force: true });
}
check('#6 Clean dist', !existsSync(distPath));
// 7. Build
const build = exec('bun run build');
check('#7 Build succeeds', build.ok, 'tsc exited with non-zero');
if (!existsSync(distPath)) {
fail('#8 No test files in dist', 'dist/ does not exist after build');
fail('#9 No fixture files in dist', 'dist/ does not exist after build');
return;
}
// 8. No test files in dist
const testGlob = new Glob('**/*.test.*');
const testFiles = Array.from(testGlob.scanSync({ cwd: distPath, absolute: false }));
check('#8 No test files in dist', testFiles.length === 0,
`Found: ${testFiles.join(', ')}`);
// 9. No fixture files in dist
const fixtureGlob = new Glob('**/fixtures/**');
const fixtureFiles = Array.from(fixtureGlob.scanSync({ cwd: distPath, absolute: false }));
check('#9 No fixture files in dist', fixtureFiles.length === 0,
`Found: ${fixtureFiles.join(', ')}`);
}
// ── Phase 3: Pack audit ────────────────────────────────────────────
function phase3() {
heading('Phase 3: Pack audit');
// 10. npm pack --dry-run
const pack = exec('npm pack --dry-run --json 2>/dev/null');
let fileList: string[] = [];
if (pack.ok) {
try {
const parsed = JSON.parse(pack.stdout);
const files = (parsed[0]?.files ?? parsed.files ?? []) as Array<{ path: string }>;
fileList = files.map((f) => f.path);
} catch {
// Fallback: parse the non-JSON dry-run output
const lines = pack.stdout.split('\n');
fileList = lines
.map((l) => l.replace(/^npm notice\s+\d+[\w.]+\s+/, '').trim())
.filter((l) => l.includes('/') || l.endsWith('.js') || l.endsWith('.json'));
}
}
check('#10 `npm pack --dry-run` succeeds', pack.ok && fileList.length > 0,
'npm pack failed or returned empty file list');
if (fileList.length === 0) {
fail('#11 No source .ts files in tarball', 'Skipped — no file list');
fail('#12 No test files in tarball', 'Skipped — no file list');
fail('#13 No config files in tarball', 'Skipped — no file list');
fail('#14 No fixtures in tarball', 'Skipped — no file list');
fail('#15 Entry point in tarball', 'Skipped — no file list');
return;
}
// 11. No source .ts files (allow .d.ts and .d.ts.map)
const rawTs = fileList.filter(
(f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.d.ts.map')
);
check('#11 No source .ts files in tarball', rawTs.length === 0,
`Found: ${rawTs.join(', ')}`);
// 12. No test files
const testInPack = fileList.filter((f) => f.includes('.test.'));
check('#12 No test files in tarball', testInPack.length === 0,
`Found: ${testInPack.join(', ')}`);
// 13. No config files
const configInPack = fileList.filter((f) => f.includes('tsconfig'));
check('#13 No config files in tarball', configInPack.length === 0,
`Found: ${configInPack.join(', ')}`);
// 14. No fixtures
const fixturesInPack = fileList.filter((f) => f.includes('fixtures'));
check('#14 No fixtures in tarball', fixturesInPack.length === 0,
`Found: ${fixturesInPack.join(', ')}`);
// 15. Entry point present
const hasIndex = fileList.some((f) => f.includes('dist/index.js'));
const hasTypes = fileList.some((f) => f.includes('dist/index.d.ts'));
check('#15 Entry point present in tarball', hasIndex && hasTypes,
`index.js: ${hasIndex}, index.d.ts: ${hasTypes}`);
}
// ── Phase 4: Consumer smoke test ───────────────────────────────────
function phase4() {
heading('Phase 4: Consumer smoke test');
const distIndex = join(ROOT, 'dist', 'linter', 'index.js');
const distTypes = join(ROOT, 'dist', 'linter', 'index.d.ts');
// 16. Import resolution (ESM)
const tmpDir = mkdtempSync(join(tmpdir(), 'check-pkg-'));
const smokeFile = join(tmpDir, 'smoke.mjs');
writeFileSync(
smokeFile,
`import { lint } from '${distIndex}';\n` +
`if (typeof lint !== 'function') { process.exit(1); }\n` +
`console.log('ok');\n`
);
const importCheck = exec(`node ${smokeFile}`);
if (!importCheck.ok || !importCheck.stdout.trim().endsWith('ok')) {
console.error('Smoke test failed!');
console.error('STDOUT:', importCheck.stdout);
console.error('STDERR:', importCheck.stderr);
}
check('#16 Import resolution (ESM)', importCheck.ok && importCheck.stdout.trim().endsWith('ok'),
'Could not import lint() from dist/index.js');
// 17. Type declarations exist and export lint
const dtsContent = existsSync(distTypes) ? readFileSync(distTypes, 'utf-8') : '';
const hasLintExport = dtsContent.includes('export') && dtsContent.includes('lint');
const hasLintReportType = dtsContent.includes('LintReport');
check('#17 Type declarations valid',
hasLintExport && hasLintReportType,
`index.d.ts missing lint export or LintReport type`);
// 18. Runtime sanity
const sanityFile = join(tmpDir, 'sanity.mjs');
writeFileSync(
sanityFile,
`import { lint } from '${distIndex}';\n` +
`const result = lint('---\\nname: Test\\ncolors:\\n primary: "#ff0000"\\n---');\n` +
`const keys = Object.keys(result);\n` +
`const expected = ['designSystem','findings','summary','tailwindConfig'];\n` +
`const hasAll = expected.every(k => keys.includes(k));\n` +
`if (!hasAll) {\n` +
` console.error('Missing keys. Actual:', keys);\n` +
` process.exit(1);\n` +
`}\n` +
`if (typeof result.designSystem !== 'object') { process.exit(1); }\n` +
`if (!Array.isArray(result.findings)) { process.exit(1); }\n` +
`if (typeof result.summary.errors !== 'number') { process.exit(1); }\n` +
`console.log('ok');\n`
);
const sanityCheck = exec(`node ${sanityFile}`);
if (!sanityCheck.ok || !sanityCheck.stdout.trim().endsWith('ok')) {
console.error('Sanity check failed!');
console.error('STDOUT:', sanityCheck.stdout);
console.error('STDERR:', sanityCheck.stderr);
}
check('#18 Runtime sanity', sanityCheck.ok && sanityCheck.stdout.trim().endsWith('ok'),
'lint() did not return expected shape');
// 19. CLI entry point works
const cliIndex = join(ROOT, 'dist', 'index.js');
const cliCheck = exec(`node ${cliIndex} --help`);
check('#19 CLI entry point valid', cliCheck.ok && !cliCheck.stderr.includes('ENOENT'),
'CLI failed to run or reported missing files');
// 20. CLI spec command works
const specCheck = exec(`node ${cliIndex} spec`);
check('#20 CLI spec command valid', specCheck.ok && !specCheck.stderr.includes('Failed to load spec.md'),
'CLI spec command failed to load spec.md');
// Cleanup
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best effort
}
}
// ── Main ───────────────────────────────────────────────────────────
console.log('🔍 Package verification: @google/design.md\n');
phase1_config();
phase2();
phase1_paths();
phase3();
phase4();
console.log(`\n${'═'.repeat(60)}`);
console.log(`${passed} passed ❌ ${failed} failed`);
console.log(`${'═'.repeat(60)}\n`);
if (failed > 0) {
process.exit(1);
}
@@ -0,0 +1,49 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { join } from 'node:path';
const CLI = join(import.meta.dir, '../index.ts');
function runCli(args: string[]): { code: number | null; stdout: string; stderr: string } {
const proc = Bun.spawnSync(['bun', 'run', CLI, ...args], { stdout: 'pipe', stderr: 'pipe' });
return {
code: proc.exitCode,
stdout: Buffer.from(proc.stdout).toString('utf-8'),
stderr: Buffer.from(proc.stderr).toString('utf-8'),
};
}
describe('CLI error output', () => {
it('exits 2 with a friendly error and no stack trace when the input file is missing', () => {
const { code, stdout, stderr } = runCli(['lint', 'definitely-does-not-exist-90af.md']);
expect(code).toBe(2);
expect(stdout).toBe('');
// Exactly one line on stderr — no second, stack-trace error.
const lines = stderr.trim().split('\n').filter(Boolean);
expect(lines.length).toBe(1);
expect(lines[0]).toContain('not found');
expect(lines[0]).toContain('definitely-does-not-exist-90af.md');
});
it('reports an unknown export format with a coded error envelope and exit 1', () => {
// Format is validated before any input is read, so the file path is unused.
const { code, stderr } = runCli(['export', '--format', 'bogus', 'unused.md']);
expect(code).toBe(1);
const err = JSON.parse(stderr.trim());
expect(err.error).toBe('INVALID_FORMAT');
expect(err.message).toContain('Invalid format');
});
});
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from '../linter/index.js';
import { diffMaps } from '../utils.js';
import type { ComponentDef } from '../linter/model/spec.js';
function serializeComponents(components: Map<string, ComponentDef>): Map<string, Record<string, unknown>> {
const result = new Map<string, Record<string, unknown>>();
for (const [name, comp] of components) {
result.set(name, Object.fromEntries(comp.properties));
}
return result;
}
const BASE = `---
name: Base
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "#ffffff"
padding: 12px
---
`;
describe('diff: components', () => {
it('reports no component changes when files are identical', () => {
const before = lint(BASE);
const after = lint(BASE);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.added).toEqual([]);
expect(result.removed).toEqual([]);
expect(result.modified).toEqual([]);
});
it('detects an added component', () => {
const afterContent = BASE.replace(
'padding: 12px',
'padding: 12px\n button-secondary:\n backgroundColor: "{colors.secondary}"\n textColor: "#ffffff"',
);
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.added).toContain('button-secondary');
expect(result.removed).toEqual([]);
});
it('detects a removed component', () => {
const afterContent = `---
name: After
colors:
primary: "#1A1C1E"
secondary: "#6C7278"
---
`;
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.removed).toContain('button-primary');
expect(result.added).toEqual([]);
});
it('detects a modified component property', () => {
const afterContent = BASE.replace('padding: 12px', 'padding: 16px');
const before = lint(BASE);
const after = lint(afterContent);
const result = diffMaps(
serializeComponents(before.designSystem.components),
serializeComponents(after.designSystem.components),
);
expect(result.modified).toContain('button-primary');
expect(result.added).toEqual([]);
expect(result.removed).toEqual([]);
});
});
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput, diffMaps, FileReadError } from '../utils.js';
import type { ComponentDef } from '../linter/model/spec.js';
export default defineCommand({
meta: {
name: 'diff',
description: 'Compare two DESIGN.md files and report changes.',
},
args: {
before: {
type: 'positional',
description: 'Path to the "before" DESIGN.md',
required: true,
},
after: {
type: 'positional',
description: 'Path to the "after" DESIGN.md',
required: true,
},
format: {
type: 'string',
description: 'Output format: json or text',
default: 'json',
},
},
async run({ args }) {
let beforeContent: string, afterContent: string;
try {
beforeContent = await readInput(args.before);
afterContent = await readInput(args.after);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const beforeReport = lint(beforeContent);
const afterReport = lint(afterContent);
const diff = {
tokens: {
colors: diffMaps(beforeReport.designSystem.colors, afterReport.designSystem.colors),
typography: diffMaps(beforeReport.designSystem.typography, afterReport.designSystem.typography),
rounded: diffMaps(beforeReport.designSystem.rounded, afterReport.designSystem.rounded),
spacing: diffMaps(beforeReport.designSystem.spacing, afterReport.designSystem.spacing),
components: diffMaps(
serializeComponents(beforeReport.designSystem.components),
serializeComponents(afterReport.designSystem.components),
),
},
findings: {
before: beforeReport.summary,
after: afterReport.summary,
delta: {
errors: afterReport.summary.errors - beforeReport.summary.errors,
warnings: afterReport.summary.warnings - beforeReport.summary.warnings,
},
},
regression: afterReport.summary.errors > beforeReport.summary.errors
|| afterReport.summary.warnings > beforeReport.summary.warnings,
};
console.log(formatOutput(diff, args));
process.exitCode = diff.regression ? 1 : 0;
},
});
function serializeComponents(components: Map<string, ComponentDef>): Map<string, Record<string, unknown>> {
const result = new Map<string, Record<string, unknown>>();
for (const [name, comp] of components) {
result.set(name, Object.fromEntries(comp.properties));
}
return result;
}
@@ -0,0 +1,45 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, afterAll } from 'bun:test';
import { join } from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const CLI = join(import.meta.dir, '../index.ts');
function run(args: string[]): { code: number | null; stdout: string } {
const proc = Bun.spawnSync(['bun', 'run', CLI, ...args], { stdout: 'pipe', stderr: 'pipe' });
return { code: proc.exitCode, stdout: Buffer.from(proc.stdout).toString('utf-8') };
}
describe('export exit code', () => {
const dir = mkdtempSync(join(tmpdir(), 'designmd-export-'));
const badFile = join(dir, 'DESIGN.md');
// An invalid color is a lint *error*, but the export itself still succeeds.
writeFileSync(badFile, '---\ncolors:\n primary: "notacolor"\n---\n## Colors\n');
afterAll(() => rmSync(dir, { recursive: true, force: true }));
it('exits 0 on a successful export even when the source has lint errors', () => {
const { code, stdout } = run(['export', '--format', 'json-tailwind', badFile]);
expect(code).toBe(0);
expect(stdout.trim().length).toBeGreaterThan(0);
});
it('lint still exits 1 on the same source (the validation gate)', () => {
const { code } = run(['lint', badFile]);
expect(code).toBe(1);
});
});
+72
View File
@@ -0,0 +1,72 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { fileURLToPath } from 'node:url';
import exportCommand from './export.js';
const FIXTURE_PATH = fileURLToPath(new URL('../linter/fixtures/DESIGN-test.md', import.meta.url));
describe('export command', () => {
let logSpy: any;
let errorSpy: any;
beforeEach(() => {
process.exitCode = undefined;
logSpy = spyOn(console, 'log').mockImplementation(() => {});
errorSpy = spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = 0;
});
it('outputs css-vars custom properties with an optional prefix', async () => {
await exportCommand.run!({
args: {
file: FIXTURE_PATH,
format: 'css-vars',
prefix: 'ds',
},
} as any);
expect(errorSpy.mock.calls.length).toBe(0);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toStartWith(':root {\n');
expect(output).toContain(' --ds-color-primary: #006b5a;');
expect(output).toContain(' --ds-spacing-unit: 8px;');
expect(output).toContain(' --ds-rounded-sm: 0.25rem;');
expect(output).toEndWith('}\n');
expect(process.exitCode).toBe(0);
});
it('errors with exit code 1 for invalid export formats', async () => {
await exportCommand.run!({
args: {
file: FIXTURE_PATH,
format: 'not-a-format',
},
} as any);
expect(logSpy.mock.calls.length).toBe(0);
expect(errorSpy.mock.calls.length).toBe(1);
const error = JSON.parse(errorSpy.mock.calls[0][0]);
expect(error.error).toContain('Invalid format "not-a-format"');
expect(process.exitCode).toBe(1);
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint, TailwindEmitterHandler, TailwindV4EmitterHandler, serializeTailwindV4, CssVarsEmitterHandler, serializeCssVars } from '../linter/index.js';
import { DtcgEmitterHandler } from '../linter/dtcg/handler.js';
import { readInput, FileReadError } from '../utils.js';
const FORMATS = ['css-tailwind', 'json-tailwind', 'tailwind', 'dtcg', 'css-vars'] as const;
type ExportFormat = typeof FORMATS[number];
export default defineCommand({
meta: {
name: 'export',
description: 'Export DESIGN.md tokens to other formats. `css-tailwind` emits Tailwind v4 CSS @theme; `json-tailwind` emits Tailwind v3 theme.extend JSON; `tailwind` is an alias for `json-tailwind`; `dtcg` emits W3C Design Tokens; `css-vars` emits CSS custom properties.',
},
args: {
file: {
type: 'positional',
description: 'Path to DESIGN.md (use "-" for stdin)',
required: true,
},
format: {
type: 'string',
description: `Output format: ${FORMATS.join(', ')}`,
required: true,
},
prefix: {
type: 'string',
description: 'Optional CSS custom property prefix for css-vars output.',
required: false,
},
},
async run({ args }) {
const format = args.format as string;
const prefix = typeof args.prefix === 'string' ? args.prefix : undefined;
// Validate --format against closed enum
if (!FORMATS.includes(format as ExportFormat)) {
console.error(JSON.stringify({
error: 'INVALID_FORMAT',
message: `Invalid format "${format}". Valid formats: ${FORMATS.join(', ')}`,
}));
process.exitCode = 1;
return;
}
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);
if (format === 'css-tailwind') {
const handler = new TailwindV4EmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
process.stdout.write(serializeTailwindV4(result.data.theme));
} else if (format === 'json-tailwind' || format === 'tailwind') {
const handler = new TailwindEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
console.log(JSON.stringify(result.data, null, 2));
} else if (format === 'dtcg') {
const handler = new DtcgEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.code, message: result.error.message }));
process.exitCode = 1;
return;
}
console.log(JSON.stringify(result.data, null, 2));
} else if (format === 'css-vars') {
const handler = new CssVarsEmitterHandler();
const result = handler.execute(report.designSystem);
if (!result.success) {
console.error(JSON.stringify({ error: result.error.message }));
process.exitCode = 1;
return;
}
console.log(serializeCssVars(result.data.declarations, { prefix }));
}
// A successful export exits 0 even if the source has lint findings; those
// are surfaced by `lint`, not by whether the export itself produced output.
// The error branches above set a non-zero code and return before this point.
},
});
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { lint } from '../linter/index.js';
import { readInput, formatOutput, FileReadError } from '../utils.js';
export default defineCommand({
meta: {
name: 'lint',
description: 'Validate a DESIGN.md file for structural correctness.',
},
args: {
file: {
type: 'positional',
description: 'Path to DESIGN.md (use "-" for stdin)',
required: true,
},
format: {
type: 'string',
description: 'Output format: json or text',
default: 'json',
},
},
async run({ args }) {
let content: string;
try {
content = await readInput(args.file);
} catch (error) {
if (error instanceof FileReadError) {
process.stderr.write(`Error: ${error.friendlyMessage}\n`);
process.exitCode = 2;
return;
}
throw error;
}
const report = lint(content);
const output = {
findings: report.findings,
summary: report.summary,
};
console.log(formatOutput(output, args));
process.exitCode = report.summary.errors > 0 ? 1 : 0;
},
});
+94
View File
@@ -0,0 +1,94 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import specCommand from './spec.js';
describe('spec command', () => {
let logSpy: any;
beforeEach(() => {
logSpy = spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
logSpy.mockRestore();
});
it('outputs spec markdown by default', async () => {
await specCommand.run!({
args: {},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toContain('# DESIGN.md Format');
});
it('outputs spec and rules table when --rules is passed', async () => {
await specCommand.run!({
args: {
rules: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).toContain('# DESIGN.md Format');
expect(output).toContain('| Rule | Severity | What it checks |');
});
it('outputs only rules table when --rules-only is passed', async () => {
await specCommand.run!({
args: {
rulesOnly: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const output = logSpy.mock.calls[0][0];
expect(output).not.toContain('# DESIGN.md Format');
expect(output).toContain('| Rule | Severity | What it checks |');
});
it('outputs JSON when --format json is passed', async () => {
await specCommand.run!({
args: {
format: 'json',
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const outputStr = logSpy.mock.calls[0][0];
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.spec).toContain('# DESIGN.md Format');
});
it('outputs JSON with rules when --format json and --rules are passed', async () => {
await specCommand.run!({
args: {
format: 'json',
rules: true,
},
} as any);
expect(logSpy.mock.calls.length).toBe(1);
const outputStr = logSpy.mock.calls[0][0];
const output = JSON.parse(outputStr);
expect(output.spec).toBeDefined();
expect(output.rules).toBeDefined();
expect(output.rules.length).toBe(10);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand } from 'citty';
import { getSpecContent, getRulesTable } from '../linter/spec-gen/spec-helpers.js';
import { DEFAULT_RULE_DESCRIPTORS } from '../linter/linter/rules/index.js';
export default defineCommand({
meta: {
name: 'spec',
description: 'Output the DESIGN.md format specification.',
},
args: {
rules: {
type: 'boolean',
description: 'Append the active linting rules table.',
},
rulesOnly: {
type: 'boolean',
description: 'Output only the active linting rules table.',
},
format: {
type: 'string',
description: 'Output format (markdown, json).',
default: 'markdown',
},
},
async run({ args }) {
const rulesTable = getRulesTable(DEFAULT_RULE_DESCRIPTORS);
if (args.format === 'json') {
const jsonOutput: any = {};
if (args.rulesOnly) {
jsonOutput.rules = DEFAULT_RULE_DESCRIPTORS.map(r => ({
name: r.name,
severity: r.severity,
description: r.description,
}));
} else {
jsonOutput.spec = getSpecContent();
if (args.rules) {
jsonOutput.rules = DEFAULT_RULE_DESCRIPTORS.map(r => ({
name: r.name,
severity: r.severity,
description: r.description,
}));
}
}
console.log(JSON.stringify(jsonOutput, null, 2));
return;
}
if (args.rulesOnly) {
console.log(rulesTable);
return;
}
let output = getSpecContent();
if (args.rules) {
output += '\n\n## Active Linting Rules\n\n' + rulesTable;
}
console.log(output);
},
});
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { defineCommand, runMain } from 'citty';
import { VERSION } from './version.js';
import lintCommand from './commands/lint.js';
import diffCommand from './commands/diff.js';
import exportCommand from './commands/export.js';
import specCommand from './commands/spec.js';
const main = defineCommand({
meta: {
name: 'design.md',
version: VERSION,
description: 'Agent-first CLI for DESIGN.md — the hands and eyes for design system work.',
},
subCommands: {
lint: lintCommand,
diff: diffCommand,
export: exportCommand,
spec: specCommand,
},
});
runMain(main);
@@ -0,0 +1,135 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { CssVarsEmitterHandler } from './handler.js';
import { serializeCssVars } from './serialize.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension } from '../model/spec.js';
function emptyState(overrides?: Partial<DesignSystemState>): DesignSystemState {
return {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
};
}
function makeColor(hex: string, r: number, g: number, b: number): ResolvedColor {
return { type: 'color', hex, r, g, b, luminance: 0 };
}
function makeDim(value: number, unit: string): ResolvedDimension {
return { type: 'dimension', value, unit };
}
describe('CssVarsEmitterHandler', () => {
const handler = new CssVarsEmitterHandler();
test('empty state produces valid empty :root block', () => {
const result = handler.execute(emptyState());
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.declarations).toEqual([]);
expect(serializeCssVars(result.data.declarations)).toBe(':root {\n}\n');
});
test('colors emit --color-* declarations with lowercase hex values', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
['white', makeColor('#FFFFFF', 255, 255, 255)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --color-primary: #1a1c1e;\n'
+ ' --color-white: #ffffff;\n'
+ '}\n',
);
});
test('spacing and rounded dimensions preserve numeric values and units', () => {
const state = emptyState({
spacing: new Map([
['sm', makeDim(8, 'px')],
['md', makeDim(1, 'rem')],
]),
rounded: new Map([
['card', makeDim(12, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --spacing-sm: 8px;\n'
+ ' --spacing-md: 1rem;\n'
+ ' --rounded-card: 12px;\n'
+ '}\n',
);
});
test('nested token names collapse dots to hyphens for valid CSS property names', () => {
const state = emptyState({
colors: new Map([
['background.light', makeColor('#FFFFFF', 255, 255, 255)],
]),
spacing: new Map([
['gap.lg', makeDim(24, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations)).toBe(
':root {\n'
+ ' --color-background-light: #ffffff;\n'
+ ' --spacing-gap-lg: 24px;\n'
+ '}\n',
);
});
test('prefix option adds a custom prefix to emitted property names', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
expect(serializeCssVars(result.data.declarations, { prefix: 'ds' })).toBe(
':root {\n'
+ ' --ds-color-primary: #1a1c1e;\n'
+ '}\n',
);
});
});
@@ -0,0 +1,64 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { CssVarDeclaration, CssVarsEmitterSpec, CssVarsEmitterResult } from './spec.js';
import type { DesignSystemState, ResolvedDimension } from '../model/spec.js';
/**
* Pure function mapping DesignSystemState → CSS custom property declarations.
* No side effects.
*/
export class CssVarsEmitterHandler implements CssVarsEmitterSpec {
execute(state: DesignSystemState): CssVarsEmitterResult {
const declarations: CssVarDeclaration[] = [];
for (const [name, color] of state.colors) {
declarations.push({
name: `color-${this.cssSafe(name)}`,
value: color.hex.toLowerCase(),
});
}
this.mapDimensionGroup(declarations, 'spacing', state.spacing);
this.mapDimensionGroup(declarations, 'rounded', state.rounded);
return { success: true, data: { declarations } };
}
private mapDimensionGroup(
declarations: CssVarDeclaration[],
group: 'spacing' | 'rounded',
dims: Map<string, ResolvedDimension>,
): void {
for (const [name, dim] of dims) {
declarations.push({
name: `${group}-${this.cssSafe(name)}`,
value: this.dimToString(dim),
});
}
}
private dimToString(dim: ResolvedDimension): string {
return `${dim.value}${dim.unit}`;
}
/**
* Make a token name safe for a CSS custom property. Nested tokens flatten to
* dotted keys (e.g. `background.light`); a literal dot makes a browser drop
* the declaration, so collapse dots to hyphens.
*/
private cssSafe(name: string): string {
return name.replace(/\./g, '-');
}
}
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { CssVarDeclaration } from './spec.js';
export interface SerializeCssVarsOptions {
prefix?: string | undefined;
}
/**
* Serialize declarations to a CSS `:root { ... }` block string.
* Pure function — no I/O. Values are emitted verbatim.
*/
export function serializeCssVars(
declarations: CssVarDeclaration[],
options: SerializeCssVarsOptions = {},
): string {
const variablePrefix = options.prefix ? `${options.prefix}-` : '';
const lines = declarations.map(
declaration => ` --${variablePrefix}${declaration.name}: ${declaration.value};`,
);
if (lines.length === 0) return ':root {\n}\n';
return `:root {\n${lines.join('\n')}\n}\n`;
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { DesignSystemState } from '../model/spec.js';
export const CssVarDeclarationSchema = z.object({
name: z.string(),
value: z.string(),
});
export type CssVarDeclaration = z.infer<typeof CssVarDeclarationSchema>;
// ── Result ─────────────────────────────────────────────────────────
export const CssVarsEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.object({
declarations: z.array(CssVarDeclarationSchema),
}),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
export type CssVarsEmitterResult = z.infer<typeof CssVarsEmitterResultSchema>;
// ── Interface ──────────────────────────────────────────────────────
export interface CssVarsEmitterSpec {
execute(state: DesignSystemState): CssVarsEmitterResult;
}
@@ -0,0 +1,138 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { writeFileSync, mkdtempSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { spawnSync } from 'child_process';
import { lint } from '../lint.js';
import { DtcgEmitterHandler } from './handler.js';
describe('DTCG Conformance', () => {
// Skipped: @terrazzo/token-types@^2.4.0 was removed from npm (404).
// This test depends on installing Terrazzo from the public registry.
// Re-enable once Terrazzo publishes a fix. See: https://github.com/google-labs-code/design.md/issues/106
test.skip('Terrazzo can parse our DTCG output and generate CSS', () => {
const fixtureContent = `---
name: Test Brand
colors:
primary: "#1A1C1E"
accent: "#4A90D9"
spacing:
sm: 8px
md: 16px
typography:
heading:
fontFamily: Inter
fontSize: 24px
fontWeight: 700
lineHeight: 1.2em
letterSpacing: 0px
---
# Spec
`;
// 1. Export to DTCG
const report = lint(fixtureContent);
const handler = new DtcgEmitterHandler();
const result = handler.execute(report.designSystem);
expect(result.success).toBe(true);
if (!result.success) return;
// 2. Create temp dir
const tmpDir = mkdtempSync(join(tmpdir(), 'dtcg-test-'));
try {
// 3. Write tokens.json
writeFileSync(join(tmpDir, 'tokens.json'), JSON.stringify(result.data, null, 2));
// 4. Write minimal terrazzo.config.js
// We use JS to avoid needing to resolve TS imports in the spawned process
writeFileSync(
join(tmpDir, 'terrazzo.config.js'),
`
import { defineConfig } from '@terrazzo/cli';
import pluginCSS from '@terrazzo/plugin-css';
export default defineConfig({
tokens: ['./tokens.json'],
outDir: './out/',
plugins: [pluginCSS()],
});
`
);
// We also need a package.json in the temp dir to make it a module,
// so we can use ES imports in terrazzo.config.js
writeFileSync(
join(tmpDir, 'package.json'),
JSON.stringify({ type: 'module' })
);
// Install dependencies in temp dir so they can be imported in config
// Using bun add should be fast if cached
const customPath = process.env.PATH || '';
const installProc = spawnSync('bun', ['add', '@terrazzo/cli', '@terrazzo/plugin-css'], {
cwd: tmpDir,
env: { ...process.env, PATH: customPath },
shell: true
});
if (installProc.status !== 0) {
console.error('Install failed:', installProc.stderr.toString());
}
// 5. Run Terrazzo build
const proc = spawnSync('npx', ['@terrazzo/cli', 'build'], {
cwd: tmpDir,
env: { ...process.env, PATH: customPath },
shell: true
});
if (proc.status !== 0) {
console.error('Terrazzo stderr:', proc.stderr.toString());
console.error('Terrazzo stdout:', proc.stdout.toString());
}
expect(proc.status).toBe(0);
// 6. Verify CSS was generated
const cssPath = join(tmpDir, 'out/index.css');
if (!existsSync(cssPath)) {
console.log('Temp dir contents:', spawnSync('ls', ['-R', tmpDir]).stdout.toString());
}
expect(existsSync(cssPath)).toBe(true);
if (existsSync(cssPath)) {
const css = readFileSync(cssPath, 'utf-8');
expect(css).toContain('--color-primary');
expect(css).toContain('--color-accent');
expect(css).toContain('--spacing-sm');
// Terrazzo might flatten typography differently, let's check for a part of it
expect(css).toContain('heading');
}
} finally {
// Cleanup
try {
spawnSync('rm', ['-rf', tmpDir]);
} catch {
// best effort
}
}
}, 30000); // Increase timeout for npx
});
@@ -0,0 +1,194 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, test, expect } from 'bun:test';
import { DtcgEmitterHandler } from './handler.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js';
function emptyState(overrides?: Partial<DesignSystemState>): DesignSystemState {
return {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
...overrides,
};
}
function makeColor(hex: string, r: number, g: number, b: number): ResolvedColor {
return { type: 'color', hex, r, g, b, luminance: 0 };
}
function makeDim(value: number, unit: string): ResolvedDimension {
return { type: 'dimension', value, unit };
}
describe('DtcgEmitterHandler', () => {
const handler = new DtcgEmitterHandler();
test('empty state produces valid DTCG file with $schema', () => {
const result = handler.execute(emptyState());
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$schema']).toBe('https://www.designtokens.org/schemas/2025.10/format.json');
// No groups created for empty maps
expect(result.data['color']).toBeUndefined();
expect(result.data['spacing']).toBeUndefined();
expect(result.data['typography']).toBeUndefined();
});
test('name and description → top-level $description', () => {
const result = handler.execute(emptyState({ name: 'Acme', description: 'Acme Design System' }));
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$description']).toBe('Acme Design System');
});
test('name without description → $description uses name', () => {
const result = handler.execute(emptyState({ name: 'Acme' }));
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data['$description']).toBe('Acme');
});
test('colors → DTCG color tokens with sRGB components in 01 range', () => {
const state = emptyState({
colors: new Map([
['primary', makeColor('#1A1C1E', 0x1A, 0x1C, 0x1E)],
['white', makeColor('#FFFFFF', 255, 255, 255)],
['black', makeColor('#000000', 0, 0, 0)],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const colorGroup = result.data['color'] as Record<string, unknown>;
expect(colorGroup['$type']).toBe('color');
const primary = colorGroup['primary'] as Record<string, unknown>;
const primaryValue = primary['$value'] as Record<string, unknown>;
expect(primaryValue['colorSpace']).toBe('srgb');
expect(primaryValue['hex']).toBe('#1a1c1e');
const components = primaryValue['components'] as number[];
expect(components[0]).toBeCloseTo(0x1A / 255, 2);
expect(components[1]).toBeCloseTo(0x1C / 255, 2);
expect(components[2]).toBeCloseTo(0x1E / 255, 2);
// Black = [0, 0, 0]
const black = colorGroup['black'] as Record<string, unknown>;
const blackComponents = (black['$value'] as Record<string, unknown>)['components'] as number[];
expect(blackComponents).toEqual([0, 0, 0]);
// White = [1, 1, 1]
const white = colorGroup['white'] as Record<string, unknown>;
const whiteComponents = (white['$value'] as Record<string, unknown>)['components'] as number[];
expect(whiteComponents).toEqual([1, 1, 1]);
});
test('spacing → DTCG dimension tokens with { value, unit }', () => {
const state = emptyState({
spacing: new Map([
['sm', makeDim(8, 'px')],
['md', makeDim(1, 'rem')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const spacingGroup = result.data['spacing'] as Record<string, unknown>;
expect(spacingGroup['$type']).toBe('dimension');
const sm = spacingGroup['sm'] as Record<string, unknown>;
expect(sm['$value']).toEqual({ value: 8, unit: 'px' });
const md = spacingGroup['md'] as Record<string, unknown>;
expect(md['$value']).toEqual({ value: 1, unit: 'rem' });
});
test('rounded → DTCG dimension tokens under "rounded" group', () => {
const state = emptyState({
rounded: new Map([
['sm', makeDim(4, 'px')],
]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const roundedGroup = result.data['rounded'] as Record<string, unknown>;
expect(roundedGroup['$type']).toBe('dimension');
const sm = roundedGroup['sm'] as Record<string, unknown>;
expect(sm['$value']).toEqual({ value: 4, unit: 'px' });
});
test('typography → DTCG typography composite tokens', () => {
const heading: ResolvedTypography = {
type: 'typography',
fontFamily: 'Inter',
fontSize: makeDim(24, 'px'),
fontWeight: 700,
lineHeight: makeDim(1.2, 'em'),
letterSpacing: makeDim(0.5, 'px'),
};
const state = emptyState({
typography: new Map([['heading', heading]]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const typoGroup = result.data['typography'] as Record<string, unknown>;
const headingToken = typoGroup['heading'] as Record<string, unknown>;
expect(headingToken['$type']).toBe('typography');
const value = headingToken['$value'] as Record<string, unknown>;
expect(value['fontFamily']).toBe('Inter');
expect(value['fontSize']).toEqual({ value: 24, unit: 'px' });
expect(value['fontWeight']).toBe(700);
expect(value['lineHeight']).toBe(1.2);
expect(value['letterSpacing']).toEqual({ value: 0.5, unit: 'px' });
});
test('typography with missing fields omits them from $value', () => {
const minimal: ResolvedTypography = {
type: 'typography',
fontFamily: 'Roboto',
};
const state = emptyState({
typography: new Map([['body', minimal]]),
});
const result = handler.execute(state);
expect(result.success).toBe(true);
if (!result.success) return;
const value = ((result.data['typography'] as Record<string, unknown>)['body'] as Record<string, unknown>)['$value'] as Record<string, unknown>;
expect(value['fontFamily']).toBe('Roboto');
expect(value['fontSize']).toBeUndefined();
expect(value['fontWeight']).toBeUndefined();
expect(value['lineHeight']).toBeUndefined();
expect(value['letterSpacing']).toBeUndefined();
});
});
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DtcgEmitterSpec, DtcgEmitterResult, DtcgTokenFile, DtcgToken, DtcgGroup, DtcgColorValue, DtcgDimensionValue, DtcgTypographyValue } from './spec.js';
import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js';
const DTCG_SCHEMA_URL = 'https://www.designtokens.org/schemas/2025.10/format.json';
/**
* Pure function mapping DesignSystemState → DTCG tokens.json (W3C Design Tokens Format Module 2025.10).
* No side effects.
*/
export class DtcgEmitterHandler implements DtcgEmitterSpec {
execute(state: DesignSystemState): DtcgEmitterResult {
const file: DtcgTokenFile = {
$schema: DTCG_SCHEMA_URL,
};
if (state.name || state.description) {
file.$description = state.description || state.name;
}
const colorGroup = this.mapColors(state);
if (colorGroup) file['color'] = colorGroup;
const spacingGroup = this.mapDimensionGroup(state.spacing);
if (spacingGroup) file['spacing'] = spacingGroup;
const roundedGroup = this.mapDimensionGroup(state.rounded);
if (roundedGroup) file['rounded'] = roundedGroup;
const typographyGroup = this.mapTypography(state);
if (typographyGroup) file['typography'] = typographyGroup;
return { success: true, data: file as Record<string, unknown> };
}
private mapColors(state: DesignSystemState): DtcgGroup | null {
if (state.colors.size === 0) return null;
const group: DtcgGroup = { $type: 'color' };
for (const [name, color] of state.colors) {
group[name] = {
$value: this.colorToValue(color),
} as DtcgToken;
}
return group;
}
private colorToValue(color: ResolvedColor): DtcgColorValue {
return {
colorSpace: 'srgb',
components: [
this.round(color.r / 255),
this.round(color.g / 255),
this.round(color.b / 255),
],
hex: color.hex.toLowerCase(),
};
}
private mapDimensionGroup(dims: Map<string, ResolvedDimension>): DtcgGroup | null {
if (dims.size === 0) return null;
const group: DtcgGroup = { $type: 'dimension' };
for (const [name, dim] of dims) {
group[name] = {
$value: this.dimToValue(dim),
} as DtcgToken;
}
return group;
}
private dimToValue(dim: ResolvedDimension): DtcgDimensionValue {
return { value: dim.value, unit: dim.unit };
}
private mapTypography(state: DesignSystemState): DtcgGroup | null {
if (state.typography.size === 0) return null;
const group: DtcgGroup = {};
for (const [name, typo] of state.typography) {
group[name] = {
$type: 'typography',
$value: this.typographyToValue(typo),
} as DtcgToken;
}
return group;
}
private typographyToValue(typo: ResolvedTypography): DtcgTypographyValue {
const value: DtcgTypographyValue = {};
if (typo.fontFamily) value.fontFamily = typo.fontFamily;
if (typo.fontSize) value.fontSize = this.dimToValue(typo.fontSize);
if (typo.fontWeight !== undefined) value.fontWeight = typo.fontWeight;
if (typo.letterSpacing) value.letterSpacing = this.dimToValue(typo.letterSpacing);
if (typo.lineHeight) {
// DTCG lineHeight is a unitless multiplier of fontSize.
// Our model stores it as a ResolvedDimension. Convert if possible.
// If unit is a relative unit, just use the numeric value as a multiplier.
value.lineHeight = typo.lineHeight.value;
}
return value;
}
/** Round to 3 decimal places for clean output. */
private round(n: number): number {
return Math.round(n * 1000) / 1000;
}
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { DesignSystemState } from '../model/spec.js';
// ── DTCG Value Types (W3C Design Tokens Format Module 2025.10) ────
export interface DtcgColorValue {
colorSpace: 'srgb';
components: [number, number, number];
hex?: string;
}
export interface DtcgDimensionValue {
value: number;
unit: string;
}
export interface DtcgTypographyValue {
fontFamily?: string;
fontSize?: DtcgDimensionValue;
fontWeight?: number;
letterSpacing?: DtcgDimensionValue;
lineHeight?: number;
}
// ── DTCG Token & Group Structures ─────────────────────────────────
export interface DtcgToken {
$type?: string;
$value: DtcgColorValue | DtcgDimensionValue | DtcgTypographyValue | string | number;
$description?: string;
}
export interface DtcgGroup {
$type?: string;
$description?: string;
[key: string]: DtcgToken | DtcgGroup | string | undefined;
}
/** The complete tokens.json output file. */
export interface DtcgTokenFile extends DtcgGroup {
$schema?: string;
}
// ── Result ─────────────────────────────────────────────────────────
export const DtcgEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.record(z.unknown()),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
}),
}),
]);
export type DtcgEmitterResult = z.infer<typeof DtcgEmitterResultSchema>;
// ── Interface ──────────────────────────────────────────────────────
export interface DtcgEmitterSpec {
execute(state: DesignSystemState): DtcgEmitterResult;
}
@@ -0,0 +1,136 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { fixSectionOrder } from './handler.js';
import type { FixerInput } from './spec.js';
describe('FixerHandler', () => {
it('should reorder sections to canonical order', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent).toContain('## Overview\ncontent\n## Colors\ncontent');
}
});
it('should preserve prelude at the top', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: '', content: 'Prelude content' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent.startsWith('Prelude content')).toBe(true);
}
});
it('should append unknown sections at the end', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Unknown', content: '## Unknown\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Overview', content: '## Overview\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
expect(result.fixedContent.endsWith('## Unknown\ncontent')).toBe(true);
}
});
it('should recognize "Brand & Style" as a known section via alias', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Brand & Style', content: '## Brand & Style\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
// Brand & Style (alias for Overview) should come before Colors
expect(result.fixedContent).toContain('## Brand & Style\ncontent\n## Colors\ncontent');
}
});
it('should recognize "Layout & Spacing" as a known section via alias', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Layout & Spacing', content: '## Layout & Spacing\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
// Colors should come before Layout & Spacing
expect(result.fixedContent).toContain('## Colors\ncontent\n## Layout & Spacing\ncontent');
}
});
it('should handle a full real-world section order with aliases', () => {
const input: FixerInput = {
content: '',
sections: [
{ heading: 'Components', content: '## Components\ncontent' },
{ heading: 'Brand & Style', content: '## Brand & Style\ncontent' },
{ heading: 'Typography', content: '## Typography\ncontent' },
{ heading: 'Colors', content: '## Colors\ncontent' },
{ heading: 'Layout & Spacing', content: '## Layout & Spacing\ncontent' },
{ heading: 'Elevation & Depth', content: '## Elevation & Depth\ncontent' },
{ heading: 'Shapes', content: '## Shapes\ncontent' },
]
};
const result = fixSectionOrder(input);
expect(result.success).toBe(true);
if (result.success) {
const idx = (heading: string) => result.fixedContent.indexOf(`## ${heading}`);
expect(idx('Brand & Style')).toBeLessThan(idx('Colors'));
expect(idx('Colors')).toBeLessThan(idx('Typography'));
expect(idx('Typography')).toBeLessThan(idx('Layout & Spacing'));
expect(idx('Layout & Spacing')).toBeLessThan(idx('Elevation & Depth'));
expect(idx('Elevation & Depth')).toBeLessThan(idx('Shapes'));
expect(idx('Shapes')).toBeLessThan(idx('Components'));
}
});
});
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { FixerInput, FixerResult } from './spec.js';
import { CANONICAL_ORDER, resolveAlias } from '../linter/rules/section-order.js';
export function fixSectionOrder(input: FixerInput): FixerResult {
const { sections } = input;
const prelude = sections.find(s => s.heading === '');
const known = sections.filter(s => {
if (s.heading === '') return false;
return CANONICAL_ORDER.includes(resolveAlias(s.heading));
});
const unknown = sections.filter(s => {
if (s.heading === '') return false;
return !CANONICAL_ORDER.includes(resolveAlias(s.heading));
});
// Sort known sections by canonical order
known.sort((a, b) => {
return CANONICAL_ORDER.indexOf(resolveAlias(a.heading)) - CANONICAL_ORDER.indexOf(resolveAlias(b.heading));
});
const resultSections = [];
if (prelude) resultSections.push(prelude);
resultSections.push(...known);
resultSections.push(...unknown);
// Join content with newlines.
// We might need to ensure there are enough newlines between sections.
// The parser keeps the trailing newlines if they are part of the section content.
// Let's see if we need to add a newline between them.
// If we join with '\n', and content already ends with '\n', we might get double newlines.
// Let's just join them for now and see what happens in tests!
const fixedContent = resultSections.map(s => s.content).join('\n');
const beforeOrder = sections.map(s => s.heading).filter(h => h !== '');
const afterOrder = resultSections.map(s => s.heading).filter(h => h !== '');
return {
success: true,
fixedContent,
details: {
beforeOrder,
afterOrder
}
};
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
export const FixerInputSchema = z.object({
content: z.string(),
sections: z.array(z.object({
heading: z.string(),
content: z.string(),
})),
});
export type FixerInput = z.infer<typeof FixerInputSchema>;
export type FixerResult =
| { success: true; fixedContent: string; details?: { beforeOrder: string[]; afterOrder: string[] } }
| { success: false; error: string };
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from './index.js';
import { readFileSync } from 'fs';
import { join } from 'path';
describe('Fixture Test', () => {
it('processes DESIGN-test.md', () => {
// Use import.meta.dir to get the current directory in Bun ESM
const path = join(import.meta.dir, 'fixtures', 'DESIGN-test.md');
const content = readFileSync(path, 'utf-8');
const result = lint(content);
// Basic state assertions
expect(result.designSystem.name).toBe('Pacific Mint Dental');
expect(result.designSystem.colors.size).toBeGreaterThan(0);
expect(result.designSystem.typography.size).toBeGreaterThan(0);
// Check a specific color
const surface = result.designSystem.colors.get('surface');
expect(surface).toBeDefined();
expect(surface?.hex).toBe('#f9f9ff');
// Check a typography scale
const displayLg = result.designSystem.typography.get('display-lg');
expect(displayLg).toBeDefined();
expect(displayLg?.fontFamily).toBe('Manrope');
expect(displayLg?.fontSize?.value).toBe(48);
expect(displayLg?.fontSize?.unit).toBe('px');
// fontWeight: '700' (string) is now parsed as number
expect(displayLg?.fontWeight).toBe(700);
// letterSpacing: -0.02em is parsed (model is generous) but flagged by linter
expect(displayLg?.letterSpacing).toBeDefined();
expect(displayLg?.letterSpacing?.value).toBe(-0.02);
expect(displayLg?.letterSpacing?.unit).toBe('em');
// Check lint results — should have no errors for em units now
const unitErrors = result.findings.filter(
(d: { severity: string; message: string }) => d.severity === 'error' && d.message.includes('invalid unit')
);
expect(unitErrors.length).toBe(0);
// We expect at least the summary info
expect(result.summary.infos).toBeGreaterThan(0);
});
});
@@ -0,0 +1,132 @@
---
name: The Alpine Observatory
colors:
surface: '#0a1325'
surface-dim: '#0a1325'
surface-bright: '#30394d'
surface-container-lowest: '#050e20'
surface-container-low: '#131b2e'
surface-container: '#171f32'
surface-container-high: '#212a3d'
surface-container-highest: '#2c3548'
on-surface: '#dae2fc'
on-surface-variant: '#d5c3b6'
inverse-surface: '#dae2fc'
inverse-on-surface: '#283044'
outline: '#9d8e81'
outline-variant: '#50453a'
surface-tint: '#f6bb81'
primary: '#f6bb81'
on-primary: '#4a2800'
primary-container: '#c58f59'
on-primary-container: '#4c2a00'
inverse-primary: '#815524'
secondary: '#c9c6c1'
on-secondary: '#31312d'
secondary-container: '#474743'
on-secondary-container: '#b7b5af'
tertiary: '#b7c9d8'
on-tertiary: '#22323e'
tertiary-container: '#8b9caa'
on-tertiary-container: '#233440'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#ffdcbe'
primary-fixed-dim: '#f6bb81'
on-primary-fixed: '#2c1600'
on-primary-fixed-variant: '#663d0e'
secondary-fixed: '#e5e2dc'
secondary-fixed-dim: '#c9c6c1'
on-secondary-fixed: '#1c1c18'
on-secondary-fixed-variant: '#474743'
tertiary-fixed: '#d3e5f4'
tertiary-fixed-dim: '#b7c9d8'
on-tertiary-fixed: '#0c1d28'
on-tertiary-fixed-variant: '#384955'
background: '#0a1325'
on-background: '#dae2fc'
surface-variant: '#2c3548'
typography:
heading-display:
fontFamily: Marcellus
fontSize: 48px
fontWeight: '400'
lineHeight: '1.1'
letterSpacing: 0.02em
heading-section:
fontFamily: Marcellus
fontSize: 24px
fontWeight: '400'
lineHeight: '1.3'
letterSpacing: 0.05em
body-journal:
fontFamily: newsreader
fontSize: 18px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0em
telemetry-data:
fontFamily: IBM Plex Mono
fontSize: 12px
fontWeight: '500'
lineHeight: '1.5'
letterSpacing: 0.15em
telemetry-label:
fontFamily: IBM Plex Mono
fontSize: 10px
fontWeight: '400'
lineHeight: '1.2'
letterSpacing: 0.2em
spacing:
panel-gap: 1rem
margin-edge: 2rem
gutter: 1rem
unit: 4px
---
## Brand & Style
This design system channels the rigorous spirit of 19th-century exploration, blending the intellectual weight of a Royal Geographical Society with the technical precision required for high-altitude survival. The aesthetic is defined as **Scientific Alpinism**—a hybrid of tactile, historic materials and cold, celestial data.
The UI should evoke a sense of "The Sublime"—a mixture of awe and peril found at extreme elevations. It utilizes a **Modern-Tactile** approach where information is treated like artifacts on a cartographers desk or telemetry viewed through a brass sextant. It avoids all modern softness, favoring the rigid structures of physical instruments and the starkness of the night sky above the treeline.
## Colors
The palette is anchored by **The Void**, a deep observatory navy that serves as the infinite backdrop of the high-altitude atmosphere. **The Lens** (Parchment) provides a high-contrast surface for intensive reading, mimicking the hand-drawn maps of early expeditions.
**The Instrument** (Antique Brass) is used exclusively for interactive elements and critical focus points, representing the physical tools of navigation. **The Hardware** (Glacial Steel) provides the structural framework, acting as the thin, cold line between the observer and the environment. Use parchment sparingly for primary content containers to create a "magnified" effect against the dark canvas.
## Typography
Typography functions as both narrative and data. **Primary Markings** (Marcellus) lend an air of classical authority and historical permanence to headers.
**The Journal** text uses Newsreader (as a proxy for the requested EB Garamond style) to facilitate long-form reading of expedition logs and alpine surveys. It should feel literary and intentional.
**The Telemetry** (IBM Plex Mono) is the voice of the machine. It must always be presented in uppercase with wide tracking, simulating the etched labels on brass equipment or the printed output of a barometer. This font is used for navigation, coordinates, and metadata.
## Layout & Spacing
The design system utilizes a **Fixed Grid** philosophy inspired by technical drafting sheets. The layout is composed of rigid panels separated by a mandatory **1rem gap**, ensuring that every module feels like a distinct instrument housed within a larger kit.
Structure is reinforced by 1px Glacial Steel borders. Layouts should be symmetrical where possible, mimicking the balanced lens of a telescope. Use white space not for "breathability," but to isolate specific data points, much like a star map isolates celestial bodies. Alignment should be strictly mathematical, with no rounded corners to break the geometry.
## Elevation & Depth
Depth is achieved through **Tonal Layering** and structural framing rather than shadows. The global canvas is the deepest level (The Void). Information panels (The Lens) sit on top as flat, non-elevated surfaces.
To indicate hierarchy, use "crosshair" intersection points where 1px borders meet. Subtle 1px insets can be used to suggest that a piece of glass has been "mounted" into a frame. There are no ambient shadows; the "light" in this system is binary—either an element is illuminated by the brass accent color or it remains in the cold steel of the background.
## Shapes
The shape language is strictly **Linear and Sharp**. A 0px border radius is enforced across all components, from buttons to large containers. This communicates precision, danger, and the uncompromising nature of high-altitude environments. Decorative elements are limited to 45-degree angled corners (chamfers) and compass-inspired iconography.
## Components
- **Action Orreries (Buttons):** Rectangular with 0px radius. Default state features a 1px Navy border and transparent background. On hover, the background fills with Antique Brass, and text shifts to Navy.
- **The Ledger (Lists):** Rows are separated by 1px Glacial Steel rules. Each row starts with a Telemetry-style timestamp or coordinate.
- **The Sextant (Inputs):** Input fields are underlined only or fully boxed in Glacial Steel. Focus state changes the border to Antique Brass with a small crosshair icon appearing in the top-right corner.
- **Specimen Cards:** Containers using the Parchment background. They must feature a 1px Steel border and often include small "latitude/longitude" telemetry in the four corners to frame the content.
- **Navigation:** Top-level navigation is centered and tracked wide using IBM Plex Mono. Active links are underlined with an Antique Brass 1px line.
- **Celestial Markers:** Use thin plus-signs (+) at the corners of main sections to act as registration marks for the UI "lens."
@@ -0,0 +1,151 @@
---
name: The Cartographer's Atlas
colors:
surface: '#0f131c'
surface-dim: '#0f131c'
surface-bright: '#353942'
surface-container-lowest: '#0a0e16'
surface-container-low: '#181c24'
surface-container: '#1c2028'
surface-container-high: '#262a33'
surface-container-highest: '#31353e'
on-surface: '#dfe2ee'
on-surface-variant: '#c7c6cc'
inverse-surface: '#dfe2ee'
inverse-on-surface: '#2c3039'
outline: '#909096'
outline-variant: '#46464c'
surface-tint: '#c3c6d7'
primary: '#c3c6d7'
on-primary: '#2c303d'
primary-container: '#0a0e1a'
on-primary-container: '#777b8a'
inverse-primary: '#5a5e6d'
secondary: '#b9c8dc'
on-secondary: '#233241'
secondary-container: '#3c4a5b'
on-secondary-container: '#abbacd'
tertiary: '#ecc246'
on-tertiary: '#3d2e00'
tertiary-container: '#150e00'
on-tertiary-container: '#987700'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#dfe2f3'
primary-fixed-dim: '#c3c6d7'
on-primary-fixed: '#171b28'
on-primary-fixed-variant: '#434654'
secondary-fixed: '#d5e4f9'
secondary-fixed-dim: '#b9c8dc'
on-secondary-fixed: '#0e1d2b'
on-secondary-fixed-variant: '#3a4858'
tertiary-fixed: '#ffe08e'
tertiary-fixed-dim: '#ecc246'
on-tertiary-fixed: '#241a00'
on-tertiary-fixed-variant: '#584400'
background: '#0f131c'
on-background: '#dfe2ee'
surface-variant: '#31353e'
typography:
display-hero:
fontFamily: Newsreader
fontSize: 84px
fontWeight: '300'
lineHeight: '1.1'
letterSpacing: 0.05em
headline-xl:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '400'
lineHeight: '1.2'
letterSpacing: 0.02em
headline-md:
fontFamily: Newsreader
fontSize: 32px
fontWeight: '400'
lineHeight: '1.3'
letterSpacing: 0.02em
body-lg:
fontFamily: Manrope
fontSize: 18px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
body-md:
fontFamily: Manrope
fontSize: 16px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
label-caps:
fontFamily: Work Sans
fontSize: 12px
fontWeight: '600'
lineHeight: '1.0'
letterSpacing: 0.25em
coordinate:
fontFamily: Work Sans
fontSize: 10px
fontWeight: '400'
lineHeight: '1.0'
letterSpacing: 0.1em
spacing:
unit: 4px
gutter: 24px
margin: 64px
section-gap: 128px
---
## Brand & Style
This design system establishes a high-end editorial atmosphere that bridges the gap between 18th-century maritime navigation and 21st-century data visualization. The brand personality is authoritative, mysterious, and precise. It targets an intellectually curious audience—scholars, analysts, and enthusiasts of long-form digital storytelling.
The design style is a hybrid of **Minimalism** and **Modern Editorial**. It relies on monumental typography and extreme tonal shifts rather than decorative chrome. The aesthetic response should feel like unfolding a rare, heavy-paper map in a dimly lit study: quiet, intentional, and vast.
## Colors
The palette is anchored in "Obsidian Canvas," a near-black that provides infinite depth.
- **Neutral (#080C14):** Used for the primary background/void.
- **Primary (#0A0E1A):** Used for structural panels, cards, and inset surfaces. The contrast between Neutral and Primary is subtle, creating depth without harsh lines.
- **Secondary (#2C3A4A):** Reserved for ultra-thin dividers or structural guides when tonal contrast is insufficient. Use with extreme restraint.
- **Tertiary/Accent (#C9A227):** This vivid gold is the "Compass Rose" of the UI. It is restricted to exactly one occurrence per view—typically the primary action or a singular focal point of data.
## Typography
Typography is the primary vehicle for the "Cartographer" aesthetic.
- **Headlines:** Use **Newsreader** for its high-contrast, traditional serif quality. Display sizes should use light weights with generous tracking to feel monumental and airy.
- **Body:** Use **Manrope** for readability. The 1.7 line height is mandatory to maintain an "open" editorial feel against the dark background.
- **Labels & Annotations:** Use **Work Sans** in all-caps with wide tracking. These mimic the technical coordinates found on nautical charts.
## Layout & Spacing
This design system utilizes a **Fixed Grid** model within a full-bleed canvas. While images and background panels may stretch from edge to edge, the typographic content adheres to a strict 12-column grid with wide margins.
The rhythm is "sparse." High-density information is discouraged. Use massive vertical gaps (section-gaps) to separate narrative beats. Elements should feel like islands in a dark ocean.
## Elevation & Depth
There are no shadows in this design system. Depth is achieved through **Tonal Layering** and **Negative Space**:
1. **Level 0 (Background):** #080C14 (The base canvas).
2. **Level 1 (Panels):** #0A0E1A (Used for content blocks or "floating" map segments).
3. **Level 2 (Interaction):** Hover states use slight shifts in background color or the introduction of a #2C3A4A hairline border.
Avoid stacking more than two levels of depth. The interface should feel flat and planar, like a physical map spread across a table.
## Shapes
The shape language is strictly **Sharp**. A 0px border radius is applied to every element—buttons, cards, input fields, and images. This reinforces the precision of cartography and the "cut" feel of archival paper.
## Components
- **Buttons:** Large, rectangular, 0px radius. The primary CTA is the only element allowed to use the Gold (#C9A227) background with dark text. Secondary buttons are transparent with a thin #2C3A4A border.
- **Cards:** Defined by tonal change (#0A0E1A) against the background. No borders unless necessary for accessibility.
- **Inputs:** Minimalist bottom-border only, or a solid Primary color block. Use Label-caps for field headers.
- **Lists:** Clean rows with wide vertical padding. Use "Coordinate" style typography for indices (e.g., 001, 002).
- **The Compass Rose:** A bespoke icon or navigational element that serves as the single gold accent in a composition, used to return to "North" (the home screen) or trigger the primary narrative flow.
- **Data Points:** Small, sharp squares or crosshair glyphs (using Secondary color) for map annotations.
@@ -0,0 +1,183 @@
---
name: Pacific Mint Dental
colors:
surface: '#f9f9ff'
surface-dim: '#cfdaf1'
surface-bright: '#f9f9ff'
surface-container-lowest: '#ffffff'
surface-container-low: '#f0f3ff'
surface-container: '#e7eeff'
surface-container-high: '#dee8ff'
surface-container-highest: '#d8e3fa'
on-surface: '#111c2c'
on-surface-variant: '#3d4945'
inverse-surface: '#263142'
inverse-on-surface: '#ebf1ff'
outline: '#6d7a75'
outline-variant: '#bcc9c4'
surface-tint: '#006b5a'
primary: '#006b5a'
on-primary: '#ffffff'
primary-container: '#4cbfa6'
on-primary-container: '#004a3d'
inverse-primary: '#69dabf'
secondary: '#075fab'
on-secondary: '#ffffff'
secondary-container: '#70aeff'
on-secondary-container: '#004077'
tertiary: '#59605e'
on-tertiary: '#ffffff'
tertiary-container: '#a7aeac'
on-tertiary-container: '#3b4240'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#87f6db'
primary-fixed-dim: '#69dabf'
on-primary-fixed: '#00201a'
on-primary-fixed-variant: '#005143'
secondary-fixed: '#d4e3ff'
secondary-fixed-dim: '#a4c9ff'
on-secondary-fixed: '#001c39'
on-secondary-fixed-variant: '#004884'
tertiary-fixed: '#dde4e1'
tertiary-fixed-dim: '#c1c8c5'
on-tertiary-fixed: '#161d1b'
on-tertiary-fixed-variant: '#414846'
background: '#f9f9ff'
on-background: '#111c2c'
surface-variant: '#d8e3fa'
typography:
display-lg:
fontFamily: Manrope
fontSize: 48px
fontWeight: '700'
lineHeight: 56px
letterSpacing: -0.02em
headline-lg:
fontFamily: Manrope
fontSize: 32px
fontWeight: '600'
lineHeight: 40px
letterSpacing: -0.01em
headline-md:
fontFamily: Manrope
fontSize: 24px
fontWeight: '600'
lineHeight: 32px
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: '400'
lineHeight: 28px
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
label-lg:
fontFamily: Inter
fontSize: 14px
fontWeight: '600'
lineHeight: 20px
letterSpacing: 0.01em
label-sm:
fontFamily: Inter
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
unit: 8px
container-max: 1200px
gutter: 24px
margin-mobile: 16px
margin-desktop: 40px
---
## Brand & Style
The design system is anchored in the concept of "Clinical Serenity." Aimed at urban professionals in Seattle, the aesthetic balances the precision of modern dentistry with the calming atmosphere of a high-end wellness studio. The personality is approachable yet authoritative, designed to reduce patient anxiety through visual clarity and soft interactions.
The design style utilizes a **Modern Corporate** foundation with **Soft-Minimalist** overlays. It avoids the harsh sterility of traditional medical interfaces by using generous whitespace, translucent layers, and a palette inspired by the Pacific Northwests natural light and water. The UI should feel airy and breathable, prioritizing ease of navigation and a sense of cleanliness.
## Colors
The palette is centered on a "Calming Mint" primary tone that signals health and freshness. This is paired with a "Soft Sky Blue" secondary to reinforce feelings of trust and stability.
- **Primary:** A vibrant yet desaturated mint green used for calls to action and key brand indicators.
- **Secondary:** A soft blue used for supportive information, secondary actions, and navigational accents.
- **Surface:** Crisp white is the dominant background color to maintain a clinical standard of cleanliness.
- **Accents:** Tertiary mint-whites are used for large background sections to soften the contrast against pure white.
- **Typography:** Deep slate grays replace pure black to ensure the interface remains soft and legible without being aggressive.
## Typography
This design system utilizes a dual-font strategy to balance character with utility.
**Manrope** is used for headlines. Its geometric yet slightly rounded apertures provide a contemporary, friendly look that mirrors the "roundedness" of the brand's shape language.
**Inter** is used for all body copy and functional labels. Chosen for its exceptional legibility in clinical contexts, it ensures that medical information and appointment details are communicated with absolute clarity. Hierarchy is established through weight shifts (Medium to Semibold) rather than dramatic size changes to maintain a calm, steady rhythm.
## Layout & Spacing
The layout follows a **Fixed Grid** philosophy for desktop views, centering content within a 1200px container to create an organized, professional feel. On smaller screens, the system transitions to a fluid model with generous margins.
The rhythm is built on a strictly enforced 8px base unit.
- Use **24px (3 units)** for standard gutters and element spacing.
- Use **48px-64px (6-8 units)** for vertical section spacing to maintain an "airy" and unhurried feel.
- Elements should be aligned to a 12-column grid to ensure information-heavy pages (like dental history or treatment plans) remain structured and digestible.
## Elevation & Depth
To convey a sense of modern care, the design system utilizes **Ambient Shadows** and **Tonal Layers**. Depth is used sparingly to signify interactivity and importance.
- **Level 1 (Base):** Crisp white or tertiary-mint backgrounds.
- **Level 2 (Cards/Widgets):** Subtle 1px borders in a light gray-blue or a very soft, diffused shadow (15% opacity primary color tint) to lift the element slightly from the background.
- **Level 3 (Modals/Popovers):** Higher diffusion shadows with no blur-offset, creating a "glow" effect that feels more like light than a physical shadow.
Avoid heavy blacks or harsh dropshadows. All depth should feel "feathered" and light, as if diffused through a soft-box.
## Shapes
The shape language is defined by **Moderate Roundedness**. This approach softens the "sharpness" associated with dental tools and clinical environments, replacing it with a comforting, organic feel.
- **Primary containers:** Use a 12px to 16px corner radius.
- **Buttons and Inputs:** Use a consistent 8px radius to feel modern but structured.
- **Small elements (Tags/Chips):** Can utilize pill-shapes (fully rounded) to differentiate them from functional inputs.
Consistent radii across all components ensure the interface feels cohesive and intentionally designed.
## Components
### Buttons
Primary buttons are solid Mint Green with white text and 8px rounded corners. Secondary buttons use a "Soft Blue" outline or ghost style. Hover states should involve a subtle scale-up (1.02x) rather than a dramatic color change to keep the interaction gentle.
### Input Fields
Inputs feature a light gray-blue border that transitions to the Primary Mint color on focus. Labels are always positioned above the field for maximum accessibility. Validation states (error/success) should use soft, desaturated versions of red and green to avoid alarming the user.
### Calendar Widget
The signature component of this design system. It utilizes a soft-shadowed card (Level 2 elevation) with high-contrast dates. Selected dates are highlighted with a Primary Mint circle. The header navigation (Month/Year) uses the secondary Sky Blue to provide clear visual separation from the functional grid.
### Cards & Appointment Summaries
Cards use a Level 1 elevation (subtle border) and generous internal padding (24px). They are used to group treatment steps or upcoming appointments, creating a "tiled" look that organizes the user's health journey.
### Progress Indicators
Thin, horizontal bars using a Mint Green fill on a light mint track, used to show treatment plan completion or booking steps.
@@ -0,0 +1,176 @@
---
name: Heritage
colors:
surface: '#fbf9f6'
surface-dim: '#dbdad7'
surface-bright: '#fbf9f6'
surface-container-lowest: '#ffffff'
surface-container-low: '#f5f3f0'
surface-container: '#efeeeb'
surface-container-high: '#eae8e5'
surface-container-highest: '#e4e2df'
on-surface: '#1b1c1a'
on-surface-variant: '#44474a'
inverse-surface: '#30312f'
inverse-on-surface: '#f2f0ed'
outline: '#75777a'
outline-variant: '#c5c6ca'
surface-tint: '#5d5e61'
primary: '#000101'
on-primary: '#ffffff'
primary-container: '#1a1c1e'
on-primary-container: '#838486'
inverse-primary: '#c6c6c9'
secondary: '#595f65'
on-secondary: '#ffffff'
secondary-container: '#dde3ea'
on-secondary-container: '#5f656b'
tertiary: '#040000'
on-tertiary: '#ffffff'
tertiary-container: '#400300'
on-tertiary-container: '#db5b45'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#e2e2e5'
primary-fixed-dim: '#c6c6c9'
on-primary-fixed: '#1a1c1e'
on-primary-fixed-variant: '#454749'
secondary-fixed: '#dde3ea'
secondary-fixed-dim: '#c1c7ce'
on-secondary-fixed: '#161c21'
on-secondary-fixed-variant: '#41474d'
tertiary-fixed: '#ffdad3'
tertiary-fixed-dim: '#ffb4a6'
on-tertiary-fixed: '#3f0300'
on-tertiary-fixed-variant: '#881f0f'
background: '#fbf9f6'
on-background: '#1b1c1a'
surface-variant: '#e4e2df'
typography:
h1:
fontFamily: Public Sans
fontSize: 48px
fontWeight: '600'
lineHeight: '1.1'
letterSpacing: -0.02em
h2:
fontFamily: Public Sans
fontSize: 32px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: -0.01em
h3:
fontFamily: Public Sans
fontSize: 24px
fontWeight: '600'
lineHeight: '1.3'
body-lg:
fontFamily: Public Sans
fontSize: 18px
fontWeight: '400'
lineHeight: '1.6'
body-md:
fontFamily: Public Sans
fontSize: 16px
fontWeight: '400'
lineHeight: '1.6'
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.0'
letterSpacing: 0.1em
label-numeral:
fontFamily: Space Grotesk
fontSize: 14px
fontWeight: '500'
lineHeight: '1.0'
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
base: 16px
xs: 4px
sm: 8px
md: 16px
lg: 32px
xl: 64px
gutter: 24px
margin: 32px
---
## Brand & Style
This design system is built upon a philosophy of **Architectural Minimalism** mixed with **Journalistic Gravitas**. It is designed for high-performance athletic heritage brands, marathon organizers, and prestigious sporting publications. The aesthetic targets an audience that values discipline, endurance, and historical prestige.
The UI evokes a premium matte finish, avoiding glossy gradients or excessive shadows in favor of structural clarity and "Color Stacking." The emotional response is one of calm authority—resembling a high-end broadsheet newspaper or a contemporary gallery exhibition. It prioritizes legibility, precision timing, and editorial flow.
## Colors
The palette is rooted in high-contrast neutrals and a single, evocative accent color.
- **Primary (#1A1C1E):** A deep ink used for headlines and core text to provide maximum readability and a sense of permanence.
- **Secondary (#6C7278):** A sophisticated slate used primarily for utilitarian elements like borders, captions, and metadata.
- **Tertiary (#B8422E):** Known as "Boston Clay," this vibrant earthy red is the sole driver for interaction, used exclusively for primary actions and critical highlights.
- **Neutral (#F7F5F2):** A warm limestone that serves as the foundation for all pages, providing a softer, more organic feel than pure white.
- **Surface (#FFFFFF):** Pure white is reserved for foreground cards and content sections to create a "stacked" physical appearance against the neutral canvas.
## Typography
The typography strategy leverages two distinct weights of **Public Sans** for the narrative and **Space Grotesk** for technical data.
- **Headlines:** Set in Public Sans Semi-Bold to establish an institutional and trustworthy voice.
- **Body:** Public Sans Regular at 16px ensures contemporary professionalism and long-form readability.
- **Labels:** Space Grotesk is used for all technical data, time-stamps, and metadata. Its geometric construction evokes the precision of a digital stopwatch or race clock. Labels are strictly uppercase with generous letter spacing to enhance their "technical" feel.
## Layout & Spacing
This design system utilizes a **Relaxed Fixed Grid** approach. Content is organized within a standard 12-column grid to maintain structural alignment, but the spacing between sections is generous to allow for "breathability."
The 16px base unit dictates all padding and margins. Vertical rhythm is strictly enforced in multiples of 8px or 16px. Margins on the outer edges of the viewport should be substantial (32px+) to reinforce the editorial, "magazine-style" layout.
## Elevation & Depth
Depth in this system is achieved through **Color Stacking** and **Architectural Outlines** rather than shadows.
1. **Base Layer:** The Neutral (#F7F5F2) background serves as the ground.
2. **Surface Layer:** White (#FFFFFF) cards or sections sit directly on the ground.
3. **Definition:** Every surface layer is defined by a 1px solid Secondary (#6C7278) border.
Shadows should be avoided entirely to maintain the matte, premium finish. The hierarchy is established purely through the contrast between the limestone background and the pure white foreground containers.
## Shapes
The shape language is defined by **Architectural Sharpness**. All interactive elements, containers, and inputs utilize a minimal **4px corner radius**. This provides just enough softness to feel modern while maintaining a rigid, engineered aesthetic that reflects the precision of the marathon theme.
## Components
- **Buttons:** Primary buttons are solid "Boston Clay" (#B8422E) with white text. They use a 4px radius and 16px horizontal padding. No shadows; hover states should darken the background color slightly.
- **Inputs:** Text fields use a White background with a 1px Secondary border. On focus, the border increases to 2px and changes to Tertiary (#B8422E). Label text should use the Space Grotesk label style positioned above the field.
- **Cards:** Cards are pure white with a 1px Secondary border. They should never have shadows. Use generous internal padding (24px or 32px) to maintain the relaxed editorial feel.
- **Chips/Badges:** Use a transparent background with a 1px Secondary border and Space Grotesk labels. If used for status, the border can take on the Tertiary color.
- **Lists:** Items are separated by 1px Secondary horizontal rules. Ensure high vertical padding (16px) between items to prevent visual clutter.
- **Data Displays:** For timing and race results, use Space Grotesk numerals in Primary ink to emphasize the precision and "clock" aesthetic.
## Do's and Don'ts
### Do:
- **Do** use asymmetrical margins. If the left margin is `16 (5.5rem)`, try making the right margin `24 (8.5rem)` to create an editorial layout.
- **Do** use `Space Grotesk` for anything that feels like "data" or "process."
- **Do** lean into the "Limestone" warmth. Pure grey (#808080) is too cold; always use the `Slate Gray` (#6C7278) which has a hint of blue-gold.
### Don't:
- **Don't** use 100% black. Always use `Deep Ink` (#1A1C1E).
- **Don't** use "pill" buttons. The `4px` radius is a strict rule to maintain architectural discipline.
- **Don't** use dividers. If two pieces of content need separation, increase the spacing token (e.g., move from `4` to `6`) or change the background tone.
@@ -0,0 +1,151 @@
---
name: The Cartographer's Atlas
colors:
surface: '#0f131c'
surface-dim: '#0f131c'
surface-bright: '#353942'
surface-container-lowest: '#0a0e16'
surface-container-low: '#181c24'
surface-container: '#1c2028'
surface-container-high: '#262a33'
surface-container-highest: '#31353e'
on-surface: '#dfe2ee'
on-surface-variant: '#c7c6cc'
inverse-surface: '#dfe2ee'
inverse-on-surface: '#2c3039'
outline: '#909096'
outline-variant: '#46464c'
surface-tint: '#c3c6d7'
primary: '#c3c6d7'
on-primary: '#2c303d'
primary-container: '#0a0e1a'
on-primary-container: '#777b8a'
inverse-primary: '#5a5e6d'
secondary: '#b9c8dc'
on-secondary: '#233241'
secondary-container: '#3c4a5b'
on-secondary-container: '#abbacd'
tertiary: '#ecc246'
on-tertiary: '#3d2e00'
tertiary-container: '#150e00'
on-tertiary-container: '#987700'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#dfe2f3'
primary-fixed-dim: '#c3c6d7'
on-primary-fixed: '#171b28'
on-primary-fixed-variant: '#434654'
secondary-fixed: '#d5e4f9'
secondary-fixed-dim: '#b9c8dc'
on-secondary-fixed: '#0e1d2b'
on-secondary-fixed-variant: '#3a4858'
tertiary-fixed: '#ffe08e'
tertiary-fixed-dim: '#ecc246'
on-tertiary-fixed: '#241a00'
on-tertiary-fixed-variant: '#584400'
background: '#0f131c'
on-background: '#dfe2ee'
surface-variant: '#31353e'
typography:
display-xl:
fontFamily: Newsreader
fontSize: 84px
fontWeight: '700'
lineHeight: '1.1'
letterSpacing: 0.05em
headline-lg:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: 0.02em
headline-md:
fontFamily: Newsreader
fontSize: 32px
fontWeight: '500'
lineHeight: '1.3'
body-lg:
fontFamily: Noto Serif
fontSize: 20px
fontWeight: '400'
lineHeight: '1.7'
body-md:
fontFamily: Noto Serif
fontSize: 17px
fontWeight: '400'
lineHeight: '1.7'
label-caps:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.5'
letterSpacing: 0.2em
quote-editorial:
fontFamily: Newsreader
fontSize: 28px
fontWeight: '400'
lineHeight: '1.4'
spacing:
unit: 8px
gutter: 24px
margin: 64px
panel-padding: 120px
---
## Brand & Style
This design system establishes an atmosphere of intellectual authority and discovery. It targets a sophisticated audience that values long-form investigative journalism, historical context, and precision data. The brand personality is scholarly yet avant-garde, blending the archival feel of physical parchment and ink with the crispness of modern digital mapping.
The aesthetic follows a **High-Contrast / Minimalist** approach. It rejects modern trends of soft shadows and rounded corners in favor of a rigid, monumental structure. The emotional response is intended to be one of quiet focus, evoking the feeling of a researcher in a darkened library illuminated by a single high-intensity lamp.
## Colors
The palette is restricted to four core tones to maintain an editorial rigor.
- **Obsidian Canvas (#080C14):** The foundational ground. It provides a deep, non-distracting void that allows content to emerge.
- **Ink Navy (#0A0E1A):** Used for primary content containers and headings to create a subtle shift from the background without losing the dark-mode immersion.
- **Slate Structure (#2C3A4A):** The color of technicality. Used for hair-line borders, grid lines, and utilitarian UI elements.
- **Antique Gold (#C9A227):** A singular, high-intensity accent. It must be used sparingly—ideally only once per screen view—to act as a beacon for the most important action or data point.
## Typography
The typography system relies on the interplay between traditional literary serifs and technical geometric sans-serifs.
- **Headlines:** Use **Newsreader** at large scales. Its high-contrast strokes and sharp serifs command attention. Increased tracking on display sizes enhances the "monumental" feel.
- **Body:** **Noto Serif** is utilized for its warmth and legibility over long periods. The 1.7 line height is mandatory to prevent text blocks from feeling dense or unapproachable.
- **Labels & UI:** **Space Grotesk** serves as the annotation layer. It is used in all-caps with wide tracking to mimic the coordinate labels found on topographical charts.
## Layout & Spacing
This design system employs a **Full-Bleed Panel Grid** with scroll-snap functionality. Each panel represents a "chapter" or "map sheet" in the experience.
- **Offset Text Blocks:** Avoid centering text. Content should be offset to the left or right of the vertical center line to create a dynamic, editorial rhythm.
- **Alternating Panels:** Visual weight should shift between panels (e.g., a text-heavy slate panel followed by a full-screen image/data visualization on the obsidian canvas).
- **Margins:** Generous margins (64px+) ensure that content never feels crowded, maintaining the "Atlas" feel of vast, explored territory.
## Elevation & Depth
In accordance with the flat, cartographic nature of the design system, **shadows are strictly prohibited**. Depth is created exclusively through:
- **Tonal Stepping:** Layering the Primary Ink Navy (#0A0E1A) over the Neutral Obsidian (#080C14).
- **Hairline Borders:** Using 1px solid Slate (#2C3A4A) to define boundaries between panels or components.
- **Z-Index Layering:** Elements like fixed navigation or labels float over content with 100% opacity, relying on color contrast rather than blur or shadow to stand out.
## Shapes
The shape language is defined by the **0px border radius**. All containers, buttons, and decorative elements must utilize sharp, 90-degree angles. This reflects the precision of a mapmaker's tools and the rigid lines of architectural drafting. No exceptions are made for circular profile images or icons; these should be framed in square or rectangular containers.
## Components
### Buttons
Primary buttons use the Antique Gold (#C9A227) fill with Navy (#0A0E1A) text. They are rectangular (0px radius) and lack any hover shadow; hover states are indicated by a 1px Slate (#2C3A4A) outline or a slight color shift in the gold.
### Pull Quotes
Quotes are treated with high editorial importance. They feature the large Newsreader italic typeface and are anchored by a 2px vertical gold border on the left. They should often be placed in the "offset" layout area to break the body text flow.
### Lists & Annotations
Lists use the Label-style font (Space Grotesk) for bullets or numbers. Items are separated by subtle horizontal hair-lines in Slate (#2C3A4A).
### Input Fields
Inputs are simple 1px Slate outlines against the Navy surface. Focus states are indicated by the border changing to Gold. Labeling always sits above the field in uppercase, wide-tracked Space Grotesk.
### Data Panels
A unique component for this design system is the "Coordinate Panel"—a small, fixed UI element in the corner of the viewport that displays progress or metadata in the Label font style, mimicking the legend of a map.
@@ -0,0 +1,132 @@
---
name: Neo-Esoteric Monument
colors:
surface: '#fbf9f4'
surface-dim: '#dbdad5'
surface-bright: '#fbf9f4'
surface-container-lowest: '#ffffff'
surface-container-low: '#f5f4ef'
surface-container: '#efeee9'
surface-container-high: '#e9e8e3'
surface-container-highest: '#e3e2de'
on-surface: '#1b1c19'
on-surface-variant: '#4d4545'
inverse-surface: '#30312e'
inverse-on-surface: '#f2f1ec'
outline: '#7f7575'
outline-variant: '#d0c4c4'
surface-tint: '#615d5d'
primary: '#000000'
on-primary: '#ffffff'
primary-container: '#1d1b1b'
on-primary-container: '#878382'
inverse-primary: '#cbc5c5'
secondary: '#745b1d'
on-secondary: '#ffffff'
secondary-container: '#fedc91'
on-secondary-container: '#785f21'
tertiary: '#000000'
on-tertiary: '#ffffff'
tertiary-container: '#410004'
on-tertiary-container: '#dd5853'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#e7e1e1'
primary-fixed-dim: '#cbc5c5'
on-primary-fixed: '#1d1b1b'
on-primary-fixed-variant: '#494646'
secondary-fixed: '#ffdf9a'
secondary-fixed-dim: '#e3c37a'
on-secondary-fixed: '#251a00'
on-secondary-fixed-variant: '#5a4305'
tertiary-fixed: '#ffdad7'
tertiary-fixed-dim: '#ffb3ad'
on-tertiary-fixed: '#410004'
on-tertiary-fixed-variant: '#8a1b1d'
background: '#fbf9f4'
on-background: '#1b1c19'
surface-variant: '#e3e2de'
typography:
display-serif:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '400'
lineHeight: '1.1'
letterSpacing: -0.02em
quote-editorial:
fontFamily: Newsreader
fontSize: 24px
fontWeight: '400'
lineHeight: '1.4'
body-main:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0.01em
metadata-caps:
fontFamily: Inter
fontSize: 11px
fontWeight: '700'
lineHeight: '1.2'
letterSpacing: 0.15em
label-small:
fontFamily: Inter
fontSize: 13px
fontWeight: '500'
lineHeight: '1'
letterSpacing: 0.05em
spacing:
unit: 4px
margin-page: 64px
gutter: 32px
block-gap: 48px
element-gap: 16px
---
## Brand & Style
The design system is rooted in the "Minimalist Neo-Esoteric" aesthetic—a fusion of ancient monumentalism and modern editorial precision. It evokes the feeling of a rare, physical manuscript or a stone-carved archive rather than a digital interface. The audience is intellectual and discerning, seeking a "grave" and quiet space for high-signal discourse.
The style is **Tactile and Minimalist**, rejecting standard web conventions (like heavy gradients or standard blue links) in favor of a craftsmen-focused approach. It utilizes physical metaphors—heavy cardstock, etched metal highlights, and hard-edged shadows—to create a sense of permanence and weight. The emotional response is one of reverence, focused intensity, and analog tactile satisfaction.
## Colors
The palette is anchored by the **Warm Alabaster** base, which acts as a physical canvas. **Rich Charcoal** provides the weight for primary text and structural boundaries, creating a sense of "monumental" grounding.
**Matte Brass** is reserved for the highest tier of editorial importance and interactive states, mimicking the look of inlaid metal. **Deep Crimson** is used exclusively as a structural "bloodline"—a 1px offset shadow or a hair-thin line—to suggest depth and history without resorting to digital blurs. Avoid vibrant colors; the palette must remain muted, antique, and serious.
## Typography
This design system uses a high-contrast typographic pairing. **Newsreader** (serving the Serif requirement) is used for headlines and blockquotes, appearing authoritative and literary. **Inter** handles all functional data and body text, providing a clean, utilitarian counterpoint.
Metadata and labels must utilize **heavy tracking** (letter-spacing) and uppercase styling to evoke the feeling of architectural engravings. Body text should maintain generous line height to ensure the "cardstock" canvas feels breathable and premium.
## Layout & Spacing
The layout follows a **Fixed Grid** philosophy, centered on the screen like an open book. It uses a rigorous 12-column grid with wide gutters to emphasize the "monumental" nature of the content.
Whitespace is not "empty" but is treated as "physical margin." Elements are spaced with a mathematical rhythm based on a 4px baseline, but large-scale components (like sections or articles) use exaggerated vertical gaps to slow the reader's pace and demand attention.
## Elevation & Depth
Depth in this design system is achieved through **Physical Offsets** rather than ambient blurs.
1. **The Etch:** Instead of a drop shadow, interactive cards use a 1px or 2px solid offset in **Deep Crimson (#9F2B2A)**. This creates a "cut" or "stamped" look.
2. **The Inlay:** Interactive elements in their active state may shift 1px down and to the right, simulating a physical button press.
3. **Tonal Stacking:** Surfaces remain flat on the Alabaster background, using thin Charcoal borders (0.5pt to 1pt) to define boundaries. No soft shadows are permitted. Hierarchy is communicated through typographic scale and the presence of the Crimson offset.
## Shapes
The shape language is strictly **Sharp (0px)**. Any curvature would betray the "monumental" and "architectural" intent of the system. Rectangles represent stone slabs and cut paper. All buttons, cards, and input fields must feature perfectly square corners. Decorative elements like dividers should be 1px solid lines, occasionally interrupted by a small diamond or square glyph to mark a center point.
## Components
* **Buttons:** Rectangular with a 1px Charcoal border. On hover, the background fills with Matte Brass and the text remains Charcoal. Use the "Etch" depth (Deep Crimson offset) only for primary actions.
* **Cards (Feed Items):** Flat containers with a thin bottom border. The "upvote" or "rank" number is displayed in high-contrast Serif, while metadata (author, time) is in spaced-out Sans-serif caps.
* **Chips/Tags:** Small, all-caps labels with no background, separated by a vertical pipe `|` or a simple 1px border.
* **Lists:** Items are separated by generous whitespace and thin horizontal rules. No bullet points; use typographic hierarchy or numerical indicators in Matte Brass.
* **Input Fields:** A single bottom border in Charcoal. Labels sit above in metadata-style caps. The cursor should be a solid Charcoal block.
* **The "Artifact" (Special Component):** A featured quote or key post encased in a double-border frame (1px Charcoal outer, 1px Brass inner) to signify its "esoteric" value.
@@ -0,0 +1,40 @@
The oval is a monument. Every city has one — worn terracotta, chalked lanes, the same quarter-mile run ten thousand times by ten thousand different people at ten thousand different speeds. This is a publication about Washington DC's running tracks: where they are, what they feel like underfoot, who has run them, and why the oval endures as the purest form of athletic space.
The aesthetic draws from the physical materials of the track itself and the tradition of serious amateur athletics — collegiate, unhurried, earned. The palette is the track: terracotta surface, forest green infield, cream of an old race programme, white of a lane marking. The typography is authoritative and editorial, like the annals of an athletics club that has been meeting on the same oval since 1923. Every design decision references something real and physical, not digital.
# Design System
## Colors
- **Primary** (#1B3026): Deep midnight forest green — near-black, the infield at first light. All text, headings, core content.
- **Secondary** (#7A6A5A): Warm weathered slate — aged concrete, worn rubber track. Captions, metadata, supporting structure.
- **Tertiary** (#B5491C): Track terracotta — the surface itself. The single vivid accent. Used once per panel for CTAs and emphasis only.
- **Neutral** (#F2ECE2): Warm cream — archival paper, a race programme from 1948, linen. The canvas.
## Typography
- **Headline Font**: High-contrast collegiate editorial serif — authoritative, slightly condensed, the kind found on the cover of a century-old athletics club annual. Large sizes use tight tracking. Headlines are left-aligned, never centered.
- **Body Font**: A refined humanist serif or classic editorial sans for extended reading. Generous line height (1.7). Warm and legible. The prose of a serious running magazine.
- **Label Font**: Monospace, all uppercase, wide tracking — like a race timing display, a lane marker, a split time on a stopwatch. Section eyebrows in muted warm gray.
## Elevation
No shadows. Depth through tonal surface variation only — warm cream panels giving way to slightly deeper cream containers. The page reads like a printed object, not a glowing screen. No drop shadows anywhere.
## Shape
Roundedness: 0px. The track is geometry — precise arcs, exact distances, chalk-white right angles. No softness.
## Components
- **Buttons**: 0px radius. Tertiary terracotta fill. Dark forest green text. One per panel — the lane marker, the single instruction. Use an outer ring at forest-green / 10% opacity instead of a solid border.
- **Pull quotes**: Large editorial serif, left-bordered with a 2px terracotta rule. The voice of a runner, a coach, a timekeeper.
- **Track labels**: Monospace eyebrow style — track name, surface type, distance, ward. Like the data panel beside a race course map.
- **No icons on buttons.** No centered layouts. No solid borders alongside shadows.
## Do's and Don'ts
- Do use terracotta exactly once per panel — it is the lane marker, not the paint.
- Do left-align all heroes with a split layout: large headline left (~3/5), description and CTA right (~2/5), both top-aligned.
- Do let photography and typography share equal weight — this is an editorial publication, not a product page.
- Don't use warm backgrounds other than the cream canvas — warmth lives in the terracotta accent only.
- Don't center section headings — inline heading + subheading on the same line, heading dark bold, subheading in warm gray medium weight.
- Don't use solid borders alongside shadows — outer ring at 10% opacity only.
- Don't use sidebars or headers. Minimal to no chrome.
---
Layout: Lookbook / Editorial Spread — Full-bleed imagery dominates each panel. Text overlays or sits adjacent in generous margins. The layout reads vertically like turning pages in a physical catalogue. Each panel is its own composition — a full-width photograph of a specific track paired with sparse editorial text and a single terracotta CTA. Scrolling feels deliberate. Scroll-snap behavior.
Decoration: A subtle canvas grid of thin ruled lines between panels — horizontal lines at full viewport width, vertical lines within the page container. Like the lane markings of the track extended onto the page. Low contrast, architectural, not decorative.
Build the home page for "The Tracks of Washington DC" — a prestige editorial publication cataloguing every running track in the city. The page has 6 full-viewport panels:
1. **Hero**: Full-bleed aerial photograph of a terracotta running track at dawn. Magazine title "The Tracks of Washington DC" in large left-aligned collegiate serif. Subheading right: "A field guide to the city's ovals — their surfaces, their histories, their regulars." Single terracotta CTA: "Begin the Circuit".
2. **Georgetown Waterfront Track**: Full-bleed photograph. Editorial left panel with track name as monospace eyebrow, headline about the track's character, body prose (23 sentences), distance and surface specs as data labels.
3. **A runner mid-stride on the back straight**: Cinematic, wide. Pull quote left-bordered in terracotta — a single line about what the oval teaches. No CTA. The image is the content.
4. **Anacostia Park Track**: Mirror of panel 2 — image right, text left. Different character, different story.
5. **The Washington Circuit map**: A simple graphic showing all tracks across the city as points on a minimal street map. Monospace labels. Distances. Surface types. Forest green and terracotta on cream.
6. **The Guide**: Newsletter / field guide sign-up. Forest green surface container. Cream text. "Know every oval." Single terracotta CTA: "Get the Guide".
@@ -0,0 +1,10 @@
# Test System
## Colors
```yaml
colors:
primary: "#647D66"
```
## Overview
Description here.
@@ -0,0 +1,134 @@
---
name: The Tracks of Washington DC
colors:
surface: '#fff9ee'
surface-dim: '#dfd9d0'
surface-bright: '#fff9ee'
surface-container-lowest: '#ffffff'
surface-container-low: '#f9f3e9'
surface-container: '#f3ede3'
surface-container-high: '#ede7dd'
surface-container-highest: '#e8e2d8'
on-surface: '#1d1b16'
on-surface-variant: '#424844'
inverse-surface: '#33302a'
inverse-on-surface: '#f6f0e6'
outline: '#727874'
outline-variant: '#c2c8c2'
surface-tint: '#4d6357'
primary: '#061b12'
on-primary: '#ffffff'
primary-container: '#1b3026'
on-primary-container: '#81988b'
inverse-primary: '#b4ccbe'
secondary: '#6b5c4c'
on-secondary: '#ffffff'
secondary-container: '#f4dfcb'
on-secondary-container: '#716252'
tertiary: '#300a00'
on-tertiary: '#ffffff'
tertiary-container: '#531700'
on-tertiary-container: '#e96f3f'
error: '#ba1a1a'
on-error: '#ffffff'
error-container: '#ffdad6'
on-error-container: '#93000a'
primary-fixed: '#d0e8d9'
primary-fixed-dim: '#b4ccbe'
on-primary-fixed: '#0a1f16'
on-primary-fixed-variant: '#364b40'
secondary-fixed: '#f4dfcb'
secondary-fixed-dim: '#d7c3b0'
on-secondary-fixed: '#241a0e'
on-secondary-fixed-variant: '#524436'
tertiary-fixed: '#ffdbcf'
tertiary-fixed-dim: '#ffb59b'
on-tertiary-fixed: '#380d00'
on-tertiary-fixed-variant: '#812900'
background: '#fff9ee'
on-background: '#1d1b16'
surface-variant: '#e8e2d8'
typography:
headline-display:
fontFamily: Newsreader
fontSize: 72px
fontWeight: '700'
lineHeight: '1.1'
letterSpacing: -0.02em
headline-lg:
fontFamily: Newsreader
fontSize: 48px
fontWeight: '600'
lineHeight: '1.2'
letterSpacing: -0.01em
body-main:
fontFamily: Noto Serif
fontSize: 18px
fontWeight: '400'
lineHeight: '1.7'
letterSpacing: 0.01em
body-sm:
fontFamily: Noto Serif
fontSize: 14px
fontWeight: '400'
lineHeight: '1.6'
letterSpacing: 0px
label-mono:
fontFamily: Space Grotesk
fontSize: 12px
fontWeight: '500'
lineHeight: '1.0'
letterSpacing: 0.2em
spacing:
grid-columns: '5'
headline-span: '3'
content-span: '2'
gutter: 24px
margin: 40px
unit: 8px
---
## Brand & Style
This design system establishes a high-prestige editorial aesthetic that blends collegiate tradition with modern architectural precision. The brand identity is authoritative and quiet, evoking the intellectual rigor of a historical archive and the physical presence of iron and stone.
The design style is **Brutalist-Minimalism**. It utilizes hard edges, raw structural lines, and a deliberate absence of decorative embellishments like shadows or gradients. The visual language favors high-contrast layouts and generous negative space to elevate the subject matter—the transit and architecture of the capital—to a level of fine art.
## Colors
The palette is rooted in a "Warm Cream" canvas that provides a softer, more sophisticated background than pure white.
- **Deep Midnight Forest Green** serves as the primary ink, used for all headlines and foundational text to ensure maximum authority.
- **Warm Weathered Slate** is reserved for secondary information, metadata, and captions, providing a softer visual hierarchy.
- **Track Terracotta** is used sparingly as a singular accent for calls to action, drawing the eye to the primary interaction point on each panel.
## Typography
The typographic system relies on a sharp contrast between three distinct voices:
- **Headlines:** Set in a condensed, high-contrast serif (Newsreader). These must always be left-aligned to maintain a rigid vertical axis.
- **Body:** A humanist serif (Noto Serif) designed for long-form legibility. The generous 1.7 line height ensures a luxurious, breathable reading experience.
- **Labels:** A monospace font (Space Grotesk) used for administrative data, timestamps, and "eyebrow" text. The wide tracking is inspired by mechanical stopwatches and archival stamps.
## Layout & Spacing
The layout follows a strict asymmetrical split-grid model based on a 5-column system.
- **Split Ratio:** Content is divided into a 3/5 width for headlines and primary imagery, and a 2/5 width for descriptions, metadata, and CTAs.
- **The Grid:** A subtle canvas grid of thin ruled lines (0.5px Forest Green at 10% opacity) should be visible or implied, aligning all elements to a rigid structure.
- **Alignment:** Center-alignment is strictly prohibited. All elements must anchor to the left margin or the internal 3/5 split line.
- **Imagery:** Use full-bleed imagery that breaks through the margins to create a lookbook feel.
## Elevation & Depth
This design system avoids all drop shadows and blurs. Depth is achieved exclusively through **Tonal Variation** and layering.
Elements are perceived as being on different planes through the use of solid color blocks and ruled lines. To separate sections, use thin 1px horizontal rules in Primary Forest Green. High-priority panels may use a subtle shift in background color from the Neutral Cream to a slightly darker variant of the Secondary Slate at extremely low opacity (3-5%).
## Shapes
The shape language is defined by **Hard Right Angles**.
- **Border Radius:** All containers, buttons, and decorative elements must have a 0px radius.
- **Geometry:** Arcs and circles may be used for specific technical diagrams or "Track" iconography, but they must be precise, geometric, and never "organic" or "hand-drawn."
## Components
- **Buttons:** Rectangular with a 0px radius. Fill is Track Terracotta; text is Primary Forest Green. Each button is framed by a 1px outer ring in Primary Forest Green at 10% opacity, spaced 4px from the button edge. No icons are permitted within buttons.
- **Pull Quotes:** Set in the editorial headline serif. These feature a 2px solid vertical border in Track Terracotta on the left side only.
- **Track Labels:** Monospace "eyebrow" labels used above headlines. These should always be uppercase with 0.2em tracking.
- **Metadata Lists:** Key-value pairs (e.g., "STATION: UNION") set in the Secondary Slate color, using a mix of Monospace for keys and Humanist Serif for values.
- **Dividers:** 1px solid ruled lines. Use sparingly to define the 3/5 - 2/5 split or to separate editorial sections.
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { lint } from './index.js';
describe('Integration: full pipeline', () => {
it('processes a frontmatter DESIGN.md through the complete pipeline', () => {
const content = `---
name: Kindred Spirit
description: Describes the design system for a pet care assistant.
colors:
primary: "#647D66"
secondary: "#A3B8A5"
typography:
headline-lg:
fontFamily: Google Sans Display
fontSize: 42px
fontWeight: 500
lineHeight: 50px
letterSpacing: 1.2px
body-lg:
fontFamily: Roboto
fontSize: 14px
fontWeight: 400
lineHeight: 20px
letterSpacing: 1.2px
rounded:
regular: 4px
lg: 8px
xl: 12px
full: 9999px
spacing:
gutter-s: 8px
gutter-l: 16px
---
# Kindred Spirit Design System
The palette uses a deep "Evergreen" primary for health-sector credibility.
`;
const result = lint(content);
// ── State assertions ────────────────────────────────────────────
expect(result.designSystem.colors.size).toBe(2);
expect(result.designSystem.typography.size).toBe(2);
expect(result.designSystem.rounded.size).toBe(4);
expect(result.designSystem.spacing.size).toBe(2);
expect(result.designSystem.name).toBe('Kindred Spirit');
// ── Lint assertions ─────────────────────────────────────────────
expect(result.summary.errors).toBe(0);
// Should have at least the info summary
expect(result.summary.infos).toBeGreaterThan(0);
// ── Tailwind assertions ─────────────────────────────────────────
expect(result.tailwindConfig.success).toBe(true);
if (result.tailwindConfig.success) {
const config = result.tailwindConfig.data;
expect(config.theme?.extend?.colors?.['primary']).toBe('#647d66');
expect(config.theme?.extend?.fontFamily?.['headline-lg']).toContain('Google Sans Display');
expect(config.theme?.extend?.borderRadius?.['full']).toBe('9999px');
expect(config.theme?.extend?.spacing?.['gutter-s']).toBe('8px');
}
});
it('processes a code-block DESIGN.md with components', () => {
const content = `# Kindred Spirit
\`\`\`yaml
colors:
primary: "#647D66"
white: "#ffffff"
\`\`\`
\`\`\`yaml
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.white}"
rounded: 8px
padding: 12px
\`\`\`
`;
const result = lint(content);
expect(result.designSystem.colors.size).toBe(2);
expect(result.designSystem.components.size).toBe(1);
expect(result.summary.errors).toBe(0);
// The component should have resolved backgroundColor to the primary color
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
});
it('correctly detects errors in a broken DESIGN.md', () => {
const content = `---
colors:
primary: "#647D66"
bad-color: not-a-color
components:
card:
backgroundColor: "{colors.nonexistent}"
textColor: "#ffffff"
---`;
const result = lint(content);
// Should have errors: invalid color + broken reference
expect(result.summary.errors).toBeGreaterThanOrEqual(2);
});
it('warns on a misspelled top-level key via the default rule set', () => {
const content = `---
name: Example
colours:
primary: "#647D66"
---`;
const result = lint(content);
const finding = result.findings.find(
f => f.message === 'Unknown key "colours" — did you mean "colors"?'
);
expect(finding).toBeDefined();
expect(finding!.severity).toBe('warning');
expect(finding!.path).toBe('colours');
});
it('stays silent for custom extension keys that are not close to any known key', () => {
const content = `---
name: Example
icons:
search: "search-icon.svg"
motion:
fast: "100ms"
---`;
const result = lint(content);
const unknownKeyFindings = result.findings.filter(f =>
f.message.startsWith('Unknown key ')
);
expect(unknownKeyFindings).toEqual([]);
});
});
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ── Primary API ────────────────────────────────────────────────────
export { lint } from './lint.js';
export type { LintReport, LintOptions } from './lint.js';
// ── Result types ───────────────────────────────────────────────────
export type {
DesignSystemState,
ResolvedColor,
ResolvedDimension,
ResolvedTypography,
ResolvedValue,
ComponentDef,
} from './model/spec.js';
export type { Finding, Severity } from './linter/spec.js';
export type { TailwindEmitterResult, TailwindThemeExtend } from './tailwind/spec.js';
export type { TailwindV4EmitterResult, TailwindV4ThemeData } from './tailwind/v4/spec.js';
export type { DtcgEmitterResult, DtcgTokenFile } from './dtcg/spec.js';
export type { CssVarsEmitterResult, CssVarDeclaration } from './css-vars/spec.js';
// ── Advanced linting ───────────────────────────────────────────────
export { runLinter, preEvaluate } from './linter/runner.js';
export { DEFAULT_RULES } from './linter/rules/index.js';
export type { LintRule } from './linter/rules/types.js';
export type { GradedTokenEdits, TokenEditEntry } from './linter/spec.js';
export {
brokenRef,
missingPrimary,
contrastCheck,
orphanedTokens,
tokenSummary,
missingSections,
missingTypography,
unknownKey,
tokenLikeIgnored,
} from './linter/rules/index.js';
export { contrastRatio } from './model/handler.js';
export { TailwindEmitterHandler } from './tailwind/handler.js';
export { TailwindV4EmitterHandler } from './tailwind/v4/handler.js';
export { serializeToCss as serializeTailwindV4 } from './tailwind/v4/serialize.js';
export { DtcgEmitterHandler } from './dtcg/handler.js';
export { CssVarsEmitterHandler } from './css-vars/handler.js';
export { serializeCssVars } from './css-vars/serialize.js';
export { fixSectionOrder } from './fixer/handler.js';
export type { FixerInput, FixerResult } from './fixer/spec.js';
+150
View File
@@ -0,0 +1,150 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { ParserHandler } from './parser/handler.js';
import type { ParsedDesignSystem } from './parser/spec.js';
import { ModelHandler } from './model/handler.js';
import { runLinter } from './linter/runner.js';
import { TailwindEmitterHandler } from './tailwind/handler.js';
import type { DesignSystemState } from './model/spec.js';
import type { Finding } from './linter/spec.js';
import type { LintRule } from './linter/rules/types.js';
import type { TailwindEmitterResult } from './tailwind/spec.js';
export interface LintOptions {
/** Custom lint rules. Defaults to DEFAULT_RULES if omitted. */
rules?: LintRule[];
}
export interface LintReport {
/** The fully resolved design system model. */
designSystem: DesignSystemState;
/** All findings from the linter. */
findings: Finding[];
/** Aggregate counts by severity. */
summary: { errors: number; warnings: number; infos: number };
/** Generated Tailwind CSS theme configuration. */
tailwindConfig: TailwindEmitterResult;
/** Markdown heading names found in the document. */
sections: string[];
/** The partitioned document sections. */
documentSections: Array<{ heading: string; content: string }>;
}
/**
* Lint a DESIGN.md document.
*
* Parses the markdown, resolves all design tokens into a typed model,
* runs lint rules, and generates a Tailwind CSS theme configuration.
*
* @param content - Raw DESIGN.md content (markdown with YAML frontmatter or code blocks)
* @param options - Optional configuration (custom rules, etc.)
* @returns A LintReport with the resolved design system, findings, and Tailwind config
* @throws If parsing or model resolution fails unrecoverably
*/
export function lint(content: string, options?: LintOptions): LintReport {
const parser = new ParserHandler();
const model = new ModelHandler();
const tailwind = new TailwindEmitterHandler();
const parseResult = parser.execute({ content });
// Handle parse failures gracefully
if (!parseResult.success) {
// For recoverable errors (e.g. no YAML found), return a report
// with an empty design system and a finding instead of throwing.
if (parseResult.error.recoverable) {
const emptyParsed: ParsedDesignSystem = { sourceMap: new Map() };
const { designSystem } = model.execute(emptyParsed);
// Still extract sections from the raw content even without YAML
const sections = extractSectionsFromContent(content);
return {
designSystem,
findings: [{
severity: 'warning',
message: parseResult.error.message,
}],
summary: { errors: 0, warnings: 1, infos: 0 },
tailwindConfig: tailwind.execute(designSystem),
sections: sections.map(s => s.heading).filter(Boolean),
documentSections: sections,
};
}
// Non-recoverable errors are still fatal
throw new Error(`Parse failed: ${parseResult.error.message}`);
}
const { designSystem, findings: modelFindings } = model.execute(parseResult.data);
const lintResult = runLinter(designSystem, options?.rules);
const tailwindConfig = tailwind.execute(designSystem);
const findings = [...modelFindings, ...lintResult.findings];
const summary = {
errors: modelFindings.filter((d) => d.severity === 'error').length + lintResult.summary.errors,
warnings: modelFindings.filter((d) => d.severity === 'warning').length + lintResult.summary.warnings,
infos: modelFindings.filter((d) => d.severity === 'info').length + lintResult.summary.infos,
};
return {
designSystem,
findings,
summary,
tailwindConfig,
sections: parseResult.data.sections ?? [],
documentSections: parseResult.data.documentSections ?? [],
};
}
/**
* Extract document sections from raw markdown content by finding H2 headings.
* Used as a fallback when the parser cannot extract YAML.
*/
function extractSectionsFromContent(content: string): Array<{ heading: string; content: string }> {
const lines = content.split('\n');
const sections: Array<{ heading: string; content: string }> = [];
const headingPattern = /^## (.+)$/;
let currentStart = 0;
let currentHeading = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const match = headingPattern.exec(line);
if (match) {
// Push previous section
if (i > 0) {
sections.push({
heading: currentHeading,
content: lines.slice(currentStart, i).join('\n'),
});
}
currentHeading = match[1] ?? '';
currentStart = i;
}
}
// Push final section
sections.push({
heading: currentHeading,
content: lines.slice(currentStart).join('\n'),
});
return sections;
}
@@ -0,0 +1,222 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { LinterHandler } from './handler.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
import type { DesignSystemState } from '../model/spec.js';
import type { Finding } from './spec.js';
const linter = new LinterHandler();
const modelHandler = new ModelHandler();
/** Helper: parse → build model → return state */
function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('LinterHandler', () => {
// ── Cycle 15: E3 — Broken reference emits error ──────────────────
describe('E3: broken token reference', () => {
it('emits error when a component references a non-existent token', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: {
'button': {
backgroundColor: '{colors.nonexistent}',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.some((d: Finding) => d.message.includes('does not resolve'))).toBe(true);
});
});
// ── Cycle 16: E4 — Circular reference emits error ────────────────
describe('E4: circular reference', () => {
it('emits error when circular references are detected', () => {
const state = buildState({
colors: {
'a': '{colors.b}' as string,
'b': '{colors.a}' as string,
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.some((d: Finding) => d.message.toLowerCase().includes('unresolved') || d.message.toLowerCase().includes('resolve'))).toBe(true);
});
});
// ── Cycle 17: W1 — Missing primary emits warning ─────────────────
describe('W1: missing primary color', () => {
it('emits warning when no primary color is defined', () => {
const state = buildState({
colors: { accent: '#ff0000' },
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning');
expect(warnings.some((d: Finding) => d.message.includes('primary'))).toBe(true);
});
it('does NOT emit warning when primary color IS defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning' && d.message.includes('primary'));
expect(warnings.length).toBe(0);
});
});
// ── Cycle 18: W2 — Low contrast ratio emits warning ──────────────
describe('W2: WCAG contrast failure', () => {
it('emits warning for low contrast backgroundColor/textColor pair', () => {
const state = buildState({
colors: {
'yellow': '#ffff00',
'white': '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.yellow}',
textColor: '{colors.white}',
},
},
});
const result = linter.lint(state);
const warnings = result.findings.filter((d: Finding) => d.severity === 'warning');
expect(warnings.some((d: Finding) => d.message.includes('contrast'))).toBe(true);
});
it('does NOT emit warning for high contrast pair', () => {
const state = buildState({
colors: {
'black': '#000000',
'white': '#ffffff',
},
components: {
'button-good': {
backgroundColor: '{colors.black}',
textColor: '{colors.white}',
},
},
});
const result = linter.lint(state);
const contrastWarnings = result.findings.filter(
(d: Finding) => d.severity === 'warning' && d.message.includes('contrast')
);
expect(contrastWarnings.length).toBe(0);
});
});
// ── Cycle 19: I1 — Token count summary emits info ────────────────
describe('I1: token count summary', () => {
it('emits an info diagnostic summarizing the token counts', () => {
const state = buildState({
colors: { primary: '#ff0000', secondary: '#00ff00' },
typography: {
'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 },
},
rounded: { regular: '4px' },
spacing: { 'gutter-s': '8px' },
});
const result = linter.lint(state);
const infos = result.findings.filter((d: Finding) => d.severity === 'info');
expect(infos.some((d: Finding) => d.message.includes('2 color') && d.message.includes('1 typography'))).toBe(true);
});
});
// ── Cycle 20: Clean document produces zero errors ─────────────────
describe('clean document', () => {
it('produces zero errors for a valid design system', () => {
const state = buildState({
colors: { primary: '#647D66', secondary: '#ff0000' },
typography: {
'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500, lineHeight: '50px', letterSpacing: '1.2px' },
},
rounded: { regular: '4px', lg: '8px' },
spacing: { 'gutter-s': '8px', 'gutter-l': '16px' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
textColor: '#ffffff',
},
},
});
const result = linter.lint(state);
const errors = result.findings.filter((d: Finding) => d.severity === 'error');
expect(errors.length).toBe(0);
});
});
// ── Cycle 21: preEvaluate graded menu ─────────────────────────────
describe('preEvaluate graded menu', () => {
it('groups findings into fixes, improvements, and suggestions', () => {
const state = buildState({
colors: {
primary: '#647D66',
secondary: '#ffff00',
white: '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.secondary}',
textColor: '{colors.white}',
},
'button-broken': {
backgroundColor: '{colors.nonexistent}',
textColor: '{colors.white}',
}
},
});
const graded = linter.preEvaluate(state);
expect(graded.fixes.length).toBeGreaterThan(0);
expect(graded.improvements.length).toBeGreaterThan(0);
expect(graded.suggestions.length).toBeGreaterThan(0);
});
});
// ── Summary counts ───────────────────────────────────────────────
describe('summary counts', () => {
it('correctly counts errors, warnings, and infos', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: {
'card': { backgroundColor: '{colors.nonexistent}' }
}
});
const result = linter.lint(state);
expect(result.summary.errors).toBe(result.findings.filter((d: Finding) => d.severity === 'error').length);
expect(result.summary.warnings).toBe(result.findings.filter((d: Finding) => d.severity === 'warning').length);
expect(result.summary.infos).toBe(result.findings.filter((d: Finding) => d.severity === 'info').length);
});
});
});
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type {
LinterSpec,
LintResult,
GradedTokenEdits,
} from './spec.js';
import type { DesignSystemState } from '../model/spec.js';
import { runLinter, preEvaluate } from './runner.js';
/**
* @deprecated Use `runLinter()` and `preEvaluate()` from './runner.js' directly.
* This class exists only for backward compatibility.
*/
export class LinterHandler implements LinterSpec {
lint(state: DesignSystemState): LintResult {
return runLinter(state);
}
preEvaluate(state: DesignSystemState): GradedTokenEdits {
return preEvaluate(state);
}
}
@@ -0,0 +1,55 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { brokenRef, brokenRefRule } from './broken-ref.js';
import { buildState } from './test-helpers.js';
describe('brokenRef', () => {
it('emits error for unresolved token reference', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { backgroundColor: '{colors.nonexistent}' } },
});
const findings = brokenRef(state);
expect(findings.some(d => d.message.includes('does not resolve'))).toBe(true);
});
it('returns empty when all references resolve', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { backgroundColor: '{colors.primary}' } },
});
const errors = brokenRef(state).filter(d => d.message.includes('does not resolve'));
expect(errors.length).toBe(0);
});
it('emits warning (not error) for unknown component sub-tokens', () => {
const state = buildState({
colors: { primary: '#ff0000' },
components: { button: { borderColor: '#ff0000' } },
});
const findings = brokenRef(state);
const subTokenDiag = findings.find(d => d.message.includes('not a recognized'));
expect(subTokenDiag).toBeDefined();
expect(subTokenDiag!.severity).toBe('warning');
});
it('has a valid rule descriptor', () => {
expect(brokenRefRule.name).toBe('broken-ref');
expect(brokenRefRule.severity).toBe('error');
expect(brokenRefRule.description).toBeTruthy();
expect(brokenRefRule.run).toBe(brokenRef);
});
});
@@ -0,0 +1,52 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import { VALID_COMPONENT_SUB_TOKENS } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Broken/circular references and unknown component sub-tokens.
*/
export function brokenRef(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
for (const [compName, comp] of state.components) {
// Unresolved references
for (const ref of comp.unresolvedRefs) {
findings.push({
path: `components.${compName}`,
message: `Reference ${ref} does not resolve to any defined token.`,
});
}
// Unknown component sub-tokens (lower severity override)
for (const [propName] of comp.properties) {
if (!(VALID_COMPONENT_SUB_TOKENS as readonly string[]).includes(propName)) {
findings.push({
severity: 'warning',
path: `components.${compName}.${propName}`,
message: `'${propName}' is not a recognized component sub-token. Valid sub-tokens: ${VALID_COMPONENT_SUB_TOKENS.join(', ')}.`,
});
}
}
}
return findings;
}
export const brokenRefRule: RuleDescriptor = {
name: 'broken-ref',
severity: 'error',
description: 'Broken/circular references and unknown component sub-tokens.',
run: brokenRef,
};
@@ -0,0 +1,42 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { contrastCheck } from './contrast-ratio.js';
import { buildState } from './test-helpers.js';
describe('contrastCheck', () => {
it('emits warning for low contrast pair', () => {
const state = buildState({
colors: { yellow: '#ffff00', white: '#ffffff' },
components: {
'button-bad': { backgroundColor: '{colors.yellow}', textColor: '{colors.white}' },
},
});
const findings = contrastCheck(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/contrast/);
});
it('returns empty for high contrast pair', () => {
const state = buildState({
colors: { black: '#000000', white: '#ffffff' },
components: {
'button-good': { backgroundColor: '{colors.black}', textColor: '{colors.white}' },
},
});
const contrastWarnings = contrastCheck(state).filter(d => d.message.includes('contrast'));
expect(contrastWarnings.length).toBe(0);
});
});
@@ -0,0 +1,59 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState, ResolvedColor, ResolvedValue } from '../../model/spec.js';
import { contrastRatio } from '../../model/handler.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
const WCAG_AA_MINIMUM = 4.5;
/**
* WCAG contrast ratio — warns when component backgroundColor/textColor pairs
* fall below the AA minimum of 4.5:1.
*/
export function contrastCheck(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
for (const [compName, comp] of state.components) {
const bgValue = comp.properties.get('backgroundColor');
const textValue = comp.properties.get('textColor');
if (!bgValue || !textValue) continue;
const bgColor = resolveToColor(bgValue);
const textColor = resolveToColor(textValue);
if (!bgColor || !textColor) continue;
const ratio = contrastRatio(bgColor, textColor);
if (ratio < WCAG_AA_MINIMUM) {
findings.push({
path: `components.${compName}`,
message: `textColor (${textColor.hex}) on backgroundColor (${bgColor.hex}) has contrast ratio ${ratio.toFixed(2)}:1, below WCAG AA minimum of ${WCAG_AA_MINIMUM}:1.`,
});
}
}
return findings;
}
function resolveToColor(value: ResolvedValue): ResolvedColor | null {
if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'color') {
return value as ResolvedColor;
}
return null;
}
export const contrastCheckRule: RuleDescriptor = {
name: 'contrast-ratio',
severity: 'warning',
description: 'WCAG contrast ratio — warns when component backgroundColor/textColor pairs fall below the AA minimum of 4.5:1.',
run: contrastCheck,
};
@@ -0,0 +1,67 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { LintRule, RuleDescriptor } from './types.js';
import type { DesignSystemState } from '../../model/spec.js';
import type { Finding } from '../spec.js';
import { brokenRefRule } from './broken-ref.js';
import { missingPrimaryRule } from './missing-primary.js';
import { contrastCheckRule } from './contrast-ratio.js';
import { orphanedTokensRule } from './orphaned-tokens.js';
import { tokenSummaryRule } from './token-summary.js';
import { missingSectionsRule } from './missing-sections.js';
import { sectionOrderRule } from './section-order.js';
import { missingTypographyRule } from './missing-typography.js';
import { unknownKeyRule } from './unknown-key.js';
import { tokenLikeIgnoredRule } from './token-like-ignored.js';
/** The default set of lint rule descriptors, in order. */
export const DEFAULT_RULE_DESCRIPTORS: RuleDescriptor[] = [
brokenRefRule,
missingPrimaryRule,
contrastCheckRule,
orphanedTokensRule,
tokenSummaryRule,
missingSectionsRule,
missingTypographyRule,
sectionOrderRule,
unknownKeyRule,
tokenLikeIgnoredRule,
];
/** Converts a RuleDescriptor into a LintRule by injecting severity into findings. */
function toLintRule(descriptor: RuleDescriptor): LintRule {
return (state: DesignSystemState): Finding[] =>
descriptor.run(state).map(finding => ({
severity: finding.severity ?? descriptor.severity,
path: finding.path,
message: finding.message,
}));
}
/** The default set of lint rules, executed in order. */
export const DEFAULT_RULES: LintRule[] = DEFAULT_RULE_DESCRIPTORS.map(toLintRule);
// Re-export individual rules for selective composition
export { brokenRef } from './broken-ref.js';
export { missingPrimary } from './missing-primary.js';
export { contrastCheck } from './contrast-ratio.js';
export { orphanedTokens } from './orphaned-tokens.js';
export { tokenSummary } from './token-summary.js';
export { missingSections } from './missing-sections.js';
export { missingTypography } from './missing-typography.js';
export { unknownKey } from './unknown-key.js';
export { sectionOrder } from './section-order.js';
export { tokenLikeIgnored } from './token-like-ignored.js';
export type { LintRule } from './types.js';
@@ -0,0 +1,60 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { levenshtein } from './levenshtein.js';
describe('levenshtein', () => {
it('returns 0 for identical strings', () => {
expect(levenshtein('colors', 'colors')).toBe(0);
});
it('returns 0 when both strings are empty', () => {
expect(levenshtein('', '')).toBe(0);
});
it('returns the length of the other string when one is empty', () => {
expect(levenshtein('', 'colors')).toBe(6);
expect(levenshtein('colors', '')).toBe(6);
});
it('counts a single substitution as distance 1', () => {
expect(levenshtein('cat', 'bat')).toBe(1);
});
it('counts a single insertion as distance 1', () => {
expect(levenshtein('cat', 'cats')).toBe(1);
});
it('counts a single deletion as distance 1', () => {
expect(levenshtein('cats', 'cat')).toBe(1);
});
it('is symmetric: levenshtein(a, b) === levenshtein(b, a)', () => {
expect(levenshtein('typografy', 'typography')).toBe(levenshtein('typography', 'typografy'));
expect(levenshtein('kitten', 'sitting')).toBe(levenshtein('sitting', 'kitten'));
});
it('matches the classic kitten/sitting example (distance 3)', () => {
expect(levenshtein('kitten', 'sitting')).toBe(3);
});
it('computes distance for the schema-key typo cases used by unknown-key', () => {
expect(levenshtein('colours', 'colors')).toBe(1);
expect(levenshtein('typografy', 'typography')).toBe(2);
expect(levenshtein('nam', 'name')).toBe(1);
expect(levenshtein('rounding', 'rounded')).toBe(3);
expect(levenshtein('icons', 'colors')).toBe(4);
});
});
@@ -0,0 +1,30 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/** Levenshtein edit distance between two strings. */
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i]![j] = a[i - 1] === b[j - 1]
? dp[i - 1]![j - 1]!
: 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
}
}
return dp[m]![n]!;
}
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingPrimary } from './missing-primary.js';
import { buildState } from './test-helpers.js';
describe('missingPrimary', () => {
it('emits warning when colors exist but no primary', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const findings = missingPrimary(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/primary/);
});
it('returns empty when primary IS defined', () => {
const state = buildState({ colors: { primary: '#ff0000' } });
expect(missingPrimary(state)).toEqual([]);
});
it('returns empty when no colors defined', () => {
const state = buildState({});
expect(missingPrimary(state)).toEqual([]);
});
});
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing primary color — warns when colors are defined but no 'primary' exists.
*/
export function missingPrimary(state: DesignSystemState): RuleFinding[] {
if (state.colors.size > 0 && !state.colors.has('primary')) {
return [{
path: 'colors',
message: "No 'primary' color defined. The agent will auto-generate key colors, reducing your control over the palette.",
}];
}
return [];
}
export const missingPrimaryRule: RuleDescriptor = {
name: 'missing-primary',
severity: 'warning',
description: "Missing primary color — warns when colors are defined but no 'primary' exists.",
run: missingPrimary,
};
@@ -0,0 +1,45 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingSections } from './missing-sections.js';
import { buildState } from './test-helpers.js';
describe('missingSections', () => {
it('emits info when spacing is missing but colors exist', () => {
const state = buildState({
colors: { primary: '#ff0000' },
rounded: { regular: '4px' },
// no spacing
});
const findings = missingSections(state);
const spacingNote = findings.find(d => d.path === 'spacing');
expect(spacingNote).toBeDefined();
expect(spacingNote!.message).toMatch(/spacing/);
});
it('returns empty when all sections present', () => {
const state = buildState({
colors: { primary: '#ff0000' },
rounded: { regular: '4px' },
spacing: { unit: '8px' },
});
expect(missingSections(state)).toEqual([]);
});
it('returns empty when no colors exist (nothing to compare against)', () => {
const state = buildState({});
expect(missingSections(state)).toEqual([]);
});
});
@@ -0,0 +1,44 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing sections — notes when optional sections (spacing, rounded) are absent.
*/
export function missingSections(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const sections = [
{ map: state.spacing, name: 'spacing', fallback: 'Layout spacing will fall back to agent defaults.' },
{ map: state.rounded, name: 'rounded', fallback: 'Corner rounding will fall back to agent defaults.' },
];
for (const { map, name, fallback } of sections) {
if (map.size === 0 && state.colors.size > 0) {
findings.push({
path: name,
message: `No '${name}' section defined. ${fallback}`,
});
}
}
return findings;
}
export const missingSectionsRule: RuleDescriptor = {
name: 'missing-sections',
severity: 'info',
description: 'Missing sections — notes when optional sections (spacing, rounded) are absent.',
run: missingSections,
};
@@ -0,0 +1,48 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { missingTypography } from './missing-typography.js';
import { buildState } from './test-helpers.js';
describe('missingTypography', () => {
it('emits warning when colors exist but no typography defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
// no typography
});
const findings = missingTypography(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('typography');
expect(findings[0]!.message).toMatch(/typography/i);
});
it('returns empty when typography IS defined', () => {
const state = buildState({
colors: { primary: '#ff0000' },
typography: {
'body-md': {
fontFamily: 'Inter',
fontSize: '16px',
},
},
});
expect(missingTypography(state)).toEqual([]);
});
it('returns empty when no colors defined (nothing to compare against)', () => {
const state = buildState({});
expect(missingTypography(state)).toEqual([]);
});
});
@@ -0,0 +1,38 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Missing typography — warns when colors are defined but no typography tokens exist.
* Without typography tokens, agents will fall back to their own font choices,
* reducing the author's control over the design system's typographic identity.
*/
export function missingTypography(state: DesignSystemState): RuleFinding[] {
if (state.typography.size === 0 && state.colors.size > 0) {
return [{
path: 'typography',
message: "No typography tokens defined. Agents will use default font choices, reducing your control over the design system's typographic identity.",
}];
}
return [];
}
export const missingTypographyRule: RuleDescriptor = {
name: 'missing-typography',
severity: 'warning',
description: "Missing typography — warns when colors are defined but no typography tokens exist.",
run: missingTypography,
};
@@ -0,0 +1,135 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { orphanedTokens } from './orphaned-tokens.js';
import { buildState } from './test-helpers.js';
describe('orphanedTokens', () => {
it('emits warning for color not referenced by any component', () => {
const state = buildState({
colors: { primary: '#ff0000', unused: '#00ff00' },
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
const orphan = findings.find(d => d.message.includes('unused'));
expect(orphan).toBeDefined();
});
it('returns empty when no components exist', () => {
const state = buildState({ colors: { primary: '#ff0000' } });
expect(orphanedTokens(state)).toEqual([]);
});
it('does not flag MD3 paired tokens when the family is referenced (issue #46)', () => {
// When a component references `primary`, the rest of the MD3 primary
// family (`on-primary`, `primary-container`, `on-primary-container`,
// `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`,
// `on-primary-fixed-variant`, `inverse-primary`) is part of the same
// semantic group and should not be flagged as orphaned.
const state = buildState({
colors: {
primary: '#1A1C1E',
'on-primary': '#ffffff',
'primary-container': '#e2e2e2',
'on-primary-container': '#636565',
'primary-fixed': '#e2e2e2',
'primary-fixed-dim': '#c6c6c7',
'on-primary-fixed': '#1a1c1c',
'on-primary-fixed-variant': '#454747',
'inverse-primary': '#5d5f5f',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
it('does not flag MD3 surface family when one surface token is referenced', () => {
const state = buildState({
colors: {
surface: '#0b1326',
'surface-dim': '#0b1326',
'surface-bright': '#31394d',
'surface-container': '#171f33',
'surface-container-lowest': '#060e20',
'surface-container-low': '#131b2e',
'surface-container-high': '#222a3d',
'surface-container-highest': '#2d3449',
'on-surface': '#dae2fd',
'on-surface-variant': '#c4c7c8',
'inverse-surface': '#dae2fd',
'inverse-on-surface': '#283044',
'surface-tint': '#c6c6c7',
'surface-variant': '#2d3449',
},
components: {
card: { backgroundColor: '{colors.surface-container}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
it('still flags genuinely-orphaned custom tokens outside any referenced family', () => {
// `brand-blue` is not part of the MD3 primary family, so referencing
// `primary` does not save it.
const state = buildState({
colors: {
primary: '#1A1C1E',
'on-primary': '#ffffff',
'brand-blue': '#0000ff',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
const orphan = findings.find(d => d.path === 'colors.brand-blue');
expect(orphan).toBeDefined();
// And confirms `on-primary` does not get flagged just because it's not
// directly referenced.
expect(findings.find(d => d.path === 'colors.on-primary')).toBeUndefined();
});
it('does not flag MD3 baseline families even when no component references them', () => {
// The MD3 baseline colors (primary, secondary, tertiary, error, surface,
// background, outline) are part of the standard contract. A design system
// that ships them should not get warned for components that happen to
// not exercise the full palette in their canonical examples.
const state = buildState({
colors: {
primary: '#1A1C1E',
secondary: '#6C7278',
tertiary: '#B8422E',
error: '#B3261E',
'on-error': '#ffffff',
'error-container': '#F9DEDC',
background: '#fffbfe',
'on-background': '#1c1b1f',
outline: '#79747e',
'outline-variant': '#cac4d0',
},
components: {
button: { backgroundColor: '{colors.primary}' },
},
});
const findings = orphanedTokens(state);
expect(findings).toEqual([]);
});
});
@@ -0,0 +1,107 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Reduce a Material Design 3 color token name to its family root.
*
* Strips MD3 prefixes (`on-`, `inverse-`) and suffixes (`-container*`,
* `-fixed*`, `-dim`, `-bright`, `-tint`, `-variant`). Tokens that don't match
* any MD3 pattern collapse to their own name, which means custom tokens like
* `brand-blue` keep getting flagged when truly unused.
*/
function colorFamily(name: string): string {
let n = name;
// Prefixes. `inverse-on-surface` needs both passes of `on-` removal.
n = n.replace(/^on-/, '');
n = n.replace(/^inverse-/, '');
n = n.replace(/^on-/, '');
// Suffixes. Order matters: `-container-low` must collapse before `-low`
// becomes a candidate suffix.
n = n.replace(/-container.*$/, '');
n = n.replace(/-fixed.*$/, '');
n = n.replace(/-(dim|bright|tint|variant)$/, '');
return n;
}
/**
* Material Design 3 baseline color families. Tokens belonging to these
* families are part of the MD3 standard contract and are never flagged as
* orphaned, even if a given component set doesn't reference them. Custom
* tokens (e.g. `brand-blue`, `accent-magenta`) still get flagged when unused.
*/
const MD3_STANDARD_FAMILIES = new Set([
'primary',
'secondary',
'tertiary',
'error',
'surface',
'background',
'outline',
]);
/**
* Orphaned tokens — tokens defined but never referenced by any component or
* any sibling token in the same MD3 family.
*/
export function orphanedTokens(state: DesignSystemState): RuleFinding[] {
if (state.components.size === 0) return [];
const referencedPaths = new Set<string>();
for (const [, comp] of state.components) {
for (const [, value] of comp.properties) {
if (typeof value === 'object' && value !== null && 'type' in value) {
for (const [key, symValue] of state.symbolTable) {
if (symValue === value) {
referencedPaths.add(key);
}
}
}
}
}
// A component referencing one MD3 token implies its semantic siblings are
// part of the same in-use group (e.g. `primary` brings `on-primary`,
// `primary-container`, `inverse-primary`, etc.). Compute the set of
// referenced families so siblings don't get flagged as orphaned.
const referencedFamilies = new Set<string>();
for (const path of referencedPaths) {
if (path.startsWith('colors.')) {
referencedFamilies.add(colorFamily(path.slice('colors.'.length)));
}
}
const findings: RuleFinding[] = [];
for (const [name] of state.colors) {
const path = `colors.${name}`;
if (referencedPaths.has(path)) continue;
const family = colorFamily(name);
if (referencedFamilies.has(family)) continue;
if (MD3_STANDARD_FAMILIES.has(family)) continue;
findings.push({
path,
message: `'${name}' is defined but never referenced by any component.`,
});
}
return findings;
}
export const orphanedTokensRule: RuleDescriptor = {
name: 'orphaned-tokens',
severity: 'warning',
description: 'Orphaned tokens — tokens defined but never referenced by any component.',
run: orphanedTokens,
};
@@ -0,0 +1,119 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { sectionOrder, resolveAlias, SECTION_ALIASES } from './section-order.js';
import type { DesignSystemState } from '../../model/spec.js';
describe('sectionOrder', () => {
it('should warn when sections are out of order', () => {
const state = {
sections: ['Colors', 'Overview'], // Out of order!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toContain('out of order');
});
it('should not warn when sections are in order', () => {
const state = {
sections: ['Overview', 'Colors'], // In order!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should ignore unknown sections', () => {
const state = {
sections: ['Overview', 'Unknown', 'Colors'], // Unknown section in between
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Brand & Style" as "Overview"', () => {
const state = {
sections: ['Brand & Style', 'Colors', 'Typography'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Layout & Spacing" as "Layout"', () => {
const state = {
sections: ['Overview', 'Colors', 'Typography', 'Layout & Spacing'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should resolve "Elevation" as "Elevation & Depth"', () => {
const state = {
sections: ['Layout', 'Elevation'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
it('should detect out-of-order aliased sections', () => {
const state = {
sections: ['Colors', 'Brand & Style'], // Out of order via alias!
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toContain('out of order');
});
it('should handle mixed aliases and canonical names', () => {
const state = {
sections: ['Brand & Style', 'Colors', 'Typography', 'Layout & Spacing', 'Elevation & Depth', 'Shapes', 'Components'],
} as unknown as DesignSystemState;
const findings = sectionOrder(state);
expect(findings.length).toBe(0);
});
});
describe('resolveAlias', () => {
it('should resolve known aliases', () => {
expect(resolveAlias('Brand & Style')).toBe('Overview');
expect(resolveAlias('Layout & Spacing')).toBe('Layout');
expect(resolveAlias('Elevation')).toBe('Elevation & Depth');
});
it('should pass through canonical names unchanged', () => {
expect(resolveAlias('Overview')).toBe('Overview');
expect(resolveAlias('Colors')).toBe('Colors');
expect(resolveAlias('Elevation & Depth')).toBe('Elevation & Depth');
});
it('should pass through unknown names unchanged', () => {
expect(resolveAlias('Iconography')).toBe('Iconography');
});
});
@@ -0,0 +1,66 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import {
CANONICAL_ORDER,
SECTION_ALIASES,
resolveAlias,
} from '../../spec-config.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
// Re-export for consumers
export { CANONICAL_ORDER, SECTION_ALIASES, resolveAlias };
const ORDER_MAP = new Map(CANONICAL_ORDER.map((s, i) => [s, i]));
export function sectionOrder(state: DesignSystemState): RuleFinding[] {
const findings: RuleFinding[] = [];
const sections = state.sections ?? [];
if (sections.length === 0) return findings;
// Resolve aliases, then filter to known sections for order checking
const knownSections = sections
.map(resolveAlias)
.filter(s => ORDER_MAP.has(s));
for (let i = 0; i < knownSections.length - 1; i++) {
const current = knownSections[i];
const next = knownSections[i + 1];
if (!current || !next) continue;
const currentIdx = ORDER_MAP.get(current);
const nextIdx = ORDER_MAP.get(next);
if (currentIdx === undefined || nextIdx === undefined) continue;
if (currentIdx > nextIdx) {
findings.push({
message: `Section '${current}' appears before '${next}', which is out of order. Expected order: ${CANONICAL_ORDER.join(', ')}`
});
break;
}
}
return findings;
}
export const sectionOrderRule: RuleDescriptor = {
name: 'section-order',
severity: 'warning',
description: 'Section order — warns when sections are out of canonical order.',
run: sectionOrder,
};
@@ -0,0 +1,36 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* Shared test helper for rule unit tests.
* Builds a DesignSystemState from parsed overrides, reusing the ModelHandler.
*/
import { ModelHandler } from '../../model/handler.js';
import type { ParsedDesignSystem } from '../../parser/spec.js';
import type { DesignSystemState } from '../../model/spec.js';
let modelHandler: ModelHandler | undefined;
export function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
if (!modelHandler) {
modelHandler = new ModelHandler();
}
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
@@ -0,0 +1,133 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { tokenLikeIgnored } from './token-like-ignored.js';
import { buildState } from './test-helpers.js';
import type { SourceLocation } from '../../parser/spec.js';
const loc: SourceLocation = { line: 1, column: 0, block: 'frontmatter' };
describe('tokenLikeIgnored', () => {
it('warns when an unknown key has hex color leaf values', () => {
const state = buildState({
sourceMap: new Map([['base_colors', loc]]),
rawValues: { base_colors: { ink: '#0B0F14', mist: '#F5F7FA' } },
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('base_colors');
expect(findings[0]!.message).toContain('"base_colors"');
expect(findings[0]!.message).toContain('silently ignored by export');
});
it('warns when an unknown key has a fontFamily property', () => {
const state = buildState({
sourceMap: new Map([['brand_type', loc]]),
rawValues: {
brand_type: { heading: { fontFamily: 'Inter', fontSize: '32px' } },
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('brand_type');
});
it('warns when an unknown key has CSS dimension leaf values', () => {
const state = buildState({
sourceMap: new Map([['semantic_spacing', loc]]),
rawValues: { semantic_spacing: { sm: '4px', md: '8px', lg: '16px' } },
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('semantic_spacing');
});
it('stays silent when an unknown key has a flat string value (not a map)', () => {
const state = buildState({
sourceMap: new Map([['theme', loc]]),
rawValues: { theme: 'dark' },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent when an unknown key has a number value', () => {
const state = buildState({
sourceMap: new Map([['version_major', loc]]),
rawValues: { version_major: 2 },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent when an unknown key has a non-token-like object (no hex, no dimension, no typo prop)', () => {
const state = buildState({
sourceMap: new Map([['meta', loc]]),
rawValues: { meta: { author: 'Jane', created: '2026-01-01' } },
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('stays silent for all recognized schema keys', () => {
const state = buildState({
sourceMap: new Map([
['version', loc],
['name', loc],
['colors', loc],
['typography', loc],
['spacing', loc],
['rounded', loc],
['components', loc],
]),
});
expect(tokenLikeIgnored(state)).toEqual([]);
});
it('emits one finding per token-like unknown key', () => {
const state = buildState({
sourceMap: new Map([
['base_colors', loc],
['semantic_colors', loc],
['meta', loc],
]),
rawValues: {
base_colors: { primary: '#1A73E8' },
semantic_colors: { success: '#34A853' },
meta: { owner: 'design-team' },
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(2);
expect(findings.map(f => f.path).sort()).toEqual(['base_colors', 'semantic_colors']);
});
it('warns on nested token maps', () => {
const state = buildState({
sourceMap: new Map([['palette', loc]]),
rawValues: {
palette: {
light: { brand: '#4A90E2', surface: '#FFFFFF' },
dark: { brand: '#82B1FF', surface: '#121212' },
},
},
});
const findings = tokenLikeIgnored(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('palette');
});
it('returns empty when there are no unknown keys', () => {
const state = buildState({});
expect(tokenLikeIgnored(state)).toEqual([]);
});
});
@@ -0,0 +1,108 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* CSS hex color pattern: #RGB, #RGBA, #RRGGBB, or #RRGGBBAA.
*/
const HEX_COLOR_RE = /^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
/**
* CSS dimension pattern: an optional sign, digits, and a CSS unit suffix.
*/
const CSS_DIMENSION_RE = /^-?\d*\.?\d+[a-zA-Z%]+$/;
/**
* Upper bound on a token-like leaf value's length before pattern matching.
* A hex color or CSS dimension is short; longer strings cannot match either,
* so capping the length avoids pathological regex backtracking on oversized
* attacker-supplied values.
*/
const MAX_TOKEN_VALUE_LENGTH = 64;
/**
* Typography-flavored property names that strongly suggest this map holds
* design tokens rather than arbitrary metadata.
*/
const TYPOGRAPHY_PROPS = new Set(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']);
/**
* Determine whether a plain object looks like a design-token map.
*
* A map is "token-like" when at least one leaf value is a hex color string or
* a CSS dimension string, OR at least one key is a well-known typography
* property name. Flat scalars (strings, numbers) and non-object values are
* never token-like on their own — the value must be an object.
*/
function isTokenLikeMap(value: unknown): boolean {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const obj = value as Record<string, unknown>;
return hasTokenLikeContent(obj);
}
function hasTokenLikeContent(obj: Record<string, unknown>): boolean {
for (const [key, val] of Object.entries(obj)) {
// Typography property key is itself a signal.
if (TYPOGRAPHY_PROPS.has(key)) return true;
if (typeof val === 'string') {
if (val.length <= MAX_TOKEN_VALUE_LENGTH && (HEX_COLOR_RE.test(val) || CSS_DIMENSION_RE.test(val))) return true;
} else if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
// Recurse one level for nested token maps (e.g. base_colors: { light: { ink: "#0B0F14" } })
if (hasTokenLikeContent(val as Record<string, unknown>)) return true;
}
}
return false;
}
/**
* Token-like ignored keys — warns when a top-level YAML key is not part of
* the recognized export schema and its value looks like a design-token map.
* These values will be silently dropped by `design.md export`.
*/
export function tokenLikeIgnored(state: DesignSystemState): RuleFinding[] {
const unknownKeys = state.unknownKeys ?? [];
const unknownKeyValues = state.unknownKeyValues ?? {};
const findings: RuleFinding[] = [];
for (const key of unknownKeys) {
const value = unknownKeyValues[key];
if (isTokenLikeMap(value)) {
findings.push({
path: key,
message:
`"${key}" looks like a design-token map but is not a recognized schema key ` +
`(colors, typography, spacing, rounded, components). ` +
`It will be silently ignored by export commands. ` +
`Rename it to a supported key or move its values under a recognized section.`,
});
}
}
return findings;
}
export const tokenLikeIgnoredRule: RuleDescriptor = {
name: 'token-like-ignored',
severity: 'warning',
description:
'Warns when a top-level YAML key looks like a design-token map but is not ' +
'part of the recognized export schema and will be silently ignored.',
run: tokenLikeIgnored,
};
@@ -0,0 +1,37 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { tokenSummary } from './token-summary.js';
import { buildState } from './test-helpers.js';
describe('tokenSummary', () => {
it('emits info diagnostic with token counts', () => {
const state = buildState({
colors: { primary: '#ff0000', secondary: '#00ff00' },
typography: { 'headline-lg': { fontFamily: 'Roboto', fontSize: '42px', fontWeight: 500 } },
rounded: { regular: '4px' },
spacing: { 'gutter-s': '8px' },
});
const findings = tokenSummary(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toMatch(/2 colors/);
expect(findings[0]!.message).toMatch(/1 typography/);
});
it('returns empty for completely empty state', () => {
const state = buildState({});
expect(tokenSummary(state)).toEqual([]);
});
});
@@ -0,0 +1,43 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
/**
* Token count summary — emits an info diagnostic summarizing how many
* tokens are defined in each section.
*/
export function tokenSummary(state: DesignSystemState): RuleFinding[] {
const parts: string[] = [];
if (state.colors.size > 0) parts.push(`${state.colors.size} color${state.colors.size !== 1 ? 's' : ''}`);
if (state.typography.size > 0) parts.push(`${state.typography.size} typography scale${state.typography.size !== 1 ? 's' : ''}`);
if (state.rounded.size > 0) parts.push(`${state.rounded.size} rounding level${state.rounded.size !== 1 ? 's' : ''}`);
if (state.spacing.size > 0) parts.push(`${state.spacing.size} spacing token${state.spacing.size !== 1 ? 's' : ''}`);
if (state.components.size > 0) parts.push(`${state.components.size} component${state.components.size !== 1 ? 's' : ''}`);
if (parts.length > 0) {
return [{
message: `Design system defines ${parts.join(', ')}.`,
}];
}
return [];
}
export const tokenSummaryRule: RuleDescriptor = {
name: 'token-summary',
severity: 'info',
description: 'Token count summary — emits an info diagnostic summarizing how many tokens are defined.',
run: tokenSummary,
};
@@ -0,0 +1,51 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import type { LintRule, RuleDescriptor } from './types.js';
import { DEFAULT_RULE_DESCRIPTORS } from './index.js';
describe('LintRule type', () => {
it('accepts a function that takes state and returns findings', () => {
const rule: LintRule = (_state) => [];
expect(rule({
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
})).toEqual([]);
});
it('accepts a RuleDescriptor object', () => {
const descriptor: RuleDescriptor = {
name: 'test-rule',
severity: 'info',
description: 'Test description',
run: (_state: any) => [],
};
expect(descriptor.name).toBe('test-rule');
});
it('has all rules in DEFAULT_RULE_DESCRIPTORS', () => {
expect(DEFAULT_RULE_DESCRIPTORS.length).toBe(10);
DEFAULT_RULE_DESCRIPTORS.forEach((rule: RuleDescriptor) => {
expect(rule.name).toBeTruthy();
expect(rule.severity).toBeTruthy();
expect(rule.description).toBeTruthy();
expect(rule.run).toBeTruthy();
});
});
});
@@ -0,0 +1,34 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../../model/spec.js';
import type { Finding, Severity } from '../spec.js';
/** A finding emitted by a rule — severity is injected by the descriptor. */
export interface RuleFinding {
path?: string;
message: string;
/** Optional override of the descriptor's default severity. */
severity?: Severity;
}
/** A pure lint rule: takes immutable state, returns findings. No side effects. */
export type LintRule = (state: DesignSystemState) => Finding[];
export interface RuleDescriptor {
name: string;
severity: Severity;
description: string;
run: (state: DesignSystemState) => RuleFinding[];
}
@@ -0,0 +1,122 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { unknownKey } from './unknown-key.js';
import { buildState } from './test-helpers.js';
import type { SourceLocation } from '../../parser/spec.js';
const loc: SourceLocation = { line: 1, column: 0, block: 'frontmatter' };
describe('unknownKey', () => {
it('warns and suggests "colors" for "colours" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([
['name', loc],
['colours', loc],
]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.path).toBe('colours');
expect(findings[0]!.message).toBe('Unknown key "colours" — did you mean "colors"?');
});
it('warns and suggests "typography" for "typografy" (distance 2)', () => {
const state = buildState({
sourceMap: new Map([['typografy', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "typografy" — did you mean "typography"?');
});
it('warns and suggests "name" for "nam" (distance 1)', () => {
const state = buildState({
sourceMap: new Map([['nam', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "nam" — did you mean "name"?');
});
it('matches case-insensitively (e.g. "Colors" is treated as known)', () => {
const state = buildState({
sourceMap: new Map([['Colors', loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(1);
expect(findings[0]!.message).toBe('Unknown key "Colors" — did you mean "colors"?');
});
it('stays silent for far-from-any-key extension keys', () => {
const state = buildState({
sourceMap: new Map([
['icons', loc],
['motion', loc],
['brand', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});
it('stays silent for "rounding" (distance 3 from "rounded")', () => {
const state = buildState({
sourceMap: new Map([['rounding', loc]]),
});
expect(unknownKey(state)).toEqual([]);
});
it('returns empty when all top-level keys are known', () => {
const state = buildState({
sourceMap: new Map([
['version', loc],
['name', loc],
['description', loc],
['colors', loc],
['typography', loc],
['rounded', loc],
['spacing', loc],
['components', loc],
]),
});
expect(unknownKey(state)).toEqual([]);
});
it('returns empty when there are no top-level keys', () => {
const state = buildState({});
expect(unknownKey(state)).toEqual([]);
});
it('emits one finding per misspelled key and ignores unrelated extension keys', () => {
const state = buildState({
sourceMap: new Map([
['colors', loc],
['colours', loc],
['typografy', loc],
['icons', loc],
]),
});
const findings = unknownKey(state);
expect(findings.map(f => f.path).sort()).toEqual(['colours', 'typografy']);
});
it('stays silent (and cheap) for very long keys via the length short-circuit', () => {
const state = buildState({
sourceMap: new Map([['a'.repeat(50000), loc]]),
});
const findings = unknownKey(state);
expect(findings.length).toBe(0);
});
});
@@ -0,0 +1,65 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { SCHEMA_KEYS } from '../../parser/spec.js';
import type { DesignSystemState } from '../../model/spec.js';
import type { RuleDescriptor, RuleFinding } from './types.js';
import { levenshtein } from './levenshtein.js';
/** Max edit distance to consider a typo (not a custom key). */
const MAX_TYPO_DISTANCE = 2;
/**
* Unknown key — warns when a top-level YAML key looks like a typo of a known
* schema key. The DESIGN.md schema is intentionally extensible (custom keys
* are allowed), so only close matches to known keys are reported; unrelated
* extension keys stay silent.
*/
export function unknownKey(state: DesignSystemState): RuleFinding[] {
const knownSet = new Set<string>(SCHEMA_KEYS);
return (state.unknownKeys ?? []).flatMap(key => {
if (knownSet.has(key)) return [];
let bestMatch: string | undefined;
let bestDist = Infinity;
for (const known of SCHEMA_KEYS) {
// Edit distance is at least the length difference, so a key whose length
// differs from a known key by more than the typo threshold can never be a
// typo — skip the O(n*m) distance computation for it. This keeps the rule
// cheap on long, attacker-supplied keys without changing any result.
if (Math.abs(key.length - known.length) > MAX_TYPO_DISTANCE) continue;
const dist = levenshtein(key.toLowerCase(), known.toLowerCase());
if (dist < bestDist) {
bestDist = dist;
bestMatch = known;
}
}
if (bestDist <= MAX_TYPO_DISTANCE && bestMatch) {
return [{
path: key,
message: `Unknown key "${key}" — did you mean "${bestMatch}"?`,
}];
}
return [];
});
}
export const unknownKeyRule: RuleDescriptor = {
name: 'unknown-key',
severity: 'warning',
description: 'Unknown key — warns when a top-level YAML key looks like a typo of a known schema key.',
run: unknownKey,
};
@@ -0,0 +1,94 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { runLinter, preEvaluate } from './runner.js';
import { missingPrimaryRule } from './rules/missing-primary.js';
import { tokenSummaryRule } from './rules/token-summary.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
import type { DesignSystemState } from '../model/spec.js';
const modelHandler = new ModelHandler();
function buildState(overrides: Partial<ParsedDesignSystem> = {}): DesignSystemState {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
describe('runLinter', () => {
it('runs default rules when none specified', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state);
// Should have at least a warning (missing primary) and an info (summary)
expect(result.summary.warnings).toBeGreaterThan(0);
expect(result.summary.infos).toBeGreaterThan(0);
});
it('runs only the specified subset of rules', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state, [missingPrimaryRule]);
// Only the missing primary warning — no summary info
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.message).toMatch(/primary/);
expect(result.summary.warnings).toBe(1);
expect(result.summary.infos).toBe(0);
});
it('returns empty findings for empty rules array', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const result = runLinter(state, []);
expect(result.findings).toEqual([]);
expect(result.summary).toEqual({ errors: 0, warnings: 0, infos: 0 });
});
});
describe('preEvaluate', () => {
it('groups findings into fixes, improvements, and suggestions', () => {
const state = buildState({
colors: {
primary: '#647D66',
secondary: '#ffff00',
white: '#ffffff',
},
components: {
'button-bad': {
backgroundColor: '{colors.secondary}',
textColor: '{colors.white}',
},
'button-broken': {
backgroundColor: '{colors.nonexistent}',
textColor: '{colors.white}',
}
},
});
const graded = preEvaluate(state);
expect(graded.fixes.length).toBeGreaterThan(0); // error: broken ref
expect(graded.improvements.length).toBeGreaterThan(0); // warning: contrast
expect(graded.suggestions.length).toBeGreaterThan(0); // info: summary
});
it('accepts custom rules', () => {
const state = buildState({ colors: { accent: '#ff0000' } });
const graded = preEvaluate(state, [tokenSummaryRule]);
expect(graded.fixes).toEqual([]);
expect(graded.improvements).toEqual([]);
expect(graded.suggestions.length).toBe(1);
});
});
+70
View File
@@ -0,0 +1,70 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../model/spec.js';
import type { LintResult, Finding, GradedTokenEdits, TokenEditEntry } from './spec.js';
import type { LintRule, RuleDescriptor } from './rules/types.js';
import { DEFAULT_RULES, DEFAULT_RULE_DESCRIPTORS } from './rules/index.js';
/** Type guard: checks if the array contains RuleDescriptors (objects with `run`). */
function isDescriptorArray(rules: LintRule[] | RuleDescriptor[]): rules is RuleDescriptor[] {
return rules.length > 0 && typeof rules[0] === 'object' && 'run' in rules[0];
}
/**
* Pure functional linter runner.
* Executes each rule against the state and aggregates findings.
*/
export function runLinter(
state: DesignSystemState,
rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES,
): LintResult {
const findings: Finding[] = isDescriptorArray(rules)
? rules.flatMap(desc => desc.run(state).map(f => ({
severity: f.severity ?? desc.severity,
path: f.path,
message: f.message,
})))
: rules.flatMap(rule => rule(state));
return {
findings,
summary: {
errors: findings.filter(d => d.severity === 'error').length,
warnings: findings.filter(d => d.severity === 'warning').length,
infos: findings.filter(d => d.severity === 'info').length,
},
};
}
/**
* Groups lint findings into a graded edit menu (fixes / improvements / suggestions).
*/
export function preEvaluate(
state: DesignSystemState,
rules: LintRule[] | RuleDescriptor[] = DEFAULT_RULES,
): GradedTokenEdits {
const { findings } = runLinter(state, rules);
const fixes: TokenEditEntry[] = [];
const improvements: TokenEditEntry[] = [];
const suggestions: TokenEditEntry[] = [];
for (const d of findings) {
const entry: TokenEditEntry = { path: d.path ?? '', findings: [d] };
if (d.severity === 'error') fixes.push(entry);
else if (d.severity === 'warning') improvements.push(entry);
else suggestions.push(entry);
}
return { fixes, improvements, suggestions };
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { DesignSystemState } from '../model/spec.js';
export type { Finding, Severity } from '../model/spec.js';
import type { Finding } from '../model/spec.js';
// ── LINT RESULT ────────────────────────────────────────────────────
export interface LintResult {
findings: Finding[];
summary: {
errors: number;
warnings: number;
infos: number;
};
}
// ── GRADED TOKEN EDITS ─────────────────────────────────────────────
export interface GradedTokenEdits {
/** Edits that fix errors (highest priority) */
fixes: TokenEditEntry[];
/** Edits that resolve warnings */
improvements: TokenEditEntry[];
/** Edits that are purely additive / informational */
suggestions: TokenEditEntry[];
}
export interface TokenEditEntry {
path: string;
currentValue?: string;
suggestedValue?: string;
findings: Finding[];
}
// ── INTERFACE ──────────────────────────────────────────────────────
export interface LinterSpec {
lint(state: DesignSystemState): LintResult;
preEvaluate(state: DesignSystemState): GradedTokenEdits;
}
@@ -0,0 +1,595 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export interface ParsedColorResult {
hex: string;
r: number;
g: number;
b: number;
a?: number;
luminance: number;
}
const CSS_NAMED_COLORS: Record<string, string> = {
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', azure: '#f0ffff',
beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', blanchedalmond: '#ffebcd', blue: '#0000ff',
blueviolet: '#8a2be2', brown: '#a52a2a', burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00',
chocolate: '#d2691e', coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', darkgray: '#a9a9a9',
darkgrey: '#a9a9a9', darkgreen: '#006400', darkkhaki: '#bdb76b', darkmagenta: '#8b008b', darkolivegreen: '#556b2f',
darkorange: '#ff8c00', darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b', darkslategrey: '#2f4f4f', darkslategray: '#2f4f4f', darkturquoise: '#00ced1',
darkviolet: '#9400d3', deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22', fuchsia: '#ff00ff',
gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080',
grey: '#808080', green: '#008000', greenyellow: '#adff2f', honeydew: '#f0fff0', hotpink: '#ff69b4',
indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080',
lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', lightgrey: '#d3d3d3', lightgreen: '#90ee90',
lightpink: '#ffb6c1', lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslate: '#778899',
lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00',
limegreen: '#32cd32', linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000',
mediumaquamarine: '#66cdaa', mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970',
mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5', navajowhite: '#ffdead', navy: '#000080',
oldlace: '#fdf5e6', olive: '#808000', olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500',
orchid: '#da70d6', palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb', plum: '#dda0dd',
powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399', red: '#ff0000', rosybrown: '#bc8f8f',
royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57',
seashell: '#fff5ee', sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd',
slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f', steelblue: '#4682b4',
tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', tomato: '#ff6347', turquoise: '#40e0d0',
violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00',
yellowgreen: '#9acd32', transparent: '#00000000',
};
/**
* Maximum nesting depth for recursive color-mix() resolution. Guards against
* stack exhaustion from pathologically nested, attacker-supplied color values.
*/
const MAX_COLOR_MIX_DEPTH = 32;
/**
* Parse a CSS color string into its sRGB representation + WCAG relative luminance.
* Returns null if the color is invalid.
*/
export function parseCssColor(colorStr: string, depth = 0): ParsedColorResult | null {
if (typeof colorStr !== 'string') return null;
if (depth > MAX_COLOR_MIX_DEPTH) return null;
const clean = colorStr.trim().toLowerCase();
if (!clean) return null;
// 1. Hex Color Pattern
if (clean.startsWith('#')) {
if (!/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(clean)) {
return null;
}
return parseHex(clean);
}
// 2. Named Colors lookup
if (Object.prototype.hasOwnProperty.call(CSS_NAMED_COLORS, clean)) {
return parseHex(CSS_NAMED_COLORS[clean]!);
}
// 3. Functional notations parse
const parsedFunc = tokenizeFunc(clean);
if (!parsedFunc) {
return null;
}
const { name, args } = parsedFunc;
switch (name) {
case 'rgb':
case 'rgba': {
// Supports rgb(255, 0, 0) and rgb(255 0 0 / 0.5)
// args could be: [r, g, b] or [r, g, b, a]
if (args.length !== 3 && args.length !== 4) return null;
const rRaw = parsePercentOrNumber(args[0]!, 255);
const gRaw = parsePercentOrNumber(args[1]!, 255);
const bRaw = parsePercentOrNumber(args[2]!, 255);
const aVal = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(rRaw) || isNaN(gRaw) || isNaN(bRaw) || isNaN(aVal)) return null;
const r = Math.max(0, Math.min(255, Math.round(rRaw)));
const g = Math.max(0, Math.min(255, Math.round(gRaw)));
const b = Math.max(0, Math.min(255, Math.round(bRaw)));
const a = Math.max(0, Math.min(1, aVal));
return makeResult(r, g, b, a);
}
case 'hsl':
case 'hsla': {
// Supports hsl(120, 100%, 50%) and hsl(120deg 100% 50% / 0.5)
if (args.length !== 3 && args.length !== 4) return null;
const h = parseHue(args[0]!);
const s = parsePercentOrNumber(args[1]!, 1);
const l = parsePercentOrNumber(args[2]!, 1);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(h) || isNaN(s) || isNaN(l) || isNaN(a)) return null;
const rgb = hslToRgb(h, s, l);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'hwb': {
// Supports hwb(120 0% 0% / 0.5)
if (args.length !== 3 && args.length !== 4) return null;
const h = parseHue(args[0]!);
const w = parsePercentOrNumber(args[1]!, 1);
const b = parsePercentOrNumber(args[2]!, 1);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(h) || isNaN(w) || isNaN(b) || isNaN(a)) return null;
const rgb = hwbToRgb(h, w, b);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'lab': {
// lab(l a b / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 100); // L is typically 0-100
const aVal = parseFloat(args[1]!);
const bVal = parseFloat(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(aVal) || isNaN(bVal) || isNaN(a)) return null;
const rgb = labToRgb(l, aVal, bVal);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'lch': {
// lch(l c h / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 100);
const c = parseFloat(args[1]!);
const h = parseHue(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(c) || isNaN(h) || isNaN(a)) return null;
const rgb = lchToRgb(l, c, h);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'oklab': {
// oklab(l a b / alpha) - L is 0-1 or 0%-100%
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 1);
const aVal = parseFloat(args[1]!);
const bVal = parseFloat(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(aVal) || isNaN(bVal) || isNaN(a)) return null;
const rgb = oklabToRgb(l, aVal, bVal);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'oklch': {
// oklch(l c h / alpha)
if (args.length !== 3 && args.length !== 4) return null;
const l = parsePercentOrNumber(args[0]!, 1);
const c = parseFloat(args[1]!);
const h = parseHue(args[2]!);
const a = args.length === 4 ? parseAlpha(args[3]) : 1;
if (isNaN(l) || isNaN(c) || isNaN(h) || isNaN(a)) return null;
const rgb = oklchToRgb(l, c, h);
return makeResult(rgb.r, rgb.g, rgb.b, a);
}
case 'color-mix': {
// color-mix(in srgb, color1 percentage, color2 percentage)
// Let's split the inner tokens by comma at depth 0
const subArgs = splitByComma(clean.slice(10, -1));
if (subArgs.length !== 3) return null;
// SubArg 0: color space (e.g. "in srgb")
const spaceTokens = subArgs[0]!.trim().split(/\s+/);
if (spaceTokens.length !== 2 || spaceTokens[0] !== 'in' || spaceTokens[1] !== 'srgb') {
return null; // We only support "in srgb" blending standard
}
// SubArg 1: "<color1> [weight1]"
// SubArg 2: "<color2> [weight2]"
const parsed1 = parseColorWithWeight(subArgs[1]!);
const parsed2 = parseColorWithWeight(subArgs[2]!);
if (!parsed1 || !parsed2) return null;
const c1 = parseCssColor(parsed1.colorStr, depth + 1);
const c2 = parseCssColor(parsed2.colorStr, depth + 1);
if (!c1 || !c2) return null;
// Normalize weights
let w1 = parsed1.weight;
let w2 = parsed2.weight;
if (w1 === null && w2 === null) {
w1 = 50;
w2 = 50;
} else if (w1 !== null && w2 === null) {
w2 = 100 - w1;
} else if (w2 !== null && w1 === null) {
w1 = 100 - w2;
} else if (w1 !== null && w2 !== null) {
const sum = w1 + w2;
if (sum === 0) return null;
// Scale to sum to 100
w1 = (w1 / sum) * 100;
w2 = (w2 / sum) * 100;
}
// Convert weight to 0-1 range
const f1 = w1! / 100;
const f2 = w2! / 100;
// Blend components with premultiplied alpha
const a1 = c1.a !== undefined ? c1.a : 1;
const a2 = c2.a !== undefined ? c2.a : 1;
const aMix = a1 * f1 + a2 * f2;
let r = 0, g = 0, b = 0;
if (aMix > 0) {
r = Math.round((c1.r * a1 * f1 + c2.r * a2 * f2) / aMix);
g = Math.round((c1.g * a1 * f1 + c2.g * a2 * f2) / aMix);
b = Math.round((c1.b * a1 * f1 + c2.b * a2 * f2) / aMix);
}
return makeResult(
Math.max(0, Math.min(255, r)),
Math.max(0, Math.min(255, g)),
Math.max(0, Math.min(255, b)),
Math.max(0, Math.min(1, aMix))
);
}
default:
return null;
}
}
// ── Parsing internal utilities ─────────────────────────────────────
function parseHex(hexStr: string): ParsedColorResult {
let hex = hexStr;
if (hex.length === 4) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
} else if (hex.length === 5) {
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}`;
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
let a: number | undefined;
if (hex.length === 9) {
a = parseInt(hex.slice(7, 9), 16) / 255;
}
return makeResult(r, g, b, a);
}
function makeResult(r: number, g: number, b: number, a?: number): ParsedColorResult {
// Compute hex string
const hexR = r.toString(16).padStart(2, '0');
const hexG = g.toString(16).padStart(2, '0');
const hexB = b.toString(16).padStart(2, '0');
let hex = `#${hexR}${hexG}${hexB}`;
if (a !== undefined && a < 1) {
const hexA = Math.round(a * 255).toString(16).padStart(2, '0');
hex += hexA;
}
const luminance = computeLuminance(r, g, b);
return { hex, r, g, b, a, luminance };
}
function computeLuminance(r: number, g: number, b: number): number {
const linearize = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}
function parseHue(s: string): number {
const lower = s.trim().toLowerCase();
let val = 0;
if (lower.endsWith('deg')) {
val = parseFloat(lower.slice(0, -3));
} else if (lower.endsWith('grad')) {
val = (parseFloat(lower.slice(0, -4)) * 360) / 400;
} else if (lower.endsWith('rad')) {
val = (parseFloat(lower.slice(0, -3)) * 180) / Math.PI;
} else if (lower.endsWith('turn')) {
val = parseFloat(lower.slice(0, -4)) * 360;
} else {
val = parseFloat(lower);
}
val = val % 360;
if (val < 0) val += 360;
return val;
}
function parsePercentOrNumber(s: string, refMax: number): number {
const trim = s.trim();
if (trim.endsWith('%')) {
return (parseFloat(trim.slice(0, -1)) / 100) * refMax;
}
return parseFloat(trim);
}
function parseAlpha(s: string | undefined): number {
if (s === undefined) return 1;
const trim = s.trim();
if (trim.endsWith('%')) {
return parseFloat(trim.slice(0, -1)) / 100;
}
return parseFloat(trim);
}
function tokenizeFunc(str: string): { name: string; args: string[] } | null {
const match = str.trim().match(/^([a-z-]{3,15})\((.*)\)$/i);
if (!match) return null;
const name = match[1]!.toLowerCase();
const inner = match[2]!.trim();
let coordStr = inner;
let alphaStr: string | undefined = undefined;
// Parenthesis-aware search for the first depth-0 '/' division
let slantIndex = -1;
let depth = 0;
for (let i = 0; i < inner.length; i++) {
const char = inner[i];
if (char === '(') depth++;
else if (char === ')') depth--;
else if (depth === 0 && char === '/') {
slantIndex = i;
break;
}
}
if (slantIndex !== -1) {
coordStr = inner.slice(0, slantIndex).trim();
alphaStr = inner.slice(slantIndex + 1).trim();
}
// Split coordinate parameters
const args = splitList(coordStr);
if (alphaStr !== undefined) {
args.push(alphaStr);
}
return { name, args };
}
/**
* Split a space/comma separated parameter coordinates list, ignoring nested parens
*/
function splitList(str: string): string[] {
const results: string[] = [];
let current = '';
let depth = 0;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '(') {
depth++;
current += char;
} else if (char === ')') {
depth--;
current += char;
} else if (depth === 0 && (char === ',' || /\s/.test(char))) {
if (current.trim()) {
results.push(current.trim());
current = '';
}
} else {
current += char;
}
}
if (current.trim()) {
results.push(current.trim());
}
return results;
}
function splitByComma(str: string): string[] {
const results: string[] = [];
let current = '';
let depth = 0;
for (let i = 0; i < str.length; i++) {
const char = str[i];
if (char === '(') {
depth++;
current += char;
} else if (char === ')') {
depth--;
current += char;
} else if (depth === 0 && char === ',') {
results.push(current.trim());
current = '';
} else {
current += char;
}
}
if (current.trim()) {
results.push(current.trim());
}
return results;
}
function parseColorWithWeight(subArg: string): { colorStr: string; weight: number | null } | null {
// This parses strings like "red 20%" or "20% red" or "rgb(255, 0, 0)"
const parts = splitList(subArg.trim());
if (parts.length === 0 || parts.length > 2) return null;
if (parts.length === 1) {
return { colorStr: parts[0]!, weight: null };
}
// CSS color-mix weights are percentages only; a bare number is not a valid weight.
const p0 = parts[0]!;
const p1 = parts[1]!;
const isP0Weight = p0.endsWith('%');
const isP1Weight = p1.endsWith('%');
if (isP0Weight && !isP1Weight) {
return { colorStr: p1, weight: parseFloat(p0.slice(0, -1)) };
} else if (isP1Weight && !isP0Weight) {
return { colorStr: p0, weight: parseFloat(p1.slice(0, -1)) };
}
return null;
}
// ── Chromatic conversions logic module ────────────────────────────
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r_ = 0, g_ = 0, b_ = 0;
if (h < 60) {
r_ = c; g_ = x; b_ = 0;
} else if (h < 120) {
r_ = x; g_ = c; b_ = 0;
} else if (h < 180) {
r_ = 0; g_ = c; b_ = x;
} else if (h < 240) {
r_ = 0; g_ = x; b_ = c;
} else if (h < 300) {
r_ = x; g_ = 0; b_ = c;
} else {
r_ = c; g_ = 0; b_ = x;
}
return {
r: Math.max(0, Math.min(255, Math.round((r_ + m) * 255))),
g: Math.max(0, Math.min(255, Math.round((g_ + m) * 255))),
b: Math.max(0, Math.min(255, Math.round((b_ + m) * 255))),
};
}
function hwbToRgb(h: number, w: number, b: number): { r: number; g: number; b: number } {
if (w + b >= 1) {
const sum = w + b;
const val = Math.max(0, Math.min(255, Math.round((w / sum) * 255)));
return { r: val, g: val, b: val };
}
const pure = hslToRgb(h, 1, 0.5);
const r = Math.round((pure.r / 255) * (1 - w - b) * 255 + w * 255);
const g = Math.round((pure.g / 255) * (1 - w - b) * 255 + w * 255);
const bVal = Math.round((pure.b / 255) * (1 - w - b) * 255 + w * 255);
return {
r: Math.max(0, Math.min(255, r)),
g: Math.max(0, Math.min(255, g)),
b: Math.max(0, Math.min(255, bVal)),
};
}
function labToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
// Convert Lab to D50 XYZ
const fy = (l + 16) / 116;
const fx = a / 500 + fy;
const fz = fy - b / 200;
const e = 216 / 24389;
const k = 24389 / 27;
const fx3 = fx * fx * fx;
const fz3 = fz * fz * fz;
const xr = fx3 > e ? fx3 : (116 * fx - 16) / k;
const yr = l > k * e ? fy * fy * fy : l / k;
const zr = fz3 > e ? fz3 : (116 * fz - 16) / k;
// D50 White point
const Xn = 0.96422;
const Yn = 1.0;
const Zn = 0.82521;
const x = xr * Xn;
const y = yr * Yn;
const z = zr * Zn;
// Convert D50 XYZ to D65 XYZ via Bradford chromatic adaptation
const x65 = 0.9555726312052288 * x - 0.02303316850884054 * y + 0.06316100215997244 * z;
const y65 = -0.02828971739420664 * x + 1.0099416310812543 * y + 0.021007716449297163 * z;
const z65 = 0.012298224741016325 * x - 0.02048298287477757 * y + 1.3299098463422234 * z;
// Convert D65 XYZ to Linear sRGB
const r_lin = 3.2404542 * x65 - 1.5371385 * y65 - 0.4985314 * z65;
const g_lin = -0.9692660 * x65 + 1.8760108 * y65 + 0.0415560 * z65;
const b_lin = 0.0556434 * x65 - 0.2040259 * y65 + 1.0572252 * z65;
// Convert Linear sRGB to sRGB via standard gamma correction
const gamma = (val: number) => {
return val <= 0.0031308 ? 12.92 * val : 1.055 * Math.pow(val, 1 / 2.4) - 0.055;
};
return {
r: Math.max(0, Math.min(255, Math.round(gamma(r_lin) * 255))),
g: Math.max(0, Math.min(255, Math.round(gamma(g_lin) * 255))),
b: Math.max(0, Math.min(255, Math.round(gamma(b_lin) * 255))),
};
}
function lchToRgb(l: number, c: number, h: number): { r: number; g: number; b: number } {
const hRad = (h * Math.PI) / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return labToRgb(l, a, b);
}
function oklabToRgb(l: number, a: number, b: number): { r: number; g: number; b: number } {
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
const l3 = l_ * l_ * l_;
const m3 = m_ * m_ * m_;
const s3 = s_ * s_ * s_;
const r_lin = +4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
const g_lin = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
const b_lin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
const gamma = (val: number) => {
return val <= 0.0031308 ? 12.92 * val : 1.055 * Math.pow(val, 1 / 2.4) - 0.055;
};
return {
r: Math.max(0, Math.min(255, Math.round(gamma(r_lin) * 255))),
g: Math.max(0, Math.min(255, Math.round(gamma(g_lin) * 255))),
b: Math.max(0, Math.min(255, Math.round(gamma(b_lin) * 255))),
};
}
function oklchToRgb(l: number, c: number, h: number): { r: number; g: number; b: number } {
const hRad = (h * Math.PI) / 180;
const a = c * Math.cos(hRad);
const b = c * Math.sin(hRad);
return oklabToRgb(l, a, b);
}
@@ -0,0 +1,703 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { ModelHandler, contrastRatio } from './handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';
const handler = new ModelHandler();
function makeParsed(overrides: Partial<ParsedDesignSystem> = {}): ParsedDesignSystem {
return {
sourceMap: new Map(),
...overrides,
};
}
describe('ModelHandler', () => {
// ── Cycle 9: Build symbol table from parsed colors ────────────────
describe('symbol table from colors', () => {
it('resolves valid hex colors into the symbol table', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66', secondary: '#ff0000' },
}));
const primary = result.designSystem.symbolTable.get('colors.primary');
expect(primary).toBeDefined();
expect(typeof primary === 'object' && primary !== null && 'type' in primary && primary.type === 'color').toBe(true);
if (typeof primary === 'object' && primary !== null && 'hex' in primary) {
expect(primary.hex).toBe('#647d66');
}
expect(result.designSystem.colors.size).toBe(2);
});
it('emits diagnostic for invalid color format', () => {
const result = handler.execute(makeParsed({
colors: { primary: 'invalid-color' },
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('colors.primary');
expect(result.findings[0]!.severity).toBe('error');
});
it('normalizes #RGB shorthand to #RRGGBB', () => {
const result = handler.execute(makeParsed({
colors: { accent: '#abc' },
}));
const accent = result.designSystem.colors.get('accent');
expect(accent?.hex).toBe('#aabbcc');
});
it('normalizes #RGBA shorthand to #RRGGBBAA and extracts alpha', () => {
const result = handler.execute(makeParsed({
colors: { transparent: '#abc0' },
}));
const transparent = result.designSystem.colors.get('transparent');
expect(transparent?.hex).toBe('#aabbcc00');
expect(transparent?.a).toBe(0);
});
it('accepts 8-digit hex colors and extracts alpha', () => {
const result = handler.execute(makeParsed({
colors: { semitransparent: '#FFFFFFA6' },
}));
const semitransparent = result.designSystem.colors.get('semitransparent');
expect(semitransparent?.hex).toBe('#ffffffa6');
expect(semitransparent?.a).toBeCloseTo(166 / 255, 5);
});
it('successfully parses nested color declarations (Issue #102)', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light')).toBe(true);
expect(result.designSystem.colors.has('background.dark')).toBe(true);
expect(result.designSystem.colors.get('background.light')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light')).toBe(true);
});
it('successfully parses 3-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: {
primary: '#fbfaf1',
secondary: '#f0f0f0'
}
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('background.light.primary')).toBe(true);
expect(result.designSystem.colors.has('background.light.secondary')).toBe(true);
expect(result.designSystem.colors.get('background.light.primary')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.background.light.primary')).toBe(true);
});
it('successfully parses 4-level nested color declarations', () => {
const result = handler.execute(makeParsed({
colors: {
theme: {
surface: {
background: {
base: '#fbfaf1'
}
}
}
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
expect(result.designSystem.colors.has('theme.surface.background.base')).toBe(true);
expect(result.designSystem.colors.get('theme.surface.background.base')?.hex).toBe('#fbfaf1');
expect(result.designSystem.symbolTable.has('colors.theme.surface.background.base')).toBe(true);
});
it('resolves standard CSS named colors and converts them to hex/sRGB', () => {
const result = handler.execute(makeParsed({
colors: { c1: 'red', c2: 'transparent', c3: 'aliceblue' },
}));
expect(result.findings.length).toBe(0);
const c1 = result.designSystem.colors.get('c1');
expect(c1?.hex).toBe('#ff0000');
expect(c1?.r).toBe(255);
expect(c1?.g).toBe(0);
expect(c1?.b).toBe(0);
const c2 = result.designSystem.colors.get('c2');
expect(c2?.hex).toBe('#00000000');
expect(c2?.a).toBe(0);
});
it('resolves functional rgb/rgba colors', () => {
// comma separated
const resComma = handler.execute(makeParsed({
colors: { rgb1: 'rgb(255, 100, 50)', rgba1: 'rgba(255, 100, 50, 0.5)' },
}));
expect(resComma.findings.length).toBe(0);
expect(resComma.designSystem.colors.get('rgb1')?.hex).toBe('#ff6432');
expect(resComma.designSystem.colors.get('rgba1')?.a).toBeCloseTo(0.5);
// space separated and percentages
const resSpace = handler.execute(makeParsed({
colors: { rgb2: 'rgb(100% 50% 0%)', rgba2: 'rgb(100% 50% 0% / 40%)' },
}));
expect(resSpace.findings.length).toBe(0);
expect(resSpace.designSystem.colors.get('rgb2')?.r).toBe(255);
expect(resSpace.designSystem.colors.get('rgb2')?.g).toBe(128);
expect(resSpace.designSystem.colors.get('rgba2')?.a).toBeCloseTo(0.4);
});
it('resolves functional hsl/hsla colors', () => {
const result = handler.execute(makeParsed({
colors: { hsl1: 'hsl(120, 100%, 50%)', hsla1: 'hsl(120deg 100% 50% / 0.25)' },
}));
expect(result.findings.length).toBe(0);
const hsl1 = result.designSystem.colors.get('hsl1');
expect(hsl1?.hex).toBe('#00ff00');
const hsla1 = result.designSystem.colors.get('hsla1');
expect(hsla1?.hex).toBe('#00ff0040');
expect(hsla1?.a).toBeCloseTo(0.25);
});
it('resolves functional hwb colors', () => {
const result = handler.execute(makeParsed({
colors: { hwb1: 'hwb(120 0% 0%)', hwb2: 'hwb(120 50% 50%)', hwb3: 'hwb(120 20% 40% / 0.5)' },
}));
expect(result.findings.length).toBe(0);
expect(result.designSystem.colors.get('hwb1')?.hex).toBe('#00ff00');
expect(result.designSystem.colors.get('hwb2')?.hex).toBe('#808080');
expect(result.designSystem.colors.get('hwb3')?.a).toBeCloseTo(0.5);
});
it('resolves lab, lch, oklab, oklch color spaces', () => {
const result = handler.execute(makeParsed({
colors: {
lab1: 'lab(50% 40 -20)',
lch1: 'lch(50% 44.72 333.43)',
oklab1: 'oklab(0.6 0.1 -0.1)',
oklch1: 'oklch(0.6 0.1414 315)'
},
}));
expect(result.findings.length).toBe(0);
expect(result.designSystem.colors.get('lab1')).toBeDefined();
expect(result.designSystem.colors.get('lch1')).toBeDefined();
expect(result.designSystem.colors.get('oklab1')).toBeDefined();
expect(result.designSystem.colors.get('oklch1')).toBeDefined();
});
it('resolves color-mix colors', () => {
const result = handler.execute(makeParsed({
colors: { mix1: 'color-mix(in srgb, red 20%, blue 80%)', mix2: 'color-mix(in srgb, red, white 50%)' },
}));
expect(result.findings.length).toBe(0);
const mix1 = result.designSystem.colors.get('mix1');
expect(mix1?.r).toBe(51);
expect(mix1?.b).toBe(204);
const mix2 = result.designSystem.colors.get('mix2');
expect(mix2?.r).toBe(255);
expect(mix2?.g).toBe(128);
expect(mix2?.b).toBe(128);
});
it('parses grad hue units correctly (100grad === 90deg)', () => {
const result = handler.execute(makeParsed({
colors: { grad: 'hsl(100grad 100% 50%)', deg: 'hsl(90deg 100% 50%)' },
}));
expect(result.findings.length).toBe(0);
const grad = result.designSystem.colors.get('grad');
expect(grad?.hex).toBe('#80ff00');
expect(grad?.hex).toBe(result.designSystem.colors.get('deg')?.hex);
});
it('rejects color-mix with bare-number (non-percentage) weights', () => {
const result = handler.execute(makeParsed({
colors: { bad: 'color-mix(in srgb, red 20, blue)' },
}));
// CSS color-mix weights are percentages only; a bare number is invalid.
expect(result.designSystem.colors.has('bad')).toBe(false);
expect(result.findings.some(f => f.path === 'colors.bad' && f.severity === 'error')).toBe(true);
});
});
// ── Cycle 10: Resolve single-level token reference ────────────────
describe('single-level token reference resolution', () => {
it('resolves a direct {section.token} reference in components', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
},
},
}));
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
});
});
// ── Cycle 11: Resolve chained token reference ─────────────────────
describe('chained token reference resolution', () => {
it('resolves chained refs: {a} → {b} → #value', () => {
const result = handler.execute(makeParsed({
colors: {
'brand': '#647D66',
'primary': '{colors.brand}' as string,
},
components: {
'button': {
backgroundColor: '{colors.primary}',
},
},
}));
const btn = result.designSystem.components.get('button');
const bg = btn?.properties.get('backgroundColor');
expect(typeof bg === 'object' && bg !== null && 'type' in bg && bg.type === 'color').toBe(true);
if (typeof bg === 'object' && bg !== null && 'hex' in bg) {
expect(bg.hex).toBe('#647d66');
}
});
it('resolves references to nested colors', () => {
const result = handler.execute(makeParsed({
colors: {
background: {
light: '#fbfaf1',
dark: '#11140e'
},
page: '{colors.background.light}'
}
}));
expect(result.findings.filter(f => f.severity === 'error').length).toBe(0);
const page = result.designSystem.colors.get('page');
expect(page?.hex).toBe('#fbfaf1');
});
});
// ── Cycle 12: Detect circular reference ───────────────────────────
describe('circular reference detection', () => {
it('detects circular refs and records them as unresolved', () => {
const result = handler.execute(makeParsed({
colors: {
'a': '{colors.b}' as string,
'b': '{colors.a}' as string,
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
}));
const card = result.designSystem.components.get('card');
expect(card?.unresolvedRefs.length).toBeGreaterThan(0);
});
it('detects long circular reference chains', () => {
const result = handler.execute(makeParsed({
colors: {
'a': '{colors.b}',
'b': '{colors.c}',
'c': '{colors.d}',
'd': '{colors.e}',
'e': '{colors.f}',
'f': '{colors.g}',
'g': '{colors.h}',
'h': '{colors.i}',
'i': '{colors.j}',
'j': '{colors.a}',
},
components: {
'card': {
backgroundColor: '{colors.a}',
},
},
}));
const card = result.designSystem.components.get('card');
expect(card?.unresolvedRefs.length).toBeGreaterThan(0);
});
});
// ── Cycle N: Non-standard units are parsed, not dropped ────────────
describe('non-standard dimension units', () => {
it('emits diagnostic for non-standard dimension units in typography', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontFamily: 'Roboto', fontSize: '32px', letterSpacing: '-0.02vh' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.letterSpacing');
expect(result.findings[0]!.severity).toBe('error');
});
});
describe('typography validation', () => {
it('emits diagnostic when fontFamily is a hex color', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontFamily: '#ffffff' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.fontFamily');
expect(result.findings[0]!.severity).toBe('error');
});
it('emits diagnostic when fontWeight is not a number or valid number string', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontWeight: 'bold' },
},
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('typography.headline.fontWeight');
expect(result.findings[0]!.severity).toBe('error');
});
it('accepts string representations of numbers for fontWeight', () => {
const result = handler.execute(makeParsed({
typography: {
'headline': { fontWeight: '700' },
},
}));
expect(result.findings.length).toBe(0);
const headline = result.designSystem.typography.get('headline');
expect(headline?.fontWeight).toBe(700);
});
});
describe('rounded validation', () => {
it('emits diagnostic for non-standard units in rounded', () => {
const result = handler.execute(makeParsed({
rounded: { sm: '2vh' },
}));
expect(result.findings.length).toBe(1);
expect(result.findings[0]!.path).toBe('rounded.sm');
expect(result.findings[0]!.severity).toBe('error');
});
});
// ── Cycle 13: Compute WCAG contrast ratio ─────────────────────────
describe('WCAG contrast ratio', () => {
it('computes correct contrast ratio for black on white (21:1)', () => {
const result = handler.execute(makeParsed({
colors: { black: '#000000', white: '#ffffff' },
}));
const black = result.designSystem.colors.get('black');
const white = result.designSystem.colors.get('white');
expect(black).toBeDefined();
expect(white).toBeDefined();
const ratio = contrastRatio(black!, white!);
expect(ratio).toBeCloseTo(21, 0);
});
it('computes correct contrast for identical colors (1:1)', () => {
const result = handler.execute(makeParsed({
colors: { red1: '#ff0000', red2: '#ff0000' },
}));
const ratio = contrastRatio(result.designSystem.colors.get('red1')!, result.designSystem.colors.get('red2')!);
expect(ratio).toBeCloseTo(1, 1);
});
});
describe('return signature', () => {
it('returns findings array', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#647D66' },
}));
expect(result.findings).toBeDefined();
});
});
// ── Fix #25: rounded and spacing token references ─────────────────
describe('rounded token reference resolution', () => {
it('resolves a direct token reference in rounded', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
button: '{rounded.sm}' as string,
},
}));
const button = result.designSystem.rounded.get('button');
expect(button).toBeDefined();
expect(button?.value).toBe(4);
expect(button?.unit).toBe('px');
});
it('resolves a chained token reference in rounded', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
md: '{rounded.sm}' as string,
card: '{rounded.md}' as string,
},
}));
const card = result.designSystem.rounded.get('card');
expect(card).toBeDefined();
expect(card?.value).toBe(4);
expect(card?.unit).toBe('px');
});
it('resolved rounded reference appears in symbol table', () => {
const result = handler.execute(makeParsed({
rounded: {
sm: '4px',
button: '{rounded.sm}' as string,
},
}));
const sym = result.designSystem.symbolTable.get('rounded.button');
expect(sym).toBeDefined();
expect(typeof sym === 'object' && sym !== null && 'type' in sym && sym.type === 'dimension').toBe(true);
});
});
describe('spacing token reference resolution', () => {
it('resolves a direct token reference in spacing', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
}));
const buttonPadding = result.designSystem.spacing.get('button-padding');
expect(buttonPadding).toBeDefined();
expect(buttonPadding?.value).toBe(8);
expect(buttonPadding?.unit).toBe('px');
});
it('resolves a chained token reference in spacing', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
md: '{spacing.base}' as string,
'section-gap': '{spacing.md}' as string,
},
}));
const sectionGap = result.designSystem.spacing.get('section-gap');
expect(sectionGap).toBeDefined();
expect(sectionGap?.value).toBe(8);
expect(sectionGap?.unit).toBe('px');
});
it('resolved spacing reference appears in symbol table', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
}));
const sym = result.designSystem.symbolTable.get('spacing.button-padding');
expect(sym).toBeDefined();
expect(typeof sym === 'object' && sym !== null && 'type' in sym && sym.type === 'dimension').toBe(true);
});
it('resolved spacing reference propagates correctly to component resolution', () => {
const result = handler.execute(makeParsed({
spacing: {
base: '8px',
'button-padding': '{spacing.base}' as string,
},
components: {
'button-primary': {
padding: '{spacing.button-padding}',
},
},
}));
const btn = result.designSystem.components.get('button-primary');
const padding = btn?.properties.get('padding');
expect(typeof padding === 'object' && padding !== null && 'type' in padding && padding.type === 'dimension').toBe(true);
if (typeof padding === 'object' && padding !== null && 'value' in padding) {
expect(padding.value).toBe(8);
}
});
});
// ── Fix #42: numeric component props crash model builder ──────────
describe('numeric component property values', () => {
it('does not crash when fontWeight is a bare number', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#000000' },
components: {
'button-primary': {
backgroundColor: '{colors.primary}',
fontWeight: 600 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button-primary');
expect(btn).toBeDefined();
expect(btn?.properties.get('fontWeight') as unknown).toBe(600);
});
it('stores numeric fontWeight value as-is in component properties', () => {
const result = handler.execute(makeParsed({
components: {
'heading': {
fontWeight: 700 as unknown as string,
},
},
}));
const heading = result.designSystem.components.get('heading');
expect(heading?.properties.get('fontWeight') as unknown).toBe(700);
});
it('does not crash when borderWidth is a bare number', () => {
const result = handler.execute(makeParsed({
components: {
'card': {
borderWidth: 1 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const card = result.designSystem.components.get('card');
expect(card?.properties.get('borderWidth') as unknown).toBe(1);
});
it('handles mixed numeric and string props in same component without crashing', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#ff0000' },
spacing: { md: '16px' },
components: {
'button': {
fontWeight: 600 as unknown as string,
backgroundColor: '{colors.primary}',
padding: '{spacing.md}',
borderRadius: '4px',
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button');
expect(btn?.properties.get('fontWeight') as unknown).toBe(600);
});
});
// ── Fix #75: non-string YAML scalars crash model builder ────────────
describe('non-string component property values (Issue #75)', () => {
it('does not crash when a component property is a float (opacity: 0.9)', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#FF0000', 'on-primary': '#FFFFFF' },
components: {
button: {
backgroundColor: '{colors.primary}',
textColor: '{colors.on-primary}',
opacity: 0.9 as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const btn = result.designSystem.components.get('button');
expect(btn?.properties.get('opacity') as unknown).toBe(0.9);
});
it('does not crash when a component property is a boolean (visible: true)', () => {
const result = handler.execute(makeParsed({
components: {
banner: {
visible: true as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const banner = result.designSystem.components.get('banner');
expect(banner?.properties.get('visible') as unknown).toBe(true);
});
it('handles mixed number, boolean, and string props without crashing', () => {
const result = handler.execute(makeParsed({
colors: { primary: '#ff0000' },
components: {
card: {
backgroundColor: '{colors.primary}',
borderRadius: '8px',
fontWeight: 500 as unknown as string,
opacity: 0.85 as unknown as string,
visible: true as unknown as string,
disabled: false as unknown as string,
},
},
}));
expect(result.findings.filter(f => f.severity === 'error')).toHaveLength(0);
const card = result.designSystem.components.get('card');
expect(card?.properties.get('fontWeight') as unknown).toBe(500);
expect(card?.properties.get('opacity') as unknown).toBe(0.85);
expect(card?.properties.get('visible') as unknown).toBe(true);
expect(card?.properties.get('disabled') as unknown).toBe(false);
});
});
describe('token nesting depth limit', () => {
it('emits error when token nesting depth exceeds 20', () => {
// 22 levels: Level 1..21 are objects, Level 22 is a leaf.
// forEachLeaf will be called for Level 22 with depth 21.
let obj: any = '#ffffff';
for (let i = 22; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}
const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(true);
expect(result.findings.find((f) => f.message.includes('nesting depth'))?.path).toBe('colors');
});
it('allows nesting up to depth 20', () => {
// 21 levels: Level 1..20 are objects, Level 21 is a leaf.
// forEachLeaf will be called for Level 21 with depth 20.
let obj: any = '#ffffff';
for (let i = 21; i >= 1; i--) {
obj = { [`level${i}`]: obj };
}
const result = handler.execute(makeParsed({
colors: obj,
}));
expect(result.findings.some((f) => f.message.includes('nesting depth'))).toBe(false);
// Construct the expected path: level1.level2...level21
const path = Array.from({ length: 21 }, (_, i) => `level${i + 1}`).join('.');
expect(result.designSystem.colors.has(path)).toBe(true);
});
});
describe('color-mix nesting depth limit', () => {
it('rejects pathologically nested color-mix as an invalid color without collapsing the model', () => {
let nested = 'red';
for (let i = 0; i < 50; i++) nested = `color-mix(in srgb, ${nested}, blue)`;
const result = handler.execute(makeParsed({
colors: { ok: '#ffffff', deep: nested },
}));
// The over-deep color resolves to "invalid" (a precise per-token error),
// not a thrown RangeError that collapses the whole model build.
expect(result.designSystem.colors.has('deep')).toBe(false);
expect(result.findings.some(f => f.path === 'colors.deep' && f.severity === 'error')).toBe(true);
// Other valid tokens are unaffected.
expect(result.designSystem.colors.get('ok')?.hex).toBe('#ffffff');
});
});
});
+441
View File
@@ -0,0 +1,441 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import type { ParsedDesignSystem } from '../parser/spec.js';
import { SCHEMA_KEYS } from '../parser/spec.js';
import type {
ModelSpec,
ModelResult,
ResolvedColor,
ResolvedDimension,
ResolvedTypography,
ResolvedValue,
ComponentDef,
Finding,
} from './spec.js';
import { isValidColor, isParseableDimension, isTokenReference, parseDimensionParts } from './spec.js';
import { parseCssColor } from './color-parser.js';
import {
MAX_REFERENCE_DEPTH,
MAX_TOKEN_NESTING_DEPTH,
} from '../spec-config.js';
const SCHEMA_KEY_SET: ReadonlySet<string> = new Set(SCHEMA_KEYS);
/**
* Builds a resolved DesignSystemState from parsed YAML tokens.
* Handles color parsing, dimension parsing, typography construction,
* and chained token reference resolution with cycle detection.
* Never throws — all errors returned as ModelResult failures.
*/
export class ModelHandler implements ModelSpec {
execute(input: ParsedDesignSystem): ModelResult {
try {
const findings: Finding[] = [];
const symbolTable = new Map<string, ResolvedValue>();
const colors = new Map<string, ResolvedColor>();
const typography = new Map<string, ResolvedTypography>();
const rounded = new Map<string, ResolvedDimension>();
const spacing = new Map<string, ResolvedDimension>();
// ── Phase 1: Resolve primitive tokens ──────────────────────────
// Colors
if (input.colors) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
// Store raw reference for later resolution
symbolTable.set(`colors.${name}`, raw);
} else if (isValidColor(raw)) {
const resolved = parseColor(raw);
colors.set(name, resolved);
symbolTable.set(`colors.${name}`, resolved);
} else {
findings.push({
severity: 'error',
path: `colors.${name}`,
message: `'${raw}' is not a valid color. Expected a CSS color value (e.g., #ffffff, rgb(0 0 0), oklch(0.5 0.2 240)).`,
});
// Store as-is for fallback
symbolTable.set(`colors.${name}`, raw);
}
}, '', 0, findings, 'colors');
}
// Typography
if (input.typography) {
for (const [name, props] of Object.entries(input.typography)) {
const resolved = parseTypography(props, `typography.${name}`, findings);
typography.set(name, resolved);
symbolTable.set(`typography.${name}`, resolved);
}
}
// Rounded
if (input.rounded) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
if (resolved.unit !== 'px' && resolved.unit !== 'rem' && resolved.unit !== 'em') {
findings.push({
severity: 'error',
path: `rounded.${name}`,
message: `'${raw}' has an invalid unit '${resolved.unit}'. Only px, rem, and em are allowed.`,
});
}
rounded.set(name, resolved);
symbolTable.set(`rounded.${name}`, resolved);
} else if (!isTokenReference(raw)) {
findings.push({
severity: 'error',
path: `rounded.${name}`,
message: `'${raw}' is not a valid dimension.`,
});
symbolTable.set(`rounded.${name}`, raw);
} else {
symbolTable.set(`rounded.${name}`, raw);
}
}
}, '', 0, findings, 'rounded');
}
// Spacing
if (input.spacing) {
forEachLeaf(input.spacing, (name, raw) => {
if (isParseableDimension(raw)) {
const resolved = parseDimension(raw);
spacing.set(name, resolved);
symbolTable.set(`spacing.${name}`, resolved);
} else {
symbolTable.set(`spacing.${name}`, raw);
}
}, '', 0, findings, 'spacing');
}
// ── Phase 2: Resolve chained color references ──────────────────
// Iterate color entries that are still raw references and resolve them
if (input.colors) {
forEachLeaf(input.colors, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (resolved !== null && typeof resolved === 'object' && 'type' in resolved && resolved.type === 'color') {
colors.set(name, resolved as ResolvedColor);
symbolTable.set(`colors.${name}`, resolved);
}
}
});
}
// Resolve chained rounded references
if (input.rounded) {
forEachLeaf(input.rounded, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
rounded.set(name, resolved as ResolvedDimension);
symbolTable.set(`rounded.${name}`, resolved);
}
}
});
}
// Resolve chained spacing references
if (input.spacing) {
forEachLeaf(input.spacing, (name, raw) => {
if (typeof raw === 'string' && isTokenReference(raw)) {
const resolved = resolveReference(symbolTable, raw.slice(1, -1), new Set());
if (
resolved !== null &&
typeof resolved === 'object' &&
'type' in resolved &&
resolved.type === 'dimension'
) {
spacing.set(name, resolved as ResolvedDimension);
symbolTable.set(`spacing.${name}`, resolved);
}
}
});
}
// ── Phase 3: Build components ──────────────────────────────────
const components = new Map<string, ComponentDef>();
if (input.components) {
for (const [compName, props] of Object.entries(input.components)) {
const properties = new Map<string, ResolvedValue>();
const unresolvedRefs: string[] = [];
for (const [propName, rawValue] of Object.entries(props)) {
// Non-string scalars (numbers, booleans) are valid YAML values
// that can appear in component properties (e.g. fontWeight: 600,
// visible: true, opacity: 0.9). Store them as-is rather than
// passing them to string-only helpers like isTokenReference or
// isValidColor, which would either silently coerce or crash.
if (typeof rawValue === 'number' || typeof rawValue === 'boolean') {
properties.set(propName, rawValue);
} else if (isTokenReference(rawValue)) {
const refPath = rawValue.slice(1, -1);
const resolved = resolveReference(symbolTable, refPath, new Set());
if (resolved !== null) {
properties.set(propName, resolved);
} else {
unresolvedRefs.push(rawValue);
properties.set(propName, rawValue);
}
} else if (isValidColor(rawValue)) {
properties.set(propName, parseColor(rawValue));
} else if (isParseableDimension(rawValue)) {
properties.set(propName, parseDimension(rawValue));
} else {
properties.set(propName, rawValue);
}
}
components.set(compName, { properties, unresolvedRefs });
}
}
const unknownKeys = [...input.sourceMap.keys()].filter(
key => !SCHEMA_KEY_SET.has(key)
);
const unknownKeyValues: Record<string, unknown> = {};
if (input.rawValues) {
for (const key of unknownKeys) {
if (Object.prototype.hasOwnProperty.call(input.rawValues, key)) {
unknownKeyValues[key] = input.rawValues[key];
}
}
}
return {
designSystem: {
name: input.name,
description: input.description,
colors,
typography,
rounded,
spacing,
components,
symbolTable,
sections: input.sections,
unknownKeys,
unknownKeyValues,
},
findings,
};
} catch (error) {
return {
designSystem: {
colors: new Map(),
typography: new Map(),
rounded: new Map(),
spacing: new Map(),
components: new Map(),
symbolTable: new Map(),
},
findings: [
{
severity: 'error',
message: `Unexpected error during model building: ${error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
}
}
// ── Pure utility functions ─────────────────────────────────────────
/**
* Parse a CSS color string into a ResolvedColor with RGB + WCAG luminance.
*/
export function parseColor(raw: string): ResolvedColor {
const parsed = parseCssColor(raw);
if (!parsed) {
throw new Error(`Invalid color: ${raw}`);
}
return {
type: 'color',
...parsed,
};
}
/**
* Parse a dimension string like "42px" or "1.5rem".
*/
function parseDimension(raw: string): ResolvedDimension {
const parts = parseDimensionParts(raw);
if (!parts) {
throw new Error(`Invalid dimension: ${raw}`);
}
return {
type: 'dimension',
value: parts.value,
unit: parts.unit,
};
}
/**
* Parse a typography properties object into a ResolvedTypography.
*/
function parseTypography(props: Record<string, string | number>, path: string, findings: Finding[]): ResolvedTypography {
const result: ResolvedTypography = { type: 'typography' };
if (typeof props['fontFamily'] === 'string') {
const ff = props['fontFamily'];
if (isValidColor(ff)) {
findings.push({
severity: 'error',
path: `${path}.fontFamily`,
message: `'${ff}' appears to be a color, not a valid font family.`,
});
}
result.fontFamily = ff;
}
if (props['fontWeight'] !== undefined) {
const fw = props['fontWeight'];
let fwValue: number | undefined;
if (typeof fw === 'number') {
fwValue = fw;
} else if (typeof fw === 'string') {
const parsed = Number(fw);
if (!isNaN(parsed)) {
fwValue = parsed;
}
}
if (fwValue === undefined) {
findings.push({
severity: 'error',
path: `${path}.fontWeight`,
message: `'${fw}' is not a valid font weight. Expected a number.`,
});
} else {
result.fontWeight = fwValue;
}
}
if (typeof props['fontFeature'] === 'string') result.fontFeature = props['fontFeature'];
if (typeof props['fontVariation'] === 'string') result.fontVariation = props['fontVariation'];
const dimensionProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const;
for (const prop of dimensionProps) {
const raw = props[prop];
if (typeof raw === 'string') {
if (isParseableDimension(raw)) {
const parsed = parseDimension(raw);
if (parsed.unit !== 'px' && parsed.unit !== 'rem' && parsed.unit !== 'em') {
findings.push({
severity: 'error',
path: `${path}.${prop}`,
message: `'${raw}' has an invalid unit '${parsed.unit}'. Only px, rem, and em are allowed.`,
});
}
result[prop] = parsed;
} else if (prop === 'lineHeight' && /^\d*\.?\d+$/.test(raw)) {
result[prop] = {
type: 'dimension',
value: parseFloat(raw),
unit: '',
};
} else if (!isTokenReference(raw)) {
findings.push({
severity: 'error',
path: `${path}.${prop}`,
message: `'${raw}' is not a valid dimension.`,
});
}
}
}
return result;
}
/**
* Resolve a token reference with chained resolution and cycle detection.
* Returns null if the reference cannot be resolved (not found or circular).
*/
function resolveReference(
symbolTable: Map<string, ResolvedValue>,
path: string,
visited: Set<string>,
depth: number = 0,
): ResolvedValue | null {
if (depth > MAX_REFERENCE_DEPTH) return null;
if (visited.has(path)) return null; // Circular reference
visited.add(path);
const value = symbolTable.get(path);
if (value === undefined) return null;
// If the value is itself a reference string, follow the chain
if (typeof value === 'string' && isTokenReference(value)) {
const innerPath = value.slice(1, -1);
return resolveReference(symbolTable, innerPath, visited, depth + 1);
}
return value;
}
/**
* WCAG 2.1 contrast ratio between two resolved colors.
*/
export function contrastRatio(a: ResolvedColor, b: ResolvedColor): number {
const L1 = Math.max(a.luminance, b.luminance);
const L2 = Math.min(a.luminance, b.luminance);
return (L1 + 0.05) / (L2 + 0.05);
}
/**
* Recursively iterate over an object and call a function for each leaf node.
* Leaf node paths are dot-separated (e.g. "background.light").
*/
function forEachLeaf(
obj: Record<string, any>,
fn: (path: string, value: any) => void,
prefix = '',
depth = 0,
findings?: Finding[],
rootPath?: string
) {
if (depth > MAX_TOKEN_NESTING_DEPTH) {
if (findings && rootPath) {
// Check if we've already reported this rootPath to avoid spamming
if (!findings.some((f) => f.path === rootPath && f.message.includes('nesting depth'))) {
findings.push({
severity: 'error',
path: rootPath,
message: `Token nesting depth exceeds maximum allowed depth of ${MAX_TOKEN_NESTING_DEPTH}.`,
});
}
}
return;
}
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
forEachLeaf(value, fn, fullPath, depth + 1, findings, rootPath);
} else {
fn(fullPath, value);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { isValidColor, isStandardDimension, isParseableDimension, parseDimensionParts, isTokenReference } from './spec.js';
describe('isValidColor', () => {
const validColors = ['#ff0000', '#FF0000', '#abc', '#ABC', '#647D66', '#000', '#fff', 'red', 'blue'];
const invalidColors = ['#gg0000', '#12345', '647D66', '#1234567', '', '#'];
it.each(validColors)('accepts valid hex color: %s', (color: string) => {
expect(isValidColor(color)).toBe(true);
});
it.each(invalidColors)('rejects invalid color: %s', (color: string) => {
expect(isValidColor(color)).toBe(false);
});
});
describe('isStandardDimension', () => {
const standard = ['12px', '1.5rem', '0px', '42px', '0.75rem', '100px', '12em', '-0.02em'];
const nonStandard = ['42', 'px', 'rem', '12vh', '', '12 px', '12vw'];
it.each(standard)('accepts standard dimension: %s', (dim: string) => {
expect(isStandardDimension(dim)).toBe(true);
});
it.each(nonStandard)('rejects non-standard dimension: %s', (dim: string) => {
expect(isStandardDimension(dim)).toBe(false);
});
});
describe('isParseableDimension', () => {
const parseable = [
'12px', '1.5rem', '-0.02em', '100vh', '50%', '0.75rem', '1em', '12vw',
// CSS Level 4 units now in scope
'10cqi', '20lvh', '30dvw', '5cqmin',
];
const unparseable = ['42', 'px', 'rem', '', '12 px', 'auto', 'inherit'];
it.each(parseable)('accepts parseable dimension: %s', (dim: string) => {
expect(isParseableDimension(dim)).toBe(true);
});
it.each(unparseable)('rejects unparseable dimension: %s', (dim: string) => {
expect(isParseableDimension(dim)).toBe(false);
});
});
describe('parseDimensionParts', () => {
it('parses standard dimensions', () => {
expect(parseDimensionParts('42px')).toEqual({ value: 42, unit: 'px' });
expect(parseDimensionParts('1.5rem')).toEqual({ value: 1.5, unit: 'rem' });
expect(parseDimensionParts('-0.02em')).toEqual({ value: -0.02, unit: 'em' });
});
it('parses leading-zero-free decimals (.5rem)', () => {
expect(parseDimensionParts('.5rem')).toEqual({ value: 0.5, unit: 'rem' });
expect(parseDimensionParts('-.25em')).toEqual({ value: -0.25, unit: 'em' });
});
it('returns null for bare numbers without a unit', () => {
expect(parseDimensionParts('42')).toBeNull();
expect(parseDimensionParts('')).toBeNull();
});
it('returns null for CSS keywords', () => {
expect(parseDimensionParts('auto')).toBeNull();
expect(parseDimensionParts('inherit')).toBeNull();
});
it('returns null for oversized values without pathological backtracking', () => {
// Length-capped: an absurdly long value is rejected immediately rather than
// triggering quadratic regex backtracking.
expect(parseDimensionParts('1'.repeat(100000))).toBeNull();
expect(parseDimensionParts('1'.repeat(100000) + 'px')).toBeNull();
// Legitimate dimensions well under the cap still parse.
expect(parseDimensionParts('999999.999999px')).toEqual({ value: 999999.999999, unit: 'px' });
});
});
describe('isTokenReference', () => {
it('recognizes curly-brace token references', () => {
expect(isTokenReference('{colors.primary}')).toBe(true);
expect(isTokenReference('{typography.headline-lg}')).toBe(true);
expect(isTokenReference('{colors.primary-60}')).toBe(true);
});
it('rejects non-references', () => {
expect(isTokenReference('#ff0000')).toBe(false);
expect(isTokenReference('colors.primary')).toBe(false);
expect(isTokenReference('{}')).toBe(false);
expect(isTokenReference('{ colors.primary }')).toBe(false);
});
});
+199
View File
@@ -0,0 +1,199 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { z } from 'zod';
import type { ParsedDesignSystem } from '../parser/spec.js';
import {
STANDARD_UNITS as _STANDARD_UNITS,
VALID_TYPOGRAPHY_PROPS as _VALID_TYPOGRAPHY_PROPS,
VALID_COMPONENT_SUB_TOKENS as _VALID_COMPONENT_SUB_TOKENS,
} from '../spec-config.js';
import { parseCssColor } from './color-parser.js';
export const SeveritySchema = z.enum(['error', 'warning', 'info']);
export type Severity = z.infer<typeof SeveritySchema>;
export interface Finding {
severity: Severity;
path?: string;
message: string;
}
// ── RESOLVED VALUE TYPES ───────────────────────────────────────────
export interface ResolvedColor {
type: 'color';
hex: string;
r: number;
g: number;
b: number;
/** Alpha channel from 0 to 1. Optional, defaults to 1 if not present. */
a?: number;
/** WCAG relative luminance */
luminance: number;
}
export interface ResolvedDimension {
type: 'dimension';
value: number;
/** The unit string. Standard units are 'px' and 'rem'; others are preserved but flagged by the linter. */
unit: string;
}
export interface ResolvedTypography {
type: 'typography';
fontFamily?: string | undefined;
fontSize?: ResolvedDimension | undefined;
fontWeight?: number | undefined;
lineHeight?: ResolvedDimension | undefined;
letterSpacing?: ResolvedDimension | undefined;
fontFeature?: string | undefined;
fontVariation?: string | undefined;
}
export type ResolvedValue = ResolvedColor | ResolvedDimension | ResolvedTypography | string | number | boolean;
// ── Re-exported from spec-config (single source of truth) ─────────
export const VALID_TYPOGRAPHY_PROPS = _VALID_TYPOGRAPHY_PROPS;
export const VALID_COMPONENT_SUB_TOKENS = _VALID_COMPONENT_SUB_TOKENS;
// ── STATE ──────────────────────────────────────────────────────────
export interface DesignSystemState {
name?: string | undefined;
description?: string | undefined;
colors: Map<string, ResolvedColor>;
typography: Map<string, ResolvedTypography>;
rounded: Map<string, ResolvedDimension>;
spacing: Map<string, ResolvedDimension>;
components: Map<string, ComponentDef>;
/** Flat lookup: "colors.primary" → ResolvedColor */
symbolTable: Map<string, ResolvedValue>;
/** Markdown heading names found in the document */
sections?: string[] | undefined;
/** Top-level YAML keys that are not part of the known schema */
unknownKeys?: string[] | undefined;
/** Raw YAML values for unknown top-level keys, keyed by the unknown key name */
unknownKeyValues?: Record<string, unknown> | undefined;
}
export interface ComponentDef {
properties: Map<string, ResolvedValue>;
/** Unresolved references that failed to resolve */
unresolvedRefs: string[];
}
// ── ERROR CODES ────────────────────────────────────────────────────
export const ModelErrorCode = z.enum([
'INVALID_COLOR',
'INVALID_DIMENSION',
'INVALID_TYPOGRAPHY_PROP',
'UNRESOLVED_REFERENCE',
'CIRCULAR_REFERENCE',
'REFERENCE_TO_NON_PRIMITIVE',
'NESTING_DEPTH_EXCEEDED',
'UNKNOWN_ERROR',
]);
// ── RESULT ─────────────────────────────────────────────────────────
export interface ModelResult {
designSystem: DesignSystemState;
findings: Finding[];
}
// ── INTERFACE ──────────────────────────────────────────────────────
export interface ModelSpec {
execute(input: ParsedDesignSystem): ModelResult;
}
// ── VALIDATION HELPERS ─────────────────────────────────────────────
/** Units the spec formally supports. Sourced from spec-config.ts. */
const STANDARD_UNITS: Set<string> = new Set(_STANDARD_UNITS);
/**
* All known CSS length/percentage units.
* Adding a new CSS unit = one string here. Never edit a regex.
*/
const CSS_UNITS = new Set([
// Absolute
'px', 'cm', 'mm', 'in', 'pt', 'pc',
// Relative to font
'em', 'rem', 'ex', 'ch', 'cap', 'ic', 'lh', 'rlh',
// Viewport — classic
'vh', 'vw', 'vmin', 'vmax',
// Viewport — dynamic/small/large (CSS Level 4)
'dvh', 'dvw', 'dvmin', 'dvmax',
'svh', 'svw', 'svmin', 'svmax',
'lvh', 'lvw', 'lvmin', 'lvmax',
// Container query units
'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax',
// Percentage
'%',
]);
/**
* Upper bound on a dimension string's length. Real CSS dimensions are a handful
* of characters; capping the length keeps validation linear and prevents
* pathological regex backtracking on oversized, attacker-supplied values.
*/
const MAX_DIMENSION_LENGTH = 64;
/**
* Parse a dimension string into its numeric value and unit suffix.
* Accepts an optional leading sign and optional decimal (`.5rem` is valid).
* Returns null for non-dimension strings (bare numbers, keywords like `auto`).
*/
export function parseDimensionParts(raw: string): { value: number; unit: string } | null {
if (typeof raw !== 'string') return null;
if (raw.length > MAX_DIMENSION_LENGTH) return null;
const match = raw.match(/^(-?\d*\.?\d+)([a-zA-Z%]+)$/);
if (!match) return null;
const value = parseFloat(match[1]!);
return Number.isNaN(value) ? null : { value, unit: match[2]! };
}
/**
* Validate a hex color string. Accepts #RGB, #RGBA, #RRGGBB, and #RRGGBBAA.
*/
export function isValidColor(raw: string): boolean {
return parseCssColor(raw) !== null;
}
/**
* Validate a dimension string uses a spec-standard unit (px or rem only).
*/
export function isStandardDimension(raw: string): boolean {
const parts = parseDimensionParts(raw);
return parts !== null && STANDARD_UNITS.has(parts.unit);
}
/**
* Check if a dimension string is parseable (any known CSS length/percentage unit).
* Adding support for a new unit: add it to CSS_UNITS above.
*/
export function isParseableDimension(raw: string): boolean {
const parts = parseDimensionParts(raw);
return parts !== null && CSS_UNITS.has(parts.unit);
}
/**
* @deprecated Use isStandardDimension for spec compliance or isParseableDimension for generous parsing.
*/
export const isValidDimension = isStandardDimension;
/**
* Check if a string is a token reference ({section.token}).
*/
export function isTokenReference(raw: string): boolean {
return /^\{[a-zA-Z0-9._-]+\}$/.test(raw);
}
@@ -0,0 +1,156 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { describe, it, expect } from 'bun:test';
import { ParserHandler } from './handler.js';
const handler = new ParserHandler();
describe('ParserHandler', () => {
// ── Cycle 2: Frontmatter extraction ───────────────────────────────
describe('frontmatter extraction', () => {
it('extracts YAML from frontmatter delimiters', () => {
const input = `---
name: Kindred Spirit
colors:
primary: "#647D66"
---
Some markdown content here.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.name).toBe('Kindred Spirit');
expect(result.data.colors?.['primary']).toBe('#647D66');
}
});
});
// ── Cycle 3: Code block extraction ────────────────────────────────
describe('code block extraction', () => {
it('extracts YAML from fenced yaml code blocks', () => {
const input = `# Design System
\`\`\`yaml
colors:
primary: "#ff0000"
secondary: "#00ff00"
\`\`\`
Some explanation text.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#ff0000');
expect(result.data.colors?.['secondary']).toBe('#00ff00');
}
});
it('extracts YAML code blocks with attributes', () => {
const input = `# Code block with attributes
\`\`\`yaml title="theme"
colors:
primary: "#ffffff"
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#ffffff');
}
});
});
// ── Cycle 4: Merge multiple code blocks ───────────────────────────
describe('merging multiple code blocks', () => {
it('merges separate YAML blocks into one tree', () => {
const input = `# Colors
\`\`\`yaml
colors:
primary: "#647D66"
\`\`\`
# Typography
\`\`\`yaml
typography:
headline-lg:
fontFamily: Google Sans Display
fontSize: 42px
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.colors?.['primary']).toBe('#647D66');
expect(result.data.typography?.['headline-lg']?.['fontFamily']).toBe('Google Sans Display');
}
});
});
// ── Cycle 5: Duplicate section detection ──────────────────────────
describe('duplicate section detection', () => {
it('returns DUPLICATE_SECTION when same top-level key appears in multiple blocks', () => {
const input = `
\`\`\`yaml
colors:
primary: "#ff0000"
\`\`\`
\`\`\`yaml
colors:
secondary: "#00ff00"
\`\`\`
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('DUPLICATE_SECTION');
expect(result.error.message).toContain('colors');
}
});
});
// ── Cycle 6: Malformed YAML ───────────────────────────────────────
describe('malformed YAML', () => {
it('returns YAML_PARSE_ERROR on invalid YAML syntax', () => {
const input = `---
colors:
primary: "#ff0000"
- this is invalid
---`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('YAML_PARSE_ERROR');
}
});
it('returns NO_YAML_FOUND when no YAML content exists', () => {
const input = `# Just a heading
Some markdown text with no YAML blocks.
`;
const result = handler.execute({ content: input });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('NO_YAML_FOUND');
}
});
});
});

Some files were not shown because too many files have changed in this diff Show More