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

84 lines
5.8 KiB
JavaScript

// Project rules from already-exported REA CSS. Never accesses WorkBuddy.
import fs from 'node:fs/promises'
import path from 'node:path'
import crypto from 'node:crypto'
import postcss from 'postcss'
const directory = path.resolve('.tmp/rea-css-recovered')
const manifest = JSON.parse(await fs.readFile(path.join(directory, 'manifest.json'), 'utf8'))
const destination = path.resolve('src/renderer/src/workbuddy/conversation/source-styles.css')
const families = /\.(?:cr-(?:frame|agent|self-|user-content|collapse|reasoning|tool-exp|tool-head|message-list|document|toolbar|icon-button|text-block)|cb-plan-|cb-exploration-status|conversation-timeline|conversation-high-credit-approval)/
const output = postcss.root()
const selected = []
for (const filename of ['src-CBaV_A3S.css', 'src-CyXHA0IC.css', 'contexts-BkjwYSZT.css']) {
const metadata = manifest.files.find(file => file.file === filename)
if (!metadata) throw new Error(`REA CSS not captured: ${filename}`)
const bytes = await fs.readFile(path.join(directory, filename))
if (crypto.createHash('sha256').update(bytes).digest('hex') !== metadata.sha256) throw new Error(`REA CSS digest mismatch: ${filename}`)
const source = postcss.parse(bytes, { from: filename })
let rules = 0
function project(node) {
if (node.type === 'rule') {
const selectors = node.selectors ?? []
const actionMap = { '._assistantFeedback_1jhpu_1': '.msg--assistant .wb-message-actions', '._copyButton_1ncbp_1': '.wb-message-action[data-action=copy]', '._ratingButton_13zum_7': '.wb-message-action[aria-pressed]', '._actionButton_jnle9_7': '.wb-message-action[data-action=regenerate]', '._iconOnly_jnle9_27': '.wb-message-action' }
const actionSelectors = selectors.filter(selector => /\._(?:assistantFeedback_1jhpu_1|copyButton_1ncbp_1|ratingButton_13zum_7|actionButton_jnle9_7)/.test(selector)).map(selector => {
for (const [from, to] of Object.entries(actionMap)) selector = selector.replaceAll(from, to)
return selector
})
if (actionSelectors.length) { rules++; return node.clone({ selector: actionSelectors.join(',') }) }
const userProjection = {
'._editable_1cxd2_1': '.chat-pane .yc-user-editable',
'._editable_1cxd2_1 *': '.chat-pane .yc-user-editable *',
'._editable_1cxd2_1 p': '.chat-pane .yc-user-editable p',
'._editable_1cxd2_1 span': '.chat-pane .yc-user-editable span',
'._editable_1cxd2_1 [data-slate-node=text]': '.chat-pane .yc-user-leaf',
'._editable_1cxd2_1[contenteditable=false] p': '.chat-pane .yc-user-editable p',
'._editable_1cxd2_1[contenteditable=false] [data-slate-node=text]': '.chat-pane .yc-user-leaf',
'._editable_1cxd2_1[contenteditable=false] [data-slate-leaf=true]': '.chat-pane .yc-user-leaf > span',
'._editable_1cxd2_1[contenteditable=false] [data-slate-string=true]': '.chat-pane .yc-user-leaf > span',
}
const projected = selectors.filter(selector => userProjection[selector]).map(selector => userProjection[selector])
if (projected.length) { rules++; return node.clone({ selector: [...new Set(projected)].join(',') }) }
let theme
if (selectors.includes(':root')) theme = '.chat-pane.cr-theme'
else if (selectors.includes('.light') || selectors.includes(':root.light') || selectors.includes('.cr-theme[data-cr-theme=light]')) theme = '.chat-pane.cr-theme[data-cr-theme=light]'
else if (selectors.includes('.dark') || selectors.includes(':root.dark') || selectors.includes('.cr-theme[data-cr-theme=dark]')) theme = '.chat-pane.cr-theme[data-cr-theme=dark]'
if (theme) {
const declarations = node.nodes.filter(d => d.type === 'decl' && d.prop.startsWith('--') && !/^--(?:wb-font-(?:size|line-height)-|cb-md-root$)/.test(d.prop))
if (!declarations.length) return
rules++
return postcss.rule({ selector: theme, nodes: declarations.map(d => d.clone()) })
}
const relevant = selectors.filter(selector => families.test(selector) && !/hydration|virtual-|share-mode|loading-more/.test(selector))
if (!relevant.length) return
rules++
return node.clone({ selector: relevant.join(',') })
}
if (node.type === 'atrule' && /keyframes$/.test(node.name)) return
if (node.type === 'atrule' && node.nodes) {
const children = node.nodes.map(project).filter(Boolean)
if (children.length) return node.clone({ nodes: children })
}
}
for (const node of source.nodes) {
const result = project(node)
if (result) output.append(result)
}
const animations = []
output.walkDecls(d => { if (/^animation(?:-name)?$/.test(d.prop)) animations.push(d.value) })
source.walkAtRules(/keyframes$/, node => {
if (animations.some(value => value.split(/[\s,]+/).includes(node.params))) output.append(node.clone())
})
selected.push({ file: filename, sha256: metadata.sha256, asarComparison: metadata.asarComparison?.status ?? 'not-compared', rules })
}
const header = `/* Generated by recover-conversation-styles.mjs from REA ${manifest.sourceEvidenceId}.
* Original selected rule bodies and conditional ancestors are preserved.
* Readonly user-editor selectors are projected onto static paragraph/leaf wrappers.
* Global theme declarations are scoped to .chat-pane; font-size controls remain inherited.
* Runtime/ASAR mismatches remain recorded in the source manifest.
*/\n`
await fs.writeFile(destination, header + output.toString() + '\n')
const record = { evidenceId: manifest.sourceEvidenceId, selected, destination, transformations: ['Theme selector scope only; custom-property values preserved.', 'Only listed conversation families selected; unimplemented virtualization excluded.', 'Conditional at-rule ancestors and animation keyframes retained.'] }
await fs.writeFile(path.join(directory, 'conversation-projection.json'), JSON.stringify(record, null, 2) + '\n')
console.log(JSON.stringify(record, null, 2))