Skip to content

feat(html): enable interactive export runtime - #71

Merged
project820 merged 21 commits into
mainfrom
feat/interactive-html-export
Jul 20, 2026
Merged

feat(html): enable interactive export runtime#71
project820 merged 21 commits into
mainfrom
feat/interactive-html-export

Conversation

@project820

Copy link
Copy Markdown
Owner

Closes #70

Enables inline interactive HTML export primitives, a CSP that blocks network access, app-injected theme controls, and slide navigation support. Updates direct-authoring instructions for themed and interactive output.

Verification: npm run typecheck.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 357fc93344

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set data-theme on the content root

When the model follows the new prompt and defines palettes under [data-theme="light"] / [data-theme="dark"], the CSS sanitizer scopes those selectors under [data-he-content] (or rewrites html/body/:root to that wrapper), but this runtime toggles data-theme on document.documentElement. In that normal export path the authored custom properties never match, and the fallback is skipped because the runtime detects authored [data-theme] vars, so color declarations using var() can compute as invalid/default instead of applying either palette.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-shell.ts Outdated
'</html>\n';

return { html, manifest };
return { html: injectHtmlExportRuntime(html), manifest };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer runtime injection until layout mode is known

bundleSanitizedHtml is the resolver used by src/main/main.ts, so this call injects the runtime with the default scroll mode before finalization has any chance to know that the wizard selected slides. Because injectHtmlExportRuntime returns early once id="nai-runtime" exists, even a later finalize(..., 'slide') could not replace the already-injected if(false) runtime, leaving slide exports without the new deck navigation/hiding behavior.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: DO NOT MERGE — the required verify check fails on the PR head, and slide mode never reaches the runtime finalizer.

Summary

This implements the interactive export path for #70: sanitizer relaxation, a new app-owned runtime, and revised authoring instructions.
The current branch breaks the existing HTML-export contract tests, so the required CI gate is red.
Separately, both production finalization paths omit the selected export mode, so a slide export receives the runtime's default scroll mode and never initializes slide navigation.

Findings (by severity)

  • 🤷🏻‍♂️ P0 Critical-WTF? — Required verify CI is failing on 357fc93344fe323c73c7bc9124170feed5b01cd0: GitHub's npm run test reports 26 failures. The failures include the shell contract, sanitizer expectations, finalization-byte assertions, and the wizard prompt expectation. Update the affected tests for the intended contract and add coverage for the new runtime before this can merge.
  • 🔴 P1 Highsrc/main/html-export-generation-orchestrator.ts:309 finalizes with only (webContentsId, attemptId, resolvedArtifactId), while HtmlExportPipelineService.finalize defaults its fourth argument to 'scroll'. src/main/html-export-generate.ts also carries no mode field through its input to the orchestrator. Consequently the injected runtime emits if(false) for its slide branch even when the prompt requested slide mode, so the promised slide controls and keyboard navigation are absent. Thread the resolved config mode through html:generate → generator/orchestrator → finalize, and cover both scroll and slide outputs.
📋 Detailed analysis (measured CI, diff, and validation)
  • Head: 357fc93344fe323c73c7bc9124170feed5b01cd0; diff: +62/−45 across 6 files.
  • GitHub HEAD check: verify = FAILURE (npm run test, 26 failures). mergeable=MERGEABLE, mergeStateStatus=UNSTABLE.
  • Local verification: npm run typecheck passed.
  • Focused local checks reproduced the red suite: html-export-shell.test.ts 6 failed / 12 passed; html-export-sanitize.test.ts 13 failed / 133 passed; html-export-finalize.test.ts 2 failed / 6 passed.
  • Runtime probe: the default call to injectHtmlExportRuntime(...) printed default-finalize=scroll; slide-navigation-branch=false.
  • Opened for context: html-export-runtime.ts, html-export-pipeline-service.ts, html-export-generation-orchestrator.ts, html-export-generate.ts, html-export-ipc.ts, the shell/sanitizer/finalize tests, and the direct prompt.
  • Caller trace: finalize has two production callers (html-export-generation-orchestrator.ts:309 and html-export-ipc.ts:567); neither passes mode, so both select the default.
  • Other review evidence: no existing reviews, issue comments, or unresolved review threads were present. No other open PR touches these six files. Related issue: #70.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

