<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-04-09T23:46:59-07:00</updated><id>/feed.xml</id><title type="html">Rachel Sprague</title><subtitle>Senior Data Analyst</subtitle><entry><title type="html">Adding an RSS Feed to a Jekyll Site</title><link href="/blog/2026/03/24/add-rss-feed-to-jekyll-site/" rel="alternate" type="text/html" title="Adding an RSS Feed to a Jekyll Site" /><published>2026-03-24T00:00:00-07:00</published><updated>2026-03-24T00:00:00-07:00</updated><id>/blog/2026/03/24/add-rss-feed-to-jekyll-site</id><content type="html" xml:base="/blog/2026/03/24/add-rss-feed-to-jekyll-site/"><![CDATA[<div class="image-row">
  <img src="/assets/images/blog/2026-03-24/ss1.png" alt="RSS feed screenshot" style="max-width:50%;" />
</div>

<p>I didn’t realize RSS feeds were still a thing, so (naturally), I wanted one. Turns out Jekyll basically does it for you.</p>

<hr />

<h3 id="step-1---the-plugin"><strong>Step 1 - The Plugin</strong></h3>

<p>GitHub Pages supports <code class="language-plaintext highlighter-rouge">jekyll-feed</code> out of the box — no custom build step needed. Add it to <code class="language-plaintext highlighter-rouge">_config.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">plugins</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">jekyll-feed</span>
</code></pre></div></div>

<p>Push that and your feed is live at <code class="language-plaintext highlighter-rouge">/feed.xml</code>. Easy. Like Sunday morning.</p>

<hr />

<h3 id="step-2---add-it---make-it-discoverable"><strong>Step 2 - Add It - Make It Discoverable</strong></h3>

<p>Browsers and RSS readers look for a <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tag in the page head to auto-detect feeds. Add this to the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> of your layouts:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"alternate"</span> <span class="na">type=</span><span class="s">"application/atom+xml"</span> <span class="na">title=</span><span class="s">"Rachel Sprague"</span> <span class="na">href=</span><span class="s">"/feed.xml"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Then add a visible link somewhere on the page — I put mine in the footer:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"/feed.xml"</span><span class="nt">&gt;</span>RSS<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<hr />

<h3 id="step-3---rejoice"><strong>Step 3 - Rejoice</strong></h3>

<p>That’s the whole thing. We did it, Joe.</p>

<hr />

<h3 id="rss-readers"><strong>RSS Readers</strong></h3>

<p>It’s been a long time since I’ve used an RSS Reader (RIP <a href="https://en.wikipedia.org/wiki/Google_Reader">Google Reader</a>). Apparantly I already have a <a href="https://feedly.com/">Feedly</a> account so I downloaded the app and am trying it out.</p>

<hr />

<h3 id="tools--skills"><strong>Tools &amp; Skills</strong></h3>

<ul>
  <li>Jekyll</li>
  <li>GitHub Pages</li>
  <li>RSS / Atom</li>
  <li><code class="language-plaintext highlighter-rouge">jekyll-feed</code> plugin</li>
</ul>

<hr />]]></content><author><name></name></author><category term="jekyll" /><category term="tools" /><category term="setup" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-24/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-24/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stop Repeating Yourself to Your AI Agent</title><link href="/blog/2026/03/23/stop-repeating-yourself-to-your-ai-agent/" rel="alternate" type="text/html" title="Stop Repeating Yourself to Your AI Agent" /><published>2026-03-23T00:00:00-07:00</published><updated>2026-03-23T00:00:00-07:00</updated><id>/blog/2026/03/23/stop-repeating-yourself-to-your-ai-agent</id><content type="html" xml:base="/blog/2026/03/23/stop-repeating-yourself-to-your-ai-agent/"><![CDATA[<p>AI pair programming. It’s so hot right now.</p>

<hr />

<h3 id="a-little-context"><strong>A Little Context</strong></h3>

<p>I’m a senior data analyst. I work mostly in dbt, SQL, and Git. I’ve been using AI coding assistants heavily for the past year and have tried most of the major models. Lately I’ve been on Claude (who isn’t, amirite?).</p>

<p>Bad code in a data pipeline might silently corrupt downstream reports for weeks before anyone notices. That caution that comes with data has been one of the more important things to communicate to an agent.</p>

<hr />

<h3 id="what-i-noticed"><strong>What I Noticed</strong></h3>

<p>I found myself repeating myself while working with agents. Don’t deploy yet. Let me validate first. Don’t assume the existing logic is correct. Show me what you’re changing before you change it.</p>

<p>Providing context to an agent is crucial.</p>

<hr />

<h3 id="the-agents-file"><strong>The AGENTS File</strong></h3>

<p>Most AI coding tools support some version of a persistent instructions file — a markdown file that lives in your repo and gets loaded into the agent’s context at the start of every session. Call it <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, <code class="language-plaintext highlighter-rouge">.cursorrules</code>, whatever your tool supports. Same idea.</p>

<p>I started one. Actually, I asked Claude to make one for me. And I updated the file as I would think of more context.</p>

<p>The file lives in the repo, which means it’s version controlled.</p>

<hr />

<h3 id="using-ai-to-audit-itself"><strong>Using AI to Audit Itself</strong></h3>

<p>I asked the agent to review our conversation (or conversations, if your agent has the memory) and identify patterns:</p>

<ul>
  <li>Things I consistently pushed back on</li>
  <li>Corrections I made more than once</li>
  <li>Preferences that I repeated</li>
</ul>

<p>It surfaced things I hadn’t consciously noticed myself doing. It validated that I consistently insisted on validating and adding this to the AGENTS file was the right move.</p>

<p>Now that’s in there. Next session, the agent already knows.</p>

<hr />

<h3 id="why-this-matters-for-data-work-specifically"><strong>Why This Matters for Data Work Specifically</strong></h3>

<p>An agent will optimize for shipping. That’s fine for a lot of work. For data work, I’d rather it optimize for correctness and visibility — show your work, flag assumptions, don’t touch what we’re not changing.</p>

<p>Those aren’t hard rules to follow. But the agent won’t follow them consistently unless you tell it to, and it won’t remember you told it unless it’s somewhere persistent.</p>

<p>The AGENTS file is just a way of encoding how you work so you’re not re-explaining yourself every day.</p>

<hr />

<h3 id="tools--skills"><strong>Tools &amp; Skills</strong></h3>

<ul>
  <li>AI-assisted development</li>
  <li>Prompt engineering</li>
  <li>dbt / SQL</li>
  <li>Git</li>
  <li>Workflow design</li>
</ul>

<hr />]]></content><author><name></name></author><category term="tools" /><category term="ai" /><category term="workflow" /><summary type="html"><![CDATA[AI pair programming. It’s so hot right now.]]></summary></entry><entry><title type="html">I Haven’t Bought a New Computer in Ten Years and Everything Is Fine</title><link href="/blog/2026/03/22/my-2015-macbook/" rel="alternate" type="text/html" title="I Haven’t Bought a New Computer in Ten Years and Everything Is Fine" /><published>2026-03-22T00:00:00-07:00</published><updated>2026-03-22T00:00:00-07:00</updated><id>/blog/2026/03/22/my-2015-macbook</id><content type="html" xml:base="/blog/2026/03/22/my-2015-macbook/"><![CDATA[<div class="image-row">
  <img src="/assets/images/blog/2026-03-22/ss1.png" alt="Screenshot 1" />
</div>

<hr />

<p>My laptop is a (Early) 2015 MacBook. It is over ten years old. I am still using it. Everything is fine.</p>

<p>I haven’t needed to replace it. It runs. It does what I need it to do. Working around its limitations became part of how I work.</p>

<hr />

<h3 id="what-im-running-on-it"><strong>What I’m Running On It</strong></h3>

<p>Here’s what it’s handling on a regular basis:</p>

<ul>
  <li>Building and maintaining three GitHub Pages sites</li>
  <li>Livestreaming an aquarium (yes, really)</li>
  <li>Image editing in GIMP and Inkscape — including designing our Save the Dates (I got married late last year 🎉) and now our thank you cards</li>
  <li>Running AI tools daily</li>
</ul>

<p>Not too shabby for a ten-year-old laptop.</p>

<hr />

<h3 id="how-i-work-around-the-limits"><strong>How I Work Around the Limits</strong></h3>

<p>The biggest constraint is resources. I can’t run a heavy IDE — no VS Code sitting open with a dozen extensions loaded. So I’ve built a lean workflow.</p>

<p>The GitHub web editor handles most of my coding. It’s browser-based, surprisingly capable, and doesn’t touch my local memory at all. For everything else, the Terminal app. That’s it.</p>

<p>For the aquarium livestream, yes I had to reduce the output quality. Yes I limit sources in OBS. Does it freeze sometimes? Sometimes. We don’t talk about that.</p>

<p>I did try to expand the RAM at one point. Turns out it’s soldered to the logic board and not removable. Not upgradeable. I was annoyed.</p>

<p>The upside of this constraint is that I’ve gotten very comfortable with browser-based tools and lightweight workflows. I don’t reach for heavy software because I can’t, and most of the time I don’t need to.</p>

<hr />

<h3 id="ai"><strong>AI</strong></h3>

<p>AI doesn’t make the hardware faster, but it removes the bottleneck that used to require more powerful tools. I’m building things I genuinely couldn’t have built on my own before — not for lack of skills, but lack of time. Googling everything and actually finding a good resource took time. A lot of time. Now, instant answers, instant code, instant debugging help. My 2015 MacBook doesn’t need to be faster because the workflow got smarter.</p>

<hr />

<h3 id="how-i-do-this-for-free"><strong>How I Do This For Free</strong></h3>

<p>My entire tech life has basically been: <em>how can I do this for free, or as close to free as possible, on whatever hardware I already have?</em></p>

<p>MySpace profile CSS. Geocities. Free hosting with FTP uploads. GIMP instead of Photoshop. GitHub Pages instead of a paid host. If I can do it for free, why not?</p>

<p>The 2015 MacBook fits that perfectly. It’s paid for. It works. She’s my ride or die. Literally.</p>

<hr />

<h3 id="will-i-ever-replace-it"><strong>Will I Ever Replace It?</strong></h3>

<p>Yes. One day. When she crosses the rainbow bridge.</p>

<p>And she’ll know she was loved.</p>]]></content><author><name></name></author><category term="tools" /><category term="setup" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-22/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-22/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Pulling the Latest Media from Bluesky into Your Site</title><link href="/blog/2026/03/21/bluesky-api-latest-photo-and-video/" rel="alternate" type="text/html" title="Pulling the Latest Media from Bluesky into Your Site" /><published>2026-03-21T00:00:00-07:00</published><updated>2026-03-21T00:00:00-07:00</updated><id>/blog/2026/03/21/bluesky-api-latest-photo-and-video</id><content type="html" xml:base="/blog/2026/03/21/bluesky-api-latest-photo-and-video/"><![CDATA[<p>I wanted to pull my latest photo and video posts from a Bluesky account directly into a page on my personal site. I didn’t want to embed anything or use an iframe (do people still use iframes? Genuine question - I haven’t used an iframe since 2002). Turns out Bluesky has a public API that makes this pretty straightforward.</p>

<hr />

<h3 id="what-it-does"><strong>What It Does</strong></h3>

<ul>
  <li>Fetches the most recent photo post from a Bluesky account</li>
  <li>Fetches the most recent video post from the same account</li>
  <li>Displays both inline on the page with links back to the original posts</li>
  <li>No API key required — Bluesky’s public feed endpoints are open</li>
</ul>

<hr />

<h3 id="the-api"><strong>The API</strong></h3>

<p>Bluesky’s public API is part of the AT Protocol. The endpoint I used:</p>

<p><code class="language-plaintext highlighter-rouge">https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed</code></p>

<p>Key parameters:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">actor</code> — the Bluesky handle</li>
  <li><code class="language-plaintext highlighter-rouge">filter=posts_with_media</code> — limits results to posts that have media attached</li>
  <li><code class="language-plaintext highlighter-rouge">limit=50</code> — how many posts to scan</li>
</ul>

<p>No authentication needed for public accounts.</p>

<hr />

<h3 id="how-i-got-here"><strong>How I Got Here</strong></h3>

<p>Started with just photos. That part was easy — the API response is clean and consistent for images.</p>

<p>Video was a different story. First issue: reposts were showing up in the feed and I only wanted original posts, so I had to filter those out. Second issue: Bluesky surfaces video under a few different <code class="language-plaintext highlighter-rouge">$type</code> values depending on how it was uploaded, so a single check wasn’t enough. Took some digging through the raw API response in dev tools to figure out what was actually coming back.</p>

<p>The final version handles all of it.</p>

<hr />

<h3 id="the-html"><strong>The HTML</strong></h3>

<p>Just two placeholder divs that JavaScript fills in:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h2&gt;</span>Latest Photo<span class="nt">&lt;/h2&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"latest-photo"</span><span class="nt">&gt;&lt;/div&gt;</span>

<span class="nt">&lt;h2&gt;</span>Latest Video<span class="nt">&lt;/h2&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"latest-video"</span><span class="nt">&gt;&lt;/div&gt;</span>
</code></pre></div></div>

<hr />

<h3 id="the-javascript"><strong>The JavaScript</strong></h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">loadBlueskyMedia</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">handle</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">your-handle.bsky.app</span><span class="dl">"</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">photoEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">latest-photo</span><span class="dl">"</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">videoEl</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">latest-video</span><span class="dl">"</span><span class="p">);</span>

  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span>
      <span class="s2">`https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">handle</span><span class="p">)}</span><span class="s2">&amp;filter=posts_with_media&amp;limit=50`</span>
    <span class="p">);</span>
    <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>

    <span class="kd">let</span> <span class="nx">latestPhoto</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
    <span class="kd">let</span> <span class="nx">latestVideo</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>

    <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="nx">feed</span> <span class="o">||</span> <span class="p">[]))</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">post</span> <span class="o">=</span> <span class="nx">item</span><span class="p">.</span><span class="nx">post</span><span class="p">;</span>
      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">post</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>

      <span class="c1">// Skip reposts</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">post</span><span class="p">.</span><span class="nx">reason</span><span class="p">?.</span><span class="nx">$type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">app.bsky.feed.defs#reasonRepost</span><span class="dl">"</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>

      <span class="kd">const</span> <span class="nx">embed</span> <span class="o">=</span> <span class="nx">post</span><span class="p">.</span><span class="nx">embed</span><span class="p">;</span>
      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">embed</span><span class="p">)</span> <span class="k">continue</span><span class="p">;</span>

      <span class="c1">// Photo</span>
      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">latestPhoto</span> <span class="o">&amp;&amp;</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">$type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">app.bsky.embed.images#view</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">images</span><span class="p">?.</span><span class="nx">length</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">img</span> <span class="o">=</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">images</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
        <span class="nx">latestPhoto</span> <span class="o">=</span> <span class="p">{</span> <span class="na">url</span><span class="p">:</span> <span class="nx">img</span><span class="p">.</span><span class="nx">fullsize</span> <span class="o">||</span> <span class="nx">img</span><span class="p">.</span><span class="nx">thumb</span><span class="p">,</span> <span class="na">rkey</span><span class="p">:</span> <span class="nx">post</span><span class="p">.</span><span class="nx">uri</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">pop</span><span class="p">()</span> <span class="p">};</span>
      <span class="p">}</span>

      <span class="c1">// Video — handles a few different embed types</span>
      <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">latestVideo</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">((</span><span class="nx">embed</span><span class="p">.</span><span class="nx">$type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">app.bsky.embed.video#view</span><span class="dl">"</span> <span class="o">||</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">$type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">app.bsky.embed.video#viewExternal</span><span class="dl">"</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">playlist</span><span class="p">)</span> <span class="p">{</span>
          <span class="nx">latestVideo</span> <span class="o">=</span> <span class="p">{</span> <span class="na">url</span><span class="p">:</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">playlist</span><span class="p">,</span> <span class="na">rkey</span><span class="p">:</span> <span class="nx">post</span><span class="p">.</span><span class="nx">uri</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">pop</span><span class="p">(),</span> <span class="na">thumb</span><span class="p">:</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">thumbnail</span> <span class="o">||</span> <span class="dl">""</span> <span class="p">};</span>
        <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">embed</span><span class="p">.</span><span class="nx">$type</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">app.bsky.embed.recordWithMedia#view</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">media</span><span class="p">)</span> <span class="p">{</span>
          <span class="kd">const</span> <span class="nx">media</span> <span class="o">=</span> <span class="nx">embed</span><span class="p">.</span><span class="nx">media</span><span class="p">;</span>
          <span class="k">if</span> <span class="p">(</span><span class="nx">media</span><span class="p">.</span><span class="nx">playlist</span><span class="p">)</span> <span class="p">{</span>
            <span class="nx">latestVideo</span> <span class="o">=</span> <span class="p">{</span> <span class="na">url</span><span class="p">:</span> <span class="nx">media</span><span class="p">.</span><span class="nx">playlist</span><span class="p">,</span> <span class="na">rkey</span><span class="p">:</span> <span class="nx">post</span><span class="p">.</span><span class="nx">uri</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">pop</span><span class="p">(),</span> <span class="na">thumb</span><span class="p">:</span> <span class="nx">media</span><span class="p">.</span><span class="nx">thumbnail</span> <span class="o">||</span> <span class="dl">""</span> <span class="p">};</span>
          <span class="p">}</span>
        <span class="p">}</span>
      <span class="p">}</span>

      <span class="k">if</span> <span class="p">(</span><span class="nx">latestPhoto</span> <span class="o">&amp;&amp;</span> <span class="nx">latestVideo</span><span class="p">)</span> <span class="k">break</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="c1">// Render photo</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">latestPhoto</span> <span class="o">&amp;&amp;</span> <span class="nx">photoEl</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">photoEl</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="s2">`
        &lt;a href="https://bsky.app/profile/</span><span class="p">${</span><span class="nx">handle</span><span class="p">}</span><span class="s2">/post/</span><span class="p">${</span><span class="nx">latestPhoto</span><span class="p">.</span><span class="nx">rkey</span><span class="p">}</span><span class="s2">" target="_blank" rel="noopener"&gt;
          &lt;img src="</span><span class="p">${</span><span class="nx">latestPhoto</span><span class="p">.</span><span class="nx">url</span><span class="p">}</span><span class="s2">" style="max-width:100%;border-radius:12px;"&gt;
        &lt;/a&gt;`</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">photoEl</span><span class="p">)</span> <span class="nx">photoEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">No recent photo.</span><span class="dl">"</span><span class="p">;</span>

    <span class="c1">// Render video</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">latestVideo</span> <span class="o">&amp;&amp;</span> <span class="nx">videoEl</span><span class="p">)</span> <span class="p">{</span>
      <span class="nx">videoEl</span><span class="p">.</span><span class="nx">innerHTML</span> <span class="o">=</span> <span class="s2">`
        &lt;a href="https://bsky.app/profile/</span><span class="p">${</span><span class="nx">handle</span><span class="p">}</span><span class="s2">/post/</span><span class="p">${</span><span class="nx">latestVideo</span><span class="p">.</span><span class="nx">rkey</span><span class="p">}</span><span class="s2">" target="_blank" rel="noopener"&gt;
          &lt;video controls style="max-width:100%;border-radius:12px;" </span><span class="p">${</span><span class="nx">latestVideo</span><span class="p">.</span><span class="nx">thumb</span> <span class="p">?</span> <span class="s2">`poster="</span><span class="p">${</span><span class="nx">latestVideo</span><span class="p">.</span><span class="nx">thumb</span><span class="p">}</span><span class="s2">"`</span> <span class="p">:</span> <span class="dl">""</span><span class="p">}</span><span class="s2">&gt;
            &lt;source src="</span><span class="p">${</span><span class="nx">latestVideo</span><span class="p">.</span><span class="nx">url</span><span class="p">}</span><span class="s2">"&gt;
            Your browser does not support the video tag.
          &lt;/video&gt;
        &lt;/a&gt;`</span><span class="p">;</span>
    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">videoEl</span><span class="p">)</span> <span class="nx">videoEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">No recent video.</span><span class="dl">"</span><span class="p">;</span>

  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">err</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nx">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Failed to load Bluesky media:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">err</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">photoEl</span><span class="p">)</span> <span class="nx">photoEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">Failed to load photo.</span><span class="dl">"</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">videoEl</span><span class="p">)</span> <span class="nx">videoEl</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">Failed to load video.</span><span class="dl">"</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">loadBlueskyMedia</span><span class="p">();</span>
</code></pre></div></div>

<hr />

<h3 id="tools--skills"><strong>Tools &amp; Skills</strong></h3>

<ul>
  <li>Bluesky AT Protocol API</li>
  <li>Vanilla JavaScript (async/await, fetch)</li>
  <li>HTML</li>
  <li>Jekyll</li>
  <li>GitHub Pages</li>
  <li>AI-assisted development</li>
</ul>

<hr />

<div class="image-row">
  <img src="/assets/images/blog/2026-03-21/ss1.png" alt="Bluesky media widget screenshot 1" />
  <img src="/assets/images/blog/2026-03-21/ss2.png" alt="Bluesky media widget screenshot 2" />
</div>]]></content><author><name></name></author><category term="javascript" /><category term="api" /><category term="html" /><summary type="html"><![CDATA[I wanted to pull my latest photo and video posts from a Bluesky account directly into a page on my personal site. I didn’t want to embed anything or use an iframe (do people still use iframes? Genuine question - I haven’t used an iframe since 2002). Turns out Bluesky has a public API that makes this pretty straightforward.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-21/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-21/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Widget with Rotating Text</title><link href="/blog/2026/03/20/building-a-widget-with-rotating-text/" rel="alternate" type="text/html" title="Building a Widget with Rotating Text" /><published>2026-03-20T00:00:00-07:00</published><updated>2026-03-20T00:00:00-07:00</updated><id>/blog/2026/03/20/building-a-widget-with-rotating-text</id><content type="html" xml:base="/blog/2026/03/20/building-a-widget-with-rotating-text/"><![CDATA[<p>I thought it would be fun to add a status widget to my landing page.</p>

<p>I created a widget that displays a single line that changes on every page load, pulled randomly from a list.</p>

<p>Simple. Favorite.</p>

<hr />

<h3 id="what-it-does"><strong>What It Does</strong></h3>

<ul>
  <li>Displays a “Currently:” label with a line of text beneath it</li>
  <li>Picks a random line from a list on every page load</li>
  <li>No API, no dependencies, no refresh logic — just vanilla JavaScript</li>
</ul>

<hr />

<h3 id="the-html"><strong>The HTML</strong></h3>

<p>A single card with a static label and a paragraph that JavaScript swaps out:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"section-card"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"title"</span><span class="nt">&gt;</span>Currently:<span class="nt">&lt;/span&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">id=</span><span class="s">"currently-text"</span><span class="nt">&gt;</span>Loading...<span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<hr />

<h3 id="the-javascript"><strong>The JavaScript</strong></h3>

<p>The whole thing is about ten lines:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">currentlyLines</span> <span class="o">=</span> <span class="p">[</span>
  <span class="dl">"</span><span class="s2">Claude.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Prompting.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Adding one more widget.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Whack-a-CSS-issue.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Fixing 1 layout issue, creating two more.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Exploring Github Pages.</span><span class="dl">"</span><span class="p">,</span>
  <span class="dl">"</span><span class="s2">Trying to convince myself I enjoy debugging CSS.</span><span class="dl">"</span>
<span class="p">];</span>

<span class="kd">const</span> <span class="nx">currentlyText</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">currently-text</span><span class="dl">"</span><span class="p">);</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">currentlyText</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">line</span> <span class="o">=</span> <span class="nx">currentlyLines</span><span class="p">[</span><span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">()</span> <span class="o">*</span> <span class="nx">currentlyLines</span><span class="p">.</span><span class="nx">length</span><span class="p">)];</span>
  <span class="nx">currentlyText</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="nx">line</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h3 id="why-i-like-this"><strong>Why I Like This</strong></h3>

<p>There’s no server, no API call, no build step. It’s just a list of strings and ten lines of JavaScript. Anyone who visits the page gets a slightly different experience, and updating it is as simple as editing an array.</p>

<hr />

<h3 id="whats-next"><strong>What’s Next</strong></h3>

<p>The list is easy to update so I’ll keep adding to it as things change. Eventually I might add a fade transition between loads, but for now the snap-in is fine.</p>

<hr />

<h3 id="tools--skills"><strong>Tools &amp; Skills</strong></h3>

<ul>
  <li>Vanilla JavaScript</li>
  <li>HTML</li>
  <li>Jekyll</li>
  <li>GitHub Pages</li>
  <li>AI-assisted development</li>
</ul>

<hr />

<div class="image-row">
  <img src="/assets/images/blog/2026-03-20/ss1.png" alt="Currently widget screenshot 1" />
  <img src="/assets/images/blog/2026-03-20/ss2.png" alt="Currently widget screenshot 2" />
  <img src="/assets/images/blog/2026-03-20/ss3.png" alt="Currently widget screenshot 3" />
</div>]]></content><author><name></name></author><category term="javascript" /><category term="html" /><summary type="html"><![CDATA[I thought it would be fun to add a status widget to my landing page.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-20/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-20/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Last.fm Now Playing Widget</title><link href="/blog/2026/03/19/lastfm-now-playing-widget/" rel="alternate" type="text/html" title="Building a Last.fm Now Playing Widget" /><published>2026-03-19T00:00:00-07:00</published><updated>2026-03-19T00:00:00-07:00</updated><id>/blog/2026/03/19/lastfm-now-playing-widget</id><content type="html" xml:base="/blog/2026/03/19/lastfm-now-playing-widget/"><![CDATA[<div class="image-row">
  <img src="/assets/images/blog/2026-03-19/ss1.png" alt="Widget screenshot 1" />
</div>
<hr />

<p>Music is a big part of my life. I’ve been scrobbling to <a href="https://www.last.fm">Last.fm</a> for years now (since 2008, to be exact) and I wanted to show what I was currently listening to on my personal site.</p>

<p>The result: a small widget that shows what I’m currently listening to (or last listened to), the album art, and how many times I’ve played that track.</p>

<hr />

<h3 id="what-it-does"><strong>What It Does</strong></h3>

<ul>
  <li>🎧 Shows “Now playing” if a track is actively scrobbling</li>
  <li>🎧 Falls back to “Last played” with a time ago stamp if nothing is playing</li>
  <li>Displays album art (clickable, opens my Last.fm library)</li>
  <li>Shows my personal play count for that track</li>
  <li>Refreshes automatically every 60 seconds</li>
  <li>Pulses with a soft blue glow when something is actively playing</li>
</ul>

<hr />

<h3 id="the-api"><strong>The API</strong></h3>

<p>Last.fm has a free, public API. You just need to register for an API key at <a href="https://www.last.fm/api">last.fm/api</a>.</p>

<p>The two endpoints I used:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">user.getrecenttracks</code> — pulls the most recent scrobble, and flags it as <code class="language-plaintext highlighter-rouge">nowplaying</code> if it’s live</li>
  <li><code class="language-plaintext highlighter-rouge">track.getInfo</code> — pulls metadata for a specific track, including your personal play count</li>
</ul>

<hr />

<h3 id="the-html"><strong>The HTML</strong></h3>

<p>A simple card structure:</p>
<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"music"</span> <span class="na">class=</span><span class="s">"now-item"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"track-status"</span><span class="nt">&gt;</span>🎧 Loading music...<span class="nt">&lt;/div&gt;</span>
  <span class="nt">&lt;img</span> <span class="na">id=</span><span class="s">"album-art"</span> <span class="na">src=</span><span class="s">""</span> <span class="na">alt=</span><span class="s">"Album art"</span> <span class="nt">/&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"track-name"</span><span class="nt">&gt;&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"track-timestamp"</span><span class="nt">&gt;&lt;/div&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"track-playcount"</span><span class="nt">&gt;&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<hr />

<h3 id="the-javascript"><strong>The JavaScript</strong></h3>

<p>The main function fetches recent tracks, checks if something is actively playing, then fires a second request to get the play count:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">LASTFM_API_KEY</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">your_api_key_here</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">LASTFM_USER</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">your_username</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">REFRESH_MS</span> <span class="o">=</span> <span class="mi">60000</span><span class="p">;</span>

<span class="k">async</span> <span class="kd">function</span> <span class="nx">updateNowPlaying</span><span class="p">()</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">recentUrl</span> <span class="o">=</span> <span class="s2">`https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&amp;user=</span><span class="p">${</span><span class="nx">LASTFM_USER</span><span class="p">}</span><span class="s2">&amp;api_key=</span><span class="p">${</span><span class="nx">LASTFM_API_KEY</span><span class="p">}</span><span class="s2">&amp;format=json&amp;limit=1`</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">recentUrl</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
  <span class="kd">const</span> <span class="nx">track</span> <span class="o">=</span> <span class="nx">data</span><span class="p">?.</span><span class="nx">recenttracks</span><span class="p">?.</span><span class="nx">track</span><span class="p">?.[</span><span class="mi">0</span><span class="p">];</span>

  <span class="kd">const</span> <span class="nx">artist</span> <span class="o">=</span> <span class="nx">track</span><span class="p">.</span><span class="nx">artist</span><span class="p">[</span><span class="dl">"</span><span class="s2">#text</span><span class="dl">"</span><span class="p">];</span>
  <span class="kd">const</span> <span class="nx">title</span> <span class="o">=</span> <span class="nx">track</span><span class="p">.</span><span class="nx">name</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">art</span> <span class="o">=</span> <span class="nx">track</span><span class="p">.</span><span class="nx">image</span><span class="p">?.[</span><span class="mi">2</span><span class="p">]?.[</span><span class="dl">"</span><span class="s2">#text</span><span class="dl">"</span><span class="p">]</span> <span class="o">||</span> <span class="dl">""</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">isNowPlaying</span> <span class="o">=</span> <span class="nx">track</span><span class="p">[</span><span class="dl">"</span><span class="s2">@attr</span><span class="dl">"</span><span class="p">]?.</span><span class="nx">nowplaying</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">;</span>

  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">track-status</span><span class="dl">"</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span>
    <span class="nx">isNowPlaying</span> <span class="p">?</span> <span class="dl">"</span><span class="s2">🎧 Now playing:</span><span class="dl">"</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">🎧 Last played:</span><span class="dl">"</span><span class="p">;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">track-name</span><span class="dl">"</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">artist</span><span class="p">}</span><span class="s2"> — </span><span class="p">${</span><span class="nx">title</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">album-art</span><span class="dl">"</span><span class="p">).</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">art</span><span class="p">;</span>

  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">isNowPlaying</span> <span class="o">&amp;&amp;</span> <span class="nx">track</span><span class="p">.</span><span class="nx">date</span><span class="p">?.</span><span class="nx">uts</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">diffMinutes</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">((</span><span class="k">new</span> <span class="nb">Date</span><span class="p">()</span> <span class="o">-</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="nx">track</span><span class="p">.</span><span class="nx">date</span><span class="p">.</span><span class="nx">uts</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">))</span> <span class="o">/</span> <span class="mi">60000</span><span class="p">);</span>
    <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">track-timestamp</span><span class="dl">"</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span>
      <span class="nx">diffMinutes</span> <span class="o">&lt;</span> <span class="mi">60</span> <span class="p">?</span> <span class="s2">`scrobbled </span><span class="p">${</span><span class="nx">diffMinutes</span><span class="p">}</span><span class="s2"> min ago`</span>
                       <span class="p">:</span> <span class="s2">`scrobbled </span><span class="p">${</span><span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nx">diffMinutes</span> <span class="o">/</span> <span class="mi">60</span><span class="p">)}</span><span class="s2"> hr ago`</span><span class="p">;</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">infoUrl</span> <span class="o">=</span> <span class="s2">`https://ws.audioscrobbler.com/2.0/?method=track.getInfo&amp;api_key=</span><span class="p">${</span><span class="nx">LASTFM_API_KEY</span><span class="p">}</span><span class="s2">&amp;artist=</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">artist</span><span class="p">)}</span><span class="s2">&amp;track=</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">title</span><span class="p">)}</span><span class="s2">&amp;user=</span><span class="p">${</span><span class="nx">LASTFM_USER</span><span class="p">}</span><span class="s2">&amp;format=json`</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">infoRes</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">infoUrl</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">infoData</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">infoRes</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>
  <span class="nb">document</span><span class="p">.</span><span class="nx">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">track-playcount</span><span class="dl">"</span><span class="p">).</span><span class="nx">textContent</span> <span class="o">=</span>
    <span class="s2">`</span><span class="p">${</span><span class="nx">infoData</span><span class="p">?.</span><span class="nx">track</span><span class="p">?.</span><span class="nx">userplaycount</span> <span class="o">||</span> <span class="mi">1</span><span class="p">}</span><span class="s2"> plays`</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nx">animateBoxUpdate</span><span class="p">(</span><span class="nx">box</span><span class="p">,</span> <span class="nx">callback</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">box</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">opacity</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
  <span class="nx">box</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">transform</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">translateY(4px)</span><span class="dl">"</span><span class="p">;</span>
  <span class="nx">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">callback</span><span class="p">();</span>
    <span class="nx">box</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">opacity</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
    <span class="nx">box</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">transform</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">translateY(0)</span><span class="dl">"</span><span class="p">;</span>
  <span class="p">},</span> <span class="mi">300</span><span class="p">);</span>
