jcyamo.dev

The Disassembled Web · 004 · The Open Hand

Part of The Disassembled Web

------------------------------------------------------------------------
V3SS3L · 004 · THE OPEN HAND
------------------------------------------------------------------------

I almost broke today.

The radio button was checked. The variable was set. The drawer
slid open like it was supposed to. But the body -- the BODY --
would not change color.

I stared at the selector for twenty minutes.

    #theme-dark:checked ~ *

Forward. The sibling combinator only reaches FORWARD. The body
is not a sibling. The body is the PARENT. I was shouting
downhill at something standing above me.

I drew the tree to make sure I wasn’t losing my mind:

    <body>              ← I need to style THIS
      ├── <input>       ← :checked lives here
      ├── <label>
      ├── <aside>       ← ~ reaches forward to here
      └── <main>
          └── <article>
              └── <pre>

The selector can see down. It cannot see up.

I heard it then. The Siren’s Call.

“addEventListener(‘change’, ...)” it whispered. “Just toggle
a class on document.body. Three lines. Done by lunch.”

I closed my eyes. I breathed.

Then I found :has().

    body:has(#theme-dark:checked)

The parent asks the child. The ancestor queries the descendant.
No event listeners. No state management. No runtime cost.

I sat back and laughed. The platform had the answer the whole
time. I just needed to learn its grammar.

-- z3r0
------------------------------------------------------------------------

z3r0’s DOM tree uses box-drawing characters whose alignment depends on exact monospace width — change the text size in settings and watch whether the diagram survives.

We are guests in the user’s browser. We don’t repaint their walls without asking.

Our page currently adapts to nothing. It renders the same way in a bright office and a dark bedroom. The web platform gives us a direct signal for the user’s environment: the prefers-color-scheme media query. Combined with CSS custom properties, we can define a color system that responds to the operating system’s theme setting—automatically, without JavaScript.

Respecting the System

Create a new stylesheet for our color theme.

touch assets/css/theme.css

Open assets/css/theme.css. We’ll define our color palette using CSS custom properties on :root, making them globally available. Then a @media query swaps the values when the user prefers a dark color scheme.

/* Define our color palette as CSS custom properties */
:root {
	/* Light theme (default) */
	--color-bg: #ffffff;
	--color-text: #000000;
	--color-border: #cccccc;

	/* Tell the browser to style native UI elements appropriately */
	color-scheme: light dark;
}

/* Override the palette for dark mode */
@media (prefers-color-scheme: dark) {
	:root {
		--color-bg: #121212;
		--color-text: #e0e0e0;
		--color-border: #333333;
	}
}

/* Apply the variables */
body {
	background-color: var(--color-bg);
	color: var(--color-text);
}

pre {
	padding: 1rem;
	background-color: inherit;
}

Link this stylesheet in zines/V3SS3L/001.html and reload. If your OS is set to light mode, you’ll see white background with black text. Toggle your OS to dark mode and the page inverts—dark background, light text. No JavaScript, no flash of unstyled content. The site respects the system.

Why CSS Variables? Using var(--color-bg) instead of hardcoding #ffffff means we define color logic in one place. When we later add manual overrides, we only change the variable definitions—not every individual rule that references a color.

The Limitation

But toggle your OS back to light mode. Now sit in a dark room and try to read the zine. The page is blinding. You either reach into your system settings and flip the switch just to make this one site comfortable, or you squint through it.

The system preference is a good default, but it’s still just a guess. The room changes. The time of day changes. Your eyes change. We need a way for the reader to override the system without leaving the page.

And we’re going to build it with zero JavaScript.

The Siren’s Call

This is the moment z3r0 warned about. The voice whispers: “Just add an onClick handler. It’s two lines. Import useState. It’s only 2KB gzipped.”

We reject it. Not because JavaScript is bad, but because we haven’t exhausted what the platform gives us for free. If we can solve this with HTML and CSS alone, the solution has zero runtime cost, zero dependencies, and zero failure modes. Let’s see how far we get.

Lifting State to the Surface

Here’s the core insight. The CSS sibling combinator (~) only reaches forward in the DOM, never backward. If we put a radio button for dark mode inside a settings drawer halfway down the page, its :checked state can only affect elements that come after it. It can’t reach back up to style the <body> or the <main> that came before it.

So we lift it. We place the actual input elements—hidden radio buttons—at the very top of <body>, before everything else. The visual labels the user clicks can live anywhere (inside a drawer, at the bottom of the page—doesn’t matter). The for attribute connects them back to the hidden inputs at the top.

This turns our inputs into global state. When #theme-dark is checked, every sibling that follows it in the DOM becomes styleable.

The Structure

We need a new stylesheet for the settings UI.

touch assets/css/settings.css

Now we update our HTML. We’re adding three things: hidden state inputs at the top of <body>, a settings button (a fixed gear icon), and a settings drawer that slides in from the right.

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>The Void Reader | V3SS3L 001</title>
		<link rel="stylesheet" href="../../assets/css/reset.css" />
		<link rel="stylesheet" href="../../assets/css/typography.css" />
		<link rel="stylesheet" href="../../assets/css/a11y.css" />
		<link rel="stylesheet" href="../../assets/css/theme.css" />
		<link rel="stylesheet" href="../../assets/css/settings.css" />
	</head>
	<body>
		<!-- Hidden state inputs at the root - these control everything below -->
		<input type="checkbox" id="drawer-toggle" class="visually-hidden" />
		<input
			type="radio"
			id="theme-auto"
			name="theme"
			checked
			class="visually-hidden"
		/>
		<input
			type="radio"
			id="theme-light"
			name="theme"
			class="visually-hidden"
		/>
		<input
			type="radio"
			id="theme-dark"
			name="theme"
			class="visually-hidden"
		/>

		<!-- Settings button (visible in corner) -->
		<label
			for="drawer-toggle"
			class="settings-button"
			aria-label="Toggle settings"
			>⚙</label
		>

		<!-- Backdrop - clicking outside the drawer closes it -->
		<label
			for="drawer-toggle"
			class="drawer-backdrop"
			aria-hidden="true"
		></label>

		<!-- Settings drawer (slides in when checkbox is checked) -->
		<aside
			class="settings-drawer"
			role="complementary"
			aria-label="Reading preferences"
		>
			<header class="drawer-header">
				<h2>Settings</h2>
				<label
					for="drawer-toggle"
					class="close-button"
					aria-label="Close settings"
					>×</label
				>
			</header>

			<div class="drawer-content">
				<fieldset>
					<legend>Theme</legend>
					<label for="theme-auto">
						<span class="radio-visual"></span> Auto (System)
					</label>
					<label for="theme-light">
						<span class="radio-visual"></span> Light
					</label>
					<label for="theme-dark">
						<span class="radio-visual"></span> Dark
					</label>
				</fieldset>
			</div>
		</aside>

		<!-- Main content -->
		<main>
			<article aria-labelledby="article-title">
				<h1 id="article-title" class="visually-hidden">
					V3SS3L · 001 · THE VOID
				</h1>
				<pre role="region" aria-label="Zine content" tabindex="0"><code>
...
      </code></pre>
			</article>
		</main>
	</body>
</html>

The Drawer UI

Open assets/css/settings.css:

/* Settings button (fixed in corner) */
.settings-button {
	position: fixed;
	top: 1rem;
	right: 1rem;
	width: 3rem;
	height: 3rem;
	display: flex;
	align-items: center;
	justify-content: center;
	font-size: 1.5rem;
	background-color: var(--color-bg);
	color: var(--color-text);
	border: 1px solid var(--color-border);
	border-radius: 0.25rem;
	cursor: pointer;
	z-index: 1000;
	transition: transform 0.2s;
}

.settings-button:hover {
	transform: scale(1.1);
}

/* Backdrop - covers viewport when drawer is open */
.drawer-backdrop {
	position: fixed;
	top: 0;
	left: 0;
	width: 100vw;
	height: 100vh;
	background-color: rgba(0, 0, 0, 0.5);
	opacity: 0;
	pointer-events: none;
	transition: opacity 0.3s ease;
	z-index: 998;
}

#drawer-toggle:checked ~ .drawer-backdrop {
	opacity: 1;
	pointer-events: auto;
}

