mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Improve deck home previews and plugin gallery performance (#2698)
Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
co-authored by
qiongyu1999
parent
9912fa899a
commit
10e11531a1
@@ -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."
|
||||
|
||||
@@ -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`');
|
||||
});
|
||||
|
||||
@@ -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**');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface ProjectMetadata {
|
||||
intent?: 'live-artifact';
|
||||
fidelity?: 'wireframe' | 'high-fidelity';
|
||||
speakerNotes?: boolean;
|
||||
slideCount?: string;
|
||||
animations?: boolean;
|
||||
includeLandingPage?: boolean;
|
||||
includeOsWidgets?: boolean;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user