About Light Mode CSS Generator
One PHP file. It reads a list of dark-first stylesheets, finds every literal light color: they set, and writes the light-theme counterpart scoped under a theme selector — while leaving alone every rule that paints its own dark background.
No Composer. No extensions beyond a default PHP build. PHP 7.4 or newer.
Why this exists
A stylesheet written dark-first is full of #fff, rgba(255,255,255,.72) and #f5f5f5. None of that is a problem until someone adds a light mode. Then every one of those is white text on a white page.
There are two honest ways to fix it. Convert every literal to a token, which is the right answer and is a week of work across a mature codebase. Or write the light counterparts by hand, which starts fast and is the kind of task where one miss is a paragraph nobody notices for a month, on a page nobody visits, until a customer does.
This does the mechanical half:
/* source */
.note { color: #ffffff; }
/* generated */
:root[data-theme="light"] .note { color:#131315; }
It rewrites its output file from scratch on every run, so it belongs in a build step or a pre-commit hook and stays correct as the sources change.
The rule that makes it trustworthy
Not every white color: is a bug in light mode:
.badge { background: #12161f; color: #ffffff; }
That white is correct, and it stays correct after the theme flips, because the badge paints its own dark fill. Text and fill are one pair. Flip half a pair and you get near-black on a dark chip — or near-black on brand blue.
So before rewriting anything, the generator asks whether the rule’s own block paints a fill that survives the flip. It skips the rule when it finds an accent or CTA custom property, a gradient built from one, or an opaque dark literal below the configurable dark-fill threshold. none, transparent and 0 0 paint nothing, so a rule with those is ordinary page text and gets rewritten normally.
Every skip is printed with the reason:
skipped (text sits on a fill of its own): 1
- fixtures/sample.css .badge color:#ffffff [fill is a dark literal #12161f]
This guard was not designed in the abstract. On the codebase this came from it was added after two rules — a 404 button on an accent gradient, a shop bubble on var(--accent) — were flipped to near-black and measured at 2.29:1.
What it will not do
It only rewrites color:. That is a real limitation and it bites in one specific, common place:
.display {
background-image: linear-gradient(90deg, #ffffff, #dbe4ff);
background-clip: text;
color: transparent;
}
There is no colour literal here to change. The light is in the gradient and the color is transparent. In light mode that heading is white on white, and this tool cannot fix it. You have to fix it by hand.
What it does instead is refuse to let that pass quietly. Any rule using background-clip:text with a light colour stop is listed:
needs a human: 1
! fixtures/sample.css .display
light gradient behind background-clip:text — this tool only rewrites color:, fix by hand
That limitation is real on the site this was built for, and a clean run over an unreadable heading would be worse than no tool at all.
Also out of scope, on purpose: backgrounds, borders and shadows (a light background inside a dark theme is usually an inverted chip that is already correct once the tokens flip, and rewriting those blind is how a generated stylesheet starts inventing bugs); saturated colours, since only near-greys within the chroma threshold are touched; colours already going through a token, which have nothing to map; and &-nested CSS blocks, which are counted and reported rather than guessed at. Inline style="" and CSS living in a database are invisible to it, because it reads files.
Configured, not edited
Everything that was a constant in the in-house version is now an argument or a config key: the source list, the output path, the ink colour, the theme selector, the saturated-token list, and four thresholds.
php gen-light-css.php --source "assets/css/*.css" --out css/light.gen.css
php gen-light-css.php --source "css/*.css" --out css/day.css
--ink "#2b1a00" --selector "html.theme-day"
php gen-light-css.php --config light.config.json --dry-run
Paths inside a config file resolve against that file’s directory, so the config lives next to the stylesheets. Any CLI flag given as well overrides it. --json gives the whole report as structured data for a build script.
--dry-run prints the per-file rule counts, every skip with its reason, every rule needing a human, and the complete stylesheet it would have written — then exits without creating a file. Run that first against a real codebase.
How a colour is mapped
Alpha is preserved. A dark theme usually writes its text ramp as one white at several alphas — .72 body, .58 meta, .46 muted — and that is a deliberate hierarchy:
rgba(255,255,255,.72) → rgba(19,19,21,.72)
rgba(255,255,255,.46) → rgba(19,19,21,.46)
Flattening all three to one black would be readable and visually dead.
Opaque greys mirror their lightness: the brighter the source, the darker the result, clamped between your ink and the ink ceiling. #ffffff becomes exactly the configured ink; #d6d6d6 becomes proportionally lighter. The channel offsets of the ink carry through, so a slightly tinted ink stays tinted at every step instead of turning flat.
!important survives. When a block sets color: twice, the last one wins, the way the cascade says. A selector that already sits at the root — :root, html — has that token replaced rather than nested, because :root[data-theme="light"] :root matches nothing.
Where it comes from
This is the generalised form of tools/gen-light-text.php from the bineret.com theme, where its source list, output path, ink and theme selector were constants edited in place and every path was absolute to one host. The rebuild it was part of is written up at https://bineret.com/work/rebuilding-bineret-com/.
The parser was replaced along the way. The original used a single regex that assumed no at-rules with braces — true of that theme, not true generally. This version walks the braces, descends into @media, @supports, @layer and @container, and re-wraps the output in the same at-rule. Added since: --dry-run, --json, glob expansion in the source list, root-selector replacement, last-color:-wins, and the background-clip:text warning.
Included
gen-light-css.php is the whole tool — one file, nothing else needed to run it. Alongside it: a working example config, and two fixtures with a self-test that asserts each outcome.
fixtures/sample.css is four rules and is the honest summary of what this tool is:
| Rule | Result |
.note — a plain light color: |
rewritten |
.badge — light color: on its own dark background |
skipped, reason printed |
.lede — color: var(--text-primary) |
skipped, nothing to map |
.display — white gradient behind background-clip:text |
skipped, and reported as needing a human |
fixtures/coverage.css covers the rest: alpha across three levels, a mid-grey, a saturated colour, an already-dark colour, text on var(--accent…) and on a var(--cta…) gradient, background:none, !important, two color: declarations in one block, a rule inside @media, a :root selector, and an @font-face that must be ignored.
php selftest.php
MIT licensed.
Key capabilities
It knows what not to rewrite
Before touching a rule, it asks whether that rule paints a fill of its own that survives the theme flip — an accent or CTA token, a gradient built from one, or an opaque dark literal. Those are left alone, because the text and the fill are one pair and flipping half of a pair is how you get near-black on brand blue.
Alpha survives the trip
A dark theme usually writes its text ramp as one white at several alphas: .72 body, .58 meta, .46 muted. That is a hierarchy, not an accident. The colour is mapped and the alpha is kept, so rgba(255,255,255,.46) becomes rgba(19,19,21,.46) rather than collapsing into the same black as everything else.
Configured, not edited
Source list, output path, ink colour, theme selector, the saturated-token list and all four thresholds are CLI flags or JSON config keys. Paths in a config file resolve against that file, so the config lives next to the stylesheets. Nothing needs editing inside the script to point it at a new project.
--dry-run before you trust it
Prints the per-file rule counts, every skip with its reason, every rule that needs a human, and the complete stylesheet it would have written — then exits without creating a file. This is the first thing to run against a real codebase, and the self-test confirms it leaves nothing behind.
Honest about its one blind spot
It only rewrites color:. A white gradient behind background-clip:text has no colour literal to change and stays white on white in light mode. The generator cannot fix that — so it lists every such rule under 'needs a human' rather than reporting a clean run over a heading nobody can read.
Re-runnable by design
The output file is rewritten from scratch on every run, with a DO NOT EDIT header naming the tool, the scope and the ink. Put it in a build step or a pre-commit hook and the light theme stays in step with the dark one as the sources change.
Questions & Answers
Why does it skip white text on my buttons?
Because that white is correct. A rule that paints its own dark or accent-coloured fill is a pair: the text and the background belong together and both survive the theme flip. Rewriting only the text gives you near-black on brand blue. Every skip is printed with the reason, so you can check the judgement rather than trust it.
What if my accent token is not called --accent?
Pass --fill-token with your own names, or set fillTokens in the config. The list you give replaces the default one entirely.
It did not fix my gradient heading.
It cannot, and this is the one limitation worth reading before you buy into it. A white gradient behind background-clip:text has color: transparent and no literal to rewrite. That heading stays white on white in light mode and you have to fix it by hand. What the tool does is list it under needs a human with its selector, so it is on a list rather than a surprise.
Does it touch backgrounds, borders or shadows?
No, deliberately. A light background inside a dark theme is usually an inverted chip that is already correct once the tokens flip. Rewriting those blind is how a generated stylesheet starts inventing bugs.
What about colours that are already tokens?
Left alone. color: var(--text-primary) has nothing to map — the token changes value when the theme flips, which is the point of the token. Only literals are in scope.
Does it understand @media?
Yes. It descends into @media, @supports, @layer and @container and re-wraps the emitted rule in the same at-rule. It does not resolve & in nested CSS: blocks nested inside a style rule are counted and reported as not visited, not silently dropped.
Is it safe to run twice?
It is designed for it. The output file is rewritten from scratch on every run, so it belongs in a build step or a git hook.
Can I use it on a Sass or Tailwind build?
Point it at the compiled CSS, not the source. It parses CSS, not a preprocessor language.
Tutorials
Install
unzip light-mode-css-generator.zip
cd light-mode-css-generator
php -l gen-light-css.php
That is the install. PHP 7.4 or newer, CLI SAPI, no Composer and no extensions beyond a default build. If your host has several PHP binaries, call the one you mean by its full path.
Look before you leap
php gen-light-css.php --source fixtures/sample.css --dry-run
--dry-run prints the per-file counts, every skip with its reason, every rule that needs a human, and the complete stylesheet it would have written — then exits without writing anything. Run this first against a real codebase.
Generate
php gen-light-css.php
--source "assets/css/*.css"
--source "assets/css/sections/*.css"
--out css/light.gen.css
Load the result after your dark stylesheets. It only ever sets color, and only under the theme selector, so it is inert in dark mode.
From a config file
php gen-light-css.php --config light.config.json
{
"sources": ["assets/css/*.css"],
"out": "css/light.gen.css",
"ink": "#131315",
"selector": ":root[data-theme="light"]",
"fillTokens": ["accent", "cta", "brand", "danger", "success"]
}
Paths inside a config resolve against that file's directory, so it can sit beside the stylesheets. A CLI flag given as well overrides the config. light.config.example.json in the package is a working one.
Different ink, different theme hook
php gen-light-css.php --source "css/*.css" --out css/day.css
--ink "#2b1a00" --selector "html.theme-day"
Run the self-test
php selftest.php
It generates from both fixtures and asserts each outcome line by line, including that --dry-run creates no file.
Support
This is free, MIT-licensed source. What that gets you and what it does not:
What is included. The complete script with its reasoning in the comments, a fixture whose four rules cover the rewrite case and the three skip cases, a second fixture covering alpha, at-rules, tokens and thresholds, and a self-test that asserts every outcome. The README documents the skip rule and the background-clip:text limitation in full. If it does not behave as documented, that is a defect worth reporting.
What is not included. No installation service, no scheduled updates, no SLA, and no response-time commitment — none has been measured, so none is promised.
Reaching us. Use the contact page on bineret.com. A useful report is the --json output plus the smallest CSS rule that reproduces it; a described-in-prose case usually cannot be checked.
Before reporting. Run with --dry-run and read the skip reasons. Most surprises are the fill guard doing its job on a rule that paints its own background.
Modifying it. MIT — fork it, vendor it, change the mapping curve, add your own token list. The licence header should stay in the source.
