diff --git a/RELEASE-0.2.1.md b/RELEASE-0.2.1.md new file mode 100644 index 000000000..bcc0a425b --- /dev/null +++ b/RELEASE-0.2.1.md @@ -0,0 +1,73 @@ +# @arijs/bootswatch-ve v0.2.1 + +Shared Bootstrap state classes are now one contract each, composed at the call site. 35 per-family state aliases are gone — 18 of them had shipped with no CSS behind them since the package existed. + +> **Read this before upgrading.** Despite the patch number, this release removes exported names. `^0.2.0` resolves to it automatically, and a project using any of the removed names will fail to compile. The failure is a compile error, not a silent visual regression — see *Migrating* below. + +## Why + +`dropdownItemActive`, `listGroupItemActive` and two siblings were exported, hashed, and referenced by none of the 27 themes. Applying one was a silent no-op: correct import, valid TypeScript, a real class in the DOM, and zero CSS behind it. + +They were not an oversight. A per-family alias collapses a compound source selector into one identifier — `.dropdown-item.active` → `dropdownItemActive`, `.btn.show` → `btnShowHook`. 33 identifiers stood for 9 Bootstrap state classes, and each one only existed in the CSS because someone hand-wrote an entry in the converter's divergence manifest. Families without a demo page never got an entry, so 18 of the 33 shipped dead. + +The alias bought nothing: `${navLink}${navLinkActive}` and `${navLink}${active}` have identical specificity and match the same elements. Compound selectors already isolate components — `${modal}${show}` cannot match a dropdown — so a shared `active` is safe. + +## Breaking changes + +**35 exported names removed.** Compose the shared state contract instead: + +```ts +// before +import { dropdownItem, dropdownItemActive } from '@arijs/bootswatch-ve/dropdowns' +cx(dropdownItem, isActive && dropdownItemActive) + +// after +import { dropdownItem } from '@arijs/bootswatch-ve/dropdowns' +import { active } from '@arijs/bootswatch-ve/global' +cx(dropdownItem, isActive && active) +``` + +| entry | removed | replace with | +|---|---|---| +| `accordion` | `accordionButtonCollapsed`, `accordionCollapseShow`, `accordionCollapsing` | `collapsed`, `show`, `collapsing` | +| `alerts` | `alertBtnCloseDisabledHook` | `disabled` | +| `breadcrumb` | `breadcrumbItemActive` | `active` | +| `buttons` | `btnActiveHook`, `btnDisabledHook` | `active`, `disabled` | +| `carousel` | `carouselActive`, `carouselItemActive`, `carouselIndicatorActive` | `active` | +| `dropdowns` | `btnShowHook`, `dropdownMenuShow`, `dropdownItemActive`, `dropdownItemDisabled` | `show`, `show`, `active`, `disabled` | +| `list-group` | `listGroupItemActive`, `listGroupItemDisabled` | `active`, `disabled` | +| `modal` | `modalShowHook`, `modalFade` | `show`, `fade` | +| `navbar` | `collapseShow` | `show` | +| `navs` | `navLinkActive`, `navLinkDisabled`, `tabPaneActive`, `navItemShow` | `active`, `disabled`, `active`, `show` | +| `pagination` | `pageItemActive`, `pageItemDisabled` | `active`, `disabled` | +| `popovers` | `popoverShow`, `popoverFade`, `popoverVe` | `show`, `fade`, `popover` | +| `toasts` | `toastShow`, `toastShowing`, `toastFade`, `toastHide` | `show`, `showing`, `fade`, — | +| `tooltips` | `tooltipShow`, `tooltipFade`, `tooltipVe` | `show`, `fade`, `tooltip` | + +`active`, `disabled`, `show`, `showing`, `fade`, `collapsed` and `collapsing` all come from `@arijs/bootswatch-ve/global`. `popover` and `tooltip` come from their own entries — the templates already stamped them alongside the `*Ve` aliases, so dropping those changes nothing visually. + +`toastHide` has no replacement: `.hide` has no rule in any theme's `bootstrap.css`, so there was never anything to map it to. Bootstrap's own literal `'hide'` is used for `CLASS_NAME_HIDE`. + +**91 class hashes changed.** Removing an export shifts Vanilla Extract's per-file identifier counter, so the contracts declared after it in the same module get new hashes — `dropdownItem`, `modal`, `carouselItem` and 88 others. Names and CSS ship together, so importing the name keeps working; this only matters if you hardcoded a hash string somewhere. + +## Fixed + +- **`badgeInfo` and `badgeWarning` are exported again.** Superhero is the only theme that still carries the Bootstrap 3/4 `.badge-info` / `.badge-warning` rules, and the contract builder decided liveness by looking at the `bootstrap` theme alone — so both names were dropped from the package as dead duplicates. Liveness is now the union of all 27 themes. +- **`containerFluid` pointed at a dead hash.** The contract builder collected modules by filename and skipped `layout/container.css.ts`, so the name resolved to the unstyled copy in the literal registry while the themes styled the layout one. +- **Utility CSS never loaded in granular mode.** 350 components requested a `utilities/used` family that had been retired, and an unknown family is a silent no-op outside DEV, so no utility rule was fetched on any route. Visible wherever a utility drove layout — `.flex-wrap` on a wrapping pagination, `.mb-3` losing to `.nav { margin-bottom: 0 }`. + +## Guard rails + +An exported contract with no CSS behind it can no longer reach npm. + +- `npm run check:contract-wiring` — static, runs before a build; also checks that every class token in the source themes maps to a declared contract. +- `build-contract.mjs` now asserts liveness for every exported name against the emitted CSS. It previously only checked names declared by two modules, which is exactly how the four dead contracts got published. +- Deliberate inertness is declared in `scripts/contract-wiring-allowlist.mjs` with a reason. A state hook is never a valid entry there. + +Dead exports in the package: **90 → 68**, all allowlisted (element markers, Bootstrap 3/4 names BS5 dropped, demo scaffolding, and 14 tagged `TRIAGE` pending a decision from whoever knows the original intent). + +## Verification + +- **Rule-set equivalence across all 27 themes.** Every `globalStyle` in the generated family chunks, with the old aliases normalised to the literal contracts they stood for: **0 distinct rules dropped, 0 added**. Raw rule count falls by exactly 11 per theme — the duplicated mirror rules collapsing into the generic one. +- **Screenshots, granular loader, baselines recaptured with the same browser:** bootstrap 333/339 and darkly 119/119, zero mismatches, most at `0.000000`. The 6 skipped are routes whose `@screenshot` directive height is pinned to a browser build the verification container does not have. +- 27 themes, 31 contract entries, 2209 exported classes, 0 residual literal `--bs-*`. diff --git a/scripts/_cssdiff.mjs b/scripts/_cssdiff.mjs index 45fd8b300..d6922fe7b 100644 Binary files a/scripts/_cssdiff.mjs and b/scripts/_cssdiff.mjs differ diff --git a/scripts/_fixdedupe.mjs b/scripts/_fixdedupe.mjs index d92ad2d27..9f4d1e693 100644 --- a/scripts/_fixdedupe.mjs +++ b/scripts/_fixdedupe.mjs @@ -1,44 +1,84 @@ -/** Report tokens the dedupe pass dropped that were not renamed aliases. */ -import { execFileSync } from 'node:child_process' -import { readdir, readFile } from 'node:fs/promises' -import path from 'node:path' - -const BASE = process.argv[2] ?? 'main' -const SRC = 've-project2/src/components' -const RENAMED = new Set(['navLinkActive','navLinkDisabled','pageItemActive','pageItemDisabled','accordionButtonCollapsed','toastShow','toastShowing','toastFade','toastHide','modalShowHook','modalFade','tooltipShow','tooltipFade','tooltipVe','popoverShow','popoverFade','popoverVe','btnShowHook','btnActiveHook','btnDisabledHook','dropdownMenuShow','dropdownItemActive','dropdownItemDisabled','listGroupItemActive','listGroupItemDisabled','breadcrumbItemActive','carouselActive','carouselItemActive','carouselIndicatorActive','accordionCollapseShow','accordionCollapsing','alertBtnCloseDisabledHook','collapseShow','navItemShow','tabPaneActive']) -const STATE = new Set(['active','disabled','show','showing','fade','collapsed','collapsing']) - -async function collect(dir) { - const out = [] - for (const e of await readdir(dir, { withFileTypes: true })) { - const p = path.join(dir, e.name) - if (e.isDirectory()) out.push(...(await collect(p))) - else if (/\.tsx?$/.test(e.name)) out.push(p) - } - return out -} -const counts = (s) => { - const m = new Map() - for (const x of s.matchAll(/\$\{(\w+)\}/g)) m.set(x[1], (m.get(x[1]) ?? 0) + 1) - return m -} - -let n = 0 -for (const file of (await collect(SRC)).sort()) { - let base = '' - try { base = execFileSync('git', ['show', `${BASE}:${file}`], { encoding: 'utf8' }) } catch { continue } - const before = counts(base) - const after = counts(await readFile(file, 'utf8')) - const lost = [] - for (const [t, c] of before) { - if (RENAMED.has(t)) continue - const kept = after.get(t) ?? 0 - // a state token legitimately absorbs occurrences from the aliases it replaced - if (STATE.has(t) && kept >= c) continue - if (kept < c) lost.push(`${t} ${c}→${kept}`) - } - if (lost.length === 0) continue - n++ - console.log(`${file}\n ${lost.join(', ')}`) -} -console.log(`\n${n} file(s) with dropped non-alias tokens`) +/** Report tokens the dedupe pass dropped that were not renamed aliases. */ +import { execFileSync } from 'node:child_process' +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const BASE = process.argv[2] ?? 'main' +const SRC = 've-project2/src/components' +const RENAMED = new Set([ + 'navLinkActive', + 'navLinkDisabled', + 'pageItemActive', + 'pageItemDisabled', + 'accordionButtonCollapsed', + 'toastShow', + 'toastShowing', + 'toastFade', + 'toastHide', + 'modalShowHook', + 'modalFade', + 'tooltipShow', + 'tooltipFade', + 'tooltipVe', + 'popoverShow', + 'popoverFade', + 'popoverVe', + 'btnShowHook', + 'btnActiveHook', + 'btnDisabledHook', + 'dropdownMenuShow', + 'dropdownItemActive', + 'dropdownItemDisabled', + 'listGroupItemActive', + 'listGroupItemDisabled', + 'breadcrumbItemActive', + 'carouselActive', + 'carouselItemActive', + 'carouselIndicatorActive', + 'accordionCollapseShow', + 'accordionCollapsing', + 'alertBtnCloseDisabledHook', + 'collapseShow', + 'navItemShow', + 'tabPaneActive', +]) +const STATE = new Set(['active', 'disabled', 'show', 'showing', 'fade', 'collapsed', 'collapsing']) + +async function collect(dir) { + const out = [] + for (const e of await readdir(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name) + if (e.isDirectory()) out.push(...(await collect(p))) + else if (/\.tsx?$/.test(e.name)) out.push(p) + } + return out +} +const counts = (s) => { + const m = new Map() + for (const x of s.matchAll(/\$\{(\w+)\}/g)) m.set(x[1], (m.get(x[1]) ?? 0) + 1) + return m +} + +let n = 0 +for (const file of (await collect(SRC)).sort()) { + let base = '' + try { + base = execFileSync('git', ['show', `${BASE}:${file}`], { encoding: 'utf8' }) + } catch { + continue + } + const before = counts(base) + const after = counts(await readFile(file, 'utf8')) + const lost = [] + for (const [t, c] of before) { + if (RENAMED.has(t)) continue + const kept = after.get(t) ?? 0 + // a state token legitimately absorbs occurrences from the aliases it replaced + if (STATE.has(t) && kept >= c) continue + if (kept < c) lost.push(`${t} ${c}→${kept}`) + } + if (lost.length === 0) continue + n++ + console.log(`${file}\n ${lost.join(', ')}`) +} +console.log(`\n${n} file(s) with dropped non-alias tokens`) diff --git a/scripts/capture-leaf-screenshots/preview-server.mjs b/scripts/capture-leaf-screenshots/preview-server.mjs index 8b9ecff03..5b0e81e9d 100644 --- a/scripts/capture-leaf-screenshots/preview-server.mjs +++ b/scripts/capture-leaf-screenshots/preview-server.mjs @@ -1,187 +1,187 @@ -import { execSync, spawn, spawnSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import path from 'node:path' -import process from 'node:process' -import { setTimeout as delay } from 'node:timers/promises' - -import { ROOT, VE_ROOT, VE2_ROOT } from './constants.mjs' - -export async function waitForServer(url, timeoutMs = 45000) { - const started = Date.now() - let lastError - - while (Date.now() - started < timeoutMs) { - try { - const response = await fetch(url) - if (response.ok) return - } catch (error) { - lastError = error - } - await delay(500) - } - - throw new Error(`Vite server did not become ready at ${url}: ${lastError}`) -} - -function killPortWindows(port) { - // `2>nul` is a Windows redirect; on POSIX the shell reads it as a filename and - // litters a file called `nul` in the repo root on every capture run. - if (process.platform !== 'win32') return - try { - const out = execSync(`netstat -ano -p TCP 2>nul`, { encoding: 'utf8' }) - const line = out - .split('\n') - .find((entry) => entry.includes(`:${port} `) && entry.includes('LISTENING')) - if (!line) return - const pid = line.trim().split(/\s+/).at(-1) - if (pid && /^\d+$/.test(pid) && pid !== '0') { - execSync(`taskkill /PID ${pid} /F 2>nul`, { stdio: 'ignore' }) - console.log(`Killed existing process on port ${port} (PID ${pid})`) - } - } catch { - // ignore - port may not be in use - } -} - -// package.json declares `packageManager: npm`, and pnpm refuses to run a project -// that names another manager — so the baseline app's build/preview must use npm. -function getPackageManagerCommand() { - return process.platform === 'win32' ? 'npm.cmd' : 'npm' -} - -export function buildProject() { - execSync(`${getPackageManagerCommand()} run build -- --logLevel error`, { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - }) -} - -export function buildVeProject() { - execSync(`${process.execPath} scripts/run-ve-vite.mjs build --quiet`, { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - }) -} - -export function assertBuildOutputExists() { - const distIndex = path.join(ROOT, 'dist', 'index.html') - if (existsSync(distIndex)) return - throw new Error( - 'Missing build output at dist/index.html. Run with --build (or run "npm run build") before screenshot capture.', - ) -} - -export function startPreviewServer() { - killPortWindows(4173) - const command = `${getPackageManagerCommand()} run preview -- --host 127.0.0.1 --port 4173 --strictPort` - return spawn(command, { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - shell: true, - }) -} - -export function startVePreviewServer() { - killPortWindows(4174) - return spawn(process.execPath, ['scripts/run-ve-vite.mjs', 'preview'], { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - }) -} - -export function buildVe2Project() { - const result = spawnSync( - process.execPath, - ['scripts/run-ve-vite.mjs', 'build', 've-project2', '--quiet'], - { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - }, - ) - if (result.status !== 0) { - throw new Error('VE2 build failed') - } -} - -export function assertVe2BuildOutputExists() { - const distIndex = path.join(VE2_ROOT, 'dist', 'index.html') - if (existsSync(distIndex)) return - throw new Error( - 'Missing VE2 build output at ve-project2/dist/index.html. Run VE2 verification mode (auto-build) or run "node scripts/run-ve-vite.mjs build ve-project2".', - ) -} - -export function startVe2PreviewServer() { - killPortWindows(4175) - return spawn(process.execPath, ['scripts/run-ve-vite.mjs', 'preview', 've-project2'], { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - }) -} - -export function assertVeBuildOutputExists() { - const distIndex = path.join(VE_ROOT, 'dist', 'index.html') - if (existsSync(distIndex)) return - throw new Error( - 'Missing VE build output at ve-project/dist/index.html. Run VE verification mode (auto-build) or run "node scripts/run-ve-vite.mjs build".', - ) -} - -export function startDevServer() { - ensureScreenshotsJunction() - killPortWindows(4173) - const command = `${getPackageManagerCommand()} run dev -- --host 127.0.0.1 --port 4173 --strictPort` - return spawn(command, { - cwd: ROOT, - env: process.env, - stdio: 'inherit', - shell: true, - }) -} - -function ensureScreenshotsJunction() { - const junctionTarget = path.join(ROOT, 'screenshots') - const junctionLink = path.join(ROOT, 'public', 'theme') - if (!existsSync(junctionLink)) { - try { - execSync(`mklink /J "${junctionLink}" "${junctionTarget}"`, { - stdio: 'ignore', - shell: true, - }) - } catch { - // ignore if already exists - } - } -} - -export async function stopServer(serverProcess) { - if (!serverProcess || serverProcess.killed) return - - if (process.platform === 'win32') { - const pid = serverProcess.pid - if (pid && Number.isInteger(pid)) { - try { - execSync(`taskkill /PID ${pid} /T /F 2>nul`, { stdio: 'ignore' }) - } catch { - // ignore cleanup failures - } - } - } else { - serverProcess.kill('SIGTERM') - } - - await new Promise((resolve) => { - if (serverProcess.exitCode !== null) { - resolve() - return - } - serverProcess.once('exit', () => resolve()) - serverProcess.once('error', () => resolve()) - }) -} +import { execSync, spawn, spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { setTimeout as delay } from 'node:timers/promises' + +import { ROOT, VE_ROOT, VE2_ROOT } from './constants.mjs' + +export async function waitForServer(url, timeoutMs = 45000) { + const started = Date.now() + let lastError + + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url) + if (response.ok) return + } catch (error) { + lastError = error + } + await delay(500) + } + + throw new Error(`Vite server did not become ready at ${url}: ${lastError}`) +} + +function killPortWindows(port) { + // `2>nul` is a Windows redirect; on POSIX the shell reads it as a filename and + // litters a file called `nul` in the repo root on every capture run. + if (process.platform !== 'win32') return + try { + const out = execSync(`netstat -ano -p TCP 2>nul`, { encoding: 'utf8' }) + const line = out + .split('\n') + .find((entry) => entry.includes(`:${port} `) && entry.includes('LISTENING')) + if (!line) return + const pid = line.trim().split(/\s+/).at(-1) + if (pid && /^\d+$/.test(pid) && pid !== '0') { + execSync(`taskkill /PID ${pid} /F 2>nul`, { stdio: 'ignore' }) + console.log(`Killed existing process on port ${port} (PID ${pid})`) + } + } catch { + // ignore - port may not be in use + } +} + +// package.json declares `packageManager: npm`, and pnpm refuses to run a project +// that names another manager — so the baseline app's build/preview must use npm. +function getPackageManagerCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +export function buildProject() { + execSync(`${getPackageManagerCommand()} run build -- --logLevel error`, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }) +} + +export function buildVeProject() { + execSync(`${process.execPath} scripts/run-ve-vite.mjs build --quiet`, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }) +} + +export function assertBuildOutputExists() { + const distIndex = path.join(ROOT, 'dist', 'index.html') + if (existsSync(distIndex)) return + throw new Error( + 'Missing build output at dist/index.html. Run with --build (or run "npm run build") before screenshot capture.', + ) +} + +export function startPreviewServer() { + killPortWindows(4173) + const command = `${getPackageManagerCommand()} run preview -- --host 127.0.0.1 --port 4173 --strictPort` + return spawn(command, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: true, + }) +} + +export function startVePreviewServer() { + killPortWindows(4174) + return spawn(process.execPath, ['scripts/run-ve-vite.mjs', 'preview'], { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }) +} + +export function buildVe2Project() { + const result = spawnSync( + process.execPath, + ['scripts/run-ve-vite.mjs', 'build', 've-project2', '--quiet'], + { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }, + ) + if (result.status !== 0) { + throw new Error('VE2 build failed') + } +} + +export function assertVe2BuildOutputExists() { + const distIndex = path.join(VE2_ROOT, 'dist', 'index.html') + if (existsSync(distIndex)) return + throw new Error( + 'Missing VE2 build output at ve-project2/dist/index.html. Run VE2 verification mode (auto-build) or run "node scripts/run-ve-vite.mjs build ve-project2".', + ) +} + +export function startVe2PreviewServer() { + killPortWindows(4175) + return spawn(process.execPath, ['scripts/run-ve-vite.mjs', 'preview', 've-project2'], { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + }) +} + +export function assertVeBuildOutputExists() { + const distIndex = path.join(VE_ROOT, 'dist', 'index.html') + if (existsSync(distIndex)) return + throw new Error( + 'Missing VE build output at ve-project/dist/index.html. Run VE verification mode (auto-build) or run "node scripts/run-ve-vite.mjs build".', + ) +} + +export function startDevServer() { + ensureScreenshotsJunction() + killPortWindows(4173) + const command = `${getPackageManagerCommand()} run dev -- --host 127.0.0.1 --port 4173 --strictPort` + return spawn(command, { + cwd: ROOT, + env: process.env, + stdio: 'inherit', + shell: true, + }) +} + +function ensureScreenshotsJunction() { + const junctionTarget = path.join(ROOT, 'screenshots') + const junctionLink = path.join(ROOT, 'public', 'theme') + if (!existsSync(junctionLink)) { + try { + execSync(`mklink /J "${junctionLink}" "${junctionTarget}"`, { + stdio: 'ignore', + shell: true, + }) + } catch { + // ignore if already exists + } + } +} + +export async function stopServer(serverProcess) { + if (!serverProcess || serverProcess.killed) return + + if (process.platform === 'win32') { + const pid = serverProcess.pid + if (pid && Number.isInteger(pid)) { + try { + execSync(`taskkill /PID ${pid} /T /F 2>nul`, { stdio: 'ignore' }) + } catch { + // ignore cleanup failures + } + } + } else { + serverProcess.kill('SIGTERM') + } + + await new Promise((resolve) => { + if (serverProcess.exitCode !== null) { + resolve() + return + } + serverProcess.once('exit', () => resolve()) + serverProcess.once('error', () => resolve()) + }) +} diff --git a/scripts/contract-wiring-allowlist.mjs b/scripts/contract-wiring-allowlist.mjs index 6daa1780b..447a58ebe 100644 --- a/scripts/contract-wiring-allowlist.mjs +++ b/scripts/contract-wiring-allowlist.mjs @@ -1,116 +1,115 @@ -/** - * Contracts that are deliberately unwired — no theme writes CSS for them. - * Consumed by scripts/check-contract-wiring.mjs. Every entry needs a reason. - * - * A state hook does NOT belong here. Shared Bootstrap state classes map 1:1 to the - * literal contracts (`active`, `disabled`, `show`, `fade`, `collapsed`, …) and compose - * at the call site — see docs/ve2-literal-conversion-plan.md §8.6. Adding - * `dropdownItemActive` here instead of deleting it would re-create the bug the gate - * exists to prevent: a name that type-checks, ships a hash, and styles nothing. - * - * Entries tagged TRIAGE are inherited debt: unwired, plausibly intentional, not yet - * confirmed by anyone who knows the original intent. Resolve them by either deleting - * the contract or wiring it — not by upgrading the tag. - */ - -const ELEMENT_MARKER = - 'Element marker: stamped so descendant/combinator rules can target the element. ' + - 'Carries no base style of its own.' -const BOOTSTRAP_LEGACY = - 'Bootstrap 3/4 class name that Bootstrap 5 dropped (BS5 uses .text-bg-* / .text-*). ' + - 'Kept so the published API does not break for consumers still passing it.' -const RUNTIME_MARKER = - 'Runtime-only marker: Bootstrap JS toggles it, no theme stylesheet has a rule for it.' -const APP_SCAFFOLDING = - 'Demo-page scaffolding, not a Bootstrap class. Styled by the docs app, not by a theme.' -const TRIAGE = 'TRIAGE — unwired, intent unconfirmed.' - -/** @type {Record} */ -export const UNWIRED_ALLOWLIST = { - // ── Element markers ────────────────────────────────────────────────────────── - elBlockquote: ELEMENT_MARKER, - elCite: ELEMENT_MARKER, - elDataBsTarget: ELEMENT_MARKER, - elDiv: ELEMENT_MARKER, - elEm: ELEMENT_MARKER, - elFieldset: ELEMENT_MARKER, - elFigcaption: ELEMENT_MARKER, - elForm: ELEMENT_MARKER, - elHgroup: ELEMENT_MARKER, - elHidden: ELEMENT_MARKER, - elI: ELEMENT_MARKER, - elMain: ELEMENT_MARKER, - elMark: ELEMENT_MARKER, - elNav: ELEMENT_MARKER, - elReadonly: ELEMENT_MARKER, - elS: ELEMENT_MARKER, - elSection: ELEMENT_MARKER, - elTable: ELEMENT_MARKER, - elTbody: ELEMENT_MARKER, - elTd: ELEMENT_MARKER, - elTfoot: ELEMENT_MARKER, - elTh: ELEMENT_MARKER, - elThead: ELEMENT_MARKER, - elTr: ELEMENT_MARKER, - - // ── Bootstrap 3/4 names Bootstrap 5 dropped ────────────────────────────────── - badgeDanger: BOOTSTRAP_LEGACY, - badgeDark: BOOTSTRAP_LEGACY, - badgeLight: BOOTSTRAP_LEGACY, - badgePrimary: BOOTSTRAP_LEGACY, - badgeRoundedPill: BOOTSTRAP_LEGACY, - badgeSecondary: BOOTSTRAP_LEGACY, - badgeSuccess: BOOTSTRAP_LEGACY, - badgeTextDark: BOOTSTRAP_LEGACY, - progressBarDanger: BOOTSTRAP_LEGACY, - progressBarInfo: BOOTSTRAP_LEGACY, - progressBarSuccess: BOOTSTRAP_LEGACY, - progressBarTextDark: BOOTSTRAP_LEGACY, - progressBarWarning: BOOTSTRAP_LEGACY, - spinnerDanger: BOOTSTRAP_LEGACY, - spinnerDark: BOOTSTRAP_LEGACY, - spinnerInfo: BOOTSTRAP_LEGACY, - spinnerLight: BOOTSTRAP_LEGACY, - spinnerPrimary: BOOTSTRAP_LEGACY, - spinnerSecondary: BOOTSTRAP_LEGACY, - spinnerSuccess: BOOTSTRAP_LEGACY, - spinnerWarning: BOOTSTRAP_LEGACY, - - // ── Runtime-only markers ───────────────────────────────────────────────────── - carouselSlide: - `${RUNTIME_MARKER} Bootstrap's CLASS_NAME_SLIDE; \`.slide\` has no rule in any theme's bootstrap.css.`, - - // ── Split by the divergence manifest ───────────────────────────────────────── - body: - 'Split into bodyFrame (canvas) + bodyText (typography) by the body-split divergence, ' + - 'so the full `body` contract is intentionally never emitted.', - - // ── Demo-page scaffolding ──────────────────────────────────────────────────── - actionsRow: APP_SCAFFOLDING, - bdPlaceholderImg: APP_SCAFFOLDING, - bdPlaceholderImgLg: APP_SCAFFOLDING, - cardHorizontalColBody: APP_SCAFFOLDING, - cardHorizontalColImg: APP_SCAFFOLDING, - cardHorizontalRow: APP_SCAFFOLDING, - placeholderIcon: APP_SCAFFOLDING, - toastBrand: APP_SCAFFOLDING, - toastExample: APP_SCAFFOLDING, - toastRuntime: APP_SCAFFOLDING, - toastTimestamp: APP_SCAFFOLDING, - - // ── Inherited debt ─────────────────────────────────────────────────────────── - alertBtnClose: `${TRIAGE} Maps .btn-close; literal owns btnClose.`, - btnGroupButton: TRIAGE, - btnGroupInteractive: TRIAGE, - floatingLabel: TRIAGE, - form: TRIAGE, - formControlFloating: TRIAGE, - horizontalRuleCard: TRIAGE, - inputFontFamily: `${TRIAGE} Element-group contract superseded by el* reboot rules.`, - marginEnd2: `${TRIAGE} Maps .me-2; literal owns me2.`, - mediaMiddle: TRIAGE, - navButtonReset: TRIAGE, - navbarBgLight: `${TRIAGE} Absorbs \`navbar-light bg-light\`.`, - navbarDarkBgPrimary: `${TRIAGE} Absorbs \`navbar-dark bg-primary\`.`, - rowCol: TRIAGE, -} +/** + * Contracts that are deliberately unwired — no theme writes CSS for them. + * Consumed by scripts/check-contract-wiring.mjs. Every entry needs a reason. + * + * A state hook does NOT belong here. Shared Bootstrap state classes map 1:1 to the + * literal contracts (`active`, `disabled`, `show`, `fade`, `collapsed`, …) and compose + * at the call site — see docs/ve2-literal-conversion-plan.md §8.6. Adding + * `dropdownItemActive` here instead of deleting it would re-create the bug the gate + * exists to prevent: a name that type-checks, ships a hash, and styles nothing. + * + * Entries tagged TRIAGE are inherited debt: unwired, plausibly intentional, not yet + * confirmed by anyone who knows the original intent. Resolve them by either deleting + * the contract or wiring it — not by upgrading the tag. + */ + +const ELEMENT_MARKER = + 'Element marker: stamped so descendant/combinator rules can target the element. ' + + 'Carries no base style of its own.' +const BOOTSTRAP_LEGACY = + 'Bootstrap 3/4 class name that Bootstrap 5 dropped (BS5 uses .text-bg-* / .text-*). ' + + 'Kept so the published API does not break for consumers still passing it.' +const RUNTIME_MARKER = + 'Runtime-only marker: Bootstrap JS toggles it, no theme stylesheet has a rule for it.' +const APP_SCAFFOLDING = + 'Demo-page scaffolding, not a Bootstrap class. Styled by the docs app, not by a theme.' +const TRIAGE = 'TRIAGE — unwired, intent unconfirmed.' + +/** @type {Record} */ +export const UNWIRED_ALLOWLIST = { + // ── Element markers ────────────────────────────────────────────────────────── + elBlockquote: ELEMENT_MARKER, + elCite: ELEMENT_MARKER, + elDataBsTarget: ELEMENT_MARKER, + elDiv: ELEMENT_MARKER, + elEm: ELEMENT_MARKER, + elFieldset: ELEMENT_MARKER, + elFigcaption: ELEMENT_MARKER, + elForm: ELEMENT_MARKER, + elHgroup: ELEMENT_MARKER, + elHidden: ELEMENT_MARKER, + elI: ELEMENT_MARKER, + elMain: ELEMENT_MARKER, + elMark: ELEMENT_MARKER, + elNav: ELEMENT_MARKER, + elReadonly: ELEMENT_MARKER, + elS: ELEMENT_MARKER, + elSection: ELEMENT_MARKER, + elTable: ELEMENT_MARKER, + elTbody: ELEMENT_MARKER, + elTd: ELEMENT_MARKER, + elTfoot: ELEMENT_MARKER, + elTh: ELEMENT_MARKER, + elThead: ELEMENT_MARKER, + elTr: ELEMENT_MARKER, + + // ── Bootstrap 3/4 names Bootstrap 5 dropped ────────────────────────────────── + badgeDanger: BOOTSTRAP_LEGACY, + badgeDark: BOOTSTRAP_LEGACY, + badgeLight: BOOTSTRAP_LEGACY, + badgePrimary: BOOTSTRAP_LEGACY, + badgeRoundedPill: BOOTSTRAP_LEGACY, + badgeSecondary: BOOTSTRAP_LEGACY, + badgeSuccess: BOOTSTRAP_LEGACY, + badgeTextDark: BOOTSTRAP_LEGACY, + progressBarDanger: BOOTSTRAP_LEGACY, + progressBarInfo: BOOTSTRAP_LEGACY, + progressBarSuccess: BOOTSTRAP_LEGACY, + progressBarTextDark: BOOTSTRAP_LEGACY, + progressBarWarning: BOOTSTRAP_LEGACY, + spinnerDanger: BOOTSTRAP_LEGACY, + spinnerDark: BOOTSTRAP_LEGACY, + spinnerInfo: BOOTSTRAP_LEGACY, + spinnerLight: BOOTSTRAP_LEGACY, + spinnerPrimary: BOOTSTRAP_LEGACY, + spinnerSecondary: BOOTSTRAP_LEGACY, + spinnerSuccess: BOOTSTRAP_LEGACY, + spinnerWarning: BOOTSTRAP_LEGACY, + + // ── Runtime-only markers ───────────────────────────────────────────────────── + carouselSlide: `${RUNTIME_MARKER} Bootstrap's CLASS_NAME_SLIDE; \`.slide\` has no rule in any theme's bootstrap.css.`, + + // ── Split by the divergence manifest ───────────────────────────────────────── + body: + 'Split into bodyFrame (canvas) + bodyText (typography) by the body-split divergence, ' + + 'so the full `body` contract is intentionally never emitted.', + + // ── Demo-page scaffolding ──────────────────────────────────────────────────── + actionsRow: APP_SCAFFOLDING, + bdPlaceholderImg: APP_SCAFFOLDING, + bdPlaceholderImgLg: APP_SCAFFOLDING, + cardHorizontalColBody: APP_SCAFFOLDING, + cardHorizontalColImg: APP_SCAFFOLDING, + cardHorizontalRow: APP_SCAFFOLDING, + placeholderIcon: APP_SCAFFOLDING, + toastBrand: APP_SCAFFOLDING, + toastExample: APP_SCAFFOLDING, + toastRuntime: APP_SCAFFOLDING, + toastTimestamp: APP_SCAFFOLDING, + + // ── Inherited debt ─────────────────────────────────────────────────────────── + alertBtnClose: `${TRIAGE} Maps .btn-close; literal owns btnClose.`, + btnGroupButton: TRIAGE, + btnGroupInteractive: TRIAGE, + floatingLabel: TRIAGE, + form: TRIAGE, + formControlFloating: TRIAGE, + horizontalRuleCard: TRIAGE, + inputFontFamily: `${TRIAGE} Element-group contract superseded by el* reboot rules.`, + marginEnd2: `${TRIAGE} Maps .me-2; literal owns me2.`, + mediaMiddle: TRIAGE, + navButtonReset: TRIAGE, + navbarBgLight: `${TRIAGE} Absorbs \`navbar-light bg-light\`.`, + navbarDarkBgPrimary: `${TRIAGE} Absorbs \`navbar-dark bg-primary\`.`, + rowCol: TRIAGE, +} diff --git a/scripts/pack-dist.mjs b/scripts/pack-dist.mjs index dc317fdac..af8fcd16e 100644 --- a/scripts/pack-dist.mjs +++ b/scripts/pack-dist.mjs @@ -46,23 +46,6 @@ async function listThemes() { .sort() } -/** Nomes exportados por um bundle JS de `export { a, b, c }`. */ -function parseExportNames(js) { - const m = js.match(/export\s*\{([^}]*)\}/s) - if (!m) return [] - return m[1] - .split(',') - .map((s) => - s - .trim() - .split(/\s+as\s+/) - .pop() - .trim(), - ) - .filter(Boolean) - .sort() -} - async function assembleTheme(theme) { const from = path.join(SRC, theme) const to = path.join(OUT, 'themes', theme) @@ -257,7 +240,14 @@ function packageJson(themes, contract) { bugs: { url: 'https://github.com/arijs/bootswatch-solid/issues' }, sideEffects: ['**/*.css'], exports: exportsMap, - files: [...contract.entries, 'solid', 'vite', 'themes', 'contract-manifest.json', 'README.md'], + files: [ + ...contract.entries, + 'solid', + 'vite', + 'themes', + 'contract-manifest.json', + 'README.md', + ], // O plugin /vite parseia o CSS dos temas com @adobe/css-tools. dependencies: { '@adobe/css-tools': '^4.4.0',