Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions scripts/design-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Design-sync bundle generator

Generates a [Claude Design](https://claude.ai/design) **design-system** bundle from
ScriptHammer's real DaisyUI components and brand themes, then you push it with Claude
Code's `/design-sync`.

## What this is (and isn't)

`/design-sync` does **not** publish an npm package or upload your `.tsx` source. It
uploads **self-contained preview HTML cards** into a claude.ai design-system project.
Each card's first line is a marker comment — `<!-- @dsCard group="Buttons" -->` — and
the Design System pane compiles those markers into its card index automatically.

ScriptHammer's atomic/presentational components style purely from DaisyUI classes +
the two brand themes (`scripthammer-dark`, `scripthammer-light`). So each card just
needs the brand CSS attached and the real DaisyUI class markup — no hand-written CSS,
full fidelity.

## Files

| File | Role |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `manifest.js` | Curated component list. Each entry's class strings are copied **verbatim** from the component source (e.g. `Button.tsx`'s `variantClasses`/`sizeClasses`) so cards stay faithful. Add a component here. |
| `gen-theme.js` | Compiles `theme.css` — `@tailwindcss/postcss` + DaisyUI scoped to the two brand themes only (the 32 stock themes are omitted). Reads `classes.txt` as the safelist. |
| `gen-cards.js` | Emits the `@dsCard` HTML. `--classes` writes the safelist; no arg emits cards with `theme.css` inlined, rendered **side-by-side dark + light**. |
| `build.js` | One-command orchestrator: classes → theme → cards into an output dir. |

## Build (Docker-first — required)

`@tailwindcss/postcss` + DaisyUI live in the container, so the build runs there:

```bash
docker compose exec -w /app scripthammer node scripts/design-sync/build.js
```

Output defaults to `/tmp/ds-bundle` **inside the container** (it's a build artifact,
not source — nothing is written into the repo). To get it onto the host, either copy
it out:

```bash
docker compose cp scripthammer:/tmp/ds-bundle ./ds-bundle
```

…or point `DS_OUT` at a path under the repo (which is bind-mounted at `/app`) — but
remember to keep it out of git:

```bash
DS_OUT=/app/.ds-bundle docker compose exec -w /app scripthammer \
node scripts/design-sync/build.js
```

## Push to claude.ai

From Claude Code, in this repo:

```
/design-sync
```

It will `list_projects` (first run grants design scope), `create_project "ScriptHammer"`
if none exists, `finalize_plan`, then `write_files` from the bundle dir. Re-running with
an existing project updates it incrementally — one component at a time is fine.

Plan paths to finalize: `theme.css`, `tokens/**`, `components/**/*.html`.

## Adding a component

1. Open the component's `.tsx`, copy its variant/size class maps verbatim.
2. Add a `render()` function + a manifest entry in `manifest.js` (pick a `group`).
3. Rebuild and eyeball before pushing:

```bash
docker compose exec -w /app scripthammer node scripts/design-sync/build.js
# screenshot a card to confirm it renders (OKLCH resolves, dark ≠ light):
docker compose exec scripthammer chromium --headless --no-sandbox \
--screenshot=/tmp/shot.png "file:///tmp/ds-bundle/components/<slug>/index.html"
```

## Scope

Synced: design tokens (colors, shape, typography) + the portable atomic/presentational
tier. **Excluded:** components whose visual output is state/data-driven (auth, payment,
admin, map, game, and anything importing `@/contexts`, `@/services`, fetching hooks, or
canvas/3D) — they have no honest static preview.

## Note on DaisyUI v5 drift

DaisyUI 5 (beta) renamed/removed several v4 classes the app still uses:
`card-bordered`→`card-border`, `card-compact`→`card-sm`, and `input-bordered` /
`form-control` / `label-text` were removed (the base `.input` is bordered; v5 uses
`.fieldset` + `.label`). The manifest uses the **v5** names so cards render correctly.
The app components still carry the v4 names (dead classes — harmless, but worth a future
cleanup pass).
69 changes: 69 additions & 0 deletions scripts/design-sync/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* build.js — one-command builder for the ScriptHammer design-sync bundle.
*
* Runs the full pipeline into an output dir:
* 1. gen-cards --classes → classes.txt (safelist)
* 2. gen-theme → theme.css (scoped DaisyUI + both brand themes)
* 3. gen-cards → token + component preview cards (@dsCard HTML)
*
* MUST run inside the scripthammer container (needs @tailwindcss/postcss + DaisyUI):
*
* docker compose exec -w /app scripthammer node scripts/design-sync/build.js
*
* Output defaults to /tmp/ds-bundle inside the container (NOT the repo — the
* cards + theme.css are build artifacts, not source). Override with DS_OUT:
*
* DS_OUT=/tmp/my-bundle docker compose exec -w /app scripthammer \
* node scripts/design-sync/build.js
*
* Then, from the host, push to claude.ai with Claude Code's /design-sync
* (copy the bundle out of the container first, or point DS_OUT at a bind mount).
* See scripts/design-sync/README.md.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const OUT = process.env.DS_OUT || '/tmp/ds-bundle';
const SCRIPTS = __dirname;

fs.mkdirSync(OUT, { recursive: true });

function run(label, args) {
process.stdout.write(`\n▶ ${label}\n`);
execFileSync('node', args, {
stdio: 'inherit',
env: { ...process.env, DS_OUT: OUT },
});
}

