Share the ported _globals.scss rules through one module
Seven shadow-root components each carry their own copy of DX Life's
base/_globals.scss rules, because page CSS cannot reach into a shadow
root. Measured first: where the copies overlap they are byte-identical
(the Cyrillic stack 3x, strong{font-weight:500} 2x, scrollbar-gutter 2x),
so this is deduplication, not a fix.
The module exports FUNCTIONS, not strings, and the measurement forced
that: 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 fixed rule string would have to
pick one class and silently stop styling the other five. The caller owns
its selector; the module owns the declaration.
cyrillicHeading takes a selector LIST, not a selector. GoalCard styles
one heading class; RegionSectionCard groups two into a single rule
sharing one declaration block. A one-selector signature emits two rules
instead of one -- the same CSS to a browser, but not the same
stylesheet, and the bar here is unchanged, not equivalent.
Migrated GoalCard and RegionSectionCard. regions-dashboard's two files
port the same rules and stay with their owner; the harness records them
so that migration can run against this same baseline.
The img border-radius rule (_globals.scss:80) is documented as
deliberately NOT ported, because an invisible omission is one that gets
"fixed" later.
PROOF: internal/check-ported-globals-identical.mjs executes each
component's setup, captures the string handed to useShadowStyles and
hashes it. The emitted CSS is byte-identical across the refactor
(987d94b1cd66110d, 897d948b17b21efe, both captured from 45e31f102).
The harness's first version hashed the SOURCE of the call and was
wrong: this refactor replaces a literal with a function call, so the
source changes by construction -- a text-hash gate fails a perfect
refactor and cannot tell that from a broken one. It measured the
artifact when the property under test was the output. It failed exactly
that way on first run.
Shown capable of failing, four ways: dropping letter-spacing, a wrong
font name, 500->600, and splitting the grouped rule into two. All four
go red; restoring goes green. Components that cannot be evaluated are
reported NOT MEASURED (exit 75), never as a pass.
This commit is contained in:
parent
45e31f1028
commit
32d2e4dc59
5 changed files with 338 additions and 30 deletions
228
internal/check-ported-globals-identical.mjs
Normal file
228
internal/check-ported-globals-identical.mjs
Normal file
|
|
@ -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 <script setup> body, and keep the first argument
|
||||
* handed to useShadowStyles. That string is what gets injected into the shadow root. Hash THAT.
|
||||
* No browser is launched and none is needed — the CSS is assembled in plain JS at setup time.
|
||||
*
|
||||
* node internal/check-ported-globals-identical.mjs --write (baseline)
|
||||
* node internal/check-ported-globals-identical.mjs (compare)
|
||||
* node internal/check-ported-globals-identical.mjs --from <git-ref>
|
||||
* evaluate the components AS OF a git ref instead of the working tree — which is how the
|
||||
* pre-refactor baseline is recovered after the fact, without checking anything out.
|
||||
*
|
||||
* ⚠ WHITESPACE IS NORMALISED, declarations are NOT. A run of spaces or a newline between
|
||||
* declarations cannot change rendering, and the concatenation seams legitimately move when a literal
|
||||
* becomes a call. Selectors, properties, values and RULE GROUPING all survive normalisation — so
|
||||
* splitting one grouped rule into two separate rules is still reported as a change, which is correct:
|
||||
* it is the same CSS to a browser, but it is not the same stylesheet, and this gate's contract is
|
||||
* "unchanged", not "equivalent".
|
||||
*
|
||||
* Exit 0 pass · 1 fail · 75 NOT MEASURED (a component could not be evaluated — never reported as a
|
||||
* pass, because an unevaluated component proves nothing at all).
|
||||
*/
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { parse } from '@vue/compiler-sfc'
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||
const REPO = resolve(HERE, '..')
|
||||
const BASELINE = join(HERE, 'fixtures', 'ported-globals-baseline.json')
|
||||
|
||||
// The files under refactor. regions-dashboard's two are listed as OWNED ELSEWHERE: the harness
|
||||
// records them too, so regions can migrate against the same baseline rather than inventing one.
|
||||
// ★ `owned:false` means RECORDED, NOT GATED. regions-dashboard's two files port the same globals and
|
||||
// belong to another agent; capturing their hashes lets them migrate against this same baseline
|
||||
// instead of inventing one. But they must not be able to FAIL my run — a gate that goes red on a
|
||||
// file its owner is mid-edit trains everyone to ignore it. They are reported, never enforced.
|
||||
// Both currently evaluate only partially (they read props and coerce objects at setup time, which
|
||||
// the stubs do not model); that is a gap in the harness, NOT evidence about their CSS, and it is
|
||||
// reported as NOT MEASURED rather than quietly skipped.
|
||||
const FILES = [
|
||||
{ path: 'library/components/regions/GoalCard.vue', owned: true },
|
||||
{ path: 'library/components/regions/RegionSectionCard.vue', owned: true },
|
||||
{ path: 'plugin-designs/regions-dashboard/region-dashboards.vue', owned: false },
|
||||
{ path: 'plugin-designs/regions-dashboard/regions-list.vue', owned: false },
|
||||
]
|
||||
const OWNED = new Set(FILES.filter((f) => f.owned).map((f) => f.path))
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const fromRef = (() => {
|
||||
const i = args.indexOf('--from')
|
||||
return i >= 0 ? args[i + 1] : null
|
||||
})()
|
||||
|
||||
/** Read a file from the working tree, or from a git ref when --from is given. */
|
||||
function readSource(rel) {
|
||||
if (!fromRef) {
|
||||
const p = join(REPO, rel)
|
||||
return existsSync(p) ? readFileSync(p, 'utf8') : null
|
||||
}
|
||||
try {
|
||||
return execFileSync('git', ['show', `${fromRef}:${rel}`], { cwd: REPO, encoding: 'utf8' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a component's <script setup> far enough to capture its useShadowStyles argument.
|
||||
*
|
||||
* Every import is stubbed with a Proxy that returns a callable no-op for any name, EXCEPT
|
||||
* useShadowStyles (captured) and portedGlobals (loaded for real — its output is the thing under
|
||||
* test). Vue's own reactivity/lifecycle functions are stubbed too: nothing mounts, so onMounted
|
||||
* never fires and no DOM is touched.
|
||||
*
|
||||
* @returns {{css: string}|{error: string}}
|
||||
*/
|
||||
async function emittedCss(src, rel) {
|
||||
const { descriptor, errors } = parse(src, { filename: rel })
|
||||
if (errors.length) return { error: `SFC parse: ${errors[0].message}` }
|
||||
const block = descriptor.scriptSetup || descriptor.script
|
||||
if (!block) return { error: 'no <script> block' }
|
||||
|
||||
let captured = null
|
||||
const globals = await import('../library/styles/portedGlobals.js').catch(() => null)
|
||||
|
||||
// A stub that tolerates any shape a component might use it in: called, constructed, or
|
||||
// property-accessed. defineProps/defineEmits are compiler macros, so they must return something
|
||||
// indexable rather than undefined.
|
||||
const anyFn = () => new Proxy(function () {}, {
|
||||
get: (t, k) => (k === 'value' ? undefined : anyFn()),
|
||||
apply: () => anyFn(),
|
||||
})
|
||||
const stubModule = (spec) => new Proxy({}, {
|
||||
get(_t, name) {
|
||||
if (name === 'useShadowStyles') return (css) => { captured = css }
|
||||
if (globals && name in globals) return globals[name]
|
||||
return anyFn()
|
||||
},
|
||||
})
|
||||
|
||||
// Rewrite `import ... from 'x'` into a binding off a stub, and drop the macros the SFC compiler
|
||||
// would normally handle. Everything else runs as written — including the string concatenation and
|
||||
// the portedGlobals calls, which is the entire point.
|
||||
let code = block.content
|
||||
.replace(/^\s*import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?\s*$/gm, (m, clause, spec) => {
|
||||
const ns = clause.match(/^\*\s+as\s+(\w+)$/)
|
||||
if (ns) return `const ${ns[1]} = __stub(${JSON.stringify(spec)});`
|
||||
const named = clause.match(/\{([\s\S]*)\}/)
|
||||
const def = clause.replace(/\{[\s\S]*\}/, '').replace(/,\s*$/, '').trim()
|
||||
let out = ''
|
||||
if (def) out += `const ${def} = __stub(${JSON.stringify(spec)});`
|
||||
if (named) {
|
||||
// ⚠ `import { A as B }` is NOT destructuring syntax — the rename operator there is `:`.
|
||||
// Rewriting the clause verbatim produced a SyntaxError on the first real file that used it.
|
||||
const bindings = named[1]
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => s.replace(/^(\w+)\s+as\s+(\w+)$/, '$1: $2'))
|
||||
.join(', ')
|
||||
out += `const {${bindings}} = __stub(${JSON.stringify(spec)});`
|
||||
}
|
||||
return out || `__stub(${JSON.stringify(spec)});`
|
||||
})
|
||||
.replace(/^\s*import\s+['"][^'"]+['"];?\s*$/gm, '')
|
||||
|
||||
const fn = new Function('__stub', '__macros', `
|
||||
const { defineProps, defineEmits, defineExpose, defineOptions, withDefaults } = __macros;
|
||||
${code}
|
||||
`)
|
||||
try {
|
||||
fn(stubModule, {
|
||||
defineProps: anyFn(), defineEmits: anyFn(), defineExpose: anyFn(),
|
||||
defineOptions: anyFn(), withDefaults: anyFn(),
|
||||
})
|
||||
} catch (e) {
|
||||
return { error: `setup threw: ${e.message}` }
|
||||
}
|
||||
if (typeof captured !== 'string') return { error: 'useShadowStyles was never called with a string' }
|
||||
return { css: captured.replace(/\s+/g, ' ').trim() }
|
||||
}
|
||||
|
||||
const now = {}
|
||||
const unmeasured = [] // owned files that failed to evaluate — these BLOCK
|
||||
const unmeasuredOther = [] // recorded-only files — these are reported, never blocking
|
||||
for (const { path: f } of FILES) {
|
||||
const src = readSource(f)
|
||||
if (src === null) { now[f] = null; continue }
|
||||
const r = await emittedCss(src, f)
|
||||
if (r.error) {
|
||||
;(OWNED.has(f) ? unmeasured : unmeasuredOther).push(`${f}: ${r.error}`)
|
||||
continue
|
||||
}
|
||||
now[f] = createHash('sha256').update(r.css).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
if (args.includes('--write')) {
|
||||
if (unmeasured.length) {
|
||||
console.error('PORTED-GLOBALS: refusing to write a baseline with unevaluated components:')
|
||||
for (const u of unmeasured) console.error(` ! ${u}`)
|
||||
process.exit(75)
|
||||
}
|
||||
writeFileSync(BASELINE, JSON.stringify({
|
||||
_why: 'Emitted-CSS hashes, captured by EXECUTING each component\'s setup and hashing the string it hands useShadowStyles. Whitespace-normalised; declarations and rule grouping are not. A ported-globals extraction must not change these.',
|
||||
_source: fromRef ? `git ref ${fromRef}` : 'working tree',
|
||||
hashes: now,
|
||||
}, null, 2) + '\n')
|
||||
console.log(`PORTED-GLOBALS: baseline written (${fromRef ? 'from ' + fromRef : 'working tree'})`)
|
||||
for (const { path: f, owned } of FILES) {
|
||||
if (now[f] === undefined) continue
|
||||
console.log(` ${now[f] || '(absent)'} ${f}${owned ? '' : ' [recorded only — regions owns it]'}`)
|
||||
}
|
||||
for (const u of unmeasuredOther) console.log(` (not measured) ${u}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!existsSync(BASELINE)) {
|
||||
console.log('PORTED-GLOBALS: SKIP — no baseline; run with --write before refactoring')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ⚠ NOT MEASURED is not a pass. A component that failed to evaluate carries no evidence either way,
|
||||
// and reporting it green is how a gate ends up certifying a file it never read.
|
||||
if (unmeasured.length) {
|
||||
console.error('PORTED-GLOBALS: NOT MEASURED (exit 75) — these components could not be evaluated, so')
|
||||
console.error(' this run says NOTHING about whether the CSS changed. Not a pass, not a fail.')
|
||||
for (const u of unmeasured) console.error(` ! ${u}`)
|
||||
process.exit(75)
|
||||
}
|
||||
|
||||
const base = JSON.parse(readFileSync(BASELINE, 'utf8')).hashes
|
||||
const fails = []
|
||||
let compared = 0
|
||||
for (const { path: f } of FILES) {
|
||||
if (base[f] === undefined || base[f] === null) continue
|
||||
if (now[f] === undefined) continue // recorded-only and unevaluated — reported above, not enforced
|
||||
compared++
|
||||
if (base[f] !== now[f]) {
|
||||
fails.push(`${f}\n before: ${base[f]}\n after: ${now[f]}\n the emitted CSS CHANGED — a ported-globals extraction must be byte-identical, so either the\n module's output differs from the literal it replaced, or a declaration was dropped`)
|
||||
}
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error('PORTED-GLOBALS: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
for (const u of unmeasuredOther) console.log(`PORTED-GLOBALS: note — not measured (recorded only, not gated): ${u}`)
|
||||
console.log(`PORTED-GLOBALS: PASS — the emitted CSS of ${compared} component(s) is byte-identical to the pre-refactor baseline.`)
|
||||
8
internal/fixtures/ported-globals-baseline.json
Normal file
8
internal/fixtures/ported-globals-baseline.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"_why": "Emitted-CSS hashes, captured by EXECUTING each component's setup and hashing the string it hands useShadowStyles. Whitespace-normalised; declarations and rule grouping are not. A ported-globals extraction must not change these.",
|
||||
"_source": "git ref 45e31f102",
|
||||
"hashes": {
|
||||
"library/components/regions/GoalCard.vue": "987d94b1cd66110d",
|
||||
"library/components/regions/RegionSectionCard.vue": "897d948b17b21efe"
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@
|
|||
-->
|
||||
<script setup>
|
||||
import { useShadowStyles } from '../../composables/useShadowStyles.js'
|
||||
import { cyrillicHeading, strongMedium } from '../../styles/portedGlobals.js'
|
||||
import { useCardSurface } from '../../composables/useCardSurface.js'
|
||||
|
||||
defineProps({
|
||||
|
|
@ -76,22 +77,17 @@ useShadowStyles(
|
|||
// duplication is deliberate rather than overlooked. Using .rd-h2 itself would put two identical
|
||||
// selectors in one shadow root from two different files -- the shape that drifts silently the first
|
||||
// time one is tuned. The plugin keeps .rd-h2 for the one heading it still owns (its table title).
|
||||
// The real fix is a shared ported-globals module beside the token layer, which is a batch of its
|
||||
// own; carded, not smuggled in here.
|
||||
// (The ported-globals module this comment used to card now exists -- ../../styles/portedGlobals.js
|
||||
// -- and the Cyrillic rule below comes from it. The h2 clamp itself stays local: it is this card's
|
||||
// own sizing, not a globals rule.)
|
||||
'.rd-goal-card__title{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl)); line-height:1;' +
|
||||
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' font-weight:400; }' +
|
||||
// ★ 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.
|
||||
// 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"] .rd-goal-card__title, [lang="ru-RU"] .rd-goal-card__title{' +
|
||||
' font-family:"Oswald","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
||||
' letter-spacing:-0.02em; }' +
|
||||
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44. The rule, the reasons
|
||||
// it is load-bearing (Bebas Neue has no Cyrillic glyphs) and why it matches BOTH tag forms are
|
||||
// documented once at its source, ../../styles/portedGlobals.js -- this card supplies only its own
|
||||
// heading class, because the declaration is shared and the subject is not.
|
||||
cyrillicHeading('.rd-goal-card__title') +
|
||||
|
||||
// ── the figure. .font-size-4xl .font-weight-medium / .font-size-lg
|
||||
// ★ NO line-height HERE. Theirs is `<p class="font-size-4xl font-weight-medium">` and the
|
||||
|
|
@ -134,12 +130,14 @@ useShadowStyles(
|
|||
// margin hid a real difference, so the probe was right and incomplete at the same time.
|
||||
'.rd-goal{ margin:auto 0 0; font-size:var(--font-size-sm); }' +
|
||||
'.rd-goal-card > .rd-goal{ margin-top:auto; }' +
|
||||
// base/_globals.scss:200-202 — <strong> is --font-weight-medium (500) on their site, not the UA's
|
||||
// bold. The plugin's own `.rd-root strong` STAYS where it is: UupgsList renders a <strong> in the
|
||||
// same shadow root and depends on it, so this is a second rule for this card's own subtree, not a
|
||||
// move. Checked by reading the whole root rather than this card's markup -- a file-scoped grep
|
||||
// would have called the plugin's copy dead and deleted a rule another component needs.
|
||||
'.rd-goal strong{ font-weight:500; }'
|
||||
// <strong> is --font-weight-medium on their site, not the UA's bold (portedGlobals.js carries the
|
||||
// source line). ★ SCOPED to .rd-goal, and the scope is the point: the plugin's own `.rd-root
|
||||
// strong` STAYS where it is, because UupgsList renders a <strong> in the same shadow root and
|
||||
// depends on it. So this is a SECOND rule for this card's subtree, not a move -- which is why the
|
||||
// shared helper is composed behind a prefix rather than emitted bare. Checked by reading the whole
|
||||
// root rather than this card's markup: a file-scoped grep would have called the plugin's copy dead
|
||||
// and deleted a rule another component needs.
|
||||
'.rd-goal ' + strongMedium()
|
||||
, 'goal-card')
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <p> 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
|
||||
|
|
|
|||
77
library/styles/portedGlobals.js
Normal file
77
library/styles/portedGlobals.js
Normal file
|
|
@ -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 — <strong> 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',
|
||||
})
|
||||
Loading…
Reference in a new issue