feat(v12): chat REPL + permalinks + gallery + spec + distribution prep

The "best in the game" release: takes designlang from one-shot extractor
to iterative + shareable + standardized + distributed.

ITERATIVE — designlang chat
- src/chat.js: REPL over a live extraction. Heuristic-only router
  parses natural-ish English ("make it brutalist", "primary #ff4800",
  "sharpen radii", "dark mode") into structured operations on the
  design object. Re-derives DTCG / Tailwind / CSS vars / DESIGN.md
  on `save`. Loads from URL or existing *-design-tokens.json.
- bin: new `chat <target>` subcommand. ~360 lines, zero new deps.

SHAREABLE — permalinks
- website/lib/cache.js: getCachedByHash + listRecent helpers over
  the existing Blob cache (24h TTL), no new infra.
- website/lib/build-files.js: shared file-builder used by both
  /api/extract (streaming) and /x/[hash] (permalink page).
- website/app/x/[hash]/page.js + PermalinkViewer.js: every cached
  extraction is now a stable URL. Stat strip + Copy permalink CTA +
  the same 12-tab ResultViewer.
- /api/extract emits a `permalink` event up front; HeroExtractor
  rewrites the URL bar via history.replaceState so refresh-and-share
  works mid-stream.
- website/app/gallery/page.js: public directory of recent extractions,
  palette-strip cards, relative timestamps, intent + material + library
  signals. ISR-cached every 10 min.

STANDARDIZED — DESIGN.md spec
- website/app/spec/page.js: formal spec doc. Eight canonical sections,
  YAML front-matter schema, reference implementation pointer, CC BY 4.0
  license, credit to design-extractor.com for pioneering the format.
- website/public/badge.svg: embeddable Verified badge (paper + ink + accent).
- Sitemap + robots updated with /spec, /gallery.

DISTRIBUTED — submission playbook
- marketplace/SUBMISSION-PLAYBOOK.md: ordered list of 6 marketplaces
  with click-time estimates and review-queue waits.
- marketplace/{figma,cursor,vscode,claude-code-skill,raycast,chrome}-listing.md:
  pre-written listing copy, manifests, screenshot briefs, tweet drafts
  for each. Click "submit" tomorrow morning; nothing left to write.

