mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
060540f73c
commit
5077a1cd38
28 changed files with 3471 additions and 94 deletions
47
.github/workflows/landing-page-ci.yml
vendored
47
.github/workflows/landing-page-ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
37
.github/workflows/landing-page-deploy.yml
vendored
37
.github/workflows/landing-page-deploy.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
42
apps/landing-page/app/_components/skill-row.astro
Normal file
42
apps/landing-page/app/_components/skill-row.astro
Normal 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>
|
||||
167
apps/landing-page/app/_components/sub-page-layout.astro
Normal file
167
apps/landing-page/app/_components/sub-page-layout.astro
Normal 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>
|
||||
31
apps/landing-page/app/_components/system-card.astro
Normal file
31
apps/landing-page/app/_components/system-card.astro
Normal 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>
|
||||
693
apps/landing-page/app/_lib/catalog.ts
Normal file
693
apps/landing-page/app/_lib/catalog.ts
Normal 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)));
|
||||
}
|
||||
80
apps/landing-page/app/content.config.ts
Normal file
80
apps/landing-page/app/content.config.ts
Normal 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 };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
73
apps/landing-page/app/pages/craft/[slug].astro
Normal file
73
apps/landing-page/app/pages/craft/[slug].astro
Normal 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>
|
||||
50
apps/landing-page/app/pages/craft/index.astro
Normal file
50
apps/landing-page/app/pages/craft/index.astro
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
168
apps/landing-page/app/pages/skills/[slug].astro
Normal file
168
apps/landing-page/app/pages/skills/[slug].astro
Normal 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>
|
||||
131
apps/landing-page/app/pages/skills/index.astro
Normal file
131
apps/landing-page/app/pages/skills/index.astro
Normal 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>
|
||||
79
apps/landing-page/app/pages/skills/mode/[mode].astro
Normal file
79
apps/landing-page/app/pages/skills/mode/[mode].astro
Normal 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>
|
||||
78
apps/landing-page/app/pages/skills/scenario/[scenario].astro
Normal file
78
apps/landing-page/app/pages/skills/scenario/[scenario].astro
Normal 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>
|
||||
121
apps/landing-page/app/pages/systems/[slug].astro
Normal file
121
apps/landing-page/app/pages/systems/[slug].astro
Normal 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>
|
||||
|
|
@ -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>
|
||||
60
apps/landing-page/app/pages/systems/index.astro
Normal file
60
apps/landing-page/app/pages/systems/index.astro
Normal 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>
|
||||
88
apps/landing-page/app/pages/templates/[slug].astro
Normal file
88
apps/landing-page/app/pages/templates/[slug].astro
Normal 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>
|
||||
56
apps/landing-page/app/pages/templates/index.astro
Normal file
56
apps/landing-page/app/pages/templates/index.astro
Normal 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>
|
||||
932
apps/landing-page/app/sub-pages.css
Normal file
932
apps/landing-page/app/sub-pages.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
242
apps/landing-page/scripts/generate-previews.ts
Normal file
242
apps/landing-page/scripts/generate-previews.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue