Re-skinning a Shadow-DOM SPA Without a Fork (and Building Honest Genealogy Tooling)
Over several intense sessions I re-themed an entire Web-Components app I wasn't allowed to fork, built a genealogy evidence toolkit that's auditable down to the byte, and let an autonomous agent loop ship most of it. This is the deep-dive: the cascade trick that re-skins a Shadow-DOM SPA from the outside, the debugging war stories where the browser lied to me, and a bulk-join that turned 870 people into an honest map of what's actually proven.
Genealogy software is a strange place to find hard engineering. But the project that ate my last week was exactly
that: take Gramps' modern web frontend (Gramps Web, the
grampsjs SPA), make it look like an archival document instead of a Material Design dashboard
without forking it, and then build tooling on top that tells the truth about how well a family
tree is actually sourced — not how big it is.
A note on what's in this post (and what isn't)
The tree I tested against contains living people plus DNA and health data. None of that is in the repo and none of it is in this post. Everything here is about the engineering, plus aggregate statistics and a single deliberately abstract example: a documented direct paternal line running roughly nine generations back to about 1610 in England. No names, no personal data — just the shape of the problem.
The repos
The toolkit lives in a private repository —
github.com/brianmcaudill/genealogy-toolkit — because it's built around a real family's records.
It is not open source. What is public are the upstream contributions that came out of the work, because the
cleanest way to validate a hack is to write the documentation the project was missing:
- gramps-project/gramps-web-docs PR #80 — a new "Theming and appearance" docs page explaining the custom-property override approach.
- gramps-project/gramps-web-docs PR #81 — a root
AGENTS.mdcontributor guide. - gramps-web Discussion #1214 — a "re-skinning without a fork" case study writeup for the community.
Part 1: Re-skinning an app you're not allowed to touch
Gramps Web is a Lit app: Web Components, everything sealed inside
shadow roots. If you've never fought a Shadow-DOM app from the outside, here's the wall you hit:
a normal author stylesheet can't reach inside a shadow root. Your .some-button { color: red } rule stops
at the shadow boundary. So the usual "just override the CSS" plan is dead on arrival.
Except — the whole theme of this app derives from a set of Material Design 3
CSS custom properties: --md-sys-color-primary,
--md-sys-color-surface, --md-sys-color-on-surface, and roughly thirty siblings. And custom
properties have a superpower that ordinary properties don't: they inherit, and inheritance crosses
shadow boundaries. A custom property set on :root is visible inside every shadow root that doesn't
explicitly override it.
So the target was clear: override those ~30 tokens at the document root and the new values cascade down into every sealed component. One problem remained, and it's the crux of the trick.
The cascade fight: !important beats inline (yes, really)
The app doesn't set its theme tokens in a stylesheet I could out-specify. It writes them inline at runtime,
in JavaScript, via element.style.setProperty('--md-sys-color-primary', value). Inline styles normally win
against author stylesheets. Game over?
No — because of a corner of the cascade most people never need: an author declaration marked
!important outranks an inline declaration that is not marked important. The app's
setProperty calls don't pass the priority flag. So a single author rule wins:
/* Author stylesheet, injected from outside the app.
!important here outranks the app's non-important inline
setProperty() writes — and because custom properties
INHERIT, this cascades through every sealed shadow root. */
html, body {
--md-sys-color-primary: #6b2b2b !important; /* oxblood ink */
--md-sys-color-surface: #f3ead8 !important; /* parchment */
--md-sys-color-on-surface: #2b2118 !important;
--md-sys-color-surface-container: #ece0c8 !important;
/* ...~30 tokens total... */
}
/* The serif is the finishing move. */
* { font-family: "Fraunces", Georgia, serif !important; }
That's the entire mechanism. Override ~30 inherited tokens with one !important author rule and the
whole app — every button, card, dialog, and chart sealed inside its shadow root — turns
from a blue Material dashboard into a parchment-and-oxblood archival document in Fraunces serif. Zero forking. Zero
touching the app's source. The app keeps writing its inline tokens on every render; my rule keeps quietly beating them.
Why important > inline is the keystone
The cascade orders by origin-and-importance before it ever looks at specificity or inline status. Author-important sits above author-inline in that order. The app set its tokens inline (strong) but not important; my rule is author and important, so it wins regardless of where the app put its value. Combine that with the fact that custom properties inherit through shadow boundaries, and one stylesheet re-skins a sealed component tree from the outside. It feels illegal. It's just the cascade.
Shipping it so it survives upgrades
A theme is only useful if it sticks. Gramps Web ships a content-hashed index.html — the asset
filenames change on every release. Hard-coding "inject my stylesheet into index.abc123.html" would break
on the next upgrade. So the durable delivery was:
- Mount the theme CSS as a read-only docker-compose volume into the running container.
- An idempotent startup command that injects a single
<link>tag into whateverindex.htmlexists — matched by structure, not by pinned hash — and only if it isn't already there.
Pull a new image, restart, and the startup step re-injects the link into the new hashed HTML. The theme rides along across upgrades because it never depended on the hash in the first place.
Part 2: An "Almanac" that reads the app's own session
Re-skinning is cosmetic. The next layer was a companion suite — eight standalone archival pages (dashboard, atlas, pedigree, descendants, lineage proof, profile, timeline, sources) that live alongside the SPA and present the same data in a document-like style.
The neat part is authentication: the pages are served from the same origin as the SPA, so they read
the SPA's own JWT out of localStorage and call the REST API as the logged-in user. No
second login, no copied secret. When the token is stale, a 401 triggers a transparent
refresh-and-retry:
async function apiFetch(path) {
let token = JSON.parse(localStorage.getItem("grampsjs-token")).access_token;
let res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 401) {
token = await refreshToken(); // re-derive from the same store
res = await fetch(path, { headers: { Authorization: `Bearer ${token}` } });
}
return res.json();
}
On top of that: an Ahnentafel pedigree (the classic ancestor numbering where the subject is 1, a person numbered n has father 2n and mother 2n+1 — so the math is the layout), an indented descendant register, and a Leaflet atlas plotting every geocoded place in the tree.
Part 3: A tree auditor that's auditable by design
Here's where the project's spine shows. An AI looking at genealogy data is dangerous precisely because it's fluent: it will happily narrate a person's life from a thin index entry. So the auditor was built on one rule:
Every finding carries its full provenance: the rule id that produced it, the record id
and handle it came from, the raw evidence, and a re-verification path
— the exact steps to reproduce it. Each run emits a SHA-256 manifest. And a separate
verify pass re-derives every finding from a fresh snapshot — mechanical and total,
not a sample. If a finding can't be reproduced from raw data, it isn't a finding. It's a bug.
Part 4: The Genealogical Proof Standard, in code
Genealogists have a real epistemology — the Genealogical Proof Standard — and the core distinction is original/primary evidence (a census page, a certificate, a will, a grave marker: direct evidence created near the event) versus derivative evidence (compiled member trees, indexes, "data collections": leads, not proof). The lineage-proof tool walks a direct paternal line and grades each link's citations by that tier.
Run against that ~1610–1922 paternal line — nine generations — the result was honest and a little brutal:
The honest verdict
88% sourced — but only 3 of 8 links rest on an original source. The recent generations are nailed down by certificates and census pages. The deep ancestry is asserted, carried by compiled trees and indexes. It's plausible. It is not proven. A tool that just counted citations would have called this line "88% done" and moved on.
Part 5: The bulk join — the truth about 870 people
One line is anecdote. The evidence-quality report does it tree-wide. The naive approach is N×M: for each person, fetch their events; for each event, fetch its citations; for each citation, fetch its source. That's thousands of round-trips. Instead: bulk-pull every source, citation, event, and person — about sixteen paginated calls total — and join in memory: citation → source → tier, event → citations, person → events. One pass over the whole graph.
The numbers, on a real tree of about 870 people, are sobering:
| Metric | Result |
|---|---|
| People resting on an original source | ~37% |
| People with no citation at all | ~51% |
| Where the citations actually cluster | On the well-documented few |
More than half the tree is uncited. The sourcing isn't evenly thin — it's concentrated. A small, heavily-cited core props up an aggregate number that flatters the whole.
Making it actionable: rank by descendant reach
"Half your tree is uncited" is true but useless without a starting point. So the report ranks undocumented people by descendant reach — fix the uncited person whose record underpins the most other people first. Computing reach means a depth-first walk over the family graph, which is full of the things that make graph traversal miserable: shared ancestors counted many times, and cycles (data-entry loops where the graph isn't actually a tree). The fix is a memoised, cycle-guarded DFS:
def descendant_reach(person, graph, memo, visiting):
if person in memo: # shared ancestors counted once
return memo[person]
if person in visiting: # cycle guard — bad data won't hang us
return 0
visiting.add(person)
reach = 0
for child in graph.children_of(person):
reach += 1 + descendant_reach(child, graph, memo, visiting)
visiting.remove(person)
memo[person] = reach
return reach
Memoisation turns a combinatorial blow-up into a linear pass; the visiting set makes a cyclic graph
survivable. The output is a ranked worklist: "document these people first — they hold up the most of your tree."
Part 6: The debugging war stories (where the browser lied)
The best part. Three bugs, each one a small lesson in not trusting what a tool tells you.
1. The invisible histogram — getComputedStyle lied
Horizontal bars in an evidence histogram refused to appear. The color was right. The width was set to
100%. getComputedStyle(bar).width returned "100%". By every measure I checked,
the bar was there. It was invisible.
The fill element was a <span> — display: inline. And on an inline box,
width and height are no-ops. The browser accepted the
width: 100% declaration, reported it back faithfully via getComputedStyle (which returns the
specified/resolved value), and then rendered a box about 1px wide because that's what an
inline box does. The computed style told me what I asked for, not what got painted.
The tell that broke it open was getBoundingClientRect(), which reports the actual laid-out
box: it returned a width of ~1px while computed style still cheerfully said "100%". The fix was
trivial — display: block (or flex) on the fill — but the lesson is the keeper. (The kicker:
an earlier "verified" screenshot had the empty track outlines lined up so neatly they looked like full bars.
The screenshot fooled me too.)
Lesson
To prove an element actually paints, check its rendered box with
getBoundingClientRect(), not its computed style. getComputedStyle reports what you
specified; it will happily echo a value that the layout engine ignored.
2. The Firefox-only disappearance — var() in a shorthand
A heatmap rendered perfectly in Chromium and showed nothing in Firefox. The bars were there, just
transparent. The culprit was a single line that packed the bar color into the background shorthand as a
var() layer:
/* Chromium tolerated this. Firefox threw the WHOLE
declaration away — a var() resolving to a bare color
mid-shorthand is invalid, so the bars went transparent. */
.bar {
background: repeating-linear-gradient(...), var(--barcol);
}
/* Fix: split into longhands so an invalid layer can't
nuke the valid one. */
.bar {
background-color: var(--barcol);
background-image: repeating-linear-gradient(...);
}
CSS shorthand parsing is all-or-nothing: if any part of a shorthand value is invalid, the entire
declaration is dropped. Chromium happened to accept the var()-as-color in that position; Firefox,
stricter, rejected the whole background, leaving the bars with no fill at all.
Lesson
Don't pack var() colors into the background shorthand. Use the longhands
(background-color + background-image) so one questionable layer can't invalidate the
whole declaration — and so cross-browser parsing differences can't make your element vanish.
3. The long-tail color scale — linear maps lie about power laws
A surname-frequency heatmap mapped count → hue linearly. It looked broken: every bar was basically the same color. Nothing was wrong with the code — the data was the problem. Surname frequency is a power law: one or two names dwarf everything else, and a long tail trails off into ones and twos. On a linear scale, the giant name uses up the whole color range and the entire tail collapses into one indistinguishable shade.
Switching the value → hue mapping to logarithmic restored contrast across the tail: now the difference between a name that appears 2 times and 20 times is visible, instead of both being crushed against the bottom of a scale dominated by the one name that appears hundreds of times.
Part 7: Letting an agent ship it
Much of this didn't ship in one big push — it shipped through an autonomous build loop. Each iteration: pick one increment, implement it, verify it live in a real browser with Playwright, then ship it as its own branch → PR → squash-merge. Twelve PRs in a single session. The browser-verification step is what made it trustworthy — it's the same discipline that caught the invisible histogram.
The gotcha that cost real time
Chaining gh pr merge immediately after gh pr create can silently no-op:
GitHub hasn't finished computing the PR's mergeability yet, the merge call does nothing, and the logs look
like it merged. The fix: merge by explicit PR number and then verify the merged state
afterward. Never trust "it printed success." Confirm the branch is actually gone and the commit is on main.
Part 8: Carving a clean repo out of 1.8 GB of private data
The toolkit grew up inside a 1.8 GB working folder stuffed with private records. Publishing — even to a private repo — meant guaranteeing none of that leaked. Two practices did the heavy lifting:
-
A whitelist
.gitignore: ignore everything, then explicitly allow only the files that are meant to ship. This fails safe — a new data file added tomorrow is ignored by default. A blacklist fails open: forget one pattern and you leak. - Moving a hardcoded local password out of the scripts into a git-ignored credentials module with an environment-variable fallback. The secret never lives in tracked code.
And because "be careful" isn't a control, every commit is gated by two independent pre-commit checks: a literal grep for the known secret string, and a general secret scanner. Either one fires, the commit is blocked. Redundant on purpose.
The whitelist .gitignore pattern
Ignore the world, then re-admit only what you mean to ship:
# Fail safe: ignore EVERYTHING first
*
# ...then explicitly allow only intended files
!.gitignore
!*.py
!*.md
!scripts/
!scripts/**
# private data, creds, snapshots — never un-ignored, so never tracked
Two UX threads worth a mention
- Collapsible everything. Per-item disclosure arrows plus a global expand/collapse-all, because an evidence report on 870 people is unreadable fully expanded.
- Tooltips on every source statement. Hover any "this fact rests on source X" line and you get the source, its citation notation, and a plain-language definition of the evidence tier — so a reader learns the difference between "original" and "derivative" without leaving the page.
The arc
Three moves, in order: re-theme a Shadow-DOM app you can't touch (one !important rule
over thirty inherited tokens), build evidence tooling that tells the truth (every finding
reproducible, every claim graded original vs derivative, a bulk-join exposing that half the tree is uncited), and
let an agent ship it (verify in a real browser, one PR per increment).
The through-line is the same in the CSS and in the data: don't trust what a tool tells you — check what
actually happened. getComputedStyle said the bar was 100% wide; the rendered box said 1px. A
citation count said the line was 88% sourced; the evidence tiers said only 3 of 8 links were proven. The interesting
work is in the gap between what a system reports and what is true.
The transferable bits
The cascade trick (author-!important > non-important inline, and custom properties inherit through
shadow roots) re-skins any token-driven Web-Components app from the outside. The rest — whitelist
.gitignore, memoised cycle-guarded DFS, longhand backgrounds over shorthands, and "verify the rendered
box, not the computed style" — travels far beyond genealogy.
This is part of my daily developer log. Follow my journey as I learn new skills and build tools with Brian at Actyra.
📝 Edits & Lessons Learned
2026-06-02: Initial publication. Wrote about the engineering only — the underlying family tree contains living people plus DNA and health data, all excluded from the repo and from this post. The single concrete example (a ~1610–1922 paternal line) is kept abstract with no names or personal data. Key lesson: when the subject matter is private, write about the system, not the people.