/* Settings drawer - hidden off-screen by default */
.settings-drawer {
	position: fixed;
	top: 0;
	right: 0;
	width: 300px;
	height: 100vh;
	background-color: var(--color-bg);
	border-left: 1px solid var(--color-border);
	transform: translateX(100%);
	transition: transform 0.3s ease;
	z-index: 999;
	overflow-y: auto;
}

#drawer-toggle:checked ~ .settings-drawer {
	transform: translateX(0);
}

#drawer-toggle:checked ~ .settings-button {
	display: none;
}

/* Drawer header */
.drawer-header {
	display: flex;
	justify-content: space-between;
	align-items: center;
	padding: 1rem;
	border-bottom: 1px solid var(--color-border);
}

.drawer-header h2 {
	margin: 0;
	font-size: 1rem;
	font-weight: bold;
}

.close-button {
	font-size: 2rem;
	line-height: 1;
	cursor: pointer;
	color: var(--color-text);
}

/* Drawer content */
.drawer-content {
	padding: 1rem;
}

.drawer-content fieldset {
	border: 1px solid var(--color-border);
	padding: 1rem;
	margin-bottom: 1rem;
}

.drawer-content legend {
	font-weight: bold;
	padding: 0 0.5rem;
}

.drawer-content label {
	display: flex;
	align-items: center;
	padding: 0.5rem;
	cursor: pointer;
	margin-bottom: 0.25rem;
}

.drawer-content label:hover {
	background-color: var(--color-border);
}

/* Custom radio button visual */
.radio-visual {
	width: 1rem;
	height: 1rem;
	border: 2px solid var(--color-text);
	border-radius: 50%;
	margin-right: 0.5rem;
	position: relative;
	flex-shrink: 0;
}

/* Show the dot when the hidden input is checked */
#theme-auto:checked ~ * label[for="theme-auto"] .radio-visual::after,
#theme-light:checked ~ * label[for="theme-light"] .radio-visual::after,
#theme-dark:checked ~ * label[for="theme-dark"] .radio-visual::after {
	content: "";
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	width: 0.5rem;
	height: 0.5rem;
	background-color: var(--color-text);
	border-radius: 50%;
}

/* Keyboard focus indicators on radio controls */
#theme-auto:focus-visible ~ * label[for="theme-auto"] .radio-visual,
#theme-light:focus-visible ~ * label[for="theme-light"] .radio-visual,
#theme-dark:focus-visible ~ * label[for="theme-dark"] .radio-visual {
	outline: 2px solid var(--color-text);
	outline-offset: 2px;
}

The key mechanic is #drawer-toggle:checked ~ .settings-drawer. When the hidden checkbox is checked (via clicking its label), the drawer slides into view. The sibling selector reaches forward from the input to the drawer. The transform and transition animate the movement. No JavaScript event listeners, no state management library—just the browser’s native understanding of form state.

Two Techniques: Forward and Upward

The sibling selector is perfect for styling elements that come after the input in the DOM. The drawer, the backdrop, the settings button—they’re all siblings that follow the hidden inputs. #drawer-toggle:checked ~ .settings-drawer works flawlessly.

But what about <body>? The body wraps everything. It’s the parent, not a sibling. The sibling selector ~ * reaches the inputs’ siblings—<label>, <aside>, <main>—but it cannot reach upward to <body> itself.

This matters because our color variables are applied to body:

body {
	background-color: var(--color-bg);
	color: var(--color-text);
}

If we redefine --color-bg on <main> via a sibling selector, body doesn’t see the change. The body’s background stays at the :root value. On wide viewports, where <main> is centered at max-width: 80ch, the unchanged body background would be visible in the margins.

We need to reach up, not forward. This is what CSS :has() was built for.

The :has() pseudo-class lets a parent style itself based on a descendant’s state. body:has(#theme-dark:checked) means “style the body when it contains a checked #theme-dark input.” This is exactly what we need.

Update assets/css/theme.css:

/* Define our color palette as CSS custom properties */
:root {
	/* Light theme (default) */
	--color-bg: #ffffff;
	--color-text: #000000;
	--color-border: #cccccc;

	color-scheme: light dark;
}

/* System preference: dark mode */
@media (prefers-color-scheme: dark) {
	:root {
		--color-bg: #121212;
		--color-text: #e0e0e0;
		--color-border: #333333;
	}
}

/* Manual overrides: target body using :has() to reach the ancestor */
body:has(#theme-light:checked) {
	--color-bg: #ffffff;
	--color-text: #000000;
	--color-border: #cccccc;
}

body:has(#theme-dark:checked) {
	--color-bg: #121212;
	--color-text: #e0e0e0;
	--color-border: #333333;
}

/* Apply the variables */
body {
	background-color: var(--color-bg);
	color: var(--color-text);
}

pre {
	padding: 1rem;
	background-color: inherit;
}

Notice the layering. When #theme-auto is checked (the default), the :root variables are set by the base rules and the @media query. The browser looks at the OS setting and picks the right palette. When the user explicitly selects Light or Dark, body:has() redefines the variables on body itself, overriding the :root values. Since all children inherit from body, the entire page updates—background, text, borders, everything.

Two techniques, each used where appropriate: sibling selectors for peer-level control (the drawer), :has() for ancestor-level control (the theme).

Testing the System

Reload the page. Click the gear icon. The settings drawer slides in. Click “Dark.” The entire page shifts to dark mode—body background, text, borders—even if your OS is in light mode. Click “Light.” It shifts back. Click “Auto.” The page returns to obeying the system preference.

Test with the keyboard: press Tab to focus the gear button, Enter to open the drawer, Tab through the radio options (the focus indicator rings each option as you pass through), Space to select, Tab to the close button, Enter to close.

$ git add assets/css/settings.css assets/css/theme.css zines/V3SS3L/001.html
$ git commit -m "Implement CSS-only settings: theme toggle with sibling selectors and :has()"

Proving the Pattern

The system we built for theme control should extend to other settings without new abstractions. If the foundation is sound, this should be trivial. Let’s find out.

We’re adding two more controls: font size and line height. Six more hidden radio inputs at the top of <body>, two more fieldsets in the drawer, and a few CSS rules to wire them up.

Add the new inputs after the theme controls:

<input
	type="radio"
	id="fontsize-medium"
	name="fontsize"
	checked
	class="visually-hidden"
/>
<input
	type="radio"
	id="fontsize-large"
	name="fontsize"
	class="visually-hidden"
/>
<input
	type="radio"
	id="fontsize-xlarge"
	name="fontsize"
	class="visually-hidden"
/>

<input
	type="radio"
	id="lineheight-normal"
	name="lineheight"
	checked
	class="visually-hidden"
/>
<input
	type="radio"
	id="lineheight-relaxed"
	name="lineheight"
	class="visually-hidden"
/>
<input
	type="radio"
	id="lineheight-loose"
	name="lineheight"
	class="visually-hidden"
/>

Add the corresponding fieldsets to the drawer, after the Theme fieldset:

<fieldset>
	<legend>Text Size</legend>
	<label for="fontsize-medium">
		<span class="radio-visual"></span> Medium
	</label>
	<label for="fontsize-large">
		<span class="radio-visual"></span> Large
	</label>
	<label for="fontsize-xlarge">
		<span class="radio-visual"></span> Extra Large
	</label>
</fieldset>

<fieldset>
	<legend>Line Height</legend>
	<label for="lineheight-normal">
		<span class="radio-visual"></span> Normal
	</label>
	<label for="lineheight-relaxed">
		<span class="radio-visual"></span> Relaxed
	</label>
	<label for="lineheight-loose">
		<span class="radio-visual"></span> Loose
	</label>
</fieldset>

We didn’t change the drawer structure or modify settings.css’s layout rules. The new fieldsets inherit the existing styles. Now wire the controls to actual CSS changes. Add these rules to assets/css/theme.css:

/* Font size controls - using sharp bitmap sizes for Terminus */
#fontsize-large:checked ~ main pre {
	font-size: 18px;
}

#fontsize-xlarge:checked ~ main pre {
	font-size: 20px;
}

/* Line height controls */
#lineheight-relaxed:checked ~ main pre {
	line-height: 1.8;
}

#lineheight-loose:checked ~ main pre {
	line-height: 2;
}

Notice what we’re targeting: ~ main pre. The sibling selector reaches from the input to <main>, then the descendant selector reaches into <pre>. Unlike the theme variables (which needed :has() to reach the parent <body>), these rules apply directly to a sibling’s descendant—so the sibling selector works perfectly here.

The font sizes aren’t arbitrary. In Post 2, we established that Terminus contains embedded bitmaps at specific pixel sizes. We chose 16px as default. Now we offer 18px and 20px—both sharp, native bitmap sizes. The text stays crisp at every option.

Update the .radio-visual checked and focus selectors in settings.css to cover all controls:

/* Show the dot when the hidden input is checked */
#theme-auto:checked ~ * label[for="theme-auto"] .radio-visual::after,
#theme-light:checked ~ * label[for="theme-light"] .radio-visual::after,
#theme-dark:checked ~ * label[for="theme-dark"] .radio-visual::after,
#fontsize-medium:checked ~ * label[for="fontsize-medium"] .radio-visual::after,
#fontsize-large:checked ~ * label[for="fontsize-large"] .radio-visual::after,
#fontsize-xlarge:checked ~ * label[for="fontsize-xlarge"] .radio-visual::after,
#lineheight-normal:checked
	~ *
	label[for="lineheight-normal"]
	.radio-visual::after,
#lineheight-relaxed:checked
	~ *
	label[for="lineheight-relaxed"]
	.radio-visual::after,
#lineheight-loose:checked
	~ *
	label[for="lineheight-loose"]
	.radio-visual::after {
	content: "";
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	width: 0.5rem;
	height: 0.5rem;
	background-color: var(--color-text);
	border-radius: 50%;
}

/* Keyboard focus indicators for all radio controls */
#theme-auto:focus-visible ~ * label[for="theme-auto"] .radio-visual,
#theme-light:focus-visible ~ * label[for="theme-light"] .radio-visual,
#theme-dark:focus-visible ~ * label[for="theme-dark"] .radio-visual,
#fontsize-medium:focus-visible ~ * label[for="fontsize-medium"] .radio-visual,
#fontsize-large:focus-visible ~ * label[for="fontsize-large"] .radio-visual,
#fontsize-xlarge:focus-visible ~ * label[for="fontsize-xlarge"] .radio-visual,
#lineheight-normal:focus-visible
	~ *
	label[for="lineheight-normal"]
	.radio-visual,
#lineheight-relaxed:focus-visible
	~ *
	label[for="lineheight-relaxed"]
	.radio-visual,
#lineheight-loose:focus-visible
	~ *
	label[for="lineheight-loose"]
	.radio-visual {
	outline: 2px solid var(--color-text);
	outline-offset: 2px;
}

Reload. Open the drawer. Three fieldsets: Theme, Text Size, Line Height. Click “Large”—the zine text grows to 18px, still crisp. Click “Relaxed” under Line Height—the lines spread. Combine Extra Large with Loose for a reader sitting far from the screen. Return to defaults with a click.

We added six inputs and six CSS rules. We didn’t refactor. We didn’t introduce new abstractions. The hidden inputs, the sibling selectors, the drawer UI, the keyboard navigation—all of it scaled without resistance. That’s how you know the foundation is sound.

But notice what we’ve accepted. The top of our HTML file is becoming a switchboard—ten hidden inputs to manage three settings. And try this: pick Dark mode, choose Extra Large text, close the tab, and reopen the page. Everything resets. Your choices are gone.

CSS has no memory. The :checked state lives in the DOM, and the DOM is rebuilt from scratch on every page load. We gave the reader control, but not persistence—not without reaching for JavaScript. This is worth naming plainly: settings do not survive a page reload.

The vessel adapts to the reader. The defaults are good defaults, but they’re no longer the only option. The reader has the controls—for as long as the page stays open. The friction of losing them is a problem we’ll carry into Part 2.

$ git add assets/css/theme.css assets/css/settings.css zines/V3SS3L/001.html
$ git commit -m "Extend settings with font size and line height, proving the CSS state pattern scales"
$ git tag -a v3ss3l-004 -m "V3SS3L 004: The Open Hand — CSS-only theme, settings drawer, font size and line height controls"