feat(website): switch homepage hero svg with site theme

The homepage hero rendered once, against the svg exporter's own fixed
dark canvas background, so it always looked like a dark-mode prompt
even when a reader had the site in light mode.

export_themes.mjs now renders the default config twice - once
unchanged for dark, once with --background-color=#ffffff (Infima's
own light background) for light - and hero.json carries both as svg/
svgLight. The homepage ships both renders in its static HTML and picks
between them with plain CSS keyed off Docusaurus's own
html[data-theme] attribute, the same convention custom.css's
--omp-card-background override already uses, so the switch is instant
and needs no client re-render.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Entire-Checkpoint: e4462dff49f1
This commit is contained in:
Jan De Dobbeleer
2026-07-30 19:44:41 +02:00
committed by Jan De Dobbeleer
co-authored by Copilot App
parent 5c877b6381
commit 73bda6801d
5 changed files with 95 additions and 21 deletions
+28 -7
View File
@@ -44,6 +44,16 @@ const CONFIG = {
// was last built - and keeps generated/themes.json from growing by the size of every theme's
// source on top of its already-large inlined SVGs.
RAW_GITHUB_BASE_URL: 'https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes',
// Infima's own light-theme background (no --ifm-background-color override lives in
// custom.css, so this is Infima's default) - every SVG this exporter renders for the site's
// own light/dark toggle (the hero, and each theme's own light-mode render below) needs an
// explicit canvas background for its light half: most bundled themes (and always the hero's
// built-in default config) set no terminal background of their own, so without this flag they
// fall back to svg's own dark default (svg.go's defaultCanvasBackground) regardless of which
// mode the reader's browser is in. A theme that *does* set its own terminal background (e.g.
// tokyonight_storm) is unaffected either way - config_export_image.go's --background-color
// always loses to a theme's own background, exactly like a real terminal would.
LIGHT_BACKGROUND: '#ffffff',
FONT_FAMILY: VICTOR_MONO.FONT_FAMILY,
CELL_WIDTH: VICTOR_MONO.CELL_WIDTH,
LINE_HEIGHT: VICTOR_MONO.LINE_HEIGHT,
@@ -219,7 +229,12 @@ const OMP_BIN = process.env.OMP_BIN || 'oh-my-posh';
// configPath is null for the homepage's own render: oh-my-posh with no --config falls back to
// the config it builds in Go (src/config/default.go), which is the prompt someone sees before
// they have configured anything. There is no file to point at, and no bundled theme matches it.
function buildPoshArgs(configPath, outputPath) {
//
// backgroundColor is optional (see LIGHT_BACKGROUND) - it is what makes a theme's own light-mode
// render actually light when the theme sets no terminal background of its own; a theme that does
// (e.g. tokyonight_storm) keeps its own look regardless, since --background-color always loses
// to a theme's own background (config_export_image.go).
function buildPoshArgs(configPath, outputPath, backgroundColor) {
return [
'config',
'export',
@@ -231,6 +246,7 @@ function buildPoshArgs(configPath, outputPath) {
`--line-height=${CONFIG.LINE_HEIGHT}`,
`--fill-ascent=${CONFIG.FILL_ASCENT}`,
`--fill-descent=${CONFIG.FILL_DESCENT}`,
...(backgroundColor ? [`--background-color=${backgroundColor}`] : []),
// segment_data.json is hand-written on purpose (see buildDataFileWithTrending):
// its synthetic values are what make the renders look like a plausible
// machine. oh-my-posh warns that the file carries no recorder marker, which is
@@ -258,14 +274,16 @@ function buildPoshArgs(configPath, outputPath) {
// with, and the GitHub URL both the heading and the render link to. rawConfigUrl and format are
// what "Open in Studio" (ThemeGallery/index.js) needs to fetch and parse the theme's actual
// config at click time - see RAW_GITHUB_BASE_URL's own comment for why that's a URL, not the
// file content itself.
function buildManifestEntry(themeName, themeFile, svg) {
// file content itself. svgLight is the theme's own light-mode render (see LIGHT_BACKGROUND) -
// <ThemeCard/> ships both and picks between them the same way the homepage hero does.
function buildManifestEntry(themeName, themeFile, svg, svgLight) {
return {
name: themeName,
githubUrl: `${CONFIG.GITHUB_BASE_URL}/${themeFile}`,
rawConfigUrl: `${CONFIG.RAW_GITHUB_BASE_URL}/${themeFile}`,
format: getThemeFormat(themeFile),
svg,
svgLight,
};
}
@@ -306,15 +324,17 @@ async function exportTheme(themeFile) {
// it lives in the OS temp dir rather than under website/, leaving nothing behind
// for git status to notice.
const svg = await renderSVG(configPath, themeFile);
const svgLight = await renderSVG(configPath, `${themeFile} (light)`, CONFIG.LIGHT_BACKGROUND);
console.info(`Exported ${themeFile}`);
return { entry: buildManifestEntry(themeName, themeFile, svg), fileName: themeFile };
return { entry: buildManifestEntry(themeName, themeFile, svg, svgLight), fileName: themeFile };
}
// One render, returning the SVG. label only ever appears in messages, so the caller can name
// what it asked for - a theme file, or the built-in default config, which has no file at all.
async function renderSVG(configPath, label) {
// backgroundColor is optional (see buildPoshArgs).
async function renderSVG(configPath, label, backgroundColor) {
// A per-run scratch path, like buildDataFileWithTrending's temp data file: the svg only needs
// to exist long enough to be read back, so it lives in the OS temp dir rather than under
// website/, leaving nothing behind for git status to notice.
@@ -323,7 +343,7 @@ async function renderSVG(configPath, label) {
let stderr;
try {
({ stderr } = await execFileAsync(OMP_BIN, buildPoshArgs(configPath, outputPath)));
({ stderr } = await execFileAsync(OMP_BIN, buildPoshArgs(configPath, outputPath, backgroundColor)));
} catch (error) {
// execFileAsync only rejects on a non-zero exit code - a genuine render failure, not
// incidental stderr output. Fail the build loudly instead of silently dropping the render.
@@ -383,8 +403,9 @@ async function main() {
console.log(`Successfully exported ${manifest.length} themes to ${CONFIG.MANIFEST_FILE}`);
const heroSVG = await renderSVG(null, 'the default config');
const heroSVGLight = await renderSVG(null, 'the default config (light)', CONFIG.LIGHT_BACKGROUND);
await promises.writeFile(CONFIG.HERO_FILE, JSON.stringify({ svg: heroSVG }));
await promises.writeFile(CONFIG.HERO_FILE, JSON.stringify({ svg: heroSVG, svgLight: heroSVGLight }));
console.log(`Wrote the default config to ${CONFIG.HERO_FILE} for the homepage`);
+13 -10
View File
@@ -26,12 +26,14 @@ const path = require('path');
const { readGeneratedJson, readGeneratedArray } = require('../readGeneratedJson');
const MANIFEST_PATH = path.join(__dirname, '../../generated/themes.json');
// Written by the same npm script as the manifest, holding one render of the built-in default
// config for the homepage (see export_themes.mjs's HERO_FILE). It carries only an svg: unlike a
// manifest entry there is no theme name or themes/ file behind it, because that config is built
// in Go. Guarded here for the same reason the manifest is: a missing or malformed file should
// fail the build with a note saying which command writes it, not as a webpack error once <Home/>
// renders.
// Written by the same npm script as the manifest, holding two renders of the built-in default
// config for the homepage (see export_themes.mjs's HERO_FILE): svg for dark mode, svgLight for
// light mode - the homepage (src/pages/index.js) picks between them via CSS keyed off
// Docusaurus's own html[data-theme] attribute, not by re-rendering client-side. Unlike a manifest
// entry there is no theme name or themes/ file behind either render, because that config is
// built in Go. Guarded here for the same reason the manifest is: a missing or malformed file
// should fail the build with a note saying which command writes it, not as a webpack error once
// <Home/> renders.
const HERO_PATH = path.join(__dirname, '../../generated/hero.json');
const GENERATED_BY = 'This file is generated by `npm run themes` (which shells out to an oh-my-posh binary on ' +
@@ -45,16 +47,17 @@ function isThemeEntry(entry) {
&& typeof entry.githubUrl === 'string'
&& typeof entry.rawConfigUrl === 'string'
&& THEME_FORMATS.includes(entry.format)
&& typeof entry.svg === 'string';
&& typeof entry.svg === 'string'
&& typeof entry.svgLight === 'string';
}
function validateHero() {
const hero = readGeneratedJson(HERO_PATH, 'oh-my-posh-themes', GENERATED_BY);
if (!hero || typeof hero.svg !== 'string' || !hero.svg) {
if (!hero || typeof hero.svg !== 'string' || !hero.svg || typeof hero.svgLight !== 'string' || !hero.svgLight) {
throw new Error(
`oh-my-posh-themes plugin: malformed ${HERO_PATH}: ${JSON.stringify(hero)}. ` +
'Expected { svg }.',
'Expected { svg, svgLight }.',
);
}
}
@@ -68,7 +71,7 @@ function validateManifest() {
if (!isThemeEntry(entry)) {
throw new Error(
`oh-my-posh-themes plugin: malformed entry in ${MANIFEST_PATH}: ${JSON.stringify(entry)}. ` +
'Expected { name, githubUrl, rawConfigUrl, format, svg }.',
'Expected { name, githubUrl, rawConfigUrl, format, svg, svgLight }.',
);
}
+14 -2
View File
@@ -74,7 +74,7 @@ function OpenInStudioButton({ name, rawConfigUrl, format }) {
// SVG inherits the page's own @font-face (see custom.css's "Victor Mono"
// declaration), so the icons and powerline glyphs render without any font
// embedding, subsetting, or data URI of their own.
function ThemeCard({ name, githubUrl, rawConfigUrl, format, svg }) {
function ThemeCard({ name, githubUrl, rawConfigUrl, format, svg, svgLight }) {
return (
<div className={styles.card}>
<div className={styles.headingRow}>
@@ -84,7 +84,18 @@ function ThemeCard({ name, githubUrl, rawConfigUrl, format, svg }) {
<OpenInStudioButton name={name} rawConfigUrl={rawConfigUrl} format={format} />
</div>
<Link to={githubUrl} className={styles.render}>
<span className={styles.svgWrapper} dangerouslySetInnerHTML={{ __html: svg }} />
{/* Two renders (dark svg, light svgLight - see export_themes.mjs's LIGHT_BACKGROUND)
ship in the static HTML; custom.css's shared omp-light-only/omp-dark-only classes
pick between them off Docusaurus's own html[data-theme] attribute, the same instant
CSS-only switch the homepage hero uses (src/pages/index.js). */}
<span
className={`${styles.svgWrapper} omp-dark-only`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
<span
className={`${styles.svgWrapper} omp-light-only`}
dangerouslySetInnerHTML={{ __html: svgLight }}
/>
</Link>
</div>
);
@@ -113,6 +124,7 @@ function ThemeGallery() {
rawConfigUrl={theme.rawConfigUrl}
format={theme.format}
svg={theme.svg}
svgLight={theme.svgLight}
/>
))}
</div>
+27
View File
@@ -50,6 +50,33 @@ html[data-theme="dark"] {
--omp-card-background: var(--ifm-background-surface-color);
}
/* Shared "show only the render matching the reader's light/dark toggle" pair - any component
that ships two pre-rendered SVGs (one per mode) rather than picking one client-side wraps each
in one of these instead of introducing its own version: the homepage hero
(src/pages/index.js) and the theme gallery (ThemeGallery/index.js) both need it. defaultMode is
'light' (docusaurus.config.js), so light is the unadorned baseline here and dark is the
html[data-theme="dark"] override, the same convention --omp-card-background above uses.
The selector is doubled (.omp-light-only.omp-light-only) to raise specificity to (0,2,0):
these classes sit alongside a component's own single-class wrapper (e.g. .svgWrapper,
0,1,0) on the very same element, and CSS modules bundle after custom.css in Docusaurus's
webpack output, so a plain (0,1,0) rule here would lose the tie-break to that later,
equally-specific display:block and never actually hide anything. */
.omp-light-only.omp-light-only {
display: block;
}
.omp-dark-only.omp-dark-only {
display: none;
}
html[data-theme="dark"] .omp-light-only.omp-light-only {
display: none;
}
html[data-theme="dark"] .omp-dark-only.omp-dark-only {
display: block;
}
/* Typography and spacing scale for the segment catalog and studio pages (see their own
styles.module.css) - collapses the nine near-identical font sizes and the ad-hoc gap values
the two pages had each accumulated on their own into one shared set of steps, and points
+13 -2
View File
@@ -113,14 +113,25 @@ function Home() {
{/* The prompt oh-my-posh renders with no config of its own, drawn by the same SVG
encoder the gallery and every segment doc use - not a screenshot, and not one of
the bundled themes. It inherits the page's own @font-face, so the icons and
powerline glyphs draw without embedding a font, and the text stays selectable. */}
powerline glyphs draw without embedding a font, and the text stays selectable.
Two renders (dark svg, light svgLight - see export_themes.mjs's HERO_LIGHT_
BACKGROUND) ship in the static HTML; custom.css's shared omp-light-only/
omp-dark-only classes pick between them off Docusaurus's own html[data-theme]
attribute, so the switch is instant and CSS-only - no client re-render, no flash of
the wrong variant while React hydrates. */}
<div className={styles.heroPrompt}>
<span
className={styles.svgWrapper}
className={classnames(styles.svgWrapper, "omp-dark-only")}
role="img"
aria-label="The prompt Oh My Posh renders out of the box"
dangerouslySetInnerHTML={{ __html: hero.svg }}
/>
<span
className={classnames(styles.svgWrapper, "omp-light-only")}
role="img"
aria-label="The prompt Oh My Posh renders out of the box"
dangerouslySetInnerHTML={{ __html: hero.svgLight }}
/>
</div>
<div className={styles.installBox}>