Home nav: Gallery + Spec links added; v11 → v12 label.
Tests: 317/317 pass.
This commit is contained in:
Manav Arya Singh
2026-04-26 21:55:00 +04:00
parent 72f563e644
commit cd58dcaece
20 changed files with 1387 additions and 95 deletions
+15
View File
@@ -1085,6 +1085,21 @@ program
}
});
// ── Chat — REPL over a live extraction (v12) ──────────────
program
.command('chat <target>')
.description('Interactive REPL over an extraction. <target> is either a URL or a path to an existing *-design-tokens.json file.')
.option('-o, --out <dir>', 'output directory for `save`', './chat-output')
.action(async (target, opts) => {
try {
const { runChat } = await import('../src/chat.js');
await runChat(target, opts);
} catch (err) {
console.error(chalk.red(`\n ${err.message}\n`));
process.exit(1);
}
});
// ── Replay — record a short WebM of motion from a URL ─────
program
.command('replay <url>')
+95
View File
@@ -0,0 +1,95 @@
# designlang — Distribution Submission Playbook
Submit each marketplace below. All listing copy + manifests are pre-written.
Review queues take 17 days; this playbook is everything you need to click "submit".
---
## 1. Figma Community (`figma-plugin/`)
**Goal:** publicly listed in the Figma Community plugin store.
**Steps:**
1. Open Figma Desktop → Community → Publish a plugin.
2. Upload from local: `figma-plugin/`.
3. Use the title, tagline, description, tags, and cover-image brief from `marketplace/figma-listing.md`.
4. Screenshots: take 4 from the live extraction studio.
5. Submit. Review queue: 37 days.
---
## 2. Cursor — Custom Tool listing
**Goal:** Cursor users can `Cmd+Shift+P → designlang: Extract` from any URL.
**Steps:**
1. Read `marketplace/cursor-listing.md`.
2. The provided `mcp.json` snippet is what users paste into `~/.cursor/mcp.json`.
3. We don't submit to Cursor's marketplace directly (no formal review process yet);
the MCP integration is documented and self-served. Promotion = blog post + tweet.
---
## 3. VS Code Marketplace (`vscode-extension/`)
**Goal:** publicly listed at marketplace.visualstudio.com/items?itemName=designlang.
**Steps:**
1. Install `vsce` once: `npm install -g @vscode/vsce`.
2. From `vscode-extension/`: `vsce package` → produces `.vsix`.
3. Create publisher account: <https://marketplace.visualstudio.com/manage>.
4. `vsce publish` (or upload the `.vsix` via the dashboard).
5. Use the listing copy from `marketplace/vscode-listing.md`.
6. Review queue: ~24h.
---
## 4. Claude Code Skill registry
**Goal:** designlang appears in the official Claude Code skills list.
**Steps:**
1. Read `marketplace/claude-code-skill.md` — it contains the SKILL.md the registry expects.
2. The SKILL.md is already emitted by `designlang <url> --emit-agent-rules` at
`.claude/skills/designlang/SKILL.md`. We submit a copy at the project root for discovery.
3. Open a PR against <https://github.com/anthropics/claude-code-skills> (or the
then-current registry repo) — title: "Add: designlang skill".
---
## 5. Raycast Store (`raycast-extension/`)
**Goal:** designlang as a one-keystroke Raycast command.
**Steps:**
1. Read `marketplace/raycast-listing.md`.
2. From `raycast-extension/`: follow Raycast's contribution guide
<https://developers.raycast.com/basics/publish-an-extension>.
3. PR to <https://github.com/raycast/extensions>.
4. Review queue: 514 days.
---
## 6. Chrome Web Store (`chrome-extension/`)
**Goal:** one-click extract from any tab via the Chrome toolbar.
**Steps:**
1. Read `marketplace/chrome-listing.md`.
2. Zip `chrome-extension/`: `cd chrome-extension && zip -r ../designlang-chrome.zip .`.
3. Upload at <https://chrome.google.com/webstore/devconsole>. ($5 one-time dev fee.)
4. Review queue: 13 days.
---
## Sequencing
Do them in this order — easiest payoff first, most setup last:
1. **Cursor MCP** (no review, just docs + tweet — ship today).
2. **Chrome Web Store** ($5 fee, 13 day review — submit today).
3. **VS Code Marketplace** (free, ~24h review — submit today).
4. **Figma Community** (free, 37 day review — submit today).
5. **Claude Code Skills registry** (PR-based, async — submit this week).
6. **Raycast Store** (PR-based, 514 day review — submit this week).
Total click-time across all six: ~90 minutes. The wall-clock for everything to be live: ~2 weeks.
+45
View File
@@ -0,0 +1,45 @@
# Chrome Web Store — designlang extension
## Name
designlang — Extract design system from this tab
## Summary (max 132 chars)
One click to extract any website's design system: tokens, typography, spacing, components, voice. $0, no account, MIT.
## Description (long)
designlang lives in your Chrome toolbar. Click the icon on any tab; the active URL is sent to designlang.app, which runs a Playwright extraction and opens the result inline as tabs (DESIGN.md, DTCG tokens, Tailwind, CSS variables, Figma variables, and 7 more).
Permissions:
`activeTab` — read the URL of the current tab. Nothing else.
Use cases:
• Read a competitor's design tokens in 5 seconds
• Pull a Tailwind config off any production site
• Generate a DESIGN.md for AI coding agents (Claude Code, Cursor, Windsurf)
• One-click copy hex / radius / shadow values
• Import a Figma Variable collection from any URL
Open source: https://github.com/Manavarya09/design-extract
## Category
Developer Tools
## Languages
English
## Screenshots (4 — 1280x800)
1. Browser tab on a real site (e.g. Stripe), with the designlang toolbar icon highlighted.
2. The same tab after clicking — designlang.app opens with extraction in progress (token paint streaming).
3. The result viewer with the DESIGN.md tab active, source code visible, copy/download buttons.
4. The result viewer on the Tailwind config tab, showing extracted palette + scale.
## Submission
ZIP the `chrome-extension/` directory, upload at <https://chrome.google.com/webstore/devconsole>.
$5 one-time developer fee.
+52
View File
@@ -0,0 +1,52 @@
# Claude Code Skills registry — designlang
## Skill name
designlang
## SKILL.md (the file the registry expects)
```markdown
---
name: designlang
description: Reverse-engineer any website into a complete design system. Outputs DTCG W3C tokens, motion, anatomy, voice, page intent, material language, plus a single agent-native DESIGN.md and a one-command Next.js clone. Use when the user wants to extract, mirror, or compare design tokens against a live URL.
when_to_use: |
Use this skill when the user wants to:
- Extract design tokens, palette, typography, or shadows from a public website
- Generate a DESIGN.md for an existing site (agent-native single-file artifact)
- Clone a website's design as a runnable Next.js starter
- Compare local tokens against a deployed site (drift detection)
- Import a website's tokens into Figma, Tailwind, iOS, Android, Flutter, or WordPress
arguments:
- name: url
description: Public URL of the site to extract from.
required: true
---
# designlang skill
`npx designlang <url>` runs a Playwright extraction and writes ~25 files
(DTCG tokens, Tailwind, CSS vars, Figma vars, motion, anatomy, voice,
intent, material, library detection, prompt pack for v0/Lovable/Cursor,
plus the single-file `DESIGN.md`).
Sub-commands:
- `designlang clone <url>` — generates a working Next.js repo from the extraction
- `designlang ci <url> --tokens ./tokens.json` — drift bot, writes a PR-comment markdown
- `designlang studio` — local web studio over the latest extraction
- `designlang chat <url>` — REPL with mutations (sharpen / soften / dark / brutalist / glass / swap-color / swap-font)
- `designlang mcp` — stdio MCP server
For agent rules and ready-to-paste prompts, run with `--emit-agent-rules` or read
the `*-prompts/` directory.
The full spec for `DESIGN.md` is at <https://designlang.app/spec>.
```
## Submission flow
1. Fork <https://github.com/anthropics/claude-code> (or the then-current registry repo).
2. Add `skills/designlang/SKILL.md` with the content above.
3. PR title: `Add: designlang skill`.
4. PR body: link to <https://designlang.app>, describe the surface, mention 1.6k stars + 5K npm downloads.
5. Tag the PR with `skill` and `community`.
+55
View File
@@ -0,0 +1,55 @@
# Cursor — designlang MCP integration
## What it gives the user
Inside Cursor (and Windsurf, and Claude Desktop), `designlang` exposes the **last extraction** as a live MCP resource. Cursor can read tokens, regions, components, and CSS health without re-extracting, and call MCP tools to refine the design (`designlang chat` ops are wired here).
## Install (paste into `~/.cursor/mcp.json`)
```json
{
"mcpServers": {
"designlang": {
"command": "npx",
"args": ["-y", "designlang", "mcp", "--output-dir", "./design-extract-output"]
}
}
}
```
## What Cursor sees
Resources:
- `designlang://latest/tokens` — DTCG W3C token tree of the most recent extraction
- `designlang://latest/regions` — semantic region map (nav, hero, pricing, …)
- `designlang://latest/components` — component clusters with variant/size/state
- `designlang://latest/css-health` — specificity graph + unused-CSS report
- `designlang://latest/design-md` — the agent-native single-file artifact
Tools:
- `designlang_extract` — paste a URL, get tokens
- `designlang_chat` — apply mutations (sharpen / soften / dark / brutalist / glass / swap-color / swap-font)
- `designlang_clone` — generate a working Next.js repo from a URL
- `designlang_drift` — compare local tokens against a live URL
## Tweet thread for launch
```
1/ Cursor + designlang. Paste any URL inside Cursor and it reads the live design system as MCP resources.
`~/.cursor/mcp.json`:
{
"mcpServers": {
"designlang": {
"command": "npx",
"args": ["-y", "designlang", "mcp"]
}
}
}
2/ Then ask: "Refactor my <Button> to match stripe.com's design language" — Cursor reads designlang://latest/tokens and rewrites against the real palette, radii, shadows, voice.
3/ It also exposes `designlang_chat` as a tool. "Make it brutalist" → tokens regenerate. "Swap primary to #ff4800" → updated. Then ask Cursor to apply the new tokens to your repo.
4/ $0. MIT. https://designlang.app
```
+50
View File
@@ -0,0 +1,50 @@
# Figma Community — designlang plugin listing
## Title
designlang — Import any website's design system
## Tagline (max 100 chars)
Paste a URL, get a Figma Variable collection — colors, typography, spacing — pulled live from any site.
## Description (max 1000 chars)
designlang reverse-engineers any website into a complete design system and imports it directly into Figma as a Variable collection.
Paste a URL or paste an exported `*-figma-variables.json` from the [designlang CLI](https://www.npmjs.com/package/designlang). The plugin creates a fresh VariableCollection (or updates an existing one), maps every primitive, semantic, and composite token, and supports multi-mode payloads (light/dark) automatically.
What you get:
• Colors with proper roles (primary, secondary, accent, background, foreground, neutrals)
• Typography variables (sans, mono, body size, heading scale)
• Spacing scale (with detected base unit)
• Radii (xs/sm/md/lg/xl/full)
• Shadows (kept as raw strings — Figma doesn't natively type box-shadow)
Free, MIT-licensed, no account. The whole system also runs as a CLI: `npx designlang <url>`.
## Tags (pick up to 12)
design-system, design-tokens, design-extractor, dtcg, figma-variables, color-palette, typography, tokens, brand, design-system-import, w3c, ai-coding-agents
## Cover-image brief
Layout: paper background (#f3f1ea), single orange accent (#ff4800).
Hero: "designlang" in Fraunces, 96px, with the `d` mark to the left.
Below: a 5-swatch palette strip showing primary / secondary / accent / fg / bg.
Right side: a tiny mock Figma Variable panel showing 6 imported variables.
## Screenshots (4)
1. Plugin UI on plugin run — "Paste URL or upload .json" state.
2. Mid-import — token list streaming in.
3. Figma side panel showing the imported VariableCollection with 24 colors expanded.
4. After import — a frame using the imported variables (button + card mock).
## Support email
[your email]
## Source code link
https://github.com/Manavarya09/design-extract/tree/main/figma-plugin
+38
View File
@@ -0,0 +1,38 @@
# Raycast Store — designlang extension
## Title
designlang — Extract a website's design system
## Subtitle
Paste any URL, get DTCG tokens / DESIGN.md / Tailwind config in one keystroke.
## Description
Designlang reverse-engineers any website into a complete design system, all from inside Raycast.
Triggers a Playwright extraction (or pulls from cache), then opens the result in your default browser as a shareable permalink at `designlang.app/x/<hash>`. Paste the URL, hit ⌘ Enter, see the design system within 5 seconds.
What you get every time:
• A shareable permalink (works on every device, no account)
• A single-file DESIGN.md for AI coding agents
• 12 file formats: DTCG tokens, Tailwind config, CSS variables, Figma variables, shadcn theme, React/Vue/Svelte themes, iOS SwiftUI, Android Compose, Flutter, WordPress block theme
Free, MIT, no signup.
## Categories
Developer Tools, Productivity
## Keywords
design, tokens, design-system, css, tailwind, figma, dtcg, ai
## Author
Manav Arya Singh (@manavarya09)
## Submission
PR to https://github.com/raycast/extensions with the contents of `raycast-extension/`.
+46
View File
@@ -0,0 +1,46 @@
# VS Code Marketplace — designlang listing
## Display name
designlang — Extract any website's design system
## Description (short, max 200 chars)
CLI + MCP + VS Code commands that reverse-engineer any URL into DTCG tokens, Tailwind, CSS vars, Figma variables, and an agent-native DESIGN.md. $0, MIT, no account.
## README (long description)
The README at the root of `vscode-extension/` is what the marketplace renders. Make sure it covers:
1. **What it does** — paste a URL, extract a design system, import into the active workspace.
2. **Commands**
- `Designlang: Extract from URL` (Cmd+Shift+P)
- `Designlang: Apply to Workspace` (writes Tailwind + CSS vars next to the user's existing files)
- `Designlang: Open DESIGN.md`
- `Designlang: Compare with Production` (drift bot inside the editor)
3. **MCP integration** — point at the same `npx designlang mcp` server, gets the live extraction.
4. **Screenshots** — the streaming token paint inside the VS Code panel.
## Categories
Programming Languages, Other, Visualization, AI
## Tags
design-system, design-tokens, dtcg, w3c, tailwind, figma, css, ai, mcp, claude, cursor
## Pricing
Free.
## License
MIT — link to the LICENSE file at the repo root.
## Repo
https://github.com/Manavarya09/design-extract
## Publisher
`designlang` (matches package.json `name`)
+356
View File
@@ -0,0 +1,356 @@
// designlang chat — REPL over a live extraction.
//
// Heuristic-only in v12.0: the operations below cover the cases real users
// reach for first. LLM fallback ships in v12.1 (--smart). The router parses
// natural-ish English ("sharpen radii", "make it brutalist", "swap primary
// to #ff4800") into structured operations on the design object, re-derives
// tokens, and prints a tight diff.
import { createInterface } from 'readline';
import { stdin as input, stdout as output } from 'process';
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs';
import { join, resolve } from 'path';
import chalk from 'chalk';
import { extractDesignLanguage } from './index.js';
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
import { formatDesignMd } from './formatters/design-md.js';
import { formatTailwind } from './formatters/tailwind.js';
import { formatCssVars } from './formatters/css-vars.js';
import { nameFromUrl } from './utils.js';
function isHex(s) {
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s.trim());
}
function hexToRgb(hex) {
const m = String(hex).trim().toLowerCase().replace(/^#/, '');
const full = m.length === 3 ? m.split('').map((c) => c + c).join('') : m.slice(0, 6);
return {
r: parseInt(full.slice(0, 2), 16) || 0,
g: parseInt(full.slice(2, 4), 16) || 0,
b: parseInt(full.slice(4, 6), 16) || 0,
};
}
function rgbToHex({ r, g, b }) {
return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
}
function opSharpenRadii(design, factor = 0.5) {
const radii = design.borders?.radii || [];
const next = radii.map((r) => ({ ...r, value: Math.max(0, Math.round((r.value || 0) * factor)) }));
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
return {
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
changes: ['radii sharpened', ...changes],
};
}
function opSoftenRadii(design, factor = 2) {
const radii = design.borders?.radii || [];
const next = radii.map((r) => ({ ...r, value: Math.min(64, Math.round((r.value || 0) * factor) || 4) }));
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
return {
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
changes: ['radii softened', ...changes],
};
}
function opDarkMode(design) {
const colors = design.colors || {};
const bgs = colors.backgrounds || ['#ffffff'];
const txt = colors.text || ['#171717'];
const swapped = { ...colors, backgrounds: txt.slice(), text: bgs.slice() };
return {
design: { ...design, colors: swapped },
changes: [
`background: ${bgs[0]}${txt[0]}`,
`foreground: ${txt[0]}${bgs[0]}`,
],
};
}
function opMakeBrutalist(design) {
const radii = (design.borders?.radii || []).map((r) => ({ ...r, value: 0 }));
const shadows = (design.shadows?.values || []).map((s) => ({
...s,
raw: '4px 4px 0 0 currentColor',
value: '4px 4px 0 0 currentColor',
}));
const families = (design.typography?.families || []).slice();
const monoFam = families.find((f) => /mono|consol|courier|jet|sf-mono|geist mono/i.test(f.name)) || { name: 'JetBrains Mono', count: 1, weights: [400] };
return {
design: {
...design,
borders: { ...(design.borders || {}), radii },
shadows: { ...(design.shadows || {}), values: shadows },
typography: {
...(design.typography || {}),
families: [monoFam, ...families.filter((f) => f !== monoFam)].slice(0, 3),
},
materialLanguage: { ...(design.materialLanguage || {}), label: 'brutalist', confidence: 1.0 },
},
changes: [
'radii → 0 (sharp corners)',
'shadows → hard offset (4px 4px 0 0)',
'primary font → mono',
'material → brutalist',
],
};
}
function opMakeGlass(design) {
const radii = (design.borders?.radii || []).map((r) => ({
...r,
value: Math.max(r.value || 8, 16),
}));
const shadows = (design.shadows?.values || []).map((s, i) => ({
...s,
raw: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
value: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
}));
return {
design: {
...design,
borders: { ...(design.borders || {}), radii },
shadows: { ...(design.shadows || {}), values: shadows },
materialLanguage: { ...(design.materialLanguage || {}), label: 'glass', confidence: 1.0 },
},
changes: [
'radii ≥ 16px (rounded)',
'shadows → soft, depth-stacked',
'material → glass',
],
};
}
function opSwapColor(design, role, hex) {
if (!isHex(hex)) return { design, changes: [`error: ${hex} is not a hex color`] };
const colors = { ...(design.colors || {}) };
const before = colors[role]?.hex;
if (!before) {
return { design, changes: [`error: no ${role} color in this extraction (try primary, secondary, accent)`] };
}
const next = { ...colors[role], hex };
return {
design: { ...design, colors: { ...colors, [role]: next } },
changes: [`${role}: ${before}${hex}`],
};
}
function opSwapFont(design, name) {
const families = (design.typography?.families || []).slice();
const before = families[0]?.name || '—';
const replaced = [{ name, count: families[0]?.count || 0, weights: families[0]?.weights || [400, 600] }, ...families.slice(1)];
return {
design: { ...design, typography: { ...(design.typography || {}), families: replaced } },
changes: [`primary font: ${before}${name}`],
};
}
function opReset(_design, original) {
return { design: structuredClone(original), changes: ['reset to original extraction'] };
}
function parseCommand(line) {
const s = String(line).trim().toLowerCase();
if (!s) return null;
if (s === 'help' || s === '?') return { kind: 'help' };
if (s === 'quit' || s === 'exit' || s === ':q') return { kind: 'quit' };
if (s === 'reset' || s === 'undo all') return { kind: 'reset' };
if (s === 'save' || s === 'export' || s === 'write') return { kind: 'save' };
if (s === 'show' || s === 'print' || s === 'state') return { kind: 'state' };
if (s.startsWith('show ') || s.startsWith('print ')) {
return { kind: 'show', what: s.split(/\s+/)[1] };
}
if (/(make it |make this |go )?brutalist/.test(s)) return { kind: 'op', op: 'brutalist' };
if (/(make it |make this |go )?glass(morph)?/.test(s)) return { kind: 'op', op: 'glass' };
if (/(dark mode|dark theme|invert|go dark)/.test(s)) return { kind: 'op', op: 'dark' };
if (/sharp(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'sharpen' };
if (/(soft|round)(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'soften' };
const colorRe = /(primary|secondary|accent)\s*(?:to|=|:)?\s*(#[0-9a-f]{3,8})/i;
const cm = colorRe.exec(line);
if (cm) return { kind: 'op', op: 'swap-color', role: cm[1].toLowerCase(), hex: cm[2] };
const fontRe = /(?:font|typeface)\s*(?:to|=|:)?\s*([A-Za-z][\w\s-]{1,40})/i;
const fm = fontRe.exec(line);
if (fm) return { kind: 'op', op: 'swap-font', name: fm[1].trim() };
return { kind: 'unknown', input: line };
}
function printHelp() {
console.log('');
console.log(chalk.bold(' Commands:'));
const rows = [
['sharpen / soften', 'halve / double every radius'],
['dark mode', 'swap background ↔ foreground'],
['brutalist', 'radii → 0, hard shadows, mono font'],
['glass', 'rounded radii, soft layered shadows'],
['primary #ff4800', 'swap a role color (primary | secondary | accent)'],
['font Inter', 'swap the primary font family'],
['show / state', 'print current palette + tokens'],
['reset', 'restore the original extraction'],
['save', 'write DTCG, Tailwind, CSS vars, DESIGN.md to ./chat-output'],
['quit', 'exit'],
];
for (const [cmd, desc] of rows) {
console.log(' ' + chalk.cyan(cmd.padEnd(28)) + chalk.gray(desc));
}
console.log('');
}
function printState(design) {
const c = design.colors || {};
const t = design.typography || {};
const r = design.borders?.radii || [];
console.log('');
console.log(chalk.bold(' Current state'));
console.log(' ' + chalk.gray('palette:'.padEnd(14)) + [c.primary?.hex, c.secondary?.hex, c.accent?.hex, c.backgrounds?.[0], c.text?.[0]].filter(Boolean).join(' · '));
console.log(' ' + chalk.gray('font:'.padEnd(14)) + (t.families?.[0]?.name || '—'));
console.log(' ' + chalk.gray('radii:'.padEnd(14)) + (r.map((x) => `${x.label || '?'}=${x.value}`).join(' · ') || '—'));
console.log(' ' + chalk.gray('material:'.padEnd(14)) + (design.materialLanguage?.label || 'flat'));
console.log('');
}
function applyOp(parsed, current) {
switch (parsed.op) {
case 'sharpen': return opSharpenRadii(current);
case 'soften': return opSoftenRadii(current);
case 'dark': return opDarkMode(current);
case 'brutalist': return opMakeBrutalist(current);
case 'glass': return opMakeGlass(current);
case 'swap-color': return opSwapColor(current, parsed.role, parsed.hex);
case 'swap-font': return opSwapFont(current, parsed.name);
default: return { design: current, changes: ['no-op'] };
}
}
function saveDesign(design, outDir) {
mkdirSync(outDir, { recursive: true });
const url = design.meta?.url || 'extraction';
const prefix = nameFromUrl(url);
const dtcg = formatDtcgTokens(design);
const written = [];
const write = (name, content) => {
const p = join(outDir, name);
writeFileSync(p, content, 'utf-8');
written.push(p);
};
write(`${prefix}-design-tokens.json`, JSON.stringify(dtcg, null, 2));
write(`${prefix}-tailwind.config.js`, formatTailwind(design));
write(`${prefix}-variables.css`, formatCssVars(design));
write(`${prefix}-DESIGN.md`, formatDesignMd(design));
return written;
}
function synthesizeDesignFromTokens(tokens, sourcePath) {
const findHex = (...paths) => {
for (const p of paths) {
const parts = p.split('.');
let v = tokens;
for (const k of parts) {
v = v?.[k];
if (!v) break;
}
if (v && typeof v.$value === 'string') return v.$value;
}
return null;
};
const primary = findHex('color.primary', 'primitive.color.brand.primary', 'primitive.color.primary');
const secondary = findHex('color.secondary');
const accent = findHex('color.accent', 'primitive.color.brand.accent');
const bg = findHex('color.background', 'primitive.color.background.bg0', 'primitive.color.neutral.n100');
const fg = findHex('color.foreground', 'primitive.color.text.text0', 'primitive.color.foreground');
return {
meta: { url: `file://${sourcePath}`, title: 'imported tokens' },
colors: {
primary: primary ? { hex: primary, count: 1 } : null,
secondary: secondary ? { hex: secondary, count: 1 } : null,
accent: accent ? { hex: accent, count: 1 } : null,
backgrounds: bg ? [bg] : ['#ffffff'],
text: fg ? [fg] : ['#171717'],
neutrals: [],
all: [],
},
typography: { families: [{ name: 'system-ui', count: 1, weights: [400, 600] }], headings: [], body: { size: 16 } },
spacing: { base: 4, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
shadows: { values: [{ label: 'md', raw: '0 4px 6px rgba(0,0,0,0.1)', value: '0 4px 6px rgba(0,0,0,0.1)' }] },
borders: { radii: [{ label: 'md', value: 8 }] },
breakpoints: [],
components: {},
variables: {},
materialLanguage: { label: 'flat', confidence: 0.5 },
};
}
export async function runChat(target, opts = {}) {
const outDir = resolve(opts.out || './chat-output');
let design;
if (target && /\.json$/.test(target) && existsSync(target)) {
console.log(chalk.gray(` Loading tokens from ${target}`));
const tokens = JSON.parse(readFileSync(target, 'utf-8'));
design = synthesizeDesignFromTokens(tokens, target);
} else {
let url = String(target);
if (!url.startsWith('http')) url = `https://${url}`;
console.log(chalk.gray(` Extracting ${url}… (this takes a few seconds)`));
design = await extractDesignLanguage(url);
}
const original = structuredClone(design);
console.log('');
console.log(chalk.bold(' designlang chat'));
console.log(chalk.gray(' type "help" for commands · Ctrl+D to quit'));
printState(design);
const rl = createInterface({ input, output, prompt: chalk.gray('> ') });
rl.prompt();
for await (const line of rl) {
const parsed = parseCommand(line);
if (!parsed) { rl.prompt(); continue; }
if (parsed.kind === 'help') { printHelp(); rl.prompt(); continue; }
if (parsed.kind === 'quit') { rl.close(); break; }
if (parsed.kind === 'state' || parsed.kind === 'show') { printState(design); rl.prompt(); continue; }
if (parsed.kind === 'reset') {
const r = opReset(design, original);
design = r.design;
r.changes.forEach((c) => console.log(' ' + chalk.gray('•') + ' ' + c));
printState(design);
rl.prompt();
continue;
}
if (parsed.kind === 'save') {
const files = saveDesign(design, outDir);
console.log('');
for (const f of files) console.log(' ' + chalk.green('✓') + ' ' + f);
console.log('');
rl.prompt();
continue;
}
if (parsed.kind === 'unknown') {
console.log(chalk.yellow(` Didn't catch that. Try "help" for commands.`));
rl.prompt();
continue;
}
if (parsed.kind === 'op') {
const r = applyOp(parsed, design);
design = r.design;
console.log('');
r.changes.forEach((c) => console.log(' ' + chalk.green('•') + ' ' + c));
console.log('');
rl.prompt();
}
}
console.log('');
console.log(chalk.gray(' bye'));
}
+9 -90
View File
@@ -9,24 +9,10 @@
// { type:'error', error } — terminal failure
import { extractDesignLanguage } from '../../../../src/index.js';
import { formatMarkdown } from '../../../../src/formatters/markdown.js';
import { formatDesignMd } from '../../../../src/formatters/design-md.js';
import { formatTailwind } from '../../../../src/formatters/tailwind.js';
import { formatCssVars } from '../../../../src/formatters/css-vars.js';
import { formatPreview } from '../../../../src/formatters/preview.js';
import { formatFigma } from '../../../../src/formatters/figma.js';
import { formatReactTheme, formatShadcnTheme } from '../../../../src/formatters/theme.js';
import { formatWordPress, formatWordPressTheme } from '../../../../src/formatters/wordpress.js';
import { formatDtcgTokens } from '../../../../src/formatters/dtcg-tokens.js';
import { formatIosSwiftUI } from '../../../../src/formatters/ios-swiftui.js';
import { formatAndroidCompose } from '../../../../src/formatters/android-compose.js';
import { formatFlutterDart } from '../../../../src/formatters/flutter-dart.js';
import { formatAgentRules } from '../../../../src/formatters/agent-rules.js';
import { nameFromUrl } from '../../../../src/utils.js';
import { validateTargetUrl } from '../../../../website/lib/url-safety.js';
import { checkRate, checkRateBlob } from '../../../../website/lib/rate-limit.js';
import { cacheKey, getCached, putCached } from '../../../../website/lib/cache.js';
import { buildFiles, buildSummary } from '../../../../website/lib/build-files.js';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -83,79 +69,6 @@ function* walkDtcgTokens(tree, path = []) {
}
}
function buildSummary(design) {
return {
url: design.meta?.url,
title: design.meta?.title,
colors: design.colors?.all?.length ?? 0,
colorList: (design.colors?.all || []).slice(0, 20).map((c) => c.hex),
fonts: design.typography?.families?.map((f) => f.name).join(', ') || 'none detected',
spacingCount: design.spacing?.scale?.length ?? 0,
spacingBase: design.spacing?.base ?? null,
shadowCount: design.shadows?.values?.length ?? 0,
radiiCount: design.borders?.radii?.length ?? 0,
componentCount: Object.keys(design.components || {}).length,
cssVarCount: Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v).length, 0),
a11yScore: design.accessibility?.score ?? null,
a11yFailCount: design.accessibility?.failCount ?? 0,
score: design.score,
};
}
function buildFiles(design, targetUrl) {
const prefix = nameFromUrl(targetUrl);
const dtcg = formatDtcgTokens(design);
const dtcgJson = JSON.stringify(dtcg, null, 2);
const files = {
[`${prefix}-DESIGN.md`]: formatDesignMd(design),
[`${prefix}-design-language.md`]: formatMarkdown(design),
[`${prefix}-design-tokens.json`]: dtcgJson,
[`${prefix}-tailwind.config.js`]: formatTailwind(design),
[`${prefix}-variables.css`]: formatCssVars(design),
[`${prefix}-preview.html`]: formatPreview(design),
[`${prefix}-figma-variables.json`]: formatFigma(design),
[`${prefix}-theme.js`]: formatReactTheme(design),
[`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
[`${prefix}-wordpress-theme.json`]: formatWordPress(design),
};
// MCP companion JSON — same subset the CLI writes.
files[`${prefix}-mcp.json`] = JSON.stringify({
colors: { all: design.colors?.all || [] },
regions: design.regions || [],
componentClusters: design.componentClusters || [],
accessibility: { remediation: design.accessibility?.remediation || [] },
cssHealth: design.cssHealth || null,
}, null, 2);
// iOS
files['ios/DesignTokens.swift'] = formatIosSwiftUI(dtcg);
// Android (returns { filename: content })
const android = formatAndroidCompose(dtcg);
for (const name of Object.keys(android)) {
files[`android/${name}`] = android[name];
}
// Flutter
files['flutter/design_tokens.dart'] = formatFlutterDart(dtcg);
// WordPress block theme (5 files)
const wpTheme = formatWordPressTheme(dtcg, design);
for (const name of Object.keys(wpTheme)) {
files[`wordpress-theme/${name}`] = wpTheme[name];
}
// Agent rules
const agentFiles = formatAgentRules({ design, tokens: dtcg, url: targetUrl });
for (const name of Object.keys(agentFiles)) {
files[name] = agentFiles[name];
}
return { files, dtcg };
}
function extractIp(request) {
const xff = request.headers.get('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
@@ -165,8 +78,11 @@ function extractIp(request) {
}
// Emit cached payload as a simulated stream so the hero paints consistently.
async function streamCached(controller, cached, targetUrl) {
async function streamCached(controller, cached, targetUrl, hash) {
controller.enqueue(ndjson({ type: 'cache', cached: true }));
// Permalink up front — the client can rewrite the URL bar to /x/<hash>
// before any heavy paint, so refresh-and-share works during the stream.
controller.enqueue(ndjson({ type: 'permalink', hash }));
for (const stage of STAGES) {
controller.enqueue(ndjson({ type: 'stage', name: stage }));
await new Promise((r) => setTimeout(r, 40));
@@ -231,12 +147,15 @@ export async function POST(request) {
async start(controller) {
try {
if (cached) {
await streamCached(controller, cached, targetUrl);
await streamCached(controller, cached, targetUrl, key);
controller.close();
return;
}
// Pre-stage markers — best-effort progress since extraction is atomic.
// Emit the permalink hash early so the URL bar can rewrite to /x/<hash>
// before the heavy paint begins.
controller.enqueue(ndjson({ type: 'permalink', hash: key }));
controller.enqueue(ndjson({ type: 'stage', name: 'crawl' }));
const browserOpts = await getBrowserOptions();
+9
View File
@@ -71,6 +71,15 @@ export default function HeroExtractor() {
case 'cache':
setCached(true);
break;
case 'permalink': {
// Rewrite the URL bar so a refresh / share lands on /x/<hash>.
// history.replaceState avoids a Next router navigation (we want to keep
// the live extraction state, just change the URL).
if (typeof window !== 'undefined' && event.hash) {
try { window.history.replaceState({}, '', `/x/${event.hash}`); } catch {}
}
break;
}
case 'stage':
setStage(event.name);
break;
+127
View File
@@ -0,0 +1,127 @@
import { listRecent } from '../../lib/cache';
export const dynamic = 'force-dynamic';
export const revalidate = 600; // 10 min — gallery shouldn't be hammered live
export const metadata = {
title: 'Gallery — design systems extracted by designlang',
description:
'Public gallery of recent extractions. Every entry is a permalink: tap a card to read the DESIGN.md, browse DTCG tokens, copy the Tailwind config, or download the full bundle. $0, MIT, no signup.',
alternates: { canonical: 'https://designlang.app/gallery' },
openGraph: {
title: 'designlang gallery',
description: 'Recent extractions — every card is a shareable permalink.',
},
};
function relTime(ms) {
const s = Math.floor((Date.now() - ms) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function host(url) {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; }
}
export default async function Gallery() {
const entries = await listRecent(48);
return (
<main className="page" style={{ paddingBottom: 'var(--r9)' }}>
<header style={{ paddingTop: 'var(--r4)', paddingBottom: 'var(--r5)' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 'var(--r5)',
borderBottom: 'var(--hair)',
paddingBottom: 'var(--r3)',
}}
>
<a href="/" className="mono" style={{ fontSize: 13, letterSpacing: '0.02em', borderBottom: 0, display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/mark.svg" alt="" width={22} height={22} style={{ display: 'block' }} />
designlang <span style={{ color: 'var(--ink-3)', marginLeft: 12 }}>v12</span>
</a>
<nav className="mono" style={{ display: 'flex', gap: 'var(--r5)', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
<a href="/" style={{ borderBottom: 0 }}>Home</a>
<a href="/features" style={{ borderBottom: 0 }}>Features</a>
<a href="/spec" style={{ borderBottom: 0 }}>Spec</a>
<a href="/vs/design-extractor" style={{ borderBottom: 0, color: 'var(--accent)' }}>vs</a>
</nav>
</div>
</header>
<section style={{ paddingBlock: 'var(--r7) var(--r6)' }}>
<div className="section-label" style={{ marginBottom: 'var(--r5)' }}>
<span>§ gallery</span>
</div>
<h1 className="display" style={{ fontSize: 'clamp(40px, 6vw, 72px)', letterSpacing: '-0.03em', lineHeight: 1.0 }}>
Every extraction,<br />
a <em style={{ color: 'var(--accent)', fontStyle: 'italic' }}>permalink</em>.
</h1>
<p className="prose" style={{ fontSize: 17, lineHeight: 1.55, color: 'var(--ink-2)', maxWidth: '52ch', marginTop: 'var(--r4)' }}>
A live feed of sites people have run through designlang. Each card opens a full
shareable view DESIGN.md, DTCG tokens, Tailwind, all 12 outputs, copy &amp; download.
</p>
</section>
{entries.length === 0 ? (
<div className="mono" style={{ padding: 'var(--r7) 0', color: 'var(--ink-3)', fontSize: 13, letterSpacing: '0.04em' }}>
No recent extractions yet. <a href="/" style={{ color: 'var(--accent)' }}>Run the first one </a>
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 0,
borderTop: 'var(--hair)',
borderLeft: 'var(--hair)',
}}
>
{entries.map((e) => (
<a
key={e.hash}
href={`/x/${e.hash}`}
style={{
display: 'block',
padding: 'var(--r4)',
borderBottom: '1px solid var(--ink)',
borderRight: '1px solid var(--ink)',
borderBottom_: '1px solid var(--ink)',
background: 'var(--paper)',
color: 'var(--ink)',
borderBottomStyle: 'solid',
textDecoration: 'none',
}}
>
{/* Palette strip — paints the card identity in 2s. */}
<div style={{ display: 'flex', height: 56, marginBottom: 'var(--r3)', border: '1px solid var(--ink)' }}>
{[e.primary, e.secondary, e.accent, e.foreground, e.background]
.filter(Boolean)
.map((c, i) => (
<div key={i} style={{ flex: 1, background: c }} />
))}
</div>
<div className="display" style={{ fontSize: 18, letterSpacing: '-0.01em', lineHeight: 1.2, marginBottom: 4, wordBreak: 'break-word' }}>
{host(e.url)}
</div>
<div className="mono" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 'var(--r3)' }}>
{[e.intent, e.material, e.library].filter((x) => x && x !== 'unknown').join(' · ') || '—'}
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--ink-2)', display: 'flex', justifyContent: 'space-between', borderTop: '1px solid var(--paper-3)', paddingTop: 8 }}>
<span>{e.colors} colors</span>
<span style={{ color: 'var(--ink-3)' }}>{relTime(e.generatedAt)}</span>
</div>
</a>
))}
</div>
)}
</main>
);
}
+3 -2
View File
@@ -20,17 +20,18 @@ export default function Home() {
<img src="/mark.svg" alt="" width={22} height={22} style={{ display: 'block' }} />
<span className="mono" style={{ fontSize: 13, letterSpacing: '0.02em' }}>
designlang
<span style={{ color: 'var(--ink-3)', marginLeft: 12 }}>v11</span>
<span style={{ color: 'var(--ink-3)', marginLeft: 12 }}>v12</span>
</span>
</span>
<nav
className="mono"
style={{ display: 'flex', gap: 'var(--r5)', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}
>
<a href="/gallery" style={{ borderBottom: 0 }}>Gallery</a>
<a href="/spec" style={{ borderBottom: 0 }}>Spec</a>
<a href="/features" style={{ borderBottom: 0 }}>Features</a>
<a href="/vs/design-extractor" style={{ borderBottom: 0, color: 'var(--accent)' }}>vs</a>
<a href="https://github.com/Manavarya09/design-extract" style={{ borderBottom: 0 }}>GitHub</a>
<a href="https://www.npmjs.com/package/designlang" style={{ borderBottom: 0 }}>npm</a>
</nav>
</div>
</header>
+5 -3
View File
@@ -3,9 +3,11 @@ import { SITE_URL } from './seo-config';
export default function sitemap() {
const now = new Date();
const routes = [
{ path: '', priority: 1.0, freq: 'daily' },
{ path: '/features', priority: 0.9, freq: 'weekly' },
{ path: '/vs/design-extractor', priority: 0.9, freq: 'weekly' },
{ path: '', priority: 1.0, freq: 'daily' },
{ path: '/features', priority: 0.9, freq: 'weekly' },
{ path: '/gallery', priority: 0.9, freq: 'hourly' },
{ path: '/spec', priority: 0.9, freq: 'monthly' },
{ path: '/vs/design-extractor', priority: 0.9, freq: 'weekly' },
];
return routes.map(({ path, priority, freq }) => ({
url: `${SITE_URL}${path}`,
+157
View File
@@ -0,0 +1,157 @@
export const metadata = {
title: 'DESIGN.md spec — designlang',
description:
'The DESIGN.md format spec — single-file, 8-section, YAML front matter agent-native design system artifact. Open spec, MIT-licensed reference implementation, embeddable Verified badge.',
alternates: { canonical: 'https://designlang.app/spec' },
openGraph: {
title: 'DESIGN.md — open spec',
description: 'The agent-native design-system artifact, formalized.',
},
};
export default function Spec() {
return (
<main className="page" style={{ paddingBottom: 'var(--r9)', maxWidth: '72ch', margin: '0 auto' }}>
<header style={{ paddingTop: 'var(--r4)', paddingBottom: 'var(--r5)' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 'var(--r5)',
borderBottom: 'var(--hair)',
paddingBottom: 'var(--r3)',
}}
>
<a href="/" className="mono" style={{ fontSize: 13, letterSpacing: '0.02em', borderBottom: 0, display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/mark.svg" alt="" width={22} height={22} style={{ display: 'block' }} />
designlang <span style={{ color: 'var(--ink-3)', marginLeft: 12 }}>v12</span>
</a>
<nav className="mono" style={{ display: 'flex', gap: 'var(--r5)', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
<a href="/" style={{ borderBottom: 0 }}>Home</a>
<a href="/gallery" style={{ borderBottom: 0 }}>Gallery</a>
<a href="/features" style={{ borderBottom: 0 }}>Features</a>
<a href="/vs/design-extractor" style={{ borderBottom: 0, color: 'var(--accent)' }}>vs</a>
</nav>
</div>
</header>
<article style={{ paddingBlock: 'var(--r6)', fontSize: 16, lineHeight: 1.65 }}>
<div className="section-label" style={{ marginBottom: 'var(--r4)' }}>
<span>§ DESIGN.md spec v1.0</span>
</div>
<h1 className="display" style={{ fontSize: 'clamp(40px, 6vw, 72px)', letterSpacing: '-0.03em', lineHeight: 1.0, marginBottom: 'var(--r5)' }}>
The agent-native design-system artifact, <em style={{ color: 'var(--accent)', fontStyle: 'italic' }}>formalized</em>.
</h1>
<p style={{ marginBottom: 'var(--r4)', color: 'var(--ink-2)' }}>
A <code className="mono">DESIGN.md</code> is a single Markdown file at the root of a repo (or alongside an extraction) that
captures a website&rsquo;s design system in a form an LLM can read and an engineer can edit. It is the design
counterpart to <code className="mono">AGENTS.md</code>.
</p>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>Structure</h2>
<p>A valid <code className="mono">DESIGN.md</code> has two layers:</p>
<ol style={{ paddingLeft: 22, marginBottom: 'var(--r4)' }}>
<li><strong>YAML front matter</strong> holding the machine-readable token snapshot colors, typography, spacing, radii, shadows.</li>
<li><strong>Markdown body</strong> with up to eight canonical sections, in this order, any of which may be omitted if not relevant.</li>
</ol>
<h3 className="display" style={{ fontSize: 21, marginTop: 'var(--r5)', marginBottom: 'var(--r3)' }}>The eight canonical sections</h3>
<ol style={{ paddingLeft: 22, marginBottom: 'var(--r4)' }}>
<li><strong>Overview</strong> a one-paragraph design rationale plus page intent and material language.</li>
<li><strong>Colors</strong> palette table (role · hex · usage), neutrals, total unique count.</li>
<li><strong>Typography</strong> families, weights, body size, heading scale.</li>
<li><strong>Layout</strong> spacing base + scale, breakpoints, grid/flex primitive counts.</li>
<li><strong>Elevation and Depth</strong> shadow scale and z-index layers.</li>
<li><strong>Shapes</strong> border-radius scale.</li>
<li><strong>Components</strong> detected patterns and anatomy table (kind · variants · sizes · instances).</li>
<li><strong>Do&rsquo;s and Don&rsquo;ts</strong> actionable directional guidance derived from voice + lint findings.</li>
</ol>
<p>Sections may be added (e.g. <em>Motion</em>, <em>Voice</em>, <em>Imagery</em>) but should appear after the eight canonical ones.</p>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>Front-matter schema</h2>
<pre className="mono" style={{ background: 'var(--ink)', color: 'var(--paper)', padding: 'var(--r4) var(--r5)', overflowX: 'auto', fontSize: 12.5, lineHeight: 1.7 }}>
{`site: string # canonical name
url: string # source URL (preferred)
generated_at: ISO 8601 # UTC timestamp
generator: "tool@vX.Y" # tool identifier
intent: string # landing | pricing | docs | blog | product |
material: string # flat | brutalist | glass | soft-ui | material-you |
library: string? # shadcn/ui | radix | mui | chakra | tailwind-ui |
tokens:
colors:
primary: "#hex"
secondary?: "#hex"
accent?: "#hex"
background: "#hex"
foreground: "#hex"
typography:
sans: string
mono?: string
base: integer # px
spacing:
base?: integer # px
scale: integer[]
radii?:
xs|sm|md|lg|xl|full: integer # px
shadows?:
sm|md|lg|xl: string # raw box-shadow value`}
</pre>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>Reference implementation</h2>
<p>
designlang ships an MIT-licensed, dependency-light reference emitter at{' '}
<a className="mono" href="https://github.com/Manavarya09/design-extract/blob/main/src/formatters/design-md.js" style={{ borderBottom: '1px solid currentColor' }}>
src/formatters/design-md.js
</a>
. Generate a sample with:
</p>
<pre className="mono" style={{ background: 'var(--ink)', color: 'var(--accent)', padding: 'var(--r4) var(--r5)', overflowX: 'auto', fontSize: 13 }}>
{`npx designlang stripe.com # writes stripe-com-DESIGN.md`}
</pre>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>The Verified badge</h2>
<p style={{ marginBottom: 'var(--r4)' }}>
Sites that publish their own <code className="mono">DESIGN.md</code> at <code className="mono">/DESIGN.md</code> may embed the
following SVG badge a one-shot signal to crawlers, AI agents, and design-system tooling that the
spec is honored:
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: 'var(--r4)',
background: 'var(--paper-2)',
border: '1px solid var(--ink)',
marginBottom: 'var(--r4)',
flexWrap: 'wrap',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/badge.svg" alt="DESIGN.md verified" width={140} height={28} style={{ display: 'block' }} />
<code className="mono" style={{ fontSize: 11, color: 'var(--ink-2)', wordBreak: 'break-all' }}>
&lt;a href=&quot;https://designlang.app/spec&quot;&gt;&lt;img src=&quot;https://designlang.app/badge.svg&quot; alt=&quot;DESIGN.md verified&quot;/&gt;&lt;/a&gt;
</code>
</div>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>Compatibility</h2>
<p>
The spec was inspired by the <code className="mono">DESIGN.md</code> convention pioneered at{' '}
<a href="https://www.design-extractor.com" rel="nofollow noopener" style={{ borderBottom: '1px solid currentColor' }}>design-extractor.com</a>.
designlang adopts the eight-section structure verbatim and extends the front matter with the v10 semantic
layer (<code className="mono">intent</code>, <code className="mono">material</code>, <code className="mono">library</code>) so
downstream tools have richer ground truth without sacrificing single-file portability.
</p>
<h2 className="display" style={{ fontSize: 28, marginTop: 'var(--r6)', marginBottom: 'var(--r3)' }}>License</h2>
<p>
The spec is published under <a href="https://creativecommons.org/licenses/by/4.0/" rel="noopener" style={{ borderBottom: '1px solid currentColor' }}>CC BY 4.0</a> copy, fork, extend, embed.
Contributions welcome at <a className="mono" href="https://github.com/Manavarya09/design-extract" style={{ borderBottom: '1px solid currentColor' }}>github.com/Manavarya09/design-extract</a>.
</p>
</article>
</main>
);
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { useCallback, useState } from 'react';
import ResultViewer from '../../components/ResultViewer';
export default function PermalinkViewer({ hash, url, title, summary, files }) {
const [zipBusy, setZipBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const handleDownloadZip = useCallback(async () => {
setZipBusy(true);
try {
const { zipFilesToUrl } = await import('../../../lib/zip-files');
const { url: blobUrl, filename } = await zipFilesToUrl(files, {
name: `designlang-${hash.slice(0, 8)}`,
});
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} finally { setZipBusy(false); }
}, [files, hash]);
const sharePermalink = useCallback(async () => {
if (typeof window === 'undefined') return;
const link = `${window.location.origin}/x/${hash}`;
try {
await navigator.clipboard.writeText(link);
setShareCopied(true);
setTimeout(() => setShareCopied(false), 1500);
} catch {/* ignore */}
}, [hash]);
return (
<main className="page" style={{ paddingBottom: 'var(--r9)' }}>
<header style={{ paddingTop: 'var(--r4)', paddingBottom: 'var(--r5)' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 'var(--r5)',
borderBottom: 'var(--hair)',
paddingBottom: 'var(--r3)',
}}
>
<a href="/" className="mono" style={{ fontSize: 13, letterSpacing: '0.02em', borderBottom: 0, display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/mark.svg" alt="" width={22} height={22} style={{ display: 'block' }} />
designlang <span style={{ color: 'var(--ink-3)', marginLeft: 12 }}>v12</span>
</a>
<nav className="mono" style={{ display: 'flex', gap: 'var(--r5)', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
<a href="/" style={{ borderBottom: 0 }}>Home</a>
<a href="/gallery" style={{ borderBottom: 0 }}>Gallery</a>
<a href="/spec" style={{ borderBottom: 0 }}>Spec</a>
<a href="/features" style={{ borderBottom: 0 }}>Features</a>
<a href="/vs/design-extractor" style={{ borderBottom: 0, color: 'var(--accent)' }}>vs</a>
</nav>
</div>
</header>
<section style={{ paddingBlock: 'var(--r6) var(--r5)' }}>
<div className="section-label" style={{ marginBottom: 'var(--r4)' }}>
<span>§ permalink /x/{hash.slice(0, 8)}</span>
</div>
<h1 className="display" style={{ fontSize: 'clamp(28px, 4vw, 44px)', letterSpacing: '-0.025em', lineHeight: 1.1, marginBottom: 'var(--r3)', wordBreak: 'break-word' }}>
{title}
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--ink-3)', letterSpacing: '0.04em', marginBottom: 'var(--r4)', wordBreak: 'break-all' }}>
{url}
</p>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="button"
onClick={sharePermalink}
className="cta"
style={{ background: 'var(--paper)', color: 'var(--ink)', border: '1px solid var(--ink)', boxShadow: 'none' }}
>
{shareCopied ? 'Link copied' : 'Copy permalink'}
</button>
<a className="cta" href="/" style={{ background: 'transparent', color: 'var(--ink)', border: '1px solid var(--ink)', boxShadow: 'none', borderBottom: 'none' }}>
Extract another
</a>
</div>
{/* Stat strip */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: 'var(--r5)',
borderTop: 'var(--hair)',
borderBottom: 'var(--hair)',
marginTop: 'var(--r6)',
padding: 'var(--r5) 0',
}}
>
<Numeral value={summary.colors} label="colors" />
<Numeral value={summary.spacingCount} label="spacing" />
<Numeral value={summary.shadowCount} label="shadows" />
<Numeral value={summary.componentCount} label="components" />
<Numeral
value={summary.score?.overall ?? '—'}
label={`score ${summary.score?.grade ? `(${summary.score.grade})` : ''}`}
/>
</div>
</section>
<ResultViewer files={files} onDownloadZip={handleDownloadZip} downloadBusy={zipBusy} />
</main>
);
}
function Numeral({ value, label }) {
return (
<div>
<div className="display" style={{ fontSize: 'clamp(28px, 4vw, 48px)', lineHeight: 1 }}>
{value}
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6, textTransform: 'uppercase', letterSpacing: '0.1em' }}>
{label}
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from 'next/navigation';
import { getCachedByHash } from '../../../lib/cache';
import { buildFiles, buildSummary } from '../../../lib/build-files';
import PermalinkViewer from './PermalinkViewer';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function generateMetadata({ params }) {
const { hash } = await params;
const cached = await getCachedByHash(hash);
if (!cached) {
return {
title: 'Extraction not found — designlang',
description: 'This designlang permalink has expired or never existed. Run a fresh extraction at designlang.app.',
};
}
const { design } = cached;
const url = design.meta?.url || '';
const title = design.meta?.title || url;
const palette = (design.colors?.all || []).slice(0, 5).map((c) => c.hex).join(' · ');
return {
title: `${title} — design system extracted by designlang`,
description: `${url} · ${design.colors?.all?.length ?? 0} colors · ${design.typography?.families?.[0]?.name || 'system'} · ${design.materialLanguage?.label || 'flat'} material · palette ${palette}.`,
alternates: { canonical: `https://designlang.app/x/${hash}` },
openGraph: {
title: `${title} — designlang`,
description: `Design system extracted from ${url}.`,
url: `https://designlang.app/x/${hash}`,
},
};
}
export default async function PermalinkPage({ params }) {
const { hash } = await params;
const cached = await getCachedByHash(hash);
if (!cached) notFound();
const { design } = cached;
const url = design.meta?.url || '';
const { files } = buildFiles(design, url);
const summary = buildSummary(design);
return <PermalinkViewer hash={hash} url={url} title={design.meta?.title || url} summary={summary} files={files} />;
}
+84
View File
@@ -0,0 +1,84 @@
// Shared file-builder used by /api/extract (streaming) and /x/[hash]
// (permalink page). Pure function over a `design` object.
import { formatMarkdown } from '../../src/formatters/markdown.js';
import { formatDesignMd } from '../../src/formatters/design-md.js';
import { formatTailwind } from '../../src/formatters/tailwind.js';
import { formatCssVars } from '../../src/formatters/css-vars.js';
import { formatPreview } from '../../src/formatters/preview.js';
import { formatFigma } from '../../src/formatters/figma.js';
import { formatReactTheme, formatShadcnTheme } from '../../src/formatters/theme.js';
import { formatWordPress, formatWordPressTheme } from '../../src/formatters/wordpress.js';
import { formatDtcgTokens } from '../../src/formatters/dtcg-tokens.js';
import { formatIosSwiftUI } from '../../src/formatters/ios-swiftui.js';
import { formatAndroidCompose } from '../../src/formatters/android-compose.js';
import { formatFlutterDart } from '../../src/formatters/flutter-dart.js';
import { formatAgentRules } from '../../src/formatters/agent-rules.js';
import { nameFromUrl } from '../../src/utils.js';
export function buildFiles(design, targetUrl) {
const prefix = nameFromUrl(targetUrl);
const dtcg = formatDtcgTokens(design);
const dtcgJson = JSON.stringify(dtcg, null, 2);
const files = {
[`${prefix}-DESIGN.md`]: formatDesignMd(design),
[`${prefix}-design-language.md`]: formatMarkdown(design),
[`${prefix}-design-tokens.json`]: dtcgJson,
[`${prefix}-tailwind.config.js`]: formatTailwind(design),
[`${prefix}-variables.css`]: formatCssVars(design),
[`${prefix}-preview.html`]: formatPreview(design),
[`${prefix}-figma-variables.json`]: formatFigma(design),
[`${prefix}-theme.js`]: formatReactTheme(design),
[`${prefix}-shadcn-theme.css`]: formatShadcnTheme(design),
[`${prefix}-wordpress-theme.json`]: formatWordPress(design),
};
files[`${prefix}-mcp.json`] = JSON.stringify({
colors: { all: design.colors?.all || [] },
regions: design.regions || [],
componentClusters: design.componentClusters || [],
accessibility: { remediation: design.accessibility?.remediation || [] },
cssHealth: design.cssHealth || null,
}, null, 2);
files['ios/DesignTokens.swift'] = formatIosSwiftUI(dtcg);
const android = formatAndroidCompose(dtcg);
for (const name of Object.keys(android)) {
files[`android/${name}`] = android[name];
}
files['flutter/design_tokens.dart'] = formatFlutterDart(dtcg);
const wpTheme = formatWordPressTheme(dtcg, design);
for (const name of Object.keys(wpTheme)) {
files[`wordpress-theme/${name}`] = wpTheme[name];
}
const agentFiles = formatAgentRules({ design, tokens: dtcg, url: targetUrl });
for (const name of Object.keys(agentFiles)) {
files[name] = agentFiles[name];
}
return { files, dtcg, prefix };
}
export function buildSummary(design) {
return {
url: design.meta?.url,
title: design.meta?.title,
colors: design.colors?.all?.length ?? 0,
colorList: (design.colors?.all || []).slice(0, 20).map((c) => c.hex),
fonts: design.typography?.families?.map((f) => f.name).join(', ') || 'none detected',
spacingCount: design.spacing?.scale?.length ?? 0,
spacingBase: design.spacing?.base ?? null,
shadowCount: design.shadows?.values?.length ?? 0,
radiiCount: design.borders?.radii?.length ?? 0,
componentCount: Object.keys(design.components || {}).length,
cssVarCount: Object.values(design.variables || {}).reduce((s, v) => s + Object.keys(v).length, 0),
a11yScore: design.accessibility?.score ?? null,
a11yFailCount: design.accessibility?.failCount ?? 0,
score: design.score,
};
}
+61
View File
@@ -71,3 +71,64 @@ export async function putCached(key, { design }) {
console.error('[cache] write failed', err?.message);
}
}
// ─── Public permalink helpers — used by /x/[hash] and /gallery ───
//
// Permalinks are forever (well, 24h while the Blob entry exists). The page
// re-derives files from the cached `design` object on each request — same
// pipeline the streaming /api/extract uses on a cache hit.
export async function getCachedByHash(hash) {
if (!/^[a-f0-9]{64}$/.test(hash)) return null;
const cached = await getCached(hash);
return cached;
}
export async function listRecent(limit = 24) {
if (!hasBlob()) return [];
try {
const { list } = await import('@vercel/blob');
// Pull more than we need so we can drop expired ones.
const result = await list({ prefix: 'extract-cache/', limit: limit * 2 });
const entries = await Promise.all(
(result.blobs || [])
.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
.slice(0, limit * 2)
.map(async (blob) => {
try {
const m = blob.pathname.match(/^extract-cache\/([a-f0-9]{64})\.json$/);
if (!m) return null;
const res = await fetch(blob.url, { cache: 'no-store' });
if (!res.ok) return null;
const payload = await res.json();
const generatedAt = typeof payload?.generatedAt === 'number' ? payload.generatedAt : 0;
if (Date.now() - generatedAt > TTL_MS) return null;
const design = payload?.design;
if (!design) return null;
return {
hash: m[1],
url: design.meta?.url || '',
title: design.meta?.title || '',
generatedAt,
colors: design.colors?.all?.length ?? 0,
primary: design.colors?.primary?.hex || null,
secondary: design.colors?.secondary?.hex || null,
accent: design.colors?.accent?.hex || null,
foreground: design.colors?.text?.[0] || null,
background: design.colors?.backgrounds?.[0] || null,
fontFamily: design.typography?.families?.[0]?.name || null,
intent: design.pageIntent?.type || null,
material: design.materialLanguage?.label || null,
library: design.componentLibrary?.library || null,
score: design.score?.overall ?? null,
grade: design.score?.grade ?? null,
};
} catch { return null; }
})
);
return entries.filter(Boolean).slice(0, limit);
} catch (err) {
console.error('[cache] listRecent failed', err?.message);
return [];
}
}
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="28" viewBox="0 0 160 28" role="img" aria-label="DESIGN.md verified">
<title>DESIGN.md verified</title>
<rect width="100" height="28" fill="#0a0908"/>
<rect x="100" width="60" height="28" fill="#ff4800"/>
<g font-family="ui-monospace, SFMono-Regular, 'JetBrains Mono', monospace" font-size="11" font-weight="500">
<text x="50" y="18" fill="#f3f1ea" text-anchor="middle" letter-spacing="0.06em">DESIGN.md</text>
<text x="130" y="18" fill="#0a0908" text-anchor="middle" letter-spacing="0.1em">VERIFIED</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 596 B