Skip to content

Feature/refactor - #115

Merged
danielhstahl merged 15 commits into
masterfrom
feature/refactor
Sep 13, 2026
Merged

danielhstahl merged 15 commits into
masterfrom
feature/refactor

Conversation

@danielhstahl

Copy link
Copy Markdown
Member

No description provided.

Covers workspace-pge and workspace-ojh.

Toolchain (workspace-pge):
- Declare @ant-design/icons, react and react-dom as explicit dependencies;
  they were imported but never declared.
- Add typecheck / test / test:watch scripts, engines "^22.12.0 || >=24.0.0"
  and .nvmrc 24.
- src/setupTests.js now imports '@testing-library/jest-dom/vitest' and is
  wired in via setupFiles; previously the setup file was never loaded.
- Add a harness test so a silently-broken test harness cannot ship again.

Spec bootstrap (workspace-ojh):
- src/swagger_spec.json is git-ignored yet imported by App.tsx, so a fresh
  clone could not build. `npm run spec` now materializes it.
- scripts/downloadYML.js fetches scripts/releases.json itself, treats the
  GitHub token as optional (the asset is public), retries with backoff,
  validates the YAML, and writes only on full success so a failed run can
  never clobber a good spec.
- scripts/checkSpec.js runs as prebuild/predev, so a missing spec fails
  with a message naming `npm run spec` instead of an unresolved-import error.
- CI: both workflows use `npm run spec`, .nvmrc and npm caching. test.yml
  now actually runs typecheck and `npm test` - it previously only built,
  which was a workspace-pge criterion that had not been implemented.

Note: regenerating the spec picked up a real upstream change vs the stale
local copy - /cgmy/riskmetric and /cgmyse/riskmetric differ in where
`additionalProperties: false` attaches (semantically minor).
…m App

Covers workspace-938 and workspace-7da.

Logo and module renames (workspace-938):
- Remove the unused `import React` that failed noUnusedLocals under strict TS.
- Move Logo to src/components/Logo.tsx with a typed LogoProps interface
  (height/width/className, defaulting to 1em/1em/logo-primary).
- Rename styles.tsx -> styles.ts and copyToClipboard.tsx -> .ts; neither
  contains JSX, so the .tsx extension was wrong.

Firebase initialization (workspace-7da):
- src/firebase.ts now owns config composition (src/config.json plus the
  build-time VITE_FirebaseAPIKey) and a lazy, memoised singleton.
- App.tsx no longer calls initializeApp()/getAuth() at module scope, so
  importing it performs no side effects and components can be rendered in
  tests without live Firebase wiring, or with a stubbed module.
- Also drops the stale `process.env.REACT_APP_FirebaseAPIKey` CRA comment.

Verified: tsc clean, build passes, tests green. The lazy-init invariant was
proven in a browser by asserting getApps() is empty after importing App.tsx
and equals one only after the first getFirebaseAuth() call.
…pboard API

copyToClipboard was a vendored gist built on the deprecated
document.execCommand('copy'). It injected a hidden <textarea>, clobbered the
user's existing text selection, leaned on four non-null assertions against
document.getSelection(), and could not report failure - so the UI always
claimed the token had been copied.

It also carried outright dead code: el.contentEditable and el.readOnly were
saved from a freshly created element (so always the defaults) and restored
after that element had already been removed from the DOM.

navigator.clipboard.writeText needs none of that: no DOM injection, no
selection to trample, and it reports failures asynchronously.

A legacy execCommand fallback is deliberately NOT kept. Every browser in the
project's browserslist supports the async API ("not dead" excludes IE11 and
legacy Safari) and both deploy targets are secure contexts - GitHub Pages
serves HTTPS and vite dev runs on localhost - so the fallback would only add
deprecated surface for browsers this project does not support.

The caller now awaits the promise and shows a distinct failure message,
distinguishing an unsupported context from a refused write.
…E_TAG

Environment configuration was implicit and partly dead: the key arrived via
import.meta.env with a stale Create React App-era comment describing a
process.env variable that no longer applied, and CI appended VITE_TAG to .env
on every deploy while nothing in src ever read it, so the release tag never
reached the page.

src/env.ts is now the only place env is read, typed via an ImportMetaEnv
augmentation, and it validates the required key before render. A misconfigured
deployment shows a readable Configuration error panel instead of a fully
rendered page whose auth calls fail for reasons nothing on screen explains.

Two details worth recording:

