first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
# open-design-landing
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/open-design-landing/
├── 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.
## Migrating from `editorial-collage`
This skill replaces the older `editorial-collage` folder:
- **Path:** `skills/editorial-collage/``skills/open-design-landing/`.
- **Shared assets:** downstream paths such as `../editorial-collage/assets/`
(for example from the slide-deck skill) should use
[`../open-design-landing/assets/`](./assets/) — see
[`open-design-landing-deck`](../open-design-landing-deck/README.md).
## See also
- [`design-systems/atelier-zero/DESIGN.md`](../../design-systems/atelier-zero/DESIGN.md) — colors, type, motion tokens.
- [`apps/landing-page/`](../../apps/landing-page/) — Astro static site that mirrors this skills markup at deploy time.
- [`skills/open-design-landing-deck/`](../open-design-landing-deck/) — sibling skill that produces a slide deck in the same visual language.
+321
View File
@@ -0,0 +1,321 @@
---
name: open-design-landing
description: >
Produce a world-class single-page editorial landing site in the
Atelier Zero visual language (Monocle / Apartamento / Études editorial
collage) — the same aesthetic Open Design uses for its own marketing
surface. 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; a
separate path can mirror the Astro marketing site in `apps/landing-page/`.
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
- open design landing
od:
category: brand-page
surface: web
scenario: marketing
featured: 1
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` is the historical
enum label for cloning the Astro-based `apps/landing-page/` tree and
wiring the same content. `both` writes both products into the output dir.
image_strategy:
type: enum
values: [generate, placeholder, bring-your-own]
default: placeholder
description: >
`generate` calls gpt-image-2 (fal.ai or Azure) for all 16 slots.
`placeholder` writes paper-textured SVG frames so the layout is
fully visible without an image budget. `bring-your-own` assumes
the user has dropped 16 PNGs at `imagery.assets_path` already.
image_provider:
type: enum
values: [fal, azure]
default: fal
description: Provider for `image_strategy: generate`. fal.ai is faster.
outputs:
- path: <out>/index.html
when: output_format in [standalone-html, both]
description: Self-contained HTML with Atelier Zero CSS inlined.
- path: <out>/assets/*.png (or *.svg)
description: 16 collage assets, generated or placeholder per strategy.
- path: <out>/nextjs/
when: output_format in [nextjs-app, both]
description: Astro static tree mirroring apps/landing-page (folder name is historical).
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.
---
# open-design-landing
Build a single-page editorial landing site (or a slide deck — see the
sibling [`open-design-landing-deck`](../open-design-landing-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.
This is the canonical Open Design marketing-page recipe — the example
output is the very page you see at [open-design](https://github.com/nexu-io/open-design).
The skill is fully **parameterized**. The agent fills one typed
`inputs.json` from the user's brief; the composer turns that JSON +
the canonical [`styles.css`](./styles.css) into a deployable artifact.
```text
inputs.json + styles.css 16 image slots
│ │
└──────────► scripts/compose.ts ◄────────────┘
<out>/index.html (self-contained)
<out>/assets/ (PNG or SVG)
```
---
## What you get
A single HTML file with **all** of:
- Editorial topbar (volume / issue / language strip), Headroom-style
sticky nav with live GitHub star count.
- 8 numbered Roman-numeral sections with paper-textured background:
hero (with 3 stat rings + 4-step index), about, capabilities (4 cards),
labs (5 cards + filter pills + progress bar), method (4 steps with
thumbnails), selected work (dark slab + 2 tilted cards), testimonial
(pull quote + 5 partner glyphs), CTA (ribbon + email pill).
- Footer with 4 link columns + huge italic-serif kicker word.
- Scroll-reveal motion on every section (IntersectionObserver, respects
`prefers-reduced-motion`).
- Fully responsive at 1280 / 1080 / 880 / 560 breakpoints.
---
## Workflow contract
Run these four steps in order. The agent should **complete** each step
before moving on, and prefer asking the user a focused question over
inventing copy.
### 1. Gather brand inputs
Use `AskQuestion` (or the equivalent in your UI) to collect the brand
brief in chunks; do **not** dump the entire `schema.ts` on the user.
Map their answers into `inputs.json` matching the typed shape.
The eight question groups, in order:
| Group | Schema fields | Min answers | Notes |
| :---- | :------------------------------------------------------ | :---------- | :--------------------------------------- |
| 1 | `brand.{name,mark,tagline,description,location}` | 5 | Mark = single glyph (Ø, ▲, ★…) |
| 2 | `brand.{license,version,year,primary_url,contact_email}`| 4 | URL is required; license defaults Apache-2.0 |
| 3 | `nav[]` (up to 5) | 3 | Optional count badges |
| 4 | `hero.{label,headline,lead,primary,secondary,stats}` | All | Headline as `MixedText` (sans+em+dot) |
| 5 | `about` + `capabilities.cards[4]` | All | 4 cards × {num,tag,title,body} |
| 6 | `labs.cards[5]` + `method.steps[4]` | All | Both grids fixed-arity |
| 7 | `work.cards[2]` + `testimonial` | All | 5 partner glyphs as inline SVG path data |
| 8 | `cta` + `footer.{columns[4],mega}` | All | Mega kicker is a `MixedText` like the headlines |
Open [`inputs.example.json`](./inputs.example.json) for a complete
worked example (Open Design itself).
### 2. Decide the image strategy
| Strategy | When to choose | Cost / latency |
| :---------------- | :------------------------------------------------------ | :-------------------- |
| `placeholder` | First pass. Demo. Slide internal. No image budget yet. | $0, <1s |
| `generate` | Final delivery. Brand wants original collages. | ~$0.40, ~6 min |
| `bring-your-own` | User has art direction PNGs. Drop them at `assets_path`.| $0, 0s |
Set `inputs.imagery.strategy` accordingly.
#### `placeholder` — frame mode
```bash
npx tsx scripts/placeholder.ts <out>/assets/
```
Writes 16 `.svg` files (with `.png` aliases for compatibility) into
`<out>/assets/`. Each placeholder shows the slot id, ratio, pixel
dimensions, and the prompt hint from `image-manifest.json`. The
composer's `<img src='./assets/hero.png'>` etc. just work.
#### `generate` — gpt-image-2 mode
```bash
FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> --out=<out>/assets/
```
Calls fal.ai's `openai/gpt-image-2` synchronous endpoint per slot.
Composes prompts as: **style anchor** (paper-collage editorial system)
+ **brand variables** (name / nav / headline / italic emphasis pulled
from `inputs.json`) + **per-slot composition** (e.g. cropped plaster
head + tree growing through arch). Skips slots whose target file
already exists; pass `--force` to re-render.
Without `FAL_KEY`, the script prints the prompts so the operator can
route them through the `/gpt-image-fal` slash-command skill manually.
#### `bring-your-own`
Drop 16 PNGs matching `assets/image-manifest.json` filenames at
`inputs.imagery.assets_path`. Done.
### 3. Compose the artifact
```bash
npx tsx scripts/compose.ts <inputs.json> <out>/index.html
```
The composer reads `inputs.json` and `../styles.css`, then writes one
self-contained HTML file. The page includes:
- The full Atelier Zero stylesheet, inlined.
- All section markup with `data-reveal` attributes for staggered
scroll motion.
- Inline IntersectionObserver script (mirrors
`apps/landing-page/app/_components/reveal-root.tsx`).
- Inline Headroom nav script (mirrors `header.tsx`).
- Inline GitHub star-count fetcher (auto-detects from `brand.primary_url`).
### 4. (Optional) Mirror the deployable Astro site
For deployable production output, **fork the `apps/landing-page/`**
package: copy it into your workspace, align `app/page.tsx` with content
from your `inputs.json`, and copy your `<out>/assets/*.png` into the
paths expected by `app/image-assets.ts` / R2 URLs. Build with
`pnpm --filter @open-design/landing-page build` for a static `out/`
export ready for any CDN.
> A future iteration may bundle a composer that emits the full
> `apps/landing-page/` tree from `inputs.json` in one command. Until
> then, fork-and-edit is the supported path.
---
## Self-check before delivering
Before marking done, the agent **must** verify:
- [ ] `<out>/index.html` opens in a browser without console errors.
- [ ] All 16 image slots load (no 404s in DevTools network tab).
- [ ] Headline italic emphasis spans render in Playfair (not sans).
- [ ] Coral terminating dots appear at every `display` h1/h2 end.
- [ ] Scroll from top to bottom; every section animates in once.
- [ ] Resize to 880px and 560px; no horizontal scroll, no overlap.
- [ ] `prefers-reduced-motion: reduce` (DevTools → Rendering) disables
transitions cleanly.
- [ ] Lighthouse: contrast AA, font-display swap, no layout shift on the
hero (CLS < 0.05).
---
## Files in this skill
```text
skills/open-design-landing/
├── 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 Astro landing-page
fork; 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 Astro static counterpart.
- [`skills/open-design-landing-deck/`](../open-design-landing-deck/) — sibling slides skill that reuses this design system.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,168 @@
{
"$schema": "https://open-design.dev/schemas/image-manifest.v1.json",
"skill": "open-design-landing",
"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
}
]
}
@@ -0,0 +1,246 @@
# Atelier Zero — Image Generation Prompt Pack
This pack is consumed by the `open-design-landing` skill. Every page-level
image is rendered with `gpt-image-fal` (preferred) or `gpt-image-azure`.
The pack has three layers:
1. **Style anchor** — the long block that tells the model what
universe we are in. Always prepend to every prompt.
2. **Variable slots** — the per-render content (subject, motifs,
accent, page type).
3. **Per-slot variants** — explicit composition templates for hero,
about, capabilities, method tiles, lab cards, work cards,
testimonial, and CTA.
Render at 1024×1024 minimum for square slots (hero / about / capabilities
/ testimonial / cta), 816×816 for the four method tiles, and 768×1024 for
portrait slots (lab cards, featured work). Authoritative per-slot
dimensions and aspect ratios live in `image-manifest.json` — treat that
file as the source of truth. Save as PNG to `assets/<slot>.png`.
---
## 1. Style anchor (always prepend)
```text
Use case: ads-marketing
Asset type: editorial website hero / creative studio landing page visual
Primary request: Generate a refined editorial web page composition in the
same visual language as a high-end creative AI research studio.
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
Bauhaus geometric composition, classical plaster sculpture fragments,
brutalist/minimal architecture, art-direction website mockup, premium
agency aesthetic.
Scene/backdrop: warm off-white handmade paper background with subtle
grain, faint vertical folds, scanned paper fibers, lightly aged print
texture, thin drafting lines and registration marks.
Subject: a surreal collage combining a cropped classical plaster head or
face fragment, abstract architectural blocks, archways or stairs, sky
cutouts, one small human figure, a delicate tree or botanical element,
and geometric color planes.
Composition/framing: wide 16:9 web page layout, strong asymmetrical
grid, generous negative space, large typography area on the left or
top-left, collage focal object on the right or center-right, precise
alignment, thin divider lines, small UI navigation details.
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
restrained, tactile, poetic, premium, research-driven.
Color palette: warm ivory, stone beige, soft concrete gray, deep black
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
accent, pale sky blue only inside small sky/image cutouts.
Materials/textures: matte plaster, limestone, travertine, concrete, rough
torn paper edges, halftone print grain, translucent vellum-like overlays,
fine grid paper, dotted matrix patterns.
Typography: large clean grotesk sans-serif for main headline, elegant
high-contrast italic serif for emphasized words, tiny uppercase coral
labels, compact UI microcopy. Text must be crisp, readable, and spelled
exactly as provided.
Graphic details: thin hairline circles, partial arcs, crosshair marks,
small black dots, dotted grids, fine coordinate lines, numbered
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
Constraints: preserve a high-end editorial web design feel; keep spacing
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
no busy gradients; no generic stock-photo look.
Avoid: distorted typography, misspelled text, extra random words, heavy
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
plastic materials, overly decorative UI cards, cluttered composition,
low-resolution textures, watermarks.
```
## 2. Variable slots (substitute per render)
```text
Brand/logo text: "<BRAND_NAME>"
Navigation text: "<NAV_1>", "<NAV_2>", "<NAV_3>", "<NAV_4>", "<NAV_5>"
Eyebrow label: "<EYEBROW>"
Main headline: "<MAIN_HEADLINE>"
Italic emphasis words: "<ITALIC_WORDS>"
Body copy: "<BODY_COPY>"
Primary button: "<PRIMARY_CTA>"
Secondary button: "<SECONDARY_CTA>"
Footer/micro labels: "<FOOTER_LABELS>"
Main collage subject: <plaster head | eye | hand | arch | stair | tree | landscape | object>
Inserted texture motifs:<sky, mountain, ocean, eye close-up, dancer, stone, fabric, map, grid, handwritten note>
Accent color: <washed coral red | mustard yellow | pale blue | muted sage>
Page type: <hero | about | capabilities | method tile | lab card | work card | testimonial | cta>
```
## 3. Per-slot composition templates
### `hero.png` — 1:1 (1024×1024)
```text
Composition/framing: left half is intentionally empty/quiet to allow real
HTML headline overlay; right half holds a tall surreal collage of a
cropped classical plaster head with the top sliced open, sky/architecture
cutouts visible inside the head, a delicate young tree growing through
the composition, a coral sun disk behind, a mustard accent ring at the
base, hairline coordinate marks and dotted matrices around it, a small
human figure standing for scale in the lower-left of the image. Page
type: hero landing.
```
### `about.png` — 1:1 (1024×1024)
```text
Composition: a surreal museum-vitrine arrangement of a partial plaster
profile head facing right, with an open archway carved through the
torso, sky cutout inside the arch, a tree seedling growing out of the
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
trace contours. Strong negative space top-left for a side-note overlay.
Page type: about / manifesto plate.
```
### `capabilities.png` — 1:1 (1024×1024)
```text
Composition: a Bauhaus-grid stack of architectural fragments — a coral
arch on the left, a beige concrete column center, a mustard small disc
upper-right, a delicate tree mid-frame, a small classical hand fragment
holding a pencil bottom-center. Crosshair and circular hairlines
overlay. Page type: capabilities matrix.
```
### `method-1.png` … `method-4.png` — 1:1 (816×816)
```text
Composition: a single visual metaphor per step.
method-1 — a magnifying glass over a small architectural map (Detect)
method-2 — a clipboard with a tiny questionnaire and a coral pen (Discover)
method-3 — a compass + ruler + color swatch fan (Direct)
method-4 — a printer's tray with stacked paper sheets exiting (Deliver)
Each on the warm paper ground with hairline grid, a single coral or
mustard accent piece, and one numbered annotation tag. Page type:
method tile.
```
### `lab-1.png` … `lab-5.png` — 3:4 (768×1024)
```text
Composition: portrait-oriented experiment cards. Each is a square-ish
plaster-and-architecture vignette, vertical, with a single dominant
subject:
lab-1 — a stack of folded magazine spreads
lab-2 — a film strip + a synthetic eye + a soundwave hairline
lab-3 — a typewriter with prompt cards in the carriage
lab-4 — five small dotted gauges arranged in a circle (5-dim critique)
lab-5 — a glass dome / cloche over a tiny sandbox cityscape (Sandbox)
Use the same paper ground; allow soft drop shadow but stay restrained.
Page type: lab card.
```
### `work-1.png` & `work-2.png` — 3:4 (768×1024)
```text
Composition: featured work plates.
work-1 — guizang-ppt: an oversized open magazine spread on a desk,
coral spine, mustard tab. Slight perspective.
work-2 — dating-web: a concrete dashboard slab, a coral graph bar
rising, a small classical bust beside it for scale.
Both on the warm paper ground with crop marks.
Page type: work card.
```
### `testimonial.png` — 1:1 (1024×1024)
```text
Composition: a classical plaster bust facing 3/4 left, slightly cropped,
with a small sky cutout where the eye would be, a thin coral arc around
the back of the head, mustard dot at the chin. Quiet background, lots of
negative space upper right. Page type: testimonial portrait.
```
### `cta.png` — 1:1 (1024×1024)
```text
Composition: a closing-plate collage — a mustard sun behind a single
coral arch on the right, a delicate tree growing through the arch, a
small human figure in the lower-left foreground reading a folded
broadsheet, hairline coordinate ladder up the left edge, and a small
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.
```
## 4. Chinese project input template
Use only when the model copy must be Chinese; otherwise prefer English
for legibility. Keep verbatim text short.
```text
请生成一张 16:9 横版网页视觉稿,风格为高级创意 AI 工作室官网:现代瑞士编辑排版、
包豪斯几何、古典石膏雕塑拼贴、极简建筑、手工纸张肌理、细线工程制图标记。
品牌文字:"<品牌名>"
导航:"<导航1>", "<导航2>", "<导航3>", "<导航4>"
小标签:"<小标签>"
主标题必须逐字渲染:"<主标题>"
强调词(斜体衬线):"<强调词>"
正文必须逐字渲染:"<正文>"
按钮文字:"<按钮1>", "<按钮2>"
画面主体:<主体描述>
贴图与元素:<天空 / 石材 / 植物 / 人物 / 眼睛 / 山脉 / 水面 / UI 截图等>
构图:<左文右图 / 右文左图 / 顶部大标题下方横向卡片 / 中央拼贴 / 时间线分栏>
色彩:暖象牙白纸张、黑色文字、石灰/混凝土灰、炭黑、低饱和珊瑚红点缀、
少量芥末黄或浅天蓝。
限制:文字清晰可读、不添加多余文字、不要水印、不要卡通、不要霓虹、
不要厚重阴影、不要俗套科技蓝紫渐变。
```
## 5. Calling convention
Pseudocode for an agent driver:
```ts
for (const slot of imageManifest.slots) {
const prompt = [
STYLE_ANCHOR,
fillVars(VARIABLE_SLOTS, brand),
PER_SLOT[slot.id],
].join('\n\n');
await gptImageFal({
prompt,
width: slot.width,
height: slot.height,
quality: 'high',
output: `assets/${slot.id}.png`,
});
}
```
If `gpt-image-fal` is unavailable, the same prompts work with
`gpt-image-azure` — but mask-based inpainting is azure-only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because it is too large Load Diff
@@ -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.3.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.3.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&rsquo;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": "↳ &nbsp; pnpm tools-dev &nbsp; · &nbsp; 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&nbsp;<span style='color:var(--coral);'>Open Design</span>"
}
},
"about": {
"label": "About the studio",
"ix": "· Nº 02",
"headline": [
{ "text": "We treat " },
{ "text": "your agent", "em": true },
{ "text": " as a creative " },
{ "text": "collaborator,", "em": true },
{ "text": " not a black box" },
{ "text": ".", "dot": true }
],
"lead": "The strongest coding agents already live on your laptop. We don't ship one — we wire them into a skill-driven design workflow that runs locally with <code class='code-inline'>pnpm tools-dev</code>, deploys the web layer to Vercel, and stays BYOK at every layer.",
"cta_label": "Read our approach",
"cta_href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon",
"footer_text": "Research · Design · Engineering · Repeat",
"stamp_top": "Studio practice",
"stamp_bottom": "Est. MMXXVI",
"side_note": "From model behavior<br/>to visual taste, we<br/>prototype the full<br/>stack of creative<br/>systems.",
"caption": {
"bold": "Studies in form · perception · machine imagination.",
"rest": "(Open Design, MMXXVI)"
}
},
"capabilities": {
"label": "Capabilities",
"ix": "· Nº 03",
"headline": [
{ "text": "Skills, systems, and surfaces " },
{ "text": "for creative", "em": true },
{ "text": " intelligence" },
{ "text": ".", "dot": true }
],
"lead": "We blend human taste with whichever agent you already trust to ship interfaces, decks, and editorial pages that feel intentional, expressive, and alive.",
"ribbon": "<b>OPEN DESIGN</b> &nbsp;·&nbsp; CAPABILITIES MATRIX &nbsp;·&nbsp; OD/26",
"cards": [
{
"num": "01",
"tag": "Skills",
"icon_svg": "<circle cx='9' cy='9' r='5'/><path d='M14 14l5 5'/>",
"title": "Skills,\nnot plugins",
"body": "31 file-based <code class='code-inline sm'>SKILL.md</code> bundles. Drop a folder in, restart the daemon, it appears.",
"href": "https://github.com/nexu-io/open-design/tree/main/skills"
},
{
"num": "02",
"tag": "Systems",
"icon_svg": "<rect x='3.5' y='3.5' width='8' height='8'/><rect x='12.5' y='3.5' width='8' height='8'/><rect x='3.5' y='12.5' width='8' height='8'/><rect x='12.5' y='12.5' width='8' height='8'/>",
"title": "Design Systems\nas Markdown",
"body": "72 portable <code class='code-inline sm'>DESIGN.md</code> systems — Linear, Vercel, Stripe, Apple, Cursor, Figma…",
"href": "https://github.com/nexu-io/open-design/tree/main/design-systems"
},
{
"num": "03",
"tag": "Adapters",
"icon_svg": "<circle cx='8' cy='12' r='4.5'/><circle cx='16' cy='12' r='4.5'/>",
"title": "12 Agent\nAdapters",
"body": "Claude · Codex · Gemini · Cursor · Copilot · OpenCode · Devin · Hermes · Pi · Kimi · Kiro · Qwen — auto-detected on $PATH.",
"href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon"
},
{
"num": "04",
"tag": "BYOK",
"icon_svg": "<path d='M5 8h14v8H5z'/><path d='M9 12h6M12 9v6'/>",
"title": "BYOK\nat every layer",
"body": "OpenAI-compatible proxy. DeepSeek, Groq, OpenRouter, your self-hosted vLLM — paste a baseUrl + key, ship.",
"href": "https://github.com/nexu-io/open-design"
}
]
},
"labs": {
"label": "Labs",
"ix": "· Nº 04",
"headline": [
{ "text": "A living archive of " },
{ "text": "experiments", "em": true },
{ "text": " in skills, decks, and machine-made form" },
{ "text": ".", "dot": true }
],
"pills": [
{ "label": "All", "count": "31", "active": true },
{ "label": "Prototype", "count": "27" },
{ "label": "Deck", "count": "04" },
{ "label": "Mobile", "count": "03" },
{ "label": "Office", "count": "08" }
],
"meta": {
"ring": "05",
"bold": "Ongoing experiments",
"sub": "documenting ideas in flux<br/>building intelligence<br/>through making"
},
"cards": [
{ "badge": "Deck", "num": "Nº 01", "year": "2026", "title": "Magazine Decks", "body": "Editorial-grade slide decks with <code class='code-inline sm'>guizang-ppt</code>. Magazine layout, WebGL hero.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/guizang-ppt" },
{ "badge": "Media", "num": "Nº 02", "year": "2026", "title": "Synthetic Matter", "body": "Gpt-image-2 + Seedance + HyperFrames. Image, video, audio — same chat surface as code.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/hyperframes" },
{ "badge": "Loop", "num": "Nº 03", "year": "2026", "title": "Prompt Choreography","body": "The interactive question form pops before a single pixel is improvised. 30s of radios beats 30min of redirects.","href": "https://github.com/nexu-io/open-design/tree/main/skills/design-brief" },
{ "badge": "Critique", "num": "Nº 04", "year": "2026", "title": "Visual Reasoning", "body": "5-dim self-critique gates every artifact: philosophy · hierarchy · execution · specificity · restraint.", "href": "https://github.com/nexu-io/open-design/tree/main/skills/critique" },
{ "badge": "Runtime", "num": "Nº 05", "year": "2026", "title": "Soft Systems", "body": "Sandboxed iframe preview. Streaming todos. Real-cwd filesystem. Adaptive loops between human and machine.", "href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon" }
],
"progress": { "total": 8, "filled": 5 },
"foot": "05 / 31 SKILLS &nbsp;·&nbsp; <a class='library-link' href='https://github.com/nexu-io/open-design/tree/main/skills' target='_blank' rel='noreferrer noopener' style='color:var(--coral);'>VIEW FULL LIBRARY →</a>"
},
"method": {
"label": "Method",
"ix": "· Nº 05",
"headline": [
{ "text": "From " },
{ "text": "signals", "em": true },
{ "text": " to systems" },
{ "text": ".", "dot": true }
],
"right": "Every stage is iterative, visual, and research-driven — composable files, not opaque prompts.",
"steps": [
{ "num": "01", "title": "Detect", "body": "The daemon scans your $PATH for 12 coding agents and auto-loads 31 skills + 72 systems on boot." },
{ "num": "02", "title": "Discover", "body": "Turn 1 is a question form — surface, audience, tone, scale, brand context. Locked in 30 seconds." },
{ "num": "03", "title": "Direct", "body": "Pick one of 5 deterministic visual directions. Palette in OKLch, font stack, layout posture cues." },
{ "num": "04", "title": "Deliver", "body": "The agent writes to disk, you preview in a sandboxed iframe, export HTML / PDF / PPTX / ZIP / Markdown." }
],
"foot_left": "Skills inform everything. Files make it real.",
"foot_right_bold": "github.com/nexu-io/open-design",
"foot_right_rest": "Apache-2.0"
},
"work": {
"label": "Selected work",
"headline": [
{ "text": "Skills that turn briefs into " },
{ "text": "memorable", "em": true },
{ "text": " shippable " },
{ "text": "artifacts", "em": true },
{ "text": ".", "dot": true }
],
"link_label": "View all 31 skills",
"link_href": "https://github.com/nexu-io/open-design/tree/main/skills",
"cards": [
{
"small_label": "Featured skill",
"index": "01 / 31",
"title": "guizang-ppt",
"body": "Magazine-style web PPT for product launches and pitch decks. Bundled verbatim, original LICENSE preserved.",
"year": "2026 · DECK",
"tag": "DEFAULT"
},
{
"small_label": "Companion system",
"index": "04 / 72",
"title": "kami",
"body": "An editorial paper system. Warm parchment canvas, ink-blue accent, serif-led hierarchy — multilingual by design (EN · zh-CN · ja).",
"year": "2026 · PAPER",
"tag": "SYSTEM"
}
]
},
"testimonial": {
"label": "Collaborators",
"ix": "· Nº 06",
"quote": [
{ "text": "Open Design helped us turn vague " },
{ "text": "AI ideas", "em": true },
{ "text": " into a visual system that felt " },
{ "text": "sharp, believable,", "em": true },
{ "text": " and genuinely new." }
],
"author": { "initial": "m", "name": "Mina Kovac", "title": "Creative Director · North Form" },
"partners_text": "Standing on the shoulders of teams shipping open-source design culture.",
"partners": [
{ "glyph_svg": "<path d='M5 24L20 6L35 24M12 18h16'/>", "name": "huashu-design", "role": "Philosophy", "href": "https://github.com/alchaincyf/huashu-design" },
{ "glyph_svg": "<path d='M8 24L20 6L24 22L36 4'/>", "name": "guizang-ppt", "role": "Decks", "href": "https://github.com/op7418/guizang-ppt-skill" },
{ "glyph_svg": "<rect x='6' y='6' width='4' height='18'/><rect x='14' y='6' width='4' height='18'/><rect x='22' y='6' width='4' height='18'/><rect x='30' y='6' width='4' height='18'/>", "name": "multica-ai", "role": "Daemon", "href": "https://github.com/multica-ai/multica" },
{ "glyph_svg": "<circle cx='15' cy='15' r='9'/><path d='M15 6v18M6 15h18'/>", "name": "open-codesign", "role": "UX", "href": "https://github.com/OpenCoworkAI/open-codesign" },
{ "glyph_svg": "<path d='M5 8l9 7-9 7M20 24h18'/>", "name": "Devin CLI", "role": "Terminal", "href": "https://devin.ai/terminal" },
{ "glyph_svg": "<rect x='4' y='5' width='22' height='18'/><rect x='14' y='9' width='22' height='18'/>", "name": "hyperframes", "role": "Frames", "href": "https://github.com/heygen-com/hyperframes" }
],
"read_more_label": "Read more stories",
"read_more_href": "https://github.com/nexu-io/open-design"
},
"cta": {
"label": "Start a conversation",
"ix": "· Nº 07",
"headline": [
{ "text": "Let's build something " },
{ "text": "open", "em": true },
{ "text": " and " },
{ "text": "visually", "em": true },
{ "text": " unforgettable" },
{ "text": ".", "dot": true }
],
"lead": "Star us on GitHub, drop into the issues, or run <code class='code-inline'>pnpm tools-dev</code> tonight. Three commands and the loop is yours.",
"primary": { "label": "Star on GitHub", "href": "https://github.com/nexu-io/open-design" },
"ribbon": "OPEN DESIGN &nbsp;·&nbsp; FIN."
},
"wire": {
"title": "From the field",
"cities": [
{ "name": "Berlin", "coord": "52.52°N" },
{ "name": "Tokyo", "coord": "35.68°N" },
{ "name": "Shanghai", "coord": "31.23°N" },
{ "name": "Beijing", "coord": "39.90°N" },
{ "name": "Taipei", "coord": "25.03°N" },
{ "name": "Singapore", "coord": "1.35°N" },
{ "name": "Bangalore", "coord": "12.97°N" },
{ "name": "Dubai", "coord": "25.20°N" },
{ "name": "Lagos", "coord": "6.52°N" },
{ "name": "Nairobi", "coord": "1.29°S" },
{ "name": "Cape Town", "coord": "33.92°S" },
{ "name": "Lisbon", "coord": "38.72°N" },
{ "name": "Madrid", "coord": "40.42°N" },
{ "name": "Paris", "coord": "48.86°N" },
{ "name": "London", "coord": "51.51°N" },
{ "name": "Amsterdam", "coord": "52.37°N" },
{ "name": "Stockholm", "coord": "59.33°N" },
{ "name": "Toronto", "coord": "43.65°N" },
{ "name": "New York", "coord": "40.71°N" },
{ "name": "San Francisco", "coord": "37.77°N" },
{ "name": "Mexico City", "coord": "19.43°N" },
{ "name": "São Paulo", "coord": "23.55°S" },
{ "name": "Sydney", "coord": "33.87°S" }
],
"contributors": [
{ "handle": "tw93", "role": "kami", "href": "https://github.com/tw93" },
{ "handle": "op7418", "role": "guizang", "href": "https://github.com/op7418" },
{ "handle": "alchaincyf", "role": "huashu", "href": "https://github.com/alchaincyf" },
{ "handle": "multica-ai", "role": "daemon", "href": "https://github.com/multica-ai" },
{ "handle": "OpenCoworkAI", "role": "codesign", "href": "https://github.com/OpenCoworkAI" },
{ "handle": "nexu-io", "role": "studio", "href": "https://github.com/nexu-io" },
{ "handle": "you", "role": "be next", "href": "https://github.com/nexu-io/open-design/graphs/contributors" }
]
},
"footer": {
"brand_description": "The open-source alternative to Claude Design. Built on the shoulders of <a class='inline-link' href='https://github.com/alchaincyf/huashu-design' target='_blank' rel='noreferrer noopener'>huashu-design</a>, <a class='inline-link' href='https://github.com/op7418/guizang-ppt-skill' target='_blank' rel='noreferrer noopener'>guizang-ppt</a>, <a class='inline-link' href='https://github.com/multica-ai/multica' target='_blank' rel='noreferrer noopener'>multica-ai</a>, and <a class='inline-link' href='https://github.com/OpenCoworkAI/open-codesign' target='_blank' rel='noreferrer noopener'>open-codesign</a>.",
"brand_cta": {
"label": "Download desktop",
"href": "https://github.com/nexu-io/open-design/releases",
"meta": "macOS · v0.3.0"
},
"columns": [
{ "title": "Studio", "links": [
{ "label": "Capabilities", "href": "#agents" },
{ "label": "Labs", "href": "#labs" },
{ "label": "Method", "href": "https://github.com/nexu-io/open-design/tree/main/apps/daemon" },
{ "label": "Manifesto", "href": "https://github.com/nexu-io/open-design" }
]},
{ "title": "Library", "links": [
{ "label": "31 Skills", "href": "https://github.com/nexu-io/open-design/tree/main/skills" },
{ "label": "72 Systems", "href": "https://github.com/nexu-io/open-design/tree/main/design-systems" },
{ "label": "5 Directions", "href": "https://github.com/nexu-io/open-design/tree/main/design-systems" },
{ "label": "5 Frames", "href": "https://github.com/nexu-io/open-design/tree/main/skills/hyperframes" }
]},
{ "title": "Connect", "links": [
{ "label": "GitHub", "href": "https://github.com/nexu-io/open-design" },
{ "label": "Issues", "href": "https://github.com/nexu-io/open-design/issues" },
{ "label": "Contributors", "href": "https://github.com/nexu-io/open-design/graphs/contributors" },
{ "label": "Releases", "href": "https://github.com/nexu-io/open-design/releases" }
]},
{ "title": "Docs", "links": [
{ "label": "Quickstart", "href": "https://github.com/nexu-io/open-design/blob/main/QUICKSTART.md" },
{ "label": "Architecture", "href": "https://github.com/nexu-io/open-design/blob/main/docs/architecture.md" },
{ "label": "Skill Protocol", "href": "https://github.com/nexu-io/open-design/blob/main/docs/skills-protocol.md" },
{ "label": "Roadmap", "href": "https://github.com/nexu-io/open-design/blob/main/docs/roadmap.md" }
]}
],
"mega": [
{ "text": "Open " },
{ "text": "Design", "em": true },
{ "text": "." }
]
},
"imagery": {
"strategy": "bring-your-own",
"assets_path": "./assets/",
"provider": "fal"
}
}
+447
View File
@@ -0,0 +1,447 @@
/**
* open-design-landing — input schema.
*
* This is the contract between users and `scripts/compose.ts`. A valid
* `inputs.json` matching `EditorialCollageInputs` is enough to produce
* a complete Atelier Zero landing page, end-to-end, with no further
* code changes needed.
*
* Convention: every field that drives visible copy lives here. The
* structural CSS, layout grid, motion, and 16 image slots are fixed by
* the design system (`design-systems/atelier-zero/DESIGN.md`); only
* brand identity and content text are user-controlled.
*/
/* ---------- text helpers ---------- */
/**
* A `MixedText` is a sentence whose visual rhythm comes from alternating
* sans-serif and italic-serif spans. Encode it as an array of segments;
* the composer concatenates them into HTML, wrapping `em: true` segments
* in `<em>` tags. The trailing `dot: true` segment renders the coral
* full-stop accent.
*
* Example:
* [
* { text: 'We treat ' },
* { text: 'your agent', em: true },
* { text: ' as a creative ' },
* { text: 'collaborator,', em: true },
* { text: ' not a black box' },
* { text: '.', dot: true },
* ]
*/
export interface TextSegment {
text: string;
/** Wrap in <em> for italic-serif emphasis. */
em?: boolean;
/** Render as the coral terminating dot accent (use as the final segment). */
dot?: boolean;
}
export type MixedText = TextSegment[];
/* ---------- brand block ---------- */
export interface BrandBlock {
/** Display name (appears in nav, footer, og:title, browser tab). */
name: string;
/** Single glyph for the circled brand mark — `Ø`, `▲`, `★`, etc. */
mark: string;
/**
* Two-line meta block in the nav: `<b>{title}</b>{subtitle}` with a
* dividing rule. e.g. `{ title: 'Studio Nº 01', subtitle: 'Berlin / Open / Earth' }`.
*/
meta: { title: string; subtitle: string };
/** Filed-under tagline shown in the topbar. */
filed_under: string;
/** Tagline shown in the page <title> alongside the brand. */
tagline: string;
/** SEO description; appears in `<meta name='description'>`. */
description: string;
/** ISO 639-1 language code; defaults to `en`. */
locale?: string;
/** Edition badge — `'Vol. 01 / Issue Nº 26'`. */
edition: string;
/** Visible build version — `'v0.4.6'`. */
version: string;
/** SPDX license identifier or short label — `'Apache-2.0'`. */
license: string;
/** Primary CTA URL (Star on GitHub, etc.). */
primary_url: string;
/** Star-button label in the nav. */
primary_url_label: string;
/**
* Optional secondary CTA URL surfaced as a ghost pill in the nav and as
* a button in the footer brand column. When set, the marketing surface
* advertises a "Download" entry so users know they can install directly.
*/
download_url?: string;
/** Label for the download CTA — defaults to `'Download'` when omitted. */
download_url_label?: string;
/** Email address shown in the CTA section. */
contact_email: string;
/** Pretty location line — `'Berlin / Open / Earth'`. */
location: string;
/** Coordinates string — `'52.5200° N · 13.4050° E'`. */
coordinates: string;
/** Year of publication — `'2026'`. */
year: string;
/** Roman numeral year for the footer kicker — `'MMXXVI'`. */
year_roman: string;
/** Founding tagline — `'Est. MMXXVI'`. */
founded: string;
/** Side rails (the rotated text fixed to viewport edges). */
rails: { right: string; left: string };
/** Topbar live channel languages — `['EN', 'DE', '中文', '日本語']`. First entry is bolded. */
languages: string[];
/** Topbar pulse text — `'Live · v0.4.6'`. */
status: string;
}
/* ---------- nav ---------- */
export interface NavLink {
label: string;
href: string;
/** Optional superscript count badge — `'31'`, `'72'`, etc. */
count?: string;
}
/* ---------- hero ---------- */
export interface HeroStat {
/** Number or short string inside the ring — `'31'`. */
value: string;
/** Bold label below the ring — `'skills'`. */
label: string;
/** Sub-label — `'shippable'`. */
sub: string;
/** Visual treatment: dashed border (default), solid border, or coral accent. */
variant?: 'dashed' | 'solid' | 'coral';
}
export interface HeroIndexItem {
/** Two-digit number — `'01'`. */
num: string;
/** Step name — `'Detect'`. */
label: string;
/** Mark this item as the active one (rendered in solid ink). */
active?: boolean;
}
export interface HeroBlock {
/** Eyebrow label (left) — `'Open-source design studio'`. */
label: string;
/** Eyebrow index (right of label) — `'· Nº 01'`. */
ix: string;
/** The H1 — encoded as MixedText. */
headline: MixedText;
/** Lead paragraph; can include `<code>` via raw HTML — keep ASCII-quotes safe. */
lead: string;
/** Primary CTA. */
primary: { label: string; href: string };
/** Secondary CTA. */
secondary: { label: string; href: string };
/** Three stat rings displayed below the CTAs. */
stats: [HeroStat, HeroStat, HeroStat];
/** Bottom-left meta line in the hero foot. */
meta: string;
/** Four index items rendered over the hero collage. */
index: [HeroIndexItem, HeroIndexItem, HeroIndexItem, HeroIndexItem];
/** Image annotations (corner labels). */
annotations: {
tl: string;
tr: string;
bl: string;
br: string;
};
}
/* ---------- about ---------- */
export interface AboutBlock {
label: string;
ix: string;
headline: MixedText;
lead: string;
cta_label: string;
cta_href: string;
/** Footer row text — `'Research · Design · Engineering · Repeat'`. */
footer_text: string;
/** Stamp top line (coral) — `'Studio practice'`. */
stamp_top: string;
/** Stamp bottom line (ink) — `'Est. MMXXVI'`. */
stamp_bottom: string;
/** Side note (right of the about image). */
side_note: string;
/** Caption below the about image. */
caption: { bold: string; rest: string };
}
/* ---------- capabilities ---------- */
export interface CapabilityCard {
/** Two-digit accent — `'01'`. */
num: string;
/** Tag — `'Skills'`. */
tag: string;
/** SVG inner contents (paths/circles/rects only — no <svg> wrapper). */
icon_svg: string;
/** Title; use \n for line breaks. */
title: string;
/** Body; can include `<code>` raw HTML. */
body: string;
href: string;
}
export interface CapabilitiesBlock {
label: string;
ix: string;
headline: MixedText;
lead: string;
ribbon: string;
/** Exactly four cards. */
cards: [CapabilityCard, CapabilityCard, CapabilityCard, CapabilityCard];
}
/* ---------- labs ---------- */
export interface LabPill {
label: string;
count: string;
active?: boolean;
}
export interface LabCard {
badge: string;
num: string;
year: string;
title: string;
body: string;
href: string;
}
export interface LabsBlock {
label: string;
ix: string;
headline: MixedText;
pills: LabPill[];
meta: { ring: string; bold: string; sub: string };
/** Exactly five lab cards. */
cards: [LabCard, LabCard, LabCard, LabCard, LabCard];
/** Progress bar — total segments and how many are filled. */
progress: { total: number; filled: number };
foot: string;
}
/* ---------- method ---------- */
export interface MethodStep {
num: string;
title: string;
body: string;
}
export interface MethodBlock {
label: string;
ix: string;
headline: MixedText;
right: string;
/** Exactly four steps. */
steps: [MethodStep, MethodStep, MethodStep, MethodStep];
foot_left: string;
foot_right_bold: string;
foot_right_rest: string;
}
/* ---------- work ---------- */
export interface WorkCard {
small_label: string;
index: string;
title: string;
body: string;
year: string;
tag: string;
}
export interface WorkBlock {
label: string;
headline: MixedText;
link_label: string;
link_href: string;
/** Two cards — first regular, second has the .alt tilt. */
cards: [WorkCard, WorkCard];
}
/* ---------- testimonial / partners ---------- */
export interface Partner {
/** SVG inner contents (paths/circles/rects only — no <svg> wrapper). */
glyph_svg: string;
name: string;
role: string;
/** Click target for the partner card. When omitted, falls back to `'#'`. */
href?: string;
}
export interface TestimonialBlock {
label: string;
ix: string;
/** Quote with em emphasis; the leading `"` and trailing `"` are added by the composer. */
quote: MixedText;
author: { initial: string; name: string; title: string };
partners_text: string;
/** Up to five partners; the design fits five comfortably. */
partners: Partner[];
read_more_label: string;
read_more_href: string;
}
/* ---------- cta ---------- */
export interface CTABlock {
label: string;
ix: string;
headline: MixedText;
lead: string;
primary: { label: string; href: string };
ribbon: string;
}
/* ---------- wire / global ticker ---------- */
/**
* A single city pinned to the studio's "from the field" ticker. The
* marquee renders `{coord} {name}`, so keep `coord` short — `52.52°N`,
* `1.29°S`, etc.
*/
export interface WireCity {
/** Display name — `'Berlin'`, `'São Paulo'`. Title-case is fine; the
* stylesheet uppercases it visually. */
name: string;
/** Latitude only, prettified — `'52.52°N'`. */
coord: string;
}
/**
* A named contributor / lineage handle in the ticker's bottom row. The
* marquee renders `@{handle} {role}` and the whole pill becomes a link
* to `href` (typically a GitHub profile or org page).
*/
export interface WireContributor {
/** GitHub-style handle without the leading `@` — `'tw93'`, `'OpenCoworkAI'`. */
handle: string;
/** Short role tag — `'kami'`, `'core'`, `'be next'`. Rendered in coral. */
role: string;
/** Click target for the handle pill. */
href: string;
}
/**
* Optional editorial ticker rendered between the hero and the about
* section. Two counter-scrolling marquees: cities (left → right) and
* contributors (right → left). Designed to signal that the project is
* global and community-driven without disrupting the roman-numeral
* section count.
*/
export interface WireBlock {
/** Bold uppercase headline on the left rail — `'From the field'`. */
title: string;
/** Sub-label — `'Open · 23 cities · 6 contributors'`. Optional; computed
* from the lists when omitted. */
subtitle?: string;
cities: WireCity[];
contributors: WireContributor[];
}
/* ---------- footer ---------- */
export interface FooterColumn {
title: string;
links: { label: string; href: string }[];
}
export interface FooterBlock {
brand_description: string;
/**
* Optional CTA rendered under the brand description in the footer
* (e.g. `{ label: 'Download desktop', href: 'https://.../releases',
* meta: 'macOS · v0.3.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;
}
@@ -0,0 +1,821 @@
#!/usr/bin/env -S npx -y tsx
/**
* open-design-landing — HTML composer.
*
* Reads `inputs.json` (matching `../schema.ts`) and writes a single
* self-contained HTML file with the Atelier Zero stylesheet inlined,
* the 16 collage images referenced by relative URL, and the
* scroll-reveal + headroom-nav scripts embedded.
*
* Usage:
* npx tsx scripts/compose.ts <inputs.json> <output.html>
*
* Re-generate the canonical example:
* npx tsx scripts/compose.ts inputs.example.json example.html
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, resolve, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
EditorialCollageInputs,
MixedText,
HeroIndexItem,
HeroStat,
CapabilityCard,
LabPill,
LabCard,
MethodStep,
WorkCard,
Partner,
FooterColumn,
SectionRule,
} from '../schema';
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/* ------------------------------------------------------------------ *
* helpers
* ------------------------------------------------------------------ */
/** Render a `MixedText` into HTML (sans/em/dot segments). */
function mixed(text: MixedText): string {
return text
.map((seg) => {
if (seg.dot) return `<span class='dot'>${seg.text}</span>`;
if (seg.em) return `<em>${seg.text}</em>`;
return seg.text;
})
.join('');
}
/** Newline → `<br/>` for multi-line headings/labels. */
function br(s: string): string {
return s.replace(/\n/g, '<br/>');
}
/** External-link attribute pair. */
function ext(href: string): string {
if (/^(https?:|mailto:|\/\/)/i.test(href)) {
return ` target='_blank' rel='noreferrer noopener'`;
}
return '';
}
const ARROW_OUT = `<svg viewBox='0 0 24 24'><path d='M5 19L19 5M19 5H8M19 5v11'/></svg>`;
const ARROW_PLUS = `<svg viewBox='0 0 24 24'><circle cx='12' cy='12' r='9'/><path d='M9 12h6M12 9v6'/></svg>`;
/** A small CSS class we reference from inputs as `code-inline` / `code-inline sm`. */
const CODE_INLINE_CSS = `
.code-inline {
font-family: var(--mono);
font-size: 14px;
background: var(--bone);
padding: 1px 6px;
border-radius: 4px;
}
.code-inline.sm { font-size: 12px; padding: 0 4px; }
`;
/* ------------------------------------------------------------------ *
* section renderers
* ------------------------------------------------------------------ */
function renderHead(i: EditorialCollageInputs, css: string): string {
return `<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>${i.brand.name}${i.brand.tagline}</title>
<meta name='description' content='${i.brand.description}' />
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
<link href='https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800;900&family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,500;0,600;1,400;1,500;1,600;1,700&family=JetBrains+Mono:wght@400;500&display=swap' rel='stylesheet' />
<style>${css}${CODE_INLINE_CSS}</style>
</head>`;
}
function renderRails(i: EditorialCollageInputs): string {
return `
<div class='side-rail right' data-od-id='rail-right'>
<span class='rail-text'>${i.brand.rails.right}</span>
</div>
<div class='side-rail left' data-od-id='rail-left'>
<span class='rail-text'>${i.brand.rails.left}</span>
</div>`;
}
function renderTopbar(i: EditorialCollageInputs): string {
const langs = i.brand.languages
.map((l, idx) => (idx === 0 ? `<b>${l}</b>` : l))
.join(' · ');
return `
<div class='topbar' data-od-id='topbar'>
<div class='container topbar-inner'>
<span><b>OD / ${i.brand.year}</b> &nbsp;·&nbsp; ${i.brand.edition}</span>
<span class='mid'>
<span>Filed under <b class='coral'>${i.brand.filed_under}</b></span>
<span>${i.brand.license} · Made on Earth</span>
</span>
<span class='right'>
<a class='topbar-link' href='${i.brand.primary_url}/releases'${ext(i.brand.primary_url)}><span class='pulse'></span>${i.brand.status}</a>
<span>${langs}</span>
</span>
</div>
</div>`;
}
function renderNav(i: EditorialCollageInputs): string {
const links = i.nav
.map(
(link) =>
`<li><a href='${link.href}'${ext(link.href)}>${link.label}${
link.count ? `<span class='num'>${link.count}</span>` : ''
}</a></li>`,
)
.join('\n ');
return `
<header class='nav' data-od-id='nav'>
<div class='container nav-inner'>
<a href='#top' class='brand'>
<span class='brand-mark'>${i.brand.mark}</span>
<span>${i.brand.name}</span>
<span class='brand-meta'><b>${i.brand.meta.title}</b>${i.brand.meta.subtitle}</span>
</a>
<nav>
<ul class='nav-links'>
${links}
</ul>
</nav>
<div class='nav-side'>
${
i.brand.download_url
? `<a class='nav-cta ghost' href='${i.brand.download_url}'${ext(i.brand.download_url)}>${i.brand.download_url_label ?? 'Download'}</a>
`
: ''
}<a class='nav-cta' href='${i.brand.primary_url}'${ext(i.brand.primary_url)}>${i.brand.primary_url_label}</a>
<span class='status-dot' aria-hidden='true'></span>
</div>
</div>
</header>`;
}
function renderSecRule(r: SectionRule): string {
return `
<div class='sec-rule'>
<span class='roman'>${r.roman}</span>
<span class='meta-grp'>
<span>${r.meta[0]}</span>
<span class='dot-mark'>${r.meta[1]}</span>
<span>${r.meta[2]}</span>
</span>
<span>${r.pagination}</span>
</div>`;
}
function renderHeroStat(s: HeroStat): string {
const variant = s.variant ?? 'dashed';
const ringClass = variant === 'solid' ? 'ring solid' : variant === 'coral' ? 'ring coral' : 'ring';
return `<div class='stat'>
<span class='${ringClass}'>${s.value}</span>
<span class='stat-label'><b>${s.label}</b>${s.sub}</span>
</div>`;
}
function renderHeroIndex(item: HeroIndexItem): string {
return `<span${item.active ? ` class='on'` : ''}><span class='n'>${item.num}</span>${item.label}</span>`;
}
function renderHero(i: EditorialCollageInputs): string {
const stats = i.hero.stats.map(renderHeroStat).join('\n ');
const index = i.hero.index.map(renderHeroIndex).join('\n ');
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
return `
<section class='hero' id='top' data-od-id='hero'>
<div class='container'>
<div class='sec-rule'>
<span class='roman'>I.</span>
<span class='meta-grp'>
<span>Hero / Cover Plate</span>
<span class='dot-mark'>•</span>
<span>${i.brand.name} / Volume 01</span>
</span>
<span>001 / 008</span>
</div>
</div>
<div class='container hero-grid'>
<div class='hero-copy'>
<span class='label' data-reveal>${i.hero.label} <span class='ix'>${i.hero.ix}</span></span>
<h1 class='display' data-reveal>${mixed(i.hero.headline)}</h1>
<p class='lead' data-reveal>${i.hero.lead}</p>
<div class='hero-actions' data-reveal>
<a class='btn btn-primary' href='${i.hero.primary.href}'${ext(i.hero.primary.href)}>
${i.hero.primary.label}
<span class='arrow'>${ARROW_OUT}</span>
</a>
<a class='btn btn-ghost' href='${i.hero.secondary.href}'${ext(i.hero.secondary.href)}>
${i.hero.secondary.label}
<span class='arrow'>${ARROW_PLUS}</span>
</a>
</div>
<div class='hero-stats' data-reveal>
${stats}
</div>
<div class='hero-foot' data-reveal>
<span class='meta'>${i.hero.meta}</span>
<span class='coord'>${i.brand.coordinates}</span>
</div>
</div>
<div class='hero-art' data-reveal='scale'>
<span class='corner tl'></span>
<span class='corner tr'></span>
<span class='corner bl'></span>
<span class='corner br'></span>
<span class='annot annot-tl coord'>${i.hero.annotations.tl}</span>
<span class='annot annot-tr'>${i.hero.annotations.tr}</span>
<span class='annot annot-bl coord'>${i.hero.annotations.bl}</span>
<span class='annot annot-br'>${i.hero.annotations.br}</span>
<img src='${assets}hero.png' alt='' />
<div class='index'>
${index}
</div>
</div>
</div>
</section>`;
}
function renderAbout(i: EditorialCollageInputs): string {
const r = i.rules.about;
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
return `
<section class='about' data-od-id='about'>
<div class='container'>
${renderSecRule(r).trim()}
<div class='about-grid'>
<div class='about-copy' data-reveal>
<span class='label'>${i.about.label} <span class='ix'>${i.about.ix}</span></span>
<h2 class='display'>${mixed(i.about.headline)}</h2>
<p class='lead'>${i.about.lead}</p>
<a class='btn btn-ghost' href='${i.about.cta_href}'${ext(i.about.cta_href)}>
${i.about.cta_label}
<span class='arrow'>${ARROW_OUT}</span>
</a>
<div class='footer-row'>
<span class='mark'>${i.brand.mark}</span>
<span>${i.about.footer_text}</span>
<span class='stamp'>
<span>${i.about.stamp_top}</span>
<span style='color: var(--ink);'>${i.about.stamp_bottom}</span>
</span>
</div>
</div>
<div class='about-art' data-reveal='right'>
<img src='${assets}about.png' alt='' />
<div class='about-side-note'>
<b></b>
${i.about.side_note}
</div>
<div class='about-caption'>
<b>${i.about.caption.bold}</b>
${i.about.caption.rest}
</div>
</div>
</div>
</div>
</section>`;
}
function renderCapabilityCard(c: CapabilityCard): string {
return `<div class='card' data-reveal>
<div class='num'>${c.num}<span class='tag'>${c.tag}</span></div>
<svg class='icon' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.5'>
${c.icon_svg}
</svg>
<h3>${br(c.title)}</h3>
<p>${c.body}</p>
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Learn more about ${c.tag}'>
${ARROW_OUT}
</a>
</div>`;
}
function renderCapabilities(i: EditorialCollageInputs): string {
const cards = i.capabilities.cards.map(renderCapabilityCard).join('\n ');
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
return `
<section class='capabilities' id='agents' data-od-id='capabilities'>
<div class='container'>
${renderSecRule(i.rules.capabilities).trim()}
<div class='capabilities-grid'>
<div class='capabilities-art' data-reveal='left'>
<span class='corner tl'></span>
<span class='corner br'></span>
<img src='${assets}capabilities.png' alt='' />
<div class='ribbon'>${i.capabilities.ribbon}</div>
</div>
<div class='capabilities-copy' data-reveal>
<span class='label'>${i.capabilities.label} <span class='ix'>${i.capabilities.ix}</span></span>
<h2 class='display'>${mixed(i.capabilities.headline)}</h2>
<p class='lead'>${i.capabilities.lead}</p>
<div class='cards'>
${cards}
</div>
</div>
</div>
</div>
</section>`;
}
function renderLabPill(p: LabPill): string {
return `<button class='pill${p.active ? ' active' : ''}'>${p.label}<span class='count'>${p.count}</span></button>`;
}
function renderLabCard(c: LabCard, n: number, assets: string): string {
return `<div class='lab' data-reveal>
<div class='lab-img'><span class='badge'>${c.badge}</span><img src='${assets}lab-${n}.png' alt='' /></div>
<div class='num-row'><span>${c.num}</span><span>${c.year}</span></div>
<h4>${c.title}</h4>
<p>${c.body}</p>
<a class='arrow-mark' href='${c.href}'${ext(c.href)} aria-label='Open ${c.title}'>${ARROW_OUT}</a>
</div>`;
}
function renderLabs(i: EditorialCollageInputs): string {
const pills = i.labs.pills.map(renderLabPill).join('\n ');
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
const cards = i.labs.cards
.map((c, idx) => renderLabCard(c, idx + 1, assets))
.join('\n ');
const progress = Array.from({ length: i.labs.progress.total }, (_, k) =>
k < i.labs.progress.filled ? `<span class='on'></span>` : `<span></span>`,
).join('');
return `
<section class='labs' id='labs' data-od-id='labs'>
<div class='container'>
${renderSecRule(i.rules.labs).trim()}
<div class='labs-head'>
<div data-reveal>
<span class='label'>${i.labs.label} <span class='ix'>${i.labs.ix}</span></span>
<h2 class='display' style='margin-top:30px;'>${mixed(i.labs.headline)}</h2>
</div>
<div class='pills' data-reveal='right'>
${pills}
</div>
</div>
<div class='labs-meta'>
<span class='ring'>${i.labs.meta.ring}</span>
<div class='meta-text'>
<b>${i.labs.meta.bold}</b>
${i.labs.meta.sub}
</div>
</div>
<div class='labs-grid'>
${cards}
</div>
<div class='labs-foot'>
<div class='progress'>
${progress}
</div>
<span class='meta'>${i.labs.foot}</span>
</div>
</div>
</section>`;
}
function renderMethodStep(s: MethodStep, last: boolean, n: number, assets: string): string {
return `<div class='method-step' data-reveal>
<div class='num'>${s.num}</div>
<h4>${s.title}${last ? '' : ` <span class='arrow-r'>→</span>`}</h4>
<p>${s.body}</p>
<div class='img'><img src='${assets}method-${n}.png' alt='' /></div>
</div>`;
}
function renderMethod(i: EditorialCollageInputs): string {
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
const steps = i.method.steps
.map((s, idx, arr) => renderMethodStep(s, idx === arr.length - 1, idx + 1, assets))
.join('\n ');
return `
<section class='method' data-od-id='method'>
<div class='container'>
${renderSecRule(i.rules.method).trim()}
<div class='method-head'>
<div data-reveal>
<span class='label'>${i.method.label} <span class='ix'>${i.method.ix}</span></span>
<h2 class='display' style='margin-top:30px;'>${mixed(i.method.headline)}</h2>
</div>
<div class='right' data-reveal='right'>
<span class='plus'>+</span>
<p>${i.method.right}</p>
</div>
</div>
<div class='method-grid'>
${steps}
</div>
<div class='method-foot'>
<div class='left'>
<span class='ring'></span>
<span>${i.method.foot_left}</span>
</div>
<div class='right'><a class='method-repo-link' href='https://${i.method.foot_right_bold}'${ext('https://x')}><b>${i.method.foot_right_bold}</b></a> &nbsp;·&nbsp; ${i.method.foot_right_rest}</div>
</div>
</div>
</section>`;
}
function renderWorkCard(c: WorkCard, idx: number, assets: string, href: string): string {
return `<a class='work-card${idx === 1 ? ' alt' : ''}' data-reveal href='${href}'${ext(href)}>
<div class='label-row'>
<span class='small-label'>${c.small_label}</span>
<span class='index'>${c.index}</span>
</div>
<h3>${c.title}</h3>
<p>${c.body}</p>
<div class='img'><img src='${assets}work-${idx + 1}.png' alt='' /></div>
<div class='meta-row'>
<span class='year'>${c.year}</span>
<span>${c.tag}</span>
</div>
</a>`;
}
function renderWork(i: EditorialCollageInputs): string {
const r = i.rules.work;
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
// Use the first nav link as the work-card href fallback (we don't model per-card hrefs in WorkCard).
const fallbackHref = i.nav.find((l) => /skills/i.test(l.label))?.href ?? '#';
const cards = i.work.cards
.map((c, idx) => renderWorkCard(c, idx, assets, fallbackHref))
.join('\n ');
return `
<section class='tight' data-od-id='work'>
<div class='work'>
<div class='work-rule'>
<span class='roman'>${r.roman}</span>
<span style='display:inline-flex;gap:24px;'>
<span>${r.meta[0]}</span>
<span style='color:var(--coral);'>${r.meta[1]}</span>
<span>${r.meta[2]}</span>
</span>
<span>${r.pagination}</span>
</div>
<div class='work-grid'>
<div class='work-copy' data-reveal>
<span class='label'>${i.work.label}</span>
<h2>${mixed(i.work.headline)}</h2>
<a class='work-link' href='${i.work.link_href}'${ext(i.work.link_href)}>${i.work.link_label}</a>
</div>
${cards}
</div>
<div class='work-arrows'>
<button class='nav-btn'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M14 6l-6 6 6 6'/></svg></button>
<button class='nav-btn active'><svg width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6'><path d='M10 6l6 6-6 6'/></svg></button>
</div>
</div>
</section>`;
}
function renderPartner(p: Partner, href: string): string {
return `<a class='partner' data-reveal href='${href}'${ext(href)}>
<div class='glyph'>
<svg viewBox='0 0 80 30' fill='none' stroke='currentColor' stroke-width='2'>
${p.glyph_svg}
</svg>
</div>
<span>${p.name}</span>
<small>${p.role}</small>
</a>`;
}
function renderTestimonial(i: EditorialCollageInputs): string {
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
// Each Partner can carry its own href. We fall back to the testimonial
// read-more URL (then '#') so older brand inputs without per-partner
// links still render valid anchors.
const fallback = i.testimonial.read_more_href ?? '#';
const partners = i.testimonial.partners
.map((p) => renderPartner(p, p.href ?? fallback))
.join('\n ');
return `
<section class='testimonial' data-od-id='testimonial'>
<div class='container'>
${renderSecRule(i.rules.testimonial).trim()}
<div class='testimonial-grid'>
<div class='testimonial-copy' data-reveal>
<span class='label'>${i.testimonial.label} <span class='ix'>${i.testimonial.ix}</span></span>
<h2 style='margin-top:30px;'>&ldquo;${mixed(i.testimonial.quote)}&rdquo;</h2>
<div class='author'>
<span class='avatar'>${i.testimonial.author.initial}</span>
<p>${i.testimonial.author.name}<br/><span>${i.testimonial.author.title}</span></p>
</div>
<div class='divider'></div>
<p class='partners-text'>${i.testimonial.partners_text}</p>
<div class='partners'>
${partners}
</div>
<a class='read-more' href='${i.testimonial.read_more_href}'${ext(i.testimonial.read_more_href)}>${i.testimonial.read_more_label}</a>
</div>
<div class='testimonial-art' data-reveal='right'>
<img src='${assets}testimonial.png' alt='' />
</div>
</div>
</div>
</section>`;
}
function renderCTA(i: EditorialCollageInputs): string {
const assets = i.imagery.assets_path.replace(/\/?$/, '/');
return `
<section class='cta' id='contact' data-od-id='cta'>
<div class='container'>
${renderSecRule(i.rules.cta).trim()}
<div class='cta-grid'>
<div data-reveal>
<span class='label'>${i.cta.label} <span class='ix'>${i.cta.ix}</span></span>
<h2 class='display'>${mixed(i.cta.headline)}</h2>
<p class='lead'>${i.cta.lead}</p>
<div class='cta-actions'>
<a class='btn btn-primary' href='${i.cta.primary.href}'${ext(i.cta.primary.href)}>
${i.cta.primary.label}
<span class='arrow'>${ARROW_OUT}</span>
</a>
<a class='email-pill' href='${i.brand.contact_email}'${ext(i.brand.contact_email)}>
${/^mailto:/.test(i.brand.contact_email) ? i.brand.contact_email.replace(/^mailto:/, '') : 'Open an issue'}
<span class='arrow-circle'></span>
</a>
</div>
<div class='cta-foot'>
<span class='stamp'> Live</span>
<span>${i.brand.version} / ${i.brand.license}</span>
<span style='margin-left:auto;'>${i.brand.coordinates}</span>
</div>
</div>
<div class='cta-art' data-reveal='right'>
<img src='${assets}cta.png' alt='' />
<div class='index'>Nº 08</div>
<div class='ribbon'>${i.cta.ribbon}</div>
</div>
</div>
</div>
</section>`;
}
function renderFooterColumn(c: FooterColumn): string {
const links = c.links
.map((l) => `<li><a href='${l.href}'${ext(l.href)}>${l.label}</a></li>`)
.join('\n ');
return `<div class='foot-col'>
<h5>${c.title}</h5>
<ul>
${links}
</ul>
</div>`;
}
function renderFooter(i: EditorialCollageInputs): string {
const cols = i.footer.columns.map(renderFooterColumn).join('\n ');
// Resolve the footer brand CTA — explicit `footer.brand_cta` wins,
// otherwise inherit `brand.download_url` so a single field lights up
// both the nav and the footer download entry.
const brandCta =
i.footer.brand_cta ??
(i.brand.download_url
? {
label: i.brand.download_url_label ?? 'Download desktop',
href: i.brand.download_url,
meta: i.brand.version,
}
: null);
const brandCtaHtml = brandCta
? `
<a class='foot-cta' href='${brandCta.href}'${ext(brandCta.href)}>${brandCta.label}${
brandCta.meta ? `<span class='meta'>${brandCta.meta}</span>` : ''
}</a>`
: '';
return `
<footer data-od-id='footer'>
<div class='container'>
<div class='foot-grid'>
<div class='foot-brand'>
<a href='#top' class='brand'>
<span class='brand-mark'>${i.brand.mark}</span>
<span>${i.brand.name}</span>
</a>
<p style='margin-top:18px;'>${i.footer.brand_description}</p>${brandCtaHtml}
</div>
${cols}
</div>
<div class='foot-bottom'>
<span><span class='pulse'></span>● <b style='color:var(--ink);'>${i.brand.name}</b> · ${i.brand.license} · ${i.brand.year} / ${i.brand.edition}</span>
<span class='right'>
<span>${i.brand.location}</span>
<span>${i.brand.coordinates}</span>
<span style='color:var(--coral);'>♥ ${i.brand.year_roman}</span>
</span>
</div>
<div class='foot-mega'>
<div class='word' data-reveal='rise-lg'>${mixed(i.footer.mega)}</div>
</div>
</div>
</footer>`;
}
function renderWire(i: EditorialCollageInputs): string {
const w = i.wire;
if (!w || (w.cities.length === 0 && w.contributors.length === 0)) return '';
// Duplicate each list so the marquee CSS animation translates -50%
// and lands seamlessly at the start of the second copy.
const cityRow = [...w.cities, ...w.cities]
.map(
(c) =>
`<span class='wire-item'><span class='wire-dot'>·</span><span class='wire-coord'>${c.coord}</span><span class='wire-name'>${c.name}</span></span>`,
)
.join('\n ');
const contribRow = [...w.contributors, ...w.contributors]
.map(
(c) =>
`<a class='wire-item is-link' href='${c.href}'${ext(c.href)} aria-label='Open ${c.handle} on GitHub'><span class='wire-dot'>·</span><span class='wire-handle'>@${c.handle}</span><span class='wire-role'>${c.role}</span></a>`,
)
.join('\n ');
const subtitle =
w.subtitle ??
`Open · ${w.cities.length} cities · ${Math.max(w.contributors.length - 1, 0)} contributors`;
return `
<section class='wire' data-od-id='wire' aria-label='Global wire — cities and contributors'>
<div class='container wire-inner'>
<div class='wire-left'>
<span class='wire-mark' aria-hidden='true'><span class='wire-pulse'></span></span>
<span class='wire-title'>
<b>${w.title}</b>
<span>${subtitle}</span>
</span>
</div>
<div class='wire-rows'>
<div class='wire-row'>
<div class='marquee-track' aria-hidden='true'>
${cityRow}
</div>
</div>
<div class='wire-row reverse'>
<div class='marquee-track'>
${contribRow}
</div>
</div>
</div>
</div>
</section>`;
}
/* ------------------------------------------------------------------ *
* inline scripts (mirror apps/landing-page/app/_components/*)
* ------------------------------------------------------------------ */
const REVEAL_AND_NAV_SCRIPT = `
<script>
/*
* Scroll-reveal observer — mirrors apps/landing-page/app/_components/reveal-root.tsx.
* Watches every [data-reveal] element and flips data-revealed='true'
* when it first enters the viewport, triggering the CSS transition.
*/
(function () {
var elements = document.querySelectorAll('[data-reveal]:not([data-revealed])');
if (!elements.length) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (var i = 0; i < elements.length; i++) elements[i].dataset.revealed = 'true';
return;
}
var observer = new IntersectionObserver(function (entries) {
for (var i = 0; i < entries.length; i++) {
if (!entries[i].isIntersecting) continue;
entries[i].target.dataset.revealed = 'true';
observer.unobserve(entries[i].target);
}
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
for (var j = 0; j < elements.length; j++) observer.observe(elements[j]);
})();
/*
* Headroom-style sticky header — mirrors apps/landing-page/app/_components/header.tsx.
* Hides the nav on downward scroll, re-pins it on upward scroll, and
* always keeps it visible near the top of the page.
*/
(function () {
var nav = document.querySelector('header.nav');
if (!nav) return;
var SHOW_TOP = 100;
var DELTA = 6;
var lastY = window.scrollY || 0;
function onScroll() {
var y = window.scrollY || 0;
var d = y - lastY;
if (y <= SHOW_TOP) {
nav.classList.remove('is-hidden');
} else if (d > DELTA) {
nav.classList.add('is-hidden');
} else if (d < -DELTA) {
nav.classList.remove('is-hidden');
}
lastY = y;
}
window.addEventListener('scroll', onScroll, { passive: true });
})();
</script>`;
const STAR_SCRIPT_TEMPLATE = (repo: string) => `
<script>
/*
* GitHub star count — pulls live count and replaces the placeholder
* text in the nav CTA. Failures fall back silently.
*/
(function () {
var cta = document.querySelector('a.nav-cta:not(.ghost)');
if (!cta) return;
function format(n) {
if (!isFinite(n) || n <= 0) return '0';
if (n < 1000) return String(n);
var k = (n / 1000).toFixed(1).replace(/\\.0$/, '');
return k + 'K';
}
fetch('https://api.github.com/repos/${repo}', {
headers: { Accept: 'application/vnd.github+json' }
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data || typeof data.stargazers_count !== 'number') return;
cta.textContent = 'Star · ' + format(data.stargazers_count);
cta.setAttribute('aria-label', 'Star on GitHub — ' + format(data.stargazers_count) + ' stars');
})
.catch(function () { /* leave placeholder on failure */ });
})();
</script>`;
/* ------------------------------------------------------------------ *
* top-level
* ------------------------------------------------------------------ */
function repoFromUrl(url: string): string | null {
const m = url.match(/github\.com\/([^/]+)\/([^/?#]+)/i);
return m ? `${m[1]}/${m[2]}` : null;
}
export function renderPage(inputs: EditorialCollageInputs, css: string): string {
const repo = repoFromUrl(inputs.brand.primary_url);
const starScript = repo ? STAR_SCRIPT_TEMPLATE(repo) : '';
return [
`<!DOCTYPE html>`,
`<html lang='${inputs.brand.locale ?? 'en'}'>`,
renderHead(inputs, css),
`<body>`,
renderRails(inputs),
`<div class='shell'>`,
renderTopbar(inputs),
renderNav(inputs),
renderHero(inputs),
renderWire(inputs),
renderAbout(inputs),
renderCapabilities(inputs),
renderLabs(inputs),
renderMethod(inputs),
renderWork(inputs),
renderTestimonial(inputs),
renderCTA(inputs),
renderFooter(inputs),
`</div>`,
REVEAL_AND_NAV_SCRIPT,
starScript,
`</body>`,
`</html>`,
``,
].join('\n');
}
async function main(): Promise<void> {
const [, , inputsArg, outputArg] = process.argv;
if (!inputsArg || !outputArg) {
console.error('Usage: npx tsx scripts/compose.ts <inputs.json> <output.html>');
process.exit(1);
}
const inputsPath = isAbsolute(inputsArg) ? inputsArg : resolve(process.cwd(), inputsArg);
const outputPath = isAbsolute(outputArg) ? outputArg : resolve(process.cwd(), outputArg);
const stylesPath = resolve(SKILL_ROOT, 'styles.css');
const [inputsRaw, css] = await Promise.all([
readFile(inputsPath, 'utf8'),
readFile(stylesPath, 'utf8'),
]);
const inputs = JSON.parse(inputsRaw) as EditorialCollageInputs;
const html = renderPage(inputs, css);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, html, 'utf8');
console.log(`✓ wrote ${outputPath} (${(html.length / 1024).toFixed(1)} KB)`);
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -0,0 +1,325 @@
#!/usr/bin/env -S npx -y tsx
/**
* open-design-landing — gpt-image-2 generator (fal.ai backend).
*
* Generates the 16 collage assets defined in `assets/image-manifest.json`
* by composing per-slot prompts (style anchor + brand variables +
* per-slot composition) and calling fal.ai's `openai/gpt-image-2`
* synchronous endpoint. Downloads each result to the `--out` directory.
*
* Requires `FAL_KEY` in the environment. If it is missing, the script
* prints the prompts it would have sent so an operator can route them
* through the `/gpt-image-fal` skill manually, or set the key and re-run.
*
* Usage:
* FAL_KEY=... npx tsx scripts/imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta]
*
* Cost note: 16 images × ~$0.025 each ≈ $0.40 per full run at high
* quality. Re-running is idempotent — slots whose target file already
* exists are skipped unless `--force` is passed.
*/
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import { resolve, dirname, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { EditorialCollageInputs } from '../schema';
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
interface ManifestSlot {
id: string;
file: string;
width: number;
height: number;
ratio: string;
prompt_section: string;
required: boolean;
rekey_on_brand_change: boolean;
}
interface Manifest { slots: ManifestSlot[] }
/* ------------------------------------------------------------------ *
* prompt constants (mirror assets/imagegen-prompts.md verbatim)
* ------------------------------------------------------------------ */
const STYLE_ANCHOR = `Use case: ads-marketing
Asset type: editorial website hero / creative studio landing page visual
Primary request: Generate a refined editorial web page composition in the
same visual language as a high-end creative AI research studio.
Style/medium: sophisticated digital collage, modern Swiss editorial layout,
Bauhaus geometric composition, classical plaster sculpture fragments,
brutalist/minimal architecture, art-direction website mockup, premium
agency aesthetic.
Scene/backdrop: warm off-white handmade paper background with subtle
grain, faint vertical folds, scanned paper fibers, lightly aged print
texture, thin drafting lines and registration marks.
Subject: a surreal collage combining a cropped classical plaster head or
face fragment, abstract architectural blocks, archways or stairs, sky
cutouts, one small human figure, a delicate tree or botanical element,
and geometric color planes.
Composition/framing: wide 16:9 web page layout, strong asymmetrical
grid, generous negative space, large typography area on the left or
top-left, collage focal object on the right or center-right, precise
alignment, thin divider lines, small UI navigation details.
Lighting/mood: soft diffused daylight, museum-like calm, intelligent,
restrained, tactile, poetic, premium, research-driven.
Color palette: warm ivory, stone beige, soft concrete gray, deep black
text, muted charcoal, washed coral-red accent, occasional mustard-yellow
accent, pale sky blue only inside small sky/image cutouts.
Materials/textures: matte plaster, limestone, travertine, concrete, rough
torn paper edges, halftone print grain, translucent vellum-like overlays,
fine grid paper, dotted matrix patterns.
Typography: large clean grotesk sans-serif for main headline, elegant
high-contrast italic serif for emphasized words, tiny uppercase coral
labels, compact UI microcopy. Text must be crisp, readable, and spelled
exactly as provided.
Graphic details: thin hairline circles, partial arcs, crosshair marks,
small black dots, dotted grids, fine coordinate lines, numbered
annotations, small arrow buttons, simple pill buttons, minimal logo mark.
Constraints: preserve a high-end editorial web design feel; keep spacing
elegant and uncluttered; no cartoon style; no neon colors; no glossy 3D;
no busy gradients; no generic stock-photo look.
Avoid: distorted typography, misspelled text, extra random words, heavy
shadows, childish illustration, cyberpunk, saturated purple/blue palette,
plastic materials, overly decorative UI cards, cluttered composition,
low-resolution textures, watermarks.`;
const PER_SLOT: Record<string, string> = {
hero: `Composition/framing: left half is intentionally empty/quiet to allow real
HTML headline overlay; right half holds a tall surreal collage of a
cropped classical plaster head with the top sliced open, sky/architecture
cutouts visible inside the head, a delicate young tree growing through
the composition, a coral sun disk behind, a mustard accent ring at the
base, hairline coordinate marks and dotted matrices around it, a small
human figure standing for scale in the lower-left of the image. Page
type: hero landing.`,
about: `Composition: a surreal museum-vitrine arrangement of a partial plaster
profile head facing right, with an open archway carved through the
torso, sky cutout inside the arch, a tree seedling growing out of the
shoulder, and a coral half-circle behind the head. Tiny dotted hairlines
trace contours. Strong negative space top-left for a side-note overlay.
Page type: about / manifesto plate.`,
capabilities: `Composition: a Bauhaus-grid stack of architectural fragments — a coral
arch on the left, a beige concrete column center, a mustard small disc
upper-right, a delicate tree mid-frame, a small classical hand fragment
holding a pencil bottom-center. Crosshair and circular hairlines
overlay. Page type: capabilities matrix.`,
'method-1': `Composition: a magnifying glass over a small architectural map. Coral
accent disc behind. One numbered annotation tag '01 · Detect'.
Page type: method tile.`,
'method-2': `Composition: a clipboard with a tiny questionnaire and a coral pen,
on the warm paper ground. Mustard sticker corner. Annotation '02 ·
Discover'. Page type: method tile.`,
'method-3': `Composition: a compass + ruler + color swatch fan arranged like an
architect's drafting kit. Coral accent on the swatch. Annotation
'03 · Direct'. Page type: method tile.`,
'method-4': `Composition: a printer's tray with stacked paper sheets exiting,
mustard ribbon tag. Annotation '04 · Deliver'. Page type: method tile.`,
'lab-1': `Portrait composition: a stack of folded magazine spreads, slight
perspective, coral spine, mustard tab. Page type: lab card.`,
'lab-2': `Portrait composition: a film strip + a synthetic eye + a soundwave
hairline. Coral arc behind. Page type: lab card.`,
'lab-3': `Portrait composition: a typewriter with prompt cards in the carriage,
coral platen knob. Page type: lab card.`,
'lab-4': `Portrait composition: five small dotted gauges arranged in a circle
(5-dim critique), one filled coral. Page type: lab card.`,
'lab-5': `Portrait composition: a glass dome / cloche over a tiny sandbox
cityscape, mustard sun behind. Page type: lab card.`,
'work-1': `Portrait composition: an oversized open magazine spread on a desk,
coral spine, mustard tab. Slight perspective. Page type: work card.`,
'work-2': `Portrait composition: a concrete dashboard slab, a coral graph bar
rising, a small classical bust beside it for scale. Page type: work card.`,
testimonial: `Composition: a classical plaster bust facing 3/4 left, slightly cropped,
with a small sky cutout where the eye would be, a thin coral arc around
the back of the head, mustard dot at the chin. Quiet background, lots of
negative space upper right. Page type: testimonial portrait.`,
cta: `Composition: a closing-plate collage — a mustard sun behind a single
coral arch on the right, a delicate tree growing through the arch, a
small human figure in the lower-left foreground reading a folded
broadsheet, hairline coordinate ladder up the left edge, and a small
"FIN." dotted seal in the upper-right. Page type: closing CTA plate.`,
};
/* ------------------------------------------------------------------ *
* prompt builder
* ------------------------------------------------------------------ */
function brandVarsBlock(inputs: EditorialCollageInputs): string {
// Pull the brand-shaped strings the model should bias toward.
const navText = inputs.nav.slice(0, 5).map((n) => `"${n.label}"`).join(', ');
const eyebrow = `${inputs.hero.label} ${inputs.hero.ix}`;
const headline = inputs.hero.headline.map((s) => s.text).join('');
const italic = inputs.hero.headline.filter((s) => s.em).map((s) => `"${s.text}"`).join(', ');
const body = inputs.hero.lead.replace(/<[^>]+>/g, '').replace(/&[^;]+;/g, '');
return `Brand/logo text: "${inputs.brand.name}"
Navigation text: ${navText}
Eyebrow label: "${eyebrow}"
Main headline: "${headline}"
Italic emphasis words: ${italic}
Body copy: "${body}"
Primary button: "${inputs.hero.primary.label}"
Secondary button: "${inputs.hero.secondary.label}"
Footer/micro labels: "${inputs.brand.location}", "${inputs.brand.coordinates}"`;
}
export function promptForSlot(slot: ManifestSlot, inputs: EditorialCollageInputs): string {
const override = inputs.imagery.prompts?.[slot.id];
const composition = override ?? PER_SLOT[slot.id] ?? `Page type: ${slot.id} plate.`;
return [STYLE_ANCHOR, brandVarsBlock(inputs), composition].join('\n\n');
}
/* ------------------------------------------------------------------ *
* fal.ai client (raw fetch — no npm dependency)
* ------------------------------------------------------------------ */
interface FalImageResult {
images: Array<{ url: string; width?: number; height?: number; content_type?: string }>;
}
async function callFalGptImage(
prompt: string,
width: number,
height: number,
apiKey: string,
): Promise<Uint8Array> {
// fal.ai exposes both queue (async) and run (sync) endpoints. Use sync
// for simpler scripting; per-image latency is ~25-45s.
const endpoint = 'https://fal.run/openai/gpt-image-2';
const res = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Key ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt,
image_size: { width, height },
num_images: 1,
quality: 'high',
output_format: 'png',
background: 'opaque',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable>');
throw new Error(`fal.run/openai/gpt-image-2 ${res.status}: ${text.slice(0, 400)}`);
}
const json = (await res.json()) as FalImageResult;
const url = json.images?.[0]?.url;
if (!url) throw new Error('fal.ai response missing images[0].url');
const dl = await fetch(url);
if (!dl.ok) throw new Error(`download ${url} failed: ${dl.status}`);
const buf = await dl.arrayBuffer();
return new Uint8Array(buf);
}
/* ------------------------------------------------------------------ *
* top-level
* ------------------------------------------------------------------ */
interface CliArgs {
inputsPath: string;
outDir: string;
only?: Set<string>;
force: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const inputsPath = argv[2];
if (!inputsPath || inputsPath.startsWith('--')) {
throw new Error('Usage: imagegen.ts <inputs.json> [--out=assets/] [--only=hero,cta] [--force]');
}
let outDir = './assets/';
let only: Set<string> | undefined;
let force = false;
for (const arg of argv.slice(3)) {
if (arg.startsWith('--out=')) outDir = arg.slice('--out='.length);
else if (arg.startsWith('--only=')) only = new Set(arg.slice('--only='.length).split(','));
else if (arg === '--force') force = true;
else throw new Error(`unknown arg: ${arg}`);
}
return {
inputsPath: isAbsolute(inputsPath) ? inputsPath : resolve(process.cwd(), inputsPath),
outDir: isAbsolute(outDir) ? outDir : resolve(process.cwd(), outDir),
only,
force,
};
}
async function fileExists(path: string): Promise<boolean> {
try {
const s = await stat(path);
return s.isFile() && s.size > 256;
} catch {
return false;
}
}
async function main(): Promise<void> {
const { inputsPath, outDir, only, force } = parseArgs(process.argv);
const apiKey = process.env.FAL_KEY ?? '';
const dryRun = !apiKey;
const inputs = JSON.parse(await readFile(inputsPath, 'utf8')) as EditorialCollageInputs;
const manifestPath = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest;
await mkdir(outDir, { recursive: true });
const targets = manifest.slots.filter((s) => !only || only.has(s.id));
if (dryRun) {
console.log(`FAL_KEY not set — dry run. Printing prompts for ${targets.length} slot(s).\n`);
} else {
console.log(`Generating ${targets.length} slot(s) → ${outDir}`);
}
for (const slot of targets) {
const target = resolve(outDir, slot.file);
if (!force && (await fileExists(target))) {
console.log(`· ${slot.id} — skip (exists)`);
continue;
}
const prompt = promptForSlot(slot, inputs);
if (dryRun) {
console.log(`\n=== ${slot.id} (${slot.width}×${slot.height}) → ${slot.file} ===`);
console.log(prompt);
console.log(`=== end ${slot.id} ===\n`);
continue;
}
process.stdout.write(`· ${slot.id} (${slot.width}×${slot.height}) … `);
try {
const png = await callFalGptImage(prompt, slot.width, slot.height, apiKey);
await writeFile(target, png);
console.log(`ok (${(png.byteLength / 1024).toFixed(0)} KB)`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.log(`fail — ${msg}`);
}
}
if (dryRun) {
console.log(`\nNext: set FAL_KEY in env and re-run to generate, or paste each prompt block into /gpt-image-fal manually.`);
}
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -0,0 +1,174 @@
#!/usr/bin/env -S npx -y tsx
/**
* open-design-landing — SVG framework placeholder generator.
*
* When `imagery.strategy === 'placeholder'`, this script writes one
* paper-textured SVG file per slot in `assets/image-manifest.json`.
* The generated files live alongside the schema-named PNGs that the
* composer references (`hero.png`, `about.png`, `lab-1.png`, …) so
* the layout renders fully without any image budget.
*
* Each placeholder shows: slot id · ratio · pixel dimensions · the
* `prompt_section` hint copied from the manifest. Drop the real PNG
* with the same filename to swap in production imagery; no markup
* change required.
*
* Usage:
* npx tsx scripts/placeholder.ts <out-dir>
*
* Default out-dir is `./assets/`.
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { resolve, dirname, isAbsolute, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const SKILL_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
interface ManifestSlot {
id: string;
file: string;
width: number;
height: number;
ratio: string;
prompt_section: string;
required: boolean;
rekey_on_brand_change: boolean;
}
interface Manifest {
skill: string;
design_system: string;
slots: ManifestSlot[];
}
const PAPER = '#efe7d2';
const INK_FAINT = '#8b8676';
const CORAL = '#ed6f5c';
const LINE = 'rgba(21, 20, 15, 0.16)';
/** Compose a single paper-textured SVG for one slot. */
export function placeholderSvg(slot: ManifestSlot): string {
const w = slot.width;
const h = slot.height;
const cx = w / 2;
const cy = h / 2;
const isPortrait = h > w;
const titleSize = Math.round(Math.min(w, h) * (isPortrait ? 0.075 : 0.07));
const metaSize = Math.round(Math.min(w, h) * 0.028);
const dimsSize = Math.round(Math.min(w, h) * 0.024);
// Inner frame inset.
const inset = Math.round(Math.min(w, h) * 0.04);
const frame = {
x: inset,
y: inset,
w: w - inset * 2,
h: h - inset * 2,
};
// Diagonal strokes for the classic "image goes here" cross.
const cross = `
<line x1='${frame.x}' y1='${frame.y}' x2='${frame.x + frame.w}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
<line x1='${frame.x + frame.w}' y1='${frame.y}' x2='${frame.x}' y2='${frame.y + frame.h}' stroke='${INK_FAINT}' stroke-opacity='0.22' stroke-width='1' />
`;
const cornerLen = Math.round(Math.min(w, h) * 0.05);
const corners = `
<path d='M${frame.x} ${frame.y + cornerLen} L${frame.x} ${frame.y} L${frame.x + cornerLen} ${frame.y}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
<path d='M${frame.x + frame.w - cornerLen} ${frame.y} L${frame.x + frame.w} ${frame.y} L${frame.x + frame.w} ${frame.y + cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
<path d='M${frame.x} ${frame.y + frame.h - cornerLen} L${frame.x} ${frame.y + frame.h} L${frame.x + cornerLen} ${frame.y + frame.h}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
<path d='M${frame.x + frame.w - cornerLen} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h} L${frame.x + frame.w} ${frame.y + frame.h - cornerLen}' stroke='${INK_FAINT}' fill='none' stroke-width='1.5' />
`;
return `<?xml version='1.0' encoding='UTF-8'?>
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 ${w} ${h}' width='${w}' height='${h}'>
<defs>
<filter id='paper'>
<feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/>
<feColorMatrix values='0 0 0 0 0.18 0 0 0 0 0.16 0 0 0 0 0.12 0 0 0 0.07 0'/>
</filter>
</defs>
<!-- paper base -->
<rect width='${w}' height='${h}' fill='${PAPER}' />
<rect width='${w}' height='${h}' filter='url(#paper)' />
<!-- frame -->
<rect x='${frame.x}' y='${frame.y}' width='${frame.w}' height='${frame.h}' fill='none' stroke='${LINE}' stroke-dasharray='6 6' />
${cross}
${corners}
<!-- coral plate index, top-left -->
<text x='${inset + 14}' y='${inset + 26}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' font-weight='600' letter-spacing='2' fill='${CORAL}'>PLATE · ${slot.id.toUpperCase()}</text>
<!-- coordinates, top-right -->
<text x='${w - inset - 14}' y='${inset + 26}' text-anchor='end' font-family='JetBrains Mono, monospace' font-size='${dimsSize}' fill='${INK_FAINT}'>${w} × ${h} · ${slot.ratio}</text>
<!-- centered title block -->
<text x='${cx}' y='${cy - titleSize * 0.2}' text-anchor='middle' font-family='Playfair Display, serif' font-style='italic' font-weight='500' font-size='${titleSize}' fill='#15140f'>${escapeXml(slot.id)}</text>
<text x='${cx}' y='${cy + metaSize * 1.6}' text-anchor='middle' font-family='Inter Tight, system-ui, sans-serif' font-size='${metaSize}' letter-spacing='3' fill='${INK_FAINT}'>${escapeXml(slot.prompt_section.toUpperCase())}</text>
<!-- bottom slug -->
<text x='${inset + 14}' y='${h - inset - 14}' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>${slot.required ? 'REQUIRED' : 'OPTIONAL'} · ${slot.rekey_on_brand_change ? 'REKEY ON BRAND' : 'STABLE'}</text>
<text x='${w - inset - 14}' y='${h - inset - 14}' text-anchor='end' font-family='Inter Tight, system-ui, sans-serif' font-size='${dimsSize}' letter-spacing='2' fill='${INK_FAINT}'>OPEN DESIGN · ATELIER ZERO</text>
</svg>`;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
async function loadManifest(): Promise<Manifest> {
const path = resolve(SKILL_ROOT, 'assets', 'image-manifest.json');
return JSON.parse(await readFile(path, 'utf8')) as Manifest;
}
/**
* Write `<out>/<slot.file>` for every slot. The composer references
* slots by .png filename; we honor that by writing `<basename>.svg`
* AND a `<basename>.png.svg` symlink-style fallback. Most static
* hosts serve SVG to <img> just fine, so the practical convention
* is: if you want placeholders, point your `imagery.assets_path` at
* a directory of `.svg` files OR rename the SVGs to `.png` (some
* browsers honor extensionless content-sniffing).
*
* For the most reliable result, write BOTH:
* - `<id>.svg` — clean, editable
* - `<file>` — same SVG content under the .png filename so the
* composer's `<img src='./assets/<id>.png'>` works
* without changing markup.
*/
export async function writePlaceholders(outDir: string): Promise<string[]> {
const manifest = await loadManifest();
await mkdir(outDir, { recursive: true });
const written: string[] = [];
for (const slot of manifest.slots) {
const svg = placeholderSvg(slot);
const svgPath = resolve(outDir, `${slot.id}.svg`);
const pngPath = resolve(outDir, slot.file);
await writeFile(svgPath, svg, 'utf8');
await writeFile(pngPath, svg, 'utf8');
written.push(svgPath, pngPath);
}
return written;
}
async function main(): Promise<void> {
const [, , outArg] = process.argv;
const out = isAbsolute(outArg ?? '')
? outArg!
: resolve(process.cwd(), outArg ?? './assets/');
const written = await writePlaceholders(out);
const pngs = written.filter((p) => p.endsWith('.png')).length;
const svgs = written.filter((p) => p.endsWith('.svg')).length;
console.log(`✓ wrote ${pngs} png-named placeholders + ${svgs} svg files into ${out}`);
console.log(` (${written.map((p) => basename(p)).join(', ')})`);
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
File diff suppressed because it is too large Load Diff