<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Jonnie Hallman (@destroytoday)</title>
        <link>https://destroytoday.com/feeds/all</link>
        <description>An indie design engineer based in Brooklyn.</description>
        <lastBuildDate>Sun, 12 Apr 2026 11:46:25 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/nuxt-community/feed-module</generator>
        <item>
            <title><![CDATA[Eating my own dogfood again]]></title>
            <link>https://destroytoday.com/blog/eating-my-own-dogfood-again</link>
            <guid>https://destroytoday.com/blog/eating-my-own-dogfood-again</guid>
            <pubDate>Sat, 11 Apr 2026 11:59:00 GMT</pubDate>
            <description><![CDATA[Now that I’m freelancing full-time, and using Cushion again, I’ve fallen in love with it all over again, but I also feel every rough spot.]]></description>
            <content:encoded><![CDATA[<p>Now that <a href='/blog/going-independent-again'>I’ve gone independent</a> after a 6+ year hiatus in the full-time world, I’m now freelancing again, which also means I’m actively using <a href="https://cushionapp.com">Cushion</a> again. At first, I only needed to track time and invoice for <a href='/blog/animating-quines-for-larva-labs'>a friend project</a>, but now that I’m 100% on my own, I need to make sure I’m on track financially for the year—that’s where Cushion really clicks. </p><p>As soon as I started using Cushion again as a full-time freelancer, I fell in love with it all over again. I also realized that it still holds up after all these years! As a returning <em>user</em>, however, I immediately noticed a few rough spots since I last freelanced. These are the kind of rough spots that I’d otherwise miss if I weren’t a user of my own app—and I did miss them. After reminding myself that I can actually fix the rough spots that I come across, I spent an evening smoothing them out. </p><p>The next day, I used Cushion as I normally would, but with the usual pain points no longer there. I felt an instant jolt of delight when an especially cumbersome flow in my daily routine was reduced to a single click. If I were a regular user, I would’ve never bothered to reach out to support to mention the extra clicks in this flow, but I’d still feel them chipping away at me. Rough spots like this could’ve worn me down enough that I might eventually fall <em>out</em> of love with the app. But because I work on Cushion—and now use it regularly—as soon as I feel a rough spot myself, I want to fix it immediately because I know other users feel it, too, …but not enough to report it. This prompted me to send a message to my users, giving them the chance to share any rough spots they experience in their day-to-day use of Cushion, but haven’t bothered to mention.</p><p>Unsurprisingly, I heard back from a handful of users with plenty of rough spots to keep me busy for a while. The biggest surprise, however, was the number of users who replied that they couldn’t think of any. (Some Cushion users are too nice to give it to me straight!) While I appreciate the kindness, I’m craving feedback right now. I spent the next few days recording all the rough spots into a to-do list. Now I have the low-hanging fruit I need to build some momentum as I get back in the saddle. </p><p>I’m not a “Let’s fucking go!” guy, but now would be the time to say it.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Eating%20my%20own%20dogfood%20again">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Animating Quines for Larva Labs]]></title>
            <link>https://destroytoday.com/blog/animating-quines-for-larva-labs</link>
            <guid>https://destroytoday.com/blog/animating-quines-for-larva-labs</guid>
            <pubDate>Tue, 16 Dec 2025 14:00:00 GMT</pubDate>
            <description><![CDATA[A collaboration with my friends, Matt Hall and John Watkinson, on their generative art project that blurs the line between its code and the art it produces.]]></description>
            <content:encoded><![CDATA[<figure
            class='
              Image
              isWide
              
              
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?fm=webp&w=1440&q=80'>
            <img
              alt='Quine collection'
              src='https://images.ctfassets.net/zi79s2th73f3/7eefFO0pxE9KocqUIufVAM/707f3b204cd6b70e4e7865a6470b4d74/quine-collection.png?w=1440&q=80'
              width='720'
              height='376'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>“Quine” by Larva Labs</p>
          </figcaption>
          </figure><p>A couple months ago, when I was first planning to <a href='/blog/going-independent-again'>go independent again</a>, I ran into my friend, John Watkinson, on the walk home from my studio. John is half of <a href="https://larvalabs.com">Larva Labs</a> along with my good friend, Matt Hall, and they’re responsible for industry-defining projects, like <a href="https://www.larvalabs.com/cryptopunks">CryptoPunks</a> and <a href="https://www.larvalabs.com/autoglyphs">Autoglyphs</a>. I actually sat next to them when they were first working on CryptoPunks, and they asked if I wanted to have any. I remember telling them that I was too busy working on <a href="https://cushionapp.com">Cushion</a> to be bothered. So that was cool. In any case, we caught up for a bit and I mentioned my plans to return to freelance life. John had an immediate lightbulb reaction, as if this was perfect timing—and it was. It turns out Matt and John were weeks away from announcing a new project, and they were looking for someone to animate it.</p><figure
            class='
              Image
              isWide
              hasShadow
              
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?fm=webp&w=1440&q=80'>
            <img
              alt='Quine code'
              src='https://images.ctfassets.net/zi79s2th73f3/1lIF4dmV28XAglerHJ6mAV/03a80c8b4e1d89c553a38d8b1f6fa4d1/quine-code-zoom.png?w=1440&q=80'
              width='720'
              height='605.343396226415'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>A quine at 100% scale</p>
          </figcaption>
          </figure><p>Matt invited me over to their studio, where they told me about <a href="https://larvalabs.com/quine">Quine</a>. In their words, “Quine is a generative art project that blurs the line between its code and the art it produces.” At first, these quines just look like cool procedurally-generated pixel art. And, while that’s true, if you look closer, you’ll see that there’s real code embedded within each quine. Matt explained to me that if you extract the code and run it, it’ll generate the next variation of that quine’s sequence. I was immediately intrigued from that concept alone, but it goes even deeper than that.</p><figure
            class='
              Image
              isWide
              
              
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?fm=webp&w=1440&q=80'>
            <img
              alt='Quine 3-gen example'
              src='https://images.ctfassets.net/zi79s2th73f3/5QnmPhSQ3pAr58ej0Azcem/6b4a9e5b806669324d8a53c32546427a/quine-3-gen.png?w=1440&q=80'
              width='720'
              height='437.78620166793024'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>Each quine has <a href="https://larvalabs.com/writing/2025-11-4-18-0/a-field-guide-to-quine">a number of characteristics</a>, including one that Matt and John refer to as its “quinity”. This can be a 3-Quine, a 5-Quine, a 7-Quine, etc. Basically, the quinity relates to the quine’s generation loop. For a 3-Quine, if you would repeatedly extract and run the code within each quine generation, after the third generation, it’ll loop back to the beginning. A 5-Quine will loop after the fifth generation, etc. As an engineer, I caught myself chuckling and shaking my head at how cool and clever this is. And, if you’re a sucker for collectibles, it gets even more interesting. Apparently, there are two very rare types of quines, known as a “Perfect-Quines” and “Pseudo-Quines”. Perfect-Quines, when executed, will only ever recreate themselves. And, Pseudo-Quines will generate an effectively infinite number of generations without ever looping. At this point, I went from intrigued to <em>hooked</em>.</p><p>So, where does animation come into play? If the written explanation of Quine had you seeing the math meme, you’re not alone. Sometimes the easiest way to explain something complex is through visuals. Matt and John thought that an animation simulating the generation process would go a long way to demonstrate the overall concept. That’s where I come in.</p><p>At its core, the code that generates and is embedded within each quine is JavaScript, and when executed, this code generates an SVG. Coincidentally, I happen to have a <em>ton</em> of experience animating SVGs from my time at <a href="https://stripe.com">Stripe</a>, so I’m right at home with this project. The SVGs that make up these quines, however, are <em>very</em> dense to say the least. Each quine is 1440x2560 pixels in dimension, and the squares that construct each quine are 14x14 pixels with a 2-pixel gap between them. This means that a single quine as an SVG could potentially have up to 14,400 <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;rect&gt;</code> elements. On top of this, each quine could also include between 3,500 and 4,500 <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;tspan&gt;</code> elements from the code printed within it. For anyone familiar with SVG performance, this is <em>a lot</em> of elements to display, let alone animate. I didn’t waste any time even considering we stick with SVGs before immediately switching my attention to <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;canvas&gt;</code>—the hardware-accelerated HTML element that can animate thousands of shapes without breaking a sweat. Even though Canvas can run circles around SVGs, I still felt compelled to do some performance testing.</p><figure
            class='
              Video
              isWide
              hasShadow
              
            '
          >
            <div style="max-width: 1920px; max-height: 1080px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 56.25%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/1BO5ZoVZXYyjIwFKmHBSZI/7a3ca77303651e6c2cfc27f7516f279c/quine-perf-test-crop.jpg"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/L00DFtszBQqtbK8lYg4w8/d57c785e4680aaef74cca49904a8b26b/quine-perf-test-crop.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/L00DFtszBQqtbK8lYg4w8/d57c785e4680aaef74cca49904a8b26b/quine-perf-test-crop.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Performance testing</p>
          </figcaption>
          </figure><p>I started by testing the extreme—animating all the shapes individually. I had no intention of actually running with this approach, and I knew it would perform the slowest, but I still wanted to establish a baseline to gauge <em>how</em> slow this would be—surprisingly, not bad! I then tried animating the text that would represent the code. Instead of animating the positions again, I focused on transforming scale. Again, not bad! Next, I tested the more realistic approach—animating elements as groups. This would certainly improve performance because I’m not only animating a single layer, but I’m also avoiding the iteration loop on every element. Lastly, I tested animating groups of elements that are layered behind text. Because of the flat nature of <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;canvas&gt;</code>, I didn’t expect this to factor into performance at all, but I was still curious to see it as a disorienting visual!</p><figure
            class='
              Video
              isWide
              
              hasBorder
            '
          >
            <div style="max-width: 2620px; max-height: 1080px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 41.221374045801525%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/6Peys8I2Lzna5414BKybpY/6a6c8e8b9cafb86e05072c5b36a661e4/image.png"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/5hqaX4IhILg8WQ6WkuG0dk/6382f5bdbfe46e3559922f49b4c4f196/quine-printing-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/5hqaX4IhILg8WQ6WkuG0dk/6382f5bdbfe46e3559922f49b4c4f196/quine-printing-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine printing variations</p>
          </figcaption>
          </figure><p>Once I had a better grasp on performance, I moved onto the actual animation. When thinking about the quine generation process, I immediately imagined a quine being “printed,” like an inkjet printer—line by line in a single direction. From the performance tests involving layered groups, I also imagined printing individual colors as separate passes, like with screen printing. Combining the two metaphors, I iterated through several variations:</p><ul><li><p>single direction linear passes where the “ink” carries with the squeegee until it reaches its spot</p></li><li><p>single direction passes with easing</p></li><li><p>bi-directional passes with easing</p></li><li><p>single direction linear passes with a printer line that reveals each row</p></li></ul><p>I found myself most drawn to the mechanical approach—slow and linear. I wanted the quine to emerge from each layer with a sense of anticipation. </p><figure
            class='
              Video
              isWide
              hasShadow
              
            '
          >
            <div style="max-width: 2608px; max-height: 1440px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 55.21472392638037%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/4D3IQsvwgIVtVRcoHOv3zh/207f04174879141210fe96217268e692/image.png"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/3Tidl5OI6u0MHjxbHDBhGd/bad99afd3cf03b87e327d7389beb0d3f/quine-text-60fps-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/3Tidl5OI6u0MHjxbHDBhGd/bad99afd3cf03b87e327d7389beb0d3f/quine-text-60fps-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine code inversion</p>
          </figcaption>
          </figure><p>Now that I had the movement dialed in, I zoomed in on the detailed part of the quine—the code within it. Originally, I “printed” the code together with its squares, but this buried the lede too much. By printing them together, they held equal weight despite the code being the reason the quine even existed. And to the untrained eye, you might even miss the code altogether if it’s printed along with the squares. Thinking back to the concept of layers, I decided to try printing the code first—on its own layer—to emphasize that it can stand alone and that the viewer should notice it. Only then do I start printing the layers of squares to combine the two. In doing so, I introduced another transition opportunity—the code inverting itself as the squares are printed. With this visual, we really drive home the message that there’s code within the quine.</p><figure
            class='
              Video
              isWide
              
              hasBorder
            '
          >
            <div style="max-width: 1920px; max-height: 1440px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 75%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/38HGIflioTZPUDsFbyUbWk/fe2e1252eb97226a3c4c3e9206bdf847/image.jpeg"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/6BqKFeGeVIdtSGJiXDlbsc/e5b24672684d50e34575db77160818f0/quine-scan-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/6BqKFeGeVIdtSGJiXDlbsc/e5b24672684d50e34575db77160818f0/quine-scan-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine scanning animation</p>
          </figcaption>
          </figure><p>With the printing animation behind me, it was time to move onto the <em>scanning</em> animation. I already highlighted that there’s code within the quine, but I also needed to communicate that the code is both meaningful and legible—not just a random snippet. People typically interact with code using an IDE, so why not include one in the demonstration? Taking a page out of the text streaming effect often seen in AI chat interfaces, I decided to combine this effect with a faux “scanner” that passes over the original quine. As simple and straightforward as this is, it gets the point across that the code within the quine is actually real <em>and</em> recognizable when displayed in a familiar format and environment.</p><figure
            class='
              Video
              isWide
              
              hasBorder
            '
          >
            <div style="max-width: 2560px; max-height: 1440px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 56.25%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/4kjuQQgNYanKeL2F1uBLlr/a0c9104fa7bba32a2862de976cddfdf8/quine-second-generation-poster.jpeg"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/6C13auvV7WGQLsR9nPALrz/2dd10c98bfae852009557e9c1e46e95f/quine-2nd-generation-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/6C13auvV7WGQLsR9nPALrz/2dd10c98bfae852009557e9c1e46e95f/quine-2nd-generation-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine generation animation</p>
          </figcaption>
          </figure><p>Now that I had printing and scanning animations in a good place, it was time to combine the two. I would start with the printing animation for the original quine generation, but then I’d reuse it for the next generation, in parallel with the scanning animation. Even though I built these animations separately, they locked into place so well that I had no notes. The overall animation continued to write itself as long as I continued to lean on the analog metaphors. Print the quine. Scan the quine. Scanning the quine prints the quine.</p><figure
            class='
              Video
              isWide
              hasShadow
              
            '
          >
            <div style="max-width: 1920px; max-height: 1080px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 56.25%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/gcYV0DqZJFOS6fY9dpsB8/f0389dc5d4c6f07a6ecbd39f7187ded1/quine-interview-poster.jpeg"
                  controls="controls"
                  false
                  false
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/24aOLpy2m8vTGVuhImWvAM/32e9f1fec3ed46ec4ebb9e9480160977/quine-interview-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/24aOLpy2m8vTGVuhImWvAM/32e9f1fec3ed46ec4ebb9e9480160977/quine-interview-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>The final Quine announcement video (animation at 0:52)</p>
          </figcaption>
          </figure><p>Upon screen recording the animation, I had my deliverable—an animation that would be used as b-roll for Matt and John’s announcement video. And, thanks to the animation being in video format instead of live and interactive, I didn’t need to worry about all of the typical considerations that I’m used to, like responsiveness, performance on slower machines, compatibility with different browsers, or what even happens next. I could focus on a single animation that visualizes the core concept while Matt voices over the it with the verbal explanation. Larva Labs announced Quine on <a href="https://www.artblocks.io/exhibitions/quine-by-larva-labs">Art Blocks</a>, where they also held their auction for 477 of the 497 quines. The auction was another success with the sale closing at 7.56 ETH ($31,000) per Quine NFT.</p><hr/><p>Once the dust settled from the auction, Larva Labs reached out again regarding an extension of the project. In a few weeks, they would be displaying Quine in gallery format at <a href="https://www.artbasel.com/miami-beach">Art Basel</a> in Miami. In addition to framed prints of several quines on the walls and a massively long table with a grid of every quine, they would also have a 4K TV to,  again, aid in visually demonstrating the concept, but as a looping video this time. The video format deliverable still saved me from the considerations that go into a live animation, but now I needed to “upgrade” the animation. Instead of finishing the animation after scanning and printing once, it would need to generate an entire quine’s sequence with transitions, automatically proceed to the next quine, then loop back to the beginning when finished. John provided me with a list of 10 quines, with quinities ranging from 3 to 11. This would result in an animation that is 8 minutes and 28 seconds in length.</p><figure
            class='
              Video
              isWide
              
              hasBorder
            '
          >
            <div style="max-width: 2560px; max-height: 1440px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 56.25%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/1GVU0DQZ1VxP0TwzbUHWip/3547c1693023f192a34f7843c9bb80e8/quine-sequence-poster.jpeg"
                  controls="controls"
                  false
                  loop="loop"
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/3jYnkJUyEFJ7qlhW1l2RB3/5e23f195808ec2fe5815aac3793e7ace/quine-sequence-o.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/3jYnkJUyEFJ7qlhW1l2RB3/5e23f195808ec2fe5815aac3793e7ace/quine-sequence-o.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine sequence animation</p>
          </figcaption>
          </figure><p>Luckily, the layout of the quine generation flow, along with my intuition for how it should move, led me to nail it on the first try. Upon scanning and printing the next generation in a quine’s sequence, the two generations and its “code editor” would slide and fade, so the generated image would become the source image, which would get scanned and print the following generation. In theory, I could let the animation run uninterrupted and it would loop forever because my actual animation code literally extracts the quine’s code and uses it to generate the next animation. A generator of generators, itself.</p><figure
            class='
              Video
              isWide
              hasShadow
              
            '
          >
            <div style="max-width: 1920px; max-height: 1080px; margin: 0 auto;">
              <div class="Video__container" style="padding-bottom: 56.25%">
                <video
                  poster="https://images.ctfassets.net/zi79s2th73f3/3FjPob6h0nTQ2BmB7aP32h/6e536b3105972740b44c11517ab672a7/quine-art-basel-poster.jpeg"
                  controls="controls"
                  false
                  false
                  tabindex="0"
                  onload="this.dataset.jsLoaded = ''"
                >
                  <source src="https://videos.ctfassets.net/zi79s2th73f3/680euiqB1C8xoupSjAwBkT/f5a5ee3f43730fe1211f47160f1f4e76/quine-art-basel.mp4" type="video/mp4">
                  Video tag not supported. Download the video <a href="https://videos.ctfassets.net/zi79s2th73f3/680euiqB1C8xoupSjAwBkT/f5a5ee3f43730fe1211f47160f1f4e76/quine-art-basel.mp4">here</a>.
                </video>
              </div>
            </div>
            <figcaption>
            <p>Quine at Art Basel (animation at 0:05, 0:19, and 0:40)</p>
          </figcaption>
          </figure><p>It might go without saying, but I absolutely loved working on this project. After years of solely working on product and web, life presented me with an opportunity to return to the creative code world—in a practical sense. I also got to collaborate with close friends I haven’t jammed with since the last time I freelanced in 2018. This truly felt like the perfect reintroduction to independent life, and it was all because I bumped into John on the walk home from the studio that night. </p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Animating%20Quines%20for%20Larva%20Labs">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Going independent again]]></title>
            <link>https://destroytoday.com/blog/going-independent-again</link>
            <guid>https://destroytoday.com/blog/going-independent-again</guid>
            <pubDate>Fri, 12 Dec 2025 18:30:00 GMT</pubDate>
            <description><![CDATA[I’m returning to independent life after a long stint cosplaying an employee in the full-time world.]]></description>
            <content:encoded><![CDATA[<p>After 6+ years with a consistent salary and good healthcare, I’m taking the leap again. While I thoroughly enjoyed the stability and benefits of a full-time job—especially after <a href='/blog/burning-out-and-finding-stability'>burning out</a>—the timing for a change has never felt more right and I’m just itching to get back out there. I can feel it in my bones and I’m ready. But when I say “taking the leap” or “getting back out there”, I mean it in a way that’s more like a <em>return</em> to independent life than a departure from job life. A return to my natural environment.</p><p>If I’m being honest, I’ve always felt like an imposter at a full-time job—a freelancer in full-timers’ clothing. Not needing to pay for healthcare, my laptop, or lunch was surreal at first, like I was getting away with something. Holidays counted as actual time off instead of regular workdays without distraction. Tax season was the push of a button compared to the soul-crushing marathon that I’d brace for each year. To a freelancer, full-time life felt like an all-inclusive resort.</p><p>Over the years, I definitely embraced this more stable way of life, but it never felt real to me. In the back of my mind, I knew I’d someday return to independent life. As much as I wanted to fully commit myself to a company—no matter how successful it might be—it would never come close to the excitement I have for my own ideas. This excitement is a palpable energy that’s impossible to hide. The thought that I could someday support myself by growing one of my own ideas into a sustainable career is a constant daydream that feels almost painfully attainable.</p><p>I’ve had this thought before, and I came up short, but this time feels different. I have an additional decade of experience under my belt, an exponentially larger network than before, and a real hunger to jam with startups again. We also live in a time where being independent isn’t as isolating or limiting as it once was. And, most importantly, I have an idea that makes me feel like I’m going to burst out of my skin every time I think about it. Without a doubt in my mind, this <em>is</em> the time. This is <em>my</em> time. I’m so ready to run it back.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Going%20independent%20again">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Cleaning up the cobwebs]]></title>
            <link>https://destroytoday.com/blog/cleaning-up-the-cobwebs</link>
            <guid>https://destroytoday.com/blog/cleaning-up-the-cobwebs</guid>
            <pubDate>Thu, 29 Aug 2024 12:35:00 GMT</pubDate>
            <description><![CDATA[After years of side-eyeing an unreleased feature that has been nagging me while slowing down the app, I finally take the time to remove it from the app.]]></description>
            <content:encoded><![CDATA[<p>Lately, I’ve felt the need to be proactive about cleaning up parts of <a href="https://cushionapp.com">Cushion</a> that either no longer serve a purpose, slow the app down, or both. Top on this list for a while sits a feature that I actually never released out of a (very) private beta—only five-ish users were trying it out and this was years ago. The feature was a swing-for-the-fences to potentially grow Cushion exponentially through what folks call “network effect” in the startup biz. It didn’t work out for a number of reasons—and I’m glad it didn’t—but its footprint touched so much of the app that by the time I was ready to remove it, I knew it’d be a significant undertaking. The feature is what I call “collaboration”.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?fm=webp&w=1440&q=80'>
            <img
              alt='Kill collaboration screenshot'
              src='https://images.ctfassets.net/zi79s2th73f3/xIT3DVhGLveRs0IZatxR3/6fd4c2607bfd0f4738ed0334b5e78aa9/Screenshot_2024-09-06_at_8.42.35_AM.png?w=1440&q=80'
              width='720'
              height='336.5217391304348'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>Collaboration in Cushion was like “teams for freelancers”, but there were no dedicated teams. Any user could invite any <em>other</em> user to a project. Or they could invite a non-Cushion  user to a project solely to track their time, like a subcontractor. Then when it came time to create an invoice, they could include their own tracked time along with the subcontractors’. The original user would simply pay extra for “seats” for their collaborators, and that would be an extra $5 per seat per month or so. By inviting subcontractors to a project, this would potentially encourage more non-Cushion users to start using Cushion. This is that “network effect” I mentioned, combined with a way to get more monthly recurring revenue out of a user—especially since a handful of folks have begged for multiple users in the past.</p><p>On paper, it sounded like a great idea that makes complete sense. In the implementation, I think we went about it the wrong way both on the backend and on the frontend. Instead of taking the straightforward approach of introducing a separate parent “team” model that could have many users and be the owner of the shared projects, we decided to shoehorn the database associations for collaboration into the existing user model. With this approach, a user could have “members” (users) and “readable” and “editable” datasets for anything that’s shared. Then for anything created, like time tracking entries or workloads, the user ID for these would be the member, but model would also be tied to the parent user. <em>This</em> led to an entire layer of complexity added to almost every database query that involved these models. And <em>that</em> led to slower database queries for <em>everyone</em> in the app—not only folks using collaboration.</p><p>On the frontend, “collaboration” was its entirely own section with subsections for all the existing top-level sections—scheduling, time-tracking, etc. This essentially doubled the footprint of the app, but in a way that <em>felt</em> duplicated instead of built-in or well-thought-out. Again, if I had only followed the straight-forward approach of a team-like model where you could view different workspaces, ala Slack, it would be much more straightforward—especially if one of those workspaces is your own personal one. </p><p>In hindsight, the correct solution seems so simple, but I need to remember that six years of time and experience has accumulated since then. Of course, it seems simple now, but it didn’t then—especially in the stressful situation of needing a home run. I can’t knock myself for that, but on the glass-half-full perspective, at least I didn’t actually launch collaboration, get loads of folks to rely on it, then realize it wasn’t right for the app. And, if I ever wanted to revisit the concept with a more straightforward approach, I now have the wherewithal to do it in steps. First, I’d introduce the concept of workspaces for individual users, so they could have multiple businesses. <em>Then</em>, I could introduce the ability to invite other users to those workspaces. <em>That</em> would be the correct approach. </p><p>As much as I want to give it another shot, I first need to make Cushion perfect for the individual. Then I could reconsider collaboration. With this in mind, I’m determined to be much more consistent with user research going forward, so I could polish all the rough spots that nag folks on a regular basis. If you’re eager to share your thoughts, I’ve re-enabled chat in the app. Hit me up!</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Cleaning%20up%20the%20cobwebs">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a table for the nth time - Part 2]]></title>
            <link>https://destroytoday.com/blog/building-a-table-for-the-nth-time-part-2</link>
            <guid>https://destroytoday.com/blog/building-a-table-for-the-nth-time-part-2</guid>
            <pubDate>Wed, 10 Jan 2024 13:23:00 GMT</pubDate>
            <description><![CDATA[Following up on the initial post about building a table in Cushion, I actually detail my approach this time.]]></description>
            <content:encoded><![CDATA[<p>In the <a href='/blog/building-a-table-for-the-nth-time'>last post about tables</a>, I revisited all the tables I built for <a href="https://cushionapp.com">Cushion</a> over the past 10 years and described both the tech and approach I used. These tables relied on the tech that was available to me at the time, which resulted in tables built with jQuery, CoffeeScript, Angular, and old versions of Vue. Now that I’ve been using Vue 3’s Composition API and TypeScript for several years, I’m especially comfortable and confident in this most recent approach (which is always the way it should be).</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 3 table'
              src='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1332&q=80'
              width='666'
              height='379.3157360406091'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>When I first started building the table, I knew I wanted to lean on markup as much as I could. I’m a purest at heart, so I prefer writing HTML when I need HTML and CSS when I need CSS. If you read the previous post, I’ve certainly had my fair share of “clever” approaches to rendering HTML, like concatenating strings, and I’m <em>so</em> over that phase of my engineering life. Luckily, Vue 3 is incredibly fast when it comes to rendering, so I don’t need to worry about performance the way I did with Angular back in the day. Now, I can just write the markup, wire up the data, and expect instant rendering.</p><p>Since I knew I’d be reusing this table throughout Cushion, I decided to make a “base” table that I could use across clients, projects, and invoices, but I wanted to make sure it was specific to “items”—not an entirely generic base table. I’ll explain. There’s bound to be areas where I need a table that doesn’t look like the tables for invoices, etc., and doesn’t use the kind of data that goes into an invoice table, so instead of making a <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;Table&gt;</code> component and calling it a day, I opted for an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTable&gt;</code> that’s more intentional in its use and infers that this is a table for “items”. (I admit “item” is an incredibly ambiguous word here, but I strangely don’t like using the word “model”, which is what I’m referring to—a model with an ID.)</p><p>From here, I was able to build all the child components for an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>ItemTable</code>, which include an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableCell&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;td&gt;</code>), <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableHeader&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;th&gt;</code>), and <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableRow&gt;</code> (or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;tr&gt;</code>). These components are intentionally primitive because they’re meant to be extended. Within the components themselves, however, they’re styled to the design of the item table. They also handle all the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>aria-role</code> attributes, so I can maintain accessibility while ejecting from the table-based <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>display</code> styles. With these low-level components, I can… </p><ul><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTable&gt;</code> to make an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTable&gt;</code> that takes an array of invoices and renders them as invoice rows</p></li><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableRow&gt;</code> to make an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTableRow&gt;</code> that takes an invoice and renders the relevant cells</p></li><li><p>extend the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;ItemTableCell&gt;</code> to make a collection of cells to handle any formatting needs, like currency, dates, durations, etc. </p></li></ul><p>All of this combined lets me easily compose tables while compartmentalizing their logic and styling. I no longer need a long config object full of callbacks to determine everything, like in past attempts. I do still have an initial config, but it’s limited—in a good way—and makes much more sense now through the use of composables (or hooks in React).</p><p>The last time I rebuilt this table, in 2017, the concept of composables didn’t even exist. Now, I’m able to configure a table with a `useTable` composable that takes an array of columns (for config), an array of rows (for data), and an optional order (column name and direction), then returns reactive arrays for the filtered columns and sorted rows. 

<pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  columns<span class="token operator">:</span> <span class="token punctuation">[</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Color"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token function-variable function">sortKey</span><span class="token operator">:</span> <span class="token punctuation">(</span>invoice<span class="token punctuation">)</span> <span class="token operator">=></span> invoice<span class="token punctuation">.</span>client<span class="token operator">?.</span>color <span class="token operator">||</span> <span class="token string">"#bbb"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"String"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"number"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"String"</span><span class="token punctuation">,</span> <span class="token function-variable function">sortKey</span><span class="token operator">:</span> <span class="token punctuation">(</span>invoice<span class="token punctuation">)</span> <span class="token operator">=></span> invoice<span class="token punctuation">.</span>client<span class="token operator">?.</span>name <span class="token operator">||</span> <span class="token string">"(no client)"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Created"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"created_at"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Updated"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"updated_at"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Sent"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"sent_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Due"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"due_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Paid"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Date"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"paid_on"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token punctuation">{</span>name<span class="token operator">:</span> <span class="token string">"Amount"</span><span class="token punctuation">,</span> type<span class="token operator">:</span><span class="token string">"Currency"</span><span class="token punctuation">,</span> sortKey<span class="token operator">:</span> <span class="token string">"total"</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
  <span class="token punctuation">]</span><span class="token punctuation">,</span>
  rows<span class="token operator">:</span> invoices<span class="token punctuation">,</span>
  order<span class="token operator">:</span> <span class="token punctuation">{</span>column<span class="token operator">:</span> <span class="token string">"Amount"</span><span class="token punctuation">,</span> direction<span class="token operator">:</span> <span class="token string">"Desc"</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><p>The config for the columns includes the column name as well as its type and sort key, which is either a key on the model or a callback to dig deeper. Callbacks are handy for specific cases where the sort key isn’t the raw property value. In Cushion’s case, sorting by color—which is a thing—requires that I convert the hex colors into HSL colors and sort by hue. As for “type”, I’m especially excited about this approach because it removes so much manual work. As an example, if a column is of type “date” or “currency”, the table knows to align those columns to the right and narrow their widths. Or, for a color-typed column, I could actually pass the raw hex color as the sort key, like above, and the column could know to automatically convert it to a hue before sorting.</p><p><pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  <span class="token operator">...</span>
  includedColumns<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> <span class="token string">"Amount"</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><p>If anyone’s especially curious, you might be wondering why this composable would need the array of columns only to return them again. This is because it also takes an <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>includedColumns</code> property, which is an array of the column names to show. In Cushion, there are actually three invoice tables under the “Invoices” tab—“Drafts”, “Invoiced”, and “Paid”. All of these tables render invoice rows, but each of these tables show different columns that are relevant to the invoices’ status (e.g., a due date for the “Invoiced” table and a paid date for the “Paid table”).</p><p><pre class='language-ts'><code><span class="token keyword">const</span> <span class="token punctuation">{</span> columns<span class="token punctuation">,</span> rows <span class="token punctuation">}</span> <span class="token operator">=</span> <span class="token function">useTable</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  <span class="token operator">...</span>
  includedColumns<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"Color"</span><span class="token punctuation">,</span> <span class="token string">"Number"</span><span class="token punctuation">,</span> <span class="token string">"Client"</span><span class="token punctuation">,</span> <span class="token operator">...</span>props<span class="token punctuation">.</span>includedColumns<span class="token punctuation">,</span> <span class="token string">"Amount"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
  order<span class="token operator">:</span> props<span class="token punctuation">.</span>order<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre></p><p>In the past, these were three separate tables, which meant a lot of copy/pasting and reusing code on a column-by-column basis. This time around, however, I’m actually thrilled with the idea of using a single <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;InvoiceTable&gt;</code> component that by default includes <em>all</em> of the possible columns that an invoice table could have. Then, I can specify which columns to show—solely by name—as well as which column to sort by. This makes the actual implementation of each invoice table <em>incredibly</em> simple because the code is only several lines of markup whereas before each table had its own file with a full config. Also, now that Vue <a href="https://github.com/vuejs/rfcs/discussions/436">supports generically typed props</a>, composing these tables is much easier and type-safe because the table knows which columns and specific model it supports.</p><p><pre class='language-html'><code><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>InvoicesTable</span>
  <span class="token attr-name">title</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Paid<span class="token punctuation">"</span></span>
  <span class="token attr-name">emptyMessage</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>No paid invoices<span class="token punctuation">"</span></span>
  <span class="token attr-name">:invoices</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>paidInvoices<span class="token punctuation">"</span></span>
  <span class="token attr-name">:includedColumns</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>['Sent', 'Paid']<span class="token punctuation">"</span></span>
  <span class="token attr-name">:order</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>{column: 'Paid', direction: 'Desc' }<span class="token punctuation">"</span></span>
<span class="token punctuation">/></span></span></code></pre></p><p>While I’m really happy about this approach, I admit I’ve only dealt with rendering so far, which is the first step, but it’s the easy one. Next up, I’ll need to dive into interactions, like context menus and drag-and-drop. I’m not worried about these, but I know full well that they’re pivotal decision moments. Can I still maintain a “pure” approach that’s clean, reusable, and intuitive? I think so! And that’s my goal.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Building%20a%20table%20for%20the%20nth%20time%20-%20Part%202">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to use Playwright with GitHub Actions for e2e testing of Vercel preview deployments]]></title>
            <link>https://destroytoday.com/blog/how-to-use-playwright-with-github-actions-for-e2e-testing-of-vercel-preview</link>
            <guid>https://destroytoday.com/blog/how-to-use-playwright-with-github-actions-for-e2e-testing-of-vercel-preview</guid>
            <pubDate>Tue, 02 Jan 2024 14:00:00 GMT</pubDate>
            <description><![CDATA[I spent way too long trying to find out how to set up e2e testing with Vercel preview deploys, so I wrote a quick post to save others.]]></description>
            <content:encoded><![CDATA[<p>I normally wouldn’t write such a quick technical post with barely any story involved, but I spent an entire night trudging through outdated Stack Overflow answers, conflicting documentation, and surprisingly limited search results, then I stumbled upon <a href="https://vercel.com/guides/how-can-i-run-end-to-end-tests-after-my-vercel-preview-deployment">this Vercel guide</a> that seemed like it didn’t want to be found. To help steer the search results toward a simple solution that actually works, I feel obligated to contribute a post on how to use <a href="https://playwright.dev/">Playwright</a> with GitHub Actions for e2e testing of <a href="https://vercel.com/docs/deployments/preview-deployments">Vercel preview deployments</a>.</p><p>Here’s the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>e2e.yml</code> workflow file that you need to add to your project’s <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>.github/workflows</code> directory (assuming you’re using Node v20 and <a href="https://pnpm.io/">pnpm</a>):</p><p><pre class='language-yaml'><code><span class="token key atrule">name</span><span class="token punctuation">:</span> End<span class="token punctuation">-</span>to<span class="token punctuation">-</span>end testing
<span class="token key atrule">on</span><span class="token punctuation">:</span>
  <span class="token key atrule">deployment_status</span><span class="token punctuation">:</span>
<span class="token key atrule">jobs</span><span class="token punctuation">:</span>
  <span class="token key atrule">e2e</span><span class="token punctuation">:</span>
    <span class="token key atrule">if</span><span class="token punctuation">:</span> github.event_name == 'deployment_status' <span class="token important">&amp;&amp;</span> github.event.deployment_status.state == 'success'
    <span class="token key atrule">timeout-minutes</span><span class="token punctuation">:</span> <span class="token number">60</span>
    <span class="token key atrule">runs-on</span><span class="token punctuation">:</span> ubuntu<span class="token punctuation">-</span>latest
    <span class="token key atrule">steps</span><span class="token punctuation">:</span>
    <span class="token punctuation">-</span> <span class="token key atrule">uses</span><span class="token punctuation">:</span> actions/checkout@v3
    <span class="token punctuation">-</span> <span class="token key atrule">uses</span><span class="token punctuation">:</span> actions/setup<span class="token punctuation">-</span>node@v3
      <span class="token key atrule">with</span><span class="token punctuation">:</span>
        <span class="token key atrule">node-version</span><span class="token punctuation">:</span> 20.x
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Install dependencies
      <span class="token key atrule">run</span><span class="token punctuation">:</span> npm install <span class="token punctuation">-</span>g pnpm <span class="token important">&amp;&amp;</span> pnpm install
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Install Playwright Browsers
      <span class="token key atrule">run</span><span class="token punctuation">:</span> pnpm exec playwright install <span class="token punctuation">-</span><span class="token punctuation">-</span>with<span class="token punctuation">-</span>deps
    <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> Run Playwright tests
      <span class="token key atrule">env</span><span class="token punctuation">:</span>
        <span class="token key atrule">BASE_URL</span><span class="token punctuation">:</span> $<span class="token punctuation">{</span><span class="token punctuation">{</span> github.event.deployment_status.environment_url <span class="token punctuation">}</span><span class="token punctuation">}</span>
      <span class="token key atrule">run</span><span class="token punctuation">:</span> pnpm exec playwright test
</code></pre></p><p>This should read pretty easily if you’re familiar with GitHub Actions, but it triggers upon a deployment status change and only runs Playwright if the deploy is successful.</p><p>In your Playwright config, point the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>use.baseURL</code> property to your <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>BASE_URL</code> environment variable, which represents the generated URL for the preview deployment:</p><p><pre class='language-ts'><code><span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token function">defineConfig</span><span class="token punctuation">(</span><span class="token punctuation">{</span>
  use<span class="token operator">:</span> <span class="token punctuation">{</span>
    baseURL<span class="token operator">:</span> process<span class="token punctuation">.</span>env<span class="token punctuation">.</span><span class="token constant">BASE_URL</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><p>Finally, push to GitHub. Assuming you have automatic deploys set up with Vercel and GitHub, you’ll see the results of your deploy in your pull request, then upon a successful deploy, the e2e workflow will start running—pointed at your preview deploy. It’s this easy. Don’t be like me and go down the outdated path of <em>waiting</em> for Vercel deploys or deploying <em>from</em> GitHub Actions. You don’t need that. And if you do, you don’t need this post.</p><p>Also, If Playwright seems to stall when running, it’s probably because you have <a href="https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/vercel-authentication">Vercel Authentication</a> enabled in your Deployment Protection settings. You’ll either want to pay Vercel $150/mo for <a href="https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection/protection-bypass-automation">Protection Bypass for Automation</a>, or disable authentication. Unless you’re working on something super secret, disabling authentication is fine. And if it <em>is</em> super secret, you can afford $150/mo.</p><p>Hope this helps someone.</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20How%20to%20use%20Playwright%20with%20GitHub%20Actions%20for%20e2e%20testing%20of%20Vercel%20preview%20deployments">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a table for the nth time]]></title>
            <link>https://destroytoday.com/blog/building-a-table-for-the-nth-time</link>
            <guid>https://destroytoday.com/blog/building-a-table-for-the-nth-time</guid>
            <pubDate>Sat, 30 Dec 2023 14:01:00 GMT</pubDate>
            <description><![CDATA[Now that I have data to wire up, I set out to build a table component, but first I revisit past attempts.]]></description>
            <content:encoded><![CDATA[<p>Now that I have the <a href='/blog/a-return-to-tabbed-navigation'>navigation</a> roughed-in for the <a href='/blog/imagining-cushion-circa-2016-in-2024'>Cushion rethink</a>, I can start building my first feature-based view. Since the goal of this exercise is to return to Cushion’s prime—with a side-goal of building the perfect Cushion for <a href="https://www.jenmussari.com/">my wife</a>, a freelance lettering artist who wants to do admin work as little as possible—I’m focusing on invoicing and income tracking. These are the two aspects of freelancing that <em>every</em> freelancer needs to do, so it’s a solid target. When I think about where to start, I need to strip everything down and think in terms of usable milestones. While the visualizations are the most compelling part of Cushion’s design, they’re not the most useful on their own. These at-a-glance visuals can only tell you so much before you need to dive into the details. This leads me to the tables.</p><p>If I’m looking to build a working app sooner than later—and I’ve already proof-of-concept’d out the fun-yet-uncertain parts—then I can start with the boring-yet-essential parts. These tables are basically spreadsheets fed from the database. They ingest the data, display it, and add it all up in a way that’s useful for freelancers. At the end of the day, when a user is looking for a specific date, invoice amount, or payment status, they’re going to look at the tables first. Then, for a quick look at their progress over the year, they’ll check out the visualizations.</p><p>Believe it or not, I’ve actually built half a dozen versions of these tables for Cushion over the years, and what I’ve realized from revisiting the code is that my age and experience is directly correlated with my “cleverness”—or lack thereof. The younger I was when writing the code, the more roundabout my approach was. As I got older, my code became more straightforward. I’ve found that this applies to my code in general, but it was truly highlighted when revisiting the code for my tables.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/thicmYTyn9wkhNyg8W9ry/408eb0f3922f48401cb05c704e0346b9/2014-04-16-name-sort.png?fm=webp&w=425&q=80'>
            <img
              alt='2014-04-16-name-sort'
              src='https://images.ctfassets.net/zi79s2th73f3/thicmYTyn9wkhNyg8W9ry/408eb0f3922f48401cb05c704e0346b9/2014-04-16-name-sort.png?w=425&q=80'
              width='212.5'
              height='125.5'
              style='width: 100%; max-width: 212.5px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table component built with Vue.js circa 2014</p>
          </figcaption>
          </figure><p>The <a href='/blog/building-the-table-with-vue-js'>first table</a> I built for Cushion was actually written with Vue.js, but it never made it to production because I <a href='/blog/switching-to-angularjs'>switched to Angular</a> shortly after. Vue.js was brand new at the time and not battle-tested enough for me to have confidence in it yet. As a funny aside, I know for a fact that the journal post about this table was written a long time ago because its images weren’t even retina yet! </p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Angular directive table'
              src='https://images.ctfassets.net/zi79s2th73f3/7lJseagLY3yXv1kUGFxWgx/98f4b10cc4e025c91cff9f3a8bb0d56b/Screenshot_2023-12-30_at_6.29.52_PM.png?w=1332&q=80'
              width='666'
              height='404.05309734513276'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Angular and string concatenation circa 2014</p>
          </figcaption>
          </figure><p>The first table <em>that made it to production</em> is hilariously a single Angular file that takes a config object and renders the HTML by plus-equalling a string all the way down… Yeah, you heard right. I assume this was for performance reasons (<em>because Angular</em>), but it’s also complete madness. At the same time, the code is well-organized and pretty easy to grasp, but here’s the best way to put it: I wouldn’t want to see this code on the first day of my new job.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Angular coffeescript table'
              src='https://images.ctfassets.net/zi79s2th73f3/3btqJQmpaDqyb8C6G742hc/a749de3342c14d5cf52f9db7f65be055/Screenshot_2023-12-30_at_6.24.52_PM.png?w=1332&q=80'
              width='666'
              height='431.02153846153846'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Angular and lodash templating circa 2016</p>
          </figcaption>
          </figure><p>The next iteration of the table was a CoffeeScript class that gets extended and uses getter methods for everything. Again, too clever and requires a sherpa to find your way around the code, but at least I used templates this time (with <a href="https://lodash.com">lodash</a>) instead of manually concatenating a string. I did notice, however, that I completely abandoned semantics with this version by ditching <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> tags in favor of living in <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;div&gt;</code> city for easier styling. Little did I know you can have the best of both worlds with semantic tags, easy styling, and proper accessibility, but I didn’t learn that until years later.</p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 2 table'
              src='https://images.ctfassets.net/zi79s2th73f3/3IRDvmjz5nGUh2JpffzSY5/a5383fc5be9a693bb23c49003b4e9f93/Screenshot_2023-12-30_at_6.25.45_PM.png?w=1332&q=80'
              width='666'
              height='313.70467289719625'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Vue 2 Options API circa 2017</p>
          </figcaption>
          </figure><p>The most recent iteration of the table (albeit five years ago) uses Vue 2’s Options API and <a href="https://v2.vuejs.org/v2/guide/mixins.html">Mixins</a>. This approach had a lot going for it because of Vue’s templating and reactivity, which was a <em>huge</em> leap forward because I no longer needed to manually re-render the HTML with every change. I still relied heavily on config objects, but they were now <a href="https://v2.vuejs.org/v2/guide/computed.html">computed properties</a>, so I also didn’t need to directly call getter methods like before—I simply looped through the data and referenced the properties. </p><p>My overall gripe with this version was the use of mixins. I never loved mixins in Vue because it’s never clear what a mixin is doing under the hood unless you dig through the code. This makes troubleshooting and searching for specific references really difficult—especially when a mixin creates props or reactive data. This is also why I’m head-over-heels for Vue’s <a href="https://vuejs.org/guide/extras/composition-api-faq.html">composition API</a> because, combined with <a href="https://vuejs.org/guide/reusability/composables">composables</a> (aka “hooks”), everything you need to reference is exposed, and it feels like writing straight vanilla JavaScript—nothing is buried and nothing feels “magical” (in the way that you can’t understand or troubleshoot it).</p><p>Ok, phew. This brings us to today. I’m building a table for the nth time, but the scenario is different:</p><ul><li><p>I have 10 more years of experience under my belt</p></li><li><p>I now fully test the frontend, which helps to weed out “clever” code</p></li><li><p>I’m committed to semantic HTML and accessibility, so the markup will be “proper”</p></li><li><p>I’m using Vue 3 with the Composition API, which is the closest Vue’s syntax has gotten to clean ol’ vanilla JS</p></li></ul><p>I haven’t <em>finished</em> building the table, so this post is somewhat premature, but there was plenty of “history” to share before writing about the latest iteration. That said, I’m making great progress already. </p><figure
            class='
              Image
              
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=960&w=1332&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=960&w=1332&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1280&w=1332&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1280&w=1332&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?fm=webp&w=1332&q=80'>
            <img
              alt='Vue 3 table'
              src='https://images.ctfassets.net/zi79s2th73f3/nJKiKdGk0JMquuRuXCpA0/dea760f88ddae6b3c774e4ba1d748f5a/Screenshot_2023-12-30_at_11.28.46_PM.png?w=1332&q=80'
              width='666'
              height='379.3157360406091'
              style='width: 100%; max-width: 666px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            <figcaption>
            <p>Table built with Vue 3 Composition API circa 2023</p>
          </figcaption>
          </figure><p>I mentioned before about using <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> tags with proper accessibility <em>and</em> flexible styling. Here’s what I meant. If we keep the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;table&gt;</code> as <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>display: table</code>, styling is affected by the default table styles, which make certain styles impossible, like putting a bottom border on a <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;tr&gt;</code>. If, however, you set the table’s display to <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>grid</code> or <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>flex</code>, you can gain the styling flexibility, but you also <em>lose</em> accessibility—the table will no longer have its “table” role (in Safari, at least. It looks like other browsers no longer drop the role as of the time of this writing). Fortunately, it’s not difficult at all to reinstate accessibility roles for the table by simply setting the <code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>role</code> attribute on each element. Every single table element has its own role value, and since I’m writing my own components for these elements, it couldn’t be easier to set these roles once and have them applied everywhere. I could actually write an entire post about this, but… it’s getting late, so I’m going to stop here.</p><p>While I do feel great so far about how I’m building this table component, I know I’m still in the <em>easy</em> stage—I have yet to implement any interactions, including sorting, drag &amp; drop, or context menus, so there’s still plenty to work through. That said, I feel like I’m in the best position I’ve ever been in to write the most future-proof version of this table yet. And I know—it’s just a table. But when you’ve iterated on a single component over the course of a decade, recreating it every few years with the latest tech becomes a fun exercise, like building a to-do app with different frameworks. Or maybe I’m strange for thinking this is fun… It <em>is</em> fun, though, …right?</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Building%20a%20table%20for%20the%20nth%20time">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[A return to tabbed navigation]]></title>
            <link>https://destroytoday.com/blog/a-return-to-tabbed-navigation</link>
            <guid>https://destroytoday.com/blog/a-return-to-tabbed-navigation</guid>
            <pubDate>Wed, 27 Dec 2023 14:27:00 GMT</pubDate>
            <description><![CDATA[With a focus on navigation, I return to the early days when Cushion had a simple tabbed nav.]]></description>
            <content:encoded><![CDATA[<p>Once an app achieves even the slightest bit of complexity, it’s time to think about navigation. Not only does navigation help the user move throughout the app, but if you pay close attention, it also becomes a complexity meter for your app. As the app starts to grow in scope, if you find yourself rethinking the navigation solely to accommodate new sections that wouldn’t otherwise fit in the current navigation, that’s a warning sign. Unfortunately with <a href="https://cushionapp.com">Cushion</a>, I flew too close to the sun, ignored the signs, and found myself with a sidebar navigation—one of the clearest indications that an app is trying to do too much.</p><p>I still remember <a href="https://twitter.com/destroytoday/status/785128886430470144">this ironic tweet of mine</a> and how I fell right into this hole:</p><picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&w=960&'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?w=960&'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&w=1280&'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?w=1280&'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?fm=webp&'>
            <img
              alt='Sidebar nav tweet'
              src='https://images.ctfassets.net/zi79s2th73f3/636ONBIWffD0Q0v1hfJGKG/71dd46bcf63a870553ce797d4a125b22/hero.png?'
              width='660'
              height='236'
              style='width: 100%; max-width: 660px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture><p>Now that I’m <a href='/blog/imagining-cushion-circa-2016-in-2024'>rethinking everything</a>, I’m eager to go back to basics with a navigation that limits—or rather <em>contains</em>—the scope. The original tab-based navigation for Cushion actually fits the bill pretty well, as it’s confined to the max-width of the app, but it’s also influenced by the tab count. Three tabs look great. Four tabs is pushing it. Five tabs?—time for a long look in the mirror. That’s probably an extreme take, but this time around, I’m really aiming for laser focus.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (grey)'
              src='https://images.ctfassets.net/zi79s2th73f3/5H597dhhk857OPtpUdOXbJ/9e124f5b393ed1770db94bedb50a9bd3/grey.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>For Cushion, if I want tabs for “Clients”, “Projects”, and “Invoices”, then a few admin tabs, like “Account”, “Preferences”, and “Billing”, I can simply put those in separate views, like I did with the original Cushion—one view for actually using the app and the other for admin.  Clicking the user’s name in the header takes them to the admin view and swaps out the tabs for the admin tabs. Then clicking Cushion’s logo follows the standard of returning “home” to the main view of the app, where the feature-focused tabs are shown.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (admin)'
              src='https://images.ctfassets.net/zi79s2th73f3/4OC9DjRrmi40OofdXKSbqU/87e954fb09415e5a29a4a492b7c55b6e/admin.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>Going back to tab count for a moment, while I <em>do</em> think three is the sweet spot for feature-focused tabs, I actually don’t have a problem with a higher tab count for admin views. The user doesn’t spend nearly as much time there as they do in the actual app, so I’m not bothered by a few extra tabs for clarity—as long as the user can get to where they need to go in two clicks or fewer, I’m happy.</p><p>Since I’m looking to return to simpler times while also modernizing and improving Cushion along the way, one consideration I’m thinking about is personalization through color. The existing Cushion is pretty grey, but the original Cushion was <em>very</em> grey. That said, once the user starts filling up the graphs with client colors, the grey fades into the background as a canvas. In any case, I’m toying with the idea of introducing a personalized “theme” color that could be unique between users. Strangely, Cushion has always had a setting for the user’s favorite color, but I never ended up wiring that to anything. While I’m currently focused on the navigation, this might be a good time to tie the theme color to the UI.</p><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (yellow)'
              src='https://images.ctfassets.net/zi79s2th73f3/76MwIx2jx0hBHOy7i3l1cb/6c2bf00284c84b32400343396b523f9b/yellow.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (purple)'
              src='https://images.ctfassets.net/zi79s2th73f3/4fdwQCgn9RXfjqxtlt2aqB/a7644cf6aa78f1d98e360820e0b352cb/purple.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><figure
            class='
              Image
              isWide
              
              hasBorder
            '
          >
            <picture>
            <source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=960&w=1440&q=80'
                      media='(max-width: 480px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=960&w=1440&q=80'
                    media='(max-width: 480px)'
                  ><source
                      type='image/webp'
                      srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=1280&w=1440&q=80'
                      media='(max-width: 640px)'
                    ><source
                    srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=1280&w=1440&q=80'
                    media='(max-width: 640px)'
                  >
            <source type='image/webp' srcset='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?fm=webp&w=1440&q=80'>
            <img
              alt='Nav rethink (tan)'
              src='https://images.ctfassets.net/zi79s2th73f3/myTbKXC667F48yDJGk1Wq/881c24091241305d085cee111fe5c736/tan.png?w=1440&q=80'
              width='720'
              height='130.9090909090909'
              style='width: 100%; max-width: 720px'
              loading='lazy'
              onload='this.dataset.jsLoaded = ""'
            >
          </picture>
            
          </figure><p>By using the background of the top “header” area for the theme color, I could introduce personalization while still maintaining a neutral canvas for the graph below it. The tabs would then need to embrace black or white alpha colors instead of using a greyscale, and I’d need to add logic for determining whether to use black or white text to maintain strong contrast, but that’s easy enough. The real question would be whether I provide a preset color palette to ensure the UI looks good or allow custom colors. The answer to this might be the “why not both?” meme.</p><p>I’m off to a good start with this navigation already, and I feel good about its longevity. I <em>am</em> anticipating several edge cases, like clicking into an invoice and how to handle that with tabs, but I’m not worried—there are plenty of best practices to lead the way and I’m not looking to reinvent the wheel. </p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20A%20return%20to%20tabbed%20navigation">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[End-to-end testing with Playwright]]></title>
            <link>https://destroytoday.com/blog/end-to-end-testing-with-playwright</link>
            <guid>https://destroytoday.com/blog/end-to-end-testing-with-playwright</guid>
            <pubDate>Thu, 21 Dec 2023 15:16:00 GMT</pubDate>
            <description><![CDATA[With the stack picked out, I set up end-to-end testing from the start, so I can get in the habit of testing user flows without any excuses.]]></description>
            <content:encoded><![CDATA[<p>In addition to <a href='/blog/vercel-supabase-vue-and-vite'>the new stack</a> for the <a href="https://cushionapp.com">Cushion</a> rewrite, I also want to touch on the testing harness. Back when I started Cushion, I was big into testing—and I’m still proud that the Ruby backend has rock solid test coverage—but for whatever reason, the frontend doesn’t have much test coverage beyond the client-facing invoice page. I’m not sure why, considering I fully tested the <a href="https://teuxdeux.com">TeuxDeux</a> frontend when I worked on it before Cushion, but maybe I was too excited for rapid progress. In any case, testing the frontend is so easy now that there’s not much of an excuse not to.</p><p>Personally, I’ve never had a problem thoroughly unit testing components, but when it comes to code that interacts with the API—where you <em>should</em> rely on end-to-end testing—I always feel the weight of the initial setup and tell myself I’ll do it later, but never do. With the existing Cushion, if I wanted to set up e2e testing at this point, I’d need to find a way to run all the servers within GitHub Actions, which is a daunting task (for me). Alternatively, I could do what plenty of folks probably do, which is to run their e2e tests against the staging server. Then, you just need to make sure you clean up after each run.</p><p>Since I’m starting from scratch—and determined to start on the right foot—I can keep e2e testing in mind from the get-go. Of course, I’ll also unit test components, but the coverage from e2e tests will be invaluable for sanity-checking the actual user flows. It’s far too easy for unit tests to pass while an unwritten e2e test would be failing—especially if you resort to unit testing API interactions with spies, but your API changes and your spies don’t. (I can hear a few of you nervously laughing right now…)</p><p>By using <a href="https://supabase.com/">Supabase</a> as my pseudo-backend that provides a direct client-side connection to the database, it’s much easier to set up e2e testing from the beginning (as long as their API is available). They do also have a <a href="https://supabase.com/docs/guides/self-hosting">self-hosted</a> version if I wanted to go full neckbeard, but hosted will suffice for me.</p><p>As for the test harness, I’m using <a href="https://playwright.dev/">Playwright</a>, which has emerged as the go-to option for e2e testing. There are others, like <a href="https://www.cypress.io/">Cypress</a>, but I feel like Playwright speaks more my language and would be familiar to anyone who has used <a href="https://jestjs.io/">Jest</a> or <a href="https://vitest.dev/">Vitest</a>. For those who haven’t written <em>any</em> e2e tests before, it’s actually fun. You simply write line-by-line what the user should do, like “go to this page”, “find this input”, “fill it in”, “click this button”. Obviously this is pseudocode, but the actual API reads just as easily. Playwright also has a <a href="https://playwright.dev/docs/codegen">codegen feature</a> where you can record yourself clicking through the browser and it’ll write the test for you. Seriously, with features like this, you have no excuse.</p><p>So far, I’ve been able to fully test the auth flow as well as an initial setup form. These two cases have given me enough experience in this new stack to know how to quickly handle most situations. Along the way, I’m even writing helper functions, like one-line sign in, etc. The only hiccup I’ve come across is how best to reset the database, so I can do a clean run each time. I’m sure you can run a query on the database directly before or after the tests run, but instead, I saw this as an opportunity to implement a feature folks have asked for in the past—resetting an account.</p><p>For Cushion, users often become subscribers for a couple years, then they might take a full-time job and no longer need to freelance or use the app. Several years later, they might return, but want to start fresh with an empty account instead of returning to their old data. This is where a reset feature comes in handy.</p><p>I do realize that this is a dangerous feature and resetting your account is not something to take likely—especially for the users who have been with Cushion since the beginning. With those folks, we’re talking about almost 10 years of data. Because of this, resetting your account will certainly come with a safeguard in the form of a dramatic “Are you sure?” prompt that makes you type exactly what this feature does. I imagine I’ll eventually opt for a database query to clear the database to save time on click-throughs, but for now, this works and unblocks me.</p><p>Next up, I’ll start to dig into the actual app by setting up all the models as I need them, and make something usable. I’m excited!</p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20End-to-end%20testing%20with%20Playwright">Reply via email</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Vercel, Supabase, Vue, and Vite]]></title>
            <link>https://destroytoday.com/blog/vercel-supabase-vue-and-vite</link>
            <guid>https://destroytoday.com/blog/vercel-supabase-vue-and-vite</guid>
            <pubDate>Wed, 20 Dec 2023 13:59:00 GMT</pubDate>
            <description><![CDATA[As I rethink Cushion in the modern age, I detail my choices for platform, stack, and database.]]></description>
            <content:encoded><![CDATA[<p>Now that the <a href='/blog/imagining-cushion-circa-2016-in-2024'>last post</a> is behind me, I want to start getting into the details of a 2016 version of <a href="https://cushionapp.com">Cushion</a> built in 2024. My original plan was to only refresh the frontend and then rely on the existing API to save time, presumably. That would’ve worked fine, but I also feel like it would’ve still restricted my choices and influenced how I develop this new version. The priority <em>is</em> refreshing the frontend, but that doesn’t mean there isn’t an equally long to-do list of improvements and simplifications I want to make on the backend as well. If I’m “stuck” with a backend that I started writing 10 years ago, I’ll miss out on all the innovations that have happened since then—especially in this new world of frontend-friendly backends.</p><p>The existing Cushion is hosted mostly on <a href="https://heroku.com">Heroku</a>—a once-exciting platform that now seems stuck in 2010, when it was acquired by Salesforce. There’s still a lot to love about Heroku, but I’m looking elsewhere this time, in the direction of <a href="https://vercel.com">Vercel</a>. This is a no-brainer for me and not much of a risk because I’ve been using Vercel for pretty much every website I’ve hosted over the past several years. The speed and ease in which you can go from repo to hosted website—with automatic deployments between dev, preview, and prod—is the way the web should be. That said, this will be my first time hosting an actual app with real users on Vercel—beyond a marketing site or a hosted form—so I can’t say I’ve had to troubleshoot something going wrong on Vercel. At the same time, I plan to greatly simplify the backend because it honestly doesn’t need much beyond what can be done in the database. And that’s a perfect segue into our next topic—the database. </p><p>Since the start, Cushion has used <a href="https://www.postgresql.org/">Postgres</a> as its main database, and I have no regrets with that choice—I’ve always been a relational guy and Postgres only seems to get more and more feature-rich with each and every upgrade. The world <em>around</em> Postgres has gotten pretty interesting over the years, too, with database providers making the new Postgres features more turnkey. This is why I’ve landed on <a href="https://supabase.com/">Supabase</a> as my database provider. I have no problem coding a backend, but as soon as I saw that you can use a Postgres database directly from a static frontend <em>and</em> it handles authentication?—I was sold. On top of that, Supabase takes care of the hairiness of Postgres’s realtime features, so you can subscribe to messages, presence, and database changes with a simple client-side listener—sold again. These features unlock so much potential for a modern Cushion, and Supabase makes the features so easily accessible that I can casually experiment with a random idea before feeling like I need to commit.</p><p>Now that I’ve touched on the backend—or rather the lack of need for a traditional backend—let’s look at the frontend. Anyone who knows me knows my preference for <a href="https://vuejs.org/">Vue</a>, so this is another no-brainer. Oddly enough, though, the majority of Cushion’s frontend actually isn’t Vue. When I first started working on Cushion back in 2014, Vue was only starting to make a name for itself. <a href='/blog/building-the-table-with-vue-js'>I gave it a shot</a> for a few trips around the block, and while it was fun, exciting and new, Vue didn’t have the maturity I was looking for, so I friend-zoned it. I ended up playing it safe with <a href="https://angular.io/">Angular</a> (v1.2), which shared some similarities with Vue at the time, but was also structured like the MVCS (model, view, controller, service) architectures I knew and loved in the <a href="https://en.wikipedia.org/wiki/ActionScript">AS3</a> days. While I found that appealing at the time, I can assure you my interests have since changed. A few years into using Angular, I fell out of love because of its complexity and sluggishness. Fortunately for me, Vue had grown up a lot in those years, seemingly waiting out my relationship with Angular. We’ve been together ever since.</p><p>While most of the Vue code that lives in the existing Cushion is <a href="https://v2.vuejs.org/">Vue 2</a> with the Options API, I’m using Vue 3 with the <a href="https://vuejs.org/guide/extras/composition-api-faq.html">Composition API</a>, which is so much more TypeScript-friendly and reads like vanilla JS. When using <a href="https://vuejs.org/api/sfc-script-setup.html"><code class='language-markup' style='white-space: pre-wrap; word-break: break-word'>&lt;script setup&gt;</code></a>, any const or function is exposed to the template automatically, so it’s immediately familiar to any straight-JS dev and even more inviting than it was before. I could wax poetic about my love of Vue, but I’ll stop right here and save you all.</p><p>When we talk about frontend, we unfortunately still need to talk about bundling. Back in 2014, the initial build scripts for Cushion’s frontend used <a href="https://gulpjs.com/">Gulp</a>—remember Gulp?? Gulp existed well before we had hot module replacement, but we did still had <a href="https://chromewebstore.google.com/detail/livereload/jnihajbhpnppcggbcgedagnkighmdlei?pli=1">LiveReload</a> to refresh the entire page on save. For several years, I tried to migrate to <a href="https://webpack.js.org/">Webpack</a>, but gave up each time—those who have also tried know what I’ve been through. Then, in 2019, I successfully switched to <a href="https://cli.vuejs.org/">Vue CLI</a>, which does use Webpack under the hood, but at least provides a wrapper around it to make it less painful. Still, with an Angular frontend that has Vue embedded throughout, there was no way I could get HMR to cooperate.</p><p>These days, Vue CLI has been replaced with <a href="https://vitejs.dev/">Vite</a>—an incredibly quick, easy-to-setup, and extensible bundler and dev server from the folks behind Vue. I’ve been using Vite ever since building ProtoPen, my prototyping environment when I worked at <a href="https://stripe.com/">Stripe</a>. Like everything Vue-related, it speaks my language, and I just <em>get it</em> from the start. I’ve tried other frameworks over the years, like <a href="https://nuxt.com/">Nuxt</a>, but the magic that happens behind the scenes always ends up causing problems down the road, and I always find myself needing to troubleshoot magic. To save myself the future headache, I’m keeping it simple, and sticking with straight Vite.</p><p>That’s all for the initial setup. We’ll see if these choices stand the test of time. </p><p><a href="mailto:jonnie@destroytoday.com?subject=Re%3A%20Vercel%2C%20Supabase%2C%20Vue%2C%20and%20Vite">Reply via email</a></p>]]></content:encoded>
        </item>
    </channel>
</rss>