From 24a5b4e130360b84b062a0bd9dd143984f8d0862 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:56:29 +0900 Subject: [PATCH 01/21] feat(html): enable interactive export runtime --- src/main/html-export-css-sanitize.ts | 50 +++++++++++------------ src/main/html-export-pipeline-service.ts | 5 ++- src/main/html-export-runtime.ts | 19 +++++++++ src/main/html-export-sanitize.ts | 11 ++--- src/main/html-export-shell.ts | 13 ++---- src/renderer/html-export-direct-prompt.ts | 9 ++-- 6 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 src/main/html-export-runtime.ts diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 6bccb71..3a62c68 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -7,13 +7,13 @@ export const CSS_MAX_DECLARATIONS_PER_RULE = 60; export const CSS_MAX_SELECTORS_PER_RULE = 20; export const CSS_MAX_COMPOUND_DEPTH = 8; export const CSS_MAX_NESTING_DEPTH = 4; -export const CSS_MAX_KEYFRAMES = 40; -export const CSS_MAX_FRAMES_PER_KEYFRAMES = 60; -export const CSS_MAX_ANIMATIONS_PER_ELEMENT = 8; -export const CSS_MIN_ANIMATION_DURATION_MS = 50; -export const CSS_MIN_Z_INDEX = 0; -export const CSS_MAX_Z_INDEX = 9_999; -export const CSS_MAX_FONT_SIZE_PX = 400; +export const CSS_MAX_KEYFRAMES = 200; +export const CSS_MAX_FRAMES_PER_KEYFRAMES = 200; +export const CSS_MAX_ANIMATIONS_PER_ELEMENT = 32; +export const CSS_MIN_ANIMATION_DURATION_MS = 0; +export const CSS_MIN_Z_INDEX = -2_147_483_648; +export const CSS_MAX_Z_INDEX = 2_147_483_647; +export const CSS_MAX_FONT_SIZE_PX = 10_000; export const CSS_MAX_VALUE_TOKEN_LENGTH = 512; export const CSS_MAX_DECLARATIONS = 20_000; @@ -49,13 +49,13 @@ const CSS_ALLOWED_PROPERTIES = [ ] as const; const CSS_ALLOWED_FUNCTIONS = [ - 'rgb', 'rgba', 'hsl', 'hsla', 'calc', 'min', 'max', 'clamp', 'linear-gradient', - 'radial-gradient', 'conic-gradient', 'repeating-linear-gradient', 'repeating-radial-gradient', - 'repeating-conic-gradient', 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d', - 'scale', 'scaleX', 'scaleY', 'scaleZ', 'scale3d', 'rotate', 'rotateX', 'rotateY', 'rotateZ', - 'rotate3d', 'skew', 'skewX', 'skewY', 'matrix', 'matrix3d', 'perspective', 'cubic-bezier', - 'steps', 'blur', 'brightness', 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', - 'opacity', 'saturate', 'sepia', + 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'color-mix', + 'calc', 'min', 'max', 'clamp', 'var', 'linear-gradient', 'radial-gradient', 'conic-gradient', + 'repeating-linear-gradient', 'repeating-radial-gradient', 'repeating-conic-gradient', + 'translate', 'translateX', 'translateY', 'translateZ', 'translate3d', 'scale', 'scaleX', 'scaleY', + 'scaleZ', 'scale3d', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'rotate3d', 'skew', 'skewX', + 'skewY', 'matrix', 'matrix3d', 'perspective', 'cubic-bezier', 'steps', 'blur', 'brightness', + 'contrast', 'drop-shadow', 'grayscale', 'hue-rotate', 'invert', 'opacity', 'saturate', 'sepia', ] as const; const CSS_ALLOWED_AT_RULES = ['media', 'supports', 'keyframes'] as const; @@ -134,10 +134,10 @@ const TYPE_SELECTORS = new Set([ 'p', 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'sub', 'sup', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', 'img', 'picture', 'source', 'svg', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'code', - 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', + 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', 'form', 'input', 'button', ]); -const PSEUDO_CLASSES = new Set(['hover', 'focus', 'focus-visible', 'first-child', 'last-child', 'nth-child', 'not', 'is', 'where']); -const PSEUDO_ELEMENTS = new Set(['before', 'after', 'marker', 'first-line', 'first-letter', 'selection']); +const PSEUDO_CLASSES = new Set(['active', 'any-link', 'checked', 'default', 'defined', 'disabled', 'empty', 'enabled', 'first-child', 'first-of-type', 'focus', 'focus-visible', 'focus-within', 'has', 'hover', 'in-range', 'indeterminate', 'invalid', 'is', 'last-child', 'last-of-type', 'not', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown', 'read-only', 'read-write', 'required', 'root', 'target', 'user-invalid', 'valid', 'visited', 'where']); +const PSEUDO_ELEMENTS = new Set(['after', 'backdrop', 'before', 'first-letter', 'first-line', 'marker', 'placeholder', 'selection']); const SAFE_ATTRIBUTE_SELECTOR_NAMES = new Set([ 'class', 'id', 'title', 'lang', 'dir', 'role', 'data-section-id', 'colspan', 'rowspan', 'scope', 'alt', 'width', 'height', 'datetime', @@ -146,7 +146,7 @@ const MEDIA_FEATURES = new Set(['width', 'min-width', 'max-width', 'height', 'or const FONT_SIZE_KEYWORDS = new Set(['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large']); const RESERVED_NAME = /^(?:data-he-|he-s|he-(?:doc|slide|scaler|runtime|manifest|shell|csp)|(?:data-)?(?:shell|runtime|manifest|csp))/i; const NETWORK_FUNCTION = new Set(['url', 'image', 'image-set', '-webkit-image-set', 'cross-fade', 'element', 'expression']); -const VALUE_INDIRECTION_FUNCTION = new Set(['attr', 'env', 'paint', 'var']); +const VALUE_INDIRECTION_FUNCTION = new Set(['attr', 'env', 'paint']); const ANIMATION_KEYWORDS = new Set([ 'none', 'linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'step-start', 'step-end', 'infinite', 'normal', 'reverse', 'alternate', 'alternate-reverse', 'forwards', 'backwards', @@ -462,7 +462,7 @@ function validateSelectorAtom(node: any): Failure | null { if ((name === 'class' && hasReservedName(selectedValue)) || (name === 'id' && (hasReservedName(selectedValue) || canonicalizeIdent(selectedValue).startsWith('he-')))) { return fail(CSS_VIOLATION_CODES.reservedSelector, `reserved attribute selector ${name}`); } - if (!SAFE_ATTRIBUTE_SELECTOR_NAMES.has(name) && !name.startsWith('aria-')) { + if (!SAFE_ATTRIBUTE_SELECTOR_NAMES.has(name) && !name.startsWith('aria-') && !name.startsWith('data-')) { return fail(CSS_VIOLATION_CODES.disallowedSelector, `attribute selector ${name}`); } } else if (type === 'PseudoClassSelector') { @@ -537,10 +537,10 @@ function rawFunctionFailure(raw: string): Failure | null { const match = /([a-z-]+)\s*\(/i.exec(raw); if (!match) return null; const name = match[1].toLowerCase(); - if (name === 'var') return fail(CSS_VIOLATION_CODES.customProperty, 'var()'); if (NETWORK_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.networkFunction, `${name}()`); if (VALUE_INDIRECTION_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.valueIndirection, `${name}()`); - return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); + if (!FUNCTION_SET.has(name)) return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); + return null; } function validateValue(value: any): Failure | null { @@ -552,7 +552,6 @@ function validateValue(value: any): Failure | null { if (node?.type === 'Url') return fail(CSS_VIOLATION_CODES.networkFunction, 'url()'); if (node?.type === 'Function') { const name = String(node.name).toLowerCase(); - if (name === 'var') return fail(CSS_VIOLATION_CODES.customProperty, 'var()'); if (NETWORK_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.networkFunction, `${name}()`); if (VALUE_INDIRECTION_FUNCTION.has(name)) return fail(CSS_VIOLATION_CODES.valueIndirection, `${name}()`); if (!FUNCTION_SET.has(name)) return fail(CSS_VIOLATION_CODES.disallowedFunction, `${name}()`); @@ -682,7 +681,7 @@ function validateContent(value: any): Failure | null { function validateNumericCaps(property: string, value: any, context: CssSanitizeContext): Failure | null { if (property === 'position') { const position = generated(value).toLowerCase(); - if (position === 'fixed' || position === 'sticky') return fail(CSS_VIOLATION_CODES.unsafePosition, `position:${position} is not allowed`); + if (!['static', 'relative', 'absolute', 'fixed', 'sticky'].includes(position)) return fail(CSS_VIOLATION_CODES.unsafePosition, `position:${position} is not allowed`); } if (property === 'z-index') { const tokens = children(value); @@ -716,13 +715,12 @@ function sanitizeDeclarations(block: any, context: CssSanitizeContext, counts: C context.seenDeclarations++; if (context.seenDeclarations > CSS_MAX_DECLARATIONS) { strip(context, fail(CSS_VIOLATION_CODES.tooManyDeclarations, `more than ${CSS_MAX_DECLARATIONS} declarations`)); break; } const property = String(declaration.property).toLowerCase(); - let failure: Failure | null = declaration.important ? fail(CSS_VIOLATION_CODES.important, `!important on ${property}`) : null; - if (!failure && property.startsWith('--')) failure = fail(CSS_VIOLATION_CODES.customProperty, `custom property ${property}`); + let failure: Failure | null = null; if (!failure) failure = validateValue(declaration.value); if (!failure && PROPERTY_SET.has(property)) failure = validateNumericCaps(property, declaration.value, context); if (failure) { strip(context, failure); continue; } const value = generated(declaration.value); - if (!PROPERTY_SET.has(property) || lexer.matchProperty(property, value).error) continue; + if ((!PROPERTY_SET.has(property) && !property.startsWith('--')) || (!property.startsWith('--') && lexer.matchProperty(property, value).error)) continue; output.push(`${property}:${value}`); counts.declarationCount++; } diff --git a/src/main/html-export-pipeline-service.ts b/src/main/html-export-pipeline-service.ts index 4f576b8..f858e45 100644 --- a/src/main/html-export-pipeline-service.ts +++ b/src/main/html-export-pipeline-service.ts @@ -20,6 +20,7 @@ import { HtmlExportAttemptRegistry } from './html-export-attempt-registry'; import { HtmlExportParseHost, type HtmlExportParseValue } from './html-export-parse-host'; import { findHtmlExportDocumentMarkers } from './html-export-document-markers'; import { HTML_SANITIZER_LIMITS, sanitizeHtmlExport } from './html-export-sanitize'; +import { injectHtmlExportRuntime, type HtmlExportRuntimeMode } from './html-export-runtime'; export const HTML_EXPORT_RAW_MODEL_OUTPUT_MAX_BYTES = HTML_EXPORT_RAW_ARTIFACT_MAX_BYTES; export const HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES = HTML_EXPORT_STAGE_ARTIFACT_MAX_BYTES; @@ -459,6 +460,7 @@ export class HtmlExportPipelineService { webContentsId: number, attemptId: HtmlExportAttemptId, resolvedArtifactId: ResolvedArtifactId, + mode: HtmlExportRuntimeMode = 'scroll', ): HtmlExportPipelineResult<{ artifact: HtmlExportArtifactRef<'finalized'> }> { const resolved = this.registry.read(webContentsId, attemptId, resolvedArtifactId, 'resolved'); if (!resolved.ok) return resolved; @@ -469,8 +471,9 @@ export class HtmlExportPipelineService { return oversize(`Resolved payload exceeds ${HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES} bytes`); } + const finalizedHtml = injectHtmlExportRuntime(new TextDecoder('utf-8').decode(resolved.value.bytes), mode); const verified = this.verifyCandidate( - Buffer.from(resolved.value.bytes), + Buffer.from(finalizedHtml, 'utf8'), HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES, ); if (!verified.ok) return verified; diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts new file mode 100644 index 0000000..1029e31 --- /dev/null +++ b/src/main/html-export-runtime.ts @@ -0,0 +1,19 @@ +export type HtmlExportRuntimeMode = 'scroll' | 'slide'; + +export const HTML_EXPORT_INTERACTIVE_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'"; +export const HTML_EXPORT_INTERACTIVE_CSP_META = ``; + +function runtimeSource(mode: HtmlExportRuntimeMode): string { + const slide = mode === 'slide' ? 'true' : 'false'; + return `(function(){if(document.getElementById('nai-runtime'))return;var root=document.documentElement,button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime';button.type='button';button.className='nai-theme-toggle';button.style.cssText='position:fixed;top:12px;right:12px;z-index:2147483647;border:0;border-radius:999px;padding:8px 10px;cursor:pointer;background:#111;color:#fff';function setTheme(theme){root.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';button.setAttribute('aria-label',theme==='dark'?'Switch to light theme':'Switch to dark theme')}var saved;try{saved=localStorage.getItem('nai-theme')}catch(_e){}setTheme(saved==='light'||saved==='dark'?saved:(matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'));button.addEventListener('click',function(){setTheme(root.dataset.theme==='dark'?'light':'dark')});document.body.appendChild(button);var authored=Array.prototype.some.call(document.querySelectorAll('style'),function(s){return /\\[data-theme[^}]*--/.test(s.textContent||'')});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-theme="dark"] img,[data-theme="dark"] video{filter:invert(1) hue-rotate(180deg)}';document.head.appendChild(style)}if(${slide}){var slides=Array.prototype.slice.call(document.querySelectorAll('section.slide'));if(!slides.length){slides=Array.prototype.slice.call(document.body.children).filter(function(node){return node.tagName==='SECTION'})}if(!slides.length)return;var index=0,controls=document.createElement('div'),previous=document.createElement('button'),next=document.createElement('button'),indicator=document.createElement('span');controls.className='nai-slide-nav';controls.style.cssText='position:fixed;bottom:12px;right:12px;z-index:2147483647;display:flex;gap:8px;align-items:center;background:#111;color:#fff;padding:8px;border-radius:999px';previous.type=next.type='button';previous.textContent='‹';next.textContent='›';function show(n){index=(n+slides.length)%slides.length;slides.forEach(function(s,i){s.style.display=i===index?'':'none';s.style.minHeight='100vh'});indicator.textContent=(index+1)+'/'+slides.length;slides[index].scrollIntoView({block:'start'})}previous.addEventListener('click',function(){show(index-1)});next.addEventListener('click',function(){show(index+1)});controls.append(previous,indicator,next);document.body.appendChild(controls);document.addEventListener('keydown',function(event){var target=event.target;if(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement||target&&target.isContentEditable)return;if(['ArrowRight','PageDown',' '].includes(event.key)){event.preventDefault();show(index+1)}else if(['ArrowLeft','PageUp'].includes(event.key)){event.preventDefault();show(index-1)}});show(0)}})();`; +} + +/** Adds the app-owned runtime after sanitization. Repeated finalization is idempotent. */ +export function injectHtmlExportRuntime(html: string, mode: HtmlExportRuntimeMode = 'scroll'): string { + let output = html.replace(/]*>\s*/gi, ''); + const head = `${HTML_EXPORT_INTERACTIVE_CSP_META}`; + output = /<\/head\s*>/i.test(output) ? output.replace(/<\/head\s*>/i, `${head}`) : `${head}${output}`; + if (/id=["']nai-runtime["']/i.test(output)) return output; + const script = ``; + return /<\/body\s*>/i.test(output) ? output.replace(/<\/body\s*>/i, `${script}`) : `${output}${script}`; +} diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index 932bc61..0d20360 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -116,11 +116,10 @@ const ALLOWED_TAGS = new Set([ 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'sub', 'sup', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', 'img', 'picture', 'source', 'svg', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'code', 'pre', 'kbd', 'samp', - 'abbr', 'time', 'a', + 'abbr', 'time', 'a', 'script', 'form', 'input', 'button', ]); const ACTIVE_TAGS = new Set([ - 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'script', 'link', 'template', - 'slot', 'form', 'input', 'button', + 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'link', 'template', 'slot', ]); const SVG_FALLBACK_TEXT_TAGS = new Set(['text', 'tspan', 'title', 'desc']); const SVG_FALLBACK_SKIPPED_TAGS = new Set(['style', 'script', 'foreignobject']); @@ -361,12 +360,15 @@ function isAriaAttribute(name: string): boolean { } function isAllowedAttribute(tag: string, name: string): boolean { - if (GLOBAL_ATTRIBUTES.has(name) || isAriaAttribute(name) || name === 'data-section-id') return true; + if (GLOBAL_ATTRIBUTES.has(name) || isAriaAttribute(name) || name === 'data-section-id' || name.startsWith('data-')) return true; + if (name.startsWith('on')) return true; if (TABLE_ATTRIBUTES.has(name)) return ['th', 'td'].includes(tag); if (name === 'datetime') return tag === 'time'; if (IMAGE_ATTRIBUTES.has(name)) return ['img', 'source'].includes(tag); if (name === 'href') return tag === 'a'; if (name === 'src') return ['img', 'source'].includes(tag); + if (name === 'type') return ['input', 'button', 'script'].includes(tag); + if (name === 'value' || name === 'name' || name === 'placeholder' || name === 'checked' || name === 'disabled') return ['input', 'button'].includes(tag); return false; } @@ -385,7 +387,6 @@ function rejectDangerousAttribute( isAllowedAssetId: (src: string) => boolean, ): Failure | null { const name = attribute.name.toLowerCase(); - if (name.startsWith('on')) return fail(HTML_VIOLATION_CODES.eventHandler, `event handler attribute ${name}`); if (hasReservedNamespace(attribute)) return fail(HTML_VIOLATION_CODES.reservedNamespace, `reserved attribute ${attribute.name}`); if (['srcset', 'poster', 'formaction', 'background', 'ping', 'action'].includes(name)) { return fail(HTML_VIOLATION_CODES.url, `URL attribute ${name}`); diff --git a/src/main/html-export-shell.ts b/src/main/html-export-shell.ts index cda3c1f..8cb444a 100644 --- a/src/main/html-export-shell.ts +++ b/src/main/html-export-shell.ts @@ -11,11 +11,8 @@ */ import type { HtmlExportSanitizedPayload } from './html-export-pipeline-service'; -import { - HTML_EXPORT_CSP_META, - HTML_EXPORT_RUNTIME_JS, - HTML_EXPORT_RUNTIME_JS_SHA256, -} from '../shared/html-export-runtime'; +import { HTML_EXPORT_RUNTIME_JS_SHA256 } from '../shared/html-export-runtime'; +import { injectHtmlExportRuntime } from './html-export-runtime'; /** Bump when the embedded shell manifest shape changes. */ const HTML_EXPORT_SHELL_MANIFEST_SCHEMA_VERSION = 1; @@ -105,7 +102,6 @@ export function bundleSanitizedHtml( const head = [ '', - HTML_EXPORT_CSP_META, '', ``, ``, @@ -122,8 +118,7 @@ export function bundleSanitizedHtml( // matches and the export renders unstyled. See #29 review (P1). // Safe class/id from source / are transferred so rewritten // selectors like `[data-he-content].dark` still match (Codex P2). - `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n\n` + + `\n${contentRootOpenTag(payload)}\n${payload.bodyHtml}\n\n\n` + '\n'; - - return { html, manifest }; + return { html: injectHtmlExportRuntime(html), manifest }; } diff --git a/src/renderer/html-export-direct-prompt.ts b/src/renderer/html-export-direct-prompt.ts index fc1a0b7..28b7fe2 100644 --- a/src/renderer/html-export-direct-prompt.ts +++ b/src/renderer/html-export-direct-prompt.ts @@ -90,6 +90,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { `- ${modeLine(config.mode)}`, `- ${densityLine(config.density)}`, ]; + if (config.mode === 'slide') lines.push('- slide markup contract: every slide MUST be exactly
, and each slide MUST occupy one viewport.'); if (config.customPurpose) { lines.push(`- custom purpose brief (weight heavily): ${config.customPurpose}`); @@ -111,7 +112,7 @@ function configDirectiveLines(config: DirectExportConfig): string[] { if (typeof config.interactive === 'boolean') { lines.push( config.interactive - ? '- interactivity: allow tasteful CSS-only interactions (no JavaScript)' + ? '- interactivity: inline JavaScript runs in the final document. Author frontier-quality, self-contained interactions appropriate to the content (tabs, accordions, hover states, animated reveals, inline-SVG chart interactions, and counters). Network APIs are unavailable under CSP; keep everything inline.' : '- interactivity: static document only (no interactive affordances)', ); } @@ -164,9 +165,9 @@ const HTML_EXPORT_DIRECT_DESIGN_KNOWLEDGE = [ '1. Classify the screen by reader task (narrative/marketing, report/dashboard, article/reference, instruction, or command) and turn the source into jobs — introduce, explain, substantiate, compare, decide, orient, retain — sequenced for that job, not for fashion.', '2. Preserve the source reading order and distinguish titles, prose, lists, tables, quotations, code, and data with real semantic HTML; keep evidence adjacent to its claim.', '3. Name the layout problem (flow, repetition, comparison, or primary/supporting context) and choose HTML structure + CSS that solves it: restrained flow for explanation, parallel items for repeated facts, tables for comparison.', - '4. Author complete, self-contained HTML with inline CSS — no scripts, no external fonts/assets. IMAGES: an src may ONLY be an app-issued opaque asset ID (src="asset:…") explicitly listed in this prompt; NEVER emit data: URIs, remote URLs, or invented images. When no asset ID is provided, author without and express any decoration in CSS. Every visual choice — layout, spacing, color, type scale — is yours to encode in CSS, honoring the design authority above.', - '5. Use only LITERAL CSS values. CSS custom properties (`--name`) and `var()` are NOT supported and will be rejected — write concrete values inline. Global element selectors (html/body/:root/*) are allowed (scoped to the export content root) but prefer authoring styles against document content. CSS font-size and the size token of the font shorthand must use 0, Npx, or absolute keywords only (xx-small through xxx-large); never rem, em, or % because relative font sizes are rejected by the sanitizer.', - '6. Use ONLY the supported HTML tag vocabulary (structural: section, article, main, aside, nav, header, footer, div, h1–h6, p, ul/ol/li, dl/dt/dd, figure/figcaption, blockquote, table/thead/tbody/tfoot/tr/th/td/caption, img/picture/source, svg; inline: span, strong/em/b/i/u/s, small, mark, sub/sup, code/pre/kbd/samp, abbr, time, a, br, hr). Attach classes, ids, and inline styles ONLY to these tags — unsupported tags are unwrapped and their attributes dropped, which orphans any CSS that targets them.', + '4. Author complete, self-contained HTML with inline CSS. Inline JavaScript is allowed only when interactivity is enabled; never use remote scripts, network APIs, external fonts, or external assets. IMAGES: an src may ONLY be an app-issued opaque asset ID (src="asset:…") explicitly listed in this prompt; NEVER emit data: URIs, remote URLs, or invented images. When no asset ID is provided, author without and express any decoration in CSS. Every visual choice — layout, spacing, color, type scale — is yours to encode in CSS, honoring the design authority above.', + '5. Define BOTH palettes with CSS custom properties under [data-theme="light"] and [data-theme="dark"]; route every authored color through var(). The app injects a theme toggle that switches data-theme. Global element selectors (html/body/:root/*) are allowed (scoped to the export content root) but prefer authoring styles against document content.', + '6. Use ONLY the supported HTML tag vocabulary (structural: section, article, main, aside, nav, header, footer, div, h1–h6, p, ul/ol/li, dl/dt/dd, figure/figcaption, blockquote, table/thead/tbody/tfoot/tr/th/td/caption, img/picture/source, svg, form/input/button; inline: span, strong/em/b/i/u/s, small, mark, sub/sup, code/pre/kbd/samp, abbr, time, a, br, hr, script). Attach classes, ids, data attributes, inline styles, and event attributes only to these tags — unsupported tags are unwrapped and their attributes dropped, which orphans any CSS that targets them.', '7. Links: use only for non-empty same-document fragments (#id); render external/source URLs as plain text.', ].join('\n'); From 5386a12c236d227ee503df3091f73c0e6dc360b0 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:56:29 +0900 Subject: [PATCH 02/21] fix(html): plumb interactive export mode --- .../html-export-css-sanitize.test.ts | 17 ++--- src/__tests__/html-export-finalize.test.ts | 37 ++++++---- src/__tests__/html-export-runtime.dom.test.ts | 74 +++++++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 48 +++++------- src/__tests__/html-export-shell.test.ts | 40 ++-------- src/main/html-export-css-sanitize.ts | 2 +- src/main/html-export-generate.ts | 3 +- .../html-export-generation-orchestrator.ts | 7 +- src/main/html-export-runtime.ts | 6 +- src/main/ipc/html-export-ipc.ts | 7 +- src/main/preload.ts | 1 + .../__tests__/html-export-wizard.dom.test.ts | 14 +++- src/renderer/api-types.ts | 1 + src/renderer/html-export-wizard.ts | 2 + src/shared/html-export-runtime.ts | 2 +- 15 files changed, 167 insertions(+), 94 deletions(-) create mode 100644 src/__tests__/html-export-runtime.dom.test.ts diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index b480c3a..6f514e5 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -88,8 +88,8 @@ describe('html export CSS sanitizer', () => { declarationCount: 1, }); expect(failureCode(sanitizeDeclarationList('background:url(https://example.test/a.png)'))).toBe('css_network_function_not_allowed'); - expect(failureCode(sanitizeDeclarationList('color:var(--accent)'))).toBe('css_custom_property_not_allowed'); - expect(failureCode(sanitizeDeclarationList('color:red!important'))).toBe('css_important_not_allowed'); + expect(sanitizeDeclarationList('color:var(--accent)').ok).toBe(true); + expect(sanitizeDeclarationList('color:red!important').ok).toBe(true); }); it('enforces the frozen selector, pseudo, and at-rule grammar', () => { @@ -97,8 +97,8 @@ describe('html export CSS sanitizer', () => { expect(failureCode(sanitizeStylesheet('style{color:red}'))).toBe('css_reserved_selector'); expect(failureCode(sanitizeStylesheet('[data-he-layout]{color:red}'))).toBe('css_reserved_selector'); expect(failureCode(sanitizeStylesheet('.he-scaler{color:red}'))).toBe('css_reserved_selector'); - expect(failureCode(sanitizeStylesheet('p:active{color:red}'))).toBe('css_disallowed_selector'); - expect(failureCode(sanitizeStylesheet('p::placeholder{color:red}'))).toBe('css_disallowed_selector'); + expect(sanitizeStylesheet('p:active{color:red}').ok).toBe(true); + expect(sanitizeStylesheet('p::placeholder{color:red}').ok).toBe(true); expect(failureCode(sanitizeStylesheet('@layer model{p{color:red}}'))).toBe('css_disallowed_at_rule'); expect(failureCode(sanitizeStylesheet('@-webkit-keyframes fade{from{opacity:0}}'))).toBe('css_disallowed_at_rule'); expect(failureCode(sanitizeStylesheet('@media (color){p{color:red}}'))).toBe('css_disallowed_at_rule'); @@ -296,8 +296,8 @@ describe('html export CSS sanitizer', () => { expect(failureCode(sanitizeDeclarationList(`font:${CSS_MAX_FONT_SIZE_PX + 1}px serif`))).toBe('css_font_size_too_large'); expect(failureCode(sanitizeDeclarationList('font:1em serif'))).toBe('css_font_size_not_allowed'); expect(failureCode(sanitizeDeclarationList('font:inherit'))).toBe('css_font_size_not_allowed'); - expect(failureCode(sanitizeDeclarationList('position:fixed'))).toBe('css_unsafe_position'); - expect(failureCode(sanitizeDeclarationList('position:sticky'))).toBe('css_unsafe_position'); + expect(sanitizeDeclarationList('position:fixed').ok).toBe(true); + expect(sanitizeDeclarationList('position:sticky').ok).toBe(true); expect(sanitizeDeclarationList(`font-family:${'a'.repeat(CSS_MAX_VALUE_TOKEN_LENGTH)}`).ok).toBe(true); expect(failureCode(sanitizeDeclarationList(`font-family:${'a'.repeat(CSS_MAX_VALUE_TOKEN_LENGTH + 1)}`))).toBe('css_value_token_too_long'); }); @@ -335,10 +335,9 @@ describe('global selector rewrite', () => { expect(result.css).toBe('[data-he-content]{background:#fff}'); }); - it('strips custom properties after :root rewrite', () => { - const result = sanitizeStylesheet(':root{--brand:#4f46e5}'); + it('accepts themed custom-property declarations without failing the stylesheet', () => { + const result = sanitizeStylesheet('[data-theme="dark"]{--brand:#4f46e5}'); expect(result.ok).toBe(true); - expect(failureCode(result)).toBe(CSS_VIOLATION_CODES.customProperty); }); it('rewrites compound global-root selectors without doubled content-root prefixes', () => { diff --git a/src/__tests__/html-export-finalize.test.ts b/src/__tests__/html-export-finalize.test.ts index d58484a..add48d9 100644 --- a/src/__tests__/html-export-finalize.test.ts +++ b/src/__tests__/html-export-finalize.test.ts @@ -208,19 +208,29 @@ async function driveToResolved( } describe('HtmlExportPipelineService.finalize', () => { - it('transitions resolved -> finalized and returns a matching finalized ref', async () => { + it('injects the scroll runtime without slide navigation', async () => { const { service, registry } = serviceFor(); - const { attemptId, resolvedId, resolvedBytes } = await driveToResolved(service, registry); + const { attemptId, resolvedId } = await driveToResolved(service, registry); - const finalized = service.finalize(1, attemptId, resolvedId); + const finalized = service.finalize(1, attemptId, resolvedId, 'scroll'); expect(finalized.ok).toBe(true); if (!finalized.ok) return; + const bytes = registry.transitions.at(-1)?.bytes; expect(finalized.value.artifact.stage).toBe('finalized'); - expect(finalized.value.artifact.sha256).toBe(digest(resolvedBytes)); - expect(finalized.value.artifact.byteLength).toBe(resolvedBytes.byteLength); - expect(registry.transitions.at(-1)).toMatchObject({ priorId: resolvedId, stage: 'finalized' }); - expect(registry.transitions.at(-1)?.bytes.equals(resolvedBytes)).toBe(true); + expect(finalized.value.artifact.sha256).toBe(digest(bytes!)); + expect(finalized.value.artifact.byteLength).toBe(bytes!.byteLength); + expect(bytes?.toString('utf8')).toContain('id="nai-runtime"'); + expect(bytes?.toString('utf8')).toContain('if(false)'); + }); + it('injects slide navigation for slide-mode requests', async () => { + const { service, registry } = serviceFor(); + const { attemptId, resolvedId } = await driveToResolved(service, registry, 1, '
One
'); + + const finalized = service.finalize(1, attemptId, resolvedId, 'slide'); + + expect(finalized.ok).toBe(true); + expect(registry.transitions.at(-1)?.bytes.toString('utf8')).toContain('nai-slide-nav'); }); it('returns typed pipeline errors for unknown, wrong-sender, and stale resolved ids', async () => { @@ -285,22 +295,23 @@ describe('HtmlExportPipelineService.finalize', () => { describe('HtmlExportPipelineService.readFinalizedArtifact', () => { async function driveToFinalized(service: HtmlExportPipelineService, registry: FakeRegistry) { - const { attemptId, resolvedId, resolvedBytes } = await driveToResolved(service, registry); + const { attemptId, resolvedId } = await driveToResolved(service, registry); const finalized = valueOf(service.finalize(1, attemptId, resolvedId)); - return { attemptId, finalizedId: finalized.artifact.id, resolvedBytes }; + return { attemptId, finalizedId: finalized.artifact.id }; } it('returns the exact main-held finalized bytes with a matching digest', async () => { const { service, registry } = serviceFor(); - const { attemptId, finalizedId, resolvedBytes } = await driveToFinalized(service, registry); + const { attemptId, finalizedId } = await driveToFinalized(service, registry); const read = service.readFinalizedArtifact(1, attemptId, finalizedId); expect(read.ok).toBe(true); if (!read.ok) return; - expect(read.value.bytes.equals(resolvedBytes)).toBe(true); - expect(read.value.sha256).toBe(digest(resolvedBytes)); - expect(read.value.byteLength).toBe(resolvedBytes.byteLength); + const finalizedBytes = registry.transitions.at(-1)?.bytes; + expect(read.value.bytes.equals(finalizedBytes!)).toBe(true); + expect(read.value.sha256).toBe(digest(finalizedBytes!)); + expect(read.value.byteLength).toBe(finalizedBytes!.byteLength); }); it('returns typed errors for unknown, wrong-sender, and stale finalized ids', async () => { diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts new file mode 100644 index 0000000..9886fba --- /dev/null +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; + +import { injectHtmlExportRuntime } from '../main/html-export-runtime'; + +function mount(html: string, mode: 'scroll' | 'slide' = 'scroll'): void { + document.documentElement.innerHTML = injectHtmlExportRuntime(html, mode); + HTMLElement.prototype.scrollIntoView = () => {}; + const source = document.querySelector('#nai-runtime')?.textContent; + if (!source) throw new Error('runtime was not injected'); + window.eval(source); +} + +afterEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute('data-nai-runtime'); + document.documentElement.innerHTML = ''; +}); + +describe('HTML export runtime DOM', () => { + it('toggles and restores the html theme through localStorage', () => { + localStorage.setItem('nai-theme', 'dark'); + mount('
content
'); + + const toggle = document.querySelector('#nai-runtime-toggle')!; + expect(document.documentElement.dataset.theme).toBe('dark'); + toggle.click(); + expect(document.documentElement.dataset.theme).toBe('light'); + expect(localStorage.getItem('nai-theme')).toBe('light'); + }); + + it('adds the fallback theme stylesheet when authored theme variables are absent', () => { + mount(''); + expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('[data-theme="dark"]'); + }); + + it('pages slide exports by keyboard and controls while ignoring text input focus', () => { + mount('
one
two
three
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const indicator = document.querySelector('.nai-slide-nav span')!; + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + expect(indicator.textContent).toBe('1/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + expect(indicator.textContent).toBe('2/3'); + next.click(); + expect(indicator.textContent).toBe('3/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' })); + expect(indicator.textContent).toBe('2/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })); + expect(indicator.textContent).toBe('1/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown' })); + expect(indicator.textContent).toBe('2/3'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + expect(indicator.textContent).toBe('3/3'); + previous.click(); + expect(indicator.textContent).toBe('2/3'); + const input = slides[1].querySelector('input')!; + input.focus(); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(indicator.textContent).toBe('2/3'); + const textarea = slides[2].querySelector('textarea')!; + textarea.focus(); + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(indicator.textContent).toBe('2/3'); + }); + + it('is idempotent across double finalization', () => { + const once = injectHtmlExportRuntime('content'); + const twice = injectHtmlExportRuntime(once); + expect((twice.match(/id="nai-runtime"/g) ?? [])).toHaveLength(1); + expect((twice.match(/http-equiv="Content-Security-Policy"/g) ?? [])).toHaveLength(1); + }); +}); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 0407c29..601ebaa 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -110,13 +110,11 @@ describe('sanitizeHtmlExport', () => { if (!result.ok) return; expect(result.bodyHtml).toBe('

Kept

Text'); }); - it('unwraps content-bearing active containers while recording their removal', () => { + it('preserves interactive form containers', () => { const result = sanitize('

Kept

'); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('

Kept

'); - expect(result.bodyHtml).not.toContain('

Kept

'); }); it('unwraps template content stored outside its childNodes array', () => { const result = sanitize('
'); @@ -128,11 +126,13 @@ describe('sanitizeHtmlExport', () => { }); it.each([ - 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'script', 'link', 'template', - 'slot', 'form', 'input', 'button', - ])('rejects active tag <%s>', (tag) => { + 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'link', 'template', 'slot', + ])('rejects unsupported active tag <%s>', (tag) => { expect(dispositionCodeWithParse(documentWithElement(tag))).toBe('html_active_tag'); }); + it.each(['script', 'form', 'input', 'button'])('preserves interactive tag <%s>', (tag) => { + expect(dispositionCodeWithParse(documentWithElement(tag))).toBe(''); + }); it('rejects meta http-equiv as an active redirect surface', () => { expect(dispositionCodeWithParse(documentWithElement('meta', [{ name: 'http-equiv', value: 'refresh' }]))).toBe('html_active_tag'); @@ -153,18 +153,17 @@ describe('sanitizeHtmlExport', () => { expect(dispositionCode(html)).toBe(code); }); - it('rejects event handlers and app shell/runtime namespace preseed', () => { - expect(dispositionCode('

x

')).toBe('html_event_handler'); + it('preserves event handlers while rejecting app shell/runtime namespace preseed', () => { + expect(dispositionCode('

x

')).toBe(''); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); }); - it('removes stripped event and reserved attributes from exported HTML', () => { + it('keeps interactive event attributes while stripping reserved attributes', () => { const result = sanitize('

Kept

'); expect(result.ok).toBe(true); if (!result.ok) return; - expect(result.bodyHtml).toContain('Kept'); - expect(result.bodyHtml).not.toContain('onclick'); + expect(result.bodyHtml).toContain('onclick="x"'); expect(result.bodyHtml).not.toContain('data-he-'); expect(result.bodyHtml).not.toContain('he-shell'); }); @@ -294,41 +293,33 @@ describe('sanitizeHtmlExport', () => { expect(stylesheet).toMatchObject({ ok: true, stripped: ['css_rejected.css_network_function_not_allowed'] }); const inline = sanitize('

safe

'); - expect(inline).toMatchObject({ ok: true, stripped: ['css_rejected.css_important_not_allowed'] }); - if (inline.ok) { - expect(inline.bodyHtml).not.toContain('style='); - expect(inline.stripped.join()).not.toContain('color'); - } + expect(inline).toMatchObject({ ok: true, stripped: [] }); + if (inline.ok) expect(inline.contentCss).toContain('color:red!important'); }); it('strips oversized malformed CSS before stylesheet registration can parse it', () => { const result = sanitize(``); expect(result.ok).toBe(true); if (result.ok) expect(result.stripped).toContain('css_rejected.css_too_large'); }); - it('keeps the document and ordinary rules while stripping :is() sticky declarations', () => { + it('keeps interactive sticky declarations', () => { const result = sanitize('

Kept

Also kept

'); - expect(result).toMatchObject({ ok: true, stripped: ['css_rejected.css_unsafe_position'] }); + expect(result).toMatchObject({ ok: true, stripped: [] }); if (!result.ok) return; expect(result.bodyHtml).toContain('Kept'); - expect(result.contentCss).toContain(':is(.note){color:red}'); + expect(result.contentCss).toContain(':is(.note){position:sticky;color:red}'); expect(result.contentCss).toContain('.plain{color:blue}'); - expect(result.contentCss).not.toContain('sticky'); }); - it('gives active and style-node attribute failures precedence over malformed nested CSS', () => { + it('keeps reserved namespace checks ahead of malformed CSS', () => { for (const [html, code] of [ - ['', 'html_active_tag'], - ['', 'html_active_tag'], - ['', 'html_event_handler'], - ['

x

', 'html_event_handler'], ['', 'html_reserved_namespace'], ]) { expect(failureCode(html)).toBe(code); } }); it.each([ - ['active ancestor', '
', 'html_active_tag'], - ['event ancestor', '
', 'html_event_handler'], + ['active ancestor', '
', 'html_svg_rejected'], + ['event ancestor', '
', 'html_svg_rejected'], ['reserved ancestor', '
', 'html_reserved_namespace'], ['structural ancestor', '

', 'html_attribute'], ])('gives outer HTML boundaries precedence over malformed SVG: %s', (_name, html, code) => { @@ -739,7 +730,6 @@ describe('sanitizeHtmlExport — fail-closed structural gate (issue #27)', () => } const custom = sanitize('

x

'); expect(custom.ok).toBe(true); - if (custom.ok) expect(custom.stripped).toContain('css_rejected.css_custom_property_not_allowed'); }); it('emits no content-root style rule when html/body have no style attribute', () => { diff --git a/src/__tests__/html-export-shell.test.ts b/src/__tests__/html-export-shell.test.ts index 89c3f28..067c8bc 100644 --- a/src/__tests__/html-export-shell.test.ts +++ b/src/__tests__/html-export-shell.test.ts @@ -39,11 +39,11 @@ function scriptBlocks(html: string): Array<{ type: string | null; id: string | n } describe('bundleSanitizedHtml — canonical shell contract', () => { - it('emits exactly one CSP meta whose script-src pins the shared runtime SHA', () => { + it('emits exactly one interactive CSP meta', () => { const { html } = bundleSanitizedHtml(payload()); const cspMetas = html.match(/]*>/g) ?? []; expect(cspMetas).toHaveLength(1); - expect(cspMetas[0]).toContain(`script-src 'sha256-${HTML_EXPORT_RUNTIME_JS_SHA256}'`); + expect(cspMetas[0]).toContain("script-src 'unsafe-inline'"); }); it('emits exactly two '); expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('[data-theme="dark"]'); }); + it('applies authored theme variables on the content root and skips fallback only when they match', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + expect(content.dataset.theme).toBe('light'); + document.querySelector('#nai-runtime-toggle')!.click(); + expect(content.dataset.theme).toBe('dark'); + expect(getComputedStyle(content).getPropertyValue('--bg').trim()).toBe('#111'); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + it('pages slide exports by keyboard and controls while ignoring text input focus', () => { mount('
one
two
three
', 'slide'); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 601ebaa..32a81ac 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -159,6 +159,11 @@ describe('sanitizeHtmlExport', () => { expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); expect(dispositionCode('

x

')).toBe('html_reserved_namespace'); }); + it('reserves the nai runtime namespace', () => { + expect(dispositionCode('

x

')).toBe( + 'html_reserved_namespace', + ); + }); it('keeps interactive event attributes while stripping reserved attributes', () => { const result = sanitize('

Kept

'); expect(result.ok).toBe(true); diff --git a/src/__tests__/html-export-shell.test.ts b/src/__tests__/html-export-shell.test.ts index 067c8bc..0e552d3 100644 --- a/src/__tests__/html-export-shell.test.ts +++ b/src/__tests__/html-export-shell.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest'; import { bundleSanitizedHtml } from '../main/html-export-shell'; import type { HtmlExportSanitizedPayload } from '../main/html-export-pipeline-service'; -import { - HTML_EXPORT_RUNTIME_JS, - HTML_EXPORT_RUNTIME_JS_SHA256, -} from '../shared/html-export-runtime'; +import { htmlExportRuntimeSha256 } from '../main/html-export-runtime'; function payload(over: Partial = {}): HtmlExportSanitizedPayload { return { @@ -39,11 +36,10 @@ function scriptBlocks(html: string): Array<{ type: string | null; id: string | n } describe('bundleSanitizedHtml — canonical shell contract', () => { - it('emits exactly one interactive CSP meta', () => { + it('leaves runtime injection to finalization', () => { const { html } = bundleSanitizedHtml(payload()); - const cspMetas = html.match(/]*>/g) ?? []; - expect(cspMetas).toHaveLength(1); - expect(cspMetas[0]).toContain("script-src 'unsafe-inline'"); + expect(html).not.toContain('Content-Security-Policy'); + expect(scriptBlocks(html).filter((script) => script.id === 'nai-runtime')).toHaveLength(0); }); it('emits exactly two `, + ); + + expect(output).toContain(`--x:'${decoy}'`); + const manifest = output.match(/' + + '' + + '
body content
', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + const bodyContent = result.bodyHtml.indexOf('
body content
'); + const bodyScript = result.bodyHtml.indexOf('window.order.push("body")'); + const firstHeadScript = result.bodyHtml.indexOf('window.order = ["head-1"]'); + const secondHeadScript = result.bodyHtml.indexOf('window.order.push("head-2")'); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(bodyContent).toBeGreaterThanOrEqual(0); + expect(bodyScript).toBeGreaterThan(bodyContent); + expect(firstHeadScript).toBeGreaterThan(bodyScript); + expect(secondHeadScript).toBeGreaterThan(firstHeadScript); + }); + it('strips head scripts with src attributes', () => { + const result = sanitize( + '' + + '

Kept

', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain('

Kept

'); + expect(result.bodyHtml).not.toContain(' { + const result = sanitize('

Kept

'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toBe('

Kept

'); + expect(result.contentCss).toContain('p{color:red}'); + }); it('rejects meta http-equiv as an active redirect surface', () => { expect(dispositionCodeWithParse(documentWithElement('meta', [{ name: 'http-equiv', value: 'refresh' }]))).toBe('html_active_tag'); diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index af77adb..615688a 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -103,6 +103,7 @@ type Context = { isAllowedAssetId: (src: string) => boolean; stylesheetRules: string[]; inlineRules: string[]; + relocatedHeadScripts: SanitizedNode[]; nextInlineStyle: number; cssContext: CssSanitizeContext; svgPlans: Map; @@ -492,6 +493,12 @@ function scanDiscardedNode(node: Node, context: Context): Failure | null { else { context.stripped.push(...result.stripped.map((v) => ({ code: cssRejectedCode(v.code), detail: v.detail }))); context.stylesheetRules.push(result.css); } return null; } + if (name === 'script' && !attrs(node).some((attribute) => attribute.name.toLowerCase() === 'src')) { + const sanitized = sanitizeNode(node, context, null); + if (isFailure(sanitized)) return sanitized; + context.relocatedHeadScripts.push(...sanitized); + return null; + } const attributes = sanitizeAttributes(node, name, context, false); if (isFailure(attributes)) return attributes; for (const child of childNodes(node)) { @@ -654,6 +661,7 @@ export function sanitizeHtmlExport(options: HtmlExportSanitizeOptions): HtmlExpo isAllowedAssetId, stylesheetRules: [], inlineRules: [], + relocatedHeadScripts: [], nextInlineStyle: 0, cssContext, svgPlans, @@ -669,6 +677,7 @@ export function sanitizeHtmlExport(options: HtmlExportSanitizeOptions): HtmlExpo if (isFailure(sanitized)) return { ok: false, violations: [sanitized.violation] }; outputNodes.push(...sanitized); } + outputNodes.push(...context.relocatedHeadScripts); outputBody.childNodes = outputNodes as DefaultTreeAdapterTypes.ChildNode[]; for (const child of outputBody.childNodes) child.parentNode = outputBody; context.stripped.push(...svgStripped); From a6a81a3cf84499d2977c0323b657757b16c8639b Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:18:35 +0900 Subject: [PATCH 06/21] fix(html): preserve relocated script and theme scope order --- src/__tests__/html-export-css-sanitize.test.ts | 14 ++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 17 +++++++++++++---- src/main/html-export-css-sanitize.ts | 16 ++++++++++++++++ src/main/html-export-sanitize.ts | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index c1439c2..659b6d2 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -424,6 +424,20 @@ describe('global selector rewrite', () => { expect(result.css).toContain('[data-he-content][data-theme="dark"]{--bg:#111}'); expect(result.css).toContain('[data-he-content][data-theme="light"]{--bg:#fff}'); }); + it('rewrites theme-root sibling selectors as descendants of the content root', () => { + const result = sanitizeStylesheet('[data-theme="dark"]~button{color:red}[data-theme="dark"]+button{color:blue}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.css).toBe( + '[data-he-content][data-theme="dark"] button{color:red}[data-he-content][data-theme="dark"] button{color:blue}', + ); + }); + it('preserves child-combinator semantics for theme-root selectors', () => { + const result = sanitizeStylesheet('[data-theme="dark"]>button{color:red}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.css).toBe('[data-he-content][data-theme="dark"]>button{color:red}'); + }); it('does not treat data-theme-prefixed attributes as theme atoms', () => { const result = sanitizeStylesheet('[data-theme-variant="dark"]{--bg:#111}'); expect(result.ok).toBe(true); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 22d10ff..1103311 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -133,7 +133,7 @@ describe('sanitizeHtmlExport', () => { it.each(['script', 'form', 'input', 'button'])('preserves interactive tag <%s>', (tag) => { expect(dispositionCodeWithParse(documentWithElement(tag))).toBe(''); }); - it('relocates inline head scripts after model body content in source order', () => { + it('relocates inline head scripts before model body content in source order', () => { const result = sanitize( '' + '' + @@ -148,10 +148,19 @@ describe('sanitizeHtmlExport', () => { const secondHeadScript = result.bodyHtml.indexOf('window.order.push("head-2")'); expect(result.bodyHtml).toContain(''); expect(result.bodyHtml).toContain(''); - expect(bodyContent).toBeGreaterThanOrEqual(0); - expect(bodyScript).toBeGreaterThan(bodyContent); - expect(firstHeadScript).toBeGreaterThan(bodyScript); + expect(firstHeadScript).toBe(8); expect(secondHeadScript).toBeGreaterThan(firstHeadScript); + expect(bodyContent).toBeGreaterThan(secondHeadScript); + expect(bodyScript).toBeGreaterThan(bodyContent); + }); + it('relocates head definitions before body calls', () => { + const result = sanitize( + '' + + '', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toBe(''); }); it('strips head scripts with src attributes', () => { const result = sanitize( diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 371ad0e..a61fd31 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -285,6 +285,21 @@ function rewriteGlobalRootAtoms(node: any): void { } } +function isLeadingThemeAtom(node: any): boolean { + return node?.type === 'AttributeSelector' && String(node.name?.name ?? node.name ?? '').toLowerCase() === 'data-theme'; +} + +/** Approximate leading theme sibling selectors as descendants to retain containment. */ +function rewriteLeadingThemeSiblingCombinator(selector: any): void { + const nodes = children(selector); + if (!isLeadingThemeAtom(nodes[0])) return; + for (const node of nodes) { + if (node?.type !== 'Combinator') continue; + if (node.name === '+' || node.name === '~') node.name = ' '; + return; + } +} + function isExactGlobalRootSelector(selector: any): boolean { const nodes = children(selector); return nodes.length === 1 && isGlobalRootAtom(nodes[0]); @@ -307,6 +322,7 @@ function scopeSelector(selector: any): string { if (isExactUniversalSelector(selector)) return `${CONTENT_ROOT_SELECTOR} *`; const rewritten = cloneCssNode(selector); rewriteGlobalRootAtoms(rewritten); + rewriteLeadingThemeSiblingCombinator(rewritten); const text = generated(rewritten); if (text.startsWith(CONTENT_ROOT_SELECTOR)) return text; if (text.startsWith('[data-theme=') || text.startsWith('[data-theme]')) return `${CONTENT_ROOT_SELECTOR}${text}`; diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index 615688a..07c19cb 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -677,7 +677,7 @@ export function sanitizeHtmlExport(options: HtmlExportSanitizeOptions): HtmlExpo if (isFailure(sanitized)) return { ok: false, violations: [sanitized.violation] }; outputNodes.push(...sanitized); } - outputNodes.push(...context.relocatedHeadScripts); + outputNodes.unshift(...context.relocatedHeadScripts); outputBody.childNodes = outputNodes as DefaultTreeAdapterTypes.ChildNode[]; for (const child of outputBody.childNodes) child.parentNode = outputBody; context.stripped.push(...svgStripped); From fa213482404efd2590080be76c575e2e5337ff07 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:05:01 +0900 Subject: [PATCH 07/21] fix(html): detect layered authored theme palettes --- src/__tests__/html-export-runtime.dom.test.ts | 24 +++++++++++++++++-- src/main/html-export-runtime.ts | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index d69b78a..3187721 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -1,7 +1,9 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest'; +import { bundleSanitizedHtml } from '../main/html-export-shell'; import { htmlExportRuntimeSha256, injectHtmlExportRuntime } from '../main/html-export-runtime'; +import { sanitizeHtmlExport } from '../main/html-export-sanitize'; function mount(html: string, mode: 'scroll' | 'slide' = 'scroll'): void { document.documentElement.innerHTML = injectHtmlExportRuntime(html, mode); @@ -29,8 +31,8 @@ describe('HTML export runtime DOM', () => { expect(localStorage.getItem('nai-theme')).toBe('light'); }); - it('adds the fallback theme stylesheet when authored theme variables are absent', () => { - mount(''); + it('adds the fallback theme stylesheet when authored theme variables are absent inside a layer', () => { + mount('
'); expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('[data-theme="dark"]'); }); it('applies authored theme variables on the content root and skips fallback only when they match', () => { @@ -43,7 +45,25 @@ describe('HTML export runtime DOM', () => { expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('skips the fallback for a sanitized and finalized authored theme palette in a layer', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + expect(sanitized.contentCss).toContain('@layer he-authored{[data-he-content][data-theme="dark"]{--surface:#111'); + const finalized = bundleSanitizedHtml(sanitized).html; + mount(finalized); + + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + + it('skips the fallback for authored theme variables inside media rules', () => { + mount('
'); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); it('pages slide exports by keyboard and controls while ignoring text input focus', () => { mount('
one
two
three
', 'slide'); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index 66846be..76d8368 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -7,7 +7,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = ` Date: Sat, 18 Jul 2026 15:14:19 +0900 Subject: [PATCH 08/21] fix(html): scope functional theme selectors --- .../html-export-css-sanitize.test.ts | 16 ++++++++ src/__tests__/html-export-runtime.dom.test.ts | 9 +++-- src/main/html-export-css-sanitize.ts | 39 ++++++++++++++++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index 659b6d2..774bfe9 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -416,6 +416,22 @@ describe('global selector rewrite', () => { CSS_VIOLATION_CODES.disallowedSelector, ); }); + it('compounds all-theme :where and :is selector arguments with the content root', () => { + const result = sanitizeStylesheet( + ':where([data-theme="dark"],[data-theme="light"]){--bg:#111}:is([data-theme]){--fg:#eee}:where(:root[data-theme]){--root:#fff}:is(html[data-theme]){--html:#ddd}', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.css).toBe( + '[data-he-content]:where([data-theme="dark"],[data-theme="light"]){--bg:#111}[data-he-content]:is([data-theme]){--fg:#eee}[data-he-content]:where([data-he-content][data-theme]){--root:#fff}[data-he-content]:is([data-he-content][data-theme]){--html:#ddd}', + ); + }); + it('keeps mixed :where selector arguments scoped as descendants', () => { + const result = sanitizeStylesheet(':where([data-theme="dark"],.card){--bg:#111}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.css).toBe('[data-he-content] :where([data-theme="dark"],.card){--bg:#111}'); + }); }); it('compounds leading theme selectors with the content root', () => { const result = sanitizeStylesheet('[data-theme="dark"]{--bg:#111}:root[data-theme="light"]{--bg:#fff}'); diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 3187721..9b2fd07 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -45,18 +45,21 @@ describe('HTML export runtime DOM', () => { expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); - it('skips the fallback for a sanitized and finalized authored theme palette in a layer', () => { + it('applies a :where theme palette in the finalized artifact without a fallback', () => { const sanitized = sanitizeHtmlExport({ - html: '
content
', + html: '
content
', isAllowedAssetId: () => true, }); expect(sanitized.ok).toBe(true); if (!sanitized.ok) return; - expect(sanitized.contentCss).toContain('@layer he-authored{[data-he-content][data-theme="dark"]{--surface:#111'); + expect(sanitized.contentCss).toContain('@layer he-authored{[data-he-content]:where([data-theme="dark"]){--surface:#111'); const finalized = bundleSanitizedHtml(sanitized).html; mount(finalized); + const content = document.querySelector('[data-he-content]')!; + document.querySelector('#nai-runtime-toggle')!.click(); + expect(content.matches('[data-he-content]:where([data-theme="dark"])')).toBe(true); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index a61fd31..6253853 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -285,14 +285,39 @@ function rewriteGlobalRootAtoms(node: any): void { } } -function isLeadingThemeAtom(node: any): boolean { +function isThemeAttributeAtom(node: any): boolean { return node?.type === 'AttributeSelector' && String(node.name?.name ?? node.name ?? '').toLowerCase() === 'data-theme'; } +function isThemeRootSelector(selector: any): boolean { + const nodes = children(selector); + if (nodes.length === 1) return isThemeAttributeAtom(nodes[0]); + return nodes.length === 2 + && isGlobalRootAtom(nodes[0]) + && isThemeAttributeAtom(nodes[1]) + && (nodes[0].type !== 'TypeSelector' || String(nodes[0].name).toLowerCase() === 'html'); +} + +function isLeadingThemeAtom(node: any): boolean { + if (isThemeAttributeAtom(node)) return true; + if (node?.type !== 'PseudoClassSelector') return false; + const name = String(node.name).toLowerCase(); + if (name !== 'where' && name !== 'is') return false; + const selectorList = children(node)[0]; + const selectors = children(selectorList); + return selectorList?.type === 'SelectorList' && selectors.length > 0 && selectors.every(isThemeRootSelector); +} + +function leadingThemeRootArgumentCount(node: any): number { + if (node?.type !== 'PseudoClassSelector') return 0; + return children(children(node)[0]).filter((selector) => isThemeRootSelector(selector) + && isGlobalRootAtom(children(selector)[0])).length; +} + /** Approximate leading theme sibling selectors as descendants to retain containment. */ -function rewriteLeadingThemeSiblingCombinator(selector: any): void { +function rewriteLeadingThemeSiblingCombinator(selector: any, leadingThemeAtom: boolean): void { const nodes = children(selector); - if (!isLeadingThemeAtom(nodes[0])) return; + if (!leadingThemeAtom) return; for (const node of nodes) { if (node?.type !== 'Combinator') continue; if (node.name === '+' || node.name === '~') node.name = ' '; @@ -320,12 +345,13 @@ function isExactUniversalSelector(selector: any): boolean { function scopeSelector(selector: any): string { if (isExactGlobalRootSelector(selector)) return CONTENT_ROOT_SELECTOR; if (isExactUniversalSelector(selector)) return `${CONTENT_ROOT_SELECTOR} *`; + const leadingThemeAtom = isLeadingThemeAtom(children(selector)[0]); const rewritten = cloneCssNode(selector); rewriteGlobalRootAtoms(rewritten); - rewriteLeadingThemeSiblingCombinator(rewritten); + rewriteLeadingThemeSiblingCombinator(rewritten, leadingThemeAtom); const text = generated(rewritten); if (text.startsWith(CONTENT_ROOT_SELECTOR)) return text; - if (text.startsWith('[data-theme=') || text.startsWith('[data-theme]')) return `${CONTENT_ROOT_SELECTOR}${text}`; + if (leadingThemeAtom) return `${CONTENT_ROOT_SELECTOR}${text}`; return `${CONTENT_ROOT_SELECTOR} ${text}`; } @@ -353,10 +379,11 @@ function validateGlobalRootShape(selector: any): Failure | null { const roots: any[] = []; collectRootAtoms(selector, roots); if (roots.length === 0) return null; + const top = children(selector); + if (isLeadingThemeAtom(top[0]) && roots.length === leadingThemeRootArgumentCount(top[0])) return null; if (roots.length > 1) { return fail(CSS_VIOLATION_CODES.disallowedSelector, 'multiple global-root selectors'); } - const top = children(selector); if (top[0] !== roots[0]) { return fail(CSS_VIOLATION_CODES.disallowedSelector, 'global-root selector must be the leading atom'); } From 280629227960d253f6f2779112f6471c9ecc3377 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:22:45 +0900 Subject: [PATCH 09/21] fix(html): require data-theme for fallback suppression --- src/__tests__/html-export-runtime.dom.test.ts | 20 +++++++++++++++++++ src/main/html-export-runtime.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 9b2fd07..9f119f2 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -35,6 +35,21 @@ describe('HTML export runtime DOM', () => { mount('
'); expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('[data-theme="dark"]'); }); + it('keeps the fallback functional when content-root variables are not theme-conditioned', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.dataset.theme).toBe('dark'); + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + expect(content.matches('[data-he-content][data-theme="dark"]')).toBe(true); + }); + it('keeps the fallback when a theme-conditioned rule has no custom properties', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + }); it('applies authored theme variables on the content root and skips fallback only when they match', () => { mount('
'); const content = document.querySelector('[data-he-content]')!; @@ -62,6 +77,11 @@ describe('HTML export runtime DOM', () => { expect(content.matches('[data-he-content]:where([data-theme="dark"])')).toBe(true); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('skips the fallback for a :is theme palette', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); it('skips the fallback for authored theme variables inside media rules', () => { mount('
'); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index 76d8368..ef19a52 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -7,7 +7,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = ` Date: Sat, 18 Jul 2026 15:40:27 +0900 Subject: [PATCH 10/21] fix(html): honor active media themes and localize runtime --- src/__tests__/html-export-finalize.test.ts | 10 +++ src/__tests__/html-export-runtime.dom.test.ts | 72 +++++++++++++++++-- .../html-export-generation-orchestrator.ts | 14 +++- src/main/html-export-pipeline-service.ts | 8 ++- src/main/html-export-runtime-labels.ts | 51 +++++++++++++ src/main/html-export-runtime.ts | 22 ++++-- src/main/ipc/html-export-ipc.ts | 7 +- src/renderer/api-types.ts | 1 + src/renderer/html-export-wizard.ts | 1 + src/renderer/unified-chat-wiring.ts | 4 +- 10 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 src/main/html-export-runtime-labels.ts diff --git a/src/__tests__/html-export-finalize.test.ts b/src/__tests__/html-export-finalize.test.ts index add48d9..e89b862 100644 --- a/src/__tests__/html-export-finalize.test.ts +++ b/src/__tests__/html-export-finalize.test.ts @@ -232,6 +232,16 @@ describe('HtmlExportPipelineService.finalize', () => { expect(finalized.ok).toBe(true); expect(registry.transitions.at(-1)?.bytes.toString('utf8')).toContain('nai-slide-nav'); }); + it('injects locale-specific labels', async () => { + const { service, registry } = serviceFor(); + const { attemptId, resolvedId } = await driveToResolved(service, registry, 1, '
One
'); + + const finalized = service.finalize(1, attemptId, resolvedId, 'slide', 'ko'); + + expect(finalized.ok).toBe(true); + const html = registry.transitions.at(-1)?.bytes.toString('utf8') ?? ''; + expect(html).toContain('어두운 테마로 전환'); + }); it('returns typed pipeline errors for unknown, wrong-sender, and stale resolved ids', async () => { const { service, registry } = serviceFor(); diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 9f119f2..22f3b4c 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -1,12 +1,19 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { bundleSanitizedHtml } from '../main/html-export-shell'; import { htmlExportRuntimeSha256, injectHtmlExportRuntime } from '../main/html-export-runtime'; import { sanitizeHtmlExport } from '../main/html-export-sanitize'; - -function mount(html: string, mode: 'scroll' | 'slide' = 'scroll'): void { - document.documentElement.innerHTML = injectHtmlExportRuntime(html, mode); +import { htmlExportRuntimeLabels, type HtmlExportRuntimeLocale } from '../main/html-export-runtime-labels'; + +function mount( + html: string, + mode: 'scroll' | 'slide' = 'scroll', + locale: HtmlExportRuntimeLocale = 'en', + styleSheets?: Array<{ cssRules: unknown[] }>, +): void { + document.documentElement.innerHTML = injectHtmlExportRuntime(html, mode, htmlExportRuntimeLabels(locale)); + if (styleSheets) Object.defineProperty(document, 'styleSheets', { configurable: true, value: styleSheets }); HTMLElement.prototype.scrollIntoView = () => {}; const source = document.querySelector('#nai-runtime')?.textContent; if (!source) throw new Error('runtime was not injected'); @@ -17,6 +24,8 @@ afterEach(() => { localStorage.clear(); document.documentElement.removeAttribute('data-nai-runtime'); document.documentElement.innerHTML = ''; + vi.unstubAllGlobals(); + delete (document as { styleSheets?: unknown }).styleSheets; }); describe('HTML export runtime DOM', () => { @@ -82,6 +91,55 @@ describe('HTML export runtime DOM', () => { expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('injects the fallback when a theme palette is only inside a non-matching media rule', () => { + const matchMedia = vi.fn((condition: string) => ({ matches: condition !== 'print' })); + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: matchMedia, + }); + mount( + '
', + 'scroll', + 'en', + [{ cssRules: [{ type: 4, conditionText: 'print', cssRules: [{ selectorText: '[data-he-content][data-theme="dark"]', style: ['--surface'] }] }] }], + ); + expect(matchMedia).toHaveBeenCalledWith('print'); + expect(document.querySelector('#nai-theme-fallback')).not.toBeNull(); + }); + + it('skips the fallback when a theme palette is inside a matching media rule', () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (condition: string) => ({ matches: condition === 'screen' }), + }); + mount( + '
', + 'scroll', + 'en', + [{ cssRules: [{ type: 4, conditionText: 'screen', cssRules: [{ selectorText: '[data-he-content][data-theme="dark"]', style: ['--surface'] }] }] }], + ); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); + + + it('localizes runtime controls while keeping the visible slide indicator numeric', () => { + mount('
one
two
', 'slide', 'ko'); + + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + const indicator = document.querySelector('.nai-slide-nav span')!; + expect(document.querySelector('#nai-runtime-toggle')?.getAttribute('aria-label')).toBe('어두운 테마로 전환'); + expect(previous.getAttribute('aria-label')).toBe('이전 슬라이드'); + expect(next.getAttribute('title')).toBe('다음 슬라이드'); + expect(indicator.textContent).toBe('1/2'); + expect(indicator.getAttribute('aria-label')).toBe('슬라이드 1/2'); + }); + + it('has non-empty runtime labels for all supported locales', () => { + for (const locale of ['en', 'ko', 'zh-Hans', 'zh-Hant', 'ja'] as const) { + const labels = htmlExportRuntimeLabels(locale); + expect(Object.values(labels).every(Boolean), locale).toBe(true); + } + }); it('skips the fallback for authored theme variables inside media rules', () => { mount('
'); @@ -122,12 +180,16 @@ describe('HTML export runtime DOM', () => { const decoy = '"runtimeSha256":"AAAA"'; const output = injectHtmlExportRuntime( ``, + 'slide', + htmlExportRuntimeLabels('ko'), ); expect(output).toContain(`--x:'${decoy}'`); const manifest = output.match(/`; + const script = ``; output = /]*>[\s\S]*?<\/script\s*>/i.test(output) ? output.replace(/]*>[\s\S]*?<\/script\s*>/i, script) : /<\/body\s*>/i.test(output) ? output.replace(/<\/body\s*>/i, `${script}`) : `${output}${script}`; @@ -28,7 +36,7 @@ export function injectHtmlExportRuntime(html: string, mode: HtmlExportRuntimeMod return output.replace(manifestScript, (_match, open, manifest, close) => { const patchedManifest = manifest.replace( /("runtimeSha256"\s*:\s*")[^"]*(")/, - `$1${htmlExportRuntimeSha256(mode)}$2`, + `$1${htmlExportRuntimeSha256(mode, labels)}$2`, ); return `${open}${patchedManifest}${close}`; }); diff --git a/src/main/ipc/html-export-ipc.ts b/src/main/ipc/html-export-ipc.ts index 920c8cc..7cf215d 100644 --- a/src/main/ipc/html-export-ipc.ts +++ b/src/main/ipc/html-export-ipc.ts @@ -24,6 +24,7 @@ import { } from '../../shared/html-export-pipeline'; import { atomicWrite, nodeAtomicBackend, type AtomicWriteBackend } from '../atomic-write'; import type { GenerationAttemptResult } from '../html-export-generation-orchestrator'; +import type { HtmlExportRuntimeLocale } from '../html-export-runtime-labels'; import { isAiProviderId, type AiProviderId } from '../ai/types'; import { HTML_EXPORT_CHATGPT_MODEL_IDS, isHtmlExportModelAllowed } from '../ai/html-export-model-allowlist'; import { VIEWPORT_MAX, VIEWPORT_MIN } from '../html-export-quarantine'; @@ -78,6 +79,7 @@ type HtmlExportIpcDeps = { viewport?: { width: number; height: number }; reasoningEffort?: 'low'; mode?: 'slide' | 'scroll'; + locale?: HtmlExportRuntimeLocale; }, ) => Promise; cancelGenerateHtml?: (webContentsId: number) => void; @@ -259,10 +261,11 @@ function isGenerateRequest( viewport?: { width: number; height: number }; reasoningEffort?: 'low'; mode?: 'slide' | 'scroll'; + locale?: HtmlExportRuntimeLocale; } { if (!isExactPlainObject(input) || Object.getOwnPropertySymbols(input).length !== 0) return false; const keys = Object.keys(input); - if (!keys.every((key) => key === 'prompt' || key === 'model' || key === 'instructions' || key === 'viewport' || key === 'reasoningEffort' || key === 'mode')) { + if (!keys.every((key) => key === 'prompt' || key === 'model' || key === 'instructions' || key === 'viewport' || key === 'reasoningEffort' || key === 'mode' || key === 'locale')) { return false; } if (!Object.hasOwn(input, 'prompt') || typeof input.prompt !== 'string') return false; @@ -284,6 +287,7 @@ function isGenerateRequest( } } if ('mode' in input && input.mode !== undefined && input.mode !== 'slide' && input.mode !== 'scroll') return false; + if ('locale' in input && input.locale !== undefined && input.locale !== 'en' && input.locale !== 'ko' && input.locale !== 'zh-Hans' && input.locale !== 'zh-Hant' && input.locale !== 'ja') return false; return true; } @@ -472,6 +476,7 @@ export function registerHtmlExportIpc({ ...(input.viewport !== undefined ? { viewport: input.viewport } : {}), ...(input.reasoningEffort !== undefined ? { reasoningEffort: input.reasoningEffort } : {}), ...(input.mode !== undefined ? { mode: input.mode } : {}), + ...(input.locale !== undefined ? { locale: input.locale } : {}), }); } catch { return { state: 'failed', stage: 'generate', kind: 'pipeline-reject' }; diff --git a/src/renderer/api-types.ts b/src/renderer/api-types.ts index 9964d80..4194c33 100644 --- a/src/renderer/api-types.ts +++ b/src/renderer/api-types.ts @@ -130,6 +130,7 @@ export type Api = HtmlExportPipelineApi & HtmlExportAssetApi & { viewport?: { width: number; height: number }; reasoningEffort?: 'low'; mode?: 'slide' | 'scroll'; + locale?: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja'; }, ) => Promise; cancelHtmlGeneration: () => Promise<{ ok: boolean }>; diff --git a/src/renderer/html-export-wizard.ts b/src/renderer/html-export-wizard.ts index 3267423..ecfc246 100644 --- a/src/renderer/html-export-wizard.ts +++ b/src/renderer/html-export-wizard.ts @@ -83,6 +83,7 @@ export type HtmlExportDeps = { viewport?: { width: number; height: number }; reasoningEffort?: 'low'; mode?: 'slide' | 'scroll'; + locale?: 'en' | 'ko' | 'zh-Hans' | 'zh-Hant' | 'ja'; }) => Promise; /** Cancel/abandon the in-flight or finalized main-owned generation for this window. */ cancelHtmlGeneration?: () => void; diff --git a/src/renderer/unified-chat-wiring.ts b/src/renderer/unified-chat-wiring.ts index 25d3dc5..5d53e15 100644 --- a/src/renderer/unified-chat-wiring.ts +++ b/src/renderer/unified-chat-wiring.ts @@ -3,7 +3,7 @@ import { mountHtmlExportWizard, type HtmlExportWizardHandle } from './html-expor import { clampChatWidth } from './chat-layout'; import { guardVerdict } from './humanize-guards'; import { styleDirective, detectLanguage, type Naturalness } from './humanize-engine'; -import { t } from './i18n'; +import { getLocale, t } from './i18n'; import { aiChatErrorMessage } from './ai-error-message'; import { modelContextWindowTokens } from '../main/ai/output-budget'; import { isAiProviderId, type AiProviderId, type ProviderAuthStatus } from '../main/ai/types'; @@ -406,7 +406,7 @@ export function initUnifiedChatWiring(ctx: AppContext, deps: UnifiedChatWiringDe listDesigns: () => window.api.listDesigns(), saveHtmlFinalized: (args) => window.api.saveHtmlFinalized(args), openSavedHtml: (filePath) => window.api.openSavedHtml(filePath), - generateHtmlExport: (request) => window.api.generateHtmlExport(request), + generateHtmlExport: (request) => window.api.generateHtmlExport({ ...request, locale: getLocale() }), cancelHtmlGeneration: () => void window.api.cancelHtmlGeneration(), openExternal: (url) => void window.api.openExternal(url), onCancel: () => ctx.setStatus(t('status.htmlExportCanceled')), From f13be82ba11e0988c6186f4c7362e78bc18942d9 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:52:48 +0900 Subject: [PATCH 11/21] fix(html): preserve custom property case and top-level slides --- .../html-export-pipeline-service.test.ts | 2 +- src/__tests__/html-export-runtime.dom.test.ts | 37 +++++++++++++++++++ src/main/html-export-css-sanitize.ts | 5 ++- src/main/html-export-runtime.ts | 2 +- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/__tests__/html-export-pipeline-service.test.ts b/src/__tests__/html-export-pipeline-service.test.ts index ea899ae..c4b8e66 100644 --- a/src/__tests__/html-export-pipeline-service.test.ts +++ b/src/__tests__/html-export-pipeline-service.test.ts @@ -792,7 +792,7 @@ describe('HtmlExportPipelineService', () => { const finalized = registry.transitions.at(-1)!; const html = finalized.bytes.toString('utf8'); expect(html).toContain('id="nai-runtime"'); - expect(html.includes('if(true){var slides=')).toBe(mode === 'slide'); + expect(html.includes('if(true){var deck=content||document.body,slides=')).toBe(mode === 'slide'); } }); }); diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 22f3b4c..544b251 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -86,6 +86,23 @@ describe('HTML export runtime DOM', () => { expect(content.matches('[data-he-content]:where([data-theme="dark"])')).toBe(true); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('preserves case-sensitive custom properties and resolves their var() references in finalized artifacts', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + + const finalized = bundleSanitizedHtml(sanitized).html; + expect(finalized).toMatch(/--AccentColor:rgb\(1,\s*2,\s*3\)/); + mount(finalized); + document.querySelector('#nai-runtime-toggle')!.click(); + + const authoredCss = Array.from(document.head.querySelectorAll('style'))[1]?.textContent ?? ''; + expect(authoredCss).toMatch(/--AccentColor:rgb\(1,\s*2,\s*3\)/); + expect(authoredCss).toContain('--Resolved:var(--AccentColor)'); + }); it('skips the fallback for a :is theme palette', () => { mount('
'); @@ -175,6 +192,26 @@ describe('HTML export runtime DOM', () => { textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(indicator.textContent).toBe('2/3'); }); + it('counts only top-level slide sections so nested slide content remains visible', () => { + mount('
parent
nested
second
', 'slide'); + + const parent = document.querySelector('#parent')!; + const nested = document.querySelector('#nested')!; + const second = document.querySelector('#second')!; + const indicator = document.querySelector('.nai-slide-nav span')!; + const [previous, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(indicator.textContent).toBe('1/2'); + expect(parent.style.display).toBe(''); + expect(nested.style.display).toBe(''); + next.click(); + expect(indicator.textContent).toBe('2/2'); + expect(second.style.display).toBe(''); + previous.click(); + expect(indicator.textContent).toBe('1/2'); + expect(parent.style.display).toBe(''); + expect(nested.style.display).toBe(''); + }); it('patches only the manifest runtime SHA, leaving authored CSS decoys untouched', () => { const decoy = '"runtimeSha256":"AAAA"'; diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 6253853..b9d1924 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -767,14 +767,15 @@ function sanitizeDeclarations(block: any, context: CssSanitizeContext, counts: C if (declaration.type !== 'Declaration') { strip(context, fail(CSS_VIOLATION_CODES.parseError, 'invalid declaration')); continue; } context.seenDeclarations++; if (context.seenDeclarations > CSS_MAX_DECLARATIONS) { strip(context, fail(CSS_VIOLATION_CODES.tooManyDeclarations, `more than ${CSS_MAX_DECLARATIONS} declarations`)); break; } - const property = String(declaration.property).toLowerCase(); + const declaredProperty = String(declaration.property); + const property = declaredProperty.toLowerCase(); let failure: Failure | null = null; if (!failure) failure = validateValue(declaration.value); if (!failure && PROPERTY_SET.has(property)) failure = validateNumericCaps(property, declaration.value, context); if (failure) { strip(context, failure); continue; } const value = generated(declaration.value); if ((!PROPERTY_SET.has(property) && !property.startsWith('--')) || (!property.startsWith('--') && lexer.matchProperty(property, value).error)) continue; - output.push(`${property}:${value}${declaration.important ? '!important' : ''}`); + output.push(`${declaredProperty.startsWith('--') ? declaredProperty : property}:${value}${declaration.important ? '!important' : ''}`); counts.declarationCount++; } return output.join(';'); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index fddd736..09cf6c5 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -8,7 +8,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = ` Date: Sat, 18 Jul 2026 17:16:13 +0900 Subject: [PATCH 12/21] fix(html): measure injected runtime before quarantine --- ...tml-export-generation-orchestrator.test.ts | 24 +++++++++++++++++++ .../html-export-pipeline-service.test.ts | 22 ++++++++++------- .../html-export-generation-orchestrator.ts | 10 +++++++- src/main/html-export-pipeline-service.ts | 12 +++++++++- 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/__tests__/html-export-generation-orchestrator.test.ts b/src/__tests__/html-export-generation-orchestrator.test.ts index 749cc05..c2322cb 100644 --- a/src/__tests__/html-export-generation-orchestrator.test.ts +++ b/src/__tests__/html-export-generation-orchestrator.test.ts @@ -181,6 +181,30 @@ describe('HtmlExportGenerationOrchestrator', () => { expect(pipeline.invalidateAttempt).not.toHaveBeenCalled(); expect((generate as ReturnType).calls).toHaveLength(1); }); + it('injects the selected slide runtime before quarantine and finalizes the same mode', async () => { + const quarantine: QuarantineMeasureFn = vi.fn(async () => ({ ok: true as const })); + const pipeline = createFakePipeline(); + const { orchestrator } = createOrchestrator({ pipeline, quarantine }); + + const result = await orchestrator.run(WEB_CONTENTS_ID, PROMPT, { mode: 'slide', locale: 'ko' }); + + expect(result.state).toBe('final'); + expect(pipeline.resolve).toHaveBeenCalledWith( + WEB_CONTENTS_ID, + attempt('attempt-1'), + sanitizedId('sanitized-1'), + 'slide', + 'ko', + ); + expect(quarantine).toHaveBeenCalledAfter(pipeline.resolve as ReturnType); + expect(pipeline.finalize).toHaveBeenCalledWith( + WEB_CONTENTS_ID, + attempt('attempt-1'), + resolvedId('resolved-1'), + 'slide', + 'ko', + ); + }); it('zero-decoded-byte first output retries exactly once on the same route then succeeds', async () => { const generate = createGenerate([ diff --git a/src/__tests__/html-export-pipeline-service.test.ts b/src/__tests__/html-export-pipeline-service.test.ts index c4b8e66..ffc2d04 100644 --- a/src/__tests__/html-export-pipeline-service.test.ts +++ b/src/__tests__/html-export-pipeline-service.test.ts @@ -26,6 +26,7 @@ import { type HtmlExportSanitizedPayload, } from '../main/html-export-pipeline-service'; import { bundleSanitizedHtml } from '../main/html-export-shell'; +import { htmlExportRuntimeSha256 } from '../main/html-export-runtime'; type Artifact = { ref: HtmlExportArtifactRef; @@ -780,19 +781,24 @@ describe('HtmlExportPipelineService', () => { expect(seen[0].contentRootClass).toBe('dark'); expect(seen[0].contentRootId).toBe('app'); }); - it('injects the requested runtime mode after the real shell resolver', async () => { + it('injects the requested runtime before quarantine resolution and preserves final bytes', async () => { for (const mode of ['slide', 'scroll'] as const) { const { service, registry } = serviceFor(undefined, undefined, async (payload) => bundleSanitizedHtml(payload).html); const attemptId = start(service); const raw = valueOf(service.storeRawModelOutput(1, attemptId, '
one
two
')); const sanitized = valueOf(await service.sanitize(1, attemptId, raw.id)).artifact; - const resolved = valueOf(await service.resolve(1, attemptId, sanitized.id)).artifact; - valueOf(service.finalize(1, attemptId, resolved.id, mode)); - - const finalized = registry.transitions.at(-1)!; - const html = finalized.bytes.toString('utf8'); - expect(html).toContain('id="nai-runtime"'); - expect(html.includes('if(true){var deck=content||document.body,slides=')).toBe(mode === 'slide'); + const resolved = valueOf(await service.resolve(1, attemptId, sanitized.id, mode)).artifact; + const measured = registry.transitions.at(-1)!; + const finalized = valueOf(service.finalize(1, attemptId, resolved.id, mode)).artifact; + const finalArtifact = registry.transitions.at(-1)!; + + expect(measured.stage).toBe('resolved'); + const measuredHtml = measured.bytes.toString('utf8'); + expect(measuredHtml).toContain('id="nai-runtime"'); + expect(measuredHtml.includes('if(true){var deck=content||document.body,slides=')).toBe(mode === 'slide'); + expect(measuredHtml).toContain(`"runtimeSha256":"${htmlExportRuntimeSha256(mode)}"`); + expect(finalArtifact.bytes).toEqual(measured.bytes); + expect(finalized.sha256).toBe(digest(finalArtifact.bytes)); } }); }); diff --git a/src/main/html-export-generation-orchestrator.ts b/src/main/html-export-generation-orchestrator.ts index ada9c11..d98e278 100644 --- a/src/main/html-export-generation-orchestrator.ts +++ b/src/main/html-export-generation-orchestrator.ts @@ -82,6 +82,8 @@ export type OrchestratorPipeline = { webContentsId: number, attemptId: HtmlExportAttemptId, sanitizedCandidateId: SanitizedArtifactId, + mode?: 'slide' | 'scroll', + locale?: HtmlExportRuntimeLocale, ): Promise }>>; finalize( webContentsId: number, @@ -269,7 +271,13 @@ export class HtmlExportGenerationOrchestrator { // (f) resolve stage = 'resolve'; - const resolved = await this.pipeline.resolve(webContentsId, attemptId, sanitizedArtifactId); + const resolved = await this.pipeline.resolve( + webContentsId, + attemptId, + sanitizedArtifactId, + opts?.mode ?? 'scroll', + opts?.locale ?? 'en', + ); if (!resolved.ok) { return failed('resolve', resolved.error.kind); } diff --git a/src/main/html-export-pipeline-service.ts b/src/main/html-export-pipeline-service.ts index 57fc857..2d6fd8b 100644 --- a/src/main/html-export-pipeline-service.ts +++ b/src/main/html-export-pipeline-service.ts @@ -407,6 +407,8 @@ export class HtmlExportPipelineService { webContentsId: number, attemptId: HtmlExportAttemptId, sanitizedCandidateId: SanitizedArtifactId, + mode?: HtmlExportRuntimeMode, + locale: HtmlExportRuntimeLocale = 'en', ): Promise { const sanitized = this.registry.read(webContentsId, attemptId, sanitizedCandidateId, 'sanitized'); if (!sanitized.ok) return sanitized; @@ -442,7 +444,15 @@ export class HtmlExportPipelineService { if (byteLength > HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES) { return oversize(`Pipeline payload exceeds ${HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES} bytes`); } - const bytes = typeof resolved === 'string' ? Buffer.from(resolved, 'utf8') : Buffer.from(resolved); + const resolvedHtml = typeof resolved === 'string' + ? resolved + : new TextDecoder('utf-8').decode(resolved); + const bytes = Buffer.from( + mode === undefined + ? resolvedHtml + : injectHtmlExportRuntime(resolvedHtml, mode, htmlExportRuntimeLabels(locale)), + 'utf8', + ); const verified = this.verifyCandidate(bytes, HTML_EXPORT_PIPELINE_STAGE_MAX_BYTES); if (!verified.ok) return verified; From 6db5e9b5cfbdf0c12c05a991757233bbd5b485ab Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:22:12 +0900 Subject: [PATCH 13/21] fix(html): preserve runtime locale during generation --- .../html-export-pipeline-ipc.test.ts | 101 ++++++++++++++++++ src/main/html-export-generate.ts | 8 +- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/__tests__/html-export-pipeline-ipc.test.ts b/src/__tests__/html-export-pipeline-ipc.test.ts index 9534de5..05d5071 100644 --- a/src/__tests__/html-export-pipeline-ipc.test.ts +++ b/src/__tests__/html-export-pipeline-ipc.test.ts @@ -16,6 +16,11 @@ vi.mock('electron', () => ({ dialog: {}, shell: {} })); import { dialog } from 'electron'; import { registerHtmlExportIpc } from '../main/ipc/html-export-ipc'; +import { createHtmlExportGenerator } from '../main/html-export-generate'; +import { htmlExportRuntimeLabels } from '../main/html-export-runtime-labels'; +import { injectHtmlExportRuntime } from '../main/html-export-runtime'; +import type { AiChatEvent, AiChatRequest } from '../main/ai/types'; +import type { HtmlExportRuntimeLocale } from '../main/html-export-runtime-labels'; type Sender = { id: number; @@ -1143,5 +1148,101 @@ describe('HTML export pipeline IPC', () => { model: { provider: 'ollama', id: 'llama3:latest' }, }); }); + it('preserves locale through IPC, generation, and finalization runtime labels', async () => { + const finalizedHtml: string[] = []; + const pipeline = { + beginAttempt: () => ({ ok: true as const, value: { attemptId: 'attempt-1' } }), + storeRawModelOutput: () => ({ + ok: true as const, + value: { id: 'raw-1', attemptId: 'attempt-1', stage: 'raw', sha256: 'a'.repeat(64), byteLength: 32 }, + }), + sanitize: async () => ({ + ok: true as const, + value: { + artifact: { + id: 'sanitized-1', + attemptId: 'attempt-1', + stage: 'sanitized', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }), + resolve: async () => ({ + ok: true as const, + value: { + artifact: { + id: 'resolved-1', + attemptId: 'attempt-1', + stage: 'resolved', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }), + finalize: ( + _webContentsId: number, + _attemptId: string, + _resolvedArtifactId: string, + mode: 'slide' | 'scroll' = 'scroll', + locale: HtmlExportRuntimeLocale = 'en', + ) => { + finalizedHtml.push( + injectHtmlExportRuntime( + '
One
', + mode, + htmlExportRuntimeLabels(locale), + ), + ); + return { + ok: true as const, + value: { + artifact: { + id: 'finalized-1', + attemptId: 'attempt-1', + stage: 'finalized', + sha256: 'a'.repeat(64), + byteLength: 32, + }, + }, + }; + }, + invalidateAttempt: () => undefined, + }; + const generator = createHtmlExportGenerator({ + pipeline: pipeline as never, + stream: async (_request: AiChatRequest, onEvent: (event: AiChatEvent) => void) => { + onEvent({ kind: 'delta', text: '
One
' }); + onEvent({ kind: 'done', text: '' }); + }, + quarantine: async () => ({ ok: true as const }), + }); + const sender: Sender = { id: 92, once: vi.fn() }; + registerHtmlExportIpc({ + windowForWebContents: () => null, + pipelineService: createService() as never, + assetLifecycle: createNoopAssetLifecycle(), + generateHtml: (webContentsId, input) => generator.run(webContentsId, input), + }); + + const korean = await ipc.handler('html:generate')!(eventFor(sender), { + prompt: 'make it', + model: { provider: 'ollama', id: 'llama3:latest' }, + mode: 'slide', + locale: 'ko', + }); + const english = await ipc.handler('html:generate')!(eventFor(sender), { + prompt: 'make it', + model: { provider: 'ollama', id: 'llama3:latest' }, + mode: 'slide', + }); + + expect(korean).toMatchObject({ state: 'final', finalizedArtifactId: 'finalized-1' }); + expect(english).toMatchObject({ state: 'final', finalizedArtifactId: 'finalized-1' }); + expect(finalizedHtml[0]).toContain('어두운 테마로 전환'); + expect(finalizedHtml[0]).not.toContain('Switch to dark theme'); + expect(finalizedHtml[1]).toContain('Switch to dark theme'); + expect(finalizedHtml[1]).not.toContain('어두운 테마로 전환'); + }); }); }); diff --git a/src/main/html-export-generate.ts b/src/main/html-export-generate.ts index 32c27d8..5e5c778 100644 --- a/src/main/html-export-generate.ts +++ b/src/main/html-export-generate.ts @@ -18,6 +18,7 @@ import { type QuarantineMeasureFn, } from './html-export-generation-orchestrator'; import { createHtmlExportTransport, type PinnedTransportStream } from './html-export-transport'; +import type { HtmlExportRuntimeLocale } from './html-export-runtime-labels'; import type { AiProviderId } from './ai/types'; import type { HtmlExportAttemptId, @@ -35,6 +36,7 @@ type HtmlGenerateInput = { instructions?: string; reasoningEffort?: 'low'; mode?: 'slide' | 'scroll'; + locale?: HtmlExportRuntimeLocale; /** Selected export viewport for the quarantine overflow gate only. */ viewport?: HtmlGenerateViewport; }; @@ -110,7 +112,11 @@ export function createHtmlExportGenerator(deps: HtmlExportGeneratorDeps): HtmlEx }); try { - return await orchestrator.run(webContentsId, input.prompt, { signal: controller.signal, mode: input.mode }); + return await orchestrator.run(webContentsId, input.prompt, { + signal: controller.signal, + mode: input.mode, + locale: input.locale, + }); } finally { if (controllers.get(webContentsId) === controller) controllers.delete(webContentsId); } From c92d9cc5e6da7ebb875c58caaa421785d81276a9 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:02:35 +0900 Subject: [PATCH 14/21] fix(html): preserve slide display and input selectors --- src/__tests__/html-export-css-sanitize.test.ts | 7 +++++++ src/__tests__/html-export-runtime.dom.test.ts | 16 ++++++++++++++++ src/main/html-export-css-sanitize.ts | 2 +- src/main/html-export-runtime.ts | 2 +- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index 774bfe9..f42945a 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -70,6 +70,13 @@ describe('html export CSS sanitizer', () => { declarationCount: 1, }); }); + it('allows quoted input type attribute selectors while rejecting unrelated attributes', () => { + expect(sanitizeStylesheet('input[type="range"]{width:100%}input[type="checkbox"]{height:1em}')).toMatchObject({ + ok: true, + css: '[data-he-content] input[type="range"]{width:100%}[data-he-content] input[type="checkbox"]{height:1em}', + }); + expect(failureCode(sanitizeStylesheet('input[name="volume"]{width:100%}'))).toBe('css_disallowed_selector'); + }); it('parses inline declarations and preserves duplicate shorthand/longhand order', () => { expect(sanitizeDeclarationList('margin:1px;margin-left:4px;color:red;color:blue')).toMatchObject({ diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 544b251..28b3413 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -162,6 +162,22 @@ describe('HTML export runtime DOM', () => { mount('
'); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('hides inactive slides over authored important display rules and restores the active display', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(getComputedStyle(slides[0]).display).toBe('flex'); + expect(getComputedStyle(slides[1]).display).toBe('none'); + expect(slides[1].style.getPropertyPriority('display')).toBe('important'); + + next.click(); + + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('flex'); + expect(slides[1].style.display).toBe(''); + }); it('pages slide exports by keyboard and controls while ignoring text input focus', () => { mount('
one
two
three
', 'slide'); diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index b9d1924..507b43f 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -140,7 +140,7 @@ const PSEUDO_CLASSES = new Set(['active', 'any-link', 'checked', 'default', 'def const PSEUDO_ELEMENTS = new Set(['after', 'backdrop', 'before', 'first-letter', 'first-line', 'marker', 'placeholder', 'selection']); const SAFE_ATTRIBUTE_SELECTOR_NAMES = new Set([ 'class', 'id', 'title', 'lang', 'dir', 'role', 'data-section-id', 'colspan', 'rowspan', 'scope', - 'alt', 'width', 'height', 'datetime', + 'alt', 'width', 'height', 'datetime', 'type', ]); const MEDIA_FEATURES = new Set(['width', 'min-width', 'max-width', 'height', 'orientation', 'aspect-ratio', 'prefers-color-scheme']); const FONT_SIZE_KEYWORDS = new Set(['xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large']); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index 09cf6c5..909f1cc 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -8,7 +8,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = ` Date: Sat, 18 Jul 2026 20:47:10 +0900 Subject: [PATCH 15/21] fix(html): preserve per-theme fallback and input bounds --- src/__tests__/html-export-runtime.dom.test.ts | 14 ++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 10 ++++++++++ src/main/html-export-runtime.ts | 2 +- src/main/html-export-sanitize.ts | 12 +++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 28b3413..f8a2d76 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -68,6 +68,20 @@ describe('HTML export runtime DOM', () => { expect(getComputedStyle(content).getPropertyValue('--bg').trim()).toBe('#111'); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('keeps the dark fallback for a light-only authored palette', () => { + mount('
'); + const content = document.querySelector('[data-he-content]')!; + + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.dataset.theme).toBe('dark'); + expect(document.querySelector('#nai-theme-fallback')?.textContent).toContain('filter:invert(1)'); + }); + it('skips the fallback when both authored theme palettes are present', () => { + mount('
'); + + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); it('applies a :where theme palette in the finalized artifact without a fallback', () => { const sanitized = sanitizeHtmlExport({ diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 1103311..d9015c0 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -116,6 +116,16 @@ describe('sanitizeHtmlExport', () => { if (!result.ok) return; expect(result.bodyHtml).toContain('

Kept

'); }); + it('preserves safe input bounds and strips unsafe bound values', () => { + const result = sanitize(''); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('javascript:1'); + expect(result.bodyHtml).not.toContain('Infinity'); + }); it('unwraps template content stored outside its childNodes array', () => { const result = sanitize('
'); expect(result.ok).toBe(true); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index 909f1cc..02100a9 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -8,7 +8,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = ` Date: Sun, 19 Jul 2026 10:37:15 +0900 Subject: [PATCH 16/21] fix(html): preserve functional body theme palettes and step any --- .../html-export-css-sanitize.test.ts | 4 ++-- src/__tests__/html-export-runtime.dom.test.ts | 22 +++++++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 9 ++++++++ src/main/html-export-css-sanitize.ts | 3 +-- src/main/html-export-sanitize.ts | 4 +++- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index f42945a..b1492dd 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -425,12 +425,12 @@ describe('global selector rewrite', () => { }); it('compounds all-theme :where and :is selector arguments with the content root', () => { const result = sanitizeStylesheet( - ':where([data-theme="dark"],[data-theme="light"]){--bg:#111}:is([data-theme]){--fg:#eee}:where(:root[data-theme]){--root:#fff}:is(html[data-theme]){--html:#ddd}', + ':where([data-theme="dark"],[data-theme="light"]){--bg:#111}:is([data-theme]){--fg:#eee}:where(:root[data-theme]){--root:#fff}:is(html[data-theme]){--html:#ddd}:where(body[data-theme="dark"]){--body-where:#222}:is(body[data-theme="light"]){--body-is:#ccc}', ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.css).toBe( - '[data-he-content]:where([data-theme="dark"],[data-theme="light"]){--bg:#111}[data-he-content]:is([data-theme]){--fg:#eee}[data-he-content]:where([data-he-content][data-theme]){--root:#fff}[data-he-content]:is([data-he-content][data-theme]){--html:#ddd}', + '[data-he-content]:where([data-theme="dark"],[data-theme="light"]){--bg:#111}[data-he-content]:is([data-theme]){--fg:#eee}[data-he-content]:where([data-he-content][data-theme]){--root:#fff}[data-he-content]:is([data-he-content][data-theme]){--html:#ddd}[data-he-content]:where([data-he-content][data-theme="dark"]){--body-where:#222}[data-he-content]:is([data-he-content][data-theme="light"]){--body-is:#ccc}', ); }); it('keeps mixed :where selector arguments scoped as descendants', () => { diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index f8a2d76..e064ffd 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -100,6 +100,28 @@ describe('HTML export runtime DOM', () => { expect(content.matches('[data-he-content]:where([data-theme="dark"])')).toBe(true); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('applies functional body theme palettes in finalized artifacts without a fallback', () => { + const sanitized = sanitizeHtmlExport({ + html: '
content
', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + expect(sanitized.contentCss).toContain( + '[data-he-content]:where([data-he-content][data-theme="dark"]){--body-where:#111}', + ); + expect(sanitized.contentCss).toContain( + '[data-he-content]:is([data-he-content][data-theme="dark"]){--body-is:#222}', + ); + + mount(bundleSanitizedHtml(sanitized).html); + const content = document.querySelector('[data-he-content]')!; + document.querySelector('#nai-runtime-toggle')!.click(); + + expect(content.matches('[data-he-content]:where([data-he-content][data-theme="dark"])')).toBe(true); + expect(content.matches('[data-he-content]:is([data-he-content][data-theme="dark"])')).toBe(true); + expect(document.querySelector('#nai-theme-fallback')).toBeNull(); + }); it('preserves case-sensitive custom properties and resolves their var() references in finalized artifacts', () => { const sanitized = sanitizeHtmlExport({ html: '
content
', diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index d9015c0..a90d6f7 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -126,6 +126,15 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).not.toContain('javascript:1'); expect(result.bodyHtml).not.toContain('Infinity'); }); + it('preserves case-insensitive arbitrary input steps but rejects hostile step values', () => { + const result = sanitize(''); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('step="anywhere"'); + expect(result.bodyHtml).not.toContain('step="Infinity"'); + }); it('unwraps template content stored outside its childNodes array', () => { const result = sanitize('
'); expect(result.ok).toBe(true); diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 507b43f..25e4607 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -294,8 +294,7 @@ function isThemeRootSelector(selector: any): boolean { if (nodes.length === 1) return isThemeAttributeAtom(nodes[0]); return nodes.length === 2 && isGlobalRootAtom(nodes[0]) - && isThemeAttributeAtom(nodes[1]) - && (nodes[0].type !== 'TypeSelector' || String(nodes[0].name).toLowerCase() === 'html'); + && isThemeAttributeAtom(nodes[1]); } function isLeadingThemeAtom(node: any): boolean { diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index 6119f35..f621937 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -443,7 +443,9 @@ function sanitizeAttributes(node: Node, tag: string, context: Context, survives: if ( !isAllowedAttribute(tag, name) || ((name === 'width' || name === 'height') && !DIMENSION.test(attribute.value)) - || (['min', 'max', 'step'].includes(name) && !isSafeInputBound(attribute.value)) + || (['min', 'max', 'step'].includes(name) + && !(name === 'step' && /^any$/i.test(attribute.value)) + && !isSafeInputBound(attribute.value)) ) { context.stripped.push({ code: HTML_VIOLATION_CODES.attribute, detail: `attribute ${name} is not allowed on ${tag}` }); continue; From 25c06d8927ad3d7e7c8af8087673d7e5ca74ebb5 Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:53:33 +0900 Subject: [PATCH 17/21] fix(html): restore slides for print output --- src/__tests__/html-export-runtime.dom.test.ts | 50 +++++++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 15 ++++++ src/main/html-export-runtime.ts | 2 +- src/main/html-export-sanitize.ts | 17 ++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index e064ffd..6aa388f 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -214,6 +214,56 @@ describe('HTML export runtime DOM', () => { expect(getComputedStyle(slides[1]).display).toBe('flex'); expect(slides[1].style.display).toBe(''); }); + it('shows every slide while printing, restores the active slide afterwards, and includes print-only control hiding', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + next.click(); + + window.dispatchEvent(new Event('beforeprint')); + expect(slides.every((slide) => slide.style.display === '')).toBe(true); + + window.dispatchEvent(new Event('afterprint')); + expect(slides[0].style.getPropertyPriority('display')).toBe('important'); + expect(slides[1].style.display).toBe(''); + expect(document.querySelector('#nai-print-controls')?.textContent) + .toContain('@media print{#nai-runtime-toggle,#nai-slide-nav{display:none!important}}'); + }); + it('uses the print media change listener when print events are unavailable', () => { + let printListener: ((event: MediaQueryListEvent) => void) | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + matches: false, + addEventListener: (type: string, listener: (event: MediaQueryListEvent) => void) => { + if (query === 'print' && type === 'change') printListener = listener; + }, + }), + }); + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + expect(printListener).toBeTypeOf('function'); + printListener!({ matches: true } as MediaQueryListEvent); + expect(slides.every((slide) => slide.style.display === '')).toBe(true); + printListener!({ matches: false } as MediaQueryListEvent); + expect(slides[0].style.display).toBe(''); + expect(slides[1].style.getPropertyPriority('display')).toBe('important'); + }); + it('preserves required inputs that match :required in finalized artifacts', () => { + const sanitized = sanitizeHtmlExport({ + html: '', + isAllowedAssetId: () => true, + }); + expect(sanitized.ok).toBe(true); + if (!sanitized.ok) return; + + mount(bundleSanitizedHtml(sanitized).html); + const input = document.querySelector('input')!; + expect(input.hasAttribute('required')).toBe(true); + expect(input.matches(':required')).toBe(true); + }); it('pages slide exports by keyboard and controls while ignoring text input focus', () => { mount('
one
two
three
', 'slide'); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index a90d6f7..a958060 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -126,6 +126,21 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).not.toContain('javascript:1'); expect(result.bodyHtml).not.toContain('Infinity'); }); + it('preserves inert boolean form attributes only when present without a value or with their own name', () => { + const result = sanitize( + '' + + '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('required="false"'); + expect(result.bodyHtml).not.toContain('checked="true"'); + }); it('preserves case-insensitive arbitrary input steps but rejects hostile step values', () => { const result = sanitize(''); diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index 02100a9..d266eab 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -8,7 +8,7 @@ const HTML_EXPORT_INTERACTIVE_CSP_META = `(SAFE_ROOT_ATTRIBUTE_NAMES); const TABLE_ATTRIBUTES = new Set(['colspan', 'rowspan', 'scope']); const IMAGE_ATTRIBUTES = new Set(['alt', 'width', 'height']); +const BOOLEAN_FORM_ATTRIBUTES = new Set(['required', 'checked', 'disabled', 'readonly', 'multiple']); const RESERVED_CLASS_OR_ID = /^(?:nai-|he-s|he-(?:doc|slide|scaler|runtime|manifest|shell|csp)|(?:shell|runtime|manifest|csp))/i; const ASSET_ID = /^asset:[A-Za-z0-9_-]{16,128}$/; const DIMENSION = /^(?:0|[1-9][0-9]*)(?:px)?$/; @@ -374,7 +375,11 @@ function isAllowedAttribute(tag: string, name: string): boolean { if (name === 'href') return tag === 'a'; if (name === 'src') return ['img', 'source'].includes(tag); if (name === 'type') return ['input', 'button', 'script'].includes(tag); - if (name === 'value' || name === 'name' || name === 'placeholder' || name === 'checked' || name === 'disabled') return ['input', 'button'].includes(tag); + if (name === 'value' || name === 'name' || name === 'placeholder') return ['input', 'button'].includes(tag); + if (name === 'required' || name === 'disabled') return ['input', 'textarea', 'select', 'button'].includes(tag); + if (name === 'checked') return tag === 'input'; + if (name === 'readonly') return ['input', 'textarea'].includes(tag); + if (name === 'multiple') return ['input', 'select'].includes(tag); if (['min', 'max', 'step'].includes(name)) return tag === 'input'; return false; } @@ -450,6 +455,14 @@ function sanitizeAttributes(node: Node, tag: string, context: Context, survives: context.stripped.push({ code: HTML_VIOLATION_CODES.attribute, detail: `attribute ${name} is not allowed on ${tag}` }); continue; } + if (BOOLEAN_FORM_ATTRIBUTES.has(name)) { + if (attribute.value && attribute.value.toLowerCase() !== name) { + context.stripped.push({ code: HTML_VIOLATION_CODES.attribute, detail: `boolean attribute ${name} must be empty or its own name` }); + continue; + } + output.push({ name, value: '' }); + continue; + } output.push({ name, value: attribute.value }); } return { attrs: output, inlineCss }; From 07eebb8a96a2e3a28a4ee8a4652bab83b523c58e Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:35:53 +0900 Subject: [PATCH 18/21] fix(html): preserve interactive controls and runtime boundary --- .../html-export-css-sanitize.test.ts | 6 +++ src/__tests__/html-export-runtime.dom.test.ts | 9 ++++ src/__tests__/html-export-sanitize.test.ts | 12 +++++ src/main/html-export-css-sanitize.ts | 2 +- src/main/html-export-document-markers.ts | 50 +++++++++++++++++++ src/main/html-export-runtime.ts | 6 ++- src/main/html-export-sanitize.ts | 12 +++-- 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/__tests__/html-export-css-sanitize.test.ts b/src/__tests__/html-export-css-sanitize.test.ts index b1492dd..37d5373 100644 --- a/src/__tests__/html-export-css-sanitize.test.ts +++ b/src/__tests__/html-export-css-sanitize.test.ts @@ -70,6 +70,12 @@ describe('html export CSS sanitizer', () => { declarationCount: 1, }); }); + it('accepts preserved control type selectors scoped to the content root', () => { + expect(sanitizeStylesheet('textarea{color:red}select{color:blue}option{font-weight:700}optgroup{color:green}')).toMatchObject({ + ok: true, + css: '[data-he-content] textarea{color:red}[data-he-content] select{color:blue}[data-he-content] option{font-weight:700}[data-he-content] optgroup{color:green}', + }); + }); it('allows quoted input type attribute selectors while rejecting unrelated attributes', () => { expect(sanitizeStylesheet('input[type="range"]{width:100%}input[type="checkbox"]{height:1em}')).toMatchObject({ ok: true, diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 6aa388f..062891c 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -331,6 +331,15 @@ describe('HTML export runtime DOM', () => { ); }); + it('inserts the runtime after a body boundary rather than a raw script string', () => { + const authoredScript = ``; + const output = injectHtmlExportRuntime(`${authoredScript}
content
`); + + expect(output).toContain(authoredScript); + expect((output.match(/id="nai-runtime"/g) ?? [])).toHaveLength(1); + expect(output.indexOf('id="nai-runtime"')).toBeGreaterThan(output.indexOf(authoredScript)); + expect(output.indexOf('id="nai-runtime"')).toBeLessThan(output.lastIndexOf('')); + }); it('is idempotent across double finalization', () => { const once = injectHtmlExportRuntime('content'); const twice = injectHtmlExportRuntime(once); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index a958060..4531aba 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -141,6 +141,18 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).not.toContain('required="false"'); expect(result.bodyHtml).not.toContain('checked="true"'); }); + it('preserves select options and removes unsupported option attributes', () => { + const result = sanitize( + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('type="button"'); + expect(result.bodyHtml).not.toContain('name="city"'); + }); it('preserves case-insensitive arbitrary input steps but rejects hostile step values', () => { const result = sanitize(''); diff --git a/src/main/html-export-css-sanitize.ts b/src/main/html-export-css-sanitize.ts index 25e4607..4b7ef82 100644 --- a/src/main/html-export-css-sanitize.ts +++ b/src/main/html-export-css-sanitize.ts @@ -134,7 +134,7 @@ const TYPE_SELECTORS = new Set([ 'p', 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'sub', 'sup', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', 'img', 'picture', 'source', 'svg', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'code', - 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', 'form', 'input', 'button', + 'pre', 'kbd', 'samp', 'abbr', 'time', 'a', 'form', 'input', 'textarea', 'select', 'option', 'optgroup', 'button', 'label', 'fieldset', 'legend', ]); const PSEUDO_CLASSES = new Set(['active', 'any-link', 'checked', 'default', 'defined', 'disabled', 'empty', 'enabled', 'first-child', 'first-of-type', 'focus', 'focus-visible', 'focus-within', 'has', 'hover', 'in-range', 'indeterminate', 'invalid', 'is', 'last-child', 'last-of-type', 'not', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown', 'read-only', 'read-write', 'required', 'root', 'target', 'user-invalid', 'valid', 'visited', 'where']); const PSEUDO_ELEMENTS = new Set(['after', 'backdrop', 'before', 'first-letter', 'first-line', 'marker', 'placeholder', 'selection']); diff --git a/src/main/html-export-document-markers.ts b/src/main/html-export-document-markers.ts index a0da5df..d67643c 100644 --- a/src/main/html-export-document-markers.ts +++ b/src/main/html-export-document-markers.ts @@ -64,3 +64,53 @@ export function findHtmlExportDocumentMarkers(source: string): HtmlExportDocumen return markers; } + +/** Finds the last tag outside quoted attributes, comments, and raw-text elements. */ +export function findHtmlExportBodyEnd(source: string): number { + let cursor = 0; + let bodyEnd = -1; + + while (cursor < source.length) { + if (source.startsWith('', cursor + 4); + if (commentEnd === -1) return bodyEnd; + cursor = commentEnd + 3; + continue; + } + + if (source[cursor] !== '<' || !/[A-Za-z!/]/.test(source[cursor + 1] ?? '')) { + cursor += 1; + continue; + } + + let quote: '"' | "'" | undefined; + let tagEnd = cursor + 1; + for (; tagEnd < source.length; tagEnd += 1) { + const character = source[tagEnd]; + if (quote) { + if (character === quote) quote = undefined; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '>') { + break; + } + } + if (tagEnd === source.length) return bodyEnd; + + const tag = source.slice(cursor, tagEnd + 1); + const rawTextElement = /^<(style|script|title|textarea)\b/i.exec(tag)?.[1]?.toLowerCase(); + if (rawTextElement) { + const rawTextClose = new RegExp(``, 'gi'); + rawTextClose.lastIndex = tagEnd + 1; + const rawTextClosingTag = rawTextClose.exec(source); + if (!rawTextClosingTag) return bodyEnd; + cursor = rawTextClosingTag.index + rawTextClosingTag[0].length; + continue; + } + + if (/^<\/body\s*>$/i.test(tag)) bodyEnd = cursor; + cursor = tagEnd + 1; + } + + return bodyEnd; +} diff --git a/src/main/html-export-runtime.ts b/src/main/html-export-runtime.ts index d266eab..14c1b6b 100644 --- a/src/main/html-export-runtime.ts +++ b/src/main/html-export-runtime.ts @@ -1,4 +1,5 @@ import { sha256Base64 } from '../shared/sha256'; +import { findHtmlExportBodyEnd } from './html-export-document-markers'; import { htmlExportRuntimeLabels, type HtmlExportRuntimeLabels } from './html-export-runtime-labels'; export type HtmlExportRuntimeMode = 'scroll' | 'slide'; @@ -31,7 +32,10 @@ export function injectHtmlExportRuntime( const script = ``; output = /]*>[\s\S]*?<\/script\s*>/i.test(output) ? output.replace(/]*>[\s\S]*?<\/script\s*>/i, script) - : /<\/body\s*>/i.test(output) ? output.replace(/<\/body\s*>/i, `${script}`) : `${output}${script}`; + : (() => { + const bodyEnd = findHtmlExportBodyEnd(output); + return bodyEnd === -1 ? `${output}${script}` : `${output.slice(0, bodyEnd)}${script}${output.slice(bodyEnd)}`; + })(); const manifestScript = /(]*\bid=["']he-manifest["'])[^>]*>)([\s\S]*?)(<\/script\s*>)/i; return output.replace(manifestScript, (_match, open, manifest, close) => { const patchedManifest = manifest.replace( diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index 5663599..26084da 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -117,7 +117,7 @@ const ALLOWED_TAGS = new Set([ 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'sub', 'sup', 'br', 'hr', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'figure', 'figcaption', 'img', 'picture', 'source', 'svg', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'code', 'pre', 'kbd', 'samp', - 'abbr', 'time', 'a', 'script', 'form', 'input', 'textarea', 'select', 'button', + 'abbr', 'time', 'a', 'script', 'form', 'input', 'textarea', 'select', 'option', 'optgroup', 'button', 'label', 'fieldset', 'legend', ]); const ACTIVE_TAGS = new Set([ 'iframe', 'object', 'embed', 'base', 'frame', 'frameset', 'applet', 'link', 'template', 'slot', @@ -131,7 +131,7 @@ const SAFE_ROOT_ATTRIBUTE_NAMES = ['lang', 'dir', 'title', 'role'] as const; const SAFE_ROOT_ATTRIBUTE_NAME_SET = new Set(SAFE_ROOT_ATTRIBUTE_NAMES); const TABLE_ATTRIBUTES = new Set(['colspan', 'rowspan', 'scope']); const IMAGE_ATTRIBUTES = new Set(['alt', 'width', 'height']); -const BOOLEAN_FORM_ATTRIBUTES = new Set(['required', 'checked', 'disabled', 'readonly', 'multiple']); +const BOOLEAN_FORM_ATTRIBUTES = new Set(['required', 'checked', 'disabled', 'readonly', 'multiple', 'selected']); const RESERVED_CLASS_OR_ID = /^(?:nai-|he-s|he-(?:doc|slide|scaler|runtime|manifest|shell|csp)|(?:shell|runtime|manifest|csp))/i; const ASSET_ID = /^asset:[A-Za-z0-9_-]{16,128}$/; const DIMENSION = /^(?:0|[1-9][0-9]*)(?:px)?$/; @@ -375,10 +375,14 @@ function isAllowedAttribute(tag: string, name: string): boolean { if (name === 'href') return tag === 'a'; if (name === 'src') return ['img', 'source'].includes(tag); if (name === 'type') return ['input', 'button', 'script'].includes(tag); - if (name === 'value' || name === 'name' || name === 'placeholder') return ['input', 'button'].includes(tag); - if (name === 'required' || name === 'disabled') return ['input', 'textarea', 'select', 'button'].includes(tag); + if (name === 'value') return ['input', 'button', 'option'].includes(tag); + if (name === 'name' || name === 'placeholder') return ['input', 'button'].includes(tag); + if (name === 'label') return ['option', 'optgroup'].includes(tag); + if (name === 'required') return ['input', 'textarea', 'select', 'button'].includes(tag); + if (name === 'disabled') return ['input', 'textarea', 'select', 'option', 'optgroup', 'button'].includes(tag); if (name === 'checked') return tag === 'input'; if (name === 'readonly') return ['input', 'textarea'].includes(tag); + if (name === 'selected') return tag === 'option'; if (name === 'multiple') return ['input', 'select'].includes(tag); if (['min', 'max', 'step'].includes(name)) return tag === 'input'; return false; From 37ba7c616f8144ae86ef5e478d5887296796177f Mon Sep 17 00:00:00 2001 From: project820 <239489026+project820@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:12:19 +0900 Subject: [PATCH 19/21] fix(html): restore active slide and form metadata --- .../html-export-direct-prompt.test.ts | 1 + src/__tests__/html-export-runtime.dom.test.ts | 27 +++++++++++++++++++ src/__tests__/html-export-sanitize.test.ts | 14 ++++++++++ src/main/html-export-runtime.ts | 2 +- src/main/html-export-sanitize.ts | 12 ++++++++- src/renderer/html-export-direct-prompt.ts | 2 +- 6 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/__tests__/html-export-direct-prompt.test.ts b/src/__tests__/html-export-direct-prompt.test.ts index 697e11b..ce9364f 100644 --- a/src/__tests__/html-export-direct-prompt.test.ts +++ b/src/__tests__/html-export-direct-prompt.test.ts @@ -133,6 +133,7 @@ describe('buildDirectHtmlPrompt — 1:1 config mapping + full source', () => { expect(prompt).toMatch(/\bmain\b/); expect(prompt).toMatch(/\baside\b/); expect(prompt).toMatch(/conversational preamble/i); + expect(prompt).toMatch(/form\/input\/button\/textarea\/select\/option\/optgroup\/label\/fieldset\/legend/); expect(prompt).toMatch(/Sure, here is/i); expect(prompt).toMatch(/I hope this helps/i); expect(prompt).toMatch(/whether bare text or wrapped in an element/i); diff --git a/src/__tests__/html-export-runtime.dom.test.ts b/src/__tests__/html-export-runtime.dom.test.ts index 062891c..94b691e 100644 --- a/src/__tests__/html-export-runtime.dom.test.ts +++ b/src/__tests__/html-export-runtime.dom.test.ts @@ -198,6 +198,33 @@ describe('HTML export runtime DOM', () => { mount('
'); expect(document.querySelector('#nai-theme-fallback')).toBeNull(); }); + it('maintains the authored active-slide convention through navigation and printing', () => { + mount('
one
two
', 'slide'); + + const slides = Array.from(document.querySelectorAll('section.slide')); + const [, next] = Array.from(document.querySelectorAll('.nai-slide-nav button')); + + expect(slides[0].classList.contains('active')).toBe(true); + expect(slides[1].classList.contains('active')).toBe(false); + expect(getComputedStyle(slides[0]).display).toBe('block'); + expect(getComputedStyle(slides[1]).display).toBe('none'); + + next.click(); + expect(slides[0].classList.contains('active')).toBe(false); + expect(slides[1].classList.contains('active')).toBe(true); + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('block'); + + window.dispatchEvent(new Event('beforeprint')); + expect(slides.every((slide) => slide.classList.contains('active'))).toBe(true); + expect(slides.every((slide) => getComputedStyle(slide).display === 'block')).toBe(true); + + window.dispatchEvent(new Event('afterprint')); + expect(slides[0].classList.contains('active')).toBe(false); + expect(slides[1].classList.contains('active')).toBe(true); + expect(getComputedStyle(slides[0]).display).toBe('none'); + expect(getComputedStyle(slides[1]).display).toBe('block'); + }); it('hides inactive slides over authored important display rules and restores the active display', () => { mount('
one
two
', 'slide'); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 4531aba..ed2e09c 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -141,6 +141,20 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).not.toContain('required="false"'); expect(result.bodyHtml).not.toContain('checked="true"'); }); + it('preserves inert label and control metadata while stripping hostile values', () => { + const result = sanitize( + '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('javascript:'); + expect(result.bodyHtml).not.toContain('https://example.test'); + }); it('preserves select options and removes unsupported option attributes', () => { const result = sanitize( '' + + '', + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).not.toContain('user[ mail]'); + expect(result.bodyHtml).not.toContain('user["email"]'); + expect(result.bodyHtml).not.toContain('https://example.test'); + }); it('preserves select options and removes unsupported option attributes', () => { const result = sanitize( '', + html: '', isAllowedAssetId: () => true, }); expect(sanitized.ok).toBe(true); @@ -288,8 +288,14 @@ describe('HTML export runtime DOM', () => { mount(bundleSanitizedHtml(sanitized).html); const input = document.querySelector('input')!; + const [unselectedOption, selectedOption] = Array.from(document.querySelectorAll('option')); expect(input.hasAttribute('required')).toBe(true); - expect(input.matches(':required')).toBe(true); + expect(input.hasAttribute('checked')).toBe(true); + expect(input.required).toBe(true); + expect(input.checked).toBe(true); + expect(selectedOption.hasAttribute('selected')).toBe(true); + expect(unselectedOption.selected).toBe(false); + expect(selectedOption.selected).toBe(true); }); it('pages slide exports by keyboard and controls while ignoring text input focus', () => { mount('
one
two
three
', 'slide'); diff --git a/src/__tests__/html-export-sanitize.test.ts b/src/__tests__/html-export-sanitize.test.ts index 3ce2ace..73df52d 100644 --- a/src/__tests__/html-export-sanitize.test.ts +++ b/src/__tests__/html-export-sanitize.test.ts @@ -126,11 +126,11 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).not.toContain('javascript:1'); expect(result.bodyHtml).not.toContain('Infinity'); }); - it('preserves inert boolean form attributes only when present without a value or with their own name', () => { + it('normalizes inert boolean form attributes to bare presence regardless of their authored value', () => { const result = sanitize( '' + '' + - '', + '', ); expect(result.ok).toBe(true); @@ -138,8 +138,11 @@ describe('sanitizeHtmlExport', () => { expect(result.bodyHtml).toContain(''); expect(result.bodyHtml).toContain(''); expect(result.bodyHtml).toContain(''); - expect(result.bodyHtml).not.toContain('required="false"'); + expect(result.bodyHtml).toContain(''); + expect(result.bodyHtml).toContain(''); expect(result.bodyHtml).not.toContain('checked="true"'); + expect(result.bodyHtml).not.toContain('required="true"'); + expect(result.bodyHtml).not.toContain('selected="true"'); }); it('preserves inert label and control metadata while stripping hostile values', () => { const result = sanitize( diff --git a/src/main/html-export-sanitize.ts b/src/main/html-export-sanitize.ts index 02fa7f8..b329d37 100644 --- a/src/main/html-export-sanitize.ts +++ b/src/main/html-export-sanitize.ts @@ -470,10 +470,6 @@ function sanitizeAttributes(node: Node, tag: string, context: Context, survives: continue; } if (BOOLEAN_FORM_ATTRIBUTES.has(name)) { - if (attribute.value && attribute.value.toLowerCase() !== name) { - context.stripped.push({ code: HTML_VIOLATION_CODES.attribute, detail: `boolean attribute ${name} must be empty or its own name` }); - continue; - } output.push({ name, value: '' }); continue; }