fix(daemon,web): block pitch-deck placeholder publishes and unbreak framework decks (#2384)

Two preview-time bugs surfaced ahead of 0.8.0:

1. Pitch-deck example (#2215): the official html-ppt-pitch-deck prompt asked
   the agent to confirm three facts first, but the manifest had no
   structured `od.inputs`, so the platform's required-input gate had no
   fields to enforce and the run could publish HTML that still contained
   unresolved fundraising placeholders (`Name to confirm`, `$X.XM`,
   `Replace this panel with`, ...). Add structured required inputs to the
   manifest and a daemon-side publication guard that rejects HTML/deck
   artifact writes whose body still contains those placeholders. Scope is
   the file-write boundary only (no assistant-text scanning), so the
   guard cannot trip on the agent's chat prose mid-clarification.

2. Framework deck preview off-screen: `injectDeckBridge` injected
   `place-content: center !important` on `.deck-shell` for every deck-mode
   srcdoc, which forced the framework's `display: grid` shell to re-center
   its implicit track. The framework's `fit()` already centers a
   `transform-origin: top left` stage with an explicit `translate(tx, ty)`
   that assumes the stage's natural layout position is (0, 0); the two
   centerings stacked and the scaled stage landed ~1000px off-screen, so
   the preview showed a sliver of slide content in the top-left with the
   rest black. Skip the override when the framework's `id="deck-stage"`
   marker is in the doc, and drop the dead `display: grid; place-items:
   center` from the deck framework template so future drift can't
   re-introduce the same stack.
This commit is contained in:
lefarcen 2026-05-20 16:20:34 +08:00 committed by GitHub
parent 65e760b88a
commit c80acfefeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 538 additions and 33 deletions

View file

@ -0,0 +1,89 @@
// Blocks an artifact publication when the body still contains template
// placeholders that should have been replaced with real content. Scoped to
// the file-write boundary for HTML-rendered artifact kinds — the moment
// the file would land in the project as a published artifact — not to the
// agent's chat prose, where these strings can legitimately appear when
// the model is asking the user to fill them in.
//
// The placeholder set is intentionally short and pitch-deck-specific:
// these are the exact markers shipped by the official html-ppt-pitch-deck
// example template that signal "an unfilled fundraising slot" rather than
// "regular product copy". Extending the list to more general words risks
// false positives on real artifacts, so the contract is: any future
// example that wants similar enforcement should either declare structured
// `od.inputs` (preferred) or contribute its specific placeholder markers
// here together with a fixture that proves they cannot appear in
// finished output.
//
// Pairs with `od.inputs` on `plugins/_official/examples/html-ppt-pitch-deck/
// open-design.json`. The inputs gate is the primary contract — this guard
// is the defense-in-depth invariant that catches agents that bypassed the
// structured contract (e.g. routed through `od-default`) but still tried
// to publish a placeholder-laden HTML/deck artifact.
import { Buffer } from 'node:buffer';
export const ARTIFACT_PUBLICATION_BLOCKED_CODE = 'ARTIFACT_PUBLICATION_BLOCKED' as const;
// HTML and deck are the artifact kinds whose bodies are user-facing
// rendered documents. Other kinds (markdown drafts, code snippets, raw
// JSON) are not subject to this guard — placeholder strings can be
// legitimate content in those.
export const PUBLICATION_GUARDED_ARTIFACT_KINDS: ReadonlySet<string> = new Set(['html', 'deck']);
// Markers shipped by the html-ppt-pitch-deck example template that
// indicate an unresolved fundraising slot. Each is a substring; matching
// is case-sensitive because the template emits these literally.
export const UNRESOLVED_ARTIFACT_PLACEHOLDERS = [
'Name to confirm',
'$X.XM',
'Replace this panel with',
'Replace role placeholders',
'Your form answer only said',
] as const;
export class ArtifactPublicationBlockedError extends Error {
readonly code = ARTIFACT_PUBLICATION_BLOCKED_CODE;
readonly placeholders: string[];
constructor(placeholders: string[]) {
super(buildArtifactPublicationBlockedMessage(placeholders));
this.name = 'ArtifactPublicationBlockedError';
this.placeholders = [...placeholders];
}
}
export function isPublicationGuardedArtifactKind(kind: unknown): boolean {
return typeof kind === 'string' && PUBLICATION_GUARDED_ARTIFACT_KINDS.has(kind);
}
export function findUnresolvedArtifactPlaceholders(value: unknown): string[] {
const text = stringifyArtifactContent(value);
if (!text) return [];
return UNRESOLVED_ARTIFACT_PLACEHOLDERS.filter((placeholder) =>
text.includes(placeholder),
);
}
export function shouldBlockArtifactPublication(value: unknown): boolean {
return findUnresolvedArtifactPlaceholders(value).length > 0;
}
export function buildArtifactPublicationBlockedMessage(placeholders: readonly string[]): string {
const list = placeholders.length > 0 ? placeholders.join(', ') : 'unknown placeholders';
return `Artifact still contains unresolved pitch-deck placeholders: ${list}. Provide the required pitch facts before publishing.`;
}
export function assertArtifactPublicationAllowed(value: unknown): void {
const placeholders = findUnresolvedArtifactPlaceholders(value);
if (placeholders.length > 0) {
throw new ArtifactPublicationBlockedError(placeholders);
}
}
function stringifyArtifactContent(value: unknown): string {
if (typeof value === 'string') return value;
if (Buffer.isBuffer(value)) return value.toString('utf8');
if (value instanceof Uint8Array) return Buffer.from(value).toString('utf8');
return '';
}

View file

@ -4,6 +4,7 @@ import {
type PluginManifest,
} from '@open-design/contracts';
import { createProjectArtifactFile } from './artifact-create.js';
import { ArtifactPublicationBlockedError } from './artifact-publication-guard.js';
import { ArtifactRegressionError } from './artifact-stub-guard.js';
import { listDesignSystems } from './design-systems.js';
import {
@ -1114,6 +1115,11 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile
},
});
}
if (err instanceof ArtifactPublicationBlockedError) {
return sendApiError(res, 422, 'ARTIFACT_PUBLICATION_BLOCKED', err.message, {
details: { placeholders: err.placeholders },
});
}
if (err?.code === 'EEXIST') {
return sendApiError(res, 409, 'FILE_EXISTS', 'file already exists');
}

