feat(editorial-collage): introduce Atelier Zero style landing page as… (#366)
* feat(editorial-collage): introduce Atelier Zero style landing page assets and documentation - Added new design system for Atelier Zero, including a detailed `DESIGN.md` file. - Created an `editorial-collage` skill with associated assets for a magazine-grade landing page. - Included example HTML and image assets for various sections (hero, about, capabilities, etc.). - Updated README files to guide usage and customization of the new skill and design system. - Introduced a new image generation prompt pack for consistent visual style across the landing page. * fix(i18n): cover atelier-zero design system and editorial-collage skill in German content Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(editorial-collage): align manifest with shipped assets and address PR review - Update image-manifest.json widths/heights/ratios to match the actual PNGs on disk: hero/about/cap/testimonial/cta = 1024x1024 (1:1), method-1..4 = 816x816 (1:1), lab-1..5 and work-1..2 = 768x1024 (3:4). Mirror the new dimensions in imagegen-prompts.md headings and in README.md. - Mark testimonial.png as rekey_on_brand_change so the manifest agrees with SKILL.md's "regenerate at minimum testimonial.png" guidance, and add work-1/work-2 to the rekey list in SKILL.md and README.md. - Add a Hero (I.) sec-rule and renumber every following section II..VIII in example.html so the eight sections walk sequentially I -> VIII and the page-of-008 counter starts at 001. - Delete editorial-artifact-system/ (16 duplicate PNGs + index.html + skills.md draft) — the canonical version is skills/editorial-collage/ and the duplicate had no consumer references. - DESIGN.md: spell out which dimensions of each magazine reference (Monocle/Apartamento/IDEA), document the rationale for single-accent vs multi-accent, and extend the anti-pattern list with AI-image-gen artifacts the system explicitly rejects. - SKILL.md: add italic_words validation guidance (trim, cap at 4, verb->noun rewrite, punctuation strip) and replace the broken-image fallback with an inline SVG placeholder sized to the slot's manifest aspect ratio. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * fix(daemon): serve skill example assets via stable API route Skill example HTML such as `skills/editorial-collage/example.html` references shipped images via `./assets/*.png`. The web app loads the example into a sandboxed iframe via `srcdoc`, where relative URLs resolve against `about:srcdoc` and the PNGs render as broken images in the Examples preview. Add a `GET /api/skills/:id/assets/*` route that serves files under the skill's `assets/` directory with path-traversal guards, and rewrite `src='./assets/<file>'` / `href='./assets/<file>'` in the example response to point at that route. The disk preview keeps working because the on-disk files are unchanged. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * feat(landing-page): add new static Next.js 16 site for Open Design marketing - Introduced a new landing page application using Next.js 16, featuring a static export setup. - Added essential files including `package.json`, `next.config.ts`, and TypeScript configuration. - Implemented global styles in `globals.css` to match the Atelier Zero design system. - Created a detailed `AGENTS.md` for module-level boundaries and purpose. - Included various image assets for the landing page, ensuring a visually cohesive experience. - Established a root layout and main page structure to support the marketing content. * style(landing-page): enhance topbar layout and improve responsiveness - Added nowrap styling to topbar elements to prevent text overflow. - Introduced media query to hide mid text in the topbar for screen widths between 1200px and 1280px. - Updated layout.tsx to suppress hydration warnings for better rendering consistency. - Removed redundant "Compiled by Open Design" text from the page component. * feat(landing-page): implement scroll-reveal animations for enhanced user experience - Added a new `RevealRoot` component to manage scroll-triggered reveal animations. - Updated `globals.css` with styles for elements using the `data-reveal` attribute, including opacity, translation, and scaling effects. - Modified `layout.tsx` to include the `RevealRoot` component for managing animations. - Enhanced `page.tsx` by adding `data-reveal` attributes to various elements for staggered reveal effects. - Implemented reduced motion support to ensure accessibility for users with motion sensitivity. * fix(landing-page): update import paths and enhance link styles - Changed the import path in `next-env.d.ts` to reference the correct routes type definition. - Enhanced `globals.css` with new styles for topbar links, work cards, and partner elements, improving hover effects and transitions. - Updated `page.tsx` to include canonical project URLs and made various links point to these URLs for better navigation and accessibility. * feat(landing-page): implement headroom-style sticky header with live GitHub star count - Introduced a new `Header` component to manage sticky navigation behavior on scroll, enhancing user experience. - Updated `globals.css` to style the sticky header, including transitions and visibility toggling based on scroll direction. - Modified `page.tsx` to replace the static header with the new `Header` component, which fetches and displays the live GitHub star count. - Ensured accessibility by providing a fallback for users who prefer reduced motion. * feat(landing-page): enhance editorial landing page with global ticker and new styles - Updated `next-env.d.ts` to reference the correct routes type definition for development. - Enhanced `globals.css` with new styles for the global ticker, including responsive design and improved overflow handling. - Introduced a new `WIRE_CITIES` and `WIRE_CONTRIBS` data structure in `page.tsx` to display a counter-scrolling marquee of cities and contributors. - Added a ghost button style for the navigation call-to-action in the header. - Updated various sections in `page.tsx` to integrate the new ticker and improve overall layout and accessibility. * refactor(landing-page): update paper texture overlay and remove multica-ai link - Enhanced comments in `globals.css` to clarify the purpose and behavior of the paper texture overlay. - Adjusted z-index of the overlay to ensure proper layering with other elements. - Removed the `multica-ai` partner link from `page.tsx` to streamline the partner section. * feat(landing-page): implement dynamic contributor marquee with GitHub integration - Added a new `Wire` component to display a counter-scrolling marquee of cities and contributors. - The contributor list is fetched live from the GitHub API, ensuring up-to-date information. - Updated `page.tsx` to integrate the `Wire` component, replacing the static contributor list with dynamic content. - Enhanced comments for clarity regarding the functionality and purpose of the global wire. * fix(i18n): add German display copy for editorial-collage-deck skill The Validate workspace test asserts that GERMAN_CONTENT_IDS.skills covers every curated skill on disk; the new editorial-collage-deck skill was missing from DE_SKILL_COPY, causing src/i18n/content.test.ts to fail. Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code) * feat(landing-page): migrate marketing site to Astro * perf(landing-page): remove React client runtime * perf(landing-page): serve images from Cloudflare resizing * fix(pr): address landing page review feedback --------- Co-authored-by: mrcfps <mrc@powerformer.com>
93
.github/workflows/landing-page-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
name: landing-page-ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
- .github/workflows/landing-page.yml
|
||||
- apps/landing-page/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
- .github/workflows/landing-page.yml
|
||||
- apps/landing-page/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: landing-page-ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate landing page
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck landing page
|
||||
run: pnpm --filter @open-design/landing-page typecheck
|
||||
|
||||
- name: Build landing page
|
||||
run: pnpm --filter @open-design/landing-page build
|
||||
|
||||
- name: Verify zero external JavaScript
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const forbidden = [
|
||||
/<script\b[^>]*\bsrc=/i,
|
||||
/type=["']module["']/i,
|
||||
/\/_astro\/[^"'<>\s]+\.js/i,
|
||||
];
|
||||
for (const pattern of forbidden) {
|
||||
if (pattern.test(html)) {
|
||||
console.error(`Unexpected client JavaScript matched ${pattern}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify Cloudflare image resizing URLs
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
|
||||
if (resizedUrls.length < 16) {
|
||||
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
|
||||
console.error('Found local /assets/*.png image reference in generated landing HTML.');
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
96
.github/workflows/landing-page-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
name: landing-page-deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- .github/workflows/landing-page-deploy.yml
|
||||
- .github/workflows/landing-page-ci.yml
|
||||
- apps/landing-page/**
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
concurrency:
|
||||
group: landing-page-deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy landing page
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck landing page
|
||||
run: pnpm --filter @open-design/landing-page typecheck
|
||||
|
||||
- name: Build landing page
|
||||
run: pnpm --filter @open-design/landing-page build
|
||||
|
||||
- name: Verify zero external JavaScript
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const forbidden = [
|
||||
/<script\b[^>]*\bsrc=/i,
|
||||
/type=["']module["']/i,
|
||||
/\/_astro\/[^"'<>\s]+\.js/i,
|
||||
];
|
||||
for (const pattern of forbidden) {
|
||||
if (pattern.test(html)) {
|
||||
console.error(`Unexpected client JavaScript matched ${pattern}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Verify Cloudflare image resizing URLs
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const { readFileSync } = require('node:fs');
|
||||
const html = readFileSync('apps/landing-page/out/index.html', 'utf8');
|
||||
const resizedUrls = html.match(/https:\/\/static\.open-design\.ai\/cdn-cgi\/image\//g) ?? [];
|
||||
if (resizedUrls.length < 16) {
|
||||
console.error(`Expected at least 16 Cloudflare resized image URLs, found ${resizedUrls.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (/(?:src|content)=["']\/assets\/[A-Za-z0-9_.-]+\.png/.test(html)) {
|
||||
console.error('Found local /assets/*.png image reference in generated landing HTML.');
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: >
|
||||
pages deploy apps/landing-page/out
|
||||
--project-name=open-design-landing
|
||||
--branch=${{ github.ref_name }}
|
||||
1
.gitignore
vendored
|
|
@ -7,6 +7,7 @@ out/
|
|||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
.astro/
|
||||
|
||||
# Local runtime data — auto-created by the daemon on first start.
|
||||
# Holds app.sqlite (project metadata), projects/<id>/ (per-project artifacts,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
deleteProjectFile,
|
||||
ensureProject,
|
||||
listFiles,
|
||||
mimeFor,
|
||||
projectDir,
|
||||
readProjectFile,
|
||||
removeProjectDir,
|
||||
|
|
@ -1385,7 +1386,10 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
|
||||
const baked = path.join(skill.dir, 'example.html');
|
||||
if (fs.existsSync(baked)) {
|
||||
return res.type('text/html').sendFile(baked);
|
||||
const html = await fs.promises.readFile(baked, 'utf8');
|
||||
return res
|
||||
.type('text/html')
|
||||
.send(rewriteSkillAssetUrls(html, skill.id));
|
||||
}
|
||||
|
||||
const tpl = path.join(skill.dir, 'assets', 'template.html');
|
||||
|
|
@ -1395,17 +1399,25 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
const tplHtml = await fs.promises.readFile(tpl, 'utf8');
|
||||
const slidesHtml = await fs.promises.readFile(slides, 'utf8');
|
||||
const assembled = assembleExample(tplHtml, slidesHtml, skill.name);
|
||||
return res.type('text/html').send(assembled);
|
||||
return res
|
||||
.type('text/html')
|
||||
.send(rewriteSkillAssetUrls(assembled, skill.id));
|
||||
} catch {
|
||||
// Fall through to raw template on read failure.
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(tpl)) {
|
||||
return res.type('text/html').sendFile(tpl);
|
||||
const html = await fs.promises.readFile(tpl, 'utf8');
|
||||
return res
|
||||
.type('text/html')
|
||||
.send(rewriteSkillAssetUrls(html, skill.id));
|
||||
}
|
||||
const idx = path.join(skill.dir, 'assets', 'index.html');
|
||||
if (fs.existsSync(idx)) {
|
||||
return res.type('text/html').sendFile(idx);
|
||||
const html = await fs.promises.readFile(idx, 'utf8');
|
||||
return res
|
||||
.type('text/html')
|
||||
.send(rewriteSkillAssetUrls(html, skill.id));
|
||||
}
|
||||
res
|
||||
.status(404)
|
||||
|
|
@ -1418,6 +1430,41 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST
|
|||
}
|
||||
});
|
||||
|
||||
// Static assets shipped beside a skill's example/template HTML. Lets the
|
||||
// example HTML reference `./assets/foo.png`-style paths that resolve
|
||||
// correctly when the response is loaded into a sandboxed `srcdoc` iframe
|
||||
// (where relative URLs would otherwise resolve against `about:srcdoc`).
|
||||
// The example response above rewrites `./assets/<file>` into a request
|
||||
// against this route; we still keep the on-disk paths human-friendly so
|
||||
// contributors can preview `example.html` straight from disk.
|
||||
app.get('/api/skills/:id/assets/*', async (req, res) => {
|
||||
try {
|
||||
const skills = await listSkills(SKILLS_DIR);
|
||||
const skill = skills.find((s) => s.id === req.params.id);
|
||||
if (!skill) {
|
||||
return res.status(404).type('text/plain').send('skill not found');
|
||||
}
|
||||
const relPath = String(req.params[0] || '');
|
||||
const assetsRoot = path.resolve(skill.dir, 'assets');
|
||||
const target = path.resolve(assetsRoot, relPath);
|
||||
if (target !== assetsRoot && !target.startsWith(assetsRoot + path.sep)) {
|
||||
return res.status(400).type('text/plain').send('invalid asset path');
|
||||
}
|
||||
if (!fs.existsSync(target)) {
|
||||
return res.status(404).type('text/plain').send('asset not found');
|
||||
}
|
||||
// The example HTML is rendered inside a sandboxed iframe (Origin: null).
|
||||
// Mirror the project /raw route's allowance so the iframe can fetch the
|
||||
// image bytes; same-origin web callers do not need this header.
|
||||
if (req.headers.origin === 'null') {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
}
|
||||
res.type(mimeFor(target)).sendFile(target);
|
||||
} catch (err) {
|
||||
res.status(500).type('text/plain').send(String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/upload', upload.array('images', 8), (req, res) => {
|
||||
const files = (req.files || []).map((f) => ({
|
||||
name: f.originalname,
|
||||
|
|
@ -3216,6 +3263,30 @@ function assembleExample(templateHtml, slidesHtml, title) {
|
|||
);
|
||||
}
|
||||
|
||||
// Skill example HTML often references shipped images via relative paths
|
||||
// like `./assets/hero.png`. Those resolve correctly when the file is
|
||||
// opened from disk, but the web app loads the example into a sandboxed
|
||||
// iframe via `srcdoc`, where the document URL is `about:srcdoc` and
|
||||
// relative URLs cannot find the assets. Rewriting them to an absolute
|
||||
// `/api/skills/<id>/assets/...` URL lets the same HTML render in both
|
||||
// places — the disk preview keeps working, and the in-app preview now
|
||||
// fetches assets through the matching route below.
|
||||
export function rewriteSkillAssetUrls(html: string, skillId: string): string {
|
||||
if (typeof html !== 'string' || html.length === 0) return html;
|
||||
// Match src/href attributes whose values point at the current skill's
|
||||
// assets (`./assets/...` or `assets/...`) or a sibling skill's assets
|
||||
// (`../other-skill/assets/...`). Quote style is preserved so we do not
|
||||
// disturb the surrounding markup.
|
||||
return html.replace(
|
||||
/(\s(?:src|href)\s*=\s*)(['"])((?:\.\.\/([^/'"#?]+)\/)?(?:\.\/)?assets\/([^'"#?]+))(\2)/gi,
|
||||
(_match, attr, openQuote, _fullPath, siblingSkillId, relPath, closeQuote) => {
|
||||
const resolvedSkillId = siblingSkillId || skillId;
|
||||
const prefix = `/api/skills/${encodeURIComponent(resolvedSkillId)}/assets/`;
|
||||
return `${attr}${openQuote}${prefix}${relPath}${closeQuote}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function isLocalSameOrigin(req, port) {
|
||||
// Accepts http + https, loopback hosts, OD_WEB_PORT, and the explicit
|
||||
// bind host — matching the global origin middleware policy exactly.
|
||||
|
|
|
|||
41
apps/daemon/tests/skill-asset-rewrite.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { rewriteSkillAssetUrls } from '../src/server.js';
|
||||
|
||||
describe('rewriteSkillAssetUrls', () => {
|
||||
it('rewrites ./assets/<file> img sources to the daemon route', () => {
|
||||
const html = `<img src='./assets/hero.png' alt='' />`;
|
||||
expect(rewriteSkillAssetUrls(html, 'editorial-collage')).toBe(
|
||||
`<img src='/api/skills/editorial-collage/assets/hero.png' alt='' />`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles double quotes and the no-leading-dot variant', () => {
|
||||
const html = `<img src="assets/cta.png"><a href="./assets/diagram.svg"></a>`;
|
||||
expect(rewriteSkillAssetUrls(html, 'foo')).toBe(
|
||||
`<img src="/api/skills/foo/assets/cta.png"><a href="/api/skills/foo/assets/diagram.svg"></a>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rewrites sibling skill asset references', () => {
|
||||
const html = `<img src='../editorial-collage/assets/hero.png' /><a href="../skill-two/assets/guide.pdf"></a>`;
|
||||
expect(rewriteSkillAssetUrls(html, 'foo')).toBe(
|
||||
`<img src='/api/skills/editorial-collage/assets/hero.png' /><a href="/api/skills/skill-two/assets/guide.pdf"></a>`,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves absolute and fragment URLs untouched', () => {
|
||||
const html = `<a href='https://example.com/assets/x.png'></a><a href='#assets'></a><img src='/assets/hero.png' />`;
|
||||
expect(rewriteSkillAssetUrls(html, 'foo')).toBe(html);
|
||||
});
|
||||
|
||||
it('URL-encodes current and sibling skill ids in rewritten routes', () => {
|
||||
const html = `<img src='./assets/hero.png' /><img src="../foo bar/assets/hero.png" />`;
|
||||
expect(rewriteSkillAssetUrls(html, '../oops')).toBe(
|
||||
`<img src='/api/skills/..%2Foops/assets/hero.png' /><img src="/api/skills/foo%20bar/assets/hero.png" />`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns non-string input unchanged', () => {
|
||||
expect(rewriteSkillAssetUrls('', 'foo')).toBe('');
|
||||
});
|
||||
});
|
||||
74
apps/landing-page/AGENTS.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# apps/landing-page/AGENTS.md
|
||||
|
||||
Follow the root `AGENTS.md` and `apps/AGENTS.md` first. This file only
|
||||
records module-level boundaries for `apps/landing-page/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
`apps/landing-page` is a stand-alone static Astro site that renders
|
||||
the canonical Open Design marketing page in the **Atelier Zero** style.
|
||||
It is the deployable counterpart to:
|
||||
|
||||
- Skill: `skills/editorial-collage/` — agent workflow + the source-of-truth
|
||||
`example.html` known-good rendering.
|
||||
- Design system: `design-systems/atelier-zero/DESIGN.md` — token spec.
|
||||
- Image assets: `skills/editorial-collage/assets/*.png` are uploaded to
|
||||
Cloudflare R2 (`open-design-static`) and served through
|
||||
`static.open-design.ai` with Image Resizing (`format=auto`). Do not
|
||||
commit local mirrored PNGs into `apps/landing-page/public/assets/`.
|
||||
|
||||
## What it is
|
||||
|
||||
- Astro static output. The route lives at `app/pages/index.astro` and
|
||||
uses React only at build time (`renderToStaticMarkup`) for the existing
|
||||
`app/page.tsx` component. The generated page is CDN-ready HTML/CSS plus
|
||||
a small inline enhancement script; no React runtime ships to browsers.
|
||||
- `astro.config.ts` always uses `output: 'static'` and emits to `out/`
|
||||
so it can be served by any CDN (Vercel, Cloudflare Pages, the daemon's
|
||||
static fallback) without a Node runtime.
|
||||
- All styles live in `app/globals.css`. Class names match the Atelier
|
||||
Zero CSS in the canonical example so visual parity is one-to-one.
|
||||
- All page imagery is referenced through `app/image-assets.ts`, which builds
|
||||
Cloudflare Image Resizing URLs for the R2 originals.
|
||||
|
||||
## What it is NOT
|
||||
|
||||
- Not part of `apps/web`. The web app is the product surface; the
|
||||
landing page is a marketing surface. They share design tokens but
|
||||
not state, routes, or runtime.
|
||||
- Not connected to `apps/daemon`. There is no `/api`, no `/artifacts`,
|
||||
no `/frames` — no proxy to set up.
|
||||
- Not multi-page. There is exactly one route (`/`) that renders the
|
||||
full landing page. If you need a second page, add it as a sibling
|
||||
Astro page route.
|
||||
|
||||
## Boundary constraints
|
||||
|
||||
- Must remain a static Astro output.
|
||||
- Must not import from `@open-design/web`, `@open-design/daemon`,
|
||||
`@open-design/desktop`, `@open-design/sidecar*`, or
|
||||
`@open-design/contracts`. Those are product runtime concerns.
|
||||
- Must not introduce a `src/` shell — keep all source under
|
||||
`app/`. If a component grows beyond ~80 lines, extract it to
|
||||
`app/_components/<name>.tsx`.
|
||||
- Must not depend on any non-Google web font.
|
||||
- When the canonical `skills/editorial-collage/example.html` changes,
|
||||
the corresponding section JSX in `app/page.tsx` and rules in
|
||||
`app/globals.css` must be updated to match. The two files are kept
|
||||
in lockstep.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/landing-page dev # http://127.0.0.1:17574
|
||||
pnpm --filter @open-design/landing-page build # static export → out/
|
||||
pnpm --filter @open-design/landing-page typecheck
|
||||
```
|
||||
|
||||
## When to update this app
|
||||
|
||||
- New section added to the canonical landing page → port it here.
|
||||
- Asset regeneration in the skill → re-mirror PNGs into
|
||||
`public/assets/`.
|
||||
- Brand re-keying for a non-Open-Design tenant → fork the app, update
|
||||
copy, swap PNGs. Do not parameterize this app for multi-tenancy.
|
||||
80
apps/landing-page/app/_components/header.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Sticky Header — static markup rendered at build time. Headroom-style
|
||||
* hide/show and the live GitHub star count are attached by the tiny inline
|
||||
* script in `app/pages/index.astro`, so this marketing page ships no React
|
||||
* runtime to the browser.
|
||||
*/
|
||||
|
||||
const REPO = 'https://github.com/nexu-io/open-design';
|
||||
const REPO_RELEASES = `${REPO}/releases`;
|
||||
const REPO_SKILLS = `${REPO}/tree/main/skills`;
|
||||
const REPO_DESIGN_SYSTEMS = `${REPO}/tree/main/design-systems`;
|
||||
|
||||
const ext = {
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
} as const;
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className='nav' data-od-id='nav' data-nav-headroom>
|
||||
<div className='container nav-inner'>
|
||||
<a href='#top' className='brand'>
|
||||
<span className='brand-mark'>Ø</span>
|
||||
<span>Open Design</span>
|
||||
<span className='brand-meta'>
|
||||
<b>Studio Nº 01</b>Berlin / Open / Earth
|
||||
</span>
|
||||
</a>
|
||||
<nav>
|
||||
<ul className='nav-links'>
|
||||
<li>
|
||||
<a href={REPO_SKILLS} {...ext}>
|
||||
Skills<span className='num'>31</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={REPO_DESIGN_SYSTEMS} {...ext}>
|
||||
Systems<span className='num'>72</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#agents'>
|
||||
Agents<span className='num'>12</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#labs'>
|
||||
Labs<span className='num'>05</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='#contact'>Contact</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className='nav-side'>
|
||||
<a
|
||||
className='nav-cta ghost'
|
||||
href={REPO_RELEASES}
|
||||
aria-label='Download Open Design desktop'
|
||||
title='Download the desktop app'
|
||||
{...ext}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
className='nav-cta'
|
||||
href={REPO}
|
||||
aria-label='Star Open Design on GitHub'
|
||||
title='Click to star us on GitHub'
|
||||
{...ext}
|
||||
>
|
||||
Star · <span data-github-stars>0</span>
|
||||
</a>
|
||||
<span className='status-dot' aria-hidden='true' />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
132
apps/landing-page/app/_components/wire.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Global wire — the slim editorial ticker between the hero and About.
|
||||
*
|
||||
* The cities row (top) is decorative and stays static. The contributors
|
||||
* row (bottom, reverse direction) renders a static fallback at build time;
|
||||
* `app/pages/index.astro` enhances it with a tiny inline GitHub fetch so
|
||||
* the browser never downloads React.
|
||||
*
|
||||
* GET https://api.github.com/repos/nexu-io/open-design/contributors
|
||||
*
|
||||
* Each entry becomes a `<a class='wire-item is-link'>` linking straight
|
||||
* to the contributor's GitHub profile. We:
|
||||
*
|
||||
* - filter out bot accounts (`type === 'Bot'` or `*[bot]` logins),
|
||||
* - keep the top N by contribution count,
|
||||
* - apply named editorial roles to known handles (kami, guizang…)
|
||||
* and fall back to "<count> commits" for everyone else,
|
||||
* - always append a trailing "@you · be next" link to the
|
||||
* contributors graph so the editorial CTA stays intact.
|
||||
*
|
||||
* If the fetch is blocked (offline, rate limited, network failure), the
|
||||
* fallback list stays visible — the section never goes empty.
|
||||
*/
|
||||
|
||||
const REPO = 'https://github.com/nexu-io/open-design';
|
||||
const REPO_CONTRIBUTORS_PAGE = `${REPO}/graphs/contributors`;
|
||||
|
||||
const ext = {
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
} as const;
|
||||
|
||||
const TRAILING_CTA: Contributor = {
|
||||
handle: 'you',
|
||||
role: 'be next',
|
||||
href: REPO_CONTRIBUTORS_PAGE,
|
||||
};
|
||||
|
||||
type Contributor = {
|
||||
handle: string;
|
||||
role: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
// SSR-safe initial list. Used until the GitHub fetch resolves AND as
|
||||
// the permanent fallback when the network is unavailable. Mirrors the
|
||||
// canonical wire row in `skills/editorial-collage/example.html` so
|
||||
// hydration is byte-stable against the static reference rendering.
|
||||
const FALLBACK: ReadonlyArray<Contributor> = [
|
||||
{ handle: 'tw93', role: 'kami', href: 'https://github.com/tw93' },
|
||||
{ handle: 'op7418', role: 'guizang', href: 'https://github.com/op7418' },
|
||||
{
|
||||
handle: 'alchaincyf',
|
||||
role: 'huashu',
|
||||
href: 'https://github.com/alchaincyf',
|
||||
},
|
||||
{
|
||||
handle: 'multica-ai',
|
||||
role: 'daemon',
|
||||
href: 'https://github.com/multica-ai',
|
||||
},
|
||||
{
|
||||
handle: 'OpenCoworkAI',
|
||||
role: 'codesign',
|
||||
href: 'https://github.com/OpenCoworkAI',
|
||||
},
|
||||
{ handle: 'nexu-io', role: 'studio', href: 'https://github.com/nexu-io' },
|
||||
TRAILING_CTA,
|
||||
];
|
||||
|
||||
type City = { name: string; coord: string };
|
||||
|
||||
export function Wire({ cities }: { cities: ReadonlyArray<City> }) {
|
||||
// Doubled tracks are required for the seamless `translateX(-50%)`
|
||||
// marquee loop defined in globals.css.
|
||||
const cityTrack = [...cities, ...cities];
|
||||
const contribTrack = [...FALLBACK, ...FALLBACK];
|
||||
|
||||
return (
|
||||
<section
|
||||
className='wire'
|
||||
data-od-id='wire'
|
||||
aria-label='Global wire — cities and contributors'
|
||||
>
|
||||
<div className='container wire-inner'>
|
||||
<div className='wire-left'>
|
||||
<span className='wire-mark' aria-hidden='true'>
|
||||
<span className='wire-pulse' />
|
||||
</span>
|
||||
<span className='wire-title'>
|
||||
<b>From the field</b>
|
||||
<span>
|
||||
Open · {cities.length} cities ·{' '}
|
||||
<span data-wire-contributors-count>{FALLBACK.length - 1}</span>{' '}
|
||||
contributors
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className='wire-rows'>
|
||||
<div className='wire-row'>
|
||||
<div className='marquee-track' aria-hidden='true'>
|
||||
{cityTrack.map((c, i) => (
|
||||
<span className='wire-item' key={`city-${i}`}>
|
||||
<span className='wire-dot'>·</span>
|
||||
<span className='wire-coord'>{c.coord}</span>
|
||||
<span className='wire-name'>{c.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='wire-row reverse'>
|
||||
<div className='marquee-track' data-wire-contributors-track>
|
||||
{contribTrack.map((c, i) => (
|
||||
<a
|
||||
className='wire-item is-link'
|
||||
key={`contrib-${i}-${c.handle}`}
|
||||
href={c.href}
|
||||
aria-label={`Open ${c.handle} on GitHub`}
|
||||
{...ext}
|
||||
>
|
||||
<span className='wire-dot'>·</span>
|
||||
<span className='wire-handle'>@{c.handle}</span>
|
||||
<span className='wire-role'>{c.role}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
1
apps/landing-page/app/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
2037
apps/landing-page/app/globals.css
Normal file
19
apps/landing-page/app/image-assets.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const R2_PUBLIC_ORIGIN = 'https://static.open-design.ai';
|
||||
const IMAGE_RESIZING_ORIGIN = R2_PUBLIC_ORIGIN;
|
||||
const ASSET_PREFIX = 'landing/assets';
|
||||
|
||||
type ImageOptions = {
|
||||
width: number;
|
||||
quality?: number;
|
||||
};
|
||||
|
||||
export function r2Asset(name: string): string {
|
||||
return `${R2_PUBLIC_ORIGIN}/${ASSET_PREFIX}/${name}`;
|
||||
}
|
||||
|
||||
export function imageAsset(name: string, { width, quality = 85 }: ImageOptions): string {
|
||||
const options = `width=${width},quality=${quality},format=auto`;
|
||||
return `${IMAGE_RESIZING_ORIGIN}/cdn-cgi/image/${options}/${r2Asset(name)}`;
|
||||
}
|
||||
|
||||
export const heroImage = imageAsset('hero.png', { width: 1024, quality: 82 });
|
||||
1205
apps/landing-page/app/page.tsx
Normal file
180
apps/landing-page/app/pages/index.astro
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
---
|
||||
import Page from '../page';
|
||||
import '../globals.css';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { heroImage } from '../image-assets';
|
||||
|
||||
const title = 'Open Design — Designing intelligence with skills, taste, and code.';
|
||||
const description =
|
||||
'Open Design is the open-source alternative to Claude Design. 12 coding-agent CLIs · 31 composable skills · 72 brand-grade design systems. Local-first, web-deployable, BYOK at every layer.';
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
|
||||
const pageHtml = renderToStaticMarkup(createElement(Page));
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#efe7d2" />
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonical} />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Open Design" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={heroImage} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={heroImage} />
|
||||
</head>
|
||||
<body>
|
||||
<Fragment set:html={pageHtml} />
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const formatStars = (count) => {
|
||||
if (!Number.isFinite(count) || count <= 0) return '0';
|
||||
if (count < 1000) return String(count);
|
||||
return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
|
||||
};
|
||||
|
||||
const enhanceHeader = () => {
|
||||
const nav = document.querySelector('[data-nav-headroom]');
|
||||
if (nav) {
|
||||
let lastY = window.scrollY;
|
||||
const showTopThreshold = 100;
|
||||
const scrollDelta = 6;
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
const y = window.scrollY;
|
||||
const delta = y - lastY;
|
||||
if (y <= showTopThreshold) nav.classList.remove('is-hidden');
|
||||
else if (delta > scrollDelta) nav.classList.add('is-hidden');
|
||||
else if (delta < -scrollDelta) nav.classList.remove('is-hidden');
|
||||
lastY = y;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
}
|
||||
|
||||
const stars = document.querySelector('[data-github-stars]');
|
||||
if (!stars) return;
|
||||
fetch('https://api.github.com/repos/nexu-io/open-design', {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||||
.then((data) => {
|
||||
if (typeof data?.stargazers_count === 'number') {
|
||||
stars.textContent = formatStars(data.stargazers_count);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const enhanceWire = () => {
|
||||
const track = document.querySelector('[data-wire-contributors-track]');
|
||||
const count = document.querySelector('[data-wire-contributors-count]');
|
||||
if (!track) return;
|
||||
|
||||
const roleOverrides = {
|
||||
tw93: 'kami',
|
||||
op7418: 'guizang',
|
||||
alchaincyf: 'huashu',
|
||||
OpenCoworkAI: 'codesign',
|
||||
'nexu-io': 'studio',
|
||||
lewislulu: 'html-ppt',
|
||||
};
|
||||
const roleFor = (login, contributions) =>
|
||||
roleOverrides[login] ?? `${contributions} ${contributions === 1 ? 'commit' : 'commits'}`;
|
||||
const isContributor = (value) =>
|
||||
value &&
|
||||
typeof value.login === 'string' &&
|
||||
typeof value.html_url === 'string' &&
|
||||
typeof value.type === 'string' &&
|
||||
typeof value.contributions === 'number';
|
||||
const renderContributor = (contributor, index) => {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'wire-item is-link';
|
||||
link.href = contributor.href;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer noopener';
|
||||
link.setAttribute('aria-label', `Open ${contributor.handle} on GitHub`);
|
||||
link.dataset.liveWireItem = String(index);
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'wire-dot';
|
||||
dot.textContent = '·';
|
||||
const handle = document.createElement('span');
|
||||
handle.className = 'wire-handle';
|
||||
handle.textContent = `@${contributor.handle}`;
|
||||
const role = document.createElement('span');
|
||||
role.className = 'wire-role';
|
||||
role.textContent = contributor.role;
|
||||
|
||||
link.append(dot, handle, role);
|
||||
return link;
|
||||
};
|
||||
|
||||
fetch('https://api.github.com/repos/nexu-io/open-design/contributors?per_page=12', {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
const live = data
|
||||
.filter(isContributor)
|
||||
.filter((c) => c.type !== 'Bot' && !c.login.endsWith('[bot]'))
|
||||
.slice(0, 12)
|
||||
.map((c) => ({
|
||||
handle: c.login,
|
||||
role: roleFor(c.login, c.contributions),
|
||||
href: c.html_url,
|
||||
}));
|
||||
if (live.length === 0) return;
|
||||
live.push({
|
||||
handle: 'you',
|
||||
role: 'be next',
|
||||
href: 'https://github.com/nexu-io/open-design/graphs/contributors',
|
||||
});
|
||||
if (count) count.textContent = String(Math.max(0, live.length - 1));
|
||||
track.replaceChildren(
|
||||
...[...live, ...live].map((contributor, index) => renderContributor(contributor, index)),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
|
||||
enhanceHeader();
|
||||
enhanceWire();
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduceMotion || !('IntersectionObserver' in window)) {
|
||||
for (const el of elements) el.dataset.revealed = 'true';
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
entry.target.dataset.revealed = 'true';
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.12, rootMargin: '0px 0px -8% 0px' },
|
||||
);
|
||||
|
||||
for (const el of elements) observer.observe(el);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
apps/landing-page/astro.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import sitemap from '@astrojs/sitemap';
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
const site = process.env.OD_LANDING_SITE ?? 'https://open-design.dev';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
site,
|
||||
srcDir: './app',
|
||||
outDir: './out',
|
||||
trailingSlash: 'always',
|
||||
integrations: [sitemap()],
|
||||
});
|
||||
28
apps/landing-page/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@open-design/landing-page",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev --host 127.0.0.1 --port 17574",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview --host 127.0.0.1 --port 17574",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.6.0",
|
||||
"astro": "^5.15.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@types/node": "^20.17.10",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~24"
|
||||
}
|
||||
}
|
||||
40
apps/landing-page/tsconfig.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"astro.config.ts",
|
||||
"app/**/*",
|
||||
"out/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"out"
|
||||
]
|
||||
}
|
||||
|
|
@ -32,6 +32,16 @@ export const RU_SKILL_COPY: Record<string, { description?: string; examplePrompt
|
|||
examplePrompt:
|
||||
'Страница документации — левая навигация, прокручиваемая область статьи, оглавление справа.',
|
||||
},
|
||||
'editorial-collage': {
|
||||
examplePrompt:
|
||||
'Спроектируйте редакционный лендинг в стиле Atelier Zero / Monocle — теплая бумажная основа, сюрреалистичный коллаж из гипса и архитектуры, сверхкрупная смешанная display-типографика с курсивной антиквой, римские цифры как маркеры секций и один коралловый акцент.',
|
||||
},
|
||||
'editorial-collage-deck': {
|
||||
examplePrompt:
|
||||
'Создайте pitch deck из 11 слайдов для «Lumen Field», студии фокусных soundscape. Обложка с hero-плашкой, два разделителя секций, два продуктовых слайда с буллетами, слайд со статистикой 12 soundscapes / 4 presets / 1 daily ritual, клиентская цитата, финальный CTA и end-card. Переиспользуйте библиотеку изображений из editorial-collage.',
|
||||
description:
|
||||
'Создает однофайловую презентацию в визуальном языке Atelier Zero: теплая бумага, акцентные spans курсивной антиквой, коралловые финальные точки и сюрреалистичные коллажные плашки. Deck использует scroll-snap-пагинацию, навигацию стрелками и пробелом, live HUD со счетчиком слайдов и progress bar, а также наследует каноническую таблицу стилей и 16-слотовую библиотеку изображений sibling-навыка `editorial-collage`.',
|
||||
},
|
||||
'email-marketing': {
|
||||
examplePrompt:
|
||||
'Спроектируйте письмо о запуске для бренда беговых кроссовок — masthead, hero-блок, крупный заголовочный lockup, сетка характеристик, CTA.',
|
||||
|
|
@ -182,6 +192,8 @@ export const RU_DESIGN_SYSTEM_SUMMARIES: Record<string, string> = {
|
|||
airbnb: 'Маркетплейс путешествий. Теплый коралловый акцент, опора на фотографию, скругленный UI.',
|
||||
airtable: 'Гибрид таблицы и базы данных. Ярко, дружелюбно, с эстетикой структурированных данных.',
|
||||
apple: 'Потребительская электроника. Премиальный воздух, SF Pro, кинематографичная подача.',
|
||||
'atelier-zero':
|
||||
'Редакционная студийная система. Теплое бумажное полотно, сюрреалистичный коллаж из гипса и архитектуры, смешанная display-типографика с курсивной антиквой, римские цифры как маркеры секций и один коралловый акцент — для журнальных лендингов, сайтов студий и manifesto-страниц.',
|
||||
binance: 'Криптобиржа. Сильный желтый акцент на монохроме, энергетика trading floor.',
|
||||
bmw: 'Премиальный автопром. Темные статусные поверхности, точная немецкая инженерная эстетика.',
|
||||
bugatti: 'Бренд гиперкаров. Кинематографично темное полотно, монохромная строгость, монументальная display-типографика.',
|
||||
|
|
@ -278,6 +290,7 @@ export const RU_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
|
|||
'Media & Consumer': 'Медиа и потребительские',
|
||||
Automotive: 'Автомобили',
|
||||
'Editorial & Print': 'Редакционные и печатные',
|
||||
'Editorial · Studio': 'Редакционная студия',
|
||||
'Retro & Nostalgic': 'Ретро и ностальгия',
|
||||
'Themed & Unique': 'Тематические и уникальные',
|
||||
Uncategorized: 'Без категории',
|
||||
|
|
|
|||
|
|
@ -70,6 +70,16 @@ const DE_SKILL_COPY: Record<string, LocalizedSkillCopy> = {
|
|||
examplePrompt:
|
||||
'Eine Dokumentationsseite — linke Navigation, scrollbarer Artikelbereich, rechte Inhaltsübersicht.',
|
||||
},
|
||||
'editorial-collage': {
|
||||
examplePrompt:
|
||||
'Entwerfen Sie eine Editorial-Landingpage im Atelier-Zero- / Monocle-Stil — warme Papierleinwand, surreale Plaster-und-Architektur-Collage, übergroße gemischte Italic-Serif-Display-Type, römische Ziffern als Sektionsmarker und ein einziger Korallenakzent.',
|
||||
},
|
||||
'editorial-collage-deck': {
|
||||
examplePrompt:
|
||||
'Erstellen Sie ein 11-Slide-Pitch-Deck für „Lumen Field“, ein Studio für Fokus-Soundscapes. Cover mit Hero-Plate, zwei Sektions-Trenner, zwei Produkt-Content-Slides mit Bullets, ein Stats-Slide mit 12 Soundscapes / 4 Presets / 1 Daily Ritual, ein Kundenzitat, ein abschließendes CTA und eine End-Card. Wiederverwenden Sie die Bildbibliothek von editorial-collage.',
|
||||
description:
|
||||
'Erstellt ein Single-File-Slide-Deck in der Atelier-Zero-Bildsprache (warmes Papier, italic-serif Akzent-Spans, korallenfarbene Schluss-Dots, surreale Collage-Platten). Das Deck nutzt Scroll-Snap-Pagination, Pfeiltasten- + Leertaste-Navigation, ein Live-HUD mit Slide-Zähler und Fortschrittsbalken und erbt das kanonische Stylesheet sowie die 16-Slot-Bildbibliothek der Schwester-Skill `editorial-collage`.',
|
||||
},
|
||||
'email-marketing': {
|
||||
examplePrompt:
|
||||
'Entwerfen Sie eine Launch-E-Mail für eine sportliche Laufschuhmarke — Masthead, Hero, großes Headline-Lockup, Specs Grid, CTA.',
|
||||
|
|
@ -220,6 +230,8 @@ const DE_DESIGN_SYSTEM_SUMMARIES: Record<string, string> = {
|
|||
airbnb: 'Reisemarktplatz. Warmer Korallenakzent, fotogetrieben, abgerundete UI.',
|
||||
airtable: 'Spreadsheet-Datenbank-Hybrid. Farbenfroh, freundlich, strukturierte Datenästhetik.',
|
||||
apple: 'Unterhaltungselektronik. Premium-Weißraum, SF Pro, filmische Bildsprache.',
|
||||
'atelier-zero':
|
||||
'Editoriales Studio-System. Warme Papierleinwand, surreale Plaster-und-Architektur-Collage, gemischte Italic-Serif-Display-Type, römische Ziffern als Sektionsmarker und ein einziger Korallenakzent — gemacht für Magazin-Landingpages, Studio-Sites und Manifestseiten.',
|
||||
binance: 'Krypto-Börse. Kräftiger gelber Akzent auf Monochrom, Trading-Floor-Dringlichkeit.',
|
||||
bmw: 'Luxusautomobil. Dunkle Premium-Flächen, präzise deutsche Engineering-Ästhetik.',
|
||||
bugatti: 'Hypercar-Marke. Kinodunkle Leinwand, monochrome Strenge, monumentale Display-Type.',
|
||||
|
|
@ -316,6 +328,7 @@ const DE_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
|
|||
'Media & Consumer': 'Medien & Consumer',
|
||||
Automotive: 'Automotive',
|
||||
'Editorial & Print': 'Editorial & Print',
|
||||
'Editorial · Studio': 'Editorial · Studio',
|
||||
'Retro & Nostalgic': 'Retro & Nostalgisch',
|
||||
'Themed & Unique': 'Thematisch & Einzigartig',
|
||||
Uncategorized: 'Nicht kategorisiert',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ will read it as part of its system prompt.
|
|||
|
||||
- **`default/`** — Neutral Modern. Hand-authored starter for the OD spec.
|
||||
- **`warm-editorial/`** — Warm Editorial. Hand-authored serif starter.
|
||||
- **`atelier-zero/`** — Atelier Zero. Hand-authored magazine-grade
|
||||
collage system: warm paper canvas, plaster-and-architecture imagery,
|
||||
oversized italic-mixed display type, Roman-numeral section markers,
|
||||
side rails of rotated micro-text, coordinate annotations, single
|
||||
coral accent. Pairs with [`skills/editorial-collage/`](../skills/editorial-collage/)
|
||||
for the canonical landing-page rendering.
|
||||
- **`kami/`** — 紙 / 纸. Editorial paper system distilled from
|
||||
[`tw93/kami`](https://github.com/tw93/kami) (MIT). Warm parchment canvas,
|
||||
ink-blue accent, serif at one weight, no italic, no cool grays. Pairs with
|
||||
|
|
|
|||
316
design-systems/atelier-zero/DESIGN.md
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# Atelier Zero
|
||||
|
||||
> Category: Editorial · Studio
|
||||
> A magazine-grade, collage-driven visual system: warm paper canvas, surreal
|
||||
> plaster-and-architecture imagery, oversized display type, hairline rules,
|
||||
> Roman-numeral section markers, and tiny editorial annotations.
|
||||
> Inspired by the production values of high-end print magazines (Monocle,
|
||||
> Apartamento, IDEA) translated into a working website.
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
|
||||
A small, high-craft studio's annual report rendered as a webpage. The
|
||||
canvas is warm handmade paper. Every surface earns its lines. Type does
|
||||
the heavy lifting; collage imagery does the storytelling. Coral provides
|
||||
the only spark of warmth; mustard, olive, and bone are quiet
|
||||
companions. The page feels printed, slightly aged, and intentionally
|
||||
restrained — never noisy, never neon.
|
||||
|
||||
- **Visual style:** editorial, collage, museum-catalog calm.
|
||||
- **Posture:** asymmetric, generous, top-biased.
|
||||
- **Reading rhythm:** Roman numerals (I, II, III…) walk the reader
|
||||
through the page like chapters in a printed essay.
|
||||
- **Mood:** intelligent, tactile, slightly poetic, unmistakably
|
||||
international.
|
||||
|
||||
### Print production references
|
||||
|
||||
The three magazines are not interchangeable inspiration — each owns a
|
||||
specific dimension of the system. When a brief asks "shift it closer to
|
||||
X", consult this map before changing tokens:
|
||||
|
||||
- **Monocle:** warm paper stock (`#efe7d2`), tight body leading (~1.55),
|
||||
monospace coordinates and SHA stamps, the international metadata
|
||||
strip ("Filed under …"), the small ★ in the nav.
|
||||
- **Apartamento:** surreal collage composition (plaster + architecture
|
||||
+ small human figure), torn-edge textures inside the imagery, the
|
||||
rotated side notes, and the willingness to leave generous negative
|
||||
space around an image.
|
||||
- **IDEA:** Roman-numeral section walks (I → VIII), oversized
|
||||
italic-serif words mixed inline with bold sans (Playfair Italic 500
|
||||
inside Inter Tight 800), hairline rules threading through method
|
||||
steps, the closing mega-word footer.
|
||||
|
||||
## 2. Color
|
||||
|
||||
All values are tokens. Do not invent new hex.
|
||||
|
||||
- **Paper:** `#efe7d2` — primary background, warm ivory.
|
||||
- **Paper-warm:** `#ece4cf` — second-tier surface tint.
|
||||
- **Paper-dark:** `#ddd2b6` — subtle wells, cards on cards.
|
||||
- **Bone:** `#f7f1de` — elevated card surface (always on Paper).
|
||||
- **Ink:** `#15140f` — body text, primary buttons, strong rules.
|
||||
- **Ink-soft:** `#2a2620` — secondary text, dense paragraphs.
|
||||
- **Ink-mute:** `#5a5448` — captions, lab descriptions.
|
||||
- **Ink-faint:** `#8b8676` — coordinates, page numbers, microcopy.
|
||||
- **Coral (accent):** `#ed6f5c` — single hot accent. CTA fills,
|
||||
Roman-numeral marks, eyebrow underlines, pulse dots, "fin." marks.
|
||||
- **Coral-soft:** `#f08e7c` — hover/secondary coral states only.
|
||||
- **Mustard:** `#e9b94a` — used sparingly: a single ★ in the nav, a
|
||||
highlighted ring in stats, occasional dot on a numbered annotation.
|
||||
- **Olive:** `#6e7448` — quiet third accent for tags or partner glyphs.
|
||||
|
||||
### Color rules
|
||||
|
||||
- One **coral** moment per ~600vh. If two CTAs are coral, the
|
||||
Roman numerals should be ink-faint instead.
|
||||
- Mustard is never used for a CTA. It is jewelry.
|
||||
- Pure white (`#fff`) only inside the dark "selected work" panel as
|
||||
inverse text. Never on Paper.
|
||||
- Pure black is forbidden. The darkest value is `Ink #15140f`.
|
||||
|
||||
### Why single-accent (not multi-accent)
|
||||
|
||||
Multi-accent editorial systems (e.g. *The New Yorker* using red for
|
||||
Opinion and teal for Culture) work when the publication has stable
|
||||
content categories and a long-term reader who learns the code. A
|
||||
single-shot studio landing page does not have that runway. One coral
|
||||
moment per ~600vh forces the agent to pick the single most important
|
||||
beat per viewport instead of balancing two chromatic hierarchies, and
|
||||
keeps the page calibrated to the warm-paper canvas. Mustard and olive
|
||||
exist as **jewelry** (≤1% surface area: a star, a dot, a partner glyph)
|
||||
— never as semantic signals, and never as CTA fills.
|
||||
|
||||
### Surface noise
|
||||
|
||||
Every page MUST overlay a faint paper noise texture using a fixed,
|
||||
pointer-events-disabled `::before` pseudo-element with a
|
||||
multiply-blend SVG turbulence at ~5–7% opacity, plus two soft
|
||||
radial gradients in `rgba(106, 92, 56, 0.06)` to simulate
|
||||
hand-pressed paper warmth.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
### Families
|
||||
|
||||
- **Display / sans:** `Inter Tight` 700–900 weights — headlines, section
|
||||
titles, button text. Letter-spacing `-0.025em` to `-0.04em` at
|
||||
display sizes.
|
||||
- **Italic emphasis / serif:** `Playfair Display` Italic, weight 500.
|
||||
Used inline inside display headlines on emphasized nouns, on Roman
|
||||
numerals, on testimonial quotes, on the brand mark `Ø`.
|
||||
- **Body:** `Inter` 300–500 — paragraph copy, lab descriptions.
|
||||
- **Mono:** `JetBrains Mono` 400–500 — code spans, coordinates,
|
||||
SHAs, plate numbers ("FIG. 01 / OD-26").
|
||||
|
||||
### Scale (px)
|
||||
|
||||
`9.5 · 10.5 · 11 · 13 · 14 · 16 · 17 · 22 · 26 · 38 · 54 · 66 · 78 · 90 · 200`
|
||||
|
||||
### Headline construction
|
||||
|
||||
Display headlines mix **bold sans** and **italic serif** in the same
|
||||
line. The serif italic carries the emotional words; the sans carries
|
||||
the structure. End every section H1/H2 with a coral period — `<span
|
||||
class="dot">.</span>`.
|
||||
|
||||
```
|
||||
Designing intelligence with skills, taste, and code.
|
||||
^^^^^^^^^ ^^^^^^^^ ^^^^^ ^^^^
|
||||
sans bold serif italic coral dot
|
||||
```
|
||||
|
||||
### Microcopy
|
||||
|
||||
- **Eyebrow / label:** 11px Inter Tight 600, `letter-spacing: 0.22em`,
|
||||
uppercase, coral, prefixed with an 18px coral hairline.
|
||||
- **Coordinates:** 10px JetBrains Mono, `letter-spacing: 0.04em`,
|
||||
ink-faint, e.g. `52.5200° N · 13.4050° E`.
|
||||
- **Page-of-pages:** `004 / 008` in Inter Tight 11px ink-faint.
|
||||
- **Roman numerals:** Playfair Italic 14px, coral, `I.` `II.` `III.` etc.
|
||||
at the head of every section rule.
|
||||
|
||||
## 4. Spacing & Grid
|
||||
|
||||
- **Container:** max-width `1360px`, side padding `64px` desktop,
|
||||
`44px` at ≤1280, `32px` at ≤1080, `24px` at ≤880.
|
||||
- **Section padding:** `130px` top+bottom desktop, `90px` for
|
||||
tight sections, `80px` ≤560.
|
||||
- **Grid:** 12-column conceptual, executed as CSS Grid with
|
||||
task-specific column ratios. Hero is `0.78fr 1.22fr`.
|
||||
- **Vertical rhythm:** 8px baseline. Allow 32–48px between
|
||||
paragraph blocks.
|
||||
- **Side rails:** Two 36px-wide fixed vertical strips on the left and
|
||||
right edges of the viewport, each containing a single rotated
|
||||
text label in 10px Inter Tight 600 letter-spaced 0.42em.
|
||||
|
||||
## 5. Layout & Composition
|
||||
|
||||
- **Top metadata strip** is mandatory: a single horizontal bar above
|
||||
the nav containing the volume/issue, a "Filed under …" badge, and a
|
||||
live-status pulse with version + locale. Inter Tight 10.5px,
|
||||
ink-faint, 1px ink-line border-bottom.
|
||||
- **Section rule** is mandatory at the top of every section:
|
||||
`[Roman.] · [meta middle] · [page-of-008]`.
|
||||
- **Image annotations**: every featured image carries 4 corner
|
||||
brackets (1px hairlines, 22×22), at least 1 plate number
|
||||
("Plate Nº 08"), and a coordinate or SHA.
|
||||
- **Hero must extend above the fold** at 1440×900 minimum. The image
|
||||
fills the viewport vertically (`calc(100vh - 160px)`), aligned to
|
||||
the right edge.
|
||||
- **Method sections** must use a 4-step layout with a horizontal
|
||||
hairline running through the step heads at the same Y, with
|
||||
`→` separators between titles.
|
||||
|
||||
## 6. Components
|
||||
|
||||
### Buttons
|
||||
|
||||
- **Primary:** coral fill `#ed6f5c`, white label, `999px` radius,
|
||||
`14px 22px` padding, with a white arrow `↗` SVG at 14px and a
|
||||
coral 0,14,26,-16 rgba shadow.
|
||||
- **Ghost:** transparent, `1px solid rgba(21,20,15,0.2)` border,
|
||||
ink label, same radius and padding.
|
||||
|
||||
### Cards
|
||||
|
||||
- **Bone-fill cards** (`#f7f1de`), 18px radius, 28×26 padding,
|
||||
inset 1px ink-at-6% ring + 30/60/-30/15 ambient shadow.
|
||||
- Each card has a `01–04` italic serif num plus a tag eyebrow on
|
||||
the same row.
|
||||
- A bottom-right 28px circular arrow mark turns coral on hover.
|
||||
|
||||
### Pill filters
|
||||
|
||||
- 10×18 padding, 999px, `1px solid line` border, transparent.
|
||||
- Active state: coral fill, white label, count separator opacity 0.7.
|
||||
|
||||
### Stat rings
|
||||
|
||||
- 32–34px circular dashed rings carrying a 2-digit number; one ring
|
||||
per row may be coral-stroked to denote the highlighted stat.
|
||||
|
||||
### Page numbers / index card
|
||||
|
||||
- Hero artwork carries a small bordered card on the right edge with
|
||||
`01–04` index entries. The current entry uses bold ink; the rest
|
||||
ink-faint. Each item prefixes the digit with a coral `01` token.
|
||||
|
||||
### Side rails
|
||||
|
||||
- Fixed 36px vertical strips at left + right edges, hidden below
|
||||
1280px. Each contains rotated 10px Inter Tight uppercase text
|
||||
letter-spaced 0.42em, never wraps.
|
||||
|
||||
### Roman section rules
|
||||
|
||||
Every section opens with a `.sec-rule`: top hairline 1px, then a
|
||||
flex row containing `[Roman]`, a centered metadata cluster, and
|
||||
the page-of-008 counter on the right.
|
||||
|
||||
## 7. Motion & Interaction
|
||||
|
||||
- **Pulse dot:** 6×6 coral circle at top metadata bar and footer,
|
||||
`pulse 2.4s ease-in-out infinite` between 1.0 and 0.35 opacity.
|
||||
- **Card hover:** translateY(-3px), arrow mark fills coral.
|
||||
- **Button hover:** translateY(-1px), darker coral fill.
|
||||
- **Pill hover:** ink-at-4% wash.
|
||||
- **Transitions:** `0.18s ease` everywhere; never longer than `0.25s`.
|
||||
- **No parallax, no scroll-jacking, no auto-rotators.** Editorial
|
||||
pages do not animate themselves at the user.
|
||||
|
||||
## 8. Voice & Brand
|
||||
|
||||
- Headlines mix declarative and italicized emotional words.
|
||||
- Body copy is plain-spoken and specific. Quote real numbers
|
||||
(12 / 31 / 72), real coordinates (52.5200° N · 13.4050° E),
|
||||
real commands (`pnpm tools-dev`).
|
||||
- Microcopy uses publication metaphors: "Filed under", "Plate Nº",
|
||||
"Vol. 01 / Issue Nº 26", "FIN.", "MMXXVI", "Edited by".
|
||||
- Latin numerals — Roman for sections, Arabic for stats.
|
||||
|
||||
## 9. Anti-patterns
|
||||
|
||||
- ❌ No drop shadows above 30px blur. No gradients on text.
|
||||
- ❌ No emoji in product copy. ★ is allowed once in the nav CTA.
|
||||
- ❌ No glassmorphism, no neon, no neumorphism, no rounded
|
||||
corners larger than 24px (except 32px on the dark "Selected Work" panel).
|
||||
- ❌ No more than one coral CTA per viewport.
|
||||
- ❌ No collage image without corner brackets and at least one
|
||||
monospace annotation.
|
||||
- ❌ No Roman numeral skipped — sections must be sequential.
|
||||
- ❌ No pure white, no pure black, no pure 100%-saturation accent.
|
||||
|
||||
### Anti-patterns specific to AI-generated imagery
|
||||
|
||||
This system is paired with `gpt-image-fal` / `gpt-image-azure` via the
|
||||
editorial-collage skill. Several common image-model defaults will
|
||||
silently break the Atelier Zero aesthetic, so they are forbidden in
|
||||
every collage prompt and rejected on visual review:
|
||||
|
||||
- ❌ No lens flares, light leaks, bloom, or cinematic post-FX. The
|
||||
paper-and-museum mood is matte, not cinematic.
|
||||
- ❌ No glitch, datamosh, RGB-split, or scanline artifacts.
|
||||
- ❌ No photorealistic human faces or stock-portrait people. Plaster
|
||||
fragments, busts, and small scale figures only — eyes never look at
|
||||
the viewer.
|
||||
- ❌ No visible AI signatures, watermarks, generator logos, or
|
||||
hallucinated model captions. The rendered surface must read as a
|
||||
printed page, not a model output.
|
||||
- ❌ No DSLR-style shallow depth-of-field bokeh on the collage
|
||||
fragments — every plane stays in focus.
|
||||
|
||||
## 10. Responsive Behavior
|
||||
|
||||
- **Desktop ≥ 1280px:** full container, two side rails visible,
|
||||
metadata strip shows all three columns.
|
||||
- **Laptop 1080–1279px:** side rails hidden, container 32–44px
|
||||
padding, metadata strip's middle column collapses.
|
||||
- **Tablet 880–1079px:** hero / about / capabilities / testimonial
|
||||
/ cta grids collapse to 1 column at 50px gap. Method becomes 2×2,
|
||||
the connecting hairline is removed. Nav links + brand-meta hide;
|
||||
brand-mark + CTA remain.
|
||||
- **Phone < 560px:** all multi-column grids become 1 col;
|
||||
section padding drops to 80px.
|
||||
|
||||
## 11. Imagery
|
||||
|
||||
This system is collage-first. Every page-level image must be
|
||||
generated to match these constraints:
|
||||
|
||||
- **Background:** warm ivory paper with subtle grain, faint vertical
|
||||
folds, drafting registration marks.
|
||||
- **Subject:** classical plaster head fragments, brutalist concrete
|
||||
blocks, archways, stairs, tree, sky cutouts, one small human figure.
|
||||
- **Color overlay:** restrained — cream, stone, charcoal, washed
|
||||
coral, occasional mustard, pale-blue inside small sky cutouts.
|
||||
- **Annotations baked in:** thin hairline circles, crosshairs,
|
||||
dotted matrices, numbered tags. Never typography that conflicts
|
||||
with on-page copy.
|
||||
|
||||
See `skills/editorial-collage/assets/imagegen-prompts.md` for the
|
||||
working prompt pack and per-section variants. All renders should be
|
||||
at 16:9 (heroes) or 1:1 (cards / about / cta), saved as PNG, ≥1024px
|
||||
on the long edge.
|
||||
|
||||
## 12. Agent Prompt Guide
|
||||
|
||||
When generating against this design system:
|
||||
|
||||
- The page is a **printed magazine** that happens to deploy. Lean
|
||||
into print metaphors before web metaphors.
|
||||
- Always include the metadata strip, the side rails, the Roman
|
||||
section rules, and a footer with a giant `Open Design.` (or brand)
|
||||
word at clamp(70px, 13vw, 200px).
|
||||
- Coral is a single character on stage. If you find yourself
|
||||
reaching for a second coral element in the same viewport, use
|
||||
ink-faint or mustard instead.
|
||||
- Italic serif words inside display headlines should always be
|
||||
emotional nouns: *intelligence*, *taste*, *memorable*, *open*,
|
||||
*visually*. Never verbs, never adjectives.
|
||||
- If asked for "more dramatic," the lever is **typography size**
|
||||
(clamp top to 90–110px) and **image height** (push to 100vh - nav).
|
||||
Do not reach for color.
|
||||
- If asked for "more minimal," remove decorative side notes and
|
||||
reduce annotations to one per image — never remove the Roman
|
||||
rules or the metadata strip.
|
||||
BIN
edited_image.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
2953
pnpm-lock.yaml
|
|
@ -6,6 +6,7 @@ const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
|
|||
|
||||
const skippedDirectories = new Set([
|
||||
".agents",
|
||||
".astro",
|
||||
".claude",
|
||||
".claude-sessions",
|
||||
".codex",
|
||||
|
|
|
|||
98
skills/editorial-collage-deck/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# editorial-collage-deck
|
||||
|
||||
Sister skill to [`editorial-collage`](../editorial-collage/). Produces
|
||||
a single-file slide deck in the **Atelier Zero** design language —
|
||||
warm-paper background, italic-serif emphasis, coral terminating dots,
|
||||
surreal collage plates — with scroll-snap pagination and arrow-key
|
||||
navigation.
|
||||
|
||||
> **Read first** — agent contract, schema, and self-check live in
|
||||
> [`SKILL.md`](./SKILL.md). This README is the human quick-start.
|
||||
|
||||
## 30-second tour
|
||||
|
||||
```bash
|
||||
# 1. Compose the worked example.
|
||||
npx tsx scripts/compose.ts inputs.example.json example.html
|
||||
|
||||
# 2. Open it.
|
||||
open example.html
|
||||
```
|
||||
|
||||
The deck assumes 16 collage assets at `../editorial-collage/assets/`
|
||||
(the sister skill ships them). Use ←/→ · Space · PageUp/PageDown ·
|
||||
Home/End to navigate.
|
||||
|
||||
## What you get
|
||||
|
||||
- N viewport-height slides (the worked example has 11) with
|
||||
`scroll-snap-type: y mandatory` for clean pagination.
|
||||
- HUD at top: brand mark · deck title · keyboard hint · live
|
||||
`NN / TT` counter.
|
||||
- Coral progress bar at the bottom that fills as you advance.
|
||||
- 7 slide kinds: `cover`, `section`, `content`, `stats`, `quote`,
|
||||
`cta`, `end`. Mix freely.
|
||||
- Same 16-slot image library as the landing-page sister skill —
|
||||
no extra prompting or rendering.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
skills/editorial-collage-deck/
|
||||
├── SKILL.md # ← agent contract (read this first)
|
||||
├── README.md # ← you are here
|
||||
├── schema.ts # typed slide variants + brand block (re-exports from sister)
|
||||
├── inputs.example.json # Open Design 11-slide pitch deck
|
||||
├── example.html # canonical rendering
|
||||
└── scripts/
|
||||
└── compose.ts # inputs.json + sister styles.css → index.html
|
||||
```
|
||||
|
||||
## Authoring a deck
|
||||
|
||||
1. Copy `inputs.example.json` to your project as `inputs.json`.
|
||||
2. Edit `brand` (or copy from a sister-skill `inputs.json` you already have).
|
||||
3. Set `deck_title` (the kicker shown in the HUD).
|
||||
4. Build the `slides` array. Each entry is one of seven kinds — see
|
||||
[`schema.ts`](./schema.ts) for the full type. A typical pitch:
|
||||
|
||||
```text
|
||||
1. cover — title plate
|
||||
2. section — chapter divider
|
||||
3-5. content — manifesto, capabilities, method
|
||||
6. stats — the numbers
|
||||
7. section — chapter divider
|
||||
8. content — selected work
|
||||
9. quote — customer testimonial
|
||||
10. cta — primary action
|
||||
11. end — kicker word
|
||||
```
|
||||
|
||||
5. Run the composer:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/compose.ts inputs.json out/index.html
|
||||
```
|
||||
|
||||
## Image strategy
|
||||
|
||||
The deck inherits the sister skill's 16-slot image library. Set
|
||||
`inputs.imagery.assets_path` to wherever those PNGs live; the example
|
||||
uses `'../editorial-collage/assets/'`.
|
||||
|
||||
To regenerate or stub:
|
||||
|
||||
```bash
|
||||
# Generate via gpt-image-2 (fal.ai)
|
||||
FAL_KEY=fal-... npx tsx ../editorial-collage/scripts/imagegen.ts \
|
||||
../editorial-collage/inputs.example.json \
|
||||
--out=../editorial-collage/assets/
|
||||
|
||||
# Or paper-textured SVG placeholders
|
||||
npx tsx ../editorial-collage/scripts/placeholder.ts ../editorial-collage/assets/
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [`editorial-collage`](../editorial-collage/) — landing page sister skill.
|
||||
- [`design-systems/atelier-zero/DESIGN.md`](../../design-systems/atelier-zero/DESIGN.md) — design tokens.
|
||||
192
skills/editorial-collage-deck/SKILL.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
---
|
||||
name: editorial-collage-deck
|
||||
description: >
|
||||
Produce a single-file slide deck in the Atelier Zero visual language
|
||||
(warm-paper background, italic-serif emphasis spans, coral terminating
|
||||
dots, surreal collage plates). The deck uses scroll-snap pagination,
|
||||
arrow-key + space navigation, a live HUD with slide counter and
|
||||
progress bar, and inherits the canonical stylesheet + 16-slot image
|
||||
library from the sister `editorial-collage` skill.
|
||||
triggers:
|
||||
- slide deck
|
||||
- 演示文稿
|
||||
- pitch deck
|
||||
- keynote
|
||||
- editorial slides
|
||||
- atelier zero deck
|
||||
od:
|
||||
category: brand-deck
|
||||
surface: web
|
||||
audience: founders pitching, conference talks, internal reviews
|
||||
tone: editorial, restrained, premium
|
||||
scale: 6-15 viewport-locked slides
|
||||
craft:
|
||||
requires:
|
||||
- typographic-rhythm
|
||||
- pixel-discipline
|
||||
inputs:
|
||||
- id: brand
|
||||
label: Brand identity (shared across slides)
|
||||
schema_path: ./schema.ts#BrandBlock
|
||||
- id: deck_title
|
||||
label: Kicker shown in the HUD top bar
|
||||
description: e.g. `'Open Design · Vol. 01 / Issue Nº 26'`.
|
||||
- id: slides
|
||||
label: Ordered list of typed slides
|
||||
description: >
|
||||
Each entry is one of seven slide kinds. Mix and match freely; the
|
||||
composer routes each by `kind`.
|
||||
schema_path: ./schema.ts#Slide
|
||||
- id: imagery
|
||||
label: Image library (defaults to sister skill's assets)
|
||||
schema_path: ../editorial-collage/schema.ts#ImageryConfig
|
||||
parameters:
|
||||
slides_recommended_count:
|
||||
type: number
|
||||
default: 11
|
||||
description: 8-15 is the sweet spot. Below 6 the deck feels thin; above 18 attendees lose the thread.
|
||||
outputs:
|
||||
- path: <out>/index.html
|
||||
description: Self-contained HTML deck — Atelier Zero CSS inlined, runtime script inline, images relative.
|
||||
capabilities_required:
|
||||
- file-write
|
||||
- node-runtime
|
||||
example_prompt: |
|
||||
Build me an 11-slide pitch deck for "Lumen Field", a focus-soundscape
|
||||
studio. Cover with hero plate, two section dividers, two product
|
||||
content slides with bullets, a stats slide showing 12 soundscapes / 4
|
||||
presets / 1 daily ritual, a customer quote, a closing CTA, and an end
|
||||
card. Reuse the editorial-collage image library.
|
||||
---
|
||||
|
||||
# editorial-collage-deck
|
||||
|
||||
Sister skill to [`editorial-collage`](../editorial-collage/). Same
|
||||
Atelier Zero visual system (warm paper, Inter Tight + Playfair Display,
|
||||
italic-serif emphasis, coral dots), but paginated as a slide deck
|
||||
instead of a long landing page.
|
||||
|
||||
```text
|
||||
inputs.json + ../editorial-collage/styles.css
|
||||
│
|
||||
└──────────► scripts/compose.ts
|
||||
│
|
||||
▼
|
||||
<out>/index.html
|
||||
(one viewport per slide, scroll-snap)
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
- A single self-contained HTML file with N viewport-height slides.
|
||||
- **Keyboard navigation**: ←/→ · ↑/↓ · PageUp/PageDown · Space · Home/End.
|
||||
- **HUD top bar**: brand mark, deck title, key hint, live slide counter.
|
||||
- **Coral progress bar** at the bottom that fills as you advance.
|
||||
- **Scroll-snap pagination** with `scroll-snap-stop: always` so each
|
||||
slide settles cleanly.
|
||||
- Reuses the **same 16-slot image library** as the sister skill — no
|
||||
duplicate assets.
|
||||
|
||||
## Slide types
|
||||
|
||||
| Kind | Use it for |
|
||||
| :---------- | :------------------------------------------------------------ |
|
||||
| `cover` | Title plate at the start. 2-column copy + collage art. |
|
||||
| `section` | Roman-numeral divider between chapters. Centered, full-bleed. |
|
||||
| `content` | Eyebrow + title + body + bullets + optional collage art. |
|
||||
| `stats` | Up to 4 large stat cells (value · label · sub-label). |
|
||||
| `quote` | Pull quote + author. Optional portrait collage on the right. |
|
||||
| `cta` | Closing pitch + 1-2 buttons. |
|
||||
| `end` | Mega italic-serif kicker word + signature footer. |
|
||||
|
||||
A typical 11-slide pitch:
|
||||
|
||||
```
|
||||
1. cover — title plate, hero collage
|
||||
2. section — "I. The problem"
|
||||
3. content — about / manifesto, bullets
|
||||
4. content — capabilities, bullets
|
||||
5. stats — 4 numbers
|
||||
6. section — "II. How it feels"
|
||||
7. content — method, bullets
|
||||
8. content — selected work
|
||||
9. quote — customer testimonial
|
||||
10. cta — primary + secondary action
|
||||
11. end — mega kicker + signature
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Author `inputs.json`
|
||||
|
||||
Start from [`inputs.example.json`](./inputs.example.json) (the Open
|
||||
Design pitch deck). The brand block, image strategy, and assets path
|
||||
mirror the sister skill — if you already filled out an
|
||||
`editorial-collage` brief, copy `brand` and `imagery` over verbatim.
|
||||
|
||||
For each slide, pick a `kind` and fill the typed fields from
|
||||
[`schema.ts`](./schema.ts). `MixedText` (sans-serif baseline + italic-serif
|
||||
emphasis spans + coral terminating dot) is the same encoding used by
|
||||
the sister skill — see its `inputs.example.json` for examples.
|
||||
|
||||
### 2. (Optional) generate or stub imagery
|
||||
|
||||
This skill does **not** ship its own image generator or placeholder
|
||||
script — it shares the 16-slot library from `editorial-collage`. To
|
||||
regenerate or stub:
|
||||
|
||||
```bash
|
||||
# generate via gpt-image-2 (fal.ai)
|
||||
FAL_KEY=... npx tsx ../editorial-collage/scripts/imagegen.ts ../editorial-collage/inputs.example.json --out=../editorial-collage/assets/
|
||||
|
||||
# or paper-textured SVG placeholders
|
||||
npx tsx ../editorial-collage/scripts/placeholder.ts ../editorial-collage/assets/
|
||||
```
|
||||
|
||||
Set your deck's `inputs.imagery.assets_path` to wherever those PNGs
|
||||
live (default in the example: `../editorial-collage/assets/`).
|
||||
|
||||
### 3. Compose the deck
|
||||
|
||||
```bash
|
||||
npx tsx scripts/compose.ts inputs.json out/index.html
|
||||
```
|
||||
|
||||
The composer reads `inputs.json`, loads the canonical Atelier Zero
|
||||
stylesheet from `../editorial-collage/styles.css`, layers deck-specific
|
||||
rules (scroll-snap container, slide layout grid, HUD, keyboard nav)
|
||||
on top, and writes one self-contained HTML file.
|
||||
|
||||
### 4. Self-check
|
||||
|
||||
- [ ] Open the HTML in a fresh browser tab; slide 1 (cover) shows
|
||||
with HUD `01 / N` in the corner.
|
||||
- [ ] Press `→` (or Space). Smoothly advances to slide 2 with
|
||||
`02 / N` in the counter and the coral progress bar filling.
|
||||
- [ ] Press `End`. Jumps to the final slide.
|
||||
- [ ] Press `Home`. Returns to slide 1.
|
||||
- [ ] `prefers-reduced-motion: reduce` (DevTools → Rendering): smooth
|
||||
scroll still works, but page transitions are instant.
|
||||
- [ ] Resize to 1080px and 640px. Slides stack appropriately; no
|
||||
horizontal scrollbar; HUD shrinks gracefully.
|
||||
- [ ] Lighthouse: contrast AA, font-display swap, no layout shift.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Reuse the sister skill's stylesheet.** The composer reads
|
||||
`../editorial-collage/styles.css` at compile time. Do not maintain a
|
||||
duplicate copy here; if Atelier Zero tokens evolve, edit them once
|
||||
in the sister skill.
|
||||
- **Reuse the sister skill's image library.** No need to re-prompt or
|
||||
re-render — the same 16 plates work for both surfaces.
|
||||
- **Keep slides single-viewport.** If a slide's content does not fit
|
||||
100vh at 1280×800 it will overflow and feel cramped. Trim copy or
|
||||
split into two slides.
|
||||
- **Do not add a router.** This is a single-file artifact. Multi-page
|
||||
decks are out of scope; for a multi-deck experience, render each
|
||||
deck separately and link from a parent index.
|
||||
|
||||
## See also
|
||||
|
||||
- [`editorial-collage`](../editorial-collage/) — landing page sister skill.
|
||||
- [`design-systems/atelier-zero/DESIGN.md`](../../design-systems/atelier-zero/DESIGN.md) — token spec.
|
||||
2266
skills/editorial-collage-deck/example.html
Normal file
210
skills/editorial-collage-deck/inputs.example.json
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
{
|
||||
"$schema": "./schema.ts",
|
||||
"_doc": "Worked example — Open Design pitch deck. 11 slides covering cover, two sections, four content slides, one stats, one quote, one CTA, one end. Reuses brand identity and assets from the sister editorial-collage skill. Run `npx tsx scripts/compose.ts inputs.example.json example.html` to build.",
|
||||
|
||||
"brand": {
|
||||
"name": "Open Design",
|
||||
"mark": "Ø",
|
||||
"meta": { "title": "Studio Nº 01", "subtitle": "Berlin / Open / Earth" },
|
||||
"filed_under": "Design · Intelligence",
|
||||
"tagline": "Designing intelligence with skills, taste, and your own agent.",
|
||||
"description": "Open Design pitch deck — Vol. 01.",
|
||||
"locale": "en",
|
||||
"edition": "Vol. 01 / Issue Nº 26",
|
||||
"version": "v0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"primary_url": "https://github.com/nexu-io/open-design",
|
||||
"primary_url_label": "Star · 0K",
|
||||
"contact_email": "https://github.com/nexu-io/open-design/issues",
|
||||
"location": "Berlin / Open / Earth",
|
||||
"coordinates": "52.5200° N · 13.4050° E",
|
||||
"year": "2026",
|
||||
"year_roman": "MMXXVI",
|
||||
"founded": "Est. MMXXVI",
|
||||
"rails": { "right": "", "left": "" },
|
||||
"languages": ["EN"],
|
||||
"status": "Live · v0.2.0"
|
||||
},
|
||||
|
||||
"deck_title": "Open Design · Vol. 01 / Issue Nº 26",
|
||||
|
||||
"slides": [
|
||||
{
|
||||
"kind": "cover",
|
||||
"eyebrow": "Open-source design studio · Nº 01",
|
||||
"title": [
|
||||
{ "text": "Designing " },
|
||||
{ "text": "intelligence", "em": true },
|
||||
{ "text": " with skills, " },
|
||||
{ "text": "taste,", "em": true },
|
||||
{ "text": " and " },
|
||||
{ "text": "code", "em": true },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"subtitle": "The open-source alternative to Anthropic's Claude Design.",
|
||||
"lead": "12 coding agents drive 31 composable skills and 72 brand-grade design systems. Local-first, web-deployable, BYOK at every layer.",
|
||||
"image_slot": "hero",
|
||||
"meta": "Berlin · MMXXVI · 52.5200° N · 13.4050° E"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "section",
|
||||
"roman": "I.",
|
||||
"title": [
|
||||
{ "text": "Why " },
|
||||
{ "text": "another", "em": true },
|
||||
{ "text": " design tool" },
|
||||
{ "text": "?", "dot": true }
|
||||
],
|
||||
"lead": "Because the strongest agents already live on your laptop — and they deserve a real workflow."
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "content",
|
||||
"layout": "right",
|
||||
"eyebrow": "About the studio · Nº 02",
|
||||
"title": [
|
||||
{ "text": "We treat " },
|
||||
{ "text": "your agent", "em": true },
|
||||
{ "text": " as a creative " },
|
||||
{ "text": "collaborator", "em": true },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"body": "We don't ship one — we wire whichever you trust into a skill-driven design workflow that runs locally with <code>pnpm tools-dev</code>, deploys to Vercel, and stays BYOK at every layer.",
|
||||
"bullets": [
|
||||
"Files, not opaque prompts — every skill is a folder of Markdown.",
|
||||
"Deterministic visual directions, not random generation.",
|
||||
"Sandboxed iframe preview, real cwd, exportable artifacts."
|
||||
],
|
||||
"image_slot": "about"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "content",
|
||||
"layout": "left",
|
||||
"eyebrow": "Capabilities · Nº 03",
|
||||
"title": [
|
||||
{ "text": "Skills, systems, surfaces — " },
|
||||
{ "text": "for creative", "em": true },
|
||||
{ "text": " intelligence" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"body": "Four composable surfaces, one feedback loop. Skills supply behavior. Systems supply taste. Adapters bridge agents. BYOK respects your wallet.",
|
||||
"bullets": [
|
||||
"31 file-based SKILL.md bundles — drop in, restart, appears.",
|
||||
"72 portable DESIGN.md systems — Linear, Vercel, Stripe, Apple…",
|
||||
"12 agent adapters — Claude · Codex · Gemini · Cursor · …",
|
||||
"OpenAI-compatible proxy — paste a baseUrl + key, ship."
|
||||
],
|
||||
"image_slot": "capabilities"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "stats",
|
||||
"eyebrow": "By the numbers · Nº 04",
|
||||
"title": [
|
||||
{ "text": "Composable, " },
|
||||
{ "text": "shippable,", "em": true },
|
||||
{ "text": " portable" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"stats": [
|
||||
{ "value": "31", "label": "Skills", "sub": "file-based, shippable today" },
|
||||
{ "value": "72", "label": "Systems", "sub": "design tokens you already trust" },
|
||||
{ "value": "12", "label": "Agents", "sub": "auto-detected on your $PATH" },
|
||||
{ "value": "3", "label": "Commands","sub": "from clone to first artifact" }
|
||||
],
|
||||
"caption": "Open Design v0.2.0 · Apache-2.0 · MMXXVI"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "section",
|
||||
"roman": "II.",
|
||||
"title": [
|
||||
{ "text": "How it " },
|
||||
{ "text": "feels", "em": true },
|
||||
{ "text": " to use it" },
|
||||
{ "text": ".", "dot": true }
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "content",
|
||||
"layout": "right",
|
||||
"eyebrow": "Method · Nº 05",
|
||||
"title": [
|
||||
{ "text": "From " },
|
||||
{ "text": "signals", "em": true },
|
||||
{ "text": " to systems" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"body": "Every project moves through four iterative stages. The agent picks each stage's tools deterministically; you stay in control.",
|
||||
"bullets": [
|
||||
"01 · Detect — daemon scans $PATH, auto-loads skills + systems.",
|
||||
"02 · Discover — 30s question form locks brand · audience · scale.",
|
||||
"03 · Direct — pick one of 5 visual directions in OKLch + type stack.",
|
||||
"04 · Deliver — write to disk, preview in sandbox, export anywhere."
|
||||
],
|
||||
"image_slot": "method-1"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "content",
|
||||
"layout": "left",
|
||||
"eyebrow": "Selected work · Nº 06",
|
||||
"title": [
|
||||
{ "text": "Skills that turn briefs into " },
|
||||
{ "text": "memorable", "em": true },
|
||||
{ "text": " artifacts" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"body": "From editorial decks to consumer dashboards — the same loop, different surface. Every output is a real file you can hand to a client tomorrow.",
|
||||
"image_slot": "work-1"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "quote",
|
||||
"quote": [
|
||||
{ "text": "Open Design helped us turn vague " },
|
||||
{ "text": "AI ideas", "em": true },
|
||||
{ "text": " into a visual system that felt " },
|
||||
{ "text": "sharp, believable,", "em": true },
|
||||
{ "text": " and genuinely new." }
|
||||
],
|
||||
"author": { "initial": "m", "name": "Mina Kovac", "title": "Creative Director · North Form" },
|
||||
"image_slot": "testimonial"
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "cta",
|
||||
"eyebrow": "Start a conversation · Nº 07",
|
||||
"title": [
|
||||
{ "text": "Let's build something " },
|
||||
{ "text": "open", "em": true },
|
||||
{ "text": " and " },
|
||||
{ "text": "visually", "em": true },
|
||||
{ "text": " unforgettable" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"body": "Star us on GitHub, drop into the issues, or run pnpm tools-dev tonight. Three commands and the loop is yours.",
|
||||
"primary": { "label": "Star on GitHub", "href": "https://github.com/nexu-io/open-design" },
|
||||
"secondary": { "label": "Open an issue", "href": "https://github.com/nexu-io/open-design/issues" }
|
||||
},
|
||||
|
||||
{
|
||||
"kind": "end",
|
||||
"mega": [
|
||||
{ "text": "Open " },
|
||||
{ "text": "Design", "em": true },
|
||||
{ "text": "." }
|
||||
],
|
||||
"footer": "Apache-2.0 · MMXXVI · Berlin · 52.5200° N · 13.4050° E"
|
||||
}
|
||||
],
|
||||
|
||||
"imagery": {
|
||||
"strategy": "bring-your-own",
|
||||
"assets_path": "../editorial-collage/assets/",
|
||||
"provider": "fal"
|
||||
}
|
||||
}
|
||||
119
skills/editorial-collage-deck/schema.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* editorial-collage-deck — input schema.
|
||||
*
|
||||
* Sister skill to `editorial-collage`. Produces a single-file slide
|
||||
* deck (scroll-snap pagination + arrow-key nav) in the Atelier Zero
|
||||
* visual language, reusing the same `styles.css` + the same 16-slot
|
||||
* image library.
|
||||
*
|
||||
* The schema is intentionally smaller than the landing page schema:
|
||||
* a deck is an ordered array of typed slides, each driving one
|
||||
* viewport-height frame. Brand identity is shared across slides.
|
||||
*/
|
||||
|
||||
import type { MixedText, BrandBlock, ImageryConfig } from '../editorial-collage/schema';
|
||||
|
||||
export type { MixedText, BrandBlock, ImageryConfig };
|
||||
|
||||
/* ---------- slide variants ---------- */
|
||||
|
||||
/** Cover slide — title plate at the start of the deck. */
|
||||
export interface CoverSlide {
|
||||
kind: 'cover';
|
||||
/** Eyebrow above the title — `'Open Design · Vol. 01'`. */
|
||||
eyebrow: string;
|
||||
/** Display title; encoded as `MixedText` for italic-serif rhythm. */
|
||||
title: MixedText;
|
||||
/** Optional sub-title under the title. */
|
||||
subtitle?: string;
|
||||
/** Lead paragraph below the title. */
|
||||
lead: string;
|
||||
/** Optional image slot id (`hero` | `cta` | …) from `image-manifest.json`. */
|
||||
image_slot?: string;
|
||||
/** Bottom-left meta line — date / location / coords. */
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
/** Section divider — Roman numeral plate between chapters. */
|
||||
export interface SectionSlide {
|
||||
kind: 'section';
|
||||
roman: string;
|
||||
/** Section title; rendered huge with italic-serif emphasis. */
|
||||
title: MixedText;
|
||||
/** Optional one-line description under the title. */
|
||||
lead?: string;
|
||||
}
|
||||
|
||||
/** Content slide — eyebrow + title + body (+ optional bullets + image). */
|
||||
export interface ContentSlide {
|
||||
kind: 'content';
|
||||
eyebrow?: string;
|
||||
title: MixedText;
|
||||
/** Body paragraph; can include `<code>` raw HTML. */
|
||||
body?: string;
|
||||
/** Optional bullet list. */
|
||||
bullets?: string[];
|
||||
/** Optional image slot id from `image-manifest.json`. */
|
||||
image_slot?: string;
|
||||
/** Layout: `left` puts copy left of art, `right` flips it, `full` centers. */
|
||||
layout?: 'left' | 'right' | 'full';
|
||||
}
|
||||
|
||||
/** Stats slide — eyebrow + title + 3-4 large stat rings. */
|
||||
export interface StatsSlide {
|
||||
kind: 'stats';
|
||||
eyebrow?: string;
|
||||
title: MixedText;
|
||||
stats: { value: string; label: string; sub?: string }[];
|
||||
/** Caption under the stat row. */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/** Quote slide — full-bleed pull quote. */
|
||||
export interface QuoteSlide {
|
||||
kind: 'quote';
|
||||
quote: MixedText;
|
||||
author: { initial: string; name: string; title: string };
|
||||
/** Optional image slot for the right-side portrait. */
|
||||
image_slot?: string;
|
||||
}
|
||||
|
||||
/** CTA slide — closing pitch with primary action. */
|
||||
export interface CTASlide {
|
||||
kind: 'cta';
|
||||
eyebrow?: string;
|
||||
title: MixedText;
|
||||
body?: string;
|
||||
primary: { label: string; href: string };
|
||||
/** Optional secondary action. */
|
||||
secondary?: { label: string; href: string };
|
||||
}
|
||||
|
||||
/** End slide — huge italic kicker word and footer signature. */
|
||||
export interface EndSlide {
|
||||
kind: 'end';
|
||||
/** The huge kicker — `'Open Design.'`. */
|
||||
mega: MixedText;
|
||||
/** Footer text under the kicker — `'Apache-2.0 · MMXXVI · Berlin'`. */
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
export type Slide =
|
||||
| CoverSlide
|
||||
| SectionSlide
|
||||
| ContentSlide
|
||||
| StatsSlide
|
||||
| QuoteSlide
|
||||
| CTASlide
|
||||
| EndSlide;
|
||||
|
||||
/* ---------- top-level ---------- */
|
||||
|
||||
export interface EditorialCollageDeckInputs {
|
||||
$schema?: string;
|
||||
brand: BrandBlock;
|
||||
/** Deck-wide title shown in the HUD — `'Open Design · Vol. 01'`. */
|
||||
deck_title: string;
|
||||
slides: Slide[];
|
||||
imagery: ImageryConfig;
|
||||
}
|
||||
698
skills/editorial-collage-deck/scripts/compose.ts
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* editorial-collage-deck — slide deck composer.
|
||||
*
|
||||
* Reads `inputs.json` (matching `../schema.ts`) and writes a single
|
||||
* self-contained HTML file: a scroll-snap deck where every slide
|
||||
* occupies one viewport. Reuses the Atelier Zero stylesheet from the
|
||||
* sister `editorial-collage` skill, then layers deck-specific rules
|
||||
* (snap container, slide layout, HUD, keyboard nav).
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/compose.ts <inputs.json> <output.html>
|
||||
*
|
||||
* Re-generate the canonical example:
|
||||
* npx tsx scripts/compose.ts inputs.example.json example.html
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
EditorialCollageDeckInputs,
|
||||
Slide,
|
||||
CoverSlide,
|
||||
SectionSlide,
|
||||
ContentSlide,
|
||||
StatsSlide,
|
||||
QuoteSlide,
|
||||
CTASlide,
|
||||
EndSlide,
|
||||
MixedText,
|
||||
} from '../schema';
|
||||
|
||||
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SISTER_STYLES = resolve(SKILL_ROOT, '..', 'editorial-collage', 'styles.css');
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* helpers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function mixed(text: MixedText): string {
|
||||
return text
|
||||
.map((seg) => {
|
||||
if (seg.dot) return `<span class='dot'>${seg.text}</span>`;
|
||||
if (seg.em) return `<em>${seg.text}</em>`;
|
||||
return seg.text;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function ext(href: string): string {
|
||||
return /^(https?:|mailto:|\/\/)/i.test(href) ? ` target='_blank' rel='noreferrer noopener'` : '';
|
||||
}
|
||||
|
||||
const ARROW_OUT = `<svg viewBox='0 0 24 24'><path d='M5 19L19 5M19 5H8M19 5v11'/></svg>`;
|
||||
|
||||
function imgFor(slot: string | undefined, assets: string): string {
|
||||
if (!slot) return '';
|
||||
return `<img src='${assets}${slot}.png' alt='' />`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* deck-specific stylesheet (layered on top of editorial-collage CSS)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const DECK_CSS = `
|
||||
/* deck container — scroll-snap pagination */
|
||||
html, body { height: 100%; }
|
||||
body { overflow: hidden; }
|
||||
.deck {
|
||||
height: 100vh;
|
||||
overflow-y: scroll;
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-behavior: smooth;
|
||||
position: relative;
|
||||
}
|
||||
.slide {
|
||||
height: 100vh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
/* Clip art / oversized content so it cannot bleed into adjacent slides
|
||||
* at narrow / tall viewports (1/1 aspect-ratio art often exceeds 100vh
|
||||
* minus padding). */
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-inner {
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
padding: 80px 80px 64px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 28px;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Cap art panels so they fit inside the slide minus the inner padding. */
|
||||
.s-cover .art,
|
||||
.s-content .art,
|
||||
.s-quote .art {
|
||||
max-height: calc(100vh - 160px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* HUD — fixed top bar + slide counter + keyboard hint */
|
||||
.deck-hud {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 32px;
|
||||
z-index: 50;
|
||||
font-family: var(--sans);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
.deck-hud .left { display: inline-flex; gap: 14px; align-items: center; pointer-events: auto; }
|
||||
.deck-hud .right { display: inline-flex; gap: 14px; align-items: center; pointer-events: auto; }
|
||||
.deck-hud .mark {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
border: 1px solid var(--ink); display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
font-family: var(--serif); font-style: italic; font-size: 12px;
|
||||
color: var(--ink); background: rgba(239,231,210,0.85);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.deck-hud .counter {
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink);
|
||||
background: rgba(239,231,210,0.85);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.deck-hud .keys {
|
||||
background: rgba(239,231,210,0.85);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* progress bar at bottom */
|
||||
.deck-progress {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: var(--line-soft);
|
||||
z-index: 50;
|
||||
}
|
||||
.deck-progress .bar {
|
||||
height: 100%;
|
||||
background: var(--coral);
|
||||
width: 0%;
|
||||
transition: width 240ms ease;
|
||||
}
|
||||
|
||||
/* ---------- COVER slide ---------- */
|
||||
.s-cover .slide-inner {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
align-content: center;
|
||||
gap: 60px;
|
||||
}
|
||||
.s-cover .copy {
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
}
|
||||
.s-cover .eyebrow {
|
||||
font-family: var(--sans); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.22em; text-transform: uppercase;
|
||||
color: var(--coral); display: inline-flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.s-cover .eyebrow::before {
|
||||
content: ''; width: 18px; height: 1px;
|
||||
background: var(--coral); display: inline-block;
|
||||
}
|
||||
.s-cover h1 {
|
||||
font-family: var(--sans);
|
||||
font-weight: 800;
|
||||
font-size: clamp(40px, 5.2vw, 80px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.028em;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
.s-cover h1 em {
|
||||
font-family: var(--serif);
|
||||
font-style: italic; font-weight: 500;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.s-cover h1 .dot { color: var(--coral); }
|
||||
.s-cover .subtitle {
|
||||
font-family: var(--serif); font-style: italic; font-weight: 500;
|
||||
font-size: 22px; color: var(--ink-soft); margin-top: -8px;
|
||||
}
|
||||
.s-cover .lead {
|
||||
font-family: var(--body); font-size: 17px;
|
||||
color: var(--ink-soft); max-width: 42ch; line-height: 1.55;
|
||||
}
|
||||
.s-cover .meta {
|
||||
margin-top: 32px;
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.s-cover .art {
|
||||
position: relative; aspect-ratio: 1 / 1; max-width: 620px;
|
||||
margin-left: auto; margin-right: 0;
|
||||
border: 1px solid var(--line-soft); border-radius: 14px;
|
||||
overflow: hidden; background: var(--bone);
|
||||
}
|
||||
.s-cover .art img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* ---------- SECTION divider slide ---------- */
|
||||
.s-section .slide-inner {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
gap: 32px;
|
||||
}
|
||||
.s-section .roman {
|
||||
font-family: var(--serif); font-style: italic; font-weight: 500;
|
||||
font-size: clamp(80px, 10vw, 160px);
|
||||
color: var(--coral); line-height: 1; letter-spacing: -0.02em;
|
||||
}
|
||||
.s-section h2 {
|
||||
font-family: var(--sans); font-weight: 800;
|
||||
font-size: clamp(54px, 6.6vw, 100px);
|
||||
letter-spacing: -0.028em; line-height: 1.0; color: var(--ink);
|
||||
max-width: 18ch; margin: 0 auto;
|
||||
}
|
||||
.s-section h2 em {
|
||||
font-family: var(--serif); font-style: italic; font-weight: 500;
|
||||
}
|
||||
.s-section h2 .dot { color: var(--coral); }
|
||||
.s-section .lead {
|
||||
font-family: var(--body); font-size: 17px;
|
||||
color: var(--ink-soft); max-width: 50ch; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- CONTENT slide ---------- */
|
||||
.s-content .slide-inner { gap: 48px; }
|
||||
.s-content.layout-left .slide-inner { grid-template-columns: 1fr 0.9fr; }
|
||||
.s-content.layout-right .slide-inner { grid-template-columns: 0.9fr 1fr; }
|
||||
.s-content.layout-right .copy { order: 2; }
|
||||
.s-content.layout-right .art { order: 1; }
|
||||
.s-content.layout-full .slide-inner { grid-template-columns: 1fr; max-width: 980px; }
|
||||
.s-content .copy { display: flex; flex-direction: column; gap: 22px; }
|
||||
.s-content .eyebrow {
|
||||
font-family: var(--sans); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.22em; text-transform: uppercase; color: var(--coral);
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.s-content .eyebrow::before {
|
||||
content: ''; width: 18px; height: 1px;
|
||||
background: var(--coral); display: inline-block;
|
||||
}
|
||||
.s-content h2 {
|
||||
font-family: var(--sans); font-weight: 800;
|
||||
font-size: clamp(40px, 4.6vw, 64px);
|
||||
letter-spacing: -0.024em; line-height: 1.05;
|
||||
color: var(--ink); margin: 0;
|
||||
}
|
||||
.s-content h2 em { font-family: var(--serif); font-style: italic; font-weight: 500; }
|
||||
.s-content h2 .dot { color: var(--coral); }
|
||||
.s-content .body {
|
||||
font-family: var(--body); font-size: 16px;
|
||||
color: var(--ink-soft); max-width: 56ch; line-height: 1.55;
|
||||
}
|
||||
.s-content .body code { font-family: var(--mono); font-size: 14px; background: var(--bone); padding: 1px 6px; border-radius: 4px; }
|
||||
.s-content ul {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.s-content li {
|
||||
font-family: var(--sans); font-size: 15px;
|
||||
color: var(--ink-soft); display: flex; gap: 14px; align-items: flex-start;
|
||||
}
|
||||
.s-content li::before {
|
||||
content: ''; width: 12px; height: 1px;
|
||||
background: var(--coral); margin-top: 11px; flex-shrink: 0;
|
||||
}
|
||||
.s-content .art {
|
||||
position: relative; aspect-ratio: 1 / 1;
|
||||
border: 1px solid var(--line-soft); border-radius: 14px;
|
||||
overflow: hidden; background: var(--bone);
|
||||
}
|
||||
.s-content .art img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* ---------- STATS slide ---------- */
|
||||
.s-stats .slide-inner { grid-template-columns: 1fr; gap: 60px; }
|
||||
.s-stats .head { display: flex; flex-direction: column; gap: 22px; }
|
||||
.s-stats .eyebrow {
|
||||
font-family: var(--sans); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.22em; text-transform: uppercase; color: var(--coral);
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.s-stats .eyebrow::before { content: ''; width: 18px; height: 1px; background: var(--coral); display: inline-block; }
|
||||
.s-stats h2 {
|
||||
font-family: var(--sans); font-weight: 800;
|
||||
font-size: clamp(44px, 5vw, 72px);
|
||||
letter-spacing: -0.026em; line-height: 1.05; max-width: 18ch; margin: 0;
|
||||
}
|
||||
.s-stats h2 em { font-family: var(--serif); font-style: italic; font-weight: 500; }
|
||||
.s-stats h2 .dot { color: var(--coral); }
|
||||
.s-stats .grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 36px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 36px;
|
||||
}
|
||||
.s-stats .stat { display: flex; flex-direction: column; gap: 10px; }
|
||||
.s-stats .stat .num {
|
||||
font-family: var(--sans); font-weight: 800;
|
||||
font-size: clamp(80px, 9vw, 140px); line-height: 1;
|
||||
letter-spacing: -0.04em; color: var(--ink);
|
||||
}
|
||||
.s-stats .stat .num em { color: var(--coral); font-family: var(--serif); font-style: italic; font-weight: 500; }
|
||||
.s-stats .stat .label {
|
||||
font-family: var(--sans); font-size: 12px;
|
||||
letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--ink); font-weight: 600;
|
||||
}
|
||||
.s-stats .stat .sub {
|
||||
font-family: var(--body); font-size: 13px;
|
||||
color: var(--ink-mute); max-width: 26ch; line-height: 1.5;
|
||||
}
|
||||
.s-stats .caption {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
color: var(--ink-faint); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---------- QUOTE slide ---------- */
|
||||
.s-quote .slide-inner { grid-template-columns: 1.4fr 0.8fr; gap: 60px; align-items: center; }
|
||||
.s-quote.no-art .slide-inner { grid-template-columns: 1fr; max-width: 980px; }
|
||||
.s-quote blockquote {
|
||||
font-family: var(--sans); font-weight: 700;
|
||||
font-size: clamp(36px, 4vw, 56px);
|
||||
letter-spacing: -0.022em; line-height: 1.15;
|
||||
color: var(--ink); margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
.s-quote blockquote em { font-family: var(--serif); font-style: italic; font-weight: 500; }
|
||||
.s-quote .author {
|
||||
margin-top: 38px; display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
.s-quote .author .avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%; background: var(--ink);
|
||||
color: var(--paper); font-family: var(--serif); font-style: italic; font-size: 22px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.s-quote .author p { font-family: var(--sans); font-size: 14px; font-weight: 600; }
|
||||
.s-quote .author p span { display: block; color: var(--ink-mute); font-weight: 400; }
|
||||
.s-quote .art {
|
||||
position: relative; aspect-ratio: 1 / 1;
|
||||
border: 1px solid var(--line-soft); border-radius: 14px;
|
||||
overflow: hidden; background: var(--bone);
|
||||
}
|
||||
.s-quote .art img { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* ---------- CTA slide ---------- */
|
||||
.s-cta .slide-inner { grid-template-columns: 1fr; max-width: 980px; gap: 32px; text-align: left; }
|
||||
.s-cta .eyebrow {
|
||||
font-family: var(--sans); font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.22em; text-transform: uppercase; color: var(--coral);
|
||||
display: inline-flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.s-cta .eyebrow::before { content: ''; width: 18px; height: 1px; background: var(--coral); display: inline-block; }
|
||||
.s-cta h2 {
|
||||
font-family: var(--sans); font-weight: 800;
|
||||
font-size: clamp(54px, 6.4vw, 96px);
|
||||
letter-spacing: -0.028em; line-height: 1.0; color: var(--ink); margin: 0;
|
||||
}
|
||||
.s-cta h2 em { font-family: var(--serif); font-style: italic; font-weight: 500; }
|
||||
.s-cta h2 .dot { color: var(--coral); }
|
||||
.s-cta .body { font-family: var(--body); font-size: 17px; color: var(--ink-soft); max-width: 50ch; line-height: 1.55; }
|
||||
.s-cta .actions { display: inline-flex; gap: 14px; margin-top: 12px; }
|
||||
|
||||
/* ---------- END slide ---------- */
|
||||
.s-end .slide-inner {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: end;
|
||||
padding-bottom: 72px;
|
||||
text-align: left;
|
||||
gap: 16px;
|
||||
max-width: none;
|
||||
padding-left: 64px; padding-right: 64px;
|
||||
}
|
||||
.s-end .word {
|
||||
font-family: var(--sans); font-weight: 900;
|
||||
font-size: clamp(80px, 14vw, 220px);
|
||||
letter-spacing: -0.04em; line-height: 1.05;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 0.18em;
|
||||
}
|
||||
.s-end .word em { font-family: var(--serif); font-style: italic; font-weight: 500; color: var(--coral); }
|
||||
.s-end .footer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 22px;
|
||||
font-family: var(--sans); font-size: 11px;
|
||||
letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* responsive */
|
||||
@media (max-width: 1080px) {
|
||||
.slide-inner { padding: 48px 56px; }
|
||||
.s-cover .slide-inner,
|
||||
.s-content.layout-left .slide-inner,
|
||||
.s-content.layout-right .slide-inner,
|
||||
.s-quote .slide-inner { grid-template-columns: 1fr; gap: 36px; }
|
||||
.s-content.layout-right .copy { order: 1; }
|
||||
.s-content.layout-right .art { order: 2; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.slide-inner { padding: 36px 24px; }
|
||||
.deck-hud { padding: 0 16px; font-size: 9.5px; letter-spacing: 0.14em; }
|
||||
.deck-hud .keys { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* slide renderers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function renderCover(s: CoverSlide, assets: string): string {
|
||||
return `<section class='slide s-cover' data-slide-kind='cover'>
|
||||
<div class='slide-inner'>
|
||||
<div class='copy'>
|
||||
<span class='eyebrow'>${s.eyebrow}</span>
|
||||
<h1>${mixed(s.title)}</h1>
|
||||
${s.subtitle ? `<div class='subtitle'>${s.subtitle}</div>` : ''}
|
||||
<p class='lead'>${s.lead}</p>
|
||||
${s.meta ? `<div class='meta'>${s.meta}</div>` : ''}
|
||||
</div>
|
||||
<div class='art'>${imgFor(s.image_slot, assets)}</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSection(s: SectionSlide): string {
|
||||
return `<section class='slide s-section' data-slide-kind='section'>
|
||||
<div class='slide-inner'>
|
||||
<div class='roman'>${s.roman}</div>
|
||||
<h2>${mixed(s.title)}</h2>
|
||||
${s.lead ? `<p class='lead'>${s.lead}</p>` : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderContent(s: ContentSlide, assets: string): string {
|
||||
const layout = s.layout ?? 'left';
|
||||
const hasArt = !!s.image_slot;
|
||||
return `<section class='slide s-content layout-${layout}${hasArt ? '' : ' no-art'}' data-slide-kind='content'>
|
||||
<div class='slide-inner'>
|
||||
<div class='copy'>
|
||||
${s.eyebrow ? `<span class='eyebrow'>${s.eyebrow}</span>` : ''}
|
||||
<h2>${mixed(s.title)}</h2>
|
||||
${s.body ? `<p class='body'>${s.body}</p>` : ''}
|
||||
${s.bullets && s.bullets.length ? `<ul>${s.bullets.map((b) => `<li>${b}</li>`).join('')}</ul>` : ''}
|
||||
</div>
|
||||
${hasArt ? `<div class='art'>${imgFor(s.image_slot, assets)}</div>` : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderStats(s: StatsSlide): string {
|
||||
const stats = s.stats
|
||||
.map(
|
||||
(st) =>
|
||||
`<div class='stat'>
|
||||
<div class='num'>${st.value}</div>
|
||||
<div class='label'>${st.label}</div>
|
||||
${st.sub ? `<div class='sub'>${st.sub}</div>` : ''}
|
||||
</div>`,
|
||||
)
|
||||
.join('\n ');
|
||||
return `<section class='slide s-stats' data-slide-kind='stats'>
|
||||
<div class='slide-inner'>
|
||||
<div class='head'>
|
||||
${s.eyebrow ? `<span class='eyebrow'>${s.eyebrow}</span>` : ''}
|
||||
<h2>${mixed(s.title)}</h2>
|
||||
</div>
|
||||
<div class='grid'>
|
||||
${stats}
|
||||
</div>
|
||||
${s.caption ? `<div class='caption'>${s.caption}</div>` : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderQuote(s: QuoteSlide, assets: string): string {
|
||||
const hasArt = !!s.image_slot;
|
||||
return `<section class='slide s-quote${hasArt ? '' : ' no-art'}' data-slide-kind='quote'>
|
||||
<div class='slide-inner'>
|
||||
<div>
|
||||
<blockquote>“${mixed(s.quote)}”</blockquote>
|
||||
<div class='author'>
|
||||
<span class='avatar'>${s.author.initial}</span>
|
||||
<p>${s.author.name}<br/><span>${s.author.title}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
${hasArt ? `<div class='art'>${imgFor(s.image_slot, assets)}</div>` : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCTA(s: CTASlide): string {
|
||||
return `<section class='slide s-cta' data-slide-kind='cta'>
|
||||
<div class='slide-inner'>
|
||||
${s.eyebrow ? `<span class='eyebrow'>${s.eyebrow}</span>` : ''}
|
||||
<h2>${mixed(s.title)}</h2>
|
||||
${s.body ? `<p class='body'>${s.body}</p>` : ''}
|
||||
<div class='actions'>
|
||||
<a class='btn btn-primary' href='${s.primary.href}'${ext(s.primary.href)}>
|
||||
${s.primary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
${
|
||||
s.secondary
|
||||
? `<a class='btn btn-ghost' href='${s.secondary.href}'${ext(s.secondary.href)}>
|
||||
${s.secondary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderEnd(s: EndSlide): string {
|
||||
return `<section class='slide s-end' data-slide-kind='end'>
|
||||
<div class='slide-inner'>
|
||||
<div class='word'>${mixed(s.mega)}</div>
|
||||
${s.footer ? `<div class='footer'>${s.footer}</div>` : ''}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSlide(s: Slide, assets: string): string {
|
||||
switch (s.kind) {
|
||||
case 'cover': return renderCover(s, assets);
|
||||
case 'section': return renderSection(s);
|
||||
case 'content': return renderContent(s, assets);
|
||||
case 'stats': return renderStats(s);
|
||||
case 'quote': return renderQuote(s, assets);
|
||||
case 'cta': return renderCTA(s);
|
||||
case 'end': return renderEnd(s);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* runtime script (keyboard nav + counter + progress)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const RUNTIME_SCRIPT = `
|
||||
<script>
|
||||
/*
|
||||
* Deck runtime — keyboard nav, counter update, progress bar.
|
||||
*
|
||||
* • ←/PageUp/k → previous slide
|
||||
* • →/PageDown/Space/j → next slide
|
||||
* • Home/End → first / last slide
|
||||
* • Updates .deck-hud .counter and .deck-progress .bar live as the
|
||||
* user scrolls or paginates.
|
||||
*/
|
||||
(function () {
|
||||
var deck = document.querySelector('.deck');
|
||||
if (!deck) return;
|
||||
var slides = Array.prototype.slice.call(document.querySelectorAll('.slide'));
|
||||
var counter = document.querySelector('.deck-hud .counter');
|
||||
var bar = document.querySelector('.deck-progress .bar');
|
||||
var total = slides.length;
|
||||
|
||||
function indexFromScroll() {
|
||||
var y = deck.scrollTop;
|
||||
var h = deck.clientHeight;
|
||||
var i = Math.round(y / h);
|
||||
if (i < 0) i = 0;
|
||||
if (i > total - 1) i = total - 1;
|
||||
return i;
|
||||
}
|
||||
function update() {
|
||||
var i = indexFromScroll();
|
||||
if (counter) counter.textContent = String(i + 1).padStart(2, '0') + ' / ' + String(total).padStart(2, '0');
|
||||
if (bar) bar.style.width = ((i + 1) / total * 100) + '%';
|
||||
}
|
||||
function goto(i) {
|
||||
if (i < 0) i = 0;
|
||||
if (i > total - 1) i = total - 1;
|
||||
slides[i].scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
deck.addEventListener('scroll', update, { passive: true });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var i = indexFromScroll();
|
||||
if (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ' || e.key === 'j') {
|
||||
e.preventDefault(); goto(i + 1);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'k') {
|
||||
e.preventDefault(); goto(i - 1);
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault(); goto(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault(); goto(total - 1);
|
||||
}
|
||||
});
|
||||
update();
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* top-level
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function renderDeck(inputs: EditorialCollageDeckInputs, baseCss: string): string {
|
||||
const assets = inputs.imagery.assets_path.replace(/\/?$/, '/');
|
||||
const slides = inputs.slides.map((s) => renderSlide(s, assets)).join('\n ');
|
||||
const total = inputs.slides.length;
|
||||
return [
|
||||
`<!DOCTYPE html>`,
|
||||
`<html lang='${inputs.brand.locale ?? 'en'}'>`,
|
||||
`<head>`,
|
||||
`<meta charset='utf-8' />`,
|
||||
`<meta name='viewport' content='width=device-width, initial-scale=1' />`,
|
||||
`<title>${inputs.deck_title}</title>`,
|
||||
`<meta name='description' content='${inputs.brand.description}' />`,
|
||||
`<link rel='preconnect' href='https://fonts.googleapis.com' />`,
|
||||
`<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />`,
|
||||
`<link href='https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap' rel='stylesheet' />`,
|
||||
`<style>${baseCss}${DECK_CSS}</style>`,
|
||||
`</head>`,
|
||||
`<body>`,
|
||||
`<div class='deck-hud'>`,
|
||||
` <div class='left'>`,
|
||||
` <span class='mark'>${inputs.brand.mark}</span>`,
|
||||
` <span>${inputs.deck_title}</span>`,
|
||||
` </div>`,
|
||||
` <div class='right'>`,
|
||||
` <span class='keys'>← / → · Space</span>`,
|
||||
` <span class='counter'>01 / ${String(total).padStart(2, '0')}</span>`,
|
||||
` </div>`,
|
||||
`</div>`,
|
||||
`<div class='deck'>`,
|
||||
` ${slides}`,
|
||||
`</div>`,
|
||||
`<div class='deck-progress'><div class='bar'></div></div>`,
|
||||
RUNTIME_SCRIPT,
|
||||
`</body>`,
|
||||
`</html>`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , inputsArg, outputArg] = process.argv;
|
||||
if (!inputsArg || !outputArg) {
|
||||
console.error('Usage: npx tsx scripts/compose.ts <inputs.json> <output.html>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputsPath = isAbsolute(inputsArg) ? inputsArg : resolve(process.cwd(), inputsArg);
|
||||
const outputPath = isAbsolute(outputArg) ? outputArg : resolve(process.cwd(), outputArg);
|
||||
|
||||
const [inputsRaw, css] = await Promise.all([
|
||||
readFile(inputsPath, 'utf8'),
|
||||
readFile(SISTER_STYLES, 'utf8'),
|
||||
]);
|
||||
const inputs = JSON.parse(inputsRaw) as EditorialCollageDeckInputs;
|
||||
const html = renderDeck(inputs, css);
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, html, 'utf8');
|
||||
console.log(
|
||||
`✓ wrote ${outputPath} (${(html.length / 1024).toFixed(1)} KB, ${inputs.slides.length} slides)`,
|
||||
);
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
109
skills/editorial-collage/README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# editorial-collage
|
||||
|
||||
Reusable skill that produces a world-class editorial landing page in
|
||||
the **Atelier Zero** design language — the warm-paper, italic-serif,
|
||||
collage-on-grid aesthetic shared by Monocle, Apartamento, and Études.
|
||||
|
||||
The skill is parameterized: fill one typed `inputs.json`, run one
|
||||
script, get a self-contained HTML file. Optionally generate 16 surreal
|
||||
collage assets with `gpt-image-2`, or fall back to paper-textured SVG
|
||||
placeholders so the layout still feels intentional with zero image
|
||||
budget.
|
||||
|
||||
> **Read first** — the agent contract, inputs schema, and self-check
|
||||
> live in [`SKILL.md`](./SKILL.md). This README is the human
|
||||
> quick-start.
|
||||
|
||||
## 30-second tour
|
||||
|
||||
```bash
|
||||
# 1. Paper-textured placeholders so the layout renders immediately.
|
||||
npx tsx scripts/placeholder.ts ./out/assets/
|
||||
|
||||
# 2. Compose the standalone HTML from the worked example.
|
||||
npx tsx scripts/compose.ts inputs.example.json ./out/index.html
|
||||
|
||||
# 3. Open it.
|
||||
open ./out/index.html
|
||||
```
|
||||
|
||||
That's it. Three commands, full editorial landing page, no API keys.
|
||||
|
||||
To brand it for yourself, copy `inputs.example.json` to `inputs.json`,
|
||||
edit the fields (the schema is self-documenting — see
|
||||
[`schema.ts`](./schema.ts)), and re-run step 2.
|
||||
|
||||
## The three image strategies
|
||||
|
||||
| Strategy | Cost | Latency | When |
|
||||
| :-------------- | :----- | :------ | :---------------------------------------- |
|
||||
| `placeholder` | $0 | <1s | First pass, demo, internal review. |
|
||||
| `generate` | ~$0.40 | ~6 min | Final delivery; original collage plates. |
|
||||
| `bring-your-own`| $0 | 0s | You have art direction PNGs ready to drop in. |
|
||||
|
||||
Set `inputs.imagery.strategy` to one of the three.
|
||||
|
||||
```bash
|
||||
# generate mode (requires FAL_KEY in env)
|
||||
FAL_KEY=fal-... npx tsx scripts/imagegen.ts inputs.json --out=./out/assets/
|
||||
```
|
||||
|
||||
Without `FAL_KEY`, the imagegen script prints the prompts so you can
|
||||
route them through the `/gpt-image-fal` slash-command skill manually.
|
||||
|
||||
## Layout at a glance
|
||||
|
||||
8 numbered Roman-numeral sections, all responsive at 1280 / 1080 / 880 / 560:
|
||||
|
||||
```
|
||||
I. Hero — display headline + 3 stat rings + 4-step index + collage plate
|
||||
II. About — manifesto + studio stamp + tilted side-note
|
||||
III. Capabilities — 4 cards (skills / systems / adapters / BYOK) + ribbon
|
||||
IV. Labs — 5 portrait cards + filter pills + progress bar
|
||||
V. Method — 4 numbered steps with thumbnails on hairline timeline
|
||||
VI. Selected work — dark slab, 2 tilted cards (one rotated -1.2°, one +2.4°)
|
||||
VII. Testimonial — pull quote + 5 partner glyphs
|
||||
VIII. CTA — closing pitch + ribbon + email pill
|
||||
Footer — 4 link columns + huge italic-serif kicker word
|
||||
```
|
||||
|
||||
Every section has scroll-reveal motion (IntersectionObserver, respects
|
||||
`prefers-reduced-motion`).
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
skills/editorial-collage/
|
||||
├── SKILL.md # ← agent contract (read this first)
|
||||
├── README.md # ← you are here
|
||||
├── schema.ts # typed inputs (single source of truth)
|
||||
├── styles.css # Atelier Zero stylesheet (single source of truth)
|
||||
├── inputs.example.json # Open Design as the worked example
|
||||
├── example.html # canonical rendering, regenerable from inputs.example.json
|
||||
├── scripts/
|
||||
│ ├── compose.ts # inputs.json + styles.css → index.html
|
||||
│ ├── imagegen.ts # gpt-image-2 wrapper (fal.ai backend)
|
||||
│ └── placeholder.ts # SVG paper-textured frames
|
||||
└── assets/
|
||||
├── *.png # 16 collage plates (Open Design instance)
|
||||
├── image-manifest.json # slot → file / dimensions / prompt mapping
|
||||
└── imagegen-prompts.md # human-readable prompt pack
|
||||
```
|
||||
|
||||
## Regenerate the canonical example
|
||||
|
||||
After editing `styles.css`, `schema.ts`, or `inputs.example.json`:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/compose.ts inputs.example.json example.html
|
||||
```
|
||||
|
||||
The `example.html` in this folder is the pre-rendered known-good demo —
|
||||
useful as a visual reference and for QA against the live composer
|
||||
output.
|
||||
|
||||
## See also
|
||||
|
||||
- [`design-systems/atelier-zero/DESIGN.md`](../../design-systems/atelier-zero/DESIGN.md) — colors, type, motion tokens.
|
||||
- [`apps/landing-page/`](../../apps/landing-page/) — Next.js 16 deployable counterpart of this skill.
|
||||
- [`skills/editorial-collage-deck/`](../editorial-collage-deck/) — sibling skill that produces a slide deck in the same visual language.
|
||||
312
skills/editorial-collage/SKILL.md
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
---
|
||||
name: editorial-collage
|
||||
description: >
|
||||
Produce a world-class single-page editorial landing site in the
|
||||
Atelier Zero visual language (Monocle / Apartamento / Études editorial
|
||||
collage). The agent fills a typed `inputs.json` from a brand brief,
|
||||
optionally generates 16 collage assets via gpt-image-2, then runs a
|
||||
pure-function composer that emits a self-contained HTML file plus a
|
||||
ready-to-deploy Next.js app. Drop-in scroll-reveal motion and a
|
||||
Headroom-style sticky nav are wired automatically.
|
||||
triggers:
|
||||
- landing page
|
||||
- 落地页
|
||||
- editorial site
|
||||
- magazine layout
|
||||
- hero collage
|
||||
- atelier zero
|
||||
od:
|
||||
category: brand-page
|
||||
surface: web
|
||||
audience: founders, design studios, OSS maintainers
|
||||
tone: editorial, restrained, premium
|
||||
scale: viewport-anchored long-form single page
|
||||
craft:
|
||||
requires:
|
||||
- pixel-discipline
|
||||
- typographic-rhythm
|
||||
inputs:
|
||||
- id: brand
|
||||
label: Brand identity
|
||||
description: Name, mark, tagline, location, languages, license, repo url.
|
||||
schema_path: ./schema.ts#BrandBlock
|
||||
- id: nav
|
||||
label: Navigation links
|
||||
description: Up to 5 nav entries, each with optional count badge.
|
||||
schema_path: ./schema.ts#NavLink
|
||||
- id: hero
|
||||
label: Hero copy + 3 stat rings + 4-step index
|
||||
schema_path: ./schema.ts#HeroBlock
|
||||
- id: about
|
||||
label: Manifesto / about block
|
||||
schema_path: ./schema.ts#AboutBlock
|
||||
- id: capabilities
|
||||
label: 4 capability cards
|
||||
schema_path: ./schema.ts#CapabilitiesBlock
|
||||
- id: labs
|
||||
label: 5 lab cards + filter pills
|
||||
schema_path: ./schema.ts#LabsBlock
|
||||
- id: method
|
||||
label: 4 method steps with thumbnails
|
||||
schema_path: ./schema.ts#MethodBlock
|
||||
- id: work
|
||||
label: 2 selected-work cards on dark slab
|
||||
schema_path: ./schema.ts#WorkBlock
|
||||
- id: testimonial
|
||||
label: Pull quote + author + 5 partner glyphs
|
||||
schema_path: ./schema.ts#TestimonialBlock
|
||||
- id: cta
|
||||
label: Closing CTA + ribbon
|
||||
schema_path: ./schema.ts#CTABlock
|
||||
- id: footer
|
||||
label: Brand description + 4 link columns + mega kicker
|
||||
schema_path: ./schema.ts#FooterBlock
|
||||
- id: imagery
|
||||
label: Image strategy (generate / placeholder / bring-your-own)
|
||||
schema_path: ./schema.ts#ImageryConfig
|
||||
parameters:
|
||||
output_format:
|
||||
type: enum
|
||||
values: [standalone-html, nextjs-app, both]
|
||||
default: standalone-html
|
||||
description: >
|
||||
`standalone-html` writes one self-contained .html (CSS inlined,
|
||||
scripts inline, images relative). `nextjs-app` clones the
|
||||
`apps/landing-page/` scaffold and wires the same content. `both`
|
||||
writes both products into the output dir.
|
||||
image_strategy:
|
||||
type: enum
|
||||
values: [generate, placeholder, bring-your-own]
|
||||
default: placeholder
|
||||
description: >
|
||||
`generate` calls gpt-image-2 (fal.ai or Azure) for all 16 slots.
|
||||
`placeholder` writes paper-textured SVG frames so the layout is
|
||||
fully visible without an image budget. `bring-your-own` assumes
|
||||
the user has dropped 16 PNGs at `imagery.assets_path` already.
|
||||
image_provider:
|
||||
type: enum
|
||||
values: [fal, azure]
|
||||
default: fal
|
||||
description: Provider for `image_strategy: generate`. fal.ai is faster.
|
||||
outputs:
|
||||
- path: <out>/index.html
|
||||
when: output_format in [standalone-html, both]
|
||||
description: Self-contained HTML with Atelier Zero CSS inlined.
|
||||
- path: <out>/assets/*.png (or *.svg)
|
||||
description: 16 collage assets, generated or placeholder per strategy.
|
||||
- path: <out>/nextjs/
|
||||
when: output_format in [nextjs-app, both]
|
||||
description: Next.js 16 App Router scaffold mirroring apps/landing-page.
|
||||
capabilities_required:
|
||||
- file-write
|
||||
- http-fetch # only when image_strategy=generate
|
||||
- node-runtime # tsx or compatible
|
||||
example_prompt: |
|
||||
Build me an editorial landing page for "Lumen Field", an indie studio
|
||||
shipping a soundscape app for focus. Coral accent, Berlin coordinates,
|
||||
mention the iOS Beta TestFlight, three stats: 12 soundscapes / 4
|
||||
presets / 1 daily ritual. Use the placeholder image strategy.
|
||||
---
|
||||
|
||||
# editorial-collage
|
||||
|
||||
Build a single-page editorial landing site (or a slide deck — see the
|
||||
sibling [`editorial-collage-deck`](../editorial-collage-deck/) skill)
|
||||
in the **Atelier Zero** design system: warm-paper background, Inter
|
||||
Tight + Playfair Display, italic serif emphasis spans, dotted hairline
|
||||
rules, coral terminating dots, scroll-reveal motion, and 16 surreal
|
||||
collage plates.
|
||||
|
||||
The skill is fully **parameterized**. The agent fills one typed
|
||||
`inputs.json` from the user's brief; the composer turns that JSON +
|
||||
the canonical [`styles.css`](./styles.css) into a deployable artifact.
|
||||
|
||||
```text
|
||||
inputs.json + styles.css 16 image slots
|
||||
│ │
|
||||
└──────────► scripts/compose.ts ◄────────────┘
|
||||
│
|
||||
▼
|
||||
<out>/index.html (self-contained)
|
||||
<out>/assets/ (PNG or SVG)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
A single HTML file with **all** of:
|
||||
|
||||
- Editorial topbar (volume / issue / language strip), Headroom-style
|
||||
sticky nav with live GitHub star count.
|
||||
- 8 numbered Roman-numeral sections with paper-textured background:
|
||||
hero (with 3 stat rings + 4-step index), about, capabilities (4 cards),
|
||||
labs (5 cards + filter pills + progress bar), method (4 steps with
|
||||
thumbnails), selected work (dark slab + 2 tilted cards), testimonial
|
||||
(pull quote + 5 partner glyphs), CTA (ribbon + email pill).
|
||||
- Footer with 4 link columns + huge italic-serif kicker word.
|
||||
- Scroll-reveal motion on every section (IntersectionObserver, respects
|
||||
`prefers-reduced-motion`).
|
||||
- Fully responsive at 1280 / 1080 / 880 / 560 breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## Workflow contract
|
||||
|
||||
Run these four steps in order. The agent should **complete** each step
|
||||
before moving on, and prefer asking the user a focused question over
|
||||
inventing copy.
|
||||
|
||||
### 1. Gather brand inputs
|
||||
|
||||
Use `AskQuestion` (or the equivalent in your UI) to collect the brand
|
||||
brief in chunks; do **not** dump the entire `schema.ts` on the user.
|
||||
Map their answers into `inputs.json` matching the typed shape.
|
||||
|
||||
The eight question groups, in order:
|
||||
|
||||
| Group | Schema fields | Min answers | Notes |
|
||||
| :---- | :------------------------------------------------------ | :---------- | :--------------------------------------- |
|
||||
| 1 | `brand.{name,mark,tagline,description,location}` | 5 | Mark = single glyph (Ø, ▲, ★…) |
|
||||
| 2 | `brand.{license,version,year,primary_url,contact_email}`| 4 | URL is required; license defaults Apache-2.0 |
|
||||
| 3 | `nav[]` (up to 5) | 3 | Optional count badges |
|
||||
| 4 | `hero.{label,headline,lead,primary,secondary,stats}` | All | Headline as `MixedText` (sans+em+dot) |
|
||||
| 5 | `about` + `capabilities.cards[4]` | All | 4 cards × {num,tag,title,body} |
|
||||
| 6 | `labs.cards[5]` + `method.steps[4]` | All | Both grids fixed-arity |
|
||||
| 7 | `work.cards[2]` + `testimonial` | All | 5 partner glyphs as inline SVG path data |
|
||||
| 8 | `cta` + `footer.{columns[4],mega}` | All | Mega kicker is a `MixedText` like the headlines |
|
||||
|
||||
Open [`inputs.example.json`](./inputs.example.json) for a complete
|
||||
worked example (Open Design itself).
|
||||
|
||||
### 2. Decide the image strategy
|
||||
|
||||
| Strategy | When to choose | Cost / latency |
|
||||
| :---------------- | :------------------------------------------------------ | :-------------------- |
|
||||
| `placeholder` | First pass. Demo. Slide internal. No image budget yet. | $0, <1s |
|
||||
| `generate` | Final delivery. Brand wants original collages. | ~$0.40, ~6 min |
|
||||
| `bring-your-own` | User has art direction PNGs. Drop them at `assets_path`.| $0, 0s |
|
||||
|
||||
Set `inputs.imagery.strategy` accordingly.
|
||||
|
||||
#### `placeholder` — frame mode
|
||||
|
||||
```bash
|
||||
npx tsx scripts/placeholder.ts <out>/assets/
|
||||
```
|
||||
|
||||
Writes 16 `.svg` files (with `.png` aliases for compatibility) into
|
||||
`<out>/assets/`. Each placeholder shows the slot id, ratio, pixel
|
||||
dimensions, and the prompt hint from `image-manifest.json`. The
|
||||
composer's `<img src='./assets/hero.png'>` etc. just work.
|
||||
|
||||
#### `generate` — gpt-image-2 mode
|
||||
|
||||
```bash
|
||||
FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> --out=<out>/assets/
|
||||
```
|
||||
|
||||
Calls fal.ai's `openai/gpt-image-2` synchronous endpoint per slot.
|
||||
Composes prompts as: **style anchor** (paper-collage editorial system)
|
||||
+ **brand variables** (name / nav / headline / italic emphasis pulled
|
||||
from `inputs.json`) + **per-slot composition** (e.g. cropped plaster
|
||||
head + tree growing through arch). Skips slots whose target file
|
||||
already exists; pass `--force` to re-render.
|
||||
|
||||
Without `FAL_KEY`, the script prints the prompts so the operator can
|
||||
route them through the `/gpt-image-fal` slash-command skill manually.
|
||||
|
||||
#### `bring-your-own`
|
||||
|
||||
Drop 16 PNGs matching `assets/image-manifest.json` filenames at
|
||||
`inputs.imagery.assets_path`. Done.
|
||||
|
||||
### 3. Compose the artifact
|
||||
|
||||
```bash
|
||||
npx tsx scripts/compose.ts <inputs.json> <out>/index.html
|
||||
```
|
||||
|
||||
The composer reads `inputs.json` and `../styles.css`, then writes one
|
||||
self-contained HTML file. The page includes:
|
||||
|
||||
- The full Atelier Zero stylesheet, inlined.
|
||||
- All section markup with `data-reveal` attributes for staggered
|
||||
scroll motion.
|
||||
- Inline IntersectionObserver script (mirrors
|
||||
`apps/landing-page/app/_components/reveal-root.tsx`).
|
||||
- Inline Headroom nav script (mirrors `header.tsx`).
|
||||
- Inline GitHub star-count fetcher (auto-detects from `brand.primary_url`).
|
||||
|
||||
### 4. (Optional) Generate the Next.js scaffold
|
||||
|
||||
For deployable production output, **fork the `apps/landing-page/`
|
||||
module**: copy it to your project root, swap the JSX in `app/page.tsx`
|
||||
for content from your `inputs.json`, and copy your `<out>/assets/*.png`
|
||||
into `public/assets/`. The Next.js variant supports `next build` →
|
||||
static `out/` export ready for any CDN.
|
||||
|
||||
> A future iteration will bundle a `scripts/compose-nextjs.ts` that
|
||||
> emits the entire `apps/landing-page/` tree from `inputs.json` so this
|
||||
> step is one command. Until then, fork-and-edit is the supported path.
|
||||
|
||||
---
|
||||
|
||||
## Self-check before delivering
|
||||
|
||||
Before marking done, the agent **must** verify:
|
||||
|
||||
- [ ] `<out>/index.html` opens in a browser without console errors.
|
||||
- [ ] All 16 image slots load (no 404s in DevTools network tab).
|
||||
- [ ] Headline italic emphasis spans render in Playfair (not sans).
|
||||
- [ ] Coral terminating dots appear at every `display` h1/h2 end.
|
||||
- [ ] Scroll from top to bottom; every section animates in once.
|
||||
- [ ] Resize to 880px and 560px; no horizontal scroll, no overlap.
|
||||
- [ ] `prefers-reduced-motion: reduce` (DevTools → Rendering) disables
|
||||
transitions cleanly.
|
||||
- [ ] Lighthouse: contrast AA, font-display swap, no layout shift on the
|
||||
hero (CLS < 0.05).
|
||||
|
||||
---
|
||||
|
||||
## Files in this skill
|
||||
|
||||
```text
|
||||
skills/editorial-collage/
|
||||
├── SKILL.md # this contract
|
||||
├── README.md # quick-start
|
||||
├── schema.ts # typed inputs (single source of truth)
|
||||
├── styles.css # Atelier Zero stylesheet (single source of truth)
|
||||
├── inputs.example.json # Open Design as the worked example
|
||||
├── example.html # canonical rendering (regenerated from inputs.example.json)
|
||||
├── scripts/
|
||||
│ ├── compose.ts # inputs.json + styles.css → index.html
|
||||
│ ├── imagegen.ts # gpt-image-2 wrapper (fal.ai)
|
||||
│ └── placeholder.ts # SVG paper-textured frames
|
||||
└── assets/
|
||||
├── *.png # 16 collage plates (Open Design instance)
|
||||
├── image-manifest.json # slot → file/dimensions/prompt mapping
|
||||
└── imagegen-prompts.md # human-readable prompt pack
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Do not** invent new colors or typefaces. Tokens live in
|
||||
`design-systems/atelier-zero/DESIGN.md`; extend the design system
|
||||
before adding a new ramp here.
|
||||
- **Do not** drop `data-reveal` attributes from generated markup.
|
||||
Without them the page goes static and feels dead.
|
||||
- **Do not** wrap the composed HTML in a framework that injects its
|
||||
own stylesheet ordering — Atelier Zero relies on stylesheet-order
|
||||
cascade for paper texture and z-index of side rails.
|
||||
- **Do not** add a separate stylesheet file for the Next.js variant;
|
||||
copy `styles.css` verbatim into `app/globals.css` so visual parity
|
||||
stays one-to-one.
|
||||
|
||||
## See also
|
||||
|
||||
- [`design-systems/atelier-zero/DESIGN.md`](../../design-systems/atelier-zero/DESIGN.md) — token spec.
|
||||
- [`apps/landing-page/`](../../apps/landing-page/) — deployable Next.js counterpart.
|
||||
- [`skills/editorial-collage-deck/`](../editorial-collage-deck/) — sibling slides skill that reuses this design system.
|
||||
BIN
skills/editorial-collage/assets/about.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
skills/editorial-collage/assets/capabilities.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
skills/editorial-collage/assets/cta.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
skills/editorial-collage/assets/hero.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
168
skills/editorial-collage/assets/image-manifest.json
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
{
|
||||
"$schema": "https://open-design.dev/schemas/image-manifest.v1.json",
|
||||
"skill": "editorial-collage",
|
||||
"design_system": "atelier-zero",
|
||||
"default_quality": "high",
|
||||
"slots": [
|
||||
{
|
||||
"id": "hero",
|
||||
"file": "hero.png",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "hero.png — 1:1",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "about",
|
||||
"file": "about.png",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "about.png — 1:1",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "capabilities",
|
||||
"file": "capabilities.png",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "capabilities.png — 1:1",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "method-1",
|
||||
"file": "method-1.png",
|
||||
"width": 816,
|
||||
"height": 816,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "method-1 (Detect)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "method-2",
|
||||
"file": "method-2.png",
|
||||
"width": 816,
|
||||
"height": 816,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "method-2 (Discover)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "method-3",
|
||||
"file": "method-3.png",
|
||||
"width": 816,
|
||||
"height": 816,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "method-3 (Direct)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "method-4",
|
||||
"file": "method-4.png",
|
||||
"width": 816,
|
||||
"height": 816,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "method-4 (Deliver)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "lab-1",
|
||||
"file": "lab-1.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "lab-1 (Magazine Decks)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "lab-2",
|
||||
"file": "lab-2.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "lab-2 (Synthetic Matter)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "lab-3",
|
||||
"file": "lab-3.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "lab-3 (Prompt Choreography)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "lab-4",
|
||||
"file": "lab-4.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "lab-4 (Visual Reasoning)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "lab-5",
|
||||
"file": "lab-5.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "lab-5 (Soft Systems)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": false
|
||||
},
|
||||
{
|
||||
"id": "work-1",
|
||||
"file": "work-1.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "work-1 (Featured)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "work-2",
|
||||
"file": "work-2.png",
|
||||
"width": 768,
|
||||
"height": 1024,
|
||||
"ratio": "3:4",
|
||||
"prompt_section": "work-2 (Companion)",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "testimonial",
|
||||
"file": "testimonial.png",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "testimonial.png — 1:1",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
},
|
||||
{
|
||||
"id": "cta",
|
||||
"file": "cta.png",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"ratio": "1:1",
|
||||
"prompt_section": "cta.png — 1:1",
|
||||
"required": true,
|
||||
"rekey_on_brand_change": true
|
||||
}
|
||||
]
|
||||
}
|
||||
246
skills/editorial-collage/assets/imagegen-prompts.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Atelier Zero — Image Generation Prompt Pack
|
||||
|
||||
This pack is consumed by the `editorial-collage` skill. Every page-level
|
||||
image is rendered with `gpt-image-fal` (preferred) or `gpt-image-azure`.
|
||||
|
||||
The pack has three layers:
|
||||
|
||||
1. **Style anchor** — the long block that tells the model what
|
||||
universe we are in. Always prepend to every prompt.
|
||||
2. **Variable slots** — the per-render content (subject, motifs,
|
||||
accent, page type).
|
||||
3. **Per-slot variants** — explicit composition templates for hero,
|
||||
about, capabilities, method tiles, lab cards, work cards,
|
||||
testimonial, and CTA.
|
||||
|
||||
Render at 1024×1024 minimum for square slots (hero / about / capabilities
|
||||
/ testimonial / cta), 816×816 for the four method tiles, and 768×1024 for
|
||||
portrait slots (lab cards, featured work). Authoritative per-slot
|
||||
dimensions and aspect ratios live in `image-manifest.json` — treat that
|
||||
file as the source of truth. Save as PNG to `assets/<slot>.png`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Style anchor (always prepend)
|
||||
|
||||
```text
|
||||
Use case: ads-marketing
|
||||
|
||||
Asset type: editorial website hero / creative studio landing page visual
|
||||
|
||||
Primary request: Generate a refined editorial web page composition in the
|
||||
same visual language as a high-end creative AI research studio.
|
||||
|
||||
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
|
||||
Bauhaus geometric composition, classical plaster sculpture fragments,
|
||||
brutalist/minimal architecture, art-direction website mockup, premium
|
||||
agency aesthetic.
|
||||
|
||||
Scene/backdrop: warm off-white handmade paper background with subtle
|
||||
grain, faint vertical folds, scanned paper fibers, lightly aged print
|
||||
texture, thin drafting lines and registration marks.
|
||||
|
||||
Subject: a surreal collage combining a cropped classical plaster head or
|
||||
face fragment, abstract architectural blocks, archways or stairs, sky
|
||||
cutouts, one small human figure, a delicate tree or botanical element,
|
||||
and geometric color planes.
|
||||
|
||||
Composition/framing: wide 16:9 web page layout, strong asymmetrical
|
||||
grid, generous negative space, large typography area on the left or
|
||||
top-left, collage focal object on the right or center-right, precise
|
||||
alignment, thin divider lines, small UI navigation details.
|
||||
|
||||
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
|
||||
restrained, tactile, poetic, premium, research-driven.
|
||||
|
||||
Color palette: warm ivory, stone beige, soft concrete gray, deep black
|
||||
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
|
||||
accent, pale sky blue only inside small sky/image cutouts.
|
||||
|
||||
Materials/textures: matte plaster, limestone, travertine, concrete, rough
|
||||
torn paper edges, halftone print grain, translucent vellum-like overlays,
|
||||
fine grid paper, dotted matrix patterns.
|
||||
|
||||
Typography: large clean grotesk sans-serif for main headline, elegant
|
||||
high-contrast italic serif for emphasized words, tiny uppercase coral
|
||||
labels, compact UI microcopy. Text must be crisp, readable, and spelled
|
||||
exactly as provided.
|
||||
|
||||
Graphic details: thin hairline circles, partial arcs, crosshair marks,
|
||||
small black dots, dotted grids, fine coordinate lines, numbered
|
||||
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
|
||||
|
||||
Constraints: preserve a high-end editorial web design feel; keep spacing
|
||||
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
|
||||
no busy gradients; no generic stock-photo look.
|
||||
|
||||
Avoid: distorted typography, misspelled text, extra random words, heavy
|
||||
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
|
||||
plastic materials, overly decorative UI cards, cluttered composition,
|
||||
low-resolution textures, watermarks.
|
||||
```
|
||||
|
||||
## 2. Variable slots (substitute per render)
|
||||
|
||||
```text
|
||||
Brand/logo text: "<BRAND_NAME>"
|
||||
Navigation text: "<NAV_1>", "<NAV_2>", "<NAV_3>", "<NAV_4>", "<NAV_5>"
|
||||
Eyebrow label: "<EYEBROW>"
|
||||
Main headline: "<MAIN_HEADLINE>"
|
||||
Italic emphasis words: "<ITALIC_WORDS>"
|
||||
Body copy: "<BODY_COPY>"
|
||||
Primary button: "<PRIMARY_CTA>"
|
||||
Secondary button: "<SECONDARY_CTA>"
|
||||
Footer/micro labels: "<FOOTER_LABELS>"
|
||||
Main collage subject: <plaster head | eye | hand | arch | stair | tree | landscape | object>
|
||||
Inserted texture motifs:<sky, mountain, ocean, eye close-up, dancer, stone, fabric, map, grid, handwritten note>
|
||||
Accent color: <washed coral red | mustard yellow | pale blue | muted sage>
|
||||
Page type: <hero | about | capabilities | method tile | lab card | work card | testimonial | cta>
|
||||
```
|
||||
|
||||
## 3. Per-slot composition templates
|
||||
|
||||
### `hero.png` — 1:1 (1024×1024)
|
||||
|
||||
```text
|
||||
Composition/framing: left half is intentionally empty/quiet to allow real
|
||||
HTML headline overlay; right half holds a tall surreal collage of a
|
||||
cropped classical plaster head with the top sliced open, sky/architecture
|
||||
cutouts visible inside the head, a delicate young tree growing through
|
||||
the composition, a coral sun disk behind, a mustard accent ring at the
|
||||
base, hairline coordinate marks and dotted matrices around it, a small
|
||||
human figure standing for scale in the lower-left of the image. Page
|
||||
type: hero landing.
|
||||
```
|
||||
|
||||
### `about.png` — 1:1 (1024×1024)
|
||||
|
||||
```text
|
||||
Composition: a surreal museum-vitrine arrangement of a partial plaster
|
||||
profile head facing right, with an open archway carved through the
|
||||
torso, sky cutout inside the arch, a tree seedling growing out of the
|
||||
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
|
||||
trace contours. Strong negative space top-left for a side-note overlay.
|
||||
Page type: about / manifesto plate.
|
||||
```
|
||||
|
||||
### `capabilities.png` — 1:1 (1024×1024)
|
||||
|
||||
```text
|
||||
Composition: a Bauhaus-grid stack of architectural fragments — a coral
|
||||
arch on the left, a beige concrete column center, a mustard small disc
|
||||
upper-right, a delicate tree mid-frame, a small classical hand fragment
|
||||
holding a pencil bottom-center. Crosshair and circular hairlines
|
||||
overlay. Page type: capabilities matrix.
|
||||
```
|
||||
|
||||
### `method-1.png` … `method-4.png` — 1:1 (816×816)
|
||||
|
||||
```text
|
||||
Composition: a single visual metaphor per step.
|
||||
method-1 — a magnifying glass over a small architectural map (Detect)
|
||||
method-2 — a clipboard with a tiny questionnaire and a coral pen (Discover)
|
||||
method-3 — a compass + ruler + color swatch fan (Direct)
|
||||
method-4 — a printer's tray with stacked paper sheets exiting (Deliver)
|
||||
Each on the warm paper ground with hairline grid, a single coral or
|
||||
mustard accent piece, and one numbered annotation tag. Page type:
|
||||
method tile.
|
||||
```
|
||||
|
||||
### `lab-1.png` … `lab-5.png` — 3:4 (768×1024)
|
||||
|
||||
```text
|
||||
Composition: portrait-oriented experiment cards. Each is a square-ish
|
||||
plaster-and-architecture vignette, vertical, with a single dominant
|
||||
subject:
|
||||
lab-1 — a stack of folded magazine spreads
|
||||
lab-2 — a film strip + a synthetic eye + a soundwave hairline
|
||||
lab-3 — a typewriter with prompt cards in the carriage
|
||||
lab-4 — five small dotted gauges arranged in a circle (5-dim critique)
|
||||
lab-5 — a glass dome / cloche over a tiny sandbox cityscape (Sandbox)
|
||||
Use the same paper ground; allow soft drop shadow but stay restrained.
|
||||
Page type: lab card.
|
||||
```
|
||||
|
||||
### `work-1.png` & `work-2.png` — 3:4 (768×1024)
|
||||
|
||||
```text
|
||||
Composition: featured work plates.
|
||||
work-1 — guizang-ppt: an oversized open magazine spread on a desk,
|
||||
coral spine, mustard tab. Slight perspective.
|
||||
work-2 — dating-web: a concrete dashboard slab, a coral graph bar
|
||||
rising, a small classical bust beside it for scale.
|
||||
Both on the warm paper ground with crop marks.
|
||||
Page type: work card.
|
||||
```
|
||||
|
||||
### `testimonial.png` — 1:1 (1024×1024)
|
||||
|
||||
```text
|
||||
Composition: a classical plaster bust facing 3/4 left, slightly cropped,
|
||||
with a small sky cutout where the eye would be, a thin coral arc around
|
||||
the back of the head, mustard dot at the chin. Quiet background, lots of
|
||||
negative space upper right. Page type: testimonial portrait.
|
||||
```
|
||||
|
||||
### `cta.png` — 1:1 (1024×1024)
|
||||
|
||||
```text
|
||||
Composition: a closing-plate collage — a mustard sun behind a single
|
||||
coral arch on the right, a delicate tree growing through the arch, a
|
||||
small human figure in the lower-left foreground reading a folded
|
||||
broadsheet, hairline coordinate ladder up the left edge, and a small
|
||||
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.
|
||||
```
|
||||
|
||||
## 4. Chinese project input template
|
||||
|
||||
Use only when the model copy must be Chinese; otherwise prefer English
|
||||
for legibility. Keep verbatim text short.
|
||||
|
||||
```text
|
||||
请生成一张 16:9 横版网页视觉稿,风格为高级创意 AI 工作室官网:现代瑞士编辑排版、
|
||||
包豪斯几何、古典石膏雕塑拼贴、极简建筑、手工纸张肌理、细线工程制图标记。
|
||||
|
||||
品牌文字:"<品牌名>"
|
||||
导航:"<导航1>", "<导航2>", "<导航3>", "<导航4>"
|
||||
小标签:"<小标签>"
|
||||
主标题必须逐字渲染:"<主标题>"
|
||||
强调词(斜体衬线):"<强调词>"
|
||||
正文必须逐字渲染:"<正文>"
|
||||
按钮文字:"<按钮1>", "<按钮2>"
|
||||
|
||||
画面主体:<主体描述>
|
||||
贴图与元素:<天空 / 石材 / 植物 / 人物 / 眼睛 / 山脉 / 水面 / UI 截图等>
|
||||
构图:<左文右图 / 右文左图 / 顶部大标题下方横向卡片 / 中央拼贴 / 时间线分栏>
|
||||
色彩:暖象牙白纸张、黑色文字、石灰/混凝土灰、炭黑、低饱和珊瑚红点缀、
|
||||
少量芥末黄或浅天蓝。
|
||||
|
||||
限制:文字清晰可读、不添加多余文字、不要水印、不要卡通、不要霓虹、
|
||||
不要厚重阴影、不要俗套科技蓝紫渐变。
|
||||
```
|
||||
|
||||
## 5. Calling convention
|
||||
|
||||
Pseudocode for an agent driver:
|
||||
|
||||
```ts
|
||||
for (const slot of imageManifest.slots) {
|
||||
const prompt = [
|
||||
STYLE_ANCHOR,
|
||||
fillVars(VARIABLE_SLOTS, brand),
|
||||
PER_SLOT[slot.id],
|
||||
].join('\n\n');
|
||||
|
||||
await gptImageFal({
|
||||
prompt,
|
||||
width: slot.width,
|
||||
height: slot.height,
|
||||
quality: 'high',
|
||||
output: `assets/${slot.id}.png`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
If `gpt-image-fal` is unavailable, the same prompts work with
|
||||
`gpt-image-azure` — but mask-based inpainting is azure-only.
|
||||
BIN
skills/editorial-collage/assets/lab-1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
skills/editorial-collage/assets/lab-2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
skills/editorial-collage/assets/lab-3.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
skills/editorial-collage/assets/lab-4.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
skills/editorial-collage/assets/lab-5.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
skills/editorial-collage/assets/method-1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
skills/editorial-collage/assets/method-2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
skills/editorial-collage/assets/method-3.png
Normal file
|
After Width: | Height: | Size: 983 KiB |
BIN
skills/editorial-collage/assets/method-4.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
skills/editorial-collage/assets/testimonial.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
skills/editorial-collage/assets/work-1.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
skills/editorial-collage/assets/work-2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
2625
skills/editorial-collage/example.html
Normal file
365
skills/editorial-collage/inputs.example.json
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
{
|
||||
"$schema": "./schema.ts",
|
||||
"_doc": "Worked example — Open Design as the brand. Run `pnpm dlx tsx scripts/compose.ts inputs.example.json out/example.html` to regenerate the canonical example.html from this file. Every field maps to a typed entry in schema.ts.",
|
||||
|
||||
"brand": {
|
||||
"name": "Open Design",
|
||||
"mark": "Ø",
|
||||
"meta": { "title": "Studio Nº 01", "subtitle": "Berlin / Open / Earth" },
|
||||
"filed_under": "Design · Intelligence",
|
||||
"tagline": "Designing intelligence with skills, taste, and your own agent.",
|
||||
"description": "Open Design is the open-source alternative to Claude Design. 12 coding-agent CLIs · 31 composable skills · 72 brand-grade design systems. Local-first, web-deployable, BYOK at every layer.",
|
||||
"locale": "en",
|
||||
"edition": "Vol. 01 / Issue Nº 26",
|
||||
"version": "v0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"primary_url": "https://github.com/nexu-io/open-design",
|
||||
"primary_url_label": "Star · 0K",
|
||||
"download_url": "https://github.com/nexu-io/open-design/releases",
|
||||
"download_url_label": "Download",
|
||||
"contact_email": "https://github.com/nexu-io/open-design/issues",
|
||||
"location": "Berlin / Open / Earth",
|
||||
"coordinates": "52.5200° N · 13.4050° E",
|
||||
"year": "2026",
|
||||
"year_roman": "MMXXVI",
|
||||
"founded": "Est. MMXXVI",
|
||||
"rails": {
|
||||
"right": "Open Design — Vol. 01 · Issue Nº 26 · Apache-2.0",
|
||||
"left": "Skills · Systems · Agents · BYOK · Local-first"
|
||||
},
|
||||
"languages": ["EN", "DE", "中文", "日本語"],
|
||||
"status": "Live · v0.2.0"
|
||||
},
|
||||
|
||||
"nav": [
|
||||
{ "label": "Skills", "href": "https://github.com/nexu-io/open-design/tree/main/skills", "count": "31" },
|
||||
{ "label": "Systems", "href": "https://github.com/nexu-io/open-design/tree/main/design-systems", "count": "72" },
|
||||
{ "label": "Agents", "href": "#agents", "count": "12" },
|
||||
{ "label": "Labs", "href": "#labs", "count": "05" },
|
||||
{ "label": "Contact", "href": "#contact" }
|
||||
],
|
||||
|
||||
"rules": {
|
||||
"about": { "roman": "II.", "meta": ["About / Manifesto", "•", "Open Design / Volume 01"], "pagination": "002 / 008" },
|
||||
"capabilities": { "roman": "III.", "meta": ["Capabilities · Skills · Systems", "•", "4 surfaces / 1 loop"], "pagination": "003 / 008" },
|
||||
"labs": { "roman": "IV.", "meta": ["Labs / Skills Catalog", "•", "05 of 31 ongoing"], "pagination": "004 / 008" },
|
||||
"method": { "roman": "V.", "meta": ["Method / Loop", "•", "04 stages, iterative"], "pagination": "005 / 008" },
|
||||
"work": { "roman": "VI.", "meta": ["Selected Work · 2026 Catalog", "•", "Edited by Open Design"], "pagination": "006 / 008" },
|
||||
"testimonial": { "roman": "VII.", "meta": ["Collaborators / Lineage", "•", "Standing on shoulders"], "pagination": "007 / 008" },
|
||||
"cta": { "roman": "VIII.", "meta": ["Contact / Conversation", "•", "Three commands to ship"], "pagination": "008 / 008" }
|
||||
},
|
||||
|
||||
"hero": {
|
||||
"label": "Open-source design studio",
|
||||
"ix": "· Nº 01",
|
||||
"headline": [
|
||||
{ "text": "Designing " },
|
||||
{ "text": "intelligence", "em": true },
|
||||
{ "text": " with skills, " },
|
||||
{ "text": "taste,", "em": true },
|
||||
{ "text": " and " },
|
||||
{ "text": "code", "em": true },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"lead": "The open-source alternative to Anthropic’s Claude Design. 12 coding agents — Claude, Codex, Cursor, Gemini and friends — drive 31 composable skills and 72 brand-grade design systems. Generate web pages, slide decks, mobile prototypes, images, even short videos — all running on your own laptop.",
|
||||
"primary": { "label": "Star us on GitHub", "href": "https://github.com/nexu-io/open-design" },
|
||||
"secondary": { "label": "Download desktop", "href": "https://github.com/nexu-io/open-design/releases" },
|
||||
"stats": [
|
||||
{ "value": "31", "label": "skills", "sub": "shippable", "variant": "solid" },
|
||||
{ "value": "72", "label": "systems", "sub": "portable", "variant": "dashed" },
|
||||
{ "value": "12", "label": "CLIs", "sub": "BYO agent", "variant": "coral" }
|
||||
],
|
||||
"meta": "↳ pnpm tools-dev · 3 commands to start",
|
||||
"index": [
|
||||
{ "num": "01", "label": "Detect" },
|
||||
{ "num": "02", "label": "Discover", "active": true },
|
||||
{ "num": "03", "label": "Direct" },
|
||||
{ "num": "04", "label": "Deliver" }
|
||||
],
|
||||
"annotations": {
|
||||
"tl": "FIG. 01 / OD-26",
|
||||
"tr": "Plate Nº 08",
|
||||
"bl": "SHA · a1b2c3d",
|
||||
"br": "Composed in <span style='color:var(--coral);'>Open Design</span>"
|
||||
}
|
||||
},
|
||||
|
||||
"about": {
|
||||
"label": "About the studio",
|
||||
"ix": "· Nº 02",
|
||||
"headline": [
|
||||
{ "text": "We treat " },
|
||||
{ "text": "your agent", "em": true },
|
||||
{ "text": " as a creative " },
|
||||
{ "text": "collaborator,", "em": true },
|
||||
{ "text": " not a black box" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"lead": "The strongest coding agents already live on your laptop. We don't ship one — we wire them into a skill-driven design workflow that runs locally with <code class='code-inline'>pnpm tools-dev</code>, deploys the web layer to Vercel, and stays BYOK at every layer.",
|
||||
"cta_label": "Read our approach",
|
||||
"cta_href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon",
|
||||
"footer_text": "Research · Design · Engineering · Repeat",
|
||||
"stamp_top": "Studio practice",
|
||||
"stamp_bottom": "Est. MMXXVI",
|
||||
"side_note": "From model behavior<br/>to visual taste, we<br/>prototype the full<br/>stack of creative<br/>systems.",
|
||||
"caption": {
|
||||
"bold": "Studies in form · perception · machine imagination.",
|
||||
"rest": "(Open Design, MMXXVI)"
|
||||
}
|
||||
},
|
||||
|
||||
"capabilities": {
|
||||
"label": "Capabilities",
|
||||
"ix": "· Nº 03",
|
||||
"headline": [
|
||||
{ "text": "Skills, systems, and surfaces " },
|
||||
{ "text": "for creative", "em": true },
|
||||
{ "text": " intelligence" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"lead": "We blend human taste with whichever agent you already trust to ship interfaces, decks, and editorial pages that feel intentional, expressive, and alive.",
|
||||
"ribbon": "<b>OPEN DESIGN</b> · CAPABILITIES MATRIX · OD/26",
|
||||
"cards": [
|
||||
{
|
||||
"num": "01",
|
||||
"tag": "Skills",
|
||||
"icon_svg": "<circle cx='9' cy='9' r='5'/><path d='M14 14l5 5'/>",
|
||||
"title": "Skills,\nnot plugins",
|
||||
"body": "31 file-based <code class='code-inline sm'>SKILL.md</code> bundles. Drop a folder in, restart the daemon, it appears.",
|
||||
"href": "https://github.com/nexu-io/open-design/tree/main/skills"
|
||||
},
|
||||
{
|
||||
"num": "02",
|
||||
"tag": "Systems",
|
||||
"icon_svg": "<rect x='3.5' y='3.5' width='8' height='8'/><rect x='12.5' y='3.5' width='8' height='8'/><rect x='3.5' y='12.5' width='8' height='8'/><rect x='12.5' y='12.5' width='8' height='8'/>",
|
||||
"title": "Design Systems\nas Markdown",
|
||||
"body": "72 portable <code class='code-inline sm'>DESIGN.md</code> systems — Linear, Vercel, Stripe, Apple, Cursor, Figma…",
|
||||
"href": "https://github.com/nexu-io/open-design/tree/main/design-systems"
|
||||
},
|
||||
{
|
||||
"num": "03",
|
||||
"tag": "Adapters",
|
||||
"icon_svg": "<circle cx='8' cy='12' r='4.5'/><circle cx='16' cy='12' r='4.5'/>",
|
||||
"title": "12 Agent\nAdapters",
|
||||
"body": "Claude · Codex · Gemini · Cursor · Copilot · OpenCode · Devin · Hermes · Pi · Kimi · Kiro · Qwen — auto-detected on $PATH.",
|
||||
"href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon"
|
||||
},
|
||||
{
|
||||
"num": "04",
|
||||
"tag": "BYOK",
|
||||
"icon_svg": "<path d='M5 8h14v8H5z'/><path d='M9 12h6M12 9v6'/>",
|
||||
"title": "BYOK\nat every layer",
|
||||
"body": "OpenAI-compatible proxy. DeepSeek, Groq, OpenRouter, your self-hosted vLLM — paste a baseUrl + key, ship.",
|
||||
"href": "https://github.com/nexu-io/open-design"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"labs": {
|
||||
"label": "Labs",
|
||||
"ix": "· Nº 04",
|
||||
"headline": [
|
||||
{ "text": "A living archive of " },
|
||||
{ "text": "experiments", "em": true },
|
||||
{ "text": " in skills, decks, and machine-made form" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"pills": [
|
||||
{ "label": "All", "count": "31", "active": true },
|
||||
{ "label": "Prototype", "count": "27" },
|
||||
{ "label": "Deck", "count": "04" },
|
||||
{ "label": "Mobile", "count": "03" },
|
||||
{ "label": "Office", "count": "08" }
|
||||
],
|
||||
"meta": {
|
||||
"ring": "05",
|
||||
"bold": "Ongoing experiments",
|
||||
"sub": "documenting ideas in flux<br/>building intelligence<br/>through making"
|
||||
},
|
||||
"cards": [
|
||||
{ "badge": "Deck", "num": "Nº 01", "year": "2026", "title": "Magazine Decks", "body": "Editorial-grade slide decks with <code class='code-inline sm'>guizang-ppt</code>. Magazine layout, WebGL hero.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/guizang-ppt" },
|
||||
{ "badge": "Media", "num": "Nº 02", "year": "2026", "title": "Synthetic Matter", "body": "Gpt-image-2 + Seedance + HyperFrames. Image, video, audio — same chat surface as code.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/hyperframes" },
|
||||
{ "badge": "Loop", "num": "Nº 03", "year": "2026", "title": "Prompt Choreography","body": "The interactive question form pops before a single pixel is improvised. 30s of radios beats 30min of redirects.","href": "https://github.com/nexu-io/open-design/tree/main/skills/design-brief" },
|
||||
{ "badge": "Critique", "num": "Nº 04", "year": "2026", "title": "Visual Reasoning", "body": "5-dim self-critique gates every artifact: philosophy · hierarchy · execution · specificity · restraint.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/critique" },
|
||||
{ "badge": "Runtime", "num": "Nº 05", "year": "2026", "title": "Soft Systems", "body": "Sandboxed iframe preview. Streaming todos. Real-cwd filesystem. Adaptive loops between human and machine.", "href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon" }
|
||||
],
|
||||
"progress": { "total": 8, "filled": 5 },
|
||||
"foot": "05 / 31 SKILLS · <a class='library-link' href='https://github.com/nexu-io/open-design/tree/main/skills' target='_blank' rel='noreferrer noopener' style='color:var(--coral);'>VIEW FULL LIBRARY →</a>"
|
||||
},
|
||||
|
||||
"method": {
|
||||
"label": "Method",
|
||||
"ix": "· Nº 05",
|
||||
"headline": [
|
||||
{ "text": "From " },
|
||||
{ "text": "signals", "em": true },
|
||||
{ "text": " to systems" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"right": "Every stage is iterative, visual, and research-driven — composable files, not opaque prompts.",
|
||||
"steps": [
|
||||
{ "num": "01", "title": "Detect", "body": "The daemon scans your $PATH for 12 coding agents and auto-loads 31 skills + 72 systems on boot." },
|
||||
{ "num": "02", "title": "Discover", "body": "Turn 1 is a question form — surface, audience, tone, scale, brand context. Locked in 30 seconds." },
|
||||
{ "num": "03", "title": "Direct", "body": "Pick one of 5 deterministic visual directions. Palette in OKLch, font stack, layout posture cues." },
|
||||
{ "num": "04", "title": "Deliver", "body": "The agent writes to disk, you preview in a sandboxed iframe, export HTML / PDF / PPTX / ZIP / Markdown." }
|
||||
],
|
||||
"foot_left": "Skills inform everything. Files make it real.",
|
||||
"foot_right_bold": "github.com/nexu-io/open-design",
|
||||
"foot_right_rest": "Apache-2.0"
|
||||
},
|
||||
|
||||
"work": {
|
||||
"label": "Selected work",
|
||||
"headline": [
|
||||
{ "text": "Skills that turn briefs into " },
|
||||
{ "text": "memorable", "em": true },
|
||||
{ "text": " shippable " },
|
||||
{ "text": "artifacts", "em": true },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"link_label": "View all 31 skills",
|
||||
"link_href": "https://github.com/nexu-io/open-design/tree/main/skills",
|
||||
"cards": [
|
||||
{
|
||||
"small_label": "Featured skill",
|
||||
"index": "01 / 31",
|
||||
"title": "guizang-ppt",
|
||||
"body": "Magazine-style web PPT for product launches and pitch decks. Bundled verbatim, original LICENSE preserved.",
|
||||
"year": "2026 · DECK",
|
||||
"tag": "DEFAULT"
|
||||
},
|
||||
{
|
||||
"small_label": "Companion system",
|
||||
"index": "04 / 72",
|
||||
"title": "kami",
|
||||
"body": "An editorial paper system. Warm parchment canvas, ink-blue accent, serif-led hierarchy — multilingual by design (EN · zh-CN · ja).",
|
||||
"year": "2026 · PAPER",
|
||||
"tag": "SYSTEM"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"testimonial": {
|
||||
"label": "Collaborators",
|
||||
"ix": "· Nº 06",
|
||||
"quote": [
|
||||
{ "text": "Open Design helped us turn vague " },
|
||||
{ "text": "AI ideas", "em": true },
|
||||
{ "text": " into a visual system that felt " },
|
||||
{ "text": "sharp, believable,", "em": true },
|
||||
{ "text": " and genuinely new." }
|
||||
],
|
||||
"author": { "initial": "m", "name": "Mina Kovac", "title": "Creative Director · North Form" },
|
||||
"partners_text": "Standing on the shoulders of teams shipping open-source design culture.",
|
||||
"partners": [
|
||||
{ "glyph_svg": "<path d='M5 24L20 6L35 24M12 18h16'/>", "name": "huashu-design", "role": "Philosophy", "href": "https://github.com/alchaincyf/huashu-design" },
|
||||
{ "glyph_svg": "<path d='M8 24L20 6L24 22L36 4'/>", "name": "guizang-ppt", "role": "Decks", "href": "https://github.com/op7418/guizang-ppt-skill" },
|
||||
{ "glyph_svg": "<rect x='6' y='6' width='4' height='18'/><rect x='14' y='6' width='4' height='18'/><rect x='22' y='6' width='4' height='18'/><rect x='30' y='6' width='4' height='18'/>", "name": "multica-ai", "role": "Daemon", "href": "https://github.com/multica-ai/multica" },
|
||||
{ "glyph_svg": "<circle cx='15' cy='15' r='9'/><path d='M15 6v18M6 15h18'/>", "name": "open-codesign", "role": "UX", "href": "https://github.com/OpenCoworkAI/open-codesign" },
|
||||
{ "glyph_svg": "<path d='M5 8l9 7-9 7M20 24h18'/>", "name": "Devin CLI", "role": "Terminal", "href": "https://devin.ai/terminal" },
|
||||
{ "glyph_svg": "<rect x='4' y='5' width='22' height='18'/><rect x='14' y='9' width='22' height='18'/>", "name": "hyperframes", "role": "Frames", "href": "https://github.com/heygen-com/hyperframes" }
|
||||
],
|
||||
"read_more_label": "Read more stories",
|
||||
"read_more_href": "https://github.com/nexu-io/open-design"
|
||||
},
|
||||
|
||||
"cta": {
|
||||
"label": "Start a conversation",
|
||||
"ix": "· Nº 07",
|
||||
"headline": [
|
||||
{ "text": "Let's build something " },
|
||||
{ "text": "open", "em": true },
|
||||
{ "text": " and " },
|
||||
{ "text": "visually", "em": true },
|
||||
{ "text": " unforgettable" },
|
||||
{ "text": ".", "dot": true }
|
||||
],
|
||||
"lead": "Star us on GitHub, drop into the issues, or run <code class='code-inline'>pnpm tools-dev</code> tonight. Three commands and the loop is yours.",
|
||||
"primary": { "label": "Star on GitHub", "href": "https://github.com/nexu-io/open-design" },
|
||||
"ribbon": "OPEN DESIGN · FIN."
|
||||
},
|
||||
|
||||
"wire": {
|
||||
"title": "From the field",
|
||||
"cities": [
|
||||
{ "name": "Berlin", "coord": "52.52°N" },
|
||||
{ "name": "Tokyo", "coord": "35.68°N" },
|
||||
{ "name": "Shanghai", "coord": "31.23°N" },
|
||||
{ "name": "Beijing", "coord": "39.90°N" },
|
||||
{ "name": "Taipei", "coord": "25.03°N" },
|
||||
{ "name": "Singapore", "coord": "1.35°N" },
|
||||
{ "name": "Bangalore", "coord": "12.97°N" },
|
||||
{ "name": "Dubai", "coord": "25.20°N" },
|
||||
{ "name": "Lagos", "coord": "6.52°N" },
|
||||
{ "name": "Nairobi", "coord": "1.29°S" },
|
||||
{ "name": "Cape Town", "coord": "33.92°S" },
|
||||
{ "name": "Lisbon", "coord": "38.72°N" },
|
||||
{ "name": "Madrid", "coord": "40.42°N" },
|
||||
{ "name": "Paris", "coord": "48.86°N" },
|
||||
{ "name": "London", "coord": "51.51°N" },
|
||||
{ "name": "Amsterdam", "coord": "52.37°N" },
|
||||
{ "name": "Stockholm", "coord": "59.33°N" },
|
||||
{ "name": "Toronto", "coord": "43.65°N" },
|
||||
{ "name": "New York", "coord": "40.71°N" },
|
||||
{ "name": "San Francisco", "coord": "37.77°N" },
|
||||
{ "name": "Mexico City", "coord": "19.43°N" },
|
||||
{ "name": "São Paulo", "coord": "23.55°S" },
|
||||
{ "name": "Sydney", "coord": "33.87°S" }
|
||||
],
|
||||
"contributors": [
|
||||
{ "handle": "tw93", "role": "kami", "href": "https://github.com/tw93" },
|
||||
{ "handle": "op7418", "role": "guizang", "href": "https://github.com/op7418" },
|
||||
{ "handle": "alchaincyf", "role": "huashu", "href": "https://github.com/alchaincyf" },
|
||||
{ "handle": "multica-ai", "role": "daemon", "href": "https://github.com/multica-ai" },
|
||||
{ "handle": "OpenCoworkAI", "role": "codesign", "href": "https://github.com/OpenCoworkAI" },
|
||||
{ "handle": "nexu-io", "role": "studio", "href": "https://github.com/nexu-io" },
|
||||
{ "handle": "you", "role": "be next", "href": "https://github.com/nexu-io/open-design/graphs/contributors" }
|
||||
]
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"brand_description": "The open-source alternative to Claude Design. Built on the shoulders of <a class='inline-link' href='https://github.com/alchaincyf/huashu-design' target='_blank' rel='noreferrer noopener'>huashu-design</a>, <a class='inline-link' href='https://github.com/op7418/guizang-ppt-skill' target='_blank' rel='noreferrer noopener'>guizang-ppt</a>, <a class='inline-link' href='https://github.com/multica-ai/multica' target='_blank' rel='noreferrer noopener'>multica-ai</a>, and <a class='inline-link' href='https://github.com/OpenCoworkAI/open-codesign' target='_blank' rel='noreferrer noopener'>open-codesign</a>.",
|
||||
"brand_cta": {
|
||||
"label": "Download desktop",
|
||||
"href": "https://github.com/nexu-io/open-design/releases",
|
||||
"meta": "macOS · v0.2.0"
|
||||
},
|
||||
"columns": [
|
||||
{ "title": "Studio", "links": [
|
||||
{ "label": "Capabilities", "href": "#agents" },
|
||||
{ "label": "Labs", "href": "#labs" },
|
||||
{ "label": "Method", "href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon" },
|
||||
{ "label": "Manifesto", "href": "https://github.com/nexu-io/open-design" }
|
||||
]},
|
||||
{ "title": "Library", "links": [
|
||||
{ "label": "31 Skills", "href": "https://github.com/nexu-io/open-design/tree/main/skills" },
|
||||
{ "label": "72 Systems", "href": "https://github.com/nexu-io/open-design/tree/main/design-systems" },
|
||||
{ "label": "5 Directions", "href": "https://github.com/nexu-io/open-design/tree/main/design-systems" },
|
||||
{ "label": "5 Frames", "href": "https://github.com/nexu-io/open-design/tree/main/skills/hyperframes" }
|
||||
]},
|
||||
{ "title": "Connect", "links": [
|
||||
{ "label": "GitHub", "href": "https://github.com/nexu-io/open-design" },
|
||||
{ "label": "Issues", "href": "https://github.com/nexu-io/open-design/issues" },
|
||||
{ "label": "Contributors", "href": "https://github.com/nexu-io/open-design/graphs/contributors" },
|
||||
{ "label": "Releases", "href": "https://github.com/nexu-io/open-design/releases" }
|
||||
]},
|
||||
{ "title": "Docs", "links": [
|
||||
{ "label": "Quickstart", "href": "https://github.com/nexu-io/open-design/blob/main/QUICKSTART.md" },
|
||||
{ "label": "Architecture", "href": "https://github.com/nexu-io/open-design/blob/main/docs/architecture.md" },
|
||||
{ "label": "Skill Protocol", "href": "https://github.com/nexu-io/open-design/blob/main/docs/skills-protocol.md" },
|
||||
{ "label": "Roadmap", "href": "https://github.com/nexu-io/open-design/blob/main/docs/roadmap.md" }
|
||||
]}
|
||||
],
|
||||
"mega": [
|
||||
{ "text": "Open " },
|
||||
{ "text": "Design", "em": true },
|
||||
{ "text": "." }
|
||||
]
|
||||
},
|
||||
|
||||
"imagery": {
|
||||
"strategy": "bring-your-own",
|
||||
"assets_path": "./assets/",
|
||||
"provider": "fal"
|
||||
}
|
||||
}
|
||||
447
skills/editorial-collage/schema.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
/**
|
||||
* editorial-collage — input schema.
|
||||
*
|
||||
* This is the contract between users and `scripts/compose.ts`. A valid
|
||||
* `inputs.json` matching `EditorialCollageInputs` is enough to produce
|
||||
* a complete Atelier Zero landing page, end-to-end, with no further
|
||||
* code changes needed.
|
||||
*
|
||||
* Convention: every field that drives visible copy lives here. The
|
||||
* structural CSS, layout grid, motion, and 16 image slots are fixed by
|
||||
* the design system (`design-systems/atelier-zero/DESIGN.md`); only
|
||||
* brand identity and content text are user-controlled.
|
||||
*/
|
||||
|
||||
/* ---------- text helpers ---------- */
|
||||
|
||||
/**
|
||||
* A `MixedText` is a sentence whose visual rhythm comes from alternating
|
||||
* sans-serif and italic-serif spans. Encode it as an array of segments;
|
||||
* the composer concatenates them into HTML, wrapping `em: true` segments
|
||||
* in `<em>` tags. The trailing `dot: true` segment renders the coral
|
||||
* full-stop accent.
|
||||
*
|
||||
* Example:
|
||||
* [
|
||||
* { text: 'We treat ' },
|
||||
* { text: 'your agent', em: true },
|
||||
* { text: ' as a creative ' },
|
||||
* { text: 'collaborator,', em: true },
|
||||
* { text: ' not a black box' },
|
||||
* { text: '.', dot: true },
|
||||
* ]
|
||||
*/
|
||||
export interface TextSegment {
|
||||
text: string;
|
||||
/** Wrap in <em> for italic-serif emphasis. */
|
||||
em?: boolean;
|
||||
/** Render as the coral terminating dot accent (use as the final segment). */
|
||||
dot?: boolean;
|
||||
}
|
||||
export type MixedText = TextSegment[];
|
||||
|
||||
/* ---------- brand block ---------- */
|
||||
|
||||
export interface BrandBlock {
|
||||
/** Display name (appears in nav, footer, og:title, browser tab). */
|
||||
name: string;
|
||||
/** Single glyph for the circled brand mark — `Ø`, `▲`, `★`, etc. */
|
||||
mark: string;
|
||||
/**
|
||||
* Two-line meta block in the nav: `<b>{title}</b>{subtitle}` with a
|
||||
* dividing rule. e.g. `{ title: 'Studio Nº 01', subtitle: 'Berlin / Open / Earth' }`.
|
||||
*/
|
||||
meta: { title: string; subtitle: string };
|
||||
/** Filed-under tagline shown in the topbar. */
|
||||
filed_under: string;
|
||||
/** Tagline shown in the page <title> alongside the brand. */
|
||||
tagline: string;
|
||||
/** SEO description; appears in `<meta name='description'>`. */
|
||||
description: string;
|
||||
/** ISO 639-1 language code; defaults to `en`. */
|
||||
locale?: string;
|
||||
/** Edition badge — `'Vol. 01 / Issue Nº 26'`. */
|
||||
edition: string;
|
||||
/** Visible build version — `'v0.4.6'`. */
|
||||
version: string;
|
||||
/** SPDX license identifier or short label — `'Apache-2.0'`. */
|
||||
license: string;
|
||||
/** Primary CTA URL (Star on GitHub, etc.). */
|
||||
primary_url: string;
|
||||
/** Star-button label in the nav. */
|
||||
primary_url_label: string;
|
||||
/**
|
||||
* Optional secondary CTA URL surfaced as a ghost pill in the nav and as
|
||||
* a button in the footer brand column. When set, the marketing surface
|
||||
* advertises a "Download" entry so users know they can install directly.
|
||||
*/
|
||||
download_url?: string;
|
||||
/** Label for the download CTA — defaults to `'Download'` when omitted. */
|
||||
download_url_label?: string;
|
||||
/** Email address shown in the CTA section. */
|
||||
contact_email: string;
|
||||
/** Pretty location line — `'Berlin / Open / Earth'`. */
|
||||
location: string;
|
||||
/** Coordinates string — `'52.5200° N · 13.4050° E'`. */
|
||||
coordinates: string;
|
||||
/** Year of publication — `'2026'`. */
|
||||
year: string;
|
||||
/** Roman numeral year for the footer kicker — `'MMXXVI'`. */
|
||||
year_roman: string;
|
||||
/** Founding tagline — `'Est. MMXXVI'`. */
|
||||
founded: string;
|
||||
/** Side rails (the rotated text fixed to viewport edges). */
|
||||
rails: { right: string; left: string };
|
||||
/** Topbar live channel languages — `['EN', 'DE', '中文', '日本語']`. First entry is bolded. */
|
||||
languages: string[];
|
||||
/** Topbar pulse text — `'Live · v0.4.6'`. */
|
||||
status: string;
|
||||
}
|
||||
|
||||
/* ---------- nav ---------- */
|
||||
|
||||
export interface NavLink {
|
||||
label: string;
|
||||
href: string;
|
||||
/** Optional superscript count badge — `'31'`, `'72'`, etc. */
|
||||
count?: string;
|
||||
}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
|
||||
export interface HeroStat {
|
||||
/** Number or short string inside the ring — `'31'`. */
|
||||
value: string;
|
||||
/** Bold label below the ring — `'skills'`. */
|
||||
label: string;
|
||||
/** Sub-label — `'shippable'`. */
|
||||
sub: string;
|
||||
/** Visual treatment: dashed border (default), solid border, or coral accent. */
|
||||
variant?: 'dashed' | 'solid' | 'coral';
|
||||
}
|
||||
|
||||
export interface HeroIndexItem {
|
||||
/** Two-digit number — `'01'`. */
|
||||
num: string;
|
||||
/** Step name — `'Detect'`. */
|
||||
label: string;
|
||||
/** Mark this item as the active one (rendered in solid ink). */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroBlock {
|
||||
/** Eyebrow label (left) — `'Open-source design studio'`. */
|
||||
label: string;
|
||||
/** Eyebrow index (right of label) — `'· Nº 01'`. */
|
||||
ix: string;
|
||||
/** The H1 — encoded as MixedText. */
|
||||
headline: MixedText;
|
||||
/** Lead paragraph; can include `<code>` via raw HTML — keep ASCII-quotes safe. */
|
||||
lead: string;
|
||||
/** Primary CTA. */
|
||||
primary: { label: string; href: string };
|
||||
/** Secondary CTA. */
|
||||
secondary: { label: string; href: string };
|
||||
/** Three stat rings displayed below the CTAs. */
|
||||
stats: [HeroStat, HeroStat, HeroStat];
|
||||
/** Bottom-left meta line in the hero foot. */
|
||||
meta: string;
|
||||
/** Four index items rendered over the hero collage. */
|
||||
index: [HeroIndexItem, HeroIndexItem, HeroIndexItem, HeroIndexItem];
|
||||
/** Image annotations (corner labels). */
|
||||
annotations: {
|
||||
tl: string;
|
||||
tr: string;
|
||||
bl: string;
|
||||
br: string;
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- about ---------- */
|
||||
|
||||
export interface AboutBlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
headline: MixedText;
|
||||
lead: string;
|
||||
cta_label: string;
|
||||
cta_href: string;
|
||||
/** Footer row text — `'Research · Design · Engineering · Repeat'`. */
|
||||
footer_text: string;
|
||||
/** Stamp top line (coral) — `'Studio practice'`. */
|
||||
stamp_top: string;
|
||||
/** Stamp bottom line (ink) — `'Est. MMXXVI'`. */
|
||||
stamp_bottom: string;
|
||||
/** Side note (right of the about image). */
|
||||
side_note: string;
|
||||
/** Caption below the about image. */
|
||||
caption: { bold: string; rest: string };
|
||||
}
|
||||
|
||||
/* ---------- capabilities ---------- */
|
||||
|
||||
export interface CapabilityCard {
|
||||
/** Two-digit accent — `'01'`. */
|
||||
num: string;
|
||||
/** Tag — `'Skills'`. */
|
||||
tag: string;
|
||||
/** SVG inner contents (paths/circles/rects only — no <svg> wrapper). */
|
||||
icon_svg: string;
|
||||
/** Title; use \n for line breaks. */
|
||||
title: string;
|
||||
/** Body; can include `<code>` raw HTML. */
|
||||
body: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface CapabilitiesBlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
headline: MixedText;
|
||||
lead: string;
|
||||
ribbon: string;
|
||||
/** Exactly four cards. */
|
||||
cards: [CapabilityCard, CapabilityCard, CapabilityCard, CapabilityCard];
|
||||
}
|
||||
|
||||
/* ---------- labs ---------- */
|
||||
|
||||
export interface LabPill {
|
||||
label: string;
|
||||
count: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface LabCard {
|
||||
badge: string;
|
||||
num: string;
|
||||
year: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface LabsBlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
headline: MixedText;
|
||||
pills: LabPill[];
|
||||
meta: { ring: string; bold: string; sub: string };
|
||||
/** Exactly five lab cards. */
|
||||
cards: [LabCard, LabCard, LabCard, LabCard, LabCard];
|
||||
/** Progress bar — total segments and how many are filled. */
|
||||
progress: { total: number; filled: number };
|
||||
foot: string;
|
||||
}
|
||||
|
||||
/* ---------- method ---------- */
|
||||
|
||||
export interface MethodStep {
|
||||
num: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface MethodBlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
headline: MixedText;
|
||||
right: string;
|
||||
/** Exactly four steps. */
|
||||
steps: [MethodStep, MethodStep, MethodStep, MethodStep];
|
||||
foot_left: string;
|
||||
foot_right_bold: string;
|
||||
foot_right_rest: string;
|
||||
}
|
||||
|
||||
/* ---------- work ---------- */
|
||||
|
||||
export interface WorkCard {
|
||||
small_label: string;
|
||||
index: string;
|
||||
title: string;
|
||||
body: string;
|
||||
year: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export interface WorkBlock {
|
||||
label: string;
|
||||
headline: MixedText;
|
||||
link_label: string;
|
||||
link_href: string;
|
||||
/** Two cards — first regular, second has the .alt tilt. */
|
||||
cards: [WorkCard, WorkCard];
|
||||
}
|
||||
|
||||
/* ---------- testimonial / partners ---------- */
|
||||
|
||||
export interface Partner {
|
||||
/** SVG inner contents (paths/circles/rects only — no <svg> wrapper). */
|
||||
glyph_svg: string;
|
||||
name: string;
|
||||
role: string;
|
||||
/** Click target for the partner card. When omitted, falls back to `'#'`. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface TestimonialBlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
/** Quote with em emphasis; the leading `"` and trailing `"` are added by the composer. */
|
||||
quote: MixedText;
|
||||
author: { initial: string; name: string; title: string };
|
||||
partners_text: string;
|
||||
/** Up to five partners; the design fits five comfortably. */
|
||||
partners: Partner[];
|
||||
read_more_label: string;
|
||||
read_more_href: string;
|
||||
}
|
||||
|
||||
/* ---------- cta ---------- */
|
||||
|
||||
export interface CTABlock {
|
||||
label: string;
|
||||
ix: string;
|
||||
headline: MixedText;
|
||||
lead: string;
|
||||
primary: { label: string; href: string };
|
||||
ribbon: string;
|
||||
}
|
||||
|
||||
/* ---------- wire / global ticker ---------- */
|
||||
|
||||
/**
|
||||
* A single city pinned to the studio's "from the field" ticker. The
|
||||
* marquee renders `{coord} {name}`, so keep `coord` short — `52.52°N`,
|
||||
* `1.29°S`, etc.
|
||||
*/
|
||||
export interface WireCity {
|
||||
/** Display name — `'Berlin'`, `'São Paulo'`. Title-case is fine; the
|
||||
* stylesheet uppercases it visually. */
|
||||
name: string;
|
||||
/** Latitude only, prettified — `'52.52°N'`. */
|
||||
coord: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named contributor / lineage handle in the ticker's bottom row. The
|
||||
* marquee renders `@{handle} {role}` and the whole pill becomes a link
|
||||
* to `href` (typically a GitHub profile or org page).
|
||||
*/
|
||||
export interface WireContributor {
|
||||
/** GitHub-style handle without the leading `@` — `'tw93'`, `'OpenCoworkAI'`. */
|
||||
handle: string;
|
||||
/** Short role tag — `'kami'`, `'core'`, `'be next'`. Rendered in coral. */
|
||||
role: string;
|
||||
/** Click target for the handle pill. */
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional editorial ticker rendered between the hero and the about
|
||||
* section. Two counter-scrolling marquees: cities (left → right) and
|
||||
* contributors (right → left). Designed to signal that the project is
|
||||
* global and community-driven without disrupting the roman-numeral
|
||||
* section count.
|
||||
*/
|
||||
export interface WireBlock {
|
||||
/** Bold uppercase headline on the left rail — `'From the field'`. */
|
||||
title: string;
|
||||
/** Sub-label — `'Open · 23 cities · 6 contributors'`. Optional; computed
|
||||
* from the lists when omitted. */
|
||||
subtitle?: string;
|
||||
cities: WireCity[];
|
||||
contributors: WireContributor[];
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
export interface FooterColumn {
|
||||
title: string;
|
||||
links: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
export interface FooterBlock {
|
||||
brand_description: string;
|
||||
/**
|
||||
* Optional CTA rendered under the brand description in the footer
|
||||
* (e.g. `{ label: 'Download desktop', href: 'https://.../releases',
|
||||
* meta: 'macOS · v0.2.0' }`). When `brand.download_url` is set this is
|
||||
* filled in automatically; explicit values take precedence.
|
||||
*/
|
||||
brand_cta?: { label: string; href: string; meta?: string };
|
||||
/** Up to five columns; the design fits five at the widest breakpoint. */
|
||||
columns: FooterColumn[];
|
||||
/** Footer mega kicker — encoded as MixedText so the brand can italicize part of it. */
|
||||
mega: MixedText;
|
||||
}
|
||||
|
||||
/* ---------- section rules (the I., II., III. dividers) ---------- */
|
||||
|
||||
export interface SectionRule {
|
||||
/** Roman numeral string — `'I.'`, `'II.'`, etc. */
|
||||
roman: string;
|
||||
/** Three middle text spans separated by a coral dot. */
|
||||
meta: [string, string, string];
|
||||
/** Pagination — `'002 / 008'`. */
|
||||
pagination: string;
|
||||
}
|
||||
|
||||
export interface SectionRules {
|
||||
about: SectionRule;
|
||||
capabilities: SectionRule;
|
||||
labs: SectionRule;
|
||||
method: SectionRule;
|
||||
work: SectionRule;
|
||||
testimonial: SectionRule;
|
||||
cta: SectionRule;
|
||||
}
|
||||
|
||||
/* ---------- image strategy ---------- */
|
||||
|
||||
/**
|
||||
* `'generate'` — call gpt-image-2 (via fal.ai or Azure) for every slot
|
||||
* using `assets/imagegen-prompts.md` as the prompt source, brand-keyed
|
||||
* via the `imagery_prompts` field on the inputs.
|
||||
* `'placeholder'` — emit SVG paper-textured frames into `out/assets/`
|
||||
* so the layout is fully rendered even with no AI image budget.
|
||||
* Users can swap real PNGs in later without touching markup.
|
||||
* `'bring-your-own'` — assume the 16 PNGs are already at the configured
|
||||
* `assets_path`; do nothing.
|
||||
*/
|
||||
export type ImageStrategy = 'generate' | 'placeholder' | 'bring-your-own';
|
||||
|
||||
export interface ImageryConfig {
|
||||
strategy: ImageStrategy;
|
||||
/** Relative path (from the output) to the asset folder. Default: `./assets/`. */
|
||||
assets_path: string;
|
||||
/** Per-slot prompt overrides for `'generate'` strategy. */
|
||||
prompts?: Record<string, string>;
|
||||
/** When `strategy: 'generate'`, which provider to call. */
|
||||
provider?: 'fal' | 'azure';
|
||||
}
|
||||
|
||||
/* ---------- top-level ---------- */
|
||||
|
||||
export interface EditorialCollageInputs {
|
||||
$schema?: string;
|
||||
brand: BrandBlock;
|
||||
nav: NavLink[];
|
||||
rules: SectionRules;
|
||||
hero: HeroBlock;
|
||||
about: AboutBlock;
|
||||
capabilities: CapabilitiesBlock;
|
||||
labs: LabsBlock;
|
||||
method: MethodBlock;
|
||||
work: WorkBlock;
|
||||
testimonial: TestimonialBlock;
|
||||
cta: CTABlock;
|
||||
footer: FooterBlock;
|
||||
/**
|
||||
* Optional editorial wire/ticker between hero and about. Omit to hide
|
||||
* the strip entirely.
|
||||
*/
|
||||
wire?: WireBlock;
|
||||
imagery: ImageryConfig;
|
||||
}
|
||||
821
skills/editorial-collage/scripts/compose.ts
Normal file
|
|
@ -0,0 +1,821 @@
|
|||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* editorial-collage — HTML composer.
|
||||
*
|
||||
* Reads `inputs.json` (matching `../schema.ts`) and writes a single
|
||||
* self-contained HTML file with the Atelier Zero stylesheet inlined,
|
||||
* the 16 collage images referenced by relative URL, and the
|
||||
* scroll-reveal + headroom-nav scripts embedded.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/compose.ts <inputs.json> <output.html>
|
||||
*
|
||||
* Re-generate the canonical example:
|
||||
* npx tsx scripts/compose.ts inputs.example.json example.html
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type {
|
||||
EditorialCollageInputs,
|
||||
MixedText,
|
||||
HeroIndexItem,
|
||||
HeroStat,
|
||||
CapabilityCard,
|
||||
LabPill,
|
||||
LabCard,
|
||||
MethodStep,
|
||||
WorkCard,
|
||||
Partner,
|
||||
FooterColumn,
|
||||
SectionRule,
|
||||
} from '../schema';
|
||||
|
||||
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* helpers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Render a `MixedText` into HTML (sans/em/dot segments). */
|
||||
function mixed(text: MixedText): string {
|
||||
return text
|
||||
.map((seg) => {
|
||||
if (seg.dot) return `<span class='dot'>${seg.text}</span>`;
|
||||
if (seg.em) return `<em>${seg.text}</em>`;
|
||||
return seg.text;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Newline → `<br/>` for multi-line headings/labels. */
|
||||
function br(s: string): string {
|
||||
return s.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
/** External-link attribute pair. */
|
||||
function ext(href: string): string {
|
||||
if (/^(https?:|mailto:|\/\/)/i.test(href)) {
|
||||
return ` target='_blank' rel='noreferrer noopener'`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const ARROW_OUT = `<svg viewBox='0 0 24 24'><path d='M5 19L19 5M19 5H8M19 5v11'/></svg>`;
|
||||
const ARROW_PLUS = `<svg viewBox='0 0 24 24'><circle cx='12' cy='12' r='9'/><path d='M9 12h6M12 9v6'/></svg>`;
|
||||
|
||||
/** A small CSS class we reference from inputs as `code-inline` / `code-inline sm`. */
|
||||
const CODE_INLINE_CSS = `
|
||||
.code-inline {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
background: var(--bone);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.code-inline.sm { font-size: 12px; padding: 0 4px; }
|
||||
`;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* section renderers
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function renderHead(i: EditorialCollageInputs, css: string): string {
|
||||
return `<head>
|
||||
<meta charset='utf-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<title>${i.brand.name} — ${i.brand.tagline}</title>
|
||||
<meta name='description' content='${i.brand.description}' />
|
||||
<link rel='preconnect' href='https://fonts.googleapis.com' />
|
||||
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
|
||||
<link href='https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap' rel='stylesheet' />
|
||||
<style>${css}${CODE_INLINE_CSS}</style>
|
||||
</head>`;
|
||||
}
|
||||
|
||||
function renderRails(i: EditorialCollageInputs): string {
|
||||
return `
|
||||
<div class='side-rail right' data-od-id='rail-right'>
|
||||
<span class='rail-text'>${i.brand.rails.right}</span>
|
||||
</div>
|
||||
<div class='side-rail left' data-od-id='rail-left'>
|
||||
<span class='rail-text'>${i.brand.rails.left}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderTopbar(i: EditorialCollageInputs): string {
|
||||
const langs = i.brand.languages
|
||||
.map((l, idx) => (idx === 0 ? `<b>${l}</b>` : l))
|
||||
.join(' · ');
|
||||
return `
|
||||
<div class='topbar' data-od-id='topbar'>
|
||||
<div class='container topbar-inner'>
|
||||
<span><b>OD / ${i.brand.year}</b> · ${i.brand.edition}</span>
|
||||
<span class='mid'>
|
||||
<span>Filed under <b class='coral'>${i.brand.filed_under}</b></span>
|
||||
<span>${i.brand.license} · Made on Earth</span>
|
||||
</span>
|
||||
<span class='right'>
|
||||
<a class='topbar-link' href='${i.brand.primary_url}/releases'${ext(i.brand.primary_url)}><span class='pulse'></span>${i.brand.status}</a>
|
||||
<span>${langs}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNav(i: EditorialCollageInputs): string {
|
||||
const links = i.nav
|
||||
.map(
|
||||
(link) =>
|
||||
`<li><a href='${link.href}'${ext(link.href)}>${link.label}${
|
||||
link.count ? `<span class='num'>${link.count}</span>` : ''
|
||||
}</a></li>`,
|
||||
)
|
||||
.join('\n ');
|
||||
return `
|
||||
<header class='nav' data-od-id='nav'>
|
||||
<div class='container nav-inner'>
|
||||
<a href='#top' class='brand'>
|
||||
<span class='brand-mark'>${i.brand.mark}</span>
|
||||
<span>${i.brand.name}</span>
|
||||
<span class='brand-meta'><b>${i.brand.meta.title}</b>${i.brand.meta.subtitle}</span>
|
||||
</a>
|
||||
<nav>
|
||||
<ul class='nav-links'>
|
||||
${links}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class='nav-side'>
|
||||
${
|
||||
i.brand.download_url
|
||||
? `<a class='nav-cta ghost' href='${i.brand.download_url}'${ext(i.brand.download_url)}>${i.brand.download_url_label ?? 'Download'}</a>
|
||||
`
|
||||
: ''
|
||||
}<a class='nav-cta' href='${i.brand.primary_url}'${ext(i.brand.primary_url)}>${i.brand.primary_url_label}</a>
|
||||
<span class='status-dot' aria-hidden='true'></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
|
||||
function renderSecRule(r: SectionRule): string {
|
||||
return `
|
||||
<div class='sec-rule'>
|
||||
<span class='roman'>${r.roman}</span>
|
||||
<span class='meta-grp'>
|
||||
<span>${r.meta[0]}</span>
|
||||
<span class='dot-mark'>${r.meta[1]}</span>
|
||||
<span>${r.meta[2]}</span>
|
||||
</span>
|
||||
<span>${r.pagination}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHeroStat(s: HeroStat): string {
|
||||
const variant = s.variant ?? 'dashed';
|
||||
const ringClass = variant === 'solid' ? 'ring solid' : variant === 'coral' ? 'ring coral' : 'ring';
|
||||
return `<div class='stat'>
|
||||
<span class='${ringClass}'>${s.value}</span>
|
||||
<span class='stat-label'><b>${s.label}</b>${s.sub}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHeroIndex(item: HeroIndexItem): string {
|
||||
return `<span${item.active ? ` class='on'` : ''}><span class='n'>${item.num}</span>${item.label}</span>`;
|
||||
}
|
||||
|
||||
function renderHero(i: EditorialCollageInputs): string {
|
||||
const stats = i.hero.stats.map(renderHeroStat).join('\n ');
|
||||
const index = i.hero.index.map(renderHeroIndex).join('\n ');
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='hero' id='top' data-od-id='hero'>
|
||||
<div class='container'>
|
||||
<div class='sec-rule'>
|
||||
<span class='roman'>I.</span>
|
||||
<span class='meta-grp'>
|
||||
<span>Hero / Cover Plate</span>
|
||||
<span class='dot-mark'>•</span>
|
||||
<span>${i.brand.name} / Volume 01</span>
|
||||
</span>
|
||||
<span>001 / 008</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='container hero-grid'>
|
||||
<div class='hero-copy'>
|
||||
<span class='label' data-reveal>${i.hero.label} <span class='ix'>${i.hero.ix}</span></span>
|
||||
<h1 class='display' data-reveal>${mixed(i.hero.headline)}</h1>
|
||||
<p class='lead' data-reveal>${i.hero.lead}</p>
|
||||
<div class='hero-actions' data-reveal>
|
||||
<a class='btn btn-primary' href='${i.hero.primary.href}'${ext(i.hero.primary.href)}>
|
||||
${i.hero.primary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<a class='btn btn-ghost' href='${i.hero.secondary.href}'${ext(i.hero.secondary.href)}>
|
||||
${i.hero.secondary.label}
|
||||
<span class='arrow'>${ARROW_PLUS}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class='hero-stats' data-reveal>
|
||||
${stats}
|
||||
</div>
|
||||
<div class='hero-foot' data-reveal>
|
||||
<span class='meta'>${i.hero.meta}</span>
|
||||
<span class='coord'>${i.brand.coordinates}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='hero-art' data-reveal='scale'>
|
||||
<span class='corner tl'></span>
|
||||
<span class='corner tr'></span>
|
||||
<span class='corner bl'></span>
|
||||
<span class='corner br'></span>
|
||||
<span class='annot annot-tl coord'>${i.hero.annotations.tl}</span>
|
||||
<span class='annot annot-tr'>${i.hero.annotations.tr}</span>
|
||||
<span class='annot annot-bl coord'>${i.hero.annotations.bl}</span>
|
||||
<span class='annot annot-br'>${i.hero.annotations.br}</span>
|
||||
<img src='${assets}hero.png' alt='' />
|
||||
<div class='index'>
|
||||
${index}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderAbout(i: EditorialCollageInputs): string {
|
||||
const r = i.rules.about;
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='about' data-od-id='about'>
|
||||
<div class='container'>
|
||||
${renderSecRule(r).trim()}
|
||||
<div class='about-grid'>
|
||||
<div class='about-copy' data-reveal>
|
||||
<span class='label'>${i.about.label} <span class='ix'>${i.about.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.about.headline)}</h2>
|
||||
<p class='lead'>${i.about.lead}</p>
|
||||
<a class='btn btn-ghost' href='${i.about.cta_href}'${ext(i.about.cta_href)}>
|
||||
${i.about.cta_label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<div class='footer-row'>
|
||||
<span class='mark'>${i.brand.mark}</span>
|
||||
<span>${i.about.footer_text}</span>
|
||||
<span class='stamp'>
|
||||
<span>${i.about.stamp_top}</span>
|
||||
<span style='color: var(--ink);'>${i.about.stamp_bottom}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='about-art' data-reveal='right'>
|
||||
<img src='${assets}about.png' alt='' />
|
||||
<div class='about-side-note'>
|
||||
<b></b>
|
||||
${i.about.side_note}
|
||||
</div>
|
||||
<div class='about-caption'>
|
||||
<b>${i.about.caption.bold}</b>
|
||||
${i.about.caption.rest}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCapabilityCard(c: CapabilityCard): string {
|
||||
return `<div class='card' data-reveal>
|
||||
<div class='num'>${c.num}<span class='tag'>${c.tag}</span></div>
|
||||
<svg class='icon' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5'>
|
||||
${c.icon_svg}
|
||||
</svg>
|
||||
<h3>${br(c.title)}</h3>
|
||||
<p>${c.body}</p>
|
||||
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Learn more about ${c.tag}'>
|
||||
${ARROW_OUT}
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCapabilities(i: EditorialCollageInputs): string {
|
||||
const cards = i.capabilities.cards.map(renderCapabilityCard).join('\n ');
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='capabilities' id='agents' data-od-id='capabilities'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.capabilities).trim()}
|
||||
<div class='capabilities-grid'>
|
||||
<div class='capabilities-art' data-reveal='left'>
|
||||
<span class='corner tl'></span>
|
||||
<span class='corner br'></span>
|
||||
<img src='${assets}capabilities.png' alt='' />
|
||||
<div class='ribbon'>${i.capabilities.ribbon}</div>
|
||||
</div>
|
||||
<div class='capabilities-copy' data-reveal>
|
||||
<span class='label'>${i.capabilities.label} <span class='ix'>${i.capabilities.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.capabilities.headline)}</h2>
|
||||
<p class='lead'>${i.capabilities.lead}</p>
|
||||
<div class='cards'>
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderLabPill(p: LabPill): string {
|
||||
return `<button class='pill${p.active ? ' active' : ''}'>${p.label}<span class='count'>${p.count}</span></button>`;
|
||||
}
|
||||
|
||||
function renderLabCard(c: LabCard, n: number, assets: string): string {
|
||||
return `<div class='lab' data-reveal>
|
||||
<div class='lab-img'><span class='badge'>${c.badge}</span><img src='${assets}lab-${n}.png' alt='' /></div>
|
||||
<div class='num-row'><span>${c.num}</span><span>${c.year}</span></div>
|
||||
<h4>${c.title}</h4>
|
||||
<p>${c.body}</p>
|
||||
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Open ${c.title}'>${ARROW_OUT}</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderLabs(i: EditorialCollageInputs): string {
|
||||
const pills = i.labs.pills.map(renderLabPill).join('\n ');
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
const cards = i.labs.cards
|
||||
.map((c, idx) => renderLabCard(c, idx + 1, assets))
|
||||
.join('\n ');
|
||||
const progress = Array.from({ length: i.labs.progress.total }, (_, k) =>
|
||||
k < i.labs.progress.filled ? `<span class='on'></span>` : `<span></span>`,
|
||||
).join('');
|
||||
return `
|
||||
<section class='labs' id='labs' data-od-id='labs'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.labs).trim()}
|
||||
<div class='labs-head'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.labs.label} <span class='ix'>${i.labs.ix}</span></span>
|
||||
<h2 class='display' style='margin-top:30px;'>${mixed(i.labs.headline)}</h2>
|
||||
</div>
|
||||
<div class='pills' data-reveal='right'>
|
||||
${pills}
|
||||
</div>
|
||||
</div>
|
||||
<div class='labs-meta'>
|
||||
<span class='ring'>${i.labs.meta.ring}</span>
|
||||
<div class='meta-text'>
|
||||
<b>${i.labs.meta.bold}</b>
|
||||
${i.labs.meta.sub}
|
||||
</div>
|
||||
</div>
|
||||
<div class='labs-grid'>
|
||||
${cards}
|
||||
</div>
|
||||
<div class='labs-foot'>
|
||||
<div class='progress'>
|
||||
${progress}
|
||||
</div>
|
||||
<span class='meta'>${i.labs.foot}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderMethodStep(s: MethodStep, last: boolean, n: number, assets: string): string {
|
||||
return `<div class='method-step' data-reveal>
|
||||
<div class='num'>${s.num}</div>
|
||||
<h4>${s.title}${last ? '' : ` <span class='arrow-r'>→</span>`}</h4>
|
||||
<p>${s.body}</p>
|
||||
<div class='img'><img src='${assets}method-${n}.png' alt='' /></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMethod(i: EditorialCollageInputs): string {
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
const steps = i.method.steps
|
||||
.map((s, idx, arr) => renderMethodStep(s, idx === arr.length - 1, idx + 1, assets))
|
||||
.join('\n ');
|
||||
return `
|
||||
<section class='method' data-od-id='method'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.method).trim()}
|
||||
<div class='method-head'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.method.label} <span class='ix'>${i.method.ix}</span></span>
|
||||
<h2 class='display' style='margin-top:30px;'>${mixed(i.method.headline)}</h2>
|
||||
</div>
|
||||
<div class='right' data-reveal='right'>
|
||||
<span class='plus'>+</span>
|
||||
<p>${i.method.right}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class='method-grid'>
|
||||
${steps}
|
||||
</div>
|
||||
<div class='method-foot'>
|
||||
<div class='left'>
|
||||
<span class='ring'></span>
|
||||
<span>${i.method.foot_left}</span>
|
||||
</div>
|
||||
<div class='right'><a class='method-repo-link' href='https://${i.method.foot_right_bold}'${ext('https://x')}><b>${i.method.foot_right_bold}</b></a> · ${i.method.foot_right_rest}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderWorkCard(c: WorkCard, idx: number, assets: string, href: string): string {
|
||||
return `<a class='work-card${idx === 1 ? ' alt' : ''}' data-reveal href='${href}'${ext(href)}>
|
||||
<div class='label-row'>
|
||||
<span class='small-label'>${c.small_label}</span>
|
||||
<span class='index'>${c.index}</span>
|
||||
</div>
|
||||
<h3>${c.title}</h3>
|
||||
<p>${c.body}</p>
|
||||
<div class='img'><img src='${assets}work-${idx + 1}.png' alt='' /></div>
|
||||
<div class='meta-row'>
|
||||
<span class='year'>${c.year}</span>
|
||||
<span>${c.tag}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderWork(i: EditorialCollageInputs): string {
|
||||
const r = i.rules.work;
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
// Use the first nav link as the work-card href fallback (we don't model per-card hrefs in WorkCard).
|
||||
const fallbackHref = i.nav.find((l) => /skills/i.test(l.label))?.href ?? '#';
|
||||
const cards = i.work.cards
|
||||
.map((c, idx) => renderWorkCard(c, idx, assets, fallbackHref))
|
||||
.join('\n ');
|
||||
return `
|
||||
<section class='tight' data-od-id='work'>
|
||||
<div class='work'>
|
||||
<div class='work-rule'>
|
||||
<span class='roman'>${r.roman}</span>
|
||||
<span style='display:inline-flex;gap:24px;'>
|
||||
<span>${r.meta[0]}</span>
|
||||
<span style='color:var(--coral);'>${r.meta[1]}</span>
|
||||
<span>${r.meta[2]}</span>
|
||||
</span>
|
||||
<span>${r.pagination}</span>
|
||||
</div>
|
||||
<div class='work-grid'>
|
||||
<div class='work-copy' data-reveal>
|
||||
<span class='label'>${i.work.label}</span>
|
||||
<h2>${mixed(i.work.headline)}</h2>
|
||||
<a class='work-link' href='${i.work.link_href}'${ext(i.work.link_href)}>${i.work.link_label}</a>
|
||||
</div>
|
||||
${cards}
|
||||
</div>
|
||||
<div class='work-arrows'>
|
||||
<button class='nav-btn'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M14 6l-6 6 6 6'/></svg></button>
|
||||
<button class='nav-btn active'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M10 6l6 6-6 6'/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderPartner(p: Partner, href: string): string {
|
||||
return `<a class='partner' data-reveal href='${href}'${ext(href)}>
|
||||
<div class='glyph'>
|
||||
<svg viewBox='0 0 80 30' fill='none' stroke='currentColor' stroke-width='2'>
|
||||
${p.glyph_svg}
|
||||
</svg>
|
||||
</div>
|
||||
<span>${p.name}</span>
|
||||
<small>${p.role}</small>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function renderTestimonial(i: EditorialCollageInputs): string {
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
// Each Partner can carry its own href. We fall back to the testimonial
|
||||
// read-more URL (then '#') so older brand inputs without per-partner
|
||||
// links still render valid anchors.
|
||||
const fallback = i.testimonial.read_more_href ?? '#';
|
||||
const partners = i.testimonial.partners
|
||||
.map((p) => renderPartner(p, p.href ?? fallback))
|
||||
.join('\n ');
|
||||
return `
|
||||
<section class='testimonial' data-od-id='testimonial'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.testimonial).trim()}
|
||||
<div class='testimonial-grid'>
|
||||
<div class='testimonial-copy' data-reveal>
|
||||
<span class='label'>${i.testimonial.label} <span class='ix'>${i.testimonial.ix}</span></span>
|
||||
<h2 style='margin-top:30px;'>“${mixed(i.testimonial.quote)}”</h2>
|
||||
<div class='author'>
|
||||
<span class='avatar'>${i.testimonial.author.initial}</span>
|
||||
<p>${i.testimonial.author.name}<br/><span>${i.testimonial.author.title}</span></p>
|
||||
</div>
|
||||
<div class='divider'></div>
|
||||
<p class='partners-text'>${i.testimonial.partners_text}</p>
|
||||
<div class='partners'>
|
||||
${partners}
|
||||
</div>
|
||||
<a class='read-more' href='${i.testimonial.read_more_href}'${ext(i.testimonial.read_more_href)}>${i.testimonial.read_more_label}</a>
|
||||
</div>
|
||||
<div class='testimonial-art' data-reveal='right'>
|
||||
<img src='${assets}testimonial.png' alt='' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCTA(i: EditorialCollageInputs): string {
|
||||
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
|
||||
return `
|
||||
<section class='cta' id='contact' data-od-id='cta'>
|
||||
<div class='container'>
|
||||
${renderSecRule(i.rules.cta).trim()}
|
||||
<div class='cta-grid'>
|
||||
<div data-reveal>
|
||||
<span class='label'>${i.cta.label} <span class='ix'>${i.cta.ix}</span></span>
|
||||
<h2 class='display'>${mixed(i.cta.headline)}</h2>
|
||||
<p class='lead'>${i.cta.lead}</p>
|
||||
<div class='cta-actions'>
|
||||
<a class='btn btn-primary' href='${i.cta.primary.href}'${ext(i.cta.primary.href)}>
|
||||
${i.cta.primary.label}
|
||||
<span class='arrow'>${ARROW_OUT}</span>
|
||||
</a>
|
||||
<a class='email-pill' href='${i.brand.contact_email}'${ext(i.brand.contact_email)}>
|
||||
${/^mailto:/.test(i.brand.contact_email) ? i.brand.contact_email.replace(/^mailto:/, '') : 'Open an issue'}
|
||||
<span class='arrow-circle'>→</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class='cta-foot'>
|
||||
<span class='stamp'>● Live</span>
|
||||
<span>${i.brand.version} / ${i.brand.license}</span>
|
||||
<span style='margin-left:auto;'>${i.brand.coordinates}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='cta-art' data-reveal='right'>
|
||||
<img src='${assets}cta.png' alt='' />
|
||||
<div class='index'>Nº 08</div>
|
||||
<div class='ribbon'>${i.cta.ribbon}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderFooterColumn(c: FooterColumn): string {
|
||||
const links = c.links
|
||||
.map((l) => `<li><a href='${l.href}'${ext(l.href)}>${l.label}</a></li>`)
|
||||
.join('\n ');
|
||||
return `<div class='foot-col'>
|
||||
<h5>${c.title}</h5>
|
||||
<ul>
|
||||
${links}
|
||||
</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFooter(i: EditorialCollageInputs): string {
|
||||
const cols = i.footer.columns.map(renderFooterColumn).join('\n ');
|
||||
// Resolve the footer brand CTA — explicit `footer.brand_cta` wins,
|
||||
// otherwise inherit `brand.download_url` so a single field lights up
|
||||
// both the nav and the footer download entry.
|
||||
const brandCta =
|
||||
i.footer.brand_cta ??
|
||||
(i.brand.download_url
|
||||
? {
|
||||
label: i.brand.download_url_label ?? 'Download desktop',
|
||||
href: i.brand.download_url,
|
||||
meta: i.brand.version,
|
||||
}
|
||||
: null);
|
||||
const brandCtaHtml = brandCta
|
||||
? `
|
||||
<a class='foot-cta' href='${brandCta.href}'${ext(brandCta.href)}>${brandCta.label}${
|
||||
brandCta.meta ? `<span class='meta'>${brandCta.meta}</span>` : ''
|
||||
}</a>`
|
||||
: '';
|
||||
return `
|
||||
<footer data-od-id='footer'>
|
||||
<div class='container'>
|
||||
<div class='foot-grid'>
|
||||
<div class='foot-brand'>
|
||||
<a href='#top' class='brand'>
|
||||
<span class='brand-mark'>${i.brand.mark}</span>
|
||||
<span>${i.brand.name}</span>
|
||||
</a>
|
||||
<p style='margin-top:18px;'>${i.footer.brand_description}</p>${brandCtaHtml}
|
||||
</div>
|
||||
${cols}
|
||||
</div>
|
||||
<div class='foot-bottom'>
|
||||
<span><span class='pulse'></span>● <b style='color:var(--ink);'>${i.brand.name}</b> · ${i.brand.license} · ${i.brand.year} / ${i.brand.edition}</span>
|
||||
<span class='right'>
|
||||
<span>${i.brand.location}</span>
|
||||
<span>${i.brand.coordinates}</span>
|
||||
<span style='color:var(--coral);'>♥ ${i.brand.year_roman}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class='foot-mega'>
|
||||
<div class='word' data-reveal='rise-lg'>${mixed(i.footer.mega)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
function renderWire(i: EditorialCollageInputs): string {
|
||||
const w = i.wire;
|
||||
if (!w || (w.cities.length === 0 && w.contributors.length === 0)) return '';
|
||||
// Duplicate each list so the marquee CSS animation translates -50%
|
||||
// and lands seamlessly at the start of the second copy.
|
||||
const cityRow = [...w.cities, ...w.cities]
|
||||
.map(
|
||||
(c) =>
|
||||
`<span class='wire-item'><span class='wire-dot'>·</span><span class='wire-coord'>${c.coord}</span><span class='wire-name'>${c.name}</span></span>`,
|
||||
)
|
||||
.join('\n ');
|
||||
const contribRow = [...w.contributors, ...w.contributors]
|
||||
.map(
|
||||
(c) =>
|
||||
`<a class='wire-item is-link' href='${c.href}'${ext(c.href)} aria-label='Open ${c.handle} on GitHub'><span class='wire-dot'>·</span><span class='wire-handle'>@${c.handle}</span><span class='wire-role'>${c.role}</span></a>`,
|
||||
)
|
||||
.join('\n ');
|
||||
const subtitle =
|
||||
w.subtitle ??
|
||||
`Open · ${w.cities.length} cities · ${Math.max(w.contributors.length - 1, 0)} contributors`;
|
||||
return `
|
||||
<section class='wire' data-od-id='wire' aria-label='Global wire — cities and contributors'>
|
||||
<div class='container wire-inner'>
|
||||
<div class='wire-left'>
|
||||
<span class='wire-mark' aria-hidden='true'><span class='wire-pulse'></span></span>
|
||||
<span class='wire-title'>
|
||||
<b>${w.title}</b>
|
||||
<span>${subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class='wire-rows'>
|
||||
<div class='wire-row'>
|
||||
<div class='marquee-track' aria-hidden='true'>
|
||||
${cityRow}
|
||||
</div>
|
||||
</div>
|
||||
<div class='wire-row reverse'>
|
||||
<div class='marquee-track'>
|
||||
${contribRow}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* inline scripts (mirror apps/landing-page/app/_components/*)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const REVEAL_AND_NAV_SCRIPT = `
|
||||
<script>
|
||||
/*
|
||||
* Scroll-reveal observer — mirrors apps/landing-page/app/_components/reveal-root.tsx.
|
||||
* Watches every [data-reveal] element and flips data-revealed='true'
|
||||
* when it first enters the viewport, triggering the CSS transition.
|
||||
*/
|
||||
(function () {
|
||||
var elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
|
||||
if (!elements.length) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
for (var i = 0; i < elements.length; i++) elements[i].dataset.revealed = 'true';
|
||||
return;
|
||||
}
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (!entries[i].isIntersecting) continue;
|
||||
entries[i].target.dataset.revealed = 'true';
|
||||
observer.unobserve(entries[i].target);
|
||||
}
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
for (var j = 0; j < elements.length; j++) observer.observe(elements[j]);
|
||||
})();
|
||||
|
||||
/*
|
||||
* Headroom-style sticky header — mirrors apps/landing-page/app/_components/header.tsx.
|
||||
* Hides the nav on downward scroll, re-pins it on upward scroll, and
|
||||
* always keeps it visible near the top of the page.
|
||||
*/
|
||||
(function () {
|
||||
var nav = document.querySelector('header.nav');
|
||||
if (!nav) return;
|
||||
var SHOW_TOP = 100;
|
||||
var DELTA = 6;
|
||||
var lastY = window.scrollY || 0;
|
||||
function onScroll() {
|
||||
var y = window.scrollY || 0;
|
||||
var d = y - lastY;
|
||||
if (y <= SHOW_TOP) {
|
||||
nav.classList.remove('is-hidden');
|
||||
} else if (d > DELTA) {
|
||||
nav.classList.add('is-hidden');
|
||||
} else if (d < -DELTA) {
|
||||
nav.classList.remove('is-hidden');
|
||||
}
|
||||
lastY = y;
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
const STAR_SCRIPT_TEMPLATE = (repo: string) => `
|
||||
<script>
|
||||
/*
|
||||
* GitHub star count — pulls live count and replaces the placeholder
|
||||
* text in the nav CTA. Failures fall back silently.
|
||||
*/
|
||||
(function () {
|
||||
var cta = document.querySelector('a.nav-cta:not(.ghost)');
|
||||
if (!cta) return;
|
||||
function format(n) {
|
||||
if (!isFinite(n) || n <= 0) return '0';
|
||||
if (n < 1000) return String(n);
|
||||
var k = (n / 1000).toFixed(1).replace(/\\.0$/, '');
|
||||
return k + 'K';
|
||||
}
|
||||
fetch('https://api.github.com/repos/${repo}', {
|
||||
headers: { Accept: 'application/vnd.github+json' }
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || typeof data.stargazers_count !== 'number') return;
|
||||
cta.textContent = 'Star · ' + format(data.stargazers_count);
|
||||
cta.setAttribute('aria-label', 'Star on GitHub — ' + format(data.stargazers_count) + ' stars');
|
||||
})
|
||||
.catch(function () { /* leave placeholder on failure */ });
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* top-level
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function repoFromUrl(url: string): string | null {
|
||||
const m = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
|
||||
return m ? `${m[1]}/${m[2]}` : null;
|
||||
}
|
||||
|
||||
export function renderPage(inputs: EditorialCollageInputs, css: string): string {
|
||||
const repo = repoFromUrl(inputs.brand.primary_url);
|
||||
const starScript = repo ? STAR_SCRIPT_TEMPLATE(repo) : '';
|
||||
return [
|
||||
`<!DOCTYPE html>`,
|
||||
`<html lang='${inputs.brand.locale ?? 'en'}'>`,
|
||||
renderHead(inputs, css),
|
||||
`<body>`,
|
||||
renderRails(inputs),
|
||||
`<div class='shell'>`,
|
||||
renderTopbar(inputs),
|
||||
renderNav(inputs),
|
||||
renderHero(inputs),
|
||||
renderWire(inputs),
|
||||
renderAbout(inputs),
|
||||
renderCapabilities(inputs),
|
||||
renderLabs(inputs),
|
||||
renderMethod(inputs),
|
||||
renderWork(inputs),
|
||||
renderTestimonial(inputs),
|
||||
renderCTA(inputs),
|
||||
renderFooter(inputs),
|
||||
`</div>`,
|
||||
REVEAL_AND_NAV_SCRIPT,
|
||||
starScript,
|
||||
`</body>`,
|
||||
`</html>`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [, , inputsArg, outputArg] = process.argv;
|
||||
if (!inputsArg || !outputArg) {
|
||||
console.error('Usage: npx tsx scripts/compose.ts <inputs.json> <output.html>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputsPath = isAbsolute(inputsArg) ? inputsArg : resolve(process.cwd(), inputsArg);
|
||||
const outputPath = isAbsolute(outputArg) ? outputArg : resolve(process.cwd(), outputArg);
|
||||
const stylesPath = resolve(SKILL_ROOT, 'styles.css');
|
||||
|
||||
const [inputsRaw, css] = await Promise.all([
|
||||
readFile(inputsPath, 'utf8'),
|
||||
readFile(stylesPath, 'utf8'),
|
||||
]);
|
||||
const inputs = JSON.parse(inputsRaw) as EditorialCollageInputs;
|
||||
const html = renderPage(inputs, css);
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, html, 'utf8');
|
||||
console.log(`✓ wrote ${outputPath} (${(html.length / 1024).toFixed(1)} KB)`);
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
325
skills/editorial-collage/scripts/imagegen.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* editorial-collage — gpt-image-2 generator (fal.ai backend).
|
||||
*
|
||||
* Generates the 16 collage assets defined in `assets/image-manifest.json`
|
||||
* by composing per-slot prompts (style anchor + brand variables +
|
||||
* per-slot composition) and calling fal.ai's `openai/gpt-image-2`
|
||||
* synchronous endpoint. Downloads each result to the `--out` directory.
|
||||
*
|
||||
* Requires `FAL_KEY` in the environment. If it is missing, the script
|
||||
* prints the prompts it would have sent so an operator can route them
|
||||
* through the `/gpt-image-fal` skill manually, or set the key and re-run.
|
||||
*
|
||||
* Usage:
|
||||
* FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta]
|
||||
*
|
||||
* Cost note: 16 images × ~$0.025 each ≈ $0.40 per full run at high
|
||||
* quality. Re-running is idempotent — slots whose target file already
|
||||
* exists are skipped unless `--force` is passed.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { resolve, dirname, isAbsolute } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { EditorialCollageInputs } from '../schema';
|
||||
|
||||
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 { slots: ManifestSlot[] }
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* prompt constants (mirror assets/imagegen-prompts.md verbatim)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const STYLE_ANCHOR = `Use case: ads-marketing
|
||||
|
||||
Asset type: editorial website hero / creative studio landing page visual
|
||||
|
||||
Primary request: Generate a refined editorial web page composition in the
|
||||
same visual language as a high-end creative AI research studio.
|
||||
|
||||
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
|
||||
Bauhaus geometric composition, classical plaster sculpture fragments,
|
||||
brutalist/minimal architecture, art-direction website mockup, premium
|
||||
agency aesthetic.
|
||||
|
||||
Scene/backdrop: warm off-white handmade paper background with subtle
|
||||
grain, faint vertical folds, scanned paper fibers, lightly aged print
|
||||
texture, thin drafting lines and registration marks.
|
||||
|
||||
Subject: a surreal collage combining a cropped classical plaster head or
|
||||
face fragment, abstract architectural blocks, archways or stairs, sky
|
||||
cutouts, one small human figure, a delicate tree or botanical element,
|
||||
and geometric color planes.
|
||||
|
||||
Composition/framing: wide 16:9 web page layout, strong asymmetrical
|
||||
grid, generous negative space, large typography area on the left or
|
||||
top-left, collage focal object on the right or center-right, precise
|
||||
alignment, thin divider lines, small UI navigation details.
|
||||
|
||||
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
|
||||
restrained, tactile, poetic, premium, research-driven.
|
||||
|
||||
Color palette: warm ivory, stone beige, soft concrete gray, deep black
|
||||
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
|
||||
accent, pale sky blue only inside small sky/image cutouts.
|
||||
|
||||
Materials/textures: matte plaster, limestone, travertine, concrete, rough
|
||||
torn paper edges, halftone print grain, translucent vellum-like overlays,
|
||||
fine grid paper, dotted matrix patterns.
|
||||
|
||||
Typography: large clean grotesk sans-serif for main headline, elegant
|
||||
high-contrast italic serif for emphasized words, tiny uppercase coral
|
||||
labels, compact UI microcopy. Text must be crisp, readable, and spelled
|
||||
exactly as provided.
|
||||
|
||||
Graphic details: thin hairline circles, partial arcs, crosshair marks,
|
||||
small black dots, dotted grids, fine coordinate lines, numbered
|
||||
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
|
||||
|
||||
Constraints: preserve a high-end editorial web design feel; keep spacing
|
||||
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
|
||||
no busy gradients; no generic stock-photo look.
|
||||
|
||||
Avoid: distorted typography, misspelled text, extra random words, heavy
|
||||
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
|
||||
plastic materials, overly decorative UI cards, cluttered composition,
|
||||
low-resolution textures, watermarks.`;
|
||||
|
||||
const PER_SLOT: Record<string, string> = {
|
||||
hero: `Composition/framing: left half is intentionally empty/quiet to allow real
|
||||
HTML headline overlay; right half holds a tall surreal collage of a
|
||||
cropped classical plaster head with the top sliced open, sky/architecture
|
||||
cutouts visible inside the head, a delicate young tree growing through
|
||||
the composition, a coral sun disk behind, a mustard accent ring at the
|
||||
base, hairline coordinate marks and dotted matrices around it, a small
|
||||
human figure standing for scale in the lower-left of the image. Page
|
||||
type: hero landing.`,
|
||||
about: `Composition: a surreal museum-vitrine arrangement of a partial plaster
|
||||
profile head facing right, with an open archway carved through the
|
||||
torso, sky cutout inside the arch, a tree seedling growing out of the
|
||||
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
|
||||
trace contours. Strong negative space top-left for a side-note overlay.
|
||||
Page type: about / manifesto plate.`,
|
||||
capabilities: `Composition: a Bauhaus-grid stack of architectural fragments — a coral
|
||||
arch on the left, a beige concrete column center, a mustard small disc
|
||||
upper-right, a delicate tree mid-frame, a small classical hand fragment
|
||||
holding a pencil bottom-center. Crosshair and circular hairlines
|
||||
overlay. Page type: capabilities matrix.`,
|
||||
'method-1': `Composition: a magnifying glass over a small architectural map. Coral
|
||||
accent disc behind. One numbered annotation tag '01 · Detect'.
|
||||
Page type: method tile.`,
|
||||
'method-2': `Composition: a clipboard with a tiny questionnaire and a coral pen,
|
||||
on the warm paper ground. Mustard sticker corner. Annotation '02 ·
|
||||
Discover'. Page type: method tile.`,
|
||||
'method-3': `Composition: a compass + ruler + color swatch fan arranged like an
|
||||
architect's drafting kit. Coral accent on the swatch. Annotation
|
||||
'03 · Direct'. Page type: method tile.`,
|
||||
'method-4': `Composition: a printer's tray with stacked paper sheets exiting,
|
||||
mustard ribbon tag. Annotation '04 · Deliver'. Page type: method tile.`,
|
||||
'lab-1': `Portrait composition: a stack of folded magazine spreads, slight
|
||||
perspective, coral spine, mustard tab. Page type: lab card.`,
|
||||
'lab-2': `Portrait composition: a film strip + a synthetic eye + a soundwave
|
||||
hairline. Coral arc behind. Page type: lab card.`,
|
||||
'lab-3': `Portrait composition: a typewriter with prompt cards in the carriage,
|
||||
coral platen knob. Page type: lab card.`,
|
||||
'lab-4': `Portrait composition: five small dotted gauges arranged in a circle
|
||||
(5-dim critique), one filled coral. Page type: lab card.`,
|
||||
'lab-5': `Portrait composition: a glass dome / cloche over a tiny sandbox
|
||||
cityscape, mustard sun behind. Page type: lab card.`,
|
||||
'work-1': `Portrait composition: an oversized open magazine spread on a desk,
|
||||
coral spine, mustard tab. Slight perspective. Page type: work card.`,
|
||||
'work-2': `Portrait composition: a concrete dashboard slab, a coral graph bar
|
||||
rising, a small classical bust beside it for scale. Page type: work card.`,
|
||||
testimonial: `Composition: a classical plaster bust facing 3/4 left, slightly cropped,
|
||||
with a small sky cutout where the eye would be, a thin coral arc around
|
||||
the back of the head, mustard dot at the chin. Quiet background, lots of
|
||||
negative space upper right. Page type: testimonial portrait.`,
|
||||
cta: `Composition: a closing-plate collage — a mustard sun behind a single
|
||||
coral arch on the right, a delicate tree growing through the arch, a
|
||||
small human figure in the lower-left foreground reading a folded
|
||||
broadsheet, hairline coordinate ladder up the left edge, and a small
|
||||
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.`,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* prompt builder
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function brandVarsBlock(inputs: EditorialCollageInputs): string {
|
||||
// Pull the brand-shaped strings the model should bias toward.
|
||||
const navText = inputs.nav.slice(0, 5).map((n) => `"${n.label}"`).join(', ');
|
||||
const eyebrow = `${inputs.hero.label} ${inputs.hero.ix}`;
|
||||
const headline = inputs.hero.headline.map((s) => s.text).join('');
|
||||
const italic = inputs.hero.headline.filter((s) => s.em).map((s) => `"${s.text}"`).join(', ');
|
||||
const body = inputs.hero.lead.replace(/<[^>]+>/g, '').replace(/&[^;]+;/g, '');
|
||||
return `Brand/logo text: "${inputs.brand.name}"
|
||||
Navigation text: ${navText}
|
||||
Eyebrow label: "${eyebrow}"
|
||||
Main headline: "${headline}"
|
||||
Italic emphasis words: ${italic}
|
||||
Body copy: "${body}"
|
||||
Primary button: "${inputs.hero.primary.label}"
|
||||
Secondary button: "${inputs.hero.secondary.label}"
|
||||
Footer/micro labels: "${inputs.brand.location}", "${inputs.brand.coordinates}"`;
|
||||
}
|
||||
|
||||
export function promptForSlot(slot: ManifestSlot, inputs: EditorialCollageInputs): string {
|
||||
const override = inputs.imagery.prompts?.[slot.id];
|
||||
const composition = override ?? PER_SLOT[slot.id] ?? `Page type: ${slot.id} plate.`;
|
||||
return [STYLE_ANCHOR, brandVarsBlock(inputs), composition].join('\n\n');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* fal.ai client (raw fetch — no npm dependency)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface FalImageResult {
|
||||
images: Array<{ url: string; width?: number; height?: number; content_type?: string }>;
|
||||
}
|
||||
|
||||
async function callFalGptImage(
|
||||
prompt: string,
|
||||
width: number,
|
||||
height: number,
|
||||
apiKey: string,
|
||||
): Promise<Uint8Array> {
|
||||
// fal.ai exposes both queue (async) and run (sync) endpoints. Use sync
|
||||
// for simpler scripting; per-image latency is ~25-45s.
|
||||
const endpoint = 'https://fal.run/openai/gpt-image-2';
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Key ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
image_size: { width, height },
|
||||
num_images: 1,
|
||||
quality: 'high',
|
||||
output_format: 'png',
|
||||
background: 'opaque',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '<unreadable>');
|
||||
throw new Error(`fal.run/openai/gpt-image-2 ${res.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
const json = (await res.json()) as FalImageResult;
|
||||
const url = json.images?.[0]?.url;
|
||||
if (!url) throw new Error('fal.ai response missing images[0].url');
|
||||
const dl = await fetch(url);
|
||||
if (!dl.ok) throw new Error(`download ${url} failed: ${dl.status}`);
|
||||
const buf = await dl.arrayBuffer();
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* top-level
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface CliArgs {
|
||||
inputsPath: string;
|
||||
outDir: string;
|
||||
only?: Set<string>;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const inputsPath = argv[2];
|
||||
if (!inputsPath || inputsPath.startsWith('--')) {
|
||||
throw new Error('Usage: imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta] [--force]');
|
||||
}
|
||||
let outDir = './assets/';
|
||||
let only: Set<string> | undefined;
|
||||
let force = false;
|
||||
for (const arg of argv.slice(3)) {
|
||||
if (arg.startsWith('--out=')) outDir = arg.slice('--out='.length);
|
||||
else if (arg.startsWith('--only=')) only = new Set(arg.slice('--only='.length).split(','));
|
||||
else if (arg === '--force') force = true;
|
||||
else throw new Error(`unknown arg: ${arg}`);
|
||||
}
|
||||
return {
|
||||
inputsPath: isAbsolute(inputsPath) ? inputsPath : resolve(process.cwd(), inputsPath),
|
||||
outDir: isAbsolute(outDir) ? outDir : resolve(process.cwd(), outDir),
|
||||
only,
|
||||
force,
|
||||
};
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() && s.size > 256;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { inputsPath, outDir, only, force } = parseArgs(process.argv);
|
||||
const apiKey = process.env.FAL_KEY ?? '';
|
||||
const dryRun = !apiKey;
|
||||
|
||||
const inputs = JSON.parse(await readFile(inputsPath, 'utf8')) as EditorialCollageInputs;
|
||||
const manifestPath = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest;
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const targets = manifest.slots.filter((s) => !only || only.has(s.id));
|
||||
if (dryRun) {
|
||||
console.log(`FAL_KEY not set — dry run. Printing prompts for ${targets.length} slot(s).\n`);
|
||||
} else {
|
||||
console.log(`Generating ${targets.length} slot(s) → ${outDir}`);
|
||||
}
|
||||
|
||||
for (const slot of targets) {
|
||||
const target = resolve(outDir, slot.file);
|
||||
if (!force && (await fileExists(target))) {
|
||||
console.log(`· ${slot.id} — skip (exists)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prompt = promptForSlot(slot, inputs);
|
||||
if (dryRun) {
|
||||
console.log(`\n=== ${slot.id} (${slot.width}×${slot.height}) → ${slot.file} ===`);
|
||||
console.log(prompt);
|
||||
console.log(`=== end ${slot.id} ===\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
process.stdout.write(`· ${slot.id} (${slot.width}×${slot.height}) … `);
|
||||
try {
|
||||
const png = await callFalGptImage(prompt, slot.width, slot.height, apiKey);
|
||||
await writeFile(target, png);
|
||||
console.log(`ok (${(png.byteLength / 1024).toFixed(0)} KB)`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`fail — ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\nNext: set FAL_KEY in env and re-run to generate, or paste each prompt block into /gpt-image-fal manually.`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
174
skills/editorial-collage/scripts/placeholder.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
#!/usr/bin/env -S npx -y tsx
|
||||
/**
|
||||
* editorial-collage — 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);
|
||||
});
|
||||
}
|
||||