No description
  • CSS 81.1%
  • JavaScript 18.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Haze Booth 083036065b Add Startpage userstyle built on Oxide RFD design tokens
Restyles the Startpage homepage and search results using tokens read off
the live Oxide RFD site with getComputedStyle rather than guessed from
the cascade: the OKLCH surface/content/stroke ramps, weight-400 headings,
.042em tracking and uppercase monospace micro-labels.

Removes Startpage's own theme machinery (site_theme meta, the
theme-flash-mitigation style, main[data-theme] custom properties), the
ad-free upsell and app-promo tiles, the header logo, the Private Search
pill and the footer. Results are compacted from 181px to 102px per row by
reclaiming the 118px filters spacer, the two 133px left rails and the
hardcoded 56px form height; the fixed header is taken out of flow so its
spacer can go with it.

startpage.user.css is canonical and unpacked/startpage.css is generated
from it by build.mjs for the MV3 "Load unpacked" harness. verify.mjs
injects the compiled sheet into live Startpage and asserts 25 invariants,
which is how the source-order and stacked-padding regressions were caught.

Selectors deliberately avoid Emotion content hashes (css-*) in favour of
data-testid attributes, real form ids and semantic class names, since the
hashes change on every deploy.
2026-10-09 15:39:00 -04:00
unpacked Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
.gitignore Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
build.mjs Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
package-lock.json Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
package.json Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
README.md Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
startpage.user.css Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00
verify.mjs Add Startpage userstyle built on Oxide RFD design tokens 2026-10-09 15:39:00 -04:00

oxide-startpage

Startpage restyled with the design tokens from Oxide's RFD site (https://rfd.shared.oxide.computer/rfd/0704) — one flat dark theme, no ad-free upsell, no logo, no "Private Search" pill, and a much denser results list (181px → 102px pitch).

Install

Stylus (recommended — GPL-3.0, no tracking, unlike Stylish which is Similarweb-owned and leaks browsing history): create a new style and paste startpage.user.css, or install via its @namespace/update URL. Do not run Stylus and Stylish at the same time.

Unpacked MV3 extension (no extension install needed, edits reload with a button): chrome://extensions → Developer mode → Load unpacked → pick unpacked/. Blink ignores @-moz-document, so the generated copy has it stripped.

Files

startpage.user.css       canonical source (UserCSS; uploadable to userstyles.world)
unpacked/manifest.json   MV3 dev harness ("Load unpacked")
unpacked/startpage.css   generated — do not edit
build.mjs                compiles the above from the canonical file
verify.mjs               injects + asserts against live Startpage

Regenerate after editing the source (fails loudly on unbalanced braces, malformed oklch(), or a broken metadata block):

npm run build

Check the result against the real site — this is what catches the source-order and stacked-padding regressions documented below:

npm install
npm run verify           # exits non-zero on failure

verify.mjs needs a real Chrome (Playwright's bundled builds don't run on NixOS — missing libglib-2.0.so.0), and launches headed off-screen to get past Anubis:

CHROME=/etc/profiles/per-user/$USER/bin/google-chrome npm run verify

The @-moz-document wrapper is what Stylus parses natively to build its Applies-to list. There is deliberately no @preprocessor uso line — see UserCSS notes.

Where the tokens came from

Oxide ships one stylesheet (/assets/index-*.css, ~106 KB) built on OKLCH scales (--color-neutral-0…1300, --color-blue-*, --color-green-*) mapped through semantic aliases. It's dark-first: [data-theme=light] are the overrides, so no attribute = dark. The live RFD page sets data-theme="dark".

Values below are getComputedStyle() results read off the real page, not guessed from the cascade:

our var Oxide token value
--ox-surface --surface-default oklch(16.2% .01 260)
--ox-raise --surface-raise oklch(19.5% .009 260)
--ox-secondary --surface-secondary oklch(21.6% .008 260)
--ox-text --content-default oklch(79% .0011 260)
--ox-text-hi --content-raise oklch(90% .0001 260)
--ox-text-2 --content-secondary oklch(71.7% .0021 260)
--ox-text-3 --content-tertiary oklch(60.6% .0031 260)
--ox-accent --content-accent oklch(77% .1919 163.7)
--ox-stroke --stroke-default oklch(31.6% .006 260)
--ox-stroke-2 --stroke-secondary oklch(24.7% .007 260)

Typography recipe, also read from the live page: body 16px / 1.33 / letter-spacing .042em / weight 400; h1 36/42, ls -.468px; h2 25/32; prose 16/24, ls .336px; code GT America Mono 13px, radius 2px. Headings are weight 400, never bold — that's the signature.

Removing Startpage's "mode"

Three parts cooperate to pick a palette:

  1. <meta id="site_theme" name="site_theme" data-site-theme="device|light|dark">
  2. <style class="theme-flash-mitigation"> — pre-paint background so there's no white flash
  3. <main data-theme="light"> + .themeProvider + .theme-checker

We override all of it and set color-scheme: dark. On the homepage the palette lives in custom properties on <main data-theme>, so those get redefined.

Gotcha — never style .theme-checker. It's an invisible probe div (<div class="theme-checker" style="color:#f2f3ff">) whose computed color Startpage's JS reads back to decide which palette it's in. Restyling it desyncs their code from reality. We win with !important instead.

Specificity note: their dark block is .css-1qp6exj:not([data-theme="light"]) = (0,2,0), which beats main[data-theme] = (0,1,1) regardless of injection order — so every custom-property override needs !important.

Note the SERP has zero --background: declarations; only the homepage uses custom properties. SERP colors are painted directly onto elements.

Removing the upgrade banner

Verified present on homepage and SERP:

.ad-free-promo-card            (+ --mobile-top, --sidebar variants)
.subscription-nav, .subscription-nav-wrapper, .nav-subscription-wrapper
.go-ad-free-button, .go-ad-free-button-wrapper, .go-ad-free-button-ripple
.top-banner, .banner-container
.middle-promo-row > .extension-and-app-middle-promo > .extension-promo | .app-promo
.sticky-nav-get-extension-cta, .extension-onboarding-serp-toast, .browser-extension-logo
[data-testid="product-cta-{ad-free,app,extension}"]
[data-testid="hamburger-menu-browser-extension-cta"]

Plus body.top-banner-visible { padding-top: 0 } — the banner is a body class, and leaving its reserved space gives you a floating header.

.home-btn-logo, .mobile-nav-logo, form[aria-label="Startpage home page"] { display: none !important; }
.private-search-dropdown-wrapper, .private-search-dropdown              { display: none !important; }

The logo nests div.home-btn-logo > form[aria-label="Startpage home page"] > svg.startpage-logo. Don't hide bare .startpage-logo — that class is also on the footer logo. "Private Search" is a dropdown button whose text is in span.dropdown-button-text, so hide the wrapper, not the text.

Density

All numbers below were read off a live SERP with getComputedStyle, then re-read after injecting to confirm.

offender baseline after
nav#filters-container padding-top 118px 4px
.filters margin-left 133px 0 (flex + 18px gap)
main > section margin-left 133px 0
main > section max-width 630px 900px
h2.wgl-title margins 19.92px top+bottom 0 / 2px
p.description margin 16/16, 16px/24px 3px 0 0, 15px/20px
div.upper (site + URL) 35px, stacked 16px, one baseline row
div.pagination-container margins 50/50 10/10
footer padding 48/48, h 157 hidden
result pitch 102px was 181px (−44%)
div.result margin 12px 0
result height 153px 102px
results in a 900px viewport ~5 7

The fixed-header trap

header is position: fixed; top: 0; height: 122px; z-index: 1000, and nav#filters-container's padding-top: 118px is the spacer that keeps everything from sliding under it. Collapsing the spacer alone makes the filter row disappear behind the header (it did, on the first attempt). So the header is taken out of flow first:

header { position: static !important; }   /* scrolls away */

Want the pinned bar back? Set position: fixed again and restore nav#filters-container { padding-top: ~126px }. The two are coupled.

Source order is load-bearing (bitten twice)

Two rules for the same element in different sections, both !important, same specificity → the later one wins, silently. This file got burned twice:

  1. div.result { margin-bottom: 1.75rem !important } in RESULTS overrode DENSITY's 12px.
  2. .w-gl-attribution { display: block !important } (left over from when it was being styled rather than removed) overrode a display: none 50 lines earlier.

Keep one rule per element, in the last block that touches it. div.result's 1.75rem and p.description's 16px/24px were both stuck for this reason — the TYPOGRAPHY and RESULTS blocks come later and re-assert at equal specificity.

Oxide tokens actually used

Read from the live RFD page in dark mode — including the ones added for the interaction layer:

--surface-hover        oklch(24.7% .007 260)   hover fill
--surface-tertiary     oklch(31.6% .006 260)
--content-quaternary   oklch(48.3% .0041 260)  most muted text
--content-accent-2     oklch(67% .1831 167)    focus ring
--surface-accent       oklch(24% .0722 183.7)  deep green, primary buttons
--stroke-tertiary      oklch(21.6% .008 260)

The canonical Oxide bordered box was read off their <pre>: background --surface-default, 1px solid --stroke-secondary, border-radius: 2px. The search field now uses exactly that recipe (it previously used --surface-raise + --stroke-default, which was slightly too light).

Accent green is reserved for meaningful emphasis only — link hover, active tab underline, focus ring — never decoration. color-mix(... currentColor 50%, transparent) underline comes from Oxide's own link rule.

Also fixed: "Visit in Anonymous View" stayed Startpage-lavender because the color lives on a child span, which ignores the parent a rule — needs .anonymous-view-link, .anonymous-view-link * { color: … }.

Search field height — height: 56px + an absolute-positioned box

Two stacked causes, found by measuring the form's subtree:

  1. form#search has an explicit height: 56px (computed height, not min-height) while its tallest child is 20px → ~34px of dead space. min-height: 0 does nothing here.
  2. But simply setting height: auto made things worse — the tabs row slid up and got painted over by the search box. Reason: .search-form-container is position: absolute; z-index: 999, so it never contributed height to .search-form-relative-container (measured h: 0). Startpage's height: 56px on the form was the only thing holding the header open.

So both are needed:

.search-input-container { padding: 6px 0 !important; margin: 0 !important; }
before after
form#search height 56px 21px → 33px with the 6px padding
header height 98px 65px
tabs row top 6 (overlapped) 29

Why it didn't look Oxide (diagnosis)

A palette of near-achromatic neutrals carries almost no signal on its own. --color-neutral-0 is oklch(16.2% .01 260) and --color-neutral-1300 is oklch(95% .0001 260) — the chroma is ~0.0001–.01, and adjacent surfaces differ by 3–5% lightness. So four things were missing, in order of impact:

  1. The typefaces. SuisseIntl and GT America Mono aren't installed, so everything fell back to Helvetica/Arial/DejaVu. Oxide's character is substantially typographic; the palette alone cannot reproduce it. This is the single biggest remaining gap and no amount of colour work fixes it.
  2. Surface layering. The density pass had set backgrounds transparent everywhere, collapsing the page to one flat field. Restored three levels: --surface-default (page, search field) / --surface-raise (header plane, chips) joined by --stroke-secondary hairlines.
  3. Accent usage. The green only appeared on hover. Oxide uses the deep green --surface-accent for selection and active states — their own rule is ::selection { background-color: var(--surface-accent); color: var(--content-accent) }, now copied verbatim.
  4. Micro-labels. Oxide labels everything in uppercase tracked mono (their table th recipe) — and the density pass had deleted the only label on the page.

Badge recipe (verbatim from Oxide's .asciidoc-body table th)

font-family: var(--font-mono);
letter-spacing: .04rem;
text-transform: uppercase;
font-feature-settings: "ss02" on,"ss03" on,"ss09" on,"ss06" on,"ss07" on,"ss08" on,"calt" off;
font-size: .6875rem; font-weight: 400; line-height: 1rem;
color: var(--content-secondary);
background-color: var(--surface-raise);
border: 1px solid var(--stroke-secondary);

Applied to .header-nav-item-button (tabs) and .dropdown-display / .dropdown-select / .filter-toggle (filters). Verified computed: 11px, uppercase, letter-spacing 0.64px, bg oklch(0.195 0.009 260), border oklch(0.247 0.007 260), radius 2px.

The active tab cannot be styled

<button class="header-nav-item-button" data-testid="Images" role="link"> — there is no aria-selected, aria-current, class, or inline style distinguishing the active tab, before or after hydration. Hydration changes only computed color (rgb(166,175,255) vs rgb(158,165,189)) and font-weight (500 vs 400). CSS cannot select on computed style.

So font-weight is deliberately not overridden — Startpage's own weight difference is the only active cue that survives. Forcing a per-tab accent would require knowing which tab is active, which the DOM does not say.

Chip padding and centring

Two separate bugs, both found by measuring the ink box rather than the element box:

  1. Stacked padding. The badge set padding: 3px 9px on .header-nav-item-button, but a leftover density rule still gave the inner .header-nav-item padding: 4px 10px + margin: 0 2px — 21px of padding per side around an 11px label. Fix: the chip owns all padding (2px 8px), the two inner wrappers are zeroed and set display: block, and the button is inline-flex + align-items/justify-content: center.
  2. The trailing &nbsp;. The label markup is All<!-- -->&nbsp; — a real text node, not a pseudo-element. text-align: center centres the label plus the nbsp, so the visible glyphs sat 3.6px left of centre (nbsp advance measured at 7.3px). Fix: negative right margin equal to the nbsp advance on .header-nav-item-text.

Measured before → after:

before after
chip width 73px 40px
chip height 32px 22px
chip padding 3px 9px + inner 4px 10px + inner margin 2px 2px 8px only
glyph centre error −3.6px 0

Note gapL/gapR around the ink box stay asymmetric on purpose — the ink box includes the nbsp. The metric that matters is the centre error of the visible glyphs, which is 0.

Search field padding

.search-input-container shipped padding: 10px 85px 10px 0 + margin-left: 16px. All zeroed. That 85px right padding was reserving room for the clear/submit buttons, so the reservation moved to input#q { padding: 0 80px 0 12px } — zero it there too and the text runs under the buttons.

Selector policy (Startpage is Next.js + Emotion)

Zero external stylesheets; 201 runtime-injected <style data-emotion> tags; class names like css-1qp6exj are content hashes that die on deploy.

use avoid
[data-testid] (search, q, gl-title-link, site-footer…) .css-*
#q, form#search (real form ids) copied DevTools selectors containing css-
real names: .search-form-container, .header-nav-item, .ad-free-promo-card, .wgl-* nth-child chains
div.result bare .result — also matches .result-title, .result-link, [data-testid=result-favicon]
a.result-title .result-title a — the anchor is .result-title, wrapping <h2 class="wgl-title">
.search-form-container for the border styling only form#search — the lavender ring rgb(167,177,252) is on the wrapper

Verifying

Playwright's bundled browsers don't run on NixOS (missing libglib-2.0.so.0); point it at system Chrome instead, and pass through Anubis with a headed context (--window-position=-4000,-4000 parks it off-screen):

timeout 240 node verify.mjs

Last run:

check SERP HOME
body background = Oxide surface oklch(0.162 0.01 260) ✓ ✓
body color = --content-default oklch(0.79 0.0011 260) ✓ ✓
letter-spacing .042em @16px 0.672px ✓ ✓
headings weight 400 ✓ ✓
ad-free promo cards hidden 2 found / 0 visible ✓ ✓
subscription nav hidden 2 found / 0 visible ✓ 1 found / 0 visible ✓
logo hidden 1 found / 0 visible ✓ ✓
"Private Search" hidden 1 found / 0 visible ✓ n/a
filter row still present 1 visible, 3 dropdowns ✓ n/a
nav#filters-container padding 118px → 4px ✓ n/a
header position fixed → static ✓ n/a
result title 17px/22px, oklch(0.9 0.0001 260), w400 ✓ n/a
URL line mono GT America Mono 13px, oklch(0.606 0.0031 260) ✓ n/a
results column 630px → 868px, left 149px → 16px ✓ n/a

Caveats

  • Fonts. SuisseIntl and GT America Mono are licensed commercial faces that Oxide happens to serve publicly. This style uses local() only, so it falls back to Helvetica/Arial unless you own them. Don't hotlink their .woff2 into a published style — that's redistributing a licensed font.
  • Still present because you didn't ask: the SERP Feedback button (.feedback-button-container), the homepage "Learn more" scroll cue, and "Hide promotional messaging" bottom-right.

UserCSS notes

The ==UserStyle== metadata block must start at byte 0 of startpage.user.css or Stylus won't parse it (build.mjs asserts this).

There is deliberately no @preprocessor uso line. That directive asks Stylus to run the USOScript preprocessor, which only runs for styles created in UserCSS mode — otherwise it warns:

Code that depends on @preprocessor won't work because this style wasn't created in UserCSS mode.

Nothing here uses USOScript ([[ ]], @var), and @-moz-document is parsed natively by Stylus, so the line only produced the warning. If you add @var knobs, re-add the directive and create the style via Manage → Styles → Import from file… ("Write new style" creates a plain style, which is how the warning appears).

Still open

  • Fonts fall back to Helvetica/Arial unless you own SuisseIntl + GT America Mono.
  • SERP Feedback button and the homepage "Learn more" scroll cue remain.
  • Header is static, so the search bar scrolls away.