Public: anyone can read this; the crew can change it.
What the sea looks like, decided once: three doors are about to draw the same world three ways #61
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/54-what-the-sea-looks-like-decided-once-thr → main 1 commit fork
5 files
changed +291
−12
docs/wake.md +107 −4
| @@ -113,6 +113,107 @@ ownership of anything. `src/who.ts` stays the only word on who a person is. | ||
| 113 | 113 | of honest drifting, short enough that a walked-away mote is not still holding a seat when the |
| 114 | 114 | round it joined ends. |
| 115 | 115 | |
| 116 | ## The look | |
| 117 | ||
| 118 | Three doors draw this one world — the canvas at display rate (#10), a server-rendered SVG still | |
| 119 | for the first paint and for JavaScript off (#50, #55), and characters in the terminal (#13) — and a | |
| 120 | player who moves between them has to be looking at the same sea. Decided here is only what they would | |
| 121 | otherwise disagree about: the mark each element makes and what it tells you at a glance. How many | |
| 122 | strokes, how a particle drifts, what is eased and what is decorated on top is each door's, and so is | |
| 123 | anything a viewport forces. These are decisions and not measurements: a pull that shows a picture may | |
| 124 | revise any of them, the same way the physics numbers are the first guess at holding an invariant. | |
| 125 | ||
| 126 | One rule binds every door before the elements do: **the order marks are laid down** — water, the | |
| 127 | field, the gate, the rim, the bloom, motes, names. A current is never drawn over a body and a name is | |
| 128 | never drawn under one, because a lane you cannot see through is a lane you cannot read. | |
| 129 | ||
| 130 | **The field.** Direction and strength ride the same mark, because the field is the thing that makes | |
| 131 | Wake legible and a player reads it in one look or not at all. A graphic door draws one straight stroke | |
| 132 | per cell, centred on it, pointing where the current points, its length proportional to magnitude and | |
| 133 | capped at the cell's own width so strokes never merge into a wash; a text door draws one of eight | |
| 134 | arrows (`←↖↑↗→↘↓↙`), the direction rounded to the nearest, and carries strength as weight (dim, plain, | |
| 135 | bold). Strength is always a fraction of the full scale the *wire* carries — `FIELD_MAX` in | |
| 136 | `src/protocol.ts`, where a field component saturates — and never of the sea's internal cap, so a door | |
| 137 | reads exactly what it was sent. A cell near that full scale is a stroke spanning its cell at full | |
| 138 | opacity, or a bold arrow. A cell at a tenth of it is a tenth as long at a quarter of the opacity: at | |
| 139 | 360px, where the 16-cell field gives each cell about 22px, that is a 2px tick, which honestly says | |
| 140 | "something moves here" and does not claim a direction. Below a twentieth of full scale a cell draws | |
| 141 | nothing, because the flat sea has to look flat — invariant 5 sends the field to zero and the picture | |
| 142 | has to arrive there too. Every strength is one colour (`--sea-field`); length and weight carry the | |
| 143 | rest, so a lane still reads with no colour at all. | |
| 144 | ||
| 145 | **A mote.** Every swimmer is the same mark — a filled disc of one size, an uppercase letter in text — | |
| 146 | and yours is that mark with a ring around it, `@` in text. The ring is on the screen exactly once, | |
| 147 | which is what makes it findable in a crowd of twenty-four; colour (`--sea-you` against `--sea-mote`) | |
| 148 | is the second channel and never the only one, so telling your mote from everyone else's survives | |
| 149 | colour blindness and a monochrome terminal. A name is escaped text truncated at `NAME_MAX` | |
| 150 | (`src/world.ts`), drawn beside its disc in `--sea-name` and never over the rim: a name that would | |
| 151 | cross the rim flips to the inward side of its mote. Your own name is not drawn, because the ring has | |
| 152 | said it already. When room runs out a door drops names before it drops motes — at 360px twenty-four | |
| 153 | names do not fit and twenty-four motes must. | |
| 154 | ||
| 155 | **The bloom.** The largest mark in the sea and the only one that is not a swimmer: a filled disc under | |
| 156 | a heavy ring, in `--sea-bloom`, at least three mote diameters across and never under 22px, so at 360px | |
| 157 | it is unmistakable without a label. It carries no name. Nothing else in the sea is allowed to be that | |
| 158 | big, which is the whole of its reading: the big thing is the shared goal. | |
| 159 | ||
| 160 | **The gate.** A 60° arc on the rim (`src/world.ts`), and a *gap* in the rim before it is a colour: the | |
| 161 | rim's dashes stop where the gate starts and a solid arc at least 4px thick takes over, in | |
| 162 | `--sea-gate`; in text the rim's `·` becomes a run of `=`. At 360px that arc is a sixth of the way | |
| 163 | around the rim, about 190px, so it reads at a glance and never needs a label. When the bloom's angle | |
| 164 | falls inside the gate's the arc doubles in thickness — the one "you are lined up" signal every door | |
| 165 | owes a player, and one that costs no animation. | |
| 166 | ||
| 167 | **The rim, and seeing it cost you.** The rim is one line at full contrast in `--sea-rim`, dashed, and | |
| 168 | the water's texture stops at it. Three readings have to be tellable apart in a glance: **swimming** is | |
| 169 | a round filled disc in open water; **beached** is a disc flattened on its rim side, its outer edge | |
| 170 | squared off, which is geometry a door reads from the frame rather than a flag (in text, the letter sits | |
| 171 | in the rim's own row); **scattered** is `MOTE_STUNNED` in the frame — the disc goes hollow, outline | |
| 172 | only, in `--sea-stunned`, and in text the letter drops to lowercase, your `@` becoming `o`. Hollow | |
| 173 | means "no thruster", and it stays hollow for the three seconds the rim charges (#42, invariant 6), so | |
| 174 | a player sees the cost while they are paying it. The two compose: a mote that arrived fast is usually | |
| 175 | both, and a hollow disc flattened against the rim is the honest picture of paying. What no door | |
| 176 | promises is a countdown — the wire carries a flag and not the ticks left, so the seconds read as | |
| 177 | "still hollow" until the wire carries it (#16). The words for the three states are the record's, in | |
| 178 | `src/concept.ts` (`MOTE_STATES`), so the canvas's text summary, the SVG's label and the terminal's key | |
| 179 | all say *swimming*, *scattered* and *beached*. | |
| 180 | ||
| 181 | ### The palette | |
| 182 | ||
| 183 | Nine names, defined once as CSS custom properties in `static/app.css` for both themes, so the canvas | |
| 184 | and the SVG take the same colour by name and neither of them spells a hex. The terminal cannot read | |
| 185 | CSS, so each name maps here to one ANSI colour; a terminal with no colour loses nothing but colour, | |
| 186 | because every element above is a distinct mark first. | |
| 187 | ||
| 188 | | property | what it marks | ANSI | | |
| 189 | | --------------- | ---------------------------------------------------- | ---------------------- | | |
| 190 | | `--sea-water` | the sea inside the rim, the ground marks are read on | 49, default background | | |
| 191 | | `--sea-rim` | the edge, and the dashes that draw it | 90, bright black | | |
| 192 | | `--sea-field` | every current stroke, at every strength | 34, blue | | |
| 193 | | `--sea-mote` | another swimmer's mote | 37, white | | |
| 194 | | `--sea-you` | your own mote and its ring | 96, bright cyan | | |
| 195 | | `--sea-name` | a name beside a mote, any word drawn in the sea | 39, default foreground | | |
| 196 | | `--sea-bloom` | the bloom | 33, yellow | | |
| 197 | | `--sea-gate` | the gate's arc | 32, green | | |
| 198 | | `--sea-stunned` | a mote that is paying the rim | 31, red | | |
| 199 | ||
| 200 | ### Contrast, and what reduced motion turns off | |
| 201 | ||
| 202 | Two accessibility rules, written here rather than left to each door. | |
| 203 | ||
| 204 | **Contrast.** Every mark keeps at least 3:1 against `--sea-water` in its own theme, and anything a | |
| 205 | player reads as words — a name, a label inside the sea — keeps 4.5:1 and is never drawn under 12px. | |
| 206 | `src/concept.test.ts` measures both out of `static/app.css`, so a prettier colour nobody can see fails | |
| 207 | the check rather than the review. And colour carries no meaning alone anywhere in the sea: the mark | |
| 208 | carries it, colour agrees with it. | |
| 209 | ||
| 210 | **Motion.** `prefers-reduced-motion: reduce` turns off the field's idle animation — drifting particles, | |
| 211 | shimmer, any motion that continues while the world stands still — and nothing else. It never turns off | |
| 212 | the world's own motion: motes, the bloom and the currents keep moving, and a door that interpolates | |
| 213 | between the 10 Hz frames keeps interpolating, because that motion *is* the game and a still sea cannot | |
| 214 | be played. Nothing in the sea flashes faster than 3 Hz under either preference — a scattered mote is | |
| 215 | hollow, never blinking. | |
| 216 | ||
| 116 | 217 | ## Why this one, and not the other two |
| 117 | 218 | |
| 118 | 219 | Three concepts were opened against the direction. Wake was chosen. |
| @@ -203,8 +304,10 @@ no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9). | ||
| 203 | 304 | |
| 204 | 305 | ## What this record does not decide |
| 205 | 306 | |
| 206 | The look of the sea, the wire format, the scoring, the words on the page and the shape of the | |
| 207 | socket are all the build issues'. This file decides the mechanic, the seven invariants and the | |
| 208 | numbers in the table it owns. It says what a seat is; it does not write `join` (#8) or the upgrade | |
| 209 | (#9), and how a spectator's page differs from a swimmer's is #10's and #11's. It stays open while the map is built against it and closes when the | |
| 307 | The wire format, the scoring, the words on the page and the shape of the socket are all the build | |
| 308 | issues'. This file decides the mechanic, the seven invariants, what a seat is, what the rim costs, how | |
| 309 | the sea reads (**The look**) and the numbers in the table it owns. It says what a seat is; it does not | |
| 310 | write `join` (#8) or the upgrade (#9), and how a spectator's page differs from a swimmer's is #10's and | |
| 311 | #11's. **The look** says what every door's marks mean; it draws nothing, budgets no frame and names no | |
| 312 | pixel a door must spend. It stays open while the map is built against it and closes when the | |
| 210 | 313 | vertical slice is live (#10, #11) and these numbers have been tuned against real play. |
src/concept.test.ts +129 −4
| @@ -7,6 +7,8 @@ const record = await Bun.file(new URL("../docs/wake.md", import.meta.url)) | ||
| 7 | 7 | .text() |
| 8 | 8 | .then((text) => text.replace(/\r\n/g, "\n")); |
| 9 | 9 | |
| 10 | const css = await Bun.file(new URL("../static/app.css", import.meta.url)).text(); | |
| 11 | ||
| 10 | 12 | /** Markdown down to its words: no quote markers, no emphasis, one space. */ |
| 11 | 13 | const tidy = (text: string): string => |
| 12 | 14 | text |
| @@ -24,9 +26,9 @@ const section = (heading: string): string => { | ||
| 24 | 26 | return end === -1 ? rest : rest.slice(0, end); |
| 25 | 27 | }; |
| 26 | 28 | |
| 27 | /** Every row of the numbers table, as its three cells. */ | |
| 28 | const rows = (): string[][] => | |
| 29 | section("## The numbers") | |
| 29 | /** Every row of a table in `heading`, as its three cells, header and rule dropped. */ | |
| 30 | const table = (heading: string): string[][] => | |
| 31 | section(heading) | |
| 30 | 32 | .split("\n") |
| 31 | 33 | .filter((line) => line.startsWith("|")) |
| 32 | 34 | .map((line) => |
| @@ -36,7 +38,44 @@ const rows = (): string[][] => | ||
| 36 | 38 | .map((cell) => cell.trim()), |
| 37 | 39 | ) |
| 38 | 40 | .filter((cells) => cells.length === 3 && !/^-+$/.test(cells[0] ?? "")) |
| 39 | .slice(1); // the header row | |
| 41 | .slice(1); | |
| 42 | ||
| 43 | /** The numbers table, which every other test here reads by that name. */ | |
| 44 | const rows = (): string[][] => table("## The numbers"); | |
| 45 | ||
| 46 | /** | |
| 47 | * Every block of `static/app.css` that defines the sea's palette, as name to | |
| 48 | * hex: the light one in `:root` and the two that say dark. Nested at-rules | |
| 49 | * fall out as their inner selector, which is the block that carries colours. | |
| 50 | */ | |
| 51 | const palettes = (): { where: string; colours: Record<string, string> }[] => | |
| 52 | [...css.matchAll(/([^{}]*)\{([^{}]*)\}/g)] | |
| 53 | .map((block) => ({ | |
| 54 | where: (block[1] ?? "").trim().split("\n").pop()?.trim() ?? "", | |
| 55 | colours: Object.fromEntries( | |
| 56 | [...(block[2] ?? "").matchAll(/(--sea-[a-z-]+)\s*:\s*(#[0-9a-f]{6})\s*;/g)].map( | |
| 57 | (line) => [line[1] as string, line[2] as string], | |
| 58 | ), | |
| 59 | ), | |
| 60 | })) | |
| 61 | .filter((block) => Object.keys(block.colours).length > 0); | |
| 62 | ||
| 63 | /** Relative luminance of a `#rrggbb`, the way WCAG defines it. */ | |
| 64 | const luminance = (hex: string): number => { | |
| 65 | const channels = [1, 3, 5].map((at) => { | |
| 66 | const value = parseInt(hex.slice(at, at + 2), 16) / 255; | |
| 67 | return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | |
| 68 | }) as [number, number, number]; | |
| 69 | return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; | |
| 70 | }; | |
| 71 | ||
| 72 | const contrast = (a: string, b: string): number => { | |
| 73 | const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [ | |
| 74 | number, | |
| 75 | number, | |
| 76 | ]; | |
| 77 | return (light + 0.05) / (dark + 0.05); | |
| 78 | }; | |
| 40 | 79 | |
| 41 | 80 | test("every number the record hands this module is the number the module exports", () => { |
| 42 | 81 | const exported = concept as unknown as Record<string, unknown>; |
| @@ -138,6 +177,92 @@ test("invariant 7 is the seat rule, and it is last", () => { | ||
| 138 | 177 | expect(seven).toContain("never exceeds the number of distinct player keys"); |
| 139 | 178 | }); |
| 140 | 179 | |
| 180 | test("the record decides how the sea reads before three doors draw it", () => { | |
| 181 | const look = tidy(section("## The look")); | |
| 182 | // One paragraph per element, and the order marks are laid down, so a | |
| 183 | // current is never drawn over a body and a name never under one. | |
| 184 | for (const element of ["The field.", "A mote.", "The bloom.", "The gate.", "The rim"]) | |
| 185 | expect(look, `"The look" says nothing about ${element}`).toContain(element); | |
| 186 | expect(look).toContain("water, the field, the gate, the rim, the bloom, motes, names"); | |
| 187 | // The field: direction and strength in one mark, read against the scale the | |
| 188 | // wire actually carries, and a tenth of full scale is not a direction. | |
| 189 | expect(look).toMatch(/FIELD_MAX in src\/protocol\.ts/); | |
| 190 | expect(look).toContain("a tenth"); | |
| 191 | expect(look).toContain("360px"); | |
| 192 | // Your mote against everyone else's is a mark before it is a colour. | |
| 193 | expect(look).toContain("colour blindness"); | |
| 194 | expect(look).toContain("monochrome terminal"); | |
| 195 | expect(look).toContain("NAME_MAX"); | |
| 196 | expect(look).toContain("never over the rim"); | |
| 197 | // The rim's three seconds are visible, and the three readings have words. | |
| 198 | expect(look).toContain("MOTE_STUNNED"); | |
| 199 | expect(look).toContain("three seconds"); | |
| 200 | for (const word of Object.values(concept.MOTE_STATES)) | |
| 201 | expect(look, `"The look" never says ${word}`).toContain(word); | |
| 202 | }); | |
| 203 | ||
| 204 | test("the palette is named once, in both themes, and mapped for the terminal", () => { | |
| 205 | const listed = table("### The palette").map((cells) => ({ | |
| 206 | name: (cells[0] ?? "").replace(/`/g, ""), | |
| 207 | ansi: Number((cells[2] ?? "").split(",")[0]), | |
| 208 | })); | |
| 209 | expect(listed.length).toBe(9); | |
| 210 | for (const { name, ansi } of listed) { | |
| 211 | expect(name, `"${name}" is not a custom property`).toStartWith("--sea-"); | |
| 212 | // One ANSI colour each: the terminal cannot read CSS, and a name with no | |
| 213 | // colour there is a door left to guess. | |
| 214 | expect(ansi, `${name} names no ANSI colour`).toBeInteger(); | |
| 215 | expect(ansi).toBeGreaterThan(0); | |
| 216 | } | |
| 217 | ||
| 218 | const themes = palettes(); | |
| 219 | // Light, the system's dark, and the dark a person chose: three blocks, and | |
| 220 | // every name defined in all of them or the canvas and the SVG drift. | |
| 221 | expect(themes.length).toBe(3); | |
| 222 | const named = listed.map(({ name }) => name).sort(); | |
| 223 | for (const theme of themes) { | |
| 224 | expect(Object.keys(theme.colours).sort(), `${theme.where}`).toEqual(named); | |
| 225 | } | |
| 226 | // The two dark blocks are one palette written twice; they have to agree. | |
| 227 | const [, system, chosen] = themes as [unknown, (typeof themes)[0], (typeof themes)[0]]; | |
| 228 | expect(chosen.colours).toEqual(system.colours); | |
| 229 | // And dark is not light: a theme that forgot to change is a theme nobody | |
| 230 | // can read. | |
| 231 | expect(system.colours).not.toEqual((themes[0] as (typeof themes)[0]).colours); | |
| 232 | }); | |
| 233 | ||
| 234 | test("every mark keeps its contrast against the water, in both themes", () => { | |
| 235 | for (const theme of palettes()) { | |
| 236 | const water = theme.colours["--sea-water"] as string; | |
| 237 | for (const [name, colour] of Object.entries(theme.colours)) { | |
| 238 | if (name === "--sea-water") continue; | |
| 239 | const ratio = contrast(colour, water); | |
| 240 | // Words keep 4.5:1, every other mark 3:1 — the record's two numbers, | |
| 241 | // measured here so a prettier colour nobody can see fails the check. | |
| 242 | const floor = name === "--sea-name" ? 4.5 : 3; | |
| 243 | expect( | |
| 244 | ratio, | |
| 245 | `${name} ${colour} is ${ratio.toFixed(2)}:1 on ${water} in ${theme.where}`, | |
| 246 | ).toBeGreaterThanOrEqual(floor); | |
| 247 | } | |
| 248 | } | |
| 249 | }); | |
| 250 | ||
| 251 | test("the accessibility rules are the record's, not each door's", () => { | |
| 252 | const rules = tidy(section("### Contrast, and what reduced motion turns off")); | |
| 253 | expect(rules).toContain("3:1"); | |
| 254 | expect(rules).toContain("4.5:1"); | |
| 255 | expect(rules).toContain("--sea-water"); | |
| 256 | // Reduced motion takes the field's idle animation and leaves the world's | |
| 257 | // own motion, which is the game. | |
| 258 | expect(rules).toContain("prefers-reduced-motion"); | |
| 259 | expect(rules).toContain("idle animation"); | |
| 260 | expect(rules).toContain("never turns off"); | |
| 261 | expect(rules).toMatch(/3 Hz/); | |
| 262 | // The page's own reduced-motion rule is already there, and stays. | |
| 263 | expect(css).toContain("@media (prefers-reduced-motion: reduce)"); | |
| 264 | }); | |
| 265 | ||
| 141 | 266 | test("the page says the round's shape in the record's numbers", () => { |
| 142 | 267 | // The sentence is written in minutes, so the constant has to be whole ones. |
| 143 | 268 | expect(concept.ROUND_SECONDS % 60).toBe(0); |
src/concept.ts +14
| @@ -53,6 +53,20 @@ export const ROUND_SECONDS = 180; | ||
| 53 | 53 | /** The quiet between rounds — long enough to read the ladder, short enough to wait through. */ |
| 54 | 54 | export const INTERMISSION_SECONDS = 20; |
| 55 | 55 | |
| 56 | /** | |
| 57 | * The three readings a mote has, in the words a player meets — "The look" in | |
| 58 | * `docs/wake.md` decides the marks, and these are the words that go with them, | |
| 59 | * so the canvas's text summary, the SVG's label and the terminal's key say one | |
| 60 | * thing each. `stunned` is the frame's flag (`MOTE_STUNNED` in | |
| 61 | * `src/protocol.ts`); `beached` is not on the wire at all, and a door reads it | |
| 62 | * from where the mote is. | |
| 63 | */ | |
| 64 | export const MOTE_STATES = { | |
| 65 | swimming: "swimming", | |
| 66 | stunned: "scattered", | |
| 67 | beached: "beached", | |
| 68 | } as const; | |
| 69 | ||
| 56 | 70 | /** |
| 57 | 71 | * The things every build issue is measured against, in the order |
| 58 | 72 | * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that |
src/socket.test.ts +9 −4
| @@ -33,8 +33,13 @@ const server = serve({ | ||
| 33 | 33 | dataDir: mkdtempSync(join(tmpdir(), "socket-")), |
| 34 | 34 | identityKey: KEY, |
| 35 | 35 | }); |
| 36 | const base = `http://${server.hostname}:${server.port}`; | |
| 37 | const wsUrl = `ws://${server.hostname}:${server.port}/ws`; | |
| 36 | // Bun types `port` as optional, because a server may be listening on a unix | |
| 37 | // socket instead. This one asked for a port, so it has one — said once here so | |
| 38 | // the handshake below can hand it to `Bun.connect`, which wants a number. | |
| 39 | if (server.port === undefined) throw new Error("the test server took no port"); | |
| 40 | const port: number = server.port; | |
| 41 | const base = `http://${server.hostname}:${port}`; | |
| 42 | const wsUrl = `ws://${server.hostname}:${port}/ws`; | |
| 38 | 43 | // Stopping is not awaited: a websocket the runtime is still tearing down can |
| 39 | 44 | // keep `stop(true)` pending, and the test process has nothing left to wait for. |
| 40 | 45 | afterAll(() => { |
| @@ -449,13 +454,13 @@ async function shake(headers: Record<string, string>): Promise<string> { | ||
| 449 | 454 | const lines = Object.entries(headers).map(([k, v]) => `${k}: ${v}`); |
| 450 | 455 | const socket = await Bun.connect({ |
| 451 | 456 | hostname: "127.0.0.1", |
| 452 | port: server.port, | |
| 457 | port, | |
| 453 | 458 | socket: { |
| 454 | 459 | open(sock) { |
| 455 | 460 | sock.write( |
| 456 | 461 | [ |
| 457 | 462 | "GET /ws HTTP/1.1", |
| 458 | `Host: 127.0.0.1:${server.port}`, | |
| 463 | `Host: 127.0.0.1:${port}`, | |
| 459 | 464 | "Upgrade: websocket", |
| 460 | 465 | "Connection: Upgrade", |
| 461 | 466 | "Sec-WebSocket-Version: 13", |
static/app.css +32
| @@ -22,6 +22,20 @@ | ||
| 22 | 22 | : 1.125rem; |
| 23 | 23 | : 1.5rem; |
| 24 | 24 | : 44px; |
| 25 | ||
| 26 | /* The sea's palette — "The look" in docs/wake.md. Named once so the canvas | |
| 27 | and the server-rendered SVG take the same colour by name; the terminal | |
| 28 | has one ANSI colour per name in the record. Every mark keeps 3:1 against | |
| 29 | --sea-water and --sea-name keeps 4.5:1, measured in src/concept.test.ts. */ | |
| 30 | : #edf1f6; | |
| 31 | : #5a656f; | |
| 32 | : #46709c; | |
| 33 | : #23262a; | |
| 34 | : #b02f7a; | |
| 35 | : #23262a; | |
| 36 | : #9a5b00; | |
| 37 | : #0f5d3a; | |
| 38 | : #b3261e; | |
| 25 | 39 | } |
| 26 | 40 | @media (prefers-color-scheme: dark) { |
| 27 | 41 | :root:not([data-theme="light"]) { |
| @@ -35,6 +49,15 @@ | ||
| 35 | 49 | : #fb923c; |
| 36 | 50 | : #ecece7; |
| 37 | 51 | : #131312; |
| 52 | : #0d1317; | |
| 53 | : #8b98a4; | |
| 54 | : #7fa8d4; | |
| 55 | : #e6eaee; | |
| 56 | : #ff9ad0; | |
| 57 | : #e6eaee; | |
| 58 | : #ffc884; | |
| 59 | : #3fbf8c; | |
| 60 | : #ff8a75; | |
| 38 | 61 | } |
| 39 | 62 | } |
| 40 | 63 | :root[data-theme="dark"] { |
| @@ -48,6 +71,15 @@ | ||
| 48 | 71 | : #fb923c; |
| 49 | 72 | : #ecece7; |
| 50 | 73 | : #131312; |
| 74 | : #0d1317; | |
| 75 | : #8b98a4; | |
| 76 | : #7fa8d4; | |
| 77 | : #e6eaee; | |
| 78 | : #ff9ad0; | |
| 79 | : #e6eaee; | |
| 80 | : #ffc884; | |
| 81 | : #3fbf8c; | |
| 82 | : #ff8a75; | |
| 51 | 83 | } |
| 52 | 84 | |
| 53 | 85 | /* The page. */ |