Merge branch 'main' of github.com:nexu-io/open-design

This commit is contained in:
pftom
2026-04-28 16:20:14 +08:00
37 changed files with 2055 additions and 264 deletions
+6 -2
View File
@@ -76,8 +76,12 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
const importTriggerRef = useRef<HTMLButtonElement | null>(null);
// initialDraft is only honored on the first non-empty value the parent
// hands us. After we seed once, the composer is fully under user control
// — re-renders that pass the same prompt back must not reseed.
const seededRef = useRef(false);
// — re-renders that pass the same prompt back must not reseed. If the
// initial useState above already consumed a non-empty initialDraft we
// mark it seeded immediately, so an early clear by the user (typing or
// backspace before the parent stops passing initialDraft) does not get
// overwritten by the effect.
const seededRef = useRef(Boolean(initialDraft));
useEffect(() => {
if (seededRef.current) return;
+43 -22
View File
@@ -5,6 +5,8 @@ import type {
AppConfig,
DesignSystemSummary,
Project,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
SkillSummary,
} from '../types';
@@ -15,7 +17,7 @@ import { ExamplesTab } from './ExamplesTab';
import { Icon } from './Icon';
import { LanguageMenu } from './LanguageMenu';
import { CenteredLoader } from './Loading';
import { NewProjectPanel, type CreateInput, type CreateTab } from './NewProjectPanel';
import { NewProjectPanel, type CreateInput } from './NewProjectPanel';
type TopTab = 'designs' | 'examples' | 'design-systems';
@@ -70,13 +72,6 @@ export function EntryView({
const t = useT();
const [topTab, setTopTab] = useState<TopTab>('designs');
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
const [panelPreset, setPanelPreset] = useState<{
tab: CreateTab;
skillId: string | null;
name: string;
pendingPrompt?: string;
nonce: number;
} | null>(null);
const [sidebarWidth, setSidebarWidth] = useState<number>(() => loadSidebarWidth());
const [resizing, setResizing] = useState(false);
@@ -98,13 +93,16 @@ export function EntryView({
: t('settings.noAgentSelected');
}, [config.mode, config.model, config.baseUrl, currentAgent, t]);
// 'Use this prompt' on an example card is a fast path — skip the form and
// create the project immediately with sane defaults derived from the skill,
// seeding the chat composer with the example prompt via pendingPrompt.
function usePromptFromSkill(skill: SkillSummary) {
setPanelPreset({
tab: tabForSkill(skill),
skillId: skill.id,
onCreateProject({
name: skill.name,
skillId: skill.id,
designSystemId: null,
metadata: metadataForSkill(skill),
pendingPrompt: skill.examplePrompt || skill.description,
nonce: Date.now(),
});
}
@@ -118,10 +116,7 @@ export function EntryView({
);
function handleCreate(input: CreateInput) {
onCreateProject({
...input,
pendingPrompt: panelPreset?.pendingPrompt,
});
onCreateProject(input);
}
const startWidthRef = useRef(0);
@@ -177,15 +172,11 @@ export function EntryView({
</div>
</div>
<NewProjectPanel
key={panelPreset?.nonce ?? 'default'}
skills={skills}
designSystems={designSystems}
defaultDesignSystemId={defaultDesignSystemId}
templates={templates}
onCreate={handleCreate}
presetTab={panelPreset?.tab}
presetSkillId={panelPreset?.skillId ?? null}
presetName={panelPreset?.name}
loading={loading}
/>
<div className="entry-side-foot">
@@ -313,8 +304,38 @@ function TopTabButton({
);
}
function tabForSkill(skill: SkillSummary): CreateTab {
// Map a skill's declared mode to project metadata. Falls back to the same
// defaults the new-project form would apply (high-fidelity prototype, no
// speaker notes on decks, no template animations) so 'Use this prompt'
// produces a project indistinguishable from one created via the form. Per-
// skill hints in SKILL.md frontmatter (od.fidelity, od.speaker_notes,
// od.animations) override the defaults so each example reproduces the
// shipped example.html — e.g. wireframe-sketch declares fidelity:wireframe.
function metadataForSkill(skill: SkillSummary): ProjectMetadata {
const kind = kindForSkill(skill);
if (kind === 'prototype') {
return { kind, fidelity: skill.fidelity ?? 'high-fidelity' };
}
if (kind === 'deck') {
return {
kind,
speakerNotes:
typeof skill.speakerNotes === 'boolean' ? skill.speakerNotes : false,
};
}
if (kind === 'template') {
return {
kind,
animations:
typeof skill.animations === 'boolean' ? skill.animations : false,
};
}
return { kind: 'other' };
}
function kindForSkill(skill: SkillSummary): ProjectKind {
if (skill.mode === 'deck') return 'deck';
if (skill.mode === 'prototype') return 'prototype';
return 'template';
if (skill.mode === 'template') return 'template';
return 'other';
}
+20 -10
View File
@@ -141,13 +141,23 @@ function HtmlViewer({
};
}, [projectId, file.name, file.mtime, liveHtml, reloadKey]);
// Detect deck-shaped HTML even when the project's skill didn't declare
// `mode: deck`. Freeform projects often produce a deck because the user
// asked for one in plain prose; without this, prev/next and Present
// never surface and the deck becomes a static, unnavigable preview.
const looksLikeDeck = useMemo(() => {
if (!source) return false;
return /class\s*=\s*['"][^'"]*\bslide\b/i.test(source);
}, [source]);
const effectiveDeck = isDeck || looksLikeDeck;
const srcDoc = useMemo(
() => (source ? buildSrcdoc(source, { deck: isDeck }) : ''),
[source, isDeck],
() => (source ? buildSrcdoc(source, { deck: effectiveDeck }) : ''),
[source, effectiveDeck],
);
useEffect(() => {
if (!isDeck) {
if (!effectiveDeck) {
setSlideState(null);
return;
}
@@ -161,7 +171,7 @@ function HtmlViewer({
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [isDeck]);
}, [effectiveDeck]);
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
const win = iframeRef.current?.contentWindow;
@@ -172,7 +182,7 @@ function HtmlViewer({
// Keyboard nav on the host, so the user can press ←/→ even when focus
// is on the chat composer or any other host control.
useEffect(() => {
if (!isDeck || mode !== 'preview') return;
if (!effectiveDeck || mode !== 'preview') return;
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target) {
@@ -195,7 +205,7 @@ function HtmlViewer({
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isDeck, mode]);
}, [effectiveDeck, mode]);
useEffect(() => {
if (!presentMenuOpen) return;
@@ -309,7 +319,7 @@ function HtmlViewer({
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
}
const showPresent = isDeck && source !== null;
const showPresent = effectiveDeck && source !== null;
const canShare = source !== null;
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
@@ -328,7 +338,7 @@ function HtmlViewer({
>
<Icon name="reload" size={14} />
</button>
{isDeck ? (
{effectiveDeck ? (
<span
className="deck-nav"
role="group"
@@ -502,12 +512,12 @@ function HtmlViewer({
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsPdf(source ?? '', exportTitle, { deck: isDeck });
exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck });
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>
{isDeck
{effectiveDeck
? t('fileViewer.exportPdfAllSlides')
: t('fileViewer.exportPdf')}
</span>
+3 -10
View File
@@ -28,9 +28,6 @@ interface Props {
defaultDesignSystemId: string | null;
templates: ProjectTemplate[];
onCreate: (input: CreateInput) => void;
presetTab?: CreateTab;
presetSkillId?: string | null;
presetName?: string;
loading?: boolean;
}
@@ -47,14 +44,11 @@ export function NewProjectPanel({
defaultDesignSystemId,
templates,
onCreate,
presetTab,
presetSkillId,
presetName,
loading = false,
}: Props) {
const t = useT();
const [tab, setTab] = useState<CreateTab>(presetTab ?? 'prototype');
const [name, setName] = useState(presetName ?? '');
const [tab, setTab] = useState<CreateTab>('prototype');
const [name, setName] = useState('');
// Design-system selection is now an *array* internally so the same
// component can drive both single-select and multi-select modes without
// duplicating state. Single-select coerces to length 0/1.
@@ -89,7 +83,6 @@ export function NewProjectPanel({
// pick a default-rendered skill (so the agent gets the right SKILL.md
// body) without requiring the user to choose one explicitly.
const skillIdForTab = useMemo(() => {
if (presetSkillId !== undefined) return presetSkillId;
if (tab === 'other') return null;
if (tab === 'prototype') {
const list = skills.filter((s) => s.mode === 'prototype');
@@ -104,7 +97,7 @@ export function NewProjectPanel({
?? null;
}
return null;
}, [tab, skills, presetSkillId]);
}, [tab, skills]);
const canCreate =
!loading && (tab !== 'template' || templateId != null);
+16 -3
View File
@@ -666,9 +666,22 @@ export function ProjectView({
[skills, project.skillId],
);
// Hand the pending prompt to ChatPane exactly once. After the first render
// we tell App to clear it so re-entering the project later doesn't reseed.
const initialDraft = project.pendingPrompt;
// Hand the pending prompt to ChatPane exactly once. We snapshot the value
// into local state on mount so it survives the ChatPane remount triggered
// when `activeConversationId` resolves from `null` to a real id (the
// `key={activeConversationId}` on ChatPane otherwise wipes the freshly
// seeded composer draft). Once the conversation id is in place — meaning
// ChatPane has remounted with the seed still available — we clear both
// the local snapshot and the persisted pendingPrompt so future
// conversation switches don't keep re-seeding the composer.
const [initialDraft, setInitialDraft] = useState<string | undefined>(
project.pendingPrompt,
);
useEffect(() => {
if (initialDraft && activeConversationId) {
setInitialDraft(undefined);
}
}, [initialDraft, activeConversationId]);
useEffect(() => {
if (project.pendingPrompt) onClearPendingPrompt();
}, [project.pendingPrompt, onClearPendingPrompt]);
+212 -20
View File
@@ -16,10 +16,10 @@
*/
export function buildSrcdoc(
html: string,
options: { deck?: boolean } = {},
options: { deck?: boolean } = {}
): string {
const head = html.trimStart().slice(0, 64).toLowerCase();
const isFullDoc = head.startsWith('<!doctype') || head.startsWith('<html');
const isFullDoc = head.startsWith("<!doctype") || head.startsWith("<html");
const wrapped = isFullDoc
? html
: `<!doctype html>
@@ -30,33 +30,174 @@ export function buildSrcdoc(
</head>
<body>${html}</body>
</html>`;
if (!options.deck) return wrapped;
return injectDeckBridge(wrapped);
const withShim = injectSandboxShim(wrapped);
if (!options.deck) return withShim;
return injectDeckBridge(withShim);
}
// Sandboxed iframes (we use `sandbox="allow-scripts"`) without
// `allow-same-origin` raise a SecurityError on first `localStorage` /
// `sessionStorage` access. Many freeform-generated decks call
// `localStorage.getItem(...)` at the top of their IIFE without a
// try/catch — when it throws, the whole script aborts and the deck
// becomes a static, unnavigable preview. We install a same-origin
// in-memory shim BEFORE any user script runs so those decks degrade
// gracefully (position just doesn't persist across reloads).
function injectSandboxShim(doc: string): string {
const shim = `<script>(function(){
function makeStore(){
var data = {};
var api = {
getItem: function(k){ return Object.prototype.hasOwnProperty.call(data, k) ? data[k] : null; },
setItem: function(k, v){ data[k] = String(v); },
removeItem: function(k){ delete data[k]; },
clear: function(){ data = {}; },
key: function(i){ return Object.keys(data)[i] || null; }
};
Object.defineProperty(api, 'length', { get: function(){ return Object.keys(data).length; } });
return api;
}
function tryShim(name){
var works = false;
try { works = !!window[name] && typeof window[name].getItem === 'function'; void window[name].length; }
catch (_) { works = false; }
if (works) return;
try { Object.defineProperty(window, name, { configurable: true, value: makeStore() }); }
catch (_) { try { window[name] = makeStore(); } catch (__) {} }
}
tryShim('localStorage');
tryShim('sessionStorage');
})();</script>`;
if (/<head[^>]*>/i.test(doc))
return doc.replace(/<head[^>]*>/i, (m) => `${m}${shim}`);
if (/<body[^>]*>/i.test(doc))
return doc.replace(/<body[^>]*>/i, (m) => `${m}${shim}`);
return shim + doc;
}
// The deck bridge supports three deck conventions found across our skills
// and freeform-generated artifacts:
// 1. Horizontal scroll decks (simple-deck, guizang-ppt) — slides laid out
// side-by-side, navigation = scrollTo({ left }).
// 2. Class-toggle decks (deck-framework, freeform pitches) — one slide
// carries `.active` or `.is-active`; siblings are display:none. Their
// own JS listens for ArrowRight/Left, so we drive them by dispatching
// synthetic KeyboardEvents.
// 3. Visibility-only decks — no class toggle, slides hidden via inline
// style. We fall back to keyboard dispatch + visibility detection.
//
// All three report `{ active, count }` back to the host so the toolbar can
// render a unified counter. A MutationObserver on each `.slide` lets us
// catch class changes from the deck's own keyboard handler.
//
// We also inject a small CSS override that fixes a common authoring
// mistake in fixed-canvas decks: a `.stage { display: grid; place-items:
// center }` only centers items within their grid cells, but the track
// itself stays `start`-aligned, so the 1920x1080 canvas top-lefts at
// (0,0) of the stage. Combined with `transform-origin: center center`,
// the scaled canvas ends up offset toward the bottom-right of any
// preview that's smaller than 1920x1080 — exactly what users see in the
// sandbox iframe. `place-content: center` centers the track itself.
function injectDeckBridge(doc: string): string {
const styleFix = `<style data-od-deck-fix>
.stage, .deck-stage, .deck-shell { place-content: center !important; }
</style>`;
const docWithStyle = /<\/head>/i.test(doc)
? doc.replace(/<\/head>/i, styleFix + "</head>")
: /<head[^>]*>/i.test(doc)
? doc.replace(/<head[^>]*>/i, (m) => m + styleFix)
: styleFix + doc;
doc = docWithStyle;
const script = `<script>(function(){
function slides(){ return document.querySelectorAll('.slide'); }
function scroller(){
if (document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
}
function activeIndex(){
return Math.round(scroller().scrollLeft / window.innerWidth);
function isScrollDeck(){
var sc = scroller();
return !!(sc && sc.scrollWidth > sc.clientWidth + 1);
}
function go(i){
function findActiveByClass(list){
for (var i=0; i<list.length; i++) {
var cl = list[i].classList;
if (cl && (cl.contains('is-active') || cl.contains('active') || cl.contains('current'))) return i;
}
return -1;
}
function findActiveByVisibility(list){
for (var i=0; i<list.length; i++) {
try {
var cs = window.getComputedStyle(list[i]);
if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') return i;
} catch (_) {}
}
return -1;
}
function activeIndex(list){
if (!list || !list.length) return 0;
if (isScrollDeck()) {
var w = Math.max(1, window.innerWidth);
return Math.max(0, Math.min(list.length - 1, Math.round(scroller().scrollLeft / w)));
}
var byClass = findActiveByClass(list);
if (byClass >= 0) return byClass;
var byVis = findActiveByVisibility(list);
if (byVis >= 0) return byVis;
return 0;
}
function dispatchKey(key){
// Bubbles so any listener on window picks it up too. We dispatch on
// document only — dispatching on window/body in addition would cause
// bubbling to fire the same document-level listener twice.
var init = { key: key, code: key, bubbles: true, cancelable: true, composed: true };
try {
document.dispatchEvent(new KeyboardEvent('keydown', init));
document.dispatchEvent(new KeyboardEvent('keyup', init));
} catch (_) {}
}
function scrollGo(i){
var list = slides();
if (!list.length) return;
var next = Math.max(0, Math.min(list.length - 1, i));
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
setTimeout(report, 360);
setTimeout(report, 380);
}
function go(action){
var list = slides();
if (!list.length) return;
if (isScrollDeck()) {
var i = activeIndex(list);
if (action === 'next') scrollGo(i + 1);
else if (action === 'prev') scrollGo(i - 1);
else if (action === 'first') scrollGo(0);
else if (action === 'last') scrollGo(list.length - 1);
return;
}
if (action === 'next') dispatchKey('ArrowRight');
else if (action === 'prev') dispatchKey('ArrowLeft');
else if (action === 'first') dispatchKey('Home');
else if (action === 'last') dispatchKey('End');
setTimeout(report, 280);
}
function gotoIndex(i){
var list = slides();
if (!list.length) return;
var target = Math.max(0, Math.min(list.length - 1, i));
if (isScrollDeck()) { scrollGo(target); return; }
var current = activeIndex(list);
var diff = target - current;
if (!diff) { report(); return; }
var key = diff > 0 ? 'ArrowRight' : 'ArrowLeft';
var n = Math.abs(diff);
for (var k = 0; k < n; k++) dispatchKey(key);
setTimeout(report, 320);
}
function report(){
try {
var list = slides();
window.parent.postMessage({
type: 'od:slide-state',
active: activeIndex(),
active: activeIndex(list),
count: list.length,
}, '*');
} catch (e) {}
@@ -64,18 +205,69 @@ function injectDeckBridge(doc: string): string {
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || data.type !== 'od:slide') return;
var list = slides();
var i = activeIndex();
if (data.action === 'next') go(i + 1);
else if (data.action === 'prev') go(i - 1);
else if (data.action === 'first') go(0);
else if (data.action === 'last') go(list.length - 1);
else if (data.action === 'go' && typeof data.index === 'number') go(data.index);
if (data.action === 'go' && typeof data.index === 'number') gotoIndex(data.index);
else go(data.action);
});
// Report once on load and on every scroll-end so the host stays in sync.
window.addEventListener('load', function(){ setTimeout(report, 200); });
document.addEventListener('scroll', function(){ clearTimeout(window.__ocdReportT); window.__ocdReportT = setTimeout(report, 120); }, { passive: true, capture: true });
document.addEventListener('scroll', function(){
clearTimeout(window.__odReportT);
window.__odReportT = setTimeout(report, 120);
}, { passive: true, capture: true });
// Nudge the deck's own fit/resize listener after layout settles. Fixed-canvas
// decks (e.g. ".canvas { width: 1920px }" + "transform: scale(...)") compute
// their scale on first run, which fires when the iframe is still 0x0 in
// sandboxed previews — the deck's fit() then resolves to scale(0) / scale(1)
// and never recovers. Re-firing 'resize' lets the deck recompute, and a
// ResizeObserver picks up later layout settles (zoom toggle, sidebar drag).
function nudgeResize(){
try { window.dispatchEvent(new Event('resize')); }
catch (_) {}
}
// Aggressively nudge during the first second so the deck catches the
// iframe's first non-zero size; bail out early once the iframe reports a
// real width. Without this loop, fixed-canvas decks render at scale(0).
function chaseFirstLayout(){
var attempts = 0;
function tick(){
attempts += 1;
var w = window.innerWidth;
nudgeResize();
if (w > 0 && attempts >= 2) return; // one extra nudge after first non-zero
if (attempts < 30) setTimeout(tick, 50);
}
tick();
}
if (document.readyState === 'complete') chaseFirstLayout();
else window.addEventListener('load', chaseFirstLayout);
// Re-nudge whenever the iframe itself is resized by the host (e.g.
// user toggles zoom, resizes the chat sidebar, exits Present).
if (typeof ResizeObserver !== 'undefined') {
try {
var ro = new ResizeObserver(function(){ nudgeResize(); });
ro.observe(document.documentElement);
} catch (_) {}
}
// For class-toggle decks the deck's own keyboard handler updates classes
// on the slide elements; an attribute observer translates that into the
// host counter without depending on scroll events.
function observeSlides(){
var list = slides();
if (!list.length) { setTimeout(observeSlides, 150); return; }
try {
var mo = new MutationObserver(function(){
clearTimeout(window.__odReportT2);
window.__odReportT2 = setTimeout(report, 60);
});
for (var i = 0; i < list.length; i++) {
mo.observe(list[i], { attributes: true, attributeFilter: ['class', 'style', 'hidden', 'aria-hidden'] });
}
} catch (e) {}
setTimeout(report, 100);
}
observeSlides();
})();</script>`;
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${script}</body>`);
if (/<\/body>/i.test(doc))
return doc.replace(/<\/body>/i, `${script}</body>`);
return doc + script;
}
+9
View File
@@ -88,6 +88,15 @@ export interface SkillSummary {
* the skill in its natural alphabetical position below all featured
* entries. Set via `od.featured` in the SKILL.md frontmatter. */
featured?: number | null;
/** Optional metadata hints, parsed from `od.fidelity`,
* `od.speaker_notes`, and `od.animations` in SKILL.md. Used by the
* Examples gallery's "Use this prompt" fast-create path to mirror the
* shipped `example.html` (e.g. wireframe-sketch declares
* `fidelity: wireframe`). Missing hints fall back to the same defaults
* the new-project form would apply. */
fidelity?: 'wireframe' | 'high-fidelity' | null;
speakerNotes?: boolean | null;
animations?: boolean | null;
hasBody: boolean;
examplePrompt: string;
}