The validation logic is written as pure functions taking the env as an
argument, because Vite replaces import.meta.env.* with literals at transform
time. They therefore cannot be stubbed at runtime, and an earlier test using
vi.stubEnv passed only by coincidentally matching a local .env - it would have
gone red on CI, which runs without one. Purity keeps every branch testable.

firebaseOptions became a function rather than a module-scope const: calling
the validator from a const would have moved the failure back to import time,
which is exactly what the Firebase extraction removed.

VITE_TAG is surfaced in the footer rather than dropped, which keeps the
existing outputTag deploy machinery meaningful. Note it is the
option_price_faas release tag, not this site's package version.
…real project

The README was the original CRA template. It documented npm start and
npm run eject - neither exists here - described npm test as interactive watch
mode, pointed at a build/ output directory this project does not use, and said
nothing about the actual stack, the required environment variables, the
OpenAPI spec bootstrap, or the GitHub Pages deploy path.

Rewritten against verified ground truth: every documented command was checked
against package.json scripts, and every internal link against the filesystem.

Three errors were caught in the draft by that check rather than left in place:
the Vitest config is vitetest.config.ts, not vitest.config.ts; scripts/releases.json
is listed as generated and git-ignored rather than as a committed file, since
presenting it otherwise repeats the exact mistake this README exists to fix;
and the migration note was reworded so it does not reintroduce the literal
legacy command strings it is describing as gone.

Troubleshooting entries are drawn from failures actually observed during this
work, not generic boilerplate.
…mponents

App.tsx mixed the auth observer, the token fetch, the signed-in/out branching
and all presentation in one 120-line component. It is now a thin composition
root: state lives in src/hooks/useAuth.ts, presentation in
src/components/{AppHeader,TokenNotice,ApiDocs,AppFooter,AuthLoading}.tsx.
App.tsx no longer imports firebase/auth at all.

Two defects the decomposition fixed on the way:

A failed getIdToken ended in a catch that only wrote to the developer console,
so the UI rendered a signed-in shell holding an empty token and Copy Token
cheerfully copied nothing. The hook now records the failure and the root
renders it.

There was no loading state. Firebase delivers the persisted session
asynchronously, so until that first callback landed the code had to guess, and
it guessed signed-out - flashing the login buttons at returning users. The
hook exposes loading/authenticated/signed-out and AuthLoading covers the gap.

useAuth takes an optional injected Auth so a test can supply a fake instead of
live Firebase wiring, which was the stated motivation for extracting Firebase in
the first place. The lazy, memoised singleton is only touched at render time, so
importing the hook stays side-effect free.

Verified with a throwaway suite (5 tests, all passing) covering the loading ->
authenticated transition, signed-out resolution, the token-failure path,
observer unregistration on unmount, and that importing App.tsx still creates no
Firebase app. That file is deliberately not committed here - the permanent test
suite is workspace-czo's deliverable - but the scenarios above are the ones to
adopt.
The header used floats for both of its children: the logo floated leading and
the menu floated trailing. Nothing contained the floats, so the vertical
alignment was faked with paddingTop = (64 - 50) / 2 recomputed in JS, and
the trailing float was free to collide with the brand instead of being
pushed away from it.

Both floats are gone. The header is a flex container with align-items: center
and the menu gets margin-inline-start: auto, which absorbs the remaining
space and makes overlap structurally impossible. The paddingTop constant and
its arithmetic are deleted rather than ported.

Spacing is now a named scale of CSS custom properties in index.css
(--space-xs..--space-4xl, --header-height, --logo-size). index.css holds the
values so there is exactly one place to retune them; every consumer
references a token. The horizontal gutter is a clamp(), so header, content
and footer share one rhythm that is 16px on a phone and 48px on a desktop
with no breakpoint bookkeeping. The old fixed 50px gutter ate 100px of a
320px screen.

Also deletes .App (referenced nowhere) and the now-unused src/styles.ts, and
moves the config-error panel off inline magic pixels onto a .config-error
class. That class lives in index.css, not App.css, because the failed-config
branch never mounts App and so never loads App.css.

Verified across 320/375/414/768/1024/1440/1920px in a real browser: no
document overflow, logo contained in the header band, and logo right edge
never crossing the menu left edge. The suite was validated with a negative
control - widening the logo to 400px fails exactly the 320/375/414 cases and
still passes 768+, confirming the assertions measure real overflow rather
than passing vacuously. Those viewport tests are held back for the permanent
suite (workspace-czo); tsc and the existing 19 tests pass, build is clean.
…in failures

