Space NinjaScott Vandehey: front-end architect & CSS specialist. Curator for Friday Front-End & CSS Basics. Author of “How to Find a Better Job in Tech.”2024-03-20T00:00:00Zhttps://spaceninja.com/Scott Vandeheyscott@spaceninja.comhttps://spaceninja.com/images/spaceninja.pngHow to Create a Website and a PDF from the Same Codebase2024-03-20T00:00:00Z2024-03-20T00:00:00Zhttps://spaceninja.com/blog/2024/web-to-pdf/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/feature-web-to-pdf-zGDRs3JYiX-1600w.jpeg">
<p>Recently a client approached us to produce a digital version of a printed information packet. Making changes to this packet was costly and time-consuming, so they wanted to convert the sections of the packet into pages on a website, with a CMS to make updates easier. The client also wanted to retain the ability to print the whole thing, with the same design quality as the existing packet. We were able to get a lot done using CSS print styles, but because browsers don’t support the full suite of CSS print styles, it was clear we’d ultimately need to generate a PDF to get the print design we desired.</p>
<p>This started a journey into the world of HTML-to-PDF services, and I’m quite pleased with the solution we landed on. I’ve since started using it in another project, and I wanted to share it with you today. In a nutshell, we’re generating a website from a CMS using <a href="https://www.11ty.dev/">Eleventy</a> and generating a PDF version of the website using <a href="https://docraptor.com/">DocRaptor</a>.</p>
<p>To make this project easier to talk about without all the client-specific details, I’ve created an <a href="https://github.com/spaceninja/eleventy-pdf">example repo</a>. It takes a public-domain Sherlock Holmes story and generates both a <a href="https://eleventy-pdf.netlify.app/">website</a> and a <a href="https://eleventy-pdf.netlify.app/pdf/a-study-in-scarlet.pdf">PDF</a>.</p>
<h2>The PDF Service</h2>
<p>To generate the PDF, we’re using an HTML-to-PDF API service called <a href="https://docraptor.com/">DocRaptor</a>. I found it to be easy to use and I’m happy to recommend it, but there are alternatives out there, including <a href="https://developer.adobe.com/document-services/apis/pdf-services/">Adobe PDF Services</a>, <a href="https://weasyprint.org/">WeasyPrint</a>, and even the tool that DocRaptor is built on, <a href="https://www.princexml.com/">PrinceXML</a>. They all do roughly the same things, and it would be easy to swap out DocRaptor for another service.</p>
<p>The main thing you need to understand is that we’re going to make an API call to a PDF generation service, and the body of our request will be the HTML it will use to generate the PDF. That HTML needs to include not only all the contents of the PDF but also all the images and styles.</p>
<p>Let’s break this down into three parts: How we generate the HTML for the PDF, the JavaScript we use to generate the PDF, and the CSS we use to style both.</p>
<h2>The HTML</h2>
<p>The example repo uses <a href="https://www.11ty.dev/">Eleventy</a> to generate the website. I like Eleventy a lot, and I think it’s a good fit for this kind of static site, but you could easily replace it with any build tool you wanted, including none at all. This process would work just as well with a hand-written HTML file. The only thing that matters is that we have a single HTML file that contains all the content we want to end up in our PDF.</p>
<p>Our goal was to have both a website and a PDF. In the example repo, the website is a public domain Sherlock Holmes story, broken up with each chapter on its own page to make reading easier. But to generate the PDF, we need a single HTML file with all the content.</p>
<p>Eleventy makes it easy to do this with <a href="https://github.com/spaceninja/eleventy-pdf/blob/main/src/a-study-in-scarlet.njk">a single file</a>:</p>
<pre class="language-njk" tabindex="0"><code class="language-njk"><span class="token delimiter punctuation">{%</span> <span class="token tag keyword">include</span> <span class="token string">'title-page.njk'</span> <span class="token operator">%</span><span class="token punctuation">}</span>
<span class="token punctuation">{</span><span class="token operator">%</span> <span class="token variable">include</span> <span class="token string">'frontispiece.njk'</span> <span class="token operator">%</span><span class="token punctuation">}</span>
<span class="token punctuation">{</span><span class="token operator">%</span> <span class="token variable">include</span> <span class="token string">'contents.njk'</span> <span class="token operator">%</span><span class="token punctuation">}</span>
<span class="token punctuation">{</span><span class="token operator">%</span> <span class="token keyword">for</span> <span class="token variable">chapterObject</span> <span class="token keyword">in</span> <span class="token variable">collections</span><span class="token punctuation">.</span><span class="token variable">chapters</span> <span class="token operator">%</span><span class="token punctuation">}</span>
<span class="token operator"><</span><span class="token variable">article</span> <span class="token variable">class</span><span class="token operator">=</span><span class="token string">"new-page"</span><span class="token operator">></span>
<span class="token punctuation">{</span><span class="token operator">%</span> <span class="token variable">include</span> <span class="token string">'chapter-header.njk'</span> <span class="token operator">%</span><span class="token punctuation">}</span>
<span class="token punctuation">{</span><span class="token punctuation">{</span> <span class="token variable">chapterObject</span><span class="token punctuation">.</span><span class="token variable">content</span> <span class="token operator">|</span> <span class="token variable">safe</span> <span class="token punctuation">}</span><span class="token punctuation">}</span>
<span class="token operator"><</span><span class="token operator">/</span><span class="token variable">article</span><span class="token operator">></span>
<span class="token punctuation">{</span><span class="token operator">%</span> <span class="token variable">endfor</span> <span class="token operator">%</span><span class="token punctuation">}</span></code></pre>
<p>We’re manually including a few PDF-specific pages, such as the title page and table of contents. Then we loop over all the book content, which is in an Eleventy collection called “chapters.” We write out the contents of each chapter on the page wrapped in an <code><article></code> element. <a href="https://eleventy-pdf.netlify.app/a-study-in-scarlet/">Here’s what this all-in-one page looks like on the website</a>, though it’s worth noting that no one will be looking at this page, it’s just being generated so we can pass it to the PDF generation service.</p>
<h2>The CSS</h2>
<p>One of the best parts of this process is that once you write the CSS for the website, you’re 90% done with the CSS for the PDF as well. DocRaptor is powered by PrinceXML under the hood, which has very good CSS support. And, since all of the DocRaptor CSS is based on <a href="https://drafts.csswg.org/css-page-3/">real-world specs for CSS print styles</a>, almost everything you write for the PDF has the bonus of giving your website good print styles.<sup class="footnote-ref"><a href="https://spaceninja.com/blog/2024/web-to-pdf/" id="fnref1">1</a></sup></p>
<p>I found that I was able to use all the CSS I wrote for the website and I only needed to add a single print stylesheet that contained some additional rules for DocRaptor such as page margins, hiding some web-only content, and adding page numbers.</p>
<p>The only “bugs” I needed to fix in my existing CSS were related to modern syntax that DocRaptor doesn’t understand just yet, like logical properties (I had to replace a few instances of <code>margin-inline</code> with <code>margin-left</code> and <code>margin-right</code>). Another common change was adding <code>page-break-inside: avoid</code> to keep images from breaking across pages, for example.</p>
<p>When testing CSS changes, I found that the browser’s print preview was often good enough, if I didn’t want to wait for another API call to generate an updated PDF. Another option in Chrome is to <a href="https://developer.chrome.com/docs/devtools/rendering/emulate-css/">emulate print media</a>, which will apply the print styles in the browser window. When testing the print layout in the browser, I found that the printed page was 816 pixels wide (<a href="https://docraptor.com/documentation/article/1067959-size-dimensions-orientation">8.5 inches at 96 pixels per inch</a>), which means my content column, after subtracting the 0.75 inches of margins, was 672 pixels wide.</p>
<p>You can view the <a href="https://github.com/spaceninja/eleventy-pdf/tree/main/src/_scss">full CSS for the example site</a>, but I’d like to talk about a few specific features.</p>
<h3>Page Margins</h3>
<p>You can easily adjust the page margins in the PDF:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@page</span></span> <span class="token punctuation">{</span>
<span class="token property">margin</span><span class="token punctuation">:</span> 0.75in<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<p>That’s actually standard CSS. You can read <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@page">more details on MDN</a>.</p>
<p>If you want to override the margins on a particular page you assign it a name, and update the rules for that page.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.full-bleed-page</span> <span class="token punctuation">{</span>
<span class="token property">page</span><span class="token punctuation">:</span> full_bleed_page<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token atrule"><span class="token rule">@page</span> full_bleed_page</span> <span class="token punctuation">{</span>
<span class="token property">margin</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<h3>Page Header</h3>
<p>Like a printed book, I wanted to put the name of the story in the header for each page. This turns out to be easy, again using standard CSS:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@page</span></span> <span class="token punctuation">{</span>
<span class="token atrule"><span class="token rule">@top</span></span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token string">'A Study in Scarlet'</span><span class="token punctuation">;</span>
<span class="token property">font-family</span><span class="token punctuation">:</span> Merriweather<span class="token punctuation">,</span> serif<span class="token punctuation">;</span>
<span class="token property">margin-top</span><span class="token punctuation">:</span> 1em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>The <code>@top</code> at-rule targets a section of the page appearing in the page margin itself. In our case, we’ve targeted the top section, added content, styled it, and given it a bit of margin from the top of the page. By default, the content will be centered.</p>
<p>Note that if you’re not careful, this content could overlap your page content.</p>
<ul>
<li><a href="https://docraptor.com/documentation/article/1067094-headers-footers-for-pdfs">DocRaptor docs: Headers & Footers</a></li>
</ul>
<h3>Page Numbers</h3>
<p>Adding page numbers is a similar operation:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@page</span></span> <span class="token punctuation">{</span>
<span class="token atrule"><span class="token rule">@bottom</span></span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token function">counter</span><span class="token punctuation">(</span>page<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token property">font-family</span><span class="token punctuation">:</span> Merriweather<span class="token punctuation">,</span> serif<span class="token punctuation">;</span>
<span class="token property">margin-top</span><span class="token punctuation">:</span> 1em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>We’re adding content to the <code>@bottom</code> section, but rather than giving it a simple string of text, we’re saying it should use the value of the page counter, which DocRaptor defines for us.</p>
<ul>
<li><a href="https://docraptor.com/documentation/article/1082618-page-numbers">DocRaptor docs: Page Numbers</a></li>
</ul>
<h3>Table of Contents</h3>
<p>Now the table of contents takes a little more work. In our HTML, we have a simple ordered list with jump links to the appropriate sections of the document, like so:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>ol</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>I<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>li</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>span</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>toc-item<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>span</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>toc-item__title<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>a</span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>#ch-I-mr-sherlock-holmes<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Mr. Sherlock Holmes<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>a</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>span</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>span</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>toc-item__page print-only<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"><</span>a</span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>#ch-I-mr-sherlock-holmes<span class="token punctuation">"</span></span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation"></</span>a</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>span</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>span</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>li</span><span class="token punctuation">></span></span>
...
<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>ol</span><span class="token punctuation">></span></span></code></pre>
<p>Note the <code>.print-only</code> class, which as you might guess hides that element from view in the browser. Also note the empty anchor tag it contains, which we will populate with a page number using CSS.</p>
<p>When this block of code is shown in the browser, we get a simple unordered list of chapter titles that are jump links to further down the document, with no page numbers.</p>
<img alt="Screenshot of the table of contents rendered in a browser with no page numbers." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/toc-browser-HtIakllfJQ-450w.webp" width="1025" height="615" srcset="https://spaceninja.com/images/toc-browser-HtIakllfJQ-450w.webp 450w, https://spaceninja.com/images/toc-browser-HtIakllfJQ-700w.webp 700w, https://spaceninja.com/images/toc-browser-HtIakllfJQ-825w.webp 825w, https://spaceninja.com/images/toc-browser-HtIakllfJQ-975w.webp 975w, https://spaceninja.com/images/toc-browser-HtIakllfJQ-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>Now, we add this CSS for the print styles:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.toc-item</span> <span class="token punctuation">{</span>
<span class="token property">display</span><span class="token punctuation">:</span> flex<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.toc-item__title</span> <span class="token punctuation">{</span>
<span class="token property">flex</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.toc-item__title::after</span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token function">leader</span><span class="token punctuation">(</span>dotted<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">/* add dot leaders */</span>
<span class="token punctuation">}</span>
<span class="token selector">.toc-item__page a::after</span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token function">target-counter</span><span class="token punctuation">(</span><span class="token function">attr</span><span class="token punctuation">(</span>href<span class="token punctuation">)</span><span class="token punctuation">,</span> page<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token comment">/* add page numbers */</span>
<span class="token punctuation">}</span></code></pre>
<p>We make each table of contents item into a flex layout and assign all the space to the title.</p>
<p>We add dot leaders using <a href="https://www.princexml.com/doc/gen-content/">generated content</a> after the title using <a href="https://www.w3.org/Style/Examples/007/leaders.en.html">the proposed <code>leader()</code> syntax</a> (which at the moment, I believe is only supported by PrinceXML!)</p>
<p>We insert the page number for the chapter using a clever bit of syntax that lets DocRaptor look up the page number that will contain the ID the jump link is targeting.</p>
<p>And then we get dot leaders and page numbers automatically added via CSS!</p>
<img alt="Screenshot of the table of contents rendered in a pdf with page numbers." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/toc-print-dZvEYcGQBb-450w.webp" width="1280" height="607" srcset="https://spaceninja.com/images/toc-print-dZvEYcGQBb-450w.webp 450w, https://spaceninja.com/images/toc-print-dZvEYcGQBb-700w.webp 700w, https://spaceninja.com/images/toc-print-dZvEYcGQBb-825w.webp 825w, https://spaceninja.com/images/toc-print-dZvEYcGQBb-975w.webp 975w, https://spaceninja.com/images/toc-print-dZvEYcGQBb-1025w.webp 1025w, https://spaceninja.com/images/toc-print-dZvEYcGQBb-1280w.webp 1280w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<ul>
<li>DocRaptor docs: <a href="https://docraptor.com/documentation/article/1082618-page-numbers">Page Numbers</a></li>
<li>DocRaptor docs: <a href="https://docraptor.com/documentation/tutorial/table-of-contents">Table of Contents</a></li>
</ul>
<h2>The JavaScript</h2>
<p>Now, let’s talk about the Node script we use to submit the HTML to the PDF generation service. You can view <a href="https://github.com/spaceninja/eleventy-pdf/blob/main/build-pdf.mjs">the full script on GitHub</a>, but I’ll walk you through the structure of what we’re doing here.</p>
<pre class="language-js" tabindex="0"><code class="language-js"><span class="token keyword">await</span> <span class="token function">generatePDF</span><span class="token punctuation">(</span><span class="token string">'dist/a-study-in-scarlet/index.html'</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre>
<p>The first thing that happens is we call the <code>generatePDF()</code> function and pass it the path to our HTML file.</p>
<h3>generatePDF()</h3>
<p>The generatePDF function is our one-stop shop for generating a PDF from an HTML file, but it farms out the work to several smaller functions for ease of maintenance.</p>
<pre class="language-js" tabindex="0"><code class="language-js"><span class="token keyword">const</span> <span class="token function-variable function">generatePDF</span> <span class="token operator">=</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token parameter">htmlPath</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
<span class="token comment">// Get the slug and path info for this HTML file</span>
<span class="token keyword">const</span> meta <span class="token operator">=</span> <span class="token function">getMeta</span><span class="token punctuation">(</span>htmlPath<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Get the contents of the HTML file</span>
<span class="token keyword">const</span> html <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">getHtmlFromFile</span><span class="token punctuation">(</span>meta<span class="token punctuation">.</span>htmlPathCWD<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Create a PDF from the HTML contents</span>
<span class="token keyword">const</span> pdf <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">fetchPDF</span><span class="token punctuation">(</span>html<span class="token punctuation">,</span> meta<span class="token punctuation">.</span>slug<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Create the output directory if it doesn't exist</span>
<span class="token keyword">await</span> fs<span class="token punctuation">.</span><span class="token function">mkdir</span><span class="token punctuation">(</span>distDir<span class="token punctuation">,</span> <span class="token punctuation">{</span> <span class="token literal-property property">recursive</span><span class="token operator">:</span> <span class="token boolean">true</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Save the PDF to a file</span>
<span class="token keyword">await</span> fs<span class="token punctuation">.</span><span class="token function">writeFile</span><span class="token punctuation">(</span>meta<span class="token punctuation">.</span>pdfPath<span class="token punctuation">,</span> pdf<span class="token punctuation">)</span><span class="token punctuation">;</span>
console<span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[PDF] Writing </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>meta<span class="token punctuation">.</span>pdfPath<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span></code></pre>
<p>First, it calls <code>getMeta()</code>, which returns information including the file slug and the full path info. Then it passes the HTML file path to <code>getHtmlFromFile()</code>, which reads the actual markup from the file and makes some changes we need for DocRaptor, like inlining the CSS and images. Then it takes the markup and passes it to <code>fetchPDF()</code>, which handles the actual API call to DocRaptor and returns PDF data. Finally, it writes the PDF data to a file.</p>
<p>Let’s take a look at those functions in more detail.</p>
<h3>getMeta()</h3>
<p>Up first, we have <code>getMeta()</code>, which accepts a path to an HTML file and returns information about that file.</p>
<pre class="language-js" tabindex="0"><code class="language-js"><span class="token keyword">const</span> <span class="token function-variable function">getMeta</span> <span class="token operator">=</span> <span class="token punctuation">(</span><span class="token parameter">htmlPath</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
<span class="token comment">// Strip `dist/` and `/index.html` from htmlPath</span>
<span class="token keyword">let</span> slug <span class="token operator">=</span> htmlPath<span class="token punctuation">.</span><span class="token function">slice</span><span class="token punctuation">(</span><span class="token number">5</span><span class="token punctuation">,</span> <span class="token operator">-</span><span class="token number">11</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Special case for the root HTML file</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>htmlPath <span class="token operator">===</span> <span class="token string">'dist/index.html'</span><span class="token punctuation">)</span> slug <span class="token operator">=</span> <span class="token string">'home'</span><span class="token punctuation">;</span>
<span class="token comment">// Create relative HTML path and PDF write destination</span>
<span class="token keyword">const</span> htmlPathCWD <span class="token operator">=</span> path<span class="token punctuation">.</span><span class="token function">join</span><span class="token punctuation">(</span>currentDir<span class="token punctuation">,</span> htmlPath<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Convert any slashes to dashes for the PDF filename</span>
<span class="token keyword">const</span> pdfSlug <span class="token operator">=</span> slug<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token string">'/'</span><span class="token punctuation">,</span> <span class="token string">'-'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Create the PDF write destination</span>
<span class="token keyword">const</span> pdfPath <span class="token operator">=</span> path<span class="token punctuation">.</span><span class="token function">join</span><span class="token punctuation">(</span>distDir<span class="token punctuation">,</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>pdfSlug<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.pdf</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">return</span> <span class="token punctuation">{</span>
slug<span class="token punctuation">,</span>
htmlPathCWD<span class="token punctuation">,</span>
pdfSlug<span class="token punctuation">,</span>
pdfPath<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span></code></pre>
<p>We get back four pieces of information, which are all used later:</p>
<ul>
<li><code>slug</code>, which is the filename, is only used for error logging if something goes wrong.</li>
<li><code>htmlPathCWD</code> is the full path to the HTML file including the current working directory. We need this to read the contents of the file in the next function.</li>
<li><code>pdfSlug</code> is used for the filename of the PDF, and we’re just replacing any slashes with dashes.</li>
<li><code>pdfPath</code> is the final output directory of the PDF, which is our <code>dist</code> folder plus the PDF filename.</li>
</ul>
<h3>getHtmlFromFile()</h3>
<p>Next, we have <code>getHtmlFromFile()</code> which is responsible not only for getting the actual contents of the HTML file but also for making changes we need for PDF generation.</p>
<pre class="language-js" tabindex="0"><code class="language-js"><span class="token keyword">const</span> inlineAssets <span class="token operator">=</span> <span class="token function">unified</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
<span class="token punctuation">.</span><span class="token function">use</span><span class="token punctuation">(</span>rehypeParse<span class="token punctuation">,</span> <span class="token punctuation">{</span> <span class="token literal-property property">fragment</span><span class="token operator">:</span> <span class="token boolean">false</span> <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">.</span><span class="token function">use</span><span class="token punctuation">(</span>rehypeInline<span class="token punctuation">)</span>
<span class="token punctuation">.</span><span class="token function">use</span><span class="token punctuation">(</span>rehypeStringify<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> <span class="token function-variable function">getHtmlFromFile</span> <span class="token operator">=</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token parameter">htmlPath</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
<span class="token comment">// Grab the HTML file contents as a string</span>
<span class="token keyword">const</span> rawHTML <span class="token operator">=</span> <span class="token keyword">await</span> fs<span class="token punctuation">.</span><span class="token function">readFile</span><span class="token punctuation">(</span>htmlPath<span class="token punctuation">,</span> <span class="token string">'utf8'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Change the CSS URI to a path so it can be inlined</span>
<span class="token keyword">let</span> updatedHTML <span class="token operator">=</span> rawHTML<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token string">'/style.css'</span><span class="token punctuation">,</span> <span class="token string">'dist/style.css'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Change any image URLs to paths so they can be inlined</span>
updatedHTML <span class="token operator">=</span> updatedHTML<span class="token punctuation">.</span><span class="token function">replaceAll</span><span class="token punctuation">(</span><span class="token string">'/images/'</span><span class="token punctuation">,</span> <span class="token string">'dist/images/'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Inline the assets</span>
<span class="token keyword">return</span> <span class="token function">String</span><span class="token punctuation">(</span><span class="token keyword">await</span> inlineAssets<span class="token punctuation">.</span><span class="token function">process</span><span class="token punctuation">(</span>updatedHTML<span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span></code></pre>
<p>Once it loads the HTML contents, it modifies any CSS and image URLs to file paths so they can be inlined. We inline the CSS and images because the only thing we pass to DocRaptor is the HTML itself. DocRaptor can load assets from public URLs, but during development work, none of our files were public, so we got in the habit of inlining them.</p>
<p>For inlining, we’re using a library called <a href="https://www.npmjs.com/package/rehype-inline">rehype-Inline</a>, which is capable of inlining CSS, JavaScript, and images in HTML documents.</p>
<h3>fetchPDF()</h3>
<p>Finally, we come to the meat of the process: Passing the HTML to DocRaptor, which will return a PDF.</p>
<pre class="language-js" tabindex="0"><code class="language-js"><span class="token keyword">const</span> <span class="token function-variable function">fetchPDF</span> <span class="token operator">=</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token parameter">html<span class="token punctuation">,</span> slug</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>docraptorApiKey<span class="token punctuation">)</span> <span class="token keyword">throw</span> <span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">(</span><span class="token string">'Missing DocRaptor API Key'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Send HTML to DocRaptor to generate PDF</span>
<span class="token keyword">const</span> pdfRes <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">fetch</span><span class="token punctuation">(</span><span class="token string">'https://docraptor.com/docs'</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
<span class="token literal-property property">method</span><span class="token operator">:</span> <span class="token string">'POST'</span><span class="token punctuation">,</span>
<span class="token literal-property property">headers</span><span class="token operator">:</span> <span class="token punctuation">{</span>
<span class="token literal-property property">Authorization</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">Basic </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>Buffer<span class="token punctuation">.</span><span class="token function">from</span><span class="token punctuation">(</span>docraptorApiKey<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">toString</span><span class="token punctuation">(</span><span class="token string">'base64'</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
<span class="token string-property property">'Content-Type'</span><span class="token operator">:</span> <span class="token string">'application/json'</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token literal-property property">body</span><span class="token operator">:</span> <span class="token constant">JSON</span><span class="token punctuation">.</span><span class="token function">stringify</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
<span class="token literal-property property">test</span><span class="token operator">:</span> docraptorTest<span class="token punctuation">,</span>
<span class="token literal-property property">document_content</span><span class="token operator">:</span> html<span class="token punctuation">,</span>
<span class="token literal-property property">type</span><span class="token operator">:</span> <span class="token string">'pdf'</span><span class="token punctuation">,</span>
<span class="token literal-property property">prince_options</span><span class="token operator">:</span> <span class="token punctuation">{</span>
<span class="token literal-property property">profile</span><span class="token operator">:</span> <span class="token string">'PDF/UA-1'</span><span class="token punctuation">,</span> <span class="token comment">// Adds accessibility features like tagging</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>pdfRes<span class="token punctuation">.</span>ok<span class="token punctuation">)</span>
<span class="token keyword">throw</span> <span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">(</span>
<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>slug<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>pdfRes<span class="token punctuation">.</span>status<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>pdfRes<span class="token punctuation">.</span>statusText<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token keyword">await</span> pdfRes<span class="token punctuation">.</span><span class="token function">text</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// Extract the PDF from the response and return it</span>
<span class="token keyword">const</span> blob <span class="token operator">=</span> <span class="token keyword">await</span> pdfRes<span class="token punctuation">.</span><span class="token function">blob</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">return</span> Buffer<span class="token punctuation">.</span><span class="token function">from</span><span class="token punctuation">(</span><span class="token keyword">await</span> blob<span class="token punctuation">.</span><span class="token function">arrayBuffer</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">'binary'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span></code></pre>
<p>This is a simple fetch request to the DocRaptor API. You’ll need to define a DocRaptor API key and tell it whether or not to use “test” mode, which is free, but adds an overlay. The one extra option we’re defining is asking DocRaptor to use the PDF/UA-1 profile, which adds accessibility features.</p>
<p>This function returns the raw PDF data, which we then save to the file system, and hey presto! We have a PDF!</p>
<h2>Conclusion</h2>
<p>I’m quite pleased with this process because each step adds to the previous ones without getting tangled up. The website doesn’t know anything about the PDF. It’s just a straightforward Eleventy site that happens to include a single-page version of the website’s contents. That website includes print styles, including a handful of rules that only work in DocRaptor, but are all based on standard or proposed CSS syntax. The PDF generation itself happens entirely in a Node script that can be updated, modified, or even replaced in the future without breaking the website.</p>
<p>I know this probably isn’t a common problem, but I hope this article helps someone else who might be looking at a similar request and isn’t sure how to get started.</p>
<h2>Footnotes</h2>
<section class="footnotes">
<ol class="footnotes-list">
<li id="fn1" class="footnote-item"><p>It’s worth noting that while the DocRaptor CSS is based on a real spec, a lot of the print-specific CSS is unsupported in most browsers. CSS Paged Media is unsupported in Safari, and has limited support in Firefox. The dot-leader styles we use for the table of contents is unsupported in <em>any</em> browser other than PrinceXML. As a result, while you’ll get good print styles in most browsers, they certainly won’t match the quality of the PDF. If more browsers supported these standards, it’s possible we could have delivered this project without needing to generate a PDF at all. <a href="https://spaceninja.com/blog/2024/web-to-pdf/" class="footnote-backref">↩︎</a></p>
</li>
</ol>
</section>
Why I Log My Media Diet2024-03-18T00:00:00Z2024-03-18T00:00:00Zhttps://spaceninja.com/blog/2024/why-i-log-my-media-diet/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/star-ratings-L4Hv1sEN92-1600w.jpeg">
<p>Annie asked me the other day if I kept journals as a kid, or in some other way recorded the books I read and movies I watched. I was stumped. I don’t recall ever doing such a thing. My mom keeps calendars where she records the events of the day and the weather, and I remember always finding this a bit odd. When would I ever need to know what the weather was like on a particular day years ago? And yet, here I am, with dedicated accounts across five different websites to track all the books and comics I read, games I play, and shows and movies I watch. How did I get here?</p>
<p>It all started with Netflix in 2004. We were living in Puyallup, Washington, had no kids, and I’d watch several movies a week. With each, I’d give it a 1 to 5 star rating on Netflix. I didn’t particularly care about the ratings, but Netflix had a recommendation algorithm that got more accurate the more movies you rated. So I started not only rating all the movies we rented but also everything I could ever remember watching. The recommendations became startlingly good as a result.</p>
<p>I was surprised to find a bonus reason to rate movies. I have a terrible memory. More than once, someone recommended a movie to me, I’d put it on my list, rent it, and then realize I’ve seen it before, but forgot. Now, with Netflix, I would go to put it in my queue, and see that I’d already watched it a few years ago, and rated it. “Ah, only 3 stars, that must be why I don’t remember it.” This happened many, many times.</p>
<p>Rating books came later. I’ve always aspired to be a voracious reader. My dad devoured science fiction novels, and the walls of our house were covered with shelves of cheap paperbacks stacked two deep. But as a poor college student and then as a poor college graduate, I didn’t have a big budget for buying books. I switched to ebooks as a necessity, because they were much cheaper.</p>
<p>I created a <a href="https://www.goodreads.com/spaceninja">GoodReads</a> account in 2013 when I noticed that Kindle had a feature to automatically mark as read on GoodReads the book you just finished, and prompt you for a star rating. Already being in the habit of doing this for movies on Netflix, and knowing how useful it was there, I started doing the same and found it just as useful.</p>
<p>Another thing GoodReads does that I love is their annual reading challenge. You set a public goal of how many books you want to read in the coming year (I’ve set a goal of 24 for a few years running), and it helpfully displays your progress when you visit the site. I actually find it really motivating to see “you’re one book behind schedule,” and it can help motivate me to make a little more time for reading.</p>
<p>In 2017, Netflix announced they were switching from a five-star rating model to a simple thumbs-up/thumbs-down system, and I had a moment of panic. I had over a decade of movie ratings that were about to disappear. I went down a rabbit hole and ended up writing a blog post called <a href="https://spaceninja.com/blog/2017/how-to-export-movie-ratings-from-netflix-and-import-into-imdb/">How to Export Movie Ratings from Netflix and Import into IMDb</a>. The end result was I preserved my ratings. I didn’t really like IMDb, though. It was good for looking movies up, but less good for keeping a record of what I watched.</p>
<p>In 2018, I discovered <a href="https://letterboxd.com/spaceninja/">Letterboxd</a>, which conveniently had a system to import ratings from IMDb, and I switched over. Since then, every movie I watch gets a rating on Letterboxd.</p>
<p>In 2021, I was looking at the cool <a href="https://letterboxd.com/spaceninja/year/2021/">year in review</a> page that Letterboxd automatically generates for you, and I realized I wanted something similar for TV Shows. I spent some time checking out the options and ended up creating an account on <a href="https://trakt.tv/users/spaceninja00">Trakt</a>, where I now record all the TV I watch.</p>
<p>And then I thought, if I’m recording books and TV and movies, I should probably record the video games I play. So I set up an account on <a href="https://www.grouvee.com/user/120880-spaceninja/shelves/644298-played/">Grouvee</a>. I don’t love Grouvee, but the other options are all flawed in some way, and I finish games infrequently enough that it’s not a problem. Still, if I found something better, I’d probably switch.</p>
<p>Most recently, a few months ago, I was complaining about how my iPad is too old to run the Marvel Unlimited app, so I have to read comics on their website, which works, but means I lost the to-read list and reading history from the app. So I started looking for where people track their comics, and now I have an account on <a href="https://leagueofcomicgeeks.com/profile/spaceninja">League of Comic Geeks</a>.</p>
<p>So… That’s how I arrived at this point. I wasn’t always a data nerd, and I still don’t think I am, really. But I do habitually log and rate every TV show, movie, video game, book, and comic that I consume.</p>
Reflections on the End of a Seven-Year D&D Campaign2024-02-07T00:00:00Z2024-02-07T00:00:00Zhttps://spaceninja.com/blog/2024/dnd-campaign/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/red-dragon-l1ziSQ4v30-1280w.jpeg">
<p>Like a lot of people, I got into D&D because of a liveplay podcast. Specifically, the <em>first</em> liveplay podcast, <em>Acquisitions Incorporated</em>, which not only kickstarted an entirely new genre of entertainment but also remained a juggernaut in the field. Anyone who wasn’t there when it started may be unaware that it began pretty humbly — Wizards of the Coast reached out to the <em>Penny Arcade</em> guys in 2008 to ask if they’d be interested in recording a gameplay session to help promote the upcoming release of the fourth edition of D&D.</p>
<p>The original team was just three players — Mike and Jerry from <em>Penny Arcade</em> and their friend Scott from <em>PvP</em> — and now legendary DM Chris Perkins. Their mix of experience with the game was perfect for promoting a new edition. Mike had never played any D&D before, while Scott and Jerry had some experience under their belts. As a result, Perkins could explain the game for newbies when talking to Mike, and get into the nitty-gritty of the changes for fourth edition when talking to Jerry and Scott. It helped that the three players were extremely funny, and used to riffing together to come up with webcomics.</p>
<p>I was hooked, and immediately bought the starter set, and by April 2009, I had roped some friends into playing a game, with me DMing. That group met infrequently, and over the next few years, I joined some games as a player, attended some pickup games at conventions, and DM’d for several groups of coworkers across a few companies.</p>
<p>But the longest stint started when I joined Say Media. In September of 2015, I put out word that I was interested in starting a D&D group, and invited anyone interested to join us. No commitment, and no need to know the rules, just show up ready to have fun. I ran a group of 5-10 coworkers through a <em>Tomb of Horrors</em>-style adventure. We all had a good time, and I put a recurring event on the calendar: Every other Wednesday after work, anyone who wants to join is welcome, I’ll run the game as long as there are at least three players.</p>
<p>Over the next few years, people drifted in and out, but the core of the group stayed together, continuing to meet every other week even after I lost my job at Say. We’d meet up at whoever’s office was convenient and had a conference room to use.</p>
<p>When Covid hit, we kept playing remotely. It took a while to adjust to playing over Zoom, but we made it work. There are fewer fun opportunities for excited cross-talk, but it’s also a lot easier to follow what’s going on with mostly only one person speaking at a time.</p>
<p>At first, I was just running pre-published adventures for the group. A mix of whatever I could find that sounded fun. At times, I would come up with my own stuff, but I had a tendency to fall back on <a href="https://dnd.wizards.com/adventurers-league">Adventurers League</a> modules because they were quick and easy to run without much prep work.</p>
<p>My group continually astonished me. One time we were playing in the city of Mulmaster, a canonically horrible place, and to be perfectly honest, my group wasn’t loving it. The vibe was wrong. I had a suspicion something needed to change when to prep for a heist, one player got a job as a security guard and spent some time wondering why he would ever go back to the heist rather than keeping the steady paycheck and low-stress guard job. The heist went off well, but soon after they were having a hard time chasing a fire-worshipping cultist who was starting a conflagration when one player said “Fuck it. I think we should leave.” And then the group GOT ON A BOAT and LEFT THE CITY. I had to scramble a bit and ended the session early.</p>
<p>When they came back next time, I announced that some time had passed, they’d sailed to the city of Neverwinter and were looking at wanted posters with their faces — since they’d fled the scene of the crime, they’d been blamed for the terrible fire that burned a huge portion of Mulmaster. The group spent some time clearing their name, but eventually named themselves the Mulmaster County Volunteer Fire Department in a cheeky nod to the event.</p>
<p>One time, I decided that we’d done plenty of dungeons, but hadn’t actually met any dragons. I spent several games leading them towards a confrontation with a dragon. This was going to be a big fight, but I thought they could handle it. The story evolved organically. If a player said something funny or clever, I’d work it in.</p>
<p>By the time they arrived at the dragon’s lair, we’d established that the dragon’s crew of cultists had been kidnapping bards across the land, forcing them to learn new songs to convince more people to worship the dragon. The group broke in, freed the bards, and went to confront the dragon — who by this point had been established as an aspiring rapper. Then, rather than fight with weapons, they challenged the dragon to a rap battle.</p>
<p>There are no rules in D&D for a rap battle. Trust me, I looked.</p>
<p>I had to improvise a series of contested performance challenges. The group aced it. It wasn’t even close. In-game, they soundly defeated the dragon — then they offered to represent him as agents. From then on, my challenge as a DM was to find creative ways to allow them to “book a show” to summon the dragon that didn’t just amount to handing them the ability to call in an air strike. Eventually, the dragon figured out they weren’t getting him good gigs and fired them.</p>
<p>Over time, the group got a bit too raucous. One player collected trophies. Another thought that was a good idea, and started collecting mementos from defeated enemies. A branch from a treant. The mechanical arm from a robotic construct. Over time, without fully considering it, the running joke had become the rather grisly practice of peeling the faces of the dead.</p>
<p>One night, a new player joined our group. Someone we didn’t know in person, but I had met at a tech meetup. She started out enthusiastic to play with us and left the evening clearly horrified after watching the group burn an enemy alive, peel his face, and then commit a bit of light genocide against a group of modrons.</p>
<p>It was a wake-up call. I remember talking to Chuck about how upset she was and how awful I felt that she had a bad time. These were all just jokes that had started small but had somehow grown into horrifying proportions. Chuck helped me realize that my discomfort had more to do with myself and realizing that I wasn’t running the kind of game I wanted to run anymore. I don’t need my players to be exemplars of justice and moral fortitude — but somehow we’d stopped even trying to be the good guys.</p>
<p>Around this time Chuck also became a much bigger part of the story. He’d joined the group as a player a few times, but it never really clicked for him. But he loved hearing me tell him about what the group got up during the games, and acting as a sounding board for my ideas. Eventually, I realized that Chuck was more than just a silent partner, he’d become a co-DM, and I started referring to him that way. Some of the best ideas in the campaign came from riffing with Chuck, each of us helping to knock the sharp edges off rough new ideas and refine them down to something approaching a plan.</p>
<p>What came out of our conversation was an adventure my players fondly (I hope) refer to as “the morality arc.” I had them be kidnapped into a dystopian future by a version of the History Channel and had them compete in a reality show gladiator program forcing the worst criminals from across history to fight to the death. That’s how they learned that they were regarded by the future as notorious villains. (I came up with a mini-game to generate a Wikipedia page that I’m still quite proud of.) In the end, the players returned to their own timeline, with a little in-game nudge to try to leave the world better than they found it. (Don’t worry, I also had a frank conversation with them about what I wanted to keep the game fun moving forward, and they were all on board.)</p>
<p>What followed was me and Chuck creating a campaign structure where the group worked for a secret organization whose explicit aim was to make the world a better place. As operatives, they had a free hand to pursue this goal as they saw fit, but they would receive bonuses for non-lethal solutions.</p>
<p>That was November 2018. What followed was four years of a single campaign of mostly published one-off adventures united by an overarching plot involving the group they worked for, the big bad the group was opposed to, and a surprisingly complicated political backstory, mostly guided by what my players responded well to.</p>
<p>The finale of that campaign happened in February 2022. It played out over three sessions and resulted in finally paying off an idea I’d been seeding and teasing for literally years, a goddamn set of Voltron armor the group had to use to fight an elder god in the form of an ancient dragon. It was so much fun, and loaded with fan-service-y callbacks to earlier encounters and characters they’d met over the years.</p>
<p>We’ve since started a new campaign, that I’m having a lot of fun with, but I will never forget the time I spent with this group, especially the longest-running characters.</p>
<ul>
<li>Averlyth Cai, the drow cleric of Bane, who spent a great deal of time spreading the good word of a terrifying god, and who ended the campaign riding around in a pimped-out coffin on spider legs.</li>
<li>Kanye Cantaliber Esq., the half-orc battle master who never met a door he didn’t kick open, carried a scroll of pedigree and always announced himself before a fight (to give his opponent a chance to flee).</li>
<li>Nissa Atlock, the gnome bard who spent the vast majority of her time slinging insults from the safety of Kanye’s shoulders and looking for new libraries to spend time in.</li>
<li>Quinumum Ebraldeth, aka “Um,” the halfling rogue who acquired a set of slippers of spider climbing, and spent every fight from that point forward as a sniper hanging from the ceiling like a bat.</li>
<li>Sorith, the dwarf monk, whose love of brewing was matched only by his distaste for other dwarves.</li>
<li>And of course, Carlos Blöodfarte, the half-elf sorcerer whose unlucky roll on a wild magic surge chart left him with a very long neck for the rest of the campaign.</li>
</ul>
<p>I love them all like children and have threatened/promised my players to bring their characters back as NPCs in an adventure I may publish someday.</p>
<p>When we used to play in the office, my boss would knock on the door and teasingly ask “Who’s winning?” And I would always tell him: “Me! I’m winning, because I somehow convinced my friends to show up every other week to tell me stories and make me laugh.”</p>
TV Shows I Loved in 20232023-12-31T00:00:00Z2023-12-31T00:00:00Zhttps://spaceninja.com/blog/2023/tv-shows-i-loved-in-2023/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/strange-new-worlds-pSfClBsQ7n-1600w.jpeg">
<p>Tracking all the television I watch has to count as one of the nerdier things I do. It also results in horrifying statistics like discovering that in 2023, <a href="https://trakt.tv/users/spaceninja00/year/2023">I watched 420 hours of television across 48 shows</a>. But, you know, fuck it. I love good storytelling, and we’re living through a golden age of TV. Here’s some of the best stuff I watched this year:</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/extraordinary-wYxwKvuxdH-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/extraordinary-wYxwKvuxdH-300w.webp 300w, https://spaceninja.com/images/extraordinary-wYxwKvuxdH-400w.webp 400w, https://spaceninja.com/images/extraordinary-wYxwKvuxdH-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt14531842/"><em>Extraordinary</em></a></h2>
<p>This show has a really simple premise: What if everyone in the world got super powers except you? Building from that gives the actors here a lot of room to play. Jen, who has no powers and is really frustrated about it, lives with her best friend Carrie, who has the power to channel the dead, which she uses at a law firm to resolve boring legal estate disputes. Carrie’s boyfriend Kash has the ability to rewind time slightly and is obsessed with starting a vigilante group. Oh, and then there’s the stray cat they adopt and name Jizzlord, who turns out to be a grown man whose power is to turn into a cat, but he got stuck that way for years and lost his memory. It’s so stupid and funny and full of good music and great outfits and Annie and I loved every minute of it.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/poker-face-Rju7rohzJ2-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/poker-face-Rju7rohzJ2-300w.webp 300w, https://spaceninja.com/images/poker-face-Rju7rohzJ2-400w.webp 400w, https://spaceninja.com/images/poker-face-Rju7rohzJ2-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt14269590/"><em>Poker Face</em></a></h2>
<p>All I needed to hear was “Natasha Lyonne in a murder mystery of the week” and I was on-board. When I found out it was created by Ryan Johnson (yeah, the <em>Knives Out</em> guy), I was even more on board.</p>
<p>The essence of the show is that Lyonne’s character has the ability to tell when people are lying to her, and a complete inability to accept that. Her most common line is “bullshit” when someone lies to her, whether it’s appropriate or not. She’s on the run from the mob, so every episode involves her arriving in a new location and getting a shitty job that pays cash, and somehow getting wrapped up in a crime. She knows she should leave it alone, but she can’t just walk away. Seriously, it’s <em>so good</em>.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/scott-pilgrim-takes-off-zZT8TU_Ncb-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/scott-pilgrim-takes-off-zZT8TU_Ncb-300w.webp 300w, https://spaceninja.com/images/scott-pilgrim-takes-off-zZT8TU_Ncb-400w.webp 400w, https://spaceninja.com/images/scott-pilgrim-takes-off-zZT8TU_Ncb-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt16969708/"><em>Scott Pilgrim Takes Off</em></a></h2>
<p>You may have heard they made an anime remake of <em>Scott Pilgrim</em> will all the actors from the movie coming back to voice their characters. That’s charming and cute, but wasn’t enough to make me want to watch it until I found this out: Episode one closely tracks the movie with a few cute changes, ending with the fight between Scott and Matthew Patel, the first evil ex. But in the anime, Patel wins, shocking everyone. From that point forward, Ramona is the main character, and it’s an excellent way to revisit these characters with a bit more maturity and insight.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/strange-new-worlds-Cjl6i3dftY-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/strange-new-worlds-Cjl6i3dftY-300w.webp 300w, https://spaceninja.com/images/strange-new-worlds-Cjl6i3dftY-400w.webp 400w, https://spaceninja.com/images/strange-new-worlds-Cjl6i3dftY-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt12327578/"><em>Star Trek: Strange New Worlds</em></a></h2>
<p>Years ago, my brother Sean and I were complaining about the lack of good Star Trek and Star Wars. These were worlds we loved, and wanted to spend more time in. Why were they just putting out one movie every few years? Let’s have more! Let’s have TV shows and multiple movies across multiple styles and genres! Where’s my Star Wars detective show? What about a Star Trek high school drama set at Starfleet Academy? Give me more!</p>
<p>Well, the years have been kind to us and both Star Trek and Star Wars now have multiple shows and movies. Star Wars nailed the “more” part, but Star Trek nailed the “multiple styles” part. We have an embarrassment of riches now. <em>Picard</em> is for the hardcore old-school fans, full of deep lore and references. <em>Discovery</em> is the next main Trek show, following the bridge crew of a new ship having new adventures (especially starting in season three, when they made the excellent decision to throw the ship into the distant future, where they’re not so hamstrung by continuity). <em>Lower Decks</em> is loving satire and homage to <em>The Next Generation</em> era. <em>Prodigy</em> is young adult animation and <em>Voyager</em> homage.</p>
<p>But <em>Strange New Worlds</em> might be my favorite of them all. More than any other show has ever done, they’ve successfully threaded the needle of making a show with the <em>vibe</em> of the original series, without the baggage. They gleefully diverge from canon when it makes sense, while also filling the show with little moments that will make long-time fans squeal.</p>
<p>Season one features a Vulcan body-swap episode, romantic tension between Spock and nurse Chapel, and loads of exploration and delight at finding new things. They’ve got plenty of great callbacks to the original series (including some dedicated recreations of scenery, lighting, and music), while also updating everything to reflect changes in culture since the sixties.</p>
<p>I seriously can’t recommend it enough. You can come in blind without knowing anything about Star Trek, or watch it as a long-time fan. It’s just genuinely great, and I hope they make a load of seasons.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/andor-mmXlZrJ-PH-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/andor-mmXlZrJ-PH-300w.webp 300w, https://spaceninja.com/images/andor-mmXlZrJ-PH-400w.webp 400w, https://spaceninja.com/images/andor-mmXlZrJ-PH-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt9253284/"><em>Andor</em></a></h2>
<p>In the same vein, <em>Andor</em> manages to deliver a big-time story to the Star Wars universe. We originally met Cassian Andor as a hard-hearted spy in <em>Rogue One</em>, and this series explores his backstory. Along the way, we get to see the changes happening across the galaxy as the Empire becomes more and more fascist, and how slow the population is to react. Andor himself is only grudgingly pulled into the resistance, and the story gives enough room to breathe and let us slowly explore how one man becomes radicalized. It’s dark at times, and refreshingly adult compared to the main films’ simplistic “good will triumph” vibe. Excellent TV, and exactly what Sean and I wanted more of — let people tell more stories in this world.</p>
</div>
</li>
</ul>
Movies I Loved in 20232023-12-31T00:00:00Z2023-12-31T00:00:00Zhttps://spaceninja.com/blog/2023/movies-i-loved-in-2023/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/jawan-EvEuPy0ALP-1600w.jpeg">
<p>What a great year for movies. I had trouble whittling this list down to the best. <a href="https://letterboxd.com/spaceninja/year/2023/">I watched 150 movies this year</a> and I’d say more than usual were fantastic. Here are some of the best.</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/baby-assassins-gDYUyTZfes-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/baby-assassins-gDYUyTZfes-300w.webp 300w, https://spaceninja.com/images/baby-assassins-gDYUyTZfes-400w.webp 400w, https://spaceninja.com/images/baby-assassins-gDYUyTZfes-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt15028452/"><em>Baby Assassins</em></a></h2>
<blockquote>
<p>Chisato and Mahilo are two high school girls who are about to graduate. They also happen to both be highly skilled assassins.</p>
</blockquote>
<p>For reasons, the organization these girls work for forces them to become roommates and get jobs. While struggling to adapt to the real world, they piss off the Yakuza and are drawn into a whirlwind of violence. It’s a great female friendship movie, with a bit of an odd couple vibe, and a surprising amount of humor, especially when they both try to get jobs at a maid cafe. Ollie and I watched this together and had a great time.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/the-banshees-of-inisherin-vuZrT6GeW7-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/the-banshees-of-inisherin-vuZrT6GeW7-300w.webp 300w, https://spaceninja.com/images/the-banshees-of-inisherin-vuZrT6GeW7-400w.webp 400w, https://spaceninja.com/images/the-banshees-of-inisherin-vuZrT6GeW7-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt11813216/"><em>The Banshees of Inisherin</em></a></h2>
<blockquote>
<p>Two lifelong friends find themselves at an impasse when one abruptly ends their relationship, with alarming consequences for both of them.</p>
</blockquote>
<p>It’s a great absurd premise: A man attempts to cut off all contact with his dull lifelong friend so he can focus on music, to the great confusion of his friend and the surrounding village. Colin Farrell and Brendan Gleeson are always a joy to watch, and Farrell’s constant frustration and confusion in the face of Gleeson’s steadfast rejection is perfect. Early on. Gleeson attempts to communicate how serious he is by threatening to cut off one of his own fingers every time Farrell talks to him. Mild spoiler warning: He is forced to follow through on this grim threat repeatedly. All told, it was an expertly crafted dark comedy, and I do recommend it, but be aware it gets a bit heavier than the trailer implies.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/el-conde-BMbD1nsrsz-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/el-conde-BMbD1nsrsz-300w.webp 300w, https://spaceninja.com/images/el-conde-BMbD1nsrsz-400w.webp 400w, https://spaceninja.com/images/el-conde-BMbD1nsrsz-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt21113540/"><em>El Conde</em></a></h2>
<blockquote>
<p>After living 250 years in this world, Augusto Pinochet, who is not dead but an aged vampire, decides to die once and for all.</p>
</blockquote>
<p>Sometimes a movie comes with an elevator pitch that so perfectly absurd that you know you’re going to watch it. In this case, the dictator Augusto Pinochet is a 250-year old vampire, and is ready to die. I mean… come on. The film is at turns great and clumsy. It’s not perfect, but I have so much respect for a movie that swings for the fences like this. I greatly enjoyed it. There’s a bit of overlap with <em>Succession</em>, in the sense that a powerful man is stepping down, and his family and entourage are circling, trying to figure out how to work the situation to their advantage. Anyways, it’s strange and unique, and worth your time.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/jawan-9z-UhuaiEn-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/jawan-9z-UhuaiEn-300w.webp 300w, https://spaceninja.com/images/jawan-9z-UhuaiEn-400w.webp 400w, https://spaceninja.com/images/jawan-9z-UhuaiEn-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt15354916/"><em>Jawan</em></a></h2>
<blockquote>
<p>A high-octane action thriller which outlines the emotional journey of a man who is set to rectify the wrongs in the society.</p>
</blockquote>
<p>Oliver and I have started watching big-budget Bollywood movies together. I love the fight scenes and dance scenes and the moments of absurdity that come from cross-cultural entertainment. Ollie loves the constant gay subtext. Male characters (and especially Shak Rukh Khan) are extremely proper around female characters, even the supposed love interest, while being overtly intimate with male companions. No doubt this is just a culture clash thing, but every time SRK kisses a male friend, Ollie can’t contain themselves.</p>
<p>This particular SRK movie is basically the plot of <em>Money Heist</em>, but Bollywood. SRK is the leader of a gang of women who commit over-the-top heists aimed at exposing government corruption to the masses. This Robin Hood story has some rough moments that felt a bit forced, but overall landed well, and the mid-movie reveal that SRK is playing two characters was great, and let him get some moments of absurd comedy during the climactic confrontation.</p>
<p>Also, there is a mafia boss who wears a Bane-style mask for no reason and is never explained or heard from again, and I love it.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/leave-the-world-behind-dk7pU8xFLf-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/leave-the-world-behind-dk7pU8xFLf-300w.webp 300w, https://spaceninja.com/images/leave-the-world-behind-dk7pU8xFLf-400w.webp 400w, https://spaceninja.com/images/leave-the-world-behind-dk7pU8xFLf-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt12747748/"><em>Leave the World Behind</em></a></h2>
<blockquote>
<p>A family's getaway to a luxurious rental home takes an ominous turn when a cyberattack knocks out their devices—and two strangers appear at their door.</p>
</blockquote>
<p>A late entry delivered by Netflix, this is the best end of the world movie I’ve ever seen reflecting the character’s confusion in the absence of clear information. It’s arguably a horror film, but there are no jump scares here, only constantly ratcheting tension as a group of people stranded in a remote house try to figure out what the hell is going on. Julia Roberts has great fun playing against type as a fairly awful and moderately racist mother. Ethan Hawke plays a perfect confused and overwhelmed dad. Mahershala Ali’s restrained efforts to keep the white people from freaking out is balanced by his daughter Myha’la Herrold’s bluntness. I was surprised by how much I liked it, and I ended up watching it again a few nights later with my brother-in-law, and appreciated it even more the second time.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/prey-yTwTWX3C7Y-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/prey-yTwTWX3C7Y-300w.webp 300w, https://spaceninja.com/images/prey-yTwTWX3C7Y-400w.webp 400w, https://spaceninja.com/images/prey-yTwTWX3C7Y-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt11866324/"><em>Prey</em></a></h2>
<blockquote>
<p>Naru, a skilled warrior of the Comanche Nation, fights to protect her tribe against one of the first highly-evolved Predators to land on Earth.</p>
</blockquote>
<p>Now, if I told you that they made a prequel to <em>Predator</em> involving an alien hunting native Americans you might be rightly suspicious that the film would be crap. But it’s not. It’s incredible, with great acting, phenomenal visuals, and a solid plot. They cast native actors, they don’t talk down to the audience, and deliver a great coming-of-age story that happens to involve hunting (and being hunted by) a terrifying monster from the sky. Ignore whatever you think about the Predator franchise and just make time to watch this as soon as possible.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/sisu-XDGnbAhDIh-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/sisu-XDGnbAhDIh-300w.webp 300w, https://spaceninja.com/images/sisu-XDGnbAhDIh-400w.webp 400w, https://spaceninja.com/images/sisu-XDGnbAhDIh-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt14846026/"><em>Sisu</em></a></h2>
<blockquote>
<p>When an ex-soldier who discovers gold in the Lapland wilderness tries to take the loot into the city, Nazi soldiers led by a brutal SS officer battle him.</p>
</blockquote>
<p>A simple John-Wick style “they crossed the wrong dude” movie, but done to absolute perfection. A group of Nazis are retreating through Lapland, and run across an old miner taking his gold into town. They attack him to steal the gold, and it goes extremely badly for them. The trailer features a scene where the Nazis have driven the old man into the fog in a minefield. The Nazi commander orders one of his soldiers forward when a landmine comes flying through the air, hitting the soldier’s helmet and exploding him. At one point, the old man lights himself on fire for seemingly no reason other than to terrify the Nazis. Highly recommended.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/they-cloned-tyrone-Ks5XD6DSNp-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/they-cloned-tyrone-Ks5XD6DSNp-300w.webp 300w, https://spaceninja.com/images/they-cloned-tyrone-Ks5XD6DSNp-400w.webp 400w, https://spaceninja.com/images/they-cloned-tyrone-Ks5XD6DSNp-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt9873892/"><em>They Cloned Tyrone</em></a></h2>
<blockquote>
<p>A series of eerie events thrusts an unlikely trio onto the trail of a nefarious government conspiracy lurking directly beneath their neighborhood.</p>
</blockquote>
<p>John Boyega is a drug dealer in a poor neighborhood called the Glen. Jamie Foxx sees him shot to death one night, and is shocked when he shows up alive the next day. Together with Teyonah Parris, they uncover a bizarre government conspiracy. I don’t want to say much else for fear of spoiling it, but just trust me, it’s a barrel ride of a film, and you should watch it.</p>
</div>
</li>
</ul>
Books I Loved in 20232023-12-30T00:00:00Z2023-12-30T00:00:00Zhttps://spaceninja.com/blog/2023/books-i-loved-in-2023/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/books-2023-HnGcatdKbs-1600w.jpeg">
<p><a href="https://www.goodreads.com/user_challenges/40607145">I read 25 books in 2023</a>. My goal was 24, which I’ve hit every year for a few years now, but I have a secret: A good chunk of the books I was reading were actually books I was reading to my son John. We read <em>Lord of the Rings</em>, some <em>Harry Potter</em>, some <em>Hitchhiker’s Guide</em>, and some <em>Discworld</em> books. But about a year ago, he kinda grew out of this phase and didn’t want me to read to him at bedtime anymore. Which is fine, I love that he let me for so long and that I was able to pass along a love of reading.</p>
<p>But it did mean that I lost a pile of books that I would have read otherwise, and I had less reading time overall, because I wasn’t sitting in his room reading my own books while he fell asleep. So the fact that I still hit my goal feels good, but it does tell me that in 2024, I need to be more intentional about carving out time to read.</p>
<p>Until then, here are the best books I read in 2023.</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/last-one-at-the-party-ukziN1ZqCj-300w.webp" width="500" height="764" srcset="https://spaceninja.com/images/last-one-at-the-party-ukziN1ZqCj-300w.webp 300w, https://spaceninja.com/images/last-one-at-the-party-ukziN1ZqCj-400w.webp 400w, https://spaceninja.com/images/last-one-at-the-party-ukziN1ZqCj-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/60763072-last-one-at-the-party"><em>Last One at the Party</em></a>, by Bethany Clift</h2>
<p>The basic premise is “What if the person who survived the end of the world was uniquely unsuited for post-apocalyptic life?”</p>
<p>Presented as a series of journal entries documenting how she deals with the complete collapse of the world (hint: poorly, then VERY poorly, then less poorly). It’s funny and charming at times, depressing and overwhelming at others, and generally does a good job of feeling like a realistic look at how someone with no survival skills would handle things.</p>
<p>I liked this so much that I recommended it to my wife, which happens infrequently enough to be noteworthy.</p>
<small>
<p><em>Content warnings: This book features a dog and a pregnancy, and I’ve added <a href="https://www.doesthedogdie.com/media/1037847">an entry on DoesTheDogDie.com</a> if you'd find that helpful.</em></p>
</small>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/half-built-garden-NDDu20zC-y-300w.webp" width="500" height="767" srcset="https://spaceninja.com/images/half-built-garden-NDDu20zC-y-300w.webp 300w, https://spaceninja.com/images/half-built-garden-NDDu20zC-y-400w.webp 400w, https://spaceninja.com/images/half-built-garden-NDDu20zC-y-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/41637112-a-half-built-garden"><em>A Half-Built Garden</em></a>, by Ruthanna Emrys</h2>
<p>A fascinating first-contact story about aliens landing in the Chesapeake Bay, where they are met by Judy, a woman from an ecological commune called a watershed network. The aliens are here to encourage (or force) humanity to flee the planet, but Judy isn’t ready to abandon the restoration work they’ve been doing — or allow the exiled remnants of corporations and nation-states to make the decision for everyone else.</p>
<p>It’s the best kind of hopeful queer science fiction. I loved every part of this story, and I bet you will too.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/expanse9-MK4KgD8fba-300w.webp" width="500" height="771" srcset="https://spaceninja.com/images/expanse9-MK4KgD8fba-300w.webp 300w, https://spaceninja.com/images/expanse9-MK4KgD8fba-400w.webp 400w, https://spaceninja.com/images/expanse9-MK4KgD8fba-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/57397125-leviathan-falls"><em>Leviathan Falls</em></a>, by James S.A. Corey</h2>
<p>I’ve told you before how much <a href="https://spaceninja.com/blog/2018/books-i-love-the-expanse-series/">I love the Expanse series</a>, but I put off reading the final books in the series for some time. The TV series caught up to me when I finished book five, and I opted to watch season six without reading the book to avoid spoilers.</p>
<p>So now that the show is over (or at least on hiatus for a time?), I came back to the books. Book six was a quick read, covering the same events as the show (though the shortened final season had to cut quite a lot of stuff). But book seven jumps several years into the future, following the crew of the Rocinante as they deal with the complex reality after the opening of the gate network.</p>
<p>I can’t really say much about it without spoiling anything, so I’ll just say that I thought this was a solid ending to the story. The final scenes with each character felt genuine and earned. If you’ve watched the show or read the books you know enough to not expect a happy-ever-after, but everyone ends up somewhere satisfyingly true to their characters.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/stolen-focus-1u7TEmj5rb-300w.webp" width="500" height="760" srcset="https://spaceninja.com/images/stolen-focus-1u7TEmj5rb-300w.webp 300w, https://spaceninja.com/images/stolen-focus-1u7TEmj5rb-400w.webp 400w, https://spaceninja.com/images/stolen-focus-1u7TEmj5rb-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/57933306-stolen-focus"><em>Stolen Focus: Why You Can’t Pay Attention—and How to Think Deeply Again</em></a>, by Johann Hari</h2>
<p>I’ll be honest: The first two times Chuck recommended this book to me, I kinda shrugged him off. It sounded like a self-help book, where the author would dive into a lot of stuff I already know about how social network algorithms are designed to keep us clicking, and ending with some trite advice that boils down to “take Twitter off your phone” and “have more self-control.”</p>
<p>But the thing about Chuck is that he’s got ADHD so sometimes he’ll recommend something to me repeatedly by mistake. In this case, in three different conversations, weeks apart, something I said about burnout or focus triggered him to recommend it. And by the third time, I realized I needed to check it out.</p>
<p>That said, I very nearly rage-quit this book. The first few chapters are some of the most insufferable shit I’ve ever read. He shares an infuriating anecdote about harassing visitors to Graceland and then snatching the phone out of his teenage nephew’s hand and yelling at him in public, all with a smug “am I the only one who sees what’s happening” vibe. After that, he talks in the most eye-rolling way about the difficulties he faced during a three-month digital detox while staying in a cabin on the beach.</p>
<p>But one night as I was angrily complaining about the book to Annie, she asked if there was anything valuable, and I was forced to concede that the chapter I’d just read had an interesting bit. I asked her to pass me a highlighter, and I marked it down so I wouldn’t lose it. Then I went back to the chapters I’d already finished and I found at least one or two valuable passages to highlight in each. So I resolved to keep reading, highlighting the worthwhile bits as I went.</p>
<p>And I’m glad I did, because the book makes a pretty remarkable shift about halfway through, moving away from “here’s what I learned on my digital detox” with an emphasis on individual actions into a strongly condemning discussion about the systemic problems that we all face that mean individual actions are unlikely to address attention problems for most people.</p>
<p>From that point, the book is really about the author getting slowly radicalized by how our attention has been stolen from us, and discussing the broad changes that we need to make, from outlawing surveillance capitalism to addressing nutrition, to the way that children are no longer allowed to play or have unstructured time.</p>
<p>I thought this was a self-help book, but it’s not. It’s a call to action, and I think everyone should read it.</p>
</div>
</li>
</ul>
Friday Front-End’s Top Links of 20232023-12-15T00:00:00Z2023-12-15T00:00:00Zhttps://spaceninja.com/blog/2023/ff-top-ten-2023/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/feature-EWj6KDf4cH-1600w.jpeg">
<p>In 2023, <a href="https://fridayfrontend.com/">Friday Front-End</a> shared a curated list of five articles and one video every week. Here are the links that were most popular:</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://www.leereamsnyder.com/web-component-and-somehow-also-js-101"><img alt="Messin’ around with web components. Also—JavaScript, generally" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-450w.webp" width="1025" height="538" srcset="https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-450w.webp 450w, https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-700w.webp 700w, https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-825w.webp 825w, https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-975w.webp 975w, https://spaceninja.com/images/web-component-and-somehow-also-js-101-5U96fJSbOA-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>10: <a href="https://www.leereamsnyder.com/web-component-and-somehow-also-js-101">Messin’ around with web components. Also—JavaScript, generally</a></h2>
<blockquote>
<p>"I think I have to use the constructor() here since I’m setting this. But also there are no good blog posts out there explaining any of this stuff and so I challenge you, nay dare you, to really explain all this to me."</p>
<p>Ha, challenge accepted.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://chriscoyier.net/2023/11/27/the-hanging-punctuation-property-in-css/"><img alt="The `hanging-punctuation` property in CSS" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-450w.webp" width="1025" height="524" srcset="https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-450w.webp 450w, https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-700w.webp 700w, https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-825w.webp 825w, https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-975w.webp 975w, https://spaceninja.com/images/hanging-punctuation-R4mcSAwA-s-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>9: <a href="https://chriscoyier.net/2023/11/27/the-hanging-punctuation-property-in-css/">The <code>hanging-punctuation</code> property in CSS</a></h2>
<blockquote>
<p>The <code>hanging-punctuation</code> property in CSS is almost a no-brainer. The classic example is a blockquote that starts with a curly-quote. Hanging that opening curly-quote into the space off to the start of the text and aligning the actual words is a better look.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://tylergaw.com/blog/complex-mpa-view-transitions/"><img alt="Complex MPA View Transitions" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-450w.webp" width="1025" height="580" srcset="https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-450w.webp 450w, https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-700w.webp 700w, https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-825w.webp 825w, https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-975w.webp 975w, https://spaceninja.com/images/complex-mpa-view-transitions-M3AErZG0F9-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>8: <a href="https://tylergaw.com/blog/complex-mpa-view-transitions/">Complex MPA View Transitions</a></h2>
<blockquote>
<p>Full page transitions are OK, but they also make a site feel like a PowerPoint. The only time I’m likely to use full page transitions is if I’m making a web-based slide deck. Even then, using only full page animations can feel flat. Animating multiple elements in an orchestrated way is a better way to use motion to create interesting effects. So, that’s what I’ve done. Let’s get into it.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://heather-buchel.com/blog/2023/10/why-your-web-design-sucks/"><img alt="It’s 2023, here is why your web design sucks." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/here-is-why-your-design-sucks-ywC_ivlzcS-450w.webp" width="450" height="236"></a></p>
</div>
<div class="media-list__content">
<h2>7: <a href="https://heather-buchel.com/blog/2023/10/why-your-web-design-sucks/">It’s 2023, here is why your web design sucks.</a></h2>
<blockquote>
<p>Exploring the reasons why we no longer have web designers. TLDR: At some point, we told design they couldn't sit with us anymore, and surprise! It backfired! Now, not only has the field and profession of web design suffered, but also, we build shitty websites.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://adactio.com/journal/20618"><img alt="HTML Web Components" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-450w.webp" width="1600" height="800" srcset="https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-450w.webp 450w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-700w.webp 700w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-825w.webp 825w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-975w.webp 975w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-1025w.webp 1025w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-1280w.webp 1280w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-1440w.webp 1440w, https://spaceninja.com/images/html-web-components-adactio-yIr71Xzgfw-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>6: <a href="https://adactio.com/journal/20618">HTML Web Components</a></h2>
<blockquote>
<p>When you wrap some existing markup in a custom element and then apply some new behaviour with JavaScript, technically you’re not doing anything you couldn’t have done before with some DOM traversal and event handling. But it’s less fragile to do it with a web component. It’s portable. It obeys the single responsibility principle. It only does one thing but it does it well.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://utilitybend.com/blog/elevate-your-css-debugging-skills-with-these-chrome-devtools-tricks-in-2024/"><img alt="Elevate your CSS debugging skills with these Chrome DevTools tricks in 2024" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-450w.webp" width="1280" height="720" srcset="https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-450w.webp 450w, https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-700w.webp 700w, https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-825w.webp 825w, https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-975w.webp 975w, https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-1025w.webp 1025w, https://spaceninja.com/images/chrome-devtools-3tnWhDXnZ5-1280w.webp 1280w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>5: <a href="https://utilitybend.com/blog/elevate-your-css-debugging-skills-with-these-chrome-devtools-tricks-in-2024/">Elevate your CSS debugging skills with these Chrome DevTools tricks in 2024</a></h2>
<blockquote>
<p>Elevate your CSS debugging skills with these powerful Chrome DevTools tricks. Learn how to tackle layers, specificity, nesting, HD color, and scroll animations.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://cloudfour.com/thinks/html-web-components-are-having-a-moment/"><img alt="HTML Web Components Are Having a Moment" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/html-web-components-XGMwzq29Dh-450w.webp" width="1025" height="576" srcset="https://spaceninja.com/images/html-web-components-XGMwzq29Dh-450w.webp 450w, https://spaceninja.com/images/html-web-components-XGMwzq29Dh-700w.webp 700w, https://spaceninja.com/images/html-web-components-XGMwzq29Dh-825w.webp 825w, https://spaceninja.com/images/html-web-components-XGMwzq29Dh-975w.webp 975w, https://spaceninja.com/images/html-web-components-XGMwzq29Dh-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>4: <a href="https://cloudfour.com/thinks/html-web-components-are-having-a-moment/">HTML Web Components Are Having a Moment</a></h2>
<blockquote>
<p>Eric’s post showed me that web components could be simpler than I thought. Jeremy came up with a brilliantly approachable name with “HTML web components.” And everyone else who wrote about them in the following weeks added to the growing sense that, yes, this was a thing that I could do, that had clear value, and I began looking for opportunities to use them.</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://poke-holo.simey.me/"><img alt="Pokémon Cards CSS Holographic Effect" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/pokemon-l7E7ppI6X_-298w.webp" width="298" height="199"></a></p>
</div>
<div class="media-list__content">
<h2>3: <a href="https://poke-holo.simey.me/">Pokémon Cards CSS Holographic Effect</a></h2>
<blockquote>
<p>A collection of advanced CSS styles to create realistic-looking effects for the faces of Pokemon cards. The cards use 3d transforms, filters, blend modes, css gradients and interactions to provide a unique experience when taking a closer look!</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://css-tricks.com/solved-with-has-vertical-spacing-in-long-form-text/"><img alt="Solved With :has(): Vertical Spacing in Long-Form Text" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/solved-with-has-WyCCB_egJY-450w.webp" width="1025" height="538" srcset="https://spaceninja.com/images/solved-with-has-WyCCB_egJY-450w.webp 450w, https://spaceninja.com/images/solved-with-has-WyCCB_egJY-700w.webp 700w, https://spaceninja.com/images/solved-with-has-WyCCB_egJY-825w.webp 825w, https://spaceninja.com/images/solved-with-has-WyCCB_egJY-975w.webp 975w, https://spaceninja.com/images/solved-with-has-WyCCB_egJY-1025w.webp 1025w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>2: <a href="https://css-tricks.com/solved-with-has-vertical-spacing-in-long-form-text/">Solved With :has(): Vertical Spacing in Long-Form Text</a></h2>
<blockquote>
<p>To recap the improvements this aims to make: No wrapper class is required; We’re working with a consistent margin direction; Collapsing margins are avoided (which may or may not be an improvement, depending on your stance); There’s no setting styles and then immediately overriding them;</p>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://dev.to/francescovetere/the-css-property-you-didnt-know-you-needed-3fk0"><img alt="The CSS property you didn’t know you needed" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/the-css-property-you-didnt-know-you-needed-nylXqhdzsu-450w.webp" width="975" height="409" srcset="https://spaceninja.com/images/the-css-property-you-didnt-know-you-needed-nylXqhdzsu-450w.webp 450w, https://spaceninja.com/images/the-css-property-you-didnt-know-you-needed-nylXqhdzsu-700w.webp 700w, https://spaceninja.com/images/the-css-property-you-didnt-know-you-needed-nylXqhdzsu-825w.webp 825w, https://spaceninja.com/images/the-css-property-you-didnt-know-you-needed-nylXqhdzsu-975w.webp 975w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2>1: <a href="https://dev.to/francescovetere/the-css-property-you-didnt-know-you-needed-3fk0">The CSS property you didn’t know you needed</a></h2>
<blockquote>
<p>Today I wanna talk about a CSS feature that doesn't get too much attention… but it should! The <code>isolation</code> property basically provides more control over how elements interact with the rest of the document, and it is often an elegant solution for <code>z-index</code> issues.</p>
</blockquote>
</div>
</li>
</ul>
<p>And here's the most popular video of 2023:</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<p><a href="https://www.youtube.com/watch?v=_2LwjfYc1x8"><img alt="Using CSS custom properties like this is a waste" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/custom-properties-P_B-h4_wSV-450w.webp" width="1280" height="720" srcset="https://spaceninja.com/images/custom-properties-P_B-h4_wSV-450w.webp 450w, https://spaceninja.com/images/custom-properties-P_B-h4_wSV-700w.webp 700w, https://spaceninja.com/images/custom-properties-P_B-h4_wSV-825w.webp 825w, https://spaceninja.com/images/custom-properties-P_B-h4_wSV-975w.webp 975w, https://spaceninja.com/images/custom-properties-P_B-h4_wSV-1025w.webp 1025w, https://spaceninja.com/images/custom-properties-P_B-h4_wSV-1280w.webp 1280w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw"></a></p>
</div>
<div class="media-list__content">
<h2><a href="https://www.youtube.com/watch?v=_2LwjfYc1x8">Using CSS custom properties like this is a waste</a></h2>
<blockquote>
<p>Custom properties are amazing, but a lot of people don’t take advantage of how awesome they are. They set them up in the <code>:root</code> and that’s it, but they can be so much more useful than that! So, in this video I explore how we take them up a notch and make our code a lot more efficient in the process.</p>
</blockquote>
</div>
</li>
</ul>
<p>Want to enjoy more great links like this in 2024? <a href="https://fridayfrontend.com/">Subscribe to the Friday Front-End newsletter</a>, or <a href="https://hachyderm.io/@fridayfrontend">follow @fridayfrontend on Mastodon</a>!</p>
HTML Web Components Are Having a Moment2023-11-29T00:00:00Z2023-11-29T00:00:00Zhttps://spaceninja.com/blog/2023/html-web-components-are-having-a-moment/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/html-web-components-nPUpuesHN7-1600w.jpeg">
<p>One nice thing about running a <a href="https://fridayfrontend.com">front-end newsletter</a> is that you get to keep a sort of birds-eye view of what’s happening across the industry as a whole. A common pattern I see is the people who live on the bleeding edge of what’s possible, like <a href="https://front-end.social/@rachelandrew">Rachel Andrew</a>, <a href="https://front-end.social/@Una">Una Kravets</a>, <a href="https://front-end.social/@jensimmons">Jen Simmons</a>, and <a href="https://front-end.social/@mia">Miriam Suzanne</a>, tend to write about exciting new things that then get shared around the community and see gradual adoption over time.</p>
<p>A less common pattern is to see a technology that everyone agrees is great, but struggles to catch on until someone writes something that manages to crystalize the pitch in a way that gets widespread adoption. These inflection points can be really exciting, seeing an idea sweep across the community like wildfire. A good example is <a href="https://follow.ethanmarcotte.com/@beep">Ethan Marcotte</a>’s original <a href="https://alistapart.com/article/responsive-web-design/">Responsive Web Design</a> article from 2010. All the pieces already existed, but Ethan put them together in a way that clicked for people and attached the name that came to define a generational shift in web design.</p>
<p>Well, I think one of those moments is happening right now! If you’ve missed the buzz around “HTML Web Components,” then consider this a nudge from me to check out any of these excellent articles that have appeared on the topic in the last few weeks:</p>
<ul>
<li><a href="https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/">Blinded by the Light DOM</a> by Eric Meyer</li>
<li><a href="https://adactio.com/journal/20618">HTML Web Components</a> by Jeremy Keith</li>
<li><a href="https://buttondown.email/cascade/archive/006-shadow-dom-is-not-a-good-default/">“Shadow DOM is not a good default”</a> by Robin Rendle</li>
<li><a href="https://blog.jim-nielsen.com/2023/html-web-components/">HTML Web Components</a> by Jim Nielsen</li>
<li><a href="https://blog.jim-nielsen.com/2023/html-web-components-an-example/">HTML Web Components: An Example</a> by Jim Nielsen</li>
<li><a href="https://www.oddbird.net/2023/11/17/components/">HTML Web Components are Just JavaScript?</a> By Miriam Suzanne</li>
<li><a href="https://www.zachleat.com/web/a-taxonomy-of-web-component-types/">An Attempted Taxonomy of Web Components</a> by Zach Leatherman</li>
<li><a href="https://www.leereamsnyder.com/web-component-and-somehow-also-js-101">Messin’ Around With Web Components</a> by Lee Reamsnyder</li>
<li><a href="https://gomakethings.com/the-elevator-pitch-for-web-components/">The Elevator Pitch for Web Components</a> by Chris Ferdinandi</li>
</ul>
<p>I don’t know about you, but I read every one of those articles, and for the first time, web components “clicked” for me. Suddenly, I understood how they could fit into our workflow, and where they’d be a good addition. I was excited about web components in a way I’d never been before.</p>
<p>Of course, as <a href="https://front-end.social/@grigs/111490293915212046">Jason pointed out</a>, web components are hardly new, and people have been writing about them for some time (<a href="https://cloudfour.com/topics/web-components/">including us here at Cloud Four!</a>). Here are just a few examples from my <a href="https://pinboard.in/u:spaceninja/t:webcomponents/">web component bookmarks</a>, which date back to 2018:</p>
<ul>
<li><a href="https://css-tricks.com/web-components-are-easier-than-you-think/">Web Components Are Easier Than You Think</a> by John Rhea</li>
<li><a href="https://cloudfour.com/thinks/mighty-morphin-web-components/">Mighty Morphin’ Web Components</a> by Tyler Sticka</li>
<li><a href="https://cloudfour.com/thinks/building-an-accessible-image-comparison-web-component/">Building an Accessible Image Comparison Web Component</a> by Paul Hebert</li>
<li><a href="https://cloudfour.com/thinks/web-components-as-progressive-enhancement/">Web Components as Progressive Enhancement</a> by Paul Hebert</li>
<li><a href="https://htmlwithsuperpowers.netlify.app">HTML With Superpowers</a> by Dave Rupert</li>
<li><a href="https://bradfrost.com/blog/post/lets-talk-about-web-components/">Let’s Talk About Web Components</a> by Brad Frost</li>
</ul>
<p>I’m embarrassed to admit that I read all of those articles, but didn’t really get the appeal at the time. My colleague <a href="https://cloudfour.com/is/paul/">Paul</a>’s posts are probably the best example: I read them, thought they were brilliant, looked at the code for his image comparison component, and went “Wow, great stuff.” He even described what we now call HTML Web Components in everything but name. But it didn’t <em>click</em>. I didn’t feel like it was something I could do. Until recently, my experience with web components was best summed up by Lea Verou’s article <a href="https://lea.verou.me/blog/2020/09/the-failed-promise-of-web-components/">The Failed Promise of Web Components</a>, where she pointed out the dependency on JavaScript and the complexity of web components as a deterrent.</p>
<p>Eric mentions the complexity of the shadow DOM as a barrier to entry in <a href="https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/">Blinded by the Light DOM</a>, but his article was the first time I really grasped that all the shadow DOM stuff was optional, and the JavaScript setup that had seemed so overwhelming in the past, suddenly seemed quite approachable.</p>
<p>And it’s clearly not just me. Eric’s post showed me that web components could be simpler than I thought. Jeremy came up with a brilliantly approachable name with “HTML web components.” And everyone else who wrote about them in the following weeks added to the growing sense that, yes, this was a thing that I could do, that had clear value, and I began looking for opportunities to use them.</p>
<p>It didn’t take long. <a href="https://cloudfour.com/is/jason-grigsby/">Jason</a>’s been tinkering with 3D models for AR applications and asked me to find a way to lazy load <a href="https://modelviewer.dev/">model-viewer</a> widgets so someone reading a blog post wouldn’t need to download a multiple-megabyte file on page load. It was an excellent opportunity to use a web component to enhance the behavior, and with a bit of assistance from <a href="https://cloudfour.com/is/tyler/">Tyler</a>, we produced <a href="https://lite-model-viewer.netlify.app/">Lite Model-Viewer</a>. It’s not the most groundbreaking thing, and it’s an incredibly specific use case. But it was easy, fun, and a great way to get some hands-on experience.</p>
<p>I suspect that in a few years’ time, we’ll look back at this month, and especially Jeremy and Eric’s articles as an inflection point. Similar to how Ethan managed to make responsive web design accessible to more people, I think we’re looking at the same thing happening right now for web components.</p>
Surprising Facts About New CSS Selectors2023-11-07T00:00:00Z2023-11-07T00:00:00Zhttps://spaceninja.com/blog/2023/surprising-facts-about-new-css-selectors/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/new-selectors-vb0eJSS2hb-1600w.jpeg">
<p>I went down a bit of a rabbit-hole recently learning about <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting">CSS Nesting</a>, specifically regarding the new <code>&</code> selector. I heard that it behaves like the <code>:is()</code> selector, and in the course of researching, I learned a lot about how these new selectors work.</p>
<p>The first thing to know is that <code>is()</code> and its siblings <code>:not()</code>, <code>:has()</code>, and <code>:where()</code> are a new<sup class="footnote-ref"><a href="https://spaceninja.com/blog/2023/surprising-facts-about-new-css-selectors/" id="fnref1">1</a></sup> type of selector called <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes#functional_pseudo-classes">functional pseudo-classes</a>. We’ve had simple pseudo-classes like <code>:hover</code> for a long time, but the ability to pass a selector list as a parameter is what distinguishes these four into a new category.</p>
<p>Here’s a quick review of the new selectors we’ll be discussing today, and then we’ll dive into the surprising things I learned:</p>
<ul>
<li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:is">Matches-Any Pseudo-class</a>, <code>:is()</code>, accepts a comma-separated list of selectors and matches any element that can be selected by one of the items in the list. For example, <code>:is(article, section, aside) h1</code> will match any <code>h1</code> element that is contained in an <code>article</code>, <code>section</code>, or <code>aside</code> element.</li>
<li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:not">Negation (or Matches-None) Pseudo-class</a>, <code>:not()</code>, represents elements that do not match a list of selectors. For example, <code>li:not(:last-child)</code> will select any list item that is not the last item in the list.</li>
<li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has">Relational Pseudo-class</a>, <code>:has()</code>, is a way to select a parent element based on its children or siblings. For example, <code>h1:has(+ p)</code> will only apply to <code>h1</code> elements that are immediately followed by a <code>p</code> element.</li>
<li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:where">Specificity-adjustment Pseudo-class</a>, <code>:where()</code>, behaves like the <code>:is()</code> selector, but its specificity is always zero.</li>
<li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Nesting_selector">Nesting Selector</a>, <code>&</code>, represents the elements matched by the parent rule in CSS Nesting, and behind the scenes, it’s treated as an <code>:is()</code> selector.</li>
</ul>
<h2>Specificity</h2>
<p>One of the most interesting properties of these new selectors is how they interact with specificity. A deep dive on specificity is beyond the scope of this article, but it’s enough to understand that an ID is more specific than a class, which is more specific than an element selector.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">#unique</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.intro</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> orange <span class="token punctuation">}</span>
<span class="token selector">p</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<!-- prettier-ignore -->
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>p</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>intro<span class="token punctuation">"</span></span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>unique<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>This will be red<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>p</span><span class="token punctuation">></span></span></code></pre>
<p>The paragraph will be red, because the ID selector is the most specific.</p>
<p>So what happens with a pseudo-class that accepts a comma-separated list of selectors, like <code>:is()</code>, <code>:not()</code>, and <code>:has()</code>? The specificity of the pseudo-class is determined by the most specific selector in the list.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:is(#unique, p)</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.intro</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<!-- prettier-ignore -->
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>p</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>intro<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>This will also be red<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>p</span><span class="token punctuation">></span></span></code></pre>
<p>You might expect the paragraph to be green, because it doesn’t have the <code>#unique</code> ID. But in this case, the most specific item in the list is the ID, so that will be the specificity of the <code>:is()</code> selector. As a result, even though the ID doesn’t apply in this case, it still affects the specificity.</p>
<p>One interesting variation on this behavior is the <code>:where()</code> selector, which behaves exactly the same as the <code>:is()</code> selector, except it <em>always</em> has zero specificity.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:where(#unique, .intro)</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">p</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<!-- prettier-ignore -->
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation"><</span>p</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>intro<span class="token punctuation">"</span></span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>unique<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>This will be green.<span class="token tag"><span class="token tag"><span class="token punctuation"></</span>p</span><span class="token punctuation">></span></span></code></pre>
<p>This is especially useful if you’re writing CSS that you want to be easy to override, such as the default styles in a WordPress theme or a third-party library.</p>
<h2>Forgiving Selector Lists</h2>
<p>When you construct a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Selector_list">selector list</a> in standard CSS, if any selector in the list is invalid — such as <code>.valid-class, :invalid-pseudo-class</code> — the entire style block will be ignored.</p>
<p>Now, you might be thinking “Who would add an invalid selector to their CSS?” An example of when you might have to do this is browser-prefixed selectors. Say if you wanted to use the <code>:fullscreen</code> pseudo-class, but needed to support some older browsers that only understand the webkit prefixed version. You would need to write this:</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:-webkit-full-screen</span> <span class="token punctuation">{</span> <span class="token property">border-color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">:fullscreen</span> <span class="token punctuation">{</span> <span class="token property">border-color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<p>If you tried to use a comma-separated list, then a browser that doesn’t understand <code>:fullscreen</code> would fail. As a result, you have to duplicate the style block for each selector.</p>
<p>Thankfully, most of the new pseudo-classes, like <code>:is()</code> and <code>:where()</code>, accept what the specification calls a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Selector_list#forgiving_selector_list">forgiving selector list</a>, meaning any invalid selectors in the list will be ignored, but the valid ones will still be used.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:is(.valid-class, :invalid-pseudo-class)</span> <span class="token punctuation">{</span> ... <span class="token punctuation">}</span></code></pre>
<p>The browser will ignore <code>:invalid-pseudo-class</code> but still apply the rule to <code>.valid-class</code>.</p>
<p>Unfortunately, the <code>:not()</code> pseudo-class uses the unforgiving selector list, so adding any invalid selectors to the list will cause the entire style block to be ignored. However, there’s a rather silly solution: Just wrap the selector list in an <code>:is()</code> pseudo-class:</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:not(:is(.valid-class, :invalid-pseudo-class))</span> <span class="token punctuation">{</span> ... <span class="token punctuation">}</span></code></pre>
<p>This has the effect of letting <code>:is()</code> strip any invalid selectors from the list, and pass the remaining selectors on to <code>:not()</code>.</p>
<p>Until recently, the <code>:has()</code> pseudo-class also used the forgiving selector list, but they had to <a href="https://github.com/w3c/csswg-drafts/issues/7676#issuecomment-1341347244">revert this in late 2022 to avoid a conflict with jQuery</a>. Now, if you might have an invalid selector being passed to <code>:has()</code>, you can use the same <code>:has(:is())</code> trick to prevent it from breaking.</p>
<h2>Things to Note</h2>
<p>There are a few things about these new selectors that may surprise you if you’re not careful. Here are a few that I’ve noticed:</p>
<ul>
<li>You can’t select pseudo-elements using the new selectors. Writing <code>a:is(::before, ::after)</code> won’t work. <a href="https://github.com/w3c/csswg-drafts/issues/2284#issuecomment-364580632">The reason for this is complicated</a>.</li>
<li>Be careful! <code>.a .b .c</code>is not the same as <code>.a :is(.b .c)</code>. The first matches any <code>.c</code> that is a child of <code>.b</code> that is a child of <code>.a</code>. The second matches any <code>.c</code> that is a child of <code>.a</code> and <code>.b</code>, regardless of order! <a href="https://www.bram.us/2023/01/17/using-is-in-complex-selectors-selects-more-than-you-might-initially-think/">Learn more in this excellent post</a> by Bramus Van Damme</li>
<li>The <code>:not()</code> selector will match everything that is "not an X". For instance, <code>body :not(table) a</code> will still apply to links inside a <code><table></code> , since <code><tr></code>, <code><tbody></code>, <code><th></code>, <code><td></code>, <code><caption></code>, etc. can all match the <code>:not(table)</code> part of the selector.</li>
<li>You may come across older articles saying that <code>:not()</code> can only accept simple selectors, and if you need to target multiple items, you’d chain them like <code>:not(.foo):not(.bar)</code>. This is outdated. The syntax has since been updated in version four of the selectors specification to accept a selector list, so you can now do <code>:not(.foo, .bar)</code>.</li>
<li>You can <a href="https://polypane.app/blog/where-is-has-new-css-selectors-that-make-your-life-easier/#browser-support">test for support of the new selectors</a> by using the <code>selector()</code> function in a <code>@supports</code> rule, like so: <code>@supports selector(:is(*))</code>. Although the <code>selector()</code> function is relatively new itself, <a href="https://caniuse.com/mdn-css_at-rules_supports_selector">it has excellent support</a>.</li>
</ul>
<h2>Nesting Selector</h2>
<p>Okay, let’s talk about the reason I went down this rabbit hole at all — the <code>&</code> nesting selector! This new selector was added for <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting">CSS Nesting</a> (though it can be used in other contexts, such as <a href="https://developer.chrome.com/articles/at-scope/#the-difference-between-scope-and-inside-scope">CSS Scoping</a>). When used in CSS Nesting, the <code>&</code> will be replaced with the parent style rule’s selector, with the same specificity as if they were wrapped with <code>:is()</code>.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">a</span> <span class="token punctuation">{</span>
<span class="token selector">&:hover</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> rebeccapurple<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token comment">/* will be treated like */</span>
<span class="token selector">:is(a):hover</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> rebeccapurple<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<p>In most cases this won’t make a difference, it’s just interesting to note, but it could produce some unwanted side effects.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">#card, .card</span> <span class="token punctuation">{</span>
<span class="token selector">.featured &</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> cornflowerblue<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token comment">/* will be treated like */</span>
<span class="token selector">.featured :is(#card, .card)</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> cornflowerblue<span class="token punctuation">;</span> <span class="token punctuation">}</span></code></pre>
<p>Since the <code>&</code> selector includes an ID, the resulting rule will use the specificity of the ID. So be aware of the potential for unexpected specificity conflicts when using <code>&</code>.</p>
<p>It’s also worth noting that since the nesting selector behaves like the <code>:is()</code> selector, it also can’t represent pseudo-elements.</p>
<!-- prettier-ignore -->
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">a, a::before, a::after</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token selector">&:hover</span> <span class="token punctuation">{</span> <span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token comment">/* will be treated like */</span>
<span class="token selector">a, a::before, a::after</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">a:hover</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<p>As you can see, the pseudo-elements are ignored in the hover state in the final rule.</p>
<h2>Conclusion</h2>
<p>I set out to learn a bit about how CSS nesting works, especially the new <code>&</code> selector, and I ended up on a deep dive into the <code>:is()</code> selector and its siblings. I learned much more than I expected to, and came away with a better understanding of how they interact with specificity, and why some are more forgiving than others. I hope this was as useful for you.</p>
<h2>Footnotes</h2>
<section class="footnotes">
<ol class="footnotes-list">
<li id="fn1" class="footnote-item"><p>There may be some graybeards in the audience who are saying “Now wait just a minute here, <code>:not()</code> isn’t new, it was added back in IE9!” Yes, technically <code>:not()</code> is the older sibling here, introduced in the <a href="https://www.w3.org/TR/2018/REC-selectors-3-20181106/">Selectors Level 3</a> specification. The rest were introduced in <a href="https://w3c.github.io/csswg-drafts/selectors/%23negation">Selectors Level 4</a>. However, at the same time, <code>:not()</code> was changed from only accepting a simple selector to accepting a selector list like the others, so it’s easiest to think of it as a complete rewrite and consider these selectors as a group. <a href="https://spaceninja.com/blog/2023/surprising-facts-about-new-css-selectors/" class="footnote-backref">↩︎</a></p>
</li>
</ol>
</section>
When to Nest CSS2023-10-05T00:00:00Z2023-10-05T00:00:00Zhttps://spaceninja.com/blog/2023/when-to-nest-css/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/css-nesting-k2XKURlRCt-1600w.jpeg">
<p>With the recent news that <a href="https://caniuse.com/css-nesting">CSS nesting is now available in the major evergreen browsers</a>, our team was discussing how it <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting">differs from nesting in Sass</a>, and the question came up — When should you use nesting?</p>
<p>There’s a simple answer and a slightly more complicated answer. The simple answer is “avoid nesting.” The more practical, but also more complex answer is “nest pseudo-selectors, parent modifiers, media queries, and selectors that don’t work without nesting.”</p>
<p>Let’s discuss it a little bit, but first, I’ll give my standard disclaimer: This is what works for our team, it’s not a carved-in-stone standard, and you’re welcome to ignore this advice if it doesn’t work for your situation.</p>
<h2>Avoid Nesting</h2>
<p>We use <a href="https://spaceninja.com/2018/09/18/what-is-modular-css/">modular CSS</a> naming conventions like <a href="https://en.bem.info/methodology/naming-convention/">BEM</a> and <a href="https://github.com/suitcss/suit/blob/master/doc/naming-conventions.md">SUIT</a>. One thing these all have in common is a recommendation to decrease the specificity of your selectors whenever possible. This leads to practical guidance like avoiding ID selectors in favor of class selectors and avoiding cascading selectors whenever possible.</p>
<p>Here’s a practical example. If you’ve got a <code>.button</code> class and you’ve got a modifier to make it larger, like <code>.button.is-large</code>, that’ll work great — until someone adds a rule saying that buttons in the sidebar should be extra-large (<code>.sidebar .button</code>). Now even if you put the <code>.is-large</code> modifier on your sidebar button, it won’t change, because the sidebar rule came later in the stylesheet, and they’re equal specificity. Suddenly you’re caught in an arms race, updating the modifier to use <code>!important</code> or specificity hacks like <code>.button.button.is-large</code>.</p>
<p>When using BEM, we’d solve this by making two modifiers for the button: <code>.button--large</code> and <code>.button--x-large</code>. Now all the selectors have the same specificity, and problems are easier to solve.</p>
<p>As a result, our rule of thumb for nesting CSS selectors is <strong>if a selector will work without being nested, then do not nest it</strong>.</p>
<h2>What Should Be Nested</h2>
<p>Of course, there are some selectors that <em>must</em> be nested to work properly. Pseudo-classes, pseudo-elements, and certain modifier classes (like <code>.is-active</code>). As well, we prefer to nest media queries to improve the readability of our stylesheets. Finally, sometimes we use nesting to add styles relative to a parent modifier, and we prefer to nest those in the child selector to make them easier to find.</p>
<h3>Pseudo-Classes & Attribute Selectors</h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* without nesting */</span>
<span class="token selector">a</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">a:hover,
a:focus</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">a[aria-current='page']</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* with nesting */</span>
<span class="token selector">a</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token selector">&:hover,
&:focus</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">&[aria-current='page']</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> green<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>In this case, nesting the pseudo-classes and attribute selectors increases the readability of the stylesheet and doesn’t change the specificity of the selector, so it’s an easy win.</p>
<h3>Pseudo-Elements</h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* without nesting */</span>
<span class="token selector">blockquote</span> <span class="token punctuation">{</span>
<span class="token property">position</span><span class="token punctuation">:</span> relative<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">blockquote::before</span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token string">"💬"</span>
<span class="token property">left</span><span class="token punctuation">:</span> -1em<span class="token punctuation">;</span>
<span class="token property">position</span><span class="token punctuation">:</span> absolute<span class="token punctuation">;</span>
<span class="token property">top</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* with nesting */</span>
<span class="token selector">blockquote</span> <span class="token punctuation">{</span>
<span class="token property">position</span><span class="token punctuation">:</span> relative<span class="token punctuation">;</span>
<span class="token selector">&::before</span> <span class="token punctuation">{</span>
<span class="token property">content</span><span class="token punctuation">:</span> <span class="token string">"💬"</span>
<span class="token property">left</span><span class="token punctuation">:</span> -1em<span class="token punctuation">;</span>
<span class="token property">position</span><span class="token punctuation">:</span> absolute<span class="token punctuation">;</span>
<span class="token property">top</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>Similar to the pseudo-class, this is a clear win for readability without increasing the specificity.</p>
<h3>Certain Modifier Classes</h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* without nesting */</span>
<span class="token selector">.nav-link</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.nav-link.is-active</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* with nesting */</span>
<span class="token selector">.nav-link</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> red<span class="token punctuation">;</span>
<span class="token selector">&.is-active</span> <span class="token punctuation">{</span>
<span class="token property">color</span><span class="token punctuation">:</span> blue<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>If this came up in a PR, my first question would be “Can we rename <code>.is-active</code> to a BEM or SUIT-style modifier class? The current name makes it sound like a utility class that can be used anywhere, but it’s actually specific to the <code>.nav-link</code> component. Changing it to <code>.nav-link--active</code> would communicate this, decrease specificity, and avoid nesting.”</p>
<p>That said, sometimes you don’t have control over a modifier class. This can happen if it’s being added by a third-party script or a WordPress plugin, for example. In that case, if you can’t change the modifier class, nesting at least improves the readability.</p>
<h3>Media Queries</h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* without nesting */</span>
<span class="token selector">h1</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 2em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">h2</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 1.5em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">min-width</span><span class="token punctuation">:</span> 40em<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
<span class="token selector">h1</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 4em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">h2</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 3em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* with nesting */</span>
<span class="token selector">h1</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 2em<span class="token punctuation">;</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">min-width</span><span class="token punctuation">:</span> 40em<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 4em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token selector">h2</span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 1.5em<span class="token punctuation">;</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">min-width</span><span class="token punctuation">:</span> 40em<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
<span class="token property">font-size</span><span class="token punctuation">:</span> 3em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>Nesting media queries is one of my favorite uses of nesting, and this simplified example really doesn’t do it justice. In the real world, there are often dozens of rules under each of the selectors, so the non-nested media query can end up <em>way</em> down the stylesheet, making maintenance difficult, since the rules that affect a single selector are scattered around the file.</p>
<p>Nesting media queries is a clear win for readability, and does not affect specificity at all.</p>
<h3>Parent Modifiers</h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* without nesting */</span>
<span class="token selector">.card</span> <span class="token punctuation">{</span>
<span class="token property">background</span><span class="token punctuation">:</span> white<span class="token punctuation">;</span>
<span class="token property">color</span><span class="token punctuation">:</span> black<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.card__title</span> <span class="token punctuation">{</span>
<span class="token property">font-weight</span><span class="token punctuation">:</span> 700<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.theme--dark .card</span> <span class="token punctuation">{</span>
<span class="token property">background</span><span class="token punctuation">:</span> black<span class="token punctuation">;</span>
<span class="token property">color</span><span class="token punctuation">:</span> white<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.theme--dark .card__title</span> <span class="token punctuation">{</span>
<span class="token property">font-weight</span><span class="token punctuation">:</span> 600<span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* with nesting */</span>
<span class="token selector">.card</span> <span class="token punctuation">{</span>
<span class="token property">background</span><span class="token punctuation">:</span> white<span class="token punctuation">;</span>
<span class="token property">color</span><span class="token punctuation">:</span> black<span class="token punctuation">;</span>
<span class="token selector">.theme--dark &</span> <span class="token punctuation">{</span>
<span class="token property">background</span><span class="token punctuation">:</span> black<span class="token punctuation">;</span>
<span class="token property">color</span><span class="token punctuation">:</span> white<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span>
<span class="token selector">.card__title</span> <span class="token punctuation">{</span>
<span class="token property">font-weight</span><span class="token punctuation">:</span> 700<span class="token punctuation">;</span>
<span class="token selector">.theme--dark &</span> <span class="token punctuation">{</span>
<span class="token property">font-weight</span><span class="token punctuation">:</span> 600<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>Just like modifier classes, when you have an element you’re styling that needs to behave differently based on a parent selector, it makes things much easier to read when you can nest the changes directly, rather than having them occur further down the file.</p>
<h3>Cascade Layers?</h3>
<p>When I wrote this, my coworker asked about nesting cascade layers. I’m going to level with you here: cascade layers are such a new feature that I haven’t used them anywhere yet. At a casual glance, I <em>think</em> you can nest layer declarations like media queries, and it would probably add the same benefits. That said, I’m not confident enough to make a recommendation.</p>
<h2>Conclusion</h2>
<p>CSS nesting is a great addition to the language, but it should be used with caution. Nesting increases specificity, which can lead to maintenance problems if you’re not careful. We recommend using a modular CSS naming convention like BEM or SUIT, which reduces the need for nesting in the first place. As a rule, <strong>if a selector will work without being nested, then do not nest it.</strong> However, there are certain situations (such as pseudo-selectors and media queries) where nesting can make things easier to understand.</p>
Books I Loved in 20222023-10-04T00:00:00Z2023-10-04T00:00:00Zhttps://spaceninja.com/blog/2023/books-i-loved-in-2022/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/books-2022-x125euwAsg-1600w.jpeg">
<p><a href="https://www.goodreads.com/user_challenges/33750365">In 2022 I read 35 books</a>, but I struggled to put this list together, because I didn’t <em>love</em> very many of them. A lot of my list was books I was reading to John during bedtime (The Hitchhiker’s Guide books, and the Discworld books), as well as several UFO-related books in preparation for <a href="https://veryexcitingtime.com">my podcast</a>. But looking back over the list, there were two that stood out.</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/nona-zKBMf7hJts-300w.webp" width="500" height="772" srcset="https://spaceninja.com/images/nona-zKBMf7hJts-300w.webp 300w, https://spaceninja.com/images/nona-zKBMf7hJts-400w.webp 400w, https://spaceninja.com/images/nona-zKBMf7hJts-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/59830943-nona-the-ninth"><em>Nona the Ninth</em></a>, by Tamsyn Muir</h2>
<blockquote>
<p>She looked up at the sky, and she bellowed: “You said you wouldn’t do anything weird!”</p>
</blockquote>
<p>This is an odd book to review. If you enjoyed <em>Gideon the Ninth</em> or <em>Harrow the Ninth</em>, the first two books in the Locked Tomb series, then you don’t need me to convince you to read. If you haven’t read them yet, then a review of the third book in a series isn’t going to convince you.</p>
<p>It’s also odd because it originally wasn’t intended as a separate book. The Nona material was originally part of the next book in the series, but, editor Carl Engle-Laird says: “Nona arrived, bursting forth from <em>Alecto the Ninth</em> with an irrepressible energy and presence. She could not be contained, and demanded her own volume.”</p>
<p>While avoiding spoilers, I can tell you that this book starts out feeling very different from the previous two, but has the same sense of playfulness and humor. It’s set in a city in a war zone and told from the perspective of Nona, a girl with a strange past and a broken memory. She lives with Pyrrha, Camilla, and Palamedes, and has a list of dogs she would like to invite to her birthday party. And if that doesn’t convince you to read, I don’t know what will.</p>
<blockquote>
<p><strong>Dogs to invite to birthday party</strong></p>
<ul>
<li>Brown one by the fish shop, average sized, four legs</li>
<li>Stop It, name assumed, lies under counter at dairy, red colour, big sized, four legs</li>
<li>White-and-black one seen once in the park, average sized, tail curled twice, three legs</li>
<li>Noodle, king of dogs in secret, white-adjacent, small sized, six legs</li>
<li>Spotted beach dog, often on beach, large sized, huge ginger eyebrows, three legs</li>
</ul>
</blockquote>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/surviving-death-7FCt9Xv7ID-300w.webp" width="500" height="772" srcset="https://spaceninja.com/images/surviving-death-7FCt9Xv7ID-300w.webp 300w, https://spaceninja.com/images/surviving-death-7FCt9Xv7ID-400w.webp 400w, https://spaceninja.com/images/surviving-death-7FCt9Xv7ID-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.goodreads.com/book/show/32613374-surviving-death"><em>Surviving Death: A Journalist Investigates Evidence for an Afterlife</em></a>, by Leslie Kean</h2>
<p>I loved Kean’s first book, <em>UFOs: Generals, Pilots, And Government Officials Go On The Record</em>, but I was slow to pick up her second. To be honest, I didn’t until I watched the Netflix series <em>Surviving Death</em>, which is based on her book. I was hoping for more detail beyond what the show covered, and the book does deliver that. I appreciated that Kean used the same format as <em>UFOs</em>, with her commentary sections interspersed with sections written by researchers explaining how they approach such a difficult-to-study topic.</p>
<p>The reincarnation sections plug nicely into another book I greatly enjoyed on the topic, <em>Journey of Souls</em>, by Michael Newton. Kean shares some extraordinary stories, including a young boy with recurring nightmares of being trapped in the cockpit of a burning plane as it crashed, and how the details he remembered led the family to find a particular WWII pilot whose life matches uncannily well.</p>
<p>The mediumship sections are presented well, and Kean is well aware that there’s a less compelling story to tell, with a “you had to be there” kind of vibe. I didn’t mind it, but I wasn’t compelled by it either.</p>
<p>Overall, if you’re interested in a good high-level overview of what information we have about life after death, I recommend it. That said, all the best bits were covered in the Netflix series, so feel free to watch that if you’re not sure about the book.</p>
</div>
</li>
</ul>
Starfield’s Accessibility Problems2023-09-19T00:00:00Z2023-09-19T00:00:00Zhttps://spaceninja.com/blog/2023/starfield-accessibility/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-1600w.jpeg">
<p>I’ve been playing a lot of <em>Starfield</em> lately, the new game from Bethesda Studios, and I really enjoy it. Flying my little spaceship around, pretending to be Naomi Nagata from <em>The Expanse</em>, and digging the NASA-punk design aesthetic is a lot of fun. But there’s one little thing that bothers me.</p>
<p>In the game, you interact with a lot of computers. Ship controls, desktop terminals, and information kiosks abound. And I have yet to interact with a single one that didn’t have some sort of accessibility problem.</p>
<p>To be clear, I’m not talking about the game itself — <a href="https://www.gamesradar.com/starfield-accessibility-verdict/">though it has its share of accessibility woes</a> — I’m talking about the ability to use the in-game computer UIs. Let me show you some examples:</p>
<img alt="Screenshot of an in-game kiosk showing news about Ryujin Industries. Below a block of marketing text, there are two buttons: “Celebrating 20 Years” and “Now Hiring.” One of the buttons is dark red, and one is bright red. There is no way to tell which is focused." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-450w.webp" width="1600" height="900" srcset="https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-450w.webp 450w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-700w.webp 700w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-825w.webp 825w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-975w.webp 975w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-1025w.webp 1025w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-1280w.webp 1280w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-1440w.webp 1440w, https://spaceninja.com/images/starfield-kiosk-ryujin-7udySdp8n5-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>There are a lot of information kiosks in <em>Starfield</em>, and they suffer from a common accessibility problem — insufficient focus indicators. Here we have a kiosk with only two options, using the Ryujin Industries brand color of red. Quick question: which button is active right now? There’s literally no way to tell.</p>
<p>When building websites in the real world, this is a common problem. For sighted users with a mouse, highlighting a button by changing color when they hover over it is fine, because the user knows where their mouse is. But for low-vision users or keyboard users, we add a focus ring to make it clear which button is active. Ryujin Industries may have an accessibility lawsuit in their future.</p>
<img alt="Screenshot of an in-game kiosk labelled “New Atlantis Information.” A set of four buttons is centered in the screen, the first highlighted in a slightly lighter color. An easily-missable scrollbar is to the left of the buttons, indicating there are more just off-screen." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-450w.webp" width="1600" height="900" srcset="https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-450w.webp 450w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-700w.webp 700w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-825w.webp 825w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-975w.webp 975w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-1025w.webp 1025w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-1280w.webp 1280w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-1440w.webp 1440w, https://spaceninja.com/images/starfield-kiosk-na-1-J7AExq9SgB-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>Here’s another example, from the big city of New Atlantis, one of the first places you visit in the game. Although they have the same insufficient focus indicator, it’s less of a problem because there’s more than one button, so it’s at least clear which one is colored differently. But there’s another, sneakier problem here. How many buttons are available?</p>
<img alt="Screenshot of the same in-game kiosk labelled “New Atlantis Information.” A set of four buttons is still centered in the screen, but it has been scrolled down to reveal the final two buttons in the list that were not visible at first. There is easily enough space on screen to show all six buttons at once." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-450w.webp" width="1600" height="900" srcset="https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-450w.webp 450w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-700w.webp 700w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-825w.webp 825w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-975w.webp 975w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-1025w.webp 1025w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-1280w.webp 1280w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-1440w.webp 1440w, https://spaceninja.com/images/starfield-kiosk-na-2-WVNWTLgnsj-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>Surprise! It’s <em>six</em>. That’s right, there were two buttons scrolled off-screen. Now, technically, there is a scrollbar, but I can tell you that I didn’t notice it until I took these screenshots. It’s that tiny thin grey line to the left of the buttons.</p>
<p>This is a bad design for a few reasons. First, the thinness of the scrollbar means it’s easy to miss. The fact that it’s using the same thickness and muted grey as <em>all the other decorative lines</em> in the background aggravates the problem. Finally it’s on the left side, which is non-traditional for left-to-right languages.</p>
<p>I would suggest moving the scrollbar to the right, making it thicker and tweaking the colors to stand out a bit more. Or even consider ditching the scrollbar entirely. There’s plenty of room on the screen to show all six buttons.</p>
<p>Oh, and I’d get rid of those four dots at the bottom of the list. They look like an ellipsis, which makes me think I’m supposed to be able to expand it to see more, but it’s just a design element.</p>
<p>Perhaps because the scrollbar is so hard to notice, the designer of this next screen did away with it entirely, opting to use pagination instead.</p>
<img alt="Screenshot of an in-game kiosk labelled “Welcome to New Atlantis.” A large block of text describing the city is shown below, with a simple set of pagination controls indicating there are two pages and you’re viewing the first." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-450w.webp" width="1600" height="900" srcset="https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-450w.webp 450w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-700w.webp 700w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-825w.webp 825w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-975w.webp 975w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-1025w.webp 1025w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-1280w.webp 1280w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-1440w.webp 1440w, https://spaceninja.com/images/starfield-kiosk-na-read-MRD5uQLNYJ-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>To their credit, the pagination controls aren’t hidden away, though there are still some problems here.</p>
<p>They certainly don’t have enough contrast, making them difficult to read for low-vision users, and the current page indicator, like the buttons, is only indicated by a color change. This could be addressed by darkening the text on the controls and by making the active page indicator a bit larger than the others.</p>
<img alt="Screenshot of an in-game desktop computer interface. A set of four file icons are shown on the desktop, with one highlighted, showing it’s active. A window is open to the right of the icons, showing the text of the active file. The text has the same easy-to-miss left-hand scrollbar as discussed earlier." class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-450w.webp" width="1600" height="900" srcset="https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-450w.webp 450w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-700w.webp 700w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-825w.webp 825w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-975w.webp 975w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-1025w.webp 1025w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-1280w.webp 1280w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-1440w.webp 1440w, https://spaceninja.com/images/starfield-desktop--wFe9dpSCB-1600w.webp 1600w" sizes="(min-width: 64em) 783px, (min-width: 48em) 711px, (min-width: 42em) 633px, 100vw">
<p>Lastly, we have a view of the standard <em>Starfield</em> desktop computer interface. Files and folders are shown on the desktop and can be activated to open a window showing the contents. The active file is only indicated via color, but the contrast change is strong enough that it may be sufficient.</p>
<p>But let’s turn our attention to the window showing the text of the file. Oh dear, our low-contrast skinny left-hand scrollbar has returned. There is (practically) no indication to the user that they’re missing the last bit of the file. The scrollbar recommendations I made before apply here, but in addition, they might consider adding a <a href="https://css-tricks.com/books/greatest-css-tricks/scroll-shadows/">scroll shadow</a> along the bottom to hint to the user that there’s more to read just off-screen.</p>
<p>I love <em>Starfield</em>, but I find it depressing that even in a game emphasizing hope and human achievements, they couldn’t imagine a world with accessible user interfaces.</p>
In Praise of Vite2023-03-28T15:35:06Z2023-03-28T15:35:06Zhttps://spaceninja.com/blog/2023/in-praise-of-vite/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/unicorn-hearts-vite-ZuJDrKWPXm-1600w.jpeg">
<p>I consider myself a fairly capable developer. I even enjoy working with the infrastructure that powers our projects. I love setting up our design tokens, preprocessors, linters, and other tools that help us write better code. That said, there’s one thing that causes me to break into a sweat: configuring complex build tools like Webpack and Babel.</p>
<p>These tools, while undeniably powerful, are some of the most arcane and difficult to work with I’ve ever used. I’m sure there are people out there who are rolling their eyes at me, and find this stuff completely understandable. I’m happy for you! But I don’t think I’m alone in feeling this way.</p>
<p>Let me share an example Webpack config from a Nuxt project we maintain (feel free to just skim past this, I’m just making a point about complexity):</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token comment">/* nuxt.config.js */</span>
module<span class="token punctuation">.</span>exports <span class="token operator">=</span> <span class="token punctuation">{</span>
<span class="token comment">/*
** You can extend webpack config here
*/</span>
<span class="token function">extend</span><span class="token punctuation">(</span><span class="token parameter">config<span class="token punctuation">,</span> <span class="token punctuation">{</span> isDev<span class="token punctuation">,</span> isClient<span class="token punctuation">,</span> <span class="token literal-property property">loaders</span><span class="token operator">:</span> <span class="token punctuation">{</span> vue <span class="token punctuation">}</span> <span class="token punctuation">}</span></span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token comment">/**
* Transpile All Node Modules
*/</span>
<span class="token keyword">const</span> jsRule <span class="token operator">=</span> config<span class="token punctuation">.</span>module<span class="token punctuation">.</span>rules<span class="token punctuation">.</span><span class="token function">find</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token parameter">rule</span><span class="token punctuation">)</span> <span class="token operator">=></span> rule<span class="token punctuation">.</span>test<span class="token punctuation">.</span><span class="token function">test</span><span class="token punctuation">(</span><span class="token string">'.js'</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">// don't transpile babel helpers and core-js</span>
jsRule<span class="token punctuation">.</span>exclude <span class="token operator">=</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">(core-js|babel)</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">;</span>
<span class="token keyword">const</span> babelOptions <span class="token operator">=</span> jsRule<span class="token punctuation">.</span>use<span class="token punctuation">[</span><span class="token number">0</span><span class="token punctuation">]</span><span class="token punctuation">.</span>options<span class="token punctuation">;</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>isClient<span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token comment">// By default, babel will assume all modules are ES modules. This would</span>
<span class="token comment">// lead babel to inject ES imports even in commonjs files.</span>
<span class="token comment">// Source Type unambiguous forces babel to check each file individually</span>
<span class="token comment">// and decide whether it is commonjs or an ES module.</span>
babelOptions<span class="token punctuation">.</span>sourceType <span class="token operator">=</span> <span class="token string">'unambiguous'</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token comment">/**
* Allow vue-loader to transform assets in `data-srcset` and `data-src`
* as well as `srcset` and `src`.
*
* @see https://dev.to/ignore_you/minify-generate-webp-and-lazyload-images-in-your-vue-nuxt-application-1ilm
*/</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>isClient<span class="token punctuation">)</span> <span class="token punctuation">{</span>
vue<span class="token punctuation">.</span>transformAssetUrls<span class="token punctuation">.</span>img <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token string">'data-src'</span><span class="token punctuation">,</span> <span class="token string">'src'</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
vue<span class="token punctuation">.</span>transformAssetUrls<span class="token punctuation">.</span>source <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token string">'data-srcset'</span><span class="token punctuation">,</span> <span class="token string">'srcset'</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token comment">/**
* Allow Inline SVGs
*
* Nuxt has a single rule for all image types that uses `file-loader`.
* This rule says "For SVG images with the inline parameter,
* use `vue-svg-loader` instead."
*
* @see https://vue-svg-loader.js.org/faq.html#how-to-use-both-inline-and-external-svgs
*/</span>
<span class="token keyword">const</span> svgRule <span class="token operator">=</span> config<span class="token punctuation">.</span>module<span class="token punctuation">.</span>rules<span class="token punctuation">.</span><span class="token function">find</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token parameter">rule</span><span class="token punctuation">)</span> <span class="token operator">=></span>
rule<span class="token punctuation">.</span>test<span class="token punctuation">.</span><span class="token function">test</span><span class="token punctuation">(</span><span class="token string">'.svg'</span><span class="token punctuation">)</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
svgRule<span class="token punctuation">.</span>test <span class="token operator">=</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">\.(png|jpe?g|gif|webp)$</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">;</span>
config<span class="token punctuation">.</span>module<span class="token punctuation">.</span>rules<span class="token punctuation">.</span><span class="token function">push</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
<span class="token literal-property property">test</span><span class="token operator">:</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">\.svg$</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">,</span>
<span class="token literal-property property">oneOf</span><span class="token operator">:</span> <span class="token punctuation">[</span>
<span class="token punctuation">{</span>
<span class="token literal-property property">resourceQuery</span><span class="token operator">:</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">inline</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">,</span>
<span class="token literal-property property">use</span><span class="token operator">:</span> <span class="token punctuation">[</span>
<span class="token comment">// babel loader is run after the svg files are transpiled into vue</span>
<span class="token comment">// components (webpack runs loaders bottom-to-top)</span>
<span class="token punctuation">{</span>
<span class="token literal-property property">loader</span><span class="token operator">:</span> <span class="token string">'babel-loader'</span><span class="token punctuation">,</span>
<span class="token literal-property property">options</span><span class="token operator">:</span> babelOptions<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">{</span>
<span class="token literal-property property">loader</span><span class="token operator">:</span> <span class="token string">'vue-svg-loader'</span><span class="token punctuation">,</span>
<span class="token literal-property property">options</span><span class="token operator">:</span> <span class="token punctuation">{</span>
<span class="token literal-property property">svgo</span><span class="token operator">:</span> <span class="token boolean">false</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">{</span>
<span class="token literal-property property">loader</span><span class="token operator">:</span> <span class="token string">'file-loader'</span><span class="token punctuation">,</span>
<span class="token literal-property property">options</span><span class="token operator">:</span> <span class="token punctuation">{</span>
<span class="token literal-property property">esModule</span><span class="token operator">:</span> <span class="token boolean">false</span><span class="token punctuation">,</span>
<span class="token literal-property property">name</span><span class="token operator">:</span> <span class="token string">'assets/[name].[hash:8].[ext]'</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token comment">/**
* Run ESLint on save
*/</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>isDev <span class="token operator">&&</span> isClient<span class="token punctuation">)</span> <span class="token punctuation">{</span>
<span class="token comment">// eslint-disable-next-line global-require</span>
<span class="token keyword">const</span> ESLintPlugin <span class="token operator">=</span> <span class="token function">require</span><span class="token punctuation">(</span><span class="token string">'eslint-webpack-plugin'</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
config<span class="token punctuation">.</span>plugins<span class="token punctuation">.</span><span class="token function">push</span><span class="token punctuation">(</span>
<span class="token keyword">new</span> <span class="token class-name">ESLintPlugin</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
<span class="token literal-property property">extensions</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">'js'</span><span class="token punctuation">,</span> <span class="token string">'vue'</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span></code></pre>
<p>Believe it or not, that’s a relatively simple and well-documented config. We only needed to add a few things, and the devs who added them left helpful comments and links to documentation. Still, when something goes wrong? It’s a nightmare trying to figure out why and how to fix it.</p>
<p>And that’s why I’ve been so thrilled with <a href="https://vitejs.dev/">Vite</a> (pronounced “veet,” French for “quick”), a modern dev environment and build tool that completely replaces Webpack. I could bore you with details like how it takes advantage of browser-native <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules">JavaScript modules</a> to support <a href="https://vitejs.dev/guide/dep-pre-bundling.html">dependency pre-bundling</a> and <a href="https://vitejs.dev/guide/features.html#hot-module-replacement">hot-module replacement</a>, or how it was originally created to speed up Vue, but has been converted to a framework-agnostic tool, or that in just two years it’s grown to over <a href="https://www.npmjs.com/package/vite">3 million downloads per week</a>. But frankly, you’d be better served checking out <a href="https://vitejs.dev/guide/features.html">Vite’s features page</a>.</p>
<p>What I want to rave about is what I consider the best feature of Vite. The thing that’s had the most dramatic impact on the way I work, and why it’s so useful to me. I want to talk about Vite’s <em>simplicity</em>.</p>
<p>Remember that “simple” Webpack config? Here’s the Vite config from the same project after we upgraded:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token comment">/* nuxt.config.js */</span>
<span class="token keyword">import</span> svgLoader <span class="token keyword">from</span> <span class="token string">'vite-svg-loader'</span><span class="token punctuation">;</span>
<span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token function">defineNuxtConfig</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
<span class="token literal-property property">vite</span><span class="token operator">:</span> <span class="token punctuation">{</span>
<span class="token literal-property property">plugins</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token function">svgLoader</span><span class="token punctuation">(</span><span class="token punctuation">{</span> <span class="token literal-property property">svgo</span><span class="token operator">:</span> <span class="token boolean">false</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre>
<p>That’s it! That’s the whole thing! All the same features, but with only a single line of config to load a plugin to inline SVGs.</p>
<p>Compared to Webpack, Vite is delightfully easy to use. As an opinionated tool, it simply handles most of the things we need right out of the box. Your config file is likely to be minimal. In many cases, it’s only used to load plugins that help Vite understand how to process things like Vue single-file components or inlining SVGs. On several of my <a href="https://github.com/spaceninja/excuse-generator">simpler side projects</a>, there’s no config file <em>at all!</em></p>
<p>At the most basic level, Vite only cares about your entry file — the <code>index.html</code> file that lives at the root of your app. Any CSS or JS files you load from there will be processed by Vite.</p>
<ul>
<li>Want to use Sass? Install Sass with npm, and Vite will automatically process any Sass files you link to.</li>
<li>How about PostCSS? No problem! Just add a PostCSS config file, and Vite will figure it out.</li>
<li>Need to load some information from a JSON file? Can do! Vite allows you to directly import it into your JS file.</li>
<li>TypeScript? You got it! Just use the <code>*.ts</code> extension, and Vue will handle everything for you.</li>
</ul>
<p>To do all these things in older projects using Webpack and Babel required a nightmare of configuration, plugins, and maintenance.</p>
<p>There are a lot of <a href="https://vitejs.dev/guide/why.html">technical reasons why Vite is great</a>. But for me, it removes the single most painful part of modern web development. At the end of the day, <em>it just works</em>. With very little instruction, it does everything I want. Load this file, process it as needed, and let me get back to writing code.</p>
<p>Thanks, Vite!</p>
TV Shows I Loved in 20222022-12-31T00:00:00Z2022-12-31T00:00:00Zhttps://spaceninja.com/blog/2022/tv-shows-i-loved-in-2022/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/cyberpunk-edgerunners-oqLbM_-TnO-1600w.jpeg">
<p>Last year I started tracking what TV shows I watch so I can produce these end-of-the-year lists, and I continued the habit in 2022. As a result, I know that <a href="https://trakt.tv/users/spaceninja00/year/2022">I watched 342 hours of television across 47 shows</a>. Here’s some of my favorites.</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/all-of-us-are-dead-Fb4L5_Asg_-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/all-of-us-are-dead-Fb4L5_Asg_-300w.webp 300w, https://spaceninja.com/images/all-of-us-are-dead-Fb4L5_Asg_-400w.webp 400w, https://spaceninja.com/images/all-of-us-are-dead-Fb4L5_Asg_-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt14169960/"><em>All of Us Are Dead</em></a></h2>
<p>This is an excellent zombie show set in a South Korean high school. There’s drama, there’s romance, there’s a growing hoard of undead trying to kill everyone. I watched this with Ollie and we loved every minute and can’t wait for season 2.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/cyberpunk-edgerunners-O9_QjcgEWM-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/cyberpunk-edgerunners-O9_QjcgEWM-300w.webp 300w, https://spaceninja.com/images/cyberpunk-edgerunners-O9_QjcgEWM-400w.webp 400w, https://spaceninja.com/images/cyberpunk-edgerunners-O9_QjcgEWM-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt12590266/"><em>Cyberpunk: Edgerunners</em></a></h2>
<p>I already loved the world of Cyberpunk, which is an odd thing to say about a severely fucked-up dystopia. The makers of Cyberpunk 2077 did a phenomenal job of extending the visual language for this type of future beyond the rainy neon streets of Blade Runner, showing us what the world looks like in broad daylight, and injecting more neon color, music, and personality.</p>
<p>Then this fantastic anime kicked things up another notch. The style of everything here is jaw-dropping. The casual, almost cartoony violence. The slow-motion repeated color-shifted frames of David using his Sandevistan implant. The constant delight of getting to visit locations I know well from the game.</p>
<p>But more than any of that, this is a good story. David character arc is tragic, and the story doesn’t shy away from the constant threat of cyberpsychosis, and of increasing risks the gang needs to take to achieve glory. The final confrontation managed to not only deliver a gut punch of emotion, but also gave me an additional reason to dislike a bad guy from the video game.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/for-all-mankind-_VBF8wgTaj-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/for-all-mankind-_VBF8wgTaj-300w.webp 300w, https://spaceninja.com/images/for-all-mankind-_VBF8wgTaj-400w.webp 400w, https://spaceninja.com/images/for-all-mankind-_VBF8wgTaj-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt7772588/"><em>For All Mankind</em></a></h2>
<p>I get frustrated quickly with historical fiction, and despite the involvement of <em>Battlestar Galactica</em>’s Ronald D. Moore, the early NASA 1960s setting did not appeal to me. Honestly, I only came back to watch season one when I saw a teaser for season 3.</p>
<p>In a nutshell, what this show does well is that each season is set about a decade apart, following the diverging history from a single change in our timeline: What if the Russians had beaten the US to the moon landing?</p>
<p>Season one is set in the sixties and follows the events at NASA as they desperately attempt to catch up and avoid Russian dominance in space. Season two is set in the seventies and covers the establishment of competing moon bases by Russia and the US. Season three is set in the eighties and covers the race to establish a Mars base.</p>
<p>Once I knew that, it was easy to push through my dislike of the sixties setting, and enjoy the ride. The characters are great, the story is great, and I can’t wait to watch season three.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/ms-marvel-rOEIQGS_uI-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/ms-marvel-rOEIQGS_uI-300w.webp 300w, https://spaceninja.com/images/ms-marvel-rOEIQGS_uI-400w.webp 400w, https://spaceninja.com/images/ms-marvel-rOEIQGS_uI-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt10857164/"><em>Ms. Marvel</em></a></h2>
<p>I’m sure like most people you’re suffering from Marvel burnout. But trust me, this series is an absolute joy. Kamala Khan (played by the fantastic Iman Vellani) is a “brown girl from Jersey” who grows up in a world shaped by the rise of the Avengers. Her walls are covered with posters of her favorite hero, Captain Marvel. She writes fan fiction and has a YouTube channel discussing superhero events. But she’s also Muslim, from a Pakistani family, and when she gains her own super powers, everything about her personality informs the type of hero she tries to become. It’s joyful and optimistic, while also leaning into the practical realities she needs to face in today’s world. I can’t recommend it strongly enough.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/slow-horses-SwpKrhy8kd-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/slow-horses-SwpKrhy8kd-300w.webp 300w, https://spaceninja.com/images/slow-horses-SwpKrhy8kd-400w.webp 400w, https://spaceninja.com/images/slow-horses-SwpKrhy8kd-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt5875444/"><em>Slow Horses</em></a></h2>
<blockquote>
<p>If you’re not aware of the books or the show, here’s the set up: Slough House is the British Intelligence version of American police departments’ “rubber gun squad,” where intel operatives who can’t be fired for cause but are not wanted are sent to languish. There, they do administrative shitwork for the rest of their lives until they quit in disgust. This purgatory is presided over by the vile, flatulent anti-George Smiley, Jackson Lamb, a man with an awesome and terrifying Cold War reputation who must surely have fucked up in some epic way to have been placed in charge of Slough House. He is monstrous, callous and casually abusive to his charges and, in fact, anyone else who dares stray into his drunken squinting gaze. The stories are about the various limbo’d agents of Slough House, and the things Jackson Lamb is up to when nobody is looking.</p>
</blockquote>
<p>That’s how <a href="https://warrenellis.ltd/books/full-of-noisy-bastards-bad-actors-mick-herron/">Warren Ellis described the show</a> in his newsletter and convinced me to check it out.</p>
<p>I can’t recommend it enough if you enjoy spy stuff. Gary Oldman is a masterful bit of casting here. You may be familiar with his turn as legendary spy George Smiley in <em>Tinker, Tailor, Soldier, Spy</em>, where he was always calm and sedate and in control. Jackson Lamb is none of those things, and Oldman commands every scene he’s in, noisily slurping noodles, farting, drinking, and dropping the most devastating one-liners about everyone in his eyesight. His absolute contempt for everyone around him is second only to his fury when anyone fucks with him or his territory. As he says, “They’re all losers, but they’re <em>my</em> losers.”</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/lower-decks-P3EDiAFVOZ-300w.webp" width="500" height="735" srcset="https://spaceninja.com/images/lower-decks-P3EDiAFVOZ-300w.webp 300w, https://spaceninja.com/images/lower-decks-P3EDiAFVOZ-400w.webp 400w, https://spaceninja.com/images/lower-decks-P3EDiAFVOZ-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt9184820/"><em>Star Trek: Lower Decks</em></a></h2>
<p>Recommending a Star Trek show is complicated, because people have complicated relationships with Star Trek. You certainly already have your own opinions about whether Star Trek is good and worth watching, but give me a second to recommend this to you.</p>
<p>A few years ago, Seth MacFarlane launched a show called <em>The Orville</em>, which couldn’t make up it’s mind whether it was a blistering satire of Star Trek, or a loving continuation of <em>The Next Generation.</em> Because it couldn’t commit, it never really succeeded at either, pulling it’s satire punches, and so dedicated to the vibe of <em>TNG</em> that it couldn’t deliver anything new.</p>
<p>So it was surprising to me to find that <em>Lower Decks</em>, a show that constantly vacillates between being a loving homage to <em>The Next Generation</em> and a blistering satire of Star Trek as a whole, is consistently <em>excellent.</em> That turns out to be because it was created by Mike McMahan, the man behind the twitter account <a href="https://twitter.com/TNG_S8">@TNG_S8</a>, which consisted entirely of episode synopses for a fictional eighth season of <em>The Next Generation</em>. Here are some of my favorites:</p>
<blockquote>
<p>A pod of quantum dolphins are struck by the starboard nacelle, Picard defends himself in the dolphin murder trial. Guinan learns hockey. <a href="https://twitter.com/TNG_S8/status/129725214975197185">*</a></p>
</blockquote>
<blockquote>
<p>Riker's ex-girlfriend arrives and dies, leaving behind a pile of glowing dust and a mystery. Picard is trapped on a turbolift with a horse. <a href="https://twitter.com/TNG_S8/status/131031473372401664">*</a></p>
</blockquote>
<blockquote>
<p>Riker protects a class of alien school kids and their attractive teacher during a lava storm. A flock of tiny, flightless birds hunt Wesley. <a href="https://twitter.com/TNG_S8/status/264437485227094016">*</a></p>
</blockquote>
<blockquote>
<p>Picard must debate a copy of himself… to the death. Geordi and Data really want to find their snake before anyone notices it’s gone. <a href="https://twitter.com/TNG_S8/status/309811863418449920">*</a></p>
</blockquote>
<p>I love how well he nailed the big dramatic plot contrasted with a silly b-story. “Guinan learns hockey” kills me every time. I can just imagine Picard visiting her for advice on the dolphin trial, but they’re on the holodeck and she’s in the penalty box the whole time.</p>
<p>Anyway, <em>Lower Decks</em> is that. It follows the lower-ranked crew of a Starfleet ship, and how they just try to live their lives while the bridge crew get in absurd situations. It’s really really good.</p>
</div>
</li>
</ul>
Movies I Loved in 20222022-12-29T00:00:00Z2022-12-29T00:00:00Zhttps://spaceninja.com/blog/2022/movies-i-loved-in-2022/Scott Vandehey
<img alt="" src="https://spaceninja.com/images/everything-everywhere-all-at-once-e_KfKulFvV-1600w.jpeg">
<p><a href="https://letterboxd.com/spaceninja/year/2022/">I watched 95 movies this year</a>, several of which are absolute top-of-list bangers that I will happily watch over and over. The kind of movie I will sit you down and talk at you about animatedly at great length until you agree to watch it. In fact, let’s not wait, pull up a chair, let’s watch one right now!</p>
<ul class="media-list">
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/bullet-train-agZg7jQXUe-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/bullet-train-agZg7jQXUe-300w.webp 300w, https://spaceninja.com/images/bullet-train-agZg7jQXUe-400w.webp 400w, https://spaceninja.com/images/bullet-train-agZg7jQXUe-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt12593682/"><em>Bullet Train</em></a></h2>
<blockquote>
<p>Five assassins aboard a swiftly-moving bullet train find out that their missions have something in common.</p>
</blockquote>
<p>The core of this movie is Brad Pitt, who’s taken a simple job to steal a briefcase from a train, and instead runs into every assassin in the world, and they’re all mad at him, and he can’t figure out why. Pitt is always at his best playing lovable confused losers, and he nails it here. “Why?” He yells as a dude tries to stab him. “I don’t even know you, man!”</p>
<p>But the best thing is that every other character in this movie is also delightful. One of my favorite scenes is when Brian Tyree Henry delivers a monologue about how people are like characters in Thomas the Tank Engine to the exasperation of his partner. This movie features a Weekend at Bernies scene, a Kill Bill scene, and a Speed scene.</p>
<p>Seriously. I know it’s just a dumb action movie, but it’s now one of my favorites. Just trust me, order some pizza, pop an edible, and enjoy.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/everything-everywhere-all-at-once-o84wC4vfBb-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/everything-everywhere-all-at-once-o84wC4vfBb-300w.webp 300w, https://spaceninja.com/images/everything-everywhere-all-at-once-o84wC4vfBb-400w.webp 400w, https://spaceninja.com/images/everything-everywhere-all-at-once-o84wC4vfBb-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt6710474/"><em>Everything Everywhere All At Once</em></a></h2>
<blockquote>
<p>An aging Chinese immigrant is swept up in an insane adventure, where she alone can save what's important to her by connecting with the lives she could have led in other universes.</p>
</blockquote>
<p>Jesus, I don’t even begin to know how to talk about this movie. First, the cast is incredible. Michelle Yeoh effortlessly grounds every scene she’s in. Ke Huy Quan is adorable and sweet and delightful. Stephanie Hsu is a fucking force of nature. And Jamie Lee Curtis milks every bit of humor out of every line she gets. Then drop all four of them into a complicated multiverse scenario that ultimately boils down to the complexity of mother/daughter relationships. This is one of those rare perfect movies that I look forward to watching over and over.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/glass-onion-IgBiXeqfWf-300w.webp" width="500" height="749" srcset="https://spaceninja.com/images/glass-onion-IgBiXeqfWf-300w.webp 300w, https://spaceninja.com/images/glass-onion-IgBiXeqfWf-400w.webp 400w, https://spaceninja.com/images/glass-onion-IgBiXeqfWf-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt11564570/"><em>Glass Onion</em></a></h2>
<blockquote>
<p>World-famous detective Benoit Blanc heads to Greece to peel back the layers of a mystery surrounding a tech billionaire and his eclectic crew of friends.</p>
</blockquote>
<p>Listen, I just want Ryan Johnson and Daniel Craig to keep making <em>Knives Out</em> films forever. This was the easiest possible sell for me. That said, there are some fucking hilarious pandemic jokes in here. When Kate Hudson showed up wearing a decorative mesh mask, Annie and I fucking lost it. Ed Norton plays a perfectly insufferable Elon Musk type, Dave Bautista nails the mens-rights YouTuber vibe, and Janelle Monáe is perfect.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/nope-03ouiGlWqb-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/nope-03ouiGlWqb-300w.webp 300w, https://spaceninja.com/images/nope-03ouiGlWqb-400w.webp 400w, https://spaceninja.com/images/nope-03ouiGlWqb-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt10954984/"><em>Nope</em></a></h2>
<blockquote>
<p>Residents in a lonely gulch of inland California bear witness to an uncanny, chilling discovery.</p>
</blockquote>
<p>I love Jordan Peele, but I’m not a huge horror movie fan, so I haven’t watched any of his other films, even though I’ve heard they’re excellent. This one came to me in a round-about way when the UFO community started excitedly sharing the trailer because it shows a UFO. I’m happy to say this movie is fantastic. It’s creepy and unsettling without relying on jump scares, and features at least two scenes of Daniel Kaluuya looking at some crazy shit happening and just shaking his head, saying “nope” and refusing to engage.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/palm-springs-DEUbYs-NEk-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/palm-springs-DEUbYs-NEk-300w.webp 300w, https://spaceninja.com/images/palm-springs-DEUbYs-NEk-400w.webp 400w, https://spaceninja.com/images/palm-springs-DEUbYs-NEk-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt9484998/"><em>Palm Springs</em></a></h2>
<blockquote>
<p>Stuck in a time loop, two wedding guests develop a budding romance while living the same day over and over again.</p>
</blockquote>
<p>If the words “Andy Samberg meets Cristin Milioti at a wedding and is hunted by J.K. Simmons while stuck in a Groundhog Day time loop” aren’t enough to make you watch this, I don’t know what else I can say.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/the-sunlit-night-0c8dvIdp2g-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/the-sunlit-night-0c8dvIdp2g-300w.webp 300w, https://spaceninja.com/images/the-sunlit-night-0c8dvIdp2g-400w.webp 400w, https://spaceninja.com/images/the-sunlit-night-0c8dvIdp2g-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt8368394/"><em>The Sunlit Night</em></a></h2>
<blockquote>
<p>An aspiring painter meets eccentric locals and a fellow New Yorker while working on a barn in Norway.</p>
</blockquote>
<p>Jenny Slate takes an apprenticeship with a cranky painter in Norway, where she meets a sad boy and learns about herself. It sounds awful and trite, but it’s lovely and hopeful. Also starring Zach Galifianakis as a Viking and Gillian Anderson as a Russian. I watched this and then immediately watched it again with Annie.</p>
</div>
</li>
<li class="media-list__item">
<div class="media-list__media">
<img alt="" class="" decoding="async" loading="lazy" src="https://spaceninja.com/images/rrr-r10x46LuNC-300w.webp" width="500" height="750" srcset="https://spaceninja.com/images/rrr-r10x46LuNC-300w.webp 300w, https://spaceninja.com/images/rrr-r10x46LuNC-400w.webp 400w, https://spaceninja.com/images/rrr-r10x46LuNC-500w.webp 500w" sizes="(min-width: 48em) 250px, (min-width: 40em) 200px, (min-width: 32em) 150px, 100px">
</div>
<div class="media-list__content">
<h2><a href="https://www.imdb.com/title/tt8178634/"><em>RRR</em></a></h2>
<blockquote>
<p>A fictional history of two legendary revolutionaries’ journey away from home before they began fighting for their country in the 1920s.</p>
</blockquote>
<p>I can think of no better way to recommend this movie than to show you this video:</p>
<iframe class="is-standard-width" width="560" height="315" src="https://www.youtube.com/embed/SbT3fKt80k8?si=CPlW3V1pBVKwzXfy" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen=""></iframe>
<p>If you liked that (And how could you not? Suspender dance!) the rest of the movie also includes tiger fights, bromance, and revenge.</p>
</div>
</li>
</ul>