open-design/apps/landing-page/app/_components/template-card.astro
Jane 9d65e26c0f
feat(landing-page): card grid + share popover for /plugins/templates/ (#3185)
* feat(landing-page): YouMind-style grid + share popover for /plugins/templates/

The list-style catalog rows that landed in PR #3010 read as a long
table of items rather than a discoverable grid. Product feedback (after
benchmarking against youmind.com/zh-CN/seedance-2-0-prompts) wanted:

- A YouMind-shape card with a top accent band, video / poster preview
  area, author + attribution row, an excerpt frame, and a primary CTA
  paired with a share button.
- Hover-autoplay on the 46 video templates whose manifest carries a
  Cloudflare Stream MP4. The data was already there since PR #3010;
  the catalog row just rendered the poster as a static `<img>`.
- A counter chip on the right of the hero that surfaces the live total
  (`Total · 231`) instead of baking the number into the H1
  ("231 runnable templates."). The hero now reads as
  `OPEN SOURCE CLAUDE DESIGN` eyebrow + `Templates.` static H1, which
  also threads the brand keyword into the page's SEO surface.
- A six-question FAQ block below the grid covering license, BYOK keys,
  contribution, and the "open source Claude Design alternative"
  positioning explicitly.

Implementation:

- `_components/template-card.astro` — new card component. Accent band
  hue is derived from `od.mode` so artifacts of the same kind get a
  consistent color (video green, prototype blue, deck mustard, image
  wisteria, hyperframes coral, audio amber, live-artifact teal),
  falling back to a stable per-index hue for unrecognized modes.
  Featured tag (yellow, on-brand) is visible when the manifest tag
  list contains `featured`; the rest of the card is locale-resolved
  via the same `resolveBundledTitle` / `resolveBundledDescription`
  helpers PR #3010 added.

- `pages/plugins/templates/index.astro` + `[kind]/index.astro` — grid
  layout (`.tpl-grid`, `repeat(auto-fill, minmax(340px, 1fr))`),
  hero with counter chip, FAQ section on the parent only. Adjacent
  filter strips share a single divider rather than drawing one each,
  so the kind + scene chip block reads as one filter unit instead of
  three stacked horizontal cuts.

- Hover-autoplay observer + share button click handler bundled into
  one `<script>` per page so they share the same boot lifecycle. The
  earlier split version dispatched `astro:page-load` from the autoplay
  block before the share block's listener attached, which dropped
  the share click on the floor; the merged init() runs eagerly when
  DOM is ready, re-runs idempotently on `astro:page-load` (Astro view
  transition), and uses `data-tpl-init` / `data-tpl-share-bound`
  markers to prevent double-binding.

- Card share is a popover, not a system share sheet. The detail page's
  `<dialog class="detail-share-dialog">` UI is reused (single instance
  per page populated per click), but `<dialog>.show()` runs in
  non-modal mode and JS positions it via `getBoundingClientRect()` to
  unfold above-right of the trigger button. Outside-click and Escape
  close the popover; the existing `data-share-copy` / `data-copy-link`
  handlers in `header-enhancer.astro` wire Copy text + Copy link
  automatically. Width tuned to 420px so it fits next to a 340px-wide
  card without spilling onto the next column.

- `_redirects` already covers retired Skills + Craft routes (PR #3010)
  so this grid pivot doesn't need new redirects.

Out of scope for this PR (kept lean):
- Multi-locale hero + FAQ copy. Hero / FAQ render in English on every
  locale right now; the `pcopy.tileTemplates` chip rail and per-card
  title/description still localize per PR #3010. Locale rollout for
  the hero + FAQ is a follow-up.
- Sort + filter buttons in the YouMind reference top-right (we still
  show artifact-kind chips only). Sort by featured weight is the
  most likely next step.
- `od.featured` weight as a featured proxy. We currently key off
  `tags?.includes('featured')` which is 0-match across the catalog
  today; promoting the numeric weight into `BundledPluginRecord` is a
  separate small commit.

`pnpm --filter @open-design/landing-page typecheck` clean (0 errors).

* feat(landing-page): localize templates chrome + FAQPage JSON-LD + hover-only autoplay

Three follow-ups Looper flagged on the YouMind-style grid (PR #3185):

- **Localizable hero / FAQ / card chrome.** PR #3185 wired the grid
  through `pcopy` for record titles + descriptions but hard-coded the
  surrounding chrome — hero eyebrow / lead / counter label, FAQ head,
  Featured tag, "Read full prompt", "Use this template", and the
  share-button `aria-label` — to English. `/ja/plugins/templates/`,
  `/zh-CN/plugins/templates/video/`, etc. now ship those strings via
  `pcopy.*` keys (`templatesHeroEyebrow`, `templatesHeroLead`,
  `templatesCounterLabel`, `cardFeaturedTag`, `cardReadFullPrompt`,
  `cardUseTemplate`, `cardShareAria`, `faqHead`, `faqItems`). English
  is the base; per-locale overrides for hero copy + 6 FAQ Q&A pairs
  remain a follow-up (the PR-#3185 "Out of scope" item), so the 17
  non-English locales fall back to English chrome instead of showing
  undefined values.

- **`FAQPage` JSON-LD entity.** The visible accordion was a SEO
  surface but `jsonLd` was still a single `CollectionPage`. Switched
  it to an array and appended a `FAQPage` whose `mainEntity` is each
  question + answer from `pcopy.faqItems`, so the structured-data
  payload search engines see and the visible <details> share one
  source of truth — drift between them is now mechanical, not
  editorial.

- **Hover-only autoplay (not viewport autoplay).** The previous
  observer played every video the moment its card scrolled into the
  viewport, which contradicted the PR's stated hover-autoplay
  contract and spawned N simultaneous decoders on a casual scroll.
  The IntersectionObserver now hydrates `data-src` -> `src` lazily
  (one-shot, then unobserve) at a 300px rootMargin; `play()` and
  `pause()` are gated to `pointerenter` / `pointerleave` (plus
  `focusin` / `focusout` for keyboard users) on the parent
  `.tpl-media` host so hovering anywhere on the preview frame
  triggers playback. Same change applied to the `[kind]` route so
  faceted pages behave identically.

Validation: pnpm --filter @open-design/landing-page typecheck -> 0
errors / 0 warnings; local dev (port 3061) renders 231 cards / 46
data-tpl-autoplay markers / FAQPage entity present in jsonLd / 6 FAQ
summaries; zh-CN locale falls back to English chrome (expected, the
locale routes themselves remain out of scope per PR #3185).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:17:56 +00:00

182 lines
7.5 KiB
Text

---
/*
* Card-style row used by `/plugins/templates/` (and its kind-scoped
* facets) — the YouMind-inspired layout product called for after PR
* #3010 shipped:
*
* ┌──────────────────────────┐
* │ ▆ accent band │
* │ [Featured] │ ← optional, top-anchored
* │ ┌──────────────────────┐ │
* │ │ 16:9 video / poster │ │ ← hover-autoplay when video
* │ └──────────────────────┘ │
* │ @author DATE │
* │ ┌──────────────────────┐ │
* │ │ Read full prompt → │ │
* │ │ <description clamp> │ │
* │ └──────────────────────┘ │
* │ [ Use this template ] [⤴] │
* └──────────────────────────┘
*
* The legacy `plugin-row.astro` row component still backs
* `/plugins/skills/` and other list-style routes; this file is
* intentionally separate so a future refactor can split the data
* shapes cleanly. For now it accepts the same `BundledPluginRecord`
* shape that the templates page already iterates over.
*
* Hover-autoplay is wired here via a small inline script per page —
* one IntersectionObserver across all `<video data-tpl-autoplay>`
* elements pauses anything outside the viewport so we don't spawn
* 200 simultaneous decoders on a long grid.
*/
import {
resolveBundledDescription,
resolveBundledTitle,
type BundledPluginRecord,
} from '../_lib/bundled-plugins';
import { localeFromPath, localizedHref } from '../i18n';
import { localizeTaxonomyValue } from '../content-i18n';
import { getPluginsCopy } from '../_lib/plugins-i18n';
export interface Props {
record: BundledPluginRecord;
/** Index used to derive a stable accent-band hue. */
index: number;
/** Force-mark the card as featured (e.g. top-N from `od.featured`). */
featured?: boolean;
/**
* Subcategory slug used by the per-kind page's client-side scene
* filter to toggle visibility — same contract `plugin-row.astro`
* exposes so the existing filter script keeps working when the row
* is rendered as a card.
*/
dataScene?: string;
}
const { record, index, featured = false, dataScene } = Astro.props;
const locale = localeFromPath(Astro.url.pathname);
const href = (path: string) => localizedHref(path, locale);
const name = resolveBundledTitle(record, locale);
const description = resolveBundledDescription(record, locale);
const detailHref = href(record.detailHref);
/*
* Share text — bake the localized share template into a `data-share-text`
* attribute on the share button so the page-level click handler can
* surface it via the Web Share API on mobile or the clipboard on desktop
* without each card needing its own dialog. The detail page still ships
* a full `<dialog>` for the platform jump-to grid; cards on the catalog
* surface get the lighter affordance.
*/
const pcopy = getPluginsCopy(locale);
const shareUrl = new URL(detailHref, 'https://open-design.ai').toString();
const shareText = pcopy.shareTemplate({ title: name, url: shareUrl });
/*
* Accent band — derived from the plugin's `mode` so the same kind of
* artifact gets the same hue across the grid (videos green, prototypes
* blue, etc.). Falls back to a stable per-index hue rotation so cards
* without a recognized mode still get a band rather than disappearing
* visually. The seven-slot palette mirrors YouMind's color-coded
* top-band pattern but uses our paper-tone-friendly hues.
*/
const ACCENT_BY_MODE: Record<string, string> = {
video: '#7fb46a', // mossy green
prototype: '#5b8db8', // dusty blue
deck: '#e9b94a', // mustard (matches token)
image: '#a285c2', // wisteria
hyperframes: '#d97373', // coral-rose
audio: '#d28d3f', // amber
'live-artifact': '#5fb0a8', // teal
'design-system': '#7a8857', // olive (matches token's --olive ish)
};
const PALETTE = ['#7fb46a', '#5b8db8', '#e9b94a', '#a285c2', '#d97373', '#d28d3f', '#5fb0a8', '#7a8857'];
const accent = (record.mode && ACCENT_BY_MODE[record.mode]) ?? PALETTE[index % PALETTE.length];
/*
* Author + date strip. Author surfaces the manifest's `author.name`
* with a leading `@` per YouMind convention; first-party manifests
* read as `@open-design`. Date is intentionally absent — bundled
* manifests don't carry a `published_at` and inferring it from git
* mtime adds build-time complexity for a soft signal. We render
* a paper-toned `Open Design` attribution badge in the date slot
* instead so the row still balances visually.
*/
const authorRaw = record.authorName ?? 'Open Design';
const authorHandle = `@${authorRaw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
/*
* Localized chip labels. The catalog row's prior chip rail leaked
* raw English mode/scenario slugs on un-localized rows; the same
* fallback rule applies here. When `localizeTaxonomyValue` returns
* undefined the chip drops rather than showing a kebab slug.
*/
const modeLabel = localizeTaxonomyValue(record.mode, locale);
const scenarioLabel = localizeTaxonomyValue(record.scenario, locale);
const isVideoPreview = record.previewType === 'video' && record.previewVideo;
---
<article class={`tpl-card${featured ? ' tpl-card-featured' : ''}`} data-mode={record.mode ?? 'misc'} data-scene={dataScene}>
<span class="tpl-band" aria-hidden="true" style={`background:${accent}`}></span>
{featured && <span class="tpl-featured-tag">{pcopy.cardFeaturedTag}</span>}
<a class="tpl-media" href={detailHref} aria-label={name}>
{isVideoPreview ? (
<video
class="tpl-media-video"
muted
loop
playsinline
preload="none"
poster={record.previewPoster}
data-tpl-autoplay
data-src={record.previewVideo}
/>
) : record.previewPoster ? (
<img
class="tpl-media-poster"
src={record.previewPoster}
alt={pcopy.previewImageAlt(name)}
loading="lazy"
decoding="async"
/>
) : (
<span class="tpl-media-empty" aria-hidden="true" />
)}
{modeLabel && <span class="tpl-media-kind">{modeLabel}</span>}
</a>
<div class="tpl-meta">
<span class="tpl-author">{authorHandle}</span>
<span class="tpl-meta-date">Open Design</span>
</div>
<a class="tpl-excerpt" href={detailHref}>
<span class="tpl-excerpt-head">{pcopy.cardReadFullPrompt}</span>
<h3 class="tpl-excerpt-title">{name}</h3>
<p class="tpl-excerpt-body">{description}</p>
</a>
<div class="tpl-actions">
<a class="tpl-cta" href={detailHref}>{pcopy.cardUseTemplate}</a>
<button
type="button"
class="tpl-share"
data-tpl-card-share
data-share-text={shareText}
data-share-url={shareUrl}
data-share-title={name}
aria-label={pcopy.cardShareAria(name)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
</div>
</article>