Adding an OAuth provider previously meant copying a whole <Row><Col> block and
its pair of empty spacer columns, then wiring another button by hand. Each
provider also had its AuthProvider constructed at module scope, and every
signInWithPopup call was fire-and-forget with no catch.

Providers now live in a config array (src/authProviders.ts). Adding one is a
single entry: key, label, icon, and a create() factory. The login panel maps
over it, so no component, markup or layout change is needed, and new buttons
inherit width, centring, pending state and error handling for free.

Sign-in failures are now visible. describeSignInError maps the Firebase codes
users actually hit - popup closed, popup blocked, network failure, account
already exists, too many requests - onto a sentence they can act on, with
unknown codes still surfacing the underlying message. useAuth owns signIn and
tracks pendingProvider so a second click cannot stack a request Firebase
would cancel anyway.

Providers are built in create() at sign-in time rather than at import. Beyond
keeping the import side-effect free, this gives every attempt a fresh
provider, so a cancelled popup cannot leave stale scopes behind for the next.

Centring uses flexbox with a max-width token instead of empty grid columns, so
it works at 320px rather than only at md and up. Also drops the
react-social-login-buttons dependency: it baked 29 hardcoded brand colours
into its own JS, which no ConfigProvider theming could reach, and conflicts
with the token-based theming direction. antd Buttons with @ant-design/icons
brand glyphs cover it.

Found while verifying, and fixed:
- describeSignInError used `cause instanceof Error ? cause.message : String(cause)`,
  so a thrown object literal collapsed to "[object Object]" and the user saw
  nothing useful. Now reads `message` structurally.
- This project has no global box-sizing reset and antd only sets border-box on
  its own components, so any of our own elements using width:100% with
  horizontal padding would overflow the viewport. Added a reset scoped to our
  classes; a global * reset would reach into Swagger UI, whose layout we
  neither own nor want to perturb.

Verified with 19 throwaway tests (38 total, all passing) covering: one button
per config entry for any count; the shipped config rendering all three
providers; the whole entry reaching signIn; rendering constructing no provider;
shipped factories returning a fresh instance per call; error mapping for
closed/blocked/network/unknown/non-Error; other buttons disabled while one is
in flight; and centring at 320/375/768/1920 with equal side gaps, width capped
at the token, tappable targets and no document overflow. An end-to-end test
drives the real App with a mocked popup: the click reaches signInWithPopup with
a real GoogleAuthProvider, and the mapped message appears in the DOM. Held
back for the permanent suite (workspace-czo), preserved at
/tmp/sju-verified-login.test.tsx and /tmp/sju-verified-signin-e2e.test.tsx.
The 'Adding a sign-in provider' example told readers to call
new OAuthAuthProvider("microsoft.com"). No such export exists - the real
generic class is OAuthProvider. Confirmed against the @firebase/auth
typings (OAuthProvider: 33 hits, OAuthAuthProvider: 0) and reproduced:

  TS2724: '"firebase/auth"' has no exported member named 'OAuthAuthProvider'.
          Did you mean 'OAuthProvider'?

Self-inflicted in the previous commit and caught only by checking my own
claim instead of assuming it. Also verified the corrected snippet actually
typechecks against the real ProviderConfig type, and moved LoginPanel into
the components/ block of the repository-layout tree instead of listing it
as a stray top-level path.
Addresses the four acceptance criteria:

1. `npm test` runs a non-zero number of tests and passes locally: 66 tests,
   9 files.
2. The test workflow fails the build on test failure. Verified empirically rather
   than assumed: a deliberately failing test exits 1; a run matching zero test
   files also exits 1, so the suite cannot quietly empty itself and still report
   green; no continue-on-error anywhere in the workflows.
3. useAuth, copyToClipboard, the login component and the header/menu all have
   tests. The first three were held out of the tree pending this ticket and are now
   permanent; the header/menu gap is filled by AppHeader.test.tsx, which tests
   behaviour (the Log Out item tracks session state and fires exactly once)
   rather than only the geometry the viewport suite already measures.
4. The stale jest-dom import is gone; setupTests.js uses the /vitest subpath.

Also makes coverage reporting real. The config declared a `coverage` block but
@vitest/coverage-v8 was never installed, so `--coverage` died with MISSING
DEPENDENCY - a trap waiting for anyone who tried it. Installed the matching
provider and added `npm run test:coverage`. Now 80% lines overall, with the four
AC-critical modules at useAuth 87%, copyToClipboard 100%, LoginPanel 100%,
AppHeader 100%.

