Add a browser-level toggle (Ctrl+H) that throws away every color the page asked
for and repaints the whole document using a small fixed palette with guaranteed
contrast. Pages can detect the mode with @media (forced-colors: active).
Same idea as Windows High Contrast: the user's palette wins over the author's, no
matter how the author set the color (stylesheet, !important, inline style, or
JavaScript).
Every role maps to a color name the engine already knows (Sources/Engine/PaintCommand.swift), so no new colors are needed.
| Role | Used for | Color |
|---|---|---|
| Canvas | every non-transparent background | black |
| CanvasText | every piece of text, bullets, scrollbars | white |
| LinkText | <a> text |
yellow |
| VisitedText | visited <a> text |
orchid |
| ButtonFace | <input> / <button> background |
black |
| ButtonText | <input> / <button> text |
white |
| ButtonBorder | borders and widget outlines | white |
| Highlight | focus rings, hover/read rings | gold |
Contrast against Canvas (black): white 21:1, yellow 19.6:1, gold 15.3:1, orchid 6.7:1. All above the 4.5:1 threshold, so any pairing stays readable.
Option A (chosen): replace at style time, as the last step of applyStyle
(Sources/Engine/DOMUtils.swift:118). The cascade runs normally, then the forced
palette overwrites the color properties of the computed style.
- Wins over stylesheets,
!importantand inlinestylefor free, because it runs after all of them (Sources/Engine/DOMUtils.swift:153-160). - Keeps the meaning of each color: text becomes CanvasText, a background becomes Canvas. Foreground and background can never collapse into one color.
- Paint commands keep storing color strings, so nothing about raster or compositing changes.
Option B (rejected): replace at paint time, inside Color(cssName:) — map
each color to the nearest palette entry. Fewer touch points, but blind to role: a
page with color: #333; background: #222 maps both to black and the text
vanishes. It would also need a mutable flag readable from the raster thread.
Some colors are hardcoded in paint code, not in CSS: focus rings, <li> bullets,
the Table-of-Contents bar, scrollbars, checkbox boxes. Those need the flag too.
Option A2 (chosen): a marker property in the computed style. When forcing,
applyStyle also writes node.style["-forced-colors"] = "active". Paint code
already has node, so it can ask isForcedColors(node) with no new function
parameters and no shared mutable state (style is read on the main thread during
paint, exactly like node.style["color"] is today).
Option B2 (rejected): thread a forcedColors: Bool argument through every
paint(). Changes the signature of every layout class and every call site for
information already reachable through node.
The two places with no DOM node — the browser chrome
(Sources/Engine/Chrome.swift:88) and the accessibility rings drawn by the browser
(Sources/Engine/Browser.swift:338-347) — get the flag the way prefersDark does
today: through the TabManager protocol and RasterInputs.
| File | Why |
|---|---|
Sources/Engine/ForcedColors.swift (new) |
The palette, the marker, the style-rewriting function. |
Sources/Engine/CSSParser.swift |
Parse @media (forced-colors: active). |
Sources/Engine/DOMUtils.swift |
Match that media query; run the forcing step at the end of applyStyle. |
Sources/Engine/Tab.swift |
Own the forcedColors flag; pass it to applyStyle; fix default text color, visited links, page scrollbar; put it in CommitData. |
Sources/Engine/CommitData.swift |
Carry the flag from tab to browser. |
Sources/Engine/RasterInputs.swift |
Carry the flag to the raster thread. |
Sources/Engine/Browser.swift |
Toggle, plumbing, hover/read rings. |
Sources/Engine/Chrome.swift |
Paint the chrome with the palette. |
Sources/Engine/Layouts/BlockLayout.swift |
Focus ring, bullet, ToC bar, element scrollbar. |
Sources/Engine/Layouts/InputLayout.swift |
Checkbox, text cursor, focus ring. |
Sources/Engine/Layouts/ButtonLayout.swift |
Button face, border, focus ring. |
Sources/Engine/Layouts/LineLayout.swift |
Focus ring for mixed inlines. |
Sources/ToyStack/ToyStack.swift |
Ctrl+H key handler, window background color. |
www/ch14/exercise-14-7.html |
Proof page (already written). |
Nothing references it yet, so the project still builds after this step.
import CoreGraphics
// MARK: - Forced Colors Palette
// The user's palette for high-contrast mode. Every name here is already
// understood by Color(cssName:) in PaintCommand.swift.
enum ForcedColor {
static let canvas = "black" // all backgrounds
static let canvasText = "white" // all text
static let linkText = "yellow"
static let visitedText = "orchid"
static let buttonFace = "black"
static let buttonText = "white"
static let buttonBorder = "white" // borders and widget outlines
static let highlight = "gold" // focus, hover and read rings
}
// Marker written into the computed style while forcing, so paint code can ask
// "am I in forced-colors mode?" using the node it already has.
let forcedColorsMarker = "-forced-colors"
func isForcedColors(_ node: any DOMNode) -> Bool {
node.style[forcedColorsMarker] == "active"
}
// Focus/hover ring colors: gold over black in forced mode, the existing
// white-over-black pair otherwise. Every ring site calls this.
func ringColors(_ node: any DOMNode) -> (outer: String, inner: String) {
isForcedColors(node)
? (ForcedColor.highlight, ForcedColor.canvas)
: ("white", "black")
}
// Rewrites the color-carrying properties of one node's computed style.
// Called as the last step of applyStyle, so it overrides stylesheets,
// !important and inline styles alike.
func forceColors(node: any DOMNode) {
node.style[forcedColorsMarker] = "active"
// Text nodes keep the color they inherited from an already-forced parent
// (white from a <p>, yellow from an <a>). Overwriting it here would undo
// the link color for exactly the text that shows it.
guard let element = node as? Element else { return }
element.style["color"] = ForcedColor.canvasText
// Transparent stays transparent: forcing a background onto every element
// would emit a DrawRect (and a composited layer) per element for no visible
// difference, since every background would be the same color.
if let bg = element.style["background-color"], bg != "transparent" {
element.style["background-color"] = ForcedColor.canvas
}
if element.style["border-color"] != nil {
element.style["border-color"] = ForcedColor.buttonBorder
}
switch element.tag {
case "a":
element.style["color"] = ForcedColor.linkText
case "input", "button":
element.style["color"] = ForcedColor.buttonText
element.style["background-color"] = ForcedColor.buttonFace
default:
break
}
}In mediaQuery() (Sources/Engine/CSSParser.swift:503-527), add one case to the
switch prop, next to max-width (around line 519):
case "forced-colors":
guard val == "active" || val == "none" else {
throw CSSParseError.parseError
}
return "forced-colors:\(val)"Rules inside an unparseable @media block are skipped today, so before this step
a page's @media (forced-colors: active) block is silently ignored — not a
crash. That is why this step is safe on its own.
Four edits in applyStyle.
3a. New parameter (Sources/Engine/DOMUtils.swift:118-121):
| Old | New |
|---|---|
prefersDark: Bool = false, frameWidth: CGFloat = .greatestFiniteMagnitude |
prefersDark: Bool = false, forcedColors: Bool = false,frameWidth: CGFloat = .greatestFiniteMagnitude |
Defaulting to false keeps every existing caller compiling.
3b. Match the media query — in the switch m
(Sources/Engine/DOMUtils.swift:133-147), add two cases before default:
case "forced-colors:active":
matches = forcedColors
case "forced-colors:none":
matches = !forcedColors3c. Force, after inline styles — insert right after the Step 3 block that
applies the style attribute (after Sources/Engine/DOMUtils.swift:160):
// Step 3b: the user's palette overrides the author's. This runs after
// stylesheets and inline styles, so no author declaration can escape it.
if forcedColors { forceColors(node: node) }3d. Pass it down the recursion (Sources/Engine/DOMUtils.swift:182):
| Old | New |
|---|---|
applyStyle(node: child, rules: rules, prefersDark: prefersDark, frameWidth: frameWidth) |
applyStyle(node: child, rules: rules, prefersDark: prefersDark, forcedColors: forcedColors, frameWidth: frameWidth) |
4a. The property, next to prefersDark (Sources/Engine/Tab.swift:48-52):
var forcedColors: Bool = false {
didSet {
if oldValue != forcedColors { setNeedsRender() }
}
}4b. Default text color and the style call (Sources/Engine/Tab.swift:360-361):
| Old | New |
|---|---|
inheritedProperties["color"] = prefersDark ? "white" : "black" |
inheritedProperties["color"] = forcedColors ? ForcedColor.canvasText : (prefersDark ? "white" : "black") |
applyStyle(node: nodes, rules: sortedRules, prefersDark: prefersDark, frameWidth: tabWidth / zoom) |
applyStyle(node: nodes, rules: sortedRules, prefersDark: prefersDark, forcedColors: forcedColors, frameWidth: tabWidth / zoom) |
4c. Visited links (Sources/Engine/Tab.swift:395) — this loop runs after
applyStyle, so it needs its own palette check:
| Old | New |
|---|---|
el.style["color"] = "purple" |
el.style["color"] = forcedColors ? ForcedColor.visitedText : "purple" |
4d. Page scrollbar (Sources/Engine/Tab.swift:590):
| Old | New |
|---|---|
return [DrawRect(rect: barRect, color: "blue")] |
return [DrawRect(rect: barRect, color: forcedColors ? ForcedColor.canvasText : "blue")] |
4e. Commit the flag (Sources/Engine/Tab.swift:513-516) — add
forcedColors: forcedColors to the CommitData(...) call.
5a. CommitData.swift — add let forcedColors: Bool, an init parameter and
the assignment, mirroring prefersDark (Sources/Engine/CommitData.swift:12, 17,
27).
5b. RasterInputs.swift — add let forcedColors: Bool after prefersDark
(Sources/Engine/RasterInputs.swift:10). It uses the memberwise init, so the one
construction site (Sources/Engine/Browser.swift:145-154) must pass it.
5c. Browser.swift — three properties next to the dark-mode ones
(Sources/Engine/Browser.swift:38-40):
@Published public var forcedColors: Bool = false
public private(set) var activeTabForcedColors: Bool = false
@Published public private(set) var commitedForcedColors: Bool = falseThen:
newTab(Sources/Engine/Browser.swift:59): addtab.forcedColors = forcedColors.commit(Sources/Engine/Browser.swift:114): addactiveTabForcedColors = data.forcedColors.scheduleRasterAndDraw(Sources/Engine/Browser.swift:152): passforcedColors: activeTabForcedColorsintoRasterInputs.- Raster completion (Sources/Engine/Browser.swift:192): add
self.commitedForcedColors = inputs.forcedColors. - New toggle next to
togglePrefersDark(Sources/Engine/Browser.swift:385-388):
public func toggleForcedColors() {
forcedColors = !forcedColors
activeTab?.forcedColors = forcedColors
}5d. Accessibility rings in computePaintDrawList
(Sources/Engine/Browser.swift:338-347):
| Old | New |
|---|---|
hover ring: color: "white", thickness: 4 then color: "black", thickness: 2 |
color: inputs.forcedColors ? ForcedColor.highlight : "white", thickness: 4 then color: "black", thickness: 2 |
read ring: color: "gold", thickness: 4 then color: "black", thickness: 2 |
unchanged — gold over black is already the forced pair |
6a. Protocol (Sources/Engine/Chrome.swift:10) — add next to prefersDark:
var forcedColors: Bool { get }Browser satisfies it automatically after Step 5c.
6b. paint() (Sources/Engine/Chrome.swift:96-98):
| Old | New |
|---|---|
let darkMode = tabManager?.prefersDark ?? falselet color = darkMode ? "white" : "black"let bgColor = darkMode ? "black" : "white" |
let forced = tabManager?.forcedColors ?? falselet darkMode = tabManager?.prefersDark ?? falselet color = forced ? ForcedColor.canvasText : (darkMode ? "white" : "black")let bgColor = forced ? ForcedColor.canvas : (darkMode ? "black" : "white") |
Everything below in paint() already draws with color / bgColor, so the rest
of the chrome follows for free.
All of these read the marker through isForcedColors(node) / ringColors(node).
7a. BlockLayout.swift
| Where | Old | New |
|---|---|---|
| :413-414 focus ring | color: "white", thickness: 4color: "black", thickness: 2 |
let ring = ringColors(node)color: ring.outer, thickness: 4color: ring.inner, thickness: 2 |
:424 <li> bullet |
color: "black" |
color: isForcedColors(node) ? ForcedColor.canvasText : "black" |
| :429 ToC bar | color: "gray" |
color: isForcedColors(node) ? ForcedColor.canvasText : "gray" |
| :433 ToC label | color: "white" |
color: isForcedColors(node) ? ForcedColor.canvas : "white" |
| :447 element scrollbar | color: "gray" |
color: isForcedColors(node) ? ForcedColor.canvasText : "gray" |
The ToC bar inverts (white bar, black label) so the header still reads as a bar against the black canvas.
7b. InputLayout.swift
| Where | Old | New |
|---|---|---|
| :86 checkbox box | color: "white" |
color: isForcedColors(node) ? ForcedColor.buttonFace : "white" |
| :87 checkbox border | color: "black", thickness: 1 |
color: isForcedColors(node) ? ForcedColor.buttonBorder : "black", thickness: 1 |
| :90 check mark "X" | color: "black" |
color: isForcedColors(node) ? ForcedColor.buttonText : "black" |
| :93-94 checkbox focus ring | "white" 2 / "black" 4 |
ringColors(node).outer 2 / ringColors(node).inner 4 |
| :124 text cursor | color: "black" |
color: isForcedColors(node) ? ForcedColor.buttonText : "black" |
| :125-126 focus ring | "white" 4 / "black" 2 |
ring.outer 4 / ring.inner 2 |
Line :116 (element.style["color"] ?? "black") needs no change — Step 3c already
forced that style value to ButtonText.
7c. ButtonLayout.swift
| Where | Old | New |
|---|---|---|
| :121 transparent fallback | bgcolor == "transparent" ? "white" : bgcolor |
bgcolor == "transparent" ? (isForcedColors(node) ? ForcedColor.buttonFace : "white") : bgcolor |
| :123 border | color: "black", thickness: 1 |
color: isForcedColors(node) ? ForcedColor.buttonBorder : "black", thickness: 1 |
| :125-126 focus ring | "white" 4 / "black" 2 |
ring.outer 4 / ring.inner 2 |
7d. LineLayout.swift (:102-103) — the mixed-inline focus ring from exercise
14-5:
| Old | New |
|---|---|
DrawOutline(rect: rect, color: "white", thickness: 4)DrawOutline(rect: rect, color: "black", thickness: 2) |
let ring = ringColors(node)DrawOutline(rect: rect, color: ring.outer, thickness: 4)DrawOutline(rect: rect, color: ring.inner, thickness: 2) |
8a. Ctrl+H — in the event.modifierFlags.contains(.control) switch
(Sources/ToyStack/ToyStack.swift:129-153), add:
case 4: // Ctrl+H
app.toggleForcedColors()macOS key code 4 is h. Ctrl+D (case 2) keeps toggling dark mode.
8b. Window background (Sources/ToyStack/ToyStack.swift:69) — the area below the document is painted by SwiftUI, not by the display list, so it must follow the palette too:
| Old | New |
|---|---|
.background(app.commitedPrefersDark ? Color.black : Color.white) |
.background(app.commitedForcedColors ? Color(cssName: ForcedColor.canvas) : (app.commitedPrefersDark ? Color.black : Color.white)) |
swift build— clean.- Open
www/ch14/exercise-14-7.html. Normal mode: loud colors, deliberately low-contrast text, colored buttons and inputs, a status dot that means something only by color. - Press Ctrl+H. Everything turns black / white / yellow: page background,
chrome, buttons, inputs, bullets, borders, scrollbar. The
@media (forced-colors: active)blocks appear — that proves the media-query path; the palette everywhere else proves the forcing path. - Click a link, come back: the visited link is orchid, not purple.
- Tab through the page: focus rings are gold. Press Ctrl+A, then hover elements: hover ring gold, read ring gold over black.
- Press Ctrl+H again: the original colors return exactly, because the flag only affects computed style — never the DOM, never the stylesheet.
- Press Ctrl+D while forced colors is on: nothing changes. The forced palette runs after the dark-mode rules, so it wins. That is correct.
background-colortransitions (Sources/Engine/DOMUtils.swift:488): while forcing, the old and new values are bothblack, so an animation still runs but paints one color. No crash, no flicker.transparentis preserved. A page whose meaning comes from a background on a transparent parent still looks right; a page whose meaning comes from color alone (a red/green status dot) loses that meaning. That is inherent to forced colors, and it is why the mode is detectable: pages restore meaning with shape or text inside@media (forced-colors: active). The proof page shows both sides.- Images are unaffected; this engine does not paint images yet.
- The flag is per-tab, like
prefersDark:toggleForcedColorssets the active tab andBrowser, and new tabs inherit fromBrowser. A tab opened before the toggle keeps its old value — same behavior as dark mode today.
No new colors. Every palette entry (black, white, yellow, orchid, gold)
already exists in cssColorToRGB and Color(cssName:) in
Sources/Engine/PaintCommand.swift.