mykiwi/mykiwi.blogpublic Fork
⑂ fb319ad
Commits
⬇ Clone ▾
git clone https://git.rickub.com/mykiwi/mykiwi.blog.git
git clone ssh://git@rickub.com/mykiwi/mykiwi.blog.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Let the reader pick the theme, and spread it from the switchUnverified

The palette flipped on prefers-color-scheme alone, so the only way to
read the site the other way round was to change your machine. There is
now a switch at the right end of the masthead, and the change sweeps out
from it rather than landing all at once.

The icon is one disc wearing two faces. The sun is that disc plus eight
rays; the moon is the same disc with a second circle in an SVG mask slid
over its rim to carve the crescent, while the rays fold away. Both move
with transform only and never an SVG geometry attribute - Safari is
unreliable there, as the note about `d` in baseof.html already records.

What gets stored is an override, not a theme. `data-theme` is absent
until the reader asks for something their OS is not already saying, and
toggling back into agreement removes it rather than pinning the same
value, so a machine that turns dark at sunset still carries the site
with it. Every dark rule therefore has two halves - "the OS says dark
and nothing overrides it", and "the reader pinned dark" - and writing
that pair out by hand in four places is how the two drift apart, so the
`dark` and `dark-root` mixins write it instead. The print block needed
`:root[data-theme]` alongside `:root` for the same reason: a pinned
theme is an attribute selector and would otherwise have outranked it and
printed the page on black paper.

Reading the stored value happens inline in <head>, above the stylesheet,
because a script that runs any later paints the wrong theme first and
corrects it. Nothing is written onto the button itself: which face the
icon wears, and which of its two labels is the accessible name, both
fall out of `data-theme` in CSS. That is not tidiness - hx-boost morphs
a freshly rendered masthead in on every navigation, and anything JS had
put on the element would be silently reverted. Two labels rather than
one so the button announces the action, not a state; display:none is
what keeps the other out of the accessible name.

Three places would otherwise have gone on following the OS and quietly
contradicted the choice. The theme-color metas keep their media queries
while the OS decides and are rewritten so exactly one matches once a
theme is pinned. The feed page honours the pin in both of its renderers,
the XSLT one and the feed.js standby, though it carries no switch of its
own - it is somewhere you pass through on the way to a reader. And the
hello world plate was declaring `color-scheme` on itself, which would
have left it the one thing on the page still reading the OS; dropping
the declaration lets it inherit the root's, pinned or not.

The spread is a view transition whose new snapshot is clipped to a
circle growing out of the button, over the old one held still
underneath - the same shape grafikart.fr uses. The pseudo-element rules
that hold those snapshots still are scoped to [data-theme-shift], and
that scope is the part worth keeping: hx-boost wraps every navigation in
a view transition too, and it is these same two pseudo-elements that
carry its cross-fade. Written bare, the rule would have flattened
page-to-page navigation into a hard cut. Anything without
startViewTransition, and anyone who has asked for less motion, changes
theme outright instead - a circle sweeping the whole screen is squarely
what that request is about.

Checked in Chromium against the minified build: no flash on load with a
theme pinned, the choice surviving a boosted navigation, six overlapping
transitions starting and finishing cleanly under a mashed button, the
metas flipping, both feed renderers dark on a light machine, and
reduced motion changing the theme with no transition at all.

