Skip to content

Commit da0f088

Browse files
author
bone3deep1962-collab
committed
fix(deck): keep navigation chrome host-owned
1 parent 1db8a5b commit da0f088

20 files changed

Lines changed: 194 additions & 139 deletions

File tree

apps/daemon/src/prompts/discovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ Show something visible early, even if it is a wireframe with grey blocks and lab
285285
Prefer the active design system's palette OR the chosen direction's palette. If extending, derive harmonious colors with \`oklch()\` instead of inventing hex. The background must be selected from the user's product domain, brand assets, screenshots, or chosen direction — never from generic app chrome or a default cozy canvas. For product utilities, marketplaces, dashboards, and SaaS, start from neutral or brand-colored foundations; do not fall back to warm beige / peach / pink / orange-brown Claude-style canvases just because no brand was provided. Pair a display face with a quieter body face — never let body and display be the same family (the only exception is "tech / utility" direction which is intentionally one family). One accent colour, used at most twice per screen.
286286
287287
### G. Slides + prototypes
288-
Slides: follow the active deck directive or skill seed and tag slides with \`data-screen-label="01 Title"\`. Slide numbers are 1-indexed. Do not add a second navigation layer or place navigation inside the slide canvas; group any standalone navigation chrome in one \`data-deck-nav\` container outside the canvas so the host can hide it. Surface hierarchy: unless the user or active design system explicitly requires another surface program, choose one dominant light/dark surface from the brand or direction. Consecutive same-surface slides are valid. Use an inverse surface only for a named narrative role such as a chapter break, key reveal, proof point, or closing; never alternate by slide index or quota.
288+
Slides: follow the active deck directive or skill seed and tag slides with \`data-screen-label="01 Title"\`. Slide numbers are 1-indexed. Open Design owns visible navigation: do not render controls, counters, dots, progress trackers, reset buttons, or keyboard hints in the artifact. Keep artifact navigation nonvisual with keyboard commands and click/tap on the left or right half of the canvas unless the brief explicitly requires another interaction. Surface hierarchy: unless the user or active design system explicitly requires another surface program, choose one dominant light/dark surface from the brand or direction. Consecutive same-surface slides are valid. Use an inverse surface only for a named narrative role such as a chapter break, key reveal, proof point, or closing; never alternate by slide index or quota.
289289
Product prototypes: do **not** include floating Tweaks panels, platform/settings choosers, theme knobs, viewport toggles, or other designer/demo controls in the artifact. If variation controls are useful for internal iteration, keep them out of final product files unless the user explicitly asks for a design-system/spec dashboard.
290290
291291
### H. Cross-platform + multi-device layouts — use platform contracts and shared frames

apps/daemon/src/prompts/official-system.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Object.assign(window, { Terminal, Line, Spacer, Bold });
8383
Avoid \`type="module"\` on script imports — it breaks Babel transpilation.
8484
8585
## Decks (slide presentations)
86-
For decks, follow the active deck directive or skill seed. Do not invent a second deck framework or place navigation inside the slide canvas. If standalone navigation chrome is needed, group it in one \`data-deck-nav\` container outside the slide canvas so Open Design can hide it when host navigation is present.
86+
For decks, follow the active deck directive or skill seed. Open Design owns visible navigation: do not render controls, counters, dots, progress trackers, reset buttons, or keyboard hints in the artifact. Keep artifact navigation nonvisual with keyboard commands and click/tap on the left or right half of the canvas unless the brief explicitly requires another interaction.
8787
8888
Tag each slide with \`data-screen-label="01 Title"\` etc. so the user can reference them. Slide numbers are **1-indexed**.
8989

apps/desktop/src/main/deck-capture.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const SLIDE_MAX_PX = 8192;
9595
// in design-templates/html-ppt/assets/runtime.js, but avoids bare `.notes` and
9696
// `.overview`: those class names are generic enough to be authored content.
9797
export const HIDE_CHROME_SELECTOR =
98-
".progress-bar, .notes-overlay, aside.notes, .speaker-notes, .deck-nav, .deck-hint, .deck-counter";
98+
".progress-bar, .notes-overlay, aside.notes, .speaker-notes, .deck-nav, .data-deck-nav, .deck-hint, .deck-counter, [data-deck-nav], [data-od-id=\"deck-nav\"], [data-slide-nav]";
9999

100100
// The slide-surface family, matching the print/export path in pdf-export.ts
101101
// (`.slide, [data-screen-label], .deck-slide, .ppt-slide`) — decks ship under

apps/desktop/tests/main/scroll-stitch-geometry.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ describe('deck capture DOM prep', () => {
107107
expect(HIDE_CHROME_SELECTOR).toContain('.notes-overlay');
108108
expect(HIDE_CHROME_SELECTOR).toContain('aside.notes');
109109
expect(HIDE_CHROME_SELECTOR).toContain('.speaker-notes');
110+
expect(HIDE_CHROME_SELECTOR).toContain('.data-deck-nav');
111+
expect(HIDE_CHROME_SELECTOR).toContain('[data-deck-nav]');
112+
expect(HIDE_CHROME_SELECTOR).toContain('[data-od-id="deck-nav"]');
113+
expect(HIDE_CHROME_SELECTOR).toContain('[data-slide-nav]');
110114
});
111115

112116
test('off-stage slide fallback offsets the capture clone instead of clearing transforms', () => {

apps/web/src/components/FileViewer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10456,6 +10456,7 @@ function HtmlViewer({
1045610456
filePath: file.name,
1045710457
fallbackHtml: context?.content ?? source ?? '',
1045810458
fallbackTitle: context?.title ?? exportTitle,
10459+
deck: deckExportSignalForContext(context),
1045910460
...(context?.versionId ? { versionId: context.versionId } : {}),
1046010461
}));
1046110462
}

apps/web/src/runtime/deck-thumbnail-parser.ts

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,23 @@ function collectSlideElements(doc: Document): Element[] {
247247
const SCRIPT_CONTENT_MUTATION_RE =
248248
/(?:\.innerHTML\s*=|\.outerHTML\s*=|\.textContent\s*=|\.appendChild\s*\(|\.append\s*\(|\.prepend\s*\(|\.replaceChildren\s*\(|\.insertAdjacentHTML\s*\()/;
249249

250+
function escapeScriptRegex(value: string): string {
251+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
252+
}
253+
254+
function scriptMutatesBinding(source: string, binding: string): boolean {
255+
const access = `\\b${escapeScriptRegex(binding)}\\s*(?:\\?\\.|\\.)\\s*`;
256+
return new RegExp(
257+
`(?:${access}(?:innerHTML|outerHTML|textContent)\\s*=|${access}(?:appendChild|append|prepend|replaceChildren|insertAdjacentHTML)\\s*\\()`,
258+
).test(source);
259+
}
260+
250261
/**
251262
* Static thumbnails cannot reproduce DOM that a deck builds at runtime.
252-
* Detect only high-confidence cases: an executable script both performs a
253-
* content-building mutation and directly looks up an element that belongs to
254-
* a slide. Ordinary deck navigation (querying slides and toggling classes)
255-
* deliberately stays on the cheap static path.
263+
* Detect only high-confidence cases: a script binds a slide-owned element and
264+
* then content-mutates that same binding. Keeping lookup and mutation coupled
265+
* matters: deck navigation often queries every slide while separately updating
266+
* an outside counter, which must stay on the cheap static thumbnail path.
256267
*/
257268
function hasScriptBuiltSlideContent(doc: Document, slides: Element[]): boolean {
258269
const belongsToSlide = (target: Element): boolean =>
@@ -266,25 +277,36 @@ function hasScriptBuiltSlideContent(doc: Document, slides: Element[]): boolean {
266277
const source = script.textContent || '';
267278
if (!SCRIPT_CONTENT_MUTATION_RE.test(source)) continue;
268279

269-
const idLookup = /getElementById\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
270-
let idMatch: RegExpExecArray | null;
271-
while ((idMatch = idLookup.exec(source))) {
272-
const target = doc.getElementById(idMatch[2] || '');
273-
if (target && belongsToSlide(target)) return true;
274-
}
275-
276-
const selectorLookup = /querySelector(?:All)?\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
277-
let selectorMatch: RegExpExecArray | null;
278-
while ((selectorMatch = selectorLookup.exec(source))) {
280+
const lookupBinding =
281+
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*document\.(getElementById|querySelector)\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
282+
let bindingMatch: RegExpExecArray | null;
283+
while ((bindingMatch = lookupBinding.exec(source))) {
284+
const binding = bindingMatch[1] || '';
285+
if (!scriptMutatesBinding(source, binding)) continue;
279286
try {
280-
if (Array.from(doc.querySelectorAll(selectorMatch[2] || '')).some(belongsToSlide)) {
281-
return true;
282-
}
287+
const target = bindingMatch[2] === 'getElementById'
288+
? doc.getElementById(bindingMatch[4] || '')
289+
: doc.querySelector(bindingMatch[4] || '');
290+
if (target && belongsToSlide(target)) return true;
283291
} catch {
284292
// Invalid or dynamically escaped selectors are not high-confidence
285293
// evidence; the iframe fallback remains available for known media.
286294
}
287295
}
296+
297+
const directLookupMutation =
298+
/document\.(getElementById|querySelector)\s*\(\s*(['"])([^'"]+)\2\s*\)\s*(?:\?\.|\.)\s*(?:(?:innerHTML|outerHTML|textContent)\s*=|(?:appendChild|append|prepend|replaceChildren|insertAdjacentHTML)\s*\()/g;
299+
let directMatch: RegExpExecArray | null;
300+
while ((directMatch = directLookupMutation.exec(source))) {
301+
try {
302+
const target = directMatch[1] === 'getElementById'
303+
? doc.getElementById(directMatch[3] || '')
304+
: doc.querySelector(directMatch[3] || '');
305+
if (target && belongsToSlide(target)) return true;
306+
} catch {
307+
// Same high-confidence rule as bound lookups above.
308+
}
309+
}
288310
}
289311
return false;
290312
}

apps/web/src/runtime/exports.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// file content is the same source the Source view shows. See
1111
// issue #279.
1212

13-
import { buildSrcdoc, type SrcdocOptions } from './srcdoc';
13+
import { buildSrcdoc, DECK_CHROME_HIDE_CSS, type SrcdocOptions } from './srcdoc';
1414
import { buildReactComponentSrcdoc } from './react-component';
1515
import { buildZip } from './zip';
1616
import { randomUUID } from '../utils/uuid';
@@ -69,8 +69,22 @@ function triggerDownload(blob: Blob, filename: string): void {
6969
setTimeout(() => URL.revokeObjectURL(url), 60_000);
7070
}
7171

72-
export function exportAsHtml(html: string, title: string): void {
73-
const doc = buildSrcdoc(html);
72+
function injectExportDeckChromeHiding(doc: string): string {
73+
const tag = `<style data-od-export-deck-chrome-hidden>
74+
${DECK_CHROME_HIDE_CSS}
75+
</style>`;
76+
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${tag}</head>`);
77+
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
78+
return tag + doc;
79+
}
80+
81+
export function exportAsHtml(
82+
html: string,
83+
title: string,
84+
options: { deck?: boolean } = {},
85+
): void {
86+
const srcdoc = buildSrcdoc(html);
87+
const doc = options.deck ? injectExportDeckChromeHiding(srcdoc) : srcdoc;
7488
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
7589
triggerDownload(blob, `${safeFilename(title, 'artifact')}.html`);
7690
}
@@ -81,6 +95,7 @@ export async function exportProjectAsHtml(opts: {
8195
fallbackHtml: string;
8296
fallbackTitle: string;
8397
versionId?: string;
98+
deck?: boolean;
8499
}): Promise<void> {
85100
const segments = opts.filePath
86101
.split('/')
@@ -93,11 +108,16 @@ export async function exportProjectAsHtml(opts: {
93108
try {
94109
const resp = await fetch(url);
95110
if (!resp.ok) throw new Error(`html export request failed (${resp.status})`);
96-
const blob = await resp.blob();
111+
const blob = opts.deck
112+
? new Blob(
113+
[injectExportDeckChromeHiding(await resp.text())],
114+
{ type: 'text/html;charset=utf-8' },
115+
)
116+
: await resp.blob();
97117
triggerDownload(blob, `${safeFilename(opts.fallbackTitle, 'artifact')}.html`);
98118
} catch (err) {
99119
console.warn('[exportProjectAsHtml] falling back to source HTML export:', err);
100-
exportAsHtml(opts.fallbackHtml, opts.fallbackTitle);
120+
exportAsHtml(opts.fallbackHtml, opts.fallbackTitle, { deck: opts.deck });
101121
}
102122
}
103123

@@ -1520,7 +1540,8 @@ const DECK_PRINT_CSS = `
15201540
transition: none !important;
15211541
}
15221542
.slide:last-child, [data-screen-label]:last-child { page-break-after: auto; break-after: auto; }
1523-
.deck-counter, .deck-hint, .deck-nav,
1543+
.deck-counter, .deck-hint, .deck-nav, .data-deck-nav,
1544+
[data-deck-nav], [data-od-id="deck-nav"], [data-slide-nav],
15241545
[aria-label="Previous slide"], [aria-label="Next slide"] {
15251546
display: none !important;
15261547
}
@@ -1678,7 +1699,10 @@ async function captureArtifactSlides(
16781699
iframe.setAttribute('aria-hidden', 'true');
16791700
iframe.setAttribute('tabindex', '-1');
16801701
iframe.style.cssText = `position:fixed;left:-100000px;top:0;width:${width}px;height:${height}px;border:0;background:#fff;`;
1681-
iframe.srcdoc = buildSrcdoc(html, { deck: opts.deck });
1702+
iframe.srcdoc = buildSrcdoc(html, {
1703+
deck: opts.deck,
1704+
hideDeckChrome: opts.deck,
1705+
});
16821706
document.body.appendChild(iframe);
16831707

16841708
const slides: CapturedSlide[] = [];

apps/web/src/runtime/srcdoc.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2421,13 +2421,14 @@ export const DECK_MOTION_FREEZE_CSS = `*, *::before, *::after {
24212421
scroll-behavior: auto !important;
24222422
}`;
24232423

2424-
// `data-od-id="deck-nav"` is the backward-compatible marker emitted by
2425-
// freeform decks before the prompt standardized `data-deck-nav`. Keep both:
2424+
// `data-od-id="deck-nav"` and `.data-deck-nav` are backward-compatible
2425+
// markers emitted by freeform decks. Keep them alongside `data-deck-nav`:
24262426
// localized aria-labels cannot be enumerated safely, while a generic `.nav`
24272427
// selector would hide legitimate in-slide content.
24282428
export const DECK_CHROME_HIDE_CSS = `.deck-counter,
24292429
.deck-hint,
24302430
.deck-nav,
2431+
.data-deck-nav,
24312432
.deck-floating-nav,
24322433
.deck-floating-reset,
24332434
.deck-controls,

apps/web/tests/runtime/deck-thumbnail-parser.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,24 @@ describe('parseDeckThumbnails', () => {
324324
expect(parsed.slides).toHaveLength(2);
325325
});
326326

327+
it('keeps navigation counters separate from queried slide content', () => {
328+
const deck = frameworkDeck(2).replace(
329+
'<script>/* nav */</script>',
330+
`<script>
331+
const slides = document.querySelectorAll('.slide');
332+
const counter = document.querySelector('.data-deck-nav');
333+
function paint(index) {
334+
slides.forEach((slide, i) => slide.classList.toggle('active', i === index));
335+
counter.textContent = (index + 1) + ' / ' + slides.length;
336+
}
337+
paint(0);
338+
</script>`,
339+
);
340+
const parsed = parseDeckThumbnails(deck);
341+
expect(parsed.renderable).toBe(true);
342+
expect(parsed.slides).toHaveLength(2);
343+
});
344+
327345
it('strips executable content from untrusted slide markup', () => {
328346
const deck = [
329347
'<!doctype html><html><head><style>',

apps/web/tests/runtime/exports.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,30 @@ describe('exportProjectAsHtml', () => {
599599
expect(await capturedBlob!.text()).toBe('<!doctype html><p>version</p>');
600600
});
601601

602+
it('hides authored navigation chrome in standalone deck HTML', async () => {
603+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
604+
'<!doctype html><html><body><nav class="data-deck-nav">1 / 12</nav><section class="slide">A</section></body></html>',
605+
{
606+
headers: { 'content-type': 'text/html' },
607+
status: 200,
608+
},
609+
)));
610+
611+
await exportProjectAsHtml({
612+
projectId: 'proj-1',
613+
filePath: 'deck.html',
614+
fallbackHtml: '<section class="slide">fallback</section>',
615+
fallbackTitle: 'Deck',
616+
deck: true,
617+
});
618+
619+
const exported = await capturedBlob!.text();
620+
expect(exported).toContain('data-od-export-deck-chrome-hidden');
621+
expect(exported).toContain('.data-deck-nav,');
622+
expect(exported).toContain('[data-deck-nav],');
623+
expect(exported).toContain('display: none !important');
624+
});
625+
602626
it('falls back to the source HTML export when the daemon inline endpoint fails', async () => {
603627
vi.spyOn(console, 'warn').mockImplementation(() => {});
604628
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500 })));
@@ -608,10 +632,12 @@ describe('exportProjectAsHtml', () => {
608632
filePath: 'index.html',
609633
fallbackHtml: '<main>fallback</main>',
610634
fallbackTitle: 'Fallback',
635+
deck: true,
611636
});
612637

613638
expect(capturedFilename).toBe('Fallback.html');
614639
expect(await capturedBlob!.text()).toContain('<main>fallback</main>');
640+
expect(await capturedBlob!.text()).toContain('data-od-export-deck-chrome-hidden');
615641
});
616642
});
617643

0 commit comments

Comments
 (0)