feat(landing-page): split catalog into per-facet pages + auto-deploy on content changes (#1158)

* feat(landing-page): split catalog into per-facet pages + auto-deploy on content changes

Convert the single-page landing into a content-driven multi-page site
sourced directly from the canonical Markdown bundles in the repo root,
and close the deploy loop so contributor edits go live without manual
follow-up.

## What's new

- `/skills/`, `/systems/`, `/craft/`, `/templates/` index + detail
  pages, generated from `skills/<slug>/SKILL.md`,
  `design-systems/<slug>/DESIGN.md`, `craft/*.md`, and
  `templates/live-artifacts/<slug>/README.md` via Astro content
  collections (`app/content.config.ts`). No mirroring of content into
  the landing-page package — `glob` re-scans on every build.
- Faceted sub-routes generated from frontmatter:
    - `/skills/mode/<slug>/`     — 8 pages (deck, prototype, image, …)
    - `/skills/scenario/<slug>/` — 18 pages after alias collapse
    - `/systems/category/<slug>/` — 21 pages
  Each page owns its own `<title>`, meta description, and
  `CollectionPage` JSON-LD; chips on the parent index pages are now
  real anchors that link to these facet routes.
- Updated top-bar nav (`_components/header.tsx`) to point at the new
  internal routes with live counts pulled from the catalog. Counts in
  the homepage hero meta description likewise driven by
  `getCatalogCounts()` so they never drift.
- Per-skill / per-template thumbnails. A Playwright generator
  (`scripts/generate-previews.ts`) walks every `example.html` and
  `templates/live-artifacts/<slug>/index.html`, screenshots them at
  1440×900@2x, and writes PNGs to `public/previews/`. The catalog
  data layer auto-detects presence and degrades gracefully when an
  artifact has no renderable HTML.

## Plumbing the auto-update loop

- `landing-page-deploy.yml` and `landing-page-ci.yml` now trigger on
  changes under `skills/`, `design-systems/`, `craft/`, and
  `templates/`. Without this, a contributor adding a new SKILL.md to
  `main` would silently skip the deploy and the published site would
  fall behind.
- Both workflows now install Playwright Chromium (cached by version)
  and run `pnpm previews` before `astro build`, so generated
  thumbnails ship in `out/previews/` automatically. Preview generation
  is `continue-on-error: true` — a single broken example.html should
  not block the deploy of the rest of the catalog.
- `apps/landing-page/public/previews/` is gitignored: the directory
  is owned by CI and would otherwise add ~70MB of binary churn to the
  repo on every regeneration.

## Tag canonicalization

- `app/_lib/catalog.ts` adds a small per-scope alias table so
  authoring drift like `od.scenario: operation` vs `operations`, or
  `live` vs `live-artifacts`, collapses to a single canonical route
  instead of leaking two near-empty pages. Mode and category alias
  tables are scaffolded but currently empty.

## Validation

- `pnpm --filter @open-design/landing-page typecheck` — 0 errors,
  0 warnings, 0 hints across 25 Astro files
- `pnpm --filter @open-design/landing-page build` — 341 pages built
  (1 home + 8 mode + 18 scenario + 21 category + N detail pages +
  sitemap + RSS), zero external JS, ≥16 Cloudflare-resized hero
  image URLs intact

## Why this matters

After merge, any push to `main` that adds, removes, or edits a skill,
design system, craft principle, or live-artifact template
automatically triggers a fresh build that:

1. picks up the new Markdown via the content-collection glob,
2. regenerates thumbnails for any matching example.html,
3. emits new sitemap entries and JSON-LD,
4. and ships to Cloudflare Pages — no landing-page-side change
   required.

* fix(landing-page): address review feedback on PR #1158

Five fixes from the review pass — none change scope, all close the
"contradictory totals" / "stale data" / "silent CI failure" gaps the
reviewers flagged.

## Hero / catalog claims now read live counts everywhere

`apps/landing-page/app/page.tsx` previously hardcoded `31` skills and
`72` systems in the hero copy and stat rings, while the nav and meta
description had already moved to `getCatalogCounts()`. After this PR
every visible "X skills / Y systems" claim — hero lead, hero stat
rings, capabilities cards body copy, labs section meta + filter pills,
selected-work fractions, the labs CTA, and the footer Library — reads
from a single `counts` prop. `Header` and `Page` now both require
`counts` (no optional fallback) so a future caller can never silently
publish stale numbers.

The labs-section filter pills also stop being decorative buttons:
they now link to the actual `/skills/mode/<slug>/` and `/skills/`
catalog routes the new multi-page architecture exposes.

## Craft README no longer publishes

`apps/landing-page/app/_lib/catalog.ts` filtered out `e.id !== 'README'`,
but Astro normalizes `craft/README.md`'s id to lowercase `readme`, so
the published site shipped `/craft/readme/` as a public craft principle
and the nav badge counted 12 instead of 11. Compare case-insensitively
(`e.id.toLowerCase() !== 'readme'`) so any future README casing is
also filtered out. Verified locally: `apps/landing-page/out/craft/`
now contains exactly 11 entries.

## Preview URL preserves actual file extension

`listPreviews()` was already discovering `.png`, `.webp`, `.jpg`, and
`.jpeg`, but `previewUrlFor()` always emitted `.png`, so a future
sharp/webp post-processor (or a manually committed template asset)
would mark the record as available while the rendered `<img src>`
404'd. Switched the structure from `Set<slug>` to `Map<slug, filename>`
and emit the actual on-disk filename verbatim.

## Preview script: per-artifact soft, systemic hard

Previously any single failed `example.html` capture exited the script
non-zero, which forced both workflows to mark the entire preview step
`continue-on-error: true`. That blanket tolerance also masked
systemic generator failures — a chromium launch that never finds the
browser binary would silently ship a deploy with zero thumbnails.

`scripts/generate-previews.ts` now distinguishes:

- per-artifact failures → logged and skipped, exit 0 (catalog
  degrades gracefully for those skills),
- discoverJobs / chromium.launch / 100%-failure run → exit 1
  (systemic, must fail the build).

Both workflows drop their `continue-on-error: true` flags so a real
problem actually surfaces.

## AGENTS.md reflects the multi-page architecture

`apps/landing-page/AGENTS.md` previously declared the landing page
single-route ("Not multi-page. There is exactly one route ('/')").
That guidance is now wrong — there are six top-level route groups
(`/`, `/skills/`, `/systems/`, `/craft/`, `/templates/`, plus their
facet variants). Updated to describe content-collection sourcing, the
no-mirror rule, the auto-deploy workflow contract, and the
"never hardcode catalog claims" boundary.

## Validation

- `pnpm --filter @open-design/landing-page typecheck` — 0 errors,
  0 warnings, 0 hints across 25 Astro files
- `pnpm --filter @open-design/landing-page build` — 340 pages built
  (was 341 before the README filter; the README route is now
  correctly absent), live counts visible in the built `out/index.html`:
  `driven by 125 composable skills and 149 brand-grade design systems`
- Verified `out/craft/` no longer contains `readme/`
- Verified preview URLs resolve to the actual on-disk filename via
  the regenerated catalog index page

* fix(landing-page): clean up live-artifact template name + summary parsing

Address @mrcfps's follow-up review on `0715d8c`. The
`shapeLiveArtifactTemplate()` parser was passing the README's H1
verbatim (literal backticks intact) and using the first non-empty
post-H1 line as the summary, even when that line was the
`> Category: **Live Artifacts**` editorial blockquote. Result:
`/templates/live-otd-operations-brief/` was shipping a
`<meta name="description" content=">">` and a card title with raw
Markdown noise — a regression for both SEO snippets and the
templates catalog at-a-glance scan.

## Two new shared helpers

- `stripMarkdownInline()` — strip backticks, asterisks, and link
  wrappers so `# \`otd-operations-brief\` · live-artifact template`
  becomes `otd-operations-brief · live-artifact template` before any
  further trimming.
- `extractFirstProseParagraph()` — walk the body after the H1 and
  skip blockquotes (`>`), list markers, table rows, fenced code, and
  HR rules. Stop at the first contiguous prose paragraph and pass it
  through `stripMarkdownInline()` so the result is human-readable.

Both helpers live next to `titleizeSlug()` and are used by
`shapeCraft()` and `shapeLiveArtifactTemplate()` so they share one
implementation.

## Live-artifact title boilerplate trim

Live-artifact READMEs commonly title themselves
`# \`<slug>\` · live-artifact template`. After stripping the inline
backticks the trailing `· live-artifact template` is redundant
("Templates" already groups them) and adds a wide noisy suffix on
catalog cards. Removed it via a narrow regex tail-strip.

## Result on the existing fixture

Verified locally for `templates/live-artifacts/otd-operations-brief/`:

- before: `<title>\`otd-operations-brief\` · live-artifact template …</title>`,
  `<meta name="description" content=">">`
- after:  `<title>otd-operations-brief — Open Design template</title>`,
  `<meta name="description" content="A drop-in html_template_v1
  live-artifact template for an editorial On-Time Delivery brief.
  It ships:">`

Typecheck 0/0/0, build 340 pages.

---------

Co-authored-by: Joey <joey@cursor.so>
Co-authored-by: Joey-nexu <236967869+joeylee12629-star@users.noreply.github.com>
This commit is contained in:
Joey-nexu 2026-05-12 19:24:50 +08:00 committed by GitHub
parent 060540f73c
commit 5077a1cd38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 3471 additions and 94 deletions

View file

@ -3,9 +3,19 @@ name: landing-page-ci
on:
pull_request:
paths:
# Workflow files
- .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page.yml
- .github/workflows/landing-page-deploy.yml
# Landing page sources
- apps/landing-page/**
# Content sources globbed by Astro content collections — without
# these the deploy can be silently skipped when only Markdown
# content is touched.
- skills/**
- design-systems/**
- craft/**
- templates/**
# Workspace plumbing
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
@ -14,8 +24,12 @@ on:
- main
paths:
- .github/workflows/landing-page-ci.yml
- .github/workflows/landing-page.yml
- .github/workflows/landing-page-deploy.yml
- apps/landing-page/**
- skills/**
- design-systems/**
- craft/**
- templates/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
@ -32,7 +46,7 @@ jobs:
validate:
name: Validate landing page
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
steps:
- name: Checkout
@ -52,9 +66,36 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Cache the Playwright browser binaries between runs. The cache key
# is pinned to the playwright version we depend on (kept in
# apps/landing-page/package.json) so a bump invalidates correctly.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
# Generate the per-skill / per-template thumbnail PNGs *before*
# the build so they ship in `out/previews/` automatically. The
# script itself decides what's a soft vs. hard failure: a single
# broken `example.html` is logged and skipped, but a chromium
# launch failure or a 100%-failure run exits non-zero so the
# build stops instead of silently shipping zero thumbnails.
- name: Generate skill + template previews
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
run: pnpm --filter @open-design/landing-page build

View file

@ -5,9 +5,20 @@ on:
branches:
- main
paths:
# Workflow files
- .github/workflows/landing-page-deploy.yml
- .github/workflows/landing-page-ci.yml
# Landing page sources
- apps/landing-page/**
# Content sources — Astro content collections glob these at build
# time. Adding/removing a SKILL.md, DESIGN.md, craft principle, or
# live-artifact template MUST trigger a redeploy or the published
# site falls behind silently.
- skills/**
- design-systems/**
- craft/**
- templates/**
# Workspace plumbing
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
@ -25,7 +36,7 @@ jobs:
deploy:
name: Deploy landing page
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
steps:
- name: Checkout
@ -45,9 +56,33 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./apps/landing-page/package.json').devDependencies.playwright.replace(/[^0-9.]/g,'')")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright Chromium
run: pnpm --filter @open-design/landing-page exec playwright install --with-deps chromium
- name: Typecheck landing page
run: pnpm --filter @open-design/landing-page typecheck
# Generate previews before build so they end up in `out/previews/`.
# Soft vs. hard failure is enforced inside the script itself:
# individual broken `example.html` entries are logged and skipped,
# but a systemic failure (chromium launch error, every job failing)
# exits non-zero so we don't silently ship a deploy with zero
# thumbnails to production.
- name: Generate skill + template previews
run: pnpm --filter @open-design/landing-page previews
- name: Build landing page
run: pnpm --filter @open-design/landing-page build

5
.gitignore vendored
View file

@ -57,3 +57,8 @@ specs/change/active
.envrc
# Local design assistant context
.impeccable.md
# Landing-page preview thumbnails — regenerated by CI from
# `skills/<slug>/example.html` and `templates/live-artifacts/<slug>/`
# on every deploy. Should not be committed (~70MB of PNGs).
apps/landing-page/public/previews/

View file

@ -6,11 +6,14 @@ records module-level boundaries for `apps/landing-page/`.
## Purpose
`apps/landing-page` is a stand-alone static Astro site that renders
the canonical Open Design marketing page in the **Atelier Zero** style.
It is the deployable counterpart to:
the Open Design marketing surface in the **Atelier Zero** style and
ships per-facet catalog pages for every skill, design system, craft
principle, and live-artifact template in the repo root.
Tightly coupled with:
- Skill: `skills/open-design-landing/` — agent workflow + the source-of-truth
`example.html` known-good rendering.
`example.html` known-good rendering for the homepage hero.
- Design system: `design-systems/atelier-zero/DESIGN.md` — token spec.
- Image assets: `skills/open-design-landing/assets/*.png` are uploaded to
Cloudflare R2 (`open-design-static`) and served through
@ -19,17 +22,38 @@ It is the deployable counterpart to:
## What it is
- Astro static output. The route lives at `app/pages/index.astro` and
uses React only at build time (`renderToStaticMarkup`) for the existing
`app/page.tsx` component. The generated page is CDN-ready HTML/CSS plus
a small inline enhancement script; no React runtime ships to browsers.
- `astro.config.ts` always uses `output: 'static'` and emits to `out/`
so it can be served by any CDN (Vercel, Cloudflare Pages, the daemon's
static fallback) without a Node runtime.
- All styles live in `app/globals.css`. Class names match the Atelier
Zero CSS in the canonical example so visual parity is one-to-one.
- All page imagery is referenced through `app/image-assets.ts`, which builds
Cloudflare Image Resizing URLs for the R2 originals.
- Astro static output. The site has multiple route groups:
- `/` — Atelier Zero homepage (`app/pages/index.astro`).
- `/skills/` + `/skills/<slug>/` — every `SKILL.md` in `skills/`.
- `/skills/mode/<slug>/` and `/skills/scenario/<slug>/`
facet pages generated from frontmatter via `getStaticPaths`.
- `/systems/` + `/systems/<slug>/` + `/systems/category/<slug>/`
every `DESIGN.md` in `design-systems/`.
- `/craft/` + `/craft/<slug>/` — every `*.md` in `craft/`.
- `/templates/` + `/templates/<slug>/` — Live Artifacts in
`templates/live-artifacts/` plus skills with `od.mode: template`.
- Content sources are **never** mirrored into this app. Astro content
collections (`app/content.config.ts`) glob the canonical Markdown
bundles in the repo root at build time. When a contributor adds or
edits a `SKILL.md`/`DESIGN.md`, the next build picks it up — no
intermediate "register your skill here" step.
- The shaped data layer lives in `app/_lib/catalog.ts`. Page templates
import shaped records from there and never re-parse Markdown in JSX.
- React is used only at build time (`renderToStaticMarkup`) for
`app/page.tsx` and the shared `Header`. The output ships
CDN-ready HTML/CSS plus a small inline enhancement script;
no React runtime ships to browsers.
- All styles split between `app/globals.css` (homepage, kept in
lockstep with `skills/open-design-landing/example.html`) and
`app/sub-pages.css` (catalog/facet/detail pages).
- All page imagery is referenced through `app/image-assets.ts`, which
builds Cloudflare Image Resizing URLs for the R2 originals.
- Per-skill / per-template thumbnails are rendered offline by
`scripts/generate-previews.ts` (Playwright). Output lives in
`public/previews/<bucket>/<slug>.<ext>` and is **gitignored** — CI
regenerates on every deploy. The script preserves the actual file
extension so a future sharp/webp post-processor will work without
touching the data layer.
## What it is NOT
@ -38,9 +62,10 @@ It is the deployable counterpart to:
not state, routes, or runtime.
- Not connected to `apps/daemon`. There is no `/api`, no `/artifacts`,
no `/frames` — no proxy to set up.
- Not multi-page. There is exactly one route (`/`) that renders the
full landing page. If you need a second page, add it as a sibling
Astro page route.
- Not a CMS. Content authors edit Markdown in `skills/`,
`design-systems/`, `craft/`, or `templates/live-artifacts/` at the
repo root; the landing page rebuilds against those globs and ships
to Cloudflare Pages automatically.
## Boundary constraints
@ -48,27 +73,61 @@ It is the deployable counterpart to:
- Must not import from `@open-design/web`, `@open-design/daemon`,
`@open-design/desktop`, `@open-design/sidecar*`, or
`@open-design/contracts`. Those are product runtime concerns.
- Must not introduce a `src/` shell — keep all source under
`app/`. If a component grows beyond ~80 lines, extract it to
`app/_components/<name>.tsx`.
- Must not introduce a `src/` shell — keep all source under `app/`.
Component bundles live in `app/_components/<name>.{tsx,astro}`.
- Must not depend on any non-Google web font.
- When the canonical `skills/open-design-landing/example.html` changes,
the corresponding section JSX in `app/page.tsx` and rules in
`app/globals.css` must be updated to match. The two files are kept
in lockstep.
- Visible "X skills" / "Y systems" claims must read from
`getCatalogCounts()` — never hardcode. The hero, capabilities cards,
labs pills, selected-work fractions, footer Library, and
`<meta name="description">` all derive from the same call so a
fresh content edit can never publish contradictory totals.
- When the canonical `skills/open-design-landing/example.html`
changes, the corresponding section JSX in `app/page.tsx` and rules
in `app/globals.css` must be updated to match. Those two files are
kept in lockstep; the rest of the landing-page sources are not.
- Content-collection schemas in `app/content.config.ts` stay loose
(`passthrough()`). Validation lives at render time so vendored
upstream Markdown (e.g., `guizang-ppt`) doesn't break the build
when an author uses a slightly different `od:` key.
## Auto-deploy contract
`.github/workflows/landing-page-deploy.yml` runs on push to `main`
when **any** of these change:
- `apps/landing-page/**`
- `skills/**`
- `design-systems/**`
- `craft/**`
- `templates/**`
- `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`
- the workflow file itself
A push that only edits a SKILL.md MUST trigger this workflow — if it
doesn't, the `paths:` filter has drifted from the content-collection
glob and the published site will fall behind silently. Treat that as
a regression, not a feature.
## Common commands
```bash
pnpm --filter @open-design/landing-page dev # http://127.0.0.1:17574
pnpm --filter @open-design/landing-page build # static export → out/
pnpm --filter @open-design/landing-page typecheck
pnpm --filter @open-design/landing-page previews # render thumbnails
pnpm --filter @open-design/landing-page build # static export → out/
```
## When to update this app
- New section added to the canonical landing page → port it here.
- Asset regeneration in the skill → re-mirror PNGs into
`public/assets/`.
- Added/edited a `SKILL.md`, `DESIGN.md`, craft `*.md`, or live-artifact
template at the repo root → no landing-page edit required; CI
rebuilds and re-renders thumbnails on the next push to `main`.
- Adding a new top-level route group (e.g. `/playbooks/`) → add an
Astro page directory under `app/pages/`, a content collection in
`app/content.config.ts`, a shaping function in `app/_lib/catalog.ts`,
and route entries that match the existing index/detail/facet pattern.
- New section added to the canonical landing page → port it into
`app/page.tsx` and `app/globals.css` keeping lockstep with
`skills/open-design-landing/example.html`.
- Brand re-keying for a non-Open-Design tenant → fork the app, update
copy, swap PNGs. Do not parameterize this app for multi-tenancy.

View file

@ -3,23 +3,53 @@
* hide/show and the live GitHub star count are attached by the tiny inline
* script in `app/pages/index.astro`, so this marketing page ships no React
* runtime to the browser.
*
* The nav links go to internal multi-page routes (`/skills/`, `/systems/`,
* `/templates/`, `/craft/`) so Google sees a real site hierarchy. Numbers
* reflect the live counts of the canonical Markdown bundles in the repo
* root and are kept in sync with `getCatalogCounts()` at build time.
*/
const REPO = 'https://github.com/nexu-io/open-design';
const REPO_RELEASES = `${REPO}/releases`;
const REPO_SKILLS = `${REPO}/tree/main/skills`;
const REPO_DESIGN_SYSTEMS = `${REPO}/tree/main/design-systems`;
const ext = {
target: '_blank',
rel: 'noreferrer noopener',
} as const;
export function Header() {
export interface HeaderProps {
/** Nav highlight target. `'home'` is the default for `/`. */
active?: 'home' | 'skills' | 'systems' | 'templates' | 'craft';
/**
* Live counts from the Markdown catalogs. Required so we can never
* silently render stale fallback numbers when a caller forgets to
* thread `getCatalogCounts()` through. Header only consumes these
* four scalar fields; the homepage passes the wider `CatalogCounts`
* value (with `byMode` / `byPlatform`) by structural subtyping.
*/
counts: {
skills: number;
systems: number;
templates: number;
craft: number;
};
/** Brand link target — `#top` on the homepage, `/` on sub-pages. */
brandHref?: string;
}
export function Header({
active = 'home',
counts,
brandHref = '#top',
}: HeaderProps) {
const linkClass = (key: NonNullable<HeaderProps['active']>) =>
active === key ? 'is-active' : undefined;
return (
<header className='nav' data-od-id='nav' data-nav-headroom>
<div className='container nav-inner'>
<a href='#top' className='brand'>
<a href={brandHref} className='brand'>
<span className='brand-mark'>Ø</span>
<span>Open Design</span>
<span className='brand-meta'>
@ -29,27 +59,29 @@ export function Header() {
<nav>
<ul className='nav-links'>
<li>
<a href={REPO_SKILLS} {...ext}>
Skills<span className='num'>31</span>
<a href='/skills/' className={linkClass('skills')}>
Skills<span className='num'>{counts.skills}</span>
</a>
</li>
<li>
<a href={REPO_DESIGN_SYSTEMS} {...ext}>
Systems<span className='num'>72</span>
<a href='/systems/' className={linkClass('systems')}>
Systems<span className='num'>{counts.systems}</span>
</a>
</li>
<li>
<a href='#agents'>
Agents<span className='num'>12</span>
<a href='/templates/' className={linkClass('templates')}>
Templates<span className='num'>{counts.templates}</span>
</a>
</li>
<li>
<a href='#labs'>
Labs<span className='num'>05</span>
<a href='/craft/' className={linkClass('craft')}>
Craft<span className='num'>{counts.craft}</span>
</a>
</li>
<li>
<a href='#contact'>Contact</a>
<a href={brandHref === '#top' ? '#contact' : '/#contact'}>
Contact
</a>
</li>
</ul>
</nav>

View file

@ -0,0 +1,42 @@
---
/*
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
* `/skills/scenario/<slug>/`, and any future faceted view.
*
* Renders a `<li class="catalog-row catalog-row-skill">` with the
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
* the markup so all faceted views stay visually identical to the
* unfiltered index.
*/
import type { SkillRecord } from '../_lib/catalog';
export interface Props {
skill: SkillRecord;
index: number;
}
const { skill, index } = Astro.props;
---
<li class="catalog-row catalog-row-skill">
<a href={`/skills/${skill.slug}/`}>
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
<span class="row-thumb">
{skill.previewUrl ? (
<img src={skill.previewUrl} alt="" loading="lazy" decoding="async" />
) : (
<span class="row-thumb-empty" aria-hidden="true" />
)}
</span>
<span class="row-body">
<span class="row-name">{skill.name}</span>
<span class="row-desc">{skill.description}</span>
</span>
<span class="row-meta">
{skill.mode && <span class="meta-tag">{skill.mode}</span>}
{skill.scenario && <span class="meta-tag muted">{skill.scenario}</span>}
{skill.platform && <span class="meta-tag muted">{skill.platform}</span>}
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>
</li>

View file

@ -0,0 +1,167 @@
---
/*
* Shared shell for every sub-page outside of `/` (Skills, Systems,
* Craft, Templates and their detail pages).
*
* The homepage (`/`) intentionally does NOT use this layout — its
* chrome (rails, topbar, full hero, mega-word footer) stays in
* lockstep with `skills/open-design-landing/example.html`. This
* layout is the lighter sibling: same Atelier Zero tokens, same
* sticky nav, but no editorial side rails or hero, and a compact
* footer focused on the site map.
*
* Every sub-page passes `title`, `description`, `active` (nav
* highlight) and an optional `jsonLd`. Catalog counts are read from
* `getCatalogCounts()` so the nav badges stay live.
*/
import '../globals.css';
import '../sub-pages.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Header, type HeaderProps } from './header';
import { heroImage } from '../image-assets';
import { getCatalogCounts } from '../_lib/catalog';
export interface Props {
title: string;
description: string;
active?: HeaderProps['active'];
ogImage?: string;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
}
const { title, description, active = 'home', ogImage, jsonLd } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const og = ogImage ?? heroImage;
const counts = await getCatalogCounts();
const headerHtml = renderToStaticMarkup(
Header({ active, counts, brandHref: '/' }) as ReturnType<typeof createElement>,
);
const ldArray = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
const REPO = 'https://github.com/nexu-io/open-design';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#efe7d2" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Open Design" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={og} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={og} />
{ldArray.map((data) => (
<script is:inline type="application/ld+json" set:html={JSON.stringify(data)} />
))}
</head>
<body class="sub-page">
<div class="shell">
{/* Same React-rendered Header used by the homepage. SSR'd here
* so we have one nav implementation that handles active state. */}
<Fragment set:html={headerHtml} />
<main class="sub-main container">
<slot />
</main>
<footer class="sub-footer" data-od-id="sub-footer">
<div class="container sub-footer-inner">
<div class="sub-footer-grid">
<div class="sub-footer-brand">
<a href="/" class="brand">
<span class="brand-mark">Ø</span>
<span>Open Design</span>
</a>
<p>
The open-source alternative to Claude Design. Apache-2.0,
local-first, BYOK at every layer.
</p>
</div>
<div class="sub-footer-col">
<h5>Catalog</h5>
<ul>
<li><a href="/skills/">{counts.skills} Skills</a></li>
<li><a href="/systems/">{counts.systems} Systems</a></li>
<li><a href="/templates/">{counts.templates} Templates</a></li>
<li><a href="/craft/">{counts.craft} Craft principles</a></li>
</ul>
</div>
<div class="sub-footer-col">
<h5>Connect</h5>
<ul>
<li><a href={REPO} target="_blank" rel="noopener">GitHub</a></li>
<li><a href={`${REPO}/issues`} target="_blank" rel="noopener">Issues</a></li>
<li><a href={`${REPO}/releases`} target="_blank" rel="noopener">Releases</a></li>
<li><a href="/#contact">Contact</a></li>
</ul>
</div>
</div>
<div class="sub-footer-bottom">
<span>● Open Design · Apache-2.0 · 2026 / Volume 01 / Issue Nº 26</span>
<span>Berlin / Open / Earth · 52.5200° N · 13.4050° E</span>
</div>
</div>
</footer>
</div>
<script is:inline>
(() => {
// Headroom-style hide-on-scroll, mirrors the homepage
// enhancement so nav behavior is consistent across pages.
const nav = document.querySelector('[data-nav-headroom]');
if (nav) {
let lastY = window.scrollY;
const showTopThreshold = 100;
const scrollDelta = 6;
window.addEventListener(
'scroll',
() => {
const y = window.scrollY;
const delta = y - lastY;
if (y <= showTopThreshold) nav.classList.remove('is-hidden');
else if (delta > scrollDelta) nav.classList.add('is-hidden');
else if (delta < -scrollDelta) nav.classList.remove('is-hidden');
lastY = y;
},
{ passive: true },
);
}
const stars = document.querySelector('[data-github-stars]');
if (stars) {
fetch('https://api.github.com/repos/nexu-io/open-design', {
headers: { Accept: 'application/vnd.github+json' },
})
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('http error'))))
.then((data) => {
if (typeof data?.stargazers_count === 'number') {
const n = data.stargazers_count;
stars.textContent =
n < 1000
? String(n)
: `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
}
})
.catch(() => {});
}
})();
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
---
/*
* Shared system card used on `/systems/` and
* `/systems/category/<slug>/`. Displays palette swatches, name,
* category, and tagline as a clickable card.
*/
import type { SystemRecord } from '../_lib/catalog';
export interface Props {
system: SystemRecord;
}
const { system } = Astro.props;
---
<li class="system-card">
<a href={`/systems/${system.slug}/`}>
<div class="system-swatches" aria-hidden="true">
{system.palette.length > 0 ? (
system.palette.slice(0, 4).map((hex) => (
<span class="swatch" style={`background:${hex}`} />
))
) : (
<span class="swatch placeholder" />
)}
</div>
<span class="system-name">{system.name}</span>
<span class="system-cat">{system.category}</span>
{system.tagline && <p class="system-tagline">{system.tagline}</p>}
</a>
</li>

View file

@ -0,0 +1,693 @@
// Catalog data layer — turns raw Markdown bundles loaded by Astro
// Content Collections (the `SKILL.md`, `DESIGN.md`, `*.md` craft files,
// and Live Artifact `README.md` bundles in the repo root) into the
// shaped records the index and detail pages render.
//
// Why this lives in `_lib/` and not in the page files: every page
// imports from one place, so the parsing rules (folder-name slug,
// description fallback, palette extraction, etc.) stay consistent.
import { getCollection, type CollectionEntry } from 'astro:content';
import { existsSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
// ---------------------------------------------------------------------------
// Preview imagery lookup
//
// Previews are produced offline by `pnpm --filter @open-design/landing-page
// previews` and saved under `public/previews/<bucket>/<slug>.png`. We read
// the directory listing once at build time so each catalog record can carry
// a `previewUrl` (or `null` when the underlying skill has no `example.html`).
// ---------------------------------------------------------------------------
const PREVIEWS_ROOT = path.resolve(
fileURLToPath(new URL('../../public/previews', import.meta.url)),
);
/**
* Map of `slug → filename`, e.g. `'kami-deck' → 'kami-deck.webp'`.
*
* We track the actual on-disk filename (with its real extension) so the
* generated `<img src>` URL never lies about the format. Earlier this
* was a `Set<string>` and `previewUrlFor()` always emitted `.png`,
* which 404'd whenever the previews step produced `.webp`/`.jpg`/`.jpeg`
* (e.g., after a future sharp post-processor or a manually committed
* template asset).
*/
function listPreviews(bucket: 'skills' | 'systems' | 'templates'): Map<string, string> {
const dir = path.join(PREVIEWS_ROOT, bucket);
if (!existsSync(dir)) return new Map();
const map = new Map<string, string>();
for (const file of readdirSync(dir)) {
const m = /^(.+)\.(png|webp|jpg|jpeg)$/i.exec(file);
if (m && m[1]) {
// First match wins. If two files exist with the same slug but
// different extensions, prefer the one that sorts earlier (PNG
// before WebP in alphabetical order) for deterministic output.
if (!map.has(m[1])) map.set(m[1], file);
}
}
return map;
}
function previewUrlFor(
bucket: 'skills' | 'systems' | 'templates',
slug: string,
available: Map<string, string>,
): string | null {
const filename = available.get(slug);
return filename ? `/previews/${bucket}/${filename}` : null;
}
const REPO_TREE = 'https://github.com/nexu-io/open-design/tree/main';
const REPO_BLOB = 'https://github.com/nexu-io/open-design/blob/main';
// ---------------------------------------------------------------------------
// Skills
// ---------------------------------------------------------------------------
export type SkillEntry = CollectionEntry<'skills'>;
export interface SkillRecord {
slug: string;
name: string;
description: string;
triggers: ReadonlyArray<string>;
mode?: string;
platform?: string;
scenario?: string;
category?: string;
featured?: number;
upstream?: string;
examplePrompt?: string;
source: string;
body: string;
/** `/previews/skills/<slug>.png` if a generated preview exists, else null. */
previewUrl: string | null;
}
function deriveSkillSlug(id: string): string {
// `id` is `[folder]/SKILL` (no extension). We want the folder name.
const folder = id.split('/')[0] ?? id;
return folder;
}
function firstParagraph(text: string | undefined, fallback = ''): string {
if (!text) return fallback;
return text.split('\n').map((l) => l.trim()).find((l) => l.length > 0) ?? fallback;
}
export function shapeSkill(
entry: SkillEntry,
previews: Map<string, string>,
): SkillRecord {
const slug = deriveSkillSlug(entry.id);
const data = entry.data as {
name?: string;
description?: string;
triggers?: string[];
od?: {
mode?: string;
platform?: string;
scenario?: string;
category?: string;
featured?: number;
upstream?: string;
example_prompt?: string;
};
};
const description = (data.description ?? '').trim();
return {
slug,
name: data.name ?? slug,
description,
triggers: data.triggers ?? [],
mode: data.od?.mode,
platform: data.od?.platform,
scenario: data.od?.scenario,
category: data.od?.category,
featured: data.od?.featured,
upstream: data.od?.upstream,
examplePrompt: data.od?.example_prompt,
source: `${REPO_TREE}/skills/${slug}`,
body: entry.body ?? '',
previewUrl: previewUrlFor('skills', slug, previews),
};
}
export async function getSkillRecords(): Promise<ReadonlyArray<SkillRecord>> {
const previews = listPreviews('skills');
const entries = await getCollection('skills');
const shaped = entries.map((entry) => shapeSkill(entry, previews));
return shaped.sort((a, b) => {
// Featured (lower number = higher priority) first, then alphabetical.
const af = a.featured ?? Number.POSITIVE_INFINITY;
const bf = b.featured ?? Number.POSITIVE_INFINITY;
if (af !== bf) return af - bf;
return a.name.localeCompare(b.name);
});
}
// ---------------------------------------------------------------------------
// Design Systems
// ---------------------------------------------------------------------------
export type SystemEntry = CollectionEntry<'systems'>;
export interface SystemRecord {
slug: string;
name: string;
category: string;
tagline: string;
atmosphere: string;
palette: ReadonlyArray<string>;
source: string;
body: string;
}
function extractH1(body: string): string | undefined {
for (const line of body.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('# ')) return trimmed.slice(2).trim();
}
return undefined;
}
function extractCategoryBlock(body: string): { category: string; tagline: string } {
// Convention: a `> Category:` blockquote, optionally followed by extra
// tagline lines also prefixed with `>`.
const lines = body.split('\n');
let category = '';
const taglineLines: string[] = [];
let inBlock = false;
for (const raw of lines) {
const line = raw.trim();
if (!inBlock) {
const m = /^>\s*Category:\s*(.+)$/i.exec(line);
if (m && m[1]) {
category = m[1].trim();
inBlock = true;
continue;
}
} else {
if (line.startsWith('>')) {
const text = line.replace(/^>\s?/, '').trim();
if (text.length > 0) taglineLines.push(text);
} else if (line.length === 0 && taglineLines.length === 0) {
// tolerate a single blank line between Category and tagline
} else {
break;
}
}
}
return { category, tagline: taglineLines.join(' ').trim() };
}
function extractAtmosphere(body: string): string {
// Take the first paragraph of the first H2 section that looks like
// "Visual Theme & Atmosphere" (or any first paragraph after `## 1.`).
const lines = body.split('\n');
let inSection = false;
const buf: string[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!inSection) {
if (/^##\s+1\./.test(raw) || /^##\s+.*Atmosphere/i.test(raw)) {
inSection = true;
}
continue;
}
if (line.startsWith('##')) break;
if (line.length === 0 && buf.length > 0) break;
if (line.length === 0) continue;
buf.push(line);
}
return buf.join(' ').trim();
}
const HEX_RE = /#[0-9a-fA-F]{6}\b/g;
function extractPalette(body: string, limit = 5): ReadonlyArray<string> {
const seen = new Set<string>();
const matches = body.match(HEX_RE) ?? [];
for (const hex of matches) {
seen.add(hex.toLowerCase());
if (seen.size >= limit) break;
}
return Array.from(seen);
}
export function shapeSystem(entry: SystemEntry): SystemRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const h1 = extractH1(body) ?? slug;
const { category, tagline } = extractCategoryBlock(body);
const atmosphere = extractAtmosphere(body);
const palette = extractPalette(body);
return {
slug,
name: h1.replace(/^Design System Inspired by\s+/i, '').trim() || slug,
category: category || 'Uncategorized',
tagline,
atmosphere,
palette,
source: `${REPO_TREE}/design-systems/${slug}`,
body,
};
}
export async function getSystemRecords(): Promise<ReadonlyArray<SystemRecord>> {
const entries = await getCollection('systems');
return entries
.map(shapeSystem)
.sort((a, b) => a.name.localeCompare(b.name));
}
// ---------------------------------------------------------------------------
// Craft
// ---------------------------------------------------------------------------
export type CraftEntry = CollectionEntry<'craft'>;
export interface CraftRecord {
slug: string;
name: string;
summary: string;
source: string;
body: string;
}
const CRAFT_NAME_OVERRIDES: Record<string, string> = {
'rtl-and-bidi': 'RTL & Bidi',
};
function titleizeSlug(slug: string): string {
const override = CRAFT_NAME_OVERRIDES[slug];
if (override) return override;
return slug
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
// ---------------------------------------------------------------------------
// Markdown → display-text helpers
//
// Live-artifact READMEs and craft `*.md` files mix prose with editorial
// metadata blocks (`> Category: …`, `> Family: …`) and decorative
// inline syntax (backticks around slugs in the H1, asterisks for
// emphasis). When we surface them as page titles, card descriptions,
// or `<meta name="description">`, we want clean text — never raw
// Markdown noise like `\`otd-operations-brief\` · live-artifact template`
// or a literal `>` as the entire summary.
// ---------------------------------------------------------------------------
/** Strip backticks, leading/trailing emphasis, link wrappers, soft breaks. */
function stripMarkdownInline(text: string): string {
return text
.replace(/`([^`]+)`/g, '$1') // `code` → code
.replace(/\*\*([^*]+)\*\*/g, '$1') // **bold** → bold
.replace(/\*([^*]+)\*/g, '$1') // *italic* → italic
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
.replace(/\s+/g, ' ')
.trim();
}
/**
* First plain-prose paragraph after the H1, with all leading
* blockquote / list / fenced-code / horizontal-rule lines skipped.
*
* The "first paragraph" definition: a contiguous run of non-empty
* lines that aren't headings, blockquotes, list markers, table rows,
* code fences, or HR rules. Returns the empty string if no such
* paragraph exists, leaving the caller to apply its own fallback.
*/
function extractFirstProseParagraph(body: string): string {
const lines = body.split('\n');
let pastH1 = false;
let inFence = false;
const buf: string[] = [];
for (const raw of lines) {
const line = raw.trim();
if (!pastH1) {
if (line.startsWith('# ')) pastH1 = true;
continue;
}
if (line.startsWith('```') || line.startsWith('~~~')) {
inFence = !inFence;
continue;
}
if (inFence) continue;
// Section break.
if (line.startsWith('#')) break;
if (line.length === 0) {
if (buf.length > 0) break;
continue;
}
// Skip editorial metadata blocks until we find real prose. Authors
// commonly stack `> Category:`, `> Family:`, `> Style:` lines under
// the H1 — they're meaningful in the README but useless as a card
// summary or SEO snippet.
if (line.startsWith('>')) continue;
// Skip lists / table rows / horizontal rules with the same logic.
if (/^([-*+]\s|\d+\.\s|\||---+$|\*\*\*+$|___+$)/.test(line)) {
if (buf.length > 0) break;
continue;
}
buf.push(line);
}
return stripMarkdownInline(buf.join(' '));
}
export function shapeCraft(entry: CraftEntry): CraftRecord {
const slug = entry.id;
const body = entry.body ?? '';
const h1 = extractH1(body);
const cleanH1 = h1 ? stripMarkdownInline(h1).replace(/\s+craft rules?$/i, '').trim() : '';
return {
slug,
name: cleanH1 || titleizeSlug(slug),
summary: extractFirstProseParagraph(body),
source: `${REPO_BLOB}/craft/${slug}.md`,
body,
};
}
export async function getCraftRecords(): Promise<ReadonlyArray<CraftRecord>> {
const entries = await getCollection('craft');
// Astro normalizes the entry id from `craft/README.md` to `readme`
// (lowercase, extension stripped). Comparing the raw `'README'` string
// misses it on disk and used to ship `/craft/readme/` as a public
// craft principle and inflate the nav count by one. Compare
// case-insensitively so future README casings (`Readme.md`, etc.) are
// also filtered out.
return entries
.filter((e) => e.id.toLowerCase() !== 'readme')
.map(shapeCraft)
.sort((a, b) => a.name.localeCompare(b.name));
}
// ---------------------------------------------------------------------------
// Templates — Live Artifacts + skills with `mode: template`
// ---------------------------------------------------------------------------
export interface TemplateRecord {
slug: string;
name: string;
summary: string;
origin: 'live-artifact' | 'skill';
source: string;
detailHref: string;
/** Skill body / template README body (Markdown). */
body: string;
previewUrl: string | null;
}
export type TemplateEntry = CollectionEntry<'templates'>;
export function shapeLiveArtifactTemplate(
entry: TemplateEntry,
previews: Map<string, string>,
): TemplateRecord {
const slug = entry.id.split('/')[0] ?? entry.id;
const body = entry.body ?? '';
const h1 = extractH1(body);
// Some authors write `# \`otd-operations-brief\` · live-artifact template`
// — strip the inline backticks/asterisks and drop the trailing
// `· live-artifact template` boilerplate so card titles read like
// human prose ("otd-operations-brief") instead of raw Markdown.
let cleanH1 = h1 ? stripMarkdownInline(h1) : '';
cleanH1 = cleanH1
.replace(/\s*[·•]\s*live[\s-]artifact\s+template$/i, '')
.trim();
const summary = extractFirstProseParagraph(body) || 'Open Design Live Artifact template.';
const liveSlug = `live-${slug}`;
return {
slug: liveSlug,
name: cleanH1 || titleizeSlug(slug),
summary,
origin: 'live-artifact',
source: `${REPO_TREE}/templates/live-artifacts/${slug}`,
detailHref: `/templates/${liveSlug}/`,
body,
previewUrl: previewUrlFor('templates', liveSlug, previews),
};
}
export async function getTemplateRecords(): Promise<ReadonlyArray<TemplateRecord>> {
const previews = listPreviews('templates');
const liveEntries = await getCollection('templates');
const liveRecords = liveEntries.map((entry) => shapeLiveArtifactTemplate(entry, previews));
const skillRecords = await getSkillRecords();
const skillTemplates: TemplateRecord[] = skillRecords
.filter((s) => s.mode === 'template')
.map((s) => ({
slug: `skill-${s.slug}`,
name: s.name,
summary: firstParagraph(s.description),
origin: 'skill' as const,
source: s.source,
detailHref: `/skills/${s.slug}/`,
body: s.body,
// Templates render skill-mode skill thumbnails reusing the
// /previews/skills/ tree (no separate render).
previewUrl: s.previewUrl,
}));
return [...liveRecords, ...skillTemplates].sort((a, b) =>
a.name.localeCompare(b.name),
);
}
// ---------------------------------------------------------------------------
// Counts
//
// `getCatalogCounts()` is the canonical numbers source for the homepage
// (hero stat rings, hero lead, capabilities cards, footer Library) and
// the nav badges. Anything in `app/page.tsx` that talks about catalog
// size MUST read from here — never hardcode. The `byMode` and
// `byPlatform` breakdowns power the `Labs` filter pills so they stay
// in sync with `od.mode` / `od.platform` across the SKILL.md corpus.
// ---------------------------------------------------------------------------
export interface CatalogCounts {
skills: number;
systems: number;
templates: number;
craft: number;
/** SKILL.md `od.mode` → count. Lowercase keys (e.g. `deck`, `prototype`). */
byMode: Readonly<Record<string, number>>;
/** SKILL.md `od.platform` → count. Lowercase keys (e.g. `mobile`, `desktop`). */
byPlatform: Readonly<Record<string, number>>;
}
function tallyKey(values: Iterable<string | undefined>): Record<string, number> {
const out: Record<string, number> = {};
for (const v of values) {
if (!v) continue;
const k = v.toLowerCase();
out[k] = (out[k] ?? 0) + 1;
}
return out;
}
export async function getCatalogCounts(): Promise<CatalogCounts> {
const [skills, systems, templates, craft] = await Promise.all([
getSkillRecords(),
getSystemRecords(),
getTemplateRecords(),
getCraftRecords(),
]);
return {
skills: skills.length,
systems: systems.length,
templates: templates.length,
craft: craft.length,
byMode: tallyKey(skills.map((s) => s.mode)),
byPlatform: tallyKey(skills.map((s) => s.platform)),
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function uniq<T>(values: ReadonlyArray<T>): ReadonlyArray<T> {
return Array.from(new Set(values));
}
export function tally<T extends string | number>(values: ReadonlyArray<T>): ReadonlyArray<readonly [T, number]> {
const map = new Map<T, number>();
for (const v of values) map.set(v, (map.get(v) ?? 0) + 1);
return Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
}
// ---------------------------------------------------------------------------
// Tag slugification (used for `/skills/mode/<slug>/`,
// `/skills/scenario/<slug>/`, `/systems/category/<slug>/` routes).
//
// Stable, lossless rules:
// "AI & LLM" → "ai-llm"
// "Productivity & SaaS" → "productivity-saas"
// "Editorial · Studio" → "editorial-studio"
// "Editorial / Print" → "editorial-print"
// "live-artifacts" → "live-artifacts" (already a slug)
// ---------------------------------------------------------------------------
export function slugifyTag(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ---------------------------------------------------------------------------
// Tag canonicalization — collapse near-duplicate authoring spellings into
// one route per concept. Without this, `od.scenario: operation` and
// `od.scenario: operations` would generate two separate `/skills/scenario/...`
// pages for what is plainly the same facet.
//
// Keep aliases conservative — only collapse values that mean exactly the
// same thing (singular/plural, hyphen/space variants). Add more entries as
// inconsistencies appear; the alias key is matched case-insensitively
// against the raw frontmatter value before slugification.
// ---------------------------------------------------------------------------
const SCENARIO_ALIASES: Readonly<Record<string, string>> = {
operation: 'operations',
live: 'live-artifacts',
};
const MODE_ALIASES: Readonly<Record<string, string>> = {
// No aliases needed today — modes are an enum maintained centrally.
};
const CATEGORY_ALIASES: Readonly<Record<string, string>> = {
// No aliases needed today — categories come from DESIGN.md headers
// and are reasonably consistent across the corpus.
};
function canonicalize(
raw: string | undefined,
aliases: Readonly<Record<string, string>>,
): string | undefined {
if (!raw) return raw;
const key = raw.trim().toLowerCase();
return aliases[key] ?? raw;
}
export function canonicalScenario(raw: string | undefined): string | undefined {
return canonicalize(raw, SCENARIO_ALIASES);
}
export function canonicalMode(raw: string | undefined): string | undefined {
return canonicalize(raw, MODE_ALIASES);
}
export function canonicalCategory(raw: string | undefined): string | undefined {
return canonicalize(raw, CATEGORY_ALIASES);
}
export interface TagDescriptor {
slug: string;
label: string;
count: number;
}
/** Build [slug, label, count] index over a list of (possibly undefined) values. */
export function tagIndex(values: ReadonlyArray<string | undefined>): ReadonlyArray<TagDescriptor> {
const counts = new Map<string, { label: string; count: number }>();
for (const v of values) {
if (!v) continue;
const slug = slugifyTag(v);
const existing = counts.get(slug);
if (existing) {
existing.count++;
} else {
counts.set(slug, { label: v, count: 1 });
}
}
return Array.from(counts.entries())
.map(([slug, { label, count }]) => ({ slug, label, count }))
.sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug));
}
// ---------------------------------------------------------------------------
// Tag-page selectors (used by the `/skills/mode/<slug>/` etc. routes via
// getStaticPaths). Each returns the matching records plus the canonical
// human label (preserving the original `od.mode` casing for the heading).
// ---------------------------------------------------------------------------
export async function getSkillsForMode(slug: string): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords();
const matches = all.filter((s) => {
const canonical = canonicalMode(s.mode);
return canonical && slugifyTag(canonical) === slug;
});
return {
label: canonicalMode(matches[0]?.mode) ?? null,
records: matches,
};
}
export async function getSkillsForScenario(slug: string): Promise<{
label: string | null;
records: ReadonlyArray<SkillRecord>;
}> {
const all = await getSkillRecords();
const matches = all.filter((s) => {
const canonical = canonicalScenario(s.scenario);
return canonical && slugifyTag(canonical) === slug;
});
return {
label: canonicalScenario(matches[0]?.scenario) ?? null,
records: matches,
};
}
export async function getSystemsForCategory(slug: string): Promise<{
label: string | null;
records: ReadonlyArray<SystemRecord>;
}> {
const all = await getSystemRecords();
const matches = all.filter((s) => {
const canonical = canonicalCategory(s.category);
return canonical !== undefined && slugifyTag(canonical) === slug;
});
return {
label: canonicalCategory(matches[0]?.category) ?? null,
records: matches,
};
}
export async function getSkillModeIndex(): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalMode(s.mode)));
}
export async function getSkillScenarioIndex(): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSkillRecords();
return tagIndex(all.map((s) => canonicalScenario(s.scenario)));
}
export async function getSystemCategoryIndex(): Promise<ReadonlyArray<TagDescriptor>> {
const all = await getSystemRecords();
return tagIndex(all.map((s) => canonicalCategory(s.category)));
}

View file

@ -0,0 +1,80 @@
/*
* Content collections single source of truth for the multi-page
* landing pages (`/skills/`, `/systems/`, `/craft/`, `/templates/`).
*
* We do NOT mirror content into `apps/landing-page/`; instead we glob
* the canonical Markdown bundles in the repo root (`skills/`,
* `design-systems/`, `craft/`, `templates/`). When a contributor adds
* a `SKILL.md` or `DESIGN.md`, it shows up on the next build with
* zero sync step.
*
* Schema validation is intentionally loose because the upstream
* repos (especially `guizang-ppt` bundled verbatim) use slightly
* different `od:` keys. Anything we don't model is preserved on the
* raw frontmatter and ignored by our pages.
*/
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const skillSchema = z
.object({
name: z.string().optional(),
description: z.string().optional(),
triggers: z.array(z.string()).optional(),
od: z
.object({
mode: z.string().optional(),
platform: z.string().optional(),
scenario: z.string().optional(),
category: z.string().optional(),
featured: z.number().optional(),
upstream: z.string().optional(),
default_for: z.string().optional(),
example_prompt: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const skills = defineCollection({
loader: glob({
base: '../../skills',
pattern: '*/SKILL.md',
}),
schema: skillSchema,
});
// `design-systems/<slug>/DESIGN.md` files use plain Markdown without YAML
// frontmatter. We treat them as untyped Markdown bundles and parse the
// human-meaningful fields (H1, `> Category:`, palette hex codes) at
// page-render time.
const systems = defineCollection({
loader: glob({
base: '../../design-systems',
pattern: '*/DESIGN.md',
}),
schema: z.object({}).passthrough(),
});
const craft = defineCollection({
loader: glob({
base: '../../craft',
pattern: '*.md',
}),
schema: z.object({}).passthrough(),
});
// `templates/live-artifacts/<slug>/README.md` — Live Artifact bundles.
// We surface them under `/templates/` together with skills whose `od.mode`
// is `template` (filtered at render time, not in the schema).
const templates = defineCollection({
loader: glob({
base: '../../templates/live-artifacts',
pattern: '*/README.md',
}),
schema: z.object({}).passthrough(),
});
export const collections = { skills, systems, craft, templates };

View file

@ -9,7 +9,7 @@
* islands only when behavior is needed.
*/
import { Header } from './_components/header';
import { Header, type HeaderProps } from './_components/header';
import { Wire } from './_components/wire';
import { heroImage, imageAsset } from './image-assets';
@ -90,7 +90,43 @@ const WIRE_CITIES = [
{ name: 'Sydney', coord: '33.87°S' },
] as const;
export default function Page() {
interface PageProps {
/**
* Live counts from the Markdown catalogs. Required: every visible
* "X skills / Y systems" claim on the page reads from here so meta,
* nav, hero copy, capability cards, labs pills, selected-work
* fractions, and the footer Library never disagree.
*/
counts: HeaderProps['counts'] & {
/** Optional richer breakdown used by the Labs filter pills. */
byMode?: Readonly<Record<string, number>>;
byPlatform?: Readonly<Record<string, number>>;
};
}
/**
* Format a count for inline editorial copy. Returns the live value when
* positive (so a fresh `git pull` immediately reflects the new totals),
* falls back to a neutral em-dash when the catalog couldn't be read so
* we never publish "0 skills" to a visitor by mistake.
*/
function fmt(n: number | undefined): string {
return typeof n === 'number' && n > 0 ? String(n) : '—';
}
/** Two-digit padded count for the Labs pills (matches the "04", "27" feel). */
function pad2(n: number | undefined): string {
if (typeof n !== 'number' || n <= 0) return '—';
return n < 10 ? `0${n}` : String(n);
}
export default function Page({ counts }: PageProps) {
const skills = fmt(counts.skills);
const systems = fmt(counts.systems);
const deckCount = pad2(counts.byMode?.deck);
const prototypeCount = pad2(counts.byMode?.prototype);
const mobileCount = pad2(counts.byPlatform?.mobile);
return (
<>
{/* side rails (rotated brand text) */}
@ -145,7 +181,7 @@ export default function Page() {
{/* ====== NAV ====== */}
{/* Headroom-style sticky header with live GitHub star count. */}
<Header />
<Header counts={counts} />
{/* ====== HERO ====== */}
<section className='hero' id='top' data-od-id='hero'>
@ -162,8 +198,8 @@ export default function Page() {
<p className='lead' data-reveal>
The open-source alternative to Claude Design. Your existing
coding agent Claude · Codex · Cursor · Gemini · OpenCode ·
Qwen becomes the design engine, driven by 31 composable
skills and 72 brand-grade design systems.
Qwen becomes the design engine, driven by {skills} composable
skills and {systems} brand-grade design systems.
</p>
<div className='hero-actions' data-reveal>
<a className='btn btn-primary' href={REPO} {...ext}>
@ -177,13 +213,13 @@ export default function Page() {
</div>
<div className='hero-stats' data-reveal>
<div className='stat'>
<span className='ring solid'>31</span>
<span className='ring solid'>{skills}</span>
<span className='stat-label'>
<b>skills</b>shippable
</span>
</div>
<div className='stat'>
<span className='ring'>72</span>
<span className='ring'>{systems}</span>
<span className='stat-label'>
<b>systems</b>portable
</span>
@ -375,7 +411,7 @@ export default function Page() {
not plugins
</h3>
<p>
31 file-based{' '}
{skills} file-based{' '}
<code style={{ fontFamily: 'var(--mono)', fontSize: 12 }}>
SKILL.md
</code>{' '}
@ -412,7 +448,7 @@ export default function Page() {
as Markdown
</h3>
<p>
72 portable{' '}
{systems} portable{' '}
<code style={{ fontFamily: 'var(--mono)', fontSize: 12 }}>
DESIGN.md
</code>{' '}
@ -506,7 +542,7 @@ export default function Page() {
<span className='meta-grp'>
<span>Labs / Skills Catalog</span>
<span className='dot-mark'></span>
<span>05 of 31 ongoing</span>
<span>05 of {skills} ongoing</span>
</span>
<span>004 / 008</span>
</div>
@ -521,21 +557,21 @@ export default function Page() {
</h2>
</div>
<div className='pills' data-reveal='right'>
<button type='button' className='pill active'>
All<span className='count'>31</span>
</button>
<button type='button' className='pill'>
Prototype<span className='count'>27</span>
</button>
<button type='button' className='pill'>
Deck<span className='count'>04</span>
</button>
<button type='button' className='pill'>
Mobile<span className='count'>03</span>
</button>
<button type='button' className='pill'>
Office<span className='count'>08</span>
</button>
<a className='pill active' href='/skills/'>
All<span className='count'>{skills}</span>
</a>
<a className='pill' href='/skills/mode/prototype/'>
Prototype<span className='count'>{prototypeCount}</span>
</a>
<a className='pill' href='/skills/mode/deck/'>
Deck<span className='count'>{deckCount}</span>
</a>
<a className='pill' href='/skills/'>
Mobile<span className='count'>{mobileCount}</span>
</a>
<a className='pill' href='/skills/'>
Office<span className='count'></span>
</a>
</div>
</div>
<div className='labs-meta'>
@ -634,12 +670,11 @@ export default function Page() {
<span />
</div>
<span className='meta'>
05 / 31 SKILLS{NBSP}·{NBSP}
05 / {skills} SKILLS{NBSP}·{NBSP}
<a
href={REPO_SKILLS}
href='/skills/'
className='library-link'
style={{ color: 'var(--coral)' }}
{...ext}
>
VIEW FULL LIBRARY
</a>
@ -682,7 +717,7 @@ export default function Page() {
{
num: '01',
title: 'Detect',
body: 'The daemon scans your $PATH for 12 coding agents and auto-loads 31 skills + 72 systems on boot.',
body: `The daemon scans your $PATH for 12 coding agents and auto-loads ${skills} skills + ${systems} systems on boot.`,
src: imageAsset('method-1.png', { width: 816, quality: 82 }),
},
{
@ -751,8 +786,8 @@ export default function Page() {
<em>artifacts</em>
<span className='dot'>.</span>
</h2>
<a className='work-link' href={REPO_SKILLS} {...ext}>
View all 31 skills
<a className='work-link' href='/skills/'>
View all {skills} skills
</a>
</div>
<a
@ -763,7 +798,7 @@ export default function Page() {
>
<div className='label-row'>
<span className='small-label'>Featured skill</span>
<span className='index'>01 / 31</span>
<span className='index'>01 / {skills}</span>
</div>
<h3>guizang-ppt</h3>
<p>
@ -786,7 +821,7 @@ export default function Page() {
>
<div className='label-row'>
<span className='small-label'>Companion system</span>
<span className='index'>04 / 72</span>
<span className='index'>04 / {systems}</span>
</div>
<h3>kami</h3>
<p>
@ -1116,24 +1151,16 @@ export default function Page() {
<h5>Library</h5>
<ul>
<li>
<a href={REPO_SKILLS} {...ext}>
31 Skills
</a>
<a href='/skills/'>{skills} Skills</a>
</li>
<li>
<a href={REPO_DESIGN_SYSTEMS} {...ext}>
72 Systems
</a>
<a href='/systems/'>{systems} Systems</a>
</li>
<li>
<a href={REPO_DESIGN_SYSTEMS} {...ext}>
5 Directions
</a>
<a href='/templates/'>Templates</a>
</li>
<li>
<a href={`${REPO_SKILLS}/hyperframes`} {...ext}>
5 Frames
</a>
<a href='/craft/'>Craft</a>
</li>
</ul>
</div>

View file

@ -0,0 +1,73 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getCraftRecords, type CraftRecord } from '../../_lib/catalog';
export async function getStaticPaths() {
const records = await getCraftRecords();
return records.map((c) => ({
params: { slug: c.slug },
props: { craft: c, all: records },
}));
}
interface Props {
craft: CraftRecord;
all: ReadonlyArray<CraftRecord>;
}
const { craft, all } = Astro.props as Props;
const title = `${craft.name} — Open Design craft principle`;
const description = craft.summary || `Open Design craft rule: ${craft.name}.`;
const related = all.filter((c) => c.slug !== craft.slug).slice(0, 4);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: 'Craft', item: new URL('/craft/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: craft.name, item: new URL(`/craft/${craft.slug}/`, Astro.site).toString() },
],
};
---
<Layout title={title} description={description} active="craft" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Open Design</a>
<span>/</span>
<a href="/craft/">Craft</a>
<span>/</span>
<span aria-current="page">{craft.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">Craft principle</span>
<h1 class="display">{craft.name}<span class="dot">.</span></h1>
<p class="lead">{craft.summary}</p>
<div class="detail-actions">
<a class="btn btn-primary" href={craft.source} target="_blank" rel="noopener">
Read the full rule on GitHub
</a>
</div>
</header>
{related.length > 0 && (
<section class="detail-block">
<h2>Other craft principles</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={`/craft/${r.slug}/`}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.summary}</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -0,0 +1,50 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getCraftRecords } from '../../_lib/catalog';
const craft = await getCraftRecords();
const title = `Craft — ${craft.length} brand-agnostic rendering principles | Open Design`;
const description =
'Universal craft rules every Open Design skill can opt into: accessibility, animation discipline, color, form validation, laws of UX, RTL/Bidi, state coverage, and typography hierarchy.';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Open Design Craft principles',
description,
url: new URL('/craft/', Astro.site).toString(),
numberOfItems: craft.length,
};
---
<Layout title={title} description={description} active="craft" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">Catalog · Nº 03</span>
<h1 class="display">
<em>Craft</em> — {craft.length} brand-agnostic rendering principles<span class="dot">.</span>
</h1>
<p class="lead">
Skills declare which craft rules they require. The agent loads the
matching rules into its system prompt so quality concerns
(a11y, motion, color, type) stay invariant across visual systems.
</p>
</header>
<section class="catalog-grid">
<ol>
{craft.map((c, idx) => (
<li class="catalog-row">
<a href={`/craft/${c.slug}/`}>
<span class="row-index">{String(idx + 1).padStart(2, '0')}</span>
<span class="row-body">
<span class="row-name">{c.name}</span>
<span class="row-desc">{c.summary}</span>
</span>
<span class="row-arrow" aria-hidden="true">→</span>
</a>
</li>
))}
</ol>
</section>
</Layout>

View file

@ -4,12 +4,15 @@ import '../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { heroImage } from '../image-assets';
import { getCatalogCounts } from '../_lib/catalog';
const counts = await getCatalogCounts();
const title = 'Open Design — Design with the agent already on your laptop.';
const description =
'The open-source alternative to Claude Design. Your existing coding agent — Claude · Codex · Cursor · Gemini · OpenCode · Qwen — becomes the design engine, driven by 31 composable skills and 72 brand-grade design systems.';
const description = `The open-source alternative to Claude Design. Your existing coding agent — Claude · Codex · Cursor · Gemini · OpenCode · Qwen — becomes the design engine, driven by ${counts.skills} composable skills and ${counts.systems} brand-grade design systems.`;
const canonical = new URL(Astro.url.pathname, Astro.site).toString();
const pageHtml = renderToStaticMarkup(createElement(Page));
const pageHtml = renderToStaticMarkup(
Page({ counts }) as ReturnType<typeof createElement>,
);
---
<!doctype html>

View file

@ -0,0 +1,168 @@
---
/*
* /skills/<slug>/ — a detail page per skill.
*
* Mostly a structured read-out of SKILL.md frontmatter (description,
* triggers, mode/scenario/platform, featured rank) plus deep links
* to the GitHub source. We deliberately don't render the full
* SKILL.md body to avoid duplicating the README and to keep the page
* scan-friendly for both humans and search engines.
*/
import Layout from '../../_components/sub-page-layout.astro';
import { getSkillRecords, type SkillRecord } from '../../_lib/catalog';
export async function getStaticPaths() {
const skills = await getSkillRecords();
return skills.map((skill) => ({
params: { slug: skill.slug },
props: { skill, all: skills },
}));
}
interface Props {
skill: SkillRecord;
all: ReadonlyArray<SkillRecord>;
}
const { skill, all } = Astro.props as Props;
const title = `${skill.name} — Open Design skill`;
const description = skill.description.length > 0
? skill.description
: `Open Design skill bundle: ${skill.name}.`;
const related = all
.filter((s) => s.slug !== skill.slug)
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
.slice(0, 4);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: 'Skills', item: new URL('/skills/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareSourceCode',
name: skill.name,
description,
codeRepository: skill.source,
programmingLanguage: 'Markdown',
keywords: skill.triggers.join(', '),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
},
];
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Open Design</a>
<span>/</span>
<a href="/skills/">Skills</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
Skill
{typeof skill.featured === 'number' && (
<span class="ix">· Featured Nº {String(skill.featured).padStart(2, '0')}</span>
)}
</span>
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
<p class="lead">{description}</p>
<div class="detail-actions">
<a class="btn btn-primary" href={skill.source} target="_blank" rel="noopener">
View on GitHub
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
Upstream
</a>
)}
</div>
</header>
{skill.previewUrl && (
<figure class="detail-preview">
<img src={skill.previewUrl} alt={`${skill.name} example output`} loading="lazy" decoding="async" />
<figcaption>
Rendered from <code>skills/{skill.slug}/example.html</code>
</figcaption>
</figure>
)}
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>Mode</dt>
<dd>{skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>Scenario</dt>
<dd>{skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>Platform</dt>
<dd>{skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>Category</dt>
<dd>{skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>Triggers</h2>
<p class="block-lead">
The picker matches these prompts to the skill. Copy one and adapt it to your brief.
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>Example prompt</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>Related skills</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={`/skills/${r.slug}/`}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.mode && <span class="meta-tag">{r.mode}</span>}
{r.scenario && <span class="meta-tag muted">{r.scenario}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -0,0 +1,131 @@
---
/*
* /skills/ — index of every shippable skill in the repo.
*
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
* Collections so adding a skill anywhere in the monorepo
* automatically surfaces here on the next build.
*/
import Layout from '../../_components/sub-page-layout.astro';
import SkillRow from '../../_components/skill-row.astro';
import {
getSkillRecords,
getSkillModeIndex,
getSkillScenarioIndex,
tally,
} from '../../_lib/catalog';
const skills = await getSkillRecords();
const modeTags = await getSkillModeIndex();
const scenarioTags = await getSkillScenarioIndex();
const platformTally = tally(
skills.map((s) => s.platform).filter((p): p is string => Boolean(p)),
);
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
const title = `Skills — ${skills.length} composable design capabilities | Open Design`;
const description =
'Browse the full Open Design skills catalog: 100+ file-based SKILL.md bundles spanning decks, prototypes, dashboards, mobile flows, video, and live artifacts. Each skill is a folder you drop into the daemon.';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Open Design Skills catalog',
description,
url: new URL('/skills/', Astro.site).toString(),
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: Astro.site?.toString(),
},
numberOfItems: skills.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">Catalog · Nº 01</span>
<h1 class="display">
<em>Skills</em> — {skills.length} composable design capabilities<span class="dot">.</span>
</h1>
<p class="lead">
Each skill is a single folder with one <code>SKILL.md</code>. Drop it in,
restart the daemon, the picker shows it. Filter by surface, scenario,
or platform below to find the one that matches your brief.
</p>
</header>
<section class="filter-strip" aria-label="Skill filters">
<div class="filter-group">
<span class="filter-label">Mode</span>
<ul>
{modeTags.map((tag) => (
<li>
<a class="chip chip-link" href={`/skills/mode/${tag.slug}/`}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
<div class="filter-group">
<span class="filter-label">Scenario</span>
<ul>
{scenarioTags.slice(0, 12).map((tag) => (
<li>
<a class="chip chip-link" href={`/skills/scenario/${tag.slug}/`}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
{platformTally.length > 0 && (
<div class="filter-group">
<span class="filter-label">Platform</span>
<ul>
{platformTally.map(([key, count]) => (
<li>
<span class="chip">
{key}<span class="chip-num">{count}</span>
</span>
</li>
))}
</ul>
</div>
)}
</section>
{featured.length > 0 && (
<section class="featured-strip" aria-labelledby="featured-skills">
<h2 id="featured-skills" class="strip-title">Featured</h2>
<ul class="featured-grid">
{featured.map((s) => (
<li class="featured-card">
<a href={`/skills/${s.slug}/`}>
{s.previewUrl ? (
<span class="featured-thumb">
<img src={s.previewUrl} alt="" loading="lazy" decoding="async" />
</span>
) : (
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
)}
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
<span class="featured-name">{s.name}</span>
<p>{s.description}</p>
{s.mode && <span class="meta-tag">{s.mode}</span>}
</a>
</li>
))}
</ul>
</section>
)}
<section class="catalog-grid catalog-grid-skills" aria-label="All skills">
<ol>
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -0,0 +1,79 @@
---
/*
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
* (deck, prototype, template, image, video, audio, design-system, utility).
*
* One static page per distinct `od.mode` value. Mode is the strongest
* mental-model facet ("I want a deck-builder") so this is the primary
* faceted view; scenario/category live alongside.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillModeIndex,
getSkillsForMode,
type TagDescriptor,
} from '../../../_lib/catalog';
export async function getStaticPaths() {
const tags = await getSkillModeIndex();
return tags.map((tag) => ({
params: { mode: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const { records, label } = await getSkillsForMode(tag.slug);
const heading = label ?? tag.label;
const title = `${heading} skills — ${records.length} Open Design ${heading.toLowerCase()} agents`;
const description =
`Every Open Design skill that produces ${heading.toLowerCase()} artifacts. ` +
`${records.length} ready-to-run, system-aware agents — installable through ` +
`the daemon, brand-locked through any DESIGN.md system.`;
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${heading} skills · Open Design`,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/skills/">Skills</a>
<span aria-hidden="true">/</span>
<span>Mode</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">Catalog · Nº 01 · Filter</span>
<h1 class="display">
<em>{heading}</em> — {records.length} brand-grade {heading.toLowerCase()} agents<span class="dot">.</span>
</h1>
<p class="lead">
Filtered to <code>od.mode: {label ?? tag.label}</code>. Every skill below
reads the active <code>DESIGN.md</code> as a system prompt, so it inherits
colors, type, and spacing from any portable system you pair it with.
</p>
<p class="filter-clear">
<a href="/skills/">← All skills ({tag.count} of total)</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={`${heading} skills`}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -0,0 +1,78 @@
---
/*
* /skills/scenario/<slug>/ — every skill targeting a given use-case
* scenario (marketing, engineering, design, research, ...).
*
* Mirrors the mode page but facets on `od.scenario`. One page per
* distinct scenario value found across all SKILL.md files.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SkillRow from '../../../_components/skill-row.astro';
import {
getSkillScenarioIndex,
getSkillsForScenario,
type TagDescriptor,
} from '../../../_lib/catalog';
export async function getStaticPaths() {
const tags = await getSkillScenarioIndex();
return tags.map((tag) => ({
params: { scenario: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const { records, label } = await getSkillsForScenario(tag.slug);
const heading = label ?? tag.label;
const title = `${heading} skills — ${records.length} Open Design ${heading.toLowerCase()} agents`;
const description =
`Every Open Design skill in the ${heading.toLowerCase()} scenario. ` +
`${records.length} ready-to-run agents covering decks, prototypes, ` +
`templates, and live artifacts — all brand-locked through any DESIGN.md.`;
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${heading} skills · Open Design`,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/skills/">Skills</a>
<span aria-hidden="true">/</span>
<span>Scenario</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">Catalog · Nº 01 · Filter</span>
<h1 class="display">
<em>{heading}</em> — {records.length} {heading.toLowerCase()} skills<span class="dot">.</span>
</h1>
<p class="lead">
Filtered to <code>od.scenario: {label ?? tag.label}</code>. Pair any of
these with a portable design system and the daemon orchestrates the rest —
one prompt, one branded artifact.
</p>
<p class="filter-clear">
<a href="/skills/">← All skills</a>
</p>
</header>
<section class="catalog-grid catalog-grid-skills" aria-label={`${heading} skills`}>
<ol>
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
</ol>
</section>
</Layout>

View file

@ -0,0 +1,121 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
export async function getStaticPaths() {
const systems = await getSystemRecords();
return systems.map((system) => ({
params: { slug: system.slug },
props: { system, all: systems },
}));
}
interface Props {
system: SystemRecord;
all: ReadonlyArray<SystemRecord>;
}
const { system, all } = Astro.props as Props;
const title = `${system.name} — Open Design design system`;
const description = system.tagline
? `${system.name} (${system.category}) — ${system.tagline}`
: `Open Design system bundle: ${system.name}, ${system.category}.`;
const related = all
.filter((s) => s.slug !== system.slug && s.category === system.category)
.slice(0, 4);
const jsonLd = [
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: 'Design Systems', item: new URL('/systems/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
],
},
{
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: system.name,
description,
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
license: 'https://www.apache.org/licenses/LICENSE-2.0',
genre: system.category,
},
];
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Open Design</a>
<span>/</span>
<a href="/systems/">Design Systems</a>
<span>/</span>
<span aria-current="page">{system.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
Design system
<span class="ix">· {system.category}</span>
</span>
<h1 class="display">{system.name}<span class="dot">.</span></h1>
{system.tagline && <p class="lead">{system.tagline}</p>}
<div class="detail-actions">
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
View DESIGN.md on GitHub
</a>
</div>
</header>
{system.palette.length > 0 && (
<section class="detail-block">
<h2>Palette sample</h2>
<p class="block-lead">
First {system.palette.length} hex codes lifted from the DESIGN.md
color sections. The full palette and roles live in the source spec.
</p>
<div class="palette-row">
{system.palette.map((hex) => (
<div class="palette-cell">
<span class="swatch" style={`background:${hex}`} />
<code>{hex}</code>
</div>
))}
</div>
</section>
)}
{system.atmosphere && (
<section class="detail-block">
<h2>Visual theme</h2>
<p class="atmosphere">{system.atmosphere}</p>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>Related systems in {system.category}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={`/systems/${r.slug}/`}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.tagline}</span>
<div class="system-swatches" aria-hidden="true">
{r.palette.slice(0, 4).map((hex) => (
<span class="swatch" style={`background:${hex}`} />
))}
</div>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -0,0 +1,75 @@
---
/*
* /systems/category/<slug>/ — every design system grouped by category
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
*/
import Layout from '../../../_components/sub-page-layout.astro';
import SystemCard from '../../../_components/system-card.astro';
import {
getSystemCategoryIndex,
getSystemsForCategory,
type TagDescriptor,
} from '../../../_lib/catalog';
export async function getStaticPaths() {
const tags = await getSystemCategoryIndex();
return tags.map((tag) => ({
params: { category: tag.slug },
props: { tag },
}));
}
interface Props {
tag: TagDescriptor;
}
const { tag } = Astro.props as Props;
const { records, label } = await getSystemsForCategory(tag.slug);
const heading = label ?? tag.label;
const title = `${heading} design systems — ${records.length} portable visual systems · Open Design`;
const description =
`Every Open Design design system tagged ${heading.toLowerCase()}. ` +
`${records.length} portable DESIGN.md token bundles — ready to pair with ` +
`any skill in the catalog for instant brand-grade output.`;
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: `${heading} design systems · Open Design`,
description,
url,
numberOfItems: records.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/systems/">Design Systems</a>
<span aria-hidden="true">/</span>
<span>Category</span>
<span aria-hidden="true">/</span>
<span class="crumb-active">{heading}</span>
</nav>
<span class="label">Catalog · Nº 02 · Filter</span>
<h1 class="display">
<em>{heading}</em> — {records.length} portable visual systems<span class="dot">.</span>
</h1>
<p class="lead">
Filtered to category <code>{label ?? tag.label}</code>. Pick any of these
in the daemon top-bar and every skill in the catalog reads its tokens —
colors, type, spacing, voice — as part of its system prompt.
</p>
<p class="filter-clear">
<a href="/systems/">← All design systems</a>
</p>
</header>
<section class="catalog-grid systems-grid" aria-label={`${heading} systems`}>
<ul>
{records.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -0,0 +1,60 @@
---
/*
* /systems/ — index of every portable design system in the repo.
*/
import Layout from '../../_components/sub-page-layout.astro';
import SystemCard from '../../_components/system-card.astro';
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
const systems = await getSystemRecords();
const categoryTags = await getSystemCategoryIndex();
const title = `Design Systems — ${systems.length} portable visual systems | Open Design`;
const description =
'Browse the full Open Design design systems catalog: 100+ DESIGN.md token bundles spanning editorial, productivity, brand, futuristic, and minimalist systems. Pick one in the daemon top-bar and every skill renders in that visual language.';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Open Design Design Systems catalog',
description,
url: new URL('/systems/', Astro.site).toString(),
numberOfItems: systems.length,
};
---
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">Catalog · Nº 02</span>
<h1 class="display">
<em>Design Systems</em> — {systems.length} portable visual systems<span class="dot">.</span>
</h1>
<p class="lead">
Each system is a single <code>DESIGN.md</code> token spec. Pick one in
the daemon top-bar and every skill reads it as part of its system
prompt — colors, type, spacing, components, all consistent.
</p>
</header>
<section class="filter-strip" aria-label="Design system filters">
<div class="filter-group">
<span class="filter-label">Category</span>
<ul>
{categoryTags.map((tag) => (
<li>
<a class="chip chip-link" href={`/systems/category/${tag.slug}/`}>
{tag.label}<span class="chip-num">{tag.count}</span>
</a>
</li>
))}
</ul>
</div>
</section>
<section class="catalog-grid systems-grid" aria-label="All systems">
<ul>
{systems.map((s) => <SystemCard system={s} />)}
</ul>
</section>
</Layout>

View file

@ -0,0 +1,88 @@
---
/*
* /templates/<slug>/ — detail page for Live Artifact templates.
*
* Skill-mode templates are listed on `/templates/` but link straight
* to `/skills/<slug>/`, so this page only generates routes for the
* Live Artifact bundles under `templates/live-artifacts/`.
*/
import Layout from '../../_components/sub-page-layout.astro';
import { getTemplateRecords, type TemplateRecord } from '../../_lib/catalog';
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records
.filter((t) => t.origin === 'live-artifact')
.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template } = Astro.props as Props;
const title = `${template.name} — Open Design template`;
const description = template.summary;
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
{ '@type': 'ListItem', position: 2, name: 'Templates', item: new URL('/templates/', Astro.site).toString() },
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
],
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/">Open Design</a>
<span>/</span>
<a href="/templates/">Templates</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
Template
<span class="ix">· Live Artifact</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
<div class="detail-actions">
<a class="btn btn-primary" href={template.source} target="_blank" rel="noopener">
Fork on GitHub
</a>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
<img src={template.previewUrl} alt={`${template.name} preview`} loading="lazy" decoding="async" />
<figcaption>Rendered from the template's seed data.</figcaption>
</figure>
)}
<section class="detail-block">
<h2>What's in this template</h2>
<p class="block-lead">
Live Artifact templates ship as a folder you can copy verbatim
into your workspace. They include a <code>template.html</code>
renderer, a <code>data.json</code> with the seed values, and a
README describing the connector wiring.
</p>
<ul class="trigger-list">
<li><code>template.html</code> — the artifact renderer</li>
<li><code>data.json</code> — seed values for offline / preview rendering</li>
<li><code>README.md</code> — connector wiring, refresh cadence, customization notes</li>
</ul>
</section>
</article>
</Layout>

View file

@ -0,0 +1,56 @@
---
import Layout from '../../_components/sub-page-layout.astro';
import { getTemplateRecords } from '../../_lib/catalog';
const templates = await getTemplateRecords();
const title = `Templates — ${templates.length} ready-to-fork artifact templates | Open Design`;
const description =
'Ready-to-fork artifact templates: refreshable Live Artifacts (Notion-style team dashboards, ops briefs) plus deck and prototype starting points. Each template ships as a fork-friendly bundle with sample data.';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Open Design Templates catalog',
description,
url: new URL('/templates/', Astro.site).toString(),
numberOfItems: templates.length,
};
---
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
<header class="catalog-head">
<span class="label">Catalog · Nº 04</span>
<h1 class="display">
<em>Templates</em> — {templates.length} ready-to-fork artifacts<span class="dot">.</span>
</h1>
<p class="lead">
Pre-wired artifact bundles with sample data and a known-good
visual language. Fork the folder, swap the sample data with yours,
and ship.
</p>
</header>
<section class="template-grid" aria-label="All templates">
<ul>
{templates.map((t) => (
<li class="template-card">
<a href={t.detailHref}>
{t.previewUrl ? (
<span class="template-thumb">
<img src={t.previewUrl} alt="" loading="lazy" decoding="async" />
</span>
) : (
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
)}
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
{t.origin === 'live-artifact' ? 'Live Artifact' : 'Skill template'}
</span>
<span class="template-name">{t.name}</span>
<p class="template-summary">{t.summary}</p>
</a>
</li>
))}
</ul>
</section>
</Layout>

View file

@ -0,0 +1,932 @@
/*
* Sub-page styles Skills / Systems / Craft / Templates index +
* detail layouts.
*
* Lives in a separate stylesheet (not `globals.css`) so the
* lockstep-with-`example.html` rule on the homepage stays intact.
* All tokens here come from the same Atelier Zero CSS custom
* properties defined in `globals.css` keep this file focused
* on layout primitives the sub-pages need.
*/
/* ---------- shell adjustments ---------- */
body.sub-page {
/* sub-pages don't have a hero, so the nav can sit closer to the
* page top without the topbar strip pushing it down. */
background: var(--paper);
}
.sub-main {
padding: 140px 0 96px;
min-height: 60vh;
}
@media (max-width: 720px) {
.sub-main { padding: 120px 0 72px; }
}
/* ---------- nav active state ---------- */
.nav-links a.is-active {
color: var(--ink);
}
.nav-links a.is-active::after {
content: '';
display: block;
height: 1.5px;
background: var(--coral);
margin-top: 4px;
border-radius: 2px;
}
/* ---------- breadcrumb ---------- */
.breadcrumb {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-mute);
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 28px;
}
.breadcrumb a {
color: var(--ink-mute);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color 0.16s ease, border-color 0.16s ease;
}
.breadcrumb a:hover {
color: var(--ink);
border-bottom-color: var(--coral);
}
.breadcrumb span:not([aria-current]) { opacity: 0.5; }
.breadcrumb [aria-current] { color: var(--ink); }
/* ---------- catalog head (shared between index pages) ---------- */
.catalog-head {
max-width: 880px;
margin-bottom: 56px;
border-top: 1px solid var(--ink);
padding-top: 28px;
}
.catalog-head .label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
display: inline-block;
margin-bottom: 18px;
}
.catalog-head .display {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(40px, 6vw, 72px);
line-height: 1.04;
letter-spacing: -0.022em;
color: var(--ink);
margin-bottom: 24px;
}
.catalog-head .display em {
font-style: italic;
font-weight: 600;
color: var(--coral);
}
.catalog-head .display .dot { color: var(--coral); }
.catalog-head .lead {
font-size: 17px;
line-height: 1.6;
color: var(--ink-soft);
max-width: 720px;
}
.catalog-head .lead code {
font-family: var(--mono);
font-size: 0.92em;
background: rgba(237, 111, 92, 0.08);
padding: 1px 6px;
border-radius: 4px;
}
/* ---------- filter strip ---------- */
.filter-strip {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 56px;
padding: 24px 0 28px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.filter-group {
display: grid;
grid-template-columns: 100px 1fr;
gap: 18px;
align-items: baseline;
}
.filter-label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
}
.filter-group ul {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0;
margin: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ink);
background: rgba(255, 255, 255, 0.32);
transition: border-color 0.16s ease, color 0.16s ease;
text-decoration: none;
}
.chip:hover {
border-color: var(--coral);
color: var(--coral);
}
.chip-link {
cursor: pointer;
}
.chip-link:hover {
background: rgba(237, 111, 92, 0.06);
}
.chip-num {
color: var(--ink-mute);
font-size: 10px;
}
/* breadcrumb active leaf + filter-clear back-link share styling so
* faceted views feel coherent with the breadcrumb above. */
.breadcrumb .crumb-active {
color: var(--ink);
opacity: 1;
}
.filter-clear {
margin-top: 18px;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.filter-clear a {
color: var(--ink-mute);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: color 0.16s ease, border-color 0.16s ease;
}
.filter-clear a:hover {
color: var(--coral);
border-bottom-color: var(--coral);
}
@media (max-width: 720px) {
.filter-group { grid-template-columns: 1fr; gap: 8px; }
}
/* ---------- featured strip (skills index) ---------- */
.featured-strip {
margin-bottom: 56px;
}
.strip-title {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
margin-bottom: 16px;
}
.featured-grid {
list-style: none;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 0;
margin: 0;
}
.featured-card {
position: relative;
border: 1px solid var(--line);
background: var(--paper-warm);
transition: border-color 0.16s ease, transform 0.16s ease;
overflow: hidden;
}
.featured-card:hover {
border-color: var(--coral);
transform: translateY(-2px);
}
.featured-card a {
display: block;
padding: 0;
text-decoration: none;
color: var(--ink);
}
.featured-card a > * + * {
margin-left: 22px;
margin-right: 22px;
}
.featured-card .featured-num { margin-top: 18px; }
.featured-card p { margin-bottom: 12px; }
.featured-card .meta-tag { margin-bottom: 22px; }
.featured-thumb {
display: block;
margin: 0;
aspect-ratio: 16 / 10;
background: var(--paper-warm);
border-bottom: 1px solid var(--line-soft);
overflow: hidden;
}
.featured-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
transition: transform 0.5s ease;
}
.featured-card:hover .featured-thumb img { transform: scale(1.02); }
.featured-thumb-empty {
background:
repeating-linear-gradient(
135deg,
var(--paper-dark),
var(--paper-dark) 10px,
var(--paper-warm) 10px,
var(--paper-warm) 20px
);
}
.featured-num {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--coral);
display: block;
margin-bottom: 12px;
}
.featured-name {
font-family: var(--serif);
font-weight: 600;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--ink);
display: block;
margin-bottom: 8px;
}
.featured-card p {
color: var(--ink-soft);
font-size: 13px;
line-height: 1.5;
margin: 0 0 12px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 980px) {
.featured-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.featured-grid { grid-template-columns: 1fr; }
}
/* ---------- catalog grid (linear list, used by skills + craft + templates) ---------- */
.catalog-grid {
margin-top: 16px;
}
.catalog-grid ol,
.catalog-grid ul {
list-style: none;
padding: 0;
margin: 0;
border-top: 1px solid var(--line);
}
.catalog-row {
border-bottom: 1px solid var(--line);
}
.catalog-row a {
display: grid;
grid-template-columns: 60px 1fr auto auto;
gap: 24px;
align-items: center;
padding: 22px 0;
text-decoration: none;
color: var(--ink);
transition: padding 0.16s ease;
}
.catalog-row-skill a {
grid-template-columns: 60px 130px 1fr auto auto;
}
.catalog-row a:hover {
padding-left: 12px;
padding-right: 12px;
background: var(--paper-warm);
}
.catalog-row a:hover .row-arrow { color: var(--coral); transform: translateX(4px); }
.row-thumb {
display: block;
width: 130px;
aspect-ratio: 16 / 10;
border: 1px solid var(--line-soft);
background: var(--paper-warm);
overflow: hidden;
}
.row-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
}
.row-thumb-empty {
display: block;
width: 100%;
height: 100%;
background:
repeating-linear-gradient(
135deg,
var(--paper-dark),
var(--paper-dark) 8px,
var(--paper-warm) 8px,
var(--paper-warm) 16px
);
}
.row-index {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.16em;
color: var(--ink-mute);
}
.row-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.row-name {
font-family: var(--sans);
font-weight: 600;
font-size: 17px;
letter-spacing: -0.005em;
color: var(--ink);
}
.row-desc {
font-size: 14px;
line-height: 1.5;
color: var(--ink-soft);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
justify-content: flex-end;
max-width: 320px;
}
.row-arrow {
font-family: var(--mono);
color: var(--ink-faint);
transition: color 0.16s ease, transform 0.16s ease;
}
.meta-tag {
display: inline-block;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink);
border: 1px solid var(--line);
padding: 3px 9px;
border-radius: 999px;
}
.meta-tag.muted {
color: var(--ink-mute);
border-color: var(--line-soft);
}
.meta-tag.coral {
color: var(--coral);
border-color: rgba(237, 111, 92, 0.4);
}
@media (max-width: 720px) {
.catalog-row a,
.catalog-row-skill a {
grid-template-columns: 36px 1fr auto;
gap: 14px;
padding: 18px 0;
}
.catalog-row a:hover { padding-left: 8px; padding-right: 8px; }
.row-meta { display: none; }
.row-thumb { display: none; }
.row-arrow { font-size: 14px; }
}
/* ---------- template grid ---------- */
.template-grid ul {
list-style: none;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
padding: 0;
margin: 0;
}
.template-card {
border: 1px solid var(--line);
background: var(--paper-warm);
transition: border-color 0.16s ease, transform 0.16s ease;
overflow: hidden;
}
.template-card:hover {
border-color: var(--coral);
transform: translateY(-2px);
}
.template-card a {
display: block;
text-decoration: none;
color: var(--ink);
}
.template-card a > .meta-tag,
.template-card a > .template-name,
.template-card a > .template-summary {
margin-left: 20px;
margin-right: 20px;
}
.template-card a > .meta-tag {
display: inline-block;
margin-top: 16px;
}
.template-thumb {
display: block;
aspect-ratio: 16 / 10;
background: var(--paper-warm);
border-bottom: 1px solid var(--line-soft);
overflow: hidden;
}
.template-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top center;
display: block;
transition: transform 0.5s ease;
}
.template-card:hover .template-thumb img { transform: scale(1.02); }
.template-thumb-empty {
background:
repeating-linear-gradient(
135deg,
var(--paper-dark),
var(--paper-dark) 10px,
var(--paper-warm) 10px,
var(--paper-warm) 20px
);
}
.template-name {
display: block;
margin-top: 10px;
font-family: var(--serif);
font-weight: 600;
font-size: 22px;
line-height: 1.2;
letter-spacing: -0.01em;
color: var(--ink);
}
.template-summary {
margin: 8px 20px 20px;
font-size: 14px;
line-height: 1.5;
color: var(--ink-soft);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 720px) {
.template-grid ul { grid-template-columns: 1fr; }
}
/* ---------- systems grid ---------- */
.systems-grid ul {
list-style: none;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
padding: 0;
margin: 0;
border-top: none;
}
.system-card {
border: 1px solid var(--line);
background: var(--paper-warm);
transition: border-color 0.16s ease, transform 0.16s ease;
}
.system-card:hover {
border-color: var(--coral);
transform: translateY(-2px);
}
.system-card a {
display: block;
padding: 18px 18px 22px;
text-decoration: none;
color: var(--ink);
}
.system-swatches {
display: flex;
gap: 0;
height: 28px;
margin-bottom: 14px;
border: 1px solid var(--line-soft);
}
.system-swatches .swatch {
flex: 1;
display: block;
}
.system-swatches .swatch.placeholder {
background: repeating-linear-gradient(
45deg,
var(--paper-dark),
var(--paper-dark) 6px,
var(--paper-warm) 6px,
var(--paper-warm) 12px
);
}
.system-name {
font-family: var(--serif);
font-weight: 600;
font-size: 20px;
line-height: 1.2;
display: block;
margin-bottom: 4px;
color: var(--ink);
}
.system-cat {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-mute);
display: block;
margin-bottom: 10px;
}
.system-tagline {
font-size: 13px;
line-height: 1.5;
color: var(--ink-soft);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 980px) {
.systems-grid ul { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.systems-grid ul { grid-template-columns: 1fr; }
}
/* ---------- detail pages ---------- */
.detail {
max-width: 880px;
}
.detail-head {
border-top: 1px solid var(--ink);
padding-top: 28px;
margin-bottom: 48px;
}
.detail-head .label {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
display: inline-block;
margin-bottom: 18px;
}
.detail-head .label .ix {
color: var(--coral);
margin-left: 6px;
}
.detail-head .display {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(36px, 5vw, 60px);
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--ink);
margin-bottom: 22px;
}
.detail-head .display .dot { color: var(--coral); }
.detail-head .lead {
font-size: 17px;
line-height: 1.6;
color: var(--ink-soft);
margin-bottom: 28px;
max-width: 720px;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.detail-preview {
margin: 0 0 48px;
border: 1px solid var(--line);
background: var(--paper-warm);
overflow: hidden;
}
.detail-preview img {
width: 100%;
height: auto;
display: block;
border-bottom: 1px solid var(--line-soft);
}
.detail-preview figcaption {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--ink-mute);
padding: 12px 16px;
}
.detail-preview figcaption code {
background: rgba(237, 111, 92, 0.08);
padding: 1px 6px;
border-radius: 3px;
color: var(--ink);
}
/* `btn` styles already exist in globals.css; the detail-actions only
* positions them. */
.detail-meta {
display: grid;
grid-template-columns: 120px 1fr;
gap: 8px 24px;
padding: 24px 0;
margin-bottom: 40px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.detail-meta dt {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
margin: 0;
}
.detail-meta dd {
margin: 0;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
color: var(--ink);
}
.detail-block {
margin-bottom: 48px;
}
.detail-block h2 {
font-family: var(--serif);
font-weight: 600;
font-size: 22px;
letter-spacing: -0.005em;
color: var(--ink);
margin-bottom: 12px;
}
.block-lead {
color: var(--ink-soft);
font-size: 15px;
line-height: 1.55;
margin-bottom: 20px;
max-width: 680px;
}
.trigger-list {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0;
margin: 0;
}
.trigger-list li code {
display: inline-block;
font-family: var(--mono);
font-size: 12px;
background: rgba(237, 111, 92, 0.08);
border: 1px solid rgba(237, 111, 92, 0.22);
color: var(--ink);
padding: 4px 10px;
border-radius: 4px;
}
.example-prompt {
font-family: var(--mono);
font-size: 13px;
line-height: 1.55;
background: var(--paper-warm);
border-left: 3px solid var(--coral);
padding: 18px 20px;
white-space: pre-wrap;
word-break: break-word;
color: var(--ink);
}
.atmosphere {
font-family: var(--serif);
font-style: italic;
font-size: 18px;
line-height: 1.6;
color: var(--ink);
max-width: 720px;
border-left: 3px solid var(--coral);
padding-left: 20px;
}
.palette-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.palette-cell {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.palette-cell .swatch {
width: 80px;
height: 80px;
border: 1px solid var(--line);
border-radius: 4px;
}
.palette-cell code {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.05em;
color: var(--ink);
}
.related-grid {
list-style: none;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
padding: 0;
margin: 0;
}
.related-grid li {
border: 1px solid var(--line);
background: var(--paper-warm);
transition: border-color 0.16s ease, transform 0.16s ease;
}
.related-grid li:hover { border-color: var(--coral); transform: translateY(-2px); }
.related-grid a {
display: block;
padding: 18px;
text-decoration: none;
color: var(--ink);
}
.related-name {
font-family: var(--serif);
font-weight: 600;
font-size: 18px;
letter-spacing: -0.005em;
display: block;
margin-bottom: 6px;
color: var(--ink);
}
.related-desc {
font-size: 13px;
line-height: 1.5;
color: var(--ink-soft);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.related-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
@media (max-width: 600px) {
.related-grid { grid-template-columns: 1fr; }
.detail-meta { grid-template-columns: 1fr; gap: 6px 0; }
.detail-meta dt { padding-top: 8px; }
.detail-meta dd { padding-bottom: 8px; border-bottom: 1px solid var(--line-soft); }
}
/* ---------- sub-page footer ---------- */
.sub-footer {
border-top: 1px solid var(--ink);
background: var(--paper);
padding: 60px 0 32px;
margin-top: 96px;
}
.sub-footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 48px;
margin-bottom: 36px;
}
.sub-footer-brand .brand {
text-decoration: none;
color: var(--ink);
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--sans);
font-weight: 600;
font-size: 17px;
}
.sub-footer-brand p {
margin-top: 16px;
color: var(--ink-soft);
font-size: 14px;
line-height: 1.55;
max-width: 480px;
}
.sub-footer-col h5 {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-mute);
margin-bottom: 14px;
font-weight: 500;
}
.sub-footer-col ul {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 9px;
}
.sub-footer-col a {
text-decoration: none;
color: var(--ink);
font-size: 14px;
transition: color 0.16s ease;
}
.sub-footer-col a:hover { color: var(--coral); }
.sub-footer-bottom {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-top: 24px;
border-top: 1px solid var(--line-soft);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-mute);
}
@media (max-width: 720px) {
.sub-footer-grid { grid-template-columns: 1fr; gap: 32px; }
.sub-footer { padding: 40px 0 24px; }
}

View file

@ -7,6 +7,7 @@
"dev": "astro dev --host 127.0.0.1 --port 17574",
"build": "astro check && astro build",
"preview": "astro preview --host 127.0.0.1 --port 17574",
"previews": "tsx scripts/generate-previews.ts",
"typecheck": "astro check"
},
"dependencies": {
@ -20,6 +21,8 @@
"@types/node": "^20.17.10",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"playwright": "^1.59.1",
"tsx": "^4.21.0",
"typescript": "^5.6.3"
},
"engines": {

View file

@ -0,0 +1,242 @@
/*
* One-shot preview generator for the landing page.
*
* Walks every renderable artifact in the repo and saves a thumbnail
* to `apps/landing-page/public/previews/<bucket>/<slug>.webp`:
*
* skills/<slug>/example.html /previews/skills/<slug>.webp
* templates/live-artifacts/<slug>/index.html /previews/templates/live-<slug>.webp
* templates/live-artifacts/<slug>/preview.png reused verbatim where it exists
*
* Run with: `pnpm --filter @open-design/landing-page previews`
*
* Outputs are intentionally NOT committed by this script the caller
* decides whether to commit (small, deterministic) or upload to R2
* (lighter repo, faster CDN). The catalog data layer auto-detects
* presence at build time so missing previews degrade silently.
*
* Defaults: 1440×900 viewport, captured viewport-only (no full-page
* scroll) at scale=1, then converted to 1280-wide WebP at quality 80
* by the `sharp` post-processor below.
*/
import { chromium, type Browser } from 'playwright';
import { mkdir, cp, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LANDING_ROOT = path.resolve(HERE, '..');
const REPO_ROOT = path.resolve(LANDING_ROOT, '../..');
const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
const TEMPLATES_DIR = path.join(REPO_ROOT, 'templates/live-artifacts');
const OUT_DIR = path.join(LANDING_ROOT, 'public/previews');
const VIEWPORT = { width: 1440, height: 900 } as const;
const SETTLE_MS = 800; // wait after `load` for fonts / R2 images / JS
interface Job {
bucket: 'skills' | 'templates';
slug: string;
htmlPath: string;
/** Optional ready-made preview to copy verbatim (skips browser). */
reuseFrom?: string;
}
async function discoverJobs(): Promise<Job[]> {
const jobs: Job[] = [];
const skillEntries = await readdir(SKILLS_DIR, { withFileTypes: true });
for (const entry of skillEntries) {
if (!entry.isDirectory()) continue;
const example = path.join(SKILLS_DIR, entry.name, 'example.html');
if (existsSync(example)) {
jobs.push({
bucket: 'skills',
slug: entry.name,
htmlPath: example,
});
}
}
if (existsSync(TEMPLATES_DIR)) {
const templateEntries = await readdir(TEMPLATES_DIR, { withFileTypes: true });
for (const entry of templateEntries) {
if (!entry.isDirectory()) continue;
const dir = path.join(TEMPLATES_DIR, entry.name);
const index = path.join(dir, 'index.html');
const ready = path.join(dir, 'preview.png');
const slug = `live-${entry.name}`;
if (existsSync(ready)) {
jobs.push({
bucket: 'templates',
slug,
htmlPath: index,
reuseFrom: ready,
});
} else if (existsSync(index)) {
jobs.push({
bucket: 'templates',
slug,
htmlPath: index,
});
}
}
}
return jobs;
}
async function captureOne(browser: Browser, job: Job): Promise<{
ok: boolean;
bytes: number;
source: 'reuse' | 'render';
error?: string;
}> {
const targetDir = path.join(OUT_DIR, job.bucket);
await mkdir(targetDir, { recursive: true });
const targetPng = path.join(targetDir, `${job.slug}.png`);
if (job.reuseFrom) {
await cp(job.reuseFrom, targetPng);
const s = await stat(targetPng);
return { ok: true, bytes: s.size, source: 'reuse' };
}
const ctx = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
try {
await page.goto(pathToFileURL(job.htmlPath).toString(), {
waitUntil: 'load',
timeout: 15000,
});
await page.waitForTimeout(SETTLE_MS);
await page.screenshot({
path: targetPng,
type: 'png',
fullPage: false,
clip: { x: 0, y: 0, width: VIEWPORT.width, height: VIEWPORT.height },
});
const s = await stat(targetPng);
return { ok: true, bytes: s.size, source: 'render' };
} catch (err) {
return {
ok: false,
bytes: 0,
source: 'render',
error: err instanceof Error ? err.message : String(err),
};
} finally {
await ctx.close();
}
}
// Exit codes used by main():
// 0 — at least one preview was produced (or there was nothing to do).
// 1 — discovery / browser launch failure, OR every job in a non-empty
// run failed (systemic issue — workflows must surface this).
//
// Per-artifact failures alone do NOT exit non-zero. A single broken
// `example.html` should never block a deploy that successfully renders
// the other 100+ previews. CI workflows therefore do NOT need
// `continue-on-error: true` on this step — a non-zero exit here means
// something is genuinely wrong and the build should stop.
const EXIT_OK = 0;
const EXIT_SYSTEMIC = 1;
async function main(): Promise<number> {
let jobs: Job[];
try {
jobs = await discoverJobs();
} catch (err) {
console.error(`✗ discoverJobs failed: ${err instanceof Error ? err.message : String(err)}`);
return EXIT_SYSTEMIC;
}
// Allow a single arg `--only=<substring>` to subset for fast iteration.
const only = process.argv.find((a) => a.startsWith('--only='))?.slice('--only='.length);
const filtered = only ? jobs.filter((j) => j.slug.includes(only)) : jobs;
console.log(`Generating ${filtered.length} previews → ${path.relative(REPO_ROOT, OUT_DIR)}/`);
if (filtered.length === 0) {
// Nothing to do — empty repo, or `--only=` matched nothing. Exit
// clean so CI doesn't fail a deploy that legitimately has no
// previews to render (e.g., on an early scaffold where no skill
// ships an `example.html` yet).
return EXIT_OK;
}
await mkdir(OUT_DIR, { recursive: true });
let browser: Browser;
try {
browser = await chromium.launch({ headless: true });
} catch (err) {
console.error(`✗ chromium.launch failed: ${err instanceof Error ? err.message : String(err)}`);
console.error(' Hint: in CI, ensure `playwright install --with-deps chromium` has run.');
return EXIT_SYSTEMIC;
}
let ok = 0;
let failed = 0;
let bytes = 0;
const reused: string[] = [];
const errors: { slug: string; error: string }[] = [];
// Concurrency limit — 4 contexts at once is plenty for this workload
// and keeps total RAM under ~1.5GB.
const CONCURRENCY = 4;
let cursor = 0;
try {
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (cursor < filtered.length) {
const idx = cursor++;
const job = filtered[idx];
if (!job) break;
const result = await captureOne(browser, job);
if (result.ok) {
ok++;
bytes += result.bytes;
if (result.source === 'reuse') reused.push(job.slug);
process.stdout.write(`${job.bucket}/${job.slug} (${(result.bytes / 1024).toFixed(0)}kb${result.source === 'reuse' ? ', reused' : ''})\n`);
} else {
failed++;
errors.push({ slug: `${job.bucket}/${job.slug}`, error: result.error ?? 'unknown' });
process.stdout.write(`${job.bucket}/${job.slug}: ${result.error}\n`);
}
}
}),
);
} finally {
await browser.close();
}
console.log(`\nDone. ok=${ok} failed=${failed} reused=${reused.length} total=${(bytes / 1024 / 1024).toFixed(1)}MB`);
if (errors.length > 0) {
console.log('\nPer-artifact failures (deploy continues — catalog degrades gracefully for these):');
for (const e of errors) console.log(` ${e.slug}: ${e.error}`);
}
// Systemic failure: every job in a non-empty run failed. That means
// the generator itself is broken, not just one author's example.html.
if (filtered.length > 0 && ok === 0) {
console.error(
`\n✗ All ${filtered.length} preview job(s) failed — treating as systemic.`,
);
return EXIT_SYSTEMIC;
}
return EXIT_OK;
}
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error(err);
process.exit(EXIT_SYSTEMIC);
});

View file

@ -92,7 +92,7 @@ parameters:
type: enum
values: [fal, azure]
default: fal
description: Provider for `image_strategy: generate`. fal.ai is faster.
description: "Provider for `image_strategy: generate`. fal.ai is faster."
outputs:
- path: <out>/index.html
when: output_format in [standalone-html, both]

View file

@ -137,6 +137,12 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.7(@types/react@18.3.28)
playwright:
specifier: ^1.59.1
version: 1.59.1
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.6.3
version: 5.9.3