The strings are in all five languages. Their wording in es, pt and ja is
mine and worth a second look.
Romain Gautier committed 2026-09-21T01:33:55+02:00 Browse files
fb319ad parent: b85ee5c
modified blog/assets/css/style.scss +208 -33
@@ -9,6 +9,41 @@
99
1010 $wide: 67.5625rem;
1111
12+// The reader's theme choice is a `data-theme` attribute on <html>, written
13+// before first paint by the boot script in baseof.html. With no choice made
14+// the attribute is absent and the OS preference alone decides, which is why
15+// every dark rule has two halves: "the OS says dark and nothing overrides
16+// it", and "the reader pinned dark outright". Writing that pair by hand
17+// everywhere is how the two drift apart, so it lives here instead. Both
18+// halves outrank the light rule they follow on specificity, not just source
19+// order - an attribute selector on :root is one class more specific - which
20+// is what lets a pinned theme win from anywhere in the file.
21+@mixin dark-root {
22+ @media (prefers-color-scheme: dark) {
23+ :root:not([data-theme="light"]) {
24+ @content;
25+ }
26+ }
27+
28+ :root[data-theme="dark"] {
29+ @content;
30+ }
31+}
32+
33+// The same condition for a rule that is not on :root itself - the element
34+// becomes a descendant of the themed root.
35+@mixin dark {
36+ @media (prefers-color-scheme: dark) {
37+ :root:not([data-theme="light"]) & {
38+ @content;
39+ }
40+ }
41+
42+ :root[data-theme="dark"] & {
43+ @content;
44+ }
45+}
46+
1247 %chrome {
1348 font-family: var(--mono);
1449 font-size: 0.72rem;
@@ -111,19 +146,26 @@ $wide: 67.5625rem;
111146 color-scheme: light dark;
112147 }
113148
114-@media (prefers-color-scheme: dark) {
115- :root {
116- --paper: #15171a;
117- --paper-raised: #1d2025;
118- --ink: #ddd8cd;
119- --ink-muted: #9b958a;
120- --ink-faint: #8a8377;
121- --rule: #2b2f35;
122- --rule-strong: #3f454d;
123- --accent: #e08a45;
124- --accent-wash: #e08a4526;
125- --glow: #1f242b;
126- }
149+@include dark-root {
150+ --paper: #15171a;
151+ --paper-raised: #1d2025;
152+ --ink: #ddd8cd;
153+ --ink-muted: #9b958a;
154+ --ink-faint: #8a8377;
155+ --rule: #2b2f35;
156+ --rule-strong: #3f454d;
157+ --accent: #e08a45;
158+ --accent-wash: #e08a4526;
159+ --glow: #1f242b;
160+
161+ color-scheme: dark;
162+}
163+
164+// Pinning light has to say so out loud: :root's own `color-scheme: light
165+// dark` leaves the browser free to paint form controls, scrollbars and the
166+// canvas behind the page dark, whatever the tokens above it say.
167+:root[data-theme="light"] {
168+ color-scheme: light;
127169 }
128170
129171 @media (min-width: $wide) {
@@ -349,6 +391,135 @@ body {
349391 }
350392 }
351393
394+// Controls, as opposed to the title: kept together at the right end of the
395+// masthead row. Baseline alignment is what the header uses, but a round icon
396+// has no baseline worth aligning to, so the group centres its own children
397+// and hands the header a single baseline - the "About" link's - to work with.
398+.masthead__tools {
399+ flex: none;
400+ display: flex;
401+ align-items: center;
402+ gap: 0.85rem;
403+}
404+
405+// --- Theme toggle ----------------------------------------------------------
406+
407+.theme-toggle {
408+ flex: none;
409+ display: inline-flex;
410+ align-items: center;
411+ justify-content: center;
412+ width: 1.75rem;
413+ height: 1.75rem;
414+ // The disc is optically narrower than its box; pulling the box past the
415+ // text column keeps the icon, not the padding around it, on the margin.
416+ margin-right: -0.3rem;
417+ padding: 0;
418+ border: 0;
419+ border-radius: 50%;
420+ background: none;
421+ color: var(--ink-faint);
422+ cursor: pointer;
423+ -webkit-tap-highlight-color: transparent;
424+
425+ &:hover {
426+ color: var(--accent);
427+ }
428+
429+ &__icon {
430+ display: block;
431+ width: 1.06rem;
432+ height: 1.06rem;
433+ }
434+
435+ &__orb {
436+ fill: currentColor;
437+ }
438+
439+ &__rays {
440+ fill: none;
441+ stroke: currentColor;
442+ stroke-width: 1.7;
443+ stroke-linecap: round;
444+ // SVG elements take their transform origin from the viewBox by default,
445+ // so this is the centre of the icon for both of the parts below.
446+ transform-origin: 12px 12px;
447+ }
448+
449+ // Parked clear of the disc: with nothing of it overlapping, the mask
450+ // removes nothing and the moon reads as a full sun.
451+ &__bite {
452+ transform: translate(7.5px, -7.5px);
453+ transform-origin: 12px 12px;
454+ }
455+
456+ @include dark {
457+ .theme-toggle__rays {
458+ opacity: 0;
459+ transform: rotate(-45deg) scale(0.35);
460+ }
461+
462+ .theme-toggle__bite {
463+ transform: none;
464+ }
465+ }
466+
467+ @media (prefers-reduced-motion: no-preference) {
468+ .theme-toggle__rays,
469+ .theme-toggle__bite {
470+ transition:
471+ transform 0.5s cubic-bezier(0.32, 0.5, 0.3, 1),
472+ opacity 0.32s ease;
473+ }
474+ }
475+}
476+
477+// The theme change spreads out of the switch as a growing circle instead of
478+// landing everywhere at once. The moving part is a view transition: its new
479+// snapshot, clipped to that circle, over its old one held still underneath.
480+// baseof.html drives the clip, which is the half that needs the button's
481+// position; this is the half that has to say the two snapshots do nothing on
482+// their own, or their default cross-fade would run under the wipe.
483+//
484+// Scoped to [data-theme-shift], and that is the part worth pausing on:
485+// hx-boost wraps every navigation in a view transition too, and it is these
486+// same two pseudo-elements that carry its cross-fade. Written bare, the rule
487+// below would flatten page-to-page navigation into a hard cut. The attribute
488+// is on the root for the length of the theme transition and no longer.
489+//
490+// mix-blend-mode goes back to normal because the default, plus-lighter, is
491+// what makes two half-faded snapshots add up to a whole one - and what would
492+// otherwise light up the rim of a hard-edged circle sweeping over an opaque
493+// page.
494+:root[data-theme-shift] {
495+ &::view-transition-old(root),
496+ &::view-transition-new(root) {
497+ animation: none;
498+ mix-blend-mode: normal;
499+ }
500+}
501+
502+// Exactly one of the button's two labels is in the accessibility tree at a
503+// time, so it announces the action rather than the state. display:none is
504+// what keeps the other out of the accessible name - .visually-hidden alone
505+// would leave both of them in it, and the button would read as a
506+// contradiction. The survivor stays hidden on screen; the icon says it.
507+.theme-toggle__say--light {
508+ display: none;
509+}
510+
511+.theme-toggle__say--dark {
512+ @include dark {
513+ display: none;
514+ }
515+}
516+
517+.theme-toggle__say--light {
518+ @include dark {
519+ display: inline;
520+ }
521+}
522+
352523 // --- Prose ---------------------------------------------------------------
353524
354525 .prose {
@@ -874,7 +1045,7 @@ $alerts: (
8741045 &.alert-#{$name} {
8751046 --alert: #{list.nth($pair, 1)};
8761047
877- @media (prefers-color-scheme: dark) {
1048+ @include dark {
8781049 --alert: #{list.nth($pair, 2)};
8791050 }
8801051 }
@@ -1643,7 +1814,12 @@ blockquote.alert {
16431814 // --- Print -----------------------------------------------------------------
16441815
16451816 @media print {
1646- :root {
1817+ // `:root[data-theme]` as well as `:root`, and not for tidiness: a pinned
1818+ // theme is an attribute selector, so on specificity it would otherwise
1819+ // outrank this block and print the page in its screen colours - ink-heavy
1820+ // dark paper included - however far down the file this sits.
1821+ :root,
1822+ :root[data-theme] {
16471823 --paper: #fff;
16481824 --paper-raised: #fff;
16491825 --ink: #000;
@@ -1662,7 +1838,8 @@ blockquote.alert {
16621838 }
16631839
16641840 .colophon,
1665- .skip-link {
1841+ .skip-link,
1842+ .theme-toggle {
16661843 display: none;
16671844 }
16681845
@@ -1714,23 +1891,21 @@ blockquote.alert {
17141891 --syn-del-fg: #a3231f;
17151892 }
17161893
1717-@media (prefers-color-scheme: dark) {
1718- :root {
1719- --syn-comment: #8d867a;
1720- --syn-keyword: #e08a45;
1721- --syn-string: #a6c98d;
1722- --syn-number: #c3a2ec;
1723- --syn-func: #7fb6d6;
1724- --syn-type: #ddc477;
1725- --syn-var: #dda58c;
1726- --syn-punct: #8b857a;
1727- --syn-error: #ec8b7f;
1728- --syn-error-bg: #ec8b7f1f;
1729- --syn-ins-bg: #79c98d1a;
1730- --syn-del-bg: #ec8b7f1a;
1731- --syn-ins-fg: #8fd0a1;
1732- --syn-del-fg: #ef9a8f;
1733- }
1894+@include dark-root {
1895+ --syn-comment: #8d867a;
1896+ --syn-keyword: #e08a45;
1897+ --syn-string: #a6c98d;
1898+ --syn-number: #c3a2ec;
1899+ --syn-func: #7fb6d6;
1900+ --syn-type: #ddc477;
1901+ --syn-var: #dda58c;
1902+ --syn-punct: #8b857a;
1903+ --syn-error: #ec8b7f;
1904+ --syn-error-bg: #ec8b7f1f;
1905+ --syn-ins-bg: #79c98d1a;
1906+ --syn-del-bg: #ec8b7f1a;
1907+ --syn-ins-fg: #8fd0a1;
1908+ --syn-del-fg: #ef9a8f;
17341909 }
17351910
17361911 .highlight {
@@ -9,6 +9,41 @@
9 9
10 $wide: 67.5625rem;10 $wide: 67.5625rem;
11 11
12+// The reader's theme choice is a `data-theme` attribute on <html>, written
13+// before first paint by the boot script in baseof.html. With no choice made
14+// the attribute is absent and the OS preference alone decides, which is why
15+// every dark rule has two halves: "the OS says dark and nothing overrides
16+// it", and "the reader pinned dark outright". Writing that pair by hand
17+// everywhere is how the two drift apart, so it lives here instead. Both
18+// halves outrank the light rule they follow on specificity, not just source
19+// order - an attribute selector on :root is one class more specific - which
20+// is what lets a pinned theme win from anywhere in the file.
21+@mixin dark-root {
22+ @media (prefers-color-scheme: dark) {
23+ :root:not([data-theme="light"]) {
24+ @content;
25+ }
26+ }
27+
28+ :root[data-theme="dark"] {
29+ @content;
30+ }
31+}
32+
33+// The same condition for a rule that is not on :root itself - the element
34+// becomes a descendant of the themed root.
35+@mixin dark {
36+ @media (prefers-color-scheme: dark) {
37+ :root:not([data-theme="light"]) & {
38+ @content;
39+ }
40+ }
41+
42+ :root[data-theme="dark"] & {
43+ @content;
44+ }
45+}
46+
12 %chrome {47 %chrome {
13 font-family: var(--mono);48 font-family: var(--mono);
14 font-size: 0.72rem;49 font-size: 0.72rem;
@@ -111,19 +146,26 @@ $wide: 67.5625rem;
111 color-scheme: light dark;146 color-scheme: light dark;
112 }147 }
113 148
114-@media (prefers-color-scheme: dark) {149+@include dark-root {
115- :root {150+ --paper: #15171a;
116- --paper: #15171a;151+ --paper-raised: #1d2025;
117- --paper-raised: #1d2025;152+ --ink: #ddd8cd;
118- --ink: #ddd8cd;153+ --ink-muted: #9b958a;
119- --ink-muted: #9b958a;154+ --ink-faint: #8a8377;
120- --ink-faint: #8a8377;155+ --rule: #2b2f35;
121- --rule: #2b2f35;156+ --rule-strong: #3f454d;
122- --rule-strong: #3f454d;157+ --accent: #e08a45;
123- --accent: #e08a45;158+ --accent-wash: #e08a4526;
124- --accent-wash: #e08a4526;159+ --glow: #1f242b;
125- --glow: #1f242b;160+
126- }161+ color-scheme: dark;
162+}
163+
164+// Pinning light has to say so out loud: :root's own `color-scheme: light
165+// dark` leaves the browser free to paint form controls, scrollbars and the
166+// canvas behind the page dark, whatever the tokens above it say.
167+:root[data-theme="light"] {
168+ color-scheme: light;
127 }169 }
128 170
129 @media (min-width: $wide) {171 @media (min-width: $wide) {
@@ -349,6 +391,135 @@ body {
349 }391 }
350 }392 }
351 393
394+// Controls, as opposed to the title: kept together at the right end of the
395+// masthead row. Baseline alignment is what the header uses, but a round icon
396+// has no baseline worth aligning to, so the group centres its own children
397+// and hands the header a single baseline - the "About" link's - to work with.
398+.masthead__tools {
399+ flex: none;
400+ display: flex;
401+ align-items: center;
402+ gap: 0.85rem;
403+}
404+
405+// --- Theme toggle ----------------------------------------------------------
406+
407+.theme-toggle {
408+ flex: none;
409+ display: inline-flex;
410+ align-items: center;
411+ justify-content: center;
412+ width: 1.75rem;
413+ height: 1.75rem;
414+ // The disc is optically narrower than its box; pulling the box past the
415+ // text column keeps the icon, not the padding around it, on the margin.
416+ margin-right: -0.3rem;
417+ padding: 0;
418+ border: 0;
419+ border-radius: 50%;
420+ background: none;
421+ color: var(--ink-faint);
422+ cursor: pointer;
423+ -webkit-tap-highlight-color: transparent;
424+
425+ &:hover {
426+ color: var(--accent);
427+ }
428+
429+ &__icon {
430+ display: block;
431+ width: 1.06rem;
432+ height: 1.06rem;
433+ }
434+
435+ &__orb {
436+ fill: currentColor;
437+ }
438+
439+ &__rays {
440+ fill: none;
441+ stroke: currentColor;
442+ stroke-width: 1.7;
443+ stroke-linecap: round;
444+ // SVG elements take their transform origin from the viewBox by default,
445+ // so this is the centre of the icon for both of the parts below.
446+ transform-origin: 12px 12px;
447+ }
448+
449+ // Parked clear of the disc: with nothing of it overlapping, the mask
450+ // removes nothing and the moon reads as a full sun.
451+ &__bite {
452+ transform: translate(7.5px, -7.5px);
453+ transform-origin: 12px 12px;
454+ }
455+
456+ @include dark {
457+ .theme-toggle__rays {
458+ opacity: 0;
459+ transform: rotate(-45deg) scale(0.35);
460+ }
461+
462+ .theme-toggle__bite {
463+ transform: none;
464+ }
465+ }
466+
467+ @media (prefers-reduced-motion: no-preference) {
468+ .theme-toggle__rays,
469+ .theme-toggle__bite {
470+ transition:
471+ transform 0.5s cubic-bezier(0.32, 0.5, 0.3, 1),
472+ opacity 0.32s ease;
473+ }
474+ }
475+}
476+
477+// The theme change spreads out of the switch as a growing circle instead of
478+// landing everywhere at once. The moving part is a view transition: its new
479+// snapshot, clipped to that circle, over its old one held still underneath.
480+// baseof.html drives the clip, which is the half that needs the button's
481+// position; this is the half that has to say the two snapshots do nothing on
482+// their own, or their default cross-fade would run under the wipe.
483+//
484+// Scoped to [data-theme-shift], and that is the part worth pausing on:
485+// hx-boost wraps every navigation in a view transition too, and it is these
486+// same two pseudo-elements that carry its cross-fade. Written bare, the rule
487+// below would flatten page-to-page navigation into a hard cut. The attribute
488+// is on the root for the length of the theme transition and no longer.
489+//
490+// mix-blend-mode goes back to normal because the default, plus-lighter, is
491+// what makes two half-faded snapshots add up to a whole one - and what would
492+// otherwise light up the rim of a hard-edged circle sweeping over an opaque
493+// page.
494+:root[data-theme-shift] {
495+ &::view-transition-old(root),
496+ &::view-transition-new(root) {
497+ animation: none;
498+ mix-blend-mode: normal;
499+ }
500+}
501+
502+// Exactly one of the button's two labels is in the accessibility tree at a
503+// time, so it announces the action rather than the state. display:none is
504+// what keeps the other out of the accessible name - .visually-hidden alone
505+// would leave both of them in it, and the button would read as a
506+// contradiction. The survivor stays hidden on screen; the icon says it.
507+.theme-toggle__say--light {
508+ display: none;
509+}
510+
511+.theme-toggle__say--dark {
512+ @include dark {
513+ display: none;
514+ }
515+}
516+
517+.theme-toggle__say--light {
518+ @include dark {
519+ display: inline;
520+ }
521+}
522+
352 // --- Prose ---------------------------------------------------------------523 // --- Prose ---------------------------------------------------------------
353 524
354 .prose {525 .prose {
@@ -874,7 +1045,7 @@ $alerts: (
874 &.alert-#{$name} {1045 &.alert-#{$name} {
875 --alert: #{list.nth($pair, 1)};1046 --alert: #{list.nth($pair, 1)};
876 1047
877- @media (prefers-color-scheme: dark) {1048+ @include dark {
878 --alert: #{list.nth($pair, 2)};1049 --alert: #{list.nth($pair, 2)};
879 }1050 }
880 }1051 }
@@ -1643,7 +1814,12 @@ blockquote.alert {
1643 // --- Print -----------------------------------------------------------------1814 // --- Print -----------------------------------------------------------------
1644 1815
1645 @media print {1816 @media print {
1646- :root {1817+ // `:root[data-theme]` as well as `:root`, and not for tidiness: a pinned
1818+ // theme is an attribute selector, so on specificity it would otherwise
1819+ // outrank this block and print the page in its screen colours - ink-heavy
1820+ // dark paper included - however far down the file this sits.
1821+ :root,
1822+ :root[data-theme] {
1647 --paper: #fff;1823 --paper: #fff;
1648 --paper-raised: #fff;1824 --paper-raised: #fff;
1649 --ink: #000;1825 --ink: #000;
@@ -1662,7 +1838,8 @@ blockquote.alert {
1662 }1838 }
1663 1839
1664 .colophon,1840 .colophon,
1665- .skip-link {1841+ .skip-link,
1842+ .theme-toggle {
1666 display: none;1843 display: none;
1667 }1844 }
1668 1845
@@ -1714,23 +1891,21 @@ blockquote.alert {
1714 --syn-del-fg: #a3231f;1891 --syn-del-fg: #a3231f;
1715 }1892 }
1716 1893
1717-@media (prefers-color-scheme: dark) {1894+@include dark-root {
1718- :root {1895+ --syn-comment: #8d867a;
1719- --syn-comment: #8d867a;1896+ --syn-keyword: #e08a45;
1720- --syn-keyword: #e08a45;1897+ --syn-string: #a6c98d;
1721- --syn-string: #a6c98d;1898+ --syn-number: #c3a2ec;
1722- --syn-number: #c3a2ec;1899+ --syn-func: #7fb6d6;
1723- --syn-func: #7fb6d6;1900+ --syn-type: #ddc477;
1724- --syn-type: #ddc477;1901+ --syn-var: #dda58c;
1725- --syn-var: #dda58c;1902+ --syn-punct: #8b857a;
1726- --syn-punct: #8b857a;1903+ --syn-error: #ec8b7f;
1727- --syn-error: #ec8b7f;1904+ --syn-error-bg: #ec8b7f1f;
1728- --syn-error-bg: #ec8b7f1f;1905+ --syn-ins-bg: #79c98d1a;
1729- --syn-ins-bg: #79c98d1a;1906+ --syn-del-bg: #ec8b7f1a;
1730- --syn-del-bg: #ec8b7f1a;1907+ --syn-ins-fg: #8fd0a1;
1731- --syn-ins-fg: #8fd0a1;1908+ --syn-del-fg: #ef9a8f;
1732- --syn-del-fg: #ef9a8f;
1733- }
1734 }1909 }
1735 1910
1736 .highlight {1911 .highlight {
modified blog/i18n/en.toml +6 -0
@@ -4,6 +4,12 @@ other = "Skip to content"
44 [about]
55 other = "About"
66
7+[themeToDark]
8+other = "Switch to dark theme"
9+
10+[themeToLight]
11+other = "Switch to light theme"
12+
713 [minRead]
814 other = "{{ .Count }} min read"
915
@@ -4,6 +4,12 @@ other = "Skip to content"
4 [about]4 [about]
5 other = "About"5 other = "About"
6 6
7+[themeToDark]
8+other = "Switch to dark theme"
9+
10+[themeToLight]
11+other = "Switch to light theme"
12+
7 [minRead]13 [minRead]
8 other = "{{ .Count }} min read"14 other = "{{ .Count }} min read"
9 15
modified blog/i18n/es.toml +6 -0
@@ -4,6 +4,12 @@ other = "Saltar al contenido"
44 [about]
55 other = "Acerca de"
66
7+[themeToDark]
8+other = "Cambiar al tema oscuro"
9+
10+[themeToLight]
11+other = "Cambiar al tema claro"
12+
713 [minRead]
814 other = "{{ .Count }} min de lectura"
915
@@ -4,6 +4,12 @@ other = "Saltar al contenido"
4 [about]4 [about]
5 other = "Acerca de"5 other = "Acerca de"
6 6
7+[themeToDark]
8+other = "Cambiar al tema oscuro"
9+
10+[themeToLight]
11+other = "Cambiar al tema claro"
12+
7 [minRead]13 [minRead]
8 other = "{{ .Count }} min de lectura"14 other = "{{ .Count }} min de lectura"
9 15
modified blog/i18n/fr.toml +6 -0
@@ -4,6 +4,12 @@ other = "Passer au contenu"
44 [about]
55 other = "À propos"
66
7+[themeToDark]
8+other = "Passer au thème sombre"
9+
10+[themeToLight]
11+other = "Passer au thème clair"
12+
713 [minRead]
814 other = "{{ .Count }} min de lecture"
915
@@ -4,6 +4,12 @@ other = "Passer au contenu"
4 [about]4 [about]
5 other = "À propos"5 other = "À propos"
6 6
7+[themeToDark]
8+other = "Passer au thème sombre"
9+
10+[themeToLight]
11+other = "Passer au thème clair"
12+
7 [minRead]13 [minRead]
8 other = "{{ .Count }} min de lecture"14 other = "{{ .Count }} min de lecture"
9 15
modified blog/i18n/ja.toml +6 -0
@@ -4,6 +4,12 @@ other = "コンテンツへスキップ"
44 [about]
55 other = "このサイトについて"
66
7+[themeToDark]
8+other = "ダークテーマに切り替える"
9+
10+[themeToLight]
11+other = "ライトテーマに切り替える"
12+
713 [minRead]
814 other = "{{ .Count }}分で読了"
915
@@ -4,6 +4,12 @@ other = "コンテンツへスキップ"
4 [about]4 [about]
5 other = "このサイトについて"5 other = "このサイトについて"
6 6
7+[themeToDark]
8+other = "ダークテーマに切り替える"
9+
10+[themeToLight]
11+other = "ライトテーマに切り替える"
12+
7 [minRead]13 [minRead]
8 other = "{{ .Count }}分で読了"14 other = "{{ .Count }}分で読了"
9 15
modified blog/i18n/pt.toml +6 -0
@@ -4,6 +4,12 @@ other = "Pular para o conteúdo"
44 [about]
55 other = "Sobre"
66
7+[themeToDark]
8+other = "Mudar para o tema escuro"
9+
10+[themeToLight]
11+other = "Mudar para o tema claro"
12+
713 [minRead]
814 other = "{{ .Count }} min de leitura"
915
@@ -4,6 +4,12 @@ other = "Pular para o conteúdo"
4 [about]4 [about]
5 other = "Sobre"5 other = "Sobre"
6 6
7+[themeToDark]
8+other = "Mudar para o tema escuro"
9+
10+[themeToLight]
11+other = "Mudar para o tema claro"
12+
7 [minRead]13 [minRead]
8 other = "{{ .Count }} min de leitura"14 other = "{{ .Count }} min de leitura"
9 15
modified blog/layouts/_default/baseof.html +111 -2
@@ -4,8 +4,117 @@
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1">
66 <meta name="color-scheme" content="light dark">
7- <meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
8- <meta name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)">
7+ <meta id="theme-color-light" name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
8+ <meta id="theme-color-dark" name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)">
9+ {{- /*
10+ The masthead's light/dark switch, both halves of it.
11+
12+ Reading the stored choice has to happen here - inline, in <head>, above
13+ the stylesheet - and not from a deferred or end-of-body script: a
14+ script that runs after first paint means the page is painted in the
15+ wrong theme and then corrected, which is the white flash this whole
16+ arrangement exists to avoid. It is a handful of statements precisely
17+ because everything in front of it is blocked on them.
18+
19+ What it writes is only ever an *override*: `data-theme` is absent until
20+ the reader asks for something other than what their OS says, and
21+ pressing the switch back to agreement removes it again rather than
22+ pinning the same value. So a machine that flips to dark at sunset
23+ carries the site with it, which pinning-by-default would quietly break.
24+ style.scss's `dark` mixin spells out the matching CSS condition.
25+
26+ The two theme-color metas above are wired the same way. While the OS
27+ decides they keep their media queries and the browser picks; a pinned
28+ theme rewrites the pair so exactly one of them matches, since a browser
29+ takes the first theme-color whose media applies and would otherwise go
30+ on tinting its chrome from the OS preference alone.
31+
32+ The click listener is delegated from the document rather than bound to
33+ the button: hx-boost morphs a freshly rendered masthead in on every
34+ navigation, and a listener attached to the element it replaces would go
35+ with it. Nothing is written onto the button either - which face the
36+ icon shows and which of its two labels is the accessible name both fall
37+ out of `data-theme` in CSS - so a swap has nothing of ours to revert.
38+ */}}
39+ <script>
40+ (() => {
41+ const KEY = "theme", root = document.documentElement;
42+ const still = matchMedia("(prefers-reduced-motion: reduce)");
43+ const MEDIA = {
44+ light: "(prefers-color-scheme: light)",
45+ dark: "(prefers-color-scheme: dark)",
46+ };
47+ const apply = (pin) => {
48+ if (pin) root.dataset.theme = pin; else delete root.dataset.theme;
49+ for (const name of ["light", "dark"]) {
50+ const meta = document.getElementById("theme-color-" + name);
51+ if (meta) meta.media = pin ? (pin === name ? "all" : "not all") : MEDIA[name];
52+ }
53+ };
54+
55+ {{- /* Private mode and blocked site data both make this throw rather
56+ than return null, and the theme is not worth a broken page. */}}
57+ let stored = null;
58+ try { stored = localStorage.getItem(KEY); } catch {}
59+ if (stored === "light" || stored === "dark") apply(stored);
60+
61+ {{- /* The new theme spreads out from the switch rather than landing
62+ everywhere at once: a view transition in which the *new* snapshot is
63+ clipped to a circle growing out of the button, over the old one left
64+ standing still underneath. Only this side knows where the button is,
65+ so the geometry lives here; style.scss carries the matching
66+ pseudo-element rules and the reason they are scoped.
67+
68+ The radius has to reach the farthest corner of the viewport or the
69+ wipe stops short of it. Which corner that is needs no search: the two
70+ axes are independent, so the larger distance on each names it.
71+
72+ Anything without startViewTransition, and anyone who has asked for
73+ less motion, gets the theme change outright instead - a circle
74+ sweeping the whole screen is squarely what that request is about. */}}
75+ let shifting = 0;
76+ const spread = (button, change) => {
77+ if (still.matches || !document.startViewTransition) return change();
78+ const box = button.getBoundingClientRect();
79+ const x = box.left + box.width / 2, y = box.top + box.height / 2;
80+ const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
81+ const vt = document.startViewTransition(change);
82+ shifting++;
83+ root.dataset.themeShift = "";
84+ {{- /* Both pseudo-elements are held still by CSS, so this animation
85+ is the only one running and the transition lasts exactly as long as
86+ it does. ready rejects on a transition the browser skipped, which is
87+ nothing to handle but must not go unhandled. */}}
88+ vt.ready.then(() => {
89+ root.animate(
90+ {clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`]},
91+ {
92+ duration: 560,
93+ {{- /* The curve the signal sweep and .rise already use. */}}
94+ easing: "cubic-bezier(0.32, 0.5, 0.3, 1)",
95+ pseudoElement: "::view-transition-new(root)",
96+ },
97+ );
98+ }, () => {});
99+ {{- /* A second press skips the first transition, so the attribute
100+ comes off with the last one out and not the first. */}}
101+ const done = () => { if (!--shifting) delete root.dataset.themeShift; };
102+ vt.finished.then(done, done);
103+ };
104+
105+ document.addEventListener("click", (e) => {
106+ const button = e.target.closest?.("[data-theme-toggle]");
107+ if (!button) return;
108+ const sys = matchMedia(MEDIA.dark).matches ? "dark" : "light";
109+ const next = (root.dataset.theme || sys) === "dark" ? "light" : "dark";
110+ const pin = next === sys ? null : next;
111+ spread(button, () => apply(pin));
112+ try {
113+ if (pin) localStorage.setItem(KEY, pin); else localStorage.removeItem(KEY);
114+ } catch {}
115+ });
116+ })();
117+ </script>
9118 {{ if site.Params.noindex }}
10119 <meta name="robots" content="noindex, nofollow">
11120 {{ end }}
@@ -4,8 +4,117 @@
4 <meta charset="utf-8">4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <meta name="color-scheme" content="light dark">6 <meta name="color-scheme" content="light dark">
7- <meta name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">7+ <meta id="theme-color-light" name="theme-color" content="#f5f2ec" media="(prefers-color-scheme: light)">
8- <meta name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)">8+ <meta id="theme-color-dark" name="theme-color" content="#15171a" media="(prefers-color-scheme: dark)">
9+ {{- /*
10+ The masthead's light/dark switch, both halves of it.
11+
12+ Reading the stored choice has to happen here - inline, in <head>, above
13+ the stylesheet - and not from a deferred or end-of-body script: a
14+ script that runs after first paint means the page is painted in the
15+ wrong theme and then corrected, which is the white flash this whole
16+ arrangement exists to avoid. It is a handful of statements precisely
17+ because everything in front of it is blocked on them.
18+
19+ What it writes is only ever an *override*: `data-theme` is absent until
20+ the reader asks for something other than what their OS says, and
21+ pressing the switch back to agreement removes it again rather than
22+ pinning the same value. So a machine that flips to dark at sunset
23+ carries the site with it, which pinning-by-default would quietly break.
24+ style.scss's `dark` mixin spells out the matching CSS condition.
25+
26+ The two theme-color metas above are wired the same way. While the OS
27+ decides they keep their media queries and the browser picks; a pinned
28+ theme rewrites the pair so exactly one of them matches, since a browser
29+ takes the first theme-color whose media applies and would otherwise go
30+ on tinting its chrome from the OS preference alone.
31+
32+ The click listener is delegated from the document rather than bound to
33+ the button: hx-boost morphs a freshly rendered masthead in on every
34+ navigation, and a listener attached to the element it replaces would go
35+ with it. Nothing is written onto the button either - which face the
36+ icon shows and which of its two labels is the accessible name both fall
37+ out of `data-theme` in CSS - so a swap has nothing of ours to revert.
38+ */}}
39+ <script>
40+ (() => {
41+ const KEY = "theme", root = document.documentElement;
42+ const still = matchMedia("(prefers-reduced-motion: reduce)");
43+ const MEDIA = {
44+ light: "(prefers-color-scheme: light)",
45+ dark: "(prefers-color-scheme: dark)",
46+ };
47+ const apply = (pin) => {
48+ if (pin) root.dataset.theme = pin; else delete root.dataset.theme;
49+ for (const name of ["light", "dark"]) {
50+ const meta = document.getElementById("theme-color-" + name);
51+ if (meta) meta.media = pin ? (pin === name ? "all" : "not all") : MEDIA[name];
52+ }
53+ };
54+
55+ {{- /* Private mode and blocked site data both make this throw rather
56+ than return null, and the theme is not worth a broken page. */}}
57+ let stored = null;
58+ try { stored = localStorage.getItem(KEY); } catch {}
59+ if (stored === "light" || stored === "dark") apply(stored);
60+
61+ {{- /* The new theme spreads out from the switch rather than landing
62+ everywhere at once: a view transition in which the *new* snapshot is
63+ clipped to a circle growing out of the button, over the old one left
64+ standing still underneath. Only this side knows where the button is,
65+ so the geometry lives here; style.scss carries the matching
66+ pseudo-element rules and the reason they are scoped.
67+
68+ The radius has to reach the farthest corner of the viewport or the
69+ wipe stops short of it. Which corner that is needs no search: the two
70+ axes are independent, so the larger distance on each names it.
71+
72+ Anything without startViewTransition, and anyone who has asked for
73+ less motion, gets the theme change outright instead - a circle
74+ sweeping the whole screen is squarely what that request is about. */}}
75+ let shifting = 0;
76+ const spread = (button, change) => {
77+ if (still.matches || !document.startViewTransition) return change();
78+ const box = button.getBoundingClientRect();
79+ const x = box.left + box.width / 2, y = box.top + box.height / 2;
80+ const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
81+ const vt = document.startViewTransition(change);
82+ shifting++;
83+ root.dataset.themeShift = "";
84+ {{- /* Both pseudo-elements are held still by CSS, so this animation
85+ is the only one running and the transition lasts exactly as long as
86+ it does. ready rejects on a transition the browser skipped, which is
87+ nothing to handle but must not go unhandled. */}}
88+ vt.ready.then(() => {
89+ root.animate(
90+ {clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`]},
91+ {
92+ duration: 560,
93+ {{- /* The curve the signal sweep and .rise already use. */}}
94+ easing: "cubic-bezier(0.32, 0.5, 0.3, 1)",
95+ pseudoElement: "::view-transition-new(root)",
96+ },
97+ );
98+ }, () => {});
99+ {{- /* A second press skips the first transition, so the attribute
100+ comes off with the last one out and not the first. */}}
101+ const done = () => { if (!--shifting) delete root.dataset.themeShift; };
102+ vt.finished.then(done, done);
103+ };
104+
105+ document.addEventListener("click", (e) => {
106+ const button = e.target.closest?.("[data-theme-toggle]");
107+ if (!button) return;
108+ const sys = matchMedia(MEDIA.dark).matches ? "dark" : "light";
109+ const next = (root.dataset.theme || sys) === "dark" ? "light" : "dark";
110+ const pin = next === sys ? null : next;
111+ spread(button, () => apply(pin));
112+ try {
113+ if (pin) localStorage.setItem(KEY, pin); else localStorage.removeItem(KEY);
114+ } catch {}
115+ });
116+ })();
117+ </script>
9 {{ if site.Params.noindex }}118 {{ if site.Params.noindex }}
10 <meta name="robots" content="noindex, nofollow">119 <meta name="robots" content="noindex, nofollow">
11 {{ end }}120 {{ end }}
modified blog/layouts/_default/home.feedscript.js +22 -6
@@ -76,6 +76,17 @@
7676 const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? "";
7777
7878 out.documentElement.setAttribute("lang", C.lang);
79+ // The theme the reader pinned with the masthead switch, read from the
80+ // same localStorage key baseof.html writes. This page carries no switch
81+ // of its own - it is somewhere you pass through on the way to a feed
82+ // reader, not somewhere you sit - so all it does is honour a choice made
83+ // elsewhere. Nothing pinned leaves `pinned` null and the media queries
84+ // below decide, as they did before there was anything to pin.
85+ let pinned = null;
86+ try { pinned = localStorage.getItem("theme"); } catch {}
87+ if (pinned !== "light" && pinned !== "dark") pinned = null;
88+ if (pinned) out.documentElement.setAttribute("data-theme", pinned);
89+ const pinnedPaper = pinned === "dark" ? C.paperDark : C.paper;
7990
8091 const head = out.head;
8192 const meta = (attrs) => {
@@ -85,9 +96,13 @@
8596 };
8697 meta({charset: "utf-8"});
8798 meta({name: "viewport", content: "width=device-width, initial-scale=1"});
88- meta({name: "color-scheme", content: "light dark"});
89- meta({name: "theme-color", content: C.paper, media: "(prefers-color-scheme: light)"});
90- meta({name: "theme-color", content: C.paperDark, media: "(prefers-color-scheme: dark)"});
99+ meta({name: "color-scheme", content: pinned ?? "light dark"});
100+ if (pinned) {
101+ meta({name: "theme-color", content: pinnedPaper});
102+ } else {
103+ meta({name: "theme-color", content: C.paper, media: "(prefers-color-scheme: light)"});
104+ meta({name: "theme-color", content: C.paperDark, media: "(prefers-color-scheme: dark)"});
105+ }
91106 meta({name: "robots", content: "noindex"});
92107 // The background, inline, ahead of the stylesheet link below: this page
93108 // can be painted once before an external stylesheet has been applied, and
@@ -96,9 +111,10 @@
96111 // On body, as style.scss has it: a background on the root element is the
97112 // canvas, and would keep the body's own from ever reaching it again.
98113 const canvas = out.createElement("style");
99- canvas.textContent =
100- `body{background:${C.paper}}` +
101- `@media (prefers-color-scheme: dark){body{background:${C.paperDark}}}`;
114+ canvas.textContent = pinned
115+ ? `body{background:${pinnedPaper}}`
116+ : `body{background:${C.paper}}` +
117+ `@media (prefers-color-scheme: dark){body{background:${C.paperDark}}}`;
102118 head.appendChild(canvas);
103119 const docTitle = `${text(src, "channel > title") || C.site} · ${C.pageTitle}`;
104120 head.appendChild(make("title", null, docTitle));
@@ -76,6 +76,17 @@
76 const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? "";76 const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? "";
77 77
78 out.documentElement.setAttribute("lang", C.lang);78 out.documentElement.setAttribute("lang", C.lang);
79+ // The theme the reader pinned with the masthead switch, read from the
80+ // same localStorage key baseof.html writes. This page carries no switch
81+ // of its own - it is somewhere you pass through on the way to a feed
82+ // reader, not somewhere you sit - so all it does is honour a choice made
83+ // elsewhere. Nothing pinned leaves `pinned` null and the media queries
84+ // below decide, as they did before there was anything to pin.
85+ let pinned = null;
86+ try { pinned = localStorage.getItem("theme"); } catch {}
87+ if (pinned !== "light" && pinned !== "dark") pinned = null;
88+ if (pinned) out.documentElement.setAttribute("data-theme", pinned);
89+ const pinnedPaper = pinned === "dark" ? C.paperDark : C.paper;
79 90
80 const head = out.head;91 const head = out.head;
81 const meta = (attrs) => {92 const meta = (attrs) => {
@@ -85,9 +96,13 @@
85 };96 };
86 meta({charset: "utf-8"});97 meta({charset: "utf-8"});
87 meta({name: "viewport", content: "width=device-width, initial-scale=1"});98 meta({name: "viewport", content: "width=device-width, initial-scale=1"});
88- meta({name: "color-scheme", content: "light dark"});99+ meta({name: "color-scheme", content: pinned ?? "light dark"});
89- meta({name: "theme-color", content: C.paper, media: "(prefers-color-scheme: light)"});100+ if (pinned) {
90- meta({name: "theme-color", content: C.paperDark, media: "(prefers-color-scheme: dark)"});101+ meta({name: "theme-color", content: pinnedPaper});
102+ } else {
103+ meta({name: "theme-color", content: C.paper, media: "(prefers-color-scheme: light)"});
104+ meta({name: "theme-color", content: C.paperDark, media: "(prefers-color-scheme: dark)"});
105+ }
91 meta({name: "robots", content: "noindex"});106 meta({name: "robots", content: "noindex"});
92 // The background, inline, ahead of the stylesheet link below: this page107 // The background, inline, ahead of the stylesheet link below: this page
93 // can be painted once before an external stylesheet has been applied, and108 // can be painted once before an external stylesheet has been applied, and
@@ -96,9 +111,10 @@
96 // On body, as style.scss has it: a background on the root element is the111 // On body, as style.scss has it: a background on the root element is the
97 // canvas, and would keep the body's own from ever reaching it again.112 // canvas, and would keep the body's own from ever reaching it again.
98 const canvas = out.createElement("style");113 const canvas = out.createElement("style");
99- canvas.textContent =114+ canvas.textContent = pinned
100- `body{background:${C.paper}}` +115+ ? `body{background:${pinnedPaper}}`
101- `@media (prefers-color-scheme: dark){body{background:${C.paperDark}}}`;116+ : `body{background:${C.paper}}` +
117+ `@media (prefers-color-scheme: dark){body{background:${C.paperDark}}}`;
102 head.appendChild(canvas);118 head.appendChild(canvas);
103 const docTitle = `${text(src, "channel > title") || C.site} · ${C.pageTitle}`;119 const docTitle = `${text(src, "channel > title") || C.site} · ${C.pageTitle}`;
104 head.appendChild(make("title", null, docTitle));120 head.appendChild(make("title", null, docTitle));
modified blog/layouts/_default/home.feedstyle.xsl +39 -4
@@ -60,8 +60,38 @@
6060 <meta charset="utf-8"/>
6161 <meta name="viewport" content="width=device-width, initial-scale=1"/>
6262 <meta name="color-scheme" content="light dark"/>
63- <meta name="theme-color" content="{{ transform.XMLEscape $paper }}" media="(prefers-color-scheme: light)"/>
64- <meta name="theme-color" content="{{ transform.XMLEscape $paperDark }}" media="(prefers-color-scheme: dark)"/>
63+ <meta id="theme-color-light" name="theme-color" content="{{ transform.XMLEscape $paper }}" media="(prefers-color-scheme: light)"/>
64+ <meta id="theme-color-dark" name="theme-color" content="{{ transform.XMLEscape $paperDark }}" media="(prefers-color-scheme: dark)"/>
65+ {{- /* The theme the reader pinned with the masthead switch, applied
66+ here too so arriving at the feed from a dark site is not a white
67+ page. This carries no switch of its own - it is a place you pass
68+ through on the way to a reader, not one you sit and read - so all
69+ it does is honour a choice made elsewhere, from the same
70+ localStorage key baseof.html writes. With nothing pinned the
71+ attribute stays off and the media queries below decide, exactly as
72+ before.
73+
74+ colorScheme is set straight on the element rather than through the
75+ meta above: a pinned light theme on a dark machine would otherwise
76+ have the browser paint its own form controls and canvas dark for as
77+ long as the stylesheet takes to arrive, which on a transform's
78+ output is not zero - see the note on the inline rule below.
79+
80+ Written without `lt`, `gt` or `amp` characters so it needs no
81+ escaping and reads as the JavaScript it is. */}}
82+ <script>
83+ try {
84+ var pinned = localStorage.getItem("theme");
85+ if (pinned === "light" || pinned === "dark") {
86+ document.documentElement.setAttribute("data-theme", pinned);
87+ document.documentElement.style.colorScheme = pinned;
88+ ["light", "dark"].forEach(function (name) {
89+ var meta = document.getElementById("theme-color-" + name);
90+ if (meta) meta.media = name === pinned ? "all" : "not all";
91+ });
92+ }
93+ } catch (e) {}
94+ </script>
6595 {{- /* The background, inline, ahead of the stylesheet link below.
6696 A transform's result is painted once before an external stylesheet
6797 it names has been applied, and that first paint uses the browser's
@@ -73,8 +103,13 @@
73103 background on the root element is the canvas, and would stop the
74104 body's own - the paper colour and the glow above it - from ever
75105 reaching the canvas again. The stylesheet's rule comes after this
76- one and wins the moment it arrives. */}}
77- <style>body{background:{{ transform.XMLEscape $paper }}}@media (prefers-color-scheme: dark){body{background:{{ transform.XMLEscape $paperDark }}}}</style>
106+ one and wins the moment it arrives.
107+
108+ Four rules and not two: the last pair is the pinned theme the script
109+ above may just have set, which has to outrank the media query for
110+ the reader whose machine says one thing and whose choice says the
111+ other. */}}
112+ <style>body{background:{{ transform.XMLEscape $paper }}}@media (prefers-color-scheme: dark){body{background:{{ transform.XMLEscape $paperDark }}}}:root[data-theme="light"] body{background:{{ transform.XMLEscape $paper }}}:root[data-theme="dark"] body{background:{{ transform.XMLEscape $paperDark }}}</style>
78113 {{- /* Not a page anyone should reach from a search result; the feed
79114 URL itself is the thing worth indexing, and only as a feed. */}}
80115 <meta name="robots" content="noindex"/>
@@ -60,8 +60,38 @@
60 <meta charset="utf-8"/>60 <meta charset="utf-8"/>
61 <meta name="viewport" content="width=device-width, initial-scale=1"/>61 <meta name="viewport" content="width=device-width, initial-scale=1"/>
62 <meta name="color-scheme" content="light dark"/>62 <meta name="color-scheme" content="light dark"/>
63- <meta name="theme-color" content="{{ transform.XMLEscape $paper }}" media="(prefers-color-scheme: light)"/>63+ <meta id="theme-color-light" name="theme-color" content="{{ transform.XMLEscape $paper }}" media="(prefers-color-scheme: light)"/>
64- <meta name="theme-color" content="{{ transform.XMLEscape $paperDark }}" media="(prefers-color-scheme: dark)"/>64+ <meta id="theme-color-dark" name="theme-color" content="{{ transform.XMLEscape $paperDark }}" media="(prefers-color-scheme: dark)"/>
65+ {{- /* The theme the reader pinned with the masthead switch, applied
66+ here too so arriving at the feed from a dark site is not a white
67+ page. This carries no switch of its own - it is a place you pass
68+ through on the way to a reader, not one you sit and read - so all
69+ it does is honour a choice made elsewhere, from the same
70+ localStorage key baseof.html writes. With nothing pinned the
71+ attribute stays off and the media queries below decide, exactly as
72+ before.
73+
74+ colorScheme is set straight on the element rather than through the
75+ meta above: a pinned light theme on a dark machine would otherwise
76+ have the browser paint its own form controls and canvas dark for as
77+ long as the stylesheet takes to arrive, which on a transform's
78+ output is not zero - see the note on the inline rule below.
79+
80+ Written without `lt`, `gt` or `amp` characters so it needs no
81+ escaping and reads as the JavaScript it is. */}}
82+ <script>
83+ try {
84+ var pinned = localStorage.getItem("theme");
85+ if (pinned === "light" || pinned === "dark") {
86+ document.documentElement.setAttribute("data-theme", pinned);
87+ document.documentElement.style.colorScheme = pinned;
88+ ["light", "dark"].forEach(function (name) {
89+ var meta = document.getElementById("theme-color-" + name);
90+ if (meta) meta.media = name === pinned ? "all" : "not all";
91+ });
92+ }
93+ } catch (e) {}
94+ </script>
65 {{- /* The background, inline, ahead of the stylesheet link below.95 {{- /* The background, inline, ahead of the stylesheet link below.
66 A transform's result is painted once before an external stylesheet96 A transform's result is painted once before an external stylesheet
67 it names has been applied, and that first paint uses the browser's97 it names has been applied, and that first paint uses the browser's
@@ -73,8 +103,13 @@
73 background on the root element is the canvas, and would stop the103 background on the root element is the canvas, and would stop the
74 body's own - the paper colour and the glow above it - from ever104 body's own - the paper colour and the glow above it - from ever
75 reaching the canvas again. The stylesheet's rule comes after this105 reaching the canvas again. The stylesheet's rule comes after this
76- one and wins the moment it arrives. */}}106+ one and wins the moment it arrives.
77- <style>body{background:{{ transform.XMLEscape $paper }}}@media (prefers-color-scheme: dark){body{background:{{ transform.XMLEscape $paperDark }}}}</style>107+
108+ Four rules and not two: the last pair is the pinned theme the script
109+ above may just have set, which has to outrank the media query for
110+ the reader whose machine says one thing and whose choice says the
111+ other. */}}
112+ <style>body{background:{{ transform.XMLEscape $paper }}}@media (prefers-color-scheme: dark){body{background:{{ transform.XMLEscape $paperDark }}}}:root[data-theme="light"] body{background:{{ transform.XMLEscape $paper }}}:root[data-theme="dark"] body{background:{{ transform.XMLEscape $paperDark }}}</style>
78 {{- /* Not a page anyone should reach from a search result; the feed113 {{- /* Not a page anyone should reach from a search result; the feed
79 URL itself is the thing worth indexing, and only as a feed. */}}114 URL itself is the thing worth indexing, and only as a feed. */}}
80 <meta name="robots" content="noindex"/>115 <meta name="robots" content="noindex"/>
modified blog/layouts/partials/masthead.html +9 -3
@@ -1,9 +1,15 @@
11 {{- $signal := partial "signal.html" .RelPermalink -}}
22 <header class="masthead column rise">
33 <p class="masthead__title"><a href="{{ "/" | relLangURL }}">{{ site.Title }}</a></p>
4- {{- if and .IsHome site.Params.profile }}
5- <a class="masthead__about" href="#about">{{ i18n "about" }}</a>
6- {{- end }}
4+ {{- /* Everything that is a control rather than a title, kept in one group
5+ so the masthead stays a two-part row - name on the left, tools on the
6+ right - however many of the tools happen to be showing. */}}
7+ <div class="masthead__tools">
8+ {{- if and .IsHome site.Params.profile }}
9+ <a class="masthead__about" href="#about">{{ i18n "about" }}</a>
10+ {{- end }}
11+ {{ partial "theme-toggle.html" . }}
12+ </div>
713 <div
814 class="masthead__signal"
915 aria-hidden="true"
@@ -1,9 +1,15 @@
1 {{- $signal := partial "signal.html" .RelPermalink -}}1 {{- $signal := partial "signal.html" .RelPermalink -}}
2 <header class="masthead column rise">2 <header class="masthead column rise">
3 <p class="masthead__title"><a href="{{ "/" | relLangURL }}">{{ site.Title }}</a></p>3 <p class="masthead__title"><a href="{{ "/" | relLangURL }}">{{ site.Title }}</a></p>
4- {{- if and .IsHome site.Params.profile }}4+ {{- /* Everything that is a control rather than a title, kept in one group
5- <a class="masthead__about" href="#about">{{ i18n "about" }}</a>5+ so the masthead stays a two-part row - name on the left, tools on the
6- {{- end }}6+ right - however many of the tools happen to be showing. */}}
7+ <div class="masthead__tools">
8+ {{- if and .IsHome site.Params.profile }}
9+ <a class="masthead__about" href="#about">{{ i18n "about" }}</a>
10+ {{- end }}
11+ {{ partial "theme-toggle.html" . }}
12+ </div>
7 <div13 <div
8 class="masthead__signal"14 class="masthead__signal"
9 aria-hidden="true"15 aria-hidden="true"
added blog/layouts/partials/theme-toggle.html +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+{{- /*
2+ Light/dark switch, sitting at the end of the masthead.
3+
4+ The button holds no state of its own. Which face the icon wears, and which
5+ of the two labels is its accessible name, both follow the `data-theme`
6+ attribute on <html> through CSS alone - see the `dark` mixin in style.scss.
7+ That is not neatness for its own sake: hx-boost morphs a freshly rendered
8+ copy of this markup in on every navigation, so anything a script had
9+ written onto the element would be quietly reverted, while <html> is never
10+ swapped and carries the choice across the whole visit untouched.
11+
12+ Two labels rather than one, with CSS showing exactly one at a time, so the
13+ button announces the action it performs ("switch to dark theme") instead of
14+ a state the reader then has to interpret. display:none is what keeps the
15+ other out of the accessible name.
16+*/ -}}
17+<button class="theme-toggle" type="button" data-theme-toggle>
18+ <span class="visually-hidden theme-toggle__say--dark">{{ i18n "themeToDark" }}</span>
19+ <span class="visually-hidden theme-toggle__say--light">{{ i18n "themeToLight" }}</span>
20+ {{- /*
21+ Sun and moon are the same disc. The moon is that disc with a bite taken
22+ out of it by a second circle in a mask, parked off to the side while the
23+ theme is light and slid over the rim to carve the crescent when it is
24+ dark; the rays fold away at the same time. Everything animates with
25+ `transform`, never an SVG geometry attribute - Safari is unreliable there
26+ (see the note about `d` in baseof.html), and transforms it gets right.
27+ */}}
28+ <svg class="theme-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
29+ <mask id="theme-toggle-crescent" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
30+ <rect x="0" y="0" width="24" height="24" fill="#fff" />
31+ <circle class="theme-toggle__bite" cx="15.3" cy="8.7" r="6" fill="#000" />
32+ </mask>
33+ <g class="theme-toggle__rays">
34+ <path d="M12 1.6v2.5M12 19.9v2.5M22.4 12h-2.5M4.1 12H1.6M19.35 4.65l-1.77 1.77M6.42 17.58l-1.77 1.77M19.35 19.35l-1.77-1.77M6.42 6.42l-1.77-1.77" />
35+ </g>
36+ <circle class="theme-toggle__orb" cx="12" cy="12" r="5" mask="url(#theme-toggle-crescent)" />
37+ </svg>
38+</button>
new file mode 100644
@@ -0,0 +1,38 @@
1+{{- /*
2+ Light/dark switch, sitting at the end of the masthead.
3+
4+ The button holds no state of its own. Which face the icon wears, and which
5+ of the two labels is its accessible name, both follow the `data-theme`
6+ attribute on <html> through CSS alone - see the `dark` mixin in style.scss.
7+ That is not neatness for its own sake: hx-boost morphs a freshly rendered
8+ copy of this markup in on every navigation, so anything a script had
9+ written onto the element would be quietly reverted, while <html> is never
10+ swapped and carries the choice across the whole visit untouched.
11+
12+ Two labels rather than one, with CSS showing exactly one at a time, so the
13+ button announces the action it performs ("switch to dark theme") instead of
14+ a state the reader then has to interpret. display:none is what keeps the
15+ other out of the accessible name.
16+*/ -}}
17+<button class="theme-toggle" type="button" data-theme-toggle>
18+ <span class="visually-hidden theme-toggle__say--dark">{{ i18n "themeToDark" }}</span>
19+ <span class="visually-hidden theme-toggle__say--light">{{ i18n "themeToLight" }}</span>
20+ {{- /*
21+ Sun and moon are the same disc. The moon is that disc with a bite taken
22+ out of it by a second circle in a mask, parked off to the side while the
23+ theme is light and slid over the rim to carve the crescent when it is
24+ dark; the rays fold away at the same time. Everything animates with
25+ `transform`, never an SVG geometry attribute - Safari is unreliable there
26+ (see the note about `d` in baseof.html), and transforms it gets right.
27+ */}}
28+ <svg class="theme-toggle__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
29+ <mask id="theme-toggle-crescent" maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24">
30+ <rect x="0" y="0" width="24" height="24" fill="#fff" />
31+ <circle class="theme-toggle__bite" cx="15.3" cy="8.7" r="6" fill="#000" />
32+ </mask>
33+ <g class="theme-toggle__rays">
34+ <path d="M12 1.6v2.5M12 19.9v2.5M22.4 12h-2.5M4.1 12H1.6M19.35 4.65l-1.77 1.77M6.42 17.58l-1.77 1.77M19.35 19.35l-1.77-1.77M6.42 6.42l-1.77-1.77" />
35+ </g>
36+ <circle class="theme-toggle__orb" cx="12" cy="12" r="5" mask="url(#theme-toggle-crescent)" />
37+ </svg>
38+</button>
modified blog/layouts/shortcodes/hello-world.html +9 -4
@@ -10,9 +10,10 @@
1010 the cost off every other page in the site. If a second post ever wants it,
1111 that's the moment to promote it to a fingerprinted asset.
1212
13- The palette is self-contained too - light-dark() reads the `color-scheme:
14- light dark` already declared on :root, so the plate flips with the rest of
15- the site and needs no wiring into the theme variables.
13+ The palette is self-contained too - light-dark() reads the `color-scheme`
14+ inherited from :root, so the plate flips with the rest of the site, whether
15+ that is the reader's OS or the theme they pinned, and needs no wiring into
16+ the theme variables.
1617
1718 The two fill="#..." attributes on the SVG paths are the no-CSS fallback:
1819 the <style> below outranks them wherever it applies, but anywhere the
@@ -29,7 +30,11 @@
2930 </svg>
3031 <style>
3132 .hw {
32- color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */
33+ /* No color-scheme of its own: the property is inherited, so leaving it off
34+ means light-dark() below resolves against :root's - which is the reader's
35+ OS until the masthead switch pins a theme, and the pinned one after that.
36+ Declaring it here would make the plate the one thing on the page still
37+ following the OS. Pin this figure alone with style="color-scheme: dark". */
3338 --hw-bg: light-dark(#e9eff5, #0a2342);
3439 --hw-dim: light-dark(#cddbe9, #143a6b);
3540 --hw-mid: light-dark(#8fb0d1, #2e6aa6);
@@ -10,9 +10,10 @@
10 the cost off every other page in the site. If a second post ever wants it,10 the cost off every other page in the site. If a second post ever wants it,
11 that's the moment to promote it to a fingerprinted asset.11 that's the moment to promote it to a fingerprinted asset.
12 12
13- The palette is self-contained too - light-dark() reads the `color-scheme:13+ The palette is self-contained too - light-dark() reads the `color-scheme`
14- light dark` already declared on :root, so the plate flips with the rest of14+ inherited from :root, so the plate flips with the rest of the site, whether
15- the site and needs no wiring into the theme variables.15+ that is the reader's OS or the theme they pinned, and needs no wiring into
16+ the theme variables.
16 17
17 The two fill="#..." attributes on the SVG paths are the no-CSS fallback:18 The two fill="#..." attributes on the SVG paths are the no-CSS fallback:
18 the <style> below outranks them wherever it applies, but anywhere the19 the <style> below outranks them wherever it applies, but anywhere the
@@ -29,7 +30,11 @@
29 </svg>30 </svg>
30 <style>31 <style>
31 .hw {32 .hw {
32- color-scheme: light dark; /* follows the reader's OS; pin with style="color-scheme: dark" on the figure */33+ /* No color-scheme of its own: the property is inherited, so leaving it off
34+ means light-dark() below resolves against :root's - which is the reader's
35+ OS until the masthead switch pins a theme, and the pinned one after that.
36+ Declaring it here would make the plate the one thing on the page still
37+ following the OS. Pin this figure alone with style="color-scheme: dark". */
33 --hw-bg: light-dark(#e9eff5, #0a2342);38 --hw-bg: light-dark(#e9eff5, #0a2342);
34 --hw-dim: light-dark(#cddbe9, #143a6b);39 --hw-dim: light-dark(#cddbe9, #143a6b);
35 --hw-mid: light-dark(#8fb0d1, #2e6aa6);40 --hw-mid: light-dark(#8fb0d1, #2e6aa6);