Files
ycode/scripts/rea/recover-question-styles.mjs
T

473 lines
22 KiB
JavaScript

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('<div', localOptionClassOffset)
const localOptionSnippet = localComponent.slice(localOptionStart, localOptionClassOffset + 900)
if (localOptionStart < 0 || !localOptionSnippet.includes('role="button"') || !localOptionSnippet.includes('tabIndex=') || !localOptionSnippet.includes('aria-disabled=') || !localOptionSnippet.includes('onKeyDown=')) {
throw new Error('Local QuestionFloating option must preserve the recovered div/button keyboard structure')
}
const localClasses = [...new Set([...localComponent.matchAll(/['"](question-floating(?:__[\w-]+|--[\w-]+)?)['"]/g)].map(match => 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))