Forking Ghost's Source Theme: What Isn't in the Docs
Designing a Ghost theme is the easy part. The time goes into a handful of boundary problems where Ghost doesn't give you control, and none of them is documented.
I built a Ghost theme called Smolder, a fork of Ghost's Source theme. It's at version 1.0.0 and passes gscan.
The design work was the easy half. Layout, typography, colour, the parts you can see: those went roughly as expected. Nearly all the difficult times involved about eight problems, and they have something in common. Everyone sits at a boundary Ghost doesn't hand you control over, and none of them appears in the theme documentation.
This is that list.
Google Fonts can poison your font families
I defined alias families, SmolderSerif and SmolderMono, because I couldn't get Merriweather and JetBrains Mono to render under their real names. It looked like a hack, and I kept it because removing it broke the site.
The cause turned out to be the Google Fonts stylesheet. It registers a separate @font-face for every unicode-range subset, so a single family arrives as a dozen declarations covering Latin, Latin Extended, Cyrillic, Greek and the rest. Those unloaded subset declarations poisoned the family names. Merriweather and JetBrains Mono never resolved at all, and every font stack fell straight through to my aliases.
Measured with document.fonts.check() before and after self-hosting:
| before | after | |
|---|---|---|
check('Merriweather') |
false | true |
check('JetBrains Mono') |
false | true |
| registered faces | 44 | 6 |
My code blocks had been rendering in a fallback the entire time. Never once in JetBrains Mono.
Two things are worth taking from this. First, if you self-host, follow Source's own typography partial and emit both the preload and the src URL through {{asset}}, so the two strings match exactly, and the browser doesn't fetch each file twice. Second, and more embarrassing, my assets/css/fonts.css code was dead: never linked in a template, never imported into screen.css, and outside the gulp task's input glob. Every edit I'd made to that file for weeks had changed nothing. Worth grepping your own theme for files with no references.
Ghost's origin check will lock you out of Admin
I wanted to check the theme on a real phone, so I pointed Ghost's configured url at my machine's LAN address. Admin then broke in a very specific way: login succeeded, and the page spanned forever afterwards.
Ghost's cookieCsrfProtection rejects any request whose Origin header doesn't match the configured url. Signing in at localhost while url points at 192.168.x.x creates the session correctly and then rejects the very next request that carries the cookie. The login isn't what fails. Everything after it is.
The fix is trivial once you know: sign in at the same origin you configured. The trap is that the symptom looks like a session or database problem rather than a header mismatch, and the failure appears one request after the action that caused it.
One more thing worth knowing before you start probing a login endpoint. Ghost's brute-force protection escalates hard, and it counts your attempts against your own account. A handful of failed diagnostic logins pushed mine into a lockout measured in months. If you need to reset that, it lives in the database, not anywhere in the interface.
Consent managers block the Ghost Portal silently
The Subscribe button stopped doing anything. Portal had never initialised, and the script request had returned HTTP 200, which made it look fine.
It wasn't. The real portal.min.js is 2,406,990 bytes. The browser had received 300. My consent manager's script blocker intercepted Portal before consent and returned a stub with a perfectly healthy status code.
The diagnostic lesson is the useful part. I'd first checked transferSize, which reflects cache behaviour and can look normal for a response that never arrived intact. decodedBodySize is the field that tells you what the browser actually got. If you're debugging a script that returns 200 and does nothing, compare the two.
Any consent manager with a script blocker will do this. The Portal has to be allowlisted explicitly, and it's easy to miss because nothing errors.
The comments iframe paints its own background
This one took the longest and has the least obvious answer.
Ghost's comments render in an iframe, and mine painted an unreadable white block on a dark page. The obvious suspects were all innocent: the iframe element was transparent, html inside it was transparent, and body was transparent too.
The white was the browser's own canvas, painted from color-scheme: normal within the embedded document, and it sits underneath everything CSS can address. Setting color-scheme: dark on the embedding element doesn't help, because the property doesn't propagate into an embedded document.
So, from the parent page's CSS, that canvas is unreachable. I nearly accepted a white comments block on a dark site.
The way through is that the iframe is same-origin, which means you can reach the embedded document directly from JavaScript and set the property on it:
iframe.contentDocument.documentElement.style.colorScheme = 'dark';
The canvas clears immediately. Worth being precise about why this works when the CSS route doesn't: color-scheme has to be set on the embedded document itself, and same-origin access is what lets you do that.
While you're in there, {{comments}} accepts a mode, so set it server-side in the template rather than fighting the default in the client.
Don't touch Portal's DOM
Related, and worth stating as a rule.
Portal is a React application rendered into an iframe, and it has no dark mode. The temptation is to reach in and fix it. Two things go wrong when you do.
Removing or moving nodes breaks React's reconciliation, so Portal starts throwing errors on its next render and the modal stops working entirely. And broad stylesheet injection into that iframe hits far more than you intend: a rule general enough to catch the element you're after will paint every button in the flow, including the ones that were fine.
If you need something in Portal changed, use the settings Ghost exposes and accept the rest. Anything else is a fight with somebody else's release cycle.
GScan's setting cap is an error, not a fatal error
Ghost themes are capped at 20 custom settings. I hit it and wanted to know exactly what would happen if I went over.
The rule is GS010-PJ-CUST-THEME-TOTAL-SETTINGS, it applies to every Ghost 4.x and later theme rather than only marketplace submissions, and it's declared at level: 'error' with no fatal flag. Ghost blocks activation on hasFatalErrors only. So a 21-setting theme uploads, activates, and runs, while showing a red error in the interface.
That means nothing technically breaks, but a paid listing showing a validation error still isn't something you want. I settled on 19 and split the repository: a clean branch that respects the Marketplace cap, and a separate branch for my own site with the extras.
Worth checking the rule definitions in GScan directly when you need this kind of answer. They're more precise than the prose documentation, and they're the thing actually being run.
Two CSS problems that took longer than they should have
A sticky nav that wouldn't stick. I had overflow-x: hidden on an ancestor to stop a horizontal scrollbar. hidden creates a scroll container, and position: sticky resolves against the nearest scroll container rather than the viewport, so the nav silently stopped sticking. overflow-x: clip prevents the overflow without creating one. One word, and the symptom points nowhere near the cause.
A mobile menu rendering as a giant ellipse. The nav had a pill border-radius and transition: all. When the menu opens, the element grows tall, the pill radius scales with it, and the transition animates the shape through an ellipse on the way. Transitioning specific properties rather than all fixing them is a good argument for never using all on anything whose dimensions change.
Both of these are the kind of thing where the fix is a word, and the diagnosis is an hour. Keeping Source's base CSS intact and appending customisations in clearly labelled sections is what made them findable at all, which is the same argument I made about deletability in the piece on CSS architecture.
Contrast audits miss icon-only buttons
I ran a contrast pass, fixed what it found, then opened the site in light mode and could not see the search button.
Contrast tooling generally walks text nodes. An icon-only button has no text, so it isn't in the sweep, and WCAG 1.4.11 requires 3:1 for non-text elements that convey meaning. Search, menu toggles, close buttons, theme switchers: exactly the controls that tend to be icon-only, and exactly the ones a text-based audit will report as clean.
Check them separately, in both colour schemes. More on why the automated layer only gets you part of the way in the accessibility piece.
One smaller thing in the same area: #gh-feed in Source is a class rather than an ID, so a skip link pointing at it silently goes nowhere. Check your anchors resolve rather than assuming the selector matches the convention.
What I'd tell someone forking Source
Keep the base intact and append. Source's CSS is well organised, and the temptation to restructure it is strong and wrong. Leaving it alone and adding labelled sections underneath meant I could always tell what was mine, which made every bug above cheaper to find.
Get it into git before anything else. I spent a long stretch with several thousand lines of work in exactly one place on disk, with no history and no backup. That's the actual risk on a project like this, well ahead of code quality.
Test in a real browser, in both colour schemes, on a real phone. I found the invisible search button, the white comments block, and the ellipse menu by looking, not by any check I ran.
Reduce the JavaScript. I'd accumulated a few hundred lines inlined into default.hbs, including setInterval loops reaching into Ghost's iframes to force styling. Almost all of it was unnecessary once I fixed the underlying causes, and the template came down to about a third of its size.
The pattern
Reading the list back, the difficult problems weren't in my code. They were at the edges: an origin header, a third-party script blocker, a browser-painted canvas inside an iframe, a validator's rule definitions, someone else's React application.
That matches something I've written about before, in the piece on what full-stack actually means: the interesting failures live between components rather than inside them. A Ghost theme feels like a self-contained thing until you find that four of your hardest bugs are on the other side of a boundary you don't own.
Which is worth knowing before you start, because it tells you where to look when something breaks in a way the code can't explain.