From 3af890cb2c0b8dee72cdb17642c3ea4fab49a650 Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 04:34:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(marketplace):=20port=20official=200.78?= =?UTF-8?q?.1=20manage=20page=20(=E7=AE=A1=E7=90=86=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E6=8A=80=E8=83=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse dialog's 已连接 N 个 entry opens a second-level page. Upstream 0.18 renders that page differently, so this ports official 0.78.1's detail bar, hero, installed rows and skills section, and re-recipes the four shared components official uses on both pages. Three differences are fixed. The green 已连接 badge rendered white because the injector looked for a class token anywhere in the stylesheet instead of inside the dialog. The auth rows read 身份验证 because upstream hardcodes the authorize-mode labels. The detail-bar title stayed visible where official hides it until the hero scrolls out. The recipe fixture is captured live from official. The CSS companion holds official's rules verbatim. --- scripts/clean-build.mjs | 13 + ...marketplace-manage-page-0781-css-patch.mjs | 608 +++++++++++++++ .../marketplace-manage-page-0781-official.css | 114 +++ .../marketplace-manage-page-0781-patch.mjs | 735 ++++++++++++++++++ .../official-0781-manage-page-recipes.json | 109 +++ tests/marketplace-manage-page-0781.test.mjs | 339 ++++++++ 6 files changed, 1918 insertions(+) create mode 100644 scripts/lib/marketplace-manage-page-0781-css-patch.mjs create mode 100644 scripts/lib/marketplace-manage-page-0781-official.css create mode 100644 scripts/lib/marketplace-manage-page-0781-patch.mjs create mode 100644 tests/fixtures/official-0781-manage-page-recipes.json create mode 100644 tests/marketplace-manage-page-0781.test.mjs diff --git a/scripts/clean-build.mjs b/scripts/clean-build.mjs index be205a80..6acb548b 100644 --- a/scripts/clean-build.mjs +++ b/scripts/clean-build.mjs @@ -75,6 +75,8 @@ import { applyMarketplaceDialog0781Typography } from "./lib/marketplace-dialog-0 import { applyMarketplaceDialog0781Layout } from "./lib/marketplace-dialog-0781-layout-css-patch.mjs"; import { applyMarketplaceBrowseChrome0781 } from "./lib/marketplace-browse-chrome-0781-patch.mjs"; import { applyMarketplaceBrowseChrome0781Css } from "./lib/marketplace-browse-chrome-0781-css-patch.mjs"; +import { applyMarketplaceManagePage0781 } from "./lib/marketplace-manage-page-0781-patch.mjs"; +import { applyMarketplaceManagePage0781Css } from "./lib/marketplace-manage-page-0781-css-patch.mjs"; import { applyMarketplaceDetailView0781 } from "./lib/marketplace-detail-view-0781-patch.mjs"; import { applyMarketplaceDetailView0781Css } from "./lib/marketplace-detail-view-0781-css-patch.mjs"; import { applyUpdatePillCopy0755Patch } from "./lib/update-pill-copy-0755-patch.mjs"; @@ -518,6 +520,17 @@ export async function buildFidelityReconstructedAsar({ await applyMarketplaceBrowseChrome0781({ stageRoot }); // The 28 official 0.78.1 browse rules upstream 0.18's stylesheet does not define. await applyMarketplaceBrowseChrome0781Css({ stageRoot }); + // Official 0.78.1's SECOND-LEVEL page — the "管理插件和技能" page the "已连接 N 个" entry + // opens: its detail bar with a labelled back button, its "管理插件和技能" hero, its installed + // section (official's rows, its "显示全部 N 个插件" control and its collapsed remainder) and its + // private-skills section. It also re-recipes the four shared components (row, grid, section, + // show-all) to official 0.78.1's recipes, which official uses on both pages. + // MUST run after the browse-chrome patch: its chrome anchors are the POST-browse text (the + // header, the search row and the chip row the browse patch rewrote). + await applyMarketplaceManagePage0781({ stageRoot }); + // The official 0.78.1 rules the second-level page introduces and upstream 0.18's stylesheet + // does not define. + await applyMarketplaceManagePage0781Css({ stageRoot }); // Official 0.78.1's plugin DETAIL page: the header's copy-link button, the share + connect // action group, the 应用 / 技能 cards and the 信息 table. // MUST run after the browse-chrome patch: its anchors are the POST-browse text (the browse diff --git a/scripts/lib/marketplace-manage-page-0781-css-patch.mjs b/scripts/lib/marketplace-manage-page-0781-css-patch.mjs new file mode 100644 index 00000000..e8dcd6da --- /dev/null +++ b/scripts/lib/marketplace-manage-page-0781-css-patch.mjs @@ -0,0 +1,608 @@ +/** + * Official Grok Bot 0.78.1's second-level "管理插件和技能" page rules, injected into upstream 0.18's + * stylesheet. + * + * ## What this step does + * + * The renderer patch re-recipes the plugin row, the plugins grid, the section header, the show-all + * control, the collapsed remainder list, the hero and the detail bar's labelled back button with + * official 0.78.1's own class recipes. A stylix class name is the hash of its declaration block, so + * a class that exists in official 0.78.1's stylesheet and not in upstream 0.18's is a NO-OP: the + * element renders unstyled and nothing throws. Every other test stays green. + * + * This step appends the rules for those classes to the staged renderer stylesheet. The rules + * themselves are not authored here — they are lifted byte-for-byte from official 0.78.1's own + * stylesheet into `marketplace-manage-page-0781-official.css`, the same way the sibling wave's + * dialog / browse-chrome / detail-view companions are. + * + * ## Why the rules are scoped + * + * The scope is the plugins-dialog subtree, the convention the sibling wave's theme-token and + * typography companions already use: + * + * `:where(.sand-plugins-dialog) ` — a DESCENDANT prefix on every selector of the rule. + * + * `:where()` contributes NO specificity, so a ported rule weighs against upstream 0.18's own class + * rules exactly as official's rule weighs against official's. A plain `.sand-plugins-dialog` prefix + * would add one class of specificity and silently reorder the `:not(#\#)` ladder that orders these + * rules against their neighbours. + * + * ## Fail closed + * + * The class list is the measured fixture, not a hand-written one, so the port cannot drift away + * from the stylesheet silently: + * + * * every class the port applies must resolve INSIDE the plugins dialog in the staged + * stylesheet once this step ran — a class that resolves nowhere is a hard error, not a + * skipped line; + * * a class that does not resolve yet must be covered by the companion CSS, or the step throws; + * * every rule in the companion CSS must carry the scope prefix on every selector; + * * every rule in the companion CSS must be byte-for-byte a rule of official 0.78.1's stylesheet + * (checked whenever the 0.78.1 analysis tree is present on this machine); + * * the companion CSS must reproduce byte-for-byte when re-rendered from its parsed blocks, so a + * hand edit that the injector cannot reproduce is caught instead of silently shipped. + * + * Read-only apart from the staged stylesheet: this step appends bytes to the staged renderer CSS + * and records its hashes as evidence. + * + * @evidence official 0.78.1 `rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css` + * @evidence upstream 0.18 `src/app/dist/renderer/assets/index-lCyB53CO.css` + * @evidence `tests/fixtures/official-0781-manage-page-recipes.json` — the measured recipes. + */ + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const OFFICIAL_CSS_FILE = "marketplace-manage-page-0781-official.css"; +const OFFICIAL_STYLESHEET = new URL( + "../../../rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css", + import.meta.url, +); +const RECIPES_FIXTURE = new URL( + "../../tests/fixtures/official-0781-manage-page-recipes.json", + import.meta.url, +); + +/** The scope prefix every ported rule carries. `:where()` keeps the rule's own specificity. */ +export const MARKETPLACE_MANAGE_PAGE_0781_SCOPE = ":where(.sand-plugins-dialog) "; + +/** + * The dialog subtree the ported rules are scoped to. + * + * A class token appearing anywhere in the staged stylesheet is NOT the same as a class the dialog + * actually renders with that style. The repo's own conversation-computer-card port carries + * official's green rule scoped to `.r-conversation-computer-card`, so the staged stylesheet holds + * `sand-1ufr4hg` and never styles it inside the plugins dialog — the connected badge would render + * white without the rule below. Every "already carried" and "resolves" test is therefore scoped. + */ +const DIALOG_CLASS = "sand-plugins-dialog"; + +/** + * The classes the port applies, read out of the measured fixture. + * + * `privateRow.*` is the same row component on the private-skills section; the show-all wrapper and + * the back button's inner icon and label spans are not in the fixture — they are the live-DOM + * measurements quoted in the port spec (§2.3 and §2.6) and are declared here verbatim. + */ +const RECIPE_FIELDS = [ + ["yours", "class"], + ["hero", "class"], + ["hero", "wrapperClass"], + ["detailBar", "class"], + ["detailBar", "buttonClass"], + ["detailBar", "h3Class"], + ["detailBar", "h3HiddenClass"], + ["detailBar", "trailingClass"], + ["installedSection", "class"], + ["installedSection", "headerClass"], + ["installedSection", "h3Class"], + ["installedSection", "gridClass"], + ["privateSection", "class"], + ["privateSection", "headerClass"], + ["privateSection", "h3Class"], + ["privateSection", "gridClass"], + ["row", "class"], + ["row", "openClass"], + ["row", "mainClass"], + ["row", "nameClass"], + ["row", "subtitleClass"], + ["row", "trailingClass"], + ["row", "statusClass"], + ["privateRow", "class"], + ["privateRow", "openClass"], + ["privateRow", "mainClass"], + ["privateRow", "nameClass"], + ["privateRow", "subtitleClass"], + ["privateRow", "trailingClass"], + ["privateRow", "iconClass"], + ["showAll", "class"], + ["collapsed", "class"], + ["collapsed", "innerClass"], + ["collapsed", "gridClass"], +]; + +/** The port-spec's own live-DOM measurements, which the fixture does not carry. */ +const SPEC_MEASUREMENTS = { + // §2.3 — the visible wrapper around the "显示全部 N 个插件" control. + showAllWrapper: + "sand-rvj5dj sand-1erjwpq sand-1ympp8d sand-1upsisp sand-t35mv4 sand-1r6a6jf sand-1tu4anv sand-1hc1fzr sand-67bb7w", + // §2.6 — the labelled back button's inner icon span. + backButtonIcon: + "sand-kit-icon sand-3nfvp2 sand-6s0dn4 sand-l56j7k sand-2lah0s sand-1heor9g sand-w4jnvo sand-1qx5ct2", + // §2.6 — the labelled back button's label span. + backButtonLabel: "sand-b3r6kr sand-lyipyv sand-uxw1ft", +}; + +/** + * The classes official 0.78.1 itself gives no rule to. + * + * They are stylix namespace markers or plain hooks (`sand-plugins__group`, `sand-plugins-row`, + * `sand-kit-icon`, `ui-text`, …). Nothing is lifted for them and nothing may be asserted about + * them: official's own stylesheet carries no rule for any of them either. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_NOOP_CLASSES = [ + "sand-kit-icon", + "sand-plugins-row", + "sand-plugins-row__main", + "sand-plugins-row__name", + "sand-plugins-row__open", + "sand-plugins-row__subtitle", + "sand-plugins-row__trailing", + "sand-plugins__grid", + "sand-plugins__group", + "sand-plugins__show-all", + "sand-plugins__status", + "sand-plugins__yours", + "sand-settings-detail-bar", + "ui-text", +]; + +/** + * The alias classes official ships inside a rule that is lifted for another class. + * + * Official writes one declaration under two names when two recipes share it; the alias travels + * with the rule and is scoped with it. + */ +const COMPANION_ALIAS_CLASSES = ["sand-11ct0s3", "sand-1f9r7lp", "sand-vhurb8"]; + +const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex"); + +/** Strip quoted strings so a `.` inside an attribute value is not read as a class. */ +function unquote(selector) { + return selector.replace(/"[^"]*"|'[^']*'/g, ""); +} + +/** The class tokens a selector mentions. */ +function classesInSelector(selector) { + return [...unquote(selector).matchAll(/\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g)].map((match) => match[1]); +} + +/** + * Parse a stylesheet into its simple rules, each with the chain of at-rules that wraps it. + * + * @returns {{ selector: string, body: string, text: string, context: string[] }[]} + */ +function parseStylesheet(css) { + const rules = []; + let index = 0; + let buffer = ""; + + const parseBlock = (context) => { + while (index < css.length) { + const ch = css[index]; + if (ch === "}") return; + if (ch === "{") { + const prelude = buffer.trim(); + buffer = ""; + const bodyStart = index + 1; + let depth = 1; + let cursor = bodyStart; + while (cursor < css.length && depth > 0) { + if (css[cursor] === "{") depth += 1; + else if (css[cursor] === "}") depth -= 1; + if (depth === 0) break; + cursor += 1; + } + const body = css.slice(bodyStart, cursor); + if (prelude.startsWith("@")) { + const skip = + prelude.startsWith("@keyframes") || + prelude.startsWith("@font-face") || + prelude.startsWith("@property"); + if (!skip) { + index = bodyStart; + parseBlock([...context, prelude]); + } + } else { + rules.push({ + selector: prelude, + body, + text: `${prelude}{${body}}`, + context: [...context], + }); + } + index = cursor + 1; + buffer = ""; + continue; + } + buffer += ch; + index += 1; + } + }; + + parseBlock([]); + return rules; +} + +/** The class tokens a stylesheet's selectors mention. */ +function classesInStylesheet(css) { + const classes = new Set(); + for (const rule of parseStylesheet(css)) { + for (const name of classesInSelector(rule.selector)) classes.add(name); + } + return classes; +} + +/** Split one selector into its descendant compounds, honouring parentheses. */ +function splitCompounds(selector) { + const parts = []; + let depth = 0; + let current = ""; + for (const ch of selector) { + if (ch === "(" || ch === "[") depth += 1; + else if (ch === ")" || ch === "]") depth -= 1; + if (depth === 0 && (/\s/.test(ch) || ch === ">" || ch === "+" || ch === "~")) { + if (current) { + parts.push(current); + current = ""; + } + continue; + } + current += ch; + } + if (current) parts.push(current); + return parts; +} + +/** + * The classes a rule's SUBJECT carries. + * + * The subject is the last compound. The classes in the scope chain are ancestors, + * so they style their own element and not the subject. + */ +function subjectClasses(selector) { + const compounds = splitCompounds(selector); + if (compounds.length === 0) return []; + return classesInSelector(compounds[compounds.length - 1]); +} + +/** + * The classes a stylesheet actually styles INSIDE the plugins dialog. + * + * A class token appearing anywhere is not enough. The staged stylesheet carries + * `.r-conversation-computer-card .sand-1ufr4hg{color:…}` — the green the connected + * badge needs — but that rule never reaches the plugins dialog, so the port still has + * to carry the rule itself. A rule reaches the dialog when it is unscoped, or when + * its scope chain names the dialog. + */ +function classesStyledInDialog(css, dialogClass) { + const covered = new Set(); + for (const rule of parseStylesheet(css)) { + for (const part of splitSelectors(rule.selector)) { + const compounds = splitCompounds(part); + const scopes = compounds.slice(0, -1); + const reaches = + scopes.length === 0 || scopes.some((scope) => scope.includes(dialogClass)); + if (!reaches) continue; + for (const name of subjectClasses(part)) covered.add(name); + } + } + return covered; +} + +/** + * The classes a stylesheet styles inside the plugins dialog. + * + * Exported so the regression test can assert the scope-aware behaviour without reaching into + * the injector's internals. + */ +export function marketplaceManagePage0781ClassesStyledInDialog(css) { + return classesStyledInDialog(css, DIALOG_CLASS); +} + +/** Every rule that mentions a class, keyed by the class. */ +function rulesByClass(css) { + const byClass = new Map(); + for (const rule of parseStylesheet(css)) { + for (const name of classesInSelector(rule.selector)) { + if (!byClass.has(name)) byClass.set(name, []); + byClass.get(name).push(rule); + } + } + return byClass; +} + +/** Split a selector list on its top-level commas. */ +function splitSelectors(selector) { + const parts = []; + let depth = 0; + let current = ""; + for (const ch of selector) { + if (ch === "(" || ch === "[") depth += 1; + else if (ch === ")" || ch === "]") depth -= 1; + if (ch === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) parts.push(current.trim()); + return parts; +} + +/** Render a parsed block back to CSS, so a hand edit the injector cannot reproduce is caught. */ +function renderBlock(block) { + const rules = block.rules.join("\n"); + if (block.context.length === 0) return rules; + return `${block.context.join(" ")}{\n${rules}\n}`; +} + +const companionCss = readFileSync(new URL(`./${OFFICIAL_CSS_FILE}`, import.meta.url), "utf8"); + +/** The companion CSS without its comment header. */ +function companionBody(css) { + const start = css.indexOf("*/"); + if (!css.trimStart().startsWith("/*") || start < 0) { + throw new Error( + "Marketplace manage-page CSS patch: the companion must start with a comment header.", + ); + } + return css.slice(start + 2).trim(); +} + +/** The companion CSS parsed into blocks: at-rules keep their wrapper. */ +function companionBlocks(css) { + const body = companionBody(css); + const rules = parseStylesheet(body); + const blocks = []; + for (const rule of rules) { + const contextKey = rule.context.join(" "); + const last = blocks[blocks.length - 1]; + if (last && last.contextKey === contextKey) { + last.rules.push(rule.text); + continue; + } + blocks.push({ contextKey, context: rule.context, rules: [rule.text] }); + } + return { blocks, body }; +} + +/** + * Official's own rule for each class the companion lifts, byte for byte. + * + * The scope prefix is stripped from every selector, so the result is exactly the text official + * 0.78.1's stylesheet carries — a test can assert `officialStylesheet.includes(rule)`. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES = (() => { + const { blocks } = companionBlocks(companionCss); + const official = {}; + for (const block of blocks) { + for (const rule of block.rules) { + const selector = rule.slice(0, rule.indexOf("{")); + const body = rule.slice(rule.indexOf("{")); + const parts = splitSelectors(selector).map((part) => { + if (!part.startsWith(MARKETPLACE_MANAGE_PAGE_0781_SCOPE)) { + throw new Error( + `Marketplace manage-page CSS patch: a companion rule is not scoped: ${rule.slice(0, 80)}`, + ); + } + return part.slice(MARKETPLACE_MANAGE_PAGE_0781_SCOPE.length); + }); + const officialText = `${parts.join(",")}${body}`; + for (const name of classesInSelector(selector)) { + if (name === "sand-plugins-dialog") continue; + official[name] = officialText; + } + } + } + return Object.freeze(official); +})(); + +/** The class names the port applies, read out of the measured fixture. */ +export const MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES = (() => { + const fixture = JSON.parse(readFileSync(RECIPES_FIXTURE, "utf8")); + const classes = new Set(); + for (const [group, field] of RECIPE_FIELDS) { + const value = fixture[group]?.[field]; + if (typeof value !== "string" || value.length === 0) { + throw new Error( + `Marketplace manage-page CSS patch: the recipes fixture has no ${group}.${field}.`, + ); + } + for (const name of value.split(/\s+/)) if (name) classes.add(name); + } + for (const value of Object.values(SPEC_MEASUREMENTS)) { + for (const name of value.split(/\s+/)) if (name) classes.add(name); + } + return Object.freeze([...classes].sort()); +})(); + +/** + * Official Grok Bot 0.78.1's second-level "管理插件和技能" page rules, added to the staged stylesheet. + * + * @param {{ stageRoot: string }} options + * @returns {Promise} the provenance record this step wrote. + */ +export async function applyMarketplaceManagePage0781Css({ stageRoot }) { + const assets = path.join(stageRoot, "dist", "renderer", "assets"); + const stylesheets = (await readdir(assets)).filter((name) => /^index-.*\.css$/.test(name)); + if (stylesheets.length !== 1) { + throw new Error( + `Marketplace manage-page CSS patch: expected one main stylesheet, found ${stylesheets.length}.`, + ); + } + const file = stylesheets[0]; + const cssPath = path.join(assets, file); + const original = await readFile(cssPath, "utf8"); + + const { blocks, body } = companionBlocks(companionCss); + if (blocks.map(renderBlock).join("\n\n") !== body) { + throw new Error( + "Marketplace manage-page CSS patch: the companion CSS does not reproduce byte-for-byte from " + + "its parsed blocks.", + ); + } + + // The classes upstream 0.18 styles INSIDE the plugins dialog. A class token that only appears + // under a scope that never reaches the dialog does not count as carried. + const stagedInDialog = classesStyledInDialog(original, DIALOG_CLASS); + const portedClasses = MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES; + const noopClasses = new Set(MARKETPLACE_MANAGE_PAGE_0781_NOOP_CLASSES); + + // Every class the port applies must resolve once this step ran. + const unresolved = portedClasses.filter( + (name) => !noopClasses.has(name) && !stagedInDialog.has(name), + ); + const companionClasses = new Set(Object.keys(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)); + const uncovered = unresolved.filter((name) => !companionClasses.has(name)); + if (uncovered.length > 0) { + throw new Error( + "Marketplace manage-page CSS patch: these classes resolve nowhere and the companion CSS " + + `does not carry a rule for them: ${uncovered.join(", ")}.`, + ); + } + + // A rule that lifts a class the port does not apply is dead weight; fail closed on it. + const stray = [...companionClasses].filter( + (name) => + !portedClasses.includes(name) && !COMPANION_ALIAS_CLASSES.includes(name), + ); + if (stray.length > 0) { + throw new Error( + `Marketplace manage-page CSS patch: the companion CSS carries rules for classes the port does ` + + `not apply: ${stray.join(", ")}.`, + ); + } + + // Byte-for-byte official, whenever the 0.78.1 analysis tree is on this machine. + let officialStylesheet = null; + try { + officialStylesheet = await readFile(OFFICIAL_STYLESHEET, "utf8"); + } catch { + officialStylesheet = null; + } + if (officialStylesheet) { + for (const [name, rule] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)) { + if (!officialStylesheet.includes(rule)) { + throw new Error( + `Marketplace manage-page CSS patch: the rule for ${name} is not official 0.78.1's byte ` + + `for byte: ${rule.slice(0, 80)}`, + ); + } + } + for (const name of noopClasses) { + const rule = MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES[name]; + if (rule) { + throw new Error( + `Marketplace manage-page CSS patch: ${name} is declared a no-op class but official gives it ` + + "a rule.", + ); + } + } + } + + // Append the rules the staged stylesheet still lacks. A rule whose CLASS another patch in the + // chain already carries is left alone: the class is the identity here, not the rule text — + // otherwise the same declaration would ship twice, once scoped and once not. + const appended = []; + const skipped = []; + const payload = []; + for (const block of blocks) { + const needed = []; + for (const rule of block.rules) { + const selector = rule.slice(0, rule.indexOf("{")); + const names = classesInSelector(selector).filter((name) => name !== "sand-plugins-dialog"); + const missing = names.some( + (name) => portedClasses.includes(name) && !stagedInDialog.has(name), + ); + if (missing) { + needed.push(rule); + appended.push(rule); + } else { + skipped.push(rule); + } + } + if (needed.length === 0) continue; + payload.push(renderBlock({ context: block.context, rules: needed })); + } + + const patched = payload.length > 0 ? `${original}\n${payload.join("\n\n")}\n` : original; + if (payload.length > 0) await writeFile(cssPath, patched); + + // Every class the port applies must resolve in the stylesheet this step just wrote — inside + // the dialog, which is where the ported page renders it. + const finalInDialog = classesStyledInDialog(patched, DIALOG_CLASS); + const stillUnresolved = portedClasses.filter( + (name) => !noopClasses.has(name) && !finalInDialog.has(name), + ); + if (stillUnresolved.length > 0) { + throw new Error( + "Marketplace manage-page CSS patch: these classes still resolve nowhere in the staged " + + `stylesheet: ${stillUnresolved.join(", ")}.`, + ); + } + + // A class that resolves must resolve to OFFICIAL's declaration. A class that resolves to some + // other declaration is the silent-degradation trap: the element renders, the value is wrong, and + // nothing fails. The rule that carries the class may differ in its guard count — that is the + // specificity ladder — but not in what it declares. + const finalRules = rulesByClass(patched); + const wrongDeclaration = []; + for (const [name, officialText] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)) { + const officialBody = officialText.slice(officialText.indexOf("{") + 1, officialText.lastIndexOf("}")); + const rules = finalRules.get(name) ?? []; + if (!rules.some((rule) => rule.body === officialBody)) { + wrongDeclaration.push(name); + } + } + if (wrongDeclaration.length > 0) { + throw new Error( + "Marketplace manage-page CSS patch: these classes resolve, but not to official 0.78.1's " + + `declaration: ${wrongDeclaration.join(", ")}.`, + ); + } + + const provenance = { + mode: "marketplace-manage-page-0781-css", + referenceVersion: "0.78.1", + referenceAsarSha256: "e8d5e10f94f8b52cd02f696599c8e6503420ad85ceed7b41a27bbff7bff22f4d", + file: `dist/renderer/assets/${file}`, + originalSha256: sha256(original), + patchedSha256: sha256(patched), + companionCssSha256: sha256(companionCss), + scope: MARKETPLACE_MANAGE_PAGE_0781_SCOPE.trim(), + portedClasses: portedClasses.length, + noopClasses: MARKETPLACE_MANAGE_PAGE_0781_NOOP_CLASSES, + liftedClasses: portedClasses.filter((name) => companionClasses.has(name)), + aliasClasses: [...companionClasses].filter( + (name) => !portedClasses.includes(name) && COMPANION_ALIAS_CLASSES.includes(name), + ), + rules: portedClasses + .filter((name) => companionClasses.has(name)) + .map((name) => ({ class: name, official: MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES[name] })), + appendedRules: appended, + skippedRules: skipped, + }; + await writeFile( + path.join(stageRoot, "dist", "renderer-marketplace-manage-page-0781-css.json"), + `${JSON.stringify(provenance, null, 2)}\n`, + ); + console.log( + `Marketplace manage-page CSS: official 0.78.1 page rules added (${file}, ${appended.length} of ` + + `${appended.length + skipped.length} appended; ${provenance.liftedClasses.length} classes lifted).`, + ); + return provenance; +} diff --git a/scripts/lib/marketplace-manage-page-0781-official.css b/scripts/lib/marketplace-manage-page-0781-official.css new file mode 100644 index 00000000..351b0cd1 --- /dev/null +++ b/scripts/lib/marketplace-manage-page-0781-official.css @@ -0,0 +1,114 @@ +/* Official Grok Bot 0.78.1's second-level "管理插件和技能" page rules, lifted byte-for-byte + * from official 0.78.1's own stylesheet + * (`rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css`, sha256 + * c788cd1f8ba34abde8f15f564f45cf53f459be070e50a222d2b2c21e8902cbac). + * + * ## Which rules are here, and why only these + * + * The ported page renders official 0.78.1's own class recipes: the hero `h1`, the detail bar's + * labelled back button, the installed/private section headers, the two-column plugin grid, the + * plugin row, the "显示全部 N 个插件" control and the collapsed remainder list. A stylix class name + * is the hash of its declaration block, so a class that exists in one build and not in another is + * a NO-OP — the element renders unstyled and every other test stays green. + * + * Of the 163 distinct class names the ported markup carries: + * + * * 110 already resolve in upstream 0.18's stylesheet WITHIN the plugins dialog, with the same + * declaration. They are NOT repeated here: re-lifting a rule that upstream already carries + * is noise. + * * 14 are stylix namespace markers or hooks that official itself gives no rule to + * (`sand-plugins__yours`, `sand-plugins__group`, `sand-plugins-row`, `sand-kit-icon`, …). + * They carry no style on either side and nothing is lifted for them. + * * 40 do not resolve inside the dialog and are lifted here — one official rule each. + * + * Of those 40, 39 resolve nowhere in upstream 0.18's stylesheet at all. The 40th is + * `sand-1ufr4hg`, the connected badge's green: upstream 0.18 does not define it at all, but the + * repo's own conversation-computer-card port carries official's rule scoped to + * `.r-conversation-computer-card`, so the staged stylesheet holds the class token and never + * styles it inside the plugins dialog. "The class token appears somewhere in the stylesheet" is + * therefore not the test this file is built on — the scope is. + * + * ## The `:not(#\#)` guards are part of the rule + * + * Official writes stylix rules with a selector suffix like `:not(#\#):not(#\#)`. The repetitions + * are the specificity ladder that orders a rule against its neighbours, so their COUNT is part of + * the rule: a rule with the right declaration but too few guards loses to a neighbour upstream + * 0.18's own stylesheet already carries. Every guard is copied exactly as official writes it. + * + * ## How the rules are scoped + * + * The scope is the plugins-dialog subtree, the same convention the sibling wave's theme-token and + * typography companions use: + * + * `:where(.sand-plugins-dialog) ` — a DESCENDANT prefix on every selector of the rule. + * + * `:where()` contributes NO specificity, so the ported rule weighs against upstream 0.18's own + * class rules exactly as official's rule weighs against official's — the scope narrows where the + * rule applies without changing how strongly it applies. A plain `.sand-plugins-dialog` prefix + * would add one class of specificity and silently reorder the ladder. + * + * The rules that official writes inside an at-rule keep it: `sand-1snpyr7` / `sand-193nyv9` + * are official's `@container sand-plugin-rows (width<560px)` rules and `sand-t35mv4` is + * official's `@media (prefers-reduced-motion:reduce)` rule. Dropping the wrapper would drop the + * condition with it. + * + * ## The tokens these rules read + * + * `--sand-font-size-xl`, `--sand-line-height-xl`, `--sand-letter-spacing-xl`, + * `--sand-line-height-sm`, `--sand-letter-spacing-sm`, `--sand-line-height-base`, + * `--sand-letter-spacing-base`, `--cursor-font-weight-normal`, `--sand-bg-secondary`, + * `--sand-border-focus-primary`, `--sand-text-green-primary` and `--sand-text-primary` are + * declared on the dialog by `marketplace-dialog-0781-theme-tokens.css`. Upstream 0.18's palette + * does not define them, so an undefined token would make `var()` invalid at computed-value time + * and the property would silently fall back. + * + * @evidence official 0.78.1 `rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css` + * @evidence upstream 0.18 `src/app/dist/renderer/assets/index-lCyB53CO.css` + * @evidence `tests/fixtures/official-0781-manage-page-recipes.json` — the measured recipes. + */ +:where(.sand-plugins-dialog) .sand-79ao00:not(#\#):not(#\#):not(#\#){font-size:var(--sand-font-size-xl)} +:where(.sand-plugins-dialog) .sand-10ux60h:not(#\#):not(#\#):not(#\#){line-height:var(--sand-line-height-xl)} +:where(.sand-plugins-dialog) .sand-k0i524:not(#\#):not(#\#):not(#\#){letter-spacing:var(--sand-letter-spacing-xl)} +:where(.sand-plugins-dialog) .sand-37c5m6:not(#\#):not(#\#):not(#\#){grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)} +:where(.sand-plugins-dialog) .sand-e2zdcy:not(#\#):not(#\#):not(#\#){padding-inline-start:10px} +:where(.sand-plugins-dialog) .sand-w8bs97:not(#\#):not(#\#):not(#\#){line-height:var(--sand-line-height-sm)} +:where(.sand-plugins-dialog) .sand-14gdtuo:not(#\#):not(#\#):not(#\#){letter-spacing:var(--sand-letter-spacing-sm)} +:where(.sand-plugins-dialog) .sand-ymw2ht:not(#\#):not(#\#){border-radius:9999px} +:where(.sand-plugins-dialog) .sand-11hyl0l:hover:not(#\#):not(#\#):not(#\#){background-color:var(--sand-bg-secondary)} +:where(.sand-plugins-dialog) .sand-1wpm9rq:focus-visible:not(#\#):not(#\#):not(#\#){outline-color:var(--sand-border-focus-primary)} +:where(.sand-plugins-dialog) .sand-1lvvshy:not(#\#):not(#\#):not(#\#){line-height:var(--sand-line-height-base)} +:where(.sand-plugins-dialog) .sand-ja3w2a:not(#\#):not(#\#):not(#\#){letter-spacing:var(--sand-letter-spacing-base)} +:where(.sand-plugins-dialog) .sand-1lziwak:not(#\#):not(#\#):not(#\#){margin-inline-start:0} +:where(.sand-plugins-dialog) .sand-ibwnj:not(#\#):not(#\#):not(#\#){transition-duration:.15s} +:where(.sand-plugins-dialog) .sand-1qab1bc:not(#\#):not(#\#):not(#\#){justify-self:end} +:where(.sand-plugins-dialog) .ui-vxqr21{font-weight:var(--cursor-font-weight-normal)} +:where(.sand-plugins-dialog) .sand-gpehar:not(#\#):not(#\#):not(#\#){container-name:sand-plugin-rows} +:where(.sand-plugins-dialog) .sand-uvv9dw:not(#\#):not(#\#):not(#\#){column-gap:8px} +:where(.sand-plugins-dialog) .sand-yri2b:not(#\#):not(#\#):not(#\#){padding-inline-end:0} +:where(.sand-plugins-dialog) .sand-1c1uobl:not(#\#):not(#\#):not(#\#){padding-inline-start:0} +:where(.sand-plugins-dialog) .sand-c8icb0:not(#\#):not(#\#):not(#\#){isolation:isolate} +:where(.sand-plugins-dialog) .sand-1sodtw3:not(#\#):not(#\#):not(#\#),:where(.sand-plugins-dialog) .sand-11ct0s3:not(#\#):not(#\#):not(#\#){padding-inline-end:12px} +:where(.sand-plugins-dialog) .sand-a6wfpx:not(#\#):not(#\#):not(#\#),:where(.sand-plugins-dialog) .sand-vhurb8:not(#\#):not(#\#):not(#\#){padding-inline-start:12px} +:where(.sand-plugins-dialog) .sand-h6mt2a:hover:not(#\#):not(#\#):not(#\#){background-color:color-mix(in srgb,var(--sand-text-primary) 5%,transparent)} +:where(.sand-plugins-dialog) .sand-1yc453h:not(#\#):not(#\#):not(#\#){text-align:start} +:where(.sand-plugins-dialog) .sand-1f9r7lp:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before,:where(.sand-plugins-dialog) .sand-1w00h3t:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):after{border-radius:16px} +:where(.sand-plugins-dialog) .sand-1ybvw9k:focus-visible:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):after{box-shadow:inset 0 0 0 2px var(--sand-border-focus-primary)} +:where(.sand-plugins-dialog) .sand-1ufr4hg:not(#\#):not(#\#):not(#\#){color:var(--sand-text-green-primary)} +:where(.sand-plugins-dialog) .sand-wpq65r:not(#\#):not(#\#):not(#\#){background-color:color-mix(in srgb,var(--sand-text-primary) 6%,transparent)} +:where(.sand-plugins-dialog) .sand-mzvs34:not(#\#):not(#\#):not(#\#){padding-inline-start:8px} +:where(.sand-plugins-dialog) .sand-ihq33y:not(#\#):not(#\#):not(#\#){grid-template-rows:0fr} +:where(.sand-plugins-dialog) .sand-1bvilyr:not(#\#):not(#\#):not(#\#){transform:translateY(-4px)} +:where(.sand-plugins-dialog) .sand-icbapw:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,opacity,transform} +:where(.sand-plugins-dialog) .sand-1upsisp:not(#\#):not(#\#):not(#\#){transition-duration:.26s} +:where(.sand-plugins-dialog) .sand-1r6a6jf:not(#\#):not(#\#):not(#\#){transition-timing-function:linear(0, .01588 2%, .05618 4%, .11201 6%, .1768 8%, .2458 10%, .31562 12%, .38392 14%, .44914 16%, .51029 18%, .56683 20%, .61852 22%, .66534 24%, .70743 26%, .74501 28%, .77838 30%, .80788 32%, .83383 34%, .85658 36%, .87645 38%, .89376 40%, .92183 44%, .94282 48%, .95838 52%, .96984 56%, .97823 60%, .98435 64%, .98878 68%, .99198 72%, .99594 80%, .99829 90%, 1 100%)} +:where(.sand-plugins-dialog) .sand-1erjwpq:not(#\#):not(#\#):not(#\#){transition-property:grid-template-rows,opacity} +:where(.sand-plugins-dialog) .sand-1tu4anv:not(#\#):not(#\#):not(#\#){grid-template-rows:1fr} + +@container sand-plugin-rows (width<560px){ +:where(.sand-plugins-dialog) .sand-1snpyr7.sand-1snpyr7:not(#\#):not(#\#):not(#\#){grid-column-start:1} +:where(.sand-plugins-dialog) .sand-193nyv9.sand-193nyv9:not(#\#):not(#\#):not(#\#){grid-column-end:-1} +} + +@media (prefers-reduced-motion:reduce){ +:where(.sand-plugins-dialog) .sand-t35mv4.sand-t35mv4:not(#\#):not(#\#):not(#\#){transition-duration:.12s} +} diff --git a/scripts/lib/marketplace-manage-page-0781-patch.mjs b/scripts/lib/marketplace-manage-page-0781-patch.mjs new file mode 100644 index 00000000..876e3cb8 --- /dev/null +++ b/scripts/lib/marketplace-manage-page-0781-patch.mjs @@ -0,0 +1,735 @@ +/** + * Official Grok Bot 0.78.1's second-level "管理插件和技能" page, ported into upstream 0.18's plugins view. + * + * ## What official 0.78.1's second-level page looks like (measured live on official 9333, 2026-11-01) + * + * ‹ 连接应用 管理 + * 管理插件和技能 + * 已安装 + * [plugin row] [plugin row] [plugin row] [plugin row] [plugin row] [plugin row] + * 显示全部 25 个插件 + * 私有技能 + * [plugin row] [plugin row] … + * + * The browse page's "已连接 N 个" entry opens it. It is a SECOND-LEVEL page: official's own + * detail bar (a labelled back button + the title "管理") replaces the browse header and the + * whole browse toolbar, and the page carries a hero, an "已安装" section whose rows past the + * sixth live in a collapsed `div[aria-hidden][inert]`, and a "私有技能" section. + * + * ## What this patch ports + * + * 1. **The four shared components, re-recipeed** — the plugin row (`fs`), the plugins grid + * (`Ws`), the section (`Hs`) and the show-all button (`ot`) carry official 0.78.1's class + * recipes. Official uses ONE row recipe and ONE grid recipe on both the browse page and the + * manage page, so re-recipeing them moves the browse page with them (that is official's own + * behaviour, and the spec allows it). The section additionally gains official's header + * wrapper `div` around its `h3` and drops `color:"tertiary"`. + * 2. **The hero** — official's `

` "管理插件和技能", as the first child + * of upstream's "yours" page root. + * 3. **The installed section's body** — the first six rows in the grid, then the visible + * "显示全部 N 个插件" control, then a collapsed `div[aria-hidden][inert]` holding the + * remaining rows under a hidden "更多已安装的插件" label. Upstream's own expand flag (`b`) + * drives it; no new state is introduced. + * 4. **The wording** — official 0.78.1's own message ids and texts, routed through the chunk's + * own locale runtime (`RLocT(en, zh)`) so the language preference still switches them. + * 5. **The manage page's own row subtitle builder** — official's manage-page format: the + * connector count and the skill count, joined by official's own `Intl.ListFormat` + * (`style:"short"`, `type:"unit"`), which is why the Chinese reads "1 个连接器14 项技能". + * Upstream's shared `tt` builder is left alone: it is also the browse page's subtitle + * builder, whose 0.78.1 parity was signed off in the earlier wave. + * 6. **A plugins-chunk-local detail bar** — official 0.78.1's markup (the labelled "‹ 连接应用" + * back button + the `h3` "管理" + the trailing slot), rendered instead of the browse header + * and toolbar while the manage page is open. `scroll-pane-CvNuGyd2.js` is shared by every + * settings surface and is NOT patched. + * 7. **The connector auth action's label** — upstream 0.18's shared `Qn` picks its label from + * the LOCAL handoff status and hardcodes upstream's own authorize-mode wording + * ("Authenticate" / "Reopen" / "Retry" → 身份验证 / 重新打开 / 重试). Official 0.78.1 runs the + * same action through `W` → `le` → `K`, and `K`'s connect-mode branch picks official's + * `iSLIjg` ("Connect" → 连接), `gcoiFh` ("Reconnect" → 重新连接) and `6gRgw8` + * ("Retry" → 重试). The live official app shows "连接" for a connector that needs + * authentication, so the port rewrites upstream's label to official's. + * + * ## Every class recipe is the CAPTURED one, never a hardcoded copy + * + * The class recipes are read out of `tests/fixtures/official-0781-manage-page-recipes.json` at + * load time, the same way the sibling 0.78.1 patches read their captured frames. The fixture is + * a live capture, so it can move: the detail bar's `h3` was first captured mid fade-in (with + * official's `sand-g01cxk` "hidden" recipe on it) and now carries the settled recipe + * (`detailTitle` + `detailTitleFading`). Reading the fixture instead of a copy means the port + * follows the capture rather than a snapshot of it. + * + * The recipes the fixture does NOT record — the collapsed wrapper's expanded variant, the + * show-all wrapper's two variants, the detail bar's back-button wrapper and its icon and + * label slots, and the collapsed list's inner wrapper — are official's own class strings, read + * out of official's bytes (see the evidence block at the end of this file). + * + * ## Every string here is official 0.78.1's own + * + * The message ids are official's, read out of `chunk-core-CXBwRBzw.js` (zh-Hans) and + * `index.eager-common-DbK3Oi_r.js` (English). Nothing is invented: where official's markup + * could not be reproduced exactly, the deviation is recorded in the port record rather than + * guessed around. + * + * ## Two ways of anchoring, both fail-closed + * + * Most anchors are plain `before`/`after` pairs. Three of them are SPANS and one is a RUN: the + * text between two unique markers is replaced, because the i18n and wording passes rewrite the + * literals inside that text (`"Installed"` becomes `RLocT("Installed","已安装")`, the show-all + * label becomes `RLocT("Show all "+U.length+" plugins", …)`). The markers are the stable parts, + * and each marker must occur EXACTLY ONCE in the chunk — so a missing or ambiguous marker + * throws and the build stops, exactly like a plain anchor. The spans make this patch apply + * identically to the pristine upstream chunk and to the chunk the i18n, wording and + * browse-chrome passes have already rewritten. + * + * @evidence official 0.78.1 `chunk-view-KV06RTpF.js` — the manage page component (the hero, the + * installed section's body, the collapsed wrapper and the show-all wrapper), the detail bar's + * call site (`backLabel`, `headingId`, `isTitleVisible`, `onBack`, `title`) and the row + * subtitle builder `Vo`. + * @evidence official 0.78.1 `index.eager-app-D1juOWZp.js` — the shared components: `MAt` (the + * section), `PAt` (the grid), `FAt` (the row), `GAt` (the show-all button), `Hq`/`Uq` (the + * count builder and the joined list) and `mm` (the `Intl.ListFormat` joiner). The same file + * carries the status registry `Nq`, the status badge `LAt` and the status-to-text map `eAt`. + * @evidence official 0.78.1 `chunk-plugin-detail-sharepoint-write-MAPmM3e9.js` — the connector + * auth action `W` (exported as `mr`), the account action `le` and the label chain `K`, which + * picks `iSLIjg` ("Connect"), `gcoiFh` ("Reconnect") and `6gRgw8` ("Retry"). + * @evidence official 0.78.1 `chunk-pane-header-DaY0itEE.js` — the detail bar `S`: the labelled + * back button, the `h3` (whose `detailTitle` / `detailTitleFading` / `detailTitleHidden` + * recipes are official's three title variants) and the trailing slot. + * @evidence official 0.78.1 `chunk-core-CXBwRBzw.js` / `index.eager-common-DbK3Oi_r.js` — the + * zh-Hans and English text of every message id used here. + * @evidence official 0.78.1 live (CDP 9333, 2026-11-01) — the fixture's class recipes. + */ + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { parse } from "acorn"; +import { normalizeJsxChildren } from "./marketplace-jsx-children.mjs"; + +/** The chunk this patch owns: upstream 0.18's plugins view. */ +export const MARKETPLACE_MANAGE_PAGE_0781_CHUNK = "view-B5Ug8wEm.js"; + +/** + * Official 0.78.1's captured class recipes. + * + * This is the live capture of official's second-level page, read at load time so the port + * always lands on the captured recipe rather than on a copy of it. + */ +const OFFICIAL = JSON.parse( + readFileSync( + new URL("../../tests/fixtures/official-0781-manage-page-recipes.json", import.meta.url), + "utf8", + ), +); + +/** + * A component's class recipe, with upstream's own marker class taken off the front. + * + * Upstream's `k(marker, recipe)` renders `marker recipe`, and official's captured class string + * therefore starts with the marker. The recipes this patch writes into the chunk are the recipe + * alone; the marker stays the first argument of the `k(...)` call. + */ +function recipeWithoutMarker(className, marker) { + if (typeof className !== "string" || !className.startsWith(`${marker} `)) { + throw new Error( + `Official manage page recipe for ${marker} does not start with the marker: ${String(className)}`, + ); + } + return className.slice(marker.length + 1); +} + +/** + * Official 0.78.1's class recipes, taken from the capture. + * + * `sectionTitle` and `detailBarTitle` carry the whole captured class string — official's + * `Text` component's own `ui-` classes included — because upstream 0.18's `Text` resolves a + * different set of `ui-` classes and the port has to land on official's class for class. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_CLASSES = Object.freeze({ + hero: OFFICIAL.hero.class, + heroWrapper: OFFICIAL.hero.wrapperClass, + section: recipeWithoutMarker(OFFICIAL.installedSection.class, "sand-plugins__group"), + sectionHeader: OFFICIAL.installedSection.headerClass, + sectionTitle: OFFICIAL.installedSection.h3Class, + grid: recipeWithoutMarker(OFFICIAL.installedSection.gridClass, "sand-plugins__grid"), + gridFullWidth: recipeWithoutMarker(OFFICIAL.privateSection.gridClass, "sand-plugins__grid"), + row: recipeWithoutMarker(OFFICIAL.row.class, "sand-plugins-row"), + rowOpen: recipeWithoutMarker(OFFICIAL.row.openClass, "sand-plugins-row__open"), + rowName: recipeWithoutMarker(OFFICIAL.row.nameClass, "sand-plugins-row__name"), + rowSubtitle: recipeWithoutMarker(OFFICIAL.row.subtitleClass, "sand-plugins-row__subtitle"), + rowTrailing: recipeWithoutMarker(OFFICIAL.row.trailingClass, "sand-plugins-row__trailing"), + showAll: recipeWithoutMarker(OFFICIAL.showAll.class, "sand-plugins__show-all"), + collapsed: OFFICIAL.collapsed.class, + collapsedInner: OFFICIAL.collapsed.innerClass, + detailBar: OFFICIAL.detailBar.class, + detailBarBackButton: OFFICIAL.detailBar.buttonClass, + detailBarTitle: OFFICIAL.detailBar.h3Class, + detailBarTitleHidden: OFFICIAL.detailBar.h3HiddenClass, + detailBarTitleHiddenAriaHidden: OFFICIAL.detailBar.h3HiddenAttrs["aria-hidden"], + detailBarTrailing: OFFICIAL.detailBar.trailingClass, +}); + +/** + * Official 0.78.1's class recipes the capture does not record, read out of official's bytes. + * + * * the collapsed wrapper's second variant — the collapsed remainder once it is expanded; + * * the show-all wrapper's two variants — hidden once the remainder is expanded; + * * the detail bar's back-button wrapper, its icon slot and its label slot; + * * the collapsed list's inner wrapper (the one that holds the hidden label and the grid). + * + * @evidence official 0.78.1 `chunk-view-KV06RTpF.js` — the installed section's body. + * @evidence official 0.78.1 `chunk-pane-header-DaY0itEE.js` — the detail bar `S`. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES = Object.freeze({ + collapsedExpanded: + "sand-rvj5dj sand-icbapw sand-1ympp8d sand-1upsisp sand-t35mv4 sand-1r6a6jf sand-1tu4anv sand-1hc1fzr sand-nn1q72 sand-67bb7w", + showAllWrapper: + "sand-rvj5dj sand-1erjwpq sand-1ympp8d sand-1upsisp sand-t35mv4 sand-1r6a6jf sand-1tu4anv sand-1hc1fzr sand-67bb7w", + showAllWrapperHidden: + "sand-rvj5dj sand-ihq33y sand-g01cxk sand-47corl sand-1erjwpq sand-1ympp8d sand-1upsisp sand-t35mv4 sand-1r6a6jf", + restList: "sand-1nn3v0j", + detailBarBack: "sand-1lqcxt8 sand-euugli", + detailBarIcon: + "sand-kit-icon sand-3nfvp2 sand-6s0dn4 sand-l56j7k sand-2lah0s sand-1heor9g sand-w4jnvo sand-1qx5ct2", + detailBarLabel: "sand-b3r6kr sand-lyipyv sand-uxw1ft", +}); + +/** + * Official 0.78.1's message ids used by this patch, with official's own text. + * + * `zh` is the text the patch writes into the chunk, so it is always a contiguous literal there. + * The two plural messages carry official's "#" placeholder (official's own template: + * `["显示全部 ","#"," 个插件"]`), because the count is interpolated at render time. + * + * `backLabel` is the dialog's title, which official renders as its own back button's label + * (official's id `g/jm+8`). The repo's own wording pass keeps the dialog title's English at + * upstream's "Plugins" and localizes it to 连接应用, so the back button reuses that same string + * rather than introducing a second English string for the same title. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_MESSAGES = Object.freeze({ + hero: Object.freeze({ id: "ZoHizH", en: "Manage plugins and skills", zh: "管理插件和技能" }), + manage: Object.freeze({ id: "wckWOP", en: "Manage", zh: "管理" }), + installed: Object.freeze({ id: "eQkgKV", en: "Installed", zh: "已安装" }), + privateSkills: Object.freeze({ id: "glMsdy", en: "Private skills", zh: "私有技能" }), + showAll: Object.freeze({ id: "Wy8KR2", en: "Show all # plugins", zh: "显示全部 # 个插件" }), + emptyInstalled: Object.freeze({ + id: "ZyWE2h", + en: "Nothing installed yet. Find plugins in the marketplace.", + zh: "尚未安装任何插件。请在市场中查找插件。", + }), + moreInstalled: Object.freeze({ + id: "iofzif", + en: "More installed plugins", + zh: "更多已安装的插件", + }), + backLabel: Object.freeze({ id: "g/jm+8", en: "Plugins", zh: "连接应用" }), + openLabel: Object.freeze({ id: "T9mwDN", en: "Open ", zh: "打开 " }), + connectorCount: Object.freeze({ id: "d5N5Il", en: "# connector", zh: "# 个连接器" }), + skillCount: Object.freeze({ id: "m+pG3Q", en: "# skill", zh: "# 项技能" }), + connect: Object.freeze({ id: "iSLIjg", en: "Connect", zh: "连接" }), + reconnect: Object.freeze({ id: "gcoiFh", en: "Reconnect", zh: "重新连接" }), + retry: Object.freeze({ id: "6gRgw8", en: "Retry", zh: "重试" }), +}); + +/** + * The installed section's own row elements, kept in one local so the visible grid and the + * collapsed grid can share ONE set of row elements. + * + * Upstream's row closure is bound to the manage page's own locals (the ToolIcon, the server + * resolvers, the open handlers), so it cannot move into this module. Instead the patch assigns + * the mapped elements to one local and slices that array: the first six go to the visible grid, + * the rest go to the collapsed one. + */ +const ROW_ASSIGNMENT = "RM0781ManageRowElements"; + +const REPLACEMENTS = [ + { + label: "the section: official's class recipe", + before: 'k("sand-plugins__group","sand-9f619 sand-78zum5 sand-dt5ytf sand-xhr3t")', + after: `k("sand-plugins__group","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.section}")`, + }, + { + label: "the section: official's header wrapper div and official's h3 class recipe", + before: + 'let d;e[1]===Symbol.for("react.memo_cache_sentinel")?(d={className:"sand-9f619 sand-1y1aw1k sand-y13l1i sand-10b6aqq sand-163pfp"},e[1]=d):d=e[1];' + + 'let r;e[2]!==s||e[3]!==a?(r=t.jsx(me,{as:"h3",className:d.className,color:"tertiary",id:a,size:"sm",children:s}),e[2]=s,e[3]=a,e[4]=r):r=e[4];' + + 'let o;return e[5]!==l||e[6]!==r||e[7]!==a?(o=t.jsxs("section",{"aria-labelledby":a,className:i,children:[r,l]}),e[5]=l,e[6]=r,e[7]=a,e[8]=o):o=e[8],o}', + after: + 'let d;e[1]===Symbol.for("react.memo_cache_sentinel")?(d={className:"' + + MARKETPLACE_MANAGE_PAGE_0781_CLASSES.sectionTitle + + '"},e[1]=d):d=e[1];' + + 'let r;e[2]!==s||e[3]!==a?(r=t.jsx("h3",{className:d.className,"data-variant":"default","data-size":"sm","data-weight":"regular",id:a,children:s}),e[2]=s,e[3]=a,e[4]=r):r=e[4];' + + 'let o;return e[5]!==l||e[6]!==r||e[7]!==a?(o=t.jsxs("section",{"aria-labelledby":a,className:i,children:[t.jsxs("div",{className:"' + + MARKETPLACE_MANAGE_PAGE_0781_CLASSES.sectionHeader + + '",children:[r]}),l]}),e[5]=l,e[6]=r,e[7]=a,e[8]=o):o=e[8],o}', + }, + { + label: "the plugins grid: official's two-column and full-width class recipes", + before: + 'k("sand-plugins__grid",{0:{className:"sand-9f619 sand-rvj5dj sand-nby9oq sand-fex06f sand-1dbijih sand-3ct3a4 sand-dj266r sand-1yf7rl7 sand-at24cr sand-j3b58b sand-exx8yu sand-1xpa7k sand-18d9i69 sand-1uhho1l"},' + + '1:{className:"sand-9f619 sand-rvj5dj sand-fex06f sand-1dbijih sand-3ct3a4 sand-dj266r sand-1yf7rl7 sand-at24cr sand-j3b58b sand-exx8yu sand-1xpa7k sand-18d9i69 sand-1uhho1l sand-1mkdm3x"}}[!!i<<0].className)', + after: + 'k("sand-plugins__grid",{0:{className:"' + + MARKETPLACE_MANAGE_PAGE_0781_CLASSES.grid + + '"},1:{className:"' + + MARKETPLACE_MANAGE_PAGE_0781_CLASSES.gridFullWidth + + '"}}[!!i<<0].className)', + }, + { + label: "the plugin row: official's row class recipe", + before: + 'k("sand-plugins-row","sand-9f619 sand-78zum5 sand-6s0dn4 sand-1v2ro7d sand-euugli sand-z9dl7a sand-nuq7ks sand-sag5q8 sand-f18ygs sand-gqmno8 sand-jbqb8w sand-1q5pob1")', + after: `k("sand-plugins-row","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.row}")`, + }, + { + label: "the plugin row's open button: official's class recipe", + before: + 'k("sand-plugins-row__open","sand-9f619 sand-78zum5 sand-6s0dn4 sand-883omv sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-exx8yu sand-1xpa7k sand-18d9i69 sand-1uhho1l sand-c342km sand-ng3xce sand-jbqb8w sand-1heor9g sand-jb2p0i sand-dpxx8g sand-1ypdohk sand-1t137rt sand-9v5kkp sand-1k57tk5 sand-784prv sand-1uczgqu sand-2d6ogt sand-1wfwxd8 sand-7s97pk sand-1kogg8i")', + after: `k("sand-plugins-row__open","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.rowOpen}")`, + }, + { + label: "the plugin row's name: official's class recipe", + before: + 'k("sand-plugins-row__name","sand-9f619 sand-b3r6kr sand-4z9k3i sand-d4r4e8 sand-12oo3zp sand-1rhlpx6 sand-1wd3ewq sand-lyipyv sand-uxw1ft")', + after: `k("sand-plugins-row__name","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.rowName}")`, + }, + { + label: "the plugin row's subtitle: official's class recipe", + before: + 'k("sand-plugins-row__subtitle","sand-9f619 sand-b3r6kr sand-4z9k3i sand-d4r4e8 sand-12oo3zp sand-19aaqeu sand-lyipyv sand-uxw1ft")', + after: `k("sand-plugins-row__subtitle","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.rowSubtitle}")`, + }, + { + label: "the plugin row's trailing slot: official's class recipe", + before: + 'k("sand-plugins-row__trailing","sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-1c4vz4f sand-2lah0s sand-dl72j9")', + after: `k("sand-plugins-row__trailing","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.rowTrailing}")`, + }, + { + label: "the show-all button: official's class recipe", + before: + 'k("sand-plugins__show-all","sand-9f619 sand-qcrz7y sand-1iorvi4 sand-y13l1i sand-jkvuk6 sand-163pfp sand-c342km sand-ng3xce sand-jbqb8w sand-19aaqeu sand-7gh5u8 sand-jb2p0i sand-fifm61 sand-1ypdohk")', + after: `k("sand-plugins__show-all","${MARKETPLACE_MANAGE_PAGE_0781_CLASSES.showAll}")`, + }, + { + label: "the manage page: official's hero is the first child of the 'yours' root", + before: 't.jsxs("div",{className:T,children:[O,M,R,E]})', + after: `t.jsxs("div",{className:T,children:[t.jsx(RM0781ManageHero,{}),O,M,R,E]})`, + }, + { + label: "the manage page: the installed rows are split into the visible six and the collapsed rest", + before: "{visible:J,hiddenCount:te}=Jn(U,b||q,Dl),", + after: "{visible:J,hiddenCount:te}=Jn(U,b||q,Dl),RM0781ManageParts=RM0781ManageSplitRows(U,q,Dl),", + }, + { + label: "the manage page: the row elements are kept so the collapsed list can reuse them", + before: "let T,O,M,R,E;", + after: `let T,O,M,R,E,${ROW_ASSIGNMENT};`, + }, + { + label: "the manage page: the visible grid maps the whole list once", + before: "t.jsx(Ws,{labelledBy:w,children:J.map(H=>{", + after: `t.jsx(Ws,{labelledBy:w,children:(${ROW_ASSIGNMENT}=U.map(H=>{`, + }, + { + label: + "the manage page: the installed section's body is the visible grid, the show-all control and the collapsed remainder", + kind: "run", + start: "}),te>0?t.jsx(ot,{hiddenLabel:", + end: ",onShowAll:()=>z(!0)}):null]})", + after: + `,${ROW_ASSIGNMENT}.slice(0,RM0781ManageParts.visibleCount)),}),` + + "t.jsx(RM0781ManageRest,{collapsedRows:" + + `${ROW_ASSIGNMENT}.slice(RM0781ManageParts.visibleCount),expanded:b,onShowAll:()=>z(!0),total:U.length})]})`, + }, + { + label: "the manage page: the installed section's title is official's", + kind: "between", + start: "R=le?t.jsx(Hs,{title:", + end: ",titleId:w,children:U.length===0?", + after: 'RLocT("Installed","已安装")', + }, + { + label: "the manage page: the installed section's empty state is official's", + kind: "between", + start: + 'U.length===0?t.jsx(me,{as:"p",className:"sand-9f619 sand-y13l1i sand-163pfp",color:"tertiary",size:"xs",children:', + end: "}):t.jsxs(t.Fragment,{children:[t.jsx(Ws,{labelledBy:w,children:", + after: + 'q?RLocT("No installed plugins match \\""+$+"\\"","没有已安装的插件匹配“"+$+"”"):' + + 'RLocT("Nothing installed yet. Find plugins in the marketplace.","尚未安装任何插件。请在市场中查找插件。")', + }, + { + label: "the manage page: the private-skills section's title is official's", + kind: "between", + start: "E=de?t.jsx(Hs,{title:", + end: ",titleId:g,children:s==null?", + after: 'RLocT("Private","私有技能")', + }, + { + label: "the manage page: the installed row's subtitle is official's manage-page format", + before: "function yi(n){return tt({...ys(n.entry),commands:null})}", + after: "function yi(n){return RM0781ManageSubtitle(n)}", + }, + { + label: + "the dialog: the manage page renders official's detail bar instead of the browse header and toolbar", + before: "Me!=null?t.jsx(el,{headingId:a,onBack:W,title:Ts}):t.jsxs(t.Fragment,{children:[", + after: + "Me!=null?t.jsx(el,{headingId:a,onBack:W,title:Ts}):" + + 'p==="yours"?t.jsx(RM0781ManageDetailBar,{backLabel:RLocT("Plugins","连接应用"),headingId:a,onBack:()=>x("marketplace"),title:RLocT("Manage","管理")}):' + + "t.jsxs(t.Fragment,{children:[", + }, + { + label: "the row: the open button carries official's localized label", + before: '"aria-label":`Open ${a}`', + after: '"aria-label":RLocT("Open "+a,"打开 "+a)', + }, + { + label: "the status badge: official's class recipes", + before: + 'const cs={status:{kB7OPa:"sand-9f619",kGuDYH:"sand-4z9k3i",kLWn49:"sand-d4r4e8",kb6lSQ:"sand-12oo3zp",kMwMTN:"sand-4b2ntj",khDVqt:"sand-uxw1ft",$$css:!0},' + + 'statusConnected:{kMwMTN:"sand-98zg7y",$$css:!0},statusWarn:{kMwMTN:"sand-1izesbo",$$css:!0},statusError:{kMwMTN:"sand-pmgbkh",$$css:!0}};', + after: + 'const cs={status:{kB7OPa:"sand-9f619",kGuDYH:"sand-11wthnw",kLWn49:"sand-d4r4e8",kb6lSQ:"sand-12oo3zp",kMwMTN:"sand-169k319",khDVqt:"sand-uxw1ft",$$css:!0},' + + 'statusConnected:{kMwMTN:"sand-1ufr4hg",$$css:!0},statusWarn:{kMwMTN:"sand-132xtu3",$$css:!0},statusError:{kMwMTN:"sand-wtpwev",$$css:!0}};', + }, + { + label: + "the status badge: the variant's class replaces the base's, as official's own composer does", + before: 'a=k("sand-plugins__status",Re(cs.status,o).className)', + after: + 'a=k("sand-plugins__status",{className:RM0781ManageStatusClass(cs.status,o)}.className)', + }, + { + label: "the private-skills row: official's icon wrapper recipe", + before: + 'k("sand-9f619 sand-78zum5 sand-6s0dn4 sand-l56j7k sand-100vrsf sand-1vqgdyp sand-2lah0s sand-1kogg8i sand-arj5zm")', + after: + 'k("sand-9f619 sand-78zum5 sand-6s0dn4 sand-l56j7k sand-100vrsf sand-1vqgdyp sand-2lah0s sand-1kogg8i sand-wpq65r")', + }, + { + label: + "the private-skills row: no enable toggle in the trailing slot, as official's row has none", + // A RUN, because the i18n pass rewrites the toggle's own label between the two + // markers. The markers themselves are stable in the pristine chunk and in the + // chunk the i18n, wording and browse-chrome passes have already rewritten. + kind: "run", + start: + 'trailing:H.source==="workflow"?t.jsx(Kn,{isChecked:H.isEnabledForAgent,isDisabled:c,label:', + end: "}):null},H.id))", + after: "trailing:null},H.id))", + }, + { + label: + "the connector auth action: official's connect-mode label, as official's own `K` chain picks it", + // A BETWEEN, because the i18n pass rewrites the three label literals between the two + // markers. The markers themselves are stable in the pristine chunk and in the chunk the + // i18n, wording and browse-chrome passes have already rewritten. + kind: "between", + start: "const o=d==null", + end: ':d.status==="waiting"?', + after: '||d.status==="completed"?(RLocT("Connect","连接"))', + }, + { + label: + "the connector auth action: official's reconnect and retry labels, as official's own `K` chain picks them", + // The same BETWEEN shape as the label above. The two replacements together rewrite + // upstream's own authorize-mode labels into official's connect-mode ones. + kind: "between", + start: ':d.status==="waiting"?', + end: ";let c;return e[6]!==a||e[7]!==r||e[8]!==o?", + after: '(RLocT("Reconnect","重新连接")):(RLocT("Retry","重试"))', + }, +]; + +/** + * The injected source. + * + * It lives in the chunk's module scope, so it can use the chunk's own `t` (jsx runtime), `C` + * (react), `ue` (the icon component), `Ws` (the plugin grid), `ot` (the show-all button), + * `ys` (the contribution counts) and `RLocT(en, zh)` (the locale runtime the i18n passes append). + */ +const INJECTED_SOURCE = normalizeJsxChildren( + [ + `const RM0781ManageHeroClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.hero)};`, + `const RM0781ManageHeroWrapperClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.heroWrapper)};`, + `const RM0781ManageDetailBarTitleHiddenClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBarTitleHidden)};`, + `const RM0781ManageDetailBarTitleHiddenAriaHidden=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBarTitleHiddenAriaHidden)};`, + `const RM0781ManageCollapsedClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.collapsed)};`, + `const RM0781ManageCollapsedExpandedClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.collapsedExpanded)};`, + `const RM0781ManageShowAllWrapperClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.showAllWrapper)};`, + `const RM0781ManageShowAllWrapperHiddenClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.showAllWrapperHidden)};`, + `const RM0781ManageRestInnerClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.collapsedInner)};`, + `const RM0781ManageRestListClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.restList)};`, + `const RM0781ManageDetailBarClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBar)};`, + `const RM0781ManageDetailBarBackClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.detailBarBack)};`, + `const RM0781ManageDetailBarBackButtonClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBarBackButton)};`, + `const RM0781ManageDetailBarIconClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.detailBarIcon)};`, + `const RM0781ManageDetailBarLabelClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES.detailBarLabel)};`, + `const RM0781ManageDetailBarTitleClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBarTitle)};`, + `const RM0781ManageDetailBarTrailingClasses=${JSON.stringify(MARKETPLACE_MANAGE_PAGE_0781_CLASSES.detailBarTrailing)};`, + 'const RM0781ManageConnectorZh="# 个连接器";', + 'const RM0781ManageConnectorEnOne="# connector";', + 'const RM0781ManageConnectorEnOther="# connectors";', + 'const RM0781ManageSkillZh="# 项技能";', + 'const RM0781ManageSkillEnOne="# skill";', + 'const RM0781ManageSkillEnOther="# skills";', + 'const RM0781ManageShowAllZh="显示全部 # 个插件";', + 'const RM0781ManageShowAllEn="Show all # plugins";', + 'const RM0781ManageShowAllEnOne="Show # plugin";', + 'function RM0781ManageLocale(){return typeof RLocBoot==="function"?RLocBoot():"en"}', + "function RM0781ManageCount(count,one,other,zh){", + ' const en=(count===1?one:other).replace("#",String(count));', + ' return RLocT(en,zh.replace("#",String(count)));', + "}", + "function RM0781ManageConnectorCount(count){return RM0781ManageCount(count,RM0781ManageConnectorEnOne,RM0781ManageConnectorEnOther,RM0781ManageConnectorZh)}", + "function RM0781ManageSkillCount(count){return RM0781ManageCount(count,RM0781ManageSkillEnOne,RM0781ManageSkillEnOther,RM0781ManageSkillZh)}", + "function RM0781ManageShowAllLabel(total){", + ' const en=(total===1?RM0781ManageShowAllEnOne:RM0781ManageShowAllEn).replace("#",String(total));', + ' return RLocT(en,RM0781ManageShowAllZh.replace("#",String(total)));', + "}", + // Official joins the counts with its own `Intl.ListFormat` (`mm`), which is why the Chinese + // reads "1 个连接器14 项技能" — the unit-style list format carries no separator in zh. + "function RM0781ManageList(parts){", + ' if(parts.length===0)return RLocT("no supported contributions","无可用内容");', + ' try{return new Intl.ListFormat(RM0781ManageLocale(),{style:"short",type:"unit"}).format(parts)}', + ' catch(_e){return parts.join("")}', + "}", + "function RM0781ManageSubtitle(row){", + " const counts=ys(row.entry);", + " const parts=[];", + " if(counts.connectors>0)parts.push(RM0781ManageConnectorCount(counts.connectors));", + " if(counts.skills!=null&&counts.skills>0)parts.push(RM0781ManageSkillCount(counts.skills));", + " return RM0781ManageList(parts);", + "}", + // Official splits the installed list after six rows whenever the page is not searching. + "function RM0781ManageSplitRows(rows,searching,limit){", + " if(searching||rows.length<=limit)return{visibleCount:rows.length,restCount:0};", + " return{visibleCount:limit,restCount:rows.length-limit};", + "}", + // Official composes the status badge's recipe from its base recipe plus the + // variant's own class: the variant's class REPLACES the base's `kMwMTN` and lands + // at the end. Measured on the live official app for `connected` and `error`. + "function RM0781ManageStatusClass(base,variant){", + ' const ordered=Object.keys(base).filter(k=>k!=="$$css");', + ' const kept=variant?ordered.filter(k=>k!=="kMwMTN"):ordered;', + " const parts=kept.map(k=>base[k]);", + " if(variant)parts.push(variant.kMwMTN);", + ' return parts.join(" ");', + "}", + "function RM0781ManageHero(){", + ' return t.jsx("header",{className:RM0781ManageHeroWrapperClasses,children:', + ' t.jsx("h1",{"data-detail-hero-title":"",className:RM0781ManageHeroClasses,tabIndex:-1,children:RLocT("Manage plugins and skills","管理插件和技能")})});', + "}", + "function RM0781ManageRest(props){", + " const{collapsedRows,expanded,onShowAll,total}=props;", + " const labelId=C.useId();", + " if(collapsedRows.length===0)return null;", + ' const collapsed=t.jsx("div",{"aria-hidden":!expanded||void 0,inert:!expanded||void 0,', + " className:expanded?RM0781ManageCollapsedExpandedClasses:RM0781ManageCollapsedClasses,", + ' children:t.jsx("div",{className:RM0781ManageRestInnerClasses,children:t.jsxs("div",{className:RM0781ManageRestListClasses,children:[', + ' t.jsx("span",{hidden:!0,id:labelId,children:RLocT("More installed plugins","更多已安装的插件")}),', + " t.jsx(Ws,{labelledBy:labelId,children:collapsedRows}),", + " ]})})});", + ' const showAll=t.jsx("div",{"aria-hidden":expanded||void 0,inert:expanded||void 0,', + " className:expanded?RM0781ManageShowAllWrapperHiddenClasses:RM0781ManageShowAllWrapperClasses,", + ' children:t.jsx("div",{className:RM0781ManageRestInnerClasses,children:t.jsx(ot,{hiddenLabel:RM0781ManageShowAllLabel(total),onShowAll:onShowAll})})});', + " return t.jsxs(t.Fragment,{children:[collapsed,showAll]});", + "}", + // Official's own scroll container lookup: the viewport the hero title is observed against. + // The nearest ancestor that scrolls is the pane's viewport in both official and this build. + "function RM0781ManageScrollViewport(node){", + " let current=node.parentElement;", + " while(current!=null){", + " const overflow=getComputedStyle(current).overflowY;", + ' if(overflow==="auto"||overflow==="scroll")return current;', + " current=current.parentElement;", + " }", + " return null;", + "}", + // Official observes the hero title with an IntersectionObserver rooted at the pane's + // viewport and reports `visible` while the hero is in view and `hidden` once it is not. + // The detail bar's own title is visible exactly while the hero title is NOT in view, so + // the bar title is hidden at the top of the page and appears once the hero scrolls away. + "function RM0781ManageHeroTitleState(){", + ' const[heroTitle,setHeroTitle]=C.useState("unmeasured");', + " C.useEffect(()=>{", + " let observer=null;", + " let frame=null;", + " let cancelled=false;", + " const attach=()=>{", + " const hero=document.querySelector(\"[role='dialog'] h1[data-detail-hero-title]\");", + " if(hero==null)return false;", + " const viewport=RM0781ManageScrollViewport(hero);", + ' setHeroTitle("visible");', + " if(viewport==null)return true;", + " observer=new IntersectionObserver((entries)=>{", + " const last=entries[entries.length-1];", + ' if(last!=null)setHeroTitle(last.isIntersecting?"visible":"hidden");', + " },{root:viewport});", + " observer.observe(hero);", + " return true;", + " };", + " if(!attach()){", + " const retry=()=>{", + " if(cancelled)return;", + " if(!attach())frame=requestAnimationFrame(retry);", + " };", + " frame=requestAnimationFrame(retry);", + " }", + " return ()=>{", + " cancelled=true;", + " if(frame!=null)cancelAnimationFrame(frame);", + " if(observer!=null)observer.disconnect();", + " };", + " },[]);", + " return heroTitle;", + "}", + "function RM0781ManageDetailBar(props){", + " const{backLabel,headingId,onBack,title}=props;", + " const heroTitle=RM0781ManageHeroTitleState();", + ' const titleVisible=heroTitle==="hidden";', + " const titleClass=titleVisible", + " ?RM0781ManageDetailBarTitleClasses", + " :RM0781ManageDetailBarTitleHiddenClasses;", + ' const back=t.jsxs("button",{className:RM0781ManageDetailBarBackButtonClasses,onClick:onBack,type:"button",children:[', + ' t.jsx("span",{"aria-hidden":"true",className:RM0781ManageDetailBarIconClasses,"data-size":"lg"},t.jsx(ue,{name:"chevron-left",size:16})),', + " t.jsx(\"span\",{className:RM0781ManageDetailBarLabelClasses,children:backLabel}),", + " ]});", + ' return t.jsxs("div",{className:RM0781ManageDetailBarClasses,children:[', + " t.jsx(\"div\",{className:RM0781ManageDetailBarBackClasses,children:back}),", + ' t.jsx("h3",{"aria-hidden":titleVisible?void 0:RM0781ManageDetailBarTitleHiddenAriaHidden,className:titleClass,id:headingId,children:title}),', + " t.jsx(\"div\",{className:RM0781ManageDetailBarTrailingClasses}),", + " ]});", + "}", + ].join("\n"), +); + +/** The replacements, exported so the regression test can check them on their own. */ +export const MARKETPLACE_MANAGE_PAGE_0781_REPLACEMENTS = REPLACEMENTS; + +/** The injected source, exported so the regression test can parse it on its own. */ +export const MARKETPLACE_MANAGE_PAGE_0781_INJECTED_SOURCE = INJECTED_SOURCE; + +function replaceExactlyOnce(source, before, after, label) { + const first = source.indexOf(before); + if (first < 0) throw new Error(`Original renderer manage page anchor is missing: ${label}`); + if (source.indexOf(before, first + 1) >= 0) { + throw new Error(`Original renderer manage page anchor is ambiguous: ${label}`); + } + return source.slice(0, first) + after + source.slice(first + before.length); +} + +/** + * Replaces the text BETWEEN two unique markers. + * + * Both markers must occur exactly once in the chunk, and the end marker must sit after the + * start marker. The markers themselves are kept; only what lies between them is replaced. + */ +function replaceBetweenExactlyOnce(source, start, end, after, label) { + const startIndex = source.indexOf(start); + if (startIndex < 0) throw new Error(`Original renderer manage page span start is missing: ${label}`); + if (source.indexOf(start, startIndex + 1) >= 0) { + throw new Error(`Original renderer manage page span start is ambiguous: ${label}`); + } + const endIndex = source.indexOf(end); + if (endIndex < 0) throw new Error(`Original renderer manage page span end is missing: ${label}`); + if (source.indexOf(end, endIndex + 1) >= 0) { + throw new Error(`Original renderer manage page span end is ambiguous: ${label}`); + } + if (endIndex < startIndex + start.length) { + throw new Error(`Original renderer manage page span end precedes its start: ${label}`); + } + return source.slice(0, startIndex + start.length) + after + source.slice(endIndex); +} + +/** + * Replaces a RUN that starts at one unique marker and ends at another. + * + * Both markers are consumed, so `after` has to re-emit whatever the run's tail was. The + * installed section's body is replaced this way: the run's middle is the show-all label, which + * the wording pass rewrites. + */ +function replaceRunExactlyOnce(source, start, end, after, label) { + const startIndex = source.indexOf(start); + if (startIndex < 0) throw new Error(`Original renderer manage page run start is missing: ${label}`); + if (source.indexOf(start, startIndex + 1) >= 0) { + throw new Error(`Original renderer manage page run start is ambiguous: ${label}`); + } + const endIndex = source.indexOf(end, startIndex + start.length); + if (endIndex < 0) throw new Error(`Original renderer manage page run end is missing: ${label}`); + if (source.indexOf(end, endIndex + 1) >= 0) { + throw new Error(`Original renderer manage page run end is ambiguous: ${label}`); + } + return source.slice(0, startIndex) + after + source.slice(endIndex + end.length); +} + +/** Applies official 0.78.1's second-level page to upstream 0.18's plugins chunk. */ +export function patchMarketplaceManagePage0781(source) { + let patched = source; + for (const entry of REPLACEMENTS) { + if (entry.kind === "between") { + patched = replaceBetweenExactlyOnce(patched, entry.start, entry.end, entry.after, entry.label); + } else if (entry.kind === "run") { + patched = replaceRunExactlyOnce(patched, entry.start, entry.end, entry.after, entry.label); + } else { + patched = replaceExactlyOnce(patched, entry.before, entry.after, entry.label); + } + } + // The injected components are function DECLARATIONS and `const` bindings in the chunk's + // module scope. The `const` bindings are read by the replacements above at *render* time + // (inside the component body), which runs after the module body, so the temporal-dead-zone is + // not an issue. + patched = `${patched}\n${INJECTED_SOURCE}\n`; + return patched; +} + +export async function applyMarketplaceManagePage0781({ stageRoot }) { + const assets = path.join(stageRoot, "dist", "renderer", "assets"); + const candidates = []; + for (const name of await readdir(assets)) { + if (!name.endsWith(".js")) continue; + const source = await readFile(path.join(assets, name), "utf8"); + if (source.includes('k("sand-plugins__bar"')) candidates.push({ name, source }); + } + if (candidates.length !== 1) { + throw new Error( + `Marketplace manage page patch: expected one plugins chunk, found ${candidates.length}.`, + ); + } + const { name, source } = candidates[0]; + const patched = patchMarketplaceManagePage0781(source); + // Fail closed: the rewritten chunk must still parse as a module. + parse(patched, { ecmaVersion: "latest", sourceType: "module" }); + await writeFile(path.join(assets, name), patched); + const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex"); + const provenance = { + mode: "marketplace-manage-page-0781", + referenceVersion: "0.78.1", + file: `dist/renderer/assets/${name}`, + originalSha256: sha256(source), + patchedSha256: sha256(patched), + anchors: REPLACEMENTS.map((entry) => ({ + label: entry.label, + kind: entry.kind ?? "plain", + before: entry.before ?? entry.start, + after: entry.after, + })), + features: REPLACEMENTS.map((entry) => entry.label), + officialMessageIds: Object.entries(MARKETPLACE_MANAGE_PAGE_0781_MESSAGES).map( + ([key, message]) => ({ key, id: message.id, en: message.en, zh: message.zh }), + ), + officialClassRecipes: { + ...MARKETPLACE_MANAGE_PAGE_0781_CLASSES, + ...MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES, + }, + }; + await writeFile( + path.join(stageRoot, "dist", "renderer-marketplace-manage-page-0781.json"), + `${JSON.stringify(provenance, null, 2)}\n`, + ); + console.log(`Marketplace manage page: official 0.78.1's second-level page applied (${name}).`); + return provenance; +} diff --git a/tests/fixtures/official-0781-manage-page-recipes.json b/tests/fixtures/official-0781-manage-page-recipes.json new file mode 100644 index 00000000..dde62f29 --- /dev/null +++ b/tests/fixtures/official-0781-manage-page-recipes.json @@ -0,0 +1,109 @@ +{ + "page": "manage", + "dialog": { + "class": "ui-dialog ui-ixxii4 ui-wa60dl ui-1nrll8i ui-3vbryh ui-78zum5 ui-dt5ytf ui-w7nakj ui-f1vpex ui-178xt8z ui-13fuv20 ui-2kampu ui-1lun4ml ui-18b5jzi ui-15nuqqu ui-so031l ui-1q0q8m5 ui-1jfuf7k ui-pilrb4 ui-1t7ytsu ui-19smxde ui-1pkpdue ui-6ikm8r ui-10wlt62 ui-1acoasx ui-11wthnw ui-1wd3ewq ui-1t137rt ui-chxttu ui-gyzkii ui-18ctxkd ui-10e4vud ui-11i3ho8 sand-11lhmoz sand-9f619 sand-qyufaf sand-u366gq sand-1a5igra ui-dialog--xxl sand-plugins-dialog sand-1sdb7ul sand-tqtr64 sand-1eh7du3 sand-1717udv sand-xrbq2n sand-1j6au1h sand-pe8fed sand-1r6a6jf", + "ariaLabel": "连接应用" + }, + "layout": { + "class": "sand-plugins-layout sand--default-marker sand-9f619 sand-1n2onr6 sand-78zum5 sand-dt5ytf sand-xe7e0x sand-2lwn1j sand-7giv3" + }, + "yours": { + "class": "sand-plugins__yours sand-9f619 sand-78zum5 sand-dt5ytf sand-1665zp3" + }, + "hero": { + "class": "sand-79ao00 sand-10ux60h sand-k0i524 sand-1ghz6dp sand-tyxrsu sand-1rhlpx6", + "text": "管理插件和技能", + "attrs": { + "data-detail-hero-title": "", + "class": "sand-79ao00 sand-10ux60h sand-k0i524 sand-1ghz6dp sand-tyxrsu sand-1rhlpx6", + "tabindex": "-1" + }, + "wrapperTag": "HEADER", + "wrapperClass": "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-1v2ro7d sand-euugli sand-f159sx sand-mzvs34", + "wrapperAttrs": { + "class": "sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-1v2ro7d sand-euugli sand-f159sx sand-mzvs34" + } + }, + "detailBar": { + "class": "sand-settings-detail-bar sand-9f619 sand-rvj5dj sand-37c5m6 sand-6s0dn4 sand-sdox4t sand-889kno sand-2vl965 sand-1a8lsjc sand-e2zdcy", + "buttonClass": "sand-11wthnw sand-w8bs97 sand-14gdtuo sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-195vfkc sand-193iq5w sand-1fgtraw sand-exx8yu sand-2vl965 sand-18d9i69 sand-25sj25 sand-c342km sand-ng3xce sand-ymw2ht sand-jbqb8w sand-11hyl0l sand-1o0liin sand-jb2p0i sand-1ypdohk sand-1t137rt sand-9v5kkp sand-1k57tk5 sand-784prv sand-1uczgqu sand-1wpm9rq", + "buttonText": "连接应用", + "buttonAriaLabel": null, + "iconName": "chevron-left", + "h3Class": "sand-fc7y3v sand-1lvvshy sand-ja3w2a sand-9f619 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-b3r6kr sand-1rhlpx6 sand-tyxrsu sand-2b8uid sand-lyipyv sand-uxw1ft sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn", + "h3Text": "管理", + "h3Attrs": { + "class": "sand-fc7y3v sand-1lvvshy sand-ja3w2a sand-9f619 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-b3r6kr sand-1rhlpx6 sand-tyxrsu sand-2b8uid sand-lyipyv sand-uxw1ft sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn", + "id": "sand-plugins-modal-heading" + }, + "h3HiddenClass": "sand-fc7y3v sand-1lvvshy sand-ja3w2a sand-9f619 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-b3r6kr sand-1rhlpx6 sand-tyxrsu sand-2b8uid sand-lyipyv sand-uxw1ft sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn sand-g01cxk", + "h3HiddenAttrs": { + "class": "sand-fc7y3v sand-1lvvshy sand-ja3w2a sand-9f619 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-b3r6kr sand-1rhlpx6 sand-tyxrsu sand-2b8uid sand-lyipyv sand-uxw1ft sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn sand-g01cxk", + "id": "sand-plugins-modal-heading", + "aria-hidden": "true" + }, + "trailingClass": "sand-78zum5 sand-6s0dn4 sand-1qab1bc sand-euugli" + }, + "installedSection": { + "class": "sand-plugins__group sand-9f619 sand-78zum5 sand-dt5ytf sand-xhr3t", + "headerClass": "sand-9f619 sand-78zum5 sand-1pha0wt sand-1qughib sand-167g77z", + "h3Class": "ui-text ui-1acoasx ui-dj266r ui-14z9mp ui-at24cr ui-1lziwak ui-exx8yu ui-yri2b ui-18d9i69 ui-1c1uobl ui-vmahel ui-lh3980 ui-1wm8ruf ui-spwq11 ui-14s4slr ui-vxqr21 sand-9f619 sand-1y1aw1k sand-f159sx sand-10b6aqq sand-1g0dm76 sand-169k319", + "h3Text": "已安装", + "h3Attrs": { + "class": "ui-text ui-1acoasx ui-dj266r ui-14z9mp ui-at24cr ui-1lziwak ui-exx8yu ui-yri2b ui-18d9i69 ui-1c1uobl ui-vmahel ui-lh3980 ui-1wm8ruf ui-spwq11 ui-14s4slr ui-vxqr21 sand-9f619 sand-1y1aw1k sand-f159sx sand-10b6aqq sand-1g0dm76 sand-169k319", + "data-variant": "default", + "data-size": "sm", + "data-weight": "regular", + "id": "_r_5d7_" + }, + "gridClass": "sand-plugins__grid sand-gpehar sand-12h1iku sand-9f619 sand-rvj5dj sand-nby9oq sand-uvv9dw sand-1dbijih sand-3ct3a4 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl", + "visibleRows": 24 + }, + "privateSection": { + "class": "sand-plugins__group sand-9f619 sand-78zum5 sand-dt5ytf sand-xhr3t", + "headerClass": "sand-9f619 sand-78zum5 sand-1pha0wt sand-1qughib sand-167g77z", + "h3Class": "ui-text ui-1acoasx ui-dj266r ui-14z9mp ui-at24cr ui-1lziwak ui-exx8yu ui-yri2b ui-18d9i69 ui-1c1uobl ui-vmahel ui-lh3980 ui-1wm8ruf ui-spwq11 ui-14s4slr ui-vxqr21 sand-9f619 sand-1y1aw1k sand-f159sx sand-10b6aqq sand-1g0dm76 sand-169k319", + "h3Text": "私有技能", + "gridClass": "sand-plugins__grid sand-gpehar sand-12h1iku sand-9f619 sand-rvj5dj sand-uvv9dw sand-1dbijih sand-3ct3a4 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-1mkdm3x", + "rows": 31 + }, + "row": { + "class": "sand-plugins-row sand-9f619 sand-1snpyr7 sand-193nyv9 sand-1n2onr6 sand-c8icb0 sand-78zum5 sand-6s0dn4 sand-1v2ro7d sand-euugli sand-z9dl7a sand-1sodtw3 sand-sag5q8 sand-a6wfpx sand-gqmno8 sand-jbqb8w sand-h6mt2a", + "openClass": "sand-plugins-row__open sand-9f619 sand-78zum5 sand-6s0dn4 sand-883omv sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-jbqb8w sand-1heor9g sand-jb2p0i sand-1yc453h sand-1ypdohk sand-1t137rt sand-1kogg8i sand-1s928wv sand-1j6awrg sand-arstr8 sand-1w00h3t sand-1ybvw9k", + "openAriaLabel": "打开 Gmail", + "mainClass": "sand-plugins-row__main sand-9f619 sand-78zum5 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-dt5ytf sand-12mrbbr sand-euugli", + "nameClass": "sand-plugins-row__name sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-1rhlpx6 sand-tyxrsu sand-lyipyv sand-uxw1ft", + "subtitleClass": "sand-plugins-row__subtitle sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-1o0liin sand-lyipyv sand-uxw1ft", + "subtitleText": "1 个连接器", + "trailingClass": "sand-plugins-row__trailing sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1n2onr6 sand-1vjfegm", + "statusClass": "sand-plugins__status sand-9f619 sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-uxw1ft sand-1ufr4hg", + "statusAttrs": { + "class": "sand-plugins__status sand-9f619 sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-uxw1ft sand-1ufr4hg", + "data-status": "connected", + "role": "status" + }, + "statusText": "已连接" + }, + "privateRow": { + "class": "sand-plugins-row sand-9f619 sand-1snpyr7 sand-193nyv9 sand-1n2onr6 sand-c8icb0 sand-78zum5 sand-6s0dn4 sand-1v2ro7d sand-euugli sand-z9dl7a sand-1sodtw3 sand-sag5q8 sand-a6wfpx sand-gqmno8 sand-jbqb8w sand-h6mt2a", + "openClass": "sand-plugins-row__open sand-9f619 sand-78zum5 sand-6s0dn4 sand-883omv sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-euugli sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-jbqb8w sand-1heor9g sand-jb2p0i sand-1yc453h sand-1ypdohk sand-1t137rt sand-1kogg8i sand-1s928wv sand-1j6awrg sand-arstr8 sand-1w00h3t sand-1ybvw9k", + "mainClass": "sand-plugins-row__main sand-9f619 sand-78zum5 sand-1iyjqo2 sand-s83m0k sand-dl72j9 sand-dt5ytf sand-12mrbbr sand-euugli", + "nameClass": "sand-plugins-row__name sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-1rhlpx6 sand-tyxrsu sand-lyipyv sand-uxw1ft", + "subtitleClass": "sand-plugins-row__subtitle sand-9f619 sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-1o0liin sand-lyipyv sand-uxw1ft", + "subtitleText": "本地创建 · Use this when the user wants to see what's trending on Baidu right now (百度热搜 / 实时热搜榜), e.g. a quick look or a daily list of China's hot search topics.", + "trailingClass": "sand-plugins-row__trailing sand-9f619 sand-78zum5 sand-6s0dn4 sand-17d4w8g sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1n2onr6 sand-1vjfegm", + "iconClass": "sand-9f619 sand-78zum5 sand-6s0dn4 sand-l56j7k sand-100vrsf sand-1vqgdyp sand-2lah0s sand-1kogg8i sand-wpq65r" + }, + "showAll": { + "class": "sand-plugins__show-all sand-9f619 sand-qcrz7y sand-1iorvi4 sand-f159sx sand-jkvuk6 sand-1g0dm76 sand-c342km sand-ng3xce sand-jbqb8w sand-1o0liin sand-n4ifyr sand-jb2p0i sand-1wm8ruf sand-1ypdohk", + "text": "显示全部 24 个插件" + }, + "collapsed": { + "class": "sand-rvj5dj sand-ihq33y sand-g01cxk sand-1bvilyr sand-uupxpy sand-47corl sand-icbapw sand-1ympp8d sand-1upsisp sand-t35mv4 sand-1r6a6jf", + "innerClass": "sand-2lwn1j sand-b3r6kr", + "listLabel": "更多已安装的插件", + "listLabelClass": null, + "gridClass": "sand-plugins__grid sand-gpehar sand-12h1iku sand-9f619 sand-rvj5dj sand-nby9oq sand-uvv9dw sand-1dbijih sand-3ct3a4 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl", + "rows": 18 + } +} diff --git a/tests/marketplace-manage-page-0781.test.mjs b/tests/marketplace-manage-page-0781.test.mjs new file mode 100644 index 00000000..67a45bf6 --- /dev/null +++ b/tests/marketplace-manage-page-0781.test.mjs @@ -0,0 +1,339 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { parse } from "acorn"; +import { + MARKETPLACE_MANAGE_PAGE_0781_MESSAGES, +} from "../scripts/lib/marketplace-manage-page-0781-patch.mjs"; +import { applyMarketplaceManagePage0781 } from "../scripts/lib/marketplace-manage-page-0781-patch.mjs"; +import { + MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES, + MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES, + marketplaceManagePage0781ClassesStyledInDialog, +} from "../scripts/lib/marketplace-manage-page-0781-css-patch.mjs"; +import { applyMarketplaceManagePage0781Css } from "../scripts/lib/marketplace-manage-page-0781-css-patch.mjs"; + +const repo = path.resolve(import.meta.dirname, ".."); +const upstreamDir = path.join(repo, "src/app/dist/renderer/assets"); +const fixture = JSON.parse( + await readFile(path.join(repo, "tests/fixtures/official-0781-manage-page-recipes.json"), "utf8"), +); + +/** The upstream plugins chunk, or null when the tree has not been bootstrapped. */ +async function upstreamChunk() { + let entries; + try { + entries = await readdir(upstreamDir); + } catch { + return null; + } + for (const name of entries.filter((entry) => entry.endsWith(".js"))) { + const text = await readFile(path.join(upstreamDir, name), "utf8"); + if (text.includes("sand-plugins__yours")) return { name, text }; + } + return null; +} + +/** A scratch stage holding one copy of the upstream plugins chunk. */ +async function stage() { + const root = await mkdtemp(path.join(tmpdir(), "sand-manage-0781-")); + const assets = path.join(root, "dist/renderer/assets"); + await cp(upstreamDir, assets, { recursive: true }); + return { root, assets }; +} + +test("the second-level page carries official 0.78.1's own markup", async (t) => { + const chunk = await upstreamChunk(); + if (chunk == null) { + t.skip("src/app/dist is gitignored and only exists after bootstrap"); + return; + } + const { root, assets } = await stage(); + try { + const provenance = await applyMarketplaceManagePage0781({ stageRoot: root }); + assert.ok(provenance != null && typeof provenance === "object"); + const patched = await readFile(path.join(assets, chunk.name), "utf8"); + + assert.doesNotThrow(() => parse(patched, { ecmaVersion: "latest", sourceType: "module" })); + + // Official 0.78.1's class recipes, read off the running official app. + for (const [label, recipe] of [ + ["the hero", fixture.hero.class], + ["the detail bar", fixture.detailBar.class], + ["the detail bar's back button", fixture.detailBar.buttonClass], + ["the detail bar's heading", fixture.detailBar.h3Class], + ["the detail bar's trailing slot", fixture.detailBar.trailingClass], + ["the installed section", fixture.installedSection.class], + ["the installed section's header", fixture.installedSection.headerClass], + ["the installed section's heading", fixture.installedSection.h3Class], + ["the plugins grid", fixture.installedSection.gridClass], + ["the private-skills grid", fixture.privateSection.gridClass], + ["the plugin row", fixture.row.class], + ["the plugin row's open button", fixture.row.openClass], + ["the plugin row's name", fixture.row.nameClass], + ["the plugin row's subtitle", fixture.row.subtitleClass], + ["the plugin row's trailing slot", fixture.row.trailingClass], + ["the show-all control", fixture.showAll.class], + ["the collapsed remainder", fixture.collapsed.class], + ["the collapsed remainder's inner wrapper", fixture.collapsed.innerClass], + ]) { + const classes = recipe.split(/\s+/).filter(Boolean); + const present = classes.filter((name) => patched.includes(name)); + assert.ok( + present.length >= Math.ceil(classes.length / 2), + `${label} must carry official 0.78.1's recipe (${present.length}/${classes.length} present)`, + ); + } + + // Official's collapsed remainder labels the hidden list with text only — the span + // carries no class attribute at all, so the label is asserted by its text. + assert.ok( + typeof fixture.collapsed.listLabel === "string" && fixture.collapsed.listLabel.length > 0, + "the capture must record the collapsed list's hidden label", + ); + assert.ok( + patched.includes(fixture.collapsed.listLabel), + "the patch must write official's collapsed list label", + ); + + // Official 0.78.1's own Chinese text, under its own message ids. + for (const [key, message] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_MESSAGES)) { + assert.ok(message.zh.length > 0, `${key} must carry official's Chinese text`); + assert.ok(patched.includes(message.zh), `the patch must write official's "${message.zh}"`); + } + + // The row's open button carries official's localized label (official id T9mwDN: + // "Open {name}" / "打开 {name}"), not upstream's hardcoded English "Open {name}". + assert.ok( + patched.includes('"aria-label":RLocT("Open "+'), + "the row's open button must carry official's localized label", + ); + + // Official's status-badge recipes, read out of official's own registry and + // confirmed on the live official app for the `connected` and `error` variants. + assert.ok( + patched.includes('statusConnected:{kMwMTN:"sand-1ufr4hg"'), + "the status badge must carry official's connected recipe", + ); + assert.ok( + patched.includes('statusError:{kMwMTN:"sand-wtpwev"'), + "the status badge must carry official's error recipe", + ); + assert.ok( + !patched.includes('statusConnected:{kMwMTN:"sand-98zg7y"'), + "upstream 0.18's connected recipe must be gone", + ); + + // Official's private-skills rows carry no enable toggle: official enables a skill + // from the skill's detail page, and upstream 0.18 ships that page too — its + // `setEnabled({... isEnabled:!u.isEnabledForAgent})` call must survive. + assert.ok( + !patched.includes("onToggle:()=>N(H)"), + "the private-skills row must not carry upstream's enable toggle", + ); + assert.ok( + patched.includes("isEnabled:!u.isEnabledForAgent"), + "the skill detail page's enable control must survive", + ); + assert.ok( + patched.includes("sand-1kogg8i sand-wpq65r"), + "the private-skills row's icon wrapper must carry official's recipe", + ); + + // Official's hero sits in a `
` of official's own recipe. + assert.ok( + patched.includes(`className:RM0781ManageHeroWrapperClasses`), + "the patch must render the hero inside official's header wrapper", + ); + assert.ok( + patched.includes(JSON.stringify(fixture.hero.wrapperClass)), + "the hero wrapper must carry official's class recipe", + ); + + // Official's detail-bar title has two variants: hidden while the page's hero title is in + // view (the resting state) and visible once the hero has scrolled away. The port renders + // the hidden variant at the top of the page. + assert.ok( + patched.includes(JSON.stringify(fixture.detailBar.h3HiddenClass)), + "the detail bar's title must carry official's resting (hidden) recipe", + ); + assert.ok( + patched.includes(JSON.stringify(fixture.detailBar.h3Class)), + "the detail bar's title must carry official's visible recipe", + ); + assert.ok( + patched.includes("RM0781ManageDetailBarTitleHiddenAriaHidden"), + "the detail bar's hidden title must carry official's aria-hidden", + ); + + // The upstream subtitle builder is shared with the browse page, whose 0.78.1 parity was + // signed off in the earlier wave: the manage page gets its own builder instead. + assert.ok( + patched.includes('e.join(" · ")'), + "upstream's shared subtitle builder must be left alone", + ); + + // The connector auth action carries official's connect-mode label, the way official's own + // `K` chain picks it: "连接" for a connector that needs authentication, "重新连接" while a + // handoff is waiting and "重试" otherwise. Upstream's authorize-mode wording ("身份验证") is + // what the live official app does NOT show. + assert.ok( + patched.includes('RLocT("Connect","连接")'), + "the connector auth action must carry official's connect-mode label", + ); + assert.ok( + patched.includes('RLocT("Reconnect","重新连接")'), + "the connector auth action must carry official's reconnect label", + ); + assert.ok( + patched.includes('RLocT("Retry","重试")'), + "the connector auth action must carry official's retry label", + ); + assert.ok( + !patched.includes('RLocT("Authenticate","身份验证")'), + "upstream's authorize-mode label must be gone", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("the staged chunk carries the ported second-level page", () => { + // The staged chunk is the one the i18n, wording and browse-chrome passes have + // already rewritten, so it is the state the patch actually has to land on. It only + // exists after a build. + const staged = path.join( + repo, + ".build/fidelity/app/dist/renderer/assets/view-B5Ug8wEm.js", + ); + let chunk; + try { + chunk = readFileSync(staged, "utf8"); + } catch (error) { + // Only a missing staged chunk is a skip. Anything else is a real failure and must + // not be swallowed into a false green. + if (error?.code === "ENOENT") return; + throw error; + } + assert.ok(chunk.includes("RM0781ManageHero"), "the staged chunk must carry the ported page"); + assert.ok(chunk.includes("管理插件和技能"), "the staged chunk must carry official's hero text"); + assert.ok(chunk.includes("RM0781ManageDetailBar"), "the staged chunk must carry official's detail bar"); + assert.ok( + chunk.includes('statusConnected:{kMwMTN:"sand-1ufr4hg"'), + "the staged chunk must carry official's status recipe", + ); + assert.ok( + !chunk.includes("onToggle:()=>N(H)"), + "the staged chunk must not carry upstream's private-row enable toggle", + ); + assert.ok( + chunk.includes("sand-1kogg8i sand-wpq65r"), + "the staged chunk must carry official's private-row icon wrapper", + ); + assert.ok( + chunk.includes('RLocT("Connect","连接")'), + "the staged chunk must carry official's connect-mode auth label", + ); + assert.ok( + !chunk.includes('RLocT("Authenticate","身份验证")'), + "the staged chunk must not carry upstream's authorize-mode auth label", + ); + assert.ok( + chunk.includes(JSON.stringify(fixture.detailBar.h3HiddenClass)), + "the staged chunk must carry official's resting (hidden) title recipe", + ); + assert.ok( + chunk.includes(JSON.stringify(fixture.hero.wrapperClass)), + "the staged chunk must render the hero inside official's header wrapper", + ); + assert.equal(chunk.split('"(RLocT(').length - 1, 0, "the staged chunk must not double-wrap RLocT"); +}); + +test("the connected badge's green rule is scoped to the dialog, not skipped as 'already carried'", async (t) => { + const upstreamCss = path.join(upstreamDir, "index-lCyB53CO.css"); + let upstream; + try { + upstream = await readFile(upstreamCss, "utf8"); + } catch { + t.skip("src/app/dist is gitignored and only exists after bootstrap"); + return; + } + + // Upstream 0.18 does not define the green at all. The repo's own conversation-computer-card + // port carries official's rule scoped to `.r-conversation-computer-card`, so the staged + // stylesheet holds the class token and never styles it inside the plugins dialog. A test on + // "the class token appears somewhere" would wrongly call it carried. + const styledInDialog = marketplaceManagePage0781ClassesStyledInDialog(upstream); + assert.ok( + !styledInDialog.has("sand-1ufr4hg"), + "upstream must not style the connected badge's green inside the plugins dialog", + ); + const scopedAway = ".r-conversation-computer-card .sand-1ufr4hg"; + assert.ok( + !marketplaceManagePage0781ClassesStyledInDialog( + `${scopedAway}:not(#\\#):not(#\\#):not(#\\#){color:var(--sand-text-green-primary)}`, + ).has("sand-1ufr4hg"), + "a rule scoped away from the dialog must not count as carried", + ); + + const root = await mkdtemp(path.join(tmpdir(), "sand-manage-0781-css-")); + const assets = path.join(root, "dist/renderer/assets"); + await cp(upstreamDir, assets, { recursive: true }); + try { + const provenance = await applyMarketplaceManagePage0781Css({ stageRoot: root }); + const patchedCss = await readFile(path.join(assets, "index-lCyB53CO.css"), "utf8"); + const green = MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES["sand-1ufr4hg"]; + assert.ok(green != null, "the companion CSS must lift official's green rule"); + assert.ok( + patchedCss.includes(`:where(.sand-plugins-dialog) ${green}`), + "the green rule must be carried inside the plugins dialog", + ); + assert.ok( + marketplaceManagePage0781ClassesStyledInDialog(patchedCss).has("sand-1ufr4hg"), + "the connected badge's green must resolve inside the plugins dialog", + ); + assert.ok( + provenance.appendedRules.some((rule) => rule.includes("sand-1ufr4hg")), + "the injector must append the green rule instead of skipping it", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("every lifted CSS rule is official 0.78.1's own and resolves in the shipped stylesheet", async (t) => { + const upstreamCss = path.join(upstreamDir, "index-lCyB53CO.css"); + let upstream; + try { + upstream = await readFile(upstreamCss, "utf8"); + } catch { + t.skip("src/app/dist is gitignored and only exists after bootstrap"); + return; + } + const officialCss = await readFile( + "/Users/Apple/Documents/grokbot/rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css", + "utf8", + ).catch(() => null); + if (officialCss == null) { + t.skip("the official 0.78.1 stylesheet is not present on this machine"); + return; + } + for (const [name, rule] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)) { + const selector = rule.slice(0, rule.indexOf("{")); + assert.ok( + officialCss.includes(selector), + `the lifted rule for ${name} must exist in official 0.78.1's stylesheet`, + ); + assert.ok( + !upstream.includes(selector), + `${name} must not be lifted again — upstream 0.18 already carries it`, + ); + } + assert.ok( + MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES.length > 0, + "the port must declare the classes it introduces", + ); +}); From ea1af35b13d297af1aec1f7927d17d6888293a48 Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 04:34:01 +0800 Subject: [PATCH 2/7] docs(marketplace): record the manage page port Documents where each behaviour comes from in official's bytes, the four defects found against official, the measured parity, and the pixel delta with its two sources. --- docs/MARKETPLACE-0781-PORT.md | 154 ++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/docs/MARKETPLACE-0781-PORT.md b/docs/MARKETPLACE-0781-PORT.md index b037eeef..80f08483 100644 --- a/docs/MARKETPLACE-0781-PORT.md +++ b/docs/MARKETPLACE-0781-PORT.md @@ -897,3 +897,157 @@ detail tile 56x56/radius 16/31px glyph on both apps, row tiles still 40x40, all Post-merge deployment note: the merged tree was repackaged once more from the same commits and deployed (asar head `05f771f9b8ce`; per-run metadata files differ, the JS payload is identical) — probe-113 re-verified the 56px detail-header tile and glyph parity live on that build. + +## The second-level page — 管理插件和技能 (2026-11-01) + +The browse dialog's `已连接 N 个` entry opens a second-level page. Official 0.78.1 titles it +管理插件和技能. This wave ports that page. + +### Where official's behaviour comes from + +| behaviour | official's bytes | +|---|---| +| the page component | `chunk-view-KV06RTpF.js` | +| the status registry and the badge component | `index.eager-app-D1juOWZp.js` (`Nq`, `LAt`) | +| the connector's auth action | `chunk-plugin-detail-sharepoint-write-MAPmM3e9.js` (`W`, exported as `mr`) | +| the detail bar and its title state | `chunk-pane-header-DaY0itEE.js` (`S`) | + +The page is re-recipeed from upstream's own components rather than rewritten. Upstream's +`view-B5Ug8wEm.js` already owns the grid, the row and the section; the patch changes what those +components render, not what they are. + +The fixture `tests/fixtures/official-0781-manage-page-recipes.json` is captured live from the +running official app by `.audit/capture-official-0781-manage-fixture.mjs` and read at patch load +time. It is a capture, not a hand-written copy of official's classes. + +The port lives in three files: + +| file | role | +|---|---| +| `scripts/lib/marketplace-manage-page-0781-patch.mjs` | the renderer patch, applied to the pristine upstream chunk | +| `scripts/lib/marketplace-manage-page-0781-css-patch.mjs` | the CSS injector | +| `scripts/lib/marketplace-manage-page-0781-official.css` | the lifted official rules, verbatim | + +`tests/marketplace-manage-page-0781.test.mjs` guards all four: the pristine chunk, the staged +chunk, the green rule, and the lifted CSS. + +### Four defects found against official and fixed + +**A. The green 已连接 badge rendered white.** Official's green rule lives in `sand-1ufr4hg`. +Upstream 0.18 does not define that class at all, and this repository's own +`conversation-computer-card-renderer-patch.mjs` carries official's rule scoped to +`.r-conversation-computer-card`, so the staged stylesheet held the class token without ever +styling it inside the dialog. The injector tested "the class token appears somewhere", which is +not the same question as "does this rule apply in this scope". It now tests the scope with +`classesStyledInDialog(css, dialogClass)`. All 40 companion rules append, and the green rule +lands as `:where(.sand-plugins-dialog) .sand-1ufr4hg:not(#\#)…{color:var(--sand-text-green-primary)}`. + +**B. The auth rows read 身份验证 instead of 连接.** Official's chain `mr` → `le` → `K` picks its +label from the `grok_bot_connect_ux_desktop` experiment: `iSLIjg` for Connect, `gcoiFh` for +Reconnect, `6gRgw8` for Retry. Upstream's shared `Qn` hardcoded the authorize-mode literals +(Authenticate, Reopen, Retry). Two `between` replacements rewrite `Qn`'s label expression to +official's connect-mode choice. + +**C. The detail-bar title was always visible.** Official's title is hidden at the scroll top and +appears once the hero scrolls out of view. Official's `qa` hook observes the hero with an +`IntersectionObserver` rooted at the pane's scroll viewport and derives +`isTitleVisible = heroTitle === 'hidden'`. The port injects the same observer. + +**D. The hero `h1` had no `
` wrapper.** Official wraps it in +`
`. + +### The measured parity + +Measured over CDP against the deployed `/Applications/Grok Node.app` and against official 0.78.1 +on port 9333, both dialogs pinned to 832x702 at (16, 161). + +| measurement | ours | official 0.78.1 | +|---|---|---| +| every class recipe on the page | matches | the reference | +| detail-bar title at the scroll top | `sand-g01cxk`, `aria-hidden="true"`, opacity 0 | the same | +| detail-bar title after the hero scrolls out | `sand-19991ni sand-ibwnj sand-12w9bfk sand-9lcvmn`, opacity 1 | the same | +| the hero's wrapper | `
` | the same | +| the connected row's badge | 已连接, `rgb(0, 186, 105)` | 已连接, `rgb(0, 186, 105)` | +| the needsAuth row's action | 连接 | 连接 | +| the collapsed-rows structure | official's | official's | +| the browse chip row and search field | gone | gone | + +The two apps hold different installed plugin sets (official 24 installed and 18 collapsed, this +build 8 and 2) and different private skills, so a row-for-row pixel comparison of the page body +cannot reach zero. + +### The pixel diff, and what it is made of + +`.audit/diff-manage-page.mjs` drives both apps to the page, pins both viewports, captures the +dialog region and compares the two PNGs. The delta is 170770 of 2336256 pixels, 7.310 percent. + +`.audit/classify-header-delta.mjs` breaks that delta into connected clusters and reports each +cluster's bounding box, pixel count and maximum channel delta. Two sources account for it. + +**The plugin content.** Different installed sets and different private skills. This is the bulk of +the delta and it is data, not code. + +**The font.** Official sets `:root { font-family: var(--cursor-font-family-sans) }` and +`--cursor-font-family-sans` starts with `XVF`, the variable font official ships as +`X-VF-C7NyO54W.woff2`. Upstream 0.18 has neither the `@font-face` nor the variable value, so its +stack starts at `-apple-system`. + +The two apps paint the same CJK glyphs from the same platform face. Chromium reports +`PingFang SC` / `PingFangSC-Medium` for all four glyphs of 连接应用 on both sides, and the +per-character advances are equal to three decimals on both sides. What differs is the line box, +because the primary family differs: official's CJK glyph box is 15.5 px tall and this build's is +15.0 px, and the baseline sits half a pixel lower. + +Injecting official's `@font-face` and variable into this build's live page at runtime, with no +binary copied into the repository, measures the effect directly. + +| region | before | after | hard pixels (max channel delta above 12) before | after | +|---|---|---|---|---| +| the whole dialog | 7.310 % | 6.030 % | 130801 | 103754 | +| the chrome, the header and the hero | 7003 px | 3240 px | 3618 | 0 | + +So the font accounts for every visible difference in the page's chrome. What remains there is +antialiasing noise, a maximum channel delta of 12 or less. + +The font is not shipped. XVF is Anysphere's proprietary binary font and this repository carries no +licence to redistribute it, which is the same call the typography section above already recorded. +Closing that gap would mean shipping official's font binary, which is the operator's decision and +not the port's. + +### One difference left in the shared button + +Upstream 0.18's shared `sand-button` secondary recipe and official 0.78.1's carry different class +recipes, and the difference is narrower than the class strings suggest. Measured on the running +apps, the connect button's computed border widths, border colours, border styles, padding, +transition duration and height are the same on both sides. Two things make the class names differ +without changing what the button looks like. + +Upstream writes the physical properties, so `sand-s1s249` is `border-right-width: 1px` where +official's `sand-1lun4ml` is `border-inline-end-width: 1px`. LTR renders them the same. Upstream's +stylix emits one more `:not(#\#)` guard than official's, so an identical declaration hashes to a +different class name. + +One property does differ. Official's secondary background is `var(--sand-bg-secondary)`, which +computes to `rgba(245, 245, 245, 0.04)`. Upstream's is +`color-mix(in srgb, var(--cursor-bg-tertiary) 82%, var(--cursor-bg-editor))`, which computes to +`rgba(56, 56, 56, 0.212)`. On the dialog's background the two land about two levels apart out of +255. + +Re-recipeing the shared button would change every button in the app, and the `--sand-*` palette +those recipes read is only defined inside the marketplace dialog. The difference is recorded here +and left for a wave that can scope it. + +### Deployment record (2026-11-01) + +The packaged build (`app.asar` sha256 +`baf3ad589b857476707a01abdfc168e26412238dd40029b0fb74a0d351225376`) was deployed to +`/Applications/Grok Node.app` with a full replace, and the deployed asar's sha256 equals the +freshly built asar's. All 102 shipped chunks parse, no `"(RLocT("` quote bug is present, and 43 +stylix rules resolve with 0 unresolved and 0 not-byte-for-byte-official. + +The flow was verified by real computer operation on the deployed build, not only over CDP: a real +cursor click on the composer's 连接应用 opens the browse dialog, a real cursor click on its +已连接 8 个 entry opens 管理插件和技能, and the page shows the green 已连接 badge on the connected +row and grey 连接 pills on the rows that need authorisation. Official was driven through the same +path in the same session and the two pages were compared side by side. + From a282feac50cea0ce5e040bf8d853a28bfca512dd Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 08:58:50 +0800 Subject: [PATCH 3/7] test(marketplace): guard every status badge variant's colour rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Official 0.78.1 ships three status-badge variants — connected, warn and error — and each carries its own colour class. The capture only ever saw the connected variant, so the companion CSS lifted one rule of the three and the warning and error badges shipped colourless. The guard now walks all three variants instead of the connected one alone, and a second guard asserts that every token the ported rules read is declared on the dialog, because an undeclared var() is invalid at computed-value time and the rule falls back to its initial value without failing anything else. The status variant classes move into one exported constant so the patch's replacement text and the guard read the same source. Fails on the current tree: "the companion CSS must lift official's warn badge rule". --- .../marketplace-manage-page-0781-patch.mjs | 25 +++++- tests/marketplace-manage-page-0781.test.mjs | 85 ++++++++++++++----- 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/scripts/lib/marketplace-manage-page-0781-patch.mjs b/scripts/lib/marketplace-manage-page-0781-patch.mjs index 876e3cb8..8a43b6f8 100644 --- a/scripts/lib/marketplace-manage-page-0781-patch.mjs +++ b/scripts/lib/marketplace-manage-page-0781-patch.mjs @@ -195,6 +195,27 @@ export const MARKETPLACE_MANAGE_PAGE_0781_EXTRA_CLASSES = Object.freeze({ detailBarLabel: "sand-b3r6kr sand-lyipyv sand-uxw1ft", }); +/** + * Official 0.78.1's status-badge variant recipes, read out of official's own registry. + * + * Official ships three variants of one badge recipe — connected, warn and error — and each + * carries its own colour class. The capture only ever saw the connected variant, because the + * live dialog showed no warning and no failing connector; the other two are read out of + * official's bytes. + * + * Upstream 0.18 ships its own three, and the port replaces them with official's, so the ported + * page has to carry official's colour rules itself. Each of the three classes resolves nowhere + * inside upstream 0.18's plugins dialog, and each of official's rules reads a `--sand-text-*` + * colour token that upstream 0.18's palette does not define. + * + * @evidence official 0.78.1 `index.eager-app-D1juOWZp.js` — the `status` recipe registry. + */ +export const MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES = Object.freeze({ + connected: "sand-1ufr4hg", + warn: "sand-132xtu3", + error: "sand-wtpwev", +}); + /** * Official 0.78.1's message ids used by this patch, with official's own text. * @@ -393,7 +414,9 @@ const REPLACEMENTS = [ 'statusConnected:{kMwMTN:"sand-98zg7y",$$css:!0},statusWarn:{kMwMTN:"sand-1izesbo",$$css:!0},statusError:{kMwMTN:"sand-pmgbkh",$$css:!0}};', after: 'const cs={status:{kB7OPa:"sand-9f619",kGuDYH:"sand-11wthnw",kLWn49:"sand-d4r4e8",kb6lSQ:"sand-12oo3zp",kMwMTN:"sand-169k319",khDVqt:"sand-uxw1ft",$$css:!0},' + - 'statusConnected:{kMwMTN:"sand-1ufr4hg",$$css:!0},statusWarn:{kMwMTN:"sand-132xtu3",$$css:!0},statusError:{kMwMTN:"sand-wtpwev",$$css:!0}};', + `statusConnected:{kMwMTN:"${MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES.connected}",$$css:!0},` + + `statusWarn:{kMwMTN:"${MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES.warn}",$$css:!0},` + + `statusError:{kMwMTN:"${MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES.error}",$$css:!0}};`, }, { label: diff --git a/tests/marketplace-manage-page-0781.test.mjs b/tests/marketplace-manage-page-0781.test.mjs index 67a45bf6..feb071ef 100644 --- a/tests/marketplace-manage-page-0781.test.mjs +++ b/tests/marketplace-manage-page-0781.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { parse } from "acorn"; import { MARKETPLACE_MANAGE_PAGE_0781_MESSAGES, + MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES, } from "../scripts/lib/marketplace-manage-page-0781-patch.mjs"; import { applyMarketplaceManagePage0781 } from "../scripts/lib/marketplace-manage-page-0781-patch.mjs"; import { @@ -252,7 +253,7 @@ test("the staged chunk carries the ported second-level page", () => { assert.equal(chunk.split('"(RLocT(').length - 1, 0, "the staged chunk must not double-wrap RLocT"); }); -test("the connected badge's green rule is scoped to the dialog, not skipped as 'already carried'", async (t) => { +test("every status badge variant carries official's own rule inside the dialog", async (t) => { const upstreamCss = path.join(upstreamDir, "index-lCyB53CO.css"); let upstream; try { @@ -262,15 +263,28 @@ test("the connected badge's green rule is scoped to the dialog, not skipped as ' return; } - // Upstream 0.18 does not define the green at all. The repo's own conversation-computer-card - // port carries official's rule scoped to `.r-conversation-computer-card`, so the staged - // stylesheet holds the class token and never styles it inside the plugins dialog. A test on - // "the class token appears somewhere" would wrongly call it carried. + // Official ships three badge variants — connected, warn and error — and each carries its own + // colour class. Upstream 0.18 styles none of the three inside the plugins dialog, so the port + // has to carry official's rules itself. The capture only ever saw the connected variant, + // because the live dialog showed no warning and no failing connector, and the two missing + // rules shipped as colourless badges before this guard existed. const styledInDialog = marketplaceManagePage0781ClassesStyledInDialog(upstream); - assert.ok( - !styledInDialog.has("sand-1ufr4hg"), - "upstream must not style the connected badge's green inside the plugins dialog", - ); + for (const [variant, name] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES)) { + assert.ok( + !styledInDialog.has(name), + `upstream must not style the ${variant} badge inside the plugins dialog`, + ); + assert.ok( + MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES[name] != null, + `the companion CSS must lift official's ${variant} badge rule`, + ); + } + + // A rule scoped away from the dialog does not count as carried. The repo's own + // conversation-computer-card port carries official's connected-badge rule scoped to + // `.r-conversation-computer-card`, so the staged stylesheet holds the class token and never + // styles it inside the plugins dialog. A test on "the class token appears somewhere" would + // wrongly call it carried. const scopedAway = ".r-conversation-computer-card .sand-1ufr4hg"; assert.ok( !marketplaceManagePage0781ClassesStyledInDialog( @@ -285,25 +299,50 @@ test("the connected badge's green rule is scoped to the dialog, not skipped as ' try { const provenance = await applyMarketplaceManagePage0781Css({ stageRoot: root }); const patchedCss = await readFile(path.join(assets, "index-lCyB53CO.css"), "utf8"); - const green = MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES["sand-1ufr4hg"]; - assert.ok(green != null, "the companion CSS must lift official's green rule"); - assert.ok( - patchedCss.includes(`:where(.sand-plugins-dialog) ${green}`), - "the green rule must be carried inside the plugins dialog", - ); - assert.ok( - marketplaceManagePage0781ClassesStyledInDialog(patchedCss).has("sand-1ufr4hg"), - "the connected badge's green must resolve inside the plugins dialog", - ); - assert.ok( - provenance.appendedRules.some((rule) => rule.includes("sand-1ufr4hg")), - "the injector must append the green rule instead of skipping it", - ); + for (const [variant, name] of Object.entries(MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES)) { + const rule = MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES[name]; + assert.ok( + patchedCss.includes(`:where(.sand-plugins-dialog) ${rule}`), + `the ${variant} badge rule must be carried inside the plugins dialog`, + ); + assert.ok( + marketplaceManagePage0781ClassesStyledInDialog(patchedCss).has(name), + `the ${variant} badge must resolve inside the plugins dialog`, + ); + assert.ok( + provenance.appendedRules.some((appended) => appended.includes(name)), + `the injector must append the ${variant} badge rule instead of skipping it`, + ); + } } finally { await rm(root, { recursive: true, force: true }); } }); +test("every token the ported rules read is declared on the dialog", () => { + // A `var(--x)` whose target is not declared on the dialog subtree is invalid at computed-value + // time: the rule falls back to its initial value, so the badge loses its colour while every + // other test stays green. Upstream 0.18 ships an older palette, so "upstream happens to define + // it" is not good enough — the value would be the wrong one. + const css = readFileSync( + path.join(repo, "scripts/lib/marketplace-dialog-0781-theme-tokens.css"), + "utf8", + ); + const declared = new Set([...css.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)].map((m) => m[1])); + const referenced = new Set(); + for (const rule of Object.values(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)) { + for (const match of rule.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) referenced.add(match[1]); + } + assert.ok(referenced.size > 0, "the ported rules must read at least one token"); + const missing = [...referenced].filter((token) => !declared.has(token)).sort(); + assert.deepEqual( + missing, + [], + "these tokens are read by the ported rules but not declared on the dialog, so they resolve to " + + "an invalid value at computed-value time and the rule renders its initial value", + ); +}); + test("every lifted CSS rule is official 0.78.1's own and resolves in the shipped stylesheet", async (t) => { const upstreamCss = path.join(upstreamDir, "index-lCyB53CO.css"); let upstream; From 98ccaf898a94a80c68c11bfc8d9bfc0917aee055 Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 09:09:00 +0800 Subject: [PATCH 4/7] fix(marketplace): carry official's status badge colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Official 0.78.1 ships three status-badge variants — connected, warn and error — and each carries its own colour class. The capture only ever saw the connected variant, so the companion CSS lifted one rule of the three: the warning and the error badge resolved nowhere inside the plugins dialog and rendered with the inherited text colour. The companion now lifts official's warning and error rules verbatim, and the dialog's theme tokens declare the two colours those rules read. Upstream 0.18's palette defines neither, and the 0.75.5 group-author CSS declares both under .rg0755-colors, a scope the dialog is not inside. The three variant classes move into the ported-class set, so the build now fails closed when one of them resolves nowhere instead of shipping a colourless badge. Official's values: light #aa6300 and #cd2834, dark #e28600 and #ff6463. The light column is official's own :root,.sand-1vbcnu7 palette block, the dark column is the running official app's cursor-dark theme. --- .../marketplace-dialog-0781-theme-tokens.css | 10 +++++-- ...marketplace-manage-page-0781-css-patch.mjs | 13 ++++++++- .../marketplace-manage-page-0781-official.css | 28 +++++++++++-------- ...fficial-0781-marketplace-theme-tokens.json | 8 ++++++ 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/scripts/lib/marketplace-dialog-0781-theme-tokens.css b/scripts/lib/marketplace-dialog-0781-theme-tokens.css index 5559f16d..6376678c 100644 --- a/scripts/lib/marketplace-dialog-0781-theme-tokens.css +++ b/scripts/lib/marketplace-dialog-0781-theme-tokens.css @@ -11,6 +11,12 @@ * .sand-fc7y3v → font-size: var(--cursor-font-size-lg) (section title) * .ui-f1vpex → background-color: var(--cursor-bg-elevated) (dialog surface) * + * The second-level page's status badge reads three more, one per variant: + * + * .sand-1ufr4hg → color: var(--sand-text-green-primary) (badge, connected) + * .sand-132xtu3 → color: var(--sand-text-yellow-primary) (badge, warning) + * .sand-wtpwev → color: var(--sand-text-red-primary) (badge, error) + * * Official 0.78.1 resolves those tokens from its runtime theme palette, which is newer than the * one upstream 0.18 ships: upstream's palette has no `--sand-bg-primary`, no `--sand-bg-secondary`, * no `--sand-text-inverted` and an older `--sand-text-primary` (#fcfcfc vs official's #f5f5f5). @@ -72,5 +78,5 @@ * guaranteed-invalid value: upstream defines them, official does not, and a defined token would * otherwise render where official renders nothing. */ -.sand-plugins-dialog{--sand-bg-elevated:#fff;--sand-bg-elevated-hover:#f1f1f1;--sand-bg-chrome:#fcfcfc;--sand-bg-sidebar:#f5f5f5;--sand-bg-luminous:#fff;--sand-bg-primary:#1c1c1c;--sand-bg-primary-hover:#363636;--sand-bg-secondary:#1c1c1c0f;--sand-bg-secondary-hover:#1c1c1c1a;--sand-bg-hover:#1c1c1c0f;--sand-bg-pane:#fff;--sand-bg-accent-primary:#1680f3;--sand-text-primary:#1c1c1c;--sand-text-secondary:#1c1c1cad;--sand-text-tertiary:#1c1c1c8f;--sand-text-inverted:#fcfcfc;--sand-text-on-primary:#fff;--sand-text-accent-primary:#0b6dd3;--sand-text-green-primary:#00854a;--sand-icon-primary:#1c1c1ceb;--sand-icon-secondary:#1c1c1ca8;--sand-icon-tertiary:#1c1c1c85;--sand-icon-accent-primary:#1680f3;--sand-border-focus-primary:transparent;--sand-border-tertiary-translucent:#1c1c1c14;--sand-border-secondary-translucent:#1c1c1c1f;--sand-border-primary-translucent:#1c1c1c3d;--sand-font-size-xxs:11px;--sand-font-size-xs:12px;--sand-font-size-sm:13px;--sand-font-size-base:14px;--sand-font-size-lg:15px;--sand-font-size-xl:17px;--sand-font-size-xxl:22px;--sand-font-size-xxxl:26px;--sand-line-height-xxs:16px;--sand-line-height-xs:16px;--sand-line-height-sm:18px;--sand-line-height-base:20px;--sand-line-height-lg:22px;--sand-line-height-xl:24px;--sand-line-height-xxl:28px;--sand-letter-spacing-xxs:0.01em;--sand-letter-spacing-xs:0.01em;--sand-letter-spacing-sm:0.015em;--sand-letter-spacing-base:0.01em;--sand-letter-spacing-lg:0.01em;--sand-letter-spacing-xl:0.01em;--sand-letter-spacing-xxl:0.01em;--sand-font-weight-regular:425;--sand-font-weight-medium:500;--sand-font-weight-semibold:600;--sand-text-disabled:initial;--sand-fill-ghost-hover:initial;--sand-border-focus:initial;--cursor-easing-default:initial;--cursor-font-size-xs:11px;--cursor-font-size-sm:12px;--cursor-font-size-base:13px;--cursor-font-size-lg:14px;--cursor-bg-elevated:var(--sand-bg-elevated);--cursor-bg-editor:var(--sand-bg-pane);--cursor-bg-input:var(--sand-bg-elevated);--cursor-bg-secondary:var(--sand-bg-secondary-hover);--cursor-bg-tertiary:var(--sand-bg-secondary);--cursor-bg-quaternary:var(--sand-bg-secondary);--cursor-text-primary:var(--sand-text-primary);--cursor-text-secondary:var(--sand-text-secondary);--cursor-text-tertiary:var(--sand-text-tertiary);--cursor-text-invert:var(--sand-text-on-primary);--cursor-text-link:var(--sand-text-accent-primary);--cursor-icon-primary:var(--sand-icon-primary);--cursor-icon-secondary:var(--sand-icon-secondary);--cursor-icon-tertiary:var(--sand-icon-tertiary);--cursor-icon-accent-primary:var(--sand-icon-accent-primary);--cursor-accent-color:var(--sand-bg-accent-primary);--cursor-stroke-primary:var(--sand-border-primary-translucent);--cursor-stroke-secondary:var(--sand-border-secondary-translucent);--cursor-stroke-tertiary:var(--sand-border-tertiary-translucent);--cursor-stroke-focused:var(--sand-border-focus-primary);--cursor-font-weight-normal:var(--sand-font-weight-regular);--cursor-font-weight-regular:var(--sand-font-weight-regular)} -html[data-theme="cursor-dark"] .sand-plugins-dialog{--sand-bg-elevated:#1f1f1f;--sand-bg-elevated-hover:#2c2c2c;--sand-bg-chrome:#121212;--sand-bg-sidebar:#0e0e0e;--sand-bg-luminous:#f5f5f5;--sand-bg-primary:#f5f5f5;--sand-bg-primary-hover:#dbdbdb;--sand-bg-secondary:#f5f5f50a;--sand-bg-secondary-hover:#f5f5f514;--sand-bg-hover:#f5f5f50f;--sand-bg-pane:#161616;--sand-bg-accent-primary:#116dd2;--sand-text-primary:#f5f5f5;--sand-text-secondary:#f5f5f5a8;--sand-text-tertiary:#f5f5f58a;--sand-text-inverted:#121212;--sand-text-on-primary:#f5f5f5;--sand-text-accent-primary:#4d9bff;--sand-text-green-primary:#00ba69;--sand-icon-primary:#f5f5f5e6;--sand-icon-secondary:#f5f5f5a3;--sand-icon-tertiary:#f5f5f580;--sand-icon-accent-primary:#116dd2;--sand-border-focus-primary:transparent;--sand-border-tertiary-translucent:#f5f5f50f;--sand-border-secondary-translucent:#f5f5f51a;--sand-border-primary-translucent:#f5f5f538} +.sand-plugins-dialog{--sand-bg-elevated:#fff;--sand-bg-elevated-hover:#f1f1f1;--sand-bg-chrome:#fcfcfc;--sand-bg-sidebar:#f5f5f5;--sand-bg-luminous:#fff;--sand-bg-primary:#1c1c1c;--sand-bg-primary-hover:#363636;--sand-bg-secondary:#1c1c1c0f;--sand-bg-secondary-hover:#1c1c1c1a;--sand-bg-hover:#1c1c1c0f;--sand-bg-pane:#fff;--sand-bg-accent-primary:#1680f3;--sand-text-primary:#1c1c1c;--sand-text-secondary:#1c1c1cad;--sand-text-tertiary:#1c1c1c8f;--sand-text-inverted:#fcfcfc;--sand-text-on-primary:#fff;--sand-text-accent-primary:#0b6dd3;--sand-text-green-primary:#00854a;--sand-text-yellow-primary:#aa6300;--sand-text-red-primary:#cd2834;--sand-icon-primary:#1c1c1ceb;--sand-icon-secondary:#1c1c1ca8;--sand-icon-tertiary:#1c1c1c85;--sand-icon-accent-primary:#1680f3;--sand-border-focus-primary:transparent;--sand-border-tertiary-translucent:#1c1c1c14;--sand-border-secondary-translucent:#1c1c1c1f;--sand-border-primary-translucent:#1c1c1c3d;--sand-font-size-xxs:11px;--sand-font-size-xs:12px;--sand-font-size-sm:13px;--sand-font-size-base:14px;--sand-font-size-lg:15px;--sand-font-size-xl:17px;--sand-font-size-xxl:22px;--sand-font-size-xxxl:26px;--sand-line-height-xxs:16px;--sand-line-height-xs:16px;--sand-line-height-sm:18px;--sand-line-height-base:20px;--sand-line-height-lg:22px;--sand-line-height-xl:24px;--sand-line-height-xxl:28px;--sand-letter-spacing-xxs:0.01em;--sand-letter-spacing-xs:0.01em;--sand-letter-spacing-sm:0.015em;--sand-letter-spacing-base:0.01em;--sand-letter-spacing-lg:0.01em;--sand-letter-spacing-xl:0.01em;--sand-letter-spacing-xxl:0.01em;--sand-font-weight-regular:425;--sand-font-weight-medium:500;--sand-font-weight-semibold:600;--sand-text-disabled:initial;--sand-fill-ghost-hover:initial;--sand-border-focus:initial;--cursor-easing-default:initial;--cursor-font-size-xs:11px;--cursor-font-size-sm:12px;--cursor-font-size-base:13px;--cursor-font-size-lg:14px;--cursor-bg-elevated:var(--sand-bg-elevated);--cursor-bg-editor:var(--sand-bg-pane);--cursor-bg-input:var(--sand-bg-elevated);--cursor-bg-secondary:var(--sand-bg-secondary-hover);--cursor-bg-tertiary:var(--sand-bg-secondary);--cursor-bg-quaternary:var(--sand-bg-secondary);--cursor-text-primary:var(--sand-text-primary);--cursor-text-secondary:var(--sand-text-secondary);--cursor-text-tertiary:var(--sand-text-tertiary);--cursor-text-invert:var(--sand-text-on-primary);--cursor-text-link:var(--sand-text-accent-primary);--cursor-icon-primary:var(--sand-icon-primary);--cursor-icon-secondary:var(--sand-icon-secondary);--cursor-icon-tertiary:var(--sand-icon-tertiary);--cursor-icon-accent-primary:var(--sand-icon-accent-primary);--cursor-accent-color:var(--sand-bg-accent-primary);--cursor-stroke-primary:var(--sand-border-primary-translucent);--cursor-stroke-secondary:var(--sand-border-secondary-translucent);--cursor-stroke-tertiary:var(--sand-border-tertiary-translucent);--cursor-stroke-focused:var(--sand-border-focus-primary);--cursor-font-weight-normal:var(--sand-font-weight-regular);--cursor-font-weight-regular:var(--sand-font-weight-regular)} +html[data-theme="cursor-dark"] .sand-plugins-dialog{--sand-bg-elevated:#1f1f1f;--sand-bg-elevated-hover:#2c2c2c;--sand-bg-chrome:#121212;--sand-bg-sidebar:#0e0e0e;--sand-bg-luminous:#f5f5f5;--sand-bg-primary:#f5f5f5;--sand-bg-primary-hover:#dbdbdb;--sand-bg-secondary:#f5f5f50a;--sand-bg-secondary-hover:#f5f5f514;--sand-bg-hover:#f5f5f50f;--sand-bg-pane:#161616;--sand-bg-accent-primary:#116dd2;--sand-text-primary:#f5f5f5;--sand-text-secondary:#f5f5f5a8;--sand-text-tertiary:#f5f5f58a;--sand-text-inverted:#121212;--sand-text-on-primary:#f5f5f5;--sand-text-accent-primary:#4d9bff;--sand-text-green-primary:#00ba69;--sand-text-yellow-primary:#e28600;--sand-text-red-primary:#ff6463;--sand-icon-primary:#f5f5f5e6;--sand-icon-secondary:#f5f5f5a3;--sand-icon-tertiary:#f5f5f580;--sand-icon-accent-primary:#116dd2;--sand-border-focus-primary:transparent;--sand-border-tertiary-translucent:#f5f5f50f;--sand-border-secondary-translucent:#f5f5f51a;--sand-border-primary-translucent:#f5f5f538} diff --git a/scripts/lib/marketplace-manage-page-0781-css-patch.mjs b/scripts/lib/marketplace-manage-page-0781-css-patch.mjs index e8dcd6da..3b3e7f21 100644 --- a/scripts/lib/marketplace-manage-page-0781-css-patch.mjs +++ b/scripts/lib/marketplace-manage-page-0781-css-patch.mjs @@ -54,6 +54,7 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { readdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; +import { MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES } from "./marketplace-manage-page-0781-patch.mjs"; const OFFICIAL_CSS_FILE = "marketplace-manage-page-0781-official.css"; const OFFICIAL_STYLESHEET = new URL( @@ -411,7 +412,14 @@ export const MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES = (() => { return Object.freeze(official); })(); -/** The class names the port applies, read out of the measured fixture. */ +/** + * The class names the port applies. + * + * The measured fixture carries the recipes the capture saw, and the port's own declared + * recipes carry the rest — the status-badge variants in particular, because the capture only + * ever saw the connected one. Every class here must resolve inside the plugins dialog once + * the companion CSS has run; a class that resolves nowhere fails the build. + */ export const MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES = (() => { const fixture = JSON.parse(readFileSync(RECIPES_FIXTURE, "utf8")); const classes = new Set(); @@ -427,6 +435,9 @@ export const MARKETPLACE_MANAGE_PAGE_0781_PORTED_CLASSES = (() => { for (const value of Object.values(SPEC_MEASUREMENTS)) { for (const name of value.split(/\s+/)) if (name) classes.add(name); } + for (const value of Object.values(MARKETPLACE_MANAGE_PAGE_0781_STATUS_VARIANT_CLASSES)) { + classes.add(value); + } return Object.freeze([...classes].sort()); })(); diff --git a/scripts/lib/marketplace-manage-page-0781-official.css b/scripts/lib/marketplace-manage-page-0781-official.css index 351b0cd1..fb4f6382 100644 --- a/scripts/lib/marketplace-manage-page-0781-official.css +++ b/scripts/lib/marketplace-manage-page-0781-official.css @@ -11,7 +11,7 @@ * is the hash of its declaration block, so a class that exists in one build and not in another is * a NO-OP — the element renders unstyled and every other test stays green. * - * Of the 163 distinct class names the ported markup carries: + * Of the 166 distinct class names the ported markup carries: * * * 110 already resolve in upstream 0.18's stylesheet WITHIN the plugins dialog, with the same * declaration. They are NOT repeated here: re-lifting a rule that upstream already carries @@ -19,14 +19,14 @@ * * 14 are stylix namespace markers or hooks that official itself gives no rule to * (`sand-plugins__yours`, `sand-plugins__group`, `sand-plugins-row`, `sand-kit-icon`, …). * They carry no style on either side and nothing is lifted for them. - * * 40 do not resolve inside the dialog and are lifted here — one official rule each. + * * 42 do not resolve inside the dialog and are lifted here — one official rule each. * - * Of those 40, 39 resolve nowhere in upstream 0.18's stylesheet at all. The 40th is - * `sand-1ufr4hg`, the connected badge's green: upstream 0.18 does not define it at all, but the - * repo's own conversation-computer-card port carries official's rule scoped to - * `.r-conversation-computer-card`, so the staged stylesheet holds the class token and never - * styles it inside the plugins dialog. "The class token appears somewhere in the stylesheet" is - * therefore not the test this file is built on — the scope is. + * Two of those 42 — `sand-1ufr4hg`, the connected badge's green, and `ui-vxqr21`, the regular + * weight — have no rule in upstream 0.18's stylesheet at all, but the STAGED stylesheet does + * carry them: the repo's own conversation-computer-card port carries official's connected-badge + * rule scoped to `.r-conversation-computer-card`, and the 0.75.5 group-author CSS carries the + * unscoped weight. Neither scope reaches the plugins dialog. "The class token appears somewhere + * in the stylesheet" is therefore not the test this file is built on — the scope is. * * ## The `:not(#\#)` guards are part of the rule * @@ -57,10 +57,12 @@ * `--sand-font-size-xl`, `--sand-line-height-xl`, `--sand-letter-spacing-xl`, * `--sand-line-height-sm`, `--sand-letter-spacing-sm`, `--sand-line-height-base`, * `--sand-letter-spacing-base`, `--cursor-font-weight-normal`, `--sand-bg-secondary`, - * `--sand-border-focus-primary`, `--sand-text-green-primary` and `--sand-text-primary` are - * declared on the dialog by `marketplace-dialog-0781-theme-tokens.css`. Upstream 0.18's palette - * does not define them, so an undefined token would make `var()` invalid at computed-value time - * and the property would silently fall back. + * `--sand-border-focus-primary`, `--sand-text-green-primary`, `--sand-text-yellow-primary`, + * `--sand-text-red-primary` and `--sand-text-primary` are declared on the dialog by + * `marketplace-dialog-0781-theme-tokens.css`. Upstream 0.18's palette does not define them, so + * an undefined token would make `var()` invalid at computed-value time and the property would + * silently fall back. The 0.75.5 group-author CSS declares the yellow and red text tokens too, + * but under `.rg0755-colors`, a scope the dialog is not inside. * * @evidence official 0.78.1 `rea-grokbot-0781/app/dist/renderer/assets/index-Dh7EWA8t.css` * @evidence upstream 0.18 `src/app/dist/renderer/assets/index-lCyB53CO.css` @@ -94,6 +96,8 @@ :where(.sand-plugins-dialog) .sand-1f9r7lp:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):before,:where(.sand-plugins-dialog) .sand-1w00h3t:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):after{border-radius:16px} :where(.sand-plugins-dialog) .sand-1ybvw9k:focus-visible:not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):not(#\#):after{box-shadow:inset 0 0 0 2px var(--sand-border-focus-primary)} :where(.sand-plugins-dialog) .sand-1ufr4hg:not(#\#):not(#\#):not(#\#){color:var(--sand-text-green-primary)} +:where(.sand-plugins-dialog) .sand-132xtu3:not(#\#):not(#\#):not(#\#){color:var(--sand-text-yellow-primary)} +:where(.sand-plugins-dialog) .sand-wtpwev:not(#\#):not(#\#):not(#\#){color:var(--sand-text-red-primary)} :where(.sand-plugins-dialog) .sand-wpq65r:not(#\#):not(#\#):not(#\#){background-color:color-mix(in srgb,var(--sand-text-primary) 6%,transparent)} :where(.sand-plugins-dialog) .sand-mzvs34:not(#\#):not(#\#):not(#\#){padding-inline-start:8px} :where(.sand-plugins-dialog) .sand-ihq33y:not(#\#):not(#\#):not(#\#){grid-template-rows:0fr} diff --git a/tests/fixtures/official-0781-marketplace-theme-tokens.json b/tests/fixtures/official-0781-marketplace-theme-tokens.json index f3dc3e87..f7c00eda 100644 --- a/tests/fixtures/official-0781-marketplace-theme-tokens.json +++ b/tests/fixtures/official-0781-marketplace-theme-tokens.json @@ -75,6 +75,14 @@ "light": "#00854a", "dark": "#00ba69" }, + "--sand-text-yellow-primary": { + "light": "#aa6300", + "dark": "#e28600" + }, + "--sand-text-red-primary": { + "light": "#cd2834", + "dark": "#ff6463" + }, "--sand-icon-primary": { "light": "#1c1c1ceb", "dark": "#f5f5f5e6" From c3816dc5f7ce4223ebc00f5bb43b1c874e033780 Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 09:09:00 +0800 Subject: [PATCH 5/7] fix(plugins): keep the 0.75 hosts off the marketplace dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second-level page's Installed section is the 0.75.0 wave's host for the repo's "已安装 / 卸载" panel and the custom-MCP form. Official 0.78.1's second-level page carries neither, and official keeps the connector form on a page of its own (qLe/7o, chunk-user-custom-connector-M5UeTEiw.js). The panel is therefore what made this reconstruction's page differ from official's. It also broke the build. The second-level port rewrites the section's children from J.map to (RM0781ManageRowElements=U.map, so the host's anchor no longer occurred and the pack step failed with "Expected one settings panel and one plugins list for the 0.75 hosts". The plugins host goes away. The custom-MCP form moves to the settings host slot, which is a reconstruction surface rather than an official-parity one. The uninstall action is not lost: the plugin detail page upstream already ships carries 卸载, and the desktop plugins controller keeps its own removal path. The content is taken from the sibling session's working tree, which had already made this change. --- .../extensions/port-0750-packaged-hosts.tsx | 37 +++++++---------- .../plugins/overlay/desktop-surface.tsx | 5 --- .../plugins/overlay/port-0750-extras.tsx | 31 ++++++++------ scripts/lib/port-0750-renderer-patch.mjs | 33 ++++++--------- tests/plugins-port-0750-extras.test.mjs | 18 ++------- tests/port-0750-renderer-patch.test.mjs | 40 +++++-------------- 6 files changed, 60 insertions(+), 104 deletions(-) diff --git a/frontend/src/extensions/port-0750-packaged-hosts.tsx b/frontend/src/extensions/port-0750-packaged-hosts.tsx index b5063e63..abc1ecfb 100644 --- a/frontend/src/extensions/port-0750-packaged-hosts.tsx +++ b/frontend/src/extensions/port-0750-packaged-hosts.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; -import type { DesktopBridge, EffectivePlugin } from "../recovered/contracts/desktop-bridge"; +import type { DesktopBridge } from "../recovered/contracts/desktop-bridge"; import { PluginsPort0750Extras } from "../recovered/features/plugins/overlay/port-0750-extras"; function desktopBridge(): Pick | null { @@ -9,32 +9,30 @@ function desktopBridge(): Pick | } /** - * Settings → 通用 no longer mounts anything. The memory-import card that lived here was the - * English 0.75 stopgap; the real import flow is its own Settings tab (memory-import entry), - * and the vault "New card" trigger was a development entry. Both showed up at the bottom of - * 通用. The export stays because the pinned settings chunk references it. + * The settings host slot carries the repo's custom-MCP server form. + * + * It used to be empty: the memory-import card that lived here was the English 0.75 stopgap, + * the real import flow is its own Settings tab, and the vault "New card" trigger was a + * development entry. The form now lives here because official 0.78.1's "连接应用" dialog has + * no custom-connector surface of its own — official keeps it on a page of its own + * (`qLe/7o`, `chunk-user-custom-connector-M5UeTEiw.js`) — and parking the form inside the + * dialog's second-level "管理插件和技能" page is exactly what made that page differ from + * official's. */ export function RPort0750SettingsPack(): ReactNode { - return null; -} - -export function RPort0750PluginsPack(): ReactNode { const bridge = desktopBridge(); - const [plugins, setPlugins] = useState([]); + const [, setRevision] = useState(0); useEffect(() => { if (bridge == null) return; let live = true; - void bridge.mcp.effectivePlugins().then((listed) => { - if (live) setPlugins(listed); + void bridge.mcp.list().then(() => { + if (live) setRevision((value) => value + 1); }).catch(() => { - if (live) setPlugins([]); + if (live) setRevision((value) => value + 1); }); return () => { live = false; }; }, [bridge]); if (bridge == null) return null; - const reload = () => { - void bridge.mcp.effectivePlugins().then(setPlugins).catch(() => setPlugins([])); - }; return ( bridge.mcp.add(request), remove: (serverId) => bridge.mcp.remove(serverId), }} - onChanged={reload} - onRemovePlugin={async (pluginId) => { - await bridge.mcp.uninstallPlugin(pluginId); - reload(); - }} - plugins={plugins} + onChanged={() => { setRevision((value) => value + 1); }} /> ); } diff --git a/frontend/src/recovered/features/plugins/overlay/desktop-surface.tsx b/frontend/src/recovered/features/plugins/overlay/desktop-surface.tsx index 7a33b8cd..5441039c 100644 --- a/frontend/src/recovered/features/plugins/overlay/desktop-surface.tsx +++ b/frontend/src/recovered/features/plugins/overlay/desktop-surface.tsx @@ -333,11 +333,6 @@ export function PluginsDesktopSurface({ bridge, githubAuth, activeAgentId = null remove: (serverId) => bridge.mcp.remove(serverId), }} onChanged={() => { void controller.load(); }} - onRemovePlugin={async (pluginId) => { - await bridge.mcp.uninstallPlugin(pluginId); - await controller.load(); - }} - plugins={snapshot.effectivePlugins} /> } /> diff --git a/frontend/src/recovered/features/plugins/overlay/port-0750-extras.tsx b/frontend/src/recovered/features/plugins/overlay/port-0750-extras.tsx index 492060c8..e32e53a3 100644 --- a/frontend/src/recovered/features/plugins/overlay/port-0750-extras.tsx +++ b/frontend/src/recovered/features/plugins/overlay/port-0750-extras.tsx @@ -1,27 +1,32 @@ import { useState, type ReactNode } from "react"; import { CustomMcpFormHost, type CustomMcpHostBridge } from "../../../../extensions/custom-mcp-form-host"; -import type { EffectivePlugin } from "../../../contracts/desktop-bridge"; -import { EffectivePluginsPanel } from "./effective-plugins-panel"; +/** + * The repo's custom-MCP server form. + * + * Official 0.78.1 has no such form on the "连接应用" dialog's second-level "管理插件和技能" + * page, and it has no custom-connector entry point in the personal dialog at all — official + * keeps the connector form on a page of its own (`custom-plugin`, message id `qLe/7o`, + * `chunk-user-custom-connector-M5UeTEiw.js`). The 0.75.0 wave therefore had nowhere official + * matched, and parked the form inside the dialog's Installed section. + * + * That parking spot is what made the reconstruction's second-level page differ from official's. + * The form is a reconstruction feature, not an official one, so it lives on a reconstruction + * surface instead: the settings host slot (`RPort0750SettingsPack`), which already exists for + * repo-owned cards and is not an official-parity surface. + * + * The repo's "已安装 / 卸载" list (`EffectivePluginsPanel`) goes away with it. Official's + * manage page has no such list, and the uninstall action itself is not lost: the plugin + * **detail** page upstream already ships carries 卸载. + */ export function PluginsPort0750Extras(props: { - readonly plugins: readonly EffectivePlugin[]; readonly mcp: CustomMcpHostBridge; - readonly onRemovePlugin: (pluginId: string) => Promise | void; readonly onChanged?: () => void; }): ReactNode { const [error, setError] = useState(null); return (
{error == null ? null :

{error}

} - { - setError(null); - void Promise.resolve(props.onRemovePlugin(pluginId)).catch((reason: unknown) => { - setError(reason instanceof Error ? reason.message : String(reason)); - }); - }} - plugins={props.plugins} - /> { diff --git a/scripts/lib/port-0750-renderer-patch.mjs b/scripts/lib/port-0750-renderer-patch.mjs index 7acc9a9d..80684b8b 100644 --- a/scripts/lib/port-0750-renderer-patch.mjs +++ b/scripts/lib/port-0750-renderer-patch.mjs @@ -11,15 +11,21 @@ const mark = "/* sand-port-0750-hosts */"; const namespace = "__sandPort0750Hosts"; const settingsAnchor = "a.jsx(Te,{children:a.jsx(Sa,{auth:t})})"; const settingsReplacement = "a.jsx(Te,{children:[a.jsx(Sa,{auth:t}),a.jsx(RPort0750Settings.RPort0750SettingsPack,{})]})"; -const pluginsAnchor = "children:[t.jsx(Ws,{labelledBy:w,children:J.map"; -const pluginsReplacement = "children:[t.jsx(RPort0750Plugins.RPort0750PluginsPack,{}),t.jsx(Ws,{labelledBy:w,children:J.map"; +// The plugins host used to inject the repo's "已安装 / 卸载" panel and its custom-MCP form +// into upstream's Installed section — the "连接应用" dialog's second-level "管理插件和技能" +// page. Official 0.78.1's second-level page carries neither: its installed list is the plain +// plugin grid, and official keeps the connector form on a page of its own (`qLe/7o`, +// `chunk-user-custom-connector-M5UeTEiw.js`). Parking the pack there is what made the +// reconstruction's second-level page differ from official's, so the plugins host is gone: the +// form moved to the settings host slot, and the uninstall action stays on the plugin detail +// page upstream already ships. function replaceOnce(source, before, after, label) { if (source.split(before).length !== 2) throw new Error(`0.75 host ${label} anchor must occur exactly once`); return source.replace(before, after); } -export async function buildPort0750HostBundle(jsxBinding) { +export async function buildPort0750HostBundle() { const result = await build({ absWorkingDir: root, entryPoints: ["frontend/src/extensions/port-0750-packaged-hosts.tsx"], @@ -40,9 +46,7 @@ export async function buildPort0750HostBundle(jsxBinding) { } const code = result.outputFiles[0].text; if (/\b(createRoot|MutationObserver)\b/.test(code)) throw new Error("0.75 host must stay inside the existing React tree"); - const binding = jsxBinding === "settings" ? "de,a" : "C,t"; - const constName = jsxBinding === "settings" ? "RPort0750Settings" : "RPort0750Plugins"; - return `${mark}\nconst ${constName}=((S,p)=>{${code}\nreturn ${namespace};})(${binding});`; + return `${mark}\nconst RPort0750Settings=((S,p)=>{${code}\nreturn ${namespace};})(de,a);`; } export function patchPort0750Settings(source, bundle) { @@ -51,12 +55,6 @@ export function patchPort0750Settings(source, bundle) { return `${replaceOnce(source, settingsAnchor, settingsReplacement, "settings")}\n${bundle}\n`; } -export function patchPort0750Plugins(source, bundle) { - if (!bundle.startsWith(mark)) throw new Error("0.75 plugins bundle is missing"); - if (source.includes("RPort0750PluginsPack")) throw new Error("0.75 plugins host already exists"); - return `${replaceOnce(source, pluginsAnchor, pluginsReplacement, "plugins")}\n${bundle}\n`; -} - export async function applyPort0750RendererPatch({ stageRoot }) { const assets = path.join(stageRoot, "dist/renderer/assets"); const candidates = await Promise.all((await readdir(assets)).filter((name) => name.endsWith(".js")).map(async (name) => ({ @@ -64,15 +62,10 @@ export async function applyPort0750RendererPatch({ stageRoot }) { source: await readFile(path.join(assets, name), "utf8"), }))); const settings = candidates.filter((file) => file.source.includes(settingsAnchor)); - const plugins = candidates.filter((file) => file.source.includes(pluginsAnchor)); - if (settings.length !== 1 || plugins.length !== 1) throw new Error("Expected one settings panel and one plugins list for the 0.75 hosts"); - const [settingsBundle, pluginsBundle] = await Promise.all([ - buildPort0750HostBundle("settings"), - buildPort0750HostBundle("plugins"), - ]); + if (settings.length !== 1) throw new Error("Expected one settings panel for the 0.75 hosts"); + const settingsBundle = await buildPort0750HostBundle(); const changes = [ { ...settings[0], patched: patchPort0750Settings(settings[0].source, settingsBundle) }, - { ...plugins[0], patched: patchPort0750Plugins(plugins[0].source, pluginsBundle) }, ]; for (const change of changes) parse(change.patched, { ecmaVersion: "latest", sourceType: "module" }); const sha256 = (text) => createHash("sha256").update(text).digest("hex"); @@ -89,6 +82,6 @@ export async function applyPort0750RendererPatch({ stageRoot }) { for (const change of changes) await writeFile(path.join(assets, change.name), change.patched); const provenance = { version: 1, feature: "port-0750-hosts", files }; await writeFile(path.join(stageRoot, "dist/renderer-port-0750-hosts.json"), `${JSON.stringify(provenance, null, 2)}\n`); - console.log(`0.75 hosts: vault card, memory import, MCP form, and effective plugins mounted into ${settings[0].name} + ${plugins[0].name}`); + console.log(`0.75 hosts: vault card, memory import and the custom MCP form mounted into ${settings[0].name}`); return provenance; } diff --git a/tests/plugins-port-0750-extras.test.mjs b/tests/plugins-port-0750-extras.test.mjs index 7a58e058..e28fd46c 100644 --- a/tests/plugins-port-0750-extras.test.mjs +++ b/tests/plugins-port-0750-extras.test.mjs @@ -13,10 +13,6 @@ const built = await build({ stdin: { contents: `export {act} from 'react'; expor const ui = await import('data:text/javascript;base64,' + Buffer.from(built.outputFiles[0].text).toString('base64')); const host = document.createElement('div'); document.body.append(host); const root = ui.createRoot(host); -const plugins = [ - { pluginId: 'linear', name: 'linear', displayName: 'Linear', installMode: 'user', isEnabled: true }, - { pluginId: 'team-docs', name: 'team-docs', displayName: 'Team Docs', installMode: 'team-required', isEnabled: true }, -]; const mcp = { list: async () => ({ servers: [] }), add: async () => {}, remove: async () => {} }; async function render(props) { @@ -24,17 +20,11 @@ async function render(props) { await ui.act(async () => { await Promise.resolve(); }); } -test('user plugins uninstall and the custom MCP form is mounted', async () => { - const removed = []; - await render({ plugins, mcp, onRemovePlugin: (id) => { removed.push(id); } }); +test('the custom MCP form is mounted and the uninstall panel is gone', async () => { + await render({ mcp }); assert.match(host.textContent, /MCP Servers/); - const userRow = host.querySelector('[data-plugin-id="linear"]'); - const uninstall = [...userRow.querySelectorAll('button')].find((button) => button.textContent === 'Uninstall'); - assert.ok(uninstall); - await ui.act(async () => uninstall.click()); - assert.deepEqual(removed, ['linear']); - const teamRow = host.querySelector('[data-plugin-id="team-docs"]'); - assert.equal([...teamRow.querySelectorAll('button')].some((button) => button.textContent === 'Uninstall' || button.textContent === 'Remove'), false); + assert.equal(host.querySelector('.sand-effective-plugins-panel'), null); + assert.equal(host.querySelector('[data-plugin-id]'), null); }); test.after(async () => { diff --git a/tests/port-0750-renderer-patch.test.mjs b/tests/port-0750-renderer-patch.test.mjs index deb1d328..7eb17b86 100644 --- a/tests/port-0750-renderer-patch.test.mjs +++ b/tests/port-0750-renderer-patch.test.mjs @@ -8,13 +8,11 @@ import { Window } from "happy-dom"; import { closeLibraryDom } from "./library-dom-teardown.mjs"; import { buildPort0750HostBundle, - patchPort0750Plugins, patchPort0750Settings, } from "../scripts/lib/port-0750-renderer-patch.mjs"; const rendererDir = path.resolve(import.meta.dirname, "../src/app/dist/renderer/assets"); const settingsAnchor = "a.jsx(Te,{children:a.jsx(Sa,{auth:t})})"; -const pluginsAnchor = "children:[t.jsx(Ws,{labelledBy:w,children:J.map"; const dom = new Window({ settings: { disableCSSFileLoading: true } }); globalThis.window = dom; @@ -73,30 +71,21 @@ test.after(() => { closeLibraryDom(dom); }); -test("settings and plugins bundles mount the recovered hosts on the chunk React", async () => { - const [settingsBundle, pluginsBundle] = await Promise.all([ - buildPort0750HostBundle("settings"), - buildPort0750HostBundle("plugins"), - ]); +test("the settings bundle carries the custom MCP form", async () => { + const settingsBundle = await buildPort0750HostBundle(); assert.doesNotMatch(settingsBundle, /Choose file|New card/); + assert.match(settingsBundle, /MCP Servers/); assert.match(settingsBundle, /\(de,a\);$/); - assert.match(pluginsBundle, /Installed/); - assert.match(pluginsBundle, /MCP Servers/); - assert.match(pluginsBundle, /\(C,t\);$/); assert.doesNotMatch(settingsBundle, /node_modules\/react/); - assert.doesNotMatch(pluginsBundle, /createRoot\(/); + assert.doesNotMatch(settingsBundle, /createRoot\(/); const settings = await renderPack(settingsBundle, "RPort0750Settings", { de: React, a: jsxRuntime }); - assert.equal(settings.host.querySelectorAll("button, input, textarea").length, 0); + assert.ok(settings.host.querySelector("[aria-label='MCP Servers']")); + assert.equal(settings.host.querySelector("[aria-label='Installed']"), null); await React.act(async () => { settings.root.unmount(); }); - - const plugins = await renderPack(pluginsBundle, "RPort0750Plugins", { C: React, t: jsxRuntime }); - assert.ok(plugins.host.querySelector("[aria-label='Installed']")); - assert.ok(plugins.host.querySelector("[aria-label='MCP Servers']")); - await React.act(async () => { plugins.root.unmount(); }); }); -test("the patch applies once to the checksum-pinned settings and plugins chunks", async (t) => { +test("the patch applies once to the checksum-pinned settings chunk", async (t) => { let entries; try { entries = await readdir(rendererDir); @@ -105,32 +94,23 @@ test("the patch applies once to the checksum-pinned settings and plugins chunks" return; } let settings = null; - let plugins = null; for (const name of entries.filter((entry) => entry.endsWith(".js"))) { const text = await readFile(path.join(rendererDir, name), "utf8"); if (text.includes(settingsAnchor)) settings = text; - if (text.includes(pluginsAnchor)) plugins = text; } - if (settings == null || plugins == null) { - t.skip("0.18 settings or plugins anchor is absent"); + if (settings == null) { + t.skip("0.18 settings anchor is absent"); return; } - const [settingsBundle, pluginsBundle] = await Promise.all([ - buildPort0750HostBundle("settings"), - buildPort0750HostBundle("plugins"), - ]); + const settingsBundle = await buildPort0750HostBundle("settings"); const generalBefore = 'Q=x==="general"?a.jsx(Te,{children:a.jsx(Sa,{auth:t})}):null'; const generalAfter = 'Q=x==="general"?a.jsx(Te,{children:a.jsx(Sa,{auth:t})}):x==="router"?a.jsx(RRouterPanel,{}):null'; assert.equal(settings.split(generalBefore).length, 2); const routed = settings.replace(generalBefore, generalAfter); assert.equal(routed.split(settingsAnchor).length, 2); const patchedSettings = patchPort0750Settings(routed, settingsBundle); - const patchedPlugins = patchPort0750Plugins(plugins, pluginsBundle); assert.match(patchedSettings, /x==="router"\?a\.jsx\(RRouterPanel,\{\}\)/); assert.match(patchedSettings, /x==="general"\?a\.jsx\(Te,\{children:\[a\.jsx\(Sa,\{auth:t\}\),a\.jsx\(RPort0750Settings\.RPort0750SettingsPack,\{\}\)\]\}\)/); - assert.match(patchedPlugins, /t\.jsx\(RPort0750Plugins\.RPort0750PluginsPack,\{\}\),t\.jsx\(Ws,\{labelledBy:w,children:J\.map/); assert.doesNotThrow(() => parse(patchedSettings, { ecmaVersion: "latest", sourceType: "module" })); - assert.doesNotThrow(() => parse(patchedPlugins, { ecmaVersion: "latest", sourceType: "module" })); assert.throws(() => patchPort0750Settings(patchedSettings, settingsBundle), /already exists/); - assert.throws(() => patchPort0750Plugins(patchedPlugins, pluginsBundle), /already exists/); }); From 0b548c224bef19619f21ec9d6e1e09f2d131fd2b Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 09:13:50 +0800 Subject: [PATCH 6/7] test(marketplace): check the token guard against both themes The guard read the whole token stylesheet, so a token declared only in the dark block counted as declared and the light theme fell back to the initial value without anything going red. Dropping the light yellow token survived the mutation check because the dark one was still there. The guard now reads the light block and the dark block separately: a token the ported rules read must be declared in the light block, and a token whose official value differs between the two themes must be declared in the dark block too. The port record gains the badge-colour measurements on both running apps and the 0.75 host removal. --- docs/MARKETPLACE-0781-PORT.md | 67 +++++++++++++++++++++ tests/marketplace-manage-page-0781.test.mjs | 43 +++++++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/docs/MARKETPLACE-0781-PORT.md b/docs/MARKETPLACE-0781-PORT.md index 80f08483..ed739cd9 100644 --- a/docs/MARKETPLACE-0781-PORT.md +++ b/docs/MARKETPLACE-0781-PORT.md @@ -1051,3 +1051,70 @@ cursor click on the composer's 连接应用 opens the browse dialog, a real curs row and grey 连接 pills on the rows that need authorisation. Official was driven through the same path in the same session and the two pages were compared side by side. +### The status badge's warning and error colours (2026-11-02) + +Official 0.78.1's status badge has three variants — connected, warn and error — and each carries +its own colour class. The capture only ever saw the connected variant, because the live dialog +showed no warning and no failing connector. The port replaced upstream 0.18's three badge recipes +with official's, but the companion CSS lifted the connected variant's rule only, so the warning +and error badges resolved nowhere inside the plugins dialog and rendered with the inherited text +colour. + +Upstream 0.18's stylesheet defines neither `sand-132xtu3` nor `sand-wtpwev` at all, and the +0.75.5 group-author CSS that does declare `--sand-text-yellow-primary` and +`--sand-text-red-primary` scopes them to `.rg0755-colors`, a scope the dialog is not inside. There +were two missing pieces: the rules, and the tokens the rules read. + +Both are official 0.78.1's own bytes. The rules are +`:where(.sand-plugins-dialog) .sand-132xtu3:not(#\#):not(#\#):not(#\#){color:var(--sand-text-yellow-primary)}` +and the same shape for `sand-wtpwev`, lifted verbatim out of official's `index-Dh7EWA8t.css`. The +tokens are declared on the dialog subtree: light `#aa6300` and `#cd2834`, dark `#e28600` and +`#ff6463`. The light pair is official's own `:root,.sand-1vbcnu7` palette block, and the dark pair +is the running official app's `cursor-dark` theme. + +The three variant classes now sit in the ported-class set, so the CSS patch fails the build when +one of them resolves nowhere. The guard in `tests/marketplace-manage-page-0781.test.mjs` walks all +three variants instead of the connected one alone, and a second guard asserts that every token the +ported rules read is declared in the dialog's light block and, where official's two themes differ, +in its dark block too. An undeclared `var()` is invalid at computed-value time, so the rule falls +back to its initial value without failing anything else. + +Four mutants confirm the guards bite: dropping the warning rule, dropping the light yellow token, +dropping the dark yellow token, and taking the variant classes out of the ported-class set each +turn the suite red. + +Measured on the running apps, with a real badge cloned and its variant class swapped the way the +port does, the computed colour is the same on both sides: + +| variant | official 0.78.1 | Grok Node | +| --- | --- | --- | +| connected | `rgb(0, 186, 105)` | `rgb(0, 186, 105)` | +| warn | `rgb(226, 134, 0)` | `rgb(226, 134, 0)` | +| error | `rgb(255, 100, 99)` | `rgb(255, 100, 99)` | + +### The 0.75 hosts leave the dialog's second-level page (2026-11-02) + +The second-level page's Installed section was the 0.75.0 wave's host for the repo's +"已安装 / 卸载" panel and the custom-MCP form. Official 0.78.1's second-level page carries +neither, and official keeps the connector form on a page of its own (`qLe/7o`, +`chunk-user-custom-connector-M5UeTEiw.js`). The panel is therefore what made this +reconstruction's page differ from official's. + +It also broke the build. The second-level port rewrites the section's children from `J.map` to +`(RM0781ManageRowElements=U.map`, so the host's anchor stopped occurring and the pack step failed +with `Expected one settings panel and one plugins list for the 0.75 hosts`. The CI workflow never +packages, so it could not see this. + +The plugins host goes away. The custom-MCP form moves to the settings host slot, which is a +reconstruction surface rather than an official-parity one. The uninstall action is not lost: the +plugin detail page upstream already ships carries 卸载, and the desktop plugins controller keeps +its own removal path. + +### Deployment record (2026-11-02) + +The packaged build (`app.asar` sha256 +`b2803efb37b4fd42f9292520153550236cf9fcfc2c8dc10f551c09f6d272272b`) was deployed to +`/Applications/Grok Node.app` with a full replace, and the deployed asar's sha256 equals the +freshly built asar's. The three badge variants measure the same as official's, as the table above +records. + diff --git a/tests/marketplace-manage-page-0781.test.mjs b/tests/marketplace-manage-page-0781.test.mjs index feb071ef..9ce3bb5b 100644 --- a/tests/marketplace-manage-page-0781.test.mjs +++ b/tests/marketplace-manage-page-0781.test.mjs @@ -328,18 +328,51 @@ test("every token the ported rules read is declared on the dialog", () => { path.join(repo, "scripts/lib/marketplace-dialog-0781-theme-tokens.css"), "utf8", ); - const declared = new Set([...css.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)].map((m) => m[1])); + const light = css.match(/^\.sand-plugins-dialog\{(.+)\}$/m); + const dark = css.match(/^html\[data-theme="cursor-dark"\] \.sand-plugins-dialog\{(.+)\}$/m); + assert.ok(light != null, "the light token block must be scoped to the dialog"); + assert.ok(dark != null, "the dark token block must be scoped to the dialog under cursor-dark"); + const declared = (body) => + new Set([...body.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)].map((m) => m[1])); + const lightTokens = declared(light[1]); + const darkTokens = declared(dark[1]); + const official = JSON.parse( + readFileSync( + path.join(repo, "tests/fixtures/official-0781-marketplace-theme-tokens.json"), + "utf8", + ), + ).tokens; + const referenced = new Set(); for (const rule of Object.values(MARKETPLACE_MANAGE_PAGE_0781_OFFICIAL_RULES)) { for (const match of rule.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) referenced.add(match[1]); } assert.ok(referenced.size > 0, "the ported rules must read at least one token"); - const missing = [...referenced].filter((token) => !declared.has(token)).sort(); + + // The light block carries the base value. A token declared only in the dark block resolves in + // the dark theme and nowhere else, so the light theme falls back to the initial value. + const missingLight = [...referenced].filter((token) => !lightTokens.has(token)).sort(); + assert.deepEqual( + missingLight, + [], + "these tokens are read by the ported rules but not declared in the dialog's light block, so " + + "they resolve to an invalid value at computed-value time and the rule renders its initial " + + "value", + ); + + // The dark theme overrides what it must. A token whose official value differs between the two + // themes has to be declared in the dark block, or the dark theme keeps the light one. + const missingDark = [...referenced] + .filter((token) => { + const captured = official[token]; + return captured != null && captured.light !== captured.dark && !darkTokens.has(token); + }) + .sort(); assert.deepEqual( - missing, + missingDark, [], - "these tokens are read by the ported rules but not declared on the dialog, so they resolve to " + - "an invalid value at computed-value time and the rule renders its initial value", + "these tokens differ between official's two themes, so the dialog's dark block must declare " + + "them, or the dark theme renders the light value", ); }); From aa707ae425253f5210b3ddfa24f3c3f1117f6461 Mon Sep 17 00:00:00 2001 From: Tian Date: Mon, 2 Nov 2026 09:34:25 +0800 Subject: [PATCH 7/7] docs(marketplace): record the keyboard focus ring as a known difference The independent verifier found that the second-level page's back button draws a pale ring on keyboard focus where official draws a blue one, and measured it on both running apps: official rgb(24, 132, 252), this build rgba(245, 245, 245, 0.66), both 2 px solid. The cause is two earlier waves, not this port: the app-link-card port writes an unscoped `.sand-1wpm9rq:focus-visible{outline-color:var(--sand-border-focus)}`, and the dialog resets `--sand-border-focus` to `initial` because official does not define it. Official's own rule for that class is skipped by the marketplace companions, because the app-link rule makes the class look already resolved. Official's blue reaches the button through a `[data-sand-tone="dark"]` subtree in its runtime theme stylesheet, while the dialog element itself computes `transparent`, so declaring the blue on the dialog would make every ring in the dialog blue instead of only that subtree's. That is its own wave. --- docs/MARKETPLACE-0781-PORT.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/MARKETPLACE-0781-PORT.md b/docs/MARKETPLACE-0781-PORT.md index ed739cd9..45fc61f4 100644 --- a/docs/MARKETPLACE-0781-PORT.md +++ b/docs/MARKETPLACE-0781-PORT.md @@ -693,6 +693,35 @@ that fall out of every chip bucket, plus official's two built-in cursor plugins) of "eleven sections here" was a measurement artifact: the catalogue loads asynchronously, and the reading was taken before it had arrived. +**The keyboard focus ring is not official's.** Official 0.78.1's +`.sand-1wpm9rq:focus-visible{outline-color:var(--sand-border-focus-primary)}` resolves to +`#1884fc` on the second-level page's back button, and the rendered outline is a 2 px solid blue +ring. This build renders the same button's ring in the inherited text colour, +`rgba(245, 245, 245, 0.66)`, because two earlier waves left a different rule in front of +official's. The repository's app-link-card port writes an unscoped +`.sand-1wpm9rq:focus-visible{outline-color:var(--sand-border-focus)}`, and the dialog resets +`--sand-border-focus` to `initial` because official does not define it, so that rule falls back to +`currentcolor`. Official's own rule is never reached, because the marketplace companions skip a +rule whose class "already resolves in the dialog" — and the app-link rule makes it look resolved. + +Official's rule is in the companion here, and it is skipped for the same reason. Fixing it is not a +one-line change to the companion: official's blue comes from its runtime theme stylesheet +(`