The design system was a fork of Bootstrap’s Sass with forty-one modified variables and eleven overridden component partials. Every patch release meant a rebase, and by August the fork was three patch versions behind because the last rebase had taken an afternoon and produced two visual regressions.
The symptom
$ git log --oneline vendor/bootstrap-fork | head -3
a41f2b8 rebase onto 5.1.3
8c9e114 rebase onto 5.1.1
3e11fa4 rebase onto 5.1.0
$ git diff v5.1.3 HEAD --stat | tail -1
52 files changed, 1204 insertions(+), 388 deletions(-)
# 52 files. of which meaningfully modified:
# _variables.scss 41 variables
# 11 component partials, each changing 2-6 declarations
# 40 files: whitespace and import-order driftFifty-two files diverged to express fifty-odd declarations is the cost of a fork, and forty of those files differ for no reason anybody chose. Each rebase reintroduces the drift and the visual regressions come from the parts nobody meant to change.
Why it happens
Bootstrap 5.0 and 5.1 compiled everything from Sass variables, so any customisation beyond the documented variable list required editing a partial — and editing a partial means forking the file, which means owning it forever.
The fix
What 5.2 exposes
/* 5.2 emits component-level custom properties */
.btn {
--bs-btn-padding-x: 0.75rem;
--bs-btn-padding-y: 0.375rem;
--bs-btn-font-size: 1rem;
--bs-btn-color: var(--bs-body-color);
--bs-btn-bg: transparent;
--bs-btn-border-color: transparent;
--bs-btn-hover-bg: ...;
/* ~20 of them, per component */
}
/* so a theme is a stylesheet that sets variables */
.btn-primary {
--bs-btn-bg: var(--brand);
--bs-btn-border-color: var(--brand);
--bs-btn-hover-bg: var(--brand-dark);
}
Nine of the eleven overridden partials became a stylesheet setting variables, which is a file rather than a fork. The two that remained were changes to the grid and to the breakpoint map, both of which are still Sass and are the boundary of what 5.2 exposes.
Runtime theming, which was not previously possible
/* a compact density, scoped, with no rebuild */
[data-density="compact"] {
--bs-card-spacer-y: 0.5rem;
--bs-card-spacer-x: 0.75rem;
--bs-btn-padding-y: 0.25rem;
--bs-body-font-size: 0.9375rem;
}
/* and a per-tenant palette, from a database value */
:root {
--brand: #1f6feb; /* rendered server-side */
}
A per-tenant palette rendered into a style tag is a feature that had required a build per tenant and is now four declarations. That is the change with a product consequence rather than a maintenance one, and it was not part of the original justification for the upgrade.
It also means a theme change is a cache invalidation rather than a deploy, which moves it from an engineering task to a support one — and that is a decision to make deliberately, because a client with a colour picker will use it.
Where the boundary is
custom properties in 5.2:
component colours, spacing, borders, typography
the body, the links, the headings
most of what a brand theme changes
still Sass, and still requiring a build:
the breakpoint map
the grid columns and gutters
the spacer scale that generates the utilities
which components are compiled at all
the utility API
so the fork became: one Sass entry point with 6 variable
overrides, plus a theme stylesheet with 47 declarations.Knowing the boundary before starting is what makes the migration a day rather than a week, because the instinct is to try to move everything and then discover that the breakpoints are not exposed. Six Sass variables and a stylesheet is the honest end state and it is a large improvement on fifty-two forked files.
The colour palette additions, and the contrast that has to be checked
// 5.2 adds a wider palette and a contrast function
@import "bootstrap/scss/functions";
$brand: #1f6feb;
$theme-colors: map-merge($theme-colors, (
"primary": $brand,
));
// color-contrast() picks black or white against a
// threshold, which is a WCAG AA approximation and not a
// guarantee — the threshold is configurable and the
// default is 4.5 against $body-color, not against the
// actual foreground you will use.
The contrast helper is genuinely useful and is an approximation, which matters because a generated foreground that passes the helper can still fail an audit against the real background. Checking the resulting pairs with an actual contrast tool is a ten-minute job per palette and is the difference between a claim and a measurement.
The migration, in order
1 upgrade the fork to 5.2, one last rebase
2 capture a visual baseline of every component page
3 delete one forked partial, replace with variables,
run the visual test, commit
4 repeat 9 times
5 the two that cannot move: keep as Sass overrides in
the entry point, not as forked files
6 delete the fork
step 3 repeated per partial is what kept every change
reviewable and every regression attributable.One partial per commit with a visual test between each is slower than doing it in one branch and is the only version where a regression is traceable to a change. Two of the nine produced differences, both spacing, both from a variable whose default in 5.2 differs from the forked value.
Verifying it worked
$ ls vendor/bootstrap-fork
ls: cannot access: No such file or directory
$ wc -l scss/_theme.scss css/theme.css
6 scss/_theme.scss
47 css/theme.css
$ npx backstop test
Passed: 41 Failed: 2
# both: 1px of card padding. accepted, rebaselined.
$ npm i [email protected]
$ npx backstop test
Passed: 43
# a patch upgrade is now npm i and a test run, rather than
# an afternoon.The patch upgrade taking one command is the outcome that mattered, and it is the thing that was measured before and after — three patch versions behind became current, and staying current stopped being a decision. The visual suite is what makes that safe and it existed before this work for unrelated reasons.
What this costs
Two theming mechanisms in one project: six Sass variables that require a build and forty-seven custom properties that do not. A developer changing a colour has to know which is which, and the boundary is not obvious from either file — a comment at the top of each saying what belongs there is the cheapest available documentation.
Runtime theming is also a capability that will be used. A per-tenant palette in the database is a feature somebody will ask to expose, and a colour picker in an admin panel produces sites with contrast failures that no engineering process catches. Constraining the picker to a palette rather than to arbitrary hex values is the answer and it is a product conversation rather than a technical one.