Failure screenshots and .vitest-attachments are gitignored; browser mode writes
them into src/ and they would otherwise land in the repo.

Switched the viewport tests off the deprecated @vitest/browser/context import to
vitest/browser. This suite is meant to be durable, so leaving an import that
"will stop working in the next major version" in every responsive test was the
kind of latent time bomb this ticket exists to remove. Verified the replacement
provides page.viewport before converting all three files.

Three findings worth recording, all from testing instead of assuming:

- I was convinced TokenNotice was broken. antd Alert's heading prop is `message`,
  and the component passes `title`, so I expected a headingless alert. antd
  6.3.1 reports the opposite: "message is deprecated, use title". The component
  was already correct and my intended fix would have broken it. Only the
  deprecation warning surfaced this; grepping for the old prop name would have
  found nothing and left the code untouched by accident rather than by knowledge.

- Two copy-feedback tests were passing vacuously. antd renders `message` into its
  own portal that survives unmount(), so tests 2 and 3 were reading test 1's
  leftover toast. A clean-state guard made them fail immediately. The fix is
  message.destroy() in beforeEach. Wiping document.body.innerHTML is wrong in a
  subtler way: antd caches that container, so later toasts render into a detached
  node that never appears, which looks like the feature broke rather than like the
  teardown was wrong.

- The coverage table in the terminal render is truncated and omitted files that
  were in fact exercised (AppHeader, LoginPanel showed nothing). Reading
  coverage-final.json directly gave the true per-file numbers. Trusting the
  console render would have led me to report coverage of modules I could not see.

These are documented in README under "Gotchas when writing tests here", along
with the render/await, effect-flush and viewport-API traps.
There was no ConfigProvider anywhere in the app; the header said theme="dark"
in the component and the logo's fill was a hex literal in the stylesheet.
Re-skinning meant hunting colours through JSX and CSS.

src/theme.ts is now the only file in src/ that contains a colour literal. It
feeds both consumers from one palette per mode: antd via
<ConfigProvider theme={themeFor(mode)}>, and our own stylesheet via CSS
custom properties applied to the document element. App.css lost its hex,
AppHeader lost its hardcoded menu scheme (now a prop sourced from the theme),
and the config-error panel is themed too because the vars are applied before
the try/catch rather than inside the happy path.

The brand colour is deliberately not the button colour. BRAND.accent is
3.90:1 on white - clears WCAG's 3:1 for the logo as non-text content, fails
the 4.5:1 needed for text. BRAND.action is the same hue darkened until white
label text clears AA in all three states (rest 5.48, hover 4.97, active
7.70), because WCAG applies to hover and active, not just rest. Component
tokens are pinned explicitly rather than left to antd's derivation, so an
antd upgrade cannot silently move a colour out of compliance.

var() has no fallback on purpose: a fallback keeps the old colour alive when a
token is missing, which is exactly how a hardcoded value smuggles itself back.

Three layers of enforcement, because the invariant needs to outlive review:
  - theme.test.ts asserts every contrast pair in light and dark, and pins the
    accent/button distinction so it cannot be "simplified" back into reuse
  - theme.appearance.test.tsx proves the token reaches the SVG fill, and that
    overriding --brand-accent restyles it without touching a component
  - npm run lint:colors fails the build on any hex/rgb outside theme.ts, wired
    into predev, prebuild and the test workflow

Verified with negative controls rather than by eye: substituting BRAND.accent
for BRAND.action fails the AA tests; grey header text on the header band
fails at 2.01:1; an unthemed button is confirmed to be antd's stock
blue rgb(22,119,255), so the themed-button assertion distinguishes real
theming from nothing. The previously-unenforced claim that theming applied at
all now has a test: a dropped ConfigProvider would fail instead of shipping
blue.

typecheck clean, 94 tests pass (12 files), build passes, lint:colors clean.
deploy.yml ran spec -> build -> upload -> publish. A deploy - including a
manually re-run one - could publish code that failed its own tests, with the
failure reported on a separate workflow that nothing was waiting on.

deploy.yml is now three jobs: test -> build -> deploy. The test job generates the
spec the module graph needs, installs Chromium and runs the gate; build declares
`needs: test`, so a failing suite stops the artifact being uploaded at all
rather than leaving it to a red tick someone has to notice.

