open-design/skills/open-design-landing/schema.ts
Tom Huang aefba56a3f
feat(skills): open-design-landing rename, kami skills, landing OG (#428)
* feat(skills): open-design-landing rename, kami skills, landing OG

- Rename editorial-collage skills to open-design-landing and -deck; refresh examples and compose script layout
- Add kami-deck and kami-landing skills with HTML examples
- Landing page: og.astro, index wiring, and style tweaks; package.json bump
- Web i18n: German and Russian copy for renamed and new skills
- Daemon test: update skill-asset-rewrite expectations for new paths
- Design systems: README and atelier-zero doc touch-ups
- Cross-skill SKILL.md reference updates

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(landing-page): document version-slot invariant and deprecation timeline

Address P3 review notes on PR #428:
- Note the `data-github-version` wrapper invariant (version string only)
  near the canonical URL block in `app/page.tsx`.
- Expand the `formatVersion` helper comment in `app/pages/index.astro`
  with concrete `release.name` / `tag_name` example shapes for each
  branch of the regex fallback.
- Tighten the `EditorialCollageDeckInputs` deprecation in
  `skills/open-design-landing-deck/schema.ts` to a specific removal
  version (v0.4.0) and add a "Migrating from editorial-collage-deck"
  section to the skill README.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* docs(landing-page, skills): clarify version slot script and rename migrations

- Describe GitHub version slots as driven by the inline enhancement script,
  not React hydration.
- Add editorial-collage → open-design-landing migration notes; fix README
  link copy (Astro static landing app).
- Extend deck README migration table with shared asset path renames.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(daemon): alias deprecated editorial-collage skill ids

The PR renames the editorial-collage / editorial-collage-deck skills
to open-design-landing / open-design-landing-deck, but the daemon
persists exact skill_id strings on projects and resolves them via
listSkills().find((s) => s.id === storedId). After the rename, any
project saved against an old id silently composes without the intended
skill prompt because the listing no longer exposes that id.

Add a SKILL_ID_ALIASES map in skills.ts plus a findSkillById() helper
that rewrites deprecated ids to their current canonical form, then
route every server-side lookup (skill detail, example HTML, asset
proxy, system-prompt composer) through it. Cover the alias map, the
resolver, and end-to-end resolution against a temp skills directory
with a regression test.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(kami-deck): route host od:slide messages through local go()

The host bridge classifies kami-deck as class-driven because go() toggles
.slide.active, but the visible slide is moved by deck.style.transform
which the bridge cannot drive. Listen for od:slide messages and dispatch
them through the local go() so toolbar next/prev and initialSlideIndex
restore actually shift the deck.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(kami-deck): sync deck transform with host-driven .active changes

The previous fix added a local od:slide listener but the host bridge in
apps/web/src/runtime/srcdoc.ts also listens for the same message and
calls setActive() (toggles .slide.active) without driving the deck
transform. Both listeners fired, the bridge re-read the just-toggled
active class, and overshot by one — and the bridge's restoreInitialSlide
path could move .active without a message at all, leaving the deck on
the original transform.

Stop the bridge from double-handling by calling stopImmediatePropagation
in the local listener (registered first because the bridge script is
appended to </body>), and add a MutationObserver that pulls the deck
transform onto whichever slide currently carries .active so the bridge's
direct setActive calls (notably the initial-slide restore) move the deck
too.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(i18n): align French content with renamed/new skills

PR #434 (French localization) merged into main with French copy for the
old editorial-collage / editorial-collage-deck skill ids; this branch
renamed those to open-design-landing / open-design-landing-deck and
added kami-deck and kami-landing. Update content.fr.ts to track the
rename and add French copy for the new kami skills so the
LOCALIZED_CONTENT_IDS coverage test passes once main is merged.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* fix(open-design-landing-deck): sync deck transform with host-driven .active changes

Apply the same fix that landed in skills/kami-deck/example.html
(commits 96b255b, 8cbca30) to the open-design-landing-deck composer
runtime: the host bridge classifies this deck as class-driven because
go() toggles .slide.active, but the visible slide is moved by
deck.style.transform which the bridge can't drive. Add an od:slide
message listener that calls stopImmediatePropagation() and routes nav
through the local go(), plus a MutationObserver that pulls the deck
transform onto whichever slide carries .active so the bridge's direct
setActive calls (notably restoreInitialSlide) move the deck too.

Regenerates example.html via scripts/compose.ts; the regeneration also
picks up upstream nav-cta and brand-meta CSS additions in the sister
open-design-landing styles.css that the example had drifted from.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

* docs(open-design-landing): align deploy story with Astro landing app

- Update SKILL contract: apps/landing-page is Astro static; clarify
  nextjs-app output_format as a historical enum label and <out>/nextjs
  as a legacy folder name.
- Replace optional-deploy section with fork + pnpm --filter landing-page build.
- Fix styles.css header and regenerate landing + deck example.html so
  inlined comments match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(deck-runtime): bypass interaction lock for host/observer slide sync

The slide deck runtimes for kami-deck and open-design-landing-deck
gate go() behind a 700ms `lock` so wheel/key/touch input bursts can't
overshoot the transform transition. But applying the same gate to the
host bridge's od:slide messages and the MutationObserver watching
`.slide.active` creates a startup race: go(0) at the end of init sets
lock=true, and any host-driven `.active` change inside that window
(notably restoreInitialSlide) fires the observer, which calls go(i),
which exits at the lock guard — leaving the visible deck on slide 1
while the host counter advances to N.

Split the actual state update into an unthrottled `applySlide(n)`
helper that updates transform, `.active`, dot nav, and the progress
bar. Keep `lock` only on the user-input path through `go()`. Route
the message listener, the MutationObserver, and the initial render
through `applySlide` directly so host-driven sync always reaches the
deck transform regardless of the throttle state.

Generated-By: looper 0.4.0 (runner=fixer, agent=claude-code)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:22:46 +08:00

447 lines
13 KiB
TypeScript

/**
* 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;
}