diff --git a/docs/MARKETPLACE-0781-PORT.md b/docs/MARKETPLACE-0781-PORT.md index 9cd1545..65cca1c 100644 --- a/docs/MARKETPLACE-0781-PORT.md +++ b/docs/MARKETPLACE-0781-PORT.md @@ -1373,3 +1373,246 @@ through 连接应用, 已连接 N 个, 查看全部 and a plugin row with real m | `"(RLocT("` quote-bug chunks | 0 | | the detail page's sections in the driven build | 账户, 工具, 配置, 应用, 信息 | +## The tool rows' switch (2026-11-02) + +### What was different + +Official 0.78.1's plugin detail page draws each tool row's on/off switch with its own Switch +component. Its checked track is accent-primary and its knob is luminous. Upstream 0.18's plugin +detail page draws the same switch with 0.18's own shared Switch component, whose checked track is +`--sand-fill-control-checked`. + +Measured over CDP on the two apps running side by side, with the Gmail detail page open and the +工具 list expanded, both switches report `aria-checked="true"`, a 26x16 track and a 12x12 knob. +Only the recipes differ: + +| property | official 0.78.1 | this build before the fix | +|---|---|---| +| track background | `rgb(17, 109, 210)` (`--sand-bg-accent-primary`) | `rgb(90, 90, 90)` (`--sand-fill-control-checked`) | +| track box-shadow | `rgba(245, 245, 245, 0.04) 0px 0px 0px 0.5px inset` | none | +| track border | none | `0.5px solid` `--sand-border-subtle` | +| knob background | `rgb(245, 245, 245)` (`--sand-bg-luminous`) | `rgb(252, 252, 252)` (`--sand-text-on-color`) | +| knob box-shadow | `rgba(245, 245, 245, 0) 0px 1px 3px 0px` | `rgba(0, 0, 0, 0) 0px 1px 3px 0px` | +| knob placement | `inset-inline-start: 2px` off, `12px` on | `left: 1.5px` plus `translate(10px)` on | + +The rebuilt app's switch therefore rendered grey while official's rendered blue, on a row that +carries official's own label recipe and official's own row recipe. + +### Why the shared switch could not be reused + +Official 0.78.1's Switch is the component `SD` in +`rea-grokbot-0781/app/dist/renderer/assets/index.eager-app-D1juOWZp.js`, with its knob recipes in +`xD` and a sixteen-entry track recipe table indexed by +`(size === "md") << 3 | isChecked << 2 | isDisabled << 1 | (isDisabled && isChecked)`. + +Upstream 0.18's shared switch is `Hlt` in `index-UbX-y3il.js` (exported as `bU`, imported into the +detail chunk as `Kn`). Its props are +`{label, labelledBy, isChecked, isDisabled, size, onToggle}` and it accepts no className, so +official's recipe cannot be passed through it. Rewriting the shared component's recipe table would +have changed every switch in the whole app, which is outside this port's scope. + +That scope is empty on both sides. Measured over CDP, this build's **only** `[role="switch"]` in the +whole app is the detail page's tool row — the browse dialog and the 管理插件和技能 page carry none, +and this build's conversation pane carries none either, where official's has eight schedule-pause +switches in its `md` size. So the detail page's tool rows are the only surface where the two apps +have a switch at all, and it is the only one that differs. + +### What the port writes + +The detail view patch replaces the one call site that renders the tool row's switch with an +injected component that carries official's own class strings. The anchor is upstream 0.18's +`view-B5Ug8wEm.js`, the second of its two `t.jsx(Kn,{` occurrences (the first is the manage +page's workflow row, which PR 26 already replaced with `trailing:null`), so the patch fails closed +if the anchor ever goes missing or ambiguous. + +| surface | official 0.78.1's recipe | +|---|---| +| track, checked | the table's index 4 (`sm` + checked), whose `sand-1cgqir` declares `background-color: var(--sand-bg-accent-primary)` | +| track, unchecked | the table's index 0 (`sm`), whose `sand-fwqdrz` declares `background-color: var(--sand-bg-secondary)` | +| knob, checked | official's `xD.knob` + `xD.knobSm` + `xD.knobSmChecked`, merged the way official's own `so()` merges them | +| knob, unchecked | official's `xD.knob` + `xD.knobSm` | + +Only the two `sm`-and-enabled entries are reachable: upstream's tool-row call site passes no +`isDisabled`, and official's `SD` renders its disabled variants only when `isDisabled` is set. The +disabled recipes are not ported, because nothing can reach them. + +All four class strings are byte-exact, and each is verified twice over: against official's own +`SD` recipe table in `index.eager-app-D1juOWZp.js`, and against the class attribute on the +running official app's live switch. Official's merge moves a key that a later recipe overrides to +the end of the class string, which is why the checked knob carries `sand-1r4y97` at the end and +not `sand-aoy8p5`. + +The unchecked pair is not observable live — all 25 of Gmail's tools are enabled — so it comes from +official's own recipe table alone. It was still checked on screen, without touching either app's +state: the live switch is cloned, official's own OFF class strings are swapped onto the clone, the +clone is inserted next to the real one and its computed styles are read. The same official class +strings are applied in both apps, so the two readings have to agree — that proves the shipped CSS +and the dialog's token scope resolve official's OFF recipe identically on both sides. + +### The DOM contract is unchanged + +Both sides render the same element, with the same five attributes and the same child: + +``` + +``` + +so the injected component adds no attribute and no class. Official's class string is passed +straight through as `className` rather than through upstream's `k()` helper, because `k(name, +recipe)` renders `name recipe` — that helper would have put a marker class in front of official's +string and broken the byte-for-byte match. The guard finds the element through +`[role="switch"]` inside `.sand-plugins-detail__tools` instead. + +`onToggle` keeps upstream's contract: `onToggle(!isChecked)`, which is also official's `SD` +contract (`c(!i)`). + +### The rules that had to be lifted + +Of the class tokens the four recipes use, 13 are declared by upstream 0.18's stylesheet with the +identical declaration, 35 more are already declared by one of this repository's own official CSS +companions, and 5 are declared by neither. Those 5 are copied verbatim from official 0.78.1's +stylesheet: + +| class | official's declaration | +|---|---| +| `sand-2k1e5v` | `box-shadow: inset 0 0 0 .5px var(--sand-border-quaternary-translucent)` | +| `sand-lrd64x` | `min-width: 26px` | +| `sand-1akln4p` | `max-height: 16px` | +| `sand-1gdvyqu` | `box-shadow: 0 1px 3px 0 var(--sand-shadow-control)` | +| `sand-1r4y97` | `inset-inline-start: 12px` | + +The CSS patch appends only the rules upstream's stylesheet does not already carry, compares whole +lines, and fails closed when any of them is not present as a standalone rule. + +### The two tokens the dialog did not have + +The switch's checked track reads `--sand-bg-accent-primary` and its knob reads `--sand-bg-luminous`. +The dialog subtree already declares both, with official's own values: `#1680f3` and `#fff` in the +light block, `#116dd2` and `#f5f5f5` in the `cursor-dark` block. Those are exactly the values +official computes on its live dialog, so the ported switch comes out the same colour. + +Five more tokens the switch reads were missing from the dialog scope, and all five are declared +now, with official's own values: + +| token | what reads it | light | `cursor-dark` | +|---|---|---|---| +| `--sand-border-quaternary-translucent` | the track's 0.5px inset rim | `#1c1c1c0f` | `#f5f5f50a` | +| `--sand-shadow-control` | the knob's drop shadow | `#1c1c1c1f` | `#f5f5f500` | +| `--sand-bg-accent-primary-hover` | the track's hover state | `#126fd4` | `#1784fb` | +| `--sand-bg-accent-primary-disabled` | the track's disabled state | `#1680f32b` | `#116dd226` | +| `--sand-bg-secondary-disabled` | the off track's disabled state | `#1c1c1c0a` | `#f5f5f505` | + +`--sand-border-quaternary-translucent` is the reason the rim matters: upstream 0.18 declares that +token only inside the computer-card patch's own scope, which the dialog is not inside, so without +the row the value would be invalid at computed-value time and the track would render +`box-shadow: none` instead of official's 0.5px inset rim. + +`--sand-shadow-control` is the reason the knob's shadow matters. Upstream 0.18 declares it at +`:root` as `#0000001f`, but the running 0.18 app computes it as `#00000000` in the `cursor-dark` +theme, while official computes `#f5f5f500` — so the knob's shadow resolves to a different colour +than official's without the row. + +`--sand-bg-accent-primary-hover` is the reason the hover state matters. The track's hover rule is +`.sand-1moagsb:hover{background-color:var(--sand-bg-accent-primary-hover)}` and upstream 0.18 +declares no such token, so without the row the rule would be invalid at computed-value time and the +track's background would compute to `transparent` while the pointer is over it. + +The three disabled and hover tokens are safe to declare: **no upstream 0.18 rule reads any of +them** (0 matches in `index-lCyB53CO.css`), so they cannot change any other surface inside the +dialog. Upstream 0.18's own stylesheet declares none of the three either. This repository's +routine-surface companion does declare them, but inside the conversation info pane's own scope +(`.sand-info-pane__inner`), which the plugins dialog is not inside, so they never reached the +switch. A sixth token the switch's recipes read, `--sand-bg-secondary-hover`, was already declared +in both blocks. + +Both values are official's own. The light column is official's `:root,.sand-1vbcnu7` palette block +in `index-Dh7EWA8t.css`; the dark column is official's computed value on the live dialog. + +### The tool row's label + +Official builds the tool row's switch label from two locale ids, read out of official's own +`chunk-plugin-detail-view-KNB3B4mm.js`: + +| id | English | zh-Hans | when | +|---|---|---|---| +| `y5UgU8` | `Disable {0}` | `停用 {0}` | the tool is enabled, so the action is to disable it | +| `FZtBeR` | `Enable {0}` | `启用 {0}` | the tool is disabled, so the action is to enable it | + +The live official app agrees: the enabled Gmail tool `Get me` carries `aria-label="停用 Get me"`. + +This build rendered only one half of that. The repository's own `extra-i18n-patch.mjs` localises the +literal `"Enable"`, but there is no `"Disable"` entry, so in the Chinese UI the enabled tool read +`Disable Create draft` while official read `停用 Get me`. The port now localises the other half +too, through the same `RLocT(english, chinese)` helper the extra-i18n patch already uses, and the +tool name stays upstream's own expression — the same one official uses. +### The commits + +The wave is two commits, and the history shows the defect before the fix: + +| commit | what it is | the detail-page suite | +|---|---|---| +| `32e6ba3` | the guards only | 42 tests, 37 pass, **5 fail** — the five new switch guards | +| `b4c18f2` | the fix — the patch, the companion CSS and the token scope | 42 tests, **42 pass**, 0 fail | + +The five guards that are red at `32e6ba3` are the tool-row switch's own recipes, its label, the +companion's five rules, the mutation that swaps official's recipes back to upstream's, and the +token scope. + +### Deployment record (2026-11-02) + +| check | result | +|---|---| +| packaged `app.asar` sha256 | `e921d4e4b085f3a8ccd32c78a63009e7c642dc219d6eb42dd86dded12607da9c` | +| deployed `/Applications/Grok Node.app` asar sha256 | the same | +| the switch in the deployed `app.asar` | official's track and knob class strings present, upstream's `sand-1jq8sgu` gone | +| official-only switch CSS rules in the deployed stylesheet | 5 of 5, standalone and byte-for-byte official | +| shipped renderer chunks that parse | 102 of 102 | +| `"(RLocT("` quote-bug chunks | 0 | +| the whole repository's offline suite | 2737 tests, 2703 pass, 0 fail, 34 skipped | +| the detail-page suite | 42 tests, 42 pass, 0 fail | + +Driven against the two apps running side by side, with the Gmail detail page open and the 工具 list +expanded, the deployed build's switch and official's agree on **every** measured property, in the +checked state and in the unchecked state reached by the clone: + +| property | official 0.78.1 | this build after the fix | +|---|---|---| +| track class | official's own, 41 classes | byte-for-byte the same | +| knob class | official's own, 12 classes | byte-for-byte the same | +| track size | 26×16 | 26×16 | +| knob size | 12×12 | 12×12 | +| track background | `rgb(17, 109, 210)` | `rgb(17, 109, 210)` | +| track box-shadow | `rgba(245, 245, 245, 0.04) 0px 0px 0px 0.5px inset` | the same | +| track border | `0px none` | `0px none` | +| knob background | `rgb(245, 245, 245)` | `rgb(245, 245, 245)` | +| knob box-shadow | `rgba(245, 245, 245, 0) 0px 1px 3px 0px` | the same | +| knob `inset-inline-start`, on | `12px` | `12px` | +| track background, off | `rgba(245, 245, 245, 0.04)` | `rgba(245, 245, 245, 0.04)` | +| knob `inset-inline-start`, off | `2px` | `2px` | +| `aria-label` | `停用 Get me` | `停用 Create draft` — the same official wording, on this build's own tool | + +Sixteen of sixteen measured properties match, on both states. + +The two screenshots of the switch itself — the same crop of the live switch in each app — are +**byte-for-byte identical**: + +``` +ec247f09f4a69bf1fc4ee671933d6adadb99b400f34750bb1f410d366326402f switch-official.png +ec247f09f4a69bf1fc4ee671933d6adadb99b400f34750bb1f410d366326402f switch-node.png +``` + +### What was not exercised, and why + +The switch was **not clicked**. Clicking it toggles a tool's permission mode on the installed +plugin, which is real state on the user's machine, and the wave had no reason to change it. The +wiring was verified without clicking instead: React's own props on the deployed button carry an +`onClick` whose source is `()=>onToggle(!on)`, which is upstream 0.18's contract for the same +control (`()=>l(!r)`), and the `onToggle` and `isChecked` props the port passes are byte-identical +to the ones upstream passed. The button's `role`, `type` and `aria-checked` props and the knob's +`aria-hidden` prop were read the same way. + +One committed tool, `scripts/verify-marketplace-detail-parity.mjs`, does not run against this +build: it opens the detail row by an `aria-label` prefix that now matches the account-menu entry, +so it measures the wrong page and reports 0 of 3. That predates this wave and was left alone. diff --git a/scripts/lib/marketplace-detail-view-0781-official.css b/scripts/lib/marketplace-detail-view-0781-official.css index 60d83d7..a988067 100644 --- a/scripts/lib/marketplace-detail-view-0781-official.css +++ b/scripts/lib/marketplace-detail-view-0781-official.css @@ -51,3 +51,17 @@ .sand-1o3jo1z:not(#\#):not(#\#):not(#\#){border-inline-end-color:#0000} .sand-v5lvn5:not(#\#):not(#\#):not(#\#){border-inline-start-color:#0000} .sand-4meti:not(#\#):not(#\#):not(#\#){transition-duration:.14s} + +/* + * Official 0.78.1's own Switch, the control the plugin detail page's per-tool rows use. + * + * The checked track is the accent primary (the `sand-1cgqir` recipe, which the routine-surface + * companion already carries) with a 0.5px inset rim, and the knob is the luminous surface with a + * drop shadow. Upstream 0.18's stylesheet declares none of these five, so they are lifted here + * verbatim from official's own stylesheet. + */ +.sand-2k1e5v:not(#\#):not(#\#):not(#\#){box-shadow:inset 0 0 0 .5px var(--sand-border-quaternary-translucent)} +.sand-lrd64x:not(#\#):not(#\#):not(#\#):not(#\#){min-width:26px} +.sand-1akln4p:not(#\#):not(#\#):not(#\#):not(#\#){max-height:16px} +.sand-1gdvyqu:not(#\#):not(#\#):not(#\#){box-shadow:0 1px 3px 0 var(--sand-shadow-control)} +.sand-1r4y97:not(#\#):not(#\#):not(#\#){inset-inline-start:12px} diff --git a/scripts/lib/marketplace-detail-view-0781-patch.mjs b/scripts/lib/marketplace-detail-view-0781-patch.mjs index 40a99aa..15c069c 100644 --- a/scripts/lib/marketplace-detail-view-0781-patch.mjs +++ b/scripts/lib/marketplace-detail-view-0781-patch.mjs @@ -40,6 +40,8 @@ * official's collapse at five rows and its "N more" button. * 5. **The 信息 table** — official's `dl` with 功能 / 开发者 / 类别 / 团队使用情况 / 网站 / 可用性, * and official's rule for which rows exist at all. + * 6. **The tool rows' switch** — official's own Switch (the accent-primary track and the luminous + * knob) instead of upstream 0.18's shared grey switch. * * ## Every string here is official 0.78.1's own * @@ -254,6 +256,37 @@ const OFFICIAL_0781_ADD_ACCOUNT = const OFFICIAL_0781_EDIT_ACCOUNT = "sand-9f619 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-1717udv sand-c342km sand-ng3xce sand-jbqb8w sand-169k319 sand-n4ifyr sand-1ypdohk"; +/** + * Official 0.78.1's own switch recipes for the detail page's per-tool rows. + * + * Upstream 0.18 renders those rows' on/off switches with its shared switch component, whose + * checked track is the grey `--sand-fill-control-checked`. Official renders the same control with + * its own switch: the checked track is the accent primary (`--sand-bg-accent-primary`), the knob + * is the luminous surface (`--sand-bg-luminous`), and the track carries no border. Both sides + * render `role="switch"`, `aria-checked="true"`, a 26x16 track and a 12x12 knob; only the recipes + * differ. + * + * Official's track recipe table is a 16-entry literal map indexed by + * `(size==="md")<<3 | checked<<2 | disabled<<1 | (disabled&&checked)`. The detail page's tool + * switch is the `sm` size and is never disabled, so only the unchecked and the checked variants + * are reachable — the two strings below. The knob recipes are official's `knob`, `knobSm` and + * `knobSmChecked` merged the way official's own merge helper merges them: a key a later recipe + * overrides moves to the end of the class string. + * + * @evidence official 0.78.1 `index.eager-app-D1juOWZp.js` — `SD` (the switch component), `xD` + * (its knob recipes) and its 16-entry track recipe table. + * @evidence official 0.78.1 live (CDP 9333, 2026-11-01) — the plugin detail page's tool-row + * switch DOM: the checked track's class string and the knob's. + */ +const OFFICIAL_0781_SWITCH_TRACK_ON = + "sand-jyslct sand-1lugfcp sand-9f619 sand-1n2onr6 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-amitd3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-ymw2ht sand-2k1e5v sand-6ikm8r sand-10wlt62 sand-14ju556 sand-gxxoiu sand-xymvpz sand-1ypdohk sand-1s07b3s sand-15406qy sand-12rn374 sand-wji4o3 sand-23j0i4 sand-lup9mm sand-lrd64x sand-1hshjfz sand-1akln4p sand-1cgqir sand-1moagsb sand-jhkic0"; +const OFFICIAL_0781_SWITCH_TRACK_OFF = + "sand-jyslct sand-1lugfcp sand-9f619 sand-1n2onr6 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-amitd3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-ymw2ht sand-fwqdrz sand-19jy9ex sand-1g6zli8 sand-2k1e5v sand-6ikm8r sand-10wlt62 sand-14ju556 sand-gxxoiu sand-xymvpz sand-1ypdohk sand-1s07b3s sand-15406qy sand-12rn374 sand-wji4o3 sand-23j0i4 sand-lup9mm sand-lrd64x sand-1hshjfz sand-1akln4p"; +const OFFICIAL_0781_SWITCH_KNOB_ON = + "sand-9f619 sand-10l6tqk sand-s7f9wi sand-16rqkct sand-7qiiw0 sand-1gdvyqu sand-2hifos sand-12rn374 sand-wji4o3 sand-smyaan sand-1kpxq89 sand-1r4y97"; +const OFFICIAL_0781_SWITCH_KNOB_OFF = + "sand-9f619 sand-10l6tqk sand-s7f9wi sand-aoy8p5 sand-16rqkct sand-7qiiw0 sand-1gdvyqu sand-2hifos sand-12rn374 sand-wji4o3 sand-smyaan sand-1kpxq89"; + /** Upstream 0.18's own recipes for the same surfaces, which the port replaces. */ const UPSTREAM_018_SECTION_LABEL = "sand-9f619 sand-1y1aw1k sand-19tmk5i sand-10b6aqq sand-zjhap9"; @@ -477,6 +510,30 @@ const REPLACEMENTS = [ after: `k("${OFFICIAL_0781_ROW_LABEL}")`, occurrences: 5, }, + { + // Official's plugin detail page renders the per-tool row's on/off switch with official's own + // Switch: the checked track is the accent primary (`--sand-bg-accent-primary`) and the knob is + // the luminous surface (`--sand-bg-luminous`). Upstream 0.18 renders the same control with its + // shared switch, whose checked track is the grey `--sand-fill-control-checked`. + // + // The port swaps the component and keeps the props: upstream's `Kn` takes the same + // `{isChecked, label, onToggle}` triple, so the row's own behaviour — the label it computes, + // the optimistic toggle and the failure callback — is untouched. Only the recipes change, so + // the row keeps upstream's own markup and the port adds official's own switch next to it. + // + // The label is official's own in both languages. Official builds it from two locale ids — + // `y5UgU8` ("Disable {0}" / "停用 {0}") for a tool that is enabled and `FZtBeR` + // ("Enable {0}" / "启用 {0}") for a tool that is not. Upstream 0.18 builds the same label from + // two English literals, and the extra-i18n pass localizes only the "Enable" half, so the + // enabled branch reads "Disable " inside a Chinese page. The port rewrites this exact + // expression, so it writes official's whole label. + label: "detail tool rows: official's own switch", + before: + 't.jsx(Kn,{isChecked:!j.isDisabled,label:`${j.isDisabled?(RLocT("Enable","启用")):"Disable"} ${P.get(j.name)??j.name}`,onToggle:()=>{c(()=>Ci({toggle:l,serverId:s,toolName:j.name,applyOptimisticToggle:o,setToggleFailure:d}))}})', + after: + 't.jsx(RM0781DetailToolSwitch,{isChecked:!j.isDisabled,label:`${j.isDisabled?(RLocT("Enable","启用")):RLocT("Disable","停用")} ${P.get(j.name)??j.name}`,onToggle:()=>{c(()=>Ci({toggle:l,serverId:s,toolName:j.name,applyOptimisticToggle:o,setToggleFailure:d}))}})', + occurrences: 1, + }, { // Official's accounts card is the elevated bordered surface, not upstream's translucent fill. label: "detail accounts section: official's card recipe", @@ -583,6 +640,14 @@ const INJECTED_SOURCE = [ "const RM0781DetailRowClasses=\"sand-9f619 sand-78zum5 sand-6s0dn4 sand-1qughib sand-167g77z sand-oyc9vk sand-1pic42t sand-2lprc sand-1onr9mi\";", "const RM0781DetailTermClasses=\"sand-9f619 sand-2lah0s sand-thy2uy sand-b3r6kr sand-uxw1ft sand-lyipyv sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-tyxrsu\";", "const RM0781DetailValueClasses=\"sand-9f619 sand-dj266r sand-2fvf9 sand-at24cr sand-1lziwak sand-b3r6kr sand-11wthnw sand-d4r4e8 sand-12oo3zp sand-169k319 sand-p4054r sand-lyipyv sand-uxw1ft\";", + // Official's own Switch, the control the per-tool rows use. The recipes are official's own class + // strings, copied verbatim from its rendered DOM and from its switch recipe table; they are NOT + // routed through the chunk's `k()` stylix helper, because that helper would prepend a custom name + // and the recipes would stop being byte-identical to official's. + "const RM0781DetailSwitchOn=" + JSON.stringify(OFFICIAL_0781_SWITCH_TRACK_ON) + ";", + "const RM0781DetailSwitchOff=" + JSON.stringify(OFFICIAL_0781_SWITCH_TRACK_OFF) + ";", + "const RM0781DetailKnobOn=" + JSON.stringify(OFFICIAL_0781_SWITCH_KNOB_ON) + ";", + "const RM0781DetailKnobOff=" + JSON.stringify(OFFICIAL_0781_SWITCH_KNOB_OFF) + ";", "const RM0781DetailCategories=" + JSON.stringify(MARKETPLACE_DETAIL_VIEW_0781_CATEGORIES) + ";", "function RM0781DetailTitleCase(text){", " const locale=RM0781Locale();", @@ -819,6 +884,19 @@ const INJECTED_SOURCE = [ " children:children,", " });", "}", + "function RM0781DetailToolSwitch(props){", + " const{isChecked,label,onToggle}=props;", + " const on=isChecked===true;", + " return t.jsx(\"button\",{", + " \"aria-checked\":on,", + " \"aria-label\":label,", + " children:t.jsx(\"span\",{\"aria-hidden\":true,className:on?RM0781DetailKnobOn:RM0781DetailKnobOff}),", + " className:on?RM0781DetailSwitchOn:RM0781DetailSwitchOff,", + " onClick:()=>onToggle(!on),", + " role:\"switch\",", + " type:\"button\",", + " });", + "}", "function RM0781DetailsList(props){", " const{label,rows}=props;", " const head=t.jsx(me,{as:\"h3\",className:RM0781DetailSectionLabelClasses,color:\"tertiary\",size:\"sm\",children:label});", diff --git a/scripts/lib/marketplace-dialog-0781-theme-tokens.css b/scripts/lib/marketplace-dialog-0781-theme-tokens.css index 6376678..6a0dde9 100644 --- a/scripts/lib/marketplace-dialog-0781-theme-tokens.css +++ b/scripts/lib/marketplace-dialog-0781-theme-tokens.css @@ -17,6 +17,22 @@ * .sand-132xtu3 → color: var(--sand-text-yellow-primary) (badge, warning) * .sand-wtpwev → color: var(--sand-text-red-primary) (badge, error) * + * The plugin detail page's tool rows carry official's own Switch, which reads two more: + * + * .sand-1cgqir → background-color: var(--sand-bg-accent-primary) (switch track, on) + * .sand-2k1e5v → box-shadow: inset 0 0 0 .5px + * var(--sand-border-quaternary-translucent) (switch track, rim) + * + * The switch's knob reads `--sand-bg-luminous`, which upstream 0.18's palette also lacks, and + * `--sand-shadow-control` for its drop shadow. Upstream 0.18 declares the latter at `:root` as + * `#0000001f`, but the running 0.18 app computes it as `#00000000` in the cursor-dark theme, so + * official's own value is reproduced here for the knob's shadow to match. + * + * The switch's track also reads `--sand-bg-accent-primary-hover` when the pointer is over it, and + * `--sand-bg-accent-primary-disabled` and `--sand-bg-secondary-disabled` when it is disabled. + * Upstream 0.18 declares none of the three, so without them those rules would be invalid at + * computed-value time and the track would lose its background on hover. + * * 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). @@ -78,5 +94,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-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} +.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-secondary-disabled:#1c1c1c0a;--sand-bg-hover:#1c1c1c0f;--sand-bg-pane:#fff;--sand-bg-accent-primary:#1680f3;--sand-bg-accent-primary-hover:#126fd4;--sand-bg-accent-primary-disabled:#1680f32b;--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-border-quaternary-translucent:#1c1c1c0f;--sand-shadow-control:#1c1c1c1f;--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-secondary-disabled:#f5f5f505;--sand-bg-hover:#f5f5f50f;--sand-bg-pane:#161616;--sand-bg-accent-primary:#116dd2;--sand-bg-accent-primary-hover:#1784fb;--sand-bg-accent-primary-disabled:#116dd226;--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;--sand-border-quaternary-translucent:#f5f5f50a;--sand-shadow-control:#f5f5f500} diff --git a/tests/fixtures/official-0781-marketplace-theme-tokens.json b/tests/fixtures/official-0781-marketplace-theme-tokens.json index f7c00ed..b96497f 100644 --- a/tests/fixtures/official-0781-marketplace-theme-tokens.json +++ b/tests/fixtures/official-0781-marketplace-theme-tokens.json @@ -12,7 +12,10 @@ "--cursor-*": "official forwards the whole `--cursor-*` family to `--sand-*`. A custom property's `var()` is substituted where the property is DECLARED, so upstream's `:root` forwarding is already computed against upstream's palette and cannot be reached by overriding `--sand-*` on the dialog. The same forwarding is re-declared on the dialog, against the dialog's own `--sand-*` values.", "initial": "the four tokens official leaves undefined are reset to `initial`, the guaranteed-invalid value, so that upstream's definitions do not render where official renders nothing.", "--cursor-font-size-*": "official's own literal cursor font sizes, declared at `:root,.ui-qruemj` outside any theme selector, so they are the same in both themes. The ported section-title rule `sand-fc7y3v` reads `var(--cursor-font-size-lg)`. Upstream 0.18 declares the same four values, but the port declares them so the rule does not depend on upstream's palette.", - "--sand-bg-elevated-hover/--sand-bg-chrome/--sand-bg-sidebar/--sand-bg-luminous": "declared by official and by neither upstream 0.18 nor an earlier revision of this block; reproduced with official's own values so a surface inside the dialog that reads them cannot fall back to an unset token." + "--sand-bg-elevated-hover/--sand-bg-chrome/--sand-bg-sidebar/--sand-bg-luminous": "declared by official and by neither upstream 0.18 nor an earlier revision of this block; reproduced with official's own values so a surface inside the dialog that reads them cannot fall back to an unset token.", + "--sand-border-quaternary-translucent": "declared by official at `:root,.sand-1vbcnu7` and by neither upstream 0.18's `:root` nor an earlier revision of this block. The plugin detail page's tool rows carry official's own Switch, whose track reads it for a 0.5px inset rim; upstream 0.18 declares that token only inside the computer-card patch's own scope, which the dialog is not inside, so without this row the rim would be invalid at computed-value time and the track would render `box-shadow: none`. The dark column is official's computed value on the live dialog.", + "--sand-shadow-control": "declared by official at `:root,.sand-1vbcnu7` as `#1c1c1c1f`; the dark column is official's computed value on the live dialog (`#f5f5f500`). The plugin detail page's tool-row switch knob reads it for its drop shadow. Upstream 0.18 declares the token at `:root,.sand-1wuigm2` as `#0000001f`, but the running 0.18 app computes it as `#00000000` in the cursor-dark theme, so without this row the knob's shadow would resolve to a different colour than official's.", + "--sand-bg-accent-primary-hover/--sand-bg-accent-primary-disabled/--sand-bg-secondary-disabled": "declared by official and by neither upstream 0.18's own stylesheet nor an earlier revision of this block. The ported switch's track reads the accent hover variant when the pointer is over it, and the two disabled variants when it is disabled. This repository's own routine-surface companion declares the same three tokens, but inside the conversation info pane's scope (`.sand-info-pane__inner`), which the plugins dialog is not inside, so they do not reach the switch. No upstream 0.18 rule reads any of the three, so declaring them on the dialog cannot change any other surface; the light column is official's `:root,.sand-1vbcnu7` palette block and the dark column is official's computed value on the live dialog." }, "tokens": { "--sand-bg-elevated": { @@ -35,6 +38,10 @@ "light": "#1c1c1c1a", "dark": "#f5f5f514" }, + "--sand-bg-secondary-disabled": { + "light": "#1c1c1c0a", + "dark": "#f5f5f505" + }, "--sand-bg-hover": { "light": "#1c1c1c0f", "dark": "#f5f5f50f" @@ -47,6 +54,14 @@ "light": "#1680f3", "dark": "#116dd2" }, + "--sand-bg-accent-primary-hover": { + "light": "#126fd4", + "dark": "#1784fb" + }, + "--sand-bg-accent-primary-disabled": { + "light": "#1680f32b", + "dark": "#116dd226" + }, "--sand-text-primary": { "light": "#1c1c1c", "dark": "#f5f5f5" @@ -115,6 +130,14 @@ "light": "#1c1c1c3d", "dark": "#f5f5f538" }, + "--sand-border-quaternary-translucent": { + "light": "#1c1c1c0f", + "dark": "#f5f5f50a" + }, + "--sand-shadow-control": { + "light": "#1c1c1c1f", + "dark": "#f5f5f500" + }, "--sand-font-size-xxs": { "light": "11px", "dark": "11px" diff --git a/tests/marketplace-detail-view-0781.test.mjs b/tests/marketplace-detail-view-0781.test.mjs index 050afd3..326c058 100644 --- a/tests/marketplace-detail-view-0781.test.mjs +++ b/tests/marketplace-detail-view-0781.test.mjs @@ -54,6 +54,62 @@ const browseCss = read("scripts", "lib", "marketplace-browse-chrome-0781-officia const layoutCss = read("scripts", "lib", "marketplace-dialog-0781-layout.css"); const routineCss = read("scripts", "lib", "routine-surface-0758-official.css"); +/** + * Official 0.78.1's own switch recipes for the detail page's per-tool rows. + * + * They are written out here rather than read from the port, so a port that swapped official's + * recipes for upstream's cannot satisfy itself. Every string is official's own class string: the + * checked track and the checked knob were read off official's live DOM, and the unchecked track is + * official's own recipe-table entry for the same control in its unchecked state — official's live + * plugin has every tool enabled, so the unchecked variant is not observable on screen. + */ +const OFFICIAL_0781_SWITCH_TRACK_ON = + "sand-jyslct sand-1lugfcp sand-9f619 sand-1n2onr6 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-amitd3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-ymw2ht sand-2k1e5v sand-6ikm8r sand-10wlt62 sand-14ju556 sand-gxxoiu sand-xymvpz sand-1ypdohk sand-1s07b3s sand-15406qy sand-12rn374 sand-wji4o3 sand-23j0i4 sand-lup9mm sand-lrd64x sand-1hshjfz sand-1akln4p sand-1cgqir sand-1moagsb sand-jhkic0"; +const OFFICIAL_0781_SWITCH_TRACK_OFF = + "sand-jyslct sand-1lugfcp sand-9f619 sand-1n2onr6 sand-3nfvp2 sand-6s0dn4 sand-1c4vz4f sand-2lah0s sand-dl72j9 sand-amitd3 sand-dj266r sand-14z9mp sand-at24cr sand-1lziwak sand-exx8yu sand-yri2b sand-18d9i69 sand-1c1uobl sand-c342km sand-ng3xce sand-ymw2ht sand-fwqdrz sand-19jy9ex sand-1g6zli8 sand-2k1e5v sand-6ikm8r sand-10wlt62 sand-14ju556 sand-gxxoiu sand-xymvpz sand-1ypdohk sand-1s07b3s sand-15406qy sand-12rn374 sand-wji4o3 sand-23j0i4 sand-lup9mm sand-lrd64x sand-1hshjfz sand-1akln4p"; +const OFFICIAL_0781_SWITCH_KNOB_ON = + "sand-9f619 sand-10l6tqk sand-s7f9wi sand-16rqkct sand-7qiiw0 sand-1gdvyqu sand-2hifos sand-12rn374 sand-wji4o3 sand-smyaan sand-1kpxq89 sand-1r4y97"; +const OFFICIAL_0781_SWITCH_KNOB_OFF = + "sand-9f619 sand-10l6tqk sand-s7f9wi sand-aoy8p5 sand-16rqkct sand-7qiiw0 sand-1gdvyqu sand-2hifos sand-12rn374 sand-wji4o3 sand-smyaan sand-1kpxq89"; + +/** Upstream 0.18's own shared-switch recipes, the ones the port replaces. */ +const UPSTREAM_018_SWITCH_CLASSES = ["sand-1jq8sgu", "sand-khjh7l"]; + +/** + * Official's five switch rules that upstream 0.18's stylesheet does not declare. + * + * They are byte-for-byte official's own rules; the two that read a token read the tokens the + * dialog's own token block declares. + */ +const OFFICIAL_0781_SWITCH_RULES = [ + ".sand-2k1e5v:not(#\\#):not(#\\#):not(#\\#){box-shadow:inset 0 0 0 .5px var(--sand-border-quaternary-translucent)}", + ".sand-lrd64x:not(#\\#):not(#\\#):not(#\\#):not(#\\#){min-width:26px}", + ".sand-1akln4p:not(#\\#):not(#\\#):not(#\\#):not(#\\#){max-height:16px}", + ".sand-1gdvyqu:not(#\\#):not(#\\#):not(#\\#){box-shadow:0 1px 3px 0 var(--sand-shadow-control)}", + ".sand-1r4y97:not(#\\#):not(#\\#):not(#\\#){inset-inline-start:12px}", +]; + +/** + * The tokens official's switch reads. + * + * Measured from the switch's own recipes — the 53 classes the port applies — and the + * declarations those classes carry in upstream 0.18's stylesheet, in this repository's official + * CSS companions and in official 0.78.1's own stylesheet. The set is written out so the guard can + * bite on a runner that carries no upstream payload at all, and so a recipe that starts reading a + * tenth token is caught on a runner that does. + */ +const OFFICIAL_0781_SWITCH_TOKENS = [ + "--sand-bg-accent-primary", + "--sand-bg-accent-primary-disabled", + "--sand-bg-accent-primary-hover", + "--sand-bg-luminous", + "--sand-bg-secondary", + "--sand-bg-secondary-disabled", + "--sand-bg-secondary-hover", + "--sand-border-quaternary-translucent", + "--sand-shadow-control", +]; + /** * The classes upstream 0.18's stylesheet already defines, so the port does not have to lift them. * @@ -79,6 +135,8 @@ const UPSTREAM_RESOLVED_CLASSES = [ "sand-11xpdln", "sand-1717udv", "sand-19jd1h0", "sand-1v2ro7d", "sand-n4ifyr", "sand-wji4o3", // Official's small secondary button recipe (the setup section's "Edit Values" button). "sand-178xt8z", "sand-so031l", "sand-13fuv20", "sand-1q0q8m5", "sand-1t7ytsu", "sand-1v8p93f", "sand-16stqrj", "sand-1k6tqyu", "sand-krqix3", "sand-87ps6o", "sand-ggy1nq", "sand-1s07b3s", "sand-1hc1fzr", "sand-uhm2yv", "sand-67bb7w", "sand-aqnwrm", "sand-1uczgqu", "sand-1wfwxd8", "sand-7s97pk", "sand-1eaenvl", "sand-1pbvl4h", "sand-d7y6wv", "sand-9h44rk", "sand-spwq11", + // The tool-row switch's own recipes (official 0.78.1's accent-primary track and its knob). + "sand-amitd3", "sand-6ikm8r", "sand-10wlt62", "sand-14ju556", "sand-xymvpz", "sand-15406qy", "sand-23j0i4", "sand-1hshjfz", "sand-10l6tqk", "sand-s7f9wi", "sand-16rqkct", "sand-smyaan", "sand-1kpxq89", ]; /** @@ -153,6 +211,120 @@ function builders() { return factory((en, zh) => zh, () => "en"); } +/** + * The port's own switch component, evaluated with a stub jsx runtime. + * + * The component IS the port's code, so the guard reads what it actually renders: the button's + * attribute set, the track's recipe and the knob's recipe. + */ +function toolSwitch() { + const factory = new Function( + "RLocT", + "RM0781Locale", + "C", + "t", + `${MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE}\nreturn RM0781DetailToolSwitch;`, + ); + const t = { jsx: (type, props) => ({ type, props }) }; + const component = factory((en, zh) => zh, () => "en", {}, t); + return (props) => component(props); +} + +/** + * The classes the port's own switch applies. + * + * They are read out of the port's injected source — the four recipe constants the switch picks + * between — so the guard follows the port instead of a copy of it. + */ +function switchRecipeClasses() { + const source = MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE; + const classes = new Set(); + for (const name of [ + "RM0781DetailSwitchOn", + "RM0781DetailSwitchOff", + "RM0781DetailKnobOn", + "RM0781DetailKnobOff", + ]) { + const at = source.indexOf(`const ${name}=`); + assert.ok(at >= 0, `the port must define ${name}, the switch's own recipe`); + const literal = source.slice(at, source.indexOf(";", at)); + for (const match of literal.matchAll(/sand-[a-z0-9+_-]+/g)) classes.add(match[0]); + } + return classes; +} + +/** + * The tokens the switch's own recipes read. + * + * A recipe's declaration lives in upstream 0.18's stylesheet, in one of this repository's official + * CSS companions, or — for the five the port lifts — in the detail companion itself, so the union + * of the available stylesheets is read. Comments are stripped first: a class named in a comment + * is not a rule, and counting it would report a rule that does not exist. + */ +function switchRecipeTokens() { + const classes = switchRecipeClasses(); + const sources = [detailCss, browseCss, layoutCss, routineCss]; + const assets = path.join(repoRoot, "src", "app", "dist", "renderer", "assets"); + if (existsSync(assets)) { + const cssFile = readdirSync(assets).find((name) => /^index-.*\.css$/.test(name)); + if (cssFile != null) sources.push(readFileSync(path.join(assets, cssFile), "utf8")); + } + const tokens = new Set(); + for (const css of sources) { + const bare = css.replace(/\/\*[\s\S]*?\*\//g, ""); + for (const name of classes) { + const rule = new RegExp(`\\.${name}(?![A-Za-z0-9_+-])[^}]*\\{([^}]*)\\}`, "g"); + let match; + while ((match = rule.exec(bare)) !== null) { + for (const token of match[1].matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) tokens.add(token[1]); + } + } + } + return tokens; +} + +/** + * Every token the switch's recipes read must be declared on the dialog in BOTH themes. + * + * 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 track loses its colour while every other + * guard 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. + */ +function assertSwitchTokensDeclared(cssText) { + const light = cssText.match(/^\.sand-plugins-dialog\{(.+)\}$/m); + const dark = cssText.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 official = JSON.parse( + read("tests", "fixtures", "official-0781-marketplace-theme-tokens.json"), + ).tokens; + const declared = (body) => + new Map([...body.matchAll(/(--[A-Za-z0-9_-]+)\s*:\s*([^;]+)/g)].map((match) => [match[1], match[2]])); + const lightTokens = declared(light[1]); + const darkTokens = declared(dark[1]); + const referenced = switchRecipeTokens(); + assert.deepEqual( + [...referenced].sort(), + [...OFFICIAL_0781_SWITCH_TOKENS].sort(), + "the ported switch's recipes must read exactly the tokens official's switch reads", + ); + for (const token of referenced) { + const captured = official[token]; + assert.ok(captured != null, `official's fixture is missing ${token}`); + assert.equal( + lightTokens.get(token), + captured.light, + `the dialog's light block must declare ${token} as official does`, + ); + assert.equal( + darkTokens.get(token), + captured.dark, + `the dialog's dark block must declare ${token} as official does`, + ); + } +} + /** An entry with official's own shape for a listed marketplace plugin. */ function pluginEntry(overrides = {}) { return { @@ -889,3 +1061,163 @@ test("the staged chunk carries the ported detail page", () => { assert.ok(chunk.includes(marker), `the staged chunk must carry ${marker}`); } }); + +test("the tool-row switch carries official 0.78.1's own track and knob recipes", () => { + // Upstream 0.18 renders the per-tool row's on/off switch with its shared switch, whose checked + // track is the grey `--sand-fill-control-checked`. Official renders the same control with its + // own switch: the checked track is the accent primary and the knob is the luminous surface. + const render = toolSwitch(); + const on = render({ isChecked: true, label: "Disable Get me", onToggle: () => {} }); + const off = render({ isChecked: false, label: "Enable Get me", onToggle: () => {} }); + + assert.equal(on.type, "button", "official's switch is a button"); + assert.deepEqual( + Object.keys(on.props).sort(), + ["aria-checked", "aria-label", "children", "className", "onClick", "role", "type"], + "official's switch renders aria-checked, aria-label, role, type and class, and nothing else", + ); + assert.equal(on.props.role, "switch", "official's switch carries role=switch"); + assert.equal(on.props.type, "button", "official's switch is a button"); + assert.equal(on.props["aria-checked"], true, "a checked switch reports aria-checked=true"); + assert.equal(on.props["aria-label"], "Disable Get me", "the switch carries the row's own label"); + assert.equal( + on.props.className, + OFFICIAL_0781_SWITCH_TRACK_ON, + "the checked track must be official's own recipe", + ); + assert.equal( + off.props["aria-checked"], + false, + "an unchecked switch reports aria-checked=false", + ); + assert.equal( + off.props.className, + OFFICIAL_0781_SWITCH_TRACK_OFF, + "the unchecked track must be official's own recipe", + ); + + assert.equal(on.props.children.type, "span", "the knob is a span"); + assert.deepEqual( + Object.keys(on.props.children.props).sort(), + ["aria-hidden", "className"], + "the knob carries nothing but its hidden flag and its recipe", + ); + assert.equal( + on.props.children.props["aria-hidden"], + true, + "the knob is hidden from the accessibility tree", + ); + assert.equal( + on.props.children.props.className, + OFFICIAL_0781_SWITCH_KNOB_ON, + "the checked knob must be official's own recipe", + ); + assert.equal( + off.props.children.props.className, + OFFICIAL_0781_SWITCH_KNOB_OFF, + "the unchecked knob must be official's own recipe", + ); + + for (const upstream of UPSTREAM_018_SWITCH_CLASSES) { + assert.ok( + !MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE.includes(upstream), + `upstream's shared switch recipe ${upstream} must not be on the tool-row switch`, + ); + } + + const patched = patchMarketplaceDetailView0781(syntheticChunk()); + assert.ok( + patched.includes("t.jsx(RM0781DetailToolSwitch,"), + "the tool row must render official's switch", + ); + assert.ok(!patched.includes("t.jsx(Kn,"), "upstream's shared switch must not render the tool row"); +}); + +test("the tool-row switch's label is official's own text in both languages", () => { + // Official builds the label from two locale ids: `y5UgU8` ("Disable {0}" / "停用 {0}") for a + // tool that is enabled and `FZtBeR` ("Enable {0}" / "启用 {0}") for a tool that is not. Official's + // live plugin carries `aria-label="停用 Get me"` on an enabled tool. Upstream 0.18 builds the + // same label from two English literals, and the extra-i18n pass localizes only the "Enable" + // half, so the enabled branch reads "Disable " inside a Chinese page. + const entry = MARKETPLACE_DETAIL_VIEW_0781_REPLACEMENTS.find((candidate) => + candidate.label.startsWith("detail tool rows"), + ); + assert.ok(entry != null, "the patch must own the tool row's switch"); + assert.ok( + entry.after.includes('RLocT("Disable","停用")'), + "the tool row's switch must carry official's own Disable text", + ); + assert.ok( + !entry.after.includes(':"Disable"'), + "the tool row's switch must not keep the bare English Disable literal", + ); + const patched = patchMarketplaceDetailView0781(syntheticChunk()); + assert.ok( + patched.includes('RLocT("Disable","停用")'), + "the patched chunk must carry official's own Disable text at the tool-row switch", + ); + assert.ok( + !patched.includes(':"Disable"} ${P.get(j.name)??j.name}'), + "the patched chunk must not keep upstream's bare English Disable branch", + ); +}); + +test("the detail companion carries official's switch rules", () => { + for (const rule of OFFICIAL_0781_SWITCH_RULES) { + assert.ok( + detailCss.includes(rule), + `official's switch rule must be lifted into the detail companion: ${rule}`, + ); + } +}); + +test("mutations: swapping official's switch recipes back to upstream's fails the guard", async () => { + const original = path.join(repoRoot, "scripts/lib/marketplace-detail-view-0781-patch.mjs"); + const source = readFileSync(original, "utf8"); + const mutated = source + .replace( + OFFICIAL_0781_SWITCH_TRACK_ON, + OFFICIAL_0781_SWITCH_TRACK_ON.replace("sand-amitd3", "sand-1jq8sgu"), + ) + .replace( + OFFICIAL_0781_SWITCH_KNOB_ON, + OFFICIAL_0781_SWITCH_KNOB_ON.replace("sand-1r4y97", "sand-khjh7l"), + ); + assert.notEqual(mutated, source, "the mutation must apply"); + const mutantFile = path.join(repoRoot, "scripts/lib/.mutant-0781-detail-switch.mjs"); + writeFileSync(mutantFile, mutated); + try { + const mutant = await import(pathToFileURL(mutantFile).href); + const mutantSource = mutant.MARKETPLACE_DETAIL_VIEW_0781_INJECTED_SOURCE; + assert.ok( + !mutantSource.includes(OFFICIAL_0781_SWITCH_TRACK_ON), + "the guard must fail when the checked track is not official's", + ); + assert.ok( + mutantSource.includes("sand-1jq8sgu"), + "the mutant must carry upstream's grey checked track", + ); + assert.ok( + !mutantSource.includes(OFFICIAL_0781_SWITCH_KNOB_ON), + "the guard must fail when the knob is not official's", + ); + assert.ok( + mutantSource.includes("sand-khjh7l"), + "the mutant must carry upstream's knob recipe", + ); + } finally { + rmSync(mutantFile, { force: true }); + } +}); + +test("every token the ported switch's recipes read is declared on the dialog in both themes", () => { + const css = read("scripts", "lib", "marketplace-dialog-0781-theme-tokens.css"); + assertSwitchTokensDeclared(css); + // The guard must bite: a dialog that stops declaring one of the switch's own tokens renders + // the rule at its initial value, so the track loses its colour. + assert.throws( + () => assertSwitchTokensDeclared(css.replace(/--sand-shadow-control:[^;]+;/, "")), + /--sand-shadow-control/, + "the guard must fail on a dialog that stops declaring the switch's own token", + ); +});