<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
        <title><![CDATA[spiffytech]]></title>
        <description><![CDATA[spiffytech's blog]]></description>
        <link>https://spiffy.tech</link>
        <generator>RSS for Node</generator>
        <lastBuildDate>Sun, 05 Apr 2026 00:24:32 GMT</lastBuildDate>
        <atom:link href="https://spiffy.tech/rss" rel="self" type="application/rss+xml"/>
        <language><![CDATA[en]]></language>
        <item>
            <title><![CDATA[Sort by Newest]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/sort-by-newest">Sort by Newest</a></h1><p>Life pro tip:</p>
<p>Google Maps thinks restaurant reviews from years ago are the &quot;most relevant&quot; reviews.</p>
<p>Things change. Restaurants decline, get new owners, etc.</p>
<p>Sorting by Newest has proven much more informative for me.</p>]]></description>
            <link>https://spiffy.tech/sort-by-newest</link>
            <guid isPermaLink="false">b913f238-5bb9-45ae-a3cf-c05e2006952a</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Sun, 18 May 2025 17:07:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[LLMs: The standard is not perfection]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/llm-perfection">LLMs: The standard is not perfection</a></h1><p>Right now, LLMs are shaking up the way we live life and access information. There&#39;s a lot of debate around how useful they can be if they&#39;ll lie to you.</p>
<h1>Everything lies to you</h1>
<p>When I get info from an LLM, I affectionately tell folks &quot;the hallucination box says...&quot;</p>
<p>But this isn&#39;t unique to LLMs!</p>
<ul>
<li>Google searches are full of incorrect or inapplicable information. A core skill of web search is sorting out what&#39;s true and what&#39;s relevant.</li>
<li>StackOverflow famously closes questions as duplicates, which are not actually duplicates. Accepted answers can be wrong, outdated, or solve a problem you don&#39;t have while confidently proclaiming it&#39;s <em>you</em> who&#39;s solving the wrong problem.</li>
<li>People you know get things wrong all the time. Wrong facts, bad opinions, faulty memories.</li>
</ul>
<p>Nobody argues these shouldn&#39;t be used because they get things wrong. We just know we have to use our judgement.</p>
<p>But those are powered by people. LLMs are machines. That changes our expectations.</p>
<p>Which seems strange, because personal computers are famous for behaving in faulty, erratic, inscrutable ways. Yet have hold them to the standard of their best days, when they function with unerring determinism.</p>
<p>I&#39;m going to keep using LLMs for personal use. Rather than grump that they&#39;re unreliable, I&#39;d rather build the skill of assessing when they&#39;re right or wrong, like I have for other tools. That way I get the benefits.</p>
<p>Programmatic use is harder, since there&#39;s no human in the loop to exercise judgement, and I&#39;d like to use LLMs in exactly the situations that are hardest to algorithmically verify. But I&#39;m sure I&#39;ll figure how to make them useful there, too.</p>]]></description>
            <link>https://spiffy.tech/llm-perfection</link>
            <guid isPermaLink="false">ec0a19b6-2835-4acd-b520-b7786398e625</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 24 Mar 2025 14:52:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[A better sentiment rating scale]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/three-scale">A better sentiment rating scale</a></h1><p>How do you rate a board game? A movie? A restaurant? You want a scale with consistency and clear communication.</p>
<h1>What do people normally do?</h1>
<p>There are two main scales:</p>
<ol>
<li>A, B, C, D, F</li>
<li>1–5, 1–10, 1–100</li>
</ol>
<p>These both have the same problem: ambiguity. Everyone has a different idea of which values map to good / bad / meh / average. Is a C &quot;average&quot;, or is it falling short of expectations?</p>
<p>Rotten Tomatoes considers &lt; 60 bad, 60–74 good, and 75+ really good.</p>
<p>Metacritic ranges are &lt; 49 is bad, 50–74 is average or uncertain, 75+ is good. So there&#39;s a 10 point range (50–59) where Metacritic thinks something is alright, but Rotten Tomatoes doesn&#39;t.</p>
<p>Net Promoter Score uses 1–10, but their interpretation is unintuitive. 9–10 are beneficial customers, 7–8 do nothing for you, and ≤ 6 are harmful to your reputation. Before learning this, I figured rating a product an 8 was pretty good!</p>
<p>Amazon, Uber, etc. all use a 5-point scale. A 4.7 means steer clear of that product or driver! Or maybe it&#39;s 4.3? Well, definitely avoid anything under 4.</p>
<p>On Google Maps, a 4.7-star restaurant in one city might be a 3.2 in another.</p>
<p>No one can agree on what number even means good vs bad, much less <em>how</em> good or bad! Adding digits of precision only makes things worse: if you&#39;re not sure what somebody&#39;s 7 vs 8 means, having them give you a 72 sure won&#39;t help.</p>
<h1>Introducing the &quot;Three&quot; Scale</h1>
<p>I have converted all my friends to use this. They start off reluctant: it&#39;s weird, and what&#39;s wrong with 1 through 10? But they quickly stop grumbling when they see how easy it is to communicate our experiences to each other, and then make decisions from that.</p>
<h2>How it works</h2>
<p>Everything is rated from −3 to +3.</p>
<p>Zero is neutral: &quot;I have no strong feelings one way on the other&quot;. Take it or leave it.</p>
<p>+1 is mild positive sentiment. It&#39;s alright, if there&#39;s nothing better available.</p>
<p>+2 is strong positive sentiment. You&#39;ll normally say &#39;yes&#39; to eating here, watching this, playing this.</p>
<p>+3 is one of the best of its kind. Your favorite restaurants, and the movies you tell everyone they <em>have</em> to see. It just doesn&#39;t get better than this!</p>
<p>Negative numbers are the same, but for hating something.</p>
<h1>&quot;Okay but so what?&quot;</h1>
<p>Now, you &amp; your friends have a common language for ratings. Now, they&#39;re <em>legible</em>.</p>
<p>You agree what&#39;s neutral. You agree what&#39;s positive, and what&#39;s negative. No guessing. There&#39;s not much room for ambiguity in between 1 and 3.</p>
<p>If someone in your group gives the restaurant a −1 rating, it&#39;s clear that place shouldn&#39;t be in the group&#39;s normal rotation. If they said −2, you had better have a good reason for going back. If they&#39;d said &quot;C-&quot;... who knows what that means 🤷‍♂️</p>
<p>My wife and I watch the trailers in the movie theater and whisper our ratings. In one word, we each know whether we&#39;re going to see a film together, separately, or at all. Whether we&#39;ll spend to see it in theaters, or wait for streaming.</p>
<p>I keep a spreadsheet tracking the ratings my board gaming group gives games. Because everyone&#39;s speaking the same language, I can sort &amp; group games to see what old favorites we should replay.</p>
<p>The lack of precision helps, not hurts. If this was widened to ±10, we could at least tell positive/neutral/negative, but we&#39;d be back to guessing how a +5 is different from a +7.</p>
<p>I don&#39;t think I&#39;ve found a situation where the Three Scale is worse than letter grades or 1–10.</p>
<p>Some friends and I pondered if it should be cut down to ±2 or ±1, but it&#39;s not clear that there&#39;s a big win there. ±3 seems like the right granularity to express mild/strong/excessive sentiment.</p>
<h1>&quot;I don&#39;t want to explain this to people!&quot;</h1>
<p>In my experience, you just have to say &quot;I rate things from −3 to +3&quot;. That&#39;s it — they get it.</p>
<h1>In conclusion</h1>
<p>It&#39;s great! Use it!</p>
<h1>Appendix: where did this come from?</h1>
<p>I swear I read about this in some medical or social sciences research paper. I recall researchers asked subjects to rate something on the Three Scale, and I loved the idea. But any time I mention this, nobody&#39;s ever seen it before. Even people who read social science research.</p>]]></description>
            <link>https://spiffy.tech/three-scale</link>
            <guid isPermaLink="false">1a9ad073-d04a-43e2-86f1-ad9d66b7a0b9</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Sat, 22 Mar 2025 17:59:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[How to waterproof a level 1 EV charging cord]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/ev-waterproof">How to waterproof a level 1 EV charging cord</a></h1><p>I charge my EV at home, but for ✨reasons✨ I use a level 1 charger that&#39;s outdoors and exposed to the elements. There are quite a few waterproof <em>chargers</em>, all of which require a plug adapter that&#39;s <em>not</em> waterproof.</p>
<p><em>(Not that any of the product listings spell that out, so I&#39;ll bet someone&#39;s burned their house down thinking the entire product was waterproof.)</em></p>
<p>Anyways, I spent quite a while figuring out how to waterproof the plug adapter. It&#39;s far, far too large for outdoor plug covers. It&#39;s too large for many utility boxes. The cords are thicker than most waterproof housings accommodate, and they don&#39;t bend enough to be routed out some housings&#39; portholes.</p>
<p>It turns out there&#39;s an easy answer: silicone tape, found at your local hardware store. It&#39;s waterproof, UV resistant, electrically insulating, and withstands extreme heat &amp; cold.</p>
<p>Just wrap it around your plug, stretching it out as you go. There&#39;s no adhesive - the material molecularly fuses to itself, leaving no gaps or leaks.</p>
<p>Works great, and doesn&#39;t require installing some kind of housing, and fits anything.</p>
<p><a href="/blog-images/blogPosts/ev-waterproof/1000002048.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/ev-waterproof/1000002048.jpg" alt=""/></a></p>]]></description>
            <link>https://spiffy.tech/ev-waterproof</link>
            <guid isPermaLink="false">c9c3deb6-403d-40fe-bd9c-38633145ddeb</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Fri, 18 Oct 2024 19:38:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[How NOT to connect a well pump to an IBC tote]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/how-not-to-connect-a-well-pump-to-an-ibc-tote">How NOT to connect a well pump to an IBC tote</a></h1><p>My brother &amp; his partner are up near Asheville, caught in the aftereffects of hurricane Helene. Most pressing for them is that Helene destroyed most of the city&#39;s water infrastructure, and it&#39;ll be weeks before even non-potable water starts flowing in most parts of the city.</p>
<p>They have access to an in-ground well with a limited water supply, but it&#39;s not near anything. So they&#39;re trucking water around in drums, siphoning it into buckets with a hose.</p>
<p>I brought them a used IBC tote I got off Facebook Marketplace, which are cheap and abundant in my area. 330 gallons of potable, food-grade water storage. I also brought a Drummond shallow-well water pump from my local Harbor Freight.</p>
<p>It took us five days of work to connect it to his home plumbing, because neither of us are plumbers, and the pump&#39;s installation instructions lied to us.</p>
<p>Here&#39;s the stuff we struggle-bussed our way through, so you don&#39;t have to.</p>
<p><a href="/blog-images/blogPosts/how-not-to-connect-a-well-pump-to-an-ibc-tote/image-2.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/how-not-to-connect-a-well-pump-to-an-ibc-tote/image-2.avif" alt=""/></a></p>
<h1>(A) Put the check valve (only) on the intake side</h1>
<p><a href="/blog-images/blogPosts/how-not-to-connect-a-well-pump-to-an-ibc-tote/well-diagram.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/how-not-to-connect-a-well-pump-to-an-ibc-tote/well-diagram.avif" alt=""/></a></p>
<p>The pump&#39;s installation instructions showed a check valve (one-way water flow) installed in between the pump and the house</p>
<p>This is wrong! It will prevent the pump from knowing the downstream pressure, so it&#39;ll short-cycle (rapidly turn on and off).</p>
<p><strong>You <em>do</em> need a check valve installed on the water <em>intake</em> line. Yes, even if you already have a foot valve!</strong> When the pump shuts off, the intake line depressurizes. That pulls water backwards through the pump, depressurizing the outlet, and the pump short-cycles trying futilely to repressurize the outlet line.</p>
<h1>(B) The pump must be horizontal</h1>
<p>The pump must be installed horizontally, not vertically. The instructions are ambiguous about this, only saying it must be &quot;level&quot;. But yeah, horizontal.</p>
<h1>(C) Keep the pump uphill of the water source</h1>
<p>We didn&#39;t confirm this still mattered after we figured everything else out, but it&#39;s mentioned in the instructions, so give it some thought.</p>
<p>We initially had the pump in the basement, with the water intake hose running downhill from where the IBC tote was installed. This did not make the pump happy, possibly because the weight of the water in the line added unexpected water pressure to the pump.</p>
<p>Once we moved the pump above the IBC tote&#39;s elevation, things got better.</p>
<h1>(D) Check your home&#39;s water pressure tank</h1>
<p>We found out ours was blown, so the pump couldn&#39;t maintain pressure. We bought a new one, correctly sized for the home (the old one was was not), and set its empty pressure to 28psi (2psi below the pump&#39;s 30psi cut-on pressure).</p>
<p>This was critical, as it gave the home a place to store pressurized water, and thus gave the pump something to pressurize.</p>
<p>We thought the pump would just hold the water lines at pressure, but that never worked for us. Worth calling out: we tested shutting the valve between the pump and the house to isolate variables, but this was never going to work, because the isolated portion of the system didn&#39;t include a pressure tank. More short-cycling ensued.</p>
<p>If you bought the pump model with the built-in accumulator tank, this might not be an issue. But that&#39;s just a guess.</p>
<h1>(E) Open your faucets</h1>
<p>You&#39;ll get air in the system and the pump will struggle to reach &amp; maintain pressure if you don&#39;t get that air out. Go run your faucets while the pump runs.</p>
<h1>(F) Get the right size outlet hose</h1>
<p>This particular pump takes a 1¼&quot; outlet hose. The instructions don&#39;t mention the size. The plastic plug is labeled 1.2&quot;, but that&#39;s yet another lie. It&#39;s 1¼&quot;.</p>
<h1>(G) Verify how far the water is being drawn</h1>
<p>The pump can only pull water so far. It seems to be more forgiving of flat terrain than of pulling the water uphill, but still, keep the IBC tote and the pump near each other if you can.</p>
<h1>(H) Keep that pump primed</h1>
<p>When you keep disconnecting &amp; reconnecting your hoses you can lose prime on the pump as it leaks water out. Re-prime it as appropriate.</p>
<h1>(I) Use a non-compression hose</h1>
<p>If you&#39;re using hoses for your water intake/outlet, be sure they&#39;re non-compression hoses. The pump will put a lot of suction and expansion pressure on the water lines, and an ordinary garden hose will shrink or expand accordingly. When the pump shuts off that pressure will go away and the hose will return to its normal size (changing its volume in the process), which changes the pressure in the hose, and the pump kicks on again.</p>
<h1>The pump&#39;s correct behavior</h1>
<p>If everything is working right, you should see the pump running <em>smoothly</em> while it reaches pressure. You can confirm it&#39;s pressurizing by attaching a tire pressure gauge to your water pressure tank.</p>
<p>Once the pump reaches pressure (50psi), it will <em>gracefully</em> shut off entirely, until you run enough water that pressure falls below 30psi. The pressure gauge will <em>not</em> read &quot;zero&quot;, which we interpreted as the pump kicking in a safety cut-off after too much short-cycling.</p>
<p>When correctly installed, the pump will <em>not</em> short cycle, stutter, or run continuously.</p>]]></description>
            <link>https://spiffy.tech/how-not-to-connect-a-well-pump-to-an-ibc-tote</link>
            <guid isPermaLink="false">3b298f3c-1b8f-4af3-920a-fef80038180a</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Wed, 16 Oct 2024 18:51:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[How many SIMs can my Pixel 8 have?]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/sim-cards">How many SIMs can my Pixel 8 have?</a></h1><p>I&#39;ve only ever used a single SIM in my phone at once because I live in a well-covered city, but I was recently on a trip to the Michigan U.P., which is… <em>not</em> well-covered.</p>
<figure class="mb-8"><div class="breakout-full [&amp;&gt;p]:flex [&amp;&gt;p]:flex-wrap [&amp;&gt;p]:justify-center [&amp;&gt;p]:gap-x-2 [&amp;&gt;p]:gap-y-8 [&amp;&gt;p&gt;*]:flex-initial [&amp;&gt;p&gt;*]:max-w-72 [&amp;&gt;p&gt;*]:h-auto [&amp;&gt;p&gt;*]:object-contain [&amp;&gt;p&gt;*]:mx-1"><p><a href="/blog-images/blogPosts/sim-cards/578ed8f3-96b5-4a0b-8a28-01020744acc0.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/578ed8f3-96b5-4a0b-8a28-01020744acc0.avif" alt=""/></a><a href="/blog-images/blogPosts/sim-cards/b50332a2-06cc-4429-beff-2d80f7da0c17.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/b50332a2-06cc-4429-beff-2d80f7da0c17.avif" alt=""/></a><a href="/blog-images/blogPosts/sim-cards/30cb376f-6fed-4275-9cae-01388f5146ff.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/30cb376f-6fed-4275-9cae-01388f5146ff.avif" alt=""/></a></p></div><figcaption class="text-center text-sm italic text-gray-600 dark:text-gray-300">AT&amp;T&#39;s coverage map (blue) is a LIE!</figcaption></figure>
<p>My Android phone (a Pixel 8) supports eSIMs, which are just pretend SIMs you download from The Internet™. I&#39;ve never used one before — the last time I switched phone service, physical SIMs were all the rage.</p>
<p>So that begs the question: if eSIMs are imaginary, how many can my phone have at once? I mean, there&#39;s no <em>physical</em> constraint, like the single SIM card tray in my phone. So, how many?</p>
<p>Google&#39;s <a href="https://web.archive.org/web/20240525202244/https://support.google.com/pixelphone/answer/9449293?hl=en">support page</a> says you can have two SIMs: two physical, two eSIM, or one of each. Well, you can&#39;t have two physical in a Pixel since they don&#39;t have two card slots, but if they <em>did</em>, then you could.</p>
<p>But what happens if I download <em>more?</em></p>
<figure class="mb-8"><p><a href="/blog-images/blogPosts/sim-cards/0541b826-7ca1-44b1-a462-599f6d50aa27.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/0541b826-7ca1-44b1-a462-599f6d50aa27.jpg" alt=""/></a></p><figcaption class="text-center text-sm italic text-gray-600 dark:text-gray-300">&quot;I don&#39;t think he knows about Third SIM Card, Pip.&quot;</figcaption></figure>
<p>So I kept my existing phone plan, bought another to give me extra coverage on the trip, then spent $4 on a third phone plan just to see what would happen.</p>
<figure class="mb-8"><p><a href="/blog-images/blogPosts/sim-cards/26a0c32d-d25f-4a63-9401-3cf615152776.gif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/26a0c32d-d25f-4a63-9401-3cf615152776.gif" alt=""/></a></p><figcaption class="text-center text-sm italic text-gray-600 dark:text-gray-300">Me when I spend money just to learn something the docs already explain</figcaption></figure>
<p>Survey says:</p>
<p>I can only have two SIMs <em>active</em>. If I add a third, my phone asks me to deactivate one of the active SIMs. I can switch back and forth between any pair of SIMs I please, and my phone service will stay active for all of them, but I can only <em>use</em> two at a time.</p>
<div class="breakout-full [&amp;&gt;p]:flex [&amp;&gt;p]:flex-wrap [&amp;&gt;p]:justify-center [&amp;&gt;p]:gap-x-2 [&amp;&gt;p]:gap-y-8 [&amp;&gt;p&gt;*]:flex-initial [&amp;&gt;p&gt;*]:max-w-72 [&amp;&gt;p&gt;*]:h-auto [&amp;&gt;p&gt;*]:object-contain [&amp;&gt;p&gt;*]:mx-1"><p><a href="/blog-images/blogPosts/sim-cards/1000001760.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/1000001760.avif" alt=""/></a><a href="/blog-images/blogPosts/sim-cards/62a17402-ceca-4b38-ae13-d1e7d090bfbe.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/62a17402-ceca-4b38-ae13-d1e7d090bfbe.avif" alt=""/></a></p></div>
<p>For completeness, of the two that are active, I can choose which is my preference for calls and for SMS. I can pick one to be &quot;it&quot; for mobile data. There&#39;s a toggle on the other SIM to &quot;use this network when it has better availability&quot;, but based on my time in the U.P., I&#39;m not sure it worked very well. My phone often had no connectivity, even when at least one network showed a bar.</p>
<p><a href="/blog-images/blogPosts/sim-cards/1000001761.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/sim-cards/1000001761.avif" alt=""/></a></p>
<p>How many total SIMs can the phone hold? I haven&#39;t seen any official answer, but over on Mastodon, <a href="https://500.social/@alvan/113027005635690184">Alvan thinks</a> it&#39;s 1 SIM + 5 eSIMs for his Pixel 7.</p>]]></description>
            <link>https://spiffy.tech/sim-cards</link>
            <guid isPermaLink="false">2efb22e4-92f7-4335-8735-af4b0a31bd5c</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Sun, 25 Aug 2024 23:10:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[I tried out the Vision Pro]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/i-tried-out-the-vision-pro">I tried out the Vision Pro</a></h1><p>This morning I went down to the Apple store at opening so I could do a demo. I was 50/50 on whether it&#39;d be just me, or a line around the block. An hour ahead of opening there was one other guy there. At quarter-til, when they started taking names, 11 people.</p>
