- CSS 81.1%
- JavaScript 18.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| unpacked | ||
| .gitignore | ||
| build.mjs | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| startpage.user.css | ||
| verify.mjs | ||
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:
<meta id="site_theme" name="site_theme" data-site-theme="device|light|dark"><style class="theme-flash-mitigation">— pre-paint background so there's no white flash<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!importantinstead.
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.
Removing the logo and "Private Search"
.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:
div.result { margin-bottom: 1.75rem !important }in RESULTS overrode DENSITY's 12px..w-gl-attribution { display: block !important }(left over from when it was being styled rather than removed) overrode adisplay: none50 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:
form#searchhas an explicitheight: 56px(computedheight, notmin-height) while its tallest child is 20px → ~34px of dead space.min-height: 0does nothing here.- But simply setting
height: automade things worse — the tabs row slid up and got painted over by the search box. Reason:.search-form-containerisposition: absolute; z-index: 999, so it never contributed height to.search-form-relative-container(measuredh: 0). Startpage'sheight: 56pxon 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:
- 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.
- 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-secondaryhairlines. - Accent usage. The green only appeared on hover. Oxide uses the deep
green
--surface-accentfor selection and active states — their own rule is::selection { background-color: var(--surface-accent); color: var(--content-accent) }, now copied verbatim. - Micro-labels. Oxide labels everything in uppercase tracked mono (their
table threcipe) — 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:
- Stacked padding. The badge set
padding: 3px 9pxon.header-nav-item-button, but a leftover density rule still gave the inner.header-nav-itempadding: 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 setdisplay: block, and the button isinline-flex+align-items/justify-content: center. - The trailing
. The label markup isAll<!-- --> — a real text node, not a pseudo-element.text-align: centercentres 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.woff2into 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.