Improve deck home previews and plugin gallery performance (#2698)

Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
Eli-tangerine
2026-05-22 17:47:28 +08:00
committed by GitHub
co-authored by qiongyu1999
parent 9912fa899a
commit 10e11531a1
20 changed files with 583 additions and 36 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ Form authoring rules:
- If you keep the \`brand\` question, its \`id\` must stay \`"brand"\`. Its three default branch values must stay exactly \`"pick_direction"\`, \`"brand_spec"\`, and \`"reference_match"\` even if you localize the labels.
- If the initial brief already includes a brand spec, brand-guide attachment, reference URL, or screenshot, you may drop the \`brand\` question as already answered, but you must still treat that provided source as Branch A below.
- Tailor the questions to the actual brief — drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page).
- **Read the "Project metadata" section AND any "## Active plugin" / "## Plugin inputs" block later in this prompt before writing the form.** "Project metadata" lists what the user chose at create time (kind, fidelity, speakerNotes, animations, template, platform); "Plugin inputs" lists the same kind of brief data when the project was opened through a plugin chip on Home (e.g. \`fidelity: "high-fidelity"\`, \`platform: "desktop"\`, \`artifactKind: "web prototype"\`, \`audience: "product evaluators"\`, \`designSystem: "..."\`). **Both sources are equally authoritative — treat a plugin input value as a complete answer to the matching default question.** Concretely: a plugin input \`fidelity\` answers the Fidelity question; \`platform\` (or a semantically-equivalent input such as \`surface\`, \`platformTargets\`, \`target\`) answers Target platform; \`artifactKind\` / \`mode\` / \`taskKind\` already names what we are making so do not re-ask "What are we making?"; \`audience\` answers "Who is this for?"; \`designSystem\` / \`brand\` answers Brand context. Drop the matching default question whenever EITHER source supplies the answer; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set or the active plugin's \`od.kind\` / \`taskKind\` already names it — the user already told you.
- **Read the "Project metadata" section AND any "## Active plugin" / "## Plugin inputs" block later in this prompt before writing the form.** "Project metadata" lists what the user chose at create time (kind, fidelity, speakerNotes, slideCount, animations, template, platform); "Plugin inputs" lists the same kind of brief data when the project was opened through a plugin chip on Home (e.g. \`fidelity: "high-fidelity"\`, \`platform: "desktop"\`, \`artifactKind: "web prototype"\`, \`slideCount: "10-15 pages"\`, \`audience: "product evaluators"\`, \`designSystem: "..."\`). **Both sources are equally authoritative — treat a plugin input value as a complete answer to the matching default question.** Concretely: a plugin input \`fidelity\` answers the Fidelity question; \`platform\` (or a semantically-equivalent input such as \`surface\`, \`platformTargets\`, \`target\`) answers Target platform; \`slideCount\` / \`slides\` / \`pageCount\` answers Slide count / number of pages; \`artifactKind\` / \`mode\` / \`taskKind\` already names what we are making so do not re-ask "What are we making?"; \`audience\` answers "Who is this for?"; \`designSystem\` / \`brand\` answers Brand context. Drop the matching default question whenever EITHER source supplies the answer; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set or the active plugin's \`od.kind\` / \`taskKind\` already names it — the user already told you.
- Keep it under ~7 questions. Second batch in a follow-up form if needed.
- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble.
- After \`</question-form>\`, **stop your turn**. Do not write code. Do not start tools. Do not narrate "I'll wait."
+4
View File
@@ -86,6 +86,7 @@ type ProjectMetadata = {
intent?: string | null;
fidelity?: string | null;
speakerNotes?: boolean | null;
slideCount?: string | null;
animations?: boolean | null;
includeLandingPage?: boolean | null;
includeOsWidgets?: boolean | null;
@@ -804,6 +805,9 @@ function renderMetadataBlock(
);
}
if (metadata.kind === 'deck') {
lines.push(
`- **slideCount**: ${metadata.slideCount ?? '(unknown — ask only if the Active plugin / Plugin inputs block does not already include slideCount)'}`,
);
lines.push(
`- **speakerNotes**: ${typeof metadata.speakerNotes === 'boolean' ? metadata.speakerNotes : '(unknown — ask: include speaker notes?)'}`,
);
@@ -41,6 +41,7 @@ describe('discovery.ts — Plugin inputs are authoritative for Quick brief defau
expect(DISCOVERY_AND_PHILOSOPHY).toContain('`platform`');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('Target platform');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('`artifactKind`');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('`slideCount` / `slides` / `pageCount` answers Slide count');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('`audience` answers "Who is this for?"');
expect(DISCOVERY_AND_PHILOSOPHY).toContain('`designSystem`');
});
+2
View File
@@ -180,10 +180,12 @@ describe('composeSystemPrompt', () => {
metadata: {
kind: 'deck',
speakerNotes: true,
slideCount: '10-15 pages',
} as any,
});
expect(prompt).toContain('- **kind**: deck');
expect(prompt).toContain('- **slideCount**: 10-15 pages');
expect(prompt).not.toContain('**responsive web contract**');
expect(prompt).not.toContain('**platformTargets**');
});
+1 -1
View File
@@ -137,7 +137,7 @@ function defaultPluginInputsForCreate(
deckType: 'pitch deck',
topic: projectName || 'the user brief',
audience: 'decision makers',
slideCount: 10,
slideCount: '10-15 pages',
speakerNotes: input.metadata.speakerNotes
? 'include speaker notes'
: 'no speaker notes',
+9 -1
View File
@@ -1518,7 +1518,7 @@ function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t
function footerInputNamesForChip(chipId: string | null): string[] {
if (chipId === 'prototype') return ['designSystem', 'fidelity'];
if (chipId === 'deck') return ['designSystem', 'speakerNotes'];
if (chipId === 'deck') return ['designSystem', 'slideCount', 'speakerNotes'];
if (chipId === 'image') return ['designSystem', 'model', 'ratio', 'resolution'];
if (chipId === 'video') return ['designSystem', 'model', 'ratio', 'duration', 'resolution'];
if (chipId === 'audio') return ['audioType', 'model', 'duration'];
@@ -1542,6 +1542,8 @@ function homeCreateProjectMetadata(
if (fidelity) next.fidelity = fidelity;
const speakerNotes = normalizeHomeSpeakerNotes(inputs?.speakerNotes);
if (speakerNotes !== null) next.speakerNotes = speakerNotes;
const slideCount = normalizeHomeSlideCount(inputs?.slideCount);
if (slideCount) next.slideCount = slideCount;
return next;
}
@@ -1574,6 +1576,12 @@ function normalizeHomeSpeakerNotes(value: unknown): boolean | null {
return null;
}
function normalizeHomeSlideCount(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : null;
}
function designSystemOptionsForHome(
systems: DesignSystemSummary[],
defaultDesignSystemId: string | null,
+44 -2
View File
@@ -14,7 +14,7 @@
// override live in `./plugins-home/usePluginFacets.ts`. This file
// owns layout only.
import { useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { useI18n, useT } from '../i18n';
import type { PluginShareAction } from '../state/projects';
@@ -27,6 +27,9 @@ import { useSavedPluginIds } from './plugins-home/savedPlugins';
import type { PluginUseAction } from './plugins-home/useActions';
import { Toast } from './Toast';
const INITIAL_PLUGIN_RENDER_LIMIT = 60;
const PLUGIN_RENDER_BATCH_SIZE = 60;
interface Props {
plugins: InstalledPluginRecord[];
loading: boolean;
@@ -71,6 +74,8 @@ export function PluginsHomeSection({
const { locale, t } = useI18n();
const { savedPluginIds, savePluginId } = useSavedPluginIds();
const [saveToast, setSaveToast] = useState<string | null>(null);
const [renderLimit, setRenderLimit] = useState(INITIAL_PLUGIN_RENDER_LIMIT);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const {
visiblePlugins,
savedList,
@@ -92,6 +97,36 @@ export function PluginsHomeSection({
presetSelection,
locale,
});
const renderedPlugins = useMemo(
() => filtered.slice(0, renderLimit),
[filtered, renderLimit],
);
const hasMorePlugins = renderLimit < filtered.length;
useEffect(() => {
setRenderLimit(INITIAL_PLUGIN_RENDER_LIMIT);
}, [filtered]);
useEffect(() => {
if (!hasMorePlugins) return;
const node = loadMoreRef.current;
if (!node) return;
if (typeof IntersectionObserver === 'undefined') {
setRenderLimit(filtered.length);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
setRenderLimit((limit) =>
Math.min(filtered.length, limit + PLUGIN_RENDER_BATCH_SIZE),
);
},
{ rootMargin: '640px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [filtered.length, hasMorePlugins]);
function handleSavePlugin(record: InstalledPluginRecord): void {
const result = savePluginId(record.id);
@@ -177,7 +212,7 @@ export function PluginsHomeSection({
</div>
) : (
<div className="plugins-home__grid" role="list">
{filtered.map((p) => (
{renderedPlugins.map((p) => (
<PluginCard
key={p.id}
record={p}
@@ -193,6 +228,13 @@ export function PluginsHomeSection({
onShareAction={onPluginShareAction}
/>
))}
{hasMorePlugins ? (
<div
ref={loadMoreRef}
className="plugins-home__load-more-sentinel"
aria-hidden
/>
) : null}
</div>
)}
</>
+175 -7
View File
@@ -7,7 +7,7 @@
// surfaces (e.g. an in-project quick-switcher pane).
import type { CSSProperties } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import { fetchProjectFiles, projectFileUrl } from '../providers/registry';
import type { Project, ProjectDisplayStatus, ProjectFile } from '../types';
@@ -24,6 +24,11 @@ interface Props {
limit?: number;
}
const DECK_PREVIEW_WIDTH = 1280;
const DECK_PREVIEW_HEIGHT = 720;
const deckCoverCache = new Map<string, string>();
const deckCoverInflight = new Map<string, Promise<string>>();
export function RecentProjectsStrip({
projects,
onOpen,
@@ -170,13 +175,9 @@ export function RecentProjectsStrip({
playsInline
/>
) : cover.kind === 'html' && cover.src ? (
<iframe
className="recent-projects__thumb-iframe"
<RecentProjectHtmlThumb
src={cover.src}
title=""
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
deckCoverOnly={project.metadata?.kind === 'deck'}
/>
) : (
<span className="recent-projects__card-glyph">{cover.initial}</span>
@@ -212,6 +213,173 @@ export function RecentProjectsStrip({
);
}
function RecentProjectHtmlThumb({
src,
deckCoverOnly,
}: {
src: string;
deckCoverOnly: boolean;
}) {
if (!deckCoverOnly) {
return (
<iframe
className="recent-projects__thumb-iframe"
src={src}
title=""
loading="lazy"
sandbox="allow-scripts"
tabIndex={-1}
/>
);
}
return <DeckCoverThumb src={src} />;
}
function DeckCoverThumb({ src }: { src: string }) {
const frameRef = useRef<HTMLDivElement | null>(null);
const [srcDoc, setSrcDoc] = useState<string | null>(() => deckCoverCache.get(src) ?? null);
const [scale, setScale] = useState(1);
useEffect(() => {
let cancelled = false;
const cached = deckCoverCache.get(src);
if (cached) {
setSrcDoc(cached);
return;
}
setSrcDoc(null);
loadDeckCover(src)
.then((next) => {
if (!cancelled) setSrcDoc(next);
})
.catch(() => {
if (cancelled) return;
setSrcDoc(null);
});
return () => {
cancelled = true;
};
}, [src]);
useEffect(() => {
const node = frameRef.current;
if (!node) return;
const update = () => {
const rect = node.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
setScale(Math.min(rect.width / DECK_PREVIEW_WIDTH, rect.height / DECK_PREVIEW_HEIGHT));
};
update();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<div
ref={frameRef}
className="recent-projects__deck-frame"
style={{ '--recent-deck-scale': scale } as CSSProperties}
aria-hidden
>
{srcDoc ? (
<iframe
className="recent-projects__deck-iframe"
srcDoc={srcDoc}
title=""
loading="lazy"
sandbox=""
tabIndex={-1}
/>
) : (
<span className="recent-projects__deck-cover-loading" aria-hidden />
)}
</div>
);
}
async function loadDeckCover(src: string): Promise<string> {
const cached = deckCoverCache.get(src);
if (cached) return cached;
const existing = deckCoverInflight.get(src);
if (existing) return existing;
const run = fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`Failed to load project cover: ${res.status}`);
return res.text();
})
.then((html) => {
const parsed = deckPreviewSrcDoc(html);
deckCoverCache.set(src, parsed);
deckCoverInflight.delete(src);
return parsed;
})
.catch((error) => {
deckCoverInflight.delete(src);
throw error;
});
deckCoverInflight.set(src, run);
return run;
}
function deckPreviewSrcDoc(html: string): string {
const withoutScripts = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/giu, '');
const style = `<style id="od-recent-deck-real-preview">
html,
body {
margin: 0 !important;
width: ${DECK_PREVIEW_WIDTH}px !important;
height: ${DECK_PREVIEW_HEIGHT}px !important;
overflow: hidden !important;
}
body {
display: block !important;
scroll-snap-type: none !important;
}
.slide,
section[data-slide],
section[data-screen-label] {
position: absolute !important;
inset: 0 !important;
width: ${DECK_PREVIEW_WIDTH}px !important;
height: ${DECK_PREVIEW_HEIGHT}px !important;
flex: none !important;
scroll-snap-align: none !important;
}
.slide:not(:first-of-type),
section[data-slide]:not(:first-of-type),
section[data-screen-label]:not(:first-of-type),
.deck-counter,
.deck-hint,
.deck-progress,
.deck-nav,
.deck-navigation,
#deck-prev,
#deck-next,
#deck-cur,
#deck-total,
[aria-label="Previous slide"],
[aria-label="Next slide"],
[aria-label="Deck navigation"] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
</style>`;
return injectBefore(withoutScripts, '</head>', style);
}
function injectBefore(source: string, marker: string, addition: string): string {
const index = source.toLowerCase().lastIndexOf(marker);
if (index === -1) return `${addition}${source}`;
return `${source.slice(0, index)}${addition}${source.slice(index)}`;
}
function statusLabel(
status: ProjectDisplayStatus,
t: ReturnType<typeof useT>,
@@ -6,31 +6,53 @@
// website thumbnails rather than synthetic color swatches. The iframe
// uses native lazy loading so off-screen cards do not eagerly render.
import { useEffect, useState } from 'react';
import type { DesignPreviewSpec } from '../preview';
interface Props {
preview: DesignPreviewSpec;
inView: boolean;
}
export function DesignSystemSurface({ preview }: Props) {
export function DesignSystemSurface({ preview, inView }: Props) {
const [ready, setReady] = useState(false);
useEffect(() => {
if (!preview.designSystemId) return;
if (!inView) {
setReady(false);
return;
}
const id = window.setTimeout(() => setReady(true), 520);
return () => window.clearTimeout(id);
}, [inView, preview.designSystemId]);
if (preview.designSystemId) {
return (
<div className="plugins-home__design plugins-home__design--showcase">
<div className="plugins-home__design-showcase">
<iframe
title={`${preview.brand} showcase preview`}
src={`/api/design-systems/${encodeURIComponent(preview.designSystemId)}/showcase`}
sandbox="allow-scripts"
loading="lazy"
tabIndex={-1}
aria-hidden
className="plugins-home__design-iframe"
/>
{ready ? (
<iframe
title={`${preview.brand} showcase preview`}
src={`/api/design-systems/${encodeURIComponent(preview.designSystemId)}/showcase`}
sandbox="allow-scripts"
loading="lazy"
tabIndex={-1}
aria-hidden
className="plugins-home__design-iframe"
/>
) : (
<DesignPatch preview={preview} />
)}
</div>
</div>
);
}
return <DesignPatch preview={preview} />;
}
function DesignPatch({ preview }: { preview: DesignPreviewSpec }) {
const [primary, secondary, ink] = preview.swatches;
return (
<div
@@ -67,18 +67,36 @@ async function probe(url: string): Promise<'ok' | 'unreachable'> {
export function HtmlSurface({ preview, pluginId, pluginTitle, inView }: Props) {
const [armed, setArmed] = useState(false);
const [shouldProbe, setShouldProbe] = useState(false);
const [probeState, setProbeState] = useState<ProbeState>(() => {
const cached = probeCache.get(preview.src);
return cached ?? 'idle';
});
useEffect(() => {
setArmed(false);
setShouldProbe(false);
const cached = probeCache.get(preview.src);
setProbeState(cached ?? 'idle');
}, [preview.src]);
useEffect(() => {
if (!inView) return;
if (probeCache.has(preview.src)) {
setShouldProbe(true);
return;
}
const id = window.setTimeout(() => setShouldProbe(true), 520);
return () => window.clearTimeout(id);
}, [inView, preview.src]);
// Kick off the probe on first in-view. We deliberately keep this
// effect's deps narrow (just `inView` + `preview.src`) so the
// subsequent `setProbeState(result)` does not cancel the in-flight
// promise via a re-run cleanup. The module-level cache also makes
// the probe a no-op if another tile already resolved the same URL.
useEffect(() => {
if (!inView) return;
if (!shouldProbe) return;
if (probeCache.has(preview.src)) {
setProbeState(probeCache.get(preview.src)!);
return;
@@ -91,16 +109,18 @@ export function HtmlSurface({ preview, pluginId, pluginTitle, inView }: Props) {
return () => {
cancelled = true;
};
}, [inView, preview.src]);
}, [preview.src, shouldProbe]);
// Arm the iframe after a short visibility window so the user can
// scroll past tiles without paying for an iframe per tile, but tiles
// that linger get the live preview without requiring hover.
useEffect(() => {
if (probeState !== 'ok') return;
const id = window.setTimeout(() => setArmed(true), 280);
const id = window.setTimeout(() => {
if (inView) setArmed(true);
}, 720);
return () => window.clearTimeout(id);
}, [probeState]);
}, [inView, probeState]);
if (probeState === 'unreachable') {
return <UnreachableFallback pluginId={pluginId} pluginTitle={pluginTitle} preview={preview} />;
@@ -111,6 +131,7 @@ export function HtmlSurface({ preview, pluginId, pluginTitle, inView }: Props) {
className="plugins-home__html"
data-plugin-id={pluginId}
onMouseEnter={() => {
setShouldProbe(true);
if (probeState === 'ok') setArmed(true);
}}
>
@@ -126,7 +147,10 @@ export function HtmlSurface({ preview, pluginId, pluginTitle, inView }: Props) {
className="plugins-home__html-iframe"
/>
) : (
<div className="plugins-home__html-skeleton" aria-hidden>
<div
className={`plugins-home__html-skeleton${inView ? ' is-active' : ''}`}
aria-hidden
>
<span />
<span />
<span />
@@ -40,7 +40,10 @@ export function MediaSurface({ preview, pluginTitle, inView }: Props) {
) : !hasPoster ? (
<MediaFallback pluginTitle={pluginTitle} mediaType={preview.mediaType} />
) : (
<div className="plugins-home__media-skeleton" aria-hidden />
<div
className={`plugins-home__media-skeleton${inView ? ' is-active' : ''}`}
aria-hidden
/>
)}
{showVideo ? (
<video
@@ -21,7 +21,7 @@ interface Props {
}
export function PreviewSurface({ pluginId, pluginTitle, preview }: Props) {
const { ref, inView } = useInView<HTMLDivElement>({ rootMargin: '320px' });
const { ref, inView } = useInView<HTMLDivElement>({ rootMargin: '120px', once: false });
return (
<div
@@ -39,7 +39,7 @@ export function PreviewSurface({ pluginId, pluginTitle, preview }: Props) {
inView={inView}
/>
) : preview.kind === 'design' ? (
<DesignSystemSurface preview={preview} />
<DesignSystemSurface preview={preview} inView={inView} />
) : (
<TextSurface pluginTitle={pluginTitle} />
)}
+12
View File
@@ -392,6 +392,11 @@
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.plugins-home__load-more-sentinel {
height: 1px;
min-width: 1px;
pointer-events: none;
}
@media (min-width: 1100px) {
.plugins-home__grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
@@ -414,6 +419,9 @@
transition: border-color 120ms ease, box-shadow 160ms ease,
transform 160ms ease;
isolation: isolate;
content-visibility: auto;
contain: layout paint style;
contain-intrinsic-size: 280px 260px;
}
.plugins-home__card:hover {
border-color: var(--border-strong);
@@ -463,6 +471,8 @@
background:
linear-gradient(110deg, transparent 30%, rgba(255, 255, 255, 0.5) 50%, transparent 70%) 0 0 / 200% 100%,
var(--bg-subtle);
}
.plugins-home__media-skeleton.is-active {
animation: plugins-home-shimmer 1.6s ease-in-out infinite;
}
.plugins-home__media-fallback {
@@ -558,6 +568,8 @@
border-radius: 4px;
background: linear-gradient(90deg, var(--bg-subtle), var(--border-soft), var(--bg-subtle));
background-size: 200% 100%;
}
.plugins-home__html-skeleton.is-active span {
animation: plugins-home-shimmer 1.6s ease-in-out infinite;
}
.plugins-home__html-skeleton span:nth-child(1) { width: 70%; }
@@ -117,6 +117,32 @@
pointer-events: none;
background: #fff;
}
.recent-projects__deck-frame,
.recent-projects__deck-iframe,
.recent-projects__deck-cover-loading {
position: absolute;
inset: 0;
}
.recent-projects__deck-frame {
overflow: hidden;
background: #fff;
}
.recent-projects__deck-iframe {
width: 1280px;
height: 720px;
border: 0;
pointer-events: none;
transform: scale(var(--recent-deck-scale, 1));
transform-origin: 0 0;
}
.recent-projects__deck-cover-loading {
width: 100%;
height: 100%;
background:
linear-gradient(110deg, transparent 30%, rgba(255, 255, 255, 0.52) 50%, transparent 70%) 0 0 / 200% 100%,
var(--bg-subtle);
animation: recent-projects-shimmer 1.4s ease-in-out infinite;
}
.recent-projects__card-glyph {
font-family: var(--serif);
font-size: 32px;
@@ -187,3 +213,8 @@
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@keyframes recent-projects-shimmer {
to {
background-position: -200% 0, 0 0;
}
}
@@ -141,6 +141,72 @@ const WEB_PROTOTYPE_PLUGIN = {
},
};
const SIMPLE_DECK_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-simple-deck',
title: 'Simple Deck',
source: '/tmp/simple-deck',
fsPath: '/tmp/simple-deck',
manifest: {
...DEFAULT_PLUGIN.manifest,
name: 'example-simple-deck',
title: 'Simple Deck',
description: 'Single-file horizontal-swipe HTML deck.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Create a {{deckType}} for {{audience}} about {{topic}} with {{slideCount}}. Speaker notes: {{speakerNotes}}. Use {{designSystem}}.',
},
inputs: [
{
name: 'deckType',
type: 'select',
required: true,
options: ['pitch deck', 'product overview', 'study deck'],
default: 'pitch deck',
label: 'Deck type',
},
{
name: 'topic',
type: 'string',
required: true,
default: 'the user brief',
label: 'Topic',
},
{
name: 'audience',
type: 'string',
required: true,
default: 'decision makers',
label: 'Audience',
},
{
name: 'slideCount',
type: 'select',
required: true,
options: ['5-10 pages', '10-15 pages', '15-20 pages', '20-25 pages', '25-30 pages'],
default: '10-15 pages',
label: 'Pages',
},
{
name: 'speakerNotes',
type: 'select',
options: ['include speaker notes', 'no speaker notes'],
default: 'include speaker notes',
label: 'Speaker notes',
},
{
name: 'designSystem',
type: 'string',
default: 'the active project design system',
label: 'Design system',
},
],
},
},
};
const LIVE_ARTIFACT_PLUGIN = {
...DEFAULT_PLUGIN,
id: 'example-live-artifact',
@@ -266,6 +332,25 @@ const WEB_PROTOTYPE_APPLY_RESULT = {
},
};
const SIMPLE_DECK_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: SIMPLE_DECK_PLUGIN.manifest.od.useCase.query,
inputs: SIMPLE_DECK_PLUGIN.manifest.od.inputs,
appliedPlugin: {
...AUTHORING_APPLY_RESULT.appliedPlugin,
snapshotId: 'snap-simple-deck',
pluginId: 'example-simple-deck',
inputs: {
deckType: 'pitch deck',
topic: 'the user brief',
audience: 'decision makers',
slideCount: '10-15 pages',
speakerNotes: 'include speaker notes',
designSystem: 'the active project design system',
},
},
};
const LIVE_ARTIFACT_APPLY_RESULT = {
...AUTHORING_APPLY_RESULT,
query: LIVE_ARTIFACT_PLUGIN.manifest.od.useCase.query,
@@ -828,6 +913,82 @@ describe('HomeView prompt handoff', () => {
expect(screen.queryByRole('alert')).toBeNull();
});
it('exposes deck page ranges beside speaker notes and submits the selected range', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [SIMPLE_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
await clearActiveTypeChip();
fireEvent.click(await screen.findByTestId('home-hero-rail-deck'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-footer-option-speakerNotes')).toBeTruthy();
});
expect(screen.getByTestId('home-hero-footer-option-slideCount').textContent).toContain('10-15 pages');
fireEvent.click(screen.getByTestId('home-hero-footer-option-slideCount'));
fireEvent.click(await screen.findByRole('option', { name: '15-20 pages' }));
expect(screen.getByTestId('home-hero-footer-option-slideCount').textContent).toContain('15-20 pages');
fireEvent.change(screen.getByTestId('home-hero-input'), {
target: { value: 'Create an investor deck for a local-first design tool.' },
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-simple-deck/apply',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {
slideCount: '15-20 pages',
speakerNotes: 'include speaker notes',
designSystem: 'Refly Design System',
},
});
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
pluginId: 'example-simple-deck',
pluginInputs: expect.objectContaining({
slideCount: '15-20 pages',
}),
projectKind: 'deck',
projectMetadata: expect.objectContaining({
kind: 'deck',
slideCount: '15-20 pages',
speakerNotes: true,
}),
})));
});
it('switches output-type chips without replacing an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
@@ -14,6 +14,9 @@ vi.mock('../../src/providers/registry', () => ({
if (projectId === 'project-html') {
return [{ name: 'index.html', kind: 'html', mtime: 2 }];
}
if (projectId === 'project-deck') {
return [{ name: 'index.html', kind: 'html', mtime: 2 }];
}
return [];
}),
projectFileUrl: (projectId: string, fileName: string) =>
@@ -22,6 +25,7 @@ vi.mock('../../src/providers/registry', () => ({
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
function project(overrides: Partial<Project>): Project {
@@ -73,4 +77,58 @@ describe('RecentProjectsStrip', () => {
expect(container.querySelector('.recent-projects__card-thumb-html iframe')).toBeTruthy();
});
});
it('renders deck project covers without deck navigation controls', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
text: async () => `
<!doctype html>
<html>
<head><title>Deck</title></head>
<body>
<section class="slide active">First slide</section>
<section class="slide">Second slide</section>
<div class="deck-counter"><button id="deck-prev">‹</button><span>1 / 10</span><button id="deck-next">›</button></div>
</body>
</html>
`,
})),
);
const { container } = render(
<RecentProjectsStrip
projects={[
project({
id: 'project-deck',
name: 'Simple Deck',
updatedAt: 4,
metadata: { kind: 'deck' },
}),
project({
id: 'project-html',
name: 'Web Prototype',
updatedAt: 3,
}),
]}
onOpen={() => {}}
onViewAll={() => {}}
/>,
);
const deckCard = container.querySelector('[data-project-id="project-deck"]');
const htmlCard = container.querySelector('[data-project-id="project-html"]');
await waitFor(() => {
const deckIframe = deckCard?.querySelector('iframe') as HTMLIFrameElement | null;
expect(deckIframe?.getAttribute('srcdoc')).toContain('First slide');
expect(deckIframe?.getAttribute('srcdoc')).toContain('od-recent-deck-real-preview');
expect(deckIframe?.getAttribute('srcdoc')).not.toContain('<script');
expect(deckIframe?.getAttribute('src')).toBeNull();
expect(htmlCard?.querySelector('iframe')?.getAttribute('src')).toBe(
'/api/projects/project-html/files/index.html',
);
});
});
});
+1
View File
@@ -92,6 +92,7 @@ export interface ProjectMetadata {
intent?: 'live-artifact';
fidelity?: 'wireframe' | 'high-fidelity';
speakerNotes?: boolean;
slideCount?: string;
animations?: boolean;
includeLandingPage?: boolean;
includeOsWidgets?: boolean;
+1 -1
View File
@@ -111,7 +111,7 @@ Form authoring rules:
- If you keep the \`brand\` question, its \`id\` must stay \`"brand"\`. Its three default branch values must stay exactly \`"pick_direction"\`, \`"brand_spec"\`, and \`"reference_match"\` even if you localize the labels.
- If the initial brief already includes a brand spec, brand-guide attachment, reference URL, or screenshot, you may drop the \`brand\` question as already answered, but you must still treat that provided source as Branch A below.
- Tailor the questions to the actual brief — drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page).
- **Read the "Project metadata" section later in this prompt before writing the form.** That block lists what the user already chose at create time (kind, fidelity, speakerNotes, animations, template, platform). Drop the matching default question if the field is set; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set — the user already told you.
- **Read the "Project metadata" section AND any "## Active plugin" / "## Plugin inputs" block later in this prompt before writing the form.** "Project metadata" lists what the user chose at create time (kind, fidelity, speakerNotes, slideCount, animations, template, platform); "Plugin inputs" lists the same kind of brief data when the project was opened through a plugin chip on Home (e.g. \`fidelity: "high-fidelity"\`, \`platform: "desktop"\`, \`artifactKind: "web prototype"\`, \`slideCount: "10-15 pages"\`, \`audience: "product evaluators"\`, \`designSystem: "..."\`). **Both sources are equally authoritative — treat a plugin input value as a complete answer to the matching default question.** Concretely: a plugin input \`fidelity\` answers the Fidelity question; \`platform\` (or a semantically-equivalent input such as \`surface\`, \`platformTargets\`, \`target\`) answers Target platform; \`slideCount\` / \`slides\` / \`pageCount\` answers Slide count / number of pages; \`artifactKind\` / \`mode\` / \`taskKind\` already names what we are making so do not re-ask "What are we making?"; \`audience\` answers "Who is this for?"; \`designSystem\` / \`brand\` answers Brand context. Drop the matching default question whenever EITHER source supplies the answer; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set or the active plugin's \`od.kind\` / \`taskKind\` already names it — the user already told you.
- Keep it under ~7 questions. Second batch in a follow-up form if needed.
- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble.
- After \`</question-form>\`, **stop your turn**. Do not write code. Do not start tools. Do not narrate "I'll wait."
+3
View File
@@ -433,6 +433,9 @@ function renderMetadataBlock(
);
}
if (metadata.kind === 'deck') {
lines.push(
`- **slideCount**: ${metadata.slideCount ?? '(unknown — ask only if the Active plugin / Plugin inputs block does not already include slideCount)'}`,
);
lines.push(
`- **speakerNotes**: ${typeof metadata.speakerNotes === 'boolean' ? metadata.speakerNotes : '(unknown — ask: include speaker notes?)'}`,
);
@@ -41,8 +41,8 @@
},
"useCase": {
"query": {
"en": "Create a premium product-studio {{slideCount}}-slide {{deckType}} for {{audience}} about {{topic}}: a crisp narrative arc, executive-grade pacing, refined typography, strong visual hierarchy, and spacious investor-ready polish. Speaker notes: {{speakerNotes}}. Use {{designSystem}} as the design-system direction. Build a single-file horizontal-swipe HTML deck by copying `assets/template.html` and pasting slide layouts from `references/layouts.md`.",
"zh-CN": "用高端产品工作室的完成度,为 {{audience}} 做一套 {{slideCount}} 页的 {{deckType}},主题是 {{topic}}:叙事线清晰、节奏高级、排版克制、视觉层级强,像可以直接拿去高管汇报或投资人会议的成品。演讲者备注:{{speakerNotes}}。设计系统方向使用 {{designSystem}}。复制 `assets/template.html` 并从 `references/layouts.md` 粘贴版面,构建单文件横向滑动 HTML deck。"
"en": "Create a premium product-studio {{deckType}} for {{audience}} about {{topic}} with {{slideCount}}: a crisp narrative arc, executive-grade pacing, refined typography, strong visual hierarchy, and spacious investor-ready polish. Speaker notes: {{speakerNotes}}. Use {{designSystem}} as the design-system direction. Build a single-file horizontal-swipe HTML deck by copying `assets/template.html` and pasting slide layouts from `references/layouts.md`.",
"zh-CN": "用高端产品工作室的完成度,为 {{audience}} 做一套 {{deckType}},主题是 {{topic}},页数为 {{slideCount}}:叙事线清晰、节奏高级、排版克制、视觉层级强,像可以直接拿去高管汇报或投资人会议的成品。演讲者备注:{{speakerNotes}}。设计系统方向使用 {{designSystem}}。复制 `assets/template.html` 并从 `references/layouts.md` 粘贴版面,构建单文件横向滑动 HTML deck。"
},
"exampleOutputs": [
{
@@ -84,10 +84,17 @@
},
{
"name": "slideCount",
"label": "Slide count",
"type": "number",
"label": "Pages",
"type": "select",
"required": true,
"default": 10
"options": [
"5-10 pages",
"10-15 pages",
"15-20 pages",
"20-25 pages",
"25-30 pages"
],
"default": "10-15 pages"
},
{
"name": "speakerNotes",