<span class="p">}</span>

<span class="nx">updateNowPlaying</span><span class="p">();</span>
<span class="nx">setInterval</span><span class="p">(</span><span class="nx">updateNowPlaying</span><span class="p">,</span> <span class="nx">REFRESH_MS</span><span class="p">);</span>
</code></pre></div></div>

<hr />

<h3 id="the-css"><strong>The CSS</strong></h3>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">#music</span> <span class="p">{</span>
  <span class="nl">background</span><span class="p">:</span> <span class="n">rgba</span><span class="p">(</span><span class="m">255</span><span class="p">,</span> <span class="m">255</span><span class="p">,</span> <span class="m">255</span><span class="p">,</span> <span class="m">0.05</span><span class="p">);</span>
  <span class="nl">border-radius</span><span class="p">:</span> <span class="m">12px</span><span class="p">;</span>
  <span class="nl">padding</span><span class="p">:</span> <span class="m">0.9rem</span> <span class="m">1rem</span><span class="p">;</span>
  <span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
  <span class="nl">flex-direction</span><span class="p">:</span> <span class="n">column</span><span class="p">;</span>
  <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  <span class="py">gap</span><span class="p">:</span> <span class="m">0.45rem</span><span class="p">;</span>
  <span class="nl">width</span><span class="p">:</span> <span class="m">250px</span><span class="p">;</span>
  <span class="nl">transition</span><span class="p">:</span> <span class="n">all</span> <span class="m">0.25s</span> <span class="n">ease</span><span class="p">;</span>