⛔ DO NOT MERGE

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2b7ca505ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated
let output = html.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>\s*/gi, '');
const head = `${HTML_EXPORT_INTERACTIVE_CSP_META}`;
output = /<\/head\s*>/i.test(output) ? output.replace(/<\/head\s*>/i, `${head}</head>`) : `${head}${output}`;
if (/id=["']nai-runtime["']/i.test(output)) return output;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve nai-runtime before skipping injection

When generated content happens to include id="nai-runtime", the sanitizer does not reserve the new nai-* namespace, so this whole-document regex treats authored markup inside [data-he-content] as the app runtime and returns before injecting the real script. In that case the finalized file loses the theme toggle, and slide exports lose navigation; please either check for the actual app <script id="nai-runtime"> outside authored content or reject/reserve that id during sanitization.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
'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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate model scripts on the interactive option

When the user selects a static export (for example the report/blog/proposal presets default to interactive: false), the pipeline still uses this same sanitizer and never passes the interactive setting into sanitizeHtmlExport, so adding script here preserves model-authored scripts unconditionally. If the model emits a script despite the static prompt, the saved file becomes interactive instead of honoring the UI knob; please strip scripts unless interactivity is enabled or carry that option into the sanitizer.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de61c1627c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]');if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.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=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],function(rule){if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches})}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][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((content||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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recurse into layered theme rules before adding fallback

Fresh evidence in this revision is that sanitized author styles are always emitted under @layer he-authored (src/main/html-export-sanitize.ts line 718), but this runtime only inspects the top-level sheet.cssRules entries for selectorText and custom-property declarations. For normal exports that follow the new prompt and define [data-theme="light"] / [data-theme="dark"] variables inside that layer (or nested media/supports), the scan misses the authored palettes, treats authored as false, and injects the inversion fallback on top of the real dark palette, so toggling dark mode corrupts the intended colors instead of using the generated theme.

Useful? React with 👍 / 👎.

@project820
project820 force-pushed the feat/interactive-html-export branch from 5a9a3a8 to 9676b17 Compare July 18, 2026 04:55
@project820
project820 force-pushed the feat/interactive-html-export branch from 9676b17 to e81e5a9 Compare July 18, 2026 04:56
@project820

Copy link
Copy Markdown
Owner Author

The DO NOT MERGE review targets stale HEAD 357fc93 (first commit). Point-by-point status at current HEAD e81e5a9 (CI: verify SUCCESS — which itself refutes the P0):

  • P0 26 test failures → fixed in the policy-test conversion round (2b7ca50); full suite 2,358 green + security/direct/roundtrip e2e green since.
  • P1 mode not threaded → fixed in 2b7ca50 (wizard→preload→IPC→generator→orchestrator→finalize, validated at IPC; integration test composes the real bundleSanitizedHtml resolver).
  • Inline P1 data-theme on content root → fixed in de61c16 (runtime mirrors data-theme onto [data-he-content]; scoper compounds [data-theme] atoms with the content root).
  • P2 defer injection until mode known → fixed in de61c16 (finalize is the sole injection authority; shell pre-injection removed).
  • P2 nai-runtime reservation → fixed in de61c16 (nai-/data-nai- reserved across ids/classes/attrs/CSS).
  • P2 layered/@media fallback recursion → tracked as follow-up in [follow-up batch] non-blocking residuals from #61/#63 review rounds #66 (CSP-backstopped display edge).
  • P2 gating model scripts on the interactive checkbox → deliberate current policy: scripts are allowed regardless (personal-use posture, network blocked by CSP); prompt only solicits interactivity when the option is on. Filed as a wizard-preference follow-up in [follow-up batch] non-blocking residuals from #61/#63 review rounds #66.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e81e5a93e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-sanitize.ts Outdated
'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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve scripts authored in the document head

When the model follows the new interactive prompt but emits its setup script in <head> (a common pattern for DOMContentLoaded handlers or module/bootstrap code), adding script to the allowed vocabulary still does not preserve it: the head branch later calls scanDiscardedNode(...) and returns [], and that scan only extracts styles/diagnostics rather than moving allowed scripts into the sanitized body. Those exports will finalize successfully but lose the authored interactivity; please either keep/move allowed head scripts or prompt/sanitize consistently so scripts outside the body are rejected rather than silently dropped.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8c248e288

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-css-sanitize.ts Outdated
const text = generated(rewritten);
return beginsAtRoot ? text : `${CONTENT_ROOT_SELECTOR} ${text}`;
if (text.startsWith(CONTENT_ROOT_SELECTOR)) return text;
if (text.startsWith('[data-theme=') || text.startsWith('[data-theme]')) return `${CONTENT_ROOT_SELECTOR}${text}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject sibling combinators on theme-root selectors

When generated CSS starts a theme selector with a sibling combinator, e.g. [data-theme="dark"] ~ button, this concatenation produces [data-he-content][data-theme=dark]~button instead of keeping the right-hand side inside the wrapper. In finalized files the app appends the theme toggle and slide controls as following body siblings, so such authored CSS can hide or restyle those app controls and breaks the sanitizer's content-root containment; reject sibling combinators for theme-root selectors or scope the rest under the content root.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
if (isFailure(sanitized)) return { ok: false, violations: [sanitized.violation] };
outputNodes.push(...sanitized);
}
outputNodes.push(...context.relocatedHeadScripts);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve relative order for relocated head scripts

When a generated document uses the normal pattern <head><script>window.init=...</script></head><body>...<script>init()</script>, the head script is queued during head scanning but then appended after all sanitized body nodes here. That reverses execution order versus the authored HTML, so body scripts can run before helpers/bootstrap state from the head exists even though both scripts were preserved; insert relocated head scripts before body scripts or keep them in the head while maintaining source order.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6a81a3cf8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate event handlers on the interactive export setting

When the user selects a static export in Detail mode, this sanitizer path still preserves inline handlers such as <button onclick="..."> because the pipeline does not pass the interactive setting into sanitizeHtmlExport. This is separate from the existing script-tag issue: event-handler attributes execute under the injected inline-script CSP even without any <script> element, so a model that emits onclick despite the static prompt produces an interactive saved file instead of honoring the UI knob.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same disposition as the script-tag twin (see PR discussion): allowing scripts/handlers regardless of the interactive checkbox is the deliberate personal-use policy — the checkbox shapes the PROMPT (whether interactivity is solicited), not the sanitize vocabulary; network is blocked by CSP either way. Both are tracked together in #66 as an optional wizard preference (strip scripts+handlers when interactive is OFF).

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — CI is green and earlier blockers are fixed, but the runtime still misdetects authored theme palettes inside the sanitizer's @layer wrapper.

Summary

This settlement re-measured the interactive HTML-export path at a6a81a3 after five follow-up commits.
The mode plumbing, content-root theme propagation, runtime namespace reservation, and relocated head-script ordering now have code and test coverage.
One P2 remains: a normal layered [data-theme] palette is missed, so the fallback inversion is injected over the authored dark theme.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-runtime.ts:10 only iterates top-level sheet.cssRules and accepts rules with selectorText. src/main/html-export-sanitize.ts:727 always emits authored styles inside @layer he-authored, whose top-level CSSLayerBlockRule has no selectorText. Thus an authored [data-he-content][data-theme="dark"]{--bg:...} palette is not detected; #nai-theme-fallback is added and its inversion/filter can corrupt the selected dark palette. Recursively inspect nested CSS grouping rules (including @layer, @media, and @supports) before deciding whether to inject the fallback, and add a finalized-artifact regression test.

Response to bot findings

  • Content-root theme propagation, deferred finalization, and nai-* reservation are present in the current diff; the focused runtime and pipeline tests cover those paths.
  • The latest commit places relocated head scripts before body scripts and scopes leading theme sibling selectors under the content root; corresponding sanitizer tests pass.
  • The owner explicitly described static-export script/handler retention as the current product policy; this review does not reclassify that policy choice as a code defect.
  • The layered-theme finding remains unresolved. All nine Codex threads are still open in GitHub, so only the items above were verified as addressed by the current code and tests.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: a6a81a3cf84499d2977c0323b657757b16c8639b; +461/−249 across 23 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub tip CI: required verify succeeded on this exact SHA (typecheck, 2,364 Vitest tests, build, security/direct/roundtrip harnesses, and knip).
  • Local validation on this SHA: npm ci, npm run typecheck, npm test (149 files / 2,364 tests), npm run build, and npm run test:html-export-direct all passed. The direct harness covered scroll/slides × landscape/portrait with zero remote requests and preview/save digest equality.
  • Files opened for context: html-export-runtime.ts, html-export-sanitize.ts, html-export-css-sanitize.ts, html-export-shell.ts, html-export-pipeline-service.ts, html-export-generation-orchestrator.ts, html-export-ipc.ts, the runtime/sanitizer/finalize/pipeline tests, and the direct harness.
  • Caller trace: bundleSanitizedHtml is the main-process resolver; finalization has production callers in the generation orchestrator and quarantine IPC handler. Both now pass the selected mode when supplied. injectHtmlExportRuntime is the only runtime injector.
  • Adversarial pass — 4 angles tried: mode/ordering (current callers thread mode and finalize is the sole injector), namespace collision (sanitizer reserves nai-*), head/body script ordering (relocated scripts are prepended), and authored palette detection. The first three were rejected by current code plus tests; the layered palette attack holds because the runtime does not recurse into grouping rules.
  • Related work: PR closes #70. No other open PR was found touching these 23 files. The initial review is here.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa21348240

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-css-sanitize.ts Outdated
const text = generated(rewritten);
return beginsAtRoot ? text : `${CONTENT_ROOT_SELECTOR} ${text}`;
if (text.startsWith(CONTENT_ROOT_SELECTOR)) return text;
if (text.startsWith('[data-theme=') || text.startsWith('[data-theme]')) return `${CONTENT_ROOT_SELECTOR}${text}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope functional theme selectors to the content root

When generated CSS uses the common specificity-neutral form :where([data-theme="dark"]) (or :is(...)) for its palette, this branch does not recognize it as a theme-root selector and falls through to prefixing a descendant selector like [data-he-content] :where([data-theme=dark]). The runtime sets data-theme on [data-he-content] itself, not on a child, so those custom-property palettes never match and color declarations using var() can render invalid or fall back to the inversion filter instead of the authored theme.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79799213f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]');if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.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);function hasAuthoredTheme(rule){if(rule.cssRules&&rule.cssRules.length)return Array.prototype.some.call(rule.cssRules,hasAuthoredTheme);if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches}var authored=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],hasAuthoredTheme)}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][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((content||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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require data-theme before suppressing fallback

