A place to cache linked articles (think custom and personal wayback machine)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. <!doctype html><!-- This is a valid HTML5 document. -->
  2. <!-- Screen readers, SEO, extensions and so on. -->
  3. <html lang="en">
  4. <!-- Has to be within the first 1024 bytes, hence before the `title` element
  5. See: https://www.w3.org/TR/2012/CR-html5-20121217/document-metadata.html#charset -->
  6. <meta charset="utf-8">
  7. <!-- Why no `X-UA-Compatible` meta: https://stackoverflow.com/a/6771584 -->
  8. <!-- The viewport meta is quite crowded and we are responsible for that.
  9. See: https://codepen.io/tigt/post/meta-viewport-for-2015 -->
  10. <meta name="viewport" content="width=device-width,initial-scale=1">
  11. <!-- Required to make a valid HTML5 document. -->
  12. <title>Modern CSS patterns in Campfire (archive) — David Larlet</title>
  13. <meta name="description" content="Publication mise en cache pour en conserver une trace.">
  14. <!-- That good ol' feed, subscribe :). -->
  15. <link rel="alternate" type="application/atom+xml" title="Feed" href="/david/log/">
  16. <!-- Generated from https://realfavicongenerator.net/ such a mess. -->
  17. <link rel="apple-touch-icon" sizes="180x180" href="/static/david/icons2/apple-touch-icon.png">
  18. <link rel="icon" type="image/png" sizes="32x32" href="/static/david/icons2/favicon-32x32.png">
  19. <link rel="icon" type="image/png" sizes="16x16" href="/static/david/icons2/favicon-16x16.png">
  20. <link rel="manifest" href="/static/david/icons2/site.webmanifest">
  21. <link rel="mask-icon" href="/static/david/icons2/safari-pinned-tab.svg" color="#07486c">
  22. <link rel="shortcut icon" href="/static/david/icons2/favicon.ico">
  23. <meta name="msapplication-TileColor" content="#f7f7f7">
  24. <meta name="msapplication-config" content="/static/david/icons2/browserconfig.xml">
  25. <meta name="theme-color" content="#f7f7f7" media="(prefers-color-scheme: light)">
  26. <meta name="theme-color" content="#272727" media="(prefers-color-scheme: dark)">
  27. <!-- Is that even respected? Retrospectively? What a shAItshow…
  28. https://neil-clarke.com/block-the-bots-that-feed-ai-models-by-scraping-your-website/ -->
  29. <meta name="robots" content="noai, noimageai">
  30. <!-- Documented, feel free to shoot an email. -->
  31. <link rel="stylesheet" href="/static/david/css/style_2021-01-20.css">
  32. <!-- See https://www.zachleat.com/web/comprehensive-webfonts/ for the trade-off. -->
  33. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_regular.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  34. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_bold.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  35. <link rel="preload" href="/static/david/css/fonts/triplicate_t4_poly_italic.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)" crossorigin>
  36. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_regular.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  37. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_bold.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  38. <link rel="preload" href="/static/david/css/fonts/triplicate_t3_italic.woff2" as="font" type="font/woff2" media="(prefers-color-scheme: dark)" crossorigin>
  39. <script>
  40. function toggleTheme(themeName) {
  41. document.documentElement.classList.toggle(
  42. 'forced-dark',
  43. themeName === 'dark'
  44. )
  45. document.documentElement.classList.toggle(
  46. 'forced-light',
  47. themeName === 'light'
  48. )
  49. }
  50. const selectedTheme = localStorage.getItem('theme')
  51. if (selectedTheme !== 'undefined') {
  52. toggleTheme(selectedTheme)
  53. }
  54. </script>
  55. <meta name="robots" content="noindex, nofollow">
  56. <meta content="origin-when-cross-origin" name="referrer">
  57. <!-- Canonical URL for SEO purposes -->
  58. <link rel="canonical" href="https://dev.37signals.com/modern-css-patterns-and-techniques-in-campfire/">
  59. <body class="remarkdown h1-underline h2-underline h3-underline em-underscore hr-center ul-star pre-tick" data-instant-intensity="viewport-all">
  60. <article>
  61. <header>
  62. <h1>Modern CSS patterns in Campfire</h1>
  63. </header>
  64. <nav>
  65. <p class="center">
  66. <a href="/david/" title="Aller à l’accueil"><svg class="icon icon-home">
  67. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-home"></use>
  68. </svg> Accueil</a> •
  69. <a href="https://dev.37signals.com/modern-css-patterns-and-techniques-in-campfire/" title="Lien vers le contenu original">Source originale</a>
  70. <br>
  71. Mis en cache le 2024-04-08
  72. </p>
  73. </nav>
  74. <hr>
  75. <p>Recently, customers who have purchased a copy of <a href="https://once.com/campfire">ONCE/Campfire</a> were invited to participate in a live walk through the app’s CSS code. Campfire was built with vanilla CSS, fully <a href="https://world.hey.com/dhh/once-1-is-entirely-nobuild-for-the-front-end-ce56f6d7">#nobuild</a> without compiling or preprocessors, and uses the latest <a href="https://web.dev">web platform</a> features available in evergreen browsers—<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS%5Fnesting">CSS nesting</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has">:has()</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:is">:is()</a>, and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:where">:where()</a>; <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color%5Fvalue/oklch">wide-gamut colors</a>, <a href="https://developer.mozilla.org/en-US/docs/Web/API/View%5FTransitions%5FAPI">View Transitions</a> and more.</p>
  76. <p>In this post we’ll take a look at how we’re using some of these features and share some helpful patterns discovered along the way.</p>
  77. <hr>
  78. <h2 id="colors">Colors</h2>
  79. <p>Campfire uses <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color%5Fvalue/oklch">oklch()</a> to define colors in CSS. <code class="language-plaintext highlighter-rouge">oklch()</code> offers access to wider color spaces (like <a href="https://webkit.org/blog/10042/wide-gamut-color-in-css-with-display-p3/">Display-P3</a>) and greatly improves developer ergonomics when working with colors. For example, let’s take a look at these greys used in Campfire’s UI.</p>
  80. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  81. <span class="py">--lch-gray</span><span class="p">:</span> <span class="m">96%</span> <span class="m">0.005</span> <span class="m">96</span><span class="p">;</span>
  82. <span class="py">--lch-gray-dark</span><span class="p">:</span> <span class="m">92%</span> <span class="m">0.005</span> <span class="m">96</span><span class="p">;</span>
  83. <span class="py">--lch-gray-darker</span><span class="p">:</span> <span class="m">75%</span> <span class="m">0.005</span> <span class="m">96</span><span class="p">;</span>
  84. <span class="p">}</span>
  85. </code></pre></div></div>
  86. <p>At first glance they may seem unfamiliar but they’re actually more readable and quite easy to use once you get acquainted.</p>
  87. <p>LCH stands for:</p>
  88. <ul>
  89. <li><strong>Lightness:</strong> perceptual lightness ranging from 0%—100%;</li>
  90. <li><strong>Chroma:</strong> the amount of color from pure grey to full saturation, 0–0.5;</li>
  91. <li><strong>Hue:</strong> the color’s angle on the color wheel, 0–360deg.</li>
  92. </ul>
  93. <p>With that in mind, we can read the colors without much effort. We can see that they all share the same hue and chroma, only the lightness differs. It’s apparent just from reading the code that <code class="language-plaintext highlighter-rouge">--lch-gray</code> and <code class="language-plaintext highlighter-rouge">--lch-gray-dark</code> are relatively close in lightness, but <code class="language-plaintext highlighter-rouge">--lch-gray-darker</code> is significantly darker. It’s also simple to adjust them programmatically or manually tweak them without using a color picker and without inadvertently shifting the hue. If you’ve ever tried to do that with RGB colors you know how tricky that can be.</p>
  94. <p>We started by defining the pure color values above but we wrap them in the <code class="language-plaintext highlighter-rouge">oklch()</code> color function and define a set of abstract custom properties that consume the values for use in our other stylesheets.</p>
  95. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--color-border</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-gray</span><span class="o">));</span>
  96. <span class="nt">--color-border-dark</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-gray-dark</span><span class="o">));</span>
  97. <span class="nt">--color-border-darker</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-gray-darker</span><span class="o">));</span>
  98. </code></pre></div></div>
  99. <p>Sure, you might be thinking, grey is easy but what about other colors? Here’s a set based on blue for links and selections.</p>
  100. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--lch-blue</span><span class="o">:</span> <span class="err">54</span><span class="o">%</span> <span class="err">0</span><span class="o">.</span><span class="err">23</span> <span class="err">255</span><span class="o">;</span>
  101. <span class="nt">--lch-blue-light</span><span class="o">:</span> <span class="err">95</span><span class="o">%</span> <span class="err">0</span><span class="o">.</span><span class="err">03</span> <span class="err">255</span><span class="o">;</span>
  102. <span class="nt">--lch-blue-dark</span><span class="o">:</span> <span class="err">80</span><span class="o">%</span> <span class="err">0</span><span class="o">.</span><span class="err">08</span> <span class="err">255</span><span class="o">;</span>
  103. <span class="nt">--color-link</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-blue</span><span class="o">));</span>
  104. <span class="nt">--color-selected</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-blue-light</span><span class="o">));</span>
  105. <span class="nt">--color-selected-dark</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-blue-dark</span><span class="o">));</span>
  106. </code></pre></div></div>
  107. <p>A quick read of these values reveals that all three are in the same color family, indicated by the same hue angle (255º). Further we can observe that links are medium lightness and saturation. The light variant has a much higher lightness value and much lower saturation making it more grey, while the dark variant is not quite as light or desaturated. We generally use the darker variants for borders around the lighter values.</p>
  108. <p>And even better, <code class="language-plaintext highlighter-rouge">oklch()</code> makes it trivial to add variants that use alpha transparency, too.</p>
  109. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">--color-link-50</span><span class="o">:</span> <span class="nt">oklch</span><span class="o">(</span><span class="nt">var</span><span class="o">(</span><span class="nt">--lch-blue</span><span class="o">)</span> <span class="o">/</span> <span class="err">0</span><span class="o">.</span><span class="err">5</span><span class="o">);</span>
  110. </code></pre></div></div>
  111. <hr>
  112. <h2 id="custom-properties">Custom Properties</h2>
  113. <p>Variables in CSS are certainly not new but we’ve developed some general usage patterns that make working with them a pleasure. Let’s look at some styles from Campfire’s <code class="language-plaintext highlighter-rouge">buttons.css</code> to demonstrate.</p>
  114. <h3 id="declared-vs-fallback-values">Declared vs. Fallback values</h3>
  115. <p>Often when using custom properties in the past, we’d set something up like this in which you declare all the custom properties at the top of the rule (or in <code class="language-plaintext highlighter-rouge">:root</code>) and then use them immediately below. Something like this:</p>
  116. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.btn</span> <span class="p">{</span>
  117. <span class="py">--btn-background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text-reversed</span><span class="p">);</span>
  118. <span class="py">--btn-border-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-border</span><span class="p">);</span>
  119. <span class="py">--btn-border-radius</span><span class="p">:</span> <span class="m">2em</span><span class="p">;</span>
  120. <span class="py">--btn-border-size</span><span class="p">:</span> <span class="m">1px</span><span class="p">;</span>
  121. <span class="py">--btn-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text</span><span class="p">);</span>
  122. <span class="py">--btn-padding</span><span class="p">:</span> <span class="m">0.5em</span> <span class="m">1.1em</span><span class="p">;</span>
  123. <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  124. <span class="nl">background-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-background</span><span class="p">);</span>
  125. <span class="nl">border-radius</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-radius</span><span class="p">);</span>
  126. <span class="nl">border</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-size</span><span class="p">)</span> <span class="nb">solid</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-color</span><span class="p">);</span>
  127. <span class="nl">color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-color</span><span class="p">);</span>
  128. <span class="nl">display</span><span class="p">:</span> <span class="n">inline-flex</span><span class="p">;</span>
  129. <span class="py">gap</span><span class="p">:</span> <span class="m">0.5em</span><span class="p">;</span>
  130. <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  131. <span class="nl">padding</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-padding</span><span class="p">);</span>
  132. <span class="p">}</span>
  133. </code></pre></div></div>
  134. <p>And that works fine but it feels like a lot of boilerplate and it’s a little defensive in that you may never use those variables again. That’s where fallback values come in handy. Instead of a litany of properties at the top of the rule, we can set the default values inline but expose a custom property that will accept another value when present. It looks like this:</p>
  135. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">color</span><span class="o">:</span> <span class="nt">var</span><span class="o">(</span><span class="nt">--btn-color</span><span class="o">,</span> <span class="nt">var</span><span class="o">(</span><span class="nt">--color-text</span><span class="o">));</span>
  136. </code></pre></div></div>
  137. <p>Here <code class="language-plaintext highlighter-rouge">--btn-color</code> is optional. If it’s set, the rule will use that value; if not, it will fall back to <code class="language-plaintext highlighter-rouge">--color-text</code>. The fallback value can be a straight value or another variable. Now we can re-write the rule above like this:</p>
  138. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.btn</span> <span class="p">{</span>
  139. <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  140. <span class="nl">background-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-background</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text-reversed</span><span class="p">));</span>
  141. <span class="nl">border-radius</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-radius</span><span class="p">);</span>
  142. <span class="nl">border</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-size</span><span class="p">,</span> <span class="m">2em</span><span class="p">)</span> <span class="nb">solid</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-color</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-border</span><span class="p">));</span>
  143. <span class="nl">color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-color</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text</span><span class="p">));</span>
  144. <span class="nl">display</span><span class="p">:</span> <span class="n">inline-flex</span><span class="p">;</span>
  145. <span class="py">gap</span><span class="p">:</span> <span class="m">0.5em</span><span class="p">;</span>
  146. <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  147. <span class="nl">padding</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-padding</span><span class="p">,</span> <span class="m">0.5em</span> <span class="m">1.1em</span><span class="p">);</span>
  148. <span class="p">}</span>
  149. </code></pre></div></div>
  150. <p>This is tighter and all the default values plus exposed variables are together, inline.</p>
  151. <p>But how do we decide where to use custom properties? There are really two cases: 1) whenever we need to use the same value in more than one place (<a href="https://en.wikipedia.org/wiki/Don%27t%5Frepeat%5Fyourself">DRY</a>) and 2) when we know a value is going to be changed.</p>
  152. <p>A good example of the first case is the <code class="language-plaintext highlighter-rouge">--btn-size</code> variable. Almost all of Campfire’s buttons are circles with an icon inside. To make sure they line up nicely with input fields we set their <code class="language-plaintext highlighter-rouge">block-size</code> using this variable.</p>
  153. <p>Because that size is exposed at the<code class="language-plaintext highlighter-rouge"> :root</code> level we can use it for buttons and input elements. And even better, we can use that value to calculate the height of the chat footer in our layout. No <a href="https://en.wikipedia.org/wiki/Magic%5Fnumber%5F%28programming%29">magic numbers</a> in sight!</p>
  154. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  155. <span class="py">--btn-size</span><span class="p">:</span> <span class="m">2.65em</span><span class="p">;</span>
  156. <span class="p">}</span>
  157. <span class="nt">body</span> <span class="p">{</span>
  158. <span class="py">--footer-height</span><span class="p">:</span> <span class="n">calc</span><span class="p">((</span><span class="n">var</span><span class="p">(</span><span class="n">--block-space</span><span class="p">))</span> <span class="err">+</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-size</span><span class="p">)</span> <span class="err">+</span> <span class="n">var</span><span class="p">(</span><span class="n">--block-space</span><span class="p">));</span>
  159. <span class="py">grid-template-rows</span><span class="p">:</span> <span class="m">1</span><span class="n">fr</span> <span class="n">var</span><span class="p">(</span><span class="n">--footer-height</span><span class="p">);</span>
  160. <span class="p">}</span>
  161. </code></pre></div></div>
  162. <p>The footer’s height consists of the button’s height plus padding above and below using the global <code class="language-plaintext highlighter-rouge">--block-space</code> variable.</p>
  163. <p>The other case for custom properties is when we know that we’ll want to change some values to create variants of an element. We think of it like a mini API for our CSS classes. Going back to our button class, we can declare variants simply by changing the value of custom properties instead of redefining a property.</p>
  164. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Variants */</span>
  165. <span class="nc">.btn--reversed</span> <span class="p">{</span>
  166. <span class="py">--btn-background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text</span><span class="p">);</span>
  167. <span class="p">}</span>
  168. <span class="nc">.btn--negative</span> <span class="p">{</span>
  169. <span class="py">--btn-background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-negative</span><span class="p">);</span>
  170. <span class="p">}</span>
  171. <span class="nd">:is</span><span class="o">(</span><span class="nc">.btn--reversed</span><span class="o">,</span> <span class="nc">.btn--negative</span><span class="o">)</span> <span class="p">{</span>
  172. <span class="py">--btn-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-text-reversed</span><span class="p">);</span>
  173. <span class="p">}</span>
  174. <span class="nc">.btn--borderless</span> <span class="p">{</span>
  175. <span class="py">--btn-border-color</span><span class="p">:</span> <span class="nb">transparent</span><span class="p">;</span>
  176. <span class="p">}</span>
  177. <span class="nc">.btn--success</span> <span class="p">{</span>
  178. <span class="nl">animation</span><span class="p">:</span> <span class="n">success</span> <span class="m">1s</span> <span class="n">ease-out</span><span class="p">;</span>
  179. <span class="err">img</span> <span class="err">{</span>
  180. <span class="nl">animation</span><span class="p">:</span> <span class="n">zoom-fade</span> <span class="m">300ms</span> <span class="n">ease-out</span><span class="p">;</span>
  181. <span class="p">}</span>
  182. <span class="err">}</span>
  183. </code></pre></div></div>
  184. <p>This makes it very clear what’s changed by these variants. Even better, as in the case of <code class="language-plaintext highlighter-rouge">.btn--success</code>, it makes on obvious distinction between changing a default property value and adding a new property (the <code class="language-plaintext highlighter-rouge">animation</code> property in this case).</p>
  185. <hr>
  186. <h2 id="css-has">CSS :has()</h2>
  187. <p>We started using <code class="language-plaintext highlighter-rouge">:has()</code> in the early stages of Campfire’s development because it offers a number of conveniences and opportunities to do with CSS what we previously had to do in server side code. We were so bullish on <code class="language-plaintext highlighter-rouge">:has()</code> that we literally shipped the first beta version of Campfire a week before Firefox shipped its release with support for <code class="language-plaintext highlighter-rouge">:has()</code>—the last of the major browsers to do so.</p>
  188. <p>You can think of <code class="language-plaintext highlighter-rouge">:has()</code> as a way to query an element about what’s inside it.</p>
  189. <p>This makes our button class very flexible. You can throw about any combination of things inside it, and it will adjust accordingly. Text only, image and text, image only, inputs (like radio buttons), or multiple images with text.</p>
  190. <p>For example, when our <code class="language-plaintext highlighter-rouge">.btn</code> class finds an image inside of it (that’s not an avatar photo), it can apply sizing and make sure it gets inverted in dark mode—without needing any kind of special classes.</p>
  191. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.btn</span> <span class="p">{</span>
  192. <span class="err">...</span>
  193. <span class="err">img</span> <span class="err">{</span>
  194. <span class="nl">-webkit-touch-callout</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  195. <span class="py">user-select</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
  196. <span class="p">}</span>
  197. <span class="o">&amp;</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:has</span><span class="o">(</span><span class="nt">img</span><span class="o">)</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.avatar</span><span class="o">))</span> <span class="p">{</span>
  198. <span class="nl">text-align</span><span class="p">:</span> <span class="n">start</span><span class="p">;</span>
  199. <span class="err">img</span> <span class="err">{</span>
  200. <span class="nl">filter</span><span class="p">:</span> <span class="nb">invert</span><span class="p">(</span><span class="m">0</span><span class="p">);</span>
  201. <span class="py">inline-size</span><span class="p">:</span> <span class="m">1.3em</span><span class="p">;</span>
  202. <span class="py">max-inline-size</span><span class="p">:</span> <span class="n">unset</span><span class="p">;</span>
  203. <span class="err">@media</span> <span class="err">(</span><span class="py">prefers-color-scheme</span><span class="p">:</span> <span class="n">dark</span><span class="p">)</span> <span class="err">{</span>
  204. <span class="n">filter</span><span class="p">:</span> <span class="nb">invert</span><span class="p">(</span><span class="m">100%</span><span class="p">);</span>
  205. <span class="p">}</span>
  206. <span class="err">}</span>
  207. <span class="err">}</span>
  208. </code></pre></div></div>
  209. <p>Most of the buttons in Campfire contain an icon image plus a hidden text element for screen readers.</p>
  210. <div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">button</span> <span class="ss">class: </span><span class="s2">"btn btn--reversed center"</span><span class="p">,</span> <span class="ss">type: </span><span class="s2">"submit"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  211. <span class="cp">&lt;%=</span> <span class="n">image_tag</span> <span class="s2">"check.svg"</span><span class="p">,</span> <span class="ss">aria: </span><span class="p">{</span> <span class="ss">hidden: </span><span class="s2">"true"</span> <span class="p">},</span> <span class="ss">size: </span><span class="mi">20</span> <span class="cp">%&gt;</span>
  212. <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"for-screen-reader"</span><span class="nt">&gt;</span>Save changes<span class="nt">&lt;/span&gt;</span>
  213. <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  214. </code></pre></div></div>
  215. <p>With <code class="language-plaintext highlighter-rouge">:has()</code> our button class can know if these elements are present and turn it into a circle icon button with the image centered inside it. <em>Notice that we’re using our <code class="language-plaintext highlighter-rouge">--btn-size</code> variable from earlier.</em></p>
  216. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&amp;</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.for-screen-reader</span><span class="o">)</span><span class="nd">:has</span><span class="o">(</span><span class="nt">img</span><span class="o">))</span> <span class="p">{</span>
  217. <span class="py">--btn-border-radius</span><span class="p">:</span> <span class="m">50%</span><span class="p">;</span>
  218. <span class="py">--btn-padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
  219. <span class="py">aspect-ratio</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
  220. <span class="py">block-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-size</span><span class="p">);</span>
  221. <span class="nl">display</span><span class="p">:</span> <span class="n">grid</span><span class="p">;</span>
  222. <span class="py">inline-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-size</span><span class="p">);</span>
  223. <span class="py">place-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  224. <span class="err">&gt;</span> <span class="err">\*</span> <span class="err">{</span>
  225. <span class="py">grid-area</span><span class="p">:</span> <span class="m">1</span><span class="p">/</span><span class="m">1</span><span class="p">;</span>
  226. <span class="p">}</span>
  227. <span class="err">}</span>
  228. </code></pre></div></div>
  229. <p>Just dump whatever you want into <code class="language-plaintext highlighter-rouge">.btn</code> and it’ll take care of the rest.</p>
  230. <p>That’s really satisfying to use as a developer but you could do this without a lot of extra effort using utility classes like <code class="language-plaintext highlighter-rouge">.btn--circle-icon</code> or <code class="language-plaintext highlighter-rouge">.btn--icon-and-text</code>. What really opened our eyes was when we were able to replace Ruby on Rails code with just CSS.</p>
  231. <p>Take, for example, the menu button that toggles the sidebar when using Campfire with a narrow viewport.</p>
  232. <p>Because the sidebar (which lists all of your chat rooms) is hidden when closed we wanted to display a small dot on the menu button to indicate that you have rooms with new, unread messages in them. Normally we’d have to write some Ruby on Rails code to handle that condition something like this:</p>
  233. <div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@room</span><span class="p">.</span><span class="nf">memberships</span><span class="p">.</span><span class="nf">unread</span><span class="p">.</span><span class="nf">any?</span> <span class="cp">%&gt;</span>
  234. // render the dot
  235. <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  236. </code></pre></div></div>
  237. <p>But with <code class="language-plaintext highlighter-rouge">:has()</code> we can do it with pure CSS alone!</p>
  238. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">#sidebar</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:not</span><span class="o">([</span><span class="nt">open</span><span class="o">])</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.unread</span><span class="o">))</span> <span class="o">&amp;</span> <span class="p">{</span>
  239. <span class="err">&amp;::after</span> <span class="err">{</span>
  240. <span class="py">--size</span><span class="p">:</span> <span class="m">1em</span><span class="p">;</span>
  241. <span class="py">aspect-ratio</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
  242. <span class="nl">background-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-negative</span><span class="p">);</span>
  243. <span class="py">block-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--size</span><span class="p">);</span>
  244. <span class="nl">border-radius</span><span class="p">:</span> <span class="n">calc</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--size</span><span class="p">)</span> <span class="err">*</span> <span class="m">2</span><span class="p">);</span>
  245. <span class="nl">content</span><span class="p">:</span> <span class="s1">""</span><span class="p">;</span>
  246. <span class="nl">flex-shrink</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
  247. <span class="py">inline-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--size</span><span class="p">);</span>
  248. <span class="py">inset-block-start</span><span class="p">:</span> <span class="n">calc</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--size</span><span class="p">)</span> <span class="p">/</span> <span class="m">-4</span><span class="p">);</span>
  249. <span class="py">inset-inline-end</span><span class="p">:</span> <span class="n">calc</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--size</span><span class="p">)</span> <span class="p">/</span> <span class="m">-4</span><span class="p">);</span>
  250. <span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
  251. <span class="p">}</span>
  252. <span class="err">}</span>
  253. </code></pre></div></div>
  254. <p>Here the we’re querying the sidebar element to 1) make sure it isn’t open (because you don’t need to see the dot if you’re already looking at the rooms list) and 2) to see if it has any elements inside it that have the <code class="language-plaintext highlighter-rouge">.unread</code> class. If those are true, draw the dot and position it. Notice that we’re using a custom property (<code class="language-plaintext highlighter-rouge">--size</code>) here for both the dimensions of the dot and to calculate its border radius and position. It’s harmonious and avoids magic numbers.</p>
  255. <p>Elsewhere, on Campfire’s account profile screen we used <code class="language-plaintext highlighter-rouge">:has()</code> to solve a problem that was nearly impossible to do even with server side code. The screen features a list of all the chat rooms you’re in and a button to toggle the state of each room. If you’ve made the room invisible in your sidebar we also wanted to be able to grey out the row to visually reinforce this critical status.</p>
  256. <p>The problem is that toggle button is a completely separate element using a different controller, rendered in a <a href="https://turbo.hotwired.dev/handbook/frames">Turbo Frame</a>. It’s the same toggle we show in the room, itself. That means the code that renders the row has no idea what status of the button is, nor does it know when the status changes.</p>
  257. <div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;li</span> <span class="na">class=</span><span class="s">"flex align-center gap margin-none min-width membership-item"</span><span class="nt">&gt;</span>
  258. <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="n">room_path</span><span class="p">(</span><span class="n">membership</span><span class="p">.</span><span class="nf">room</span><span class="p">),</span> <span class="ss">class: </span><span class="s2">"overflow-ellipsis fill-shade txt-primary txt-undecorated"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  259. <span class="nt">&lt;strong&gt;</span><span class="cp">&lt;%=</span> <span class="n">room_display_name</span><span class="p">(</span><span class="n">membership</span><span class="p">.</span><span class="nf">room</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="nt">&lt;/strong&gt;</span>
  260. <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  261. <span class="nt">&lt;hr</span> <span class="na">class=</span><span class="s">"separator"</span> <span class="na">aria-hidden=</span><span class="s">"true"</span><span class="nt">&gt;</span>
  262. <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"txt-small"</span><span class="nt">&gt;</span>
  263. <span class="cp">&lt;%=</span> <span class="n">turbo_frame_tag</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">membership</span><span class="p">.</span><span class="nf">room</span><span class="p">,</span> <span class="ss">:involvement</span><span class="p">)</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  264. <span class="cp">&lt;%=</span> <span class="n">button_to_change_involvement</span><span class="p">(</span><span class="n">membership</span><span class="p">.</span><span class="nf">room</span><span class="p">,</span> <span class="n">membership</span><span class="p">.</span><span class="nf">involvement</span><span class="p">)</span> <span class="cp">%&gt;</span>
  265. <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  266. <span class="nt">&lt;/span&gt;</span>
  267. <span class="nt">&lt;/li&gt;</span>
  268. </code></pre></div></div>
  269. <p>Now we could, of course, use Javascript to get the state, observe changes, and update the view. Or we could re-write this code to re-render the entire row when the notification state changes, but then we’d be writing a duplicate toggle that is only slightly different than the one used elsewhere.</p>
  270. <p>A third option is to write a single CSS rule!</p>
  271. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.membership-item</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.btn.invisible</span><span class="o">)</span> <span class="p">{</span>
  272. <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.5</span><span class="p">;</span>
  273. <span class="p">}</span>
  274. </code></pre></div></div>
  275. <p>If the row has a button in toggled to the <code class="language-plaintext highlighter-rouge">.invisible</code> class, dim it.</p>
  276. <p>Advances in CSS have been slowing replacing Javascript code over the last few years, now it’s coming for server side code!</p>
  277. <h3 id="one-more">One more?</h3>
  278. <p>Campfire’s direct message feature, which we call Pings, displays all of your active conversations across the top of the sidebar. Depending on how many people are involved, Campfire displays one, two, three, or four avatars to represent the chat.</p>
  279. <p>Normally our view template would need to count the number of participants and conditionally apply a class to the element so the CSS knows how to render each layout group. But with <code class="language-plaintext highlighter-rouge">:has()</code> we can effectively count the number of elements and adjust the display accordingly.</p>
  280. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Four avatars */</span>
  281. <span class="nc">.avatar__group</span> <span class="p">{</span>
  282. <span class="py">--avatar-size</span><span class="p">:</span> <span class="m">2.5ch</span><span class="p">;</span>
  283. <span class="py">block-size</span><span class="p">:</span> <span class="m">5ch</span><span class="p">;</span>
  284. <span class="nl">display</span><span class="p">:</span> <span class="n">grid</span><span class="p">;</span>
  285. <span class="py">gap</span><span class="p">:</span> <span class="m">1px</span><span class="p">;</span>
  286. <span class="py">grid-template-columns</span><span class="p">:</span> <span class="m">1</span><span class="n">fr</span> <span class="m">1</span><span class="n">fr</span><span class="p">;</span>
  287. <span class="py">grid-template-rows</span><span class="p">:</span> <span class="n">min-content</span><span class="p">;</span>
  288. <span class="py">inline-size</span><span class="p">:</span> <span class="m">5ch</span><span class="p">;</span>
  289. <span class="py">place-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  290. <span class="err">.avatar</span> <span class="err">{</span>
  291. <span class="nl">margin</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
  292. <span class="p">}</span>
  293. <span class="c">/_ Two avatars _/</span>
  294. <span class="o">&amp;</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:has</span><span class="o">(&gt;</span> <span class="nd">:last-child:nth-child</span><span class="o">(</span><span class="err">2</span><span class="o">)))</span> <span class="p">{</span>
  295. <span class="py">--avatar-size</span><span class="p">:</span> <span class="m">3.5ch</span><span class="p">;</span>
  296. <span class="err">&gt;</span> <span class="err">:first-child</span> <span class="err">{</span>
  297. <span class="py">margin-block-end</span><span class="p">:</span> <span class="m">1.5ch</span><span class="p">;</span>
  298. <span class="py">margin-inline-end</span><span class="p">:</span> <span class="m">-0.75ch</span><span class="p">;</span>
  299. <span class="p">}</span>
  300. <span class="o">&gt;</span> <span class="nd">:last-child</span> <span class="p">{</span>
  301. <span class="py">margin-block-start</span><span class="p">:</span> <span class="m">1.5ch</span><span class="p">;</span>
  302. <span class="py">margin-inline-start</span><span class="p">:</span> <span class="m">-0.75ch</span><span class="p">;</span>
  303. <span class="p">}</span>
  304. <span class="err">}</span>
  305. <span class="c">/_ Three avatars _/</span>
  306. <span class="o">&amp;</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:has</span><span class="o">(&gt;</span> <span class="nd">:last-child:nth-child</span><span class="o">(</span><span class="err">3</span><span class="o">)))</span> <span class="p">{</span>
  307. <span class="err">&gt;</span> <span class="err">:last-child</span> <span class="err">{</span>
  308. <span class="py">margin-inline</span><span class="p">:</span> <span class="m">1.25ch</span> <span class="m">-1.25ch</span><span class="p">;</span>
  309. <span class="p">}</span>
  310. <span class="err">}</span>
  311. <span class="err">}</span>
  312. </code></pre></div></div>
  313. <p>Magic 🪄</p>
  314. <hr>
  315. <h2 id="responsive-design">Responsive design</h2>
  316. <p>In this last section, we’ll take a look at Campfire’s approach to responsive design. The first thing to know is that Campfire has zero/none/nada viewport based <code class="language-plaintext highlighter-rouge">@media</code> queries. There are no attempts to assert that <em>viewports narrower than x are mobile devices</em>. Campfire’s layout fully adapts to whichever device you’re using in whichever configuration or orientation, without attempting to declare any state as “mobile”. Here’s how.</p>
  317. <h3 id="layout">Layout</h3>
  318. <p>Campfire has a single <code class="language-plaintext highlighter-rouge">@media</code> breakpoint—one value, used in a number of places.</p>
  319. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">max-width</span><span class="p">:</span> <span class="m">100ch</span><span class="p">)</span> <span class="p">{</span>
  320. <span class="o">...</span>
  321. <span class="p">}</span>
  322. </code></pre></div></div>
  323. <p>This breakpoint largely determines how the CSS grid layout must adjust when the viewport is too narrow to display the sidebar alongside the chat transcript. When the document is narrower than 100 characters, it’s not practical to render them side-by-side, so instead Campfire hides the sidebar and reveals a menu button to toggle it.</p>
  324. <p>Using characters as the unit of measure ensures that we get the right behavior no matter which device you’re using and in a number of other scenarios such as multitasking on iPad or even if you simply enlarge the font size past a certain point. Type is the heart of web pages so it makes sense for the layout to respond to it.</p>
  325. <h3 id="feature-enhancements">Feature enhancements</h3>
  326. <p>The other place we use media queries is to respond to the kind of input device the user has. It’s never been fair to assume a device with a narrow viewport has a touch screen, nor that a device with an enormous viewport does not. This blurry line is not getting clearer. But thanks to <code class="language-plaintext highlighter-rouge">@media</code> queries we can actually get useful information about a device’s capabilities. First up, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/any-hover">any-hover</a>.</p>
  327. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">any-hover</span><span class="p">:</span> <span class="n">hover</span><span class="p">)</span> <span class="p">{</span>
  328. <span class="o">&amp;</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:not</span><span class="o">(</span><span class="nd">:active</span><span class="o">)</span><span class="nd">:hover</span><span class="o">)</span> <span class="p">{</span>
  329. <span class="c">/* hover effect */</span>
  330. <span class="p">}</span>
  331. <span class="p">}</span>
  332. </code></pre></div></div>
  333. <p>This queries the user’s device to see if it has any input mechanism that is capable of hovering (probably a mouse). It won’t match on touch screen devices and will opt out of Mobile Safari’s annoying behavior that makes you double-tap things that have a hover effect. Not bad.</p>
  334. <p>But let’s look at something a little more impressive. Every message line in a Campfire chat has a <strong>•••</strong> button that reveals a menu of extra actions (<em>edit, Boost, copy, share</em>) that you can do.</p>
  335. <p>On devices with a mouse or trackpad the ideal is to only reveal the menu when you hover over the message but that would make it inaccessible on touch devices. No problem. We can use <code class="language-plaintext highlighter-rouge">any-hover</code> along with the <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@media/pointer">pointer</a> query to get the behavior we want on each kind of device.</p>
  336. <div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">any-hover</span><span class="p">:</span> <span class="n">hover</span><span class="p">)</span> <span class="n">and</span> <span class="p">(</span><span class="nb">pointer</span><span class="p">:</span> <span class="n">fine</span><span class="p">)</span> <span class="p">{</span>
  337. <span class="c">/* Reveal the button only on hover */</span>
  338. <span class="p">}</span>
  339. <span class="k">@media</span> <span class="p">(</span><span class="n">any-hover</span><span class="p">:</span> <span class="nb">none</span><span class="p">)</span> <span class="n">and</span> <span class="p">(</span><span class="nb">pointer</span><span class="p">:</span> <span class="n">coarse</span><span class="p">)</span> <span class="p">{</span>
  340. <span class="c">/_ Show the button all the time _/</span>
  341. <span class="p">}</span>
  342. </code></pre></div></div>
  343. <p>This is especially magical with a device like the iPad Pro. Which can match both queries under certain conditions, and change on-the-fly. When it’s docked on the <a href="https://www.apple.com/ipad-keyboards/">Magic Keyboard</a> with built-in trackpad, it matches the first query and the <strong>•••</strong> buttons are hidden until you hover. Lift it off the Magic Keyboard and it becomes a purely touch device—the <strong>•••</strong> buttons magically appear. It’s very cool.</p>
  344. <hr>
  345. <h2 id="whats-next">What’s next?</h2>
  346. <p>Campfire 1.0 shipped in January 2024 and by March we had already started to work on the next ONCE product. While Campfire supported bleeding edge features when was released the <a href="https://web.dev/blog/web-platform-03-2024/">web platform is rapidly changing</a> and we’re already exploring new features that have gained browser support since then. It’s a fantastic time to be working on the web.</p>
  347. <p>If you haven’t tried Campfire yet, it’s available now at <a href="https://once.com">once.com</a>, the first of a family of products that you buy once, own forever (including source code), and can do what you want with.</p>
  348. <hr>
  349. <h2 id="questions">Questions?</h2>
  350. <p>Have a question, comment or idea? Want to see more posts like this? Get in touch at <a href="mailto:jz@37signals.com">jz@37signals.com</a> or <a href="https://twitter.com/jasonzimdars">x.com/jasonzimdars</a></p>
  351. </article>
  352. <hr>
  353. <footer>
  354. <p>
  355. <a href="/david/" title="Aller à l’accueil"><svg class="icon icon-home">
  356. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-home"></use>
  357. </svg> Accueil</a> •
  358. <a href="/david/log/" title="Accès au flux RSS"><svg class="icon icon-rss2">
  359. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-rss2"></use>
  360. </svg> Suivre</a> •
  361. <a href="http://larlet.com" title="Go to my English profile" data-instant><svg class="icon icon-user-tie">
  362. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-user-tie"></use>
  363. </svg> Pro</a> •
  364. <a href="mailto:david%40larlet.fr" title="Envoyer un courriel"><svg class="icon icon-mail">
  365. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-mail"></use>
  366. </svg> Email</a> •
  367. <abbr class="nowrap" title="Hébergeur : Alwaysdata, 62 rue Tiquetonne 75002 Paris, +33184162340"><svg class="icon icon-hammer2">
  368. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-hammer2"></use>
  369. </svg> Légal</abbr>
  370. </p>
  371. <template id="theme-selector">
  372. <form>
  373. <fieldset>
  374. <legend><svg class="icon icon-brightness-contrast">
  375. <use xlink:href="/static/david/icons2/symbol-defs-2021-12.svg#icon-brightness-contrast"></use>
  376. </svg> Thème</legend>
  377. <label>
  378. <input type="radio" value="auto" name="chosen-color-scheme" checked> Auto
  379. </label>
  380. <label>
  381. <input type="radio" value="dark" name="chosen-color-scheme"> Foncé
  382. </label>
  383. <label>
  384. <input type="radio" value="light" name="chosen-color-scheme"> Clair
  385. </label>
  386. </fieldset>
  387. </form>
  388. </template>
  389. </footer>
  390. <script src="/static/david/js/instantpage-5.1.0.min.js" type="module"></script>
  391. <script>
  392. function loadThemeForm(templateName) {
  393. const themeSelectorTemplate = document.querySelector(templateName)
  394. const form = themeSelectorTemplate.content.firstElementChild
  395. themeSelectorTemplate.replaceWith(form)
  396. form.addEventListener('change', (e) => {
  397. const chosenColorScheme = e.target.value
  398. localStorage.setItem('theme', chosenColorScheme)
  399. toggleTheme(chosenColorScheme)
  400. })
  401. const selectedTheme = localStorage.getItem('theme')
  402. if (selectedTheme && selectedTheme !== 'undefined') {
  403. form.querySelector(`[value="${selectedTheme}"]`).checked = true
  404. }
  405. }
  406. const prefersColorSchemeDark = '(prefers-color-scheme: dark)'
  407. window.addEventListener('load', () => {
  408. let hasDarkRules = false
  409. for (const styleSheet of Array.from(document.styleSheets)) {
  410. let mediaRules = []
  411. for (const cssRule of styleSheet.cssRules) {
  412. if (cssRule.type !== CSSRule.MEDIA_RULE) {
  413. continue
  414. }
  415. // WARNING: Safari does not have/supports `conditionText`.
  416. if (cssRule.conditionText) {
  417. if (cssRule.conditionText !== prefersColorSchemeDark) {
  418. continue
  419. }
  420. } else {
  421. if (cssRule.cssText.startsWith(prefersColorSchemeDark)) {
  422. continue
  423. }
  424. }
  425. mediaRules = mediaRules.concat(Array.from(cssRule.cssRules))
  426. }
  427. // WARNING: do not try to insert a Rule to a styleSheet you are
  428. // currently iterating on, otherwise the browser will be stuck
  429. // in a infinite loop…
  430. for (const mediaRule of mediaRules) {
  431. styleSheet.insertRule(mediaRule.cssText)
  432. hasDarkRules = true
  433. }
  434. }
  435. if (hasDarkRules) {
  436. loadThemeForm('#theme-selector')
  437. }
  438. })
  439. </script>
  440. </body>
  441. </html>