<span class="p">}</span>

<span class="nf">#music</span><span class="nd">:hover</span> <span class="p">{</span>
  <span class="nl">transform</span><span class="p">:</span> <span class="n">translateY</span><span class="p">(</span><span class="m">-2px</span><span class="p">)</span> <span class="n">scale</span><span class="p">(</span><span class="m">1.01</span><span class="p">);</span>
<span class="p">}</span>

<span class="nf">#music</span> <span class="nt">img</span><span class="nf">#album-art</span> <span class="p">{</span>
  <span class="nl">width</span><span class="p">:</span> <span class="m">72px</span><span class="p">;</span>
  <span class="nl">height</span><span class="p">:</span> <span class="m">72px</span><span class="p">;</span>
  <span class="nl">border-radius</span><span class="p">:</span> <span class="m">8px</span><span class="p">;</span>
  <span class="nl">object-fit</span><span class="p">:</span> <span class="n">cover</span><span class="p">;</span>
  <span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
<span class="p">}</span>

<span class="nf">#music</span><span class="nc">.playing</span> <span class="p">{</span>
  <span class="nl">animation</span><span class="p">:</span> <span class="n">musicPulse</span> <span class="m">2s</span> <span class="n">infinite</span> <span class="n">ease-in-out</span><span class="p">;</span>
  <span class="nl">border-color</span><span class="p">:</span> <span class="n">rgba</span><span class="p">(</span><span class="m">120</span><span class="p">,</span> <span class="m">170</span><span class="p">,</span> <span class="m">255</span><span class="p">,</span> <span class="m">0.6</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">@keyframes</span> <span class="n">musicPulse</span> <span class="p">{</span>
  <span class="err">0</span><span class="o">%,</span> <span class="err">100</span><span class="o">%</span> <span class="p">{</span> <span class="nl">box-shadow</span><span class="p">:</span> <span class="m">0</span> <span class="m">0</span> <span class="m">6px</span> <span class="n">rgba</span><span class="p">(</span><span class="m">120</span><span class="p">,</span> <span class="m">170</span><span class="p">,</span> <span class="m">255</span><span class="p">,</span> <span class="m">0.35</span><span class="p">);</span> <span class="p">}</span>
  <span class="err">50</span><span class="o">%</span>       <span class="p">{</span> <span class="nl">box-shadow</span><span class="p">:</span> <span class="m">0</span> <span class="m">0</span> <span class="m">14px</span> <span class="n">rgba</span><span class="p">(</span><span class="m">120</span><span class="p">,</span> <span class="m">170</span><span class="p">,</span> <span class="m">255</span><span class="p">,</span> <span class="m">0.6</span><span class="p">);</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<hr />

<h3 id="tools--skills"><strong>Tools &amp; Skills</strong></h3>

<ul>
  <li>Last.fm API</li>
  <li>Vanilla JavaScript (async/await, fetch)</li>
  <li>CSS animations</li>
  <li>GitHub Pages</li>
  <li>HTML</li>
  <li>JSON / REST APIs</li>
  <li>AI-assisted development</li>
</ul>

<hr />

<h3 id="whats-next"><strong>What’s Next</strong></h3>

<p>I also built a Twitch stream status card alongside this one — same pattern, different API. <a href="/blog/2026/03/17/adding-live-twitch-status">I wrote that one up here.</a></p>]]></content><author><name></name></author><category term="javascript" /><category term="api" /><category term="css" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-19/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-19/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Linktree-Style Layout for My Site</title><link href="/blog/2026/03/18/linktree-style-landing-page/" rel="alternate" type="text/html" title="Building a Linktree-Style Layout for My Site" /><published>2026-03-18T00:00:00-07:00</published><updated>2026-03-18T00:00:00-07:00</updated><id>/blog/2026/03/18/linktree-style-landing-page</id><content type="html" xml:base="/blog/2026/03/18/linktree-style-landing-page/"><![CDATA[<p>I had a Linktree forwarding my domain. I loved it. It was easy, straightforward, and exactly what I needed for a simple online presence. But it was limited.</p>

<p>Then I discovered Github Pages and thought: <em>Why not just make my own?</em></p>

<hr />

<h3 id="the-goal"><strong>The Goal</strong></h3>

<ul>
  <li>Minimal and clean</li>
  <li>Central hub for my projects</li>
  <li>Fast to update</li>
</ul>

<hr />

<h3 id="why-linktree-style"><strong>Why Linktree-style</strong></h3>

<p>It checks all the boxes:</p>

<ul>
  <li>✅ Familiar layout (especially on mobile)</li>
  <li>✅ Simple to navigate</li>
  <li>✅ Easy to update</li>
</ul>

<hr />

<h3 id="layout-decisions"><strong>Layout decisions</strong></h3>

<p>It needed to look good on both mobile and desktop.</p>

<p>The first version was pretty minimal: logo, social icons, and a couple of links. Not much, but the structure was there.</p>

<p>Later I added a short bio and a list of projects so it didn’t look so sad. I grouped things into sections.</p>

<hr />

<h3 id="current-state"><strong>Current state</strong></h3>

<ul>
  <li>Clean layout</li>
  <li>Reusable structure</li>
  <li>Easy to extend with widgets</li>
</ul>

<hr />

<h3 id="tools-used--skills-sharpened"><strong>Tools Used &amp; Skills Sharpened</strong></h3>

<ul>
  <li>Github Pages</li>
  <li>Markdown</li>
  <li>Jekyll</li>
  <li>CSS</li>
  <li>HTML</li>
  <li>ChatGPT</li>
</ul>

<p>All on my trusty 2015 MacBook.</p>

<hr />

<h3 id="where-its-going"><strong>Where it’s going</strong></h3>

<p>I’ll keep tinkering with the site and adding content. The landing page will stay as the Linktree-style hub, linking out to other parts of the site.</p>

<hr />

<div class="image-row">
  <img src="/assets/images/blog/2026-03-18/ss1.png" alt="Layout 1" />
  <img src="/assets/images/blog/2026-03-18/ss2.png" alt="Layout 2" />
  <img src="/assets/images/blog/2026-03-18/ss3.png" alt="Layout 3" />
</div>]]></content><author><name></name></author><category term="jekyll" /><category term="css" /><category term="html" /><summary type="html"><![CDATA[I had a Linktree forwarding my domain. I loved it. It was easy, straightforward, and exactly what I needed for a simple online presence. But it was limited.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-18/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-18/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Adding a Live Twitch Status to a Static Site</title><link href="/blog/2026/03/17/adding-live-twitch-status/" rel="alternate" type="text/html" title="Adding a Live Twitch Status to a Static Site" /><published>2026-03-17T00:00:00-07:00</published><updated>2026-03-17T00:00:00-07:00</updated><id>/blog/2026/03/17/adding-live-twitch-status</id><content type="html" xml:base="/blog/2026/03/17/adding-live-twitch-status/"><![CDATA[<p>Normally, fetching live data requires a server, but we can pull the Twitch status for a channel directly from the browser using JavaScript - no backend setup needed. I also added a subtle pulsing animation when live to make it stand out visually. I’m using Github’s Twitch channel as an example</p>

<div style="justify-content:center; text-align:center; margin:2rem 0;">
  <p class="demo-label">Live Demo (updates every 60s)</p>

    <div id="twitch-status" class="twitch-card">
        <a href="https://www.twitch.tv/github" target="_blank" rel="noopener">
            <div class="text-block">
                <strong id="twitch-text">Checking status...</strong>
                <span class="duration"></span>
            </div>
        </a>
    </div>
</div>

<h2 id="the-goal">The Goal</h2>

<p>I wanted a card on my site that:</p>

<ul>
  <li>Shows whether my stream is live</li>
  <li>Displays the duration if live</li>
  <li>Updates automatically every minute</li>
  <li>Works on a completely static Jekyll site</li>
</ul>

<h2 id="step-1-html-structure">Step 1: HTML Structure</h2>

<p>I added a simple card in my <code class="language-plaintext highlighter-rouge">index.md</code></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;div id="twitch-status" class="twitch-card"&gt;
  &lt;a href="https://www.twitch.tv/github" target="_blank" rel="noopener"&gt;
    &lt;div class="text-block"&gt;
      &lt;strong id="twitch-text"&gt;Checking status...&lt;/strong&gt;
      &lt;span class="duration"&gt;&lt;/span&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;
</code></pre></div></div>

<h2 id="step-2-javascript-to-fetch-status">Step 2: JavaScript to Fetch Status</h2>

<p>I use <a href="https://decapi.me">decapi.me</a> to fetch Twitch uptime. Here’s the JavaScript:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// Twitch Status Widget
    async function updateTwitchStatus() {
    try {
        const res = await fetch("https://decapi.me/twitch/uptime/github");
        const text = await res.text();

        const card = document.getElementById("twitch-status");
        const textBlock = card.querySelector(".text-block");
        const mainText = textBlock.querySelector("#twitch-text");
        const durationEl = textBlock.querySelector(".duration");

        if (text.toLowerCase().includes("offline")) {
        mainText.textContent = "Stream Offline";
        durationEl.textContent = "";
        card.classList.remove("live");
        } else {
        mainText.textContent = "Live on Twitch";
        durationEl.textContent = text;
        card.classList.add("live");
        }
    } catch (err) {
        console.error("Failed to fetch Twitch status:", err);
        const card = document.getElementById("twitch-status");
        const textBlock = card.querySelector(".text-block");
        textBlock.querySelector("#twitch-text").textContent = "🐠 Stream status unavailable";
        textBlock.querySelector(".duration").textContent = "";
        card.classList.remove("live");
    }
    }

    // Initial load + refresh every 60s
    updateTwitchStatus();
    setInterval(updateTwitchStatus, 60000);
</code></pre></div></div>

<h2 id="step-3-styling">Step 3: Styling</h2>

<p>Here’s the CSS I added to <code class="language-plaintext highlighter-rouge">style.css</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.twitch-card {
    background: #f4f4f4;
    color: #111;
    max-width: 260px;
    margin: 0 auto;
}

.twitch-card.live {
    background: rgba(0, 128, 255, 0.3);
    animation: twitchPulse 1.8s infinite ease-in-out;
    border: 1px solid rgba(78, 228, 78, 0.6);
}

@keyframes twitchPulse {
    0%, 100% { box-shadow: 0 0 8px rgba(78, 228, 78, 0.3); }
    50% { box-shadow: 0 0 16px rgba(78, 228, 78, 0.6); }
}
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">live</code> class triggers both the background color and the pulsing animation when the stream is live.</p>

<h2 id="step-4-result">Step 4: Result</h2>

<ul>
  <li>The card updates automatically every 60 seconds.</li>
  <li>Shows <strong>Live</strong> or <strong>Offline</strong> instantly.</li>
  <li>Pulses subtly when live for attention.</li>
  <li>Works entirely on a static Jekyll site.</li>
</ul>

<h2 id="example">Example</h2>

<p>Here’s how it looks in the wild:</p>

<div class="image-row">
  <img src="/assets/images/blog/2026-03-17/ss1.png" alt="Twitch widget in use" />
</div>
<p class="image-caption">Integrated Twitch Live status</p>

<script>
    // Twitch Status Widget
    async function updateTwitchStatus() {
    try {
        const res = await fetch("https://decapi.me/twitch/uptime/github");
        const text = await res.text();

        const card = document.getElementById("twitch-status");
        const textBlock = card.querySelector(".text-block");
        const mainText = textBlock.querySelector("#twitch-text");
        const durationEl = textBlock.querySelector(".duration");

        if (text.toLowerCase().includes("offline")) {
        mainText.textContent = "Stream Offline";
        durationEl.textContent = "";
        card.classList.remove("live");
        } else {
        mainText.textContent = "Live on Twitch";
        durationEl.textContent = text;
        card.classList.add("live");
        }
    } catch (err) {
        console.error("Failed to fetch Twitch status:", err);
        const card = document.getElementById("twitch-status");
        const textBlock = card.querySelector(".text-block");
        textBlock.querySelector("#twitch-text").textContent = "🐠 Stream status unavailable";
        textBlock.querySelector(".duration").textContent = "";
        card.classList.remove("live");
    }
    }

    // Initial load + refresh every 60s
    updateTwitchStatus();
    setInterval(updateTwitchStatus, 60000);
</script>]]></content><author><name></name></author><category term="javascript" /><category term="api" /><category term="css" /><summary type="html"><![CDATA[Normally, fetching live data requires a server, but we can pull the Twitch status for a channel directly from the browser using JavaScript - no backend setup needed. I also added a subtle pulsing animation when live to make it stand out visually. I’m using Github’s Twitch channel as an example]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="/assets/images/blog/2026-03-17/ss1.png" /><media:content medium="image" url="/assets/images/blog/2026-03-17/ss1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>