When an export uses custom properties for ordinary styling but does not define theme palettes (for example sanitized :root{--fg:#111} body{color:var(--fg)} becomes a [data-he-content]{--fg:#111} rule), this check still marks it as authored just because the selector contains data-he-content and declares a --* property. The fallback inversion is then not injected, so the theme toggle only changes data-theme and dark mode has neither an authored palette nor the fallback; please require a matching theme-qualified rule such as [data-theme="dark"]/[data-theme="light"] before skipping the fallback.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2806292279

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]');if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.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);function hasAuthoredTheme(rule){if(rule.cssRules&&rule.cssRules.length)return Array.prototype.some.call(rule.cssRules,hasAuthoredTheme);if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!/\\[\\s*data-theme\\s*(?:[~|^$*]?=|\\])/i.test(rule.selectorText)||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches}var authored=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],hasAuthoredTheme)}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][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((content||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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore inactive media rules when detecting authored themes

Fresh evidence in this revision is the unconditional recursion through rule.cssRules before setting authored. When a generated stylesheet only defines [data-theme] custom properties inside a non-matching grouping rule, such as @media print or (prefers-color-scheme: dark) while the user is in light mode, this scan still suppresses the fallback even though no active palette applies, so the theme toggle can leave the export without usable dark colors; please only count active media/supports rules before skipping the fallback.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]');if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.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);function hasAuthoredTheme(rule){if(rule.cssRules&&rule.cssRules.length)return Array.prototype.some.call(rule.cssRules,hasAuthoredTheme);if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!/\\[\\s*data-theme\\s*(?:[~|^$*]?=|\\])/i.test(rule.selectorText)||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches}var authored=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],hasAuthoredTheme)}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][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((content||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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Localize injected runtime labels

AGENTS.md requires user-facing strings to use the five-locale i18n layer rather than hardcoded copy. The new app-owned export toggle always injects English aria-label text, so users exporting from ko/zh/ja locales still get English runtime controls in the saved document; please pass localized labels into finalization/runtime injection or otherwise derive them from the selected locale.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 571455fc47

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-css-sanitize.ts Outdated
if (!PROPERTY_SET.has(property) || lexer.matchProperty(property, value).error) continue;
output.push(`${property}:${value}`);
if ((!PROPERTY_SET.has(property) && !property.startsWith('--')) || (!property.startsWith('--') && lexer.matchProperty(property, value).error)) continue;
output.push(`${property}:${value}${declaration.important ? '!important' : ''}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve custom-property name casing

When a generated theme uses a mixed-case custom property such as --AccentColor and references it with var(--AccentColor), this emitter writes the already lower-cased property name while leaving the var() reference unchanged. CSS custom property names are case-sensitive, so the sanitized output defines --accentcolor but references --AccentColor, causing the prompted theme/color declarations to fall back or compute invalid.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode, labels: HtmlExportRuntimeLabels): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]'),labels=${JSON.stringify(labels)};if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';var label=theme==='dark'?labels.switchToLightTheme:labels.switchToDarkTheme;button.setAttribute('aria-label',label);button.title=label}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);function groupingRuleMatches(rule){try{if((typeof CSSMediaRule!=='undefined'&&rule instanceof CSSMediaRule)||rule.type===4)return window.matchMedia(rule.conditionText).matches;if((typeof CSSSupportsRule!=='undefined'&&rule instanceof CSSSupportsRule)||rule.type===12)return CSS.supports(rule.conditionText)}catch(_e){}return true}function hasAuthoredTheme(rule){if(rule.cssRules&&rule.cssRules.length){if(!groupingRuleMatches(rule))return false;return Array.prototype.some.call(rule.cssRules,hasAuthoredTheme)}if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!/\\[\\s*data-theme\\s*(?:[~|^$*]?=|\\])/i.test(rule.selectorText)||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches}var authored=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],hasAuthoredTheme)}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][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((content||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='›';previous.setAttribute('aria-label',labels.previousSlide);previous.title=labels.previousSlide;next.setAttribute('aria-label',labels.nextSlide);next.title=labels.nextSlide;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;var indicatorLabel=labels.slideIndicator.replace('{current}',String(index+1)).replace('{total}',String(slides.length));indicator.setAttribute('aria-label',indicatorLabel);indicator.title=indicatorLabel;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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit slide navigation to top-level slides

In slide-mode exports where a generated slide contains a nested <section class="slide">, this descendant-wide query adds both the parent and the child to the deck. When navigation reaches the nested child, show() hides the parent slide, leaving the selected child inside a display:none ancestor and producing a blank page; build the deck from direct content-root slide children instead of every descendant.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f13be82ba1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

webContentsId,
attemptId,
resolvedArtifactId,
opts?.mode,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure slide runtime before rejecting overflow

When opts.mode is slide and generated CSS lays <section class="slide"> panels in a horizontal track, this mode-specific runtime is not injected until after the quarantine block above has measured the resolved shell with every slide still visible. I checked the live main callback: main.ts rejects measurement.horizontalOverflow as layout-violation, so these slide exports fail generation even though the finalized runtime would hide inactive slides before the user opens the file. Finalize or otherwise emulate the slide runtime before quarantine measurement for slide mode.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — CI is green, but Korean/Chinese/Japanese exports still finalize with English runtime labels, and slide layout is rejected before its runtime can make the deck non-overflowing.

Summary

This PR adds the interactive HTML-export runtime, CSP confinement, theme controls, slide navigation, and related authoring contracts for #70.
The latest head fixes the previously reported custom-property casing and nested-slide defects.
Two P2 paths remain in the live generation flow: locale is dropped before finalization, and the quarantine gate measures slide output before its mode-specific runtime exists.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-generate.ts:32-40,113 drops locale from the live generation path. The renderer supplies it to html:generate and the IPC forwards it, but HtmlGenerateInput has no locale field and orchestrator.run(...) receives only signal and mode. A direct execution with locale: 'ko' reached finalize as mode='slide', locale=undefined, selecting the default English labels. Preserve and forward the validated locale, then cover the renderer → IPC → generator → finalizer path.
  • 🟠 P2 Medsrc/main/html-export-generation-orchestrator.ts:280-320 invokes quarantine before finalize; src/main/main.ts:130-142 rejects horizontalOverflow at that point. Slide mode only hides non-active slides in the runtime injected by finalization, so a horizontal slide track can be rejected while its finalized artifact would render as a single-slide deck. A probe forcing the layout gate returned failed at quarantine with zero finalizer calls. Measure a mode-correct transient artifact (or otherwise emulate the slide runtime) before enforcing the overflow verdict.

Response to bot findings

  • The content-root theme attribute, deferred injection, and nai-* reservation findings are resolved in the current runtime/sanitizer paths and their focused tests.
  • Layer/media theme traversal, functional theme selectors, theme-qualified fallback detection, relocated head-script order, and theme-root sibling containment are present in the current diff and covered by focused tests.
  • The custom-property casing and nested-slide findings are resolved by f13be82; the finalized-artifact DOM tests cover both regressions.
  • Script-tag and inline-handler retention for static exports remain the owner's documented personal-use policy in the thread, tracked as an optional preference in #66; they are not folded into this code verdict.
  • Runtime localization remains unresolved in the live path and is included above.
  • The new slide/quarantine ordering finding remains unresolved and is included above.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: f13be82ba11e0988c6186f4c7362e78bc18942d9; +741/−253 across 25 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub HEAD CI: verify succeeded on this exact SHA. Its measured steps include npm ci, preflight, typecheck, 2,366-test Vitest suite, build, security/converter/direct/roundtrip harnesses, and knip.
  • Local validation on this SHA passed: npm ci, npm run typecheck, npm run test, npm run build, npm run test:security-e2e, npm run test:converter-e2e, npm run test:html-export-direct, npm run test:roundtrip-smoke, and npm run knip.
  • Direct runtime probe after the top-level-slide fix: nested slides produced 1/2; after navigating away and back, both parent and nested slide elements remained visible.
  • Caller trace: unified-chat-wiring.ts supplies locale, html-export-ipc.ts validates/forwards it, main.ts calls htmlExportGenerator.run, and html-export-generate.ts drops it before the orchestrator. The pipeline finalizer accepts locale but defaults to English.
  • Adversarial pass — 4 angles tried: async/order (finalization happens after quarantine, and the ordering refutation holds), localization contract (live locale forwarding is absent), nested slide containment (rejected by the new regression test), and custom-property casing (rejected by the new finalized-artifact regression test).
  • Related work: this PR closes #70. No other open PR touches its changed files. No revert history was found for the core export files.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 764c59ea7b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-generate.ts Outdated

try {
return await orchestrator.run(webContentsId, input.prompt, { signal: controller.signal });
return await orchestrator.run(webContentsId, input.prompt, { signal: controller.signal, mode: input.mode });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward locale to the orchestrator

Fresh evidence in this revision is that the renderer and IPC now send/validate locale, but the live main-owned generator still omits it when calling orchestrator.run. In non-English app locales, exports therefore finalize with the default English runtime labels even though localized labels were added and the request carried the selected locale; pass input.locale through here so resolve/finalize can inject the correct labels.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 6db5e9b (pushed after this comment was filed): HtmlGenerateInput now carries the validated locale and passes it into orchestrator.run options; IPC→generator→finalizer covered by tests asserting Korean labels with locale 'ko' and English default when absent. CI green at HEAD.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6db5e9b5cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (!PROPERTY_SET.has(property) || lexer.matchProperty(property, value).error) continue;
output.push(`${property}:${value}`);
if ((!PROPERTY_SET.has(property) && !property.startsWith('--')) || (!property.startsWith('--') && lexer.matchProperty(property, value).error)) continue;
output.push(`${declaredProperty.startsWith('--') ? declaredProperty : property}:${value}${declaration.important ? '!important' : ''}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip !important that defeats slide hiding

When slide exports include common author CSS such as section.slide{display:flex!important}, preserving !important here lets the stylesheet beat the runtime's later normal-priority s.style.display='none' assignment used to hide inactive slides. Those slides remain visible, so deck navigation can show multiple slides and trigger overflow/quarantine failures; either strip !important again or make the runtime hide/show declarations use important priority.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-css-sanitize.ts Outdated
'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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow type attribute selectors for new controls

When the newly enabled interactive controls are styled with standard selectors like input[type="range"] or input[type="checkbox"], the selector validator still rejects type because SAFE_ATTRIBUTE_SELECTOR_NAMES omits it even though the HTML sanitizer preserves type on inputs/buttons/scripts. The sanitizer then drops the whole CSS rule, so generated controls can render unstyled or incorrectly; add the supported control attributes to the selector allowlist along with these new element selectors.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c92d9cc5e6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode, labels: HtmlExportRuntimeLabels): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]'),labels=${JSON.stringify(labels)};if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';var label=theme==='dark'?labels.switchToLightTheme:labels.switchToDarkTheme;button.setAttribute('aria-label',label);button.title=label}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);function groupingRuleMatches(rule){try{if((typeof CSSMediaRule!='undefined'&&rule instanceof CSSMediaRule)||rule.type===4)return window.matchMedia(rule.conditionText).matches;if((typeof CSSSupportsRule!='undefined'&&rule instanceof CSSSupportsRule)||rule.type===12)return CSS.supports(rule.conditionText)}catch(_e){}return true}function hasAuthoredTheme(rule){if(rule.cssRules&&rule.cssRules.length){if(!groupingRuleMatches(rule))return false;return Array.prototype.some.call(rule.cssRules,hasAuthoredTheme)}if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||!/\\[\\s*data-theme\\s*(?:[~|^$*]?=|\\])/i.test(rule.selectorText)||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var theme=content.dataset.theme;var matches=['light','dark'].some(function(value){content.dataset.theme=value;return content.matches(rule.selectorText)});content.dataset.theme=theme;return matches}var authored=content&&Array.prototype.some.call(document.styleSheets,function(sheet){try{return Array.prototype.some.call(sheet.cssRules||[],hasAuthoredTheme)}catch(_e){return false}});if(!authored){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][data-theme="dark"] video{filter:invert(1) hue-rotate(180deg)}';document.head.appendChild(style)}if(${slide}){var deck=content||document.body,slides=Array.prototype.slice.call(deck.querySelectorAll('section.slide')).filter(function(candidate){var ancestor=candidate.parentElement&&candidate.parentElement.closest('section.slide');return !ancestor||!deck.contains(ancestor)});if(!slides.length){slides=Array.prototype.slice.call((content||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='›';previous.setAttribute('aria-label',labels.previousSlide);previous.title=labels.previousSlide;next.setAttribute('aria-label',labels.nextSlide);next.title=labels.nextSlide;function show(n){index=(n+slides.length)%slides.length;slides.forEach(function(s,i){if(i===index)s.style.removeProperty('display');else s.style.setProperty('display','none','important');s.style.minHeight='100vh'});indicator.textContent=(index+1)+'/'+slides.length;var indicatorLabel=labels.slideIndicator.replace('{current}',String(index+1)).replace('{total}',String(slides.length));indicator.setAttribute('aria-label',indicatorLabel);indicator.title=indicatorLabel;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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a dark palette before suppressing fallback