View file

@ -21,6 +21,10 @@ import {
evaluateArtifactStubGuard,
readArtifactStubGuardConfigFromEnv,
} from './artifact-stub-guard.js';
import {
assertArtifactPublicationAllowed,
isPublicationGuardedArtifactKind,
} from './artifact-publication-guard.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
@ -640,6 +644,15 @@ export async function writeProjectFile(
const validated = validateArtifactManifestInput(artifactManifest, safeName);
if (validated.ok && validated.value) {
validatedManifest = validated.value;
// Publication guard: HTML/deck artifacts that still contain template
// placeholders (e.g. pitch-deck `Name to confirm`, `$X.XM`) must not
// land as published files. Runs at the write boundary so it covers
// every artifact that flows through writeProjectFile, regardless of
// which agent/atom produced the body. Throws
// ArtifactPublicationBlockedError which the route layer maps to 422.
if (isPublicationGuardedArtifactKind(validatedManifest.kind)) {
assertArtifactPublicationAllowed(body);
}
const identifier = typeof validatedManifest.metadata?.identifier === 'string'
? validatedManifest.metadata.identifier
: '';

View file

@ -10,11 +10,16 @@
* - DECK_FRAMEWORK_DIRECTIVE : the prompt fragment that tells the model
* what is fixed and what they're allowed to change.
*
* Pattern: 1920×1080 fixed canvas centered in the viewport via `display:grid;
* place-items:center`, scaled with `transform: scale()` whose factor is
* recomputed on every resize. Slides are `<section class="slide">` inside
* the stage, only `.slide.active` is visible. Prev/next + counter live
* OUTSIDE the scaled stage so they don't shrink with it.
* Pattern: 1920×1080 fixed canvas anchored at the shell's top-left,
* centered into the viewport by `fit()` with `transform-origin: top left`
* and an explicit `translate(tx, ty) scale(s)` whose factor is recomputed
* on every resize. The shell is intentionally NOT a grid/flex container
* any extra centering layer would stack with the explicit translate and
* push the scaled stage off-screen (see the OD srcdoc bridge's deck-fix
* placement note in `apps/web/src/runtime/srcdoc.ts:injectDeckBridge`).
* Slides are `<section class="slide">` inside the stage, only
* `.slide.active` is visible. Prev/next + counter live OUTSIDE the scaled
* stage so they don't shrink with it.
*
* Why this pattern (not horizontal scroll-snap):
* - It matches what the model has the strongest prior on, so the framework
@ -24,9 +29,11 @@
* - Print becomes trivial: render every slide as block, page-break between.
*
* Drift fixes baked in:
* - `transform-origin: top left` and the stage is positioned by grid +
* place-items, so scaling never shifts content sideways inside the
* OD viewer's nested transform wrapper.
* - `transform-origin: top left` with an explicit
* `translate(tx, ty) scale(s)`. The shell is plain block flow (no
* grid/flex/place-content), so the stage's natural top-left is (0, 0)
* and the translate centers it correctly even inside the OD viewer's
* nested transform wrapper.
* - Capture-phase keydown on BOTH window and document so iframe focus
* quirks can't swallow arrow keys.
* - Auto-focus body on load and on every click.
@ -80,8 +87,6 @@ export const DECK_SKELETON_HTML = `<!doctype html>
.deck-shell {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.deck-stage {
@ -91,7 +96,6 @@ export const DECK_SKELETON_HTML = `<!doctype html>
position: relative;
transform-origin: top left;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.slide {
position: absolute;
@ -250,11 +254,13 @@ export const DECK_SKELETON_HTML = `<!doctype html>
var idx = 0;
// ---- scale-to-fit ---------------------------------------------------
// The stage is 1920×1080 and positioned by .deck-shell's
// \`display:grid;place-items:center\`. We scale via transform with
// transform-origin:top-left, then re-center by translating to the
// remainder. This survives nested transforms (e.g. when the OD viewer
// wraps the iframe in its own scale wrapper at zoom != 100%).
// The stage is 1920×1080 and sits at .deck-shell's (0, 0) in normal
// block flow — the shell is intentionally NOT a grid/flex container,
// so the stage's natural top-left is (0, 0). We scale via transform
// with transform-origin:top-left, then translate by the remainder to
// center the scaled box in the viewport. This survives nested
// transforms (e.g. when the OD viewer wraps the iframe in its own
// scale wrapper at zoom != 100%).
function fit() {
var sw = window.innerWidth;
var sh = window.innerHeight;

View file

@ -296,6 +296,7 @@ import {
writeProjectFile,
} from './projects.js';
import { validateArtifactManifestInput } from './artifact-manifest.js';
import { ArtifactPublicationBlockedError } from './artifact-publication-guard.js';
import { readCurrentAppVersionInfo } from './app-version.js';
import {
appendMessageStatusEvent,
@ -8148,6 +8149,11 @@ export async function startServer({
const body = { file: meta };
res.json(body);
} catch (err) {
if (err instanceof ArtifactPublicationBlockedError) {
return sendApiError(res, 422, 'ARTIFACT_PUBLICATION_BLOCKED', err.message, {
details: { placeholders: err.placeholders },
});
}
sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed');
}
},

View file

@ -0,0 +1,186 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
ArtifactPublicationBlockedError,
findUnresolvedArtifactPlaceholders,
isPublicationGuardedArtifactKind,
shouldBlockArtifactPublication,
} from '../src/artifact-publication-guard.js';
import { listFiles, writeProjectFile } from '../src/projects.js';
const deckManifest = {
kind: 'deck',
renderer: 'deck-html',
title: 'Pitch deck',
exports: ['html', 'pdf'],
metadata: { identifier: 'pitch-deck' },
};
const htmlManifest = {
kind: 'html',
renderer: 'html',
title: 'Pitch HTML',
exports: ['html'],
metadata: { identifier: 'pitch-html' },
};
const markdownManifest = {
kind: 'markdown',
renderer: 'markdown',
title: 'Notes',
exports: ['md'],
metadata: { identifier: 'pitch-notes' },
};
describe('artifact publication guard — placeholder detection', () => {
it('finds every shipped placeholder when present in generated HTML', () => {
const html = `
<!doctype html>
<html>
<body>
<section>Name to confirm</section>
<section>$X.XM</section>
<section>Replace this panel with actual pipeline once known.</section>
<section>Replace role placeholders with leadership names.</section>
<section>Your form answer only said "seed deck".</section>
</body>
</html>
`;
expect(findUnresolvedArtifactPlaceholders(html)).toEqual([
'Name to confirm',
'$X.XM',
'Replace this panel with',
'Replace role placeholders',
'Your form answer only said',
]);
expect(shouldBlockArtifactPublication(html)).toBe(true);
});
it('passes pitch-deck content that already carries concrete ask and traction copy', () => {
const html = `
<!doctype html>
<html>
<body>
<section>Acme AI turns support transcripts into shipped product fixes.</section>
<section>$4.5M seed round</section>
<section>42% MoM revenue growth, 18 enterprise pilots, 91% retention.</section>
<section>Use of funds: engineering, GTM, compliance, and customer success.</section>
</body>
</html>
`;
expect(findUnresolvedArtifactPlaceholders(html)).toEqual([]);
expect(shouldBlockArtifactPublication(html)).toBe(false);
});
it('reads Buffer and Uint8Array bodies, returning [] for non-text inputs', () => {
const placeholderBuffer = Buffer.from('<html>Name to confirm</html>', 'utf8');
expect(findUnresolvedArtifactPlaceholders(placeholderBuffer)).toEqual(['Name to confirm']);
const placeholderBytes = new Uint8Array(Buffer.from('<html>$X.XM</html>', 'utf8'));
expect(findUnresolvedArtifactPlaceholders(placeholderBytes)).toEqual(['$X.XM']);
expect(findUnresolvedArtifactPlaceholders(null)).toEqual([]);
expect(findUnresolvedArtifactPlaceholders(undefined)).toEqual([]);
expect(findUnresolvedArtifactPlaceholders({ unknown: true })).toEqual([]);
});
it('only guards html and deck artifact kinds, not markdown / code / etc.', () => {
expect(isPublicationGuardedArtifactKind('html')).toBe(true);
expect(isPublicationGuardedArtifactKind('deck')).toBe(true);
expect(isPublicationGuardedArtifactKind('markdown')).toBe(false);
expect(isPublicationGuardedArtifactKind('code-snippet')).toBe(false);
expect(isPublicationGuardedArtifactKind('sketch')).toBe(false);
expect(isPublicationGuardedArtifactKind(undefined)).toBe(false);
expect(isPublicationGuardedArtifactKind(null)).toBe(false);
});
});
describe('artifact publication guard — wired into writeProjectFile', () => {
it('rejects html artifacts that still contain pitch-deck placeholders', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-html-'));
try {
await expect(
writeProjectFile(
projectsRoot,
'project-1',
'pitch-deck.html',
Buffer.from('<html><body><section>Name to confirm</section><section>$X.XM</section></body></html>'),
{ artifactManifest: htmlManifest } as unknown as Parameters<typeof writeProjectFile>[4],
),
).rejects.toBeInstanceOf(ArtifactPublicationBlockedError);
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html.artifact.json');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('rejects deck artifacts that still contain pitch-deck placeholders', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-deck-'));
try {
await expect(
writeProjectFile(
projectsRoot,
'project-1',
'pitch-deck.html',
Buffer.from('<html><body>Replace this panel with the real chart.</body></html>'),
{ artifactManifest: deckManifest } as unknown as Parameters<typeof writeProjectFile>[4],
),
).rejects.toMatchObject({
code: 'ARTIFACT_PUBLICATION_BLOCKED',
placeholders: ['Replace this panel with'],
});
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).not.toContain('pitch-deck.html');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('lets non-guarded artifact kinds pass even when their body contains placeholder substrings', async () => {
// Markdown drafts can legitimately call out unresolved fields with the
// same words; the guard is HTML/deck only. The body here would have
// tripped the guard if applied to all kinds.
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-md-'));
try {
const meta = await writeProjectFile(
projectsRoot,
'project-1',
'critique.md',
Buffer.from('# Critique\n\n- Name to confirm\n- $X.XM\n'),
{ artifactManifest: markdownManifest } as unknown as Parameters<typeof writeProjectFile>[4],
);
expect(meta).toMatchObject({ name: 'critique.md' });
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
it('passes a clean deck artifact through writeProjectFile', async () => {
const projectsRoot = await mkdtemp(path.join(tmpdir(), 'od-publication-guard-clean-'));
try {
const meta = await writeProjectFile(
projectsRoot,
'project-1',
'final-deck.html',
Buffer.from(
'<html><body><section>Acme AI · $4.5M seed</section><section>42% MoM growth · 18 enterprise pilots</section></body></html>',
),
{ artifactManifest: deckManifest } as unknown as Parameters<typeof writeProjectFile>[4],
);
expect(meta).toMatchObject({ name: 'final-deck.html' });
const files = await listFiles(projectsRoot, 'project-1');
expect(files.map((file) => file.name)).toContain('final-deck.html');
} finally {
await rm(projectsRoot, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,57 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parseManifest } from '@open-design/plugin-runtime';
// The pitch-deck example's prompt instructs the agent to "confirm three
// things first" — name + one-line pitch, key traction numbers, ask + use
// of funds — but until #2215 those facts existed only as English prose
// inside the prompt. The platform's required-input gate at apply time
// (apps/daemon/src/plugins/apply.ts:validateInputs) has nothing structured
// to enforce in that shape, so an agent could route through od-default
// and start generating with no facts collected. This test pins the three
// facts as structured `od.inputs` fields so the gate fires before any
// HTML/deck artifact is written.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, '../../..');
const manifestPath = path.join(
repoRoot,
'plugins/_official/examples/html-ppt-pitch-deck/open-design.json',
);
describe('html-ppt-pitch-deck manifest inputs', () => {
it('declares the financing facts the example prompt says must be confirmed first', async () => {
const parsed = parseManifest(await readFile(manifestPath, 'utf8'));
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
const inputs = parsed.manifest.od?.inputs ?? [];
const requiredNames = inputs
.filter((input) => input.required === true)
.map((input) => input.name);
expect(requiredNames).toEqual(
expect.arrayContaining([
'one_line_pitch',
'key_traction_numbers',
'ask_and_use_of_funds',
]),
);
for (const name of [
'one_line_pitch',
'key_traction_numbers',
'ask_and_use_of_funds',
]) {
const input = inputs.find((candidate) => candidate.name === name);
expect(input).toMatchObject({
type: 'text',
required: true,
});
expect(input?.label).toEqual(expect.any(String));
expect(input?.label?.trim()).not.toBe('');
}
});
});

View file

@ -1344,11 +1344,24 @@ html[data-od-inspect-mode] body iframe { pointer-events: none !important; }
// 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.
//
// Framework decks (apps/daemon/src/prompts/deck-framework.ts) opt out:
// their `fit()` already centers a `transform-origin: top left` stage with
// an explicit `translate(tx, ty)` that assumes the stage's natural layout
// position is (0, 0). If we force `place-content: center` on their
// `.deck-shell` grid, the implicit track gets re-centered to
// ((sw-1920)/2, (sh-1080)/2) and `fit()`'s translate stacks on top, so
// the scaled stage lands ~1000px off-screen and the user sees a mostly-
// black preview with a sliver of slide content in the top-left. Skip the
// override whenever the framework's marker id is present.
function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
const safeInitialSlideIndex = Number.isFinite(initialSlideIndex)
? Math.max(0, Math.floor(initialSlideIndex))
: 0;
const styleFix = `<style data-od-deck-fix>
const isFrameworkDeck = /\bid\s*=\s*["']deck-stage["']/i.test(doc);
const styleFix = isFrameworkDeck
? ''
: `<style data-od-deck-fix>
.stage, .deck-stage, .deck-shell { place-content: center !important; }
</style>`;
const script = `<script data-od-deck-bridge>(function(){

View file

@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
// Regression coverage for the "deck-stage shows a sliver of content in the
// top-left with the rest of the preview black" symptom. Root cause: the
// srcdoc deck bridge injected `place-content: center !important` on
// `.stage, .deck-stage, .deck-shell` for ALL deck-mode artifacts, even
// framework decks (DECK_SKELETON_HTML in apps/daemon/src/prompts/
// deck-framework.ts) whose `fit()` already centers a `transform-origin:
// top left` stage with an explicit `translate(tx, ty)` that assumes the
// stage's natural layout position is (0, 0). Forcing place-content on
// the shell re-centered the implicit grid track, doubled the offset, and
// pushed the scaled stage off-screen.
//
// The fix: detect the framework deck via its `id="deck-stage"` marker and
// skip the `data-od-deck-fix` styleFix for it. Legacy / non-framework
// decks that authored their own `.stage` grid still get the override.
function frameworkDeckHtml(): string {
return [
'<!doctype html><html><head><style>',
'.deck-shell { position: fixed; inset: 0; overflow: hidden; }',
'.deck-stage { width: 1920px; height: 1080px; position: relative; transform-origin: top left; }',
'.slide { position: absolute; inset: 0; }',
'.slide:not(.active) { display: none !important; }',
'</style></head><body>',
'<div class="deck-shell">',
' <div class="deck-stage" id="deck-stage">',
' <section class="slide active">slide 1</section>',
' <section class="slide">slide 2</section>',
' </div>',
'</div>',
'<script>(function(){ var stage = document.getElementById(\'deck-stage\'); /* fit() ... */ })();</script>',
'</body></html>',
].join('\n');
}
function legacyDeckHtml(): string {
return [
'<!doctype html><html><head><style>',
// A common authoring shape: `.stage` is the grid container with no
// explicit fit() function. This is exactly what the deck-fix style
// was designed for.
'.stage { display: grid; place-items: center; width: 100vw; height: 100vh; overflow: hidden; }',
'.canvas { width: 1920px; height: 1080px; transform-origin: center center; }',
'.slide { display: none; }',
'.slide.is-active { display: block; }',
'</style></head><body>',
'<div class="stage">',
' <div class="canvas">',
' <section class="slide is-active">slide 1</section>',
' <section class="slide">slide 2</section>',
' </div>',
'</div>',
'</body></html>',
].join('\n');
}
describe('injectDeckBridge — framework-deck detection (#deck-stage)', () => {
it('skips the place-content fix when the deck carries the framework #deck-stage marker', () => {
const out = buildSrcdoc(frameworkDeckHtml(), { deck: true });
expect(out).not.toMatch(/<style[^>]*data-od-deck-fix/);
expect(out).not.toContain('place-content: center !important');
// The bridge script itself must still ship — the framework's own
// fit() handles centering, but the host-side counter / keyboard
// bridge still needs the slide-state postMessage channel.
expect(out).toMatch(/<script[^>]*data-od-deck-bridge/);
});
it('keeps injecting the place-content fix for legacy / non-framework decks', () => {
const out = buildSrcdoc(legacyDeckHtml(), { deck: true });
expect(out).toMatch(/<style[^>]*data-od-deck-fix/);
expect(out).toContain('.stage, .deck-stage, .deck-shell { place-content: center !important; }');
expect(out).toMatch(/<script[^>]*data-od-deck-bridge/);
});
it('skips the fix when #deck-stage uses single quotes, extra whitespace, or uppercase ID syntax', () => {
// The detector should match the framework's emit shape but also
// tolerate the minor formatting variations that DOMParser /
// serializeHtmlDocument introduce in the middle of the pipeline.
const variants = [
`<div class="deck-stage" id='deck-stage'></div>`,
`<div class="deck-stage" ID = "deck-stage"></div>`,
`<div class="deck-stage" id = 'deck-stage'></div>`,
];
for (const variant of variants) {
const out = buildSrcdoc(`<!doctype html><html><body>${variant}</body></html>`, { deck: true });
expect(out, `variant ${JSON.stringify(variant)}`).not.toContain('data-od-deck-fix');
}
});
});

View file

@ -27,6 +27,13 @@ export const API_ERROR_CODES = [
// a bare filename string, an empty fallback page) instead of the full
// document. Configurable via OD_ARTIFACT_STUB_GUARD (reject|warn|off).
'ARTIFACT_REGRESSION',
// The daemon's publication guard found unresolved template placeholders
// (e.g. pitch-deck `Name to confirm` / `$X.XM`) in an HTML/deck artifact
// body at write time, so the file cannot be published. The caller should
// supply the missing facts and retry rather than republishing the same
// body. Returned by `POST /api/projects/:id/files` (and the
// `tools live-artifacts create` path) as a 422.
'ARTIFACT_PUBLICATION_BLOCKED',
'UPSTREAM_UNAVAILABLE',
'RATE_LIMITED',
// PR #974 round-4: desktop-paired daemon received an import request

View file

@ -10,11 +10,16 @@
* - DECK_FRAMEWORK_DIRECTIVE : the prompt fragment that tells the model
* what is fixed and what they're allowed to change.
*
* Pattern: 1920×1080 fixed canvas centered in the viewport via `display:grid;
* place-items:center`, scaled with `transform: scale()` whose factor is
* recomputed on every resize. Slides are `<section class="slide">` inside
* the stage, only `.slide.active` is visible. Prev/next + counter live
* OUTSIDE the scaled stage so they don't shrink with it.
* Pattern: 1920×1080 fixed canvas anchored at the shell's top-left,
* centered into the viewport by `fit()` with `transform-origin: top left`
* and an explicit `translate(tx, ty) scale(s)` whose factor is recomputed
* on every resize. The shell is intentionally NOT a grid/flex container
* any extra centering layer would stack with the explicit translate and
* push the scaled stage off-screen (see the OD srcdoc bridge's deck-fix
* placement note in `apps/web/src/runtime/srcdoc.ts:injectDeckBridge`).
* Slides are `<section class="slide">` inside the stage, only
* `.slide.active` is visible. Prev/next + counter live OUTSIDE the scaled
* stage so they don't shrink with it.
*
* Why this pattern (not horizontal scroll-snap):
* - It matches what the model has the strongest prior on, so the framework
@ -24,9 +29,11 @@
* - Print becomes trivial: render every slide as block, page-break between.
*
* Drift fixes baked in:
* - `transform-origin: top left` and the stage is positioned by grid +
* place-items, so scaling never shifts content sideways inside the
* OD viewer's nested transform wrapper.
* - `transform-origin: top left` with an explicit
* `translate(tx, ty) scale(s)`. The shell is plain block flow (no
* grid/flex/place-content), so the stage's natural top-left is (0, 0)
* and the translate centers it correctly even inside the OD viewer's
* nested transform wrapper.
* - Capture-phase keydown on BOTH window and document so iframe focus
* quirks can't swallow arrow keys.
* - Auto-focus body on load and on every click.
@ -80,8 +87,6 @@ export const DECK_SKELETON_HTML = `<!doctype html>
.deck-shell {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.deck-stage {
@ -91,7 +96,6 @@ export const DECK_SKELETON_HTML = `<!doctype html>
position: relative;
transform-origin: top left;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.slide {
position: absolute;
@ -250,11 +254,13 @@ export const DECK_SKELETON_HTML = `<!doctype html>
var idx = 0;
// ---- scale-to-fit ---------------------------------------------------
// The stage is 1920×1080 and positioned by .deck-shell's
// \`display:grid;place-items:center\`. We scale via transform with
// transform-origin:top-left, then re-center by translating to the
// remainder. This survives nested transforms (e.g. when the OD viewer
// wraps the iframe in its own scale wrapper at zoom != 100%).
// The stage is 1920×1080 and sits at .deck-shell's (0, 0) in normal
// block flow — the shell is intentionally NOT a grid/flex container,
// so the stage's natural top-left is (0, 0). We scale via transform
// with transform-origin:top-left, then translate by the remainder to
// center the scaled box in the viewport. This survives nested
// transforms (e.g. when the OD viewer wraps the iframe in its own
// scale wrapper at zoom != 100%).
function fit() {
var sw = window.innerWidth;
var sh = window.innerHeight;

View file

@ -54,6 +54,29 @@
}
]
},
"inputs": [
{
"name": "one_line_pitch",
"label": "Name + one-line pitch",
"type": "text",
"required": true,
"placeholder": "Company name and one sentence explaining what it does"
},
{
"name": "key_traction_numbers",
"label": "Key traction numbers",
"type": "text",
"required": true,
"placeholder": "Revenue, growth, users, pilots, retention, pipeline, or other investor proof"
},
{
"name": "ask_and_use_of_funds",
"label": "Ask + use of funds",
"type": "text",
"required": true,
"placeholder": "Round size and how the capital will be used"
}
],
"context": {
"skills": [
{