diff --git a/.github/workflows/landing-page-ci.yml b/.github/workflows/landing-page-ci.yml new file mode 100644 index 000000000..12ee1a7e3 --- /dev/null +++ b/.github/workflows/landing-page-ci.yml @@ -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 = [ + /]*\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 diff --git a/.github/workflows/landing-page-deploy.yml b/.github/workflows/landing-page-deploy.yml new file mode 100644 index 000000000..0d62357ef --- /dev/null +++ b/.github/workflows/landing-page-deploy.yml @@ -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 = [ + /]*\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 }} diff --git a/.gitignore b/.gitignore index 174ab2829..5edaf0e75 100644 --- a/.gitignore +++ b/.gitignore @@ -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// (per-project artifacts, diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index d08781f93..32f19ba9c 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -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/` 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//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. diff --git a/apps/daemon/tests/skill-asset-rewrite.test.ts b/apps/daemon/tests/skill-asset-rewrite.test.ts new file mode 100644 index 000000000..213708e62 --- /dev/null +++ b/apps/daemon/tests/skill-asset-rewrite.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { rewriteSkillAssetUrls } from '../src/server.js'; + +describe('rewriteSkillAssetUrls', () => { + it('rewrites ./assets/ img sources to the daemon route', () => { + const html = ``; + expect(rewriteSkillAssetUrls(html, 'editorial-collage')).toBe( + ``, + ); + }); + + it('handles double quotes and the no-leading-dot variant', () => { + const html = ``; + expect(rewriteSkillAssetUrls(html, 'foo')).toBe( + ``, + ); + }); + + it('rewrites sibling skill asset references', () => { + const html = ``; + expect(rewriteSkillAssetUrls(html, 'foo')).toBe( + ``, + ); + }); + + it('leaves absolute and fragment URLs untouched', () => { + const html = ``; + expect(rewriteSkillAssetUrls(html, 'foo')).toBe(html); + }); + + it('URL-encodes current and sibling skill ids in rewritten routes', () => { + const html = ``; + expect(rewriteSkillAssetUrls(html, '../oops')).toBe( + ``, + ); + }); + + it('returns non-string input unchanged', () => { + expect(rewriteSkillAssetUrls('', 'foo')).toBe(''); + }); +}); diff --git a/apps/landing-page/AGENTS.md b/apps/landing-page/AGENTS.md new file mode 100644 index 000000000..19d32a8b9 --- /dev/null +++ b/apps/landing-page/AGENTS.md @@ -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/.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. diff --git a/apps/landing-page/app/_components/header.tsx b/apps/landing-page/app/_components/header.tsx new file mode 100644 index 000000000..d557377a9 --- /dev/null +++ b/apps/landing-page/app/_components/header.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/landing-page/app/_components/wire.tsx b/apps/landing-page/app/_components/wire.tsx new file mode 100644 index 000000000..911405ac6 --- /dev/null +++ b/apps/landing-page/app/_components/wire.tsx @@ -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 `` 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 " 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 = [ + { 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 }) { + // Doubled tracks are required for the seamless `translateX(-50%)` + // marquee loop defined in globals.css. + const cityTrack = [...cities, ...cities]; + const contribTrack = [...FALLBACK, ...FALLBACK]; + + return ( +
+ +
+ ); +} diff --git a/apps/landing-page/app/env.d.ts b/apps/landing-page/app/env.d.ts new file mode 100644 index 000000000..e16c13c69 --- /dev/null +++ b/apps/landing-page/app/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/landing-page/app/globals.css b/apps/landing-page/app/globals.css new file mode 100644 index 000000000..b9c75c953 --- /dev/null +++ b/apps/landing-page/app/globals.css @@ -0,0 +1,2037 @@ +/* + * Atelier Zero — landing page styles. + * + * Mirrors `skills/editorial-collage/example.html` + + +
+
+ Ø + Open Design · Vol. 01 / Issue Nº 26 +
+
+ ← / → · Space + 01 / 11 +
+
+
+
+
+
+ Open-source design studio · Nº 01 +

Designing intelligence with skills, taste, and code.

+
The open-source alternative to Anthropic's Claude Design.
+

12 coding agents drive 31 composable skills and 72 brand-grade design systems. Local-first, web-deployable, BYOK at every layer.

+
Berlin · MMXXVI · 52.5200° N · 13.4050° E
+
+
+
+
+
+
+
I.
+

Why another design tool?

+

Because the strongest agents already live on your laptop — and they deserve a real workflow.

+
+
+
+
+
+ About the studio · Nº 02 +

We treat your agent as a creative collaborator.

+

We don't ship one — we wire whichever you trust into a skill-driven design workflow that runs locally with pnpm tools-dev, deploys to Vercel, and stays BYOK at every layer.

+
  • Files, not opaque prompts — every skill is a folder of Markdown.
  • Deterministic visual directions, not random generation.
  • Sandboxed iframe preview, real cwd, exportable artifacts.
+
+
+
+
+
+
+
+ Capabilities · Nº 03 +

Skills, systems, surfaces — for creative intelligence.

+

Four composable surfaces, one feedback loop. Skills supply behavior. Systems supply taste. Adapters bridge agents. BYOK respects your wallet.

+
  • 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.
+
+
+
+
+
+
+
+ By the numbers · Nº 04 +

Composable, shippable, portable.

+
+
+
+
31
+
Skills
+
file-based, shippable today
+
+
+
72
+
Systems
+
design tokens you already trust
+
+
+
12
+
Agents
+
auto-detected on your $PATH
+
+
+
3
+
Commands
+
from clone to first artifact
+
+
+
Open Design v0.2.0 · Apache-2.0 · MMXXVI
+
+
+
+
+
II.
+

How it feels to use it.

+ +
+
+
+
+
+ Method · Nº 05 +

From signals to systems.

+

Every project moves through four iterative stages. The agent picks each stage's tools deterministically; you stay in control.

+
  • 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.
+
+
+
+
+
+
+
+ Selected work · Nº 06 +

Skills that turn briefs into memorable artifacts.

+

From editorial decks to consumer dashboards — the same loop, different surface. Every output is a real file you can hand to a client tomorrow.

+ +
+
+
+
+
+
+
+
“Open Design helped us turn vague AI ideas into a visual system that felt sharp, believable, and genuinely new.”
+
+ m +

Mina Kovac
Creative Director · North Form

+
+
+
+
+
+
+
+ Start a conversation · Nº 07 +

Let's build something open and visually unforgettable.

+

Star us on GitHub, drop into the issues, or run pnpm tools-dev tonight. Three commands and the loop is yours.

+ +
+
+
+
+
Open Design.
+ +
+
+
+
+ + + + diff --git a/skills/editorial-collage-deck/inputs.example.json b/skills/editorial-collage-deck/inputs.example.json new file mode 100644 index 000000000..826643e88 --- /dev/null +++ b/skills/editorial-collage-deck/inputs.example.json @@ -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 pnpm tools-dev, 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" + } +} diff --git a/skills/editorial-collage-deck/schema.ts b/skills/editorial-collage-deck/schema.ts new file mode 100644 index 000000000..b2f572572 --- /dev/null +++ b/skills/editorial-collage-deck/schema.ts @@ -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 `` 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; +} diff --git a/skills/editorial-collage-deck/scripts/compose.ts b/skills/editorial-collage-deck/scripts/compose.ts new file mode 100644 index 000000000..a74c533a8 --- /dev/null +++ b/skills/editorial-collage-deck/scripts/compose.ts @@ -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 + * + * 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 `${seg.text}`; + if (seg.em) return `${seg.text}`; + return seg.text; + }) + .join(''); +} + +function ext(href: string): string { + return /^(https?:|mailto:|\/\/)/i.test(href) ? ` target='_blank' rel='noreferrer noopener'` : ''; +} + +const ARROW_OUT = ``; + +function imgFor(slot: string | undefined, assets: string): string { + if (!slot) return ''; + return ``; +} + +/* ------------------------------------------------------------------ * + * 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 `
+
+
+ ${s.eyebrow} +

${mixed(s.title)}

+ ${s.subtitle ? `
${s.subtitle}
` : ''} +

${s.lead}

+ ${s.meta ? `
${s.meta}
` : ''} +
+
${imgFor(s.image_slot, assets)}
+
+
`; +} + +function renderSection(s: SectionSlide): string { + return `
+
+
${s.roman}
+

${mixed(s.title)}

+ ${s.lead ? `

${s.lead}

` : ''} +
+
`; +} + +function renderContent(s: ContentSlide, assets: string): string { + const layout = s.layout ?? 'left'; + const hasArt = !!s.image_slot; + return `
+
+
+ ${s.eyebrow ? `${s.eyebrow}` : ''} +

${mixed(s.title)}

+ ${s.body ? `

${s.body}

` : ''} + ${s.bullets && s.bullets.length ? `
    ${s.bullets.map((b) => `
  • ${b}
  • `).join('')}
` : ''} +
+ ${hasArt ? `
${imgFor(s.image_slot, assets)}
` : ''} +
+
`; +} + +function renderStats(s: StatsSlide): string { + const stats = s.stats + .map( + (st) => + `
+
${st.value}
+
${st.label}
+ ${st.sub ? `
${st.sub}
` : ''} +
`, + ) + .join('\n '); + return `
+
+
+ ${s.eyebrow ? `${s.eyebrow}` : ''} +

${mixed(s.title)}

+
+
+ ${stats} +
+ ${s.caption ? `
${s.caption}
` : ''} +
+
`; +} + +function renderQuote(s: QuoteSlide, assets: string): string { + const hasArt = !!s.image_slot; + return `
+
+
+
“${mixed(s.quote)}”
+
+ ${s.author.initial} +

${s.author.name}
${s.author.title}

+
+
+ ${hasArt ? `
${imgFor(s.image_slot, assets)}
` : ''} +
+
`; +} + +function renderCTA(s: CTASlide): string { + return `
+
+ ${s.eyebrow ? `${s.eyebrow}` : ''} +

${mixed(s.title)}

+ ${s.body ? `

${s.body}

` : ''} + +
+
`; +} + +function renderEnd(s: EndSlide): string { + return `
+
+
${mixed(s.mega)}
+ ${s.footer ? `` : ''} +
+
`; +} + +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 = ` +`; + +/* ------------------------------------------------------------------ * + * 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 [ + ``, + ``, + ``, + ``, + ``, + `${inputs.deck_title}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `
`, + `
`, + ` ${inputs.brand.mark}`, + ` ${inputs.deck_title}`, + `
`, + `
`, + ` ← / → · Space`, + ` 01 / ${String(total).padStart(2, '0')}`, + `
`, + `
`, + `
`, + ` ${slides}`, + `
`, + `
`, + RUNTIME_SCRIPT, + ``, + ``, + ``, + ].join('\n'); +} + +async function main(): Promise { + const [, , inputsArg, outputArg] = process.argv; + if (!inputsArg || !outputArg) { + console.error('Usage: npx tsx scripts/compose.ts '); + 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); + }); +} diff --git a/skills/editorial-collage/README.md b/skills/editorial-collage/README.md new file mode 100644 index 000000000..0654574dc --- /dev/null +++ b/skills/editorial-collage/README.md @@ -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. diff --git a/skills/editorial-collage/SKILL.md b/skills/editorial-collage/SKILL.md new file mode 100644 index 000000000..0a427c898 --- /dev/null +++ b/skills/editorial-collage/SKILL.md @@ -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: /index.html + when: output_format in [standalone-html, both] + description: Self-contained HTML with Atelier Zero CSS inlined. + - path: /assets/*.png (or *.svg) + description: 16 collage assets, generated or placeholder per strategy. + - path: /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 ◄────────────┘ + │ + ▼ + /index.html (self-contained) + /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 /assets/ +``` + +Writes 16 `.svg` files (with `.png` aliases for compatibility) into +`/assets/`. Each placeholder shows the slot id, ratio, pixel +dimensions, and the prompt hint from `image-manifest.json`. The +composer's `` etc. just work. + +#### `generate` — gpt-image-2 mode + +```bash +FAL_KEY=... npx tsx scripts/imagegen.ts --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 /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 `/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: + +- [ ] `/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. diff --git a/skills/editorial-collage/assets/about.png b/skills/editorial-collage/assets/about.png new file mode 100644 index 000000000..a63d1ae2d Binary files /dev/null and b/skills/editorial-collage/assets/about.png differ diff --git a/skills/editorial-collage/assets/capabilities.png b/skills/editorial-collage/assets/capabilities.png new file mode 100644 index 000000000..ee087e886 Binary files /dev/null and b/skills/editorial-collage/assets/capabilities.png differ diff --git a/skills/editorial-collage/assets/cta.png b/skills/editorial-collage/assets/cta.png new file mode 100644 index 000000000..90d00a74e Binary files /dev/null and b/skills/editorial-collage/assets/cta.png differ diff --git a/skills/editorial-collage/assets/hero.png b/skills/editorial-collage/assets/hero.png new file mode 100644 index 000000000..366d9ee99 Binary files /dev/null and b/skills/editorial-collage/assets/hero.png differ diff --git a/skills/editorial-collage/assets/image-manifest.json b/skills/editorial-collage/assets/image-manifest.json new file mode 100644 index 000000000..f00ebf155 --- /dev/null +++ b/skills/editorial-collage/assets/image-manifest.json @@ -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 + } + ] +} diff --git a/skills/editorial-collage/assets/imagegen-prompts.md b/skills/editorial-collage/assets/imagegen-prompts.md new file mode 100644 index 000000000..55f712e8d --- /dev/null +++ b/skills/editorial-collage/assets/imagegen-prompts.md @@ -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/.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: "" +Navigation text: "", "", "", "", "" +Eyebrow label: "" +Main headline: "" +Italic emphasis words: "" +Body copy: "" +Primary button: "" +Secondary button: "" +Footer/micro labels: "" +Main collage subject: +Inserted texture motifs: +Accent color: +Page type: +``` + +## 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. diff --git a/skills/editorial-collage/assets/lab-1.png b/skills/editorial-collage/assets/lab-1.png new file mode 100644 index 000000000..f65527c80 Binary files /dev/null and b/skills/editorial-collage/assets/lab-1.png differ diff --git a/skills/editorial-collage/assets/lab-2.png b/skills/editorial-collage/assets/lab-2.png new file mode 100644 index 000000000..066b5cdfe Binary files /dev/null and b/skills/editorial-collage/assets/lab-2.png differ diff --git a/skills/editorial-collage/assets/lab-3.png b/skills/editorial-collage/assets/lab-3.png new file mode 100644 index 000000000..9aa8e48ef Binary files /dev/null and b/skills/editorial-collage/assets/lab-3.png differ diff --git a/skills/editorial-collage/assets/lab-4.png b/skills/editorial-collage/assets/lab-4.png new file mode 100644 index 000000000..aeb085cf8 Binary files /dev/null and b/skills/editorial-collage/assets/lab-4.png differ diff --git a/skills/editorial-collage/assets/lab-5.png b/skills/editorial-collage/assets/lab-5.png new file mode 100644 index 000000000..3b635bece Binary files /dev/null and b/skills/editorial-collage/assets/lab-5.png differ diff --git a/skills/editorial-collage/assets/method-1.png b/skills/editorial-collage/assets/method-1.png new file mode 100644 index 000000000..a2f43377d Binary files /dev/null and b/skills/editorial-collage/assets/method-1.png differ diff --git a/skills/editorial-collage/assets/method-2.png b/skills/editorial-collage/assets/method-2.png new file mode 100644 index 000000000..6f3320548 Binary files /dev/null and b/skills/editorial-collage/assets/method-2.png differ diff --git a/skills/editorial-collage/assets/method-3.png b/skills/editorial-collage/assets/method-3.png new file mode 100644 index 000000000..768439be1 Binary files /dev/null and b/skills/editorial-collage/assets/method-3.png differ diff --git a/skills/editorial-collage/assets/method-4.png b/skills/editorial-collage/assets/method-4.png new file mode 100644 index 000000000..5f32c4ac8 Binary files /dev/null and b/skills/editorial-collage/assets/method-4.png differ diff --git a/skills/editorial-collage/assets/testimonial.png b/skills/editorial-collage/assets/testimonial.png new file mode 100644 index 000000000..1a9880cc8 Binary files /dev/null and b/skills/editorial-collage/assets/testimonial.png differ diff --git a/skills/editorial-collage/assets/work-1.png b/skills/editorial-collage/assets/work-1.png new file mode 100644 index 000000000..75d57ad59 Binary files /dev/null and b/skills/editorial-collage/assets/work-1.png differ diff --git a/skills/editorial-collage/assets/work-2.png b/skills/editorial-collage/assets/work-2.png new file mode 100644 index 000000000..210707894 Binary files /dev/null and b/skills/editorial-collage/assets/work-2.png differ diff --git a/skills/editorial-collage/example.html b/skills/editorial-collage/example.html new file mode 100644 index 000000000..4e45e949c --- /dev/null +++ b/skills/editorial-collage/example.html @@ -0,0 +1,2625 @@ + + + + + +Open Design — Designing intelligence with skills, taste, and your own agent. + + + + + + + + +
+ Open Design — Vol. 01 · Issue Nº 26 · Apache-2.0 +
+
+ Skills · Systems · Agents · BYOK · Local-first +
+
+ +
+
+ OD / 2026  ·  Vol. 01 / Issue Nº 26 + + Filed under Design · Intelligence + Apache-2.0 · Made on Earth + + + Live · v0.2.0 + EN · DE · 中文 · 日本語 + +
+
+ + + +
+
+
+ I. + + Hero / Cover Plate + + Open Design / Volume 01 + + 001 / 008 +
+
+
+
+ Open-source design studio · Nº 01 +

Designing intelligence with skills, taste, and code.

+

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.

+ +
+
+ 31 + skillsshippable +
+
+ 72 + systemsportable +
+
+ 12 + CLIsBYO agent +
+
+
+ ↳   pnpm tools-dev   ·   3 commands to start + 52.5200° N · 13.4050° E +
+
+
+ + + + + FIG. 01 / OD-26 + Plate Nº 08 + SHA · a1b2c3d + Composed in Open Design + +
+ 01Detect + 02Discover + 03Direct + 04Deliver +
+
+
+
+ +
+
+
+ + + From the field + Open · 23 cities · 6 contributors + +
+
+
+ +
+ +
+
+
+ +
+
+
+ II. + + About / Manifesto + + Open Design / Volume 01 + + 002 / 008 +
+
+
+ About the studio · Nº 02 +

We treat your agent as a creative collaborator, not a black box.

+

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 pnpm tools-dev, deploys the web layer to Vercel, and stays BYOK at every layer.

+ + Read our approach + + + +
+
+ +
+ + From model behavior
to visual taste, we
prototype the full
stack of creative
systems. +
+
+ Studies in form · perception · machine imagination. + (Open Design, MMXXVI) +
+
+
+
+
+ +
+
+
+ III. + + Capabilities · Skills · Systems + + 4 surfaces / 1 loop + + 003 / 008 +
+
+
+ + + +
OPEN DESIGN  ·  CAPABILITIES MATRIX  ·  OD/26
+
+
+ Capabilities · Nº 03 +

Skills, systems, and surfaces for creative intelligence.

+

We blend human taste with whichever agent you already trust to ship interfaces, decks, and editorial pages that feel intentional, expressive, and alive.

+
+
+
01Skills
+ + + +

Skills,
not plugins

+

31 file-based SKILL.md bundles. Drop a folder in, restart the daemon, it appears.

+ + + +
+
+
02Systems
+ + + +

Design Systems
as Markdown

+

72 portable DESIGN.md systems — Linear, Vercel, Stripe, Apple, Cursor, Figma…

+ + + +
+
+
03Adapters
+ + + +

12 Agent
Adapters

+

Claude · Codex · Gemini · Cursor · Copilot · OpenCode · Devin · Hermes · Pi · Kimi · Kiro · Qwen — auto-detected on $PATH.

+ + + +
+
+
04BYOK
+ + + +

BYOK
at every layer

+

OpenAI-compatible proxy. DeepSeek, Groq, OpenRouter, your self-hosted vLLM — paste a baseUrl + key, ship.

+ + + +
+
+
+
+
+
+ +
+
+
+ IV. + + Labs / Skills Catalog + + 05 of 31 ongoing + + 004 / 008 +
+
+
+ Labs · Nº 04 +

A living archive of experiments in skills, decks, and machine-made form.

+
+
+ + + + + +
+
+
+ 05 +
+ Ongoing experiments + documenting ideas in flux
building intelligence
through making +
+
+
+
+
Deck
+
Nº 012026
+

Magazine Decks

+

Editorial-grade slide decks with guizang-ppt. Magazine layout, WebGL hero.

+ +
+
+
Media
+
Nº 022026
+

Synthetic Matter

+

Gpt-image-2 + Seedance + HyperFrames. Image, video, audio — same chat surface as code.

+ +
+
+
Loop
+
Nº 032026
+

Prompt Choreography

+

The interactive question form pops before a single pixel is improvised. 30s of radios beats 30min of redirects.

+ +
+
+
Critique
+
Nº 042026
+

Visual Reasoning

+

5-dim self-critique gates every artifact: philosophy · hierarchy · execution · specificity · restraint.

+ +
+
+
Runtime
+
Nº 052026
+

Soft Systems

+

Sandboxed iframe preview. Streaming todos. Real-cwd filesystem. Adaptive loops between human and machine.

+ +
+
+
+
+ +
+ 05 / 31 SKILLS  ·  VIEW FULL LIBRARY → +
+
+
+ +
+
+
+ V. + + Method / Loop + + 04 stages, iterative + + 005 / 008 +
+
+
+ Method · Nº 05 +

From signals to systems.

+
+
+ + +

Every stage is iterative, visual, and research-driven — composable files, not opaque prompts.

+
+
+
+
+
01
+

Detect

+

The daemon scans your $PATH for 12 coding agents and auto-loads 31 skills + 72 systems on boot.

+
+
+
+
02
+

Discover

+

Turn 1 is a question form — surface, audience, tone, scale, brand context. Locked in 30 seconds.

+
+
+
+
03
+

Direct

+

Pick one of 5 deterministic visual directions. Palette in OKLch, font stack, layout posture cues.

+
+
+
+
04
+

Deliver

+

The agent writes to disk, you preview in a sandboxed iframe, export HTML / PDF / PPTX / ZIP / Markdown.

+
+
+
+
+
+ + Skills inform everything. Files make it real. +
+ +
+
+
+ +
+ +
+ +
+
+
+ VII. + + Collaborators / Lineage + + Standing on shoulders + + 007 / 008 +
+
+
+ Collaborators · Nº 06 +

“Open Design helped us turn vague AI ideas into a visual system that felt sharp, believable, and genuinely new.”

+
+ m +

Mina Kovac
Creative Director · North Form

+
+
+

Standing on the shoulders of teams shipping open-source design culture.

+ + Read more stories +
+
+ +
+
+
+
+ +
+
+
+ VIII. + + Contact / Conversation + + Three commands to ship + + 008 / 008 +
+
+
+ Start a conversation · Nº 07 +

Let's build something open and visually unforgettable.

+

Star us on GitHub, drop into the issues, or run pnpm tools-dev tonight. Three commands and the loop is yours.

+ +
+ ● Live + v0.2.0 / Apache-2.0 + 52.5200° N · 13.4050° E +
+
+
+ +
Nº 08
+
OPEN DESIGN  ·  FIN.
+
+
+
+
+ + +
+ + + + + + diff --git a/skills/editorial-collage/inputs.example.json b/skills/editorial-collage/inputs.example.json new file mode 100644 index 000000000..2c5fe67be --- /dev/null +++ b/skills/editorial-collage/inputs.example.json @@ -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 Open Design" + } + }, + + "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 pnpm tools-dev, 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
to visual taste, we
prototype the full
stack of creative
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": "OPEN DESIGN  ·  CAPABILITIES MATRIX  ·  OD/26", + "cards": [ + { + "num": "01", + "tag": "Skills", + "icon_svg": "", + "title": "Skills,\nnot plugins", + "body": "31 file-based SKILL.md 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": "", + "title": "Design Systems\nas Markdown", + "body": "72 portable DESIGN.md 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": "", + "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": "", + "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
building intelligence
through making" + }, + "cards": [ + { "badge": "Deck", "num": "Nº 01", "year": "2026", "title": "Magazine Decks", "body": "Editorial-grade slide decks with guizang-ppt. 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  ·  VIEW FULL LIBRARY →" + }, + + "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": "", "name": "huashu-design", "role": "Philosophy", "href": "https://github.com/alchaincyf/huashu-design" }, + { "glyph_svg": "", "name": "guizang-ppt", "role": "Decks", "href": "https://github.com/op7418/guizang-ppt-skill" }, + { "glyph_svg": "", "name": "multica-ai", "role": "Daemon", "href": "https://github.com/multica-ai/multica" }, + { "glyph_svg": "", "name": "open-codesign", "role": "UX", "href": "https://github.com/OpenCoworkAI/open-codesign" }, + { "glyph_svg": "", "name": "Devin CLI", "role": "Terminal", "href": "https://devin.ai/terminal" }, + { "glyph_svg": "", "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 pnpm tools-dev 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 huashu-design, guizang-ppt, multica-ai, and open-codesign.", + "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" + } +} diff --git a/skills/editorial-collage/schema.ts b/skills/editorial-collage/schema.ts new file mode 100644 index 000000000..d8dd8e1eb --- /dev/null +++ b/skills/editorial-collage/schema.ts @@ -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 `` 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 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: `{title}{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 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; +} diff --git a/skills/editorial-collage/scripts/compose.ts b/skills/editorial-collage/scripts/compose.ts new file mode 100644 index 000000000..ac1a3e1b4 --- /dev/null +++ b/skills/editorial-collage/scripts/compose.ts @@ -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} + + + + + +`; +} + +function renderRails(i: EditorialCollageInputs): string { + return ` +
+ ${i.brand.rails.right} +
+
+ ${i.brand.rails.left} +
`; +} + +function renderTopbar(i: EditorialCollageInputs): string { + const langs = i.brand.languages + .map((l, idx) => (idx === 0 ? `${l}` : l)) + .join(' · '); + return ` +
+
+ OD / ${i.brand.year}  ·  ${i.brand.edition} + + Filed under ${i.brand.filed_under} + ${i.brand.license} · Made on Earth + + + ${i.brand.status} + ${langs} + +
+
`; +} + +function renderNav(i: EditorialCollageInputs): string { + const links = i.nav + .map( + (link) => + `
  • ${link.label}${ + link.count ? `${link.count}` : '' + }
  • `, + ) + .join('\n '); + return ` +`; +} + +function renderSecRule(r: SectionRule): string { + return ` +
    + ${r.roman} + + ${r.meta[0]} + ${r.meta[1]} + ${r.meta[2]} + + ${r.pagination} +
    `; +} + +function renderHeroStat(s: HeroStat): string { + const variant = s.variant ?? 'dashed'; + const ringClass = variant === 'solid' ? 'ring solid' : variant === 'coral' ? 'ring coral' : 'ring'; + return `
    + ${s.value} + ${s.label}${s.sub} +
    `; +} + +function renderHeroIndex(item: HeroIndexItem): string { + return `${item.num}${item.label}`; +} + +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 ` +
    +
    +
    + I. + + Hero / Cover Plate + + ${i.brand.name} / Volume 01 + + 001 / 008 +
    +
    +
    +
    + ${i.hero.label} ${i.hero.ix} +

    ${mixed(i.hero.headline)}

    +

    ${i.hero.lead}

    + +
    + ${stats} +
    +
    + ${i.hero.meta} + ${i.brand.coordinates} +
    +
    +
    + + + + + ${i.hero.annotations.tl} + ${i.hero.annotations.tr} + ${i.hero.annotations.bl} + ${i.hero.annotations.br} + +
    + ${index} +
    +
    +
    +
    `; +} + +function renderAbout(i: EditorialCollageInputs): string { + const r = i.rules.about; + const assets = i.imagery.assets_path.replace(/\/?$/, '/'); + return ` +
    +
    + ${renderSecRule(r).trim()} +
    +
    + ${i.about.label} ${i.about.ix} +

    ${mixed(i.about.headline)}

    +

    ${i.about.lead}

    + + ${i.about.cta_label} + ${ARROW_OUT} + + +
    +
    + +
    + + ${i.about.side_note} +
    +
    + ${i.about.caption.bold} + ${i.about.caption.rest} +
    +
    +
    +
    +
    `; +} + +function renderCapabilityCard(c: CapabilityCard): string { + return `
    +
    ${c.num}${c.tag}
    + + ${c.icon_svg} + +

    ${br(c.title)}

    +

    ${c.body}

    + + ${ARROW_OUT} + +
    `; +} + +function renderCapabilities(i: EditorialCollageInputs): string { + const cards = i.capabilities.cards.map(renderCapabilityCard).join('\n '); + const assets = i.imagery.assets_path.replace(/\/?$/, '/'); + return ` +
    +
    + ${renderSecRule(i.rules.capabilities).trim()} +
    +
    + + + +
    ${i.capabilities.ribbon}
    +
    +
    + ${i.capabilities.label} ${i.capabilities.ix} +

    ${mixed(i.capabilities.headline)}

    +

    ${i.capabilities.lead}

    +
    + ${cards} +
    +
    +
    +
    +
    `; +} + +function renderLabPill(p: LabPill): string { + return ``; +} + +function renderLabCard(c: LabCard, n: number, assets: string): string { + return `
    +
    ${c.badge}
    +
    ${c.num}${c.year}
    +

    ${c.title}

    +

    ${c.body}

    + ${ARROW_OUT} +
    `; +} + +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 ? `` : ``, + ).join(''); + return ` +
    +
    + ${renderSecRule(i.rules.labs).trim()} +
    +
    + ${i.labs.label} ${i.labs.ix} +

    ${mixed(i.labs.headline)}

    +
    +
    + ${pills} +
    +
    +
    + ${i.labs.meta.ring} +
    + ${i.labs.meta.bold} + ${i.labs.meta.sub} +
    +
    +
    + ${cards} +
    +
    +
    + ${progress} +
    + ${i.labs.foot} +
    +
    +
    `; +} + +function renderMethodStep(s: MethodStep, last: boolean, n: number, assets: string): string { + return `
    +
    ${s.num}
    +

    ${s.title}${last ? '' : ` `}

    +

    ${s.body}

    +
    +
    `; +} + +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 ` +
    +
    + ${renderSecRule(i.rules.method).trim()} +
    +
    + ${i.method.label} ${i.method.ix} +

    ${mixed(i.method.headline)}

    +
    +
    + + +

    ${i.method.right}

    +
    +
    +
    + ${steps} +
    +
    +
    + + ${i.method.foot_left} +
    +
    ${i.method.foot_right_bold}  ·  ${i.method.foot_right_rest}
    +
    +
    +
    `; +} + +function renderWorkCard(c: WorkCard, idx: number, assets: string, href: string): string { + return ` +
    + ${c.small_label} + ${c.index} +
    +

    ${c.title}

    +

    ${c.body}

    +
    +
    + ${c.year} + ${c.tag} +
    +
    `; +} + +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 ` +
    +
    +
    + ${r.roman} + + ${r.meta[0]} + ${r.meta[1]} + ${r.meta[2]} + + ${r.pagination} +
    +
    +
    + ${i.work.label} +

    ${mixed(i.work.headline)}

    + ${i.work.link_label} +
    + ${cards} +
    +
    + + +
    +
    +
    `; +} + +function renderPartner(p: Partner, href: string): string { + return ` +
    + + ${p.glyph_svg} + +
    + ${p.name} + ${p.role} +
    `; +} + +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 ` +
    +
    + ${renderSecRule(i.rules.testimonial).trim()} +
    +
    + ${i.testimonial.label} ${i.testimonial.ix} +

    “${mixed(i.testimonial.quote)}”

    +
    + ${i.testimonial.author.initial} +

    ${i.testimonial.author.name}
    ${i.testimonial.author.title}

    +
    +
    +

    ${i.testimonial.partners_text}

    +
    + ${partners} +
    + ${i.testimonial.read_more_label} +
    +
    + +
    +
    +
    +
    `; +} + +function renderCTA(i: EditorialCollageInputs): string { + const assets = i.imagery.assets_path.replace(/\/?$/, '/'); + return ` +
    +
    + ${renderSecRule(i.rules.cta).trim()} +
    +
    + ${i.cta.label} ${i.cta.ix} +

    ${mixed(i.cta.headline)}

    +

    ${i.cta.lead}

    + +
    + ● Live + ${i.brand.version} / ${i.brand.license} + ${i.brand.coordinates} +
    +
    +
    + +
    Nº 08
    +
    ${i.cta.ribbon}
    +
    +
    +
    +
    `; +} + +function renderFooterColumn(c: FooterColumn): string { + const links = c.links + .map((l) => `
  • ${l.label}
  • `) + .join('\n '); + return `
    +
    ${c.title}
    +
      + ${links} +
    +
    `; +} + +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 + ? ` + ${brandCta.label}${ + brandCta.meta ? `${brandCta.meta}` : '' + }` + : ''; + return ` +
    +
    +
    +
    + + ${i.brand.mark} + ${i.brand.name} + +

    ${i.footer.brand_description}

    ${brandCtaHtml} +
    + ${cols} +
    +
    + ${i.brand.name} · ${i.brand.license} · ${i.brand.year} / ${i.brand.edition} + + ${i.brand.location} + ${i.brand.coordinates} + ♥ ${i.brand.year_roman} + +
    +
    +
    ${mixed(i.footer.mega)}
    +
    +
    +
    `; +} + +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) => + `·${c.coord}${c.name}`, + ) + .join('\n '); + const contribRow = [...w.contributors, ...w.contributors] + .map( + (c) => + `·@${c.handle}${c.role}`, + ) + .join('\n '); + const subtitle = + w.subtitle ?? + `Open · ${w.cities.length} cities · ${Math.max(w.contributors.length - 1, 0)} contributors`; + return ` +
    +
    +
    + + + ${w.title} + ${subtitle} + +
    +
    +
    + +
    +
    +
    + ${contribRow} +
    +
    +
    +
    +
    `; +} + +/* ------------------------------------------------------------------ * + * inline scripts (mirror apps/landing-page/app/_components/*) + * ------------------------------------------------------------------ */ + +const REVEAL_AND_NAV_SCRIPT = ` +`; + +const STAR_SCRIPT_TEMPLATE = (repo: string) => ` +`; + +/* ------------------------------------------------------------------ * + * 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 [ + ``, + ``, + renderHead(inputs, css), + ``, + renderRails(inputs), + `
    `, + renderTopbar(inputs), + renderNav(inputs), + renderHero(inputs), + renderWire(inputs), + renderAbout(inputs), + renderCapabilities(inputs), + renderLabs(inputs), + renderMethod(inputs), + renderWork(inputs), + renderTestimonial(inputs), + renderCTA(inputs), + renderFooter(inputs), + `
    `, + REVEAL_AND_NAV_SCRIPT, + starScript, + ``, + ``, + ``, + ].join('\n'); +} + +async function main(): Promise { + const [, , inputsArg, outputArg] = process.argv; + if (!inputsArg || !outputArg) { + console.error('Usage: npx tsx scripts/compose.ts '); + 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); + }); +} diff --git a/skills/editorial-collage/scripts/imagegen.ts b/skills/editorial-collage/scripts/imagegen.ts new file mode 100644 index 000000000..71b8da81a --- /dev/null +++ b/skills/editorial-collage/scripts/imagegen.ts @@ -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 [--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 = { + 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 { + // 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(() => ''); + 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; + force: boolean; +} + +function parseArgs(argv: string[]): CliArgs { + const inputsPath = argv[2]; + if (!inputsPath || inputsPath.startsWith('--')) { + throw new Error('Usage: imagegen.ts [--out=assets/] [--only=hero,cta] [--force]'); + } + let outDir = './assets/'; + let only: Set | 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 { + try { + const s = await stat(path); + return s.isFile() && s.size > 256; + } catch { + return false; + } +} + +async function main(): Promise { + 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); + }); +} diff --git a/skills/editorial-collage/scripts/placeholder.ts b/skills/editorial-collage/scripts/placeholder.ts new file mode 100644 index 000000000..1856bccad --- /dev/null +++ b/skills/editorial-collage/scripts/placeholder.ts @@ -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 + * + * 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 = ` + + + `; + + const cornerLen = Math.round(Math.min(w, h) * 0.05); + const corners = ` + + + + + `; + + return ` + + + + + + + + + + + + + ${cross} + ${corners} + + PLATE · ${slot.id.toUpperCase()} + + ${w} × ${h} · ${slot.ratio} + + ${escapeXml(slot.id)} + ${escapeXml(slot.prompt_section.toUpperCase())} + + ${slot.required ? 'REQUIRED' : 'OPTIONAL'} · ${slot.rekey_on_brand_change ? 'REKEY ON BRAND' : 'STABLE'} + OPEN DESIGN · ATELIER ZERO +`; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function loadManifest(): Promise { + const path = resolve(SKILL_ROOT, 'assets', 'image-manifest.json'); + return JSON.parse(await readFile(path, 'utf8')) as Manifest; +} + +/** + * Write `/` for every slot. The composer references + * slots by .png filename; we honor that by writing `.svg` + * AND a `.png.svg` symlink-style fallback. Most static + * hosts serve SVG to 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: + * - `.svg` — clean, editable + * - `` — same SVG content under the .png filename so the + * composer's `` works + * without changing markup. + */ +export async function writePlaceholders(outDir: string): Promise { + 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 { + 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); + }); +} diff --git a/skills/editorial-collage/styles.css b/skills/editorial-collage/styles.css new file mode 100644 index 000000000..277c4f5b2 --- /dev/null +++ b/skills/editorial-collage/styles.css @@ -0,0 +1,1841 @@ +/* + * Atelier Zero — canonical landing-page stylesheet. + * + * This file is the SINGLE SOURCE OF TRUTH for the editorial-collage + * skill's visual system. It is consumed by: + * + * 1. `scripts/compose.ts` — inlined into the standalone HTML output. + * 2. `apps/landing-page/app/globals.css` — copied verbatim for the + * Next.js deployable counterpart. + * 3. `example.html` — the pre-rendered known-good demo. + * + * If you change tokens, layout, motion, or component styles, edit them + * here. The `@import` at top loads the four Google fonts the system + * requires (Inter Tight, Inter, Playfair Display, JetBrains Mono). + * + * Tokens, grid posture, and motion language are defined by + * `design-systems/atelier-zero/DESIGN.md`. Do not invent new colors or + * typefaces here; either extend the design system first. + */ +@import url('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'); + +:root { + --paper: #efe7d2; + --paper-warm: #ece4cf; + --paper-dark: #ddd2b6; + --ink: #15140f; + --ink-soft: #2a2620; + --ink-mute: #5a5448; + --ink-faint: #8b8676; + --coral: #ed6f5c; + --coral-soft: #f08e7c; + --mustard: #e9b94a; + --olive: #6e7448; + --bone: #f7f1de; + --line: rgba(21, 20, 15, 0.16); + --line-soft: rgba(21, 20, 15, 0.08); + --line-faint: rgba(21, 20, 15, 0.05); + --shadow: 0 30px 60px -30px rgba(21, 20, 15, 0.18); + --serif: 'Playfair Display', 'Times New Roman', serif; + --sans: 'Inter Tight', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + --body: 'Inter', -apple-system, system-ui, sans-serif; + --mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { background: var(--paper); color: var(--ink); } +body { + font-family: var(--body); + font-size: 16px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + position: relative; +} + +/* paper texture overlay across the whole page */ +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1; + background-image: + radial-gradient(circle at 12% 18%, rgba(106, 92, 56, 0.07) 0, transparent 28%), + radial-gradient(circle at 88% 72%, rgba(106, 92, 56, 0.06) 0, transparent 32%), + url("data:image/svg+xml;utf8,"); + background-size: auto, auto, 240px 240px; + mix-blend-mode: multiply; + opacity: 0.92; +} + +.shell { position: relative; z-index: 2; } +.container { + max-width: 1360px; + padding: 0 64px; + margin: 0 auto; + position: relative; +} +.container.wide { max-width: 1480px; } + +/* fixed side rails — rotated brand text on the right edge */ +.side-rail { + position: fixed; + top: 0; + bottom: 0; + width: 36px; + z-index: 3; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; +} +.side-rail.right { right: 0; border-left: 1px solid var(--line-faint); } +.side-rail.left { left: 0; border-right: 1px solid var(--line-faint); } +.side-rail .rail-text { + font-family: var(--sans); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.42em; + text-transform: uppercase; + color: var(--ink-faint); + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; +} +.side-rail.right .rail-text { transform: rotate(180deg); } +.side-rail.left .rail-text { writing-mode: vertical-rl; transform: none; } + +/* top metadata strip */ +.topbar { + border-bottom: 1px solid var(--line); + padding: 10px 0; + background: var(--paper); + position: relative; + z-index: 4; +} +.topbar-inner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); +} +.topbar-inner b { color: var(--ink); font-weight: 600; } +.topbar-inner .coral { color: var(--coral); } +.topbar-inner > span { white-space: nowrap; } +.topbar-inner .mid { display: inline-flex; gap: 26px; } +.topbar-inner .mid > span { white-space: nowrap; } +.topbar-inner .right { display: inline-flex; gap: 18px; align-items: center; } +.topbar-inner .right > span, +.topbar-inner .right > a { white-space: nowrap; } +.topbar-link { + color: inherit; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: color 160ms ease, border-color 160ms ease; +} +.topbar-link:hover { color: var(--coral); border-bottom-color: var(--coral); } +.topbar .pulse { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--coral); + display: inline-block; + margin-right: 6px; + animation: pulse 2.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* nav */ +/* + * Headroom-style sticky header. + * + * The element is always `position: sticky`, so the browser docks it to the + * top of the viewport once the topbar has scrolled away. The + * `
    ` client island then toggles the `is-hidden` modifier based + * on scroll direction, which animates the bar in and out via `transform`. + * + * When the user is at the very top of the page, the topbar is still + * visible above the nav and `position: sticky` simply leaves the nav in + * its natural flow position — exactly the brief. + */ +.nav { + padding: 22px 0; + position: sticky; + top: 0; + z-index: 50; + background: var(--paper); + transform: translateY(0); + transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1), + box-shadow 220ms ease, + border-color 220ms ease; + border-bottom: 1px solid transparent; + will-change: transform; +} +/* + * Subtle visual cue once we leave the top of the page. We can't tell from + * CSS alone whether the bar is "stuck"; the deadband + class toggle in + *
    approximates it well enough for our purpose. We rely on the + * `is-hidden` toggle to flicker the border while moving and a steady + * border once docked. + */ +.nav.is-hidden { + transform: translateY(-100%); + pointer-events: none; + box-shadow: none; +} +.nav-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.brand { + display: inline-flex; + align-items: center; + gap: 14px; + font-family: var(--sans); + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + text-decoration: none; + font-size: 18px; +} +.brand-mark { + width: 36px; height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1.5px solid var(--ink); + border-radius: 50%; + font-family: var(--serif); + font-style: italic; + font-size: 17px; + color: var(--ink); + background: transparent; +} +.brand-meta { + font-family: var(--sans); + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); + line-height: 1.3; + margin-left: 4px; + border-left: 1px solid var(--line); + padding-left: 14px; +} +.brand-meta b { display: block; color: var(--ink); font-weight: 600; } + +.nav-links { + display: flex; + gap: 38px; + list-style: none; +} +.nav-links a { + color: var(--ink); + text-decoration: none; + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + transition: color 0.18s ease; + position: relative; +} +.nav-links a:hover { color: var(--coral); } +.nav-links a .num { + font-size: 9px; + color: var(--ink-faint); + position: absolute; + top: -7px; + right: -16px; + letter-spacing: 0.04em; +} +.nav-side { + display: inline-flex; + align-items: center; + gap: 18px; +} +.nav-cta { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + border-radius: 999px; + background: var(--ink); + color: var(--paper); + font-family: var(--sans); + font-size: 13px; + font-weight: 500; + text-decoration: none; +} +.nav-cta::after { + content: '★'; + color: var(--mustard); + font-size: 11px; +} +.status-dot { + width: 28px; height: 28px; + border-radius: 50%; + border: 1px solid var(--line); + display: inline-flex; + align-items: center; + justify-content: center; +} +.status-dot::after { + content: ''; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--coral); +} + +/* ---------- typography primitives ---------- */ +.label { + 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; +} +.label::before { + content: ''; + width: 18px; + height: 1px; + background: var(--coral); + display: inline-block; +} +.label .ix { + color: var(--ink-faint); + font-weight: 500; + margin-left: 4px; +} +.display { + font-family: var(--sans); + font-weight: 800; + letter-spacing: -0.028em; + color: var(--ink); + line-height: 1.0; +} +.display em { + font-family: var(--serif); + font-style: italic; + font-weight: 500; + letter-spacing: -0.018em; +} +.display .dot { color: var(--coral); } +.lead { + font-family: var(--body); + font-size: 16px; + line-height: 1.55; + color: var(--ink-soft); + max-width: 36ch; +} +.meta { + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); +} +.coord { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.04em; + color: var(--ink-faint); +} +.roman { + font-family: var(--serif); + font-style: italic; + font-weight: 500; + color: var(--coral); +} + +/* buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 14px 22px; + border-radius: 999px; + font-family: var(--sans); + font-size: 14px; + font-weight: 500; + letter-spacing: -0.005em; + text-decoration: none; + border: 1px solid transparent; + transition: transform 0.18s ease, background 0.18s ease, color 0.18s ease; + cursor: pointer; + white-space: nowrap; +} +.btn-primary { + background: var(--coral); + color: #fff; + box-shadow: 0 14px 26px -16px rgba(237, 111, 92, 1); +} +.btn-primary:hover { transform: translateY(-1px); background: #e25e4a; } +.btn-ghost { + background: transparent; + color: var(--ink); + border-color: rgba(21, 20, 15, 0.2); +} +.btn-ghost:hover { background: rgba(21, 20, 15, 0.04); } +.btn .arrow { + width: 16px; height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.btn .arrow svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.6; } + +/* helper used inline in headlines */ +.code-inline { + font-family: var(--mono); + font-size: 14px; + background: var(--bone); + padding: 1px 6px; + border-radius: 4px; +} + +/* ---------- HERO ---------- */ +.hero { + position: relative; + padding: 0; + min-height: calc(100vh - 140px); + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--line); +} +.hero::before { + content: ''; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: var(--line-soft); + z-index: 0; + display: none; +} +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 0.78fr) minmax(0, 1.22fr); + gap: 36px; + align-items: stretch; + width: 100%; + position: relative; +} +.hero-copy { + padding: 4vh 0 4vh; + display: flex; + flex-direction: column; + position: relative; +} +.hero-copy .label { margin-bottom: 28px; } +.hero-copy .lead { margin-bottom: 30px; max-width: 38ch; font-size: 16px; } +.hero h1 { + font-size: clamp(44px, 5vw, 78px); + line-height: 1.0; + margin-bottom: 28px; +} +.hero-actions { + display: inline-flex; + align-items: center; + gap: 14px; + margin-bottom: 38px; +} +.hero-stats { + display: flex; + align-items: center; + gap: 22px; + flex-wrap: nowrap; + margin-bottom: 28px; +} +.hero-stats .stat { display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; } +.hero-stats .stat .ring { + width: 34px; height: 34px; + border-radius: 50%; + border: 1px dashed var(--ink); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--sans); + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} +.hero-stats .stat .ring.solid { border-style: solid; } +.hero-stats .stat .ring.coral { border-color: var(--coral); color: var(--coral); } +.hero-stats .stat-label { + font-family: var(--sans); + font-size: 11px; + line-height: 1.25; + color: var(--ink-soft); + letter-spacing: 0.04em; + text-transform: uppercase; +} +.hero-stats .stat-label b { display: block; font-weight: 700; color: var(--ink); font-size: 12px; } + +.hero-foot { + margin-top: auto; + padding-top: 22px; + border-top: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.hero-foot .meta { line-height: 1.4; } + +.hero-art { + position: relative; + height: calc(100vh - 160px); + max-height: 860px; + margin-left: auto; + margin-right: -12px; + width: 100%; + overflow: visible; +} +.hero-art img { + width: 100%; height: 100%; + object-fit: contain; + object-position: right center; + display: block; +} +/* image annotations */ +.annot { + position: absolute; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); + line-height: 1.4; + white-space: nowrap; +} +.annot.has-line::before { + content: ''; + position: absolute; + background: var(--ink-faint); +} +.annot-tl { top: 14px; left: 14px; } +.annot-tr { top: 14px; right: 14px; text-align: right; } +.annot-bl { bottom: 14px; left: 14px; } +.annot-br { bottom: 14px; right: 14px; text-align: right; } +.annot.coord { font-family: var(--mono); font-size: 10px; letter-spacing: 0.04em; text-transform: none; } + +.hero-art .index { + position: absolute; + right: 12px; + top: 36%; + font-family: var(--sans); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.16em; + color: var(--ink-faint); + text-transform: uppercase; + background: rgba(239, 231, 210, 0.7); + padding: 10px 12px; + border: 1px solid var(--line-soft); + border-radius: 6px; + backdrop-filter: blur(2px); +} +.hero-art .index span { display: block; line-height: 1.6; } +.hero-art .index span .n { color: var(--coral); margin-right: 6px; font-weight: 700; } +.hero-art .index span.on { color: var(--ink); font-weight: 700; } +.hero-art .index span.on .n { color: var(--coral); } + +.hero-art .corner { + position: absolute; + width: 22px; height: 22px; + border-color: var(--ink-faint); + border-style: solid; + border-width: 0; +} +.hero-art .corner.tl { top: 0; left: 0; border-top-width: 1px; border-left-width: 1px; } +.hero-art .corner.tr { top: 0; right: 0; border-top-width: 1px; border-right-width: 1px; } +.hero-art .corner.bl { bottom: 0; left: 0; border-bottom-width: 1px; border-left-width: 1px; } +.hero-art .corner.br { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; } + +/* ---------- common section header ---------- */ +section { position: relative; padding: 130px 0; } +section.tight { padding: 90px 0; } +.sec-rule { + border-top: 1px solid var(--line); + padding-top: 18px; + margin-bottom: 48px; + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); +} +.sec-rule .roman { + font-family: var(--serif); + font-style: italic; + color: var(--coral); + font-size: 14px; + letter-spacing: 0.05em; + text-transform: none; +} +.sec-rule .meta-grp { display: inline-flex; gap: 26px; } +.sec-rule .dot-mark { color: var(--coral); } + +.section-header { margin-bottom: 70px; } +.section-header .label { margin-bottom: 32px; } +.section-header h2 { + font-size: clamp(40px, 4.6vw, 66px); + max-width: 22ch; +} +.section-header .lead { margin-top: 22px; } + +/* ---------- WIRE / GLOBAL TICKER ---------- + * + * Slim editorial strip between the hero and the About section. Two + * counter-scrolling marquees (cities → and contributors ←) signal that + * the project is global and community-driven, without disrupting the + * existing roman-numeral section count. Pure CSS animation; the track + * content is duplicated in markup so the loop wraps seamlessly. + */ +.wire { + border-bottom: 1px solid var(--line); + padding: 26px 0 28px; + background: var(--paper); + position: relative; + overflow: hidden; +} +.wire-inner { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 32px; + align-items: center; +} +.wire-left { + display: inline-flex; + align-items: center; + gap: 14px; + border-right: 1px solid var(--line); + padding-right: 24px; + min-height: 56px; +} +.wire-mark { + width: 22px; + height: 22px; + border-radius: 50%; + border: 1px solid var(--line); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.wire-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--coral); + display: inline-block; + animation: pulse 2.4s ease-in-out infinite; +} +.wire-title { + font-family: var(--sans); + font-size: 11px; + line-height: 1.4; + display: flex; + flex-direction: column; + gap: 3px; +} +.wire-title b { + color: var(--ink); + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} +.wire-title span { + color: var(--ink-faint); + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.wire-rows { + display: grid; + gap: 8px; + min-width: 0; +} +.wire-row { + overflow: hidden; + mask-image: linear-gradient(90deg, transparent, black 5%, black 95%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, black 5%, black 95%, transparent); +} +.marquee-track { + display: inline-flex; + align-items: center; + gap: 36px; + width: max-content; + white-space: nowrap; + animation: marquee-x 52s linear infinite; + will-change: transform; +} +.wire-row.reverse .marquee-track { + animation-direction: reverse; + animation-duration: 64s; +} +.wire-row:hover .marquee-track { + animation-play-state: paused; +} +@keyframes marquee-x { + from { transform: translateX(0); } + to { transform: translateX(-50%); } +} +.wire-item { + display: inline-flex; + align-items: baseline; + gap: 8px; + font-family: var(--sans); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--ink-mute); + text-decoration: none; + flex-shrink: 0; +} +.wire-item .wire-dot { + color: var(--coral); + font-size: 16px; + line-height: 0; + position: relative; + top: -1px; + margin-right: 2px; +} +.wire-item .wire-coord { + font-family: var(--mono); + font-size: 10.5px; + color: var(--ink-faint); + letter-spacing: 0; +} +.wire-item .wire-name { + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--ink); + font-weight: 500; +} +.wire-item .wire-handle { + font-family: var(--mono); + color: var(--ink); + font-size: 11.5px; + font-weight: 500; +} +.wire-item .wire-role { + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--coral); + font-size: 10px; +} +.wire-item.is-link { + transition: color 160ms ease; +} +.wire-item.is-link:hover .wire-handle { + color: var(--coral); +} +@media (prefers-reduced-motion: reduce) { + .marquee-track { animation: none; } +} + +/* ---------- ABOUT ---------- */ +.about-grid { + display: grid; + grid-template-columns: 1.05fr 1fr; + gap: 80px; + align-items: center; +} +.about h2 { + font-size: clamp(44px, 5.4vw, 78px); + margin: 30px 0 36px; +} +.about .label { margin-bottom: 28px; } +.about .lead { margin-bottom: 36px; max-width: 42ch; font-size: 17px; } +.about .footer-row { + display: flex; + align-items: center; + gap: 20px; + margin-top: 56px; + color: var(--ink-faint); + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; +} +.about .footer-row .mark { + width: 30px; height: 30px; + 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: 14px; + color: var(--ink); +} +.about .stamp { + margin-left: auto; + display: inline-flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.4; +} +.about .stamp span:first-child { color: var(--coral); } +.about-art { + position: relative; + aspect-ratio: 1 / 1; + max-width: 620px; + margin-left: auto; +} +.about-art img { width: 100%; height: 100%; object-fit: contain; } +.about-side-note { + position: absolute; + right: -8px; + top: 26px; + text-align: right; + font-family: var(--sans); + font-size: 10.5px; + line-height: 1.55; + color: var(--ink-faint); + letter-spacing: 0.04em; + max-width: 16ch; +} +.about-side-note b { + display: block; + color: var(--coral); + width: 36px; + height: 1px; + background: var(--coral); + margin: 0 0 10px auto; +} +.about-caption { + position: absolute; + right: 18px; + bottom: 4px; + font-family: var(--sans); + font-size: 9.5px; + color: var(--ink-faint); + text-align: right; + letter-spacing: 0.06em; + line-height: 1.45; +} +.about-caption b { color: var(--ink); display: block; } + +/* ---------- CAPABILITIES ---------- */ +.capabilities-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 70px; + align-items: center; +} +.capabilities-art { + position: relative; + aspect-ratio: 1 / 1; + max-width: 600px; +} +.capabilities-art img { width: 100%; height: 100%; object-fit: contain; } +.capabilities-art .ribbon { + position: absolute; + right: -42px; + top: 50%; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.42em; + text-transform: uppercase; + color: var(--ink-faint); + writing-mode: vertical-rl; + transform: rotate(180deg); +} +.capabilities-art .ribbon b { color: var(--coral); } +.capabilities-art .corner { position: absolute; width: 22px; height: 22px; border-color: var(--ink-faint); border-style: solid; border-width: 0; } +.capabilities-art .corner.tl { top: 0; left: 0; border-top-width: 1px; border-left-width: 1px; } +.capabilities-art .corner.br { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; } +.capabilities-copy h2 { font-size: clamp(40px, 4.8vw, 64px); margin: 22px 0 30px; } +.cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-top: 22px; +} +.card { + padding: 28px 26px 32px; + background: var(--bone); + border-radius: 18px; + box-shadow: var(--shadow), inset 0 0 0 1px rgba(21, 20, 15, 0.06); + position: relative; + overflow: hidden; + transition: transform 0.2s ease; +} +.card:hover { transform: translateY(-3px); } +.card .num { + font-family: var(--serif); + font-style: italic; + font-size: 22px; + font-weight: 500; + color: var(--coral); + letter-spacing: 0.04em; + margin-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: baseline; +} +.card .num .tag { + font-family: var(--sans); + font-size: 9.5px; + color: var(--ink-faint); + letter-spacing: 0.18em; + text-transform: uppercase; + font-style: normal; + font-weight: 500; +} +.card .icon { + width: 28px; + height: 28px; + margin-bottom: 16px; + color: var(--ink); +} +.card h3 { + font-family: var(--sans); + font-size: 22px; + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.014em; + margin-bottom: 14px; +} +.card p { + font-family: var(--body); + font-size: 13.5px; + color: var(--ink-mute); + line-height: 1.55; + max-width: 24ch; +} +.card .arrow-mark { + position: absolute; + right: 22px; + bottom: 22px; + width: 28px; height: 28px; + border: 1px solid var(--line); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ink); + transition: all 0.18s ease; +} +.card:hover .arrow-mark { background: var(--coral); border-color: var(--coral); color: #fff; } +.card .arrow-mark svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 1.6; } + +/* ---------- LABS ---------- */ +.labs-head { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 60px; + align-items: end; + margin-bottom: 48px; +} +.labs-head h2 { font-size: clamp(40px, 4.8vw, 68px); } +.pills { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} +.pill { + padding: 9px 18px; + border-radius: 999px; + border: 1px solid var(--line); + font-family: var(--sans); + font-size: 13px; + color: var(--ink-soft); + background: transparent; + cursor: pointer; + transition: all 0.18s ease; + display: inline-flex; + align-items: center; + gap: 8px; +} +.pill:hover { background: rgba(21, 20, 15, 0.04); } +.pill.active { + background: var(--coral); + border-color: var(--coral); + color: #fff; +} +.pill .count { + font-size: 10px; + color: var(--ink-faint); + border-left: 1px solid var(--line); + padding-left: 8px; +} +.pill.active .count { color: rgba(255,255,255,0.7); border-color: rgba(255,255,255,0.3); } +.labs-meta { + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 22px; + margin-bottom: 30px; +} +.labs-meta .ring { + width: 38px; height: 38px; + border-radius: 50%; + border: 1px dashed var(--ink); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: var(--sans); + font-size: 11px; + font-weight: 700; +} +.labs-meta .meta-text { + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + line-height: 1.55; + color: var(--ink-faint); + max-width: 28ch; +} +.labs-meta .meta-text b { display: block; color: var(--ink); } +.labs-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 22px; +} +.lab { + display: flex; + flex-direction: column; +} +.lab-img { + aspect-ratio: 4 / 5; + background: var(--bone); + border-radius: 14px; + overflow: hidden; + margin-bottom: 18px; + box-shadow: var(--shadow); + position: relative; +} +.lab-img img { width: 100%; height: 100%; object-fit: cover; } +.lab-img .badge { + position: absolute; + top: 12px; + left: 12px; + background: rgba(239, 231, 210, 0.9); + color: var(--ink); + padding: 4px 9px; + border-radius: 4px; + font-family: var(--sans); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} +.lab .num-row { + font-family: var(--sans); + font-size: 10.5px; + color: var(--ink-faint); + letter-spacing: 0.14em; + margin-bottom: 8px; + display: flex; + justify-content: space-between; + text-transform: uppercase; +} +.lab h4 { + font-family: var(--sans); + font-size: 18px; + font-weight: 700; + letter-spacing: -0.014em; + margin-bottom: 8px; +} +.lab p { + font-family: var(--body); + font-size: 13px; + color: var(--ink-mute); + line-height: 1.55; + margin-bottom: 14px; +} +.lab .arrow-mark { + width: 28px; height: 28px; + border: 1px solid var(--line); + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ink); + margin-top: auto; + align-self: flex-start; +} +.lab .arrow-mark svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 1.6; } +.labs-foot { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 50px; + border-top: 1px dashed var(--line); + padding-top: 22px; +} +.progress { + display: flex; + align-items: center; + gap: 8px; +} +.progress span { + width: 26px; height: 2px; + background: var(--line); + border-radius: 2px; +} +.progress span.on { background: var(--coral); } + +/* ---------- METHOD ---------- */ +.method-head { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 60px; + align-items: start; + margin-bottom: 80px; +} +.method-head h2 { font-size: clamp(44px, 5.2vw, 76px); } +.method-head .right { + display: flex; + align-items: flex-start; + gap: 14px; + padding-top: 14px; +} +.method-head .plus { + color: var(--coral); + font-size: 24px; + line-height: 1; + font-family: var(--sans); +} +.method-head .right p { + font-family: var(--sans); + font-size: 13px; + color: var(--ink-soft); + max-width: 22ch; + line-height: 1.55; +} +.method-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 50px; + position: relative; +} +.method-grid::before { + content: ''; + position: absolute; + top: 60px; + left: 50px; + right: 50px; + height: 1px; + background: var(--line-soft); +} +.method-step { position: relative; } +.method-step .num { + font-family: var(--serif); + font-style: italic; + font-weight: 500; + font-size: 78px; + color: var(--coral); + line-height: 0.85; + margin-bottom: 24px; + letter-spacing: -0.02em; + background: var(--paper); + display: inline-block; + padding-right: 12px; + position: relative; + z-index: 1; +} +.method-step h4 { + font-family: var(--sans); + font-size: 30px; + font-weight: 800; + letter-spacing: -0.022em; + margin-bottom: 18px; + display: flex; + align-items: center; + justify-content: space-between; + padding-right: 18px; +} +.method-step h4 .arrow-r { + color: var(--ink-faint); + font-size: 22px; + line-height: 1; +} +.method-step:last-child h4 .arrow-r { display: none; } +.method-step p { + font-family: var(--body); + font-size: 13.5px; + color: var(--ink-mute); + line-height: 1.55; + margin-bottom: 24px; + max-width: 24ch; +} +.method-step .img { + aspect-ratio: 1 / 1; + background: var(--bone); + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow); +} +.method-step .img img { width: 100%; height: 100%; object-fit: cover; } +.method-foot { + margin-top: 80px; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px dashed var(--line); + padding-top: 24px; +} +.method-foot .left, +.method-foot .right { + font-family: var(--sans); + font-size: 11px; + color: var(--ink-faint); + letter-spacing: 0.18em; + text-transform: uppercase; +} +.method-foot .left { + display: inline-flex; + align-items: center; + gap: 12px; +} +.method-foot .left .ring { + width: 20px; height: 20px; + border: 1px dashed var(--ink-faint); + border-radius: 50%; +} +.method-foot .right b { color: var(--ink); } + +/* ---------- WORK ---------- */ +.work { + background: #15140f; + color: var(--paper); + border-radius: 32px; + margin: 0 64px; + overflow: hidden; + position: relative; + padding: 110px 64px; +} +.work::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + url("data:image/svg+xml;utf8,"); + background-size: 240px 240px; + opacity: 0.6; + mix-blend-mode: screen; +} +.work-rule { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid rgba(247, 241, 222, 0.16); + padding-top: 16px; + margin-bottom: 60px; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: rgba(247, 241, 222, 0.55); +} +.work-rule .roman { color: var(--coral); font-family: var(--serif); font-style: italic; font-size: 14px; letter-spacing: 0.04em; text-transform: none; } +.work-grid { + display: grid; + grid-template-columns: 1fr 1.05fr 0.85fr; + gap: 48px; + align-items: center; + position: relative; +} +.work .label { color: var(--coral); } +.work .label::before { background: var(--coral); } +.work-copy h2 { + font-family: var(--sans); + font-weight: 800; + font-size: clamp(40px, 5vw, 66px); + line-height: 1.0; + letter-spacing: -0.024em; + margin: 28px 0 36px; + color: var(--paper); +} +.work-copy h2 em { + font-family: var(--serif); + font-style: italic; + font-weight: 500; +} +.work-copy h2 .dot { color: var(--coral); } +.work-link { + display: inline-flex; + align-items: center; + gap: 18px; + color: var(--paper); + font-family: var(--sans); + font-size: 14px; + text-decoration: none; + border-bottom: 2px solid var(--coral); + padding-bottom: 12px; + width: fit-content; +} +.work-link::after { content: '↗'; color: var(--coral); } +.work-card { + background: var(--paper); + color: var(--ink); + border-radius: 18px; + padding: 32px 30px; + position: relative; + transform: rotate(-1.2deg); + text-decoration: none; + display: block; + transition: transform 280ms ease, box-shadow 280ms ease; +} +.work-card:hover { + transform: rotate(-1.2deg) translateY(-4px); + box-shadow: var(--shadow); +} +.work-card.alt { + transform: rotate(2.4deg) translateY(20px); + padding: 28px 26px; +} +.work-card.alt:hover { + transform: rotate(2.4deg) translateY(16px); + box-shadow: var(--shadow); +} +.work-card .label-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 22px; +} +.work-card .small-label { + font-family: var(--sans); + font-size: 10.5px; + color: var(--coral); + letter-spacing: 0.18em; + text-transform: uppercase; + font-weight: 600; +} +.work-card .index { + font-family: var(--mono); + font-size: 11px; + color: var(--ink-faint); + letter-spacing: 0.04em; +} +.work-card h3 { + font-family: var(--sans); + font-size: clamp(26px, 2.4vw, 38px); + font-weight: 800; + letter-spacing: -0.022em; + line-height: 1.05; + margin-bottom: 14px; +} +.work-card p { + font-family: var(--body); + font-size: 14px; + color: var(--ink-mute); + line-height: 1.55; + margin-bottom: 22px; + max-width: 28ch; +} +.work-card .img { + aspect-ratio: 4 / 3; + background: var(--bone); + border-radius: 12px; + overflow: hidden; + margin-bottom: 22px; +} +.work-card .img img { width: 100%; height: 100%; object-fit: cover; } +.work-card .meta-row { + display: flex; + justify-content: space-between; + color: var(--ink-faint); + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + border-top: 1px solid var(--line); + padding-top: 14px; +} +.work-card .year { color: var(--coral); font-weight: 600; } +.work-arrows { + position: absolute; + right: 64px; + bottom: 64px; + display: inline-flex; + align-items: center; + gap: 10px; +} +.work-arrows .nav-btn { + width: 46px; height: 46px; + border-radius: 50%; + border: 1px solid rgba(247, 241, 222, 0.2); + background: transparent; + color: var(--paper); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.work-arrows .nav-btn.active { background: var(--coral); border-color: var(--coral); } + +/* ---------- TESTIMONIAL / COLLABORATORS ---------- */ +.testimonial-grid { + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 80px; + align-items: center; +} +.testimonial-copy h2 { + font-family: var(--sans); + font-size: clamp(36px, 4vw, 54px); + font-weight: 700; + letter-spacing: -0.022em; + line-height: 1.12; + margin-bottom: 36px; +} +.testimonial-copy h2 em { + font-family: var(--serif); + font-style: italic; + font-weight: 500; +} +.author { + display: flex; + align-items: center; + gap: 18px; + margin-top: 22px; +} +.author .avatar { + width: 50px; height: 50px; + border-radius: 50%; + background: var(--ink); + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--paper); + font-family: var(--serif); + font-style: italic; + font-size: 24px; +} +.author p { + font-family: var(--sans); + font-size: 14px; + color: var(--ink); + font-weight: 600; +} +.author p span { + display: block; + color: var(--ink-mute); + font-weight: 400; +} +.divider { + border-top: 1px solid var(--line); + margin: 60px 0 32px; +} +.partners-text { + font-family: var(--body); + font-size: 14px; + color: var(--ink-mute); + margin-bottom: 26px; + max-width: 38ch; +} +.partners { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 22px; + align-items: end; +} +.partner { + display: flex; + flex-direction: column; + gap: 10px; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: transform 220ms ease; +} +.partner:hover { transform: translateY(-2px); } +.partner:hover .glyph { color: var(--coral); } +.partner:hover span { color: var(--coral); } +.partner .glyph { + height: 32px; + display: flex; + align-items: center; + color: var(--ink); + transition: color 220ms ease; +} +.partner .glyph svg { height: 100%; width: auto; max-width: 90px; } +.partner span { + font-family: var(--sans); + font-size: 13px; + color: var(--ink); + letter-spacing: -0.005em; + font-weight: 600; + transition: color 220ms ease; +} +.partner small { + font-family: var(--sans); + font-size: 10px; + color: var(--ink-faint); + letter-spacing: 0.1em; + text-transform: uppercase; +} +.read-more { + margin-top: 56px; + display: inline-flex; + align-items: center; + gap: 10px; + font-family: var(--sans); + font-size: 13px; + color: var(--ink); + text-decoration: none; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--coral); + padding-bottom: 6px; +} +.read-more::after { content: '→'; color: var(--coral); } +.testimonial-art { + position: relative; + aspect-ratio: 1 / 1; + max-width: 560px; +} +.testimonial-art img { width: 100%; height: 100%; object-fit: contain; } + +/* ---------- CTA ---------- */ +.cta-grid { + display: grid; + grid-template-columns: 1.05fr 1fr; + gap: 50px; + align-items: center; +} +.cta h2 { + font-size: clamp(54px, 6.6vw, 100px); + margin: 32px 0 32px; +} +.cta .lead { margin-bottom: 36px; max-width: 36ch; font-size: 16px; } +.cta-actions { + display: inline-flex; + align-items: center; + gap: 14px; + margin-bottom: 32px; +} +.email-pill { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 14px 18px 14px 22px; + border-radius: 999px; + border: 1px solid var(--line); + font-family: var(--sans); + font-size: 14px; + color: var(--ink); + text-decoration: none; +} +.email-pill .arrow-circle { + width: 22px; height: 22px; + border-radius: 50%; + background: var(--ink); + color: var(--paper); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; +} +.cta-foot { + display: flex; + gap: 28px; + align-items: center; + margin-top: 32px; + padding-top: 22px; + border-top: 1px solid var(--line); + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-faint); +} +.cta-foot .stamp { color: var(--coral); font-weight: 600; } +.cta-art { + position: relative; + aspect-ratio: 1 / 1; + max-width: 620px; + margin-left: auto; +} +.cta-art img { width: 100%; height: 100%; object-fit: contain; } +.cta-art .index { + position: absolute; + right: 8px; + top: 24px; + font-family: var(--serif); + font-style: italic; + font-size: 28px; + color: var(--ink-faint); +} +.cta-art .ribbon { + position: absolute; + left: -32px; + top: 50%; + font-family: var(--sans); + font-size: 10.5px; + letter-spacing: 0.42em; + text-transform: uppercase; + color: var(--ink-faint); + writing-mode: vertical-rl; + transform: rotate(180deg); +} + +/* ---------- FOOTER ---------- */ +footer { + border-top: 1px solid var(--line); + padding: 60px 0 30px; + margin-top: 60px; +} +.foot-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 1fr; + gap: 40px; + margin-bottom: 60px; +} +.foot-brand .brand { margin-bottom: 18px; } +.foot-brand p { + font-family: var(--body); + font-size: 13.5px; + color: var(--ink-mute); + line-height: 1.55; + max-width: 38ch; +} +.foot-brand p .inline-link, +.inline-link { + color: var(--ink); + text-decoration: none; + border-bottom: 1px solid var(--line); + transition: color 160ms ease, border-color 160ms ease; +} +.inline-link:hover { + color: var(--coral); + border-bottom-color: var(--coral); +} +.method-repo-link { + color: inherit; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: color 160ms ease, border-color 160ms ease; +} +.method-repo-link:hover { + color: var(--coral); + border-bottom-color: var(--coral); +} +.library-link { + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 160ms ease; +} +.library-link:hover { border-bottom-color: var(--coral); } +.foot-col h5 { + font-family: var(--sans); + font-size: 11px; + color: var(--ink); + letter-spacing: 0.18em; + text-transform: uppercase; + margin-bottom: 18px; + font-weight: 700; +} +.foot-col ul { list-style: none; } +.foot-col li { margin-bottom: 10px; } +.foot-col a { + font-family: var(--body); + font-size: 13.5px; + color: var(--ink-soft); + text-decoration: none; +} +.foot-col a:hover { color: var(--coral); } +.foot-bottom { + border-top: 1px solid var(--line); + padding-top: 22px; + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--sans); + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--ink-faint); +} +.foot-bottom .right { display: inline-flex; gap: 24px; align-items: center; } +.foot-bottom .pulse { + width: 6px; height: 6px; + border-radius: 50%; + background: var(--coral); + display: inline-block; + margin-right: 6px; + vertical-align: middle; +} +.foot-mega { + margin-top: 60px; + padding-top: 0; + padding-bottom: 12px; + border-top: 1px solid var(--line); + overflow-x: hidden; + overflow-y: visible; +} +.foot-mega .word { + font-family: var(--sans); + font-weight: 900; + font-size: clamp(70px, 13vw, 200px); + letter-spacing: -0.04em; + line-height: 1.05; + color: var(--ink); + white-space: nowrap; + margin-top: 30px; + padding-bottom: 0.18em; +} +.foot-mega .word em { + font-family: var(--serif); + font-style: italic; + font-weight: 500; + color: var(--coral); +} + +/* ---------- scroll-reveal motion ---------- + * + * Driven by `app/_components/reveal-root.tsx`. Elements with + * `data-reveal` start hidden + offset; the observer sets + * `data-revealed='true'` once they enter the viewport, triggering + * the transition. + * + * Uses `translate` / `scale` longhand properties (not `transform`) so + * that elements like `.work-card` keep their static `transform: rotate()` + * intact while still translating in. + */ +[data-reveal] { + opacity: 0; + translate: 0 28px; + transition: + opacity 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms), + translate 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms), + scale 900ms cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0ms); + will-change: opacity, translate, scale; +} +[data-reveal='left'] { translate: -36px 0; } +[data-reveal='right'] { translate: 36px 0; } +[data-reveal='scale'] { translate: 0 0; scale: 0.96; } +[data-reveal='rise-lg'] { translate: 0 64px; scale: 0.985; } +[data-reveal][data-revealed='true'] { + opacity: 1; + translate: 0 0; + scale: 1; +} + +/* stagger primitives — set --reveal-delay on grid children so siblings + * appear in sequence rather than all at once. */ +.cards > .card[data-reveal]:nth-child(1) { --reveal-delay: 0ms; } +.cards > .card[data-reveal]:nth-child(2) { --reveal-delay: 90ms; } +.cards > .card[data-reveal]:nth-child(3) { --reveal-delay: 180ms; } +.cards > .card[data-reveal]:nth-child(4) { --reveal-delay: 270ms; } + +.labs-grid > .lab[data-reveal]:nth-child(1) { --reveal-delay: 0ms; } +.labs-grid > .lab[data-reveal]:nth-child(2) { --reveal-delay: 90ms; } +.labs-grid > .lab[data-reveal]:nth-child(3) { --reveal-delay: 180ms; } +.labs-grid > .lab[data-reveal]:nth-child(4) { --reveal-delay: 270ms; } +.labs-grid > .lab[data-reveal]:nth-child(5) { --reveal-delay: 360ms; } + +.method-grid > .method-step[data-reveal]:nth-child(1) { --reveal-delay: 0ms; } +.method-grid > .method-step[data-reveal]:nth-child(2) { --reveal-delay: 110ms; } +.method-grid > .method-step[data-reveal]:nth-child(3) { --reveal-delay: 220ms; } +.method-grid > .method-step[data-reveal]:nth-child(4) { --reveal-delay: 330ms; } + +.partners > .partner[data-reveal]:nth-child(1) { --reveal-delay: 0ms; } +.partners > .partner[data-reveal]:nth-child(2) { --reveal-delay: 70ms; } +.partners > .partner[data-reveal]:nth-child(3) { --reveal-delay: 140ms; } +.partners > .partner[data-reveal]:nth-child(4) { --reveal-delay: 210ms; } +.partners > .partner[data-reveal]:nth-child(5) { --reveal-delay: 280ms; } +.partners > .partner[data-reveal]:nth-child(6) { --reveal-delay: 350ms; } + +/* hero copy — let label, headline, lead, actions, stats arrive in sequence + * so the headline isn't waiting on a single block-level reveal. */ +.hero-copy > [data-reveal]:nth-of-type(1) { --reveal-delay: 0ms; } +.hero-copy > [data-reveal]:nth-of-type(2) { --reveal-delay: 80ms; } +.hero-copy > [data-reveal]:nth-of-type(3) { --reveal-delay: 160ms; } +.hero-copy > [data-reveal]:nth-of-type(4) { --reveal-delay: 240ms; } +.hero-copy > [data-reveal]:nth-of-type(5) { --reveal-delay: 320ms; } +.hero-copy > [data-reveal]:nth-of-type(6) { --reveal-delay: 400ms; } + +@media (prefers-reduced-motion: reduce) { + [data-reveal] { + opacity: 1 !important; + translate: 0 0 !important; + scale: 1 !important; + transition: none !important; + } + /* Skip the slide-in on the sticky header for users who prefer no motion; + * the show/hide still toggles, just instantly. */ + .nav { transition: none !important; } +} + +/* responsive */ +@media (max-width: 1280px) { + .container { padding: 0 44px; } + .work { margin: 0 44px; padding: 90px 44px; } + .side-rail { display: none; } +} +/* hide topbar mid text early — between 1200 and 1280 it crowds even with nowrap */ +@media (max-width: 1200px) { + .topbar-inner .mid { display: none; } +} +@media (max-width: 1080px) { + .container { padding: 0 32px; } + .hero h1 { font-size: clamp(36px, 4.6vw, 54px); } + .section-header h2 { font-size: clamp(32px, 4vw, 50px); } + .labs-grid { grid-template-columns: repeat(5, 1fr); gap: 14px; } + .partners { grid-template-columns: repeat(3, 1fr); gap: 18px; row-gap: 28px; } + .foot-grid { grid-template-columns: 2fr 1fr 1fr; } + .foot-grid .foot-col:nth-child(4), + .foot-grid .foot-col:nth-child(5) { display: none; } +} +@media (max-width: 880px) { + .container { padding: 0 24px; } + .hero-grid, .about-grid, .capabilities-grid, .testimonial-grid, .cta-grid { + grid-template-columns: 1fr; + gap: 50px; + } + .labs-head, .method-head { grid-template-columns: 1fr; } + .labs-grid { grid-template-columns: repeat(2, 1fr); } + .method-grid { grid-template-columns: repeat(2, 1fr); gap: 36px; } + .method-grid::before { display: none; } + .work { margin: 0 12px; padding: 60px 24px; } + .work-grid { grid-template-columns: 1fr; } + .partners { grid-template-columns: repeat(3, 1fr); gap: 18px; } + .nav-links, .brand-meta, .nav-cta { display: none; } + /* wire — stack the field label above the marquee rows */ + .wire-inner { grid-template-columns: 1fr; gap: 14px; } + .wire-left { + border-right: none; + border-bottom: 1px solid var(--line); + padding-right: 0; + padding-bottom: 12px; + min-height: 0; + } +} +@media (max-width: 560px) { + .container { padding: 0 16px; } + .hero h1 { font-size: 38px; } + .labs-grid { grid-template-columns: 1fr; } + .cards { grid-template-columns: 1fr; } + .pills { justify-content: flex-start; } + section { padding: 80px 0; } + .topbar-inner { font-size: 9px; } +}