mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
* feat(skills): open-design-landing rename, kami skills, landing OG - Rename editorial-collage skills to open-design-landing and -deck; refresh examples and compose script layout - Add kami-deck and kami-landing skills with HTML examples - Landing page: og.astro, index wiring, and style tweaks; package.json bump - Web i18n: German and Russian copy for renamed and new skills - Daemon test: update skill-asset-rewrite expectations for new paths - Design systems: README and atelier-zero doc touch-ups - Cross-skill SKILL.md reference updates Co-authored-by: Cursor <cursoragent@cursor.com> * docs(landing-page): document version-slot invariant and deprecation timeline Address P3 review notes on PR #428: - Note the `data-github-version` wrapper invariant (version string only) near the canonical URL block in `app/page.tsx`. - Expand the `formatVersion` helper comment in `app/pages/index.astro` with concrete `release.name` / `tag_name` example shapes for each branch of the regex fallback. - Tighten the `EditorialCollageDeckInputs` deprecation in `skills/open-design-landing-deck/schema.ts` to a specific removal version (v0.4.0) and add a "Migrating from editorial-collage-deck" section to the skill README. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * docs(landing-page, skills): clarify version slot script and rename migrations - Describe GitHub version slots as driven by the inline enhancement script, not React hydration. - Add editorial-collage → open-design-landing migration notes; fix README link copy (Astro static landing app). - Extend deck README migration table with shared asset path renames. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(daemon): alias deprecated editorial-collage skill ids The PR renames the editorial-collage / editorial-collage-deck skills to open-design-landing / open-design-landing-deck, but the daemon persists exact skill_id strings on projects and resolves them via listSkills().find((s) => s.id === storedId). After the rename, any project saved against an old id silently composes without the intended skill prompt because the listing no longer exposes that id. Add a SKILL_ID_ALIASES map in skills.ts plus a findSkillById() helper that rewrites deprecated ids to their current canonical form, then route every server-side lookup (skill detail, example HTML, asset proxy, system-prompt composer) through it. Cover the alias map, the resolver, and end-to-end resolution against a temp skills directory with a regression test. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(kami-deck): route host od:slide messages through local go() The host bridge classifies kami-deck as class-driven because go() toggles .slide.active, but the visible slide is moved by deck.style.transform which the bridge cannot drive. Listen for od:slide messages and dispatch them through the local go() so toolbar next/prev and initialSlideIndex restore actually shift the deck. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(kami-deck): sync deck transform with host-driven .active changes The previous fix added a local od:slide listener but the host bridge in apps/web/src/runtime/srcdoc.ts also listens for the same message and calls setActive() (toggles .slide.active) without driving the deck transform. Both listeners fired, the bridge re-read the just-toggled active class, and overshot by one — and the bridge's restoreInitialSlide path could move .active without a message at all, leaving the deck on the original transform. Stop the bridge from double-handling by calling stopImmediatePropagation in the local listener (registered first because the bridge script is appended to </body>), and add a MutationObserver that pulls the deck transform onto whichever slide currently carries .active so the bridge's direct setActive calls (notably the initial-slide restore) move the deck too. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(i18n): align French content with renamed/new skills PR #434 (French localization) merged into main with French copy for the old editorial-collage / editorial-collage-deck skill ids; this branch renamed those to open-design-landing / open-design-landing-deck and added kami-deck and kami-landing. Update content.fr.ts to track the rename and add French copy for the new kami skills so the LOCALIZED_CONTENT_IDS coverage test passes once main is merged. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(open-design-landing-deck): sync deck transform with host-driven .active changes Apply the same fix that landed in skills/kami-deck/example.html (commits96b255b,8cbca30) to the open-design-landing-deck composer runtime: the host bridge classifies this deck as class-driven because go() toggles .slide.active, but the visible slide is moved by deck.style.transform which the bridge can't drive. Add an od:slide message listener that calls stopImmediatePropagation() and routes nav through the local go(), plus a MutationObserver that pulls the deck transform onto whichever slide carries .active so the bridge's direct setActive calls (notably restoreInitialSlide) move the deck too. Regenerates example.html via scripts/compose.ts; the regeneration also picks up upstream nav-cta and brand-meta CSS additions in the sister open-design-landing styles.css that the example had drifted from. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * docs(open-design-landing): align deploy story with Astro landing app - Update SKILL contract: apps/landing-page is Astro static; clarify nextjs-app output_format as a historical enum label and <out>/nextjs as a legacy folder name. - Replace optional-deploy section with fork + pnpm --filter landing-page build. - Fix styles.css header and regenerate landing + deck example.html so inlined comments match. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(deck-runtime): bypass interaction lock for host/observer slide sync The slide deck runtimes for kami-deck and open-design-landing-deck gate go() behind a 700ms `lock` so wheel/key/touch input bursts can't overshoot the transform transition. But applying the same gate to the host bridge's od:slide messages and the MutationObserver watching `.slide.active` creates a startup race: go(0) at the end of init sets lock=true, and any host-driven `.active` change inside that window (notably restoreInitialSlide) fires the observer, which calls go(i), which exits at the lock guard — leaving the visible deck on slide 1 while the host counter advances to N. Split the actual state update into an unthrottled `applySlide(n)` helper that updates transform, `.active`, dot nav, and the progress bar. Keep `lock` only on the user-input path through `go()`. Route the message listener, the MutationObserver, and the initial render through `applySlide` directly so host-driven sync always reaches the deck transform regardless of the throttle state. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
7.6 KiB
TypeScript
174 lines
7.6 KiB
TypeScript
#!/usr/bin/env -S npx -y tsx
|
||
/**
|
||
* open-design-landing — SVG framework placeholder generator.
|
||
*
|
||
* When `imagery.strategy === 'placeholder'`, this script writes one
|
||
* paper-textured SVG file per slot in `assets/image-manifest.json`.
|
||
* The generated files live alongside the schema-named PNGs that the
|
||
* composer references (`hero.png`, `about.png`, `lab-1.png`, …) so
|
||
* the layout renders fully without any image budget.
|
||
*
|
||
* Each placeholder shows: slot id · ratio · pixel dimensions · the
|
||
* `prompt_section` hint copied from the manifest. Drop the real PNG
|
||
* with the same filename to swap in production imagery; no markup
|
||
* change required.
|
||
*
|
||
* Usage:
|
||
* npx tsx scripts/placeholder.ts <out-dir>
|
||
*
|
||
* Default out-dir is `./assets/`.
|
||
*/
|
||
|
||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||
import { resolve, dirname, isAbsolute, basename } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
interface ManifestSlot {
|
||
id: string;
|
||
file: string;
|
||
width: number;
|
||
height: number;
|
||
ratio: string;
|
||
prompt_section: string;
|
||
required: boolean;
|
||
rekey_on_brand_change: boolean;
|
||
}
|
||
|
||
interface Manifest {
|
||
skill: string;
|
||
design_system: string;
|
||
slots: ManifestSlot[];
|
||
}
|
||
|
||
const PAPER = '#efe7d2';
|
||
const INK_FAINT = '#8b8676';
|
||
const CORAL = '#ed6f5c';
|
||
const LINE = 'rgba(21, 20, 15, 0.16)';
|
||
|
||
/** Compose a single paper-textured SVG for one slot. */
|
||
export function placeholderSvg(slot: ManifestSlot): string {
|
||
const w = slot.width;
|
||
const h = slot.height;
|
||
const cx = w / 2;
|
||
const cy = h / 2;
|
||
const isPortrait = h > w;
|
||
const titleSize = Math.round(Math.min(w, h) * (isPortrait ? 0.075 : 0.07));
|
||
const metaSize = Math.round(Math.min(w, h) * 0.028);
|
||
const dimsSize = Math.round(Math.min(w, h) * 0.024);
|
||
|
||
// Inner frame inset.
|
||
const inset = Math.round(Math.min(w, h) * 0.04);
|
||
const frame = {
|
||
x: inset,
|
||
y: inset,
|
||
w: w - inset * 2,
|
||
h: h - inset * 2,
|
||
};
|
||
|
||
// Diagonal strokes for the classic "image goes here" cross.
|
||
const cross = `
|
||
<line x1='${frame.x}' y1='${frame.y}' x2='${frame.x + frame.w}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
|
||
<line x1='${frame.x + frame.w}' y1='${frame.y}' x2='${frame.x}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
|
||
`;
|
||
|
||
const cornerLen = Math.round(Math.min(w, h) * 0.05);
|
||
const corners = `
|
||
<path d='M${frame.x} ${frame.y + cornerLen} L${frame.x} ${frame.y} L${frame.x + cornerLen} ${frame.y}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||
<path d='M${frame.x + frame.w - cornerLen} ${frame.y} L${frame.x + frame.w} ${frame.y} L${frame.x + frame.w} ${frame.y + cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||
<path d='M${frame.x} ${frame.y + frame.h - cornerLen} L${frame.x} ${frame.y + frame.h} L${frame.x + cornerLen} ${frame.y + frame.h}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||
<path d='M${frame.x + frame.w - cornerLen} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h - cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
|
||
`;
|
||
|
||
return `<?xml version='1.0' encoding='UTF-8'?>
|
||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${h}' width='${w}' height='${h}'>
|
||
<defs>
|
||
<filter id='paper'>
|
||
<feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/>
|
||
<feColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.16 0 0 0 0 0.12 0 0 0 0.07 0'/>
|
||
</filter>
|
||
</defs>
|
||
<!-- paper base -->
|
||
<rect width='${w}' height='${h}' fill='${PAPER}' />
|
||
<rect width='${w}' height='${h}' filter='url(#paper)' />
|
||
<!-- frame -->
|
||
<rect x='${frame.x}' y='${frame.y}' width='${frame.w}' height='${frame.h}' fill='none' stroke='${LINE}' stroke-dasharray='6 6' />
|
||
${cross}
|
||
${corners}
|
||
<!-- coral plate index, top-left -->
|
||
<text x='${inset + 14}' y='${inset + 26}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' font-weight='600' letter-spacing='2' fill='${CORAL}'>PLATE · ${slot.id.toUpperCase()}</text>
|
||
<!-- coordinates, top-right -->
|
||
<text x='${w - inset - 14}' y='${inset + 26}' text-anchor='end' font-family='JetBrains Mono, monospace' font-size='${dimsSize}' fill='${INK_FAINT}'>${w} × ${h} · ${slot.ratio}</text>
|
||
<!-- centered title block -->
|
||
<text x='${cx}' y='${cy - titleSize * 0.2}' text-anchor='middle' font-family='Playfair Display, serif' font-style='italic' font-weight='500' font-size='${titleSize}' fill='#15140f'>${escapeXml(slot.id)}</text>
|
||
<text x='${cx}' y='${cy + metaSize * 1.6}' text-anchor='middle' font-family='Inter Tight, system-ui, sans-serif' font-size='${metaSize}' letter-spacing='3' fill='${INK_FAINT}'>${escapeXml(slot.prompt_section.toUpperCase())}</text>
|
||
<!-- bottom slug -->
|
||
<text x='${inset + 14}' y='${h - inset - 14}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>${slot.required ? 'REQUIRED' : 'OPTIONAL'} · ${slot.rekey_on_brand_change ? 'REKEY ON BRAND' : 'STABLE'}</text>
|
||
<text x='${w - inset - 14}' y='${h - inset - 14}' text-anchor='end' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>OPEN DESIGN · ATELIER ZERO</text>
|
||
</svg>`;
|
||
}
|
||
|
||
function escapeXml(s: string): string {
|
||
return s
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
async function loadManifest(): Promise<Manifest> {
|
||
const path = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
|
||
return JSON.parse(await readFile(path, 'utf8')) as Manifest;
|
||
}
|
||
|
||
/**
|
||
* Write `<out>/<slot.file>` for every slot. The composer references
|
||
* slots by .png filename; we honor that by writing `<basename>.svg`
|
||
* AND a `<basename>.png.svg` symlink-style fallback. Most static
|
||
* hosts serve SVG to <img> just fine, so the practical convention
|
||
* is: if you want placeholders, point your `imagery.assets_path` at
|
||
* a directory of `.svg` files OR rename the SVGs to `.png` (some
|
||
* browsers honor extensionless content-sniffing).
|
||
*
|
||
* For the most reliable result, write BOTH:
|
||
* - `<id>.svg` — clean, editable
|
||
* - `<file>` — same SVG content under the .png filename so the
|
||
* composer's `<img src='./assets/<id>.png'>` works
|
||
* without changing markup.
|
||
*/
|
||
export async function writePlaceholders(outDir: string): Promise<string[]> {
|
||
const manifest = await loadManifest();
|
||
await mkdir(outDir, { recursive: true });
|
||
const written: string[] = [];
|
||
for (const slot of manifest.slots) {
|
||
const svg = placeholderSvg(slot);
|
||
const svgPath = resolve(outDir, `${slot.id}.svg`);
|
||
const pngPath = resolve(outDir, slot.file);
|
||
await writeFile(svgPath, svg, 'utf8');
|
||
await writeFile(pngPath, svg, 'utf8');
|
||
written.push(svgPath, pngPath);
|
||
}
|
||
return written;
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const [, , outArg] = process.argv;
|
||
const out = isAbsolute(outArg ?? '')
|
||
? outArg!
|
||
: resolve(process.cwd(), outArg ?? './assets/');
|
||
const written = await writePlaceholders(out);
|
||
const pngs = written.filter((p) => p.endsWith('.png')).length;
|
||
const svgs = written.filter((p) => p.endsWith('.svg')).length;
|
||
console.log(`✓ wrote ${pngs} png-named placeholders + ${svgs} svg files into ${out}`);
|
||
console.log(` (${written.map((p) => basename(p)).join(', ')})`);
|
||
}
|
||
|
||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||
if (isMain) {
|
||
main().catch((err) => {
|
||
console.error(err);
|
||
process.exit(1);
|
||
});
|
||
}
|