feat(landing-page): detail pages — interactive preview, share row, dual CTAs (#2679)

* feat(landing-page): detail pages — interactive preview, share row, dual CTAs

Joey requested three additions to every `/skills/<slug>/` and
`/templates/<slug>/` detail page, with opendesigner.io's skills
catalog and youmind.com's seedance prompt page as references.

What
- **Interactive preview**: a `<details>` toggle below the static thumb
  reveals an `<iframe sandbox>` rendering the canonical artifact
  (`/skills/<slug>/example.html` for skill-template origins,
  `/templates/<slug>/template.html` for live-artifact origins). The
  iframe loads lazily — only on first toggle — so the page stays fast.
  An "Open in new tab ↗" pill on top-right of the frame links to the
  same URL standalone.
- **Six-channel share row**: Reddit, X, LinkedIn, Facebook, Email,
  Copy-link. Each anchor is a vendor "intent" URL (no tracker SDKs);
  the copy-link button uses the Clipboard API with a `prompt()`
  fallback for older Safari / embedded webviews. Wired by a small
  handler appended to `header-enhancer.astro`.
- **Two primary CTAs** in the detail-actions row:
  - "Use this skill →" / "Use this template →" routes to
    `/quickstart/?skill=<slug>` (or `?template=<slug>`). The OD
    desktop client has no public protocol handler yet, so a
    `od://skill/<slug>` deep link would 404. Quickstart is the v1
    pivot; once the client registers a scheme, the anchor flips to
    a JS try-`od://`-then-fallback without changing the page surface.
  - "Find on GitHub →" deep-links into the source folder.

Share copy keeps "open-source Claude Design alternative" front and
center across every channel — same brand keyword Google associates
with the homepage and `/alternatives/claude-design/`, so each social
click reinforces the same entity claim. Per-skill name + summary
follow so a reader who lands on a friend's tweet has a concrete
reason to click.

  - X intent: "I'm using <skill> from @opendesignai — the open-source
    Claude Design alternative.\n\n<description>"
  - Reddit submit title: "<skill> — open-source Claude Design alternative"
  - Email subject: same as Reddit; body: "I thought you'd like this —
    <skill>, an open-source Claude Design alternative skill from Open
    Design.\n\n<description>\n\n<url>"
  - LinkedIn / Facebook: URL-only (those vendors auto-fetch OG meta,
    so they read the existing canonical title + image).

Surface area
- Marketing site only. `apps/landing-page/app/pages/skills/[slug].astro`,
  `pages/templates/[slug].astro`, `_components/header-enhancer.astro`,
  `sub-pages.css`.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI surfaces.
- No new top-level dependencies. WeChat QR was dropped from the v1
  scoping in favor of Joey's revised channel set; brings Reddit and
  Facebook in instead.

Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/deck-swiss-international/` shows all six share
  buttons, both CTAs, and the iframe `<details>` toggle. Same on
  `/templates/magazine-poster/`.
- Local dev: Reddit submit URL contains the SEO keyword in the title
  param; X intent URL contains the @opendesignai mention + keyword
  in the tweet body; Email mailto: subject + body wired correctly.

Followups
- Once OD desktop client registers a `od://` scheme, flip the "Use
  this skill" anchor to JS-driven try + fallback so installed users
  bypass /quickstart/.
- Translate the share copy + CTA labels across the 18 landing
  locales (currently English-only).
- `i18n.ts` `ui.catalog.skills` keys could absorb the share-copy
  template if we want per-locale share text in the future.

* fix(landing-page): preview clicks the thumb; CTA goes to releases

Two follow-ups to #2679 against Joey's review.

1. Preview UX: the thumb is the trigger
   The previous shape rendered a static thumb followed by a separate
   "View interactive preview ▸" disclosure row underneath. Joey wanted
   one composed unit: click the thumb itself to open the live frame.
   Wraps the existing `<details>` so that `<summary>` IS the thumb
   image (with a hover overlay revealing "Click for live preview ↗"),
   and once open the summary hides so the iframe lands in the same
   visual slot. The figcaption moves below the open/closed unit so it
   labels both states identically.

2. "Use this skill" / "Use this template" → /releases
   Sends users straight to the desktop-app release page rather than
   pivoting through /quickstart/. The flow is now concrete (download
   the binary now) instead of asking users to read an install doc as
   step 0. Once the desktop client registers a `od://skill/<slug>`
   protocol handler, this anchor flips to a JS try-deep-link-then-
   fallback without changing the page surface.

Note on the other two issues Joey raised:
- example.html 404: production has all 4 example files at HTTP 200
  (verified with curl). The 404 in his screenshot was production
  serving the previous deploy that pre-dates this PR; the fix is in
  flight, not a missing route. Once #2679 deploys, the iframe will
  resolve cleanly.
- Empty share copy: same root cause. Production HTML still rendered
  the pre-#2679 share row (no copy at all). Local dev confirms the
  X intent URL contains the full "I'm using <skill> from
  @opendesignai — the open-source Claude Design alternative…"
  string in the `text` param; Reddit submit URL contains the
  "<skill> — open-source Claude Design alternative" title; Email
  mailto: subject and body are wired. LinkedIn and Facebook are
  URL-only by their vendor design — those platforms read the OG
  meta tags from the destination page itself.

Surface area
- Marketing site only. `pages/skills/[slug].astro`,
  `pages/templates/[slug].astro`, `sub-pages.css`.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI.
- No new dependencies.

Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: skill detail thumb shows the live-preview overlay on
  hover; click opens the iframe in the same frame. Use this skill
  → opens https://github.com/nexu-io/open-design/releases. Same on
  the templates detail page.

* fix(landing-page): example.html copy step + share dialog with copy-then-paste flow

Two follow-ups against Joey's review of #2679.

1. example.html 404 — production was SPA-falling back to the homepage

The "404" Joey screenshotted on
`/skills/deck-guizang-editorial/example.html` was a Cloudflare Pages
SPA fallback: the URL returned HTTP 200 but the body was the
homepage HTML, so the iframe loaded "the homepage inside the iframe"
which the browser displays as broken-page. Root cause: the build
artifact never contained `out/skills/<slug>/example.html`. Astro
generates `<slug>/index.html` for the detail page from `[slug].astro`,
but the canonical `example.html` next to the SKILL.md file in the
repo root never gets copied into `out/`.

Adds `scripts/copy-example-html.ts` and chains it into the
`build` script. After `astro build`, the script walks:

  - `skills/<slug>/example.html` → `out/skills/<slug>/example.html`
  - `design-templates/<slug>/example.html` → `out/skills/<slug>/example.html`
    (design-templates surface as skill-template-origin records in the
    catalog and the iframe targets the `/skills/<slug>/example.html`
    path for those.)
  - `templates/live-artifacts/<slug>/template.html` → `out/templates/<slug>/template.html`
    (live-artifact-origin records — the iframe targets template.html.)

Source files that don't exist are silently skipped. The script
prints a summary line so the build log makes the count visible.

2. Share UX — modal with copy-then-paste flow

The previous inline 6-button row had two problems Joey called out:
  - Position was below the meta block, not prominent enough.
  - LinkedIn and Facebook ignore `text` pre-fill params, so users
    landing on those platforms saw an empty composer with no idea
    what to write. X / Reddit pre-fill works but truncates Chinese
    unpredictably.

Replaces the row with a `<dialog>` modal:
  - A `Share ↗` button sits inside `.detail-actions` next to the
    primary CTAs, so it has equal visual weight.
  - Clicking opens the dialog with the canonical share copy
    (containing the brand SEO keyword "open-source Claude Design
    alternative") in a readonly `<textarea>`.
  - `Copy text` button writes the textarea contents to the clipboard
    (with a `prompt()` fallback for older browsers) and flashes the
    coral confirmation state.
  - `Copy link only` writes just the URL.
  - Below: a row of platform jump buttons (X · LinkedIn · Reddit ·
    Facebook · Email). Each opens the vendor's compose URL in a new
    tab. The user pastes the already-copied text — uniformly
    reliable across every platform.
  - Modal closes via the × button (form method="dialog") or Escape.

Native `<dialog>` element + `showModal()` API. No new dependencies;
the JS handler lives in the existing `header-enhancer.astro`
inline script alongside the headroom + stars + hamburger handlers.

Surface area
- Marketing site only. `pages/skills/[slug].astro`,
  `pages/templates/[slug].astro`, `_components/header-enhancer.astro`,
  `sub-pages.css`, plus the new `scripts/copy-example-html.ts` and
  one-line `package.json` build script change.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI.
- No new dependencies.

Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/<slug>/` shows the `Share ↗` trigger inside
  detail-actions; clicking opens the modal with the readonly
  textarea pre-filled with the canonical share copy. Copy text /
  Copy link only both flash coral on click and write to clipboard.
  Platform buttons open compose pages in new tabs.
- After deploy: `/skills/<slug>/example.html` will resolve to the
  actual canonical example output rather than SPA-falling back to
  the homepage. Same for templates.

* fix(landing-page): example.html endpoint routes + locale-aware share + brand logos

Three follow-ups against Joey's review of #2679 round 2.

1. example.html 404 — root cause + proper fix
   The 404 Joey kept seeing was real, not a deploy lag: nothing in
   the build pipeline copied `skills/<slug>/example.html` from the
   repo root into the landing-page output. Astro generated only the
   detail-page `index.html`; Cloudflare Pages SPA-fell-back to the
   homepage on requests for `example.html`, which the browser
   rendered as "wrong page in iframe" and Joey read as 404.

   Replaces the post-build copy script (`scripts/copy-example-html.ts`,
   removed) with two Astro endpoint routes:

   - `pages/skills/[slug]/example.html.ts` — streams the canonical
     example for skill-template-origin records, including the
     design-templates passthrough
     (`design-templates/<slug>/example.html` → same URL).
   - `pages/templates/[slug]/template.html.ts` — streams the canonical
     artifact for live-artifact-origin templates.

   Both use `getStaticPaths` so Astro pre-renders into the static
   build artifact under `out/`. Works in dev (Astro dev server runs
   the endpoint live) and prod (file is on disk after `astro build`).

   Required moving `pages/skills/[slug].astro` →
   `pages/skills/[slug]/index.astro` (and same for templates) because
   Astro can't have BOTH a `[slug].astro` file AND a `[slug]/`
   directory with dynamic param children at the same level. The
   `[locale]/skills/[slug].astro` re-exporters were updated to point
   at the new index files.

   `trailingSlash: 'always'` rewrites endpoint URLs to `path/`, so the
   iframe `src` and "Open in new tab" anchor now use
   `example.html/` and `template.html/` (with trailing slash). Tested
   locally: HTTP 200 + real example HTML in the body.

2. Share copy now per-locale; description dropped
   The previous template hardcoded the framing in English ("I'm using
   X from @opendesignai…") with the description following from
   `skill.description`. Joey's catch: when the SKILL.md description is
   in one language and the page locale is another, the share text
   reads as a forced bilingual mash-up.

   Adds an inline `SHARE_COPY` table per landing locale (18 entries,
   one per locale). Drops the description from the share template
   entirely — the framing + URL is enough to prompt a click, and
   removes any chance of a bilingual mismatch when SKILL.md
   frontmatter happens to be in a non-matching language.

   The brand keyword "open-source Claude Design alternative" stays
   English because that's the canonical search query Google
   associates with the domain — translating it would split the
   entity claim. Surrounding sentence translates per locale so the
   message reads as one voice.

   Same template added for templates/[slug]/index.astro.

3. Share dialog UI: brand logos for the 4 platform jump buttons; Email dropped
   Replaces the previous text labels (`X` / `LinkedIn` / `Reddit` /
   `Facebook` / `Email`) with inline-SVG brand logos. Per Joey's
   revision the Email channel was dropped — Gmail / Outlook
   pre-fill is reliable but the audience reach is much smaller than
   the four social platforms, and removing it tightens the row.

   Logos are SimpleIcons-style SVG paths inlined directly (no font
   dependency, no external icon library). Each button keeps an
   `aria-label` plus a visually-hidden `<span class="sr-only">`
   for screen readers.

Surface area
- Marketing site only. `pages/skills/[slug]/index.astro`,
  `pages/skills/[slug]/example.html.ts`,
  `pages/templates/[slug]/index.astro`,
  `pages/templates/[slug]/template.html.ts`,
  `_components/header-enhancer.astro`, `sub-pages.css`,
  `package.json` (build script revert), and the two `[locale]/...`
  re-exporters.
- No `apps/web`, no `apps/daemon`, no contracts, no CLI surfaces.
- No new top-level dependencies.
- The two restructured detail pages keep their existing route URLs
  and existing static-paths logic — only the file location changed.

Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: `/skills/deck-guizang-editorial/example.html/` returns
  HTTP 200 with a 4942-byte body that's the actual canonical
  example output (not the homepage SPA fallback).
- Local dev: `/skills/deck-swiss-international/` share dialog shows
  4 brand-logo platform buttons (no Email); textarea contains the
  English-only framing + URL. `/zh/skills/...` shows the Chinese
  framing + URL with no English bleed-through.

* fix(landing-page): punchier share copy with emojis across 18 locales

The previous share template ("I'm using <name> from @opendesignai —
the open-source Claude Design alternative.\n\n<url>") was too flat to
spark a click — Joey called it out as 平淡 with the keyword
front-and-center but no hook.

New shape: three-line punchy block with emojis as visual anchors.

Skills surface (`/skills/<slug>/`):

  🎨 Just discovered <name> on @opendesignai — the open-source
     Claude Design alternative.
   Local-first · BYOK · your agent does the design.

  → <url>

Templates surface (`/templates/<slug>/`):

  🎨 Just forked <name> from @opendesignai — the open-source
     Claude Design alternative.
   Templates as files, not vendor docs. Fork → swap → ship.

  → <url>

Pattern per locale:
  - Line 1: action verb hook (`Just discovered` / `Just forked` /
    locale equivalent like `安利一个` / `推薦一個` / `Gerade entdeckt` /
    `Découvert` / etc) + skill name + brand keyword.
  - Line 2: tight value-prop with `·` separators — Local-first ·
    BYOK · agent does the design (skills) or Templates as files,
    not vendor docs (templates).
  - Line 3: → URL.

Both lines lead with an emoji (🎨 then ) so the post visually pops
in a feed. The brand keyword "open-source Claude Design alternative"
stays English in every locale (canonical search query for the
domain); surrounding sentence translates per locale.

All 18 landing locales rewritten — ar, de, en, es, fr, id, it, ja,
ko, nl, pl, pt-br, ru, tr, uk, vi, zh, zh-tw. Skills and templates
each have their own `SHARE_COPY` table; the templates variant has
fork-flavored framing because the user action there is fork-and-ship,
not run-once.

Surface area
- Marketing site only. `pages/skills/[slug]/index.astro` and
  `pages/templates/[slug]/index.astro`.
- No other files touched. No new dependencies.

Validation
- `pnpm --filter @open-design/landing-page typecheck` — 0 errors
- Local dev: en / zh / ja all render with emojis intact and
  language-specific framing; X intent URL preserves the multiline
  breaks via `\n` in the `text` query param.

* fix(landing-page): restore post-build copy step for preview iframes

The detail-page interactive preview iframe pointed at endpoint routes
(`pages/skills/[slug]/example.html.ts`,
`pages/templates/[slug]/template.html.ts`) introduced in 0d9c9a5, but
Astro 6 silently drops `pages/<dir>/[slug]/<file>.<ext>.ts` routes
under dynamic segments at build time — even with `export const
prerender = true` — so the URLs returned 404 in both `pnpm dev` and
the production build.

Verified locally: dev server `curl /skills/<slug>/example.html` → 404,
`find apps/landing-page/out -name 'example.html'` → 0 files after a
clean `pnpm build`.

Restore the post-build copy step that 138cbd2 had: an `astro build`
postscript that mirrors `skills/<slug>/example.html` and
`design-templates/<slug>/example.html` into the static output. While
re-introducing the script, also address the live-artifact preview
mismatch flagged by review:

  - Live-artifact records carry a `live-` slug prefix from
    `shapeLiveArtifactTemplate()` in `_lib/catalog.ts`, so the iframe
    URL is `/templates/live-<slug>/preview.html` — copy the source
    file into `out/templates/live-<slug>/preview.html` to match.
  - Serve `index.html` (the rendered preview) rather than
    `template.html` (which still contains `{{data.*}}` placeholders).
    The iframe is for visitors and reviewers, not the template
    runtime.

Detail-page iframe `src` and "Open in new tab" link in
`pages/templates/[slug]/index.astro` already use `/preview.html`;
sub-pages.css comment kept aligned.

---------

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
This commit is contained in:
Jane 2026-05-22 20:47:59 +08:00 committed by GitHub
parent 586e2a0c3b
commit 829fc01c1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1238 additions and 313 deletions

View file

@ -108,5 +108,67 @@
})
.catch(() => {});
}
// Helper used by every clipboard-write button (link copy, share
// text copy). Tries the Clipboard API; falls back to a `prompt()`
// window the user can manually copy from on browsers that block
// clipboard writes (older Safari, embedded webviews).
const writeClipboard = async (text, btn) => {
const flash = () => {
if (!btn) return;
btn.setAttribute('data-copied', 'true');
setTimeout(() => btn.removeAttribute('data-copied'), 1400);
};
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
flash();
return;
}
} catch {
// fall through
}
window.prompt('Copy', text);
};
// Copy-link buttons on detail pages and inside the share dialog.
document.querySelectorAll('[data-copy-link]').forEach((btn) => {
btn.addEventListener('click', () => {
writeClipboard(btn.getAttribute('data-copy-link') || '', btn);
});
});
// Share dialog: trigger button opens the matching `<dialog
// data-share-dialog="...">` and the `Copy text` button inside
// copies the textarea contents. Modal closes via the form's
// method="dialog" submit (the × button) or Escape key.
document.querySelectorAll('[data-share-open]').forEach((trigger) => {
trigger.addEventListener('click', () => {
const key = trigger.getAttribute('data-share-open');
const dialog = document.querySelector(`[data-share-dialog="${key}"]`);
if (dialog && typeof dialog.showModal === 'function') {
dialog.showModal();
} else if (dialog) {
// Older browsers without <dialog> support — just toggle
// a class. CSS already shows `.detail-share-dialog` open
// by default; we set `open` attr to mimic.
dialog.setAttribute('open', '');
}
});
});
document.querySelectorAll('[data-share-copy]').forEach((btn) => {
btn.addEventListener('click', () => {
const dialog = btn.closest('dialog, [data-share-dialog]');
const ta = dialog ? dialog.querySelector('[data-share-text]') : null;
if (!ta) return;
const text = ta.value || ta.textContent || '';
writeClipboard(text, btn);
// Auto-select the textarea contents so the user can manually
// copy too if they prefer that affordance.
if (ta.select) {
try { ta.select(); } catch {}
}
});
});
})();
</script>

View file

@ -1,7 +1,7 @@
---
import SkillPage, {
getStaticPaths as getSkillStaticPaths,
} from '../../skills/[slug].astro';
} from '../../skills/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {

View file

@ -1,7 +1,7 @@
---
import TemplatePage, {
getStaticPaths as getTemplateStaticPaths,
} from '../../templates/[slug].astro';
} from '../../templates/[slug]/index.astro';
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
export async function getStaticPaths() {

View file

@ -1,179 +0,0 @@
---
/*
* /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 LazyImg from '../../_components/lazy-img.astro';
import { getSkillRecords, type SkillRecord } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
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: routeSkill, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
const title = ui.catalog.skills.detailTitle(skill.name);
const description = skill.description.length > 0
? skill.description
: ui.catalog.skills.detailFallbackDescription(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: ui.catalog.skills.detailLabel, 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={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.skills.detailLabel}
{typeof skill.featured === 'number' && (
<span class="ix">{ui.catalog.skills.featuredNumber(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">
{ui.catalog.skills.viewOnGithub}
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
{ui.catalog.skills.upstream}
</a>
)}
</div>
</header>
{skill.previewUrl && (
<figure class="detail-preview">
<LazyImg
src={skill.previewUrl}
alt={`${skill.name} example output`}
loading="priority"
/>
<figcaption>
{ui.catalog.skills.previewCaption(skill.slug)}
</figcaption>
</figure>
)}
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{skill.modeLabel ?? skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{skill.platformLabel ?? skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>{ui.catalog.systems.category}</dt>
<dd>{skill.categoryLabel ?? skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.triggers}</h2>
<p class="block-lead">
{ui.catalog.skills.triggersLead}
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>{ui.catalog.skills.examplePrompt}</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.related}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/skills/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -0,0 +1,445 @@
---
/*
* /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 LazyImg from '../../../_components/lazy-img.astro';
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/*
* Localized share-copy template, keyed by landing locale. The brand
* keyword "open-source Claude Design alternative" stays in English
* because that's the canonical search query Google associates with
* the domain — translating it would split the entity claim. The
* surrounding sentence ("I'm using X from @opendesignai") translates
* per locale so the message reads as one coherent voice instead of
* mixing two scripts in a single share post.
*
* `{name}` and `{description}` are interpolated at render time.
* `{url}` is replaced with the canonical detail-page URL.
*/
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
✨ Local-first · BYOK · your agent does the design.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
✨ Local-first · BYOK · dein Agent designt.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
✨ Local-first · BYOK · votre agent fait le design.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Локально · BYOK · агент сам делает дизайн.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Local-first · BYOK · tu agente diseña.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
✨ Local-first · BYOK · seu agente faz o design.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
✨ Local-first · BYOK · il tuo agente progetta.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Local-first · BYOK · twój agent projektuje.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
✨ Local-first · BYOK · agent kamu yang nge-desain.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
✨ Local-first · BYOK · jouw agent ontwerpt.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ محلي أولًا · BYOK · وكيلك يصمّم.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Local-first · BYOK · ajanın tasarlıyor.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
✨ Local-first · BYOK · ваш агент робить дизайн.
→ ${url}`,
};
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: routeSkill, all: routeAll } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
const title = ui.catalog.skills.detailTitle(skill.name);
const description = skill.description.length > 0
? skill.description
: ui.catalog.skills.detailFallbackDescription(skill.name);
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: skill.name,
description,
url: skillUrl,
});
// Share-dialog UI strings localized inline. Keeping them next to the
// page that uses them avoids growing the global UI bundle for what's
// effectively four short labels per locale.
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
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: ui.catalog.skills.detailLabel, 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={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
<span>/</span>
<span aria-current="page">{skill.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.skills.detailLabel}
{typeof skill.featured === 'number' && (
<span class="ix">{ui.catalog.skills.featuredNumber(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">
{/*
Two primary CTAs. "Use this skill" v1 sends users to the OD
desktop release page — install the app first, then run the
skill. Routing here rather than to /quickstart/ keeps the
flow concrete (download a binary now) instead of asking
users to read an install doc. Once the desktop client
exposes a registered URL scheme, this anchor flips to a
JS-driven `od://skill/<slug>` try + fallback without
changing the page surface.
*/}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this skill →
</a>
<a
class="btn btn-ghost"
href={skill.source}
target="_blank"
rel="noopener"
>
Find on GitHub →
</a>
{skill.upstream && (
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
{ui.catalog.skills.upstream}
</a>
)}
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`skill:${skill.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{skill.previewUrl && (
<figure class="detail-preview">
{/*
Click-to-expand interactive preview. The thumb itself is the
summary of a `<details>` element — clicking the image opens
the live iframe, replacing the thumb with the canonical
`<slug>/example.html/` rendered inside a sandboxed frame. A
hover overlay on the thumb hints "Click for live preview ↗"
so the affordance is discoverable. The iframe loads lazily,
only fetching once the user actually expands.
*/}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
<LazyImg
src={skill.previewUrl}
alt={`${skill.name} example output`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={`/skills/${skill.slug}/example.html/`}
title={`${skill.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={`/skills/${skill.slug}/example.html/`}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>
{ui.catalog.skills.previewCaption(skill.slug)}
</figcaption>
</figure>
)}
{/*
Share modal — opens a `<dialog>` containing the canonical share
copy (with the brand keyword "open-source Claude Design
alternative" baked in), a one-click "Copy" button, and a row of
platform jump buttons. Each platform button just opens the
vendor's compose URL — the user pastes the already-copied text.
This works around a real cross-platform pain point: LinkedIn /
Facebook ignore pre-fill `text` params, X has length limits that
truncate Chinese content unpredictably, and Reddit's title param
survives but title-only is a weak signal. Copy-then-paste is
uniformly reliable.
The trigger sits inside `.detail-actions` instead of as a
separate row below `.detail-meta` so it has visual weight equal
to the primary CTAs. Joey called this out specifically.
*/}
<dialog
class="detail-share-dialog"
data-share-dialog={`skill:${skill.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={skillUrl}
>
{shareUi.copyLink}
</button>
</div>
{/*
Platform jump buttons — official brand logos rendered as
inline SVG (no third-party icon font, no client JS). Each
opens the vendor's compose surface in a new tab; the user
pastes the already-copied text. Email channel was dropped
per Joey's revision; the four channels here cover the
highest-value SEO + virality surfaces.
*/}
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<dl class="detail-meta">
{skill.mode && (
<Fragment>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{skill.modeLabel ?? skill.mode}</dd>
</Fragment>
)}
{skill.scenario && (
<Fragment>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
</Fragment>
)}
{skill.platform && (
<Fragment>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{skill.platformLabel ?? skill.platform}</dd>
</Fragment>
)}
{skill.category && (
<Fragment>
<dt>{ui.catalog.systems.category}</dt>
<dd>{skill.categoryLabel ?? skill.category}</dd>
</Fragment>
)}
</dl>
{skill.triggers.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.triggers}</h2>
<p class="block-lead">
{ui.catalog.skills.triggersLead}
</p>
<ul class="trigger-list">
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
</ul>
</section>
)}
{skill.examplePrompt && (
<section class="detail-block">
<h2>{ui.catalog.skills.examplePrompt}</h2>
<pre class="example-prompt">{skill.examplePrompt}</pre>
</section>
)}
{related.length > 0 && (
<section class="detail-block">
<h2>{ui.catalog.skills.related}</h2>
<ul class="related-grid">
{related.map((r) => (
<li>
<a href={href(`/skills/${r.slug}/`)}>
<span class="related-name">{r.name}</span>
<span class="related-desc">{r.description}</span>
<span class="related-meta">
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
</span>
</a>
</li>
))}
</ul>
</section>
)}
</article>
</Layout>

View file

@ -1,131 +0,0 @@
---
/*
* /templates/<slug>/ — detail page for renderable design templates and
* legacy Live Artifact template bundles.
*/
import Layout from '../../_components/sub-page-layout.astro';
import LazyImg from '../../_components/lazy-img.astro';
import { getTemplateRecords, type TemplateRecord } from '../../_lib/catalog';
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template: routeTemplate } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
const template =
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
const title = ui.catalog.templates.detailTitle(template.name);
const description = template.summary;
const originLabel =
template.origin === 'live-artifact'
? ui.catalog.templates.liveArtifact
: ui.catalog.templates.skillTemplate;
const files =
template.origin === 'live-artifact'
? [
['template.html', ui.catalog.templates.renderer],
['data.json', ui.catalog.templates.seedData],
['README.md', ui.catalog.templates.readme],
]
: [
['SKILL.md', ui.catalog.skills.detailLabel],
['example.html', ui.catalog.templates.previewCaption],
['assets/', ui.catalog.templates.detailLabel],
['references/', ui.catalog.craft.detailLabel],
];
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: ui.catalog.templates.detailLabel, 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={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.templates.detailLabel}
<span class="ix">· {originLabel}</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
{(template.mode || template.platform || template.scenario) && (
<dl class="detail-meta">
{template.mode && (
<>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{template.modeLabel ?? template.mode}</dd>
</>
)}
{template.platform && (
<>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{template.platformLabel ?? template.platform}</dd>
</>
)}
{template.scenario && (
<>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{template.scenarioLabel ?? template.scenario}</dd>
</>
)}
</dl>
)}
<div class="detail-actions">
<a class="btn btn-primary" href={template.source} target="_blank" rel="noopener">
{ui.catalog.templates.forkOnGithub}
</a>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
<LazyImg
src={template.previewUrl}
alt={`${template.name} preview`}
loading="priority"
/>
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
</figure>
)}
<section class="detail-block">
<h2>{ui.catalog.templates.whatsInside}</h2>
<p class="block-lead">
{ui.catalog.templates.whatsInsideLead}
</p>
<ul class="trigger-list">
{files.map(([name, copy]) => (
<li><code>{name}</code> — {copy}</li>
))}
</ul>
</section>
</article>
</Layout>

View file

@ -0,0 +1,356 @@
---
/*
* /templates/<slug>/ — detail page for renderable design templates and
* legacy Live Artifact template bundles.
*/
import Layout from '../../../_components/sub-page-layout.astro';
import LazyImg from '../../../_components/lazy-img.astro';
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
import {
getLandingUiCopy,
localeFromPath,
localizedHref,
type LandingLocaleCode,
} from '../../../i18n';
/* See pages/skills/[slug]/index.astro for the rationale on why these
* tables live inline rather than in the global UI bundle. Same shape,
* just keyed for the templates surface. */
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
✨ Templates as files, not vendor docs. Fork → swap → ship.
→ ${url}`,
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
→ ${url}`,
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
→ ${url}`,
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
→ ${url}`,
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
→ ${url}`,
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
→ ${url}`,
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
→ ${url}`,
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
→ ${url}`,
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
→ ${url}`,
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
→ ${url}`,
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
✨ Template come file, non come DB vendor. Fork → swap → ship.
→ ${url}`,
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
→ ${url}`,
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
→ ${url}`,
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
→ ${url}`,
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
→ ${url}`,
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
→ ${url}`,
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
→ ${url}`,
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
→ ${url}`,
};
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
};
export async function getStaticPaths() {
const records = await getTemplateRecords();
return records.map((template) => ({
params: { slug: template.slug },
props: { template },
}));
}
interface Props {
template: TemplateRecord;
}
const { template: routeTemplate } = Astro.props as Props;
const locale = localeFromPath(Astro.url.pathname);
const ui = getLandingUiCopy(locale);
const href = (path: string) => localizedHref(path, locale);
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
const template =
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
const title = ui.catalog.templates.detailTitle(template.name);
const description = template.summary;
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
name: template.name,
description: template.summary,
url: templateUrl,
});
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
const originLabel =
template.origin === 'live-artifact'
? ui.catalog.templates.liveArtifact
: ui.catalog.templates.skillTemplate;
const files =
template.origin === 'live-artifact'
? [
['template.html', ui.catalog.templates.renderer],
['data.json', ui.catalog.templates.seedData],
['README.md', ui.catalog.templates.readme],
]
: [
['SKILL.md', ui.catalog.skills.detailLabel],
['example.html', ui.catalog.templates.previewCaption],
['assets/', ui.catalog.templates.detailLabel],
['references/', ui.catalog.craft.detailLabel],
];
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: ui.catalog.templates.detailLabel, 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={ui.catalog.breadcrumbLabel}>
<a href={href('/')}>Open Design</a>
<span>/</span>
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
<span>/</span>
<span aria-current="page">{template.name}</span>
</nav>
<article class="detail">
<header class="detail-head">
<span class="label">
{ui.catalog.templates.detailLabel}
<span class="ix">· {originLabel}</span>
</span>
<h1 class="display">{template.name}<span class="dot">.</span></h1>
<p class="lead">{template.summary}</p>
{(template.mode || template.platform || template.scenario) && (
<dl class="detail-meta">
{template.mode && (
<>
<dt>{ui.catalog.skills.mode}</dt>
<dd>{template.modeLabel ?? template.mode}</dd>
</>
)}
{template.platform && (
<>
<dt>{ui.catalog.skills.platform}</dt>
<dd>{template.platformLabel ?? template.platform}</dd>
</>
)}
{template.scenario && (
<>
<dt>{ui.catalog.skills.scenario}</dt>
<dd>{template.scenarioLabel ?? template.scenario}</dd>
</>
)}
</dl>
)}
<div class="detail-actions">
{/* Two CTAs matching skills/[slug]: "Use this template" sends
users to the OD desktop release page (install first, then
use the template); "Find on GitHub" deep-links to the
source folder. See skills/[slug].astro for the broader
rationale on the release-page pivot. */}
<a
class="btn btn-primary"
href="https://github.com/nexu-io/open-design/releases"
target="_blank"
rel="noopener"
>
Use this template →
</a>
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
Find on GitHub →
</a>
<button
type="button"
class="btn btn-ghost detail-share-trigger"
data-share-open={`template:${template.slug}`}
>
{shareUi.openLabel}
</button>
</div>
</header>
{template.previewUrl && (
<figure class="detail-preview">
{/* Click-to-expand: thumb is the summary; clicking opens the
live iframe rendering the canonical artifact. Skill-template
origin → /skills/<slug>/example.html; live-artifact origin
→ /templates/<slug>/preview.html. */}
<details class="detail-preview-live">
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
<LazyImg
src={template.previewUrl}
alt={`${template.name} preview`}
loading="priority"
/>
<span class="detail-preview-thumb-overlay" aria-hidden="true">
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
</span>
</summary>
<div class="detail-preview-frame-wrap">
<iframe
src={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html/`
: `/skills/${template.slug}/example.html/`
}
title={`${template.name} interactive preview`}
loading="lazy"
sandbox="allow-scripts allow-same-origin"
class="detail-preview-frame"
/>
<a
class="detail-preview-popout"
href={
template.origin === 'live-artifact'
? `/templates/${template.slug}/preview.html/`
: `/skills/${template.slug}/example.html/`
}
target="_blank"
rel="noopener"
aria-label="Open preview in new tab"
>
Open in new tab ↗
</a>
</div>
</details>
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
</figure>
)}
{/* Share modal — same shape as skills/[slug]; see that file for the
copy-then-paste rationale and SEO keyword choice. */}
<dialog
class="detail-share-dialog"
data-share-dialog={`template:${template.slug}`}
>
<form method="dialog" class="detail-share-dialog-form">
<header class="detail-share-dialog-head">
<h2>{shareUi.title}</h2>
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
</header>
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
<textarea
class="detail-share-dialog-text"
readonly
rows="6"
data-share-text
>{shareCopy}</textarea>
<div class="detail-share-dialog-actions">
<button
type="button"
class="btn btn-primary detail-share-dialog-copy"
data-share-copy
>
{shareUi.copyText}
</button>
<button
type="button"
class="btn btn-ghost detail-share-dialog-copy-link"
data-copy-link={templateUrl}
>
{shareUi.copyLink}
</button>
</div>
<div class="detail-share-dialog-platforms">
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
<span class="sr-only">X</span>
</a>
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<span class="sr-only">LinkedIn</span>
</a>
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
<span class="sr-only">Reddit</span>
</a>
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<span class="sr-only">Facebook</span>
</a>
</div>
</form>
</dialog>
<section class="detail-block">
<h2>{ui.catalog.templates.whatsInside}</h2>
<p class="block-lead">
{ui.catalog.templates.whatsInsideLead}
</p>
<ul class="trigger-list">
{files.map(([name, copy]) => (
<li><code>{name}</code> — {copy}</li>
))}
</ul>
</section>
</article>
</Layout>

View file

@ -1293,3 +1293,275 @@ body.sub-page {
margin: 0;
max-width: none;
}
/* =========================================================
* Skill / template detail page: live preview + share row
* =========================================================
*
* `details > summary` toggles an iframe that renders the canonical
* `example.html` (or `preview.html` for live-artifacts). The frame
* is loaded lazily click the summary, that's when the network
* request goes out.
*/
/*
* Click-to-expand interactive preview. The `<summary>` IS the
* thumbnail when collapsed, users see a normal preview image; on
* hover the overlay reveals "Click for live preview ↗"; on click the
* details element opens, swapping the summary out for an iframe of
* the canonical example.html. The iframe's `loading="lazy"` plus the
* fact that `<details>` only renders content on open means the
* network request fires only when the user actually wants the live
* view.
*/
.detail-preview-live {
display: block;
}
.detail-preview-live > summary {
cursor: pointer;
list-style: none;
display: block;
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.detail-preview-live > summary::-webkit-details-marker { display: none; }
.detail-preview-thumb-trigger {
position: relative;
overflow: hidden;
}
.detail-preview-thumb-trigger img {
display: block;
width: 100%;
height: auto;
transition: transform 0.4s ease;
}
.detail-preview-thumb-trigger:hover img,
.detail-preview-thumb-trigger:focus-visible img {
transform: scale(1.015);
}
.detail-preview-thumb-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(21, 20, 15, 0);
transition: background 0.18s ease;
pointer-events: none;
}
.detail-preview-thumb-trigger:hover .detail-preview-thumb-overlay,
.detail-preview-thumb-trigger:focus-visible .detail-preview-thumb-overlay {
background: rgba(21, 20, 15, 0.42);
}
.detail-preview-thumb-cta {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--paper);
background: var(--coral);
padding: 10px 18px;
border-radius: 999px;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.detail-preview-thumb-trigger:hover .detail-preview-thumb-cta,
.detail-preview-thumb-trigger:focus-visible .detail-preview-thumb-cta {
opacity: 1;
transform: translateY(0);
}
/* Once expanded, hide the summary so the iframe takes its place. */
.detail-preview-live[open] > summary { display: none; }
.detail-preview-frame-wrap {
position: relative;
border: 1px solid var(--line);
background: var(--paper-warm);
aspect-ratio: 16 / 10;
overflow: hidden;
}
.detail-preview-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: white;
}
.detail-preview-popout {
position: absolute;
top: 12px;
right: 12px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink);
background: var(--paper);
border: 1px solid currentColor;
padding: 6px 12px;
border-radius: 999px;
text-decoration: none;
transition: background 0.12s ease, color 0.12s ease;
}
.detail-preview-popout:hover {
background: var(--ink);
color: var(--paper);
}
/*
* Share dialog replaces the previous inline 6-button row. The
* trigger is a `Share ` button inside `.detail-actions` (so it has
* visual weight equal to the primary CTAs); clicking opens a
* `<dialog>` with the canonical share copy ready to copy + a row of
* platform jump buttons that just open the vendor's compose page.
*
* The copy-then-paste flow works around platform pre-fill limits:
* LinkedIn / Facebook ignore `text` params, X truncates Chinese
* unpredictably, Reddit's title is title-only. Copy-then-paste is
* uniformly reliable.
*/
.detail-share-dialog {
border: 1px solid var(--ink);
background: var(--paper);
color: var(--ink);
width: min(540px, calc(100vw - 32px));
padding: 0;
border-radius: 4px;
box-shadow: 0 32px 80px rgba(21, 20, 15, 0.32);
}
.detail-share-dialog::backdrop {
background: rgba(21, 20, 15, 0.42);
}
.detail-share-dialog-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 24px 28px 28px;
margin: 0;
}
.detail-share-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 0;
}
.detail-share-dialog-head h2 {
font-family: var(--serif);
font-weight: 600;
font-size: 22px;
margin: 0;
}
.detail-share-dialog-close {
width: 30px;
height: 30px;
border: 1px solid var(--line);
background: transparent;
border-radius: 50%;
font-size: 18px;
line-height: 1;
cursor: pointer;
color: var(--ink-mute);
transition: color 0.12s ease, border-color 0.12s ease;
}
.detail-share-dialog-close:hover { color: var(--coral); border-color: var(--coral); }
.detail-share-dialog-lead {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--ink-mute);
}
.detail-share-dialog-text {
width: 100%;
border: 1px solid var(--line);
background: var(--paper-warm);
padding: 12px 14px;
font-family: var(--sans);
font-size: 13px;
line-height: 1.55;
color: var(--ink);
border-radius: 2px;
resize: vertical;
}
.detail-share-dialog-text:focus {
outline: none;
border-color: var(--coral);
}
.detail-share-dialog-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-share-dialog-actions .btn {
flex: 1 1 auto;
text-align: center;
}
.detail-share-dialog-copy[data-copied],
.detail-share-dialog-copy-link[data-copied] {
background: var(--coral) !important;
color: var(--paper) !important;
border-color: var(--coral) !important;
}
.detail-share-dialog-platforms {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.detail-share-dialog-platforms-label {
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-mute);
margin-right: 4px;
}
.detail-share-platform-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--ink);
border-radius: 50%;
color: var(--ink);
text-decoration: none;
background: transparent;
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
}
.detail-share-platform-btn:hover {
background: var(--ink);
color: var(--paper);
transform: translateY(-1px);
}
.detail-share-platform-btn svg {
display: block;
}
/* Visually-hidden helper for screen-reader-only platform names. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* The trigger button itself in `.detail-actions` */
.detail-share-trigger {
/* uses existing `.btn .btn-ghost` styling; this just lets future
* code target it for analytics or a11y customizations */
}
@media (max-width: 720px) {
.detail-preview-popout { top: 8px; right: 8px; padding: 4px 10px; font-size: 9.5px; }
.detail-share-dialog-form { padding: 20px 22px 24px; }
.detail-share-dialog-actions { flex-direction: column; }
}

View file

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "astro dev --host 127.0.0.1 --port 17574",
"build": "astro check && astro build",
"build": "astro check && astro build && tsx scripts/copy-example-html.ts",
"preview": "astro preview --host 127.0.0.1 --port 17574",
"previews": "tsx scripts/generate-previews.ts",
"typecheck": "astro check"

View file

@ -0,0 +1,100 @@
/**
* Post-build copier `skills/<slug>/example.html` and
* `design-templates/<slug>/example.html` get copied next to the
* static detail-page output (`out/skills/<slug>/example.html`,
* `out/templates/live-<slug>/preview.html` for live-artifacts) so the
* detail-page iframe and "Open in new tab" links resolve.
*
* Why post-build copy and not Astro endpoint routes:
* Astro 6 does not register `pages/<dir>/[slug]/<file>.<ext>.ts`
* files as static endpoints under dynamic segments the route is
* silently dropped at build time and the iframe URL 404s on deploy
* even with `export const prerender = true`. A flat copy step at the
* end of `astro build` sidesteps the routing mismatch entirely.
*
* Without this step the build artifact only contains the per-detail
* `index.html` Astro generates from `[slug]/index.astro`. Cloudflare
* Pages then SPA-fallbacks `/skills/<slug>/example.html` to the
* homepage, which the browser displays as "404 / wrong page" inside
* the iframe.
*
* Live-artifact templates carry a `live-` slug prefix
* (`shapeLiveArtifactTemplate()` in `_lib/catalog.ts`); their detail
* page sits at `/templates/live-<slug>/`, so the preview must land at
* `out/templates/live-<slug>/preview.html`. The source file is
* `index.html` (the rendered preview), not `template.html` (which
* still contains `{{data.*}}` placeholders).
*
* Runs after `astro build`. Read source from the repo-root content
* directories (`skills/`, `design-templates/`, `templates/`) same
* convention `generate-previews.ts` already uses.
*/
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(SCRIPT_DIR, '..');
const REPO_ROOT = path.resolve(APP_ROOT, '..', '..');
const OUT_DIR = path.join(APP_ROOT, 'out');
const SKILLS_SRC = path.join(REPO_ROOT, 'skills');
const DESIGN_TEMPLATES_SRC = path.join(REPO_ROOT, 'design-templates');
const LIVE_ARTIFACTS_SRC = path.join(REPO_ROOT, 'templates', 'live-artifacts');
let copied = 0;
let skipped = 0;
function copyIfExists(srcFile: string, destFile: string): boolean {
if (!existsSync(srcFile)) return false;
mkdirSync(path.dirname(destFile), { recursive: true });
copyFileSync(srcFile, destFile);
return true;
}
function listDirs(root: string): string[] {
if (!existsSync(root)) return [];
return readdirSync(root).filter((name) => {
const full = path.join(root, name);
return statSync(full).isDirectory() && !name.startsWith('_') && !name.startsWith('.');
});
}
// 1. Skills — `skills/<slug>/example.html` → `out/skills/<slug>/example.html`.
for (const slug of listDirs(SKILLS_SRC)) {
const ok = copyIfExists(
path.join(SKILLS_SRC, slug, 'example.html'),
path.join(OUT_DIR, 'skills', slug, 'example.html'),
);
if (ok) copied++;
else skipped++;
}
// 2. Design templates — `design-templates/<slug>/example.html` →
// `out/skills/<slug>/example.html`. The landing-page detail layer
// treats design templates as a flavor of skill template (see
// `_lib/catalog.ts` and `pages/templates/[slug]/index.astro` which
// routes skill-template-origin records to `/skills/<slug>/example.html`).
for (const slug of listDirs(DESIGN_TEMPLATES_SRC)) {
const ok = copyIfExists(
path.join(DESIGN_TEMPLATES_SRC, slug, 'example.html'),
path.join(OUT_DIR, 'skills', slug, 'example.html'),
);
if (ok) copied++;
}
// 3. Live-artifact templates — `templates/live-artifacts/<slug>/index.html`
// → `out/templates/live-<slug>/preview.html`. The detail-page slug
// is `live-${rawSlug}` (catalog.ts `shapeLiveArtifactTemplate()`)
// and the iframe targets `/templates/live-<slug>/preview.html`. We
// serve `index.html` (the rendered preview) rather than
// `template.html` (raw template with `{{data.*}}` placeholders).
for (const slug of listDirs(LIVE_ARTIFACTS_SRC)) {
const ok = copyIfExists(
path.join(LIVE_ARTIFACTS_SRC, slug, 'index.html'),
path.join(OUT_DIR, 'templates', `live-${slug}`, 'preview.html'),
);
if (ok) copied++;
}
console.log(`[copy-example-html] copied ${copied} files, skipped ${skipped} (no preview source in repo)`);