mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(landing-page): synthesize fallback preview cards for instruction skills
The skill catalog renders a diagonal-stripe placeholder for any skill
without a runnable example.html, which leaves ~70% of /skills/ as a
field of bare grey thumbs (instruction skills like copywriting,
creative-director, color-expert, brainstorming have no static demo
because their output depends on the agent's input).
Synthesize a typographic editorial card from each SKILL.md frontmatter
and screenshot it through the same Playwright pipeline that handles
real demos, so every catalog row carries a thumbnail. Cards include:
- OPEN DESIGN · SKILL top label + Nº NNN index (1..96 over the
instruction subset, sorted by od.featured then alphabetical)
- Big Playfair Display slug with a coral dot accent
- Italic serif description clamped to 3 lines
- mode/category chips + "Curated from <author>" attribution
- Warm-paper background with a subtle 135° stripe to thread the
landing's existing visual language
Bundle a few related improvements caught while building this:
- SkillRecord gains a `kind: 'instruction' | 'template'` field so
the detail page can render differently per kind (instruction
skills now render the SKILL.md body inline as "About this skill",
template skills keep the click-to-expand iframe demo).
- Catalog row thumbnails switch from the bespoke IntersectionObserver
pipeline to native `loading="lazy"` (with eager + fetchpriority=high
on the first 3). The observer's swap latency stranded mid-list
rows on the SVG placeholder during fast scrolls; native lazy uses
the browser's 1250-3000px lookahead so the placeholder flash is
gone.
- precise-lazyload rootMargin bumped to 1500px for any remaining
data-precise-src callers.
- CI cache key for generated previews now folds in
fallback-preview-card.ts so a template tweak invalidates the cache.
* feat(landing-page): rebuild plugins library to mirror in-app taxonomy
The marketing site's `/skills/`, `/templates/`, `/systems/`, `/craft/`
top-level entries were organized around author-supplied `od.mode` /
`od.scenario` taxonomies that visitors never see inside Open Design
itself. The in-app Plugins home (`apps/web/src/components/plugins-home/`)
groups every bundled plugin by the artifact it produces — Prototype,
Live Artifact, Slides, Image, Video, HyperFrames, Audio — and that's
the language users encounter the moment they open the product.
This PR rebuilds the public library around the same taxonomy and the
same data source so a visitor reading "Templates · 231" on the
marketing site sees the same 231 inside the app.
## What changes
- New top-level `/plugins/` hub: four tiles (Templates, Skills,
Systems, Craft) with live counts pulled straight from
`plugins/_official/<bucket>/<slug>/open-design.json` — the daemon's
bundled-plugin registry.
- `/plugins/templates/` lists every bundled plugin that lands in one
of the seven artifact kinds. Seven sub-routes
(`/plugins/templates/prototype/`, `/deck/`, `/image/`, `/video/`,
`/hyperframes/`, `/audio/`, `/live-artifact/`) carry the same chip
rail with an active state, so visitors can switch artifact kinds
with one click without losing the rail.
- Each artifact-kind sub-route shows a Scene chip rail when the kind
has scene buckets (Prototype / Slides / Image / Video each get
five-six). The Scene filter runs client-side via inline `style.display`
toggles; URLs stay one-per-kind so we don't multiply 25 × 18 locales
worth of static pages just for filter combinations.
- `/plugins/skills/` collects the instruction-only entries (mode
doesn't fit any of the seven kinds) — copywriting, color theory,
creative direction, brainstorming, etc.
- `/plugins/systems/` lists the 150 bundled design systems via the
legacy SystemCard renderer (palette swatches, tagline) so the
visual treatment matches the in-product library.
- `/plugins/craft/` keeps the existing craft principles list.
- `/plugins/<manifest-id>/` detail pages built from manifest metadata:
hero (poster image or playable Cloudflare Stream MP4 for video
templates), author / mode / scenario / tags, GitHub source link.
Author URLs pointing at the `nexu-io` org redirect to the
`nexu-io/open-design` repo so the attribution is actionable.
- Header dropdown labelled "Plugins" with the four sub-routes; footer
Library column updated to match.
- Old marketplace registry pages under `/plugins/` and
`/[locale]/plugins/` removed (they were a dormant placeholder UI;
the actual manifests it tried to load lived nowhere). The rest of
the legacy plugin-registry loader stays intact for any other
consumer.
## Preview generation
Bundled plugins ship `od.preview.poster` URLs on R2 for image and
video templates; those are used directly. The other 293 entries
(html-mode examples, design-systems, scenarios) had no poster, so
`generate-previews.ts` was extended to:
1. Screenshot a local `example.html` referenced by `od.preview.entry`
when present (134 examples).
2. Synthesize the same typographic editorial card the SKILL.md
fallback uses, sourced from manifest title / description / mode /
author (159 systems / scenarios / misc).
Output lands at `public/previews/plugins/<manifest-id>.png`. The
catalog loader checks for the local file when the manifest carries no
poster URL, so the row's `<img src>` always has something to point at.
Result: every catalog row and every detail page has a thumbnail;
visiting `/plugins/templates/video/` shows the same 48 entries the
in-app Plugins home shows, hyperframes the same 13, etc.
## Counts
- Templates: 231 (Prototype 59 + Slides 59 + Image 46 + Video 48 +
HyperFrames 13 + Audio 1 + Live Artifact 5)
- Skills: 15
- Systems: 150
- Craft: 11
Atoms (13 infrastructure plugins, `od.kind === 'atom'`) are filtered
to mirror the in-app behaviour.
* fix(landing-page): use Astro 6 render() helper for SKILL.md body
Astro 6 dropped `entry.render()` in favour of a top-level `render(entry)`
helper imported from `astro:content`. The instruction-kind skill detail
page was still using the legacy method, which compiled locally on Astro
6 only because tsx ignored the missing prototype method, but `astro
check` (run in CI) flagged it as ts(2551) and broke the workflow.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
472 lines
25 KiB
Text
472 lines
25 KiB
Text
---
|
||
/*
|
||
* /skills/<slug>/ — a detail page per skill.
|
||
*
|
||
* Two flavours render slightly differently:
|
||
* - `template` skills get a click-to-expand iframe of their
|
||
* `example.html` demo and stay deliberately brief — the demo is the
|
||
* content, the README is one click away on GitHub.
|
||
* - `instruction` skills (no runnable demo) instead render the full
|
||
* SKILL.md body inline, so the page reads like a brief: what the
|
||
* skill does, when it triggers, how to use it. Otherwise the page
|
||
* would be a one-line description and a row of CTAs.
|
||
*/
|
||
import { getEntry, render } from 'astro:content';
|
||
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);
|
||
|
||
/*
|
||
* Instruction skills don't have a runnable demo to iframe — to avoid
|
||
* a near-empty detail page, render the SKILL.md prose inline so the
|
||
* page reads like a brief. Template skills keep the page deliberately
|
||
* brief because their demo is the content; their full SKILL.md is one
|
||
* "Find on GitHub" click away.
|
||
*
|
||
* Astro 6 exposes the markdown pipeline through a top-level
|
||
* `render(entry)` helper rather than the legacy `entry.render()`
|
||
* method. The output (heading anchors, smart-typography, GFM
|
||
* tables) styles cleanly with the existing `.detail-md` rules.
|
||
*/
|
||
const skillEntry =
|
||
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
|
||
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
|
||
|
||
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.kind === 'template' && skill.previewUrl && (
|
||
<figure class="detail-preview">
|
||
{/*
|
||
Click-to-expand interactive preview. Only template-kind skills
|
||
ship a runnable example.html, so this block is gated on kind
|
||
rather than just `previewUrl` — instruction skills now have a
|
||
synthesized cover thumbnail too, but no iframe target. The
|
||
thumb is the summary of a `<details>` element: clicking opens
|
||
the live iframe, replacing the thumb with the canonical
|
||
`<slug>/example.html` rendered inside a sandboxed frame.
|
||
*/}
|
||
<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>
|
||
)}
|
||
|
||
{SkillBody && (
|
||
<section class="detail-block detail-md">
|
||
<h2>About this skill</h2>
|
||
<SkillBody />
|
||
</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>
|