import { createHash } from 'node:crypto' import { readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import postcss from 'postcss' const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') const exportDir = resolve(rootDir, '.tmp/rea-css-recovered') const runtimePath = resolve(rootDir, '.tmp/rea-runtime-scripts.json') const outputPath = resolve(rootDir, 'src/renderer/src/workbuddy/conversation/question-source.css') const runtimeEvidenceId = 'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb' const runtimeFile = 'src-0FGg1seX.js' const runtimeSha256 = 'f2c6dbae949041b4e2bb456e8a23ae2837f2d7893ac24bf471485f77c7b15178' const cssEvidenceId = 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94' const inlineResetFile = 'inline-90c5e4f0726a5658.css' const inlineResetSha256 = '90c5e4f0726a56583f83dcffecc1fe87b5ad54ec6d0d593008ac033c8fe931c9' const optionRuntimeClassOffset = 1043927 const sourceSpecs = [ { file: 'src-CBaV_A3S.css', sha256: '2090ed21045c8f8b0fc388d76424c8751bf18fcd065cbbd5d0ef485163a2e36c' }, { file: 'cb-bridge-jsj-8pWS.css', sha256: '71007d54555800c62d65d6188c743d71481cd9034e0609d3c63bb2626f9d5446' } ] const classAliases = { questionFloating: 'question-floating', questionFloatingSlideUp: null, header: 'question-floating__header', headerLeft: 'question-floating__heading', title: 'question-floating__title', titleHelp: 'question-floating__hint', headerRight: 'question-floating__header-actions', pagination: 'question-floating__pagination', pageBtn: 'question-floating__page-button', pageInfo: 'question-floating__pagination-info', skipAllBtn: 'question-floating__dismiss', content: 'question-floating__content', options: 'question-floating__options', optionItem: 'question-floating__option', optionArrow: 'question-floating__option-arrow', optionNumber: 'question-floating__option-number', selected: 'question-floating__option--selected', checkbox: 'question-floating__checkbox', disabled: 'question-floating__option--disabled', optionText: 'question-floating__option-copy', optionLabel: 'question-floating__option-label', optionDesc: 'question-floating__option-description', optionHelp: 'question-floating__option-help', customInputRow: 'question-floating__custom', customInputIcon: 'question-floating__custom-icon', focused: 'question-floating__custom--focused', customInput: 'question-floating__custom-input', footer: 'question-floating__footer', footerSingle: 'question-floating__footer--single', selectedCount: 'question-floating__selected-count', footerActions: 'question-floating__footer-actions', skipBtn: 'question-floating__skip', iconBtn: 'question-floating__advance', iconBtnDisabled: 'question-floating__advance--disabled', iconBtnActive: 'question-floating__advance--active', iconBtnSend: 'question-floating__advance--send', confirmVariant: 'question-floating--confirm' } function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex') } function sourceComment({ file, hash, offset, selectors, evidenceId, note }) { return postcss.comment({ text: ` REA evidence=${evidenceId}; source=${file}; sha256=${hash}; offset=${offset}; original-selectors=${JSON.stringify(selectors)}${note ? `; ${note}` : ''} ` }) } function isGlobalButtonOrUniversalSelector(selector) { const normalized = selector .replace(/:where\(\s*(button|input|select|textarea)\s*\)/gi, '$1') .replace(/:is\(\s*(button|input|select|textarea)\s*\)/gi, '$1') return normalized === '*' || /^(?:(?:html|body|:root)\s+)*(?:button|input|select|textarea)(?=$|[:[])/i.test(normalized) } function getClassMap(source) { const footerOffset = source.indexOf('QuestionFloatingFooter=') if (footerOffset < 0) throw new Error(`Runtime source is missing QuestionFloatingFooter: ${runtimeFile}`) const candidates = [...source.matchAll(/cH=\{([^{}]+)\}/g)] .filter(match => match.index < footerOffset && footerOffset - match.index < 12000) const match = candidates.at(-1) if (!match) throw new Error(`Runtime source is missing the QuestionFloating CSS module map: ${runtimeFile}`) const componentOffset = source.indexOf('xH=(0,yH.forwardRef', match.index) if (componentOffset < match.index) { throw new Error(`Runtime source is missing the QuestionFloating component after its CSS module map: ${runtimeFile}`) } const bindings = Object.fromEntries( [...match[1].matchAll(/([A-Za-z_$][\w$]*):([A-Za-z_$][\w$]*)/g)] .map(item => [item[1], item[2]]) ) const classNames = {} for (const [key, variable] of Object.entries(bindings)) { const assignment = variable + '=`' const position = source.lastIndexOf(assignment, match.index) if (position < 0 || /[\w$]/.test(source[position - 1] || '')) { throw new Error(`Runtime source is missing the CSS module binding ${variable} for ${key}`) } const start = position + assignment.length const end = source.indexOf('`', start) if (end < 0) throw new Error(`Runtime source has an unterminated CSS module binding for ${key}`) classNames[key] = source.slice(start, end) } const missingAliases = Object.keys(bindings).filter(key => !(key in classAliases)) const extraAliases = Object.keys(classAliases).filter(key => !(key in bindings)) if (missingAliases.length || extraAliases.length) { throw new Error(`QuestionFloating module map changed: missing aliases [${missingAliases.join(', ')}], stale aliases [${extraAliases.join(', ')}]`) } const rootClass = classNames.questionFloating const moduleId = /_([A-Za-z0-9]+)_\d+$/.exec(rootClass)?.[1] if (!moduleId || !rootClass.startsWith('_questionFloating_')) { throw new Error(`Unexpected QuestionFloating module class: ${rootClass}`) } for (const [key, className] of Object.entries(classNames)) { if (!className.includes(`_${moduleId}_`)) { throw new Error(`QuestionFloating class ${key} is outside CSS module ${moduleId}: ${className}`) } } return { componentOffset, footerOffset, moduleMapOffset: match.index, moduleId, classNames } } function classPattern(className) { return new RegExp(`\\.${className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`) } function rewriteSelector(selector, classNames) { let rewritten = selector for (const [key, sourceClass] of Object.entries(classNames)) { const alias = classAliases[key] if (!alias) continue rewritten = rewritten.replace( new RegExp(`\\.${sourceClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'g'), `.${alias}` ) } return `.chat-pane.cr-theme ${rewritten}` } function projectModuleNode(node, source, classNames, selectedRules) { if (node.type === 'rule') { const selectors = postcss.list.comma(node.selector).map(selector => selector.trim()) const selected = selectors.filter(selector => Object.entries(classNames).some(([key, className]) => ( classAliases[key] && classPattern(className).test(selector) ))) if (selected.length === 0) return [] const projected = selected.map(selector => rewriteSelector(selector, classNames)) selectedRules.push({ file: source.file, originalSelectors: selected, offset: node.source?.start?.offset ?? 0 }) return [ sourceComment({ file: source.file, hash: source.sha256, offset: node.source?.start?.offset ?? 0, selectors: selected, evidenceId: cssEvidenceId, note: `runtime stylesheet ASAR comparison=${source.asarComparison}` }), node.clone({ selector: projected.join(',') }) ] } if (node.type !== 'atrule' || !node.nodes) return [] const children = node.nodes.flatMap(child => projectModuleNode(child, source, classNames, selectedRules)) if (children.length === 0) return [] const projected = node.clone() projected.removeAll() projected.append(children) return [projected] } function projectThemeTokens(root, source, output, records) { root.walkRules(rule => { const selectors = postcss.list.comma(rule.selector).map(selector => selector.trim()) const isDark = selectors.some(selector => /(?:^|[.:])(?:dark|cb-dark)(?:$|[.: ])/.test(selector)) const isLight = selectors.some(selector => /(?:^|[.:])(?:light|cb-light)(?:$|[.: ])/.test(selector)) const theme = isDark ? 'dark' : isLight ? 'light' : null if (!theme) return const declarations = rule.nodes.filter(node => node.type === 'decl' && node.prop.startsWith('--cb-question-floating-')) if (declarations.length === 0) return const selector = `.chat-pane.cr-theme[data-cr-theme=${theme}]` const offset = rule.source?.start?.offset ?? 0 records.push({ file: source.file, originalSelectors: selectors, offset, projectedSelector: selector }) output.append(sourceComment({ file: source.file, hash: source.sha256, offset, selectors, evidenceId: cssEvidenceId, note: `theme token projection=${theme}` })) output.append(postcss.rule({ selector, nodes: declarations.map(node => node.clone()) })) }) } function projectBridgeTokens(root, source, output, records) { root.walkRules(rule => { const selectors = postcss.list.comma(rule.selector).map(selector => selector.trim()) const declarations = rule.nodes.filter(node => node.type === 'decl' && node.prop.startsWith('--cb-question-floating-')) if (declarations.length === 0) return let projectedSelector if (selectors.includes(':root') || selectors.some(selector => selector.startsWith(':root.'))) { projectedSelector = '.chat-pane.cr-theme' } else if (selectors.some(selector => selector.includes('[class*=questionFloating]'))) { projectedSelector = '.chat-pane.cr-theme .question-floating' } else if (selectors.some(selector => selector.includes('[class*=skipBtn]'))) { projectedSelector = '.chat-pane.cr-theme .question-floating__skip' } if (!projectedSelector) return const offset = rule.source?.start?.offset ?? 0 records.push({ file: source.file, originalSelectors: selectors, offset, projectedSelector }) output.append(sourceComment({ file: source.file, hash: source.sha256, offset, selectors, evidenceId: cssEvidenceId, note: `theme bridge projection=${projectedSelector}` })) output.append(postcss.rule({ selector: projectedSelector, nodes: declarations.map(node => node.clone()) })) }) } function formatContainer(container, depth) { if (!container.nodes) return container.raws.semicolon = true for (const child of container.nodes) { child.raws.before = `\n${' '.repeat(Math.max(depth + 1, 0))}` if (child.type === 'decl') { child.raws.between = ': ' continue } if (child.nodes) { child.raws.between = ' ' formatContainer(child, depth + 1) } } container.raws.after = `\n${' '.repeat(Math.max(depth, 0))}` } const runtimeBytes = await readFile(runtimePath) const runtimeExport = JSON.parse(runtimeBytes.toString('utf8')) if (runtimeExport.evidence_id !== runtimeEvidenceId) { throw new Error(`REA runtime Evidence ID changed: expected ${runtimeEvidenceId}, found ${runtimeExport.evidence_id ?? 'none'}`) } const runtimeItem = runtimeExport.items.find(item => item.file_path?.replaceAll('\\', '/').endsWith(`/assets/${runtimeFile}`)) if (!runtimeItem?.source?.artifact?.text) throw new Error(`REA runtime script is missing: ${runtimeFile}`) const runtimeSource = runtimeItem.source.artifact.text const actualRuntimeSha = sha256(Buffer.from(runtimeSource, 'utf8')) if (runtimeItem.source.artifact.sha256 !== runtimeSha256 || actualRuntimeSha !== runtimeSha256) { throw new Error(`REA runtime script digest mismatch for ${runtimeFile}: expected ${runtimeSha256}, export ${runtimeItem.source.artifact.sha256 ?? 'none'}, actual ${actualRuntimeSha}`) } const { componentOffset, footerOffset, moduleMapOffset, moduleId, classNames } = getClassMap(runtimeSource) const cssManifest = JSON.parse(await readFile(resolve(exportDir, 'manifest.json'), 'utf8')) if (cssManifest.sourceEvidenceId !== cssEvidenceId) { throw new Error(`REA CSS Evidence ID changed: expected ${cssEvidenceId}, found ${cssManifest.sourceEvidenceId ?? 'none'}`) } const globalResetRules = [] const globalButtonTextAlignRules = [] for (const record of cssManifest.files ?? []) { const bytes = await readFile(resolve(exportDir, record.file)) const actualHash = sha256(bytes) if (!record.sha256 || actualHash !== record.sha256) { throw new Error(`REA CSS digest mismatch during global reset scan for ${record.file}: manifest ${record.sha256 ?? 'none'}, actual ${actualHash}`) } const root = postcss.parse(bytes.toString('utf8'), { from: record.file }) root.walkRules(rule => { const selectors = postcss.list.comma(rule.selector).map(selector => selector.trim()) const relevantSelectors = selectors.filter(isGlobalButtonOrUniversalSelector) if (relevantSelectors.length === 0) return const declarations = rule.nodes.filter(node => node.type === 'decl') const textAlign = declarations.filter(node => node.prop.toLowerCase() === 'text-align') if (relevantSelectors.includes('*')) { globalResetRules.push({ file: record.file, sha256: actualHash, originalSelectors: selectors, offset: rule.source?.start?.offset ?? 0, declarations: declarations.map(node => node.clone()) }) } if (textAlign.length) { globalButtonTextAlignRules.push({ file: record.file, sha256: actualHash, originalSelectors: selectors, relevantSelectors, offset: rule.source?.start?.offset ?? 0, declarations: textAlign.map(node => ({ property: node.prop, value: node.value })) }) } }) } const sources = new Map() for (const spec of sourceSpecs) { const bytes = await readFile(resolve(exportDir, spec.file)) const manifestRecord = cssManifest.files?.find(record => record.file === spec.file) const actualHash = sha256(bytes) if (!manifestRecord?.sha256 || manifestRecord.sha256 !== spec.sha256 || actualHash !== spec.sha256) { throw new Error(`REA CSS digest mismatch for ${spec.file}: expected ${spec.sha256}, manifest ${manifestRecord?.sha256 ?? 'none'}, actual ${actualHash}`) } sources.set(spec.file, { file: spec.file, sha256: actualHash, asarComparison: manifestRecord.asarComparison?.status ?? 'not-compared', root: postcss.parse(bytes.toString('utf8'), { from: spec.file }) }) } const componentCss = sources.get('src-CBaV_A3S.css') const bridgeCss = sources.get('cb-bridge-jsj-8pWS.css') const optionClassName = classNames.optionItem const optionCssRules = [] componentCss.root.walkRules(rule => { const selectors = postcss.list.comma(rule.selector).map(selector => selector.trim()) if (selectors.includes(`.${optionClassName}`)) { optionCssRules.push({ originalSelectors: selectors, offset: rule.source?.start?.offset ?? 0, textAlign: rule.nodes.filter(node => node.type === 'decl' && node.prop.toLowerCase() === 'text-align').map(node => node.value) }) } }) if (optionCssRules.length !== 1 || optionCssRules[0].offset !== 650985 || optionCssRules[0].textAlign.length > 0) { throw new Error(`QuestionFloating option CSS evidence changed: ${JSON.stringify(optionCssRules)}`) } const inlineResetRule = globalResetRules.find(record => record.file === inlineResetFile && record.originalSelectors.includes('*')) if (!inlineResetRule || inlineResetRule.sha256 !== inlineResetSha256 || inlineResetRule.offset !== 5) { throw new Error(`REA global reset evidence changed: ${JSON.stringify(inlineResetRule ?? null)}`) } if (!inlineResetRule.declarations.some(node => node.prop === 'box-sizing' && node.value === 'border-box')) { throw new Error('REA global universal reset no longer declares box-sizing: border-box') } const optionClassOffset = runtimeSource.indexOf('cH.optionItem') if (optionClassOffset !== optionRuntimeClassOffset) { throw new Error(`QuestionFloating option JSX offset changed: expected ${optionRuntimeClassOffset}, found ${optionClassOffset}`) } const sourceOptionStart = runtimeSource.lastIndexOf('(0,bH.jsxs)(`div`,{', optionClassOffset) const sourceOptionSnippet = runtimeSource.slice(sourceOptionStart, optionClassOffset + 700) if (sourceOptionStart < 0 || !sourceOptionSnippet.includes('role:`button`') || !sourceOptionSnippet.includes('tabIndex:O?-1:0') || !sourceOptionSnippet.includes('"aria-disabled":O||void 0') || !sourceOptionSnippet.includes('onKeyDown:')) { throw new Error('REA QuestionFloating option no longer uses the verified div/button keyboard structure') } const output = postcss.root() const optionUniversalResetRules = globalResetRules.filter(record => record.originalSelectors.includes('*')) for (const reset of optionUniversalResetRules) { output.append(sourceComment({ file: reset.file, hash: reset.sha256, offset: reset.offset, selectors: reset.originalSelectors, evidenceId: cssEvidenceId, note: 'global universal reset projection=.chat-pane.cr-theme .question-floating__option' })) output.append(postcss.rule({ selector: '.chat-pane.cr-theme .question-floating__option', nodes: reset.declarations.map(node => node.clone()) })) } const moduleRules = [] for (const node of componentCss.root.nodes) { output.append(...projectModuleNode(node, componentCss, classNames, moduleRules)) } const sourceModuleClasses = Object.entries(classNames) .filter(([key]) => classAliases[key]) .map(([, className]) => className) const missingRules = sourceModuleClasses.filter(className => !moduleRules.some(record => record.originalSelectors.some(selector => classPattern(className).test(selector)))) if (missingRules.length) throw new Error(`REA CSS is missing QuestionFloating module rules for: ${missingRules.join(', ')}`) const animations = [] output.walkDecls(/^animation(?:-name)?$/, declaration => { animations.push(...declaration.value.split(/[\s,]+/)) }) let projectedAnimationRuleCount = 0 componentCss.root.walkAtRules(/keyframes$/, atRule => { if (!animations.includes(atRule.params)) return const offset = atRule.source?.start?.offset ?? 0 output.append(sourceComment({ file: componentCss.file, hash: componentCss.sha256, offset, selectors: [`@${atRule.name} ${atRule.params}`], evidenceId: cssEvidenceId, note: `runtime stylesheet ASAR comparison=${componentCss.asarComparison}` })) output.append(atRule.clone()) projectedAnimationRuleCount += 1 }) const themeRules = [] projectThemeTokens(componentCss.root, componentCss, output, themeRules) const bridgeRules = [] projectBridgeTokens(bridgeCss.root, bridgeCss, output, bridgeRules) const header = postcss.comment({ text: ` Generated by scripts/rea/recover-question-styles.mjs. Runtime component evidence=${runtimeEvidenceId}; source=${runtimeFile}; sha256=${runtimeSha256}; QuestionFloating offset=${componentOffset}; CSS module map offset=${moduleMapOffset}; footer offset=${footerOffset}; module=${moduleId}. CSS evidence=${cssEvidenceId}; ${componentCss.file} sha256=${componentCss.sha256}; ASAR comparison=${componentCss.asarComparison}; ${bridgeCss.file} sha256=${bridgeCss.sha256}; global reset=${inlineResetFile}@${inlineResetRule.offset}; global button text-align matches=${globalButtonTextAlignRules.length}. Selectors are scoped to .chat-pane.cr-theme and translated from the runtime CSS module map. ` }) output.prepend(header) formatContainer(output, -1) await writeFile(outputPath, output.toString() + '\n') const localComponent = await readFile(resolve(rootDir, 'src/renderer/src/workbuddy/conversation/QuestionFloating.tsx'), 'utf8') const localOptionClassOffset = localComponent.indexOf("'question-floating__option'") const localOptionStart = localComponent.lastIndexOf(' match[1]))] const mappedClasses = new Set(Object.values(classAliases).filter(Boolean)) const unmappedLocalClasses = localClasses.filter(className => !mappedClasses.has(className)) const projectedRuleCount = moduleRules.length + themeRules.length + bridgeRules.length + projectedAnimationRuleCount + optionUniversalResetRules.length console.log(JSON.stringify({ runtimeEvidenceId, runtimeFile, runtimeSha256, componentOffset, moduleMapOffset, footerOffset, cssEvidenceId, moduleId, projectedRuleCount, projectedModuleRuleCount: moduleRules.length, projectedThemeTokenRuleCount: themeRules.length, projectedBridgeTokenRuleCount: bridgeRules.length, projectedAnimationRuleCount, projectedUniversalResetRuleCount: optionUniversalResetRules.length, globalButtonTextAlignRules, globalResetSource: { evidenceId: cssEvidenceId, file: inlineResetFile, sha256: inlineResetSha256, originalSelectors: inlineResetRule.originalSelectors, offset: inlineResetRule.offset, declarations: inlineResetRule.declarations.map(node => ({ property: node.prop, value: node.value })) }, optionSource: { runtimeEvidenceId, file: runtimeFile, sha256: runtimeSha256, jsxClassOffset: optionClassOffset, element: 'div', role: 'button', cssEvidenceId, cssFile: componentCss.file, cssSha256: componentCss.sha256, cssOriginalSelectors: optionCssRules[0].originalSelectors, cssOffset: optionCssRules[0].offset, cssTextAlignDeclarations: optionCssRules[0].textAlign, localElement: 'div', localRole: 'button', localClassOffset: localOptionClassOffset }, globalCssFilesScanned: cssManifest.files?.length ?? 0, sources: sourceSpecs.map(spec => ({ file: spec.file, sha256: spec.sha256, asarComparison: cssManifest.files.find(record => record.file === spec.file)?.asarComparison?.status ?? 'not-compared' })), unmappedLocalClasses, outputPath }, null, 2))