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.
fb319ad parent: b85ee5c modified
blog/assets/css/style.scss +208 -33 | @@ -9,6 +9,41 @@ | ||
| 9 | 9 | |
| 10 | 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 | 47 | %chrome { |
| 13 | 48 | font-family: var(--mono); |
| 14 | 49 | font-size: 0.72rem; |
| @@ -111,19 +146,26 @@ $wide: 67.5625rem; | ||
| 111 | 146 | color-scheme: light dark; |
| 112 | 147 | } |
| 113 | 148 | |
| 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; | |
| 127 | 169 | } |
| 128 | 170 | |
| 129 | 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 | 523 | // --- Prose --------------------------------------------------------------- |
| 353 | 524 | |
| 354 | 525 | .prose { |
| @@ -874,7 +1045,7 @@ $alerts: ( | ||
| 874 | 1045 | &.alert-#{$name} { |
| 875 | 1046 | --alert: #{list.nth($pair, 1)}; |
| 876 | 1047 | |
| 877 | - @media (prefers-color-scheme: dark) { | |
| 1048 | + @include dark { | |
| 878 | 1049 | --alert: #{list.nth($pair, 2)}; |
| 879 | 1050 | } |
| 880 | 1051 | } |
| @@ -1643,7 +1814,12 @@ blockquote.alert { | ||
| 1643 | 1814 | // --- Print ----------------------------------------------------------------- |
| 1644 | 1815 | |
| 1645 | 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 | 1823 | --paper: #fff; |
| 1648 | 1824 | --paper-raised: #fff; |
| 1649 | 1825 | --ink: #000; |
| @@ -1662,7 +1838,8 @@ blockquote.alert { | ||
| 1662 | 1838 | } |
| 1663 | 1839 | |
| 1664 | 1840 | .colophon, |
| 1665 | - .skip-link { | |
| 1841 | + .skip-link, | |
| 1842 | + .theme-toggle { | |
| 1666 | 1843 | display: none; |
| 1667 | 1844 | } |
| 1668 | 1845 | |
| @@ -1714,23 +1891,21 @@ blockquote.alert { | ||
| 1714 | 1891 | --syn-del-fg: #a3231f; |
| 1715 | 1892 | } |
| 1716 | 1893 | |
| 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; | |
| 1734 | 1909 | } |
| 1735 | 1910 | |
| 1736 | 1911 | .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" | ||
| 4 | 4 | [about] |
| 5 | 5 | other = "About" |
| 6 | 6 | |
| 7 | +[themeToDark] | |
| 8 | +other = "Switch to dark theme" | |
| 9 | + | |
| 10 | +[themeToLight] | |
| 11 | +other = "Switch to light theme" | |
| 12 | + | |
| 7 | 13 | [minRead] |
| 8 | 14 | other = "{{ .Count }} min read" |
| 9 | 15 | |
| @@ -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" | ||
| 4 | 4 | [about] |
| 5 | 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 | 13 | [minRead] |
| 8 | 14 | other = "{{ .Count }} min de lectura" |
| 9 | 15 | |
| @@ -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" | ||
| 4 | 4 | [about] |
| 5 | 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 | 13 | [minRead] |
| 8 | 14 | other = "{{ .Count }} min de lecture" |
| 9 | 15 | |
| @@ -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 = "コンテンツへスキップ" | ||
| 4 | 4 | [about] |
| 5 | 5 | other = "このサイトについて" |
| 6 | 6 | |
| 7 | +[themeToDark] | |
| 8 | +other = "ダークテーマに切り替える" | |
| 9 | + | |
| 10 | +[themeToLight] | |
| 11 | +other = "ライトテーマに切り替える" | |
| 12 | + | |
| 7 | 13 | [minRead] |
| 8 | 14 | other = "{{ .Count }}分で読了" |
| 9 | 15 | |
| @@ -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" | ||
| 4 | 4 | [about] |
| 5 | 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 | 13 | [minRead] |
| 8 | 14 | other = "{{ .Count }} min de leitura" |
| 9 | 15 | |
| @@ -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 @@ | ||
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | 6 | <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> | |
| 9 | 118 | {{ if site.Params.noindex }} |
| 10 | 119 | <meta name="robots" content="noindex, nofollow"> |
| 11 | 120 | {{ 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 @@ | ||
| 76 | 76 | const text = (node, sel) => node.querySelector(sel)?.textContent?.trim() ?? ""; |
| 77 | 77 | |
| 78 | 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 | 91 | const head = out.head; |
| 81 | 92 | const meta = (attrs) => { |
| @@ -85,9 +96,13 @@ | ||
| 85 | 96 | }; |
| 86 | 97 | meta({charset: "utf-8"}); |
| 87 | 98 | 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 | + } | |
| 91 | 106 | meta({name: "robots", content: "noindex"}); |
| 92 | 107 | // The background, inline, ahead of the stylesheet link below: this page |
| 93 | 108 | // can be painted once before an external stylesheet has been applied, and |
| @@ -96,9 +111,10 @@ | ||
| 96 | 111 | // On body, as style.scss has it: a background on the root element is the |
| 97 | 112 | // canvas, and would keep the body's own from ever reaching it again. |
| 98 | 113 | 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}}}`; | |
| 102 | 118 | head.appendChild(canvas); |
| 103 | 119 | const docTitle = `${text(src, "channel > title") || C.site} · ${C.pageTitle}`; |
| 104 | 120 | 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 page | 107 | // The background, inline, ahead of the stylesheet link below: this page |
| 93 | // can be painted once before an external stylesheet has been applied, and | 108 | // 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 the | 111 | // 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 @@ | ||
| 60 | 60 | <meta charset="utf-8"/> |
| 61 | 61 | <meta name="viewport" content="width=device-width, initial-scale=1"/> |
| 62 | 62 | <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> | |
| 65 | 95 | {{- /* The background, inline, ahead of the stylesheet link below. |
| 66 | 96 | A transform's result is painted once before an external stylesheet |
| 67 | 97 | it names has been applied, and that first paint uses the browser's |
| @@ -73,8 +103,13 @@ | ||
| 73 | 103 | background on the root element is the canvas, and would stop the |
| 74 | 104 | body's own - the paper colour and the glow above it - from ever |
| 75 | 105 | 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> | |
| 78 | 113 | {{- /* Not a page anyone should reach from a search result; the feed |
| 79 | 114 | URL itself is the thing worth indexing, and only as a feed. */}} |
| 80 | 115 | <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 stylesheet | 96 | A transform's result is painted once before an external stylesheet |
| 67 | it names has been applied, and that first paint uses the browser's | 97 | 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 the | 103 | 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 ever | 104 | body's own - the paper colour and the glow above it - from ever |
| 75 | reaching the canvas again. The stylesheet's rule comes after this | 105 | 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 feed | 113 | {{- /* 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 @@ | ||
| 1 | 1 | {{- $signal := partial "signal.html" .RelPermalink -}} |
| 2 | 2 | <header class="masthead column rise"> |
| 3 | 3 | <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> | |
| 7 | 13 | <div |
| 8 | 14 | class="masthead__signal" |
| 9 | 15 | 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 | <div | 13 | <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 @@ | ||
| 10 | 10 | the cost off every other page in the site. If a second post ever wants it, |
| 11 | 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: | |
| 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. | |
| 16 | 17 | |
| 17 | 18 | The two fill="#..." attributes on the SVG paths are the no-CSS fallback: |
| 18 | 19 | the <style> below outranks them wherever it applies, but anywhere the |
| @@ -29,7 +30,11 @@ | ||
| 29 | 30 | </svg> |
| 30 | 31 | <style> |
| 31 | 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 | 38 | --hw-bg: light-dark(#e9eff5, #0a2342); |
| 34 | 39 | --hw-dim: light-dark(#cddbe9, #143a6b); |
| 35 | 40 | --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 of | 14 | + 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 the | 19 | 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); |