The check set is defined once, as `npm run verify` (colour guard + typecheck +
tests), and both workflows call it. Restating the steps per workflow is exactly
how the local gate and the deployed gate would drift apart. verify deliberately
excludes build: the deploy build step stamps VITE_TAG and VITE_FirebaseAPIKey
into .env first, so a build inside verify would produce an artifact without them.

Verified before wiring anything up:

  - the suite passes with .env absent. This had to be checked rather than assumed,
    because .env is git-ignored and CI has none - gating deploys on a job that
    only passes on my machine would have been worse than the gap it fixes.
  - injected failing test -> npm run verify exits 1, so publish would stop
  - stray colour literal in App.css -> exits 1 via the guard
  - a run matching zero test files exits 1, so an empty suite cannot pass
  - parsed both workflows with js-yaml and asserted: build.needs == test,
    deploy.needs == build, the test job runs spec + playwright + the gate, and
    no workflow contains continue-on-error, || true, allow_failure or if: always()

No actionlint/act is available in this environment, so the structural assertions
are machine-checked against the parsed YAML rather than by a workflow linter; the
job graph and the exit-code behaviour are verified, the GitHub-side runtime is not.

README updated: the Commands table gains verify/test:coverage/lint:colors and the
stale claim that "typecheck is the static gate" is gone, since it stopped being
true when the colour guard landed.
index.tsx and App.tsx each resolved the theme mode independently, using two
different defensive idioms for the same storage read (`globalThis.localStorage?.
getItem("theme")` vs `typeof localStorage === "undefined" ? ... : ...`). Both
worked, so nothing signalled the duplication - but the two results feed different
sinks: the CSS custom properties and the antd header/menu theme. Editing one and
not the other yields a theme that half-updates, which reads as a styling bug and
diagnoses as a wiring bug.

`readThemeMode()` in src/theme.ts is now the single reader. index.tsx resolves
once and passes the mode down; App.tsx takes it as a required prop and contains no
storage or system-preference reads of its own.

The safety nets moved to the call site rather than living inside the default
helpers. My first cut wrapped only the defaults, which meant an injected throwing
storage sailed straight through - caught by the Safari-private-mode test I wrote,
since that mode throws on storage *access* rather than returning null and an
uncaught throw here stops the app rendering at all.

scripts/checkThemeSource.js makes the invariant structural: any localStorage,
sessionStorage or matchMedia reference outside src/theme.ts fails the build.
Verified by planting a stray read in App.tsx and one in AppFooter - both flagged
with file and line. Guards are now aggregated behind `npm run lint` so test.yml and
deploy.yml cannot drift from it.

Test coverage note: injecting the environment covered every branch except the one
the app actually runs, because injection bypasses the default closures entirely.
Added src/theme.browsermode.test.ts to exercise the real localStorage and
matchMedia path. Also dropped a test I had written claiming to verify the stored
choice beating a dark OS preference - headless Chrome reports light, so it could
never fail at the thing it claimed to test; the pure resolveThemeMode tests cover
that override with the input stated explicitly.

Gate: lint + typecheck + 103 tests (13 files), and build, all pass.
Coverage thresholds set from the actual measured values, rounded down to a
whole percent: 83 statements, 81 branches, 85 functions, 83 lines. The
measured numbers and the resulting headroom are recorded in the config so
the gap between floor and reality stays visible to whoever raises it next.

Deliberately at the floor rather than an idealised target. An aspirational
number fails on every run until someone under deadline pressure sets it to
whatever turns CI green, which lands the floor lower than where we already
are -- the opposite of the point.

Thresholds only enforce when coverage actually runs, and `npm run verify`
did not run it, so both CI workflows were calling a gate that could not
bite. verify now uses test:coverage; the check set stays defined once in
package.json, as with the deploy workflow.

The include list is explicit because the denominator was not stable. The
bare "src" made v8 try to parse the JSON data files as modules; they fail
and drop out, so the same tree measured 207 statements in one run and 191
in another. A ratchet is only worth having if it ratchets a fixed set of
files. Measured values are unchanged by this.

Verified: the gate fails on a raised threshold (83.76% vs 90%), fails on
the committed config with coverage genuinely reduced (71.72% vs 83%), and
fails when the measurement itself breaks -- a run where the browser never
launches reports 0% and exits non-zero instead of passing quietly. Full
verify passes: 103 tests, both lint guards, thresholds met, deterministic
across three repeat runs.
@danielhstahl
danielhstahl merged commit 1fcf6e9 into master Sep 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant