What each app deletes, what replaces it, and where the two copies had drifted. Every migration is meant to land as one pull request per app with no change in rendered output: each package's unit tests carry the apps' own test cases over, and the apps' Chromatic baselines are the check.
Install what the app needs:
npm install @brain-bbqs/utils @brain-bbqs/ember-client @brain-bbqs/ui
npm install --save-dev @brain-bbqs/config @brain-bbqs/test-utils| Delete | Replace with |
|---|---|
configs/eslint.config.cjs |
configs/eslint.config.js calling createEslintConfig (bbqs-uploader: complexity: 15) |
configs/prettier.config.cjs |
configs/prettier.config.js re-exporting @brain-bbqs/config/prettier (clip-extractor: printWidth: 140) |
configs/tsconfig.json's compilerOptions |
"extends": "@brain-bbqs/config/tsconfig.base.json" |
configs/vite.config.ts boilerplate |
createViteConfig({ rootDir, overrides }); app-specific aliases/externals/chunks go in overrides |
configs/vitest.config.ts |
createVitestConfig({ rootDir, thresholds, coverageExclude }) |
configs/playwright.shared.ts + both configs |
createPlaywrightConfig({ rootDir, testDir }) (encoding-helper keeps globalSetup) |
configs/storybook/main.ts, preview.ts |
createStorybookMain(...), storybookPreview (clip-extractor keeps its staticDirs) |
configs/appVersion.ts |
resolveAppVersion(new URL("../package.json", import.meta.url)) |
the inline <script> at the top of index.html |
prePaintPlugin({ themeKey, settingsKey }) in the Vite config |
.pre-commit-config.yaml's --config configs/... args |
keep them, pointing at the .js files; the prettier hook changes too (below) |
Both configs/.codespellrc and .github/workflows/* stay in the apps: they are per-repository
(deploy targets, Chromatic tokens, custom words).
What the table does not show, learned adopting it in all three apps and the web-app template:
- Prettier moves to a local pre-commit hook. The
mirrors-prettierhook runs in an isolated environment, whereconfigs/prettier.config.jscannot resolve@brain-bbqs/config. Replace it with alocal,language: systemhook runningnpx --no -- prettier --write --ignore-unknown --config configs/prettier.config.js, addprettiertoci: skipbesideeslint, add aformat:checkscript (prettier --config configs/prettier.config.js --check .) and run it in the Lint workflow, and makeprettiera direct devDependency if the app relied on the hook's copy. - The Vite config imports the storage keys from the app's settings module, with the
.tsextension and"allowImportingTsExtensions": trueinconfigs/tsconfig.json. A key that was a private constant inmain.tsmoves into that module first. - Defaults an app may not have had:
worker.format: "es"(an app on Vite'siifedefault gets module workers; ffmpeg.wasm starts its worker as a module either way), the jsdom test environment (passenvironment: "node"if that was the app's), thejsoncoverage reporter, and the shared Storybook preview (light theme pinned, Storybook's backgrounds off). - Theme and flag stores keep the app's warning.
createChoiceStore(key, values, onError)andcreateFlagStore(key, onError)pass a failed write toonError, so an app whose tests assert its own "Could not save ..." wording keeps it. - A harness that strips scripts uses
bodyOf(html, { stripScripts: true })in a jsdom suite. - Hashing errors keep the app's wording.
createEtag({ emptyFile, fileChanged })returnsplanParts,hashPart,computeDandiEtag,computeMd5andreadChunksthrowing the app's own messages (bbqs-uploader's "uploaded to DANDI", clip-extractor's "please re-load it"), andreadChunksis the chunked reader for an app-local digest such as clip-extractor's SHA-256. - Nothing rendered changes. Coverage, the integration and Chromatic specs and the Storybook build
should all come out the same as on
main; a difference is a missed option, not an expected cost.
| Delete | Replace with |
|---|---|
lib/instances.ts |
EMBER_INSTANCE from ember-client; keep OAUTH_CLIENT_ID in the app |
lib/oauth.ts |
createOAuthClient({ clientId: OAUTH_CLIENT_ID, storageKey: "clip-extractor.oauth-pkce.v1" }) |
lib/api.ts |
apiFetch, listedTitle, nextPagePath, DandisetListItem (unchanged behaviour) |
lib/errors.ts |
ApiError; friendlyError(e, CLIP_EXTRACTOR_MESSAGES) with the app's 403 wording |
lib/dandisets.ts |
listIncomingDandisets (same { datasets, unverified } shape) |
lib/humanSubjects.ts, lib/users.ts |
same names from ember-client |
lib/etag.ts |
planParts, hashPart, combineDigests, computeDandiEtag, computeMd5; keep computeSha256 in the app (its hash-wasm dependency stays app-local) |
lib/interrupt.ts |
InterruptedError, isInterruption, throwIfInterrupted from utils |
lib/queue.ts |
runQueue from utils |
lib/sanitize.ts |
sanitizeSegment(s, fallback, { whitespaceAs: "+", extraAllowed: "+" }), verbatimFilename, foldDiacritics |
lib/format.ts: bytes, initialsFrom |
same names from utils; fmtTime, rulerStep, rulerLabel stay (timeline-specific) |
lib/settings.ts |
createArchiveSettingsStore<StoredSettings>(STORAGE_KEY) plus resolveConfig |
lib/types.ts: archive and upload types |
ArchiveConfig, OAuthTokenSet, FilePart, ServerPart, UploadInitResponse, CompletedPart, Asset from ember-client |
tests/integration/layout.ts |
@brain-bbqs/test-utils/playwright |
tests/chromatic/app.chromatic.test.ts loop |
forEachViewport(test, "Main page - default (no video loaded)", ...) |
the sign-in half of tests/integration/helpers.ts (stubArchive's addInitScript and admin-check route) |
seedSignedIn(page, { storageKey: STORAGE_KEY, extraSettings: { deliveryMode: "upload" } }); the S3 and assets routes stay |
| Delete | Replace with |
|---|---|
lib/instances.ts |
EMBER_INSTANCE; keep OAUTH_CLIENT_ID |
lib/oauth.ts |
createOAuthClient({ clientId, storageKey: "bbqs-uploader.oauth-pkce.v1" }) |
lib/api.ts |
apiFetch, diagnoseCors (now omits an empty Authorization header, as clip-extractor's did) |
lib/errors.ts |
friendlyError(e, UPLOADER_MESSAGES) with the app's "dandiset" wording |
lib/dandisets.ts |
const { datasets } = await listIncomingDandisets(cfg); the new unverified count is worth surfacing in the picker's empty state |
lib/humanSubjects.ts |
same names |
lib/etag.ts |
planParts, hashPart, combineDigests; the worker pool keeps calling hashPart per part |
lib/queue.ts, lib/sanitize.ts |
runQueue; sanitizeSegment, sanitizeFilename, sanitizePath (defaults match) |
lib/format.ts |
humanSize, bytesPerSecToMBps, initialsFrom, friendlyEta from utils |
lib/settings.ts |
createArchiveSettingsStore(STORAGE_KEY), createThemeStore(THEME_KEY), createFlagStore(SPEED_TIPS_COLLAPSED_KEY), resolveConfig, configProblems |
lib/types.ts |
UploaderConfig becomes ArchiveConfig (a rename; the shape is identical) |
tests/integration/helpers/layout.ts, theme.ts, auth.ts |
@brain-bbqs/test-utils/playwright (seedSignedIn takes storageKey: STORAGE_KEY; keep the app-specific assets-listing route beside it) |
the jsonResponse/stubFetch helpers in unit tests, the Storage.prototype spies |
@brain-bbqs/test-utils/vitest |
| Delete | Replace with |
|---|---|
lib/format.ts: fmtBytes, errorMessage |
same names from utils; the duration/bitrate/rate helpers stay |
tests/integration/layout.ts, the Chromatic loop |
@brain-bbqs/test-utils/playwright |
stories/utils.ts |
copy of packages/ui/stories/utils.ts is fine to keep; storybookPreview replaces preview.ts |
Each app adopts the shell in two pull requests, so Chromatic judges the stylesheet on its own:
- 4a, stylesheet and markup. The
@import, the deleted blocks, the few rules the app keeps, and the class changes inindex.html. No TypeScript changes. - 4b, behaviour helpers. The theme toggle, auth rendering, identity, human-subjects gate,
dropzone plumbing and element lookups, from
@brain-bbqs/ui. No stylesheet or markup changes.
Each recipe below was checked in Chromium against the app's main page, before and after: every
element's computed style and box at seven widths from 375px to 1600px, in light, dark, OS-dark,
the signed-in pre-paint state and a dragover state, plus the hover colours of every visible
button and link. No box moved. The only computed values that differ are ones that cannot render:
grid placement on an <img> that is not a grid item, align-items on a single-child .dz-icon,
the fade moving from a lone footer mark's image to its link, and the colour of clip-extractor's
plain .badge while it is hidden (it is only ever shown as badge ok).
What the tables do not show:
- Keep a rule where it is. A rule the app keeps stays at its place in the app's stylesheet
rather than moving to the end, since some tie with later app rules on specificity and win on
order: clip-extractor's
button:hover:not(:disabled)has to stay ahead of.seg button.active, or the active segment tints on hover. - The
@importcomes first. CSS allows@importonly at the top of a stylesheet, so the app's own:rootblocks always follow it and override the shared palette; there is no "import after the app's tokens". - Knobs are custom properties on
:root(the ui README lists them). They are read with a fallback, so setting one never needstokens.css. - Shared values follow the majority, ties to the template, so a freshly generated app needs no overrides. Where an app is the odd one out it keeps or redeclares the listed rules. Only two undo a shared rule: bbqs-uploader's phone header, which never restacked, and clip-extractor's Talmo Lab lockup, which fades its mark and its name separately.
- The app's tests follow the class renames. Specs that select the footer marks by the old
.con-brand-linkor.talmo-brand-*classes (encoding-helper's and the template'stests/integration/smoke.spec.ts, for example) move to.footer-brand-linkand its.footer-brand-logo/.footer-brand-namechildren in the same pull request; greptests/andstories/for every class the markup changes. - Nothing rendered changes. The Chromatic baselines should come out the same on both pull requests; a difference is a missed option in the package, to be fixed there first.
4a. @import "@brain-bbqs/ui/styles/index.css"; at the top of src/style.css.
Delete from style.css |
Keep or add after the import |
|---|---|
| the three token blocks | :root { --stage-bg: #000; --page-max-width: 960px; --page-padding: 1.5rem 1rem 3rem; --code-font: var(--mono); --button-primary-disabled: var(--accent); --footer-bar-padding: 0 0.5rem 0.15rem 0.5rem; --footer-brand-height: 4rem; } and, in both dark blocks, --warn-soft: #3a2f14; --err-soft: #3a1d1d; |
*, [hidden], body, main, a, code, the focus-ring rule |
|
.site-header, .brand-logo, .brand-watermark-*, header h1, .site-title, .site-subtitle, .header-actions, the theme toggle's rules |
the whole @media (max-width: 640px) header block (the shared one restacks at 600px) |
the four #oauthSigninBtn rules, #oauthSignedIn[hidden], every .oauth-* rule |
:root[data-signed-in="1"] #deliverToggleRow[hidden] |
.card, .card-heading, .card-heading h2, button, button.primary, button:disabled, button.small, button.ghost, select, .hint, .hint.ok, .hint.err, .badge, .badge.ok, .badge.restricted |
button { white-space: nowrap; }, and button:hover:not(:disabled) and button.primary:hover:not(:disabled) where they are; .hint.stopped; #dandisetId |
the dropzone block (.dropzone through .dz-browse:hover), .view-dataset-link and its two states, .dandiset-single, .dandiset-embargo-error, the seven .human-subjects-* rules, .progress, .progress-fill, .progress-fill.ok |
.progress { margin-top: 0.75rem; } |
the footer rules (.page-footer-bar through .talmo-brand-link span) and the @media (max-width: 1400px) block |
the captioned Talmo Lab lockup: .footer-brand-link.captioned { gap: 0.2rem; opacity: 1; }, .footer-brand-link.captioned .footer-brand-logo { width: 2.7rem; height: 2.7rem; opacity: 0.312; }, .footer-brand-link.captioned .footer-brand-name { color: var(--muted); font-size: 0.7rem; line-height: inherit; letter-spacing: 0.02em; white-space: normal; opacity: 0.45; } |
Markup: class="primary oauth-signin-btn" on #oauthSigninBtn; the Talmo Lab link becomes
class="footer-brand-link captioned" aria-label="Talmo Lab", its images
footer-brand-logo on-light/on-dark and its <span> footer-brand-name; the CON link becomes
footer-brand-link and its image footer-brand-logo (html/page-footer.html is the reference).
4b.
| Delete | Replace with |
|---|---|
ui/connection.ts |
refreshIdentity(shell.account, () => fetchArchiveUser(cfg)), which resolves to the user the provenance record names |
ui/elements.ts: required and the header/footer entries |
required and getShellElements() (the default ids are this app's); the player entries stay; account is typed nullable, so throw the old required() error when it is null |
the theme toggle block in main.ts |
initThemeToggle(shell.themeToggle, { storageKey: THEME_KEY }), which keeps the "Could not save theme preference:" warning |
els.versionIndicator.textContent = ... |
renderVersion(shell.versionIndicator, __APP_VERSION__) |
renderAuthUI's hide/show and delete dataset.signedIn |
renderAuthState(shell.account, isSignedIn()); the delivery-toggle line stays |
| the human-subjects confirmed set and its click handler | createHumanSubjectsGate({ banner, unconfirmed, confirmBtn, confirmed }, onChange), mapping the app's humanSubjects* elements onto those keys, and calling gate.render(els.dandisetId.value, humanSubjectsRequired && ...) where the banner was drawn; onChange runs blurTool.render() and updateDeliveryGate(); humanSubjectsUnconfirmed() becomes humanSubjectsFlagged() && gate.isBlocking(els.dandisetId.value) |
| the sign-in and sign-out click listeners | bindAccountMenu(shell.account, { onSignIn, onSignOut }) |
wireDropzone and the click/change/window listeners |
bindDropzone(els.dropzone, { onDrop, input: els.videoFile, browseButton: els.browseVideoBtn, onPick }) and the same for slpDropzone with guardWindow: false |
4a. @import "@brain-bbqs/ui/styles/index.css"; at the top of src/style.css.
Delete from style.css |
Keep or add after the import |
|---|---|
the three token blocks, *, [hidden], body, main, a, code, the focus-ring rule |
nothing: the shared palette and knob defaults are this app's |
.site-header, .site-header > a:has(.header-logo), .header-logo, .brand-watermark-*, header h1, .site-subtitle, .oauth-row, the theme toggle's rules |
@media (max-width: 600px) { .site-header { grid-template-columns: 1fr auto 1fr; } .header-logo-link, .header-actions, .site-header > h1 { grid-column: auto; grid-row: auto; } }: this header does not restack on a phone, the others do |
the three #oauth-signin-btn rules, its data-signed-in rule, #oauth-signed-in[hidden], every .oauth-* rule |
:root[data-signed-in="1"] #folder-card[hidden] |
.card, .card h2, .card-heading, .card-heading h2, select, button, button.primary, button:disabled, button.primary:disabled, .badge and its five kinds |
.badge.mute, .grid |
#dropzone, #dropzone.dragover, .dz-icon, .dz-inner p, .dz-browse, .dz-hint, .dz-inner .dz-reject |
|
.view-dataset-link and its two states, .dandiset-single, .dandiset-embargo-error, the seven .human-subjects-* rules |
.btn-arrow, .load-remote-btn, and the combined .view-dataset-link, .load-remote-btn padding rule |
.page-footer-bar through .version-link, .footer-dot, .con-brand-link img, the @media (max-width: 1400px) block |
.whats-new-link and the What's New modal; the Clear checksum cache button keeps whats-new-link too, since .footer-button-link:disabled would grey it while it is disabled; its own .progress rules, which draw a different bar |
Markup: class="primary oauth-signin-btn" on #oauth-signin-btn; class="header-logo-link" on
the header's logo link; .oauth-row becomes class="header-actions"; class="dropzone" on
#dropzone; the CON link becomes footer-brand-link inside a new <div class="footer-brands">,
its image footer-brand-logo.
4b.
| Delete | Replace with |
|---|---|
ui/connection.ts |
refreshIdentity(shell.account, () => fetchArchiveUser(cfg)) |
ui/elements.ts: required and the shell entries |
getShellElements({ themeToggle: "theme-toggle", oauthSigninBtn: "oauth-signin-btn", oauthSignedIn: "oauth-signed-in", oauthAvatar: "oauth-avatar", oauthUsername: "oauth-username", oauthSignoutBtn: "oauth-signout-btn" }); spreading shell.account into els keeps one flat lookup (and the id contract test), and getElements() throws when the sign-in button is missing, since the package treats it as optional |
the theme toggle block, saveStoredTheme/loadStoredTheme |
initThemeToggle(shell.themeToggle, { storageKey: THEME_KEY }) |
els.versionIndicator.textContent = ... |
renderVersion(shell.versionIndicator, __APP_VERSION__) |
renderAuthUI's first two lines and delete dataset.signedIn |
renderAuthState(shell.account, isSignedIn()); the visibility updates stay |
| the human-subjects confirmed set and its click handler | createHumanSubjectsGate({ banner, unconfirmed, confirmBtn, confirmed }, onChange), mapping the app's humanSubjects* elements onto those keys, and calling gate.render(els.dandisetId.value, humanSubjectsRequired && !!els.dandisetId.value); humanSubjectsUnconfirmed() becomes gate.isBlocking(...) |
ui/dropzone.ts: showReject, the drag/click/change listeners and window guard |
showDropzoneReject(els.dropzoneReject, message) and bindDropzone(els.dropzone, { onDrop, input: els.folderInput, browseButton: els.browseFolderBtn, onPick }); the folder walk and messages stay |
| the sign-in and sign-out click listeners | bindAccountMenu(shell.account, { onSignIn, onSignOut }) |
4a. @import "@brain-bbqs/ui/styles/shell.css"; at the top of src/style.css, and nothing
else from the package: its own palette, body layout and controls stay, and shell.css reads
only tokens that palette defines.
Delete from style.css |
Keep or add after the import |
|---|---|
.site-header, .site-header h1, .brand-logo, .brand-watermark-*, .header-actions, the theme toggle's rules (with :focus-visible) |
:root { --site-title-size: 2em; --footer-bar-padding: 0 0.75rem 0.35rem 0.5rem; --footer-brand-height: 60px; }, .site-header { gap: 12px; }, .brand-watermark-link { top: 2px; left: 6px; }; its own h1 and .subtitle |
the four header rules inside @media (max-width: 600px) |
the rest of that block |
.page-footer-bar through .footer-button-link:disabled, .footer-brands, .footer-brands > a, .footer-brands img, .con-brand-link img, the .talmo-brand-* rules |
.footer-brands { gap: 12px; }, and the whole @media (max-width: 1560px) block, which takes the footer out of fixed positioning earlier than the shared 1400px |
Markup: the Talmo Lab link becomes footer-brand-link captioned, its images
footer-brand-logo on-light/on-dark and .talmo-brand-name footer-brand-name; the CON link
becomes footer-brand-link and its image footer-brand-logo.
4b.
| Delete | Replace with |
|---|---|
lib/dom.ts: h, button, svgEl, svgText, escapeHtml, resetIcon, copyToClipboard, and the private writeClipboard |
same names from ui (cmdBlock imports writeClipboard); gridItem, infoIcon, teachBox, fold, dataTable, cmdBlock, section and closeInfoPopovers stay (app-only, most of them read the educational toggle) |
the theme toggle block in main.ts |
initThemeToggle(els.themeToggle, { storageKey: THEME_KEY }) |
els.versionIndicator.textContent = ... |
renderVersion(els.versionIndicator, __APP_VERSION__) |
ui/elements.ts: required |
required from ui (getShellElements() also fits: default ids, and account is null) |
It has the theme toggle, watermark, header and footer, but no account menu, human-subjects banner,
dataset picker, .site-title block or captioned mark; its only footer mark is CON, and its
dropzone is drawn its own way (a muted zone that also highlights on hover, a plain red reject
line), so it skips dropzone.css.
4a. At the top of src/style.css:
@import "@brain-bbqs/ui/styles/tokens.css";
@import "@brain-bbqs/ui/styles/base.css";
@import "@brain-bbqs/ui/styles/shell.css";
@import "@brain-bbqs/ui/styles/controls.css";
@import "@brain-bbqs/ui/styles/dataset-picker.css";
@import "@brain-bbqs/ui/styles/human-subjects.css";Delete from style.css |
Keep |
|---|---|
the three token blocks, *, [hidden], body, main, a, code, the focus-ring rule |
.card-note, .file-summary* |
.site-header, .site-header > a:has(.header-logo), .header-logo, header h1, .header-actions, .brand-watermark-*, .site-subtitle, the theme toggle's rules |
the dropzone rules and their reduced-motion block |
.card, .card h2, .card-heading, .card-heading h2, button, button.primary, button:disabled, button.primary:disabled |
|
.page-footer-bar through .version-link, .footer-dot, .con-brand-link img, the @media (max-width: 600px) and @media (max-width: 1400px) blocks |
.whats-new-link and the What's New modal |
Markup: class="header-logo-link" on the header's logo link; the CON link becomes
footer-brand-link inside a new <div class="footer-brands">, its image footer-brand-logo.
4b. getShellElements({ themeToggle: "theme-toggle" }) (account is null),
required from ui in place of the local one,
initThemeToggle(shell.themeToggle, { storageKey: THEME_KEY }) in place of the toggle block,
saveStoredTheme and loadStoredTheme (the settings test then pins THEME_KEY instead), renderVersion(...), and in ui/dropzone.ts showDropzoneReject plus
bindDropzone(els.dropzone, { onDrop, input: els.fileInput, browseButton: els.browseFileBtn, onPick }).
Two things every app shares from here: the footer's "What's New" link with the modal that shows
the app's CHANGELOG.md (@brain-bbqs/ui), and one way of unit-testing the page
(@brain-bbqs/test-utils/vitest): main.ts booted against the real index.html by
createMainHarness, one test file per boot scenario, and the index.html/elements.ts id
contract checked both ways by expectIdContract.
Each app takes both in one pull request, after the releases carrying them (ui 0.3.0 and
test-utils 0.3.0): npm install @brain-bbqs/ui@^0.3.0 and
npm install --save-dev @brain-bbqs/test-utils@^0.3.0. bbqs-uploader and the web-app template
already have What's New, so for them it is a move with no rendered change. clip-extractor and
encoding-helper gain What's New as a new feature: their pull requests are user-facing, bump the
minor version, and take an Enhancement entry such as "Added a What's New link to the footer
listing recent changes."
Each recipe below was carried out on a copy of the app's main against this package source.
- bbqs-uploader and the template render identically, checked in Chromium against a build of
main: every computed style and box of the link and of every element in the modal, plus the pixels, in light and dark, at 1600px and 390px. That held closed, open, expanded, with the link, × and "Show more" hovered, and with keyboard focus. Their unit suites pass unchanged apart from the edits listed (the uploader's 339 tests; the template's 27 plus its new contract test), above their coverage floors. For all four apps'CHANGELOG.mdfiles, the package's renderer builds, node for node, the DOM the apps'innerHTMLrenderer did. - clip-extractor and encoding-helper, once the fragment is pasted in, draw the link and modal
with the same sizes as bbqs-uploader's. The only differences are ones each app already makes
everywhere: clip-extractor's
button { white-space: nowrap }and--monocode font; encoding-helper's own font stack, 1.6 line height and palette. Both suites still pass.
What the recipes do not show:
- The dialog is named by its heading. The fragment carries
aria-labelledby="whats-new-modal-title"on the<dialog>and that id on its<h2>, so a screen reader announces "What's New" when it opens. bbqs-uploader and the template add both attributes to the markup they already have; it changes nothing on screen, andexpectIdContractcounts the heading's id as registered by the reference. - The fragment goes in two places.
html/whats-new.htmlholds the link's.footer-row, which goes first in.footer-left(above "Report a bug"), and the<dialog>, which goes after the footer bar's closing</div>. The ids are the uploader's and the template's (whats-new-button,whats-new-modal,whats-new-close,whats-new-content,whats-new-show-more); keep them, andgetWhatsNewElements()needs no arguments. - The changelog arrives with Vite's
?rawimport (import changelog from "../CHANGELOG.md?raw"), typed by thevite/clienttypes@brain-bbqs/config/tsconfig.base.jsonalready includes. No plugin, no fetch, and Vitest serves it the same way to the boot tests. - Register the modal in
elements.tsaswhatsNew: getWhatsNewElements(), so its ids are part of the contract, and callinitWhatsNew(els.whatsNew, { changelog })besiderenderVersion. - The deep link is
#changelog, not a?testinjection:/#changelogopens the modal, for a person, a Playwright spec or a Chromatic snapshot alike. Opening writes the fragment; every close (×, backdrop, Escape) strips it again. - The harness is created once per app, in
tests/unit/helpers/mainHarness.ts, asexport const { bootMain } = createMainHarness({ importMain: () => import("../../../src/main") }). The import has to stay in the app's own code: written there, Vitest transforms it and the test file'svi.mockcalls apply to what it imports. - Seeding and stubbing before
bootMain()still work. Creating the harness, not booting, empties storage, so a test's ownlocalStorage.setItembeforebootMain()survives; and afetchthe test stubbed itself is left in place (otherwise every call fails and is recorded, in(await bootMain()).fetch.calls). - Coverage thresholds do not move. clip-extractor keeps measuring
src/lib/**only; encoding-helper drops itssrc/main.tsexclusion, since the harness now boots it (its boot tests cover it fully). - The two apps gaining the feature take the changelog rule the others have in their
CLAUDE.md: never mention?testinjections inCHANGELOG.md, since it now feeds the modal.
First, since every new app is generated from it.
| Delete | Replace with |
|---|---|
src/lib/changelog.ts, tests/unit/changelog.test.ts |
the package's renderer; its cases moved into packages/ui/tests/unit/changelog.test.ts |
main.ts: from "The modal opens on the latest few versions" through if (window.location.hash === CHANGELOG_HASH) ... |
initWhatsNew(els.whatsNew, { changelog });, with initWhatsNew from @brain-bbqs/ui; drop the ./lib/changelog import |
elements.ts: the five whatsNew* entries |
whatsNew: getWhatsNewElements(), |
style.css: the What's New section (its banner comment, .whats-new-link through .whats-new-content li) |
@import "@brain-bbqs/ui/styles/whats-new.css"; after the human-subjects.css import |
tests/unit/helpers/mainHarness.ts: el, installMatchMedia, installDialogPolyfill, the body of bootMain |
createMainHarness as above, export { el } from "@brain-bbqs/test-utils/vitest", and export const pickFile = (file: File): void => pickFiles("file-input", [file]); |
main.smoke.test.ts: the ../../src/lib/changelog import |
countChangelogVersions from @brain-bbqs/ui |
Add tests/unit/elements.test.ts:
expectIdContract({ html: readIndexHtml(), lookups: [getShell, getElements] }). No page id is
unregistered today. index.html does not change. In SECURITY.md, the What's New modal leaves
the list of innerHTML uses: it now builds DOM nodes and never parses markup. In AGENTS.md,
the Tests section can name createMainHarness and expectIdContract as what the harness and
the contract test are built on.
| Delete | Replace with |
|---|---|
src/lib/changelog.ts, tests/unit/changelog.test.ts |
the package's renderer |
main.ts: from "The modal opens on the latest few versions" through if (window.location.hash === CHANGELOG_HASH) ... |
initWhatsNew(els.whatsNew, { changelog });; drop the ./lib/changelog import |
elements.ts: the five whatsNew* entries |
whatsNew: getWhatsNewElements(), |
style.css: the "Kept rather than the shared .footer-button-link" comment and .whats-new-link through .whats-new-content li |
nothing: index.css now imports whats-new.css. The Clear checksum cache button keeps its whats-new-link class |
tests/unit/helpers/mainHarness.ts: bodyFromIndexHtml, el, installMatchMedia, installDialogPolyfill, bootMain's body |
createMainHarness as above, el re-exported, and fakeFolderFile = (name, relativePath, size?) => fakeFile(name, { relativePath, size }), pickFolder = (files) => pickFiles("folder-input", files) |
main.smoke.test.ts: its inline bodyFromIndexHtml, el, fakeFolderFile, pickFolder and hand-rolled boot |
the helpers' imports and await bootMain() |
main.signed-out-override.test.ts: its hand-rolled boot |
await bootMain({ url: "?test&signed_out&mock_upload=0", matchMedia: true }) |
elements.test.ts: the readFileSync of index.html and the "finds every element" case |
expectIdContract({ html: readIndexHtml(), lookups: getElements }); keep the it.each of missing ids |
The ten boot tests that seed localStorage and the ones stubbing fetch before bootMain() stay
as they are. The contract finds two ids no lookup registers: #speed-tips (its rules key on the
.speed-tips class) and #config-card (unused). Drop both from index.html (and
#config-card from stories/DatasetCard.stories.ts, which copies it). In SECURITY.md, the "What's New" modal leaves the
list of innerHTML uses.
A new feature here. main.ts boots under the harness with no extra stubs.
| Add | What |
|---|---|
index.html |
html/whats-new.html: the row first in .footer-left, the <dialog> after the footer bar |
style.css |
@import "@brain-bbqs/ui/styles/whats-new.css"; after the shell.css import, and --whats-new-code-bg: var(--chip-bg); in the knobs' :root (its dark --accent-soft is too close to its --card for a code span to show) |
ui/elements.ts, main.ts |
whatsNew: getWhatsNewElements(); import changelog from "../CHANGELOG.md?raw" and initWhatsNew(els.whatsNew, { changelog }) after renderVersion |
tests/unit/helpers/mainHarness.ts |
createMainHarness as above |
tests/unit/main.smoke.test.ts |
a boot test: the version stamp, and What's New opening from the link and at #changelog |
| Delete | Replace with |
|---|---|
elements.test.ts: the first case's mountHtml/getElements walk |
expectIdContract({ html: readIndexHtml(), lookups: getElements }), keeping its specific assertions |
atomsTab.test.ts: FakeResizeObserver |
const stub = installObserverStub("ResizeObserver") in beforeEach, stub.restore() in afterEach; FakeResizeObserver.instances becomes stub.instances, whose observed, disconnected and fire() are as before |
inspectToc.test.ts: StubIntersectionObserver and its observers list |
installObserverStub("IntersectionObserver") the same way; observers[0] becomes stub.instances[0] |
dom.test.ts, analysisTab.test.ts: vi.stubGlobal("navigator", { ...navigator, clipboard }) and the execCommand define |
stubClipboard(writeText) and installExecCommand(execCommand), still handed the vi.fns asserted on, each restored in afterEach |
The contract registers all 28 ids with What's New in and finds none unregistered. The footer gains a row, so every page snapshot's footer changes in Chromatic, as a new feature should.
A new feature here too; style.css needs nothing, since index.css now imports whats-new.css.
| Add | What |
|---|---|
index.html |
html/whats-new.html: the row first in .footer-left, the <dialog> after the footer bar |
ui/elements.ts, main.ts |
whatsNew: getWhatsNewElements(); import changelog from "../CHANGELOG.md?raw", and initWhatsNew in main.ts's @brain-bbqs/ui import (getWhatsNewElements only in elements.ts's), with initWhatsNew(els.whatsNew, { changelog }) after renderVersion |
tests/unit/elements.test.ts |
its first id-contract test: expectIdContract({ html: readIndexHtml(), lookups: [getElements, () => getShellElements()] }) |
tests/unit/helpers/mainHarness.ts |
createMainHarness({ importMain, canvas: true, observers: ["ResizeObserver"] }): main.ts needs a 2D context for the stage and a ResizeObserver for the blur tool to boot at all |
tests/unit/main.smoke.test.ts |
a boot test, signed out: the version stamp, the empty stage, What's New; booting makes no fetch |
#selbarWrap and #speedGroup are styled by id in style.css and looked up by nothing, so
elements.ts registers them; the contract then passes without a pageOnly list. settings.test.ts's
vi.spyOn(Storage.prototype, "setItem") can become throwingStorage(). As for encoding-helper,
the footer's new row shows up in Chromatic.
@brain-bbqs/configfirst: it changes no runtime code, and the diff is the deletedconfigs/.@brain-bbqs/utilsand@brain-bbqs/test-utils: pure functions and test scaffolding, one PR.@brain-bbqs/ember-client: the app's unit tests for the deleted modules move with them; what remains are the app's tests of its ownmain.ts.@brain-bbqs/ui, in two pull requests per app (see its section above): 4a, the stylesheet import and the markup's new classes, where Chromatic shows any drift on its own; then 4b, the behaviour helpers, which change no pixel.- What's New and the unit tests, one pull request per app once ui 0.3.0 and test-utils 0.3.0 are published: the web-app template first, then bbqs-uploader (both with no rendered change), then encoding-helper and clip-extractor, which gain What's New as a feature.