mirror of
https://github.com/Manavarya09/design-extract.git
synced 2026-09-19 02:41:14 +08:00
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:
@@ -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>')
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# designlang — Distribution Submission Playbook
|
||||
|
||||
Submit each marketplace below. All listing copy + manifests are pre-written.
|
||||
Review queues take 1–7 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: 3–7 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: 5–14 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: 1–3 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, 1–3 day review — submit today).
|
||||
3. **VS Code Marketplace** (free, ~24h review — submit today).
|
||||
4. **Figma Community** (free, 3–7 day review — submit today).
|
||||
5. **Claude Code Skills registry** (PR-based, async — submit this week).
|
||||
6. **Raycast Store** (PR-based, 5–14 day review — submit this week).
|
||||
|
||||
Total click-time across all six: ~90 minutes. The wall-clock for everything to be live: ~2 weeks.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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/`.
|
||||
@@ -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
@@ -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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & 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
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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’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’s and Don’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' }}>
|
||||
<a href="https://designlang.app/spec"><img src="https://designlang.app/badge.svg" alt="DESIGN.md verified"/></a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user