import { createHash } from 'node:crypto' import { readFile, writeFile } from 'node:fs/promises' import { dirname, relative, 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 outputPath = resolve(rootDir, 'src/renderer/src/workbuddy/composer/composer-source.css') const evidenceId = 'ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94' const sourceSpecs = [ { file: 'src-CyXHA0IC.css', selectors: [ '.cr-input-box', '.cr-input-box__main', '.cr-input-container', '.cr-input-editor-host', '.cr-input-toolbar', '.cr-input-toolbar__left', '.cr-input-toolbar__right', '.cr-input-toolbar__send', '.cr-input-footer-item', '.cr-input-footer-item:hover:not(:disabled)', '.cr-input-footer-item.cr-input-footer-item--open:not(:disabled)', '.cr-input-footer-item.cr-input-footer-item--open:not(:disabled) .cr-input-footer-item__label', '.cr-input-footer-item.cr-input-footer-item--open:not(:disabled) .cr-input-footer-item__chevron', '.cr-input-footer-item:focus-visible', '.cr-input-footer-item__icon', '.cr-input-footer-item__label', '.cr-input-footer-item__label:after', '.cr-permission-setting--danger.cr-input-footer-item', '.cr-permission-setting--danger.cr-input-footer-item .cr-input-footer-item__icon', '.cr-permission-setting--danger.cr-input-footer-item .cr-input-footer-item__label', '.cr-permission-setting--danger.cr-input-footer-item .cr-input-footer-item__chevron', '.cr-permission-setting--danger:hover.cr-input-footer-item', '.cr-permission-setting--danger.cr-input-footer-item--open.cr-input-footer-item', '.cr-permission-setting--danger:hover.cr-input-footer-item .cr-input-footer-item__icon', '.cr-permission-setting--danger:hover.cr-input-footer-item .cr-input-footer-item__label', '.cr-permission-setting--danger:hover.cr-input-footer-item .cr-input-footer-item__chevron', '.cr-permission-setting--danger.cr-input-footer-item--open.cr-input-footer-item .cr-input-footer-item__icon', '.cr-permission-setting--danger.cr-input-footer-item--open.cr-input-footer-item .cr-input-footer-item__label', '.cr-permission-setting--danger.cr-input-footer-item--open.cr-input-footer-item .cr-input-footer-item__chevron', '.cr-input-toolbar__left .cr-permission-setting.cr-input-footer-item', '.cr-input-toolbar__left .cr-permission-setting.cr-input-footer-item:hover:not(:disabled)', '.cr-input-toolbar__left .cr-permission-setting.cr-input-footer-item.cr-input-footer-item--open', '.cr-send-button', '.cr-send-button:hover:not(:disabled)', '.cr-send-button:active:not(:disabled)', '.cr-send-button:disabled', '.cr-send-button--sending', '.cr-send-button--stop', '.cr-send-button--custom', '.cr-send-button--custom:hover:not(:disabled)', '.cr-send-button__icon', '.cr-send-button__icon>svg', '.cr-voice-trigger', '.cr-voice-trigger:hover:not(:disabled)', '.cr-voice-trigger--recording', '.cr-voice-trigger--recording:hover:not(:disabled)', '.cr-voice-trigger--loading', '.cr-voice-trigger>svg' ] }, { file: 'contexts-BkjwYSZT.css', selectors: [ '.conversation-input-area', '.conversation-input-area__custom-floating', '.conversation-input-area__input-stack', '.conversation-input .cr-input-box', '.conversation-input__box .cr-input-box__main', '.conversation-input__box .cr-input-container', '.conversation-input__box .cr-permission-setting:not(.cr-permission-setting--danger):not(:disabled)', '.conversation-input__box .cr-permission-setting:not(.cr-permission-setting--danger):not(:disabled) .cr-input-footer-item__label' ] } ] function normalizeSelector(selector) { return selector.trim().replace(/\s*([>+~])\s*/g, '$1').replace(/\s+/g, ' ') } function sourceHash(bytes) { return createHash('sha256').update(bytes).digest('hex') } function formatRule(selector, declarations, source, sha256, note) { const comment = `/* REA ${source.file}:1:${source.column}; sha256 ${sha256}${note ? `; ${note}` : ''} */` const body = declarations.map((declaration) => ` ${declaration}`).join('\n') return `${comment}\n${selector} {\n${body}\n}` } function declarationText(rule) { return rule.nodes .filter((node) => node.type === 'decl') .map((node) => `${node.prop}: ${node.value}${node.important ? ' !important' : ''};`) } function collectRules(root, source, selectors) { const allowed = new Set(selectors.map(normalizeSelector)) const records = [] root.walkRules((rule) => { const selected = postcss.list.comma(rule.selector) .map(normalizeSelector) .filter((selector) => allowed.has(selector)) if (selected.length === 0) return const declarations = declarationText(rule) if (declarations.length === 0) return records.push({ source, column: rule.source?.start?.column ?? 1, selectors: selected, declarations }) }) return records } function findRule(root, selector) { const expected = normalizeSelector(selector) let match root.walkRules((rule) => { if (match) return if (postcss.list.comma(rule.selector).some((item) => normalizeSelector(item) === expected)) match = rule }) if (!match) throw new Error(`REA stylesheet is missing required selector: ${selector}`) return match } function declaration(rule, property) { const match = rule.nodes.find((node) => node.type === 'decl' && node.prop === property) if (!match) throw new Error(`REA rule ${rule.selector} is missing ${property}`) return match.value } function resolveToken(token, tokens, seen = new Set()) { if (seen.has(token)) throw new Error(`Cyclic REA token reference: ${token}`) const value = tokens.get(token) if (!value) throw new Error(`REA token is missing: ${token}`) const simpleVar = /^var\((--[\w-]+)\)$/.exec(value.trim()) if (!simpleVar) return value.trim() seen.add(token) return resolveToken(simpleVar[1], tokens, seen) } const manifestBytes = await readFile(resolve(exportDir, 'manifest.json')) const manifest = JSON.parse(manifestBytes.toString('utf8')) if (manifest.sourceEvidenceId !== evidenceId) { throw new Error(`REA CSS evidence changed: expected ${evidenceId}, found ${manifest.sourceEvidenceId ?? 'none'}`) } const sources = new Map() for (const spec of sourceSpecs) { const bytes = await readFile(resolve(exportDir, spec.file)) const record = manifest.files?.find((file) => file.file === spec.file) if (!record?.sha256) throw new Error(`REA manifest has no SHA-256 for ${spec.file}`) const actualHash = sourceHash(bytes) if (actualHash !== record.sha256) { throw new Error(`REA CSS digest mismatch for ${spec.file}: manifest ${record.sha256}, actual ${actualHash}`) } sources.set(spec.file, { ...spec, bytes, sha256: actualHash, root: postcss.parse(bytes.toString('utf8'), { from: spec.file }) }) } const extraFiles = ['src-Bh3YZ35H.css', 'tokens-Df-i-zsO.css'] for (const file of extraFiles) { const bytes = await readFile(resolve(exportDir, file)) const record = manifest.files?.find((item) => item.file === file) if (!record?.sha256) throw new Error(`REA manifest has no SHA-256 for ${file}`) const actualHash = sourceHash(bytes) if (actualHash !== record.sha256) { throw new Error(`REA CSS digest mismatch for ${file}: manifest ${record.sha256}, actual ${actualHash}`) } sources.set(file, { file, bytes, sha256: actualHash, root: postcss.parse(bytes.toString('utf8'), { from: file }) }) } const cy = sources.get('src-CyXHA0IC.css') const contexts = sources.get('contexts-BkjwYSZT.css') const button = sources.get('src-Bh3YZ35H.css') const tokensRoot = sources.get('tokens-Df-i-zsO.css').root const tokens = new Map() tokensRoot.walkDecls((decl) => { if (decl.prop.startsWith('--wb-') && !tokens.has(decl.prop)) tokens.set(decl.prop, decl.value) }) const sourceRules = sourceSpecs.flatMap((spec) => { const source = sources.get(spec.file) return collectRules(source.root, source, spec.selectors) }) for (const spec of sourceSpecs) { const found = new Set(sourceRules .filter((record) => record.source.file === spec.file) .flatMap((record) => record.selectors)) const missing = spec.selectors.map(normalizeSelector).filter((selector) => !found.has(selector)) if (missing.length) throw new Error(`REA stylesheet ${spec.file} is missing selected rules: ${missing.join(', ')}`) } const slateEditorRule = findRule(cy.root, '.cr-input-editor-host [data-slate-editor]') const focusedContainerRule = findRule(cy.root, '.cr-input-container--focused') const sendButtonRule = findRule(cy.root, '.cr-send-button') const sendIconRule = findRule(cy.root, '.cr-send-button__icon') const nativeButtonRule = findRule(button.root, '.wb-button') const iconOnlyRule = findRule(button.root, '.wb-button--icon-only') const iconOnlyMediumRule = findRule(button.root, '.wb-button--icon-only.wb-button--medium') const plainButtonRule = findRule(button.root, '.wb-button--plain') const circleButtonRule = findRule(button.root, '.wb-button--circle') const footerItemRule = findRule(cy.root, '.cr-input-footer-item') const sendSize = resolveToken('--wb-button-height-medium', tokens) const sendRadius = declaration(circleButtonRule, 'border-radius') const iconOnlyWidth = declaration(iconOnlyMediumRule, 'width') const iconOnlyHeight = declaration(iconOnlyMediumRule, 'height') const footerItemHeight = declaration(footerItemRule, 'height') if (sendSize !== '32px' || footerItemHeight !== sendSize || iconOnlyWidth !== 'var(--wb-button-height-medium)' || iconOnlyHeight !== iconOnlyWidth || sendRadius !== '50%') { throw new Error(`Unexpected REA send-button sizing: height ${sendSize}, button width ${iconOnlyWidth}, height ${iconOnlyHeight}`) } if (declaration(sendButtonRule, '--wb-button-icon-size-medium') !== '32px' || declaration(sendIconRule, 'width') !== '32px') { throw new Error('REA send-button icon sizing changed; review the native-button projection before regenerating') } const sourceNotes = [ `CSS Evidence ID ${evidenceId}`, `capture target ${manifest.target?.title ?? 'unknown'}; provider ${manifest.provider?.id ?? 'unknown'}`, ...[...sources.values()].map((source) => `${source.file} sha256=${source.sha256}`), 'Only selected input-area rules are recovered; source sheets are not imported wholesale.', 'WorkBuddy input-wrapper wb-color tokens use the recovered scoped theme declarations.', 'The native textarea receives declarations selected from .cr-input-editor-host [data-slate-editor]; no Slate data attributes are added.', 'The native send button receives the required .wb-button base, icon-only, medium, and plain declarations as a local projection.' ] const cssSections = [ `/*\n * Generated by scripts/rea/recover-composer-styles.mjs.\n${sourceNotes.map((note) => ` * ${note}`).join('\n')}\n */`, ...sourceRules.map((record) => formatRule( record.selectors.join(', '), record.declarations, { file: record.source.file, column: record.column }, record.source.sha256 )), formatRule( '.cr-input-container:focus-within', declarationText(focusedContainerRule), { file: cy.file, column: focusedContainerRule.source?.start?.column ?? 1 }, cy.sha256, 'projected from the original focus state class for a native focusable container' ), formatRule( '.conversation-input__box .cr-input-container:focus-within', declarationText(findRule(contexts.root, '.conversation-input__box .cr-input-container--focused')), { file: contexts.file, column: findRule(contexts.root, '.conversation-input__box .cr-input-container--focused').source?.start?.column ?? 1 }, contexts.sha256, 'selector projection of WorkBuddy focus-border class' ), formatRule( '.chat-pane[data-cr-theme=dark] .conversation-input__box .cr-input-container:focus-within', declarationText(findRule(contexts.root, '.vscode-dark .conversation-input__box .cr-input-container--focused')), { file: contexts.file, column: findRule(contexts.root, '.vscode-dark .conversation-input__box .cr-input-container--focused').source?.start?.column ?? 1 }, contexts.sha256, 'dark theme selector projection' ), formatRule( '.cr-input-editor-host > .wb-composer__input', declarationText(slateEditorRule), { file: cy.file, column: slateEditorRule.source?.start?.column ?? 1 }, cy.sha256, 'selector projection from [data-slate-editor] to the native textarea; attribute intentionally omitted' ), formatRule( '.wb-composer__add-button.cr-input-footer-item', [ `width: ${footerItemHeight};`, `min-width: ${footerItemHeight};`, 'padding: 0;' ], { file: cy.file, column: footerItemRule.source?.start?.column ?? 1 }, cy.sha256, 'icon-only projection retains the original 32px footer-item height' ), `/* Native button adapter from ${button.file} and ${sources.get('tokens-Df-i-zsO.css').file}; .wb-button base/icon-only/plain/circle declarations, resolved medium height ${sendSize}, border radius ${sendRadius}. */\n.cr-send-button {\n${[ 'box-sizing', 'cursor', 'white-space', 'user-select', 'border', 'justify-content', 'align-items', 'font-family', 'font-weight', 'text-decoration', 'display' ].map((property) => ` ${property}: ${declaration(nativeButtonRule, property)};`).join('\n')}\n width: ${sendSize};\n min-width: ${declaration(iconOnlyRule, 'min-width')};\n height: ${sendSize};\n padding: ${declaration(iconOnlyRule, 'padding')};\n border-radius: ${sendRadius};\n color: ${declaration(plainButtonRule, 'color')};\n background-color: ${declaration(plainButtonRule, 'background-color')};\n}` ] const output = `${cssSections.join('\n\n')}\n` postcss.parse(output, { from: relative(rootDir, outputPath) }) await writeFile(outputPath, output, 'utf8') console.log(`Wrote ${relative(rootDir, outputPath)} from ${sources.size} REA CSS exports (${evidenceId}).`)