mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
65e760b88a
commit
c80acfefeb
12 changed files with 538 additions and 33 deletions
89
apps/daemon/src/artifact-publication-guard.ts
Normal file
89
apps/daemon/src/artifact-publication-guard.ts
Normal 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 '';
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
: '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
|
|
|
|||
186
apps/daemon/tests/artifact-publication-guard.test.ts
Normal file
186
apps/daemon/tests/artifact-publication-guard.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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('');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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(){
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue