diff --git a/internal/check-ported-globals-identical.mjs b/internal/check-ported-globals-identical.mjs new file mode 100644 index 000000000..5b3683443 --- /dev/null +++ b/internal/check-ported-globals-identical.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node +/** + * check-ported-globals-identical — a refactor that must change NOTHING it emits. + * + * Several shadow-root components carry their own copy of DX Life's base/_globals.scss rules, because + * page CSS cannot reach into a shadow root. The copies were MEASURED as byte-identical where they + * overlap (the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter:stable 2x), so + * extracting them into library/styles/portedGlobals.js is DEDUPLICATION, not a fix — and the proof + * obligation is therefore unusual: the emitted CSS must be IDENTICAL before and after, or the + * refactor is wrong. + * + * ★ IT EXECUTES THE COMPONENT'S SETUP AND CAPTURES THE REAL ARGUMENT. It does NOT read source text. + * The first version of this harness hashed the SOURCE of the useShadowStyles(...) call, and that was + * a defect that made the gate useless for its one job: this refactor's whole purpose is to REPLACE a + * string literal with a function call, so the source text changes by construction. A text-hash gate + * fails on a perfect refactor and cannot tell that apart from a broken one — it measures the + * ARTIFACT (how the CSS is spelled) when the property under test is the OUTPUT (what the browser + * receives). It failed exactly that way on first run, on a refactor later proven correct. + * + * So: parse the SFC, stub every import, run the diff --git a/library/components/regions/RegionSectionCard.vue b/library/components/regions/RegionSectionCard.vue index 66ca37dc4..63ff1ee62 100644 --- a/library/components/regions/RegionSectionCard.vue +++ b/library/components/regions/RegionSectionCard.vue @@ -34,6 +34,7 @@ * feature into every consumer's API. The `extra` slot keeps it where it belongs. */ import { useShadowStyles } from '../../composables/useShadowStyles.js' +import { cyrillicHeading } from '../../styles/portedGlobals.js' const props = defineProps({ /** The region's display name. Already localized by the caller. */ @@ -129,19 +130,15 @@ useShadowStyles( ' margin-inline-start:10px; }' + // .region-card__summary: --font-size-md (var(--font-size-md)), 0.85 opacity, on the header band. '.region-card__summary{ font-size:var(--font-size-md); opacity:0.85; margin:0; }' + - // ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44: - // h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } } - // and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC - // GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed - // system face the browser picks. We ship a `ru` locale and had neither the rule nor the font. + // ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44. The declaration and the + // reasons it is load-bearing live at ../../styles/portedGlobals.js; this card supplies its two + // heading classes. + // ★ BOTH CLASSES IN ONE CALL, not two, because they were ONE grouped rule and the extraction had to + // stay byte-identical: two calls emit two rules, which a browser renders the same and the gate + // reports as a change. Equivalent CSS is not the bar here; unchanged CSS is. // ONLY the headings, exactly as theirs — their .region-stat__value is a

and keeps Bebas even in // Russian, which is their behaviour and so is ours. - // BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`, - // and a host page embedding this element may set either. A selector that matched only one would - // work on their site and fail on ours, or the reverse, depending on who set the attribute. - '[lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,' + - '[lang="ru"] .region-card__title, [lang="ru-RU"] .region-card__title{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' + - ' letter-spacing:-0.02em; }' + + cyrillicHeading('.rl-title', '.region-card__title') + // .region-card__body: padding --spacing-lg / clamp(...) / --spacing-xl -> var(--spacing-lg) / clamp / var(--spacing-xl) '.region-card__body{ padding:var(--spacing-lg) clamp(var(--spacing-md), 4cqw, var(--spacing-2xl)) var(--spacing-xl); }' + // .countries-grid: DX-life's track is minmax(220px,1fr). Gap --spacing-xs / --spacing-xl diff --git a/library/styles/portedGlobals.js b/library/styles/portedGlobals.js new file mode 100644 index 000000000..c39458add --- /dev/null +++ b/library/styles/portedGlobals.js @@ -0,0 +1,77 @@ +/** + * portedGlobals — DX Life's base/_globals.scss rules, once, for shadow-root components. + * + * Page CSS cannot reach into a shadow root, so every component that must look like DX Life carries + * its own copy of that site's element rules. Seven files did, each citing base/_globals.scss by + * line. MEASURED before extracting: where the copies overlap they are BYTE-IDENTICAL (the Cyrillic + * font stack 3x, strong{font-weight:500} 2x, scrollbar-gutter:stable 2x). So this module is + * DEDUPLICATION, not a fix, and its correctness test is that the emitted CSS does not change — + * internal/check-ported-globals-identical.mjs holds the before/after hashes. + * + * ★ IT EXPORTS FUNCTIONS, NOT STRINGS, and that is forced by the measurement. The six copies of the + * Cyrillic rule share ONE declaration but have SIX DIFFERENT SUBJECTS — .rd-goal-card__title, + * .rd-h2, .rd-title, .region-card__title, .rl-title (x2). A module exporting a fixed rule string + * would have to pick one class and would silently stop styling the other five. The caller owns its + * selector; this module owns the declaration. + * + * ★ AND IT TAKES A LIST, NOT A SELECTOR, which the first draft of this module got wrong. GoalCard + * styles ONE heading class; RegionSectionCard GROUPS TWO (.rl-title and .region-card__title) into a + * single rule sharing one declaration block. A one-selector signature cannot reproduce the grouped + * form — it would emit two separate rules. Same CSS to a browser, but this refactor's whole proof is + * that the emitted bytes do not change, so "equivalent" is not good enough. Caught by diffing the + * two call sites before writing the callers, not after the hashes disagreed. + * + * Plain JS returning CSS strings, zero imports, no build step — the same pure-module shape as + * packetSets.js, so a Node gate and a Vite bundle both consume it unchanged. + */ + +/** + * base/_globals.scss:35-44 — h1,h2,h3 switch face under Cyrillic. + * + * ⚠ NOT DECORATION: Bebas Neue HAS NO CYRILLIC GLYPHS, so a Russian heading left in it falls out of + * the family entirely and lands on whatever condensed system face the browser picks. + * ⚠ BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`, + * and a host page embedding one of these elements may set either. A selector matching only one + * works on their site and fails on ours, or the reverse, depending on who set the attribute. Do not + * "simplify" this to a single form. + * + * @param {...string} selectors the caller's own heading classes, e.g. '.rl-title', '.region-card__title'. + * Several are GROUPED into one rule sharing one declaration block, which is how RegionSectionCard + * already spells it — pass them together rather than calling this twice. + * @returns {string} one CSS rule + */ +export function cyrillicHeading(...selectors) { + if (!selectors.length) throw new Error('cyrillicHeading: needs at least one selector') + const subjects = selectors + .map((s) => `[lang="ru"] ${s}, [lang="ru-RU"] ${s}`) + .join(',') + return `${subjects}{ font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;` + + ' letter-spacing:-0.02em; }' +} + +/** base/_globals.scss:200-202 — is --font-weight-medium (500) on their site, not the UA's bold. */ +export function strongMedium() { + return 'strong{ font-weight:500; }' +} + +/** + * base/_globals.scss html rule — reserve the scrollbar track so content does not shift by its width + * when a list grows past the viewport. + */ +export function scrollbarGutter() { + return 'scrollbar-gutter:stable;' +} + +/** + * ⚠ DELIBERATELY NOT PORTED, and recorded here so nobody "completes" the set later: + * + * _globals.scss:80 `img { border-radius: var(--border-radius) }` + * + * region-dashboards.vue:805 declines it on purpose. Rounding every image inside these components is + * not wanted — their site applies it in a context ours does not share. If a future component needs + * it, it should opt in explicitly rather than inherit it from a shared module, because an omission + * that is invisible is an omission that gets "fixed". + */ +export const NOT_PORTED = Object.freeze({ + 'img border-radius': '_globals.scss:80 — declined at region-dashboards.vue:805; opt in explicitly if ever needed', +})