When an export defines only a light theme custom-property rule, such as [data-theme="light"]{--fg:#111}, this detection still returns authored because it tests ['light','dark'].some(...), so the dark-mode fallback is not injected. Toggling the app control to dark then sets data-theme="dark" with neither an authored dark palette nor the fallback, making var()-based colors compute incorrectly; only suppress the fallback after finding a matching dark palette or both required palettes.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve range input bounds

When generated interactive output uses a range/number control such as <input type="range" min="1" max="5" step="1" value="3">, this allowlist keeps type/value but strips min, max, and step as disallowed attributes. The saved control silently falls back to the browser's 0–100/step=1 defaults, so JS and UI no longer match the authored document; add validated input bounds to the allowlist for these newly supported controls.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — two new P2 regressions remain at the settlement head despite green tip CI.

Summary

This Stage 2 re-measurement covers c92d9cc5, including the latest slide-display and input-selector fixes.
The prior mode, locale, grouping-rule, and slide-visibility fixes are present, but a light-only palette disables the dark fallback and range controls lose their declared bounds.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-runtime.ts:11 treats a palette for either light or dark as sufficient (['light','dark'].some(...)). With only [data-theme="light"]{--fg:...}, it suppresses #nai-theme-fallback; after the user switches to dark, there is neither an authored dark palette nor fallback inversion. Require a matching dark palette before suppressing the dark fallback, and add a light-only regression.
  • 🟠 P2 Medsrc/main/html-export-sanitize.ts:372 permits type and value on interactive inputs but strips min, max, and step. A sanitized <input type="range" min="1" max="5" step="0.5" value="3"> becomes <input type="range" value="3">, silently changing the control's range. Preserve and validate those numeric input bounds.

Response to bot findings

  • Codex's current-HEAD dark-palette and range-bound findings were independently reproduced against c92d9cc5; both are incorporated above as P2 findings.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: c92d9cc5e6da7ebb875c58caaa421785d81276a9; +922/−256 across 27 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub HEAD check: verify succeeded on this exact SHA.
  • Local validation: npm run typecheck passed. The focused HTML-export suites passed (4 files, 279 tests). The full Vitest runner reported 149 files / 2,382 tests passed but the enclosing npm test command returned exit 1 after the report, so it is not treated as a clean local gate.
  • Direct reproductions: a light-only authored palette left #nai-theme-fallback absent after toggling dark; sanitizer output for the range input was exactly <input type="range" value="3">.
  • Files opened for context: html-export-runtime.ts, html-export-sanitize.ts, html-export-css-sanitize.ts, runtime DOM tests, sanitizer tests, and pipeline IPC tests.
  • Caller trace: injectHtmlExportRuntime is the sole runtime injector; HtmlExportPipelineService.sanitize calls sanitizeHtmlExport before the interactive artifact is finalized.
  • Adversarial pass: mode/ordering and slide !important handling are covered by the current code and focused tests; the light-only theme and range-contract attacks both hold.
  • Related work: this PR closes #70. No other open PR was measured touching these files; no known revert history was found.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f807d981d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-css-sanitize.ts Outdated
Comment on lines +295 to +298
return nodes.length === 2
&& isGlobalRootAtom(nodes[0])
&& isThemeAttributeAtom(nodes[1])
&& (nodes[0].type !== 'TypeSelector' || String(nodes[0].name).toLowerCase() === 'html');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept body-qualified functional theme roots

When generated CSS uses the common pattern :where(body[data-theme="dark"]) or :is(body[data-theme="dark"]), this helper does not treat it as a theme-root selector even though non-functional body[data-theme] is otherwise a supported global-root rewrite. validateGlobalRootShape then sees the nested body root and strips the whole palette rule, so the prompted dark/light custom properties can disappear from the export; allow body here so it rewrites to the content root like html and :root.

Useful? React with 👍 / 👎.

Comment on lines +362 to +365
const SAFE_INPUT_BOUND = /^(?:[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|\d{4}(?:-\d{2}(?:-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?|\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/i;

function isSafeInputBound(value: string): boolean {
return SAFE_INPUT_BOUND.test(value) && (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value) || Number.isFinite(Number(value)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve arbitrary-step controls

The new bounds validator rejects the standard step="any" value because only numeric/date/time-looking tokens match this regex. For generated number/range inputs that intentionally allow arbitrary decimals, the sanitizer strips step, so the saved control falls back to the browser's default stepping and no longer matches the authored UI behavior; include the any keyword for step while keeping the existing checks for min/max.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — tip CI is green, but two newly surfaced interactive-export contracts still drop valid author intent.

Summary

This Stage 2 settlement re-measured 4f807d9 after the prior theme-fallback and input-bound fixes.
The required GitHub check, mergeability, full local suite, build, and export harness are green.
Two P2 edge cases remain: functional body[data-theme] palettes are stripped, and valid step="any" is removed from range/number controls.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-css-sanitize.ts:292-298 recognizes body[data-theme="dark"] as a global theme root when written directly, but rejects the equivalent :where(body[data-theme="dark"]) and :is(body[data-theme="dark"]). The measured sanitizer result is css_disallowed_selector with empty CSS, so a valid functional palette disappears from the finalized export. Treat body consistently with html/:root inside functional theme-root selectors and add a finalized-artifact regression test.
  • 🟠 P2 Medsrc/main/html-export-sanitize.ts:362-365,446 accepts numeric bounds but strips the standard HTML value step="any". A measured sanitized range input retained min="1" max="5" but lost step="any", reverting the control to browser-default stepping and changing its authored interaction. Accept any for step only, while retaining the current validation for min and max.

Response to bot findings

  • Codex's current-head findings for functional body-qualified theme roots and arbitrary-step controls were independently reproduced and are incorporated above.
  • The prior light-only-palette and numeric input-bound findings are resolved by 4f807d9 and their regression tests pass.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: 4f807d981d7d0c470fe9d8033b18979e1d942255; +957/−257 across 27 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub HEAD CI: required verify succeeded on this exact SHA.
  • Local validation passed: npm ci; npm run typecheck; npm test (149 files / 2,385 tests); npm run build; npm run test:security-e2e; npm run test:converter-e2e; npm run test:html-export-direct; and npm run test:roundtrip-smoke.
  • The direct harness passed for scroll/slides × landscape/portrait, with zero remote requests and preview/save digest equality.
  • npm run knip passed. npm audit --omit=dev --audit-level=high still reports four high-severity transitive adm-zip advisories under existing kordoc; this PR does not change package manifests or the lockfile.
  • Files opened for context: html-export-css-sanitize.ts, html-export-sanitize.ts, html-export-runtime.ts, their CSS/sanitizer/runtime tests, and AGENTS.md.
  • Caller trace: sanitizeHtmlExport invokes sanitizeStylesheet for authored CSS and sanitizes input attributes before HtmlExportPipelineService finalizes the document. injectHtmlExportRuntime then consumes the retained theme CSS and control markup.
  • Adversarial pass — 4 angles tried: slide runtime ordering, locale propagation, light-only fallback behavior, and interactive-control contract boundaries. The first three are covered by current code/tests; the functional-root and step="any" attacks hold by direct execution.
  • Related work: this PR closes #70. No other open PR touches its changed files; no related revert was found.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d550423566

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode, labels: HtmlExportRuntimeLabels): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]'),labels=${JSON.stringify(labels)};if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style');button.id='nai-runtime-toggle';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;if(content)content.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';var label=theme==='dark'?labels.switchToLightTheme:labels.switchToDarkTheme;button.setAttribute('aria-label',label);button.title=label}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);function groupingRuleMatches(rule){try{if((typeof CSSMediaRule!='undefined'&&rule instanceof CSSMediaRule)||rule.type===4)return window.matchMedia(rule.conditionText).matches;if((typeof CSSSupportsRule!='undefined'&&rule instanceof CSSSupportsRule)||rule.type===12)return CSS.supports(rule.conditionText)}catch(_e){}return true}function hasAuthoredTheme(rule,themeValue){if(rule.cssRules&&rule.cssRules.length){if(!groupingRuleMatches(rule))return false;return Array.prototype.some.call(rule.cssRules,function(child){return hasAuthoredTheme(child,themeValue)})}if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||rule.selectorText.toLowerCase().indexOf('data-theme')===-1||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var selector=rule.selectorText.toLowerCase();if(selector.indexOf('=\"'+themeValue+'\"')===-1&&selector.indexOf("='"+themeValue+"'")===-1&&selector.indexOf('='+themeValue)===-1&&selector.indexOf('data-theme]')===-1)return false;var theme=content.dataset.theme;content.dataset.theme=themeValue;var matches=content.matches(rule.selectorText);content.dataset.theme=theme;return matches}var missingThemes=content?['light','dark'].filter(function(theme){return !Array.prototype.some.call(document.styleSheets,function(sheet){if(sheet.ownerNode&&(!sheet.ownerNode.isConnected||sheet.ownerNode.id==='nai-theme-fallback'))return false;try{return Array.prototype.some.call(sheet.cssRules||[],function(rule){return hasAuthoredTheme(rule,theme)})}catch(_e){return false}})}):['light','dark'];if(missingThemes.indexOf('dark')!==-1){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][data-theme="dark"] video{filter:invert(1) hue-rotate(180deg)}';document.head.appendChild(style)}if(${slide}){var deck=content||document.body,slides=Array.prototype.slice.call(deck.querySelectorAll('section.slide')).filter(function(candidate){var ancestor=candidate.parentElement&&candidate.parentElement.closest('section.slide');return !ancestor||!deck.contains(ancestor)});if(!slides.length){slides=Array.prototype.slice.call((content||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='›';previous.setAttribute('aria-label',labels.previousSlide);previous.title=labels.previousSlide;next.setAttribute('aria-label',labels.nextSlide);next.title=labels.nextSlide;function show(n){index=(n+slides.length)%slides.length;slides.forEach(function(s,i){if(i===index)s.style.removeProperty('display');else s.style.setProperty('display','none','important');s.style.minHeight='100vh'});indicator.textContent=(index+1)+'/'+slides.length;var indicatorLabel=labels.slideIndicator.replace('{current}',String(index+1)).replace('{total}',String(slides.length));indicator.setAttribute('aria-label',indicatorLabel);indicator.title=indicatorLabel;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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore every slide before printing

When a slide export is printed or saved as PDF after the runtime initializes, show(0) leaves every inactive slide with inline display:none!important, and there is no beforeprint/afterprint handling to reveal them. The printout therefore contains only the currently selected slide rather than the complete ordered deck; the injected navigation controls also remain visible. Restore all slides and hide app controls for print, then reapply the interactive state afterward.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve required state on inputs

When generated interactive HTML contains a standard <input required>, this allowlist strips required even though the same change explicitly permits the :required and :optional CSS pseudo-classes. The finalized form then accepts an empty value and any authored input:required styling never matches, silently changing both validation and presentation; retain the boolean required attribute for inputs.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 25c06d8927

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated
const script = `<script id="nai-runtime">${runtimeSource(mode, labels)}</script>`;
output = /<script\s+id=["']nai-runtime["'][^>]*>[\s\S]*?<\/script\s*>/i.test(output)
? output.replace(/<script\s+id=["']nai-runtime["'][^>]*>[\s\S]*?<\/script\s*>/i, script)
: /<\/body\s*>/i.test(output) ? output.replace(/<\/body\s*>/i, `${script}</body>`) : `${output}${script}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Insert the runtime at a parsed body boundary

When a preserved inline script contains the valid JavaScript string '</body>' (for example, an HTML template used by an interaction), this document-wide regex treats that raw-text content as the document closing tag and inserts the app runtime inside the authored script. The injected </script> then terminates the authored script early, corrupting its code and preventing the theme/slide runtime from executing normally; locate the actual body boundary structurally or otherwise exclude script raw-text content.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
'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', 'textarea', 'select', 'button',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve option elements for allowed selects

When interactive output contains a normal <select><option value="a">A</option></select>, the newly allowed select survives but option is absent from this vocabulary, so sanitizeNode unwraps each option and drops its value/selected state. The saved select therefore contains only text nodes and has no selectable choices; preserve option (and its relevant attributes, plus optgroup if supported) alongside select.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-css-sanitize.ts Outdated
'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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow selectors for every preserved form control

When generated CSS styles the newly preserved controls with type selectors such as textarea{...} or select{...}, selector validation rejects those names because this set only adds form, input, and button. Since one rejected selector drops the entire rule, finalized text areas and selects can lose their authored layout and styling even though the HTML sanitizer explicitly retains them; include the corresponding preserved control types here.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 07eebb8a96

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated

function runtimeSource(mode: HtmlExportRuntimeMode, labels: HtmlExportRuntimeLabels): string {
const slide = mode === 'slide' ? 'true' : 'false';
return `(function(){var root=document.documentElement,content=document.querySelector('[data-he-content]'),labels=${JSON.stringify(labels)};if(root.dataset.naiRuntime)return;root.dataset.naiRuntime='true';var button=document.createElement('button'),style=document.createElement('style'),printStyle=document.createElement('style');printStyle.id='nai-print-controls';printStyle.textContent='@media print{#nai-runtime-toggle,#nai-slide-nav{display:none!important}}';document.head.appendChild(printStyle);button.id='nai-runtime-toggle';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;if(content)content.dataset.theme=theme;try{localStorage.setItem('nai-theme',theme)}catch(_e){}button.textContent=theme==='dark'?'☀':'🌙';var label=theme==='dark'?labels.switchToLightTheme:labels.switchToDarkTheme;button.setAttribute('aria-label',label);button.title=label}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);function groupingRuleMatches(rule){try{if((typeof CSSMediaRule!='undefined'&&rule instanceof CSSMediaRule)||rule.type===4)return window.matchMedia(rule.conditionText).matches;if((typeof CSSSupportsRule!='undefined'&&rule instanceof CSSSupportsRule)||rule.type===12)return CSS.supports(rule.conditionText)}catch(_e){}return true}function hasAuthoredTheme(rule,themeValue){if(rule.cssRules&&rule.cssRules.length){if(!groupingRuleMatches(rule))return false;return Array.prototype.some.call(rule.cssRules,function(child){return hasAuthoredTheme(child,themeValue)})}if(!rule.selectorText||rule.selectorText.indexOf('data-he-content')===-1||rule.selectorText.toLowerCase().indexOf('data-theme')===-1||!Array.prototype.some.call(rule.style||[],function(name){return String(name).indexOf('--')===0}))return false;var selector=rule.selectorText.toLowerCase();if(selector.indexOf('=\"'+themeValue+'\"')===-1&&selector.indexOf("='"+themeValue+"'")===-1&&selector.indexOf('='+themeValue)===-1&&selector.indexOf('data-theme]')===-1)return false;var theme=content.dataset.theme;content.dataset.theme=themeValue;var matches=content.matches(rule.selectorText);content.dataset.theme=theme;return matches}var missingThemes=content?['light','dark'].filter(function(theme){return !Array.prototype.some.call(document.styleSheets,function(sheet){if(sheet.ownerNode&&(!sheet.ownerNode.isConnected||sheet.ownerNode.id==='nai-theme-fallback'))return false;try{return Array.prototype.some.call(sheet.cssRules||[],function(rule){return hasAuthoredTheme(rule,theme)})}catch(_e){return false}})}):['light','dark'];if(missingThemes.indexOf('dark')!==-1){style.id='nai-theme-fallback';style.textContent='[data-he-content][data-theme="dark"]{filter:invert(1) hue-rotate(180deg)}[data-he-content][data-theme="dark"] img,[data-he-content][data-theme="dark"] video{filter:invert(1) hue-rotate(180deg)}';document.head.appendChild(style)}if(${slide}){var deck=content||document.body,slides=Array.prototype.slice.call(deck.querySelectorAll('section.slide')).filter(function(candidate){var ancestor=candidate.parentElement&&candidate.parentElement.closest('section.slide');return !ancestor||!deck.contains(ancestor)});if(!slides.length){slides=Array.prototype.slice.call((content||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.id='nai-slide-nav';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='›';previous.setAttribute('aria-label',labels.previousSlide);previous.title=labels.previousSlide;next.setAttribute('aria-label',labels.nextSlide);next.title=labels.nextSlide;function show(n){index=(n+slides.length)%slides.length;slides.forEach(function(s,i){if(i===index)s.style.removeProperty('display');else s.style.setProperty('display','none','important');s.style.minHeight='100vh'});indicator.textContent=(index+1)+'/'+slides.length;var indicatorLabel=labels.slideIndicator.replace('{current}',String(index+1)).replace('{total}',String(slides.length));indicator.setAttribute('aria-label',indicatorLabel);indicator.title=indicatorLabel;slides[index].scrollIntoView({block:'start'})}previous.addEventListener('click',function(){show(index-1)});next.addEventListener('click',function(){show(index+1)});function showForPrint(){slides.forEach(function(s){s.style.removeProperty('display')})}window.addEventListener('beforeprint',showForPrint);window.addEventListener('afterprint',function(){show(index)});var printMedia=window.matchMedia&&window.matchMedia('print');if(printMedia){var printChange=function(event){if(event.matches)showForPrint();else show(index)};if(printMedia.addEventListener)printMedia.addEventListener('change',printChange);else if(printMedia.addListener)printMedia.addListener(printChange)}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 instanceof HTMLButtonElement||target instanceof HTMLSelectElement||target&&target.tagName==='SUMMARY'||target&&target instanceof Element&&target.closest('[contenteditable]'))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)}})();`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the active class on the selected slide

When authored slide CSS uses the conventional .slide{display:none}.slide.active{display:block} pattern supported by the previous runtime, show() merely removes the selected slide's inline display property and never adds active. The stylesheet therefore continues hiding every slide, so quarantine can pass a zero-overflow document that opens as a blank deck; toggle active for the current slide or otherwise explicitly restore its visible display.

Useful? React with 👍 / 👎.

'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 <img> 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 <img> 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.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advertise every newly supported form element

For requests that need dropdowns or multiline inputs, the normal generation prompt explicitly says to use only this vocabulary but lists only form/input/button, omitting the newly preserved textarea, select, option, optgroup, label, fieldset, legend, and related elements. A compliant model is therefore instructed not to use the controls this change enabled, preventing them from being exercised in generated exports; keep the prompt vocabulary aligned with the sanitizer allowlist.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
if (name === 'src') return ['img', 'source'].includes(tag);
if (name === 'type') return ['input', 'button', 'script'].includes(tag);
if (name === 'value') return ['input', 'button', 'option'].includes(tag);
if (name === 'name' || name === 'placeholder') return ['input', 'button'].includes(tag);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve attributes required by the new form controls

When an interactive form contains standard markup such as <label for="notes">, <textarea name="notes" placeholder="…">, or <select name="region">, this allowlist strips for and limits name/placeholder to inputs and buttons. The saved document then loses label activation, textarea placeholder text, and the field names used by FormData or authored JavaScript; allow these attributes on the corresponding newly supported controls.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — green checks are real, but three current interactive-export contracts still lose authored behavior.

Summary

This Stage 2 settlement re-measured 07eebb8 after 18 commits and the prior P2 fixes.
The runtime, sanitizer, and direct-authoring contract now agree on most of the new surface, but active-class slide decks, form metadata, and the prompt vocabulary still diverge.
These are product-behavior regressions, not security-policy findings.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-runtime.ts:12 — slide show() clears the selected slide's inline display but never restores the conventional active class. With authored .slide{display:none}.slide.active{display:block}, the measured finalized runtime leaves both slides computed as display:none; the deck opens blank. Toggle the selected slide's active class (and preserve it through print restore), then add this CSS pattern to the DOM suite.
  • 🟠 P2 Medsrc/main/html-export-sanitize.ts:379-387 — the newly retained label, textarea, and select elements still lose functional form metadata: for is never allowlisted, and name/placeholder remain limited to input/button. A measured sanitized label/textarea/select document removed for="notes", both field names, and the textarea placeholder. Retain the matching inert form attributes and add a sanitizer regression.
  • 🟠 P2 Medsrc/renderer/html-export-direct-prompt.ts:170 — the prompt says “Use ONLY” its vocabulary but lists only form/input/button; it omits the newly supported textarea/select/option/optgroup/label/fieldset/legend controls. A compliant generator therefore cannot intentionally author much of this PR's expanded control surface. Keep the prompt and sanitizer vocabulary in sync.

Response to bot findings

  • Content-root theme propagation; deferred injection; nai-* reservation; grouped/media theme detection; functional theme roots; custom-property case; nested slides; mode/locale forwarding; quarantine ordering; important display handling; print restoration; bounds/step=any; options; and control selectors were checked against the current code and their named runtime/sanitizer/pipeline tests. npm test passed all 149 files / 2,394 tests.
  • The current active-class, prompt-vocabulary, and form-metadata reports were independently reproduced and are folded into the three P2 findings above.
  • The static-export script/inline-handler threads remain the owner's documented product policy and follow-up #66; they are not recast as code defects here.
  • GitHub still marks the historical Codex threads unresolved. The current code resolves the items listed in the first bullet, but those threads have not been administratively closed.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: 07eebb8a96a2e3a28a4ee8a4652bab83b523c58e; +1,152/−257 across 28 files; 18 commits ahead of main; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub HEAD CI: required verify succeeded on this exact SHA (completed 2026-07-19 03:37 UTC).
  • Local validation on this exact checkout: npm ci, npm run typecheck, npm test (149 files / 2,394 tests), npm run build, npm run test:security-e2e, npm run test:converter-e2e, npm run test:html-export-direct, npm run test:roundtrip-smoke, and npm run knip all exited 0.
  • Reproduction probe: an authored .slide{display:none}.slide.active{display:block} deck remained ['none','none']; label linkage, textarea/select names, and placeholder were stripped; the direct prompt omitted textarea/select/option.
  • Files opened: html-export-runtime.ts, html-export-sanitize.ts, html-export-css-sanitize.ts, html-export-pipeline-service.ts, html-export-generation-orchestrator.ts, html-export-document-markers.ts, html-export-direct-prompt.ts, and their runtime/sanitizer/CSS tests.
  • Caller trace: the orchestrator resolves with mode/locale before quarantine, HtmlExportPipelineService.resolve injects the mode-specific runtime for measurement, and finalize replaces that runtime for the saved artifact. The ordering refutation was rejected by code and tests.
  • Adversarial pass — 4 attack angles: mode/quarantine ordering and raw-script body boundaries were rejected by the current pipeline/runtime tests; active-class visibility, form semantic retention, and prompt-contract completeness each held by direct execution.
  • Delta since the last Psycho-842 review (4f807d9): the prior functional-body-theme and step="any" findings are fixed; the three findings above are newly surfaced at this head.
  • Related work: this PR closes #70. No other open PR was found touching these files; no related revert was found.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37ba7c616f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-runtime.ts Outdated
mode: HtmlExportRuntimeMode = 'scroll',
labels: HtmlExportRuntimeLabels = htmlExportRuntimeLabels(),
): string {
let output = html.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>\s*/gi, '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve CSP-like strings inside authored scripts

Although runtime insertion now finds the body structurally, this document-wide replacement still matches raw text inside preserved scripts. If an interactive export contains a JavaScript/template string such as '<meta http-equiv="Content-Security-Policy" content="example">', finalization deletes that string content and silently changes the authored interaction; replace only the actual app-owned CSP element in the parsed document/head.

Useful? React with 👍 / 👎.

Comment thread src/main/html-export-sanitize.ts Outdated
Comment on lines +368 to +374
const SAFE_FORM_METADATA_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const UNSAFE_FORM_METADATA_TEXT = /^\s*(?:javascript|data|https?|mailto|tel):/i;

function isSafeFormMetadata(name: string, value: string): boolean {
return name === 'placeholder'
? !UNSAFE_FORM_METADATA_TEXT.test(value)
: SAFE_FORM_METADATA_TOKEN.test(value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve valid structured form field names

Fresh evidence after adding form metadata support is that this new token gate still rejects valid and common field names such as items[] or user[email]. In interactive exports that construct FormData, sanitizeAttributes consequently removes those name attributes, so getAll('items[]') and similar authored logic silently receive no values; field names should not be restricted to identifier-like tokens.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — tip CI is green, but two newly reproduced P2 regressions still change saved interactive-export behavior.

Summary

This Stage 2 settlement re-measured 37ba7c6 after the previous active-slide, form-metadata, and prompt-vocabulary fixes.
The runtime and sanitizer now cover those prior paths, and the required CI plus local suite are green.
Two edge cases remain: finalization rewrites text inside authored scripts, and structured HTML form names are stripped.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-runtime.ts:28 removes every string matching a CSP <meta> tag from the serialized document, including raw text inside an allowed authored <script>. A direct finalization probe with const csp='<meta http-equiv="Content-Security-Policy" content="example">' produced const csp='', changing the authored interaction. Replace only the actual document-head CSP element structurally, and add a regression test with the string inside a script/template literal.
  • 🟠 P2 Medsrc/main/html-export-sanitize.ts:368-374,464 accepts only identifier-like name values. The measured sanitizer output for name="items[]" and name="user[email]" removes both names, so ordinary FormData lookups for repeated/nested fields fail. Preserve valid structured form names (with a narrow safety rule if needed) and cover bracket notation.

Response to bot findings

  • Codex's current CSP-like-script-string and structured-form-name reports were independently reproduced at this head and are incorporated above as P2 findings.
  • The open inline-handler thread records the owner's documented product policy: the interactive checkbox shapes the prompt, while the CSP blocks network access. This settlement does not reclassify that policy choice as a code defect.
  • The remaining historical Codex threads are still administratively unresolved in GitHub; this review does not treat their open status alone as proof of a current code defect.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: 37ba7c616f8144ae86ef5e478d5887296796177f; +1,204/−257 across 28 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub tip CI: verify succeeded on this exact SHA.
  • Local validation passed: npm ci; npm run typecheck; npm test (149 files / 2,396 tests); npm run build; npm run test:html-export-direct; and seven focused HTML-export suites (353 tests).
  • The direct harness passed for scroll/slides × landscape/portrait, including zero remote requests and preview/save digest equality.
  • Reproduction 1: injectHtmlExportRuntime(...) changed an authored script's CSP-like string to an empty string (preserved=false).
  • Reproduction 2: sanitizeHtmlExport(...) returned <form><input value="a"><input value="x"></form> for inputs named items[] and user[email], with two html_attribute dispositions.
  • Files opened for context: html-export-runtime.ts, html-export-sanitize.ts, html-export-direct-prompt.ts, and the runtime DOM, sanitizer, and prompt test suites.
  • Caller/contract trace: injectHtmlExportRuntime finalizes the saved document; sanitizeAttributes applies the name gate before finalization, so both reproductions affect the exported artifact rather than test-only helpers.
  • Adversarial pass — 3 attack angles: raw-text boundary preservation and HTML form-name contract attacks both hold by direct execution; the slide active-class path is covered by its new DOM regression and the focused suite.
  • Cross-reference: PR #71 closes #70. No other open PR was found touching these 28 files; no related revert was found in the repository history.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1914de0ab4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/html-export-sanitize.ts Outdated
Comment on lines +473 to +475
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep explicitly true boolean form attributes

When generated markup uses common explicit forms such as <input required="true">, <input checked="true">, or <option selected="true">, browsers treat the attribute's presence as enabled, but this branch strips it because the value is not empty or equal to the attribute name. The finalized form therefore silently loses its authored validation, checked, or selection state; normalize an explicit true value to the empty boolean form rather than discarding it.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — the tip is green, but explicit boolean form values still lose their browser-defined state in the finalized export.

Summary

This Stage 2 settlement re-measured 1914de0 after the prior CSP-literal and structured-form-name fixes.
GitHub CI, mergeability, the full local suite, typecheck, and the direct export harness are green.
One newly confirmed P2 remains: common explicit boolean attributes such as checked="true" are removed instead of being normalized to presence.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-sanitize.ts:472-475 rejects every nonempty boolean value unless it exactly repeats the attribute name. HTML treats presence as true, so <input checked="true">, <input required="true">, and <option selected="true"> lose their authored checked/validation/selection state after sanitization. Normalize accepted explicit true values to the empty boolean form (or preserve presence semantics), and add regression coverage for input and option controls.

Response to bot findings

  • Codex's current-head report on explicit boolean values was independently confirmed: the current sanitizer test intentionally asserts that checked="true" is absent from the output, matching the branch implementation.
  • The previous structured-name and CSP-literal findings are resolved at this head: bracketed names are accepted by SAFE_FORM_METADATA_TOKEN, and CSP replacement uses the parsed head-meta ranges rather than a document-wide raw-text match.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: 1914de0ab4137bdd84fc0cd9a581bfe171a7d18b; +1,257/−275 across 28 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub HEAD CI: verify succeeded on this exact SHA (run 29681375004); the only annotation is GitHub's Node 20 deprecation warning for actions, not a failed check.
  • Local validation on this exact SHA: npm run test passed 149 files / 2,398 tests; npm run typecheck passed; npm run test:html-export-direct passed for scroll/slides × landscape/portrait with zero remote requests and preview/save digest equality.
  • Direct reproduction: the focused sanitizer suite passes while explicitly expecting checked="true" to be stripped; source lines 472-475 implement that behavior. Browser boolean attributes are true by presence, so this changes authored form state.
  • Files opened for context: html-export-sanitize.ts, html-export-runtime.ts, html-export-document-markers.ts, html-export-pipeline-service.ts, html-export-generation-orchestrator.ts, and their sanitizer/runtime/pipeline tests.
  • Caller trace: HtmlExportPipelineService.sanitize calls sanitizeHtmlExport; its sanitizeAttributes result becomes bodyHtml, then the resolver and finalizer inject the runtime around those already-sanitized bytes. The lost boolean state therefore reaches the saved artifact.
  • Adversarial pass — 4 angles tried: raw-text CSP replacement, structured form names, explicit boolean presence semantics, and slide-mode resolve-before-quarantine ordering. The first, second, and fourth are covered by current code/tests; the boolean-presence attack holds by direct source/test execution.
  • Related work: this PR closes #70. No other open PR overlaps its changed files; no revert was found. Diff whitespace check passed.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e9277f6cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

'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', 'textarea', 'select', 'option', 'optgroup', 'button', 'label', 'fieldset', 'legend',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep root mutations aligned with scoped CSS

When a preserved script or event handler implements a common interaction by toggling a class or data attribute on document.body/document.documentElement, the matching authored CSS no longer responds: scopeSelector() rewrites body, html, and :root selectors onto [data-he-content], while the script executes unchanged against the outer shell roots. For example, body.open .panel becomes [data-he-content].open .panel, but document.body.classList.toggle('open') puts the class on the wrong element, so the interaction silently fails; preserved scripts need a compatible root-mutation contract or transformation.

Useful? React with 👍 / 👎.

@project820 project820 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Psycho-842 [BOT] review

⚖️ Verdict: NEEDS REVISION — the tip is green, but authored interactive scripts can still mutate a root that the sanitizer no longer styles.

Summary

This Stage 2 settlement re-measured 2e9277f after the boolean-attribute fix.
The runtime, pipeline, and focused export harness are green, but the CSS scoping contract and preserved script contract disagree on html/body mutations.
An otherwise valid interactive export can therefore keep its script action while losing the authored visual state.

Findings (by severity)

  • 🟠 P2 Medsrc/main/html-export-css-sanitize.ts:275-354 scopes body.open .panel to [data-he-content].open .panel, while src/main/html-export-sanitize.ts:668 preserves the authored script unchanged. A direct finalized-artifact probe confirmed document.body.classList.add('open') yields body.open=true, content.open=false, and scoped-rule-matches=false; the panel's intended state never activates. Define and implement one script-compatible root contract (for example, a stable content-root helper or compatible mutation rewrite) and add a DOM regression covering html/body class and data-attribute mutations.

Response to bot findings

  • Codex's current-head root-mutation report was independently reproduced and is incorporated above as P2.
  • The previous explicit-boolean report is resolved at this head: the sanitizer normalizes retained boolean attributes to bare presence, and the updated runtime DOM and sanitizer tests cover required, checked, and selected values.
📋 Detailed analysis (measured CI, diff, and validation)
  • Settlement head: 2e9277f6cfb12eaf6e1efc624280f698b6dbb6e9; +1,262/−275 across 28 files; mergeable=MERGEABLE, mergeStateStatus=CLEAN.
  • GitHub tip CI: verify succeeded on this exact SHA (completed 2026-07-19 15:50 UTC); the CI and head remained unchanged for 156 minutes at measurement.
  • Local validation passed on this checkout: npm ci, npm run typecheck, npm test, npm run build, and npm run test:html-export-direct. The direct harness passed scroll/slides × landscape/portrait, reported zero remote requests, and verified preview/save digest equality.
  • Direct regression probe: sanitized CSS contained [data-he-content].open .panel{display:block}. After executing the preserved authored script, the measured result was body.open=true; content.open=false; scoped-rule-matches=false.
  • Files opened for context: html-export-css-sanitize.ts, html-export-sanitize.ts, html-export-shell.ts, html-export-runtime.ts, html-export-pipeline-service.ts, html-export-generation-orchestrator.ts, html-export-generate.ts, the direct-authoring prompt, and runtime/sanitizer/pipeline tests.
  • Caller trace: sanitizeHtmlExport scopes global CSS to [data-he-content]; bundleSanitizedHtml creates that wrapper; authored scripts remain untransformed; injectHtmlExportRuntime adds the app runtime without reconciling those roots. The mismatch reaches the saved artifact.
  • Cross-reference: this PR closes #70. No other open PR touches these files, and no related revert was found in the recent history. git diff --check origin/main...HEAD passed.

This is a COMMENT review, not an approval; explicit user final sign-off is still required.

🛠️ NEEDS REVISION

🦀 Reviewed by Hermes agent BOT 🦞

@project820
project820 merged commit 5190f43 into main Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTML 내보내기: 인터랙티브 전면 개방 (A+B) — 새니타이저 완화 + CSP + 주입 런타임(테마/슬라이드) + 프롬프트 계약

1 participant