<p>The 20 Apple employees applauded and cheered as we entered the store. It was very surreal.</p>
<p>TL;DR lots of good, some underwhelming. More compelling than I expected. Some hurdles might go away after an adjustment period. Not sold at $3,500, but if they release a non-Pro for say $1,500 that&#39;ll be pretty tempting.</p>
<p><a href="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_130034217.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_130034217.jpg" alt="Display unit"/></a></p>
<p><a href="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_130141860.MP.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_130141860.MP.jpg" alt="Display unit"/></a></p>
<p><a href="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_134258562.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/i-tried-out-the-vision-pro/PXL_20240202_134258562.jpg" alt="My demo unit, served up like sushi"/></a></p>
<p>Physically, wearing it feels fine. I stopped even noticing I had it on after a minute or two. I tried both headbands, strongly preferred the single-loop one. Two-loop felt harder to adjust correctly, less secure, and less comfortable. Weight didn&#39;t bother me for the ~15 minute demo I got, hardly noticeable.</p>
<p>They stuck my glasses into a machine to measure the prescription, then selected from the ~100 sets of lenses they had on-hand. My prescription is mild, and I felt I could see clearly while using the device. A guy I met in line has a -8 prescription. Apple had lenses on hand that were either a match, or close enough. He had a good demo experience, but after the demo he found the transition back to his polycarbonate glasses jarring, since they have strong lensing effects.</p>
<p>After calibration (look at dots and click them) it dropped me straight into AR. It feels good, though imperfect. Significant motion blur if I turn my head while looking across the room. But holding still or moving slowly, I have no problem seeing everything around me. I don&#39;t feel isolated from my environment at all. Pass-through is noticeably dimmer than IRL. And foveated vision makes the scene seem less than completely clear in my peripheral vision. But I don&#39;t think I&#39;d need to remove my device for anything unless I was simply done using it.</p>
<p>There was an Apple employee seated beside me running my demo. I found it easy to chat back and forth with him, and basically forgot we were separated by a screen and cameras (except when immersive mode was on, more on that below). It just felt like a normal conversation.</p>
<p>Graphics overlaid on top of pass-through are rock-steady. They stayed exactly in place, no matter how much I rocked or shook my head. Aesthetically, overlaid graphics integrated naturally into the scene. Felt like they were supposed to be there, like a natural part of my vision (as much as anything artificial can).</p>
<p>Positioning windows around me worked well, felt fine. I could place them in X/Y/Z space, and e.g. overlap behavior felt intuitive, even though they intersected at angles (in an arc centered on me). The device is smart about mixing windows into the environment. Initially a window was blocking my view of the guy running my demo. I moved it to the background and device started drawing the window behind the guy. I could also move windows forward to do the equivalent of sitting closer to the screen.</p>
<p>Pinch controls worked alright, a little finicky. For the first minute or so, I was naturally resting my off-hand in my lap in a pose that the Vision Pro mistook for pinching, but I quickly adjusted to stop accidentally clicking everything I saw. Pinch to click, pinch+drag to scroll, pinch both hands and pull/push apart to zoom. A few times the pinch didn&#39;t catch, unclear if it&#39;s because I need practice, because I pinched too soon, or if it&#39;s a technology thing.</p>
<p>The devices has two buttons: one for photo/video (did not use), and a crown that you spin to control immersiveness, and press as the back button, or to pull up the app list if everything is closed.</p>
<p>The whole focus-follows-eyes felt natural for me. But I went in ready, having seen reviews that people were accidentally looking away from whatever they wanted to click. But it was awkward, as there&#39;s a noticeable pause between when I saw something and when it became clickable. Unsure if this is a design choice or not (i.e., to keep the display from going nuts with focus events as you look around). Made it feel slow to interact with things, and I kept trying to click before it was ready. I&#39;d probably get used to the timing in a day or so of use. I did learn that my eye flits around more than I realized.</p>
<p>The device tries to be smart about auto showing+hiding buttons and window controls, but I found it confusing. The handle to reposition windows, close things, change volume, whatever, was often not present when I wanted it and I was never sure what I was supposed to do to see it. Felt like sometimes it would appear if I stared where it was supposed to be, sometimes required a click, etc. But if I clicked, sometimes that did something <em>else</em>. Could just be a learning curve. Not enough time with the device to tell if its behavior was consistent in all situations, or if it was truly doing different things at different times.</p>
<p>They walked me through reading a web page, black text on white background with some images. It was rendered crisply, no pixelation. For (outdated) perspective, on my Oculus Rift I can barely read system menus. On the Vision Pro, I&#39;m not sure I could see pixels at all. Foveation aside, I had no trouble reading anything, and I&#39;ll bet if I spent much time in an immersive scene I could nearly forget I wasn&#39;t physically there.</p>
<p>Yet it felt challenging to read the web page. The foveated rendering left me unable to pull information from my periphery, and I didn&#39;t realize how much I relied on that. I could only &quot;see&quot; things my eyes were pointed directly at. That made it hard to skim, or keep track of where I was on the page. I kinda feel like I had to read slowly and deliberately. As someone becomes proficient at reading, they stop reading letters and start skimming word shapes. The experience made me feel like I rely on skimming sentence shapes, too, and I couldn&#39;t do that - I could only really see the word I was looking at. Maybe I&#39;d get used to this, I don&#39;t know. The actual field of vision was fine (how far around me the scene wraps); no worse than a motorcycle helmet, maybe better. First impression is I&#39;d clearly rather do a workday with a laptop than with a Vision Pro, mostly because of foveation while reading text.</p>
<p>And again, the foveation was noticeable when looking around the room I was in. Everything else felt effortless to look at, and looked great. Apps, photos, videos, system UI.</p>
<p><a href="/blog-images/blogPosts/i-tried-out-the-vision-pro/foveated_rendering.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/i-tried-out-the-vision-pro/foveated_rendering.jpg" alt="Foveated rendering"/></a></p>
<p>This is what things looked like. From Tobii.</p>
<p>They had some 3D recordings, one from an iPhone and I think another from a Vision Pro, and some stuff that resembled National Geographic footage. The NatGeo-style stuff was 180° wrap-around, the rest was a flat plane like a normal phone video, only... 3D-ified. I felt I was <em>present</em> in the scenes in a way that&#39;s truly hard to describe. Closest thing is when I played the Oculus game <em>Lone Echo</em>, free-floating around a space station. Like I wasn&#39;t watching a video, I was <em>filming</em> the video, in-person. I can see this being <em>very</em> compelling for personal moments, and if the tech ever becomes widespread, I&#39;d easily see it replacing 2D feature films (even without wrap-around view).</p>
<p>One scene, sitting on a lake shore during the rain, I cranked up to full-immersive and it felt <em>incredibly peaceful</em>. I would have just sat there in that scene relaxing for as long as I could, if they&#39;d let me. Very therapeudic. Reminds me of when I had solitude at sunset at White Sands.</p>
<p>Immersive mode works great. Shut out everything around me, made the display my whole world. Passing through people&#39;s faces did not work well. I could <em>barely</em> see the guy running the demo, like some ghostly phantasm in the shadows. I tried turning down the immersive knob, but it just started letting in background details without making the guy beside me any clearer, until I was basically back to pass-through.</p>
<p>The knob for pass-through vs immersive has a <em>lot</em> of positions between the two, but I didn&#39;t see any point to the in-between. It felt like all knob positions were basically-pass-through or basically-immersive. I didn&#39;t feel enough of a gradual change to matter.</p>
<p>They didn&#39;t put the dinosaur app in the demo script. The guy I met ignored the script and went and found it, said it totally blew him away.</p>
<p>They did not have me do any photo/video capture.</p>
<p>Sound is <em>very</em> good, to the extent I could evaluate in that environment. It doesn&#39;t do noise-canceling, but it felt that way because when stuff played it felt like the Apple store&#39;s sounds disappeared. Partially the volume was set a bit high, but even after turning it down, it still felt like the headset was all I was listening to.</p>
<p>The light visor kept falling off in my hands when I held the device. It&#39;s only attached with a weak magnet (magsafe-like), and it disconnected any time I held the headset there. I&#39;d probably still disconnect the visor all the time, even once I got used to holding the device by the bezel.</p>
<p>There were one or two moments when I tugged the battery cable trying to look around at 360 scenes, Battery pack was on the seat beside me, but the cable wound up running down my back where it was pressed into the backrest, so it didn&#39;t move freely. Probably not a problem most of the time, but I don&#39;t think it&#39;s practical to deliberately position it somewhere safe.</p>
<p>They didn&#39;t set up anyone&#39;s Persona for demos, so I couldn&#39;t see what the fake-eyeballs thing looked like from the outside.</p>
<p>I didn&#39;t try out typing, but I sure wouldn&#39;t like to do much of it with the pinch gestures. If I connected a keyboard it&#39;d be fine.</p>
<p>I don&#39;t see this replacing a laptop, at least not until the foveating stuff is better. Even then, maybe not. It&#39;s supplemental. But it could easily become most of my leisure computing. I&#39;d almost certainly prefer watching movies this way rather than using my laptop, TV, or a theater (if I&#39;m not watching socially). And it would be incredible if scenes were filmed in that immersive mode, where it felt like I was really there. I&#39;ve watched 3D movies and it&#39;s just not the same.</p>
<p>Seeing other people using the Vision did <em>not</em> give me a weird dystopian creepy feeling. Of course, they were all very animated, talking with their demo handlers and being excited to try new things. Might be different if they go all dead-eyed zombie WALL·E passenger. But there&#39;s a glimmer of hope that it won&#39;t be stigmatized like Google Glass was. Doesn&#39;t look dorky enough to be a problem either, but I&#39;m a lot less sensitive to that aesthetic than others, so take that with a grain of salt.</p>]]></description>
            <link>https://spiffy.tech/i-tried-out-the-vision-pro</link>
            <guid isPermaLink="false">3d819ccd-bba9-4eaf-9a24-8d20856d6a5b</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Fri, 02 Feb 2024 19:40:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[🫴🦋 Is this an emoji?]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/is-this-an-emoji">🫴🦋 Is this an emoji?</a></h1><p><a href="https://readstufflater.com">ReadStuffLater</a> uses emojis to tag content <a href="#why">†</a>. It&#39;s simple, it&#39;s fun, and it affords basic content organization without encouraging users to spiral into reinvent-Dewey-Decimal territory.</p>
<figure class="mb-8"><p><a href="/blog-images/blogPosts/is-this-an-emoji/Screenshot-from-2023-07-27-07-09-46.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/is-this-an-emoji/Screenshot-from-2023-07-27-07-09-46.avif" alt="screenshot of emoji tags"/></a></p><figcaption class="text-center text-sm italic text-gray-600 dark:text-gray-300">Yeah, the aesthetics need work</figcaption></figure>
<p>There&#39;s just one problem: data validation. When the client tells my server to tag a record, how can the server confirm the tag is actually an emoji? I mean, I shouldn&#39;t accept and store just <em>anything</em> in that field, right?</p>
<p>This is a much gnarlier problem than it has any right to be. If you want the TL;DR, see <a href="#solution">what I did</a> and <a href="#better-solution">what I wish I&#39;d done</a>, and <a href="#graphemes">an improved regex solution</a>!</p>
<h1>Failed idea #1: Use Regex character classes</h1>
<p>My first thought was to google around for this, and everyone recommends regex! Everyone! Well that seemed easy.</p>
<p>There is a recent(?) <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Unicode_character_class_escape">extension to regex</a> that lets you specifically ask, &quot;is this an emoji?&quot;</p>
<p>Except it&#39;s wrong. And also not available everywhere.</p>
<pre><code class="language-js">const regex = /^\p{Emoji}$/gu;
console.log(&quot;🙂&quot;.match(regex))
console.log(&quot;*️⃣&quot;.match(regex));
console.log(&quot;👨🏾&quot;.match(regex));

&gt; Array [&quot;🙂&quot;]
&gt; null
&gt; null
</code></pre>
<p>I mean, it produces kinda-okay results if you ask &quot;does this string contain <em>any number of emojis</em>&quot;. But it fails hard when you ask &quot;Is this string made of exactly one emoji, and nothing else?&quot;.</p>
<p>Also, it seems Postgres regex doesn&#39;t support these special character classes, so validation would be strictly at the application layer.</p>
<p>EDIT: Someone showed how to patch some of the holes in this approach and make it work. <a href="#graphemes">Check it out below!</a></p>
<h2>Why does the regex give the wrong answer?</h2>
<p>I&#39;m glad you asked! It turns out there isn&#39;t really such a thing as &quot;an emoji&quot;. You have code points, and code point modifiers, and code point combinations.</p>
<p>A great primer on this is <a href="https://andysalerno.com/posts/weird-emojis/"><em>Bear Plus Snowflake Equals Polar Bear</em></a>.</p>
<p>Here&#39;s the dealio: Let&#39;s say we want to display the emoji for a brown man, &quot;👨🏾&quot;. There isn&#39;t a code point for that. Instead we use &quot;👨 ZWJ 🏿&quot;.</p>
<p>ZWJ is &quot;zero-width joiner&quot;. It&#39;s a Unicode byte that <a href="https://en.wikipedia.org/wiki/Zero-width_joiner">gets used in</a> I guess the Indian Devanagari writing system? But it&#39;s also a fundamental building block for emojis.</p>
<p>Its job is &quot;when a mommy code point loves a daddy code point very much, they come together and make a whole new glyph&quot;.</p>
<p><a href="https://emojipedia.org/emoji-zwj-sequence">Basically any emoji</a> that includes at least 1 person who isn&#39;t a boring yellow person doing nothing is several characters stapled together with ZWJ. Some other things work this way too.</p>
<p>Some examples include: 👪 (man + woman + boy), 👩‍✈️ (woman + airplane), and ❤️‍🔥 (heart + fire).</p>
<p><em>(And flags are multiple code points that aren&#39;t connected by ZWJ! <a href="#flags">††</a>)</em></p>
<p><em>(If your computer doesn&#39;t have current or exhaustive emoji fonts (thanks, Linux!), you might see what&#39;s supposed to be a single glyph instead displayed as several emojis side by side, like how my computer shows &quot;<a href="https://emojipedia.org/women-with-bunny-ears-partying-light-skin-tone">Women With Bunny Ears Partying, Type-1-2</a>&quot; as &quot; 👯 🏻 ♀️&quot;.)</em></p>
<p>So our regex can&#39;t just check if a string is <em>an</em> emoji: many things we want to identify are <em>several</em> emojis stapled together.</p>
<p><em>(The way you want to think about your goal here is <a href="https://stackoverflow.com/a/27331885/191438">&quot;graphemes&quot; and &quot;glyphs&quot;</a>, not &quot;characters&quot;.)</em></p>
<p>Fortunately, when I experimented, it looked like you have to join characters in a specific order, so when you add both skin tone and hair color (&quot;👱🏿‍♂️&quot;) you can count on it happening in exactly one canonical byte sequence. Otherwise, we&#39;d have to dive into Unicode normalization (a good topic to understand anyway!).</p>
<p>Edit: Someone showed me how to make this work. <a href="#graphemes">Check it out below!</a></p>
<h1>Failed idea #2: Use Regex character ranges</h1>
<p>Alright, so we can&#39;t just use the special regex &quot;match me some emoji&quot; feature. What about a regex full of Unicode character ranges? StackOverflow sure loves those!</p>
<p>Well, they&#39;re all either too broad or too narrow.</p>
<p>You get stuff like &quot;just capture anything that&#39;s a &#39;Unicode other symbol&#39;&quot; (<code>/\p{So}+/gu</code>). This fails for the same reasons as approach #1, <em>and also</em> for the bonus reason that this character class includes symbols that aren&#39;t emojis (&#39;❤&#39;).</p>
<p>Ah, but <em>some other</em> StackOverflow answer says to just use a regex for Unicode code points! That <em>also</em> fails the same way as approach #1, plus, nobody includes exhaustive code point ranges in their SO answers.</p>
<p>Here&#39;s a partial list of valid emoji:</p>
<p><a href="/blog-images/blogPosts/is-this-an-emoji/emoji_character_ranges.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/is-this-an-emoji/emoji_character_ranges.avif" alt=""/></a></p>
<p>Two things to note:</p>
<ol>
<li>There are quite a few code ranges that include emoji! Not just handful that all the StackOverflow answers include. If you want zero false positives, you need (eyeballing it) a hundred code point ranges.</li>
<li>See all those grey empty spaces? That&#39;s non-emoji characters that are in those same code point ranges. You probably don&#39;t want to accept &quot;ª&quot;, &quot;«&quot;, etc. as emoji.</li>
</ol>
<p>So you&#39;re either including a bajillion micro-ranges, or a handful of very wide ranges that will give false positives, or you&#39;re rejecting valid emoji.</p>
<p>And once you pick some ranges, I have no idea whether they&#39;ll include the new emoji the standard adds each year.</p>
<p>So validating code point ranges is a <em>terrible</em> approach. It&#39;s just plain wrong: emojis aren&#39;t individual code points in the first place, and you&#39;ll get a huge number of either false positives and false negatives.</p>
<p><em>Oh, don&#39;t forget that JavaScript uses UTF-16, while everything else in the world uses UTF-8. If you&#39;re building a regex with Unicode code points, all your hardcoded numbers will be different.</em></p>
<h1>Failed idea #3: just stuff all possible emojis into a regex</h1>
<p>Alright, so what if I just get a list of <em>EVERY POSSIBLE EMOJI</em>, and build a regex out of them like <code>/🙂|😢|😆/</code>. It&#39;s exhaustive, it&#39;s accurate, and it&#39;ll match individual, whole glyphs.</p>
<p>Except... <code>*️⃣</code> broke my regex, because it&#39;s not its own symbol: it&#39;s a regular asterisk followed followed by other stuff: &quot;* + VS16 + COMBINING ENCLOSING KEYCAP&quot;.</p>
<p>VS16 is the Unicode byte that says &quot;Hey, this character can either look like text or like an emoji, please show it as emoji&quot;.</p>
<p>Regex wasn&#39;t happy about that - all it saw was a random asterisk in my pattern and it threw a fit.</p>
<p>I mean, even the markdown engine for this blog post mistook that as &quot;please make the rest of my post italic&quot; until I put the emoji into a code block.</p>
<p>But maybe I was on the right track trying to exhaustively match all emoji?</p>
<h1>What worked for me</h1>
<p>What I finally came up with was exhaustively validating emoji <em>shortcodes</em> instead of emojis themselves. Shortcodes are those things you type into Github or Slack to summon the emoji popup - e.g., &quot;:winking_face:&quot;.</p>
<p>The great part about shortcodes is they&#39;re strictly simple characters. Off the cuff, I think it&#39;s all <code>a-z</code> and <code>_</code>. Unsure about numbers.</p>
<p>That makes them super convenient to store or pattern match on. Not so convenient for other reasons (see the next section).</p>
<p>So when a user picks out an emoji, I find its shortcode and store that in the database. When I display an emoji, I convert the other way.</p>
<p>To build my allowlist, I found <a href="https://www.npmjs.com/package/emoji-picker-element-data">an NPM package</a> that holds the same data as the <a href="https://www.npmjs.com/package/emoji-picker-element">emoji picker</a> I&#39;m using. I wrote a script to extract all the shortcodes, generate all the appropriate variants, and turned that into a SQL list of values I could copy/paste.</p>
<p>I stuffed that into a database table and foreign key&#39;d my records to it. (I previously used a CHECK constraint using <code>IN</code>, but that made schemas very noisy.)</p>
<p>I wrote the output of that file to disk and checked it into source control. Now every time I build the app I generate the data again and compare against the oracle, so if the package&#39;s list of valid emoji gets updated, I&#39;ll get a build failure until I update my allowlist.</p>
<p>Problem: solved ✅</p>
<h1>What I wish I&#39;d done instead</h1>
<p>I should have done basically the same thing, except with the actual emoji. I used shortcodes because I got caught up in <a href="https://en.wikipedia.org/wiki/Path_dependence">path dependence</a> with the regex stuff. But if I&#39;m already using a data structure of discrete strings, why not just use the emoji themselves?</p>
<p>There&#39;s a modest advantage in network / storage efficiency (why store lot bytes when few bytes do trick?), but the real advantage would be simplicity.</p>
<p>In the emoji dataset I have, an emoji like &quot;:people_holding_hands:&quot; 🧑‍🤝‍🧑 doesn&#39;t have different shortcodes for skin tone or hair color. It&#39;s just &quot;:people_holding_hands:&quot;. <a href="https://emojipedia.org/men-holding-hands-medium-skin-tone#technical">Checking Emojipedia</a>, I get the uncertain impression that shortcodes might not be standardized, and I see some tools have different shortcodes for skin tones, while others don&#39;t.</p>
<p>I had to make up my own encoding for that, including noticing the emoji might have zero skin tones (yellow figures), or multiple skin tones (two figures of different races).</p>
<p>I also have to do a lookup every time I display an emoji. In an ideal world, I&#39;d lazy-load the emoji picker JS so it only downloads when the user actually wants to select an emoji.</p>
<p>But because I have to convert shortcodes to emoji, I have to load the picker <code>Database</code> on any page where I want to <em>display</em> an emoji, so I can figure out what glyph matches my stored data <code>people_holding_hands:3:5</code>.</p>
<p>If I were to revisit my implementation, I&#39;d just store and validate straight-up emoji.</p>
<h1>A more technical solution</h1>
<p><a href="https://lobste.rs/s/vdj66z/is_this_emoji#c_i5vq5i">Over on Lobsters</a>, user singpolyma pointed out how to test a string without needing an oracle.</p>
<p>You use your language&#39;s tools to detect if the string is a single grapheme, <em>and then</em> you check if it either passes the Emoji regex character class, or contains the Emoji variant selector code point.</p>
<p>Here&#39;s what you do:</p>
<pre><code class="language-js">const isEmoji = (e: string) =&gt; {
  const segmenter = new Intl.Segmenter();
  const regex = /\p{Emoji_Presentation}/u;
  const variantSelector = String.fromCodePoint(0xfe0f);

  return Boolean(
    Array.from(segmenter.segment(e)).length === 1 &amp;&amp;
      (e.match(regex) || e.includes(variantSelector))
  );
};
</code></pre>
<p>On my test data set, 229 out of 3,664 emoji fail the regex test by itself, such as ☺️, ☹️, ☠️, 👁️‍🗨️. But all of those contain the VS16 Emoji variant selector byte!</p>
<p>This means you use the grapheme count to tell &quot;does this look like one glyph to the user?&quot;, then follow up with &quot;does this either show as an emoji by default, or get converted into one?&quot;. All the safety, no oracles!</p>
<p>Well... mostly. It does mean any byte sequence containing VS16 will be accepted, which isn&#39;t the same thing as a <em>valid</em> emoji...</p>
<p><code>Intl.Segmenter</code> is available everywhere except Firefox. And Postgres cannot count graphemes or use the Emoji regex character class, so you can only do application-level data validation. But you&#39;re free from managing an allowlist, so there&#39;s that.</p>
<hr/>
<p>Footnotes:</p>
<p><a href="#flags-return">⏎</a> Your emoji picker includes country flags, but the Unicode Consortium doesn&#39;t want to take sides on whether Taiwan is a real country.</p>
<p>So they dodged the issue: if your text includes a 2-letter country abbreviation encoded as emoji letters, it might or might not display as a flag, depending on how your device feels.</p>
<p>So you&#39;re free to include &quot;🇹 🇼&quot; in your text, and if you <em>just so happen</em> to be in a country that doesn&#39;t find Taiwanese sovereignty objectionable, you&#39;ll see it displayed as 🇹🇼. Otherwise you&#39;ll just see 🇹 🇼.</p>
<p>EDIT: <a href="https://lobste.rs/s/vdj66z/is_this_emoji#c_lfgxua">New info from Lobsters</a>: it looks like my information is outdated! Or maybe wrong! I mean the part about how flags are rendered is correct, but the &quot;they don&#39;t say which flags are valid&quot; part might not be.</p>
<p>At some point the list of acceptable country flags <a href="https://github.com/unicode-org/cldr/blob/33a95a266905f494cc7a912749024f2dbb989de8/common/validity/region.xml#L35">got enumerated</a>. That file dates back to 2015, and <a href="https://unicode-org.atlassian.net/browse/CLDR-8741">references a Consortium task</a> seeking to clarify what &quot;subtags&quot; are valid. That Atlassian task is newer than the Github commits, so I guess its timestamp is a lie, leaving me unable to tell how early the enumeration took place.</p>
<p>However, <a href="https://unicode.org/reports/tr51/#Flags">&quot;depictions of images for flags may be subject to constraints by the administration of that region.&quot;</a></p>
<p>I would have learned this factoid sometime around the Unicode 6.0 release in 2010, so maybe they started enumerating country codes later, or maybe I just learned wrong in the first place.</p>
<hr/>
<p><a href="#why-return">⏎</a> Why emoji and not a normal tagging system with arbitrary text?</p>
<p>I want ReadStuffLater to be a very low-friction, low-cognitive-overhead experience. It&#39;s not a place to organize your second brain; it&#39;s just read-and-delete.</p>
<p>Simply making rich organization available can make people feel like they&#39;re supposed to use it. And once people think that&#39;s the kind of app this is, they&#39;ll start expecting features that are expressly out of scope.</p>
<p>Yet once a user saves hundreds of links, they need <em>something</em> besides one giant list. This is my attempt to split the difference. And for product positioning purposes, I want to signal &quot;do not expect this to be the same as Instapaper&quot;.</p>
<p>If a more second-brain-flavored reading list is what you need, I recommend <a href="https://www.instapaper.com">Instapaper</a>, <a href="https://getpocket.com">Pocket</a>, or <a href="https://wallabag.it">Wallabag</a>. They&#39;re a take on this problem with a stronger focus on long-term knowledge retention.</p>]]></description>
            <link>https://spiffy.tech/is-this-an-emoji</link>
            <guid isPermaLink="false">d5c00dd3-ebaa-4e57-b263-38297d9cbc0c</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 24 Jul 2023 18:04:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Audit log ALL the things?]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/audit-log-all-the-things">Audit log ALL the things?</a></h1><p>My app <a href="https://ReadStuffLater.com">ReadStuffLater</a> fundamentally revolves around scraping web pages with the <a href="https://microlink.io">Microlink</a> API. Sometimes that goes wrong: the target web page has a problem. Or Microlink does. Or the target throws up a captcha or blocks data center IPs or something.</p>
<p>I thought I&#39;d done an alright job of handling all the cases that could go wrong. API errors are retried, website errors retry or do something sensible based on common status codes.</p>
<p>Yet I still get links that intermittently fail to scrape for no apparent reason. There are a couple usual suspects where I thought I&#39;d handled all the failure modes, but they keep going wrong. And random pages have problems sometimes, too.</p>
<p>My strategy has been &quot;notice failed scrapes, watch the logs while reexecuting the scrape, then fix whatever I see&quot;. This has problems:</p>
<ol>
<li>Detecting a <em>legitimate</em> failure in hard. Sometimes a scrape unrecoverably fails for reasons outside my control. False negatives are real.</li>
<li>This doesn&#39;t let me diagnose transient failures, where everything is working again by the time I manually verify the problem.</li>
<li>It&#39;s a pain since I don&#39;t have good tools to peek into the scrape process. I&#39;m always setting up something ad-hoc like <code>console.log</code> in local dev.</li>
</ol>
<p>I think the right call here is an audit log. Every scrape would get its raw result stored in the DB. Status codes, body, Microlink metadata. Everything.</p>
<p>I guess I&#39;d need a way to look up the audit records for a given link. A CLI script on the prod box would probably be okay. Maybe reformat the data in a way that&#39;s convenient to munge with <code>jq</code>.</p>
<p>The interesting question is: at what point should I have recognized that I needed an audit log?</p>
<p>Should I be logging ALL external API calls? What internal operations should I log? Do I just wait until I have problems and <em>then</em> start logging? I&#39;m not sure.</p>
<p>The logical extreme of this is &quot;just adopt event sourcing&quot;. And yes, that <em>would</em> solve this problem. But what other problems would it cause? Maybe I should just adopt it piecemeal, and not for the whole system? But then I&#39;m paying the implementation complexity cost for minimal benefit.</p>
<p>Idunno. All I know is right now I sure need an audit log for this one piece of the system 🙂</p>]]></description>
            <link>https://spiffy.tech/audit-log-all-the-things</link>
            <guid isPermaLink="false">b584c84b-5841-4fe2-848b-fb32057a920b</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 24 Jul 2023 16:19:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[A Framework laptop success story]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/a-framework-success-story">A Framework laptop success story</a></h1><p>I was recently on my way out the door when I knocked over a glass of water, spilling it across my Framework laptop. I panicked and tried to dab it up, but saw that water had seeped under the keyboard and was leaking out the bottom of the laptop. The screen began to flicker.</p>
<p>I held down the power button and began disassembling the laptop. In minutes I had the laptop in pieces and ran a hair dryer over it. Water had gotten into many nooks and crannies; every time I tilted the unit a new direction water ran out from somewhere I had missed.</p>
<p>I dried up all the water I could spot, and left the unit open to air out for about 24 hours.</p>
<p>The next morning I put it back together and it works fine!</p>
<p>I don&#39;t think I&#39;d have been so lucky with other laptops I&#39;ve owned. They&#39;ve all been difficult to open, or purposely designed to keep users out. I&#39;d have been at the mercy of however well they drain, with little assurance of when (or if) it was safe to power them on again.</p>
<p>My Framework was trivial to open, even while stressed and anxious, and I had the comfort of knowing that if it <em>did</em> break, I&#39;d probably only have to replace the mainboard, and not the whole laptop.</p>
<p><a href="/blog-images/blogPosts/a-framework-success-story/2023-06-17-09-35-26-534.jpg" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/a-framework-success-story/2023-06-17-09-35-26-534.jpg" alt="disassembled laptop"/></a></p>]]></description>
            <link>https://spiffy.tech/a-framework-success-story</link>
            <guid isPermaLink="false">d33bd920-6a6b-40d5-a829-26e1e826e50d</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 19 Jun 2023 10:08:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Preserve an element's position in the viewport after a layout shift]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/preserve-an-elements-position-in-the-viewport-after-a-layout-shift">Preserve an element&#39;s position in the viewport after a layout shift</a></h1><p>My app includes content areas that expand and collapse. A lot like accordions, except they take up the whole page and can be huge.</p>
<p>When you open one, whatever&#39;s open gets closed, and that makes whatever you just clicked on jump around as the previous content area stops taking up space on the page.</p>
<p>Here&#39;s a code snippet I put together that keeps whatever you just clicked at the same spot in the viewport after the layout shift:</p>
<pre><code class="language-typescript">/**
 * This ensures that the element is at the same position within the viewport
 * after a layout shift.
 *
 * MUST be called BEFORE triggering the layout shift.
 *
 * IT can only do so much - if the layout shift cuts off enough content, the
 * element will still wind up positioned higher in the viewport than before.
 */
export const retainScrollPosition = (el: Element) =&gt; {
  const targetViewportPosition = el.getBoundingClientRect().top;

  requestAnimationFrame(() =&gt; {
    const newPagePositon = el.getBoundingClientRect().top + window.scrollY;
    window.scrollTo({ top: newPagePositon - targetViewportPosition });
  });
};
</code></pre>]]></description>
            <link>https://spiffy.tech/preserve-an-elements-position-in-the-viewport-after-a-layout-shift</link>
            <guid isPermaLink="false">fc17f730-7afe-4b78-b01d-c090e4f7b69c</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Tue, 21 Mar 2023 15:52:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[I used a Monte Carlo simulation to pick my health insurance plan]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/i-used-a-monte-carlo-simulation-to-pick-my-health-insurance-plan">I used a Monte Carlo simulation to pick my health insurance plan</a></h1><p>We have to pick a new health insurance plan this month, and we&#39;ve had a tough time making the decision.</p>
<p>You can&#39;t just add up what you&#39;ll spend - what each thing costs depends on how much you&#39;ve already spent!</p>
<p>And some things are inherently probabilistic - will I go through procedure X this year? How many visits will I need for situation Y? How many urgent care visits?</p>
<p>So complex and uncertain!</p>
<p>Inspired by vaguely recalling that I read <a href="https://lucasfcosta.com/2021/09/20/monte-carlo-forecasts.html">Lucas F. Costa&#39;s blog post</a> some time ago, I applied the Monte Carlo method to my health insurance decision.</p>
<p>I have a simplistic understanding of Monte Carlo simulations:</p>
<ol>
<li>Assign probabilities to everything that can happen in your scenario</li>
<li>Randomly selecting outcomes for each possible event, then repeat the calculations a gazillion times</li>
<li>Measure how things typically play out</li>
</ol>
<p>It can get much fancier (hello, MCMC!) but I think that&#39;s the gist of it.</p>
<p>I put together a simple TypeScript file with some arithmetic operations and calls to <code>Math.random()</code> and ran it with <a href="https://bun.sh">Bun</a>. I punched in all the reasons my wife and I will or might spend on healthcare, added in the premiums, and took the average result.</p>
<p>Surprisingly, the expensive plan will save us a a small bundle this year, even accounting for the higher premiums.</p>
<p>I feel better about the decision since I did something resembling rigorous calculation of which plan is best. In past years, I&#39;ve done my best to make a costs estimate, but I never feel confident in it.</p>]]></description>
            <link>https://spiffy.tech/i-used-a-monte-carlo-simulation-to-pick-my-health-insurance-plan</link>
            <guid isPermaLink="false">8fc2d2dd-4bcf-49b3-80fd-3cc83b4bcbe7</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Thu, 22 Dec 2022 23:39:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Dokku with Let's Encrypt behind Cloudflare]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/dokku-with-lets-encrypt-behind-cloudflare">Dokku with Let&#39;s Encrypt behind Cloudflare</a></h1><p>Dokku has a <a href="https://github.com/dokku/dokku-letsencrypt/">Let&#39;s Encrypt plugin</a> which works behind Cloudflare. There&#39;s just a little bit of chicken-and-egg setup involved.</p>
<p>Let&#39;s Encrypt needs to connect back to your server to validate ownership of your domain. You can&#39;t have Cloudflare&#39;s &quot;full&quot; TLS mode enabled when you&#39;re doing first-time validation, because in &quot;full&quot; mode Cloudflare will error out, failing to establish a TLS connection to your not-yet-TLS backend server.</p>
<p>You <em>could</em> disable &quot;full (strict)&quot; TLS mode in Cloudflare, but then you&#39;ll take all your sites down: Dokku does HTTP -&gt; HTTPS redirects on all sites configured with TLS, and will thus reject the non-TLS inbound connections from Cloudflare&#39;s networks. Or more accurately, it&#39;ll receive an inbound HTTP request from Cloudflare&#39;s servers, return a redirect to HTTPS, which Cloudflare will pass on to the client, but the client is already at an HTTPS URL, so the client will enter an infinite redirect loop.</p>
<p>You can get around all this during first-time setup by disabling Cloudflare&#39;s proxying behavior on your domain while you get Let&#39;s Encrypt set up on Dokku. After it&#39;s set up, you can turn Cloudflare proxying on, and cert renewals should work fine, since Let&#39;s Encrypt validation checks routed through Cloudflare can still establish end-to-end TLS while your certs remain valid.</p>]]></description>
            <link>https://spiffy.tech/dokku-with-lets-encrypt-behind-cloudflare</link>
            <guid isPermaLink="false">3fc075dd-41a4-4dc7-a5c9-6a76b398180c</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 13 Sep 2021 21:15:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Creating a type from nested interface properties in TypeScript]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/creating-a-type-from-nested-interface-properties-in-typescript">Creating a type from nested interface properties in TypeScript</a></h1><p>While at work I was upgrading <a href="https://graphql-code-generator.com/">graphql-code-generator</a>, and found that the generated code no longer exports the type for a query node. Instead, the type of the whole query response was exported. But I had code that relied on having the node type available.</p>
<p>To solve this, I created a new type by reading attributes from deep in the response type.</p>
<pre><code class="language-typescript">interface Foo {
    bar: {
        baz: {
            zap: {
                zoop: number;
            }[];
        };
    };
}

type zoop = Foo[&#39;bar&#39;][&#39;baz&#39;][&#39;zap&#39;][number][&#39;zoop&#39;];
</code></pre>
<p>If some of your properties are optional, you can use <code>Required&lt;T&gt;</code>. If they&#39;re possibly <code>undefined</code>, use <code>Exclude&lt;T, undefined&gt;</code>.</p>]]></description>
            <link>https://spiffy.tech/creating-a-type-from-nested-interface-properties-in-typescript</link>
            <guid isPermaLink="false">2fb8e1ae-971e-4924-8323-756da3aa5ac3</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Tue, 10 Dec 2019 02:03:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[A lazy-loading higher-order component for Svelte]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/a-lazy-loading-higher-order-component-for-svelte">A lazy-loading higher-order component for Svelte</a></h1><p>Recently, while building a simple Reddit clone, I wanted to lazy-load images and comments. That is, rather that loading all of the images and comments the instant I added a component to the DOM, I wanted to wait until the component was actually visible. This spreads out the impact of loading a page, both for the client and the server.</p>
<p>To do this, I used the <code>IntersectionObserver</code> API to create a higher-order Svelte component:</p>
<p><strong>VisibilityGuard.svelte</strong>:</p>
<pre><code class="language-html">&lt;script&gt;
  import { onMount } from &quot;svelte&quot;;

  let el = null;

  let visible = false;
  let hasBeenVisible = false;

  onMount(() =&gt; {
    const observer = new IntersectionObserver(entries =&gt; {
      console.log(&quot;entry&quot;, entries[0]);
      visible = entries[0].isIntersecting;
      hasBeenVisible = hasBeenVisible || visible;
    });
    observer.observe(el);

    return () =&gt; observer.unobserve(el);
  });
&lt;/script&gt;

&lt;div bind:this={el}&gt;
  &lt;slot {visible} {hasBeenVisible} /&gt;
&lt;/div&gt;
</code></pre>
<p>This renders a <code>div</code> and stores the rendered HTML element in the <code>el</code> variable. Once the component has been rendered (<code>onMount</code>), we use <code>IntersectionObserver</code> to watch the component, calling a callback whenever the element intersects with our viewport. We track whether the element is <em>currently</em> visible, and whether it has <em>ever been</em> visible, and we pass those to our child component (<code>slot</code>).</p>
<p>So how do we use this component?</p>
<p><strong>App.html</strong>:</p>
<pre><code class="language-html">&lt;script&gt;
	import VisibilityGuard from &#39;./VisibilityGuard.svelte&#39;;
	
	const images = new Array(100).fill(null).map((n, i) =&gt; [
		Math.floor(Math.random() * 500),
		Math.floor(Math.random() * 750)
	]);
&lt;/script&gt;

{#each images as [x, y]}
	&lt;VisibilityGuard let:hasBeenVisible&gt;
		&lt;div style=&quot;border: 1px solid black; min-height: 40px; min-width: 40px; padding: 5px; border-radius: 5px; margin-bottom: 5px;&quot;&gt;
			&lt;header&gt;I am {hasBeenVisible ? &#39;visible&#39; : &#39;invisible&#39;}&lt;/header&gt;
			&lt;img src={hasBeenVisible ? `https://placekitten.com/${x}/${y}` : null} alt=&quot;a kitten&quot; /&gt;
		&lt;/div&gt;
	&lt;/VisibilityGuard&gt;
{/each}
</code></pre>
<p>We import the <code>VisibilityGuard</code> component and use it to wrap our image. The <code>let:hasBeenVisible</code> directive declares a new variable that comes from our higher-order component, and we use that to determine whether to display our image (by setting or not setting a <code>src</code> attribute).</p>
<p>And that&#39;s that! <a href="https://svelte.dev/repl/d19802ee38b84436824f4daccea9d307?version=3.16.0">Here&#39;s a REPL of this in action</a>.</p>]]></description>
            <link>https://spiffy.tech/a-lazy-loading-higher-order-component-for-svelte</link>
            <guid isPermaLink="false">f21d7123-c2d0-40d7-a6a2-6760b40986c7</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Tue, 03 Dec 2019 02:02:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Creating a bottom nav with CSS Flexbox]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/creating-a-bottom-nav-with-css-flexbox">Creating a bottom nav with CSS Flexbox</a></h1><p>I needed to create a bottom nav (like what&#39;s used in many mobile apps) in CSS. Here&#39;s how I did it.</p>
<p>(<a href="https://codepen.io/spiffytech/pen/GRKbyjg">CodePen</a>)</p>
<pre><code class="language-html">&lt;div class=&quot;container&quot;&gt;
  &lt;div class=&quot;content&quot;&gt;
    &lt;p&gt;bar&lt;/p&gt;
    &lt;p&gt;bar&lt;/p&gt;
    &lt;p&gt;bar&lt;/p&gt;
  &lt;/div&gt;
  &lt;nav&gt;
    &lt;p&gt;foo&lt;/p&gt;
    &lt;p&gt;foo&lt;/p&gt;
    &lt;p&gt;foo&lt;/p&gt;  
    &lt;p&gt;foo&lt;/p&gt;
  &lt;/nav&gt;
&lt;/div&gt;

&lt;style&gt;
.container {
  height: 100vh;
  display: flex;
  flex-direction: column;
}

.content {
  flex: 1 1 auto;
  overflow: auto;
  align-self: stretch;
}
&lt;/style&gt;
</code></pre>]]></description>
            <link>https://spiffy.tech/creating-a-bottom-nav-with-css-flexbox</link>
            <guid isPermaLink="false">6f168086-8f8e-4e58-b3f7-01fce626af84</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Wed, 02 Oct 2019 01:00:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[Setting up Sapper with Netlify CMS]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/setting-up-sapper-with-netlify-cms">Setting up Sapper with Netlify CMS</a></h1><h1>What are Sapper and Netlify CMS?</h1>
<h2>Sapper</h2>
<p>Sapper is Svelte&#39;s answer to Next.js/Nuxt.js. It&#39;s a way of rendering Svelte code on the server so your site is compatible with JavaScript-free devices, and so it renders immediately instead of waiting for a JS blob to download, parse, and run.</p>
<p>Sapper ordinarily runs as a full server application, but using the <code>sapper export</code> command we can generate a static version of our site that we can host on Github Pages or, in this case, Netlify. That&#39;s a great way to have a very fast site that&#39;s free for small-to-medium traffic numbers.</p>
<h2>Netlify CMS</h2>
<p>Netlify CMS is as open-source content management system, meaning it&#39;s a way to create blog posts and web pages through a web page. Since it&#39;s from Netlify, the static site host, it&#39;s designed to work with static site generators like Hugo and Jekyll. We&#39;ll be adapting it to work with Sapper.</p>
<h2>Putting these together</h2>
<p>Adapting Netlify CMS to work with Sapper is pretty straightforward. First we&#39;ll follow <a href="https://www.netlifycms.org/docs/add-to-your-site/">Netlify&#39;s directions</a> for adding the CMS to a generic site. That&#39;ll give us a web interface that drops Markdown files into our Sapper site&#39;s git repository. Next, we&#39;ll update our Sapper site code to see those Markdown files and render them as blog posts.</p>
<p>You can copy/paste the same code changes we make in this tutorial to support adding entire pages to Sapper, or to add multiple content sections, like a personal and a professional blog.</p>
<p>Let&#39;s get started!</p>
<p>* Note: If you want to skip all of this and just get something working, you can clone the <a href="https://github.com/spiffytech/sapper-netlify-cms-starter">repository I made</a> for this tutorial.</p>
<h1>Let&#39;s do it - Netlify CMS</h1>
<h2>Prepare your workspace</h2>
<h3>Create a project</h3>
<p>Start your project by cloning the Sapper template git repository.</p>
<pre><code class="language-shell">$ npx degit &quot;sveltejs/sapper-template#webpack&quot; my-site
$ cd my-site
$ npm install
</code></pre>
<h3>Commit that project</h3>
<p>Go ahead and commit and push this to Github so you can create your Netlify project, which is tied to your Git repo.</p>
<p>Create a new repository on Github, and substitute that URL in the fourth command.</p>
<pre><code class="language-shell">$ git init
$ git add .
$ git commit -am &quot;Degit&#39;d the Sapper starter project&quot;
$ git remote add origin &lt;your github project URL here&gt;
$ git push
</code></pre>
<h2>Go to Netlify and create a project</h2>
<p>Now that we have a barebones Sapper project in Git, it&#39;s time to tell Netlify that we&#39;d like to host that project. Sign up at Netlify.com and begin creating your new Netlify project.</p>
<p><a href="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/wSdDzq2.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/wSdDzq2.avif" alt="New site from Git" title="Click the button to create a new site from a Git repo"/></a></p>
<p>Click the button to create a new site from a Git repo.</p>
<p><a href="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/k8doFjn.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/k8doFjn.avif" alt="github" title="Use Github as the source for your project. Netlify CMS only supports Github at this time."/></a></p>
<p>Use Github as the source for your project. Netlify CMS only supports Github at this time.</p>
<p><a href="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/VYQewcn.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/VYQewcn.avif" alt="project" title="Select the repo you created in Github"/></a></p>
<p>Select the repo you created in Github</p>
<p><a href="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/tWJv8Bp.avif" target="_blank" rel="noopener noreferrer"><img src="/blog-images/blogPosts/setting-up-sapper-with-netlify-cms/tWJv8Bp.avif" alt="configure the app" title="Configure your build process. Set the build command to generate the static Sapper site, and the publish directory to the directory where Sapper exports to."/></a></p>
<p>Configure your build process. Set the build command to generate the static Sapper site, and the publish directory to the directory where Sapper exports to.</p>
<h2>Install the Netlify CMS</h2>
<p>Back in our workspace it&#39;s time to add the Netlify CMS code to our project.</p>
<h3>Add the CMS code</h3>
<p>Create the directory <code>static/admin</code>, then add the below snippet to the file <code>static/admin/index.html</code>. This file contains the code that bootstraps the Netlify CMS.</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;meta charset=&quot;utf-8&quot; /&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
  &lt;title&gt;Content Manager&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;!-- Include the script that builds the page and powers Netlify CMS --&gt;
  &lt;script src=&quot;https://unpkg.com/netlify-cms@^2.0.0/dist/netlify-cms.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h3>Configure Netlify CMS</h3>
<p>Edit <code>static/admin/config.yml</code> and add the following:</p>
<pre><code class="language-yaml">backend:
  name: git-gateway
  branch: master # Branch to update (optional; defaults to master)
publish_mode: editorial_workflow # Allows you to save drafts before publishing them
media_folder: static/uploads # Media files will be stored in the repo under static/blog-images/uploads
public_folder: /uploads # The src attribute for uploaded media will begin with /blog-images/uploads

collections:
  - name: &quot;blog&quot; # Used in routes, e.g., /admin/collections/blog
    label: &quot;Blog&quot; # Used in the UI
    folder: &quot;static/_posts&quot; # The path to the folder where the documents are stored
    create: true # Allow users to create new documents in this collection
    slug: &quot;{{slug}}&quot; # Filename template, e.g., title.md
    fields: # The fields for each document, usually in front matter
      - {label: &quot;Layout&quot;, name: &quot;layout&quot;, widget: &quot;hidden&quot;, default: &quot;blog&quot;}
      - {label: &quot;Title&quot;, name: &quot;title&quot;, widget: &quot;string&quot;}
      - {label: &quot;Publish Date&quot;, name: &quot;date&quot;, widget: &quot;datetime&quot;}
      - {label: &quot;Body&quot;, name: &quot;body&quot;, widget: &quot;markdown&quot;}
</code></pre>
<h2>Set up Netlify authentication</h2>
<p>We&#39;ll use Netlify&#39;s authentication service -- called &quot;Identity&quot; -- tot let users log into our CMS and create posts. We&#39;ll also wire up Netlify with write access to our Git repo so the CMS can actually add the content to the repo.</p>
<h3>Activate Identity</h3>
<p>Follow <a href="https://www.netlifycms.org/docs/add-to-your-site/#enable-identity-and-git-gateway">Netlify&#39;s directions</a> to activate Identity and connect your git account to your Netlify project. Also, invite yourself as a user to the project.</p>
<h3>Add Identity code to your site</h3>
<p>We need to add the Netlify Identity code to both our admin page (so we can log in) and our main site (so it can redirect us back to the admin after we log in).</p>
<p>Take this snippet: <code>&lt;script src=&quot;https://identity.netlify.com/v1/netlify-identity-widget.js&quot;&gt;&lt;/script&gt;</code></p>
<p>And add it to the head section of your <code>static/admin/index.html</code>:</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;meta charset=&quot;utf-8&quot; /&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
  &lt;title&gt;Content Manager&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;!-- Include the script that builds the page and powers Netlify CMS --&gt;
  &lt;script src=&quot;https://unpkg.com/netlify-cms@^2.0.0/dist/netlify-cms.js&quot;&gt;&lt;/script&gt;
+ &lt;script src=&quot;https://identity.netlify.com/v1/netlify-identity-widget.js&quot;&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Also add it to the <code>&lt;svelte:head&gt;</code> section of your <code>src/routes/index.svelte</code>:</p>
<pre><code class="language-html">&lt;svelte:head&gt;
    &lt;title&gt;Sapper project template&lt;/title&gt;
+   &lt;script src=&quot;https://identity.netlify.com/v1/netlify-identity-widget.js&quot;&gt;&lt;/script&gt;
&lt;/svelte:head&gt;
</code></pre>
<ol start="3">
<li>You&#39;ll also need to add this snippet to the top of your <code>src/routes/index.svelte</code>:</li>
</ol>
<pre><code class="language-html">&lt;script&gt;
  import { onMount } from &#39;svelte&#39;;

  onMount(() =&gt; {
    if (window.netlifyIdentity) {
      window.netlifyIdentity.on(&quot;init&quot;, user =&gt; {
        if (!user) {
          window.netlifyIdentity.on(&quot;login&quot;, () =&gt; {
            document.location.href = &quot;/admin/&quot;;
          });
        }
      });
    }
  });
&lt;/script&gt;
</code></pre>
<h2>Success part 1!</h2>
<p>Commit and push your code.</p>
<pre><code class="language-shell">$ git add .
$ git commit -am &quot;Configured the site to run the Netlify CMS&quot;
$ git push
</code></pre>
<p>Wait for Netlify to deploy it, then visit your admin site (the URL will be your Netlify site + <code>/admin</code>, like <a href="https://awesome-bose-294ddb.netlify.com/admin">https://awesome-bose-294ddb.netlify.com/admin</a>), log in, and create a post! You won&#39;t see the post on your published site yet -- Sapper still doesn&#39;t know anything about the Netlify CMS content. Let&#39;s fix that!</p>
<h1>Lets do it - Sapper rendering markdown blog posts</h1>
<p>Here&#39;s where the real work comes in. Sapper, out of the box, reads posts from a rather unwieldy <code>_posts.json</code> file. We&#39;re going to replace that with reading from Markdown files that Netlify CMS creates in our repo.</p>
<h2>Install dependencies</h2>
<p>You&#39;ll need to install a few packages for managing the markdown files:</p>
<p><code>npm install mz glob markdown-it front-matter</code></p>
<ul>
<li><code>glob</code> makes it easy to get a list of Markdown files</li>
<li><code>mz</code> wraps the standard Node.js <code>fs</code> library in promises, so we can <code>async</code>/<code>await</code> our way to success</li>
<li><code>front-matter</code> reads the metadata out of our blog posts and separates it from the markdown content</li>
<li><code>markdown-it</code> will render our markdown content</li>
</ul>
<h2>Update the <code>blog.json</code> server route</h2>
<p>The built-in Sapper blog engine reads a list of all blog entries from the <code>/blog.json</code> server route, which is controlled by the <code>src/routes/blog/index.json.js</code> file. We&#39;re going to open that file and replace the whole thing with this:</p>
<pre><code class="language-javascript">import fm from &#39;front-matter&#39;;
import glob from &#39;glob&#39;;
import {fs} from &#39;mz&#39;;
import path from &#39;path&#39;;

export async function get(req, res) {
  // List the Markdown files and return their filenames
  const posts = await new Promise((resolve, reject) =&gt;
      glob(&#39;static/_posts/*.md&#39;, (err, files) =&gt; {
      if (err) return reject(err);
      return resolve(files);
    }),
  );

  // Read the files and parse the metadata + content
  const postsFrontMatter = await Promise.all(
    posts.map(async post =&gt; {
      const content = (await fs.readFile(post)).toString();
      // Add the slug (based on the filename) to the metadata, so we can create links to this blog post
      return {...fm(content).attributes, slug: path.parse(post).name};
    }),
  );

  // Sort by reverse date, because it&#39;s a blog
  postsFrontMatter.sort((a, b) =&gt; (a.date &lt; b.date ? 1 : -1));

  res.writeHead(200, {
    &#39;Content-Type&#39;: &#39;application/json&#39;,
  });

  // Send the list of blog posts to our Svelte component
  res.end(JSON.stringify(postsFrontMatter));
}
</code></pre>
<p>Now, when the <code>blog.json</code> server route is called, Sapper will scan the list of markdown files at <code>static/_posts/</code>, read the metadata for each one, and create a list of the blog titles, dates, and any other fields (besides content) that we added to our Netlify CMS <code>fields</code> section.</p>
<h2>Edit the per-post Svelte component</h2>
<p>Next up, we need to update our Svelte component to fetch the Markdown files instead of the old JSON content, then render those files to HTML and present the content to the user.</p>
<h3>Remove an unused server route</h3>
<p>Sapper provides a server route for extracting post content from the <code>_posts.js</code> file. Since we&#39;re not using that file, we need neither the file nor the server route. remove both:</p>
<pre><code class="language-shell">$ cd src/routes/blog
$ rm _posts.js [slug].json.js
</code></pre>
<h3>Render markdown posts</h3>
<p>Next, open <code>src/routes/blog/[slug].svelte</code> and replace both <code>&lt;script&gt;</code> blocks with this code:</p>
<pre><code class="language-html">&lt;script context=&quot;module&quot;&gt;                                                                                                                                                                                                                                                                   
  export async function preload({ params, query }) {
    // the `slug` parameter is available because
    // this file is called [slug].svelte
    const res = await this.fetch(`_posts/${params.slug}.md`);

    if (res.status === 200) {
      return { postMd: await res.text() };
    } else {
      this.error(res.status, data.message);
    }
  }
&lt;/script&gt;

&lt;script&gt;
  import fm from &#39;front-matter&#39;;
  import MarkdownIt from &#39;markdown-it&#39;;

  export let postMd;

  const md = new MarkdownIt();

  $: frontMatter = fm(postMd);
  $: post = {
    ...frontMatter.attributes,
    html: md.render(frontMatter.body)
  };
&lt;/script&gt;
</code></pre>
<p>We&#39;ve changed the default Sapper code in two ways:</p>
<ol>
<li>We fetch text from the server instead of JSON</li>
<li>We break up that text into metadata and content, and render the content.</li>
</ol>
<p>When we put the metadata and content back together, we&#39;re passing the rest of the Svelte component the same data it expected to get from the old <code>[slug].json.js</code> server route, and now everything renders!</p>
<h2>Success part 2!</h2>
<h3>See our work in action</h3>
<p>Our site now works! You can see it for yourself by running <code>npm run dev</code> and visiting <a href="http://localhost:3000">http://localhost:3000</a> .</p>
<h3>Verify the exported site</h3>
<p>If you want to verify your site exports correctly before committing and sending it to Netlify, you can do this:</p>
<pre><code class="language-shell">$ npm run export
$ npx serve __sapper__/export
</code></pre>
<p>if the export command didn&#39;t produce any <code>500</code> messages, visit <a href="http://localhost:5000">http://localhost:5000</a>, click around and confirm that everything works like you expect.</p>
<h3>Send it to Netlify</h3>
<pre><code class="language-shell">$ git add .
$ git commit -am &quot;Configured the site to read and publish markdown blog posts&quot;
$ git push
</code></pre>
<p>After waiting for Netlify to publish your site, you can visit your site and see the glorious blog post you created earlier!</p>
<p>You&#39;re now all set with a hosted and operational Sapper blog!</p>]]></description>
            <link>https://spiffy.tech/setting-up-sapper-with-netlify-cms</link>
            <guid isPermaLink="false">96ea1ac3-8903-432b-a714-20a3615ca728</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Mon, 05 Aug 2019 01:05:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[GraphQL Subscriptions with Vanilla JS]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/graphql-subscriptions-with-vanilla-js">GraphQL Subscriptions with Vanilla JS</a></h1><p>I&#39;ve been working on a project that uses GraphQL via Hasura. Using Subscriptions (real-time updates to queries) is a key feature for my project, but it&#39;s one that most of the simpler GraphQL client libraries don&#39;t support. That leaves me looking at heavyweight tools Apollo. Apollo is pretty complicated to configure (I&#39;m paying for a lot of features I don&#39;t use), and it _strongly_ steers you towards React hooks for accessing data; I had to dig pretty hard to find the documentation for their language-agnostic client library. I&#39;ve really come to believe data fetches shouldn&#39;t be represented declaratively, at least not the way React hooks handles it. The ergonomics are poor and the flexibility is low every time I try it.</p>
<p>So I went looking for a GraphQL client that could handle GraphQL subscriptions, which was simple to set up and worked great when used imperatively.</p>
<p>[Edit: since originally writing this, I see urql is (now?) designed for more than just React use, and has docs for imperative, non-framework-specific usage (though that&#39;s clearly not what they&#39;re steering you towards). It looks simpler to set up, though I haven&#39;t tried it yet.]</p>
<p>The solution I settled on was to use the same underlying library that powers Apollo&#39;s subscriptions feature. It&#39;s simple to use from vanilla JS, and can be used for <code>Query</code>, <code>Mutation</code> and <code>Subscription</code>. The package is <code>subscriptions-transport-ws</code>.</p>
<p>Here&#39;s an example of making an authenticated GraphQL subscription request:</p>
<pre><code class="language-javascript">import gql from &#39;graphql-tag&#39;;
import {SubscriptionClient} from &#39;subscriptions-transport-ws&#39;;

const wsclient = new SubscriptionClient(
  &#39;wss://example.com&#39;,
  {
      reconnect: true,
      connectionParams: {
        headers: {
          &#39;Authorization&#39;: `Bearer &lt;your token here&gt;`,
        }
      },
    }
);

wsclient.request({
  query: gql`
    fragment transaction on transactions {
      id
      user_id
    }
    subscription SubscribeTransactions($user_id: String!) {
      transactions(where: { user_id: { _eq: $user_id } }) {
        ...transaction
      }
    }
  `,
  variables: { user_id: &#39;capncrunch&#39; }
  // Don&#39;t forget to check for an `errors` property in the next() handler
}).subscribe({next: console.log, error: console.error})
</code></pre>]]></description>
            <link>https://spiffy.tech/graphql-subscriptions-with-vanilla-js</link>
            <guid isPermaLink="false">8dcd5943-dd24-44a5-b0df-058afed46382</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Tue, 23 Jul 2019 00:55:00 GMT</pubDate>
        </item>
        <item>
            <title><![CDATA[A Minimal Svelte Router]]></title>
            <description><![CDATA[<h1><a href="https://spiffy.tech/a-minimal-svelte-router">A Minimal Svelte Router</a></h1><p>Here is a minimal router for Svelte v3 using Page.js.</p>
<p>Whenever the route changes, Page.js sets a variable that holds the component that should be rendered for the route.</p>
<pre><code class="language-javascript">&lt;script&gt;
  import page from &#39;page&#39;;

  import Home from &#39;./views/Home.svelte&#39;;

  let route;
  let routeParams;

  function setRoute(r) {
    return function({ params }) {
      route = r;
      routeParams = params;
    };
  }

  page(&quot;/&quot;, setRoute(Home));
  page({ hashbang: true });
&lt;/script&gt;

&lt;svelte:component this={route} bind:params={routeParams} /&gt;
</code></pre>]]></description>
            <link>https://spiffy.tech/a-minimal-svelte-router</link>
            <guid isPermaLink="false">b4e95f1a-05bc-4f40-b567-599c029a30bc</guid>
            <dc:creator><![CDATA[spiffytech]]></dc:creator>
            <pubDate>Sat, 13 Jul 2019 00:48:00 GMT</pubDate>
        </item>
    </channel>
</rss>