run('1/3 extract class safelist', [
path.join(SCRIPTS, 'gen-cards.js'),
'--classes',
]);
run('2/3 compile theme.css', [path.join(SCRIPTS, 'gen-theme.js')]);
run('3/3 emit preview cards', [path.join(SCRIPTS, 'gen-cards.js')]);

const cards = [];
for (const sub of ['tokens', 'components']) {
const dir = path.join(OUT, sub);
if (!fs.existsSync(dir)) continue;
const walk = (d) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, e.name);
if (e.isDirectory()) walk(p);
else if (e.name.endsWith('.html')) cards.push(path.relative(OUT, p));
}
};
walk(dir);
}

process.stdout.write(
`\n✓ bundle ready in ${OUT}\n theme.css + ${cards.length} cards:\n` +
cards
.sort()
.map((c) => ` ${c}`)
.join('\n') +
'\n'
);
161 changes: 161 additions & 0 deletions scripts/design-sync/gen-cards.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* gen-cards.js — emit the design-sync preview cards.
*
* Node stdlib only. Driven by manifest.js. Two modes:
*
* node gen-cards.js --classes -> write classes.txt (every class used by the
* cards) so gen-theme.js can safelist them.
* Run this FIRST, then gen-theme.js.
* node gen-cards.js -> emit token and component card HTML files
* with theme.css inlined and the @dsCard marker.
* Run this AFTER theme.css exists.
*
* Each card:
* - line 1 is <!-- @dsCard group="..." --> (compiled into _ds_manifest.json)
* - inlines theme.css in a <style> (fully self-contained)
* - renders the component twice, side by side: scripthammer-dark + scripthammer-light
*/
const fs = require('fs');
const path = require('path');
const M = require('./manifest');

// Output dir: DS_OUT env wins (the runner points this at a build dir outside the
// repo scripts), else alongside this script.
const DIR = process.env.DS_OUT || __dirname;
const all = [...M.tokens, ...M.components];

// --- class extraction (for the safelist) ---
function extractClasses() {
const set = new Set();
// wrappers + structural classes the generator itself emits
[
'bg-base-100',
'bg-base-200',
'bg-base-300',
'text-base-content',
'p-4',
'p-6',
'flex',
'flex-col',
'flex-wrap',
'gap-1',
'gap-2',
'gap-3',
'gap-4',
'gap-10',
'items-center',
'items-baseline',
'items-end',
'justify-center',
'justify-end',
'pt-2',
'pt-8',
'w-16',
'w-20',
'w-28',
'w-64',
'h-12',
'h-16',
'inline-block',
'shrink-0',
'grid',
'grid-cols-2',
'sm:grid-cols-4',
'overflow-hidden',
'border',
'border-base-300',
'border-base-content',
'rounded',
'rounded-box',
'rounded-field',
'rounded-selector',
].forEach((c) => set.add(c));

// harvest from rendered markup
const fakeH = { esc: (s) => s };
for (const item of all) {
const html = item.render(fakeH);
const re = /class="([^"]+)"/g;
let m;
while ((m = re.exec(html))) {
m[1].split(/\s+/).forEach((c) => c && set.add(c));
}
}
return [...set].sort();
}

function pageWrap(group, title, themeCss, innerDark, innerLight) {
return `<!-- @dsCard group="${group}" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ScriptHammer — ${title}</title>
<style>
${themeCss}
.ds-frame { display:flex; flex-wrap:wrap; gap:1.5rem; padding:1.5rem; }
.ds-pane { flex:1 1 360px; min-width:320px; border-radius:1rem; }
.ds-label { font:600 11px/1.4 ui-sans-serif,system-ui,sans-serif; letter-spacing:.04em; text-transform:uppercase; opacity:.6; margin-bottom:.5rem; }
</style>
</head>
<body>
<div class="ds-frame">
<div class="ds-pane" data-theme="scripthammer-dark">
<div class="bg-base-100 text-base-content rounded-box p-6">
<div class="ds-label text-base-content">scripthammer-dark</div>
${innerDark}
</div>
</div>
<div class="ds-pane" data-theme="scripthammer-light">
<div class="bg-base-100 text-base-content rounded-box p-6">
<div class="ds-label text-base-content">scripthammer-light</div>
${innerLight}
</div>
</div>
</div>
</body>
</html>
`;
}

function emitCards() {
const themeCssPath = path.join(DIR, 'theme.css');
if (!fs.existsSync(themeCssPath)) {
console.error('theme.css not found — run gen-theme.js first.');
process.exit(1);
}
const themeCss = fs.readFileSync(themeCssPath, 'utf8');
const fakeH = { esc: (s) => s };

let count = 0;
for (const t of M.tokens) {
const inner = t.render(fakeH);
const html = pageWrap(t.group, t.title, themeCss, inner, inner);
const dest = path.join(DIR, 'tokens', `${t.slug}.html`);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, html);
count++;
}
for (const c of M.components) {
const inner = c.render(fakeH);
const html = pageWrap(c.group, c.title, themeCss, inner, inner);
const dest = path.join(DIR, 'components', c.slug, 'index.html');
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, html);
count++;
}
console.log(
`emitted ${count} cards (inlined theme.css ${(Buffer.byteLength(themeCss) / 1024).toFixed(1)} KB each)`
);
}

const mode = process.argv[2];
if (mode === '--classes') {
const classes = extractClasses();
fs.writeFileSync(path.join(DIR, 'classes.txt'), classes.join('\n') + '\n');
console.log(`classes.txt written: ${classes.length} classes`);
} else {
emitCards();
}
Loading
Loading