feat(web): refactor PluginsHomeSection to use tag-based filtering and introduce PluginCard component

- Replaced the legacy tabbed categorization in `PluginsHomeSection` with a tag-driven approach, allowing dynamic filtering based on plugin tags.
- Introduced a new `PluginCard` component to encapsulate the rendering of individual plugin cards, improving separation of concerns and maintainability.
- Added a `usePluginCategories` hook to manage plugin visibility and filtering logic, enhancing the overall structure and testability of the component.
- Implemented a "More" pill for overflow tags in the filter row, improving user interaction with a cleaner UI.
- Updated CSS styles to support the new layout and improve visual consistency across the plugins home section.

This update significantly enhances the user experience by providing a more flexible and intuitive way to discover and interact with plugins.
This commit is contained in:
pftom 2026-05-12 13:25:44 +08:00
parent 583bcaf64f
commit 5af84c09af
61 changed files with 8843 additions and 269 deletions

View file

@ -1,31 +1,28 @@
// Plugins discovery section on Home.
//
// Collapses what used to be four separate tabs (Examples, Image
// templates, Video templates, plus the inline plugins rail) into one
// vertical surface: a category filter pill row and a responsive grid
// of plugin cards.
// Renders a single horizontal pill row driven by *plugin tags* rather
// than a hard-coded "Image / Video / Examples / Other" taxonomy. Each
// plugin's `od.scenario / od.mode / od.surface / od.taskKind / tags`
// fields feed `pluginTags()` (`./plugins-home/scenarioTags.ts`), which
// produces normalised slugs; the section ranks them by count and
// surfaces the top N as filter pills with a "More" overflow expansion.
//
// "Featured" lives as the first pill in the filter row (not a
// separate hero card above it). The Featured pill is selected by
// default whenever the project ships at least one featured plugin
// candidate, so the most relevant card is what users see first.
// "Featured" lives at the front of the row when at least one plugin
// declares `od.featured: true` (otherwise the first scenario plugin
// stands in so the slot stays populated). It is selected by default.
//
// Featured source — every plugin whose manifest declares
// `od.featured: true`; falls back to the first scenario plugin so
// the slot stays populated even before the user marks anything.
// Categorisation, featured-derivation and filtering are factored out
// to `./plugins-home/usePluginCategories.ts` so this file can stay
// focused on layout and so unit tests can drive the hook in isolation.
import { useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { Icon } from './Icon';
export type PluginCategory =
| 'featured'
| 'all'
| 'design'
| 'image'
| 'video'
| 'examples'
| 'other';
import { PluginCard } from './plugins-home/PluginCard';
import {
usePluginCategories,
type PluginFilterKey,
} from './plugins-home/usePluginCategories';
import type { ScenarioTag } from './plugins-home/scenarioTags';
interface Props {
plugins: InstalledPluginRecord[];
@ -36,67 +33,6 @@ interface Props {
onOpenDetails: (record: InstalledPluginRecord) => void;
}
interface ManifestExtras {
featured?: boolean;
surface?: string;
}
function manifestExtras(record: InstalledPluginRecord): ManifestExtras {
const od = record.manifest?.od ?? {};
const extras = od as Record<string, unknown>;
return {
featured: extras.featured === true,
surface: typeof extras.surface === 'string' ? (extras.surface as string) : undefined,
};
}
function categoryFor(record: InstalledPluginRecord): PluginCategory {
const od = record.manifest?.od ?? {};
const extras = manifestExtras(record);
if (
od.taskKind === 'new-generation' ||
od.taskKind === 'code-migration' ||
od.taskKind === 'figma-migration' ||
od.taskKind === 'tune-collab'
) {
return 'design';
}
if (od.kind === 'scenario') return 'design';
if (extras.surface === 'image' || od.mode === 'image') return 'image';
if (extras.surface === 'video' || od.mode === 'video') return 'video';
if (od.kind === 'skill') return 'examples';
return 'other';
}
function getFeaturedPlugins(plugins: InstalledPluginRecord[]): InstalledPluginRecord[] {
const explicit = plugins.filter((p) => manifestExtras(p).featured);
if (explicit.length > 0) return explicit;
// Fallback: first scenario plugin so the Featured pill stays populated
// until someone marks one with `od.featured: true`.
const scenario = plugins.find((p) => p.manifest?.od?.kind === 'scenario');
return scenario ? [scenario] : [];
}
const CATEGORY_LABELS: Record<PluginCategory, string> = {
featured: 'Featured',
all: 'All',
design: 'Design',
image: 'Image',
video: 'Video',
examples: 'Examples',
other: 'Other',
};
const CATEGORY_ORDER: PluginCategory[] = [
'featured',
'all',
'design',
'image',
'video',
'examples',
'other',
];
export function PluginsHomeSection({
plugins,
loading,
@ -105,81 +41,25 @@ export function PluginsHomeSection({
onUse,
onOpenDetails,
}: Props) {
// `null` means "fall back to the first available category"; the
// user's explicit choice (or undefined) is tracked in the same
// state so we can stop snapping back to Featured once they pick
// something else, while still defaulting to Featured on first
// mount and any time it becomes available again.
const [pickedCategory, setPickedCategory] = useState<PluginCategory | null>(null);
const visiblePlugins = useMemo(() => {
return plugins.filter((p) => p.manifest?.od?.kind !== 'atom');
}, [plugins]);
const featuredList = useMemo(() => getFeaturedPlugins(visiblePlugins), [visiblePlugins]);
// Featured plugins also appear under their natural category (and
// under "All"), so the categorized buckets do not exclude them
// anymore. The "Featured" pill is just an additional, curated
// filter that surfaces the same records.
const categorized = useMemo(() => {
const map = new Map<PluginCategory, InstalledPluginRecord[]>();
for (const p of visiblePlugins) {
const cat = categoryFor(p);
const list = map.get(cat) ?? [];
list.push(p);
map.set(cat, list);
}
return map;
}, [visiblePlugins]);
const counts = useMemo<Record<PluginCategory, number>>(
() => ({
featured: featuredList.length,
all: visiblePlugins.length,
design: categorized.get('design')?.length ?? 0,
image: categorized.get('image')?.length ?? 0,
video: categorized.get('video')?.length ?? 0,
examples: categorized.get('examples')?.length ?? 0,
other: categorized.get('other')?.length ?? 0,
}),
[visiblePlugins, categorized, featuredList],
);
const visibleCategories = useMemo<PluginCategory[]>(() => {
return CATEGORY_ORDER.filter((c) => {
if (c === 'featured') return featuredList.length > 0;
if (c === 'all') return true;
return counts[c] > 0;
});
}, [counts, featuredList.length]);
// Resolve which pill is active right now: prefer the user's
// explicit pick, but only when it's still available; otherwise
// default to Featured (when present) or All. This keeps the
// Featured pill selected on first paint without an extra effect
// and avoids the "snap to All while plugins load" flash.
const category: PluginCategory =
pickedCategory && visibleCategories.includes(pickedCategory)
? pickedCategory
: (visibleCategories[0] ?? 'all');
const filtered = useMemo(() => {
if (category === 'featured') return featuredList;
if (category === 'all') {
return CATEGORY_ORDER.flatMap((c) =>
c === 'featured' || c === 'all' ? [] : (categorized.get(c) ?? []),
);
}
return categorized.get(category) ?? [];
}, [category, categorized, featuredList]);
const {
visiblePlugins,
featuredList,
filtered,
filter,
setFilter,
visibleTags,
overflowTags,
showOverflow,
toggleOverflow,
totalVisible,
} = usePluginCategories({ plugins });
return (
<section className="plugins-home" data-testid="plugins-home-section">
<header className="plugins-home__head">
<h2 className="plugins-home__title">Plugins</h2>
<span className="plugins-home__count">
{loading ? '…' : `${visiblePlugins.length} installed`}
{loading ? '…' : `${totalVisible} installed`}
</span>
</header>
@ -192,45 +72,16 @@ export function PluginsHomeSection({
</div>
) : (
<>
{visibleCategories.length > 1 ? (
<div
className="plugins-home__filters"
role="tablist"
aria-label="Plugin categories"
>
{visibleCategories.map((cat) => {
const isActive = category === cat;
const isFeatured = cat === 'featured';
return (
<button
key={cat}
type="button"
role="tab"
aria-selected={isActive}
className={[
'plugins-home__filter',
isActive ? 'is-active' : '',
isFeatured ? 'plugins-home__filter--featured' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => setPickedCategory(cat)}
data-testid={`plugins-home-filter-${cat}`}
>
{isFeatured ? (
<Icon
name="star"
size={11}
className="plugins-home__filter-icon"
/>
) : null}
<span>{CATEGORY_LABELS[cat]}</span>
<span className="plugins-home__filter-count">{counts[cat]}</span>
</button>
);
})}
</div>
) : null}
<FilterRow
filter={filter}
featuredCount={featuredList.length}
totalVisible={totalVisible}
visibleTags={visibleTags}
overflowTags={overflowTags}
showOverflow={showOverflow}
onPick={setFilter}
onToggleOverflow={toggleOverflow}
/>
<div className="plugins-home__grid" role="list">
{filtered.map((p) => (
@ -252,93 +103,110 @@ export function PluginsHomeSection({
);
}
interface PluginCardProps {
record: InstalledPluginRecord;
isActive: boolean;
isPending: boolean;
pendingAny: boolean;
isFeatured: boolean;
onUse: (record: InstalledPluginRecord) => void;
onOpenDetails: (record: InstalledPluginRecord) => void;
interface FilterRowProps {
filter: PluginFilterKey;
featuredCount: number;
totalVisible: number;
visibleTags: ScenarioTag[];
overflowTags: ScenarioTag[];
showOverflow: boolean;
onPick: (key: PluginFilterKey) => void;
onToggleOverflow: () => void;
}
function PluginCard({
record,
isActive,
isPending,
pendingAny,
isFeatured,
onUse,
onOpenDetails,
}: PluginCardProps) {
const hasQuery = Boolean(record.manifest?.od?.useCase?.query);
function FilterRow({
filter,
featuredCount,
totalVisible,
visibleTags,
overflowTags,
showOverflow,
onPick,
onToggleOverflow,
}: FilterRowProps) {
// Don't show the row at all when there is nothing meaningful to filter
// (only "All" available + no scenario tags). One pill in isolation is
// visual noise; the original behaviour matches this.
if (featuredCount === 0 && visibleTags.length === 0) return null;
const tagsToRender = showOverflow ? [...visibleTags, ...overflowTags] : visibleTags;
return (
<article
role="listitem"
<div className="plugins-home__filters" role="tablist" aria-label="Plugin categories">
{featuredCount > 0 ? (
<FilterPill
slug="featured"
label="Featured"
count={featuredCount}
active={filter === 'featured'}
variant="featured"
onPick={onPick}
/>
) : null}
<FilterPill
slug="all"
label="All"
count={totalVisible}
active={filter === 'all'}
onPick={onPick}
/>
{tagsToRender.map((tag) => (
<FilterPill
key={tag.slug}
slug={tag.slug}
label={tag.label}
count={tag.count}
active={filter === tag.slug}
onPick={onPick}
/>
))}
{overflowTags.length > 0 ? (
<button
type="button"
className="plugins-home__filter plugins-home__filter--more"
onClick={onToggleOverflow}
data-testid="plugins-home-filter-more"
aria-expanded={showOverflow}
>
<span>{showOverflow ? 'Less' : 'More'}</span>
<span className="plugins-home__filter-count">
{showOverflow ? '' : `+${overflowTags.length}`}
</span>
</button>
) : null}
</div>
);
}
interface FilterPillProps {
slug: PluginFilterKey;
label: string;
count: number;
active: boolean;
variant?: 'featured';
onPick: (key: PluginFilterKey) => void;
}
function FilterPill({ slug, label, count, active, variant, onPick }: FilterPillProps) {
const isFeatured = variant === 'featured';
return (
<button
type="button"
role="tab"
aria-selected={active}
className={[
'plugins-home__card',
isActive ? 'is-active' : '',
isFeatured ? 'is-featured' : '',
'plugins-home__filter',
active ? 'is-active' : '',
isFeatured ? 'plugins-home__filter--featured' : '',
]
.filter(Boolean)
.join(' ')}
data-plugin-id={record.id}
{...(isFeatured ? { 'data-featured': 'true' } : {})}
onClick={() => onPick(slug)}
data-testid={`plugins-home-filter-${slug}`}
>
<header className="plugins-home__card-head">
<span className="plugins-home__card-title" title={record.title}>
{isFeatured ? (
<Icon
name="star"
size={11}
className="plugins-home__card-featured-mark"
/>
) : null}
{record.title}
</span>
<span className={`plugins-home__trust trust-${record.trust}`}>
{record.trust}
</span>
</header>
{record.manifest?.description ? (
<p className="plugins-home__card-desc">{record.manifest.description}</p>
{isFeatured ? (
<Icon name="star" size={11} className="plugins-home__filter-icon" />
) : null}
<div className="plugins-home__card-meta">
{record.manifest?.od?.taskKind ? (
<span>{record.manifest.od.taskKind}</span>
) : null}
{record.manifest?.od?.kind ? <span>· {record.manifest.od.kind}</span> : null}
</div>
<div className="plugins-home__card-actions">
<button
type="button"
className="plugins-home__action plugins-home__action--secondary"
onClick={() => onOpenDetails(record)}
aria-label={`View details for ${record.title}`}
data-testid={`plugins-home-details-${record.id}`}
>
<Icon name="eye" size={12} />
<span>Details</span>
</button>
<button
type="button"
className="plugins-home__action plugins-home__action--primary"
onClick={() => onUse(record)}
disabled={isPending || pendingAny}
aria-busy={isPending ? 'true' : undefined}
data-testid={`plugins-home-use-${record.id}`}
>
{isPending
? 'Applying…'
: hasQuery
? isActive
? 'Reload'
: 'Use'
: isActive
? 'Active'
: 'Use'}
</button>
</div>
</article>
<span>{label}</span>
<span className="plugins-home__filter-count">{count}</span>
</button>
);
}

View file

@ -0,0 +1,92 @@
// Single plugin card rendered inside the plugins-home grid. Kept as
// its own file so PluginsHomeSection.tsx can stay focused on the
// filter row + grid layout, and so card-only visual tweaks land
// without rerendering the whole categorisation contract.
import type { InstalledPluginRecord } from '@open-design/contracts';
import { Icon } from '../Icon';
interface Props {
record: InstalledPluginRecord;
isActive: boolean;
isPending: boolean;
pendingAny: boolean;
isFeatured: boolean;
onUse: (record: InstalledPluginRecord) => void;
onOpenDetails: (record: InstalledPluginRecord) => void;
}
export function PluginCard({
record,
isActive,
isPending,
pendingAny,
isFeatured,
onUse,
onOpenDetails,
}: Props) {
const hasQuery = Boolean(record.manifest?.od?.useCase?.query);
return (
<article
role="listitem"
className={[
'plugins-home__card',
isActive ? 'is-active' : '',
isFeatured ? 'is-featured' : '',
]
.filter(Boolean)
.join(' ')}
data-plugin-id={record.id}
{...(isFeatured ? { 'data-featured': 'true' } : {})}
>
<header className="plugins-home__card-head">
<span className="plugins-home__card-title" title={record.title}>
{isFeatured ? (
<Icon name="star" size={11} className="plugins-home__card-featured-mark" />
) : null}
{record.title}
</span>
<span className={`plugins-home__trust trust-${record.trust}`}>
{record.trust}
</span>
</header>
{record.manifest?.description ? (
<p className="plugins-home__card-desc">{record.manifest.description}</p>
) : null}
<div className="plugins-home__card-meta">
{record.manifest?.od?.taskKind ? <span>{record.manifest.od.taskKind}</span> : null}
{record.manifest?.od?.kind ? <span>· {record.manifest.od.kind}</span> : null}
</div>
<div className="plugins-home__card-actions">
<button
type="button"
className="plugins-home__action plugins-home__action--secondary"
onClick={() => onOpenDetails(record)}
aria-label={`View details for ${record.title}`}
data-testid={`plugins-home-details-${record.id}`}
>
<Icon name="eye" size={12} />
<span>Details</span>
</button>
<button
type="button"
className="plugins-home__action plugins-home__action--primary"
onClick={() => onUse(record)}
disabled={isPending || pendingAny}
aria-busy={isPending ? 'true' : undefined}
data-testid={`plugins-home-use-${record.id}`}
>
{isPending
? 'Applying…'
: hasQuery
? isActive
? 'Reload'
: 'Use'
: isActive
? 'Active'
: 'Use'}
</button>
</div>
</article>
);
}

View file

@ -0,0 +1,182 @@
// Tag derivation + labelling for the Plugins home section.
//
// The home filter row is scenario-driven (per the migrate-to-plugins
// design): we no longer hard-code 4 buckets (Image / Video / Examples
// / Design). Instead every plugin emits a normalised set of tags
// (kebab-case slugs) and the section ranks tags by occurrence,
// surfacing the top N as pills with a "More" overflow.
//
// Centralising the derivation here lets the categorisation hook stay
// pure and lets tests assert tag membership without touching React.
import type { InstalledPluginRecord } from '@open-design/contracts';
// Slugs the user only ever sees because they bubble up from low-level
// plugin metadata. They never make for useful filter chips on the home
// page, so we drop them at derivation time. Keep the list small so an
// unexpected tag still reaches the UI for surfacing.
const NOISE_TAGS = new Set([
'first-party',
'third-party',
'phase-7',
'phase-1',
'atom',
'bundle',
'scenario',
'plugin',
]);
// Pretty labels for known scenario slugs. The renderer falls back to
// title-casing the slug for anything missing here, so this map only
// needs to cover the cases where humanise() would produce something
// awkward ("E-Commerce-Retail" instead of "E-commerce & retail").
const TAG_LABELS: Record<string, string> = {
image: 'Image',
video: 'Video',
audio: 'Audio',
'image-template': 'Image template',
'video-template': 'Video template',
example: 'Example',
'design-system': 'Design system',
workflow: 'Workflow',
marketing: 'Marketing',
dashboard: 'Dashboard',
landing: 'Landing',
prototype: 'Prototype',
mobile: 'Mobile',
desktop: 'Desktop',
web: 'Web',
design: 'Design',
engineering: 'Engineering',
product: 'Product',
sales: 'Sales',
finance: 'Finance',
hr: 'HR',
operations: 'Operations',
support: 'Support',
'e-commerce-retail': 'E-commerce',
'developer-tools': 'Developer tools',
'new-generation': 'New generation',
'code-migration': 'Code migration',
'figma-migration': 'Figma migration',
'tune-collab': 'Tune & collab',
};
function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');
}
export function labelForTag(slug: string): string {
const known = TAG_LABELS[slug];
if (known) return known;
return slug
.split('-')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}
// Tags the user is most likely to recognise as a "scenario" pill. They
// get a stable position at the start of the pill row regardless of
// raw frequency, so the row never reshuffles when a new plugin lands.
const PINNED_ORDER = [
'workflow',
'example',
'image',
'video',
'design-system',
];
interface ManifestExtras {
featured?: boolean;
surface?: string;
}
function manifestExtras(record: InstalledPluginRecord): ManifestExtras {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
return {
featured: od.featured === true,
surface: typeof od.surface === 'string' ? (od.surface as string) : undefined,
};
}
// Derive the set of normalised scenario tags for a single plugin. Two
// invariants the consumer can rely on:
//
// 1. Every multi-stage scenario plugin gets a synthetic `workflow`
// tag so the user can filter scripted pipelines as a group.
// 2. `surface` and `mode` are added when present so type-style
// categorisation (image / video / design-system) still works
// without the renderer hard-coding any of them.
export function pluginTags(record: InstalledPluginRecord): string[] {
const od = (record.manifest?.od ?? {}) as Record<string, unknown>;
const raw: Array<string | undefined> = [];
for (const t of record.manifest?.tags ?? []) raw.push(String(t));
if (typeof od.scenario === 'string') raw.push(od.scenario);
if (typeof od.mode === 'string') raw.push(od.mode);
const surface = manifestExtras(record).surface;
if (surface) raw.push(surface);
if (typeof od.taskKind === 'string') raw.push(od.taskKind);
if (typeof od.platform === 'string') raw.push(od.platform);
const pipelineStages =
od.pipeline && typeof od.pipeline === 'object'
? (od.pipeline as { stages?: unknown[] }).stages
: undefined;
if (Array.isArray(pipelineStages) && pipelineStages.length > 1) {
raw.push('workflow');
}
const seen = new Set<string>();
const out: string[] = [];
for (const t of raw) {
if (!t) continue;
const slug = slugify(t);
if (!slug || NOISE_TAGS.has(slug)) continue;
if (seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
export function isFeaturedPlugin(record: InstalledPluginRecord): boolean {
return manifestExtras(record).featured === true;
}
export interface ScenarioTag {
slug: string;
label: string;
count: number;
}
// Sort tags by frequency, with PINNED_ORDER tags floated to the top
// (still hidden when they have zero matches). Returns the catalog the
// pill row renders verbatim.
export function buildTagCatalog(plugins: InstalledPluginRecord[]): ScenarioTag[] {
const counts = new Map<string, number>();
for (const plugin of plugins) {
for (const slug of pluginTags(plugin)) {
counts.set(slug, (counts.get(slug) ?? 0) + 1);
}
}
const pinned = PINNED_ORDER.flatMap((slug) => {
const count = counts.get(slug);
if (!count) return [];
return [{ slug, label: labelForTag(slug), count }];
});
const pinnedSlugs = new Set(PINNED_ORDER);
const rest = Array.from(counts.entries())
.filter(([slug]) => !pinnedSlugs.has(slug))
.sort((a, b) => {
if (a[1] !== b[1]) return b[1] - a[1];
return a[0].localeCompare(b[0]);
})
.map(([slug, count]) => ({ slug, label: labelForTag(slug), count }));
return [...pinned, ...rest];
}

View file

@ -0,0 +1,112 @@
// Pure categorisation hook for the Plugins home section.
//
// Encapsulates the "what tags exist, who's featured, which subset is
// active" computation so the section component can stay focused on
// rendering. Returning derived state from one place also makes the
// behaviour straightforward to unit-test (see
// tests/components/plugins-home-categories.test.ts).
import { useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { buildTagCatalog, isFeaturedPlugin, pluginTags, type ScenarioTag } from './scenarioTags';
// Synthetic filter keys for the always-present pills at the start of
// the row. Real tag slugs follow these in the pill array; the union
// type below lets the component compare-and-render with one variable.
export type PluginFilterKey = 'featured' | 'all' | string;
// How many scenario tags we show as standalone pills before collapsing
// the remainder into a "More" expansion. Sized so the row stays on
// one line for typical (~6 type tags + a handful of scenario tags)
// catalogs while leaving an obvious overflow signal when the catalog
// grows past it.
const DEFAULT_VISIBLE_TAGS = 8;
interface UsePluginCategoriesArgs {
plugins: InstalledPluginRecord[];
visibleTagLimit?: number;
}
export interface UsePluginCategoriesResult {
visiblePlugins: InstalledPluginRecord[];
featuredList: InstalledPluginRecord[];
filtered: InstalledPluginRecord[];
filter: PluginFilterKey;
setFilter: (next: PluginFilterKey | null) => void;
visibleTags: ScenarioTag[];
overflowTags: ScenarioTag[];
showOverflow: boolean;
toggleOverflow: () => void;
totalVisible: number;
}
export function usePluginCategories(
args: UsePluginCategoriesArgs,
): UsePluginCategoriesResult {
const [picked, setPicked] = useState<PluginFilterKey | null>(null);
const [showOverflow, setShowOverflow] = useState(false);
const limit = args.visibleTagLimit ?? DEFAULT_VISIBLE_TAGS;
// Atoms are infrastructure pieces (`code-import`, `patch-edit`) that
// are not user-facing on the home grid; the original section already
// filtered them out and we preserve that contract.
const visiblePlugins = useMemo(
() => args.plugins.filter((p) => p.manifest?.od?.kind !== 'atom'),
[args.plugins],
);
const featuredList = useMemo(() => {
const explicit = visiblePlugins.filter(isFeaturedPlugin);
if (explicit.length > 0) return explicit;
const scenario = visiblePlugins.find((p) => p.manifest?.od?.kind === 'scenario');
return scenario ? [scenario] : [];
}, [visiblePlugins]);
const tagCatalog = useMemo(() => buildTagCatalog(visiblePlugins), [visiblePlugins]);
const visibleTags = useMemo(() => tagCatalog.slice(0, limit), [tagCatalog, limit]);
const overflowTags = useMemo(() => tagCatalog.slice(limit), [tagCatalog, limit]);
// Default to Featured (if any), else "All". The user's explicit
// pick wins as long as it still exists in the catalog after a data
// refresh — picking a slug that vanished simply snaps back to the
// default rather than rendering an empty grid.
const availableFilterKeys = useMemo(() => {
const keys = new Set<PluginFilterKey>(['all']);
if (featuredList.length > 0) keys.add('featured');
for (const tag of tagCatalog) keys.add(tag.slug);
return keys;
}, [tagCatalog, featuredList.length]);
const filter: PluginFilterKey =
picked && availableFilterKeys.has(picked)
? picked
: featuredList.length > 0
? 'featured'
: 'all';
const filtered = useMemo(() => {
if (filter === 'featured') return featuredList;
if (filter === 'all') return visiblePlugins;
return visiblePlugins.filter((p) => pluginTags(p).includes(filter));
}, [filter, featuredList, visiblePlugins]);
function setFilter(next: PluginFilterKey | null): void {
setPicked(next);
}
function toggleOverflow(): void {
setShowOverflow((v) => !v);
}
return {
visiblePlugins,
featuredList,
filtered,
filter,
setFilter,
visibleTags,
overflowTags,
showOverflow,
toggleOverflow,
totalVisible: visiblePlugins.length,
};
}

View file

@ -104,6 +104,18 @@
color: white;
}
/* "More" pill toggles the overflow scenario tags into the pill row
inline. Styled to read as a meta control (dashed border, muted) so
it never competes with a real category for the user's attention. */
.plugins-home__filter--more {
border-style: dashed;
color: var(--text-faint);
}
.plugins-home__filter--more:hover {
color: var(--text);
border-style: solid;
}
/* Card grid */
.plugins-home__grid {
display: grid;

View file

@ -0,0 +1,128 @@
// Tag derivation contract for the plugins-home filter row. The home
// section ranks plugins by scenario tag rather than the legacy 4
// surface buckets (image / video / examples / design); these tests
// lock the slug surface so a tag never silently drops out of the
// pill row when the manifest schema evolves.
import { describe, expect, it } from 'vitest';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
buildTagCatalog,
isFeaturedPlugin,
labelForTag,
pluginTags,
} from '../../src/components/plugins-home/scenarioTags';
function fixture(overrides: {
id: string;
title?: string;
tags?: string[];
od?: Record<string, unknown>;
}): InstalledPluginRecord {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: overrides.id,
version: '0.1.0',
...(overrides.tags ? { tags: overrides.tags } : {}),
...(overrides.od ? { od: overrides.od } : {}),
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
describe('pluginTags', () => {
it('emits image / image-template / marketing for a wrapped image template', () => {
const tags = pluginTags(
fixture({
id: 'image-template-foo',
tags: ['image-template', 'first-party', 'image', 'marketing'],
od: { kind: 'scenario', taskKind: 'new-generation', mode: 'image', surface: 'image' },
}),
);
expect(tags).toContain('image');
expect(tags).toContain('image-template');
expect(tags).toContain('marketing');
expect(tags).toContain('new-generation');
expect(tags).not.toContain('first-party');
});
it('adds a synthetic workflow tag for multi-stage scenario plugins', () => {
const tags = pluginTags(
fixture({
id: 'od-code-migration',
tags: ['scenario', 'first-party', 'code-migration'],
od: {
kind: 'scenario',
taskKind: 'code-migration',
pipeline: { stages: [{ id: 'a', atoms: ['x'] }, { id: 'b', atoms: ['y'] }] },
},
}),
);
expect(tags).toContain('workflow');
expect(tags).toContain('code-migration');
expect(tags).not.toContain('scenario');
});
it('drops noise tags but keeps surface / scenario / mode', () => {
const tags = pluginTags(
fixture({
id: 'example-saas-landing',
tags: ['example', 'first-party', 'phase-7'],
od: { kind: 'scenario', mode: 'prototype', scenario: 'marketing', surface: 'web' },
}),
);
expect(tags).toEqual(expect.arrayContaining(['example', 'marketing', 'prototype', 'web']));
expect(tags).not.toContain('first-party');
expect(tags).not.toContain('phase-7');
});
});
describe('buildTagCatalog', () => {
it('pins type tags (image / video / design-system) ahead of free-form tags', () => {
const catalog = buildTagCatalog([
fixture({ id: 'a', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
fixture({ id: 'b', tags: ['marketing'], od: { mode: 'image', surface: 'image' } }),
fixture({ id: 'c', tags: ['design-system'], od: { mode: 'design-system', surface: 'web' } }),
]);
const slugs = catalog.map((t) => t.slug);
// 'image' (pinned, count=2) should come before 'marketing' (count=2).
expect(slugs.indexOf('image')).toBeLessThan(slugs.indexOf('marketing'));
});
it('orders by count desc within the non-pinned section', () => {
const catalog = buildTagCatalog([
fixture({ id: 'a', tags: ['marketing'] }),
fixture({ id: 'b', tags: ['marketing'] }),
fixture({ id: 'c', tags: ['dashboard'] }),
]);
const slugs = catalog.map((t) => t.slug);
expect(slugs.indexOf('marketing')).toBeLessThan(slugs.indexOf('dashboard'));
});
});
describe('isFeaturedPlugin', () => {
it('returns true when od.featured === true', () => {
expect(isFeaturedPlugin(fixture({ id: 'a', od: { featured: true } }))).toBe(true);
expect(isFeaturedPlugin(fixture({ id: 'b', od: { featured: 'true' } }))).toBe(false);
expect(isFeaturedPlugin(fixture({ id: 'c' }))).toBe(false);
});
});
describe('labelForTag', () => {
it('uses the known dictionary for canonical slugs', () => {
expect(labelForTag('image')).toBe('Image');
expect(labelForTag('design-system')).toBe('Design system');
});
it('falls back to title-casing unknown slugs', () => {
expect(labelForTag('mocktail-bar')).toBe('Mocktail Bar');
});
});

View file

@ -0,0 +1,133 @@
// @vitest-environment jsdom
// Plugins home section — UI contract.
//
// The section is now driven by `usePluginCategories` (tag-based
// scenario chips) rather than the legacy 4-bucket taxonomy. This
// suite locks in:
//
// 1. Featured pill is selected by default when at least one plugin
// declares `od.featured: true`.
// 2. Clicking a scenario tag filters the grid to plugins carrying
// that tag.
// 3. The "More" pill toggles overflow tags into the row when the
// catalog exceeds the visible limit.
import { describe, expect, it, afterEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { PluginsHomeSection } from '../../src/components/PluginsHomeSection';
function makePlugin(overrides: {
id: string;
title?: string;
tags?: string[];
featured?: boolean;
mode?: string;
surface?: string;
scenario?: string;
}): InstalledPluginRecord {
return {
id: overrides.id,
title: overrides.title ?? overrides.id,
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: overrides.id,
version: '0.1.0',
title: overrides.title ?? overrides.id,
...(overrides.tags ? { tags: overrides.tags } : {}),
od: {
kind: 'scenario',
...(overrides.mode ? { mode: overrides.mode } : {}),
...(overrides.surface ? { surface: overrides.surface } : {}),
...(overrides.scenario ? { scenario: overrides.scenario } : {}),
...(overrides.featured ? { featured: true } : {}),
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('PluginsHomeSection (tag-driven)', () => {
it('defaults to the Featured pill when a plugin declares featured', () => {
const plugins = [
makePlugin({ id: 'star', featured: true, mode: 'image', tags: ['image'] }),
makePlugin({ id: 'b', mode: 'image', tags: ['image'] }),
makePlugin({ id: 'c', mode: 'video', tags: ['video'] }),
];
render(
<PluginsHomeSection
plugins={plugins}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
/>,
);
const featured = screen.getByTestId('plugins-home-filter-featured');
expect(featured.getAttribute('aria-selected')).toBe('true');
const grid = screen.getByRole('list');
const items = within(grid).getAllByRole('listitem');
expect(items).toHaveLength(1);
expect(items[0]?.getAttribute('data-plugin-id')).toBe('star');
});
it('filters by a scenario tag when its pill is clicked', () => {
const plugins = [
makePlugin({ id: 'a', mode: 'image', tags: ['image', 'marketing'] }),
makePlugin({ id: 'b', mode: 'video', tags: ['video', 'marketing'] }),
makePlugin({ id: 'c', mode: 'prototype', tags: ['prototype', 'dashboard'] }),
];
render(
<PluginsHomeSection
plugins={plugins}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
/>,
);
fireEvent.click(screen.getByTestId('plugins-home-filter-marketing'));
const items = within(screen.getByRole('list')).getAllByRole('listitem');
expect(items.map((i) => i.getAttribute('data-plugin-id')).sort()).toEqual(['a', 'b']);
});
it('toggles overflow tags into the pill row when "More" is clicked', () => {
// Build 12 plugins each tagged with a unique slug — sorted
// alphabetically by the catalog so the last one ('zz-late') is
// guaranteed to sit in the overflow tail past the default
// 8-pill visible window.
const prefixes = ['aa', 'ab', 'ac', 'ad', 'ae', 'af', 'ag', 'ah', 'ai', 'aj', 'ak', 'zz-late'];
const plugins = prefixes.map((p) =>
makePlugin({ id: `id-${p}`, tags: [p, 'image'], mode: 'image' }),
);
render(
<PluginsHomeSection
plugins={plugins}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
/>,
);
const more = screen.getByTestId('plugins-home-filter-more');
expect(more).toBeTruthy();
expect(screen.queryByTestId('plugins-home-filter-zz-late')).toBeNull();
fireEvent.click(more);
expect(screen.getByTestId('plugins-home-filter-zz-late')).toBeTruthy();
});
});

View file

@ -0,0 +1,393 @@
# Design System Inspired by Airbnb
> Category: E-Commerce & Retail
> Travel marketplace. Warm coral accent, photography-driven, rounded UI.
## 1. Visual Theme & Atmosphere
Airbnb's 2026 design feels like a travel magazine that happens to be an app — pristine white canvases give way to full-bleed photography, and the interface itself disappears so the listings can breathe. The signature Rausch coral-pink (`#ff385c`) is used sparingly but unmistakably: search CTA, active tab indicator, primary action button, the occasional price or wishlist heart. Everything else is a disciplined grayscale, with `#222222` carrying almost every line of text.
What makes the system unmistakably Airbnb is how much *faith* it places in content. Property photos are displayed at hero scale, 4:3 with edge-to-edge radius treatment. Category switching happens through a tri-tab picker (Homes / Experiences / Services) that uses 3D rendered illustrated icons (a pitched-roof house, a hot-air balloon, a service bell) — physical, tactile, almost toy-like — paired with crisp `Airbnb Cereal VF` labels. This is the rare consumer product where 3D renders and purely typographic UI coexist without tension.
The newest surface is the **Experiences** product line — same chrome, but richer card density, more photography, and a center-anchored booking panel with sticky right-rail pricing. Listing detail pages (both rooms and experiences) follow a tight template: full-bleed hero image grid → overlapping rounded booking card (sticky on scroll) → amenities → reviews (Guest Favorite awards use a big centered `4.81` rating with a laurel-wreath lockup) → map → host profile → disclosures. The rhythm is consistent whether you're booking a room or a yacht tour.
**Key Characteristics:**
- Rausch coral-pink (`#ff385c`) as a single-accent brand color, used only for primary CTAs and the search button
- Full-bleed photography at 4:3 / 16:9 with gentle corner rounding (1420px) as the primary visual vocabulary
- 3D rendered category icons paired with typographic tabs — the one place the system allows illustration
- Circular `50%` icon buttons (back arrow, share, favorite, carousel arrows) scattered throughout
- `Airbnb Cereal VF` carries every label, from 8px legal footnote to 28px section heading — a single-family system
- Product-tier color coding: Airbnb Plus (magenta `#92174d`), Airbnb Luxe (deep purple `#460479`), Airbnb (Rausch coral)
- Guest Favorite award lockup — centered giant rating number between two laurel wreaths, one of the most recognizable moments in the system
- Sticky booking panel with a price → dates → guests stack, pinned to the right rail on desktop, transforming to a bottom-anchored "Reserve" bar on mobile
- Sticky bottom mobile navigation (Explore / Wishlists / Log in) with an active-state Rausch tint
## 2. Color Palette & Roles
### Primary
- **Rausch** (`#ff385c`): The brand's signature coral-pink. CSS variable `--palette-bg-primary-core`. Used for: primary "Reserve" button, search submit button, active tab underline, wishlist heart fill, pricing emphasis. The single highest-visibility color on every page.
### Secondary & Accent
- **Deep Rausch** (`#e00b41`): A more saturated variant. CSS variable `--palette-bg-tertiary-core`. Used for pressed/active button states and gradient terminal stops.
- **Plus Magenta** (`#92174d`): CSS variable `--palette-bg-primary-plus`. The brand color for the Airbnb Plus product tier — a higher-end curated-listing offering.
- **Luxe Purple** (`#460479`): CSS variable `--palette-bg-primary-luxe`. The brand color for the Airbnb Luxe product tier — villa/estate-level rentals.
- **Info Blue** (`#428bff`): CSS variable `--palette-text-legal`. Used for legal/informational links (terms, privacy, disclosures) — the only non-monochrome link color in the system.
### Surface & Background
- **Canvas White** (`#ffffff`): The default page background. Every card, every container, every detail page starts here.
- **Soft Cloud** (`#f7f7f7`): Subtle subsurface tint used on footer backgrounds, map-view wrappers, and "everything else" sections that want to step back from the primary white.
- **Hairline Gray** (`#dddddd`): Ubiquitous 1px border color — separates cards, amenity rows, review panels, footer columns. The workhorse of the layout system.
### Neutrals & Text
- **Ink Black** (`#222222`): CSS variable `--palette-text-primary`. The system's near-black. Every heading, every body paragraph, every nav label, every price. Used for ~90% of all text on a page.
- **Charcoal** (`#3f3f3f`): CSS variable `--palette-text-focused`. Used in focused-state input text and one-step-down emphasis copy.
- **Ash Gray** (`#6a6a6a`): CSS variable `--palette-bg-tertiary-hover`. Secondary labels, "Cottage rentals" subtitle-style copy under city names, muted footer links.
- **Mute Gray** (`#929292`): CSS variable `--palette-text-link-disabled`. Disabled buttons and low-priority metadata.
- **Stone Gray** (`#c1c1c1`): Tertiary dividers, icon strokes, placeholder avatars.
### Semantic & Accent
- **Error Red** (`#c13515`): CSS variable `--palette-text-primary-error`. Form validation errors, destructive-action warnings.
- **Deep Error** (`#b32505`): CSS variable `--palette-text-secondary-error-hover`. Pressed/active variants of error states.
- **Translucent Black** (`rgba(0, 0, 0, 0.24)`): CSS variable `--palette-text-material-disabled`. Disabled material-style labels.
### Gradient System
Airbnb's brand gradient appears sparingly, typically only on the wordmark and the search-button branded moment:
```
linear-gradient(90deg, #ff385c 0%, #e00b41 50%, #92174d 100%)
```
This coral → magenta sweep is the "branded moment" — never used as a full surface, only as a narrow pill fill or logo treatment.
## 3. Typography Rules
### Font Family
- **Airbnb Cereal VF** (primary and only): The proprietary variable-weight sans-serif that carries the entire system. Fallbacks (in order): `Circular, -apple-system, system-ui, Roboto, Helvetica Neue, sans-serif`.
Weights observed in the extracted tokens: 500, 600, 700. No 400-regular — the system's "body" weight is 500, which gives every block of text a subtle extra density that reads as confident and deliberate.
OpenType features: `salt` (stylistic alternates) is used on the compact 11px and 14px 600-weight labels — likely for tighter numerals and special-character shaping. No ligature or fractional-numeral features observed.
### Hierarchy
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Section Heading | 28px / 1.75rem | 700 | 1.43 | 0 | "Inspiration for future getaways" — page-level headings |
| Subsection Heading | 22px / 1.38rem | 500 | 1.18 | -0.44px | "What this place offers", "Meet the hosts" — content dividers |
| Card Title | 21px / 1.31rem | 700 | 1.43 | 0 | Review panel headings, card lead titles |
| Listing Title | 20px / 1.25rem | 600 | 1.20 | -0.18px | "Small Group Yacht Tour, Unlimited Wine & Fruits" — listing headlines on detail pages |
| Subtitle Bold | 16px / 1.00rem | 600 | 1.25 | 0 | Host name, city name |
| Body Medium | 16px / 1.00rem | 500 | 1.25 | 0 | Primary body copy on detail pages |
| Button Large | 16px / 1.00rem | 500 | 1.25 | 0 | "Reserve", "Become a host" |
| Button Default | 14px / 0.88rem | 500 | 1.29 | 0 | Standard button labels |
| Link | 14px / 0.88rem | 500 | 1.43 | 0 | Nav links, footer links |
| Caption Medium | 14px / 0.88rem | 500 | 1.29 | 0 | Metadata, subtitle lines ("Cottage rentals", "Villa rentals") |
| Caption Bold | 14px / 0.88rem | 600 | 1.43 | 0 | `salt` feature enabled — numeric stats, small-text emphasis |
| Caption Small | 13px / 0.81rem | 400 | 1.23 | 0 | Review dates, micro-metadata |
| Micro Default | 12px / 0.75rem | 400 | 1.33 | 0 | Footer disclaimers, legal micro-copy |
| Micro Bold | 12px / 0.75rem | 700 | 1.33 | 0 | "NEW" pill labels |
| Badge Uppercase | 11px / 0.69rem | 600 | 1.18 | 0 | `salt` feature — compact category/status badges |
| Superscript | 8px / 0.50rem | 700 | 1.25 | 0.32px | Uppercase — price footnotes, decimal tails |
### Principles
- **One family, many weights.** Airbnb Cereal VF handles everything from 8px legal to 28px page headings — the visual identity comes from the family itself, not from typeface mixing.
- **500 is the new 400.** The system's "regular" weight is 500, giving every paragraph a slightly more confident texture than the web default.
- **Negative tracking on display type only.** Headings 20px+ compress tracking by -0.18 to -0.44px to feel chiseled; body sizes stay at 0 tracking for readability.
- **Tight line-heights for headlines, generous for body.** Display type runs at 1.181.25 (tight); body and caption open up to 1.43 for long-form comfort.
- **No all-caps except at 8px.** The only uppercase transform in the system is the 8px superscript — everywhere else, sentence case with subtle weight shifts does the work.
### Note on Font Substitutes
Airbnb Cereal VF is proprietary. The closest open-source substitute is **Circular Std** (still commercial) or **Inter** (free, Google Fonts) with letter-spacing reduced by -0.01em at display sizes. For strict brand fidelity, the documented fallback chain (`Circular, -apple-system, system-ui`) renders acceptably on macOS/iOS where `system-ui` resolves to San Francisco, which has similar proportions.
## 4. Component Stylings
### Buttons
**Primary CTA** ("Reserve", "Search", "Add dates")
- Background: Rausch `#ff385c`
- Text: Canvas White `#ffffff`, Airbnb Cereal 500, 16px
- Padding: ~14px vertical, 24px horizontal
- Radius: 8px (rectangular) or 50% (circular icon variant)
- Border: none
- Active/pressed: `transform: scale(0.92)` plus a 2px `#222222` focus ring at `0 0 0 2px`
**Secondary Button** ("Become a host", outlined tertiary actions)
- Background: `#ffffff`
- Text: Ink Black `#222222`, Airbnb Cereal 500, 1416px
- Padding: 10px 16px
- Radius: 20px (pill) or 8px (rectangular)
- Border: 1px solid Hairline Gray `#dddddd`
**Icon-Only Circular Button** (back arrow, share, favorite, carousel controls)
- Background: `#f2f2f2` (slightly off-white) or white with 1px translucent black border
- Icon: `#222222` outline stroke, 1620px
- Size: 3244px diameter
- Radius: 50%
- Active/pressed: `transform: scale(0.92)`; subtle 4px white ring `0 0 0 4px rgb(255,255,255)` to separate from colorful photography backgrounds
**Disabled Button**
- Background: `#f2f2f2`
- Text: Stone Gray `#c1c1c1`
- Opacity: 0.5
**Pill Tab Button** (category selector "Homes / Experiences / Services")
- Background: transparent
- Text: Ink Black `#222222`, Airbnb Cereal 500, 16px
- Padding: 8px 14px
- Active state: 2px Ink Black underline beneath the label
- Paired with a 3648px 3D-rendered illustrated icon above the label
### Cards & Containers
**Listing Card** (homepage grid, search results)
- Background: `#ffffff`
- Radius: 14px on the image, text sits directly below on transparent background
- Image: 4:3 aspect ratio, full-bleed, rounded with the same 14px radius
- Padding: none on the outer container; 12px spacing between image and metadata rows
- Shadow: none — separation comes from whitespace and the intrinsic radius of the photograph
- Metadata pattern: City/region on line 1 (16px 600), distance/duration on line 2 (14px 500 Ash Gray), date range on line 3, price row with "per night" at the bottom
**Detail Page Booking Panel** (sticky right rail on room/experience pages)
- Background: `#ffffff`
- Radius: 1420px
- Border: 1px solid Hairline Gray `#dddddd`
- Shadow: `rgba(0, 0, 0, 0.02) 0 0 0 1px, rgba(0, 0, 0, 0.04) 0 2px 6px 0, rgba(0, 0, 0, 0.1) 0 4px 8px 0` — a stacked three-layer subtle elevation
- Padding: 24px
- Width: ~370px, pinned 120140px below the viewport top
- Content: price headline → date picker → guest dropdown → primary CTA → "You won't be charged yet" footnote
**Amenity Grid Card** (on listing detail pages)
- Background: `#ffffff`
- Border: 1px solid Hairline Gray `#dddddd` at the row level (not per item)
- Padding: 16px vertical per amenity row
- Icon + label pattern: 24px outline icon on the left, 16px 500-weight label on the right
**Review Card** (individual review on detail pages)
- Background: `#ffffff`, no border
- Padding: 0 (relies on grid gaps)
- Content: 40px circular avatar + 16px 600-weight name + 14px 400 Ash Gray date on one row, then 14px 500 body paragraph below
### Inputs & Forms
**Search Bar** (primary home page)
- Background: `#ffffff`
- Border: 1px solid Hairline Gray `#dddddd` wrapping all three segments (Where / When / Who)
- Radius: 32px (full pill)
- Shadow: `rgba(0, 0, 0, 0.04) 0 2px 6px 0` — subtle floating feel
- Structure: three segments divided by thin vertical dividers, each segment has a 12px 500 label above a 14px 500 placeholder
- Submit: Rausch circular icon button at the right edge, 48px diameter
**Text Input** (generic forms)
- Background: `#ffffff`
- Border: 1px solid Hairline Gray `#dddddd`
- Radius: 8px
- Padding: 14px 16px
- Focus: border switches to Ink Black, adds `0 0 0 2px` black outer ring
- Error: border switches to `#c13515` (Error Red), helper text uses same color
**Date Picker**
- Calendar grid: 7-column layout, circular `50%` day cells 4044px wide
- Selected range: Ink Black `#222222` background with white numerals
- Start/end anchors: larger filled circles; middle dates use Soft Cloud `#f7f7f7` tint
### Navigation
**Top Nav (Desktop)**
- Height: ~80px
- Background: `#ffffff`
- Left: Airbnb wordmark+logo lockup in Rausch (102×32px)
- Center: tri-tab category picker (Homes / Experiences / Services) with 3648px 3D icons stacked above 16px 500 labels; active tab has a 2px Ink Black underline
- Right: "Become a host" text link, then 32px circular globe (language), then 36px hamburger avatar menu
- Border-bottom: 1px solid Hairline Gray `#dddddd`
**Top Nav (Mobile)**
- Single-row search pill occupies full width: "Start your search" placeholder with a small magnifier icon
- Below: tri-tab category picker persists (Homes / Experiences / Services) — illustrated icons shrink to ~28px
- Bottom-fixed tab bar: Explore (active state Rausch) / Wishlists / Log in — 24px icons above 12px labels
**Listing Detail Secondary Nav**
- Sticky horizontal scroll of anchor links (Photos · Amenities · Reviews · Location · Host) appears on scroll past the hero image
- Height: 56px
- Border-bottom: 1px solid Hairline Gray
### Image Treatment
- **Primary aspect ratios**: 4:3 for homepage listing grids, 16:9 for experience hero photography, 1:1 for avatars
- **Radius**: 14px on listing-grid images, 20px on detail-page hero photo frames, `50%` on avatars
- **Image grid on detail pages**: five-photo grid with a single large-left image (50% width) and four smaller photos in a 2×2 grid on the right, all sharing the 20px outer rounded container
- **Lazy loading**: heavy use of `loading="lazy"` with blurred placeholder previews
- **Carousel**: circular 32px arrow buttons overlay the image, centered vertically; dot indicators sit 12px above the bottom edge
### Signature Components
**Guest Favorite Award Lockup** (featured prominently on high-rated listing detail pages)
- Centered rating number rendered at 4456px 700-weight
- Two hand-drawn laurel-wreath SVG illustrations flanking left and right at ~48px tall
- Below: "Guest Favorite" label at 12px 700 uppercase with `0.32px` tracking, and a short sub-label at 14px 500 Ash Gray
- Full-width block, no container border — sits directly on white canvas
**Tri-Tab Category Picker** (appears at the top of every browse surface)
- Three tabs: Homes / Experiences / Services
- Each tab: 3D-rendered illustrated icon (~48px tall) above 16px 500 label
- Experiences and Services currently carry a small navy-blue "NEW" pill (12px 700 white text on dark blue) floating top-right of the icon
- Active tab: 2px Ink Black underline beneath the label
**Inspiration City Grid** (homepage "Inspiration for future getaways")
- 6-column grid of destination links on desktop, 2-column on mobile
- Each cell: 16px 600 city name on line 1, 14px 500 Ash Gray rental-type subtitle on line 2 ("Cottage rentals", "Villa rentals")
- No images — text-only grid
- Tabbed above by category (Popular / Arts & culture / Beach / Mountains / Outdoors / Things to do / Travel tips & inspiration / Airbnb-friendly apartments) — active tab has 2px underline and weight shift
**Reserve Sticky Card** (listing detail pages)
- Stays fixed 120px below viewport top on desktop as the user scrolls past the hero
- Collapses to a full-width bottom bar on mobile with a "From $X / night" label and a Rausch "Reserve" pill
- Always shows: price headline → date display → guest selector → Rausch CTA → "You won't be charged yet" disclaimer
**Experience Host Card** (experience detail pages)
- Full-width rounded container with a 3:2 cover photograph at top
- Host avatar (circular, 56px) overlapping the bottom edge of the cover by 50%
- Below overlap: host name at 16px 700, host tenure at 14px 500 Ash Gray, small Rausch "Message host" pill button
- Used as the transition between reviews and the amenities/location block
**"Things to know" Strip** (listing detail pages)
- 3-column grid of rule/policy blocks (House rules, Safety & property, Cancellation policy)
- Each column: icon at the top, 16px 600 heading, 14px 500 Ash Gray body, "Show more" link in Ink Black underline
- Separator: 1px Hairline Gray top and bottom borders on the overall strip
## 5. Layout Principles
### Spacing System
- **Base unit**: 8px
- **Extracted scale**: 2, 3, 4, 5.5, 6, 8, 10, 11, 12, 15, 16, 18.5, 22, 24, 32px — fine-grained with a handful of off-grid values used for pixel-perfect icon alignment
- **Section padding**: ~4864px top/bottom on desktop, 2432px on mobile
- **Card internal padding**: 24px on booking panels and large cards, 16px on amenity rows, 12px on listing-card metadata
- **Gutter between listing cards**: 24px desktop, 16px mobile
- **Between stacked text rows**: 48px (very tight — reinforces the "dense information" feel of travel listings)
### Grid & Container
- **Max content width**: 17601920px on ultra-wide (Airbnb lets the grid breathe farther than most sites); 1280px on most detail pages
- **Homepage listing grid**: 6 columns at ≥1760px, 5 at ≥1440px, 4 at ≥1128px, 3 at ≥800px, 2 at ≥550px, 1 below
- **Detail page**: 2-column asymmetric — main content ~58%, sticky booking panel ~36% on the right, ~6% gutter
- **Footer**: 3-column Support / Hosting / Airbnb
### Whitespace Philosophy
Airbnb is densely informative but never cramped. Whitespace is used to *group* — listing cards have 24px of gutter so each photograph reads as a distinct object, but the metadata under each card uses 48px gaps so the price/city/date feels like a single unit. The detail-page booking panel has 24px internal padding, but rows within (date picker, guest selector, CTA) are stacked at 12px — the boundary between the card and the page does more separation work than the content within.
### Border Radius Scale
| Radius | Use |
|--------|-----|
| 4px | Inline anchor tags, tag chips |
| 8px | Text buttons, dropdowns, small utility buttons |
| 14px | Listing card photography, generic content containers, badges |
| 20px | Primary rounded buttons (pill shape), large images, booking panel |
| 32px | Search bar pill, extra-large containers |
| 50% | All circular icon buttons, all avatars, wishlist hearts — the system's signature round geometry |
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| 0 | No shadow | Listing cards, body content, text-only sections |
| 1 | `rgba(0, 0, 0, 0.08) 0 4px 12px` | Active/pressed icon buttons (e.g., back, share, favorite) — subtle lift to indicate interaction |
| 2 | `rgba(0, 0, 0, 0.02) 0 0 0 1px, rgba(0, 0, 0, 0.04) 0 2px 6px 0, rgba(0, 0, 0, 0.1) 0 4px 8px 0` | Booking panel sticky card, modals, dropdown menus — the system's signature three-layer elevation |
| Focus Ring | `0 0 0 2px #222222` | Active-state buttons, focused search input |
| White Separator Ring | `rgb(255, 255, 255) 0 0 0 4px` | Circular buttons overlaid on photographs — a 4px white ring cleanly separates the button from colorful image backgrounds |
Shadow philosophy: Airbnb uses **stacked layered shadows** rather than a single drop. The three-layer booking-panel shadow reads as one cohesive lift but is actually three separate shadows at different opacity/blur values — creating subtle anti-aliasing at the shadow's perimeter that feels premium without being heavy.
### Decorative Depth
- **Photography as depth**: the system relies heavily on full-bleed photography to create visual depth; shadows and gradients are used sparingly so the photographs do the heavy lifting
- **Laurel wreath lockup**: the Guest Favorite award uses two SVG laurel illustrations that give the otherwise-flat rating number a ceremonial, trophy-like presence
- **3D rendered category icons**: Homes/Experiences/Services icons have their own soft internal lighting and subtle cast shadows baked into the artwork — the only place the brand allows "dimensional" illustration
## 7. Do's and Don'ts
### Do
- Reserve Rausch `#ff385c` for primary actions and the active-tab indicator — never dilute it with decorative uses.
- Let photography breathe — 4:3 crops with 1420px rounded corners, no overlaid text, no gradient scrims.
- Use Ink Black `#222222` for every text layer below Rausch — this is the system's near-black, never true `#000000`.
- Pair the tri-tab category picker's 3D illustrated icons with flat typography — don't mix illustration styles within a single surface.
- Stack three low-opacity shadows (~2%, 4%, 10%) to create the signature booking-panel elevation.
- Use Hairline Gray `#dddddd` 1px borders for every card-to-card and row-to-row divider.
- Treat the booking panel as sticky on desktop, collapsing to a bottom-anchored reserve bar on mobile.
- Use 48px spacing within metadata groups and 24px between cards — information density is intentional.
### Don't
- Don't introduce secondary accent colors outside the Rausch / Plus Magenta / Luxe Purple product-tier palette.
- Don't place text inside photographs — captions always sit below the image, never overlaid.
- Don't use all-caps labels except the single 8px superscript role.
- Don't round icon buttons to anything other than 50% — circular is the system's signature geometry.
- Don't add drop shadows to listing cards — they sit on white canvas with no elevation.
- Don't use gradient backgrounds — the only gradient in the system is a narrow Rausch → magenta sweep on the wordmark.
- Don't use the 400-regular font weight — Airbnb Cereal's body weight is 500.
- Don't override Airbnb Cereal VF with a different display face — the system is intentionally single-family.
## 8. Responsive Behavior
### Breakpoints
Airbnb declares ~60 breakpoints (design-time artifact from their component library), but the meaningful layout shifts happen at a much smaller set:
| Name | Width | Key Changes |
|------|-------|-------------|
| Ultra-wide | ≥1760px | 6-column listing grid, 17601920px max content width |
| Desktop XL | 14401759px | 5-column grid, full nav visible, sticky right-rail booking panel |
| Desktop | 11281439px | 4-column grid, sticky booking panel persists |
| Laptop | 10241127px | 34 column grid, category nav remains horizontal |
| Tablet | 8001023px | 3-column grid, global search may collapse to a single-row pill |
| Small tablet | 550799px | 2-column grid, booking panel drops to full-width inline block |
| Mobile | 375549px | 1-column stacked layout, bottom-fixed tab bar appears (Explore / Wishlists / Log in) |
| Small mobile | <375px | Edge padding tightens to 16px; category-picker icons shrink to ~28px |
### Touch Targets
All interactive elements meet or exceed 44×44px. The circular icon button family is specifically sized 3244px with 812px extended hit-area padding. The Rausch primary Reserve button is ~48px tall. The tri-tab category picker's hit area is the full label-plus-icon rectangle (typically ~64×80px per tab).
### Collapsing Strategy
- **Nav**: Top nav keeps Airbnb wordmark + tri-tab picker on tablet and above; on mobile the picker slides just below the search pill, and the globe/avatar controls move to a bottom-anchored tab bar.
- **Search bar**: Three-segment pill (Where / When / Who) with a Rausch circular submit button on desktop; collapses to a single-row "Start your search" pill on mobile, tapping which opens a full-screen search sheet.
- **Booking panel**: Sticky right-rail on ≥1128px; inline within the main content column between 8001127px; bottom-fixed "Reserve" pill on <800px.
- **Listing grid**: Reflows 6 → 5 → 4 → 3 → 2 → 1 columns across breakpoints.
- **Detail-page image grid**: Five-image layout (1 large + 4 small) on desktop; becomes a swipeable full-bleed carousel on mobile with page-dot indicators.
- **Footer**: 3-column layout collapses to stacked single-column at <800px.
### Image Behavior
- `loading="lazy"` universal, with blurred `im_w=` URL-parameterized preview thumbs served first
- Responsive images use Airbnb's `muscache.com` CDN with `im_w` query parameter for width-based delivery (`im_w=240`, `im_w=720`, `im_w=1200`, `im_w=2400`)
- No art-direction crops — the same image is scaled up/down across breakpoints
- Carousels auto-advance photo height to maintain a consistent 4:3 ratio regardless of source aspect
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: "Rausch (#ff385c)"
- Page background: "Canvas White (#ffffff)"
- Subsurface: "Soft Cloud (#f7f7f7)"
- Heading / body text: "Ink Black (#222222)"
- Secondary text: "Ash Gray (#6a6a6a)"
- Border / divider: "Hairline Gray (#dddddd)"
- Error: "Error Red (#c13515)"
- Info link: "Info Blue (#428bff)"
- Luxe tier accent: "Luxe Purple (#460479)"
- Plus tier accent: "Plus Magenta (#92174d)"
### Example Component Prompts
- "Create a primary Reserve button: Rausch (#ff385c) background, white Airbnb Cereal 500-weight label at 16px, 14px × 24px padding, 8px border-radius, no shadow. On active/pressed add `transform: scale(0.92)` with a 2px Ink Black focus ring (`0 0 0 2px #222222`)."
- "Build a listing card with a 4:3 full-bleed photograph at 14px border-radius, no container shadow; below the image stack three text rows with 4px gaps: city name at 16px 600 Ink Black, rental type at 14px 500 Ash Gray (#6a6a6a), and price range in 16px 500 Ink Black with a 14px `per night` suffix."
- "Design a sticky booking panel: white background, 14px border-radius, 1px Hairline Gray (#dddddd) border, 3-layer elevation shadow (`rgba(0,0,0,0.02) 0 0 0 1px, rgba(0,0,0,0.04) 0 2px 6px 0, rgba(0,0,0,0.1) 0 4px 8px 0`), 24px padding, 370px width, pinned 120px below viewport top on desktop. Contents: price headline, date picker, guest dropdown, Rausch primary CTA, and a 12px Ash Gray `You won't be charged yet` disclaimer."
- "Create a tri-tab category picker: three equal-width tabs labeled Homes, Experiences, Services; each tab has a ~48px 3D-rendered illustrated icon (house, balloon, bell) above a 16px 500 Ink Black label; active tab gets a 2px Ink Black underline; add a small 12px 700 white `NEW` pill on a dark navy background to the top-right of the Experiences and Services icons."
- "Render the Guest Favorite award lockup: a centered rating number at 52px 700-weight Ink Black, flanked left and right by hand-drawn SVG laurel wreaths at ~48px tall; below, a 12px 700 uppercase `GUEST FAVORITE` label with 0.32px tracking; sub-label at 14px 500 Ash Gray; full-width block sitting directly on white canvas with no container border."
### Iteration Guide
When refining existing screens generated with this design system:
1. Focus on ONE component at a time.
2. Reference specific color names and hex codes from this document (e.g., "Ink Black #222222", not "dark gray").
3. Use natural language descriptions alongside measurements ("subtle three-layer elevation" rather than a long shadow string).
4. Describe the desired "feel" ("magazine-like, photography-first" vs "dense utility").
5. Always default to Airbnb Cereal VF 500-weight for body and 600700 for emphasis — never 400.
6. Keep Rausch pink scarce — if more than one Rausch-colored element appears per viewport, consider whether one should be neutralized.
### Known Gaps
- **Homepage listing grid cards**: the main property-card grid (the primary visual surface of airbnb.com) was not fully captured in the extracted homepage screenshots — content loaded only partially. Listing Card specs above are inferred from the Inspiration grid structure and Airbnb's broader conventions; confirm exact aspect ratios and metadata hierarchy against the live site before production use.
- **Experiences category icons**: the 3D illustrated icons for Homes / Experiences / Services are served as raster assets; their exact source-file specifications (SVG vs PNG, rendered pixel dimensions) are not documented here.
- **Animation and transition timings**: not captured — static extraction scope.
- **Dark mode**: Airbnb does not ship a native dark mode in the extracted product surfaces; this document describes the single light-mode theme only.

View file

@ -0,0 +1,68 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "design-system-airbnb",
"title": "Airbnb",
"version": "0.1.0",
"description": "Travel marketplace. Warm coral accent, photography-driven, rounded UI.",
"license": "MIT",
"tags": [
"design-system",
"first-party",
"design",
"e-commerce-retail"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "design-system",
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Airbnb design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "select",
"options": [
"landing page",
"dashboard",
"marketing site",
"app screen"
],
"default": "landing page"
},
{
"name": "brief",
"label": "Brief",
"type": "text",
"placeholder": "What should the page communicate?"
}
],
"context": {
"designSystem": {
"ref": "airbnb",
"primary": true
},
"assets": [
"./DESIGN.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,315 @@
# Design System Inspired by Claude (Anthropic)
> Category: AI & LLM
> Anthropic's AI assistant. Warm terracotta accent, clean editorial layout.
## 1. Visual Theme & Atmosphere
Claude's interface is a literary salon reimagined as a product page — warm, unhurried, and quietly intellectual. The entire experience is built on a parchment-toned canvas (`#f5f4ed`) that deliberately evokes the feeling of high-quality paper rather than a digital surface. Where most AI product pages lean into cold, futuristic aesthetics, Claude's design radiates human warmth, as if the AI itself has good taste in interior design.
The signature move is the custom Anthropic Serif typeface — a medium-weight serif with generous proportions that gives every headline the gravitas of a book title. Combined with organic, hand-drawn-feeling illustrations in terracotta (`#c96442`), black, and muted green, the visual language says "thoughtful companion" rather than "powerful tool." The serif headlines breathe at tight-but-comfortable line-heights (1.101.30), creating a cadence that feels more like reading an essay than scanning a product page.
What makes Claude's design truly distinctive is its warm neutral palette. Every gray has a yellow-brown undertone (`#5e5d59`, `#87867f`, `#4d4c48`) — there are no cool blue-grays anywhere. Borders are cream-tinted (`#f0eee6`, `#e8e6dc`), shadows use warm transparent blacks, and even the darkest surfaces (`#141413`, `#30302e`) carry a barely perceptible olive warmth. This chromatic consistency creates a space that feels lived-in and trustworthy.
**Key Characteristics:**
- Warm parchment canvas (`#f5f4ed`) evoking premium paper, not screens
- Custom Anthropic type family: Serif for headlines, Sans for UI, Mono for code
- Terracotta brand accent (`#c96442`) — warm, earthy, deliberately un-tech
- Exclusively warm-toned neutrals — every gray has a yellow-brown undertone
- Organic, editorial illustrations replacing typical tech iconography
- Ring-based shadow system (`0px 0px 0px 1px`) creating border-like depth without visible borders
- Magazine-like pacing with generous section spacing and serif-driven hierarchy
## 2. Color Palette & Roles
### Primary
- **Anthropic Near Black** (`#141413`): The primary text color and dark-theme surface — not pure black but a warm, almost olive-tinted dark that's gentler on the eyes. The warmest "black" in any major tech brand.
- **Terracotta Brand** (`#c96442`): The core brand color — a burnt orange-brown used for primary CTA buttons, brand moments, and the signature accent. Deliberately earthy and un-tech.
- **Coral Accent** (`#d97757`): A lighter, warmer variant of the brand color used for text accents, links on dark surfaces, and secondary emphasis.
### Secondary & Accent
- **Error Crimson** (`#b53333`): A deep, warm red for error states — serious without being alarming.
- **Focus Blue** (`#3898ec`): Standard blue for input focus rings — the only cool color in the entire system, used purely for accessibility.
### Surface & Background
- **Parchment** (`#f5f4ed`): The primary page background — a warm cream with a yellow-green tint that feels like aged paper. The emotional foundation of the entire design.
- **Ivory** (`#faf9f5`): The lightest surface — used for cards and elevated containers on the Parchment background. Barely distinguishable but creates subtle layering.
- **Pure White** (`#ffffff`): Reserved for specific button surfaces and maximum-contrast elements.
- **Warm Sand** (`#e8e6dc`): Button backgrounds and prominent interactive surfaces — a noticeably warm light gray.
- **Dark Surface** (`#30302e`): Dark-theme containers, nav borders, and elevated dark elements — warm charcoal.
- **Deep Dark** (`#141413`): Dark-theme page background and primary dark surface.
### Neutrals & Text
- **Charcoal Warm** (`#4d4c48`): Button text on light warm surfaces — the go-to dark-on-light text.
- **Olive Gray** (`#5e5d59`): Secondary body text — a distinctly warm medium-dark gray.
- **Stone Gray** (`#87867f`): Tertiary text, footnotes, and de-emphasized metadata.
- **Dark Warm** (`#3d3d3a`): Dark text links and emphasized secondary text.
- **Warm Silver** (`#b0aea5`): Text on dark surfaces — a warm, parchment-tinted light gray.
### Semantic & Accent
- **Border Cream** (`#f0eee6`): Standard light-theme border — barely visible warm cream, creating the gentlest possible containment.
- **Border Warm** (`#e8e6dc`): Prominent borders, section dividers, and emphasized containment on light surfaces.
- **Border Dark** (`#30302e`): Standard border on dark surfaces — maintains the warm tone.
- **Ring Warm** (`#d1cfc5`): Shadow ring color for button hover/focus states.
- **Ring Subtle** (`#dedc01`): Secondary ring variant for lighter interactive surfaces.
- **Ring Deep** (`#c2c0b6`): Deeper ring for active/pressed states.
### Gradient System
- Claude's design is **gradient-free** in the traditional sense. Depth and visual richness come from the interplay of warm surface tones, organic illustrations, and light/dark section alternation. The warm palette itself creates a "gradient" effect as the eye moves through cream → sand → stone → charcoal → black sections.
## 3. Typography Rules
### Font Family
- **Headline**: `Anthropic Serif`, with fallback: `Georgia`
- **Body / UI**: `Anthropic Sans`, with fallback: `Arial`
- **Code**: `Anthropic Mono`, with fallback: `Arial`
*Note: These are custom typefaces. For external implementations, Georgia serves as the serif substitute and system-ui/Inter as the sans substitute.*
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display / Hero | Anthropic Serif | 64px (4rem) | 500 | 1.10 (tight) | normal | Maximum impact, book-title presence |
| Section Heading | Anthropic Serif | 52px (3.25rem) | 500 | 1.20 (tight) | normal | Feature section anchors |
| Sub-heading Large | Anthropic Serif | 3636.8px (~2.3rem) | 500 | 1.30 | normal | Secondary section markers |
| Sub-heading | Anthropic Serif | 32px (2rem) | 500 | 1.10 (tight) | normal | Card titles, feature names |
| Sub-heading Small | Anthropic Serif | 2525.6px (~1.6rem) | 500 | 1.20 | normal | Smaller section titles |
| Feature Title | Anthropic Serif | 20.8px (1.3rem) | 500 | 1.20 | normal | Small feature headings |
| Body Serif | Anthropic Serif | 17px (1.06rem) | 400 | 1.60 (relaxed) | normal | Serif body text (editorial passages) |
| Body Large | Anthropic Sans | 20px (1.25rem) | 400 | 1.60 (relaxed) | normal | Intro paragraphs |
| Body / Nav | Anthropic Sans | 17px (1.06rem) | 400500 | 1.001.60 | normal | Navigation links, UI text |
| Body Standard | Anthropic Sans | 16px (1rem) | 400500 | 1.251.60 | normal | Standard body, button text |
| Body Small | Anthropic Sans | 15px (0.94rem) | 400500 | 1.001.60 | normal | Compact body text |
| Caption | Anthropic Sans | 14px (0.88rem) | 400 | 1.43 | normal | Metadata, descriptions |
| Label | Anthropic Sans | 12px (0.75rem) | 400500 | 1.251.60 | 0.12px | Badges, small labels |
| Overline | Anthropic Sans | 10px (0.63rem) | 400 | 1.60 | 0.5px | Uppercase overline labels |
| Micro | Anthropic Sans | 9.6px (0.6rem) | 400 | 1.60 | 0.096px | Smallest text |
| Code | Anthropic Mono | 15px (0.94rem) | 400 | 1.60 | -0.32px | Inline code, terminal |
### Principles
- **Serif for authority, sans for utility**: Anthropic Serif carries all headline content with medium weight (500), giving every heading the gravitas of a published title. Anthropic Sans handles all functional UI text — buttons, labels, navigation — with quiet efficiency.
- **Single weight for serifs**: All Anthropic Serif headings use weight 500 — no bold, no light. This creates a consistent "voice" across all headline sizes, as if the same author wrote every heading.
- **Relaxed body line-height**: Most body text uses 1.60 line-height — significantly more generous than typical tech sites (1.41.5). This creates a reading experience closer to a book than a dashboard.
- **Tight-but-not-compressed headings**: Line-heights of 1.101.30 for headings are tight but never claustrophobic. The serif letterforms need breathing room that sans-serif fonts don't.
- **Micro letter-spacing on labels**: Small sans text (12px and below) uses deliberate letter-spacing (0.12px0.5px) to maintain readability at tiny sizes.
## 4. Component Stylings
### Buttons
**Warm Sand (Secondary)**
- Background: Warm Sand (`#e8e6dc`)
- Text: Charcoal Warm (`#4d4c48`)
- Padding: 0px 12px 0px 8px (asymmetric — icon-first layout)
- Radius: comfortably rounded (8px)
- Shadow: ring-based (`#e8e6dc 0px 0px 0px 0px, #d1cfc5 0px 0px 0px 1px`)
- The workhorse button — warm, unassuming, clearly interactive
**White Surface**
- Background: Pure White (`#ffffff`)
- Text: Anthropic Near Black (`#141413`)
- Padding: 8px 16px 8px 12px
- Radius: generously rounded (12px)
- Hover: shifts to secondary background color
- Clean, elevated button for light surfaces
**Dark Charcoal**
- Background: Dark Surface (`#30302e`)
- Text: Ivory (`#faf9f5`)
- Padding: 0px 12px 0px 8px
- Radius: comfortably rounded (8px)
- Shadow: ring-based (`#30302e 0px 0px 0px 0px, ring 0px 0px 0px 1px`)
- The inverted variant for dark-on-light emphasis
**Brand Terracotta**
- Background: Terracotta Brand (`#c96442`)
- Text: Ivory (`#faf9f5`)
- Radius: 812px
- Shadow: ring-based (`#c96442 0px 0px 0px 0px, #c96442 0px 0px 0px 1px`)
- The primary CTA — the only button with chromatic color
**Dark Primary**
- Background: Anthropic Near Black (`#141413`)
- Text: Warm Silver (`#b0aea5`)
- Padding: 9.6px 16.8px
- Radius: generously rounded (12px)
- Border: thin solid Dark Surface (`1px solid #30302e`)
- Used on dark theme surfaces
### Cards & Containers
- Background: Ivory (`#faf9f5`) or Pure White (`#ffffff`) on light surfaces; Dark Surface (`#30302e`) on dark
- Border: thin solid Border Cream (`1px solid #f0eee6`) on light; `1px solid #30302e` on dark
- Radius: comfortably rounded (8px) for standard cards; generously rounded (16px) for featured; very rounded (32px) for hero containers and embedded media
- Shadow: whisper-soft (`rgba(0,0,0,0.05) 0px 4px 24px`) for elevated content
- Ring shadow: `0px 0px 0px 1px` patterns for interactive card states
- Section borders: `1px 0px 0px` (top-only) for list item separators
### Inputs & Forms
- Text: Anthropic Near Black (`#141413`)
- Padding: 1.6px 12px (very compact vertical)
- Border: standard warm borders
- Focus: ring with Focus Blue (`#3898ec`) border-color — the only cool color moment
- Radius: generously rounded (12px)
### Navigation
- Sticky top nav with warm background
- Logo: Claude wordmark in Anthropic Near Black
- Links: mix of Near Black (`#141413`), Olive Gray (`#5e5d59`), and Dark Warm (`#3d3d3a`)
- Nav border: `1px solid #30302e` (dark) or `1px solid #f0eee6` (light)
- CTA: Terracotta Brand button or White Surface button
- Hover: text shifts to foreground-primary, no decoration
### Image Treatment
- Product screenshots showing the Claude chat interface
- Generous border-radius on media (1632px)
- Embedded video players with rounded corners
- Dark UI screenshots provide contrast against warm light canvas
- Organic, hand-drawn illustrations for conceptual sections
### Distinctive Components
**Model Comparison Cards**
- Opus 4.5, Sonnet 4.5, Haiku 4.5 presented in a clean card grid
- Each model gets a bordered card with name, description, and capability badges
- Border Warm (`#e8e6dc`) separation between items
**Organic Illustrations**
- Hand-drawn-feeling vector illustrations in terracotta, black, and muted green
- Abstract, conceptual rather than literal product diagrams
- The primary visual personality — no other AI company uses this style
**Dark/Light Section Alternation**
- The page alternates between Parchment light and Near Black dark sections
- Creates a reading rhythm like chapters in a book
- Each section feels like a distinct environment
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 3px, 4px, 6px, 8px, 10px, 12px, 16px, 20px, 24px, 30px
- Button padding: asymmetric (0px 12px 0px 8px) or balanced (8px 16px)
- Card internal padding: approximately 2432px
- Section vertical spacing: generous (estimated 80120px between major sections)
### Grid & Container
- Max container width: approximately 1200px, centered
- Hero: centered with editorial layout
- Feature sections: single-column or 23 column card grids
- Model comparison: clean 3-column grid
- Full-width dark sections breaking the container for emphasis
### Whitespace Philosophy
- **Editorial pacing**: Each section breathes like a magazine spread — generous top/bottom margins create natural reading pauses.
- **Serif-driven rhythm**: The serif headings establish a literary cadence that demands more whitespace than sans-serif designs.
- **Content island approach**: Sections alternate between light and dark environments, creating distinct "rooms" for each message.
### Border Radius Scale
- Sharp (4px): Minimal inline elements
- Subtly rounded (67.5px): Small buttons, secondary interactive elements
- Comfortably rounded (88.5px): Standard buttons, cards, containers
- Generously rounded (12px): Primary buttons, input fields, nav elements
- Very rounded (16px): Featured containers, video players, tab lists
- Highly rounded (24px): Tag-like elements, highlighted containers
- Maximum rounded (32px): Hero containers, embedded media, large cards
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow, no border | Parchment background, inline text |
| Contained (Level 1) | `1px solid #f0eee6` (light) or `1px solid #30302e` (dark) | Standard cards, sections |
| Ring (Level 2) | `0px 0px 0px 1px` ring shadows using warm grays | Interactive cards, buttons, hover states |
| Whisper (Level 3) | `rgba(0,0,0,0.05) 0px 4px 24px` | Elevated feature cards, product screenshots |
| Inset (Level 4) | `inset 0px 0px 0px 1px` at 15% opacity | Active/pressed button states |
**Shadow Philosophy**: Claude communicates depth through **warm-toned ring shadows** rather than traditional drop shadows. The signature `0px 0px 0px 1px` pattern creates a border-like halo that's softer than an actual border — it's a shadow pretending to be a border, or a border that's technically a shadow. When drop shadows do appear, they're extremely soft (0.05 opacity, 24px blur) — barely visible lifts that suggest floating rather than casting.
### Decorative Depth
- **Light/Dark alternation**: The most dramatic depth effect comes from alternating between Parchment (`#f5f4ed`) and Near Black (`#141413`) sections — entire sections shift elevation by changing the ambient light level.
- **Warm ring halos**: Button and card interactions use ring shadows that match the warm palette — never cool-toned or generic gray.
## 7. Do's and Don'ts
### Do
- Use Parchment (`#f5f4ed`) as the primary light background — the warm cream tone IS the Claude personality
- Use Anthropic Serif at weight 500 for all headlines — the single-weight consistency is intentional
- Use Terracotta Brand (`#c96442`) only for primary CTAs and the highest-signal brand moments
- Keep all neutrals warm-toned — every gray should have a yellow-brown undertone
- Use ring shadows (`0px 0px 0px 1px`) for interactive element states instead of drop shadows
- Maintain the editorial serif/sans hierarchy — serif for content headlines, sans for UI
- Use generous body line-height (1.60) for a literary reading experience
- Alternate between light and dark sections to create chapter-like page rhythm
- Apply generous border-radius (1232px) for a soft, approachable feel
### Don't
- Don't use cool blue-grays anywhere — the palette is exclusively warm-toned
- Don't use bold (700+) weight on Anthropic Serif — weight 500 is the ceiling for serifs
- Don't introduce saturated colors beyond Terracotta — the palette is deliberately muted
- Don't use sharp corners (< 6px radius) on buttons or cards softness is core to the identity
- Don't apply heavy drop shadows — depth comes from ring shadows and background color shifts
- Don't use pure white (`#ffffff`) as a page background — Parchment (`#f5f4ed`) or Ivory (`#faf9f5`) are always warmer
- Don't use geometric/tech-style illustrations — Claude's illustrations are organic and hand-drawn-feeling
- Don't reduce body line-height below 1.40 — the generous spacing supports the editorial personality
- Don't use monospace fonts for non-code content — Anthropic Mono is strictly for code
- Don't mix in sans-serif for headlines — the serif/sans split is the typographic identity
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Small Mobile | <479px | Minimum layout, stacked everything, compact typography |
| Mobile | 479640px | Single column, hamburger nav, reduced heading sizes |
| Large Mobile | 640767px | Slightly wider content area |
| Tablet | 768991px | 2-column grids begin, condensed nav |
| Desktop | 992px+ | Full multi-column layout, expanded nav, maximum hero typography (64px) |
### Touch Targets
- Buttons use generous padding (816px vertical minimum)
- Navigation links adequately spaced for thumb navigation
- Card surfaces serve as large touch targets
- Minimum recommended: 44x44px
### Collapsing Strategy
- **Navigation**: Full horizontal nav collapses to hamburger on mobile
- **Feature sections**: Multi-column → stacked single column
- **Hero text**: 64px → 36px → ~25px progressive scaling
- **Model cards**: 3-column → stacked vertical
- **Section padding**: Reduces proportionally but maintains editorial rhythm
- **Illustrations**: Scale proportionally, maintain aspect ratios
### Image Behavior
- Product screenshots scale proportionally within rounded containers
- Illustrations maintain quality at all sizes
- Video embeds maintain 16:9 aspect ratio with rounded corners
- No art direction changes between breakpoints
## 9. Agent Prompt Guide
### Quick Color Reference
- Brand CTA: "Terracotta Brand (#c96442)"
- Page Background: "Parchment (#f5f4ed)"
- Card Surface: "Ivory (#faf9f5)"
- Primary Text: "Anthropic Near Black (#141413)"
- Secondary Text: "Olive Gray (#5e5d59)"
- Tertiary Text: "Stone Gray (#87867f)"
- Borders (light): "Border Cream (#f0eee6)"
- Dark Surface: "Dark Surface (#30302e)"
### Example Component Prompts
- "Create a hero section on Parchment (#f5f4ed) with a headline at 64px Anthropic Serif weight 500, line-height 1.10. Use Anthropic Near Black (#141413) text. Add a subtitle in Olive Gray (#5e5d59) at 20px Anthropic Sans with 1.60 line-height. Place a Terracotta Brand (#c96442) CTA button with Ivory text, 12px radius."
- "Design a feature card on Ivory (#faf9f5) with a 1px solid Border Cream (#f0eee6) border and comfortably rounded corners (8px). Title in Anthropic Serif at 25px weight 500, description in Olive Gray (#5e5d59) at 16px Anthropic Sans. Add a whisper shadow (rgba(0,0,0,0.05) 0px 4px 24px)."
- "Build a dark section on Anthropic Near Black (#141413) with Ivory (#faf9f5) headline text in Anthropic Serif at 52px weight 500. Use Warm Silver (#b0aea5) for body text. Borders in Dark Surface (#30302e)."
- "Create a button in Warm Sand (#e8e6dc) with Charcoal Warm (#4d4c48) text, 8px radius, and a ring shadow (0px 0px 0px 1px #d1cfc5). Padding: 0px 12px 0px 8px."
- "Design a model comparison grid with three cards on Ivory surfaces. Each card gets a Border Warm (#e8e6dc) top border, model name in Anthropic Serif at 25px, and description in Olive Gray at 15px Anthropic Sans."
### Iteration Guide
1. Focus on ONE component at a time
2. Reference specific color names — "use Olive Gray (#5e5d59)" not "make it gray"
3. Always specify warm-toned variants — no cool grays
4. Describe serif vs sans usage explicitly — "Anthropic Serif for the heading, Anthropic Sans for the label"
5. For shadows, use "ring shadow (0px 0px 0px 1px)" or "whisper shadow" — never generic "drop shadow"
6. Specify the warm background — "on Parchment (#f5f4ed)" or "on Near Black (#141413)"
7. Keep illustrations organic and conceptual — describe "hand-drawn-feeling" style

View file

@ -0,0 +1,68 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "design-system-claude",
"title": "Claude (Anthropic)",
"version": "0.1.0",
"description": "Anthropic's AI assistant. Warm terracotta accent, clean editorial layout.",
"license": "MIT",
"tags": [
"design-system",
"first-party",
"design",
"ai-llm"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "design-system",
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Claude (Anthropic) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "select",
"options": [
"landing page",
"dashboard",
"marketing site",
"app screen"
],
"default": "landing page"
},
{
"name": "brief",
"label": "Brief",
"type": "text",
"placeholder": "What should the page communicate?"
}
],
"context": {
"designSystem": {
"ref": "claude",
"primary": true
},
"assets": [
"./DESIGN.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,370 @@
# Design System Inspired by Linear
> Category: Productivity & SaaS
> Project management. Ultra-minimal, precise, purple accent.
## 1. Visual Theme & Atmosphere
Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation.
The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo.
The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight.
**Key Characteristics:**
- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces
- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic
- Signature weight 510 (between regular and medium) for most UI text
- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system
- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`
- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
- Multi-layered shadows with inset variants for depth on dark surfaces
- Radix UI primitives as the component foundation (6 detected primitives)
- Success green (`#27a644`, `#10b981`) used only for status indicators
## 2. Color Palette & Roles
### Background Surfaces
- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone.
- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black.
- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns.
- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components.
### Text & Content
- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds.
- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content.
- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content.
- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels.
### Brand & Accent
- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces.
- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items.
- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements.
- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements.
### Status Colors
- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators.
- **Emerald** (`#10b981`): Secondary success — pill badges, completion states.
### Border & Divider
- **Border Primary** (`#23252a`): Solid dark border for prominent separations.
- **Border Secondary** (`#34343a`): Slightly lighter solid border.
- **Border Tertiary** (`#3e3e44`): Lightest solid border variant.
- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default.
- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks.
- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions.
- **Line Tertiary** (`#18191a`): Slightly more visible divider line.
### Light Mode Neutrals (for light theme contexts)
- **Light Background** (`#f7f8f8`): Page background in light mode.
- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting.
- **Light Border** (`#d0d6e0`): Visible border in light contexts.
- **Light Border Alt** (`#e6e6e6`): Alternative lighter border.
- **Pure White** (`#ffffff`): Card surfaces, highlights.
### Overlay
- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation.
## 3. Typography Rules
### Font Family
- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue`
- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo`
- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact |
| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text |
| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines |
| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles |
| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings |
| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers |
| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions |
| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content |
| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels |
| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis |
| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text |
| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text |
| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text |
| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body |
| Caption Large | Inter Variable | 14px (0.88rem) | 510590 | 1.50 | -0.182px | Sub-labels, category headers |
| Caption | Inter Variable | 13px (0.81rem) | 400510 | 1.50 | -0.13px | Metadata, timestamps |
| Label | Inter Variable | 12px (0.75rem) | 400590 | 1.40 | normal | Button text, small labels |
| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels |
| Tiny | Inter Variable | 10px (0.63rem) | 400510 | 1.50 | -0.15px | Overline text, sometimes uppercase |
| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links |
| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links |
| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links |
| Link Caption | Inter Variable | 13px (0.81rem) | 400510 | 1.50 | -0.13px | Footer, metadata links |
| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks |
| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels |
| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase |
### Principles
- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold.
- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal.
- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character.
- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts.
## 4. Component Stylings
### Buttons
**Ghost Button (Default)**
- Background: `rgba(255,255,255,0.02)`
- Text: `#e2e4e7` (near-white)
- Padding: comfortable
- Radius: 6px
- Border: `1px solid rgb(36, 40, 44)`
- Outline: none
- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px`
- Use: Standard actions, secondary CTAs
**Subtle Button**
- Background: `rgba(255,255,255,0.04)`
- Text: `#d0d6e0` (silver-gray)
- Padding: 0px 6px
- Radius: 6px
- Use: Toolbar actions, contextual buttons
**Primary Brand Button (Inferred)**
- Background: `#5e6ad2` (brand indigo)
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 6px
- Hover: `#828fff` shift
- Use: Primary CTAs ("Start building", "Sign up")
**Icon Button (Circle)**
- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)`
- Text: `#f7f8f8` or `#ffffff`
- Radius: 50%
- Border: `1px solid rgba(255,255,255,0.08)`
- Use: Close, menu toggle, icon-only actions
**Pill Button**
- Background: transparent
- Text: `#d0d6e0`
- Padding: 0px 10px 0px 5px
- Radius: 9999px
- Border: `1px solid rgb(35, 37, 42)`
- Use: Filter chips, tags, status indicators
**Small Toolbar Button**
- Background: `rgba(255,255,255,0.05)`
- Text: `#62666d` (muted)
- Radius: 2px
- Border: `1px solid rgba(255,255,255,0.05)`
- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px`
- Font: 12px weight 510
- Use: Toolbar actions, quick-access controls
### Cards & Containers
- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent)
- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle)
- Radius: 8px (standard), 12px (featured), 22px (large panels)
- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks
- Hover: subtle background opacity increase
### Inputs & Forms
**Text Area**
- Background: `rgba(255,255,255,0.02)`
- Text: `#d0d6e0`
- Border: `1px solid rgba(255,255,255,0.08)`
- Padding: 12px 14px
- Radius: 6px
**Search Input**
- Background: transparent
- Text: `#f7f8f8`
- Padding: 1px 32px (icon-aware)
**Button-style Input**
- Text: `#8a8f98`
- Padding: 1px 6px
- Radius: 5px
- Focus shadow: multi-layer stack
### Badges & Pills
**Success Pill**
- Background: `#10b981`
- Text: `#f7f8f8`
- Radius: 50% (circular)
- Font: 10px weight 510
- Use: Status dots, completion indicators
**Neutral Pill**
- Background: transparent
- Text: `#d0d6e0`
- Padding: 0px 10px 0px 5px
- Radius: 9999px
- Border: `1px solid rgb(35, 37, 42)`
- Font: 12px weight 510
- Use: Tags, filter chips, category labels
**Subtle Badge**
- Background: `rgba(255,255,255,0.05)`
- Text: `#f7f8f8`
- Padding: 0px 8px 0px 2px
- Radius: 2px
- Border: `1px solid rgba(255,255,255,0.05)`
- Font: 10px weight 510
- Use: Inline labels, version tags
### Navigation
- Dark sticky header on near-black background
- Linear logomark left-aligned (SVG icon)
- Links: Inter Variable 1314px weight 510, `#d0d6e0` text
- Active/hover: text lightens to `#f7f8f8`
- CTA: Brand indigo button or ghost button
- Mobile: hamburger collapse
- Search: command palette trigger (`/` or `Cmd+K`)
### Image Treatment
- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`)
- Top-rounded images: `12px 12px 0px 0px` radius
- Dashboard/issue previews dominate feature sections
- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px`
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px
- The 7px and 11px values suggest micro-adjustments for optical alignment
- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid)
### Grid & Container
- Max content width: approximately 1200px
- Hero: centered single-column with generous vertical padding
- Feature sections: 23 column grids for feature cards
- Full-width dark sections with internal max-width constraints
- Changelog: single-column timeline layout
### Whitespace Philosophy
- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it.
- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension.
- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation.
### Border Radius Scale
- Micro (2px): Inline badges, toolbar buttons, subtle tags
- Standard (4px): Small containers, list items
- Comfortable (6px): Buttons, inputs, functional elements
- Card (8px): Cards, dropdowns, popovers
- Panel (12px): Panels, featured cards, section containers
- Large (22px): Large panel elements
- Full Pill (9999px): Chips, filter pills, status tags
- Circle (50%): Icon buttons, avatars, status dots
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas |
| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation |
| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers |
| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows |
| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique |
| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns |
| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals |
| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements |
**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02` → `0.04``0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack.
## 7. Do's and Don'ts
### Do
- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity
- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight
- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces
- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders
- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only
- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh
- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg
### Don't
- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain
- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.020.05)
- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only
- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative
- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white
- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter
- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse
- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only
- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <600px | Single column, compact padding |
| Mobile | 600640px | Standard mobile layout |
| Tablet | 640768px | Two-column grids begin |
| Desktop Small | 7681024px | Full card grids, expanded padding |
| Desktop | 10241280px | Standard desktop, full navigation |
| Large Desktop | >1280px | Full layout, generous margins |
### Touch Targets
- Buttons use comfortable padding with 6px radius minimum
- Navigation links at 1314px with adequate spacing
- Pill tags have 10px horizontal padding for touch accessibility
- Icon buttons at 50% radius ensure circular, easy-to-tap targets
- Search trigger is prominently placed with generous hit area
### Collapsing Strategy
- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally
- Navigation: horizontal links + CTAs → hamburger menu at 768px
- Feature cards: 3-column → 2-column → single column stacked
- Product screenshots: maintain aspect ratio, may reduce padding
- Changelog: timeline maintains single-column through all sizes
- Footer: multi-column → stacked single column
- Section spacing: 80px+ → 48px on mobile
### Image Behavior
- Dashboard screenshots maintain border treatment at all sizes
- Hero visuals simplify on mobile (fewer floating UI elements)
- Product screenshots use responsive sizing with consistent radius
- Dark background ensures screenshots blend naturally at any viewport
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Brand Indigo (`#5e6ad2`)
- Page Background: Marketing Black (`#08090a`)
- Panel Background: Panel Dark (`#0f1011`)
- Surface: Level 3 (`#191a1b`)
- Heading text: Primary White (`#f7f8f8`)
- Body text: Silver Gray (`#d0d6e0`)
- Muted text: Tertiary Gray (`#8a8f98`)
- Subtle text: Quaternary Gray (`#62666d`)
- Accent: Violet (`#7170ff`)
- Accent Hover: Light Violet (`#828fff`)
- Border (default): `rgba(255,255,255,0.08)`
- Border (subtle): `rgba(255,255,255,0.05)`
- Focus ring: Multi-layer shadow stack
### Example Component Prompts
- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)."
- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px."
- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510."
- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`."
- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`."
### Iteration Guide
1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look
2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px
3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce)
4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark
5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale
6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds
7. Berkeley Mono for any code or technical content, Inter Variable for everything else

View file

@ -0,0 +1,68 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "design-system-linear-app",
"title": "Linear",
"version": "0.1.0",
"description": "Project management. Ultra-minimal, precise, purple accent.",
"license": "MIT",
"tags": [
"design-system",
"first-party",
"design",
"productivity-saas"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "design-system",
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Linear design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "select",
"options": [
"landing page",
"dashboard",
"marketing site",
"app screen"
],
"default": "landing page"
},
{
"name": "brief",
"label": "Brief",
"type": "text",
"placeholder": "What should the page communicate?"
}
],
"context": {
"designSystem": {
"ref": "linear-app",
"primary": true
},
"assets": [
"./DESIGN.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,312 @@
# Design System Inspired by Notion
> Category: Productivity & SaaS
> All-in-one workspace. Warm minimalism, serif headings, soft surfaces.
## 1. Visual Theme & Atmosphere
Notion's website embodies the philosophy of the tool itself: a blank canvas that gets out of your way. The design system is built on warm neutrals rather than cold grays, creating a distinctly approachable minimalism that feels like quality paper rather than sterile glass. The page canvas is pure white (`#ffffff`) but the text isn't pure black -- it's a warm near-black (`rgba(0,0,0,0.95)`) that softens the reading experience imperceptibly. The warm gray scale (`#f6f5f4`, `#31302e`, `#615d59`, `#a39e98`) carries subtle yellow-brown undertones, giving the interface a tactile, almost analog warmth.
The custom NotionInter font (a modified Inter) is the backbone of the system. At display sizes (64px), it uses aggressive negative letter-spacing (-2.125px), creating headlines that feel compressed and precise. The weight range is broader than typical systems: 400 for body, 500 for UI elements, 600 for semi-bold labels, and 700 for display headings. OpenType features `"lnum"` (lining numerals) and `"locl"` (localized forms) are enabled on larger text, adding typographic sophistication that rewards close reading.
What makes Notion's visual language distinctive is its border philosophy. Rather than heavy borders or shadows, Notion uses ultra-thin `1px solid rgba(0,0,0,0.1)` borders -- borders that exist as whispers, barely perceptible division lines that create structure without weight. The shadow system is equally restrained: multi-layer stacks with cumulative opacity never exceeding 0.05, creating depth that's felt rather than seen.
**Key Characteristics:**
- NotionInter (modified Inter) with negative letter-spacing at display sizes (-2.125px at 64px)
- Warm neutral palette: grays carry yellow-brown undertones (`#f6f5f4` warm white, `#31302e` warm dark)
- Near-black text via `rgba(0,0,0,0.95)` -- not pure black, creating micro-warmth
- Ultra-thin borders: `1px solid rgba(0,0,0,0.1)` throughout -- whisper-weight division
- Multi-layer shadow stacks with sub-0.05 opacity for barely-there depth
- Notion Blue (`#0075de`) as the singular accent color for CTAs and interactive elements
- Pill badges (9999px radius) with tinted blue backgrounds for status indicators
- 8px base spacing unit with an organic, non-rigid scale
## 2. Color Palette & Roles
### Primary
- **Notion Black** (`rgba(0,0,0,0.95)` / `#000000f2`): Primary text, headings, body copy. The 95% opacity softens pure black without sacrificing readability.
- **Pure White** (`#ffffff`): Page background, card surfaces, button text on blue.
- **Notion Blue** (`#0075de`): Primary CTA, link color, interactive accent -- the only saturated color in the core UI chrome.
### Brand Secondary
- **Deep Navy** (`#213183`): Secondary brand color, used sparingly for emphasis and dark feature sections.
- **Active Blue** (`#005bab`): Button active/pressed state -- darker variant of Notion Blue.
### Warm Neutral Scale
- **Warm White** (`#f6f5f4`): Background surface tint, section alternation, subtle card fill. The yellow undertone is key.
- **Warm Dark** (`#31302e`): Dark surface background, dark section text. Warmer than standard grays.
- **Warm Gray 500** (`#615d59`): Secondary text, descriptions, muted labels.
- **Warm Gray 300** (`#a39e98`): Placeholder text, disabled states, caption text.
### Semantic Accent Colors
- **Teal** (`#2a9d99`): Success states, positive indicators.
- **Green** (`#1aae39`): Confirmation, completion badges.
- **Orange** (`#dd5b00`): Warning states, attention indicators.
- **Pink** (`#ff64c8`): Decorative accent, feature highlights.
- **Purple** (`#391c57`): Premium features, deep accents.
- **Brown** (`#523410`): Earthy accent, warm feature sections.
### Interactive
- **Link Blue** (`#0075de`): Primary link color with underline-on-hover.
- **Link Light Blue** (`#62aef0`): Lighter link variant for dark backgrounds.
- **Focus Blue** (`#097fe8`): Focus ring on interactive elements.
- **Badge Blue Bg** (`#f2f9ff`): Pill badge background, tinted blue surface.
- **Badge Blue Text** (`#097fe8`): Pill badge text, darker blue for readability.
### Shadows & Depth
- **Card Shadow** (`rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px`): Multi-layer card elevation.
- **Deep Shadow** (`rgba(0,0,0,0.01) 0px 1px 3px, rgba(0,0,0,0.02) 0px 3px 7px, rgba(0,0,0,0.02) 0px 7px 15px, rgba(0,0,0,0.04) 0px 14px 28px, rgba(0,0,0,0.05) 0px 23px 52px`): Five-layer deep elevation for modals and featured content.
- **Whisper Border** (`1px solid rgba(0,0,0,0.1)`): Standard division border -- cards, dividers, sections.
## 3. Typography Rules
### Font Family
- **Primary**: `NotionInter`, with fallbacks: `Inter, -apple-system, system-ui, Segoe UI, Helvetica, Apple Color Emoji, Arial, Segoe UI Emoji, Segoe UI Symbol`
- **OpenType Features**: `"lnum"` (lining numerals) and `"locl"` (localized forms) enabled on display and heading text.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display Hero | NotionInter | 64px (4.00rem) | 700 | 1.00 (tight) | -2.125px | Maximum compression, billboard headlines |
| Display Secondary | NotionInter | 54px (3.38rem) | 700 | 1.04 (tight) | -1.875px | Secondary hero, feature headlines |
| Section Heading | NotionInter | 48px (3.00rem) | 700 | 1.00 (tight) | -1.5px | Feature section titles, with `"lnum"` |
| Sub-heading Large | NotionInter | 40px (2.50rem) | 700 | 1.50 | normal | Card headings, feature sub-sections |
| Sub-heading | NotionInter | 26px (1.63rem) | 700 | 1.23 (tight) | -0.625px | Section sub-titles, content headers |
| Card Title | NotionInter | 22px (1.38rem) | 700 | 1.27 (tight) | -0.25px | Feature cards, list titles |
| Body Large | NotionInter | 20px (1.25rem) | 600 | 1.40 | -0.125px | Introductions, feature descriptions |
| Body | NotionInter | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
| Body Medium | NotionInter | 16px (1.00rem) | 500 | 1.50 | normal | Navigation, emphasized UI text |
| Body Semibold | NotionInter | 16px (1.00rem) | 600 | 1.50 | normal | Strong labels, active states |
| Body Bold | NotionInter | 16px (1.00rem) | 700 | 1.50 | normal | Headlines at body size |
| Nav / Button | NotionInter | 15px (0.94rem) | 600 | 1.33 | normal | Navigation links, button text |
| Caption | NotionInter | 14px (0.88rem) | 500 | 1.43 | normal | Metadata, secondary labels |
| Caption Light | NotionInter | 14px (0.88rem) | 400 | 1.43 | normal | Body captions, descriptions |
| Badge | NotionInter | 12px (0.75rem) | 600 | 1.33 | 0.125px | Pill badges, tags, status labels |
| Micro Label | NotionInter | 12px (0.75rem) | 400 | 1.33 | 0.125px | Small metadata, timestamps |
### Principles
- **Compression at scale**: NotionInter at display sizes uses -2.125px letter-spacing at 64px, progressively relaxing to -0.625px at 26px and normal at 16px. The compression creates density at headlines while maintaining readability at body sizes.
- **Four-weight system**: 400 (body/reading), 500 (UI/interactive), 600 (emphasis/navigation), 700 (headings/display). The broader weight range compared to most systems allows nuanced hierarchy.
- **Warm scaling**: Line height tightens as size increases -- 1.50 at body (16px), 1.23-1.27 at sub-headings, 1.00-1.04 at display. This creates denser, more impactful headlines.
- **Badge micro-tracking**: The 12px badge text uses positive letter-spacing (0.125px) -- the only positive tracking in the system, creating wider, more legible small text.
## 4. Component Stylings
### Buttons
**Primary Blue**
- Background: `#0075de` (Notion Blue)
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 4px (subtle)
- Border: `1px solid transparent`
- Hover: background darkens to `#005bab`
- Active: scale(0.9) transform
- Focus: `2px solid` focus outline, `var(--shadow-level-200)` shadow
- Use: Primary CTA ("Get Notion free", "Try it")
**Secondary / Tertiary**
- Background: `rgba(0,0,0,0.05)` (translucent warm gray)
- Text: `#000000` (near-black)
- Padding: 8px 16px
- Radius: 4px
- Hover: text color shifts, scale(1.05)
- Active: scale(0.9) transform
- Use: Secondary actions, form submissions
**Ghost / Link Button**
- Background: transparent
- Text: `rgba(0,0,0,0.95)`
- Decoration: underline on hover
- Use: Tertiary actions, inline links
**Pill Badge Button**
- Background: `#f2f9ff` (tinted blue)
- Text: `#097fe8`
- Padding: 4px 8px
- Radius: 9999px (full pill)
- Font: 12px weight 600
- Use: Status badges, feature labels, "New" tags
### Cards & Containers
- Background: `#ffffff`
- Border: `1px solid rgba(0,0,0,0.1)` (whisper border)
- Radius: 12px (standard cards), 16px (featured/hero cards)
- Shadow: `rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.84688px, rgba(0,0,0,0.02) 0px 0.8px 2.925px, rgba(0,0,0,0.01) 0px 0.175px 1.04062px`
- Hover: subtle shadow intensification
- Image cards: 12px top radius, image fills top half
### Inputs & Forms
- Background: `#ffffff`
- Text: `rgba(0,0,0,0.9)`
- Border: `1px solid #dddddd`
- Padding: 6px
- Radius: 4px
- Focus: blue outline ring
- Placeholder: warm gray `#a39e98`
### Navigation
- Clean horizontal nav on white, not sticky
- Brand logo left-aligned (33x34px icon + wordmark)
- Links: NotionInter 15px weight 500-600, near-black text
- Hover: color shift to `var(--color-link-primary-text-hover)`
- CTA: blue pill button ("Get Notion free") right-aligned
- Mobile: hamburger menu collapse
- Product dropdowns with multi-level categorized menus
### Image Treatment
- Product screenshots with `1px solid rgba(0,0,0,0.1)` border
- Top-rounded images: `12px 12px 0px 0px` radius
- Dashboard/workspace preview screenshots dominate feature sections
- Warm gradient backgrounds behind hero illustrations (decorative character illustrations)
### Distinctive Components
**Feature Cards with Illustrations**
- Large illustrative headers (The Great Wave, product UI screenshots)
- 12px radius card with whisper border
- Title at 22px weight 700, description at 16px weight 400
- Warm white (`#f6f5f4`) background variant for alternating sections
**Trust Bar / Logo Grid**
- Company logos (trusted teams section) in their brand colors
- Horizontal scroll or grid layout with team counts
- Metric display: large number + description pattern
**Metric Cards**
- Large number display (e.g., "$4,200 ROI")
- NotionInter 40px+ weight 700 for the metric
- Description below in warm gray body text
- Whisper-bordered card container
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 2px, 3px, 4px, 5px, 6px, 7px, 8px, 11px, 12px, 14px, 16px, 24px, 32px
- Non-rigid organic scale with fractional values (5.6px, 6.4px) for micro-adjustments
### Grid & Container
- Max content width: approximately 1200px
- Hero: centered single-column with generous top padding (80-120px)
- Feature sections: 2-3 column grids for cards
- Full-width warm white (`#f6f5f4`) section backgrounds for alternation
- Code/dashboard screenshots as contained with whisper border
### Whitespace Philosophy
- **Generous vertical rhythm**: 64-120px between major sections. Notion lets content breathe with vast vertical padding.
- **Warm alternation**: White sections alternate with warm white (`#f6f5f4`) sections, creating gentle visual rhythm without harsh color breaks.
- **Content-first density**: Body text blocks are compact (line-height 1.50) but surrounded by ample margin, creating islands of readable content in a sea of white space.
### Border Radius Scale
- Micro (4px): Buttons, inputs, functional interactive elements
- Subtle (5px): Links, list items, menu items
- Standard (8px): Small cards, containers, inline elements
- Comfortable (12px): Standard cards, feature containers, image tops
- Large (16px): Hero cards, featured content, promotional blocks
- Full Pill (9999px): Badges, pills, status indicators
- Circle (100%): Tab indicators, avatars
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow, no border | Page background, text blocks |
| Whisper (Level 1) | `1px solid rgba(0,0,0,0.1)` | Standard borders, card outlines, dividers |
| Soft Card (Level 2) | 4-layer shadow stack (max opacity 0.04) | Content cards, feature blocks |
| Deep Card (Level 3) | 5-layer shadow stack (max opacity 0.05, 52px blur) | Modals, featured panels, hero elements |
| Focus (Accessibility) | `2px solid var(--focus-color)` outline | Keyboard focus on all interactive elements |
**Shadow Philosophy**: Notion's shadow system uses multiple layers with extremely low individual opacity (0.01 to 0.05) that accumulate into soft, natural-looking elevation. The 4-layer card shadow spans from 1.04px to 18px blur, creating a gradient of depth rather than a single hard shadow. The 5-layer deep shadow extends to 52px blur at 0.05 opacity, producing ambient occlusion that feels like natural light rather than computer-generated depth. This layered approach makes elements feel embedded in the page rather than floating above it.
### Decorative Depth
- Hero section: decorative character illustrations (playful, hand-drawn style)
- Section alternation: white to warm white (`#f6f5f4`) background shifts
- No hard section borders -- separation comes from background color changes and spacing
## 7. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <400px | Tight single column, minimal padding |
| Mobile | 400-600px | Standard mobile, stacked layout |
| Tablet Small | 600-768px | 2-column grids begin |
| Tablet | 768-1080px | Full card grids, expanded padding |
| Desktop Small | 1080-1200px | Standard desktop layout |
| Desktop | 1200-1440px | Full layout, maximum content width |
| Large Desktop | >1440px | Centered, generous margins |
### Touch Targets
- Buttons use comfortable padding (8px-16px vertical)
- Navigation links at 15px with adequate spacing
- Pill badges have 8px horizontal padding for tap targets
- Mobile menu toggle uses standard hamburger button
### Collapsing Strategy
- Hero: 64px display -> scales to 40px -> 26px on mobile, maintains proportional letter-spacing
- Navigation: horizontal links + blue CTA -> hamburger menu
- Feature cards: 3-column -> 2-column -> single column stacked
- Product screenshots: maintain aspect ratio with responsive images
- Trust bar logos: grid -> horizontal scroll on mobile
- Footer: multi-column -> stacked single column
- Section spacing: 80px+ -> 48px on mobile
### Image Behavior
- Workspace screenshots maintain whisper border at all sizes
- Hero illustrations scale proportionally
- Product screenshots use responsive images with consistent border radius
- Full-width warm white sections maintain edge-to-edge treatment
## 8. Accessibility & States
### Focus System
- All interactive elements receive visible focus indicators
- Focus outline: `2px solid` with focus color + shadow level 200
- Tab navigation supported throughout all interactive components
- High contrast text: near-black on white exceeds WCAG AAA (>14:1 ratio)
### Interactive States
- **Default**: Standard appearance with whisper borders
- **Hover**: Color shift on text, scale(1.05) on buttons, underline on links
- **Active/Pressed**: scale(0.9) transform, darker background variant
- **Focus**: Blue outline ring with shadow reinforcement
- **Disabled**: Warm gray (`#a39e98`) text, reduced opacity
### Color Contrast
- Primary text (rgba(0,0,0,0.95)) on white: ~18:1 ratio
- Secondary text (#615d59) on white: ~5.5:1 ratio (WCAG AA)
- Blue CTA (#0075de) on white: ~4.6:1 ratio (WCAG AA for large text)
- Badge text (#097fe8) on badge bg (#f2f9ff): ~4.5:1 ratio (WCAG AA for large text)
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Notion Blue (`#0075de`)
- Background: Pure White (`#ffffff`)
- Alt Background: Warm White (`#f6f5f4`)
- Heading text: Near-Black (`rgba(0,0,0,0.95)`)
- Body text: Near-Black (`rgba(0,0,0,0.95)`)
- Secondary text: Warm Gray 500 (`#615d59`)
- Muted text: Warm Gray 300 (`#a39e98`)
- Border: `1px solid rgba(0,0,0,0.1)`
- Link: Notion Blue (`#0075de`)
- Focus ring: Focus Blue (`#097fe8`)
### Example Component Prompts
- "Create a hero section on white background. Headline at 64px NotionInter weight 700, line-height 1.00, letter-spacing -2.125px, color rgba(0,0,0,0.95). Subtitle at 20px weight 600, line-height 1.40, color #615d59. Blue CTA button (#0075de, 4px radius, 8px 16px padding, white text) and ghost button (transparent bg, near-black text, underline on hover)."
- "Design a card: white background, 1px solid rgba(0,0,0,0.1) border, 12px radius. Use shadow stack: rgba(0,0,0,0.04) 0px 4px 18px, rgba(0,0,0,0.027) 0px 2.025px 7.85px, rgba(0,0,0,0.02) 0px 0.8px 2.93px, rgba(0,0,0,0.01) 0px 0.175px 1.04px. Title at 22px NotionInter weight 700, letter-spacing -0.25px. Body at 16px weight 400, color #615d59."
- "Build a pill badge: #f2f9ff background, #097fe8 text, 9999px radius, 4px 8px padding, 12px NotionInter weight 600, letter-spacing 0.125px."
- "Create navigation: white header. NotionInter 15px weight 600 for links, near-black text. Blue pill CTA 'Get Notion free' right-aligned (#0075de bg, white text, 4px radius)."
- "Design an alternating section layout: white sections alternate with warm white (#f6f5f4) sections. Each section has 64-80px vertical padding, max-width 1200px centered. Section heading at 48px weight 700, line-height 1.00, letter-spacing -1.5px."
### Iteration Guide
1. Always use warm neutrals -- Notion's grays have yellow-brown undertones (#f6f5f4, #31302e, #615d59, #a39e98), never blue-gray
2. Letter-spacing scales with font size: -2.125px at 64px, -1.875px at 54px, -0.625px at 26px, normal at 16px
3. Four weights: 400 (read), 500 (interact), 600 (emphasize), 700 (announce)
4. Borders are whispers: 1px solid rgba(0,0,0,0.1) -- never heavier
5. Shadows use 4-5 layers with individual opacity never exceeding 0.05
6. The warm white (#f6f5f4) section background is essential for visual rhythm
7. Pill badges (9999px) for status/tags, 4px radius for buttons and inputs
8. Notion Blue (#0075de) is the only saturated color in core UI -- use it sparingly for CTAs and links

View file

@ -0,0 +1,68 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "design-system-notion",
"title": "Notion",
"version": "0.1.0",
"description": "All-in-one workspace. Warm minimalism, serif headings, soft surfaces.",
"license": "MIT",
"tags": [
"design-system",
"first-party",
"design",
"productivity-saas"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "design-system",
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Notion design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "select",
"options": [
"landing page",
"dashboard",
"marketing site",
"app screen"
],
"default": "landing page"
},
{
"name": "brief",
"label": "Brief",
"type": "text",
"placeholder": "What should the page communicate?"
}
],
"context": {
"designSystem": {
"ref": "notion",
"primary": true
},
"assets": [
"./DESIGN.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,325 @@
# Design System Inspired by Stripe
> Category: Fintech & Crypto
> Payment infrastructure. Signature purple gradients, weight-300 elegance.
## 1. Visual Theme & Atmosphere
Stripe's website is the gold standard of fintech design -- a system that manages to feel simultaneously technical and luxurious, precise and warm. The page opens on a clean white canvas (`#ffffff`) with deep navy headings (`#061b31`) and a signature purple (`#533afd`) that functions as both brand anchor and interactive accent. This isn't the cold, clinical purple of enterprise software; it's a rich, saturated violet that reads as confident and premium. The overall impression is of a financial institution redesigned by a world-class type foundry.
The custom `sohne-var` variable font is the defining element of Stripe's visual identity. Every text element enables the OpenType `"ss01"` stylistic set, which modifies character shapes for a distinctly geometric, modern feel. At display sizes (48px-56px), sohne-var runs at weight 300 -- an extraordinarily light weight for headlines that creates an ethereal, almost whispered authority. This is the opposite of the "bold hero headline" convention; Stripe's headlines feel like they don't need to shout. The negative letter-spacing (-1.4px at 56px, -0.96px at 48px) tightens the text into dense, engineered blocks. At smaller sizes, the system also uses weight 300 with proportionally reduced tracking, and tabular numerals via `"tnum"` for financial data display.
What truly distinguishes Stripe is its shadow system. Rather than the flat or single-layer approach of most sites, Stripe uses multi-layer, blue-tinted shadows: the signature `rgba(50,50,93,0.25)` combined with `rgba(0,0,0,0.1)` creates shadows with a cool, almost atmospheric depth -- like elements are floating in a twilight sky. The blue-gray undertone of the primary shadow color (50,50,93) ties directly to the navy-purple brand palette, making even elevation feel on-brand.
**Key Characteristics:**
- sohne-var with OpenType `"ss01"` on all text -- a custom stylistic set that defines the brand's letterforms
- Weight 300 as the signature headline weight -- light, confident, anti-convention
- Negative letter-spacing at display sizes (-1.4px at 56px, progressive relaxation downward)
- Blue-tinted multi-layer shadows using `rgba(50,50,93,0.25)` -- elevation that feels brand-colored
- Deep navy (`#061b31`) headings instead of black -- warm, premium, financial-grade
- Conservative border-radius (4px-8px) -- nothing pill-shaped, nothing harsh
- Ruby (`#ea2261`) and magenta (`#f96bee`) accents for gradient and decorative elements
- `SourceCodePro` as the monospace companion for code and technical labels
## 2. Color Palette & Roles
### Primary
- **Stripe Purple** (`#533afd`): Primary brand color, CTA backgrounds, link text, interactive highlights. A saturated blue-violet that anchors the entire system.
- **Deep Navy** (`#061b31`): `--hds-color-heading-solid`. Primary heading color. Not black, not gray -- a very dark blue that adds warmth and depth to text.
- **Pure White** (`#ffffff`): Page background, card surfaces, button text on dark backgrounds.
### Brand & Dark
- **Brand Dark** (`#1c1e54`): `--hds-color-util-brand-900`. Deep indigo for dark sections, footer backgrounds, and immersive brand moments.
- **Dark Navy** (`#0d253d`): `--hds-color-core-neutral-975`. The darkest neutral -- almost-black with a blue undertone for maximum depth without harshness.
### Accent Colors
- **Ruby** (`#ea2261`): `--hds-color-accentColorMode-ruby-icon-solid`. Warm red-pink for icons, alerts, and accent elements.
- **Magenta** (`#f96bee`): `--hds-color-accentColorMode-magenta-icon-gradientMiddle`. Vivid pink-purple for gradients and decorative highlights.
- **Magenta Light** (`#ffd7ef`): `--hds-color-util-accent-magenta-100`. Tinted surface for magenta-themed cards and badges.
### Interactive
- **Primary Purple** (`#533afd`): Primary link color, active states, selected elements.
- **Purple Hover** (`#4434d4`): Darker purple for hover states on primary elements.
- **Purple Deep** (`#2e2b8c`): `--hds-color-button-ui-iconHover`. Dark purple for icon hover states.
- **Purple Light** (`#b9b9f9`): `--hds-color-action-bg-subduedHover`. Soft lavender for subdued hover backgrounds.
- **Purple Mid** (`#665efd`): `--hds-color-input-selector-text-range`. Range selector and input highlight color.
### Neutral Scale
- **Heading** (`#061b31`): Primary headings, nav text, strong labels.
- **Label** (`#273951`): `--hds-color-input-text-label`. Form labels, secondary headings.
- **Body** (`#64748d`): Secondary text, descriptions, captions.
- **Success Green** (`#15be53`): Status badges, success indicators (with 0.2-0.4 alpha for backgrounds/borders).
- **Success Text** (`#108c3d`): Success badge text color.
- **Lemon** (`#9b6829`): `--hds-color-core-lemon-500`. Warning and highlight accent.
### Surface & Borders
- **Border Default** (`#e5edf5`): Standard border color for cards, dividers, and containers.
- **Border Purple** (`#b9b9f9`): Active/selected state borders on buttons and inputs.
- **Border Soft Purple** (`#d6d9fc`): Subtle purple-tinted borders for secondary elements.
- **Border Magenta** (`#ffd7ef`): Pink-tinted borders for magenta-themed elements.
- **Border Dashed** (`#362baa`): Dashed borders for drop zones and placeholder elements.
### Shadow Colors
- **Shadow Blue** (`rgba(50,50,93,0.25)`): The signature -- blue-tinted primary shadow color.
- **Shadow Dark Blue** (`rgba(3,3,39,0.25)`): Deeper blue shadow for elevated elements.
- **Shadow Black** (`rgba(0,0,0,0.1)`): Secondary shadow layer for depth reinforcement.
- **Shadow Ambient** (`rgba(23,23,23,0.08)`): Soft ambient shadow for subtle elevation.
- **Shadow Soft** (`rgba(23,23,23,0.06)`): Minimal ambient shadow for light lift.
## 3. Typography Rules
### Font Family
- **Primary**: `sohne-var`, with fallback: `SF Pro Display`
- **Monospace**: `SourceCodePro`, with fallback: `SFMono-Regular`
- **OpenType Features**: `"ss01"` enabled globally on all sohne-var text; `"tnum"` for tabular numbers on financial data and captions.
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Features | Notes |
|------|------|------|--------|-------------|----------------|----------|-------|
| Display Hero | sohne-var | 56px (3.50rem) | 300 | 1.03 (tight) | -1.4px | ss01 | Maximum size, whisper-weight authority |
| Display Large | sohne-var | 48px (3.00rem) | 300 | 1.15 (tight) | -0.96px | ss01 | Secondary hero headlines |
| Section Heading | sohne-var | 32px (2.00rem) | 300 | 1.10 (tight) | -0.64px | ss01 | Feature section titles |
| Sub-heading Large | sohne-var | 26px (1.63rem) | 300 | 1.12 (tight) | -0.26px | ss01 | Card headings, sub-sections |
| Sub-heading | sohne-var | 22px (1.38rem) | 300 | 1.10 (tight) | -0.22px | ss01 | Smaller section heads |
| Body Large | sohne-var | 18px (1.13rem) | 300 | 1.40 | normal | ss01 | Feature descriptions, intro text |
| Body | sohne-var | 16px (1.00rem) | 300-400 | 1.40 | normal | ss01 | Standard reading text |
| Button | sohne-var | 16px (1.00rem) | 400 | 1.00 (tight) | normal | ss01 | Primary button text |
| Button Small | sohne-var | 14px (0.88rem) | 400 | 1.00 (tight) | normal | ss01 | Secondary/compact buttons |
| Link | sohne-var | 14px (0.88rem) | 400 | 1.00 (tight) | normal | ss01 | Navigation links |
| Caption | sohne-var | 13px (0.81rem) | 400 | normal | normal | ss01 | Small labels, metadata |
| Caption Small | sohne-var | 12px (0.75rem) | 300-400 | 1.33-1.45 | normal | ss01 | Fine print, timestamps |
| Caption Tabular | sohne-var | 12px (0.75rem) | 300-400 | 1.33 | -0.36px | tnum | Financial data, numbers |
| Micro | sohne-var | 10px (0.63rem) | 300 | 1.15 (tight) | 0.1px | ss01 | Tiny labels, axis markers |
| Micro Tabular | sohne-var | 10px (0.63rem) | 300 | 1.15 (tight) | -0.3px | tnum | Chart data, small numbers |
| Nano | sohne-var | 8px (0.50rem) | 300 | 1.07 (tight) | normal | ss01 | Smallest labels |
| Code Body | SourceCodePro | 12px (0.75rem) | 500 | 2.00 (relaxed) | normal | -- | Code blocks, syntax |
| Code Bold | SourceCodePro | 12px (0.75rem) | 700 | 2.00 (relaxed) | normal | -- | Bold code, keywords |
| Code Label | SourceCodePro | 12px (0.75rem) | 500 | 2.00 (relaxed) | normal | uppercase | Technical labels |
| Code Micro | SourceCodePro | 9px (0.56rem) | 500 | 1.00 (tight) | normal | ss01 | Tiny code annotations |
### Principles
- **Light weight as signature**: Weight 300 at display sizes is Stripe's most distinctive typographic choice. Where others use 600-700 to command attention, Stripe uses lightness as luxury -- the text is so confident it doesn't need weight to be authoritative.
- **ss01 everywhere**: The `"ss01"` stylistic set is non-negotiable. It modifies specific glyphs (likely alternate `a`, `g`, `l` forms) to create a more geometric, contemporary feel across all sohne-var text.
- **Two OpenType modes**: `"ss01"` for display/body text, `"tnum"` for tabular numerals in financial data. These never overlap -- a number in a paragraph uses ss01, a number in a data table uses tnum.
- **Progressive tracking**: Letter-spacing tightens proportionally with size: -1.4px at 56px, -0.96px at 48px, -0.64px at 32px, -0.26px at 26px, normal at 16px and below.
- **Two-weight simplicity**: Primarily 300 (body and headings) and 400 (UI/buttons). No bold (700) in the primary font -- SourceCodePro uses 500/700 for code contrast.
## 4. Component Stylings
### Buttons
**Primary Purple**
- Background: `#533afd`
- Text: `#ffffff`
- Padding: 8px 16px
- Radius: 4px
- Font: 16px sohne-var weight 400, `"ss01"`
- Hover: `#4434d4` background
- Use: Primary CTA ("Start now", "Contact sales")
**Ghost / Outlined**
- Background: transparent
- Text: `#533afd`
- Padding: 8px 16px
- Radius: 4px
- Border: `1px solid #b9b9f9`
- Font: 16px sohne-var weight 400, `"ss01"`
- Hover: background shifts to `rgba(83,58,253,0.05)`
- Use: Secondary actions
**Transparent Info**
- Background: transparent
- Text: `#2874ad`
- Padding: 8px 16px
- Radius: 4px
- Border: `1px solid rgba(43,145,223,0.2)`
- Use: Tertiary/info-level actions
**Neutral Ghost**
- Background: transparent (`rgba(255,255,255,0)`)
- Text: `rgba(16,16,16,0.3)`
- Padding: 8px 16px
- Radius: 4px
- Outline: `1px solid rgb(212,222,233)`
- Use: Disabled or muted actions
### Cards & Containers
- Background: `#ffffff`
- Border: `1px solid #e5edf5` (standard) or `1px solid #061b31` (dark accent)
- Radius: 4px (tight), 5px (standard), 6px (comfortable), 8px (featured)
- Shadow (standard): `rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px`
- Shadow (ambient): `rgba(23,23,23,0.08) 0px 15px 35px 0px`
- Hover: shadow intensifies, often adding the blue-tinted layer
### Badges / Tags / Pills
**Neutral Pill**
- Background: `#ffffff`
- Text: `#000000`
- Padding: 0px 6px
- Radius: 4px
- Border: `1px solid #f6f9fc`
- Font: 11px weight 400
**Success Badge**
- Background: `rgba(21,190,83,0.2)`
- Text: `#108c3d`
- Padding: 1px 6px
- Radius: 4px
- Border: `1px solid rgba(21,190,83,0.4)`
- Font: 10px weight 300
### Inputs & Forms
- Border: `1px solid #e5edf5`
- Radius: 4px
- Focus: `1px solid #533afd` or purple ring
- Label: `#273951`, 14px sohne-var
- Text: `#061b31`
- Placeholder: `#64748d`
### Navigation
- Clean horizontal nav on white, sticky with blur backdrop
- Brand logotype left-aligned
- Links: sohne-var 14px weight 400, `#061b31` text with `"ss01"`
- Radius: 6px on nav container
- CTA: purple button right-aligned ("Sign in", "Start now")
- Mobile: hamburger toggle with 6px radius
### Decorative Elements
**Dashed Borders**
- `1px dashed #362baa` (purple) for placeholder/drop zones
- `1px dashed #ffd7ef` (magenta) for magenta-themed decorative borders
**Gradient Accents**
- Ruby-to-magenta gradients (`#ea2261` to `#f96bee`) for hero decorations
- Brand dark sections use `#1c1e54` backgrounds with white text
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 1px, 2px, 4px, 6px, 8px, 10px, 11px, 12px, 14px, 16px, 18px, 20px
- Notable: The scale is dense at the small end (every 2px from 4-12), reflecting Stripe's precision-oriented UI for financial data
### Grid & Container
- Max content width: approximately 1080px
- Hero: centered single-column with generous padding, lightweight headlines
- Feature sections: 2-3 column grids for feature cards
- Full-width dark sections with `#1c1e54` background for brand immersion
- Code/dashboard previews as contained cards with blue-tinted shadows
### Whitespace Philosophy
- **Precision spacing**: Unlike the vast emptiness of minimalist systems, Stripe uses measured, purposeful whitespace. Every gap is a deliberate typographic choice.
- **Dense data, generous chrome**: Financial data displays (tables, charts) are tightly packed, but the UI chrome around them is generously spaced. This creates a sense of controlled density -- like a well-organized spreadsheet in a beautiful frame.
- **Section rhythm**: White sections alternate with dark brand sections (`#1c1e54`), creating a dramatic light/dark cadence that prevents monotony without introducing arbitrary color.
### Border Radius Scale
- Micro (1px): Fine-grained elements, subtle rounding
- Standard (4px): Buttons, inputs, badges, cards -- the workhorse
- Comfortable (5px): Standard card containers
- Relaxed (6px): Navigation, larger interactive elements
- Large (8px): Featured cards, hero elements
- Compound: `0px 0px 6px 6px` for bottom-rounded containers (tab panels, dropdown footers)
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow | Page background, inline text |
| Ambient (Level 1) | `rgba(23,23,23,0.06) 0px 3px 6px` | Subtle card lift, hover hints |
| Standard (Level 2) | `rgba(23,23,23,0.08) 0px 15px 35px` | Standard cards, content panels |
| Elevated (Level 3) | `rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px` | Featured cards, dropdowns, popovers |
| Deep (Level 4) | `rgba(3,3,39,0.25) 0px 14px 21px -14px, rgba(0,0,0,0.1) 0px 8px 17px -8px` | Modals, floating panels |
| Ring (Accessibility) | `2px solid #533afd` outline | Keyboard focus ring |
**Shadow Philosophy**: Stripe's shadow system is built on a principle of chromatic depth. Where most design systems use neutral gray or black shadows, Stripe's primary shadow color (`rgba(50,50,93,0.25)`) is a deep blue-gray that echoes the brand's navy palette. This creates shadows that don't just add depth -- they add brand atmosphere. The multi-layer approach pairs this blue-tinted shadow with a pure black secondary layer (`rgba(0,0,0,0.1)`) at a different offset, creating a parallax-like depth where the branded shadow sits farther from the element and the neutral shadow sits closer. The negative spread values (-30px, -18px) ensure shadows don't extend beyond the element's footprint horizontally, keeping elevation vertical and controlled.
### Decorative Depth
- Dark brand sections (`#1c1e54`) create immersive depth through background color contrast
- Gradient overlays with ruby-to-magenta transitions for hero decorations
- Shadow color `rgba(0,55,112,0.08)` (`--hds-color-shadow-sm-top`) for top-edge shadows on sticky elements
## 7. Do's and Don'ts
### Do
- Use sohne-var with `"ss01"` on every text element -- the stylistic set IS the brand
- Use weight 300 for all headlines and body text -- lightness is the signature
- Apply blue-tinted shadows (`rgba(50,50,93,0.25)`) for all elevated elements
- Use `#061b31` (deep navy) for headings instead of `#000000` -- the warmth matters
- Keep border-radius between 4px-8px -- conservative rounding is intentional
- Use `"tnum"` for any tabular/financial number display
- Layer shadows: blue-tinted far + neutral close for depth parallax
- Use `#533afd` purple as the primary interactive/CTA color
### Don't
- Don't use weight 600-700 for sohne-var headlines -- weight 300 is the brand voice
- Don't use large border-radius (12px+, pill shapes) on cards or buttons -- Stripe is conservative
- Don't use neutral gray shadows -- always tint with blue (`rgba(50,50,93,...)`)
- Don't skip `"ss01"` on any sohne-var text -- the alternate glyphs define the personality
- Don't use pure black (`#000000`) for headings -- always `#061b31` deep navy
- Don't use warm accent colors (orange, yellow) for interactive elements -- purple is primary
- Don't apply positive letter-spacing at display sizes -- Stripe tracks tight
- Don't use the magenta/ruby accents for buttons or links -- they're decorative/gradient only
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <640px | Single column, reduced heading sizes, stacked cards |
| Tablet | 640-1024px | 2-column grids, moderate padding |
| Desktop | 1024-1280px | Full layout, 3-column feature grids |
| Large Desktop | >1280px | Centered content with generous margins |
### Touch Targets
- Buttons use comfortable padding (8px-16px vertical)
- Navigation links at 14px with adequate spacing
- Badges have 6px horizontal padding minimum for tap targets
- Mobile nav toggle with 6px radius button
### Collapsing Strategy
- Hero: 56px display -> 32px on mobile, weight 300 maintained
- Navigation: horizontal links + CTAs -> hamburger toggle
- Feature cards: 3-column -> 2-column -> single column stacked
- Dark brand sections: maintain full-width treatment, reduce internal padding
- Financial data tables: horizontal scroll on mobile
- Section spacing: 64px+ -> 40px on mobile
- Typography scale compresses: 56px -> 48px -> 32px hero sizes across breakpoints
### Image Behavior
- Dashboard/product screenshots maintain blue-tinted shadow at all sizes
- Hero gradient decorations simplify on mobile
- Code blocks maintain `SourceCodePro` treatment, may horizontally scroll
- Card images maintain consistent 4px-6px border-radius
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Stripe Purple (`#533afd`)
- CTA Hover: Purple Dark (`#4434d4`)
- Background: Pure White (`#ffffff`)
- Heading text: Deep Navy (`#061b31`)
- Body text: Slate (`#64748d`)
- Label text: Dark Slate (`#273951`)
- Border: Soft Blue (`#e5edf5`)
- Link: Stripe Purple (`#533afd`)
- Dark section: Brand Dark (`#1c1e54`)
- Success: Green (`#15be53`)
- Accent decorative: Ruby (`#ea2261`), Magenta (`#f96bee`)
### Example Component Prompts
- "Create a hero section on white background. Headline at 48px sohne-var weight 300, line-height 1.15, letter-spacing -0.96px, color #061b31, font-feature-settings 'ss01'. Subtitle at 18px weight 300, line-height 1.40, color #64748d. Purple CTA button (#533afd, 4px radius, 8px 16px padding, white text) and ghost button (transparent, 1px solid #b9b9f9, #533afd text, 4px radius)."
- "Design a card: white background, 1px solid #e5edf5 border, 6px radius. Shadow: rgba(50,50,93,0.25) 0px 30px 45px -30px, rgba(0,0,0,0.1) 0px 18px 36px -18px. Title at 22px sohne-var weight 300, letter-spacing -0.22px, color #061b31, 'ss01'. Body at 16px weight 300, #64748d."
- "Build a success badge: rgba(21,190,83,0.2) background, #108c3d text, 4px radius, 1px 6px padding, 10px sohne-var weight 300, border 1px solid rgba(21,190,83,0.4)."
- "Create navigation: white sticky header with backdrop-filter blur(12px). sohne-var 14px weight 400 for links, #061b31 text, 'ss01'. Purple CTA 'Start now' right-aligned (#533afd bg, white text, 4px radius). Nav container 6px radius."
- "Design a dark brand section: #1c1e54 background, white text. Headline 32px sohne-var weight 300, letter-spacing -0.64px, 'ss01'. Body 16px weight 300, rgba(255,255,255,0.7). Cards inside use rgba(255,255,255,0.1) border with 6px radius."
### Iteration Guide
1. Always enable `font-feature-settings: "ss01"` on sohne-var text -- this is the brand's typographic DNA
2. Weight 300 is the default; use 400 only for buttons/links/navigation
3. Shadow formula: `rgba(50,50,93,0.25) 0px Y1 B1 -S1, rgba(0,0,0,0.1) 0px Y2 B2 -S2` where Y1/B1 are larger (far shadow) and Y2/B2 are smaller (near shadow)
4. Heading color is `#061b31` (deep navy), body is `#64748d` (slate), labels are `#273951` (dark slate)
5. Border-radius stays in the 4px-8px range -- never use pill shapes or large rounding
6. Use `"tnum"` for any numbers in tables, charts, or financial displays
7. Dark sections use `#1c1e54` -- not black, not gray, but a deep branded indigo
8. SourceCodePro for code at 12px/500 with 2.00 line-height (very generous for readability)

View file

@ -0,0 +1,68 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "design-system-stripe",
"title": "Stripe",
"version": "0.1.0",
"description": "Payment infrastructure. Signature purple gradients, weight-300 elegance.",
"license": "MIT",
"tags": [
"design-system",
"first-party",
"design",
"fintech-crypto"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "design-system",
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Stripe design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "select",
"options": [
"landing page",
"dashboard",
"marketing site",
"app screen"
],
"default": "landing page"
},
{
"name": "brief",
"label": "Brief",
"type": "text",
"placeholder": "What should the page communicate?"
}
],
"context": {
"designSystem": {
"ref": "stripe",
"primary": true
},
"assets": [
"./DESIGN.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,76 @@
---
name: dashboard
description: |
Admin / analytics dashboard in a single HTML file. Fixed left sidebar,
top bar with user/search, main grid of KPI cards and one or two charts.
Use when the brief asks for a "dashboard", "admin", "analytics", or
"control panel" screen.
triggers:
- "dashboard"
- "admin panel"
- "analytics"
- "control panel"
- "后台"
- "管理后台"
od:
mode: prototype
platform: desktop
scenario: operations
preview:
type: html
entry: index.html
design_system:
requires: true
sections: [color, typography, layout, components]
craft:
requires: [state-coverage, accessibility-baseline, laws-of-ux]
---
# Dashboard Skill
Produce a single-screen admin / analytics dashboard.
## Workflow
1. **Read the active DESIGN.md** (injected above). Colors, typography, spacing,
component styling all come from it. Do not invent new tokens.
2. **Classify** what the dashboard monitors (sales, traffic, usage, incidents,
ops, etc.) from the brief. Generate specific, plausible metric names and
values — no "Metric A / Metric B" placeholders.
3. **Lay out** the required regions:
- **Left sidebar** (220260px): brand mark at top, 68 nav links with
icons, active state uses the DS accent.
- **Top bar**: page title on the left, search input + user avatar / status
on the right.
- **Main**:
- Row 1: 34 KPI cards (label + big number + delta vs. prior period).
- Row 2: one primary chart (full width or 2/3) — render as an inline SVG
line / bar / area chart drawn from real-looking numbers.
- Row 3: one secondary chart or table (recent events, top items, etc.).
4. **Write** one self-contained HTML document:
- `<!doctype html>` through `</html>`, CSS in one inline `<style>` block.
- CSS Grid for the overall layout; Flexbox inside cards.
- Semantic HTML: `<aside>`, `<header>`, `<main>`, `<section>`.
- Tag each logical region with `data-od-id="slug"` for comment mode.
5. **Charts**: inline SVG only, no JS libraries. A line chart is ~10 lines of
`<polyline>` with a subtle area fill. A bar chart is N `<rect>`s with
DS-accent fill. Label axes lightly (muted text, smaller scale).
6. **Self-check**:
- Every color comes from DESIGN.md tokens.
- Accent used at most twice (sidebar active + one chart highlight).
- Sidebar + top bar are sticky; main scrolls independently.
- Density matches the DS mood — airy DSes get more padding, dense DSes
(trading, crypto) tighten rows.
## Output contract
Emit between `<artifact>` tags:
```
<artifact identifier="dashboard-slug" type="text/html" title="Dashboard Title">
<!doctype html>
<html>...</html>
</artifact>
```
One sentence before the artifact, nothing after.

View file

@ -0,0 +1,118 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pulse — analytics overview</title>
<style>
:root {
--bg: #fafaf9; --fg: #1c1b1a; --muted: #6b6964; --border: #e6e4e0;
--accent: #c96442; --surface: #ffffff; --good: #2f7d4a; --bad: #b53a2a;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 -apple-system, system-ui, sans-serif; display: grid; grid-template-columns: 220px 1fr; min-height: 100vh; }
.sidebar { background: var(--surface); border-right: 1px solid var(--border); padding: 16px; }
.brand { font-weight: 600; padding: 8px 10px 18px; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav a { padding: 7px 10px; border-radius: 6px; color: var(--fg); text-decoration: none; }
.nav a.active { background: var(--bg); font-weight: 500; }
.nav a:hover { background: var(--bg); }
.nav .group-label { font-size: 11px; color: var(--muted); padding: 14px 10px 6px; text-transform: uppercase; letter-spacing: 0.06em; }
main { padding: 0 28px 56px; }
.topbar { padding: 16px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
.topbar h1 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
.topbar .right { display: flex; align-items: center; gap: 12px; color: var(--muted); }
button { font: inherit; cursor: pointer; padding: 7px 13px; border-radius: 6px; }
.btn-primary { background: var(--accent); color: white; border: 1px solid var(--accent); }
.btn-secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px; }
@media (max-width: 900px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; }
.kpi .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
.kpi .value { font-size: 28px; letter-spacing: -0.02em; }
.kpi .delta { font-size: 12px; margin-top: 4px; }
.kpi .delta.up { color: var(--good); }
.kpi .delta.down { color: var(--bad); }
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 16px; }
.panel h3 { margin: 0 0 16px; font-size: 14px; font-weight: 500; }
.chart { height: 240px; background: linear-gradient(180deg, rgba(201,100,66,0.06), transparent); border-bottom: 1px solid var(--border); position: relative; overflow: hidden; }
.chart svg { width: 100%; height: 100%; display: block; }
.panels-row { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
@media (max-width: 900px) { .panels-row { grid-template-columns: 1fr; } }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 6px; border-top: 1px solid var(--border); }
th { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; }
.pill { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--bg); border: 1px solid var(--border); }
.pill.good { color: var(--good); border-color: rgba(47,125,74,0.3); }
.pill.bad { color: var(--bad); border-color: rgba(181,58,42,0.3); }
</style>
</head>
<body>
<aside class="sidebar" data-od-id="sidebar">
<div class="brand">◐ Pulse</div>
<nav class="nav">
<a href="#" class="active">Overview</a>
<a href="#">Funnels</a>
<a href="#">Cohorts</a>
<a href="#">Sessions</a>
<span class="group-label">Workspace</span>
<a href="#">Sources</a>
<a href="#">Members</a>
<a href="#">Billing</a>
<a href="#">Settings</a>
</nav>
</aside>
<main>
<div class="topbar" data-od-id="topbar">
<h1>Overview · April 2026</h1>
<div class="right">
<button class="btn-secondary">Last 30 days ▾</button>
<button class="btn-primary">+ New report</button>
</div>
</div>
<div class="kpis" data-od-id="kpis">
<div class="kpi"><div class="label">MRR</div><div class="value">$48.2K</div><div class="delta up">+12.4% MoM</div></div>
<div class="kpi"><div class="label">Active accounts</div><div class="value">3,184</div><div class="delta up">+204 this month</div></div>
<div class="kpi"><div class="label">Churn (30d)</div><div class="value">2.1%</div><div class="delta down">+0.4 pp</div></div>
<div class="kpi"><div class="label">P95 latency</div><div class="value">182 ms</div><div class="delta up">-23 ms</div></div>
</div>
<div class="panels-row">
<div class="panel" data-od-id="chart-panel">
<h3>Revenue · 30 days</h3>
<div class="chart">
<svg viewBox="0 0 600 240" preserveAspectRatio="none">
<polyline fill="none" stroke="#c96442" stroke-width="2" points="0,180 30,170 60,150 90,160 120,140 150,120 180,130 210,110 240,90 270,100 300,80 330,70 360,80 390,60 420,50 450,60 480,40 510,30 540,40 570,20 600,10" />
</svg>
</div>
</div>
<div class="panel" data-od-id="signups-panel">
<h3>New accounts</h3>
<table>
<thead><tr><th>Account</th><th>Plan</th><th>Status</th></tr></thead>
<tbody>
<tr><td>Linear</td><td>Team</td><td><span class="pill good">active</span></td></tr>
<tr><td>Cursor</td><td>Pro</td><td><span class="pill good">active</span></td></tr>
<tr><td>Notion</td><td>Team</td><td><span class="pill bad">trial</span></td></tr>
<tr><td>Vercel</td><td>Enterprise</td><td><span class="pill good">active</span></td></tr>
</tbody>
</table>
</div>
</div>
<div class="panel" data-od-id="recent-events">
<h3>Recent events</h3>
<table>
<thead><tr><th>Time</th><th>Account</th><th>Event</th><th>Plan</th></tr></thead>
<tbody>
<tr><td>2:14 pm</td><td>Acme Co</td><td>Upgraded to Team</td><td>Team</td></tr>
<tr><td>1:48 pm</td><td>Northwind</td><td>Connected GitHub</td><td>Pro</td></tr>
<tr><td>1:32 pm</td><td>Globex</td><td>Cancelled subscription</td><td>Solo</td></tr>
<tr><td>12:51 pm</td><td>Initech</td><td>New seat invited</td><td>Team</td></tr>
</tbody>
</table>
</div>
</main>
</body>
</html>

View file

@ -0,0 +1,87 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "example-dashboard",
"title": "Dashboard",
"version": "0.1.0",
"description": "Admin / analytics dashboard in a single HTML file. Fixed left sidebar,\ntop bar with user/search, main grid of KPI cards and one or two charts.\nUse when the brief asks for a \"dashboard\", \"admin\", \"analytics\", or\n\"control panel\" screen.",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/dashboard",
"tags": [
"example",
"first-party",
"prototype",
"operations",
"web",
"desktop",
"dashboard",
"admin-panel",
"analytics",
"control-panel",
"untitled"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "prototype",
"platform": "desktop",
"scenario": "operations",
"surface": "web",
"preview": {
"type": "html",
"entry": "./example.html"
},
"useCase": {
"query": "Admin / analytics dashboard in a single HTML file. Fixed left sidebar, top bar with user/search, main grid of KPI cards and one or two charts. Use when the brief asks for a \"dashboard\", \"admin\", \"analytics\", or \"control panel\" screen.",
"exampleOutputs": [
{
"path": "./example.html",
"title": "Dashboard"
}
]
},
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"designSystem": {
"primary": true
},
"craft": [
"state-coverage",
"accessibility-baseline",
"laws-of-ux"
],
"assets": [
"./example.html"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,251 @@
---
name: html-ppt
description: HTML PPT Studio — author professional static HTML presentations in many styles, layouts, and animations, all driven by templates. Use when the user asks for a presentation, PPT, slides, keynote, deck, slideshow, "幻灯片", "演讲稿", "做一份 PPT", "做一份 slides", a reveal-style HTML deck, a 小红书 图文, or any kind of multi-slide pitch/report/sharing document that should look tasteful and be usable with keyboard navigation. Triggers include keywords like "presentation", "ppt", "slides", "deck", "keynote", "reveal", "slideshow", "幻灯片", "演讲稿", "分享稿", "小红书图文", "talk slides", "pitch deck", "tech sharing", "technical presentation".
triggers:
- "ppt"
- "deck"
- "slides"
- "presentation"
- "keynote"
- "reveal"
- "slideshow"
- "幻灯片"
- "演讲稿"
- "分享稿"
- "talk slides"
- "pitch deck"
- "tech sharing"
- "technical presentation"
od:
mode: deck
scenario: marketing
featured: 19
upstream: "https://github.com/lewislulu/html-ppt-skill"
preview:
type: html
entry: index.html
design_system:
requires: false
speaker_notes: true
animations: true
example_prompt: "用 html-ppt 做一份 12 页的 HTML PPT。先帮我确认三件事内容/页数/受众、主题(从 36 套里推荐 2-3 个)、起点全 deck 模板pitch-deck / tech-sharing / weekly-report / xhs-post / presenter-mode-reveal 任选一个),对齐之后再开始写 slides。"
---
# html-ppt — HTML PPT Studio
Author professional HTML presentations as static files. One theme file = one
look. One layout file = one page type. One animation class = one entry effect.
All pages share a token-based design system in `assets/base.css`.
## Install
```bash
npx skills add https://github.com/lewislulu/html-ppt-skill
```
One command, no build. Pure static HTML/CSS/JS with only CDN webfonts.
## What the skill gives you
- **36 themes** (`assets/themes/*.css`) — minimal-white, editorial-serif, soft-pastel, sharp-mono, arctic-cool, sunset-warm, catppuccin-latte/mocha, dracula, tokyo-night, nord, solarized-light, gruvbox-dark, rose-pine, neo-brutalism, glassmorphism, bauhaus, swiss-grid, terminal-green, xiaohongshu-white, rainbow-gradient, aurora, blueprint, memphis-pop, cyberpunk-neon, y2k-chrome, retro-tv, japanese-minimal, vaporwave, midcentury, corporate-clean, academic-paper, news-broadcast, pitch-deck-vc, magazine-bold, engineering-whiteprint
- **15 full-deck templates** (`templates/full-decks/<name>/`) — complete multi-slide decks with scoped `.tpl-<name>` CSS. 8 extracted from real-world decks (xhs-white-editorial, graphify-dark-graph, knowledge-arch-blueprint, hermes-cyber-terminal, obsidian-claude-gradient, testing-safety-alert, xhs-pastel-card, dir-key-nav-minimal), 7 scenario scaffolds (pitch-deck, product-launch, tech-sharing, weekly-report, xhs-post 3:4, course-module, **presenter-mode-reveal** — 演讲者模式专用)
- **31 layouts** (`templates/single-page/*.html`) with realistic demo data
- **27 CSS animations** (`assets/animations/animations.css`) via `data-anim`
- **20 canvas FX animations** (`assets/animations/fx/*.js`) via `data-fx` — particle-burst, confetti-cannon, firework, starfield, matrix-rain, knowledge-graph (force-directed), neural-net (pulses), constellation, orbit-ring, galaxy-swirl, word-cascade, letter-explode, chain-react, magnetic-field, data-stream, gradient-blob, sparkle-trail, shockwave, typewriter-multi, counter-explosion
- **Keyboard runtime** (`assets/runtime.js`) — arrows, T (theme), A (anim), F/O, **S (presenter mode: magnetic-card popup with CURRENT / NEXT / SCRIPT / TIMER cards)**, N (notes drawer), R (reset timer in presenter)
- **FX runtime** (`assets/animations/fx-runtime.js`) — auto-inits `[data-fx]` on slide enter, cleans up on leave
- **Showcase decks** for themes / layouts / animations / full-decks gallery
- **Headless Chrome render script** for PNG export
## When to use
Use when the user asks for any kind of slide-based output or wants to turn
text/notes into a presentable deck. Prefer this over building from scratch.
### 🎤 Presenter Mode (演讲者模式 + 逐字稿)
If the user mentions any of: **演讲 / 分享 / 讲稿 / 逐字稿 / speaker notes / presenter view / 演讲者视图 / 提词器**, or says things like "我要去给团队讲 xxx", "要做一场技术分享", "怕讲不流畅", "想要一份带逐字稿的 PPT" — **use the `presenter-mode-reveal` full-deck template** and write 150300 words of 逐字稿 in each slide's `<aside class="notes">`.
See [references/presenter-mode.md](references/presenter-mode.md) for the full authoring guide including the 3 rules of speaker script writing:
1. **不是讲稿,是提示信号** — 加粗核心词 + 过渡句独立成段
2. **每页 150300 字** — 23 分钟/页的节奏
3. **用口语,不用书面语** — "因此"→"所以""该方案"→"这个方案"
All full-deck templates support the S key presenter mode (it's built into `runtime.js`). **S opens a new popup window with 4 magnetic cards**:
- 🔵 **CURRENT** — pixel-perfect iframe preview of the current slide
- 🟣 **NEXT** — pixel-perfect iframe preview of the next slide
- 🟠 **SPEAKER SCRIPT** — large-font 逐字稿 (scrollable)
- 🟢 **TIMER** — elapsed time + slide counter + prev/next/reset buttons
Each card is **draggable by its header** and **resizable by the bottom-right corner handle**. Card positions/sizes persist to `localStorage` per deck. A "Reset layout" button restores the default arrangement.
**Why the previews are pixel-perfect**: each preview is an `<iframe>` that loads the actual deck HTML with a `?preview=N` query param; `runtime.js` detects this and renders only slide N with no chrome. So the preview uses the **same CSS, theme, fonts, and viewport as the audience view** — colors and layout are guaranteed identical.
**Smooth navigation**: on slide change, the presenter window sends `postMessage({type:'preview-goto', idx:N})` to each iframe. The iframe just toggles `.is-active` between slides — **no reload, no flicker**. The two windows also stay in sync via `BroadcastChannel`.
Only `presenter-mode-reveal` is designed from the ground up around the feature with proper example 逐字稿 on every slide.
Keyboard in presenter window: `← →` navigate (syncs audience) · `R` reset timer · `Esc` close popup.
Keyboard in audience window: `S` open presenter · `T` cycle theme · `← →` navigate (syncs presenter) · `F` fullscreen · `O` overview.
## Before you author anything — ALWAYS ask or recommend
**Do not start writing slides until you understand three things.** Either ask
the user directly, or — if they already handed you rich content — propose a
tasteful default and confirm.
1. **Content & audience.** What's the deck about, how many slides, who's
watching (engineers / execs / 小红书读者 / 学生 / VC)?
2. **Style / theme.** Which of the 36 themes fits? If unsure, recommend 2-3
candidates based on tone:
- Business / investor pitch → `pitch-deck-vc`, `corporate-clean`, `swiss-grid`
- Tech sharing / engineering → `tokyo-night`, `dracula`, `catppuccin-mocha`,
`terminal-green`, `blueprint`
- 小红书图文 → `xiaohongshu-white`, `soft-pastel`, `rainbow-gradient`,
`magazine-bold`
- Academic / report → `academic-paper`, `editorial-serif`, `minimal-white`
- Edgy / cyber / launch → `cyberpunk-neon`, `vaporwave`, `y2k-chrome`,
`neo-brutalism`
3. **Starting point.** One of the 14 full-deck templates, or scratch? Point
to the closest `templates/full-decks/<name>/` and ask if it fits. If the
user's content suggests something obvious (e.g. "我要做产品发布会" →
`product-launch`), propose it confidently instead of asking blindly.
A good opening message looks like:
> 我可以给你做这份 PPT先确认三件事
> 1. 大致内容 / 页数 / 观众是谁?
> 2. 风格偏好?我建议从这 3 个主题里选一个:`tokyo-night`(技术分享默认好看)、`xiaohongshu-white`(小红书风)、`corporate-clean`(正式汇报)。
> 3. 要不要用我现成的 `tech-sharing` 全 deck 模板打底?
Only after those are clear, scaffold the deck and start writing.
## Quick start
1. **Scaffold a new deck.** From the repo root:
```bash
./scripts/new-deck.sh my-talk
open examples/my-talk/index.html
```
2. **Pick a theme.** Open the deck and press `T` to cycle. Or hard-code it:
```html
<link rel="stylesheet" id="theme-link" href="../assets/themes/aurora.css">
```
Catalog in [references/themes.md](references/themes.md).
3. **Pick layouts.** Copy `<section class="slide">...</section>` blocks out of
files in `templates/single-page/` into your deck. Replace the demo data.
Catalog in [references/layouts.md](references/layouts.md).
4. **Add animations.** Put `data-anim="fade-up"` (or `class="anim-fade-up"`) on
any element. On `<ul>`/grids, use `anim-stagger-list` for sequenced reveals.
For canvas FX, use `<div data-fx="knowledge-graph">...</div>` and include
`<script src="../assets/animations/fx-runtime.js"></script>`.
Catalog in [references/animations.md](references/animations.md).
5. **Use a full-deck template.** Copy `templates/full-decks/<name>/` into
`examples/my-talk/` as a starting point. Each folder is self-contained with
scoped CSS. Catalog in [references/full-decks.md](references/full-decks.md)
and gallery at `templates/full-decks-index.html`.
6. **Render to PNG.**
```bash
./scripts/render.sh templates/theme-showcase.html # one shot
./scripts/render.sh examples/my-talk/index.html 12 # 12 slides
```
## Authoring rules (important)
- **Always start from a template.** Don't author slides from scratch — copy the
closest layout from `templates/single-page/` first, then replace content.
- **Use tokens, not literal colors.** Every color, radius, shadow should come
from CSS variables defined in `assets/base.css` and overridden by a theme.
Good: `color: var(--text-1)`. Bad: `color: #111`.
- **Don't invent new layout files.** Prefer composing existing ones. Only add
a new `templates/single-page/*.html` if none of the 30 fit.
- **Respect chrome slots.** `.deck-header`, `.deck-footer`, `.slide-number`
and the progress bar are provided by `assets/base.css` + `runtime.js`.
- **Keyboard-first.** Always include `<script src="../assets/runtime.js"></script>`
so the deck supports ← → / T / A / F / S / O / hash deep-links.
- **One `.slide` per logical page.** `runtime.js` makes `.slide.is-active`
visible; all others are hidden.
- **Supply notes.** Wrap speaker notes in `<div class="notes">…</div>` inside
each slide. Press S to open the overlay.
- **NEVER put presenter-only text on the slide itself.** Descriptive text like
"这一页展示了……" or "Speaker: 这里可以补充……" or small explanatory captions
aimed at the presenter MUST go inside `<div class="notes">`, NOT as visible
`<p>` / `<span>` elements on the slide. The `.notes` class is `display:none`
by default — it only appears in the S overlay. Slides should contain ONLY
audience-facing content (titles, bullet points, data, charts, images).
## Writing guide
See [references/authoring-guide.md](references/authoring-guide.md) for a
step-by-step walkthrough: file structure, naming, how to transform an outline
into a deck, how to choose layouts and themes per audience, how to do a
Chinese + English deck, and how to export.
## Catalogs (load when needed)
- [references/themes.md](references/themes.md) — all 36 themes with when-to-use.
- [references/layouts.md](references/layouts.md) — all 31 layout types.
- [references/animations.md](references/animations.md) — 27 CSS + 20 canvas FX animations.
- [references/full-decks.md](references/full-decks.md) — all 15 full-deck templates.
- [references/presenter-mode.md](references/presenter-mode.md) — **演讲者模式 + 逐字稿编写指南(技术分享/演讲必看)**.
- [references/authoring-guide.md](references/authoring-guide.md) — full workflow.
## File structure
```
html-ppt/
├── SKILL.md (this file)
├── references/ (detailed catalogs, load as needed)
├── assets/
│ ├── base.css (tokens + primitives — do not edit per deck)
│ ├── fonts.css (webfont imports)
│ ├── runtime.js (keyboard + presenter + overview + theme cycle)
│ ├── themes/*.css (36 token overrides, one per theme)
│ └── animations/
│ ├── animations.css (27 named CSS entry animations)
│ ├── fx-runtime.js (auto-init [data-fx] on slide enter)
│ └── fx/*.js (20 canvas FX modules: particles/graph/fireworks…)
├── templates/
│ ├── deck.html (minimal 6-slide starter)
│ ├── theme-showcase.html (36 slides, iframe-isolated per theme)
│ ├── layout-showcase.html (iframe tour of all 31 layouts)
│ ├── animation-showcase.html (20 FX + 27 CSS animation slides)
│ ├── full-decks-index.html (gallery of all 14 full-deck templates)
│ ├── full-decks/<name>/ (14 scoped multi-slide deck templates)
│ └── single-page/*.html (31 layout files with demo data)
├── scripts/
│ ├── new-deck.sh (scaffold a deck from deck.html)
│ └── render.sh (headless Chrome → PNG)
└── examples/demo-deck/ (complete working deck)
```
## Rendering to PNG
`scripts/render.sh` wraps headless Chrome at
`/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`. For multi-slide
capture, runtime.js exposes `#/N` deep-links, and render.sh iterates 1..N.
```bash
./scripts/render.sh templates/single-page/kpi-grid.html # single page
./scripts/render.sh examples/demo-deck/index.html 8 out-dir # 8 slides, custom dir
```
## Keyboard cheat sheet
```
← → Space PgUp PgDn Home End navigate
F fullscreen
S open presenter window (magnetic cards: current/next/script/timer)
N quick notes drawer (bottom overlay)
R reset timer (in presenter window)
?preview=N URL param — force preview-only mode (single slide, no chrome)
O slide overview grid
T cycle themes (reads data-themes attr)
A cycle demo animation on current slide
#/N in URL deep-link to slide N
Esc close all overlays
```
## License & author
MIT. Copyright (c) 2026 lewis &lt;sudolewis@gmail.com&gt;.

View file

@ -0,0 +1,150 @@
/* html-ppt :: base.css — reset + shared tokens + layout primitives */
/* Default tokens. Themes in assets/themes/*.css override the :root block. */
:root {
--bg: #ffffff;
--bg-soft: #f7f7f8;
--surface: #ffffff;
--surface-2: #f2f2f4;
--border: rgba(0,0,0,.08);
--border-strong: rgba(0,0,0,.16);
--text-1: #111216;
--text-2: #55596a;
--text-3: #8a8f9e;
--accent: #3b6cff;
--accent-2: #7a5cff;
--accent-3: #ff5c8a;
--good: #1aaf6c;
--warn: #f5a524;
--bad: #e0445a;
--grad: linear-gradient(135deg,#3b6cff,#7a5cff 55%,#ff5c8a);
--grad-soft: linear-gradient(135deg,#eef2ff,#f5ecff 55%,#ffeef5);
--radius: 18px;
--radius-sm: 12px;
--radius-lg: 26px;
--shadow: 0 10px 30px rgba(18,24,40,.08), 0 2px 6px rgba(18,24,40,.04);
--shadow-lg: 0 24px 60px rgba(18,24,40,.14), 0 6px 16px rgba(18,24,40,.06);
--font-sans: 'Inter','Noto Sans SC',-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif;
--font-serif: 'Playfair Display','Noto Serif SC',Georgia,serif;
--font-mono: 'JetBrains Mono','IBM Plex Mono',SFMono-Regular,Menlo,monospace;
--font-display: var(--font-sans);
--letter-tight: -.03em;
--letter-normal: -.01em;
--ease: cubic-bezier(.4,0,.2,1);
}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--text-1);
font-family:var(--font-sans);font-weight:400;line-height:1.6;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
letter-spacing:var(--letter-normal)}
img,svg,video{max-width:100%;display:block}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
code,kbd,pre,samp{font-family:var(--font-mono)}
/* ================= SLIDE SYSTEM ================= */
.deck{position:relative;width:100vw;height:100vh;overflow:hidden;background:var(--bg)}
.slide{
position:absolute;inset:0;
display:flex;flex-direction:column;justify-content:center;
padding:72px 96px;
box-sizing:border-box;
opacity:0;pointer-events:none;
transition:opacity .5s var(--ease), transform .5s var(--ease);
transform:translateX(30px);
overflow:hidden;
}
.slide.is-active{opacity:1;pointer-events:auto;transform:translateX(0);z-index:2}
.slide.is-prev{transform:translateX(-30px)}
/* single-page standalone (used when a layout file is opened directly) */
body.single .slide{position:relative;width:100vw;height:100vh;opacity:1;transform:none;pointer-events:auto}
/* ================= TYPOGRAPHY ================= */
.eyebrow{font-size:13px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;color:var(--text-3)}
.kicker{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.08em;text-transform:uppercase}
h1.title,.h1{font-family:var(--font-display);font-size:72px;line-height:1.05;font-weight:800;letter-spacing:var(--letter-tight);margin:0 0 18px;color:var(--text-1)}
h2.title,.h2{font-family:var(--font-display);font-size:54px;line-height:1.1;font-weight:700;letter-spacing:var(--letter-tight);margin:0 0 14px}
h3,.h3{font-size:32px;line-height:1.2;font-weight:600;letter-spacing:var(--letter-normal);margin:0 0 10px}
h4,.h4{font-size:22px;line-height:1.3;font-weight:600;margin:0 0 8px}
.lede{font-size:22px;line-height:1.55;color:var(--text-2);font-weight:300;max-width:62ch}
.dim{color:var(--text-2)}
.dim2{color:var(--text-3)}
.mono{font-family:var(--font-mono)}
.serif{font-family:var(--font-serif)}
.gradient-text{background:var(--grad);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent}
/* ================= LAYOUT PRIMITIVES ================= */
.stack>*+*{margin-top:14px}
.row{display:flex;gap:24px;align-items:center}
.row.wrap{flex-wrap:wrap}
.grid{display:grid;gap:24px}
.g2{grid-template-columns:repeat(2,1fr)}
.g3{grid-template-columns:repeat(3,1fr)}
.g4{grid-template-columns:repeat(4,1fr)}
.center{display:flex;align-items:center;justify-content:center;text-align:center}
.fill{flex:1}
.sp-t{padding-top:24px}.sp-b{padding-bottom:24px}
.mt-s{margin-top:8px}.mt-m{margin-top:18px}.mt-l{margin-top:32px}
.mb-s{margin-bottom:8px}.mb-m{margin-bottom:18px}.mb-l{margin-bottom:32px}
/* ================= CARDS ================= */
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:26px 28px;box-shadow:var(--shadow);position:relative;overflow:hidden}
.card-soft{background:var(--surface-2);border:1px solid var(--border)}
.card-outline{background:transparent;border:1.5px solid var(--border-strong);box-shadow:none}
.card-accent{background:var(--surface);border-top:3px solid var(--accent)}
.card-hover{transition:transform .3s var(--ease),box-shadow .3s var(--ease)}
.card-hover:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}
.pill{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:500;
background:var(--surface-2);color:var(--text-2);border:1px solid var(--border)}
.pill-accent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 28%,transparent)}
/* ================= BARS / DIVIDERS ================= */
.divider{height:1px;background:var(--border);width:100%}
.divider-accent{height:3px;width:72px;background:var(--accent);border-radius:2px}
/* ================= CHROME (header/footer/progress) ================= */
.deck-header{position:absolute;top:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);letter-spacing:.12em;text-transform:uppercase;z-index:10;pointer-events:none}
.deck-footer{position:absolute;bottom:24px;left:40px;right:40px;display:flex;align-items:center;justify-content:space-between;
font-size:12px;color:var(--text-3);z-index:10;pointer-events:none}
.slide-number::before{content:attr(data-current)}
.slide-number::after{content:" / " attr(data-total)}
.progress-bar{position:fixed;left:0;right:0;bottom:0;height:3px;background:transparent;z-index:20}
.progress-bar > span{display:block;height:100%;width:0;background:var(--accent);transition:width .3s var(--ease)}
/* ================= PRESENTER / OVERVIEW ================= */
.notes{display:none!important}
.notes-overlay{position:fixed;inset:auto 0 0 0;max-height:42vh;background:rgba(20,22,30,.95);color:#e8ebf4;
padding:20px 32px;font-size:16px;line-height:1.6;border-top:1px solid rgba(255,255,255,.1);transform:translateY(100%);
transition:transform .3s var(--ease);z-index:40;overflow:auto;font-family:var(--font-sans)}
.notes-overlay.open{transform:translateY(0)}
.overview{position:fixed;inset:0;background:rgba(10,12,18,.92);backdrop-filter:blur(12px);z-index:50;
display:none;padding:40px;overflow:auto}
.overview.open{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;align-content:start}
.overview .thumb{background:var(--surface);border:1px solid var(--border);border-radius:12px;
aspect-ratio:16/9;overflow:hidden;cursor:pointer;position:relative;color:var(--text-1);padding:16px;
font-size:11px;transition:transform .2s var(--ease)}
.overview .thumb:hover{transform:scale(1.04)}
.overview .thumb .n{position:absolute;top:8px;left:10px;font-weight:700;font-size:14px;color:var(--text-3)}
.overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)}
/* ================= PRESENTER VIEW ================= */
/* Presenter view opens in a separate popup window (S key).
* All presenter styles are self-contained in the popup HTML generated by runtime.js.
* The audience window (this file) is NOT affected it stays as normal deck view.
* Only the .notes class below is needed to hide speaker notes from audience. */
/* ================= UTILITY ================= */
.hidden{display:none!important}
.nowrap{white-space:nowrap}
.tr{text-align:right}.tc{text-align:center}.tl{text-align:left}
.uppercase{text-transform:uppercase;letter-spacing:.12em}
/* ================= PRINT ================= */
@media print{
.slide{position:relative;opacity:1!important;transform:none!important;page-break-after:always;height:100vh}
.deck-header,.deck-footer,.progress-bar,.notes-overlay,.overview{display:none!important}
}

View file

@ -0,0 +1,9 @@
/* html-ppt :: shared webfonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@200;300;400;500;600;700;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@300;400;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,600;0,800;1,400&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');

View file

@ -0,0 +1,960 @@
/* html-ppt :: runtime.js
* Keyboard-driven deck runtime. Zero dependencies.
*
* Features:
* / space / PgUp PgDn / Home End navigation
* F fullscreen
* S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
* The original window stays as audience view, synced via BroadcastChannel.
* Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout.
* N quick notes overlay (bottom drawer)
* O slide overview grid
* T cycle themes (reads data-themes on <html> or <body>)
* A cycle demo animation on current slide
* URL hash #/N deep-link to slide N (1-based)
* Progress bar auto-managed
*/
(function () {
'use strict';
const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in',
'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep',
'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt',
'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom',
'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal'];
function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
/* ========== Parse URL for preview-only mode ==========
* When loaded as iframe.src = "index.html?preview=3", runtime enters a
* locked single-slide mode: only slide N is visible, no chrome, no keys,
* no hash updates. This is how the presenter window shows pixel-perfect
* previews by loading the actual deck file in an iframe and telling it
* to display only a specific slide.
*/
function getPreviewIdx() {
const m = /[?&]preview=(\d+)/.exec(location.search || '');
return m ? parseInt(m[1], 10) - 1 : -1;
}
ready(function () {
const deck = document.querySelector('.deck');
if (!deck) return;
const slides = Array.from(deck.querySelectorAll('.slide'));
if (!slides.length) return;
const previewOnlyIdx = getPreviewIdx();
const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
/* ===== Preview-only mode: show one slide, hide everything else ===== */
if (isPreviewMode) {
function showSlide(i) {
slides.forEach((s, j) => {
const active = (j === i);
s.classList.toggle('is-active', active);
s.style.display = active ? '' : 'none';
if (active) {
s.style.opacity = '1';
s.style.transform = 'none';
s.style.pointerEvents = 'auto';
}
});
}
showSlide(previewOnlyIdx);
/* Hide chrome that the presenter shouldn't see in preview */
const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
document.documentElement.setAttribute('data-preview', '1');
document.body.setAttribute('data-preview', '1');
/* Auto-detect theme base path for theme switching in preview mode */
function getPreviewThemeBase() {
const base = document.documentElement.getAttribute('data-theme-base');
if (base) return base;
const tl = document.getElementById('theme-link');
if (tl) {
const raw = tl.getAttribute('href') || '';
const ls = raw.lastIndexOf('/');
if (ls >= 0) return raw.substring(0, ls + 1);
}
return 'assets/themes/';
}
const previewThemeBase = getPreviewThemeBase();
/* Listen for postMessage from parent presenter window:
* - preview-goto: switch visible slide WITHOUT reloading
* - preview-theme: switch theme CSS link to match audience window */
window.addEventListener('message', function(e) {
if (!e.data) return;
if (e.data.type === 'preview-goto') {
const n = parseInt(e.data.idx, 10);
if (n >= 0 && n < slides.length) showSlide(n);
} else if (e.data.type === 'preview-theme' && e.data.name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = previewThemeBase + e.data.name + '.css';
document.documentElement.setAttribute('data-theme', e.data.name);
}
});
/* Signal to parent that preview iframe is ready */
try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
return;
}
let idx = 0;
const total = slides.length;
/* ===== BroadcastChannel for presenter sync ===== */
const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
let bc;
try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
// Are we running inside the presenter popup? (legacy flag, now unused)
const isPresenterWindow = false;
/* ===== progress bar ===== */
let bar = document.querySelector('.progress-bar');
if (!bar) {
bar = document.createElement('div');
bar.className = 'progress-bar';
bar.innerHTML = '<span></span>';
document.body.appendChild(bar);
}
const barFill = bar.querySelector('span');
/* ===== notes overlay (N key) ===== */
let notes = document.querySelector('.notes-overlay');
if (!notes) {
notes = document.createElement('div');
notes.className = 'notes-overlay';
document.body.appendChild(notes);
}
/* ===== overview grid (O key) ===== */
let overview = document.querySelector('.overview');
if (!overview) {
overview = document.createElement('div');
overview.className = 'overview';
slides.forEach((s, i) => {
const t = document.createElement('div');
t.className = 'thumb';
// Force 16:9 aspect ratio robustly
t.style.padding = '0 0 56.25% 0';
t.style.height = '0';
t.style.position = 'relative';
t.style.overflow = 'hidden';
const title = s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
// Create a container for the mini-slide
const mini = document.createElement('div');
mini.className = 'mini-slide';
mini.style.position = 'absolute';
mini.style.top = '0';
mini.style.left = '0';
mini.style.width = '1920px';
mini.style.height = '1080px';
mini.style.transformOrigin = 'top left';
mini.style.pointerEvents = 'none';
mini.style.background = 'var(--bg)';
// Clone the slide content
const clone = s.cloneNode(true);
clone.className = 'slide is-active'; // force active styles
clone.style.position = 'absolute';
clone.style.inset = '0';
clone.style.transform = 'none';
clone.style.opacity = '1';
clone.style.padding = '72px 96px'; // ensure padding is kept
mini.appendChild(clone);
t.appendChild(mini);
// Add the number and title overlay
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.inset = '0';
overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)';
overlay.style.color = '#fff';
overlay.style.zIndex = '10';
overlay.style.pointerEvents = 'none';
const n = document.createElement('div');
n.className = 'n';
n.textContent = i + 1;
n.style.position = 'absolute';
n.style.top = '12px';
n.style.left = '16px';
n.style.fontWeight = '700';
n.style.fontSize = '16px';
n.style.color = '#fff';
n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
const text = document.createElement('div');
text.className = 't';
text.textContent = title.trim().slice(0,80);
text.style.position = 'absolute';
text.style.bottom = '12px';
text.style.left = '16px';
text.style.right = '16px';
text.style.fontWeight = '600';
text.style.fontSize = '14px';
text.style.color = '#fff';
text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
overlay.appendChild(n);
overlay.appendChild(text);
t.appendChild(overlay);
t.addEventListener('click', () => { go(i); toggleOverview(false); });
overview.appendChild(t);
});
document.body.appendChild(overview);
}
/* ===== navigation ===== */
function go(n, fromRemote){
n = Math.max(0, Math.min(total-1, n));
slides.forEach((s,i) => {
s.classList.toggle('is-active', i===n);
s.classList.toggle('is-prev', i<n);
});
idx = n;
barFill.style.width = ((n+1)/total*100)+'%';
const numEl = document.querySelector('.slide-number');
if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
// notes (bottom overlay)
const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
notes.innerHTML = note ? note.innerHTML : '';
// hash
const hashTarget = '#/'+(n+1);
if (location.hash !== hashTarget && !isPresenterWindow) {
history.replaceState(null,'', hashTarget);
}
// re-trigger entry animations
slides[n].querySelectorAll('[data-anim]').forEach(el => {
const a = el.getAttribute('data-anim');
el.classList.remove('anim-'+a);
void el.offsetWidth;
el.classList.add('anim-'+a);
});
// counter-up
slides[n].querySelectorAll('.counter').forEach(el => {
const target = parseFloat(el.getAttribute('data-to')||el.textContent);
const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
const start = performance.now();
const from = 0;
function tick(now){
const t = Math.min(1,(now-start)/dur);
const v = from + (target-from)*(1-Math.pow(1-t,3));
el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
if (t<1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
// Broadcast to other window (audience ↔ presenter)
if (!fromRemote && bc) {
bc.postMessage({ type: 'go', idx: n });
}
}
/* ===== listen for remote navigation / theme changes ===== */
if (bc) {
bc.onmessage = function(e) {
if (!e.data) return;
if (e.data.type === 'go' && typeof e.data.idx === 'number') {
go(e.data.idx, true);
} else if (e.data.type === 'theme' && e.data.name) {
/* Sync theme across windows */
const i = themes.indexOf(e.data.name);
if (i >= 0) themeIdx = i;
applyTheme(e.data.name);
}
};
}
function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
function toggleOverview(force){
const isOpen = force!==undefined ? force : !overview.classList.contains('open');
overview.classList.toggle('open', isOpen);
if (isOpen) {
requestAnimationFrame(() => {
const thumbs = overview.querySelectorAll('.thumb');
if (thumbs.length) {
const scale = thumbs[0].clientWidth / 1920;
overview.querySelectorAll('.mini-slide').forEach(m => {
m.style.transform = 'scale(' + scale + ')';
});
}
});
}
}
/* ========== PRESENTER MODE — Magnetic-card popup window ========== */
/* Opens a new window with 4 draggable, resizable cards:
* CURRENT iframe(?preview=N) pixel-perfect preview of current slide
* NEXT iframe(?preview=N+1) pixel-perfect preview of next slide
* SCRIPT large speaker notes (逐字稿)
* TIMER elapsed timer + page counter + controls
* Cards remember position/size in localStorage.
* Two windows sync via BroadcastChannel.
*/
let presenterWin = null;
function openPresenterWindow() {
if (presenterWin && !presenterWin.closed) {
presenterWin.focus();
return;
}
// Build absolute URL of THIS deck file (without hash/query)
const deckUrl = location.protocol + '//' + location.host + location.pathname;
// Collect slide titles + notes (HTML strings)
const slideMeta = slides.map((s, i) => {
const note = s.querySelector('.notes, aside.notes, .speaker-notes');
return {
title: s.getAttribute('data-title') ||
(s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
notes: note ? note.innerHTML : ''
};
});
/* Capture current theme so presenter previews match the audience */
const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
if (!presenterWin) {
alert('请允许弹出窗口以使用演讲者视图');
return;
}
presenterWin.document.open();
presenterWin.document.write(presenterHTML);
presenterWin.document.close();
}
function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
const metaJSON = JSON.stringify(slideMeta);
const deckUrlJSON = JSON.stringify(deckUrl);
const channelJSON = JSON.stringify(channelName);
const themeJSON = JSON.stringify(currentTheme || '');
const storageKey = 'html-ppt-presenter:' + location.pathname;
// Build the document as a single template string for clarity
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Presenter View</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%; overflow: hidden;
background: #1a1d24;
background-image:
radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
}
/* Stage: positioned area where cards live */
#stage { position: absolute; inset: 0; overflow: hidden; }
/* Magnetic card */
.pcard {
position: absolute;
background: #0d1117;
border: 1px solid rgba(255,255,255,.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
display: flex; flex-direction: column;
overflow: hidden;
min-width: 180px; min-height: 100px;
transition: box-shadow .2s, border-color .2s;
}
.pcard.dragging { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(88,166,255,.5); border-color: #58a6ff; transition: none; z-index: 9999; }
.pcard.resizing { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(63,185,80,.5); border-color: #3fb950; transition: none; z-index: 9999; }
.pcard:hover { border-color: rgba(88,166,255,.3); }
/* Card header (drag handle) */
.pcard-head {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: rgba(255,255,255,.04);
border-bottom: 1px solid rgba(255,255,255,.06);
cursor: move;
user-select: none;
flex-shrink: 0;
}
.pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
.pcard-title {
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
font-weight: 700; color: #8b949e; flex: 1;
}
.pcard-meta { font-size: 11px; color: #6e7681; }
/* Card body */
.pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
/* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
.pcard-preview .pcard-body { background: #000; }
.pcard-preview iframe {
position: absolute; top: 0; left: 0;
width: 1920px; height: 1080px;
border: none;
transform-origin: top left;
pointer-events: none;
background: transparent;
}
.pcard-preview .preview-end {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #484f58; font-size: 14px; letter-spacing: .12em;
}
/* Notes card */
.pcard-notes .pcard-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 18px; line-height: 1.75;
color: #d0d7de;
font-family: "Noto Sans SC", -apple-system, sans-serif;
}
.pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
.pcard-notes .pcard-body strong { color: #f0883e; }
.pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
.pcard-notes .pcard-body code {
font-family: "SF Mono", monospace; font-size: .9em;
background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
}
.pcard-notes .empty { color: #484f58; font-style: italic; }
/* Timer card */
.pcard-timer .pcard-body {
display: flex; flex-direction: column; gap: 14px;
padding: 18px 20px; justify-content: center;
}
.timer-display {
font-family: "SF Mono", "JetBrains Mono", monospace;
font-size: 42px; font-weight: 700;
color: #3fb950;
letter-spacing: .04em;
line-height: 1;
}
.timer-row {
display: flex; align-items: center; gap: 12px;
font-size: 14px; color: #8b949e;
}
.timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
.timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
.timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
.timer-btn {
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
}
.timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
.timer-btn:active { transform: translateY(1px); }
/* Resize handle */
.pcard-resize {
position: absolute; right: 0; bottom: 0;
width: 18px; height: 18px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 60%, transparent 60%, transparent 70%, rgba(255,255,255,.25) 70%, rgba(255,255,255,.25) 80%, transparent 80%);
z-index: 5;
}
.pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
/* Bottom hint bar */
.hint-bar {
position: fixed; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,.6);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255,255,255,.08);
padding: 6px 16px;
font-size: 11px; color: #8b949e;
display: flex; gap: 18px; align-items: center;
z-index: 1000;
}
.hint-bar kbd {
background: rgba(255,255,255,.08);
padding: 1px 6px; border-radius: 3px;
font-family: "SF Mono", monospace;
font-size: 10px;
border: 1px solid rgba(255,255,255,.1);
color: #e6edf3;
}
.hint-bar .reset-layout {
margin-left: auto;
background: transparent; border: 1px solid rgba(255,255,255,.15);
color: #8b949e; padding: 3px 10px; border-radius: 4px;
font-size: 11px; cursor: pointer; font-family: inherit;
}
.hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
body.is-dragging-card * { user-select: none !important; }
body.is-dragging-card iframe { pointer-events: none !important; }
</style>
</head>
<body>
<div id="stage">
<div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">CURRENT</span>
<span class="pcard-meta" id="cur-meta"></span>
</div>
<div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">NEXT</span>
<span class="pcard-meta" id="nxt-meta"></span>
</div>
<div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
</div>
<div class="pcard-body" id="notes-body"></div>
<div class="pcard-resize" data-resize></div>
</div>
<div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
<div class="pcard-head" data-drag>
<span class="pcard-dot"></span>
<span class="pcard-title">TIMER</span>
</div>
<div class="pcard-body">
<div class="timer-display" id="timer-display">00:00</div>
<div class="timer-row">
<span class="label">Slide</span>
<span class="val" id="timer-count">1 / ${total}</span>
</div>
<div class="timer-controls">
<button class="timer-btn" id="btn-prev"> Prev</button>
<button class="timer-btn" id="btn-next">Next </button>
<button class="timer-btn" id="btn-reset"> Reset</button>
</div>
</div>
<div class="pcard-resize" data-resize></div>
</div>
</div>
<div class="hint-bar">
<span><kbd> </kbd> </span>
<span><kbd>R</kbd> </span>
<span><kbd>Esc</kbd> </span>
<span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
<button class="reset-layout" id="reset-layout">重置布局</button>
</div>
<script>
(function(){
var slideMeta = ${metaJSON};
var total = ${total};
var idx = ${startIdx};
var deckUrl = ${deckUrlJSON};
var STORAGE_KEY = ${JSON.stringify(storageKey)};
var bc;
try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
var iframeCur = document.getElementById('iframe-cur');
var iframeNxt = document.getElementById('iframe-nxt');
var notesBody = document.getElementById('notes-body');
var curMeta = document.getElementById('cur-meta');
var nxtMeta = document.getElementById('nxt-meta');
var timerDisplay = document.getElementById('timer-display');
var timerCount = document.getElementById('timer-count');
/* ===== Default card layout ===== */
function defaultLayout() {
var w = window.innerWidth;
var h = window.innerHeight - 36; /* leave room for hint bar */
return {
'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
'card-nxt': { x: Math.round(w*0.55) + 8, y: 16, w: w - Math.round(w*0.55) - 24, h: Math.round(h*0.42) - 16 },
'card-notes': { x: Math.round(w*0.55) + 8, y: Math.round(h*0.42) + 8, w: w - Math.round(w*0.55) - 24, h: h - Math.round(h*0.42) - 16 },
'card-timer': { x: 16, y: Math.round(h*0.62) + 8, w: Math.round(w*0.55) - 24, h: h - Math.round(h*0.62) - 16 }
};
}
/* ===== Apply / save / restore layout ===== */
function applyLayout(layout) {
Object.keys(layout).forEach(function(id){
var el = document.getElementById(id);
var l = layout[id];
if (el && l) {
el.style.left = l.x + 'px';
el.style.top = l.y + 'px';
el.style.width = l.w + 'px';
el.style.height = l.h + 'px';
}
});
rescaleAll();
}
function readLayout() {
try {
var saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
} catch(e) {}
return defaultLayout();
}
function saveLayout() {
var layout = {};
['card-cur','card-nxt','card-notes','card-timer'].forEach(function(id){
var el = document.getElementById(id);
if (el) {
layout[id] = {
x: parseInt(el.style.left,10) || 0,
y: parseInt(el.style.top,10) || 0,
w: parseInt(el.style.width,10) || 300,
h: parseInt(el.style.height,10) || 200
};
}
});
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
}
/* ===== iframe rescale to fit card body ===== */
function rescaleIframe(iframe) {
if (!iframe || iframe.style.display === 'none') return;
var body = iframe.parentElement;
var cw = body.clientWidth, ch = body.clientHeight;
if (!cw || !ch) return;
var s = Math.min(cw / 1920, ch / 1080);
iframe.style.transform = 'scale(' + s + ')';
/* Center the scaled iframe in the body */
var sw = 1920 * s, sh = 1080 * s;
iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
}
function rescaleAll() {
rescaleIframe(iframeCur);
rescaleIframe(iframeNxt);
}
window.addEventListener('resize', rescaleAll);
/* ===== Drag (move card by header) ===== */
document.querySelectorAll('[data-drag]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault();
card.classList.add('dragging');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startL = parseInt(card.style.left,10) || 0;
var startT = parseInt(card.style.top,10) || 0;
function onMove(ev){
var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
card.style.left = nx + 'px';
card.style.top = ny + 'px';
}
function onUp(){
card.classList.remove('dragging');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Resize (drag bottom-right corner) ===== */
document.querySelectorAll('[data-resize]').forEach(function(handle){
handle.addEventListener('mousedown', function(e){
if (e.button !== 0) return;
var card = handle.closest('.pcard');
if (!card) return;
e.preventDefault(); e.stopPropagation();
card.classList.add('resizing');
document.body.classList.add('is-dragging-card');
var startX = e.clientX, startY = e.clientY;
var startW = parseInt(card.style.width,10) || card.offsetWidth;
var startH = parseInt(card.style.height,10) || card.offsetHeight;
function onMove(ev){
var nw = Math.max(180, startW + ev.clientX - startX);
var nh = Math.max(100, startH + ev.clientY - startY);
card.style.width = nw + 'px';
card.style.height = nh + 'px';
if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
}
function onUp(){
card.classList.remove('resizing');
document.body.classList.remove('is-dragging-card');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
rescaleAll();
saveLayout();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
/* ===== Preview iframe ready tracking =====
* Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
* slide changes are sent via postMessage('preview-goto') so the iframe
* just toggles visibility of a different .slide no reload, no flicker.
*/
var iframeReady = { cur: false, nxt: false };
var currentTheme = ${themeJSON};
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'preview-ready') return;
var iframe = null;
if (e.source === iframeCur.contentWindow) {
iframeReady.cur = true;
iframe = iframeCur;
postPreviewGoto(iframeCur, idx);
} else if (e.source === iframeNxt.contentWindow) {
iframeReady.nxt = true;
iframe = iframeNxt;
postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
}
/* Sync current theme to the iframe */
if (iframe && currentTheme) {
try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
}
if (iframe) rescaleIframe(iframe);
});
function postPreviewGoto(iframe, n) {
try {
iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
} catch(e) {}
}
/* ===== Update content =====
* Smooth (no-reload) navigation: send postMessage to iframes instead of
* resetting src. Iframes stay loaded, just switch visible .slide.
*/
function update(n) {
n = Math.max(0, Math.min(total - 1, n));
idx = n;
/* Current preview — postMessage (smooth) */
if (iframeReady.cur) postPreviewGoto(iframeCur, n);
curMeta.textContent = (n + 1) + '/' + total;
/* Next preview */
if (n + 1 < total) {
iframeNxt.style.display = '';
var endEl = document.querySelector('#card-nxt .preview-end');
if (endEl) endEl.remove();
if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
nxtMeta.textContent = (n + 2) + '/' + total;
} else {
iframeNxt.style.display = 'none';
var body = document.querySelector('#card-nxt .pcard-body');
if (body && !body.querySelector('.preview-end')) {
var end = document.createElement('div');
end.className = 'preview-end';
end.textContent = '— END OF DECK —';
body.appendChild(end);
}
nxtMeta.textContent = 'END';
}
/* Notes */
var note = slideMeta[n].notes;
notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
/* Timer count */
timerCount.textContent = (n + 1) + ' / ' + total;
}
/* ===== Timer ===== */
var tStart = Date.now();
setInterval(function(){
var s = Math.floor((Date.now() - tStart) / 1000);
var mm = String(Math.floor(s/60)).padStart(2,'0');
var ss = String(s%60).padStart(2,'0');
timerDisplay.textContent = mm + ':' + ss;
}, 1000);
function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
/* ===== BroadcastChannel sync ===== */
if (bc) {
bc.onmessage = function(e){
if (!e.data) return;
if (e.data.type === 'go') update(e.data.idx);
else if (e.data.type === 'theme' && e.data.name) {
currentTheme = e.data.name;
/* Forward theme change to preview iframes */
[iframeCur, iframeNxt].forEach(function(iframe){
try {
iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
} catch(err) {}
});
}
};
}
function go(n) {
update(n);
if (bc) bc.postMessage({ type: 'go', idx: idx });
}
/* ===== Buttons ===== */
document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
document.getElementById('btn-reset').addEventListener('click', resetTimer);
document.getElementById('reset-layout').addEventListener('click', function(){
if (confirm('恢复默认卡片布局?')) {
try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
applyLayout(defaultLayout());
}
});
/* ===== Keyboard ===== */
document.addEventListener('keydown', function(e){
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch(e.key) {
case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total - 1); break;
case 'r': case 'R': resetTimer(); break;
case 'Escape': window.close(); break;
}
});
/* ===== Iframe load → rescale (catches initial size) ===== */
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
/* ===== Init =====
* Load each iframe ONCE with the deck file. After they post
* 'preview-ready', all subsequent navigation is via postMessage
* (smooth, no reload, no flicker).
*/
applyLayout(readLayout());
iframeCur.src = deckUrl + '?preview=' + (idx + 1);
if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
/* Initialize notes/timer/count without touching iframes */
notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
curMeta.textContent = (idx + 1) + '/' + total;
nxtMeta.textContent = (idx + 2) + '/' + total;
timerCount.textContent = (idx + 1) + ' / ' + total;
})();
</` + `script>
</body></html>`;
}
function fullscreen(){ const el=document.documentElement;
if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
else document.exitFullscreen&&document.exitFullscreen();
}
// theme cycling
const root = document.documentElement;
const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
let themeIdx = 0;
// Auto-detect theme base path from existing <link id="theme-link">
let themeBase = root.getAttribute('data-theme-base');
if (!themeBase) {
const existingLink = document.getElementById('theme-link');
if (existingLink) {
// el.getAttribute('href') gives the raw relative path written in HTML
const rawHref = existingLink.getAttribute('href') || '';
const lastSlash = rawHref.lastIndexOf('/');
themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
} else {
themeBase = 'assets/themes/';
}
}
function applyTheme(name) {
let link = document.getElementById('theme-link');
if (!link) {
link = document.createElement('link');
link.rel = 'stylesheet';
link.id = 'theme-link';
document.head.appendChild(link);
}
link.href = themeBase + name + '.css';
root.setAttribute('data-theme', name);
const ind = document.querySelector('.theme-indicator');
if (ind) ind.textContent = name;
}
function cycleTheme(fromRemote){
if (!themes.length) return;
themeIdx = (themeIdx+1) % themes.length;
const name = themes[themeIdx];
applyTheme(name);
/* Broadcast to other window (audience ↔ presenter) */
if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
}
// animation cycling on current slide
let animIdx = 0;
function cycleAnim(){
animIdx = (animIdx+1) % ANIMS.length;
const a = ANIMS[animIdx];
const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
ANIMS.forEach(x => target.classList.remove('anim-'+x));
void target.offsetWidth;
target.classList.add('anim-'+a);
target.setAttribute('data-anim', a);
const ind = document.querySelector('.anim-indicator');
if (ind) ind.textContent = a;
}
document.addEventListener('keydown', function (e) {
if (e.metaKey||e.ctrlKey||e.altKey) return;
switch (e.key) {
case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
case 'Home': go(0); break;
case 'End': go(total-1); break;
case 'f': case 'F': fullscreen(); break;
case 's': case 'S': openPresenterWindow(); break;
case 'n': case 'N': toggleNotes(); break;
case 'o': case 'O': toggleOverview(); break;
case 't': case 'T': cycleTheme(); break;
case 'a': case 'A': cycleAnim(); break;
case 'Escape': toggleOverview(false); toggleNotes(false); break;
}
});
// hash deep-link
function fromHash(){
const m = /^#\/(\d+)/.exec(location.hash||'');
if (m) go(Math.max(0, parseInt(m[1],10)-1));
}
window.addEventListener('hashchange', fromHash);
fromHash();
go(idx);
});
})();

View file

@ -0,0 +1,85 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "example-html-ppt",
"title": "Html Ppt",
"version": "0.1.0",
"description": "HTML PPT Studio — author professional static HTML presentations in many styles, layouts, and animations, all driven by templates. Use when the user asks for a presentation, PPT, slides, keynote, deck, slideshow, \"幻灯片\", \"演讲稿\", \"做一份 PPT\", \"做一份 slides\", a reveal-style HTML deck, a 小红书 图文, or any kind of multi-slide pitch/report/sharing document that should look tasteful and be usable with keyboard navigation. Triggers include keywords like \"presentation\", \"ppt\", \"slides\", \"deck\", \"keynote\", \"reveal\", \"slideshow\", \"幻灯片\", \"演讲稿\", \"分享稿\", \"小红书图文\", \"talk slides\", \"pitch deck\", \"tech sharing\", \"technical presentation\".",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/html-ppt",
"tags": [
"example",
"first-party",
"deck",
"marketing",
"web",
"ppt",
"slides",
"presentation",
"keynote",
"reveal",
"slideshow",
"untitled",
"talk-slides",
"pitch-deck",
"tech-sharing",
"technical-presentation"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "deck",
"scenario": "marketing",
"surface": "web",
"preview": {
"type": "html",
"entry": "./index.html"
},
"useCase": {
"query": "用 html-ppt 做一份 12 页的 HTML PPT。先帮我确认三件事内容/页数/受众、主题(从 36 套里推荐 2-3 个)、起点全 deck 模板pitch-deck / tech-sharing / weekly-report / xhs-post / presenter-mode-reveal 任选一个),对齐之后再开始写 slides。"
},
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"assets": [
"./assets/base.css",
"./assets/fonts.css",
"./assets/runtime.js",
"./references/animations.md",
"./references/authoring-guide.md",
"./references/full-decks.md",
"./references/layouts.md",
"./references/presenter-mode.md",
"./references/themes.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,147 @@
# Animations catalog
All animations live in `assets/animations/animations.css`. Apply them by
adding `class="anim-<name>"` OR `data-anim="<name>"` to any element
(`runtime.js` re-triggers `data-anim` elements whenever a slide becomes
active, so you get the entry effect every time you navigate onto the slide).
Open `templates/animation-showcase.html` to browse all of them — one slide
per animation, auto-playing on slide enter. Press **A** on any slide to cycle
a random animation on the current page.
## Directional fades
| name | effect | use for |
|---|---|---|
| `fade-up` | Translate from +32 px, fade. | Default for paragraph + card entry. |
| `fade-down` | Translate from -32 px, fade. | Headers / banners / callouts. |
| `fade-left` | Translate from -40 px. | Left column in a two-column layout. |
| `fade-right` | Translate from +40 px. | Right column in a two-column layout. |
## Dramatic entries
| name | effect | use for |
|---|---|---|
| `rise-in` | +60 px rise + blur-off. | Slide titles, hero headlines. |
| `drop-in` | -60 px drop + slight scale. | Banners, alert bars. |
| `zoom-pop` | Scale 0.6 → 1.04 → 1. | Buttons, stat numbers, CTAs. |
| `blur-in` | 18 px blur clears. | Cover page reveal. |
| `glitch-in` | Clip-path steps + jitter. | Tech / cyber / error states. |
## Text effects
| name | effect | use for |
|---|---|---|
| `typewriter` | Monospace-like type reveal. | One-liners, slogans. |
| `neon-glow` | Cyclic text-shadow pulse. | Terminal-green / dracula themes. |
| `shimmer-sweep` | White sheen passes across. | Metallic buttons, premium cards. |
| `gradient-flow` | Infinite horizontal gradient slide. | Brand wordmarks. |
## Lists & numbers
| name | effect | use for |
|---|---|---|
| `stagger-list` | Children rise-in one-by-one. | Any `<ul>` or `.grid`. |
| `counter-up` | Number ticks 0 → target. | KPI, stat-highlight pages. |
Counter markup:
```html
<span class="counter" data-to="1248">0</span>
```
## SVG / geometry
| name | effect | use for |
|---|---|---|
| `path-draw` | Strokes draw themselves. | Lines, arrows, diagrams. |
| `morph-shape` | Path `d` morph. | Background shapes. |
Put `class="anim-path-draw"` on `<svg>`; every path/line/circle inside gets drawn.
## 3D & perspective
| name | effect | use for |
|---|---|---|
| `parallax-tilt` | Hover → 3D tilt. | Hero cards, product shots. |
| `card-flip-3d` | Y-axis 90° flip. | Before/after reveal. |
| `cube-rotate-3d` | Rotate in from a cube side. | Section dividers. |
| `page-turn-3d` | Left-hinge page turn. | Editorial / story flows. |
| `perspective-zoom` | Pull from -400 Z. | Cover openings. |
## Ambient / continuous
| name | effect | use for |
|---|---|---|
| `marquee-scroll` | Infinite horizontal loop. | Client logo strips. |
| `kenburns` | 14 s slow zoom on images. | Hero backgrounds. |
| `confetti-burst` | Pseudo-element sparkle burst. | Thanks / win pages. |
| `spotlight` | Circular clip-path reveal. | Big reveal moments. |
| `ripple-reveal` | Corner-origin ripple reveal. | Section transitions. |
## Respecting motion preferences
All animations are disabled automatically when
`prefers-reduced-motion: reduce` is set. Do not override this.
## Tips
- Prefer `data-anim="..."` over `class="anim-..."` so that the runtime
re-triggers the animation whenever the slide becomes active.
- Use at most 1-2 distinct animation types on a single slide. Mixing 5 looks
messy.
- Stagger lists + a single hero entry = clean rhythm.
- For counter-up, pair with `stat-highlight.html` or `kpi-grid.html`.
## FX (canvas)
CSS animations are fire-and-forget entry effects. **FX** are live, continuously
running canvas/DOM effects that start when their slide becomes active and stop
when it leaves. They are loaded by `assets/animations/fx-runtime.js`, which
dynamically pulls every module under `assets/animations/fx/*.js` and watches
`.slide.is-active` to run lifecycle.
Add to any page:
```html
<script src="../assets/animations/fx-runtime.js"></script>
```
Then drop one of these into any slide:
```html
<div data-fx="particle-burst" style="width:100%;height:360px;"></div>
```
The container just needs a size — the FX auto-sizes a canvas to fit with
`ResizeObserver` + DPR correction. Colors read your theme (`--accent`,
`--accent-2`, `--ok`, `--warn`, `--danger`).
| name | effect | use case | trigger |
|---|---|---|---|
| `particle-burst` | Particles explode from center, gravity + fade, re-bursts every 2.5s. | Reveal moments, stat pages. | `<div data-fx="particle-burst">` |
| `confetti-cannon` | Colored rotating rects arcing from both bottom corners. | Thank you / success pages. | `<div data-fx="confetti-cannon">` |
| `firework` | Rockets from bottom explode into colored sparks, continuous. | Celebration, launch slides. | `<div data-fx="firework">` |
| `starfield` | 3D perspective starfield flying outward. | Sci-fi / deep space backgrounds. | `<div data-fx="starfield">` |
| `matrix-rain` | Falling green katakana + hex columns. | Cyber / security / data theme. | `<div data-fx="matrix-rain">` |
| `knowledge-graph` | Force-directed graph, 28 labeled nodes, ~50 edges, live physics. | Knowledge / RAG / graph slides. | `<div data-fx="knowledge-graph">` |
| `neural-net` | 4-6-6-3 feedforward net with pulses traveling along edges. | ML / model architecture slides. | `<div data-fx="neural-net">` |
| `constellation` | Drifting points, linked when within 150 px, opacity by distance. | Ambient hero backgrounds. | `<div data-fx="constellation">` |
| `orbit-ring` | 5 concentric rings with dots at different speeds, radial glow. | System / planet / layered concepts. | `<div data-fx="orbit-ring">` |
| `galaxy-swirl` | Logarithmic spiral of ~800 particles, slow rotation. | Cover pages, intros. | `<div data-fx="galaxy-swirl">` |
| `word-cascade` | Words fall from top, pile up at bottom. | Vocabulary / concept cloud slides. | `<div data-fx="word-cascade">` |
| `letter-explode` | Heading letters fly in from random directions, loops every ~4.5s. | Big titles, hero text. | `<div data-fx="letter-explode" data-fx-text-value="EXPLODE">` |
| `chain-react` | 8 circles with a domino pulse wave traveling across. | Pipeline / sequential flow. | `<div data-fx="chain-react">` |
| `magnetic-field` | Particles travel bezier/sin curves leaving trails. | Energy / flow / abstract. | `<div data-fx="magnetic-field">` |
| `data-stream` | Rows of scrolling hex/binary text, cyberpunk. | Data, API, security. | `<div data-fx="data-stream">` |
| `gradient-blob` | 4 drifting blurred radial gradients (additive). | Soft hero backgrounds. | `<div data-fx="gradient-blob">` |
| `sparkle-trail` | Pointer-driven sparkle emitter (auto-wiggles if idle). | Interactive reveal, hover canvases. | `<div data-fx="sparkle-trail">` |
| `shockwave` | Expanding rings from center on loop. | Impact, launch, alert. | `<div data-fx="shockwave">` |
| `typewriter-multi` | 3 lines typing concurrently with blinking block cursors (DOM). | Terminal, agent boot log. | `<div data-fx="typewriter-multi" data-fx-line1="> boot...">` |
| `counter-explosion` | Number counts 0 → target, bursts particles, resets after 4s. | KPI reveal, record highs. | `<div data-fx="counter-explosion" data-fx-to="2400">` |
FX tips:
- One FX per slide is almost always enough. Mix with regular CSS `data-anim`
effects for layered polish.
- The container needs an explicit size (height) — the canvas fills 100%.
- Every module respects theme custom properties. Set `--accent` / `--accent-2`
on the slide or element to recolor on the fly.
- Lifecycle is automatic: entering a slide starts the FX, leaving stops it and
frees the canvas. You can also call `window.__hpxReinit(el)` manually.

View file

@ -0,0 +1,141 @@
# Authoring guide
How to turn a user request ("make me a deck about X") into a finished
html-ppt deck. Follow these steps in order.
## 1. Understand the deck
Before touching files, clarify:
1. **Audience** — engineers? designers? executives? consumers?
2. **Length** — 5 min lightning? 20 min share? 45 min talk?
3. **Language** — Chinese, English, bilingual? (Noto Sans SC is preloaded.)
4. **Format** — on-screen live, PDF export, 小红书图文?
5. **Tone** — clinical / playful / editorial / cyber?
The audience + tone map to a theme; the length maps to slide count; the
format maps to runtime features (live → notes + T-cycle; PDF → page-break
CSS, already handled in `base.css`).
## 2. Pick a theme
Use `references/themes.md`. When in doubt:
- **Engineers**`catppuccin-mocha` / `tokyo-night` / `dracula`.
- **Designers / product**`editorial-serif` / `aurora` / `soft-pastel`.
- **Execs**`minimal-white` / `arctic-cool` / `swiss-grid`.
- **Consumers**`xiaohongshu-white` / `sunset-warm` / `soft-pastel`.
- **Cyber / CLI / infra**`terminal-green` / `blueprint` / `gruvbox-dark`.
- **Pitch / bold**`neo-brutalism` / `sharp-mono` / `bauhaus`.
- **Launch / product reveal**`glassmorphism` / `aurora`.
Wire the theme as `<link id="theme-link" href="../assets/themes/NAME.css">`
and list 3-5 alternatives in `data-themes` so the user can press T to audition.
## 3. Outline the deck
A solid 20-minute deck is usually:
```
cover → toc → section-divider #1 → [2-4 body pages] →
section-divider #2 → [2-4 body pages] → section-divider #3
[2-4 body pages] → cta → thanks
```
Pick 1 layout per page from `references/layouts.md`. Don't repeat the same
layout twice in a row.
## 4. Scaffold the deck
```bash
./scripts/new-deck.sh my-talk
```
This copies `templates/deck.html` into `examples/my-talk/index.html` with
paths rewritten. Add/remove `<section class="slide">` blocks to match your
outline.
## 5. Author each slide
For each outline item:
1. Open the matching single-page layout, e.g. `templates/single-page/kpi-grid.html`.
2. Copy the `<section class="slide">…</section>` block.
3. Paste into your deck.
4. Replace demo data with real data. Keep the class structure intact.
5. Set `data-title="..."` (used by the Overview grid).
6. Add `<div class="notes">…</div>` with speaker notes.
## 6. Add animations sparingly
Rules of thumb:
- Cover/title: `rise-in` or `blur-in`.
- Body content: `fade-up` for the hero element, `stagger-list` for grids/lists.
- Stat pages: `counter-up`.
- Section dividers: `perspective-zoom` or `cube-rotate-3d`.
- Closer: `confetti-burst` on the "Thanks" text.
Pick **one** accent animation per slide. Everything else should be calm.
## 7. Chinese + English decks
- Fonts are already imported in `fonts.css` (Noto Sans SC + Noto Serif SC).
- Use `lang="zh-CN"` on `<html>`.
- For bilingual titles, stack lines: `<h1 class="h1">主标题<br><span class="dim">English subtitle</span></h1>`.
- Keep English subtitles in a lighter weight (300) and dim color to avoid
visual competition.
## 8. Review in-browser
```bash
open examples/my-talk/index.html
```
Walk through every slide with ← →. Press:
- **O** — overview grid; catch any layout clipping.
- **T** — cycle themes; make sure nothing looks broken in any theme.
- **S** — open speaker notes; verify every slide has notes.
## 9. Export to PNG
```bash
# single slide
./scripts/render.sh examples/my-talk/index.html
# all slides (autodetect count by looking for .slide sections)
./scripts/render.sh examples/my-talk/index.html all
# explicit slide count + output dir
./scripts/render.sh examples/my-talk/index.html 12 out/my-talk-png
```
Output is 1920×1080 by default. Change in `render.sh` if the user wants 3:4
for 小红书图文 (1242×1660).
## 10. What to NOT do
- Don't hand-author from a blank file.
- Don't use raw hex colors in slide markup. Use tokens.
- Don't load heavy animation frameworks. Everything should stay within the
CSS/JS that already ships.
- Don't add more than one new template file unless a genuinely new layout
type is needed. Prefer composition.
- Don't delete slides from the showcase decks.
- **Don't put presenter-only text on the slide.** Any descriptive text,
narration cues, or explanations meant for the speaker (e.g. "这一页的重点是…",
"Note: mention X here", small grey captions explaining the slide's purpose)
MUST go inside `<div class="notes">`, not as visible elements. The `.notes`
div is hidden (`display:none`) and only shown via the S overlay. Slides
should contain ONLY audience-facing content.
## Troubleshooting
- **Theme doesn't switch with T**: check `data-themes` on `<body>` and
`data-theme-base` pointing to the themes directory relative to the HTML
file.
- **Fonts fall back**: make sure `fonts.css` is linked before the theme.
- **Chart.js colors wrong**: charts read CSS vars in JS; make sure they run
after the DOM is ready (`addEventListener('DOMContentLoaded', …)`).
- **PNG too small**: bump `--window-size` in `scripts/render.sh`.

View file

@ -0,0 +1,98 @@
# Full-Deck Templates
Self-contained multi-slide HTML decks under `templates/full-decks/<name>/`. Each folder contains:
- `index.html` — complete multi-slide deck (cover / section / content / code / chart or diagram / CTA / thanks, 7+ slides)
- `style.css` — scoped with `.tpl-<name>` class prefix so multiple templates can coexist
- `README.md` — short rationale, inspiration, and use guidance
All templates pull the shared `assets/fonts.css`, `assets/base.css`, and `assets/runtime.js` from the skill root. Navigate with `← →` / `space`, use `F` for fullscreen, `O` for overview.
Use these when you want a coherent, opinionated look for an entire deck — not a mix-and-match of layouts. Each template is visually distinctive enough to be identified at a glance.
---
## 1. xhs-white-editorial — 白底杂志风
- **Source inspiration:** `20260409 升级版知识库/小红书图文/v2-白底版/slide_01_cover.html` + `20260412-AI测试与安全/html/xhs-ai-testing-safety-v2.html`
- **Key visual traits:** pure-white background, top 10-color rainbow bar, 80-110px display headlines, purple→blue→green→orange→pink gradient text, macaron soft-card set (soft-purple/pink/blue/green/orange), black-on-white `.focus` pills, hero quote box.
- **When to use:** dual-purpose XHS image + horizontal deck; dense text with strong emphasis; Chinese-first audience.
- **Path:** `templates/full-decks/xhs-white-editorial/index.html`
## 2. graphify-dark-graph — 暗底知识图谱
- **Source inspiration:** `20260413-graphify/ppt/graphify.html`
- **Key visual traits:** `#06060c→#0e1020` deep-night gradient, drifting blur orbs, SVG force-directed graph overlay on cover, rainbow-shift gradient headlines, JetBrains Mono command-line glow, glass-morphism cards (warm/blue/green/purple/danger). Accent palette: amber `#e8a87c`, mint `#7ed3a4`, mist-blue `#7eb8da`, lilac `#b8a4d6`.
- **When to use:** dev-tool / CLI / knowledge-graph / data-viz launches; live-demo decks that want an "AI-native + sci-fi + warm" vibe.
- **Path:** `templates/full-decks/graphify-dark-graph/index.html`
## 3. knowledge-arch-blueprint — 奶油蓝图架构
- **Source inspiration:** `20260405-Karpathy-知识库/20260405 架构图v2.html`
- **Key visual traits:** cream paper `#F0EAE0` base, single rust accent `#B5392A`, 48px blueprint grid mask, hard 2px black border cards, pipeline step-boxes with one hero raised, right-side rust insight callout, Playfair serif big numbers, SVG dashed feedback-loop arrows. Zero gradients, zero soft shadows.
- **When to use:** system architecture diagrams, data-flow maps, engineering white-papers; you want a serious, printable, README-friendly feel.
- **Path:** `templates/full-decks/knowledge-arch-blueprint/index.html`
## 4. hermes-cyber-terminal — 暗终端 honest-review
- **Source inspiration:** `20260414-hermes-agent/ppt/hermes-record.html` + `hermes-vs-openclaw.html`
- **Key visual traits:** `#0a0c10` black, 56px cyber grid + CRT vignette + scanlines, window traffic-light chrome, `$ prompt` command-line headlines, mint-green `#7ed3a4` glow big text, JetBrains Mono throughout, stroke-only bar charts, blinking cursor, amber/green/red tag hierarchy, dark code box.
- **When to use:** reviews of CLI / agent / dev tools with trace, diff, and benchmarks; when you want the "honest technical reviewer" voice.
- **Path:** `templates/full-decks/hermes-cyber-terminal/index.html`
## 5. obsidian-claude-gradient — GitHub 暗紫渐变
- **Source inspiration:** `20260406-obsidian-claude/slides.html`
- **Key visual traits:** GitHub-dark `#0d1117`, purple+blue radial ambient plus 60px masked grid, center-aligned layout, purple pill tags, three-stop gradient text `#a855f7→#60a5fa→#34d399`, GitHub-ish code palette (`#010409` bg + purple/blue/orange/green tokens), purple-left-border highlight block.
- **When to use:** developer workflow / MCP / Agent / dev-tool tutorials; feels like GitHub Blog / Linear Changelog; config + steps heavy content.
- **Path:** `templates/full-decks/obsidian-claude-gradient/index.html`
## 6. testing-safety-alert — 红琥珀警示
- **Source inspiration:** `20260412-AI测试与安全/html/xhs-ai-testing-safety-v2.html`
- **Key visual traits:** top and bottom 45° red-black hazard stripes, red strike-through negation headlines, L1/L2/L3 green/amber/red tier cards, alert-box with circular status dot, policy-yaml code block with red left border and `bad` keyword highlighting, red/green checklist, Q1 incident stacked bar chart.
- **When to use:** safety / risk / incident post-mortem / red-team / pre-launch AI review / policy-as-code; when the audience needs to feel "this is serious, don't skim".
- **Path:** `templates/full-decks/testing-safety-alert/index.html`
## 7. xhs-pastel-card — 柔和马卡龙慢生活
- **Source inspiration:** `20260412-obsidian-skills/html/xhs-obsidian-skills.html` + pastel patterns shared with `20260409` v2-白底版
- **Key visual traits:** cream `#fef8f1` base, three soft blurred blobs, Playfair italic serif display headlines mixed with sans body, full-color 28px rounded macaron cards (peach / mint / sky / lilac / lemon / rose), italic Playfair `01-04` numerals, SVG donut chart, chip+page topbar.
- **When to use:** lifestyle / personal-growth / slow-living / emotional content; when you want a "magazine, handmade, not-so-techy" feel; themes like rest, pause, softness.
- **Path:** `templates/full-decks/xhs-pastel-card/index.html`
## 8. dir-key-nav-minimal — 方向键 8 色极简
- **Source inspiration:** `20260405-Karpathy-知识库/20260405 演示幻灯片【方向键版】.html`
- **Key visual traits:** 8 slides each on its own mono background (indigo / cream / crimson / emerald / slate / violet / white / charcoal), each with its own accent color, 160px display headline + 4px stubby accent line divider, arrow `→` prefixed Mono list, bottom-left `← →` kbd hint plus bottom-right page label, huge breathing negative space.
- **When to use:** keynote-style minimalist talk where you have something to say and not much to show; one idea per slide; talks / launches / public presentations.
- **Path:** `templates/full-decks/dir-key-nav-minimal/index.html`
---
## Scenario decks (generic, reusable)
These are not extracted from a single source — they are generic scaffolds for the most common presentation jobs. Each is visually distinctive and content-rich out of the box.
| # | Name | Slides | Feel | When to use |
|---|---|---|---|---|
| 9 | `pitch-deck` | 10 | White + blue→purple gradient, YC/VC vibe, big numbers, traction chart | Fundraising, startup pitch, investor meeting |
| 10 | `product-launch` | 8 | Dark hero + light content, warm orange→peach, feature cards, pricing tiers, CTA | Announcing a product, launch keynote |
| 11 | `tech-sharing` | 8 | GitHub-dark, JetBrains Mono, terminal code blocks, agenda + Q&A | 技术分享, internal tech talk, conference talk |
| 12 | `weekly-report` | 7 | Corporate clarity, 8-cell KPI grid, shipped list, 8-week bar chart, next-week table | 周报, team status update, business review |
| 13 | `xhs-post` | 9 | **3:4 @ 810×1080**, warm pastel, dashed sticker cards, page dots | 小红书 图文 post, Instagram carousel |
| 14 | `course-module` | 7 | Warm paper + Playfair serif, persistent left sidebar of learning objectives, MCQ self-check | 教学模块, online course, workshop module |
| 15 | `presenter-mode-reveal` 🎤 | 6 | **演讲者模式专用** · tokyo-night 默认 · 5 主题 T 键切换 · 每页带 150300 字逐字稿示例 | **技术分享/演讲/课程**—需要按 S 键看逐字稿的场景 ✨ |
Each folder: `index.html`, scoped `style.css` (prefixed `.tpl-<name>`), `README.md`. The `xhs-post` template overrides the default `.slide` box to fixed `810×1080` for 3:4 portrait.
> 🎤 **任何演讲场景(技术分享 / 课程 / 路演)都推荐用 `presenter-mode-reveal`**,或者参考 [presenter-mode.md](./presenter-mode.md) 指南给其他模板加 `<aside class="notes">` 逐字稿。
---
## Authoring notes
- Every template scopes its CSS under `.tpl-<name>` so two or more templates can load on the same page without collisions.
- Swap demo content, but keep the structural classes — they are what gives each template its identity.
- The shared runtime (`assets/runtime.js`) provides keyboard nav, fullscreen, overview grid, theme cycling — you don't need to add any JS.
- Charts are hand-rolled SVG (no CDN dependency). Feel free to replace with chart.js / echarts if you need interactive data.

View file

@ -0,0 +1,103 @@
# Layouts catalog
Every layout lives in `templates/single-page/<name>.html` as a fully
functional standalone page with realistic demo data. Open any file directly
in Chrome to see it working.
To compose a new deck: open the file, copy the `<section class="slide">…</section>`
block (or multiple blocks) into your deck HTML, and replace the demo data.
Shared CSS (base, theme, animations) is already wired by `deck.html`.
## Openers & transitions
| file | purpose |
|---|---|
| `cover.html` | Deck cover. Kicker + huge title + lede + pill row. |
| `toc.html` | Table of contents. 2×3 grid of numbered cards. |
| `section-divider.html` | Big numbered section break (02 · Theme). |
## Text-centric
| file | purpose |
|---|---|
| `bullets.html` | Classic bullet list with card-wrapped items. |
| `two-column.html` | Concept + example side by side. |
| `three-column.html` | Three equal pillars with icons. |
| `big-quote.html` | Full-bleed pull quote in editorial-serif style. |
## Numbers & data
| file | purpose |
|---|---|
| `stat-highlight.html` | One giant number + subtitle (uses `.counter` animation). |
| `kpi-grid.html` | 4 KPIs in a row with up/down deltas. |
| `table.html` | Data table with hover rows, right-aligned numerics. |
| `chart-bar.html` | Chart.js bar chart, theme-aware colors. |
| `chart-line.html` | Chart.js dual-line chart with filled area. |
| `chart-pie.html` | Chart.js doughnut + takeaways card. |
| `chart-radar.html` | Chart.js radar comparing 2 products on 6 axes. |
## Code & terminal
| file | purpose |
|---|---|
| `code.html` | Syntax-highlighted code via highlight.js (JS example). |
| `diff.html` | Hand-rolled +/- diff view. |
| `terminal.html` | Terminal window mock with traffic-light header. |
## Diagrams & flows
| file | purpose |
|---|---|
| `flow-diagram.html` | 5-node pipeline with arrows and one highlighted node. |
| `arch-diagram.html` | 3-tier architecture grid. |
| `process-steps.html` | 4 numbered steps in cards. |
| `mindmap.html` | Radial mindmap with SVG path-draw animation. |
## Plans & comparisons
| file | purpose |
|---|---|
| `timeline.html` | 5-point horizontal timeline with dots. |
| `roadmap.html` | 4-column NOW / NEXT / LATER / VISION. |
| `gantt.html` | 12-week gantt chart with 5 parallel tracks. |
| `comparison.html` | Before vs After two-panel card. |
| `pros-cons.html` | Pros and cons two-card layout. |
| `todo-checklist.html` | Checklist with checked/unchecked states. |
## Visuals
| file | purpose |
|---|---|
| `image-hero.html` | Full-bleed hero with Ken Burns gradient background. |
| `image-grid.html` | 7-cell bento grid with gradient placeholders. |
## Closers
| file | purpose |
|---|---|
| `cta.html` | Call-to-action with big gradient headline + buttons. |
| `thanks.html` | Final "Thanks" page with confetti burst. |
## Picking a layout
- **Opener**: `cover.html`, often followed by `toc.html`.
- **Section break**: `section-divider.html` before every major section.
- **Core content**: `bullets.html`, `two-column.html`, `three-column.html`.
- **Show numbers**: `stat-highlight.html` (single) or `kpi-grid.html` (4-up).
- **Show plot**: `chart-bar.html` / `chart-line.html` / `chart-pie.html` / `chart-radar.html`.
- **Show a diff or change**: `comparison.html`, `diff.html`, `pros-cons.html`.
- **Show a plan**: `timeline.html`, `roadmap.html`, `gantt.html`, `process-steps.html`.
- **Show architecture**: `arch-diagram.html`, `flow-diagram.html`, `mindmap.html`.
- **Code / demo**: `code.html`, `terminal.html`.
- **Closer**: `cta.html``thanks.html`.
## Naming / structure conventions
- Each slide is `<section class="slide" data-title="...">`.
- Header pills: `<p class="kicker">…</p>`, eyebrow: `<p class="eyebrow">…</p>`.
- Titles: `<h1 class="h1">…</h1>` / `<h2 class="h2">…</h2>`.
- Lede: `<p class="lede">…</p>`.
- Cards: `<div class="card">…</div>` (variants: `card-soft`, `card-outline`, `card-accent`).
- Grids: `.grid.g2`, `.grid.g3`, `.grid.g4`.
- Notes: `<div class="notes">…</div>` per slide.

View file

@ -0,0 +1,240 @@
# Presenter Mode Guide · 演讲者模式指南
这份文档说明如何在 html-ppt skill 里做出**带逐字稿的演讲者模式 PPT**。
## 何时使用演讲者模式
当用户的需求涉及以下任何一项时,**优先使用演讲者模式**
- 提到"**演讲**"、"**分享**"、"**讲稿**"、"**逐字稿**"、"**speaker notes**"
- 提到"**presenter view**"、"**演讲者视图**"、"**演讲者模式**"
- 需要"**30 分钟 / 45 分钟 / 1 小时**的分享"
- 说"我要去给团队讲 xxx"、"要做一场技术分享"、"要做路演"
- 强调"**不想忘词**"、"**怕讲不流畅**"、"**需要提词器**"
如果用户只要做一份"静态好看的 PPT"(例如小红书图文、产品图册、汇报 slides 自己不讲),**不需要**演讲者模式。
## 两种做法
### ✅ 推荐做法:直接用 `presenter-mode-reveal` 模板
```bash
cp -r templates/full-decks/presenter-mode-reveal examples/my-talk
```
这个模板已经预设好所有必需元素:
- 支持 S 键切换演讲者视图
- 5 个主题可用 T 键循环tokyo-night / dracula / catppuccin-mocha / nord / corporate-clean
- 左右键翻页
- 每一页都有 150300 字的示例逐字稿
- 底部有键位提示
直接改内容即可。
### 🔧 进阶做法:给任意已有模板加演讲者模式
html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck 模板都自动支持**。你只需要做两件事:
1. **每张 slide 末尾加 `<aside class="notes">`**(或 `<div class="notes">`),里面写逐字稿
2. **确认 HTML 引入了 `assets/runtime.js`**
```html
<section class="slide">
<h2>你的标题</h2>
<p>内容...</p>
<aside class="notes">
<p>这里是演讲时要说的话150-300 字...</p>
</aside>
</section>
```
## 逐字稿写作三铁律
这是整个方法论的核心。AI 在帮用户写逐字稿时必须遵守:
### 铁律 1不是讲稿是"提示信号"
**错误写法**(像在念稿):
```
大家好,欢迎来到今天的分享。今天我将要给大家介绍一下我们团队在过去三个月做的工作。
首先,我们来看一下背景情况。在过去的三个月中,我们遇到了以下几个问题……
```
**正确写法**(提示信号 + 加粗核心):
```
<p>欢迎!今天分享我们团队<strong>过去 3 个月</strong>的工作。</p>
<p>先说<em>背景</em>——三个月前我们遇到了<strong>三个核心问题</strong>
延迟高、成本炸、稳定性差。</p>
<p>接下来逐个讲解怎么解的。</p>
```
**差别**:正确版本把关键词加粗,过渡句独立成段,看一眼就能接上。
### 铁律 2每页 150300 字
- **少于 150 字**:提示不够,讲到一半会卡
- **多于 300 字**:你根本来不及扫完
- **23 分钟/页** 是最舒服的节奏
### 铁律 3用口语不用书面语
| ❌ 书面语 | ✅ 口语 |
|---|---|
| 因此 | 所以 |
| 该方案 | 这个方案 |
| 然而 | 但是 / 不过 |
| 进行优化 | 优化一下 |
| 我们将会 | 我们会 / 接下来 |
| 综上所述 | 所以简单来说 |
**检查方法**:写完读一遍,听起来像说话才对。
## 必备 HTML 结构
```html
<!DOCTYPE html>
<html lang="zh-CN" data-themes="tokyo-night,dracula,corporate-clean">
<head>
<meta charset="utf-8">
<title>...</title>
<link rel="stylesheet" href="../../../assets/fonts.css">
<link rel="stylesheet" href="../../../assets/base.css">
<link rel="stylesheet" id="theme-link" href="../../../assets/themes/tokyo-night.css">
<link rel="stylesheet" href="../../../assets/animations/animations.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="deck">
<section class="slide" data-title="Cover">
<h1>你的标题</h1>
<p>副标题</p>
<aside class="notes">
<p>讲稿段落 1<strong>加粗关键词</strong>)。</p>
<p>讲稿段落 2过渡句独立成段</p>
<p>讲稿段落 3自然收尾引出下一页</p>
</aside>
</section>
<!-- 更多 slide ... -->
</div>
<script src="../../../assets/runtime.js"></script>
</body>
</html>
```
## 演讲者视图显示的内容
`S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变)。演讲者窗口是 **4 个独立的磁吸卡片**
```
观众窗口(原页面) 演讲者窗口(磁吸卡片)
┌─────────────────┐ ┌─────────────────────┬──────────────────┐
│ │ │ 🔵 CURRENT │ 🟣 NEXT │
│ 正常 slide │ │ ━━━━━━━━━━━━━━━━ │ ━━━━━━━━━━━━━ │
│ 全屏展示 │◄►│ │ iframe preview │
│ │ │ iframe preview │ (下一页) │
│ │ │ (当前页) ├──────────────────┤
│ │ │ │ 🟠 SPEAKER SCRIPT │
│ │ │ │ ━━━━━━━━━━━━━ │
│ │ ├─────────────────────┤ [大字号逐字稿] │
│ │ │ 🟢 TIMER │ [可滚动] │
│ │ │ ⏱ 12:34 3 / 8 │ │
│ │ │ [← Prev][Next →] │ │
└─────────────────┘ └─────────────────────┴──────────────────┘
↑ BroadcastChannel 双向同步翻页 ↑
```
卡片交互规则:
- **拖动卡片 header**(带彩色圆点和标题的顶部条)→ 移动卡片位置
- **拖动卡片右下角的三角手柄** → 调整卡片大小
- **位置/尺寸自动保存到 localStorage**,下次打开恢复
- 底部 "重置布局" 按钮恢复默认排列
卡片内容:
- 🔵 **CURRENT** — 当前页 **像素级完美预览**iframe 加载原 HTML 文件的 `?preview=N` 模式,错色不可能)
- 🟣 **NEXT** — 下一页预览,同样像素级完美
- 🟠 **SPEAKER SCRIPT** — 逐字稿,字号 18px支持 `<strong>` (橘色加粗)、`<em>` (蓝色强调)、`<code>` 等 inline 样式
- 🟢 **TIMER** — 计时器不会丢失焦点,带切页按钮
两窗口同步:在任一窗口按 ← → 翻页另一个窗口自动同步BroadcastChannel
丝滑翻页iframe 只加载一次,后续翻页用 `postMessage` 切换可见的 slide**不重新加载、不闪烁**。
## 键盘快捷键(演讲者模式)
| 键 | 动作 |
|---|---|
| `S` | 打开演讲者窗口(弹出新窗口,原页面保持观众视图) |
| `←` `→` / Space / PgDn | 翻页(即使在演讲者视图里) |
| `T` | 切换主题 |
| `R` | 重置计时器(仅演讲者视图下) |
| `F` | 全屏 |
| `O` | 总览 |
| `Esc` | 关闭所有浮层 |
## 双屏演讲的标准流程
1. 打开 `index.html`,按 `S` → 弹出演讲者窗口
2. 把**观众窗口**(原页面)拖到投影 / 外接屏,按 `F` 全屏
3. 把**演讲者窗口**(弹窗)留在你面前的屏幕
4. 在任一窗口按 ← → 翻页,两边自动同步
5. 演讲者窗口里看逐字稿 + 下一页 + 计时器
> 💡 **为什么预览像素级完美**:每个预览是一个 `<iframe>`,它加载的就是同一个 deck HTML 文件,只是 URL 多了 `?preview=N` 参数。`runtime.js` 检测到这个参数时只渲染第 N 页、隐藏所有 chrome。**iframe 使用与观众视图完全相同的 CSS、主题、字体和 viewport**——颜色和排版保证一致。外层用 CSS `transform: scale()` 把 1920×1080 缩到卡片宽高,等比缩放不变形。
> 💡 **为什么不闪烁**iframe 初次加载后就常驻,翻页时 presenter 窗口通过 `postMessage({type:'preview-goto', idx:N})` 告诉 iframe 切换到第 N 页。iframe 内的 runtime.js 只切换 `.is-active` class**不重新加载、不渲染白屏**。
## 常见错误
### ❌ 把逐字稿写在 slide 可见位置
```html
<!-- 错误:这段文字观众会看到 -->
<p style="font-size:12px;color:gray">
这里讲 xxx然后讲 yyy...
</p>
```
✅ 正确:
```html
<aside class="notes">
<p>这里讲 xxx然后讲 yyy...</p>
</aside>
```
`.notes` 类默认 `display:none`,只在演讲者视图可见。
### ❌ 忘记引入 runtime.js
没有 `<script src="../../../assets/runtime.js"></script>` = 没有 S 键、没有演讲者视图、没有翻页。
### ❌ 逐字稿用书面语
念出来像 AI 机器人。**写完一定读一遍**。
### ❌ 每页 50 字
提示不够,照样忘词。
### ❌ 每页 500 字
眼睛根本扫不过来,等于没写。
## 用 AI 生成逐字稿的标准 prompt
> "请为每一张 slide 写一段 **150-300 字**的逐字稿,放在 `<aside class="notes">` 里。
> 要求:
> 1. 用**口语**,不要书面语(所以/但是/接下来,不是因此/然而/综上所述)
> 2. 把**核心关键词**用 `<strong>` 加粗
> 3. 过渡句独立成段(每段 1-3 句)
> 4. 读起来像说话,不像念稿
> 5. 结尾要有自然的过渡,引出下一页"
## 推荐搭配
- **主题**`tokyo-night`(深色,技术分享首选)、`corporate-clean`(浅色,商务汇报)、`dracula`(深色备选)
- **字体**:默认 Noto Sans SC + JetBrains Mono无需更改
- **动效**:克制使用,`fade-up` / `rise-in` 最自然,不要用 `glitch-in` / `confetti-burst` 之类花哨的
- **页数**30 分钟分享 = 812 页45 分钟 = 1216 页1 小时 = 1622 页

View file

@ -0,0 +1,107 @@
# Themes catalog
Every theme is a short CSS file in `assets/themes/` that overrides tokens
defined in `assets/base.css`. Switch themes by changing the `href` of
`<link id="theme-link">` or by pressing **T** if the deck has a
`data-themes="a,b,c"` attribute on `<body>` or `<html>`.
All themes define the same variables: `--bg`, `--bg-soft`, `--surface`,
`--surface-2`, `--border`, `--text-1/2/3`, `--accent`, `--accent-2/3`,
`--good`, `--warn`, `--bad`, `--grad`, `--grad-soft`, `--radius*`, `--shadow*`,
`--font-sans`, `--font-display`.
## Light & calm
| name | description | when to use |
|---|---|---|
| `minimal-white` | 极简白克制高级。Inter强文字层级极低阴影。 | 内部汇报、一对一技术评审、不抢内容的严肃话题 |
| `editorial-serif` | 杂志风 Playfair 衬线 + 奶油底。 | 品牌故事、文字密度大的长文演讲 |
| `soft-pastel` | 柔和马卡龙三色渐变。 | 产品发布、面向消费者、轻松话题 |
| `xiaohongshu-white` | 小红书白底 + 暖红 accent + 衬线标题。 | 小红书图文、生活/美学类内容 |
| `solarized-light` | 经典低眩光配色。 | 长时间观看的工作坊、教学 |
| `catppuccin-latte` | catppuccin 浅色。 | 开发者、极客友好的技术分享 |
## Bold & statement
| name | description | when to use |
|---|---|---|
| `sharp-mono` | 纯黑白 + Archivo Black + 硬阴影。 | 宣言类、极具冲击力的视觉 |
| `neo-brutalism` | 厚描边、硬阴影、明黄 accent。 | 创业路演、敢说敢做的调性 |
| `bauhaus` | 几何 + 红黄蓝原色。 | 设计 talk、艺术史/产品美学主题 |
| `swiss-grid` | 瑞士网格 + Helvetica 感 + 12 栏底纹。 | 严肃排版、设计行业 |
| `memphis-pop` | 孟菲斯波普背景点 + 大字标题。 | 年轻、潮流、品牌合作 |
## Cool & dark
| name | description | when to use |
|---|---|---|
| `catppuccin-mocha` | catppuccin 深。 | 开发者内部分享、长时间观看 |
| `dracula` | 经典 Dracula 紫红主色。 | 代码密集的技术分享 |
| `tokyo-night` | Tokyo Night 蓝夜。 | 偏冷技术分享、基础设施 |
| `nord` | 北欧清冷蓝白。 | 基础设施、云产品 |
| `gruvbox-dark` | 温暖复古深色。 | Terminal / vim / *nix 社群 |
| `rose-pine` | 玫瑰松,柔和暗色。 | 设计+开发交界、审美向技术 |
| `arctic-cool` | 蓝/青/石板灰 浅色版。 | 商业分析、金融、冷静理性 |
## Warm & vibrant
| name | description | when to use |
|---|---|---|
| `sunset-warm` | 橘 / 珊瑚 / 琥珀三色渐变。 | 生活方式、奖项颁发、情绪正向 |
## Effect-heavy
| name | description | when to use |
|---|---|---|
| `glassmorphism` | 毛玻璃 + 多色光斑背景。 | Apple 式发布会、产品特性展示 |
| `aurora` | 极光渐变 + blur + saturate。 | 封面 / CTA / 结语页 |
| `rainbow-gradient` | 白底 + 彩虹流动渐变 accent。 | 欢乐向、节日、庆祝页 |
| `blueprint` | 蓝图工程 + 网格底纹 + 蒙太奇字体。 | 系统架构、工程蓝图 |
| `terminal-green` | 绿屏终端 + 等宽 + 发光文字。 | CLI/black-hat/复古朋克 |
## v2 additions
### Light & professional
| name | description | when to use |
|---|---|---|
| `corporate-clean` | 纯白 + 海军蓝 accent + Inter + 保守边框。 | 董事会汇报、B2B 销售、金融保险 |
| `pitch-deck-vc` | YC 风白底 + 蓝紫渐变 accent + 大留白。 | 融资路演、种子轮、VC meeting |
| `academic-paper` | 论文白 + 衬线正文 + 黑墨 + 蓝链接。 | 学术报告、研究分享、会议论文 |
| `japanese-minimal` | 象牙白 + 朱红 accent + 极大留白 + Noto Serif。 | 品牌升级、匠人故事、禅意叙事 |
| `engineering-whiteprint` | 白底 + 坐标纸网格 + 海军墨线 + 等宽字。 | 系统设计、API 文档、架构白皮书 |
### Bold & editorial
| name | description | when to use |
|---|---|---|
| `magazine-bold` | 奶油底 + 超大 Playfair 衬线 + 橙色 spot。 | 专栏文章、封面故事、品牌月刊 |
| `news-broadcast` | 白底 + 红色竖条 + Oswald 大写 + 硬阴影。 | 突发新闻、发布通稿、数据播报 |
| `midcentury` | 奶油底 + 芥末/青/焦橙 + 锐利几何。 | 设计史、家居美学、复古品牌 |
| `retro-tv` | 暖奶油 + CRT 扫描线 + 琥珀橙 accent。 | 怀旧叙事、八零九零年代主题 |
### Effect-heavy / dramatic
| name | description | when to use |
|---|---|---|
| `cyberpunk-neon` | 纯黑 + 霓虹粉青黄 + 发光 + JetBrains Mono。 | 黑客、地下文化、赛博 talk |
| `vaporwave` | 深紫 + 粉红青蓝渐变 + 晕染光斑。 | 音乐、潮流艺术、A E S T H E T I C |
| `y2k-chrome` | 银铬渐变 + 彩虹 accent + 大圆角 + Space Grotesk。 | 千禧怀旧、时尚品牌、Gen-Z |
## How to apply
```html
<link rel="stylesheet" id="theme-link" href="../assets/themes/aurora.css">
```
Or enable `T`-cycling by listing themes on the body:
```html
<body data-themes="minimal-white,aurora,catppuccin-mocha" data-theme-base="../assets/themes/">
```
## How to extend
Copy an existing theme, rename it, and override only the variables you want to
change. Keep each theme under ~200 lines. Prefer adjusting tokens to adding
new selectors.

View file

@ -0,0 +1,104 @@
---
name: image-poster
description: |
Single-image generation skill for posters, key art, and editorial
illustrations. Defaults to gpt-image-2 but is provider-agnostic — the
same workflow drives Flux, Imagen, or Midjourney via the active
upstream tooling. Output is one or more PNG/JPEG files saved to the
project folder.
triggers:
- "poster"
- "key art"
- "illustration"
- "image"
- "cover art"
- "海报"
- "插画"
od:
mode: image
surface: image
scenario: design
preview:
type: html
entry: example.html
design_system:
requires: false
example_prompt: |
Editorial poster for an indie film festival — one bold abstract
silhouette over a warm, slightly grainy paper background; hand-set
sans serif title at the top, festival dates and venue at the bottom
in monospace. Muted ochre + ink palette.
---
# Image Poster Skill
Produce **one** finished image asset per turn unless the user asks for
variations. Image generation rewards a tight, structured prompt — your
job is to assemble that prompt from the user's brief, then dispatch.
## Resource map
```
image-poster/
├── SKILL.md ← you're reading this
└── example.html ← what the resulting card looks like in Examples
```
## Workflow
### Step 0 — Read the project metadata
The active project carries `imageModel`, `imageAspect`, and (optional)
`imageStyle` notes. Use them as the upstream model + canvas + style
anchor; only ask the user to fill them in if they're marked `(unknown
— ask)`.
### Step 1 — Compose the prompt
Plan in this exact order before calling any tool:
1. **Subject + composition** — what is in the frame, where, at what
scale; eye-line and crop.
2. **Lighting + mood** — natural / studio / moody; warm / cool; key
plus rim plus fill; time of day if outdoor.
3. **Palette + textures** — hex anchors when the user gave a brand
palette; otherwise a 3-word mood tag (e.g. "muted ochre + ink").
4. **Camera / lens** — only if the user wants photographic realism
("85mm portrait, shallow DOF") or a specific film stock.
5. **What to avoid** — common AI-slop patterns ("no extra fingers, no
warped text, no logo placeholders").
### Step 2 — Dispatch via the media contract
Use the unified dispatcher — do **not** call upstream provider APIs by
hand. Run from your shell tool:
```bash
"$OD_NODE_BIN" "$OD_BIN" media generate \
--project "$OD_PROJECT_ID" \
--surface image \
--model "<imageModel from metadata>" \
--aspect "<imageAspect from metadata>" \
--output "<short-descriptive-name>.png" \
--prompt "<the full assembled prompt from Step 1>"
```
The command prints one line of JSON: `{"file": {"name": "...", ...}}`.
The daemon writes the bytes into the project folder; the FileViewer
picks it up automatically.
### Step 3 — Hand off
Reply with a one-paragraph summary of the prompt you used and the
filename returned by the dispatcher (e.g. *I generated `hero-poster.png`
with `gpt-image-2` at 1:1.*). Do **not** emit an `<artifact>` tag.
## Hard rules
- One image per turn unless asked for variations.
- Honor `imageAspect` exactly — the upstream cost is the same; matching
the aspect avoids a re-render.
- No filler typography in the image itself unless the user asked for
in-frame text. Real copy beats lorem.
- Save every render — never describe an image without producing the
file. The user expects something to open in the file viewer.

View file

@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image poster — example</title>
<style>
:root {
--bg: #f5efe5;
--ink: #1c1b1a;
--accent: #c96442;
--muted: #8b8579;
--paper: #efe7d7;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
font-family: 'Iowan Old Style', 'Charter', Georgia, serif; }
body { min-height: 100dvh; display: grid; place-items: center; padding: 32px; }
.poster {
width: min(640px, 92vw);
aspect-ratio: 3 / 4;
background: var(--paper);
border: 1px solid rgba(28, 27, 26, 0.08);
border-radius: 6px;
box-shadow: 0 16px 48px rgba(28, 27, 26, 0.12), 0 1px 2px rgba(28, 27, 26, 0.06);
display: grid;
grid-template-rows: auto 1fr auto;
padding: 38px 32px;
position: relative;
overflow: hidden;
}
.poster::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 30% 18%, rgba(255,255,255,0.7), transparent 60%),
repeating-linear-gradient(0deg, rgba(28,27,26,0.025) 0 1px, transparent 1px 2px);
}
.eyebrow {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
display: flex;
justify-content: space-between;
align-items: center;
}
.accent-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
}
.silhouette {
align-self: center;
justify-self: center;
width: 70%;
aspect-ratio: 1 / 1;
position: relative;
}
.silhouette svg { width: 100%; height: 100%; display: block; }
.meta {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 10.5px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: end;
}
.meta strong { color: var(--ink); font-weight: 600; }
.title {
font-size: 44px;
line-height: 0.95;
margin: 18px 0 0;
letter-spacing: -0.01em;
}
.title em { font-style: italic; color: var(--accent); }
.footer {
margin-top: 12px;
font-size: 13px;
color: var(--muted);
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
}
</style>
</head>
<body>
<div class="poster">
<div class="eyebrow">
<span>Open Design · Image</span>
<span class="accent-dot" aria-hidden></span>
</div>
<div class="silhouette" aria-hidden>
<svg viewBox="0 0 100 100">
<circle cx="50" cy="38" r="18" fill="#1c1b1a" />
<path d="M22 100 C 22 70, 78 70, 78 100 Z" fill="#1c1b1a" />
<circle cx="68" cy="22" r="6" fill="#c96442" />
</svg>
</div>
<div>
<h1 class="title">An <em>image</em> project<br />produced by the agent.</h1>
<div class="meta">
<span><strong>gpt-image-2</strong></span>
<span>·</span>
<span style="text-align:right">3:4 · poster</span>
</div>
<p class="footer">Saved as PNG into the project folder.</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,76 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "example-image-poster",
"title": "Image Poster",
"version": "0.1.0",
"description": "Single-image generation skill for posters, key art, and editorial\nillustrations. Defaults to gpt-image-2 but is provider-agnostic — the\nsame workflow drives Flux, Imagen, or Midjourney via the active\nupstream tooling. Output is one or more PNG/JPEG files saved to the\nproject folder.",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/image-poster",
"tags": [
"example",
"first-party",
"image",
"design",
"poster",
"key-art",
"illustration",
"cover-art",
"untitled"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "image",
"scenario": "design",
"surface": "image",
"preview": {
"type": "html",
"entry": "./example.html"
},
"useCase": {
"query": "Editorial poster for an indie film festival — one bold abstract\nsilhouette over a warm, slightly grainy paper background; hand-set\nsans serif title at the top, festival dates and venue at the bottom\nin monospace. Muted ochre + ink palette.",
"exampleOutputs": [
{
"path": "./example.html",
"title": "Image Poster"
}
]
},
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"assets": [
"./example.html"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,102 @@
---
name: mobile-app
description: |
A mobile-app screen rendered inside a pixel-accurate iPhone 15 Pro frame
on the page. Built by copying the seed `assets/template.html` and pasting
one screen archetype from `references/layouts.md`. Use when the brief asks
for "mobile app", "iOS app", "Android app", "phone screen", or "app UI".
triggers:
- "mobile app"
- "ios app"
- "android app"
- "phone screen"
- "app ui"
- "app mockup"
- "移动端"
- "手机 app"
od:
mode: prototype
platform: mobile
scenario: design
preview:
type: html
entry: index.html
design_system:
requires: true
sections: [color, typography, layout, components]
craft:
requires: [state-coverage, animation-discipline]
---
# Mobile App Skill
Produce a single mobile-app screen mockup, framed inside a real-feeling iPhone 15 Pro device.
## Resource map
```
mobile-app/
├── SKILL.md ← you're reading this
├── assets/
│ └── template.html ← seed: device frame + screen primitives (READ FIRST)
└── references/
├── layouts.md ← 6 screen archetypes (Feed / Detail / Onboarding / Profile / Checkout / Focus)
└── checklist.md ← P0/P1/P2 self-review (anti-fake-device)
```
## Workflow
### Step 0 — Pre-flight
1. **Read `assets/template.html`** end-to-end through the `<style>` block. The Dynamic Island, status bar SVG icons, home indicator, side rails, and tab bar are all already drawn in HTML/SVG — do not re-implement them inline on each screen.
2. **Read `references/layouts.md`** so you know which 6 archetypes exist.
3. **Read the active DESIGN.md** — map its tokens to the six `:root` variables in the seed.
### Step 1 — Copy the seed
Copy `assets/template.html` to the project root as `index.html`. Replace the six `:root` variables with the active design system's tokens. Replace the page `<title>` and the caption above the device.
### Step 2 — Pick exactly one archetype
| Brief language | Use |
|---|---|
| feed, inbox, timeline, list, messages, notifications | A — Feed |
| article, post, item, recipe, song, product, song detail | B — Detail |
| sign-up, welcome, intro, walkthrough, tour | C — Onboarding |
| profile, account, user page, someone's bio | D — Profile |
| checkout, payment, order, form, settings step | E — Checkout |
| timer, map, dashboard widget, single big number | F — Focus / hero card |
A mobile screen does **one job**. If the brief seems to combine two, ship one screen and offer the other as a follow-up.
### Step 3 — Paste and fill
Copy the archetype block from `layouts.md` into `<main class="content">`, replacing the placeholder card. Fill bracketed text with real, specific copy from the brief. **Drop the `<nav class="tabbar">` block entirely** for archetypes that don't show one (B, C, E).
### Step 4 — Self-check
Run through `references/checklist.md`. Pay extra attention to:
- Frame still has the Dynamic Island, status bar SVGs, and home indicator
- Tap targets ≥ 44px
- One accent, used ≤ 2× on the screen
- Display headings still use `var(--font-display)` (serif)
### Step 5 — Emit the artifact
```
<artifact identifier="mobile-slug" type="text/html" title="Mobile — Screen Name">
<!doctype html>
<html>...</html>
</artifact>
```
One sentence before describing what's there. Stop after `</artifact>`.
## Hard rules
- **The phone is real.** Dynamic Island gap, SVG status icons, home indicator. The seed protects all three — don't rewrite the frame.
- **Single screen, single job.** No multi-tab tours, no spliced flows.
- **Accent budget = 2.** One active tab + one primary action is the default.
- **Numerics in mono** via `.num` class.
- **Display in serif** via `var(--font-display)`.
- **No external images** — use `.ph-img` placeholders.

View file

@ -0,0 +1,442 @@
<!doctype html>
<!--
OD mobile-app seed.
A pixel-accurate iPhone 15 Pro frame (390 × 844) with Dynamic Island,
status-bar SVG icons, and home indicator — drawn entirely in HTML/SVG, no
external image. The screen content lives inside `<main class="screen">`;
paste in one of the layouts from `references/layouts.md`.
Tokens at the top of `<style>` mirror the web-prototype seed so a single
DESIGN.md flows into both. Mobile spacing is tighter (~25%) and type sizes
drop one step from desktop — all pre-applied here.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>[REPLACE] Screen name · brand</title>
<style>
:root {
--bg: #fafaf7;
--surface: #ffffff;
--fg: #1a1916;
--muted: #6b6964;
--border: #e8e5df;
--accent: #c96442;
--accent-soft: color-mix(in oklch, var(--accent) 14%, transparent);
--fg-soft: color-mix(in oklch, var(--fg) 6%, transparent);
--font-display: 'Iowan Old Style', 'Charter', Georgia, serif;
--font-body: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
/* mobile type — one step down from web-prototype defaults */
--fs-h1: 26px;
--fs-h2: 20px;
--fs-h3: 16px;
--fs-body: 15px;
--fs-meta: 12px;
--radius-card: 18px;
--radius-pill: 999px;
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
background:
radial-gradient(60% 80% at 50% 0%, color-mix(in oklch, var(--accent) 6%, var(--bg)) 0%, var(--bg) 60%);
color: var(--fg);
font-family: var(--font-body);
font-size: var(--fs-body);
line-height: 1.4;
-webkit-font-smoothing: antialiased;
display: grid;
place-items: center;
padding: 32px;
}
/* ─── caption above the device ──────────────────────────────────── */
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.caption {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.caption strong { color: var(--fg); font-weight: 500; }
/* ─── device frame ──────────────────────────────────────────────── */
.device {
position: relative;
width: 390px;
height: 844px;
border-radius: 56px;
padding: 12px;
background:
linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%);
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 0 0 2px #000 inset,
0 28px 60px -12px rgba(0,0,0,0.45),
0 8px 20px -8px rgba(0,0,0,0.35);
isolation: isolate;
}
/* metallic side rails */
.device::before, .device::after {
content: '';
position: absolute;
width: 3px;
background: linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.06) 8%, transparent 16%, transparent 84%, rgba(255,255,255,0.04) 92%, transparent 100%);
top: 100px;
bottom: 100px;
pointer-events: none;
}
.device::before { left: -1px; }
.device::after { right: -1px; }
/* Dynamic Island */
.island {
position: absolute;
top: 22px;
left: 50%;
transform: translateX(-50%);
width: 124px;
height: 36px;
background: #000;
border-radius: 999px;
z-index: 5;
}
/* hardware buttons (subtle) */
.btn-rail {
position: absolute;
width: 4px;
background: #0a0a0c;
border-radius: 2px;
}
.btn-rail.left-1 { left: -3px; top: 174px; height: 32px; } /* silent */
.btn-rail.left-2 { left: -3px; top: 220px; height: 60px; } /* vol+ */
.btn-rail.left-3 { left: -3px; top: 290px; height: 60px; } /* vol- */
.btn-rail.right-1 { right: -3px; top: 250px; height: 100px; } /* power */
/* ─── screen surface ────────────────────────────────────────────── */
.screen {
position: relative;
width: 100%; height: 100%;
background: var(--bg);
border-radius: 44px;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Status bar — 47px to clear the island. SF-style time, signal/wifi/battery SVG. */
.statusbar {
flex: 0 0 47px;
padding: 18px 26px 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
font-family: var(--font-body);
font-size: 15px;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.01em;
}
.statusbar .right { display: inline-flex; align-items: center; gap: 6px; }
.statusbar svg { width: 17px; height: 11px; fill: var(--fg); }
.statusbar .battery { width: 25px; }
/* Content region — owns its scroll, frame stays still */
.content {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding: 8px 0 28px;
}
.content::-webkit-scrollbar { display: none; }
/* Home indicator (must always be the last visible thing) */
.home-indicator {
flex: 0 0 28px;
position: relative;
}
.home-indicator::after {
content: '';
position: absolute;
left: 50%; bottom: 8px;
transform: translateX(-50%);
width: 134px; height: 5px;
background: var(--fg);
border-radius: 999px;
opacity: 0.85;
}
/* ─── screen primitives — used by layouts.md ────────────────────── */
.pad { padding-inline: 20px; }
.stack { display: flex; flex-direction: column; gap: 16px; }
.row { display: flex; align-items: center; gap: 12px; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.header {
padding: 8px 20px 12px;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
}
.header h1 {
font-family: var(--font-display);
font-size: var(--fs-h1);
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
.header .icon-btn {
width: 36px; height: 36px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
display: grid; place-items: center;
color: var(--fg);
}
.header .icon-btn svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.7; }
.greeting {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 4px;
}
.h2 { font-family: var(--font-display); font-size: var(--fs-h2); letter-spacing: -0.015em; line-height: 1.2; margin: 0; }
.h3 { font-size: var(--fs-h3); font-weight: 600; line-height: 1.3; margin: 0; }
.meta { font-family: var(--font-mono); font-size: var(--fs-meta); color: var(--muted); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-card);
padding: 16px;
}
.card.accent {
background: var(--accent);
color: #fff;
border-color: transparent;
}
.card.accent .meta { color: rgba(255,255,255,0.72); }
.card.flat { background: transparent; border: 0; padding: 12px 0; border-top: 1px solid var(--border); border-radius: 0; }
.card.flat:first-child { border-top: 0; padding-top: 0; }
/* list row */
.list-row {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--border);
}
.list-row:first-child { border-top: 0; }
.list-row .avatar {
width: 40px; height: 40px;
border-radius: 50%;
background:
linear-gradient(135deg, var(--accent-soft), var(--fg-soft)),
var(--surface);
border: 1px solid var(--border);
}
.list-row .body .title { font-size: 15px; font-weight: 500; line-height: 1.25; }
.list-row .body .sub { color: var(--muted); font-size: 13px; line-height: 1.3; margin-top: 2px; }
/* tab bar */
.tabbar {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(var(--tabs, 4), 1fr);
padding: 8px 8px 0;
border-top: 1px solid var(--border);
background: color-mix(in oklch, var(--surface) 92%, transparent);
backdrop-filter: blur(20px);
}
.tab {
display: flex; flex-direction: column; align-items: center; gap: 2px;
padding: 8px 0;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.02em;
}
.tab.active { color: var(--accent); }
.tab svg { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-width: 1.7; }
.tab.active svg { stroke-width: 2; }
/* primary button — full-width, 48px tap target */
.btn-primary {
display: flex; align-items: center; justify-content: center;
width: 100%;
min-height: 48px;
padding: 14px 20px;
background: var(--accent);
color: #fff;
border: 0;
border-radius: 14px;
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
cursor: pointer;
}
.btn-secondary {
display: flex; align-items: center; justify-content: center;
width: 100%;
min-height: 48px;
padding: 14px 20px;
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
border-radius: 14px;
font: inherit;
font-size: 15px;
font-weight: 500;
}
/* image placeholder */
.ph-img {
background:
linear-gradient(135deg, var(--accent-soft), var(--fg-soft)),
var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
aspect-ratio: 4 / 3;
display: grid; place-items: center;
color: var(--muted);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
}
.ph-img.square { aspect-ratio: 1 / 1; }
.ph-img.wide { aspect-ratio: 16 / 9; }
/* pill / tag */
.pill {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
background: var(--accent-soft);
color: var(--accent);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tag {
display: inline-flex;
padding: 3px 9px;
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
font-size: 11px;
}
/* progress */
.progress { height: 6px; background: rgba(255,255,255,0.25); border-radius: 999px; overflow: hidden; }
.progress > span { display: block; height: 100%; background: #fff; }
</style>
</head>
<body>
<div class="stage">
<div class="caption"><strong>[REPLACE] App</strong> · [REPLACE] Screen name</div>
<div class="device" data-od-id="device">
<span class="btn-rail left-1" aria-hidden></span>
<span class="btn-rail left-2" aria-hidden></span>
<span class="btn-rail left-3" aria-hidden></span>
<span class="btn-rail right-1" aria-hidden></span>
<span class="island" aria-hidden></span>
<div class="screen">
<!-- ─── Status bar ─── -->
<div class="statusbar">
<span class="num">9:41</span>
<span class="right">
<!-- signal -->
<svg viewBox="0 0 17 11" aria-hidden>
<rect x="0" y="7" width="3" height="4" rx="0.6"/>
<rect x="4" y="5" width="3" height="6" rx="0.6"/>
<rect x="8" y="3" width="3" height="8" rx="0.6"/>
<rect x="12" y="0" width="3" height="11" rx="0.6"/>
</svg>
<!-- wifi -->
<svg viewBox="0 0 17 11" aria-hidden>
<path d="M8.5 1.5C5.5 1.5 2.7 2.6 0.5 4.6L2 6.1C3.8 4.5 6.1 3.6 8.5 3.6c2.4 0 4.7 0.9 6.5 2.5l1.5-1.5c-2.2-2-5-3.1-8-3.1zM3.5 7.6L5 9.1c1-0.9 2.2-1.4 3.5-1.4 1.3 0 2.5 0.5 3.5 1.4l1.5-1.5c-1.4-1.3-3.1-2-5-2-1.9 0-3.6 0.7-5 2zM6.5 10.6l2 2 2-2c-0.5-0.5-1.2-0.8-2-0.8s-1.5 0.3-2 0.8z"/>
</svg>
<!-- battery -->
<svg class="battery" viewBox="0 0 25 11" aria-hidden>
<rect x="0.5" y="0.5" width="21" height="10" rx="2.5" fill="none" stroke="currentColor" stroke-opacity="0.45"/>
<rect x="22" y="3.5" width="1.5" height="4" rx="0.4" fill="currentColor" fill-opacity="0.45"/>
<rect x="2" y="2" width="18" height="7" rx="1.4"/>
</svg>
</span>
</div>
<!-- ─── Scrollable content (paste a layout from references/layouts.md HERE) ─── -->
<main class="content" data-od-id="content">
<div class="header" data-od-id="header">
<div>
<p class="greeting">Tuesday · April 22</p>
<h1>[REPLACE] Hi there.</h1>
</div>
<button class="icon-btn" aria-label="Settings">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>
</button>
</div>
<div class="pad stack" data-od-id="empty-slot">
<div class="card" style="text-align: center; padding: 28px 20px;">
<p class="meta" style="margin: 0 0 6px;">PASTE A LAYOUT FROM</p>
<p class="h3" style="margin: 0 0 6px;">references/layouts.md</p>
<p style="margin: 0; color: var(--muted); font-size: 13px;">into <code style="font-family: var(--font-mono);">&lt;main class="content"&gt;</code></p>
</div>
</div>
</main>
<!-- ─── Tab bar (drop if the screen kind doesn't have one) ─── -->
<nav class="tabbar" style="--tabs: 4;" data-od-id="tabbar">
<a class="tab active">
<svg viewBox="0 0 24 24"><path d="M3 12 12 3l9 9"/><path d="M5 10v10h14V10"/></svg>
Home
</a>
<a class="tab">
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
Search
</a>
<a class="tab">
<svg viewBox="0 0 24 24"><path d="M22 12c0 5.5-4.5 10-10 10S2 17.5 2 12 6.5 2 12 2s10 4.5 10 10z"/><path d="M12 6v6l4 2"/></svg>
Activity
</a>
<a class="tab">
<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/></svg>
Profile
</a>
</nav>
<div class="home-indicator" aria-hidden></div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tomato — focus screen</title>
<style>
:root {
--bg: #fafaf9; --fg: #1c1b1a; --muted: #6b6964; --border: #e6e4e0;
--accent: #c96442; --surface: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); display: flex; align-items: center; justify-content: center; padding: 32px; font: 14px/1.5 -apple-system, system-ui, sans-serif; color: var(--fg); }
.frame { width: 390px; height: 844px; background: black; border-radius: 56px; padding: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.18); position: relative; }
.frame::before { content: ''; position: absolute; top: 22px; left: 50%; transform: translateX(-50%); width: 124px; height: 36px; background: black; border-radius: 999px; z-index: 5; }
.screen { width: 100%; height: 100%; background: var(--bg); border-radius: 44px; overflow: hidden; display: flex; flex-direction: column; }
.status { padding: 14px 24px 6px; display: flex; justify-content: space-between; align-items: center; font-size: 14px; font-weight: 600; }
.status .right { display: flex; gap: 6px; align-items: center; }
.header { padding: 56px 24px 24px; }
.header .greeting { color: var(--muted); font-size: 14px; margin: 0 0 4px; }
.header h1 { margin: 0; font-size: 22px; letter-spacing: -0.01em; }
.timer-card { margin: 12px 24px; background: var(--accent); color: white; border-radius: 24px; padding: 28px 24px; text-align: center; }
.timer-card .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.85; margin: 0 0 4px; }
.timer-card .countdown { font-size: 64px; line-height: 1; letter-spacing: -0.03em; font-weight: 600; margin: 6px 0 18px; font-variant-numeric: tabular-nums; }
.timer-card .progress { height: 6px; background: rgba(255,255,255,0.25); border-radius: 999px; overflow: hidden; margin-bottom: 16px; }
.timer-card .progress > span { display: block; width: 38%; height: 100%; background: white; }
.timer-card .actions { display: flex; gap: 10px; justify-content: center; }
.timer-card button { font: inherit; cursor: pointer; padding: 10px 22px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.4); background: rgba(255,255,255,0.12); color: white; font-weight: 500; }
.timer-card button.primary { background: white; color: var(--accent); border-color: white; }
.section { padding: 18px 24px 0; }
.section .label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 10px; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 12px; }
.stat .v { font-size: 22px; letter-spacing: -0.01em; line-height: 1; margin-bottom: 4px; }
.stat .l { font-size: 11px; color: var(--muted); }
.tasks { padding: 18px 24px 8px; }
.task { display: flex; align-items: center; gap: 12px; padding: 14px 0; border-top: 1px solid var(--border); }
.task:first-child { border-top: none; }
.check { width: 20px; height: 20px; border: 1.5px solid var(--border); border-radius: 50%; flex-shrink: 0; }
.task.done .check { background: var(--accent); border-color: var(--accent); }
.task.done .check::after { content: '✓'; color: white; font-size: 13px; display: block; text-align: center; line-height: 18px; }
.task .body { flex: 1; }
.task .title { font-size: 14.5px; line-height: 1.3; }
.task.done .title { color: var(--muted); text-decoration: line-through; }
.task .meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
.tabbar { margin-top: auto; display: grid; grid-template-columns: repeat(4, 1fr); padding: 10px 16px 28px; border-top: 1px solid var(--border); background: var(--surface); }
.tab { text-align: center; color: var(--muted); font-size: 11px; padding: 6px 0; }
.tab.active { color: var(--accent); font-weight: 500; }
.tab .icon { font-size: 18px; line-height: 1; margin-bottom: 4px; }
</style>
</head>
<body>
<div class="frame" data-od-id="frame">
<div class="screen">
<div class="status"><span>9:41</span><span class="right">·· 5G · 100%</span></div>
<div class="header" data-od-id="header">
<p class="greeting">Tuesday · April 22</p>
<h1>Two pomodoros to lunch.</h1>
</div>
<div class="timer-card" data-od-id="timer">
<p class="label">Focus session</p>
<div class="countdown">15:42</div>
<div class="progress"><span></span></div>
<div class="actions">
<button>Skip</button>
<button class="primary">Pause</button>
</div>
</div>
<div class="section" data-od-id="stats">
<p class="label">Today</p>
<div class="stats">
<div class="stat"><div class="v">3</div><div class="l">Sessions</div></div>
<div class="stat"><div class="v">75m</div><div class="l">Focused</div></div>
<div class="stat"><div class="v">2</div><div class="l">Tasks done</div></div>
</div>
</div>
<div class="tasks" data-od-id="tasks">
<p class="section label" style="padding: 0;">Up next</p>
<div class="task done"><div class="check"></div><div class="body"><div class="title">Review Q2 OKRs</div><div class="meta">25m · completed</div></div></div>
<div class="task"><div class="check"></div><div class="body"><div class="title">Draft sync-engine post</div><div class="meta">2 sessions estimated</div></div></div>
<div class="task"><div class="check"></div><div class="body"><div class="title">1:1 prep with Mira</div><div class="meta">1 session</div></div></div>
</div>
<nav class="tabbar" data-od-id="tabbar">
<div class="tab active"><div class="icon"></div>Focus</div>
<div class="tab"><div class="icon"></div>Tasks</div>
<div class="tab"><div class="icon">📊</div>Stats</div>
<div class="tab"><div class="icon"></div>Settings</div>
</nav>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,92 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "example-mobile-app",
"title": "Mobile App",
"version": "0.1.0",
"description": "A mobile-app screen rendered inside a pixel-accurate iPhone 15 Pro frame\non the page. Built by copying the seed `assets/template.html` and pasting\none screen archetype from `references/layouts.md`. Use when the brief asks\nfor \"mobile app\", \"iOS app\", \"Android app\", \"phone screen\", or \"app UI\".",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/mobile-app",
"tags": [
"example",
"first-party",
"prototype",
"design",
"web",
"mobile",
"mobile-app",
"ios-app",
"android-app",
"phone-screen",
"app-ui",
"app-mockup",
"untitled",
"app"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "prototype",
"platform": "mobile",
"scenario": "design",
"surface": "web",
"preview": {
"type": "html",
"entry": "./example.html"
},
"useCase": {
"query": "A mobile-app screen rendered inside a pixel-accurate iPhone 15 Pro frame on the page. Built by copying the seed `assets/template.html` and pasting one screen archetype from `references/layouts.md`. Use when the brief asks for \"mobile app\", \"iOS app\", \"Android app\", \"phone screen\", or \"app UI\".",
"exampleOutputs": [
{
"path": "./example.html",
"title": "Mobile App"
}
]
},
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"designSystem": {
"primary": true
},
"craft": [
"state-coverage",
"animation-discipline"
],
"assets": [
"./example.html",
"./assets/template.html",
"./references/checklist.md",
"./references/layouts.md"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,46 @@
# Mobile app checklist
Run this before emitting `<artifact>`. P0 must pass.
## P0 — must pass
- [ ] **Frame looks like a phone, not a generic card.** Dynamic Island visible, status bar SVG icons present (signal/wifi/battery), home indicator at bottom. The seed already does this — verify you didn't accidentally delete the island/rails/indicator markup.
- [ ] **Status bar shows real glyphs**, not text like `· · · 5G · 100%`. Use the SVG icons from the seed.
- [ ] **Home indicator is the last visible thing.** Anything below it (e.g. extra padding, accidental `<div>`) breaks the illusion.
- [ ] **Content scrolls, frame doesn't.** `<main class="content">` has `overflow-y: auto`; the surrounding `.device` does not. The page background never moves.
- [ ] **Tap targets ≥ 44px tall.** The seed's `.btn-primary` (48px), `.tab` (~50px), `.icon-btn` (36px ≥ touch with padding), `.list-row` (≥48px with padding) all pass. Don't ship a button under 44px.
- [ ] **Body text ≥ 14px.** `--fs-body: 15px` already enforces this on most copy. List-row sub text uses 13px max — that's the floor.
- [ ] **One accent, used at most twice on the screen.** Typically: one active tab + one CTA, OR one accent card + one tab. Never three.
- [ ] **No external image URLs.** Use the `.ph-img` placeholder class. External CDN images break the OD preview iframe and look fake when they 404.
- [ ] **Tab bar matches the screen kind.** Onboarding / detail / checkout: drop the `<nav class="tabbar">` entirely. Feed / focus / profile: keep it.
- [ ] **Display headlines use `var(--font-display)` (serif).** The seed binds this via `.h1`, `.h2`, `.header h1`. Don't override headings to system-sans — it instantly looks like a stock template.
- [ ] **No emoji icons in the UI.** SVG monoline only. Emoji in copy is fine ("9:41 ☀️ Tuesday" is not, but "Sunny day in Berlin" is).
- [ ] **`data-od-id` on the device, content, header, and any major sections.**
## P1 — should pass
- [ ] **One screen, one job.** A profile screen does profile things. Don't graft a checkout form onto a feed.
- [ ] **Caption above the device** names the screen (e.g. "FILEBASE · INBOX"). The seed already has the slot — fill it.
- [ ] **Status bar time is `9:41`** (Apple convention) unless the brief asks otherwise.
- [ ] **Mono font for numerics** — counts, prices, durations, dates. The seed's `.num` class binds this.
- [ ] **Real, specific copy.** "Mira Hassan · CTO" beats "User Name". "$1,920" beats "$X,XXX".
- [ ] **First-screen content fits inside the 844px frame** without requiring scroll for the primary action. If the CTA is below the fold, it's the wrong layout.
## P2 — nice to have
- [ ] **Subtle accent radial gradient on the page background** (already in seed). Removing it makes the device feel pasted onto a flat sheet.
- [ ] **Backdrop-blurred tab bar** (already in seed via `backdrop-filter`).
- [ ] **At most one image placeholder per screen.** Two placeholders on a small canvas competes for attention.
- [ ] **Subtle metallic side rails on the bezel** (already in seed via `::before`/`::after`).
## Anti-fake-device checklist
If any of these are true, the screen looks like a *card pretending to be a phone* rather than a phone:
- The device's outer corners aren't visibly more rounded (~56px) than the inner screen (~44px).
- There's no Dynamic Island gap at the top centre.
- The status bar text is grey or low-opacity (it should be `var(--fg)` at full strength).
- The home indicator is missing.
- The bottom tab bar has no top border or no backdrop blur.
The seed prevents all of these — the most common regression is the agent rewriting the frame with `border-radius: 24px` and losing the depth.

View file

@ -0,0 +1,312 @@
# Mobile app layouts
**6 paste-ready screen archetypes.** Drop into `<main class="content">` of `assets/template.html`. Don't write screens from scratch — pick the closest archetype, paste, swap copy.
## Pre-flight
1. **Read `assets/template.html`** at minimum through the `<style>` block — every class below is defined there. The Dynamic Island, status bar, home indicator, and tab bar are already drawn; do not re-implement them inline.
2. **Pick exactly one archetype.** A mobile screen does one job. Mixing "feed + checkout + profile" into one mock is the #1 reason mobile prototypes feel fake.
3. **If the archetype implies a tab bar, keep it; otherwise delete the entire `<nav class="tabbar">` block.** Onboarding, detail, and checkout screens generally don't show one.
## Class inventory
> `pad` `stack` `row` `row-between` `grid-2` `grid-3` `header` `greeting` `h2` `h3` `meta` `num` `card` `card.accent` `card.flat` `list-row` `avatar` `tag` `pill` `tabbar` `tab` `tab.active` `btn-primary` `btn-secondary` `ph-img` `progress`
If you reach for a class not on this list, define it in the seed's `<style>` first.
---
## Archetype A — Feed (home / for-you / inbox)
Top: greeting + title. Body: 46 list rows, hairline-separated. Tab bar: yes.
```html
<div class="header" data-od-id="header">
<div>
<p class="greeting">Tuesday · April 22</p>
<h1>Inbox</h1>
</div>
<button class="icon-btn" aria-label="Compose">
<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
</button>
</div>
<section class="pad" data-od-id="filters" style="margin-bottom: 8px;">
<div class="row" style="overflow-x: auto; padding-bottom: 4px;">
<span class="pill">All · 14</span>
<span class="tag">Mentions</span>
<span class="tag">Following</span>
<span class="tag">Shared</span>
</div>
</section>
<section class="pad" data-od-id="feed">
<div class="list-row">
<div class="avatar"></div>
<div class="body">
<div class="title">Mira Hassan · Sync engine v3 review</div>
<div class="sub">"Merged the chunker — egress is down 38% on Northwind."</div>
</div>
<span class="meta">2m</span>
</div>
<div class="list-row">
<div class="avatar"></div>
<div class="body">
<div class="title">#engineering · 7 new replies</div>
<div class="sub">Latency spike between 03:40 and 04:10 — probably the cron.</div>
</div>
<span class="meta">14m</span>
</div>
<div class="list-row">
<div class="avatar"></div>
<div class="body">
<div class="title">Northwind Studios · Invoice paid</div>
<div class="sub">$2,184 · April · auto-receipt sent to billing@</div>
</div>
<span class="meta">1h</span>
</div>
<div class="list-row">
<div class="avatar"></div>
<div class="body">
<div class="title">Daniel Park · Re: Next Tuesday's review</div>
<div class="sub">"I'll have the Q2 numbers by Monday EOD."</div>
</div>
<span class="meta">3h</span>
</div>
</section>
```
## Archetype B — Detail (single item)
Hero image up top, eyebrow + title + meta, body text, primary action floating at the bottom. Tab bar: no.
```html
<div class="ph-img wide" style="border-radius: 0; aspect-ratio: 4/3;" data-od-id="hero">[ Hero image ]</div>
<section class="pad" style="padding-top: 18px;" data-od-id="meta">
<span class="pill">Studio session</span>
<h1 class="h2" style="margin: 10px 0 6px;">Filebase v3 — what we shipped, what we cut.</h1>
<p class="meta">Mira Hassan · April 22 · 9 min read</p>
</section>
<section class="pad stack" style="margin-top: 18px; gap: 14px;" data-od-id="body">
<p>The biggest unlock in v3 was the new content-defined chunker. On Final Cut projects, post-edit re-uploads dropped 38× — from full multi-GB pushes to the few hundred KB that actually changed.</p>
<p>What we cut: per-folder compression. It looked great on benchmarks; on real footage it was slower than no compression at all because the chunker was already doing the dedup work.</p>
<p>Next quarter: dual-region replication on R2 + S3, rolling out to Enterprise first.</p>
</section>
<section class="pad" style="padding-top: 24px; padding-bottom: 8px;" data-od-id="cta">
<button class="btn-primary">Save to library</button>
</section>
```
## Archetype C — Onboarding (1 of N)
Illustration block + headline + subhead + paginator + primary CTA. Tab bar: no. Status bar still visible.
```html
<section class="pad stack" style="height: 100%; padding-top: 24px; padding-bottom: 24px; gap: 24px;" data-od-id="onboarding">
<div class="ph-img square" style="aspect-ratio: 1/1; max-width: 240px; margin: 0 auto;">[ Illustration ]</div>
<div style="text-align: center;">
<p class="meta" style="margin: 0 0 6px;">STEP 2 OF 4</p>
<h1 style="font-family: var(--font-display); font-size: 26px; margin: 0 0 10px; letter-spacing: -0.02em; line-height: 1.15;">Sync only what changed.</h1>
<p style="margin: 0 auto; max-width: 26ch; color: var(--muted); font-size: 14px; line-height: 1.5;">No more 4 GB re-uploads when you fix one frame. We diff at the byte level so the network stays quiet.</p>
</div>
<!-- pagination dots -->
<div class="row" style="justify-content: center; gap: 6px;">
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--border);"></span>
<span style="width: 18px; height: 6px; border-radius: 999px; background: var(--accent);"></span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--border);"></span>
<span style="width: 6px; height: 6px; border-radius: 50%; background: var(--border);"></span>
</div>
<div class="stack" style="gap: 10px; margin-top: auto;">
<button class="btn-primary">Continue</button>
<button class="btn-secondary" style="border: 0; color: var(--muted);">Skip</button>
</div>
</section>
```
> Drop the `<nav class="tabbar">` block from the seed for this archetype.
## Archetype D — Profile (someone's page)
Avatar + name + meta row; stat row; tabbed content underneath. Tab bar: yes (often the surrounding app's tabs).
```html
<section class="pad" style="padding-top: 8px;" data-od-id="head">
<div class="row" style="gap: 16px;">
<div class="avatar" style="width: 64px; height: 64px;"></div>
<div>
<h1 class="h2" style="margin: 0;">Mira Hassan</h1>
<p class="meta" style="margin: 4px 0 0;">CTO · Northwind Studios · Joined 2024</p>
</div>
</div>
<div class="row" style="margin-top: 16px; gap: 8px;">
<button class="btn-secondary" style="flex: 1; min-height: 38px; font-size: 13px;">Message</button>
<button class="btn-secondary" style="flex: 1; min-height: 38px; font-size: 13px;">Follow</button>
</div>
</section>
<section class="pad" data-od-id="stats" style="margin-top: 18px;">
<div class="grid-3">
<div class="card flat" style="text-align: center;">
<div class="num" style="font-size: 22px; letter-spacing: -0.02em;">218</div>
<div class="meta">Posts</div>
</div>
<div class="card flat" style="text-align: center;">
<div class="num" style="font-size: 22px; letter-spacing: -0.02em;">3.1k</div>
<div class="meta">Followers</div>
</div>
<div class="card flat" style="text-align: center;">
<div class="num" style="font-size: 22px; letter-spacing: -0.02em;">142</div>
<div class="meta">Following</div>
</div>
</div>
</section>
<section class="pad" data-od-id="tabs" style="margin-top: 12px;">
<div class="row" style="border-bottom: 1px solid var(--border); gap: 24px;">
<span style="padding: 12px 0; border-bottom: 2px solid var(--accent); color: var(--fg); font-weight: 500; font-size: 14px;">Posts</span>
<span style="padding: 12px 0; color: var(--muted); font-size: 14px;">Replies</span>
<span style="padding: 12px 0; color: var(--muted); font-size: 14px;">Likes</span>
</div>
</section>
<section class="pad" data-od-id="post-list" style="margin-top: 4px;">
<div class="list-row" style="grid-template-columns: 1fr;">
<div class="body">
<div class="title">"Bandwidth pricing went up 4× — sync engine choice is no longer cosmetic."</div>
<div class="sub" style="margin-top: 6px;">2 days ago · 142 likes</div>
</div>
</div>
<div class="list-row" style="grid-template-columns: 1fr;">
<div class="body">
<div class="title">"Shipped v3 today. The team carried this one."</div>
<div class="sub" style="margin-top: 6px;">5 days ago · 88 likes</div>
</div>
</div>
</section>
```
## Archetype E — Checkout / form
Stacked card sections (item summary → details → totals), bottom-fixed CTA. Tab bar: no.
```html
<section class="pad" style="padding-top: 12px;" data-od-id="title">
<h1 class="h2">Confirm order</h1>
</section>
<section class="pad" data-od-id="item">
<div class="card row" style="gap: 14px; align-items: flex-start;">
<div class="ph-img square" style="width: 64px; height: 64px; aspect-ratio: 1; border-radius: 10px;"></div>
<div style="flex: 1;">
<div class="h3">Filebase Team · annual</div>
<p class="meta" style="margin: 4px 0 0;">$4 / seat / month, billed yearly</p>
</div>
<span class="num">$1,920</span>
</div>
</section>
<section class="pad stack" data-od-id="details" style="margin-top: 14px; gap: 10px;">
<div class="card flat row-between">
<span>Seats</span>
<span class="num">40</span>
</div>
<div class="card flat row-between">
<span>Billing email</span>
<span class="meta">billing@northwind.studio</span>
</div>
<div class="card flat row-between">
<span>Payment</span>
<span class="meta">Visa · 4242</span>
</div>
</section>
<section class="pad" data-od-id="totals" style="margin-top: 14px;">
<div class="card row-between" style="border-top: 1px solid var(--fg); border-radius: 0; padding: 16px 0; background: transparent;">
<span style="font-weight: 600;">Total today</span>
<span class="num" style="font-size: 22px; letter-spacing: -0.01em;">$1,920</span>
</div>
</section>
<section class="pad" style="padding-top: 16px; padding-bottom: 12px;" data-od-id="cta">
<button class="btn-primary">Pay $1,920</button>
<p class="meta" style="text-align: center; margin: 12px 0 0;">By tapping Pay you agree to the terms.</p>
</section>
```
## Archetype F — Focus / hero card (timer, map, single tool)
A single accent-coloured hero card dominates; small supporting content underneath. Tab bar: yes.
```html
<div class="header" data-od-id="header">
<div>
<p class="greeting">Tuesday · April 22</p>
<h1>Two pomodoros to lunch.</h1>
</div>
<button class="icon-btn" aria-label="Settings">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="3" r="0.5"/><circle cx="12" cy="21" r="0.5"/><circle cx="3" cy="12" r="0.5"/><circle cx="21" cy="12" r="0.5"/></svg>
</button>
</div>
<section class="pad" data-od-id="hero-card" style="margin-top: 4px;">
<div class="card accent" style="padding: 28px 24px; text-align: center;">
<p class="meta" style="margin: 0 0 6px; color: rgba(255,255,255,0.72);">FOCUS SESSION</p>
<div class="num" style="font-size: 64px; line-height: 1; letter-spacing: -0.03em; font-weight: 600; margin: 8px 0 18px;">15:42</div>
<div class="progress" style="margin-bottom: 18px;"><span style="width: 38%;"></span></div>
<div class="row" style="justify-content: center; gap: 8px;">
<button style="padding: 10px 22px; border: 1px solid rgba(255,255,255,0.4); background: rgba(255,255,255,0.12); color: #fff; border-radius: 999px; font: inherit; font-weight: 500;">Skip</button>
<button style="padding: 10px 22px; border: 0; background: #fff; color: var(--accent); border-radius: 999px; font: inherit; font-weight: 600;">Pause</button>
</div>
</div>
</section>
<section class="pad" data-od-id="stats-row" style="margin-top: 18px;">
<p class="meta" style="margin: 0 0 8px;">TODAY</p>
<div class="grid-3">
<div class="card"><div class="num" style="font-size: 22px;">3</div><div class="meta">Sessions</div></div>
<div class="card"><div class="num" style="font-size: 22px;">75m</div><div class="meta">Focused</div></div>
<div class="card"><div class="num" style="font-size: 22px;">2</div><div class="meta">Done</div></div>
</div>
</section>
<section class="pad" data-od-id="up-next" style="margin-top: 18px;">
<p class="meta" style="margin: 0 0 8px;">UP NEXT</p>
<div>
<div class="list-row" style="grid-template-columns: 22px 1fr auto;">
<span style="width: 18px; height: 18px; border-radius: 50%; background: var(--accent);"></span>
<div class="body">
<div class="title" style="text-decoration: line-through; color: var(--muted);">Review Q2 OKRs</div>
<div class="sub">25m · completed</div>
</div>
</div>
<div class="list-row" style="grid-template-columns: 22px 1fr auto;">
<span style="width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--border);"></span>
<div class="body">
<div class="title">Draft sync-engine post</div>
<div class="sub">2 sessions estimated</div>
</div>
</div>
</div>
</section>
```
---
## Choosing an archetype from a brief
| If the brief mentions… | Use |
|---|---|
| feed, inbox, timeline, list, messages | A — Feed |
| article, post, item, recipe, song, product | B — Detail |
| sign-up, welcome, intro, walkthrough | C — Onboarding |
| profile, account, user page, bio | D — Profile |
| checkout, payment, order, form, settings step | E — Checkout |
| timer, map, dashboard widget, single big number | F — Focus |
If two fit, pick the one that better matches the *primary* action the user takes on this screen.

View file

@ -0,0 +1,124 @@
---
name: saas-landing
description: |
Single-page SaaS landing with hero, features, social proof, pricing, and CTA.
Respects the active DESIGN.md color/typography/layout tokens.
Trigger keywords: "saas landing", "marketing page", "product landing".
triggers:
- "saas landing"
- "marketing page"
- "product landing"
od:
mode: prototype
platform: desktop
scenario: marketing
preview:
type: html
entry: index.html
reload: debounce-100
design_system:
requires: true
sections: [color, typography, layout, components]
craft:
requires: [typography, color, anti-ai-slop, laws-of-ux]
inputs:
- name: product_name
type: string
required: true
- name: tagline
type: string
required: true
- name: has_pricing
type: boolean
default: true
- name: proof_count
type: integer
default: 3
min: 0
max: 6
parameters:
- name: hero_density
type: spacing
default: 96
range: [48, 200]
- name: accent_strength
type: opacity
default: 1.0
range: [0.5, 1.0]
outputs:
primary: index.html
capabilities_required:
- file_write
---
# SaaS Landing Skill
Produce a single-page SaaS landing. Agent, follow this workflow exactly.
## 1. Read context
Before writing anything:
- Read `DESIGN.md` in the current working directory. If missing, stop and ask for one.
- Identify the color palette, typography tokens, and layout principles.
- Note the "Agent Prompt Guide" section — it overrides any instruction here if they conflict.
## 2. Plan sections
Required sections, in order:
1. **Hero** — logo-or-wordmark, headline (tagline input), subhead (12 sentences), primary CTA, secondary CTA. Use the hero_density parameter as vertical padding in px.
2. **Features** — 36 feature tiles. Each: icon, short title, 12 sentence body.
3. **Social proof**`proof_count` logos or testimonials. If 0, skip this section.
4. **Pricing** — 23 tiers. Include only if `has_pricing` is true.
5. **Footer CTA** — large accent-colored band with one-button call to action.
6. **Footer** — minimal: links + copyright.
## 3. Apply design system
- All colors must come from DESIGN.md tokens. Do not invent hex values.
- Typography: use the declared display font for headlines, body font for everything else.
- Layout: respect the grid, max-width, and section spacing rules.
- Components: use declared button/card/input patterns. Do not add shadows if DESIGN.md's Depth & Elevation says minimal.
- Accent: use the accent color only once in the hero, once in the footer CTA, and for all links. Do not flood the page.
## 4. Write the file
Output a single self-contained `index.html` with:
- All CSS inlined in a `<style>` block in `<head>`.
- System font fallbacks if DESIGN.md fonts aren't loadable from Google Fonts etc.
- No external JS.
- Semantic HTML (`<header>`, `<main>`, `<section>`, `<footer>`).
- Each editable element tagged with `data-od-id="<unique-slug>"` so the host app's comment mode can target it.
## 5. Self-check
Before finishing, verify:
- [ ] All text is content-meaningful, not lorem ipsum (use product_name and tagline inputs; generate plausible specific copy for the rest).
- [ ] No broken color references (every CSS color value is in DESIGN.md's palette or a valid alpha/fallback variant).
- [ ] Responsive breakpoints match DESIGN.md's Responsive Behavior section.
- [ ] The page looks good at 1440w, 768w, and 375w (mentally simulate).
- [ ] Accent used no more than twice total.
## 6. Done
Write only `index.html`. Do not generate a separate CSS file, JS file, or README.
---
## For skill authors reading this as a reference
This is a minimal but complete skill. Structure:
```
saas-landing-skill/
├── SKILL.md ← you are here
└── assets/
└── base.html (optional starter template; this skill doesn't use one)
```
Things to notice:
- The `od:` front-matter block is optional for Claude-Code-only compatibility, but adding it lights up OD's typed inputs, sliders, preview metadata, and capability gating.
- The workflow below the front-matter is plain Markdown that the agent reads as its system prompt.
- DESIGN.md is treated as a collaborator, not an override. The skill gives the agent authority to override when the brief conflicts, but never to invent new tokens.
- `data-od-id` tagging is how we wire elements to comment mode. Skills that want comment-mode compatibility must annotate their output.
See [`../../docs/skills-protocol.md`](../../docs/skills-protocol.md) for the full protocol.

View file

@ -0,0 +1,153 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Filebase — sync that respects your bandwidth</title>
<style>
:root {
--bg: #fafaf9; --fg: #1c1b1a; --muted: #6b6964; --border: #e6e4e0;
--accent: #c96442; --surface: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.55 -apple-system, system-ui, sans-serif; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 32px; }
header { display: flex; justify-content: space-between; align-items: center; padding: 20px 0; }
.logo { font-weight: 600; font-size: 17px; letter-spacing: -0.01em; }
nav a { color: var(--fg); text-decoration: none; margin-left: 22px; font-size: 14px; }
button { font: inherit; cursor: pointer; padding: 11px 20px; border-radius: 8px; font-weight: 500; }
.btn-primary { background: var(--accent); color: white; border: 1px solid var(--accent); }
.btn-secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); }
.btn-link { background: transparent; border: none; color: var(--accent); padding: 11px 0; font-weight: 500; cursor: pointer; }
section { padding: 80px 0; }
.hero { padding: 100px 0; }
.hero h1 { font-size: clamp(44px, 6vw, 76px); line-height: 1.05; letter-spacing: -0.02em; max-width: 17ch; margin: 0 0 22px; }
.hero p { font-size: 19px; color: var(--muted); max-width: 56ch; margin: 0 0 36px; }
.hero .cta { display: flex; gap: 12px; }
.features { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 32px; }
@media (max-width: 800px) { .feature-grid { grid-template-columns: 1fr; } }
.feature h3 { font-size: 18px; margin: 0 0 8px; letter-spacing: -0.01em; }
.feature .num { font-family: ui-monospace, monospace; color: var(--accent); font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 12px; display: block; }
.feature p { margin: 0; color: var(--muted); font-size: 14.5px; }
.proof { text-align: center; }
.proof h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin: 0 0 28px; }
.logos { display: flex; justify-content: center; gap: 56px; flex-wrap: wrap; opacity: 0.6; font-weight: 600; font-size: 17px; letter-spacing: -0.01em; }
.pricing h2 { text-align: center; font-size: 36px; margin: 0 0 12px; letter-spacing: -0.02em; }
.pricing .lede { text-align: center; color: var(--muted); margin: 0 0 48px; }
.tiers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 800px) { .tiers { grid-template-columns: 1fr; } }
.tier { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 32px; }
.tier.featured { border-color: var(--accent); position: relative; }
.tier.featured::before { content: 'Recommended'; position: absolute; top: -12px; left: 24px; background: var(--accent); color: white; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 500; }
.tier h3 { margin: 0 0 8px; font-size: 18px; }
.tier .price { font-size: 40px; letter-spacing: -0.02em; margin: 6px 0 16px; }
.tier .price small { font-size: 14px; color: var(--muted); font-weight: 400; }
.tier ul { list-style: none; padding: 0; margin: 16px 0 24px; color: var(--muted); font-size: 14px; }
.tier ul li { padding: 5px 0; border-top: 1px solid var(--border); }
.tier ul li:first-child { border-top: none; }
.closing { background: var(--accent); color: white; text-align: center; }
.closing h2 { font-size: 38px; letter-spacing: -0.02em; margin: 0 0 14px; }
.closing p { opacity: 0.85; margin: 0 0 28px; }
.closing button { background: white; color: var(--accent); border: none; }
footer { padding: 28px 0; color: var(--muted); font-size: 13px; text-align: center; }
</style>
</head>
<body>
<div class="wrap">
<header data-od-id="topnav">
<span class="logo">◰ Filebase</span>
<nav>
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
<a href="#docs">Docs</a>
<button class="btn-secondary" style="margin-left: 12px;">Sign in</button>
</nav>
</header>
<section class="hero" data-od-id="hero">
<h1>File sync that doesn't eat your bandwidth.</h1>
<p>Block-level deltas, end-to-end encryption, and a pricing model that doesn't punish you for working with video.</p>
<div class="cta">
<button class="btn-primary">Get Filebase</button>
<button class="btn-link">Read the whitepaper →</button>
</div>
</section>
</div>
<section class="features" id="features" data-od-id="features">
<div class="wrap feature-grid">
<div class="feature">
<span class="num">01</span>
<h3>Block-level diffs</h3>
<p>Edit a 4 GB Final Cut project? We sync the 200 KB you changed. Not the whole file.</p>
</div>
<div class="feature">
<span class="num">02</span>
<h3>End-to-end encrypted</h3>
<p>Files are encrypted on your laptop before they leave. We can't read them. Neither can law enforcement, by design.</p>
</div>
<div class="feature">
<span class="num">03</span>
<h3>Honest pricing</h3>
<p>One flat rate for unlimited storage. No "fair use" clauses. No throttling at 90%.</p>
</div>
</div>
</section>
<section class="proof wrap" data-od-id="proof">
<h2>Used by teams at</h2>
<div class="logos"><span>Anthropic</span><span>Stripe</span><span>Linear</span><span>Vercel</span><span>Cursor</span></div>
</section>
<section class="pricing wrap" id="pricing" data-od-id="pricing">
<h2>Pricing</h2>
<p class="lede">Pick a tier. Switch or cancel any time.</p>
<div class="tiers">
<div class="tier">
<h3>Solo</h3>
<div class="price">$8<small>/mo</small></div>
<p style="color: var(--muted); margin: 0;">For individuals.</p>
<ul>
<li>1 TB storage</li>
<li>Block-level sync</li>
<li>Email support</li>
</ul>
<button class="btn-secondary" style="width: 100%;">Choose Solo</button>
</div>
<div class="tier featured">
<h3>Team</h3>
<div class="price">$14<small>/seat/mo</small></div>
<p style="color: var(--muted); margin: 0;">For teams up to 50.</p>
<ul>
<li>5 TB pooled storage</li>
<li>Shared folders & roles</li>
<li>Priority support</li>
<li>Audit log</li>
</ul>
<button class="btn-primary" style="width: 100%;">Choose Team</button>
</div>
<div class="tier">
<h3>Enterprise</h3>
<div class="price">Custom</div>
<p style="color: var(--muted); margin: 0;">SSO, on-prem keys, SLA.</p>
<ul>
<li>Unlimited storage</li>
<li>SAML / SCIM</li>
<li>Dedicated support</li>
</ul>
<button class="btn-secondary" style="width: 100%;">Talk to sales</button>
</div>
</div>
</section>
<section class="closing" data-od-id="closing">
<div class="wrap">
<h2>Sync less, ship more.</h2>
<p>14-day free trial. No credit card needed.</p>
<button>Get Filebase</button>
</div>
</section>
<footer class="wrap" data-od-id="footer">© Filebase · Privacy · Terms · Status</footer>
</body>
</html>

View file

@ -0,0 +1,110 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "example-saas-landing",
"title": "Saas Landing",
"version": "0.1.0",
"description": "Single-page SaaS landing with hero, features, social proof, pricing, and CTA.\nRespects the active DESIGN.md color/typography/layout tokens.\nTrigger keywords: \"saas landing\", \"marketing page\", \"product landing\".",
"license": "MIT",
"author": {
"name": "Open Design",
"url": "https://github.com/nexu-io"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/_official/examples/saas-landing",
"tags": [
"example",
"first-party",
"prototype",
"marketing",
"web",
"desktop",
"saas-landing",
"marketing-page",
"product-landing"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
]
},
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "prototype",
"platform": "desktop",
"scenario": "marketing",
"surface": "web",
"preview": {
"type": "html",
"entry": "./example.html"
},
"useCase": {
"query": "Single-page SaaS landing with hero, features, social proof, pricing, and CTA. Respects the active DESIGN.md color/typography/layout tokens. Trigger keywords: \"saas landing\", \"marketing page\", \"product landing\".",
"exampleOutputs": [
{
"path": "./example.html",
"title": "Saas Landing"
}
]
},
"inputs": [
{
"name": "product_name",
"type": "string",
"required": true
},
{
"name": "tagline",
"type": "string",
"required": true
},
{
"name": "has_pricing",
"type": "boolean",
"default": true
},
{
"name": "proof_count",
"type": "number",
"default": 3,
"min": 0,
"max": 6
}
],
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"designSystem": {
"primary": true
},
"craft": [
"typography",
"color",
"anti-ai-slop",
"laws-of-ux"
],
"assets": [
"./example.html"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"file-write",
"live-artifact"
]
}
]
},
"capabilities": [
"prompt:inject",
"fs:write"
]
}
}

View file

@ -0,0 +1,79 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "image-template-e-commerce-live-stream-ui-mockup",
"title": "E-commerce Live Stream UI Mockup",
"version": "0.1.0",
"description": "Generates a realistic social media live stream interface overlaying a portrait, featuring customizable chat messages, gift popups, and a product purchase card.",
"license": "CC-BY-4.0",
"author": {
"name": "神经病不想好转",
"url": "https://x.com/sjbbxhz/status/2045684734714380687#reversed-0"
},
"homepage": "https://github.com/YouMind-OpenLab/awesome-gpt-image-2",
"tags": [
"image-template",
"first-party",
"image",
"app-web-design",
"portrait",
"fantasy",
"product"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "image",
"scenario": "image",
"surface": "image",
"preview": {
"type": "image",
"poster": "https://cms-assets.youmind.com/media/1776699445498_ga2ry5_HGO7H0DWkAApdKK.jpg"
},
"useCase": {
"query": "{\n \"type\": \"live stream UI mockup\",\n \"subject\": {\n \"description\": \"portrait of {argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}, smiling, wearing a black t-shirt with a white technical schematic graphic\",\n \"background\": \"left side shows a screen with '{argument name=\\\"left background logo\\\" default=\\\"SPACEX\\\"}' text, right side shows a red '{argument name=\\\"right background logo\\\" default=\\\"Tesla T logo\\\"}' and a dark car\"\n },\n \"ui_overlay\": {\n \"top_header\": {\n \"host_info\": \"avatar, name '{argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}', subtext '55.6万本场点赞', red '关注' button\",\n \"rank_badge\": \"gold coin icon with '全站第1名'\",\n \"viewer_stats\": \"3 top viewer avatars with '12.3w', '8.6w', '5.7w', total '68.7万', 'X' close button\",\n \"right_links\": \"'更多直播 >', '礼物展馆 0/24' with blue '经典' tag\"\n },\n \"mid_left_gifts\": {\n \"count\": 2,\n \"items\": [\n \"avatar '科技爱好者', '送小心心', heart icon x 1314\",\n \"avatar '星辰大海', '送火箭', rocket icon x 666\"\n ]\n },\n \"bottom_left_chat\": {\n \"system_message\": \"level 37 badge '宇宙漫游者 加入了直播间'\",\n \"message_count\": 7,\n \"messages\": [\n \"小火箭: 马斯克!未来可期!🚀\",\n \"future: 特斯拉Model 2什么时候出\",\n \"星空梦想家: SpaceX今年能上火星吗\",\n \"AI探索者: Neuralink进展如何\",\n \"帅气的网友: 马总好!\",\n \"Mars: 第一次来你的直播,超激动!\",\n \"用户123: 讲讲AI吧会取代人类吗\"\n ]\n },\n \"bottom_right_product_card\": {\n \"hot_tag\": \"orange '热卖 x 1888'\",\n \"image\": \"Tesla Cybertruck\",\n \"title\": \"{argument name=\\\"product name\\\" default=\\\"特斯拉Cybertruck 电动皮卡\\\"}\",\n \"price\": \"{argument name=\\\"product price\\\" default=\\\"¥ 1,618,000\\\"}\",\n \"button\": \"red '抢' button\",\n \"floating_animation\": \"translucent hearts floating up the right edge\"\n },\n \"bottom_bar\": {\n \"input_field\": \"'说点什么...'\",\n \"icons\": [\"smiley face\", \"three dots\", \"shopping cart\", \"gift box\", \"share\"]\n }\n }\n}"
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"gpt-image-2"
],
"default": "gpt-image-2"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"1:1",
"16:9",
"9:16",
"4:5",
"3:2"
],
"default": "1:1"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"image-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:image-generate"
]
}
}

View file

@ -0,0 +1,22 @@
{
"id": "e-commerce-live-stream-ui-mockup",
"surface": "image",
"title": "E-commerce Live Stream UI Mockup",
"summary": "Generates a realistic social media live stream interface overlaying a portrait, featuring customizable chat messages, gift popups, and a product purchase card.",
"category": "App / Web Design",
"tags": [
"portrait",
"fantasy",
"product"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"live stream UI mockup\",\n \"subject\": {\n \"description\": \"portrait of {argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}, smiling, wearing a black t-shirt with a white technical schematic graphic\",\n \"background\": \"left side shows a screen with '{argument name=\\\"left background logo\\\" default=\\\"SPACEX\\\"}' text, right side shows a red '{argument name=\\\"right background logo\\\" default=\\\"Tesla T logo\\\"}' and a dark car\"\n },\n \"ui_overlay\": {\n \"top_header\": {\n \"host_info\": \"avatar, name '{argument name=\\\"host name\\\" default=\\\"Elon Musk\\\"}', subtext '55.6万本场点赞', red '关注' button\",\n \"rank_badge\": \"gold coin icon with '全站第1名'\",\n \"viewer_stats\": \"3 top viewer avatars with '12.3w', '8.6w', '5.7w', total '68.7万', 'X' close button\",\n \"right_links\": \"'更多直播 >', '礼物展馆 0/24' with blue '经典' tag\"\n },\n \"mid_left_gifts\": {\n \"count\": 2,\n \"items\": [\n \"avatar '科技爱好者', '送小心心', heart icon x 1314\",\n \"avatar '星辰大海', '送火箭', rocket icon x 666\"\n ]\n },\n \"bottom_left_chat\": {\n \"system_message\": \"level 37 badge '宇宙漫游者 加入了直播间'\",\n \"message_count\": 7,\n \"messages\": [\n \"小火箭: 马斯克!未来可期!🚀\",\n \"future: 特斯拉Model 2什么时候出\",\n \"星空梦想家: SpaceX今年能上火星吗\",\n \"AI探索者: Neuralink进展如何\",\n \"帅气的网友: 马总好!\",\n \"Mars: 第一次来你的直播,超激动!\",\n \"用户123: 讲讲AI吧会取代人类吗\"\n ]\n },\n \"bottom_right_product_card\": {\n \"hot_tag\": \"orange '热卖 x 1888'\",\n \"image\": \"Tesla Cybertruck\",\n \"title\": \"{argument name=\\\"product name\\\" default=\\\"特斯拉Cybertruck 电动皮卡\\\"}\",\n \"price\": \"{argument name=\\\"product price\\\" default=\\\"¥ 1,618,000\\\"}\",\n \"button\": \"red '抢' button\",\n \"floating_animation\": \"translucent hearts floating up the right edge\"\n },\n \"bottom_bar\": {\n \"input_field\": \"'说点什么...'\",\n \"icons\": [\"smiley face\", \"three dots\", \"shopping cart\", \"gift box\", \"share\"]\n }\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776699445498_ga2ry5_HGO7H0DWkAApdKK.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "神经病不想好转",
"url": "https://x.com/sjbbxhz/status/2045684734714380687#reversed-0"
}
}

View file

@ -0,0 +1,78 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "image-template-illustrated-city-food-map",
"title": "Illustrated City Food Map",
"version": "0.1.0",
"description": "Generates a hand-drawn, watercolor-style tourist map featuring numbered local food specialties, landmarks, and a legend.",
"license": "CC-BY-4.0",
"author": {
"name": "皮皮特",
"url": "https://x.com/mm_zzm44854/status/2045861258520568230#reversed-1"
},
"homepage": "https://github.com/YouMind-OpenLab/awesome-gpt-image-2",
"tags": [
"image-template",
"first-party",
"image",
"illustration",
"food",
"nature"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "image",
"scenario": "image",
"surface": "image",
"preview": {
"type": "image",
"poster": "https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg"
},
"useCase": {
"query": "{\n \"type\": \"illustrated map infographic\",\n \"style\": \"{argument name=\\\"art style\\\" default=\\\"watercolor and ink hand-drawn illustration on vintage parchment\\\"}\",\n \"title_section\": {\n \"text\": \"{argument name=\\\"city name\\\" default=\\\"成都\\\"} {argument name=\\\"map title\\\" default=\\\"吃货暴走地图\\\"}\",\n \"mascot\": \"cartoon red chili pepper wearing sunglasses and giving a thumbs up\"\n },\n \"border\": \"{argument name=\\\"border decoration\\\" default=\\\"vine of green leaves and red chili peppers\\\"}\",\n \"layout\": {\n \"background\": \"textured beige parchment paper with yellow roads, blue rivers, and green park areas\",\n \"sections\": [\n {\n \"title\": \"landmarks\",\n \"count\": 6,\n \"illustrations\": [\"traditional pavilion\", \"traditional monastery\", \"modern skyscraper with climbing panda\", \"tall TV tower\", \"traditional gate\", \"industrial buildings\"],\n \"labels\": [\"人民公园\", \"文殊院\", \"IFS\", \"339电视塔\", \"宽窄巷子\", \"东郊记忆\"]\n },\n {\n \"title\": \"food_spots\",\n \"count\": 12,\n \"illustrations\": [\"mapo tofu\", \"dumplings in chili oil\", \"skewers in pot\", \"sticky rice balls\", \"egg baking cake\", \"nine-grid hotpot\", \"sweet potato noodles\", \"cold skewers\", \"spicy mixed dish\", \"covered tea bowl\", \"ice jelly dessert\", \"spicy rabbit heads\"],\n \"labels\": [\"1 陈麻婆豆腐\", \"2 钟水饺\", \"3 春熙路\", \"4 宽窄巷子·三大炮\", \"5 建设路·叶婆婆蛋烘糕\", \"6 玉林路·小龙坎火锅\", \"7 香香巷·肥肠粉\", \"8 武侯祠大街·钵钵鸡\", \"9 东郊记忆·冒椒火辣\", \"10 人民公园·鹤鸣茶社\", \"11 锦里古街·冰粉\", \"12 双流老妈兔头\"]\n },\n {\n \"title\": \"图例\",\n \"position\": \"bottom-right\",\n \"count\": 5,\n \"items\": [\"red dot\", \"green house\", \"green tree\", \"blue line\", \"yellow double line\"],\n \"labels\": [\"美食地点\", \"地标景点\", \"公园绿地\", \"河流湖泊\", \"主要道路\"]\n }\n ],\n \"centerpiece\": \"giant panda sitting and eating bamboo\",\n \"bottom_right_extras\": [\"vintage compass rose with N, S, E, W\", \"disclaimer text '温馨提示:吃辣需谨慎,肠胃要保护~' with a red chili pepper icon\"]\n }\n}"
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"gpt-image-2"
],
"default": "gpt-image-2"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"1:1",
"16:9",
"9:16",
"4:5",
"3:2"
],
"default": "1:1"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"image-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:image-generate"
]
}
}

View file

@ -0,0 +1,21 @@
{
"id": "illustrated-city-food-map",
"surface": "image",
"title": "Illustrated City Food Map",
"summary": "Generates a hand-drawn, watercolor-style tourist map featuring numbered local food specialties, landmarks, and a legend.",
"category": "Illustration",
"tags": [
"food",
"nature"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "{\n \"type\": \"illustrated map infographic\",\n \"style\": \"{argument name=\\\"art style\\\" default=\\\"watercolor and ink hand-drawn illustration on vintage parchment\\\"}\",\n \"title_section\": {\n \"text\": \"{argument name=\\\"city name\\\" default=\\\"成都\\\"} {argument name=\\\"map title\\\" default=\\\"吃货暴走地图\\\"}\",\n \"mascot\": \"cartoon red chili pepper wearing sunglasses and giving a thumbs up\"\n },\n \"border\": \"{argument name=\\\"border decoration\\\" default=\\\"vine of green leaves and red chili peppers\\\"}\",\n \"layout\": {\n \"background\": \"textured beige parchment paper with yellow roads, blue rivers, and green park areas\",\n \"sections\": [\n {\n \"title\": \"landmarks\",\n \"count\": 6,\n \"illustrations\": [\"traditional pavilion\", \"traditional monastery\", \"modern skyscraper with climbing panda\", \"tall TV tower\", \"traditional gate\", \"industrial buildings\"],\n \"labels\": [\"人民公园\", \"文殊院\", \"IFS\", \"339电视塔\", \"宽窄巷子\", \"东郊记忆\"]\n },\n {\n \"title\": \"food_spots\",\n \"count\": 12,\n \"illustrations\": [\"mapo tofu\", \"dumplings in chili oil\", \"skewers in pot\", \"sticky rice balls\", \"egg baking cake\", \"nine-grid hotpot\", \"sweet potato noodles\", \"cold skewers\", \"spicy mixed dish\", \"covered tea bowl\", \"ice jelly dessert\", \"spicy rabbit heads\"],\n \"labels\": [\"1 陈麻婆豆腐\", \"2 钟水饺\", \"3 春熙路\", \"4 宽窄巷子·三大炮\", \"5 建设路·叶婆婆蛋烘糕\", \"6 玉林路·小龙坎火锅\", \"7 香香巷·肥肠粉\", \"8 武侯祠大街·钵钵鸡\", \"9 东郊记忆·冒椒火辣\", \"10 人民公园·鹤鸣茶社\", \"11 锦里古街·冰粉\", \"12 双流老妈兔头\"]\n },\n {\n \"title\": \"图例\",\n \"position\": \"bottom-right\",\n \"count\": 5,\n \"items\": [\"red dot\", \"green house\", \"green tree\", \"blue line\", \"yellow double line\"],\n \"labels\": [\"美食地点\", \"地标景点\", \"公园绿地\", \"河流湖泊\", \"主要道路\"]\n }\n ],\n \"centerpiece\": \"giant panda sitting and eating bamboo\",\n \"bottom_right_extras\": [\"vintage compass rose with N, S, E, W\", \"disclaimer text '温馨提示:吃辣需谨慎,肠胃要保护~' with a red chili pepper icon\"]\n }\n}",
"previewImageUrl": "https://cms-assets.youmind.com/media/1776662673014_nf0taw_HGRMNDybsAAGG88.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "皮皮特",
"url": "https://x.com/mm_zzm44854/status/2045861258520568230#reversed-1"
}
}

View file

@ -0,0 +1,79 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "image-template-profile-avatar-cinematic-south-asian-male-portrait-with-vultures",
"title": "Profile / Avatar - Cinematic South Asian Male Portrait with Vultures",
"version": "0.1.0",
"description": "A detailed cinematic portrait of a young South Asian man in a moody, dark fantasy setting surrounded by vultures and ravens.",
"license": "CC-BY-4.0",
"author": {
"name": "Jahan Zaib",
"url": "https://x.com/jzaib4269/status/2048949396222489081"
},
"homepage": "https://github.com/YouMind-OpenLab/awesome-gpt-image-2",
"tags": [
"image-template",
"first-party",
"image",
"profile-avatar",
"portrait",
"cinematic",
"fantasy"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "image",
"scenario": "image",
"surface": "image",
"preview": {
"type": "image",
"poster": "https://cms-assets.youmind.com/media/1777453132629_dmkonb_HG9Und1aYAAyo9g.jpg"
},
"useCase": {
"query": "A highly detailed cinematic portrait of a handsome {argument name=\"ethnicity\" default=\"South Asian\"} man in his late 20s or early 30s, sitting on a metal railing with a soccer goal net behind him. He has sharp facial features, dark styled hair, light stubble, and intense dark eyes. He is wearing a {argument name=\"clothing\" default=\"black zip-up hoodie, black sweatpants, and white speckled sneakers\"}. His hands are clasped together resting on his knees as he looks directly at the viewer with a confident, slightly brooding expression.\n\nHe is surrounded by a dramatic flock of large black vultures and ravens. Some vultures are flying with wings spread in a dark stormy sky, while others are perched on the railing and goalpost near him. The atmosphere is {argument name=\"atmosphere\" default=\"dark, moody, and cinematic\"} with heavy storm clouds, dramatic lighting, and a mysterious, powerful vibe. High contrast, moody color grading, ultra-realistic, photorealistic, epic composition, dark fantasy aesthetic."
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"gpt-image-2"
],
"default": "gpt-image-2"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"1:1",
"16:9",
"9:16",
"4:5",
"3:2"
],
"default": "1:1"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"image-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:image-generate"
]
}
}

View file

@ -0,0 +1,22 @@
{
"id": "profile-avatar-cinematic-south-asian-male-portrait-with-vultures",
"surface": "image",
"title": "Profile / Avatar - Cinematic South Asian Male Portrait with Vultures",
"summary": "A detailed cinematic portrait of a young South Asian man in a moody, dark fantasy setting surrounded by vultures and ravens.",
"category": "Profile / Avatar",
"tags": [
"portrait",
"cinematic",
"fantasy"
],
"model": "gpt-image-2",
"aspect": "1:1",
"prompt": "A highly detailed cinematic portrait of a handsome {argument name=\"ethnicity\" default=\"South Asian\"} man in his late 20s or early 30s, sitting on a metal railing with a soccer goal net behind him. He has sharp facial features, dark styled hair, light stubble, and intense dark eyes. He is wearing a {argument name=\"clothing\" default=\"black zip-up hoodie, black sweatpants, and white speckled sneakers\"}. His hands are clasped together resting on his knees as he looks directly at the viewer with a confident, slightly brooding expression.\n\nHe is surrounded by a dramatic flock of large black vultures and ravens. Some vultures are flying with wings spread in a dark stormy sky, while others are perched on the railing and goalpost near him. The atmosphere is {argument name=\"atmosphere\" default=\"dark, moody, and cinematic\"} with heavy storm clouds, dramatic lighting, and a mysterious, powerful vibe. High contrast, moody color grading, ultra-realistic, photorealistic, epic composition, dark fantasy aesthetic.",
"previewImageUrl": "https://cms-assets.youmind.com/media/1777453132629_dmkonb_HG9Und1aYAAyo9g.jpg",
"source": {
"repo": "YouMind-OpenLab/awesome-gpt-image-2",
"license": "CC-BY-4.0",
"author": "Jahan Zaib",
"url": "https://x.com/jzaib4269/status/2048949396222489081"
}
}

View file

@ -0,0 +1,79 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "video-template-a-decade-of-refinement-glow-up",
"title": "A Decade of Refinement Glow-Up",
"version": "0.1.0",
"description": "A transformation prompt for Seedance 2.0 showing a man's transition from a casual 2016 setting to a luxurious 2026 Dubai lifestyle while maintaining character consistency.",
"license": "CC-BY-4.0",
"author": {
"name": "Maverick | AI",
"url": "https://x.com/RizwanAly07/status/2048948726623056366"
},
"homepage": "https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts",
"tags": [
"video-template",
"first-party",
"video",
"advertising",
"cinematic",
"fantasy",
"product"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "video",
"scenario": "video",
"surface": "video",
"preview": {
"type": "video",
"poster": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/thumbnails/thumbnail.jpg",
"video": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/downloads/default.mp4"
},
"useCase": {
"query": "Create a 15-second ultra-realistic cinematic transformation video using the exact same man from the uploaded reference image. Maintain perfect face consistency, same hairstyle, facial features, identity, and body proportions throughout. No face change. Concept: “2026 is the new 2016” nostalgia-to-luxury glow-up. Scene 1: 2016 version — simple casual clothes, basic hairstyle, walking alone on a normal street, warm nostalgic colors, old Instagram aesthetic, simple life, no luxury. Scene 2: Flashback cuts — old bike ride, cheap café alone, late-night dreams, city lights, silent ambition in his eyes. Scene 3: Strong transition — speed-ramp effect, screen crack cinematic transition, time shifts from 2016 to 2026, luxury watch appears, black suit transformation begins. Scene 4: 2026 version — walking confidently in Dubai downtown, luxury black suit, sunglasses, expensive watch, black luxury car behind him, people turn and stare. Scene 5: Hero shot — rooftop skyline at sunset, slow motion, wind moving, camera rotating around him, strong eye contact, main character energy. Final scene: cinematic ending with the feeling “Same Man. Different Era.” Style: hyper-realistic, Netflix-level production, luxury transformation, dramatic lighting, viral Instagram reel style, strong masculine aura, editorial fashion visuals, 4K ultra realism, emotional and powerful storytelling."
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"seedance-2.0"
],
"default": "seedance-2.0"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"16:9",
"9:16",
"1:1",
"4:5"
],
"default": "16:9"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"video-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:video-generate"
]
}
}

View file

@ -0,0 +1,23 @@
{
"id": "a-decade-of-refinement-glow-up",
"surface": "video",
"title": "A Decade of Refinement Glow-Up",
"summary": "A transformation prompt for Seedance 2.0 showing a man's transition from a casual 2016 setting to a luxurious 2026 Dubai lifestyle while maintaining character consistency.",
"category": "Advertising",
"tags": [
"cinematic",
"fantasy",
"product"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "Create a 15-second ultra-realistic cinematic transformation video using the exact same man from the uploaded reference image. Maintain perfect face consistency, same hairstyle, facial features, identity, and body proportions throughout. No face change. Concept: “2026 is the new 2016” nostalgia-to-luxury glow-up. Scene 1: 2016 version — simple casual clothes, basic hairstyle, walking alone on a normal street, warm nostalgic colors, old Instagram aesthetic, simple life, no luxury. Scene 2: Flashback cuts — old bike ride, cheap café alone, late-night dreams, city lights, silent ambition in his eyes. Scene 3: Strong transition — speed-ramp effect, screen crack cinematic transition, time shifts from 2016 to 2026, luxury watch appears, black suit transformation begins. Scene 4: 2026 version — walking confidently in Dubai downtown, luxury black suit, sunglasses, expensive watch, black luxury car behind him, people turn and stare. Scene 5: Hero shot — rooftop skyline at sunset, slow motion, wind moving, camera rotating around him, strong eye contact, main character energy. Final scene: cinematic ending with the feeling “Same Man. Different Era.” Style: hyper-realistic, Netflix-level production, luxury transformation, dramatic lighting, viral Instagram reel style, strong masculine aura, editorial fashion visuals, 4K ultra realism, emotional and powerful storytelling.",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/d2d6d15cbc6ef4d4d4c8c9a7de7007d7/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Maverick | AI",
"url": "https://x.com/RizwanAly07/status/2048948726623056366"
}
}

View file

@ -0,0 +1,78 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "video-template-cinematic-birthday-celebration-sequence",
"title": "Cinematic Birthday Celebration Sequence",
"version": "0.1.0",
"description": "A highly detailed multi-shot video prompt for a birthday sequence, focusing on character consistency and emotional storytelling.",
"license": "CC-BY-4.0",
"author": {
"name": "Soulful Ai",
"url": "https://x.com/soulful__ai/status/2048908956178001993"
},
"homepage": "https://github.com/YouMind-OpenLab/awesome-seedance-2-prompts",
"tags": [
"video-template",
"first-party",
"video",
"cinematic",
"fantasy",
"cinematic-romance"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "video",
"scenario": "video",
"surface": "video",
"preview": {
"type": "video",
"poster": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/thumbnails/thumbnail.jpg",
"video": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/downloads/default.mp4"
},
"useCase": {
"query": "0s4s\nClose-up of a young girl waking up in a softly lit bedroom, warm golden sunlight through curtains, she gently smiles while checking her phone filled with birthday wishes, natural makeup, SAME facial features as reference image, cinematic lighting, shallow depth of field, ultra-realistic, 4K\n\n4s8s\nCut to a cozy, beautifully decorated room with balloons and fairy lights, her friends surprise her with a birthday cake, everyone cheering, she laughs happily, SAME face as reference image, joyful expressions, cinematic camera movement, vibrant colors, soft glow, high detail\n\n8s12s\nHer boyfriend enters — a well-dressed young man with neatly styled dark hair, sharp jawline, warm expressive eyes, wearing a clean elegant outfit (white shirt with a fitted blazer), minimal accessories, charming and calm presence ,he presents a beautiful bouquet of fresh flowers, she looks surprised and emotional, soft eye contact, SAME facial features maintained, romantic cinematic tone, warm lighting, slight slow motion, realistic textures, elegant framing\n\n12s16s\nFinal scene: she stands surrounded by friends, holding the bouquet and cake, boyfriend beside her smiling softly, candles glowing, she closes her eyes to make a wish, SAME face consistency, cinematic wide shot, dreamy atmosphere, soft bokeh lights, high-end film look"
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"seedance-2.0"
],
"default": "seedance-2.0"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"16:9",
"9:16",
"1:1",
"4:5"
],
"default": "16:9"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"video-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:video-generate"
]
}
}

View file

@ -0,0 +1,23 @@
{
"id": "cinematic-birthday-celebration-sequence",
"surface": "video",
"title": "Cinematic Birthday Celebration Sequence",
"summary": "A highly detailed multi-shot video prompt for a birthday sequence, focusing on character consistency and emotional storytelling.",
"category": "Cinematic",
"tags": [
"cinematic",
"fantasy",
"cinematic-romance"
],
"model": "seedance-2.0",
"aspect": "16:9",
"prompt": "0s4s\nClose-up of a young girl waking up in a softly lit bedroom, warm golden sunlight through curtains, she gently smiles while checking her phone filled with birthday wishes, natural makeup, SAME facial features as reference image, cinematic lighting, shallow depth of field, ultra-realistic, 4K\n\n4s8s\nCut to a cozy, beautifully decorated room with balloons and fairy lights, her friends surprise her with a birthday cake, everyone cheering, she laughs happily, SAME face as reference image, joyful expressions, cinematic camera movement, vibrant colors, soft glow, high detail\n\n8s12s\nHer boyfriend enters — a well-dressed young man with neatly styled dark hair, sharp jawline, warm expressive eyes, wearing a clean elegant outfit (white shirt with a fitted blazer), minimal accessories, charming and calm presence ,he presents a beautiful bouquet of fresh flowers, she looks surprised and emotional, soft eye contact, SAME facial features maintained, romantic cinematic tone, warm lighting, slight slow motion, realistic textures, elegant framing\n\n12s16s\nFinal scene: she stands surrounded by friends, holding the bouquet and cake, boyfriend beside her smiling softly, candles glowing, she closes her eyes to make a wish, SAME face consistency, cinematic wide shot, dreamy atmosphere, soft bokeh lights, high-end film look",
"previewImageUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/thumbnails/thumbnail.jpg",
"previewVideoUrl": "https://customer-qs6wnyfuv0gcybzj.cloudflarestream.com/47f113f50f5bd3794cbd83d2bb99320b/downloads/default.mp4",
"source": {
"repo": "YouMind-OpenLab/awesome-seedance-2-prompts",
"license": "CC-BY-4.0",
"author": "Soulful Ai",
"url": "https://x.com/soulful__ai/status/2048908956178001993"
}
}

View file

@ -0,0 +1,81 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"name": "video-template-hyperframes-saas-product-promo-30s",
"title": "HyperFrames: 30-Second SaaS Product Promo (Linear-style)",
"version": "0.1.0",
"description": "A 30-second HyperFrames composition modelled on Linear/ClickUp-style product films — UI 3D reveals, beat-synced kinetic typography, animated UI screenshots, end-card with logo outro. Built from HF catalog blocks (ui-3d-reveal, app-showcase, logo-outro) plus shader transitions between scenes.",
"license": "Apache-2.0",
"author": {
"name": "HeyGen",
"url": "https://x.com/HeyGen/status/2048882211022311614"
},
"homepage": "https://github.com/heygen-com/hyperframes",
"tags": [
"video-template",
"first-party",
"video",
"marketing",
"hyperframes",
"product-promo",
"saas",
"linear-style",
"kinetic-typography"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "video",
"scenario": "video",
"surface": "video",
"preview": {
"type": "video",
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png",
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.mp4"
},
"useCase": {
"query": "Build a 30-second HyperFrames product promo (1920×1080, 30fps) for a fictional SaaS app. Pull these catalog blocks first: `npx hyperframes add ui-3d-reveal`, `npx hyperframes add app-showcase`, `npx hyperframes add logo-outro`, `npx hyperframes add flash-through-white`, `npx hyperframes add chromatic-radial-split`.\n\nVisual identity: cool slate canvas #0e1116, single electric accent #6cf3c0, off-white text #f5f7fa, secondary indigo #7da4ff used only on UI chrome. Display face: \"General Sans\" 120px; body \"Inter\" 24px; mono \"JetBrains Mono\" 18px on UI bits; tabular-nums on numbers.\n\nFour scenes, each ~7s, separated by shader transitions:\n\nScene 1 (07s) HOOK — full-bleed quote-typography. Headline scales in from 0.9 over 0.6s ease expo.out, then a single mono kicker line below appears with a marker-sweep highlight (use the css-patterns marker pattern). Background: subtle grain-overlay block at 8% opacity.\nTransition at 7.0s → flash-through-white, 0.4s.\n\nScene 2 (7.414.4s) PROBLEM — three pull-quotes from \"users\" in stacked Reddit-style cards using the `reddit-post` overlay block, staggered 280ms apart, each entering with x:-60→0 + opacity 0→1 ease power3.out 0.5s. Hold for 2s on the third card. Background still grain-overlay; soft #6cf3c0 radial glow at 6% behind the stack.\nTransition at 14.4s → chromatic-radial-split, 0.5s.\n\nScene 3 (14.922.0s) SOLUTION — the `app-showcase` block (three floating phones / desktop hybrid) renders the product UI. Use ui-3d-reveal to fly in the central UI panel from z=-400 with a 0.7s ease expo.out, then stagger three feature pills (each \"plan / track / ship\") sliding in from the right over 1.6s. Animate one cursor click on the active pill at 19.5s.\nTransition at 22.0s → flash-through-white, 0.3s.\n\nScene 4 (22.330s) END-CARD — `logo-outro` block: piece-by-piece wordmark assembly with bloom glow over 1.4s, then a single CTA line \"try it · 14-day free\" fades in at 25.5s, then hold. Final 1s of grain-overlay continues for texture.\n\nNon-negotiables: all timelines `paused: true` registered to window.__timelines; entrance-only animations (no opacity-to-0 exits — transitions handle the cuts); root data-composition-id, data-width=1920, data-height=1080, data-duration=30; min font-size 60px on every headline; tabular-nums on any digit row. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 10` before render. Output: `saas-product-promo-30s.mp4`."
},
"inputs": [
{
"name": "model",
"label": "Model",
"type": "select",
"options": [
"hyperframes-html"
],
"default": "hyperframes-html"
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"options": [
"16:9",
"9:16",
"1:1",
"4:5"
],
"default": "16:9"
}
],
"context": {
"assets": [
"./template.json"
]
},
"pipeline": {
"stages": [
{
"id": "generate",
"atoms": [
"video-generate"
]
}
]
},
"capabilities": [
"prompt:inject",
"media:video-generate"
]
}
}

View file

@ -0,0 +1,19 @@
{
"id": "hyperframes-saas-product-promo-30s",
"surface": "video",
"title": "HyperFrames: 30-Second SaaS Product Promo (Linear-style)",
"summary": "A 30-second HyperFrames composition modelled on Linear/ClickUp-style product films — UI 3D reveals, beat-synced kinetic typography, animated UI screenshots, end-card with logo outro. Built from HF catalog blocks (ui-3d-reveal, app-showcase, logo-outro) plus shader transitions between scenes.",
"category": "Marketing",
"tags": ["hyperframes", "product-promo", "saas", "linear-style", "kinetic-typography"],
"model": "hyperframes-html",
"aspect": "16:9",
"prompt": "Build a 30-second HyperFrames product promo (1920×1080, 30fps) for a fictional SaaS app. Pull these catalog blocks first: `npx hyperframes add ui-3d-reveal`, `npx hyperframes add app-showcase`, `npx hyperframes add logo-outro`, `npx hyperframes add flash-through-white`, `npx hyperframes add chromatic-radial-split`.\n\nVisual identity: cool slate canvas #0e1116, single electric accent #6cf3c0, off-white text #f5f7fa, secondary indigo #7da4ff used only on UI chrome. Display face: \"General Sans\" 120px; body \"Inter\" 24px; mono \"JetBrains Mono\" 18px on UI bits; tabular-nums on numbers.\n\nFour scenes, each ~7s, separated by shader transitions:\n\nScene 1 (07s) HOOK — full-bleed quote-typography. Headline scales in from 0.9 over 0.6s ease expo.out, then a single mono kicker line below appears with a marker-sweep highlight (use the css-patterns marker pattern). Background: subtle grain-overlay block at 8% opacity.\nTransition at 7.0s → flash-through-white, 0.4s.\n\nScene 2 (7.414.4s) PROBLEM — three pull-quotes from \"users\" in stacked Reddit-style cards using the `reddit-post` overlay block, staggered 280ms apart, each entering with x:-60→0 + opacity 0→1 ease power3.out 0.5s. Hold for 2s on the third card. Background still grain-overlay; soft #6cf3c0 radial glow at 6% behind the stack.\nTransition at 14.4s → chromatic-radial-split, 0.5s.\n\nScene 3 (14.922.0s) SOLUTION — the `app-showcase` block (three floating phones / desktop hybrid) renders the product UI. Use ui-3d-reveal to fly in the central UI panel from z=-400 with a 0.7s ease expo.out, then stagger three feature pills (each \"plan / track / ship\") sliding in from the right over 1.6s. Animate one cursor click on the active pill at 19.5s.\nTransition at 22.0s → flash-through-white, 0.3s.\n\nScene 4 (22.330s) END-CARD — `logo-outro` block: piece-by-piece wordmark assembly with bloom glow over 1.4s, then a single CTA line \"try it · 14-day free\" fades in at 25.5s, then hold. Final 1s of grain-overlay continues for texture.\n\nNon-negotiables: all timelines `paused: true` registered to window.__timelines; entrance-only animations (no opacity-to-0 exits — transitions handle the cuts); root data-composition-id, data-width=1920, data-height=1080, data-duration=30; min font-size 60px on every headline; tabular-nums on any digit row. Run `npx hyperframes lint` and `npx hyperframes inspect --samples 10` before render. Output: `saas-product-promo-30s.mp4`.",
"previewImageUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.png",
"previewVideoUrl": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/app-showcase.mp4",
"source": {
"repo": "heygen-com/hyperframes",
"license": "Apache-2.0",
"author": "HeyGen",
"url": "https://x.com/HeyGen/status/2048882211022311614"
}
}

View file

@ -92,6 +92,12 @@ const residualAllowedPathPatterns: RegExp[] = [
// these skill directories must still be converted to TypeScript or explicitly
// listed in `residualAllowedExactPaths`.
/^skills\/html-ppt-zhangzara-[^/]+\/assets\/deck-stage\.js$/,
// Bundled example/skill plugins copy the upstream skill's `assets/`
// and `references/` directories verbatim so the daemon's preview
// surface can render the baked HTML without staging detours. Those
// assets are vendored runtime, never project-owned code, and must
// not be retypecasted to TypeScript.
/^plugins\/_official\/examples\/[^/]+\/(assets|references)\/.+$/,
];
function isResidualAllowedPath(repositoryPath: string): boolean {

View file

@ -0,0 +1,153 @@
// Wrap a `design-systems/<id>/DESIGN.md` as a bundled plugin under
// `plugins/_official/design-systems/<id>/`. The user-facing query is
// kept deliberately open: a design-system plugin describes "house
// style", so the user supplies the artifact kind + brief on apply
// and the agent reproduces the brand language faithfully via the
// embedded DESIGN.md.
import path from 'node:path';
import { readFile, readdir } from 'node:fs/promises';
import {
DESIGN_SYSTEMS_DIR,
PLUGINS_ROOT,
TIER_DESIGN_SYSTEMS,
buildManifest,
copyFile,
dedupeTags,
pathExists,
pluginName,
writeManifest,
type RunStats,
} from './lib.ts';
export interface DesignSystemGeneratorOptions {
ids?: string[];
limit?: number;
dryRun?: boolean;
}
export async function runDesignSystemGenerator(
opts: DesignSystemGeneratorOptions,
): Promise<RunStats> {
const stats: RunStats = { generated: [], skipped: [] };
let entries;
try {
entries = await readdir(DESIGN_SYSTEMS_DIR, { withFileTypes: true });
} catch {
return stats;
}
const folders = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((n) => !opts.ids || opts.ids.includes(n))
.sort();
const slice = opts.limit !== undefined ? folders.slice(0, opts.limit) : folders;
for (const id of slice) {
const srcFolder = path.join(DESIGN_SYSTEMS_DIR, id);
const designPath = path.join(srcFolder, 'DESIGN.md');
if (!(await pathExists(designPath))) {
stats.skipped.push({ id, reason: 'missing DESIGN.md' });
continue;
}
const raw = await readFile(designPath, 'utf8');
const { title, category, summary } = extractMeta(raw, id);
const name = pluginName('design-system', id);
const folder = path.join(PLUGINS_ROOT, TIER_DESIGN_SYSTEMS, id);
const manifest = buildManifest({
name,
title,
description: summary,
license: 'MIT',
tags: dedupeTags([
'design-system',
'first-party',
'design',
category,
]),
od: {
kind: 'scenario',
taskKind: 'new-generation',
mode: 'design-system',
scenario: 'design',
surface: 'web',
useCase: {
query:
`Generate a {{artifactKind}} using the ${title} design system. ` +
`Stay faithful to its colour palette, typography, spacing, ` +
`iconography, and component vocabulary as documented in DESIGN.md.`,
},
inputs: [
{
name: 'artifactKind',
label: 'Artifact kind',
type: 'select',
options: ['landing page', 'dashboard', 'marketing site', 'app screen'],
default: 'landing page',
},
{
name: 'brief',
label: 'Brief',
type: 'text',
placeholder: 'What should the page communicate?',
},
],
context: {
designSystem: { ref: id, primary: true },
assets: ['./DESIGN.md'],
},
pipeline: {
stages: [{ id: 'generate', atoms: ['file-write', 'live-artifact'] }],
},
capabilities: ['prompt:inject', 'fs:write'],
},
});
if (opts.dryRun) {
stats.generated.push(id);
continue;
}
await writeManifest(folder, manifest);
await copyFile(designPath, path.join(folder, 'DESIGN.md'));
stats.generated.push(id);
}
return stats;
}
interface ExtractedMeta { title: string; category: string; summary: string; }
function extractMeta(raw: string, fallbackId: string): ExtractedMeta {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? humanize(fallbackId));
const categoryMatch = /^>\s*Category:\s*(.+?)\s*$/im.exec(raw);
const category = categoryMatch?.[1]?.trim() ?? 'design-systems';
const lines = raw.split(/\r?\n/);
const firstH1Idx = lines.findIndex((l) => /^#\s+/.test(l));
let summary = '';
if (firstH1Idx !== -1) {
const rest = lines.slice(firstH1Idx + 1);
const nextHeading = rest.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? rest : rest.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
summary = window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
return { title, category, summary };
}
function cleanTitle(raw: string): string {
return raw.replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function humanize(id: string): string {
return id
.replace(/[-_]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(' ');
}

View file

@ -0,0 +1,233 @@
// Wrap a `skills/<id>/` folder as a bundled plugin under
// `plugins/_official/examples/<id>/`. We copy SKILL.md + side files
// (example.html, assets/, references/) so the daemon's bundled walker
// has everything it needs without reaching outside the plugin folder
// (the registry only resolves SKILL.md / .claude-plugin / open-design.json
// inside the plugin root — see `apps/daemon/src/plugins/registry.ts`).
import path from 'node:path';
import { readFile, readdir } from 'node:fs/promises';
import {
PLUGINS_ROOT,
SKILLS_DIR,
TIER_EXAMPLES,
buildManifest,
copyFile,
dedupeTags,
parseFrontmatter,
pathExists,
pluginName,
writeManifest,
type RunStats,
} from './lib.ts';
export interface ExampleGeneratorOptions {
ids?: string[];
limit?: number;
dryRun?: boolean;
}
interface SkillFrontmatter {
name?: string;
description?: string;
triggers?: unknown[];
od?: {
mode?: string;
surface?: string;
platform?: string;
scenario?: string;
example_prompt?: string;
fidelity?: string;
speaker_notes?: unknown;
animations?: unknown;
featured?: unknown;
design_system?: { requires?: boolean };
craft?: { requires?: string[] };
preview?: { type?: string; entry?: string };
inputs?: Array<Record<string, unknown>>;
};
}
export async function runExampleGenerator(opts: ExampleGeneratorOptions): Promise<RunStats> {
const stats: RunStats = { generated: [], skipped: [] };
let entries;
try {
entries = await readdir(SKILLS_DIR, { withFileTypes: true });
} catch {
return stats;
}
const folders = entries
.filter((e) => e.isDirectory())
.map((e) => e.name)
.filter((n) => !opts.ids || opts.ids.includes(n))
.sort();
const slice = opts.limit !== undefined ? folders.slice(0, opts.limit) : folders;
for (const id of slice) {
const srcFolder = path.join(SKILLS_DIR, id);
const skillPath = path.join(srcFolder, 'SKILL.md');
if (!(await pathExists(skillPath))) {
stats.skipped.push({ id, reason: 'missing SKILL.md' });
continue;
}
const raw = await readFile(skillPath, 'utf8');
const { data } = parseFrontmatter(raw);
const fm = data as SkillFrontmatter;
const name = pluginName('example', id);
const folder = path.join(PLUGINS_ROOT, TIER_EXAMPLES, id);
const mode = fm.od?.mode ?? 'prototype';
const surface = fm.od?.surface ?? inferSurface(mode);
const scenario = fm.od?.scenario ?? 'design';
const platform = fm.od?.platform;
const exampleFile = await sideFiles(srcFolder);
// The plugin folder ships `example.html` (the baked output), not
// the original `index.html` the skill renders into the project
// working directory. Always point preview at the in-folder file
// so the daemon's preview surface has something to render without
// running the agent first.
const previewEntry = exampleFile.hasExample ? 'example.html' : (fm.od?.preview?.entry ?? 'example.html');
const manifest = buildManifest({
name,
title: humanize(id),
description: typeof fm.description === 'string' ? fm.description.trim() : '',
license: 'MIT',
author: { name: 'Open Design', url: 'https://github.com/nexu-io' },
homepage: `https://github.com/nexu-io/open-design/tree/main/plugins/_official/${TIER_EXAMPLES}/${id}`,
tags: dedupeTags([
'example',
'first-party',
mode,
scenario,
surface,
platform,
...(Array.isArray(fm.triggers) ? fm.triggers.map(String) : []),
]),
compat: { agentSkills: [{ path: './SKILL.md' }] },
od: {
kind: 'scenario',
taskKind: 'new-generation',
mode,
...(platform ? { platform } : {}),
scenario,
surface,
preview: { type: fm.od?.preview?.type ?? 'html', entry: `./${previewEntry}` },
useCase: {
query: derivePrompt(fm),
...(exampleFile.hasExample
? { exampleOutputs: [{ path: './example.html', title: humanize(id) }] }
: {}),
},
...(Array.isArray(fm.od?.inputs) && fm.od.inputs.length > 0
? { inputs: fm.od.inputs.map(normaliseInput) }
: {}),
context: {
skills: [{ path: './SKILL.md' }],
...(fm.od?.design_system?.requires !== false
? { designSystem: { primary: true } }
: {}),
...(Array.isArray(fm.od?.craft?.requires) && fm.od.craft.requires.length > 0
? { craft: fm.od.craft.requires }
: {}),
assets: exampleFile.assets,
},
pipeline: {
stages: [{ id: 'generate', atoms: ['file-write', 'live-artifact'] }],
},
capabilities: ['prompt:inject', 'fs:write'],
},
});
if (opts.dryRun) {
stats.generated.push(id);
continue;
}
await writeManifest(folder, manifest);
await copyFile(skillPath, path.join(folder, 'SKILL.md'));
for (const rel of exampleFile.assets) {
const cleanRel = rel.replace(/^\.\//, '');
const srcAsset = path.join(srcFolder, cleanRel);
if (await pathExists(srcAsset)) {
await copyFile(srcAsset, path.join(folder, cleanRel));
}
}
stats.generated.push(id);
}
return stats;
}
function inferSurface(mode: string): string {
if (mode === 'image' || mode === 'video' || mode === 'audio') return mode;
return 'web';
}
function derivePrompt(fm: SkillFrontmatter): string {
const explicit = fm.od?.example_prompt;
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
const desc = typeof fm.description === 'string' ? fm.description.trim() : '';
if (!desc) return 'Produce the artifact described in this skill, following its workflow exactly.';
const collapsed = desc.replace(/\s+/g, ' ').trim();
return collapsed.slice(0, 320);
}
function humanize(id: string): string {
return id
.replace(/[-_]+/g, ' ')
.split(' ')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
// The plugin manifest schema only accepts a fixed set of input types
// (`string | text | select | number | boolean`), but the legacy
// SKILL.md frontmatter freely uses `integer`, `float`, etc. — fine
// for the agent-level renderer, rejected by the plugin parser. We
// coerce the long tail down to the closest supported type so the
// daemon can register the generated plugin without dropping the
// authored input list.
function normaliseInput(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
const type = typeof raw.type === 'string' ? raw.type.toLowerCase() : undefined;
if (type === 'integer' || type === 'int' || type === 'float' || type === 'double') {
out.type = 'number';
} else if (type && !['string', 'text', 'select', 'number', 'boolean'].includes(type)) {
out.type = 'string';
}
return out;
}
interface SideFileSummary { hasExample: boolean; assets: string[]; }
// Side files are everything the SKILL.md references — `example.html`,
// `assets/*`, `references/*`. We restrict to a small, well-known set so
// the generated plugin folder stays compact and predictable. A future
// patch can broaden the allowlist once we audit which file types the
// daemon's compose path actually needs.
async function sideFiles(srcFolder: string): Promise<SideFileSummary> {
const out: string[] = [];
let hasExample = false;
for (const candidate of ['example.html']) {
if (await pathExists(path.join(srcFolder, candidate))) {
out.push(`./${candidate}`);
if (candidate === 'example.html') hasExample = true;
}
}
for (const dir of ['assets', 'references']) {
const abs = path.join(srcFolder, dir);
if (!(await pathExists(abs))) continue;
let entries;
try {
entries = await readdir(abs, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
if (!e.isFile()) continue;
if (!/\.(md|html|css|js|json|txt|svg|png|jpg|jpeg|webp)$/i.test(e.name)) continue;
out.push(`./${dir}/${e.name}`);
}
}
return { hasExample, assets: out };
}

View file

@ -0,0 +1,202 @@
// Wrap a `prompt-templates/image/<id>.json` entry as a bundled plugin
// under `plugins/_official/image-templates/<plugin-id>/`. The wrapper
// preserves the original JSON beside the manifest so attribution,
// preview URLs, and the {argument …} placeholders stay accessible to
// the daemon's generator and to anyone auditing the plugin.
import path from 'node:path';
import { readFile } from 'node:fs/promises';
import {
PLUGINS_ROOT,
PROMPT_TEMPLATES_DIR,
TIER_IMAGE_TEMPLATES,
buildManifest,
copyFile,
dedupeTags,
pluginName,
writeManifest,
type RunStats,
} from './lib.ts';
interface ImageTemplateJson {
id?: string;
surface?: string;
title?: string;
summary?: string;
category?: string;
tags?: string[];
model?: string;
aspect?: string;
prompt?: string;
previewImageUrl?: string;
previewVideoUrl?: string;
source?: { repo?: string; license?: string; author?: string; url?: string };
}
export interface ImageTemplateOptions {
ids?: string[];
limit?: number;
dryRun?: boolean;
}
export async function runImageTemplateGenerator(opts: ImageTemplateOptions): Promise<RunStats> {
return runJsonTemplateGenerator({
sourceDir: path.join(PROMPT_TEMPLATES_DIR, 'image'),
targetTier: TIER_IMAGE_TEMPLATES,
namePrefix: 'image-template',
mode: 'image',
surface: 'image',
atom: 'image-generate',
capability: 'media:image-generate',
aspectOptions: ['1:1', '16:9', '9:16', '4:5', '3:2'],
defaultAspect: '1:1',
previewType: 'image',
...opts,
});
}
export interface VideoTemplateOptions extends ImageTemplateOptions {}
export async function runVideoTemplateGenerator(opts: VideoTemplateOptions): Promise<RunStats> {
return runJsonTemplateGenerator({
sourceDir: path.join(PROMPT_TEMPLATES_DIR, 'video'),
targetTier: 'video-templates',
namePrefix: 'video-template',
mode: 'video',
surface: 'video',
atom: 'video-generate',
capability: 'media:video-generate',
aspectOptions: ['16:9', '9:16', '1:1', '4:5'],
defaultAspect: '16:9',
previewType: 'video',
...opts,
});
}
interface SharedConfig {
sourceDir: string;
targetTier: string;
namePrefix: string;
mode: 'image' | 'video';
surface: 'image' | 'video';
atom: string;
capability: string;
aspectOptions: string[];
defaultAspect: string;
previewType: 'image' | 'video';
ids?: string[];
limit?: number;
dryRun?: boolean;
}
async function runJsonTemplateGenerator(cfg: SharedConfig): Promise<RunStats> {
const { readdir } = await import('node:fs/promises');
const stats: RunStats = { generated: [], skipped: [] };
let entries: string[];
try {
entries = await readdir(cfg.sourceDir);
} catch {
return stats;
}
const filtered = entries
.filter((f) => f.endsWith('.json'))
.filter((f) => !cfg.ids || cfg.ids.includes(basenameNoExt(f)))
.sort();
const slice = cfg.limit !== undefined ? filtered.slice(0, cfg.limit) : filtered;
for (const file of slice) {
const filePath = path.join(cfg.sourceDir, file);
const raw = await readFile(filePath, 'utf8');
let parsed: ImageTemplateJson;
try {
parsed = JSON.parse(raw) as ImageTemplateJson;
} catch (err) {
stats.skipped.push({ id: file, reason: `invalid json: ${(err as Error).message}` });
continue;
}
if (!parsed.id || !parsed.title || !parsed.prompt) {
stats.skipped.push({ id: file, reason: 'missing id/title/prompt' });
continue;
}
if (parsed.surface !== cfg.surface) {
stats.skipped.push({ id: parsed.id, reason: `surface=${parsed.surface} mismatch` });
continue;
}
const name = pluginName(cfg.namePrefix, parsed.id);
const folder = path.join(PLUGINS_ROOT, cfg.targetTier, parsed.id);
const manifest = buildManifest({
name,
title: parsed.title,
description: parsed.summary ?? '',
license: parsed.source?.license ?? 'CC-BY-4.0',
author: {
...(parsed.source?.author ? { name: parsed.source.author } : {}),
...(parsed.source?.url ? { url: parsed.source.url } : {}),
},
...(parsed.source?.repo
? { homepage: `https://github.com/${parsed.source.repo}` }
: {}),
tags: dedupeTags([
cfg.namePrefix,
'first-party',
cfg.surface,
parsed.category,
...(parsed.tags ?? []),
]),
od: {
kind: 'scenario',
taskKind: 'new-generation',
mode: cfg.mode,
scenario: cfg.surface,
surface: cfg.surface,
preview: previewBlock(parsed, cfg.previewType),
useCase: { query: parsed.prompt },
inputs: [
...(parsed.model
? [{
name: 'model',
label: 'Model',
type: 'select' as const,
options: [parsed.model],
default: parsed.model,
}]
: []),
{
name: 'aspect',
label: 'Aspect ratio',
type: 'select' as const,
options: cfg.aspectOptions,
default: parsed.aspect ?? cfg.defaultAspect,
},
],
context: { assets: ['./template.json'] },
pipeline: {
stages: [{ id: 'generate', atoms: [cfg.atom] }],
},
capabilities: ['prompt:inject', cfg.capability],
},
});
if (cfg.dryRun) {
stats.generated.push(parsed.id);
continue;
}
await writeManifest(folder, manifest);
await copyFile(filePath, path.join(folder, 'template.json'));
stats.generated.push(parsed.id);
}
return stats;
}
function basenameNoExt(file: string): string {
return file.replace(/\.[^.]+$/, '');
}
function previewBlock(parsed: ImageTemplateJson, type: 'image' | 'video'): Record<string, unknown> | undefined {
const block: Record<string, unknown> = { type };
if (parsed.previewImageUrl) block.poster = parsed.previewImageUrl;
if (parsed.previewVideoUrl) block.video = parsed.previewVideoUrl;
return Object.keys(block).length > 1 ? block : undefined;
}

View file

@ -0,0 +1,324 @@
// Shared helpers for the migrate-to-plugins generators. Each of the
// four source categories (image templates, video templates, examples,
// design-systems) needs the same primitives: a small YAML-frontmatter
// parser, slug/name builders, a manifest writer with stable key order,
// and a tag-normaliser so the daemon's plugin walker ingests every
// generated folder under the same metadata vocabulary.
//
// Keep this file dependency-free so the generators can run with plain
// `tsx scripts/migrate-to-plugins/main.ts` — no workspace package
// linking step required.
import { mkdir, writeFile, copyFile as fsCopyFile, stat } from 'node:fs/promises';
import path from 'node:path';
export const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..');
export const PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins', '_official');
export const SKILLS_DIR = path.join(REPO_ROOT, 'skills');
export const DESIGN_SYSTEMS_DIR = path.join(REPO_ROOT, 'design-systems');
export const PROMPT_TEMPLATES_DIR = path.join(REPO_ROOT, 'prompt-templates');
export const PLUGIN_SCHEMA = 'https://open-design.ai/schemas/plugin.v1.json';
export const PLUGIN_VERSION = '0.1.0';
// Generated plugin tiers; each maps to a subfolder under PLUGINS_ROOT.
// The daemon's bundled walker recurses one level beneath PLUGINS_ROOT,
// so adding a tier here is purely a data-only operation.
export const TIER_IMAGE_TEMPLATES = 'image-templates';
export const TIER_VIDEO_TEMPLATES = 'video-templates';
export const TIER_EXAMPLES = 'examples';
export const TIER_DESIGN_SYSTEMS = 'design-systems';
export type Frontmatter = Record<string, unknown>;
// Minimal YAML subset parser — supports scalars, nested mappings, and
// flow/block sequences. Mirrors the daemon's parser (`apps/daemon/src/
// frontmatter.ts`) at the precision required for our skill frontmatter.
export function parseFrontmatter(src: string): { data: Frontmatter; body: string } {
const text = src.replace(/^\uFEFF/, '');
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
if (!match) return { data: {}, body: text };
const yaml = match[1] ?? '';
const body = match[2] ?? '';
return { data: parseYamlSubset(yaml), body };
}
interface StackEntry { indent: number; container: Frontmatter | unknown[]; }
function parseYamlSubset(src: string): Frontmatter {
const lines = src.split(/\r?\n/);
const root: Frontmatter = {};
const stack: StackEntry[] = [{ indent: -1, container: root }];
let pendingKey: string | null = null;
let pendingBlockType: 'pipe' | 'fold' | null = null;
let blockBuf: string[] = [];
let blockIndent = -1;
function flushBlock(): void {
if (pendingKey === null) return;
const value = pendingBlockType === 'pipe'
? blockBuf.join('\n')
: blockBuf.join(' ').trim();
const top = stack[stack.length - 1];
if (top && !Array.isArray(top.container)) {
(top.container as Frontmatter)[pendingKey] = value;
}
pendingKey = null;
pendingBlockType = null;
blockBuf = [];
blockIndent = -1;
}
// Append a key/value pair to the current mapping frame. Used by both
// the plain `key: value` branch and the list-item-with-inline-mapping
// branch (`- key: value`).
function applyKeyValue(
targetContainer: Frontmatter,
indent: number,
key: string,
rest: string,
lookaheadIdx: number,
): void {
if (rest === '') {
const next = lines[lookaheadIdx] ?? '';
const nextIndent = next.match(/^\s*/)?.[0].length ?? 0;
const nextTrim = next.slice(nextIndent);
if (nextIndent > indent && (nextTrim.startsWith('- ') || nextTrim === '-')) {
const arr: unknown[] = [];
targetContainer[key] = arr;
stack.push({ indent, container: arr });
} else {
const child: Frontmatter = {};
targetContainer[key] = child;
stack.push({ indent, container: child });
}
} else if (rest === '|' || rest === '>') {
pendingKey = key;
pendingBlockType = rest === '|' ? 'pipe' : 'fold';
blockBuf = [];
const probe = lines[lookaheadIdx] ?? '';
blockIndent = probe.match(/^\s*/)?.[0].length ?? indent + 2;
} else if (rest.startsWith('[') && rest.endsWith(']')) {
const inner = rest.slice(1, -1).trim();
targetContainer[key] = inner === ''
? []
: inner.split(/\s*,\s*/).map((piece) => coerceScalar(piece));
} else {
targetContainer[key] = coerceScalar(rest);
}
}
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? '';
if (pendingBlockType !== null) {
const lineIndent = raw.match(/^\s*/)?.[0].length ?? 0;
if (raw.trim() === '' || lineIndent >= blockIndent) {
const trimmedLine = raw.slice(blockIndent);
blockBuf.push(trimmedLine);
continue;
}
flushBlock();
}
if (/^\s*(#.*)?$/.test(raw)) continue;
const indent = raw.match(/^\s*/)?.[0].length ?? 0;
while (stack.length > 1 && indent <= ((stack[stack.length - 1]?.indent) ?? -1)) {
stack.pop();
}
const top = stack[stack.length - 1];
if (!top) continue;
const trimmed = raw.slice(indent);
if (trimmed.startsWith('- ') || trimmed === '-') {
if (!Array.isArray(top.container)) continue;
const rest = trimmed.slice(2).trim();
if (rest === '') {
const child: Frontmatter = {};
top.container.push(child);
stack.push({ indent, container: child });
continue;
}
const colon = rest.indexOf(':');
// Inline mapping start: `- key: value`. Create a new object,
// push it on the array, push it on the stack so subsequent
// indented lines (matching the post-`- ` column, i.e. indent+2)
// continue to fill the same object.
if (colon !== -1 && /^[A-Za-z_][\w-]*$/.test(rest.slice(0, colon).trim())) {
const itemObj: Frontmatter = {};
top.container.push(itemObj);
stack.push({ indent: indent + 1, container: itemObj });
const key = rest.slice(0, colon).trim();
const restValue = rest.slice(colon + 1).trim();
applyKeyValue(itemObj, indent + 2, key, restValue, i + 1);
} else {
top.container.push(coerceScalar(rest));
}
continue;
}
const colon = trimmed.indexOf(':');
if (colon === -1) continue;
const key = trimmed.slice(0, colon).trim();
const rest = trimmed.slice(colon + 1).trim();
if (Array.isArray(top.container)) continue;
applyKeyValue(top.container as Frontmatter, indent, key, rest, i + 1);
}
flushBlock();
return root;
}
function coerceScalar(raw: string): unknown {
const trimmed = raw.trim();
if (trimmed === '') return '';
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
if (trimmed === 'null') return null;
if (/^-?\d+$/.test(trimmed)) return Number(trimmed);
if (/^-?\d+\.\d+$/.test(trimmed)) return Number(trimmed);
return trimmed;
}
export function slugify(value: string): string {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
.slice(0, 80) || 'untitled';
}
// Plugin manifest names must satisfy /^[a-z0-9][a-z0-9._-]*$/ — slugify
// guarantees lowercase + safe characters; we just stitch a tier prefix
// on top so the registry never collides with the legacy folder ids.
export function pluginName(prefix: string, source: string): string {
const slug = slugify(source);
return `${prefix}-${slug}`;
}
export async function ensureDir(p: string): Promise<void> {
await mkdir(p, { recursive: true });
}
export async function pathExists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
// Write a manifest with a key order that keeps the diff human-readable:
// identity → metadata → compat → od → end. Inside `od`, we keep the
// taxonomy (kind/taskKind/mode/scenario/surface) first so a reviewer can
// understand the plugin's category before drilling into pipeline/inputs.
const TOP_ORDER = [
'$schema',
'name',
'title',
'version',
'description',
'license',
'author',
'homepage',
'icon',
'tags',
'compat',
'od',
];
const OD_ORDER = [
'kind',
'taskKind',
'mode',
'platform',
'scenario',
'surface',
'featured',
'engineRequirements',
'preview',
'useCase',
'inputs',
'context',
'pipeline',
'genui',
'connectors',
'capabilities',
];
function sortKeys<T extends Record<string, unknown>>(obj: T, order: string[]): T {
const out: Record<string, unknown> = {};
for (const key of order) {
if (key in obj) out[key] = obj[key];
}
for (const key of Object.keys(obj)) {
if (!(key in out)) out[key] = obj[key];
}
return out as T;
}
export interface PluginManifestSeed {
name: string;
title: string;
description?: string;
license?: string;
author?: { name?: string; url?: string };
homepage?: string;
tags?: string[];
compat?: { agentSkills?: Array<{ path: string }> };
od?: Record<string, unknown>;
}
export function buildManifest(seed: PluginManifestSeed): Record<string, unknown> {
const base: Record<string, unknown> = {
$schema: PLUGIN_SCHEMA,
version: PLUGIN_VERSION,
...seed,
};
if (seed.od) base.od = sortKeys(seed.od, OD_ORDER);
return sortKeys(base, TOP_ORDER);
}
export async function writeManifest(folder: string, manifest: unknown): Promise<void> {
await ensureDir(folder);
const target = path.join(folder, 'open-design.json');
await writeFile(target, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
export async function copyFile(src: string, dst: string): Promise<void> {
await ensureDir(path.dirname(dst));
await fsCopyFile(src, dst);
}
// Tag normalisation. The home UI's scenario-driven chip row keys off
// these stable kebab-case tokens; mix-cased originals would explode the
// chip count without any user benefit.
export function normaliseTag(tag: string): string {
return slugify(tag);
}
export function dedupeTags(tags: Array<string | undefined | null>): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of tags) {
if (!raw) continue;
const slug = normaliseTag(String(raw));
if (!slug || seen.has(slug)) continue;
seen.add(slug);
out.push(slug);
}
return out;
}
export interface RunStats {
generated: string[];
skipped: Array<{ id: string; reason: string }>;
}
export function emptyStats(): RunStats {
return { generated: [], skipped: [] };
}

View file

@ -0,0 +1,132 @@
#!/usr/bin/env -S node --experimental-strip-types
// migrate-to-plugins/main.ts — wrap the four legacy resource roots
// (`prompt-templates/{image,video}/`, `skills/`, `design-systems/`) as
// bundled plugins under `plugins/_official/<tier>/<id>/`.
//
// Usage examples:
//
// tsx scripts/migrate-to-plugins/main.ts \
// --category image --ids e-commerce-live-stream-ui-mockup,illustrated-city-food-map
//
// tsx scripts/migrate-to-plugins/main.ts --category all --limit 3
//
// tsx scripts/migrate-to-plugins/main.ts --category example --dry-run
//
// `--ids` and `--limit` apply per-category; `--dry-run` reports what
// would be generated without writing to disk.
import {
runImageTemplateGenerator,
runVideoTemplateGenerator,
} from './image-template.ts';
import { runExampleGenerator } from './example.ts';
import { runDesignSystemGenerator } from './design-system.ts';
type Category = 'image' | 'video' | 'example' | 'design-system' | 'all';
interface CliArgs {
category: Category;
ids?: string[];
limit?: number;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = { category: 'all', dryRun: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--category') {
const next = argv[++i];
if (!next || !isCategory(next)) throw new Error(`bad --category ${next}`);
out.category = next;
} else if (arg === '--ids') {
const next = argv[++i];
if (!next) throw new Error('missing --ids value');
out.ids = next.split(',').map((s) => s.trim()).filter(Boolean);
} else if (arg === '--limit') {
const next = argv[++i];
if (!next) throw new Error('missing --limit value');
const n = Number(next);
if (!Number.isFinite(n) || n <= 0) throw new Error(`bad --limit ${next}`);
out.limit = Math.floor(n);
} else if (arg === '--dry-run') {
out.dryRun = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
} else if (arg) {
throw new Error(`unknown flag ${arg}`);
}
}
return out;
}
function isCategory(v: string): v is Category {
return v === 'image' || v === 'video' || v === 'example' || v === 'design-system' || v === 'all';
}
function printHelp(): void {
console.log(`migrate-to-plugins — wrap legacy resource roots as bundled plugins.
Flags:
--category <image|video|example|design-system|all> default: all
--ids <id1,id2,...> filter to specific source ids
--limit <n> cap items per category
--dry-run list intended writes without touching disk
--help, -h
`);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const opts = {
...(args.ids ? { ids: args.ids } : {}),
...(args.limit !== undefined ? { limit: args.limit } : {}),
dryRun: args.dryRun,
};
const all = args.category === 'all';
const totals = { generated: 0, skipped: 0 };
if (all || args.category === 'image') {
const stats = await runImageTemplateGenerator(opts);
report('image-templates', stats);
totals.generated += stats.generated.length;
totals.skipped += stats.skipped.length;
}
if (all || args.category === 'video') {
const stats = await runVideoTemplateGenerator(opts);
report('video-templates', stats);
totals.generated += stats.generated.length;
totals.skipped += stats.skipped.length;
}
if (all || args.category === 'example') {
const stats = await runExampleGenerator(opts);
report('examples', stats);
totals.generated += stats.generated.length;
totals.skipped += stats.skipped.length;
}
if (all || args.category === 'design-system') {
const stats = await runDesignSystemGenerator(opts);
report('design-systems', stats);
totals.generated += stats.generated.length;
totals.skipped += stats.skipped.length;
}
console.log(
`\nDone. generated=${totals.generated} skipped=${totals.skipped}${args.dryRun ? ' (dry-run)' : ''}`,
);
}
function report(tier: string, stats: { generated: string[]; skipped: Array<{ id: string; reason: string }> }): void {
console.log(`\n# ${tier}`);
for (const id of stats.generated) console.log(` + ${id}`);
for (const sk of stats.skipped) console.log(` ! ${sk.id}: ${sk.reason}`);
if (stats.generated.length === 0 && stats.skipped.length === 0) {
console.log(' (nothing to do)');
}
}
main().catch((err) => {
console.error('migrate-to-plugins failed:', err);
process.exit(1);
});