<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Raymond Camden</title>
    <link href="https://www.raymondcamden.com/feed.xml" rel="self" type="application/atom+xml"></link>
    <link href="https://www.raymondcamden.com/" rel="alternate" type="text/html"></link>
    <subtitle>Father, husband, developer relations and web standards expert, and cat demo builder.</subtitle>

    <updated>2026-05-19T22:04:29+00:00</updated>
    <author>
        <name>Raymond Camden</name>
        <email>raymondcamden@gmail.com</email>
    </author>
    <id>https://www.raymondcamden.com/feed.xml</id>

    <generator>Eleventy</generator>

        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog</id>
                <title>My First AI Skill for My Blog</title>
                <updated>2026-05-19T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/19/my-first-ai-skill-for-my-blog" rel="alternate" type="text/html" title="My First AI Skill for My Blog"/>
                <content type="html">
				
                        &lt;p&gt;I&apos;ve been a professional writer now for thirty plus years, and honestly, it&apos;s one of the things I&apos;m most proud about. When generative AI first exploded on the scene, a lot of people used it to help them write, and frankly, that wasn&apos;t for me. I&apos;m not the best writer, but I damn well know how to write and damn well know my own voice. That being said, I&apos;ve been really interested in how GenAI can &lt;em&gt;help&lt;/em&gt; with the process.&lt;/p&gt;
&lt;p&gt;I first wrote about this over two years ago: &lt;a href=&quot;https://www.raymondcamden.com/2024/02/02/using-generative-ai-as-your-content-assistant&quot;&gt;Using Generative AI as Your Content Assistant&lt;/a&gt;. In that post I talked about using GenAI for two very specific tasks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Helping with my titles&lt;/li&gt;
&lt;li&gt;Writing the description (which is part of the metadata for a post)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Honestly, I built those tools as proof of concept implementations and don&apos;t think I used them ever again. But lately it&apos;s been on my mind that I should think about those tools and see if there would possibly be an easier way to use GenAI to help me with my content. Specifically I&apos;m looking for two things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Spelling and grammar. I&apos;ve got a Visual Studio Code extension for that but it&apos;s not perfect and I also just miss it sometimes.&lt;/li&gt;
&lt;li&gt;And here&apos;s the big one. Sometimes I&apos;ll write about a topic and simply forget to cover something. I&apos;d love to use my AI tool to try to find those missing aspects and let me know. Maybe I won&apos;t care. Maybe I &lt;em&gt;intentionally&lt;/em&gt; skipped something. That being said, I&apos;d still like to know.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You&apos;ll notice that none of the above involves AI actually &lt;em&gt;writing&lt;/em&gt; for me. Outside of correcting spelling mistakes I made, I don&apos;t want to use my tool for that. But both of these could really help me catch things before I publish and just make the final result a little bit better.&lt;/p&gt;
&lt;h2 id=&quot;the-skill&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-skill&quot;&gt;The Skill&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To build this, I made use of &lt;a href=&quot;https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview&quot;&gt;Claude Skills&lt;/a&gt;. Skills are persistent instructions that can be used across your entire environment or per project. They&apos;re written in simple Markdown and are stored in a particular folder depending on your use. I actually cheated a bit and used Claude itself to help me create the skill.&lt;/p&gt;
&lt;p&gt;I knew about skills, knew they needed to be in a particular folder, but rather than looking it up I simply asked Claude to help me create a skill based on the needs above. It did an admirable job but I took over and tweaked things a bit.&lt;/p&gt;
&lt;p&gt;The directory is in my repository now, at &lt;code&gt;.claude/skills/prepublish&lt;/code&gt;. Here&apos;s what it looks like now (I say &apos;now&apos; as I expect to be tweaking this over time - you can also check out the &lt;a href=&quot;https://github.com/cfjedimaster/raymondcamden2023&quot;&gt;repo&lt;/a&gt; to see the latest!):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
name: prepublish
description: Review a new blog post before publishing. Finds the latest post by its dated path under `src/posts/`, then spell-checks and offers content suggestions — without touching tone.
---

# Prepublish blog post review

Run this right before pushing a new post. Blog posts live in `src/posts/YYYY/MM/DD/` as `.md` files.

## Step 1 — Find the post

Do NOT use git to figure out which post is newest. Use the dated path under `src/posts/` — sort highest year → month → day and pick the most recent `.md` file.

A reliable command: `find src/posts -type f -name &apos;*.md&apos; | sort -r | head -5`. The first entry is the latest post; the rest are there in case there&apos;s a tie or the user wants to pick another.

If multiple `.md` files share the newest date directory, ask the user which one. Otherwise, confirm in one line (&amp;quot;Reviewing `path/to/post.md` — sound right?&amp;quot;) before proceeding.

## Step 2 — Spell check

Read the post and surface misspellings. Ignore code blocks, fenced code, inline `code`, URLs, and proper nouns the user clearly intended (product names, people, libraries).

Present findings as a short list: `word → suggestion (line N)`. Don&apos;t auto-edit. Ask which corrections to apply, then apply them with Edit.

## Step 3 — Content review

Read the post end-to-end and give feedback focused on substance, not tone:

- **Things that sound stupid or unclear** — sentences that don&apos;t land, claims that need backing, awkward logic jumps.
- **Gaps worth filling** — missing context a reader would want, an example that would help, a link the user usually includes (docs, repo, related post).
- **Factual / technical accuracy** — flag anything that looks wrong or outdated. If the post references code, check it makes sense.
- **Loose ends** — TODO markers, &amp;quot;fill this in&amp;quot;, broken-looking links, placeholder text.

**Do NOT** comment on tone, voice, snark level, casual phrasing, or stylistic choices. The user likes their voice; leave it alone.

Present feedback as a numbered list grouped by category. Keep each item to one or two sentences. Don&apos;t rewrite paragraphs — point at the issue and let the user decide.

## Step 4 — Apply changes

Ask which suggestions to act on. Apply only what&apos;s confirmed. Then stop — the user handles the actual publish/push.
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;an-example-in-action&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#an-example-in-action&quot;&gt;An Example in Action&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To test, I&apos;m going to run this right now on this post, which means it may get a bit confused as the post isn&apos;t actually done, but let&apos;s just see what happens:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Hopefully that image is readable, let me know if not. But it did a great job of finding multiple grammar issues. My VS Code extension doesn&apos;t handle that (obviously).&lt;/p&gt;
&lt;p&gt;I disagreed/didn&apos;t care about the awkward comment. I did care about linking to Claude Skills so I added a link. Gap 3 was incorrect. I do talk about how I &apos;cheated&apos; (to be clear, it isn&apos;t cheating) in the following paragraph. And of course, issue 5 is simply because I ran this before I was done. Item 8 is the same.&lt;/p&gt;
&lt;p&gt;You&apos;ll notice it also offered to correct those mistakes, but I did them myself and I imagine I&apos;ll do so usually.&lt;/p&gt;
&lt;p&gt;In the end, these are all things a good editor would handle, but this is my personal blog and unfortunately I don&apos;t have an editor on call. (And even so, I&apos;d want to do this kind of check beforehand anyway.)&lt;/p&gt;
&lt;h2 id=&quot;what-next%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#what-next%3F&quot;&gt;What next?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Looking at the skill I wrote, and recognizing my own weaknesses (grammar, forgetting to cover things I should), I may actually move this skill into my global directory so I can use it for my non-blog writing as well. Anybody want to chime in and share if they&apos;ve done something similar?&lt;/p&gt;
&lt;p&gt;p.s. I ran the skill one more time - here&apos;s the latest:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/claude1.png&quot; loading=&quot;lazy&quot; alt=&quot;Final Results&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@ningdamao?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;宁 宁&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-peacefully-sits-on-a-stack-of-papers-xDDkC_odjbU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/17/links-for-you-51726</id>
                <title>Links For You (5/17/26)</title>
                <updated>2026-05-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/17/links-for-you-51726" rel="alternate" type="text/html" title="Links For You (5/17/26)"/>
                <content type="html">
				
                        &lt;p&gt;Happy Sunday, and I hope your Sunday is going better than mine. One of my kids just went to use the dryer and sparks flew. I don&apos;t mean metaphorically. Nothing like the thought of replacing another major appliance to brighten up your day, amiright?!?!&lt;/p&gt;
&lt;h2 id=&quot;find-your-(tech)-community&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#find-your-(tech)-community&quot;&gt;Find Your (Tech) Community&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a new endeavour by Brian Rinaldi to help fill the gap left behind by Meetup.com (which is still a thing just not a good thing lately) and the lack of smaller and more affordable community conferences. &lt;a href=&quot;https://devrelish.tech/&quot;&gt;DevRel(ish)&lt;/a&gt; is a community site supporting tech groups of all sizes and nature who need help organizing IRL meetups. According to the &lt;a href=&quot;https://remotesynthesis.com/posts/introducting-devrelish/&quot;&gt;launch announcement&lt;/a&gt;, this is not meant to be a replacement for larger event apps, but hopefully something more suitable for smaller groups.&lt;/p&gt;
&lt;p&gt;Check it out at &lt;a href=&quot;https://devrelish.tech/&quot;&gt;https://devrelish.tech/&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;blame-the-ox&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#blame-the-ox&quot;&gt;Blame the Ox&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ok, this isn&apos;t tech related at all, but, &amp;quot;The Ox That&apos;s Breaking Your Fantasy Map&amp;quot; is one of the most fascinating videos I&apos;ve seen in a while. And yes, it is about oxen - and fantasy maps. Even if you don&apos;t like fantasy (or oxen), it&apos;s a great watch and reminder of just how the economy affects organization and government.&lt;/p&gt;
&lt;lite-youtube videoid=&quot;MIqpvpNS5pI&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/MIqpvpNS5pI/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=MIqpvpNS5pI&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;h2 id=&quot;ai-and-goblins&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#ai-and-goblins&quot;&gt;AI and Goblins&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Last up is a good look into why &lt;a href=&quot;https://openai.com/index/where-the-goblins-came-from/&quot;&gt;goblins started appearing&lt;/a&gt; in OpenAI models. The post details how initial reports of the behavior led to investigations that ultimately led to the smoking &lt;strike&gt;goblin&lt;/strike&gt; gun.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Speaking of fantasy, I recently remembered that my young kids still have yet to see the Never Ending Story. One of them really loves horses too. This is going to be great!&lt;/p&gt;
&lt;lite-youtube videoid=&quot;2WN0T-Ee3q4&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/2WN0T-Ee3q4/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=2WN0T-Ee3q4&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year</id>
                <title>Is it hotter or colder this year?</title>
                <updated>2026-05-15T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/15/is-it-hotter-or-colder-this-year" rel="alternate" type="text/html" title="Is it hotter or colder this year?"/>
                <content type="html">
				
                        &lt;p&gt;Where I live could generously be called &amp;quot;warm&amp;quot;, but is usually closer to the surface of the sun, especially in late summer. That&apos;s why when the weather is &lt;em&gt;not&lt;/em&gt; oppressively hot, I try my best to enjoy it. We&apos;re mid-May now and honestly, this spring has been... pleasant. Suspiciously pleasant but I&apos;ll take what I can get.&lt;/p&gt;
&lt;p&gt;The last few weeks I&apos;ve been telling myself that the weather must be a good bit cooler than last year, and I finally decided to do something about it. I worked with Claude and created a little web app that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lets you enter a free form address and then use &lt;a href=&quot;https://www.geocod.io/&quot;&gt;Geocoding&lt;/a&gt; to convert it to a proper longitude and latitude. This is a super simple geocoding API with a generous free tier. Do note though it&apos;s North America only.&lt;/li&gt;
&lt;li&gt;Uses the &lt;a href=&quot;https://pirateweather.net/en/latest/&quot;&gt;Pirate Weather&lt;/a&gt; API to get historical weather information. Date wise, I&apos;m using this week, and then the same days over the previous four days.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As I mentioned, I worked with Claude on this and let it design the layout and write the code initially. I was kinda impressed by one part - the &lt;code&gt;mapWithConcurrency&lt;/code&gt; function that lets you pass an array of async function with a desired max number to run at once. It handles doing the batching and returning the final result. That makes the calls for the weather data a bit more gentle on the provider.&lt;/p&gt;
&lt;p&gt;However - I noticed it was taking a &lt;em&gt;long&lt;/em&gt; time to finish. In theory, I&apos;m doing 7 times 5 (this week plus four previous years) of calls which is 35 which doesn&apos;t &lt;em&gt;seem&lt;/em&gt; like a lot, but I did some digging. Claude had used an endpoint that was a bit old. Doing some more research I switched to the proper endpoint... which didn&apos;t support CORS.&lt;/p&gt;
&lt;p&gt;Oh no!&lt;/p&gt;
&lt;p&gt;Oh - actually - I just moved to this to &lt;a href=&quot;https://val.town&quot;&gt;val.town&lt;/a&gt; and built a quick server side proxy. It takes in the same arguments that&apos;s send to my client side code (lat, lng, and a timestamp), and passes it to the historical Pirate Weather endpoint. So here&apos;s the frontend code - again - this is being driven by a concurrency function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function fetchDayTemperatures(lat, lng, unixSeconds) {
  let req = await fetch(PIRATE_WEATHER_API_BASE, {
    method: &amp;quot;POST&amp;quot;,
    body: JSON.stringify({
      lat,
      lng,
      unixSeconds,
    }),
  });
  return await req.json();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And here&apos;s the backend code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();
  const key = Deno.env.get(&amp;quot;PIRATE&amp;quot;);

  const url = new URL(
    `https://timemachine.pirateweather.net/forecast/${key}/${body.lat},${body.lng},${body.unixSeconds}`,
  );
  url.searchParams.set(&amp;quot;units&amp;quot;, &amp;quot;us&amp;quot;);
  url.searchParams.set(&amp;quot;exclude&amp;quot;, &amp;quot;minutely,hourly,alerts,flags&amp;quot;);

  const response = await fetch(url);
  const payload = await response.json();

  if (!response.ok) {
    throw new Error(payload.error || &amp;quot;Weather lookup failed.&amp;quot;);
  }

  const day = payload.daily?.data?.[0];
  if (!day) {
    throw new Error(&amp;quot;Weather data did not include a daily summary.&amp;quot;);
  }

  return Response.json({
    timezone: payload.timezone,
    high: day.temperatureMax,
    low: day.temperatureMin,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I barely modified this from the original client side code - only switching to an environment variable for the API (their API is free, but I might as well) and returning a proper &lt;code&gt;Response&lt;/code&gt; object.&lt;/p&gt;
&lt;p&gt;Here&apos;s a screenshot of it in action:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/weather.png&quot; loading=&quot;lazy&quot; alt=&quot;Screenshot&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;You can try this yourself here: &lt;a href=&quot;https://weathercomparison.val.run/&quot;&gt;https://weathercomparison.val.run/&lt;/a&gt;. If you want to see the code, and possibly fork the val, you can do so here: &lt;a href=&quot;https://www.val.town/x/raymondcamden/weather-comparison&quot;&gt;https://www.val.town/x/raymondcamden/weather-comparison&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;so%2C-was-it-cooler%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#so%2C-was-it-cooler%3F&quot;&gt;So, was it cooler?&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;No. I was wrong. Last year was cooler than this year, but the three years before that were all higher, with 2022 being pure hell. I remember that year seeing the city working on a road that had literally buckled because of heat.&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@peteralbanese?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Peter Albanese&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/black-and-white-cat-on-glass-window-K9_Igf8ZpC0?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel</id>
                <title>Send me a message in a panel...</title>
                <updated>2026-05-14T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/14/send-me-a-message-in-a-panel" rel="alternate" type="text/html" title="Send me a message in a panel..."/>
                <content type="html">
				
                        &lt;p&gt;On my birthday a few weeks ago, one of things I got was something I&apos;ve wanted to play with for a while, the &lt;a href=&quot;https://divoom.com/products/pixoo-64&quot;&gt;Divoom Pixoo64&lt;/a&gt; pixel frame. This is pixel art frame you can hang on your wall and with an app, select art, clock faces, and more. It&apos;s fun, although the app itself isn&apos;t my favorite. But - what excites me is that it has an API you can use to change what&apos;s shown on the frame. I actually built a demo of this with Webflow you can see below:&lt;/p&gt;
&lt;lite-youtube videoid=&quot;oRyVxxi6ew8&quot; style=&quot;background-image: url(&apos;https://i.ytimg.com/vi/oRyVxxi6ew8/hqdefault.jpg&apos;);&quot;&gt;
  &lt;a href=&quot;https://youtube.com/watch?v=oRyVxxi6ew8&quot; class=&quot;lty-playbtn&quot; title=&quot;Play Video&quot;&gt;
    &lt;span class=&quot;lyt-visually-hidden&quot;&gt;Play Video&lt;/span&gt;
  &lt;/a&gt;
&lt;/lite-youtube&gt;
&lt;script defer src=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.js&quot;&gt;&lt;/script&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/lite-youtube-embed/0.3.2/lite-yt-embed.css&quot; integrity=&quot;sha512-utq8YFW0J2abvPCECXM0zfICnIVpbEpW4lI5gl01cdJu+Ct3W6GQMszVITXMtBLJunnaTp6bbzk5pheKX2XuXQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;
&lt;p&gt;
&lt;p&gt;I was thinking about how else I could play with the API and decided to do something a bit risky - build a tool that lets you (yes, you!) send me a message right to my device. How did I do it? Let me describe the process from the bottom up.&lt;/p&gt;
&lt;h2 id=&quot;the-python-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-python-server&quot;&gt;The Python Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;At the lowest level is a Python server running on my machine. Yes, this isn&apos;t persistent and not stable, but who cares. The server handles accepting a string to display and rendering it on the Pixoo:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from pixoo_ng import Pixoo, Channel
from pixoo_ng.config import PixooConfig
import time

def split_string(text: str, max_chars: int) -&amp;gt; list[str]:
    &amp;quot;&amp;quot;&amp;quot;Split a string into chunks of up to max_chars characters, without breaking words.&amp;quot;&amp;quot;&amp;quot;
    words = text.split()
    chunks = []
    current = &amp;quot;&amp;quot;

    for word in words:
        if not current:
            current = word
        elif len(current) + 1 + len(word) &amp;lt;= max_chars:
            current += &amp;quot; &amp;quot; + word
        else:
            chunks.append(current)
            current = word

    if current:
        chunks.append(current)

    return chunks

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == &apos;/favicon.ico&apos;:
            self.send_response(404)
            self.end_headers()
            return

        query = parse_qs(urlparse(self.path).query)
        input_text = (query.get(&apos;input&apos;, [&apos;&apos;])[0]).strip()

        if not input_text:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b&amp;quot;Missing &apos;input&apos; query parameter&amp;quot;)
            return

        pix = Pixoo(PixooConfig(address=&apos;192.168.0.191&apos;))

        startY = 5
        strings = split_string(input_text, 15)

        for line in strings[:8]:
            print(line, startY)
            pix.draw_text(line, (2, startY), (0, 255, 0))
            startY += 5

        pix.push()

        self.send_response(204)
        self.end_headers()
        self.wfile.flush()

        # Show it for 10 seconds...
        time.sleep(10)

        # Then go back to my regular face
        pix.set_channel(Channel.FACES)
        pix.set_face(0)

    def log_message(self, format, *args):
        print(f&amp;quot;{self.address_string()} - {format % args}&amp;quot;)


if __name__ == &amp;quot;__main__&amp;quot;:
    server = ThreadingHTTPServer((&amp;quot;&amp;quot;, 8099), Handler)
    print(&amp;quot;Listening on port 8099&amp;quot;)
    server.serve_forever()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important bits is how it handles dynamic strings. When you send text to the Pixoo, you have to handle ensuring it actually fits, so to do that, I used a function, &lt;code&gt;split_string&lt;/code&gt;, which wraps text on words into an array. I then take that array (up to 7 lines) and send each line progressively lower on the panel.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix2.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;To make this little server &amp;quot;live&amp;quot;, I simply used ngrok to expose it.&lt;/p&gt;
&lt;h2 id=&quot;calling-the-server&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#calling-the-server&quot;&gt;Calling the Server&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;To call the server, I set up an API route on &lt;a href=&quot;val.town&quot;&gt;https://val.town&lt;/a&gt; that does the minimal work of proxying a front end call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();

  //trim message to 80
  let msg = body.message.substring(0, 80);
  let API = Deno.env.get(&amp;quot;API&amp;quot;);
  console.log(`Sending ${msg}`);
  await fetch(API + `?input=${msg}`);

  return Response.json({ ok: true });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ngrok URL is an environment variable and as I don&apos;t care about the response, I return a basic boolean value back.&lt;/p&gt;
&lt;h2 id=&quot;the-front-end&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-front-end&quot;&gt;The Front End&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;And lastly, I used Claude to build a simple front end. It&apos;s just a form with a field and button:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/pix3.png&quot; loading=&quot;lazy&quot; alt=&quot;Pixoo device&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The JavaScript literally just takes the field and sends it to the server code above, but if you want to see the complete project, you can check it out on val.town: &lt;a href=&quot;https://www.val.town/x/raymondcamden/send-me-a-message&quot;&gt;https://www.val.town/x/raymondcamden/send-me-a-message&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Want to try it? I&apos;ll share the URL, but first...&lt;/p&gt;
&lt;h2 id=&quot;privilege&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#privilege&quot;&gt;Privilege&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I feel safe sharing this demo as my device is in my office, a room my kids don&apos;t go into, and I can shut it down in seconds. Heck, I&apos;ll probably forget it&apos;s running and next time I boot this machine, it won&apos;t run anyway. So yeah, no big deal.&lt;/p&gt;
&lt;p&gt;But also - I&apos;m a guy. Probably the worst thing I&apos;ll get is someone calling me foul names.&lt;/p&gt;
&lt;p&gt;I can&apos;t imagine there is any world where a woman would feel safe with this kind of connection to random people on the internet.&lt;/p&gt;
&lt;h2 id=&quot;try-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#try-it&quot;&gt;Try It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Ready to try it? I make no guarantees it will work, but then again, you&apos;ll never know. ;) Hit the form here: &lt;a href=&quot;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&quot;&gt;https://raymondcamden--45cb2f404fa111f1b5dcee650bb23af1.web.val.run/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@jayneharr33?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Jayne Harris&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-message-in-a-bottle-sitting-on-the-beach-EDTXcRCCVGk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="python" />
                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/13/what-was-that-song-the-one-with-the-words</id>
                <title>What was that song, the one with the words?</title>
                <updated>2026-05-13T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/13/what-was-that-song-the-one-with-the-words" rel="alternate" type="text/html" title="What was that song, the one with the words?"/>
                <content type="html">
				
                        &lt;p&gt;My wife and I are both big music lovers, and I&apos;m happy to have influenced her listening habits a bit and have loved what she&apos;s introduced me to. Given we both love music, we&apos;ve also been known to sing along at times. (You can take a guess as to how well that goes.) She normally gets the lyrics right. I&apos;m normally a bit more... loose in terms of how well I remember the lyrics. I was thinking about this and was curious how well AI could be used to identity lyrics and match them to a song, especially when the lyrics may not be exactly right. I spent some time hacking on it and here&apos;s what I built.&lt;/p&gt;
&lt;h2 id=&quot;strike-one&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#strike-one&quot;&gt;Strike One&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I meant to type take one, accidentally wrote strike one, and as it didn&apos;t work, I&apos;m keeping the title. ;) So my first attempt was rather simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use the &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition&quot;&gt;SpeechRecognition&lt;/a&gt; API to get a transcript while you talk or sing.&lt;/li&gt;
&lt;li&gt;Use Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/built-in&quot;&gt;Built-in AI&lt;/a&gt; to identify the song based on the lyrics.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Obviously Chrome&apos;s model would be date limited and wouldn&apos;t be able to pick up a recent song, but I figured I&apos;d give that a shot first.&lt;/p&gt;
&lt;p&gt;The SpeechRecognition API works &lt;em&gt;real&lt;/em&gt; well, but one issue I ran into was on my mobile browser. For some reason the transcription would show up twice. I was working with Cursor to build the demo and it was able to handle that issue well.&lt;/p&gt;
&lt;p&gt;Speaking of Cursor, it built the UI for me and honestly I think it did a great job:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/voice1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;The code isn&apos;t too terribly long, so I&apos;ll share the whole thing then call out important bits:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const SpeechRecognition =
  window.SpeechRecognition || window.webkitSpeechRecognition;

const isMobileSpeech =
  /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);

let recordBtn;
let transcriptEl;
let statusPanel;
let statusMessage;
let resultPanel;
let songTitleEl;
let songArtistEl;

let recognition = null;
let isRecording = false;
let finalTranscript = &amp;quot;&amp;quot;;
let displayTranscript = &amp;quot;&amp;quot;;

let session = null;

const schema = {
	type:&amp;quot;object&amp;quot;, 
	properties: {
		song: {
			type:&amp;quot;string&amp;quot;,
			description:&amp;quot;The song name.&amp;quot;
		},
		artist: {
			type:&amp;quot;string&amp;quot;,
			description:&amp;quot;The artist name.&amp;quot;
		}
	},
	required: [&amp;quot;song&amp;quot;, &amp;quot;artist&amp;quot;],
	additionalProperties: false
};

async function canDoIt() {
  if (!window.LanguageModel) {
    return false;
  }

  return (await LanguageModel.availability()) !== &amp;quot;unavailable&amp;quot;;
}

function showUnsupportedMessage() {
  const notice = document.createElement(&amp;quot;p&amp;quot;);
  notice.className = &amp;quot;intro&amp;quot;;
  notice.textContent =
    &amp;quot;Sorry, your browser doesn&apos;t support this. This must be run on Chrome 148 or later.&amp;quot;;
  document.querySelector(&amp;quot;.app&amp;quot;)?.append(notice);
}

function setRecordingUi(recording) {
  isRecording = recording;
  recordBtn.classList.toggle(&amp;quot;is-recording&amp;quot;, recording);
  recordBtn.setAttribute(&amp;quot;aria-pressed&amp;quot;, String(recording));

  const icon = recordBtn.querySelector(&amp;quot;.record-btn__icon&amp;quot;);
  const label = recordBtn.querySelector(&amp;quot;.record-btn__label&amp;quot;);

  icon.classList.toggle(&amp;quot;record-btn__icon--mic&amp;quot;, !recording);
  icon.classList.toggle(&amp;quot;record-btn__icon--stop&amp;quot;, recording);
  label.textContent = recording ? &amp;quot;Stop&amp;quot; : &amp;quot;Start recording&amp;quot;;
}

function resetOutput() {
  statusPanel.hidden = true;
  resultPanel.hidden = true;
  statusMessage.textContent = &amp;quot;&amp;quot;;
  songTitleEl.textContent = &amp;quot;&amp;quot;;
  songArtistEl.textContent = &amp;quot;&amp;quot;;
}

function transcriptFromResults(results) {
  let interimTranscript = &amp;quot;&amp;quot;;
  let finalPart = &amp;quot;&amp;quot;;

  for (let i = 0; i &amp;lt; results.length; i += 1) {
    const result = results[i];
    const text = result[0].transcript;

    if (result.isFinal) {
      finalPart += text;
    } else {
      interimTranscript += text;
    }
  }

  return {
    display: `${finalPart}${interimTranscript}`.trim(),
    final: finalPart.trim(),
  };
}

function startRecording() {
  if (!SpeechRecognition) {
    transcriptEl.textContent =
      &amp;quot;Speech recognition is not supported in this browser. Try Chrome.&amp;quot;;
    return;
  }

  resetOutput();
  finalTranscript = &amp;quot;&amp;quot;;
  displayTranscript = &amp;quot;&amp;quot;;
  transcriptEl.textContent = &amp;quot;Listening...&amp;quot;;

  recognition = new SpeechRecognition();
  recognition.lang = &amp;quot;en-US&amp;quot;;
  recognition.interimResults = true;
  recognition.continuous = !isMobileSpeech;

  recognition.onresult = (event) =&amp;gt; {
    if (isMobileSpeech) {
      const transcript = transcriptFromResults(event.results);
      displayTranscript = transcript.display;
      finalTranscript = transcript.final;
    } else {
      let interimTranscript = &amp;quot;&amp;quot;;

      for (let i = event.resultIndex; i &amp;lt; event.results.length; i += 1) {
        const result = event.results[i];
        const text = result[0].transcript;

        if (result.isFinal) {
          finalTranscript += `${text} `;
        } else {
          interimTranscript += text;
        }
      }

      displayTranscript = `${finalTranscript}${interimTranscript}`.trim();
    }

    transcriptEl.textContent = displayTranscript || &amp;quot;Listening...&amp;quot;;
  };

  recognition.onerror = (event) =&amp;gt; {
    transcriptEl.textContent = `Recognition error: ${event.error}`;
    stopRecording();
  };

  recognition.onend = () =&amp;gt; {
    if (isRecording &amp;amp;&amp;amp; recognition.continuous) {
      recognition.start();
    }
  };

  recognition.start();
  setRecordingUi(true);
}

function stopRecording() {
  if (!recognition) {
    return;
  }

  setRecordingUi(false);
  recognition.onend = null;
  recognition.stop();
  recognition = null;

  const transcript = displayTranscript.trim() || finalTranscript.trim();
  if (transcript) {
    transcriptEl.textContent = transcript;
  }
  recognize(transcript);
}

async function recognize(transcript) {
  resetOutput();
  statusPanel.hidden = false;
  statusMessage.textContent = &amp;quot;Analyzing your dulcet tones...&amp;quot;;

  if(!session) {
		session = await LanguageModel.create({
			initialInputs: [
        { 
						role: &apos;system&apos;, 
						content: 
							&apos;You are a song id bot. Given lyrics, sometimes incorrect, you try to identity the song. You only return the song and artist.&apos; 
					}
			],
			monitor(m) {
        m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
          if(e.loaded === 0 || e.loaded === 1) return;
          statusPanel.innerHTML = `Downloading, currently at ${Math.floor(e.loaded * 100)}%`;
        });
    	}	
		});
	}

  console.log(`Passing: ${transcript}`);
  let thisSession = await session.clone();
  let result = await thisSession.prompt(
    [
      { role: &apos;user&apos;, content: transcript }
    ], { responseConstraint: schema });
  
  console.log(result);
  let { song, artist } = JSON.parse(result);
  songTitleEl.textContent = song;
  songArtistEl.textContent = artist;
  resultPanel.hidden = false;
}

function initApp() {
  recordBtn = document.getElementById(&amp;quot;recordBtn&amp;quot;);
  transcriptEl = document.getElementById(&amp;quot;transcript&amp;quot;);
  statusPanel = document.getElementById(&amp;quot;statusPanel&amp;quot;);
  statusMessage = document.getElementById(&amp;quot;statusMessage&amp;quot;);
  resultPanel = document.getElementById(&amp;quot;resultPanel&amp;quot;);
  songTitleEl = document.getElementById(&amp;quot;songTitle&amp;quot;);
  songArtistEl = document.getElementById(&amp;quot;songArtist&amp;quot;);

  document.querySelector(&amp;quot;.controls&amp;quot;).hidden = false;
  document.querySelector(&amp;quot;.transcript-panel&amp;quot;).hidden = false;

  recordBtn.addEventListener(&amp;quot;click&amp;quot;, () =&amp;gt; {
    if (isRecording) {
      stopRecording();
    } else {
      startRecording();
    }
  });
}

document.addEventListener(&amp;quot;DOMContentLoaded&amp;quot;, async () =&amp;gt; {
  const supported = await canDoIt();

  if (!supported) {
    showUnsupportedMessage();
    return;
  }

  initApp();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So up top - a bunch of variables that will point to the DOM and two checks - one for speech recognition (it should always exist, just not at the same place - scratch that - not supported in Firefox, sorry) and one for mobile.&lt;/p&gt;
&lt;p&gt;I&apos;m using a JSON schema to shape Chrome AI&apos;s response to ensure it just returns the song and artist.&lt;/p&gt;
&lt;p&gt;After that - I&apos;ve got code to handle clicking the record button and transcribing. As soon as you stop recording, the transcription is handed off to Chrome for analysis.&lt;/p&gt;
&lt;p&gt;And... it worked. Poorly. If you get the lyrics right it can do ok, but it didn&apos;t really work well enough to consider it a success. You, if you are on Chrome 148 or higher, can now test this yourself, no need to flip a feature flag. It&apos;s up and running here: &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/voice_to_song/&quot;&gt;https://cfjedimaster.github.io/webdemos/voice_to_song/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;But as I said... temper your expectations.&lt;/p&gt;
&lt;h2 id=&quot;version-do-re-mi&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#version-do-re-mi&quot;&gt;Version Do Re Mi&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I decided to pivot and go from on-device AI to hitting the Gemini API directly. One nice thing about the Gemini APIs is that they have a proper free tier which meant I could put up a demo and not worry about the cost. (That being said, if you try the demo and it fails due to rate limits and such... I&apos;m sorry. I&apos;ll offer you a full and complete refund at my earlier convenience.)&lt;/p&gt;
&lt;p&gt;For this, I went back to to &lt;a href=&quot;https://www.val.town/&quot;&gt;val.town&lt;/a&gt; which I&apos;ve been enjoying the heck out of lately. I first did the quick hack to allow for static files by using this in main.ts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { staticHTTPServer } from &amp;quot;https://esm.town/v/std/utils/index.ts&amp;quot;;
export default staticHTTPServer(import.meta.url);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And then added a HTTP trigger to make it accessible. (This feels like something I think val.town could make even easier.) My HTML stayed the same (except for one tweak I&apos;ll mention below) and my JavaScript changed to remove the check for Chrome&apos;s AI and just do a simple fetch call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;let req = await fetch(API, {
method: &amp;quot;post&amp;quot;,
body: JSON.stringify({ transcript }),
});

let result = await req.json();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is calling &lt;code&gt;api.ts&lt;/code&gt; which also has a HTTP trigger:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { GoogleGenerativeAI } from &amp;quot;npm:@google/generative-ai&amp;quot;;

const schema = {
  type: &amp;quot;object&amp;quot;,
  properties: {
    song: {
      type: &amp;quot;string&amp;quot;,
      description: &amp;quot;The song name.&amp;quot;,
    },
    artist: {
      type: &amp;quot;string&amp;quot;,
      description: &amp;quot;The artist name.&amp;quot;,
    },
  },
  required: [&amp;quot;song&amp;quot;, &amp;quot;artist&amp;quot;],
};

const genAI = new GoogleGenerativeAI(Deno.env.get(&amp;quot;GEMINI_API_KEY&amp;quot;));
const model = genAI.getGenerativeModel({
  model: &amp;quot;gemini-flash-latest&amp;quot;,
});

export default async function (req: Request): Promise&amp;lt;Response&amp;gt; {
  const body = await req.json();
  console.log(&amp;quot;req&amp;quot;, body);

  let result = await model.generateContent(
    {
      contents: [{
        role: &amp;quot;user&amp;quot;,
        parts: [{
          text:
            `Identify this song based on lyrics remembered. You will be passed lyrics that the user guesses are in the song. Lyrics could be wrong. Search against the lyrics, not the title of the song. Here&apos;s what they remember: ${body.transcript}`,
        }],
      }],
      generationConfig: {
        responseMimeType: &amp;quot;application/json&amp;quot;,
        responseSchema: schema,
      },
    },
  );

  console.log(result.response.text());

  return Response.json(JSON.parse(result.response.text()));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You&apos;ll note I improved the system message a bit here. While testing with a buddy, he noted it seemed to sometimes focus on matching a title in the transcript, so I wanted to try to avoid that.&lt;/p&gt;
&lt;p&gt;Back in the HTML, I made one more important change to the instructions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Sing (or say) a few bars into your mic. We&apos;ll listen, pretend to think
very hard, then guess what song you had in mind. No guarantees. Maximum
synth.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I removed the mention of humming as that won&apos;t actually be transcribed. In theory, we could record that audio and send it to Gemini as well, but I was trying to keep this simpler.&lt;/p&gt;
&lt;p&gt;Want to try this version? Want to belt out some tunes? Head over to the live version here: &lt;a href=&quot;https://raymondcamden--4d1152f64d8111f1a0fbee650bb23af1.web.val.run/&quot;&gt;https://raymondcamden--4d1152f64d8111f1a0fbee650bb23af1.web.val.run/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;As I said, this is a free tier Gemini call so I fully expect you may hit limits. You can always fork my val (embedded below) and use your own key.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/voice1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;How well does this one work? Better! But myself, and my friends who tested, still see mistakes. That being said, it was kind of fun. Let me know what you find!&lt;/p&gt;
&lt;p&gt;Also, a big thank you (again!) to &lt;a href=&quot;https://blog.tomayac.com/&quot;&gt;Thomas Steiner&lt;/a&gt; from the Chrome team for help while I was building this out!&lt;/p&gt;
&lt;p&gt;I was going to embed the Val here, but apparently you can only embed one file so - instead, here&apos;s a simple link instead: &lt;a href=&quot;https://www.val.town/x/raymondcamden/voice-to-song&quot;&gt;https://www.val.town/x/raymondcamden/voice-to-song&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@maykogob?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Mayko Sousa&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/a-cat-yawning-with-its-mouth-open-wvDs1EswZZk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/05/04/using-val-town-and-gemini-for-sports-ball-stuff</id>
                <title>Using Val Town and Gemini for Sports Ball Stuff</title>
                <updated>2026-05-04T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/05/04/using-val-town-and-gemini-for-sports-ball-stuff" rel="alternate" type="text/html" title="Using Val Town and Gemini for Sports Ball Stuff"/>
                <content type="html">
				
                        &lt;p&gt;This is trivial as heck as the kids say, but I really want to explore &lt;a href=&quot;https://www.val.town/&quot;&gt;Val Town&lt;/a&gt; more this year and I thought of a great, and simple use for it. Both my wife and I are big Saints fans (this is their year, honest) and attend most of the games. If they&apos;re not playing at home, we&apos;re absolutely watching it on TV. We both &lt;em&gt;really&lt;/em&gt; enjoy watching football, but honestly, not enough to watch ESPN and follow the news.&lt;/p&gt;
&lt;p&gt;I thought - why not simply get a summary of NFL news from the past week and build an automation of it? I had this running in less than ten minutes with Val Town.&lt;/p&gt;
&lt;p&gt;First, the code makes use of Google&apos;s Node SDK for working with Gemini. I setup my environment variable first and then used this code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { GoogleGenerativeAI } from &amp;quot;npm:@google/generative-ai&amp;quot;;
import { email } from &amp;quot;https://esm.town/v/std/email&amp;quot;;
import { marked } from &amp;quot;npm:marked&amp;quot;;

const genAI = new GoogleGenerativeAI(Deno.env.get(&amp;quot;GEMINI_API_KEY&amp;quot;));
const model = genAI.getGenerativeModel({ model: &amp;quot;gemini-2.5-pro&amp;quot; });

const prompt = `
  Act as a sports news curator. I am a casual football fan who watches games on Sundays 
  but avoids ESPN. 

  Provide a high-level summary of NFL news from the past 7 days. 
  
  Requirements:
  - Length: 3-4 paragraphs.
  - Include relevant links for further reading.
  - Tone: Informative but accessible for a casual fan.
  - Focus: Major trades, schedule updates, and significant roster moves.

In your response, don&apos;t mention the prompt per se, just give me the summary report.
For each item in your report, generate a heading title.
`;

async function getNFLSummary(p) {
  try {
    const result = await model.generateContent(p);
    const response = await result.response;
    return response.text();

    console.log(text);
  } catch (error) {
    console.error(&amp;quot;Error generating report:&amp;quot;, error);
  }
}

let summary = await getNFLSummary(prompt);
console.log(summary);

let html = `
&amp;lt;h2&amp;gt;NFL News Summary&amp;lt;/h2&amp;gt;

${marked.parse(summary)}
`;

await email({
  subject: &amp;quot;NFL News Summary&amp;quot;,
  html,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The prompt is pretty specific and grew as I tested. The final paragraph in particular was necessary as I kept getting &amp;quot;chat&amp;quot; like responses which wouldn&apos;t make sense for an email report. I also had to ask specifically for titles for the summaries which makes it easier to skip over things I don&apos;t care about. Lastly, I considered adding a note about focusing on the Saints, but I really wanted something more generic, especially as we tend to hear a lot of Saints news via local updates and such.&lt;/p&gt;
&lt;p&gt;And the last bit just sends an email to me, from Val Town, as I don&apos;t need a custom FROM/TO here, this works just fine.&lt;/p&gt;
&lt;p&gt;The last, &lt;em&gt;last&lt;/em&gt; bit was the CRON schedule which I set as the trigger and for 9AM on Mondays. Doing a quick run produces this:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/05/nfl1.png&quot; loading=&quot;lazy&quot; alt=&quot;Email of NFL News&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I&apos;ve embedded the Val below - let me know if you fork it!&lt;/p&gt;
&lt;iframe width=&quot;100%&quot; height=&quot;400px&quot; src=&quot;https://www.val.town/embed/x/raymondcamden/nfl-roundup/main.ts&quot; title=&quot;Val Town&quot; frameborder=&quot;0&quot; allow=&quot;web-share&quot; allowfullscreen&gt;&lt;/iframe&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@aussiedave?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Dave Adamson&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/brown-and-black-wilson-football--nATH0CrkMU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="serverless" />
                
                <category term="generative ai" />
                
                
                <category term="javascript" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/30/animated-video-backgrounds-via-a-web-component-and-colorthief</id>
                <title>Animated video backgrounds via a Web Component and ColorThief</title>
                <updated>2026-04-30T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/30/animated-video-backgrounds-via-a-web-component-and-colorthief" rel="alternate" type="text/html" title="Animated video backgrounds via a Web Component and ColorThief"/>
                <content type="html">
				
                        &lt;p&gt;Earlier this year, the epic &lt;a href=&quot;https://lokeshdhakar.com/projects/color-thief/&quot;&gt;ColorThief&lt;/a&gt; library had a pretty significant update. I &lt;a href=&quot;https://www.raymondcamden.com/2026/03/04/dyanimically-adjusting-image-text-for-contrast&quot;&gt;blogged&lt;/a&gt; about a simple demo I built with it but I was fascinated by one particular demo on their site.&lt;/p&gt;
&lt;p&gt;The &amp;quot;observe&amp;quot; function in ColorThief lets you monitor a video source and grab the colors at a particular frame. Their &lt;a href=&quot;https://lokeshdhakar.com/projects/color-thief/#v3-observe&quot;&gt;demo&lt;/a&gt; uses this to create a lovely shadow background of the video. I believe some TVs have this feature as well, and honestly I&apos;d worry that would get annoying, but the ColorThief demo was pretty cool, so I thought I&apos;d try to build it with a web component.&lt;/p&gt;
&lt;p&gt;The idea would be - take any basic video element and wrap it like so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;video-bgshadow&amp;gt;
&amp;lt;video controls width=&amp;quot;250&amp;quot;&amp;gt;
    &amp;lt;source src=&amp;quot;videos/flower.mp4&amp;quot; type=&amp;quot;video/mp4&amp;quot;&amp;gt;
&amp;lt;/video&amp;gt;
&amp;lt;/video-bgshadow&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The web component would then handle:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Loading the ColorThief library&lt;/li&gt;
&lt;li&gt;Waiting for the video to be played&lt;/li&gt;
&lt;li&gt;Running the &lt;code&gt;observe&lt;/code&gt; method and updating the CSS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All in all, this wasn&apos;t too difficult. I don&apos;t think my shadow is as good as the demo (and I&apos;m totally open to people submitting a PR!), but it came out ok.&lt;/p&gt;
&lt;p&gt;I&apos;ll link to the demo below, but here&apos;s a simple example in a CodePen:&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;500&quot; data-pen-title=&quot;&amp;amp;lt;video-bgshadow&amp;amp;gt;&quot; data-preview=&quot;true&quot; data-version=&quot;2&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;yyVLXQY&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height: 500px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;&quot;&gt;
  &lt;span&gt;See the Pen &lt;a href=&quot;https://codepen.io/editor/cfjedimaster/pen/019dd938-b4d6-7494-a3f7-a6a3a1b801aa&quot;&gt;
  &amp;lt;video-bgshadow&amp;gt;&lt;/a&gt; by Raymond Camden (&lt;a href=&quot;https://codepen.io/cfjedimaster&quot;&gt;@cfjedimaster&lt;/a&gt;)
  on &lt;a href=&quot;https://codepen.io&quot;&gt;CodePen&lt;/a&gt;.&lt;/span&gt;
&lt;/p&gt;
&lt;script async src=&quot;https://public.codepenassets.com/embed/index.js&quot;&gt;&lt;/script&gt;
&lt;p&gt;Alright, so here&apos;s the code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;class VideoBGShadowComponent extends HTMLElement {
	
	constructor() {
		super();
	}
	
	async connectedCallback() {
		this.videoEl = this.querySelector(&apos;video&apos;);
		if(!this.videoEl) {
			console.warn(&apos;No &amp;lt;video&amp;gt; element found.&apos;);
			return;
		}

		// wrap the video in a new div
		this.wrapper = document.createElement(&apos;div&apos;);
		this.videoEl.parentNode.insertBefore(this.wrapper, this.videoEl);
		this.wrapper.appendChild(this.videoEl);
		this.wrapper.style.display = &apos;inline-block&apos;;
		this.videoEl.style.verticalAlign = &apos;bottom&apos;;
		if(!window.ColorThief) await this.loadCF();
		this.videoEl.addEventListener(&apos;play&apos;, this.startShadow.bind(this));
		this.videoEl.addEventListener(&apos;ended&apos;, this.endShadow.bind(this));
		this.videoEl.addEventListener(&apos;pause&apos;, this.endShadow.bind(this));

	
	}

	// Sets window.ColorThiefLoading (Promise) to deduplicate concurrent script injection across multiple instances.
	async loadCF() {
		if (!window.ColorThiefLoading) {
			window.ColorThiefLoading = new Promise((resolve) =&amp;gt; {
				const script = document.createElement(&apos;script&apos;);
				script.type = &apos;text/javascript&apos;;
				script.src = &apos;https://unpkg.com/colorthief@3/dist/umd/color-thief.global.js&apos;;
				document.head.appendChild(script);
				script.onload = resolve;
			});
		}
		return window.ColorThiefLoading;
	}

	startShadow(e) {
		console.log(&apos;video play&apos;);
		let thatWrapper = this.wrapper;
		this.controller = ColorThief.observe(e.target, {
		    throttle: 200,
		    colorCount: 5,
			  onChange(palette) {
	            const [dominant] = palette;
                thatWrapper.style.setProperty(&apos;--glow-color&apos;, dominant.css());
                thatWrapper.style.boxShadow = &apos;15px 15px 20px 8px var(--glow-color)&apos;;
		    },
		})
	}

	endShadow() {
		console.log(&apos;video play end&apos;);
		this.controller.stop();
	}

}

if(!customElements.get(&apos;video-bgshadow&apos;)) customElements.define(&apos;video-bgshadow&apos;, VideoBGShadowComponent);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I don&apos;t think there&apos;s anything necessarily interesting in here, although I struggled quite a bit with &lt;code&gt;loadCF&lt;/code&gt;. I didn&apos;t want to add the ColorThief library N times to the page. Checking for &lt;code&gt;window.ColorThief&lt;/code&gt; only works if for some reason a video wrapped with the component is added to the page &lt;em&gt;after&lt;/em&gt; the library loads. I used Claude to help me with this bit and while it &amp;quot;litters&amp;quot; the window object with a value, I think that is a fair trade off to ensure only one library is loaded. (Technically this could be further updated to first see if ColorThief exists in general as it&apos;s possible the website uses it for something else.)&lt;/p&gt;
&lt;p&gt;You can see a demo with a couple of examples here: &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/video-bgshadow/&quot;&gt;https://cfjedimaster.github.io/webdemos/video-bgshadow/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And if you think this is a good start but could be &lt;em&gt;so&lt;/em&gt; much better, I agree, help me out over at the repo: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/video-bgshadow&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/video-bgshadow&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@ansleycreative?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Matthew Ansley&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/two-people-standing-on-concrete-floor-6AQxBtaIYOk?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/26/links-for-you-42626</id>
                <title>Links For You (4/26/26)</title>
                <updated>2026-04-26T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/26/links-for-you-42626" rel="alternate" type="text/html" title="Links For You (4/26/26)"/>
                <content type="html">
				
                        &lt;p&gt;I was supposed to post this last week (I try to keep to a schedule of every two weeks), but I didn&apos;t get around to it because... nope, that&apos;s it. That&apos;s the reason. Because. And that&apos;s good enough, amiright!?!? The heat is slowly cranking up here in Louisiana and I&apos;m dreading the full on summer, but things do slow down a bit when the kids aren&apos;t in school and that&apos;s something I greatly appreciate. Before getting into this weeks links, I was reminded a few weeks back that my wife actually reads my posts so... hi baby, I love you.&lt;/p&gt;
&lt;h2 id=&quot;super-useful-web-components-ftw---%3Cform-saver%3E&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#super-useful-web-components-ftw---%3Cform-saver%3E&quot;&gt;Super useful web components FTW - &amp;lt;form-saver&amp;gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;First up is a really simple and really useful web component, &lt;a href=&quot;https://www.aaron-gustafson.com/notebook/never-lose-form-progress-again/&quot;&gt;form-saver&lt;/a&gt;. You can wrap any form with the component and instantly get client-side storage of form contents until the form is submitted. This works for all types of form fields except file fields of course. (I assume folks know this but you can&apos;t use JavaScript to set the value of a file field for security reasons.)&lt;/p&gt;
&lt;p&gt;Here&apos;s a simple usage example from the docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;form-saver&amp;gt;
  &amp;lt;form action=&amp;quot;/contact&amp;quot; method=&amp;quot;post&amp;quot;&amp;gt;
    &amp;lt;label&amp;gt;
      Name
      &amp;lt;input name=&amp;quot;name&amp;quot; autocomplete=&amp;quot;name&amp;quot; /&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;label&amp;gt;
      Email
      &amp;lt;input name=&amp;quot;email&amp;quot; type=&amp;quot;email&amp;quot; autocomplete=&amp;quot;email&amp;quot; /&amp;gt;
    &amp;lt;/label&amp;gt;
    &amp;lt;button type=&amp;quot;submit&amp;quot;&amp;gt;Send&amp;lt;/button&amp;gt;
  &amp;lt;/form&amp;gt;
&amp;lt;/form-saver&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Isn&apos;t that sweet? Thanks go to Aaron Gustafson.&lt;/p&gt;
&lt;p&gt;As a reminder (and I usually try to avoid linking to my own stuff in these posts, but it&apos;s definitely related), if you like this you may like my &lt;a href=&quot;https://www.npmjs.com/package/@raymondcamden/table-sorter&quot;&gt;table-sorter&lt;/a&gt; web component as well.&lt;/p&gt;
&lt;h2 id=&quot;share-the-python-love&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#share-the-python-love&quot;&gt;Share the Python Love&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Next up is a superb guide at &lt;a href=&quot;https://stephenlf.dev/blog/python-library-in-2026/&quot;&gt;packing Python code&lt;/a&gt; for distribution. I&apos;ve written a lot of Python code, but have only created a distribution once or twice, and this guide literally walks you from the first line of code to publication.&lt;/p&gt;
&lt;p&gt;Thanks to Stephen Funk for writing this up!&lt;/p&gt;
&lt;h2 id=&quot;the-last-quiet-thing&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-last-quiet-thing&quot;&gt;The Last Quiet Thing&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Finally, this essay, &lt;a href=&quot;https://www.terrygodier.com/the-last-quiet-thing&quot;&gt;&amp;quot;The Last Quiet Thing&amp;quot;&lt;/a&gt;, is a thought provoking deep look at how much of our lives are being stolen from devices that constantly, &lt;em&gt;endlessly&lt;/em&gt; need our attention. Not only is it incredibly well written, it&apos;s also really well designed as well.&lt;/p&gt;
&lt;p&gt;This was written by Terry Godier and thanks go to Salma Alam-Naylor for sharing it on her &lt;a href=&quot;https://buttondown.com/weirdwidewebhole/archive/&quot;&gt;newsletter&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;just-for-fun&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#just-for-fun&quot;&gt;Just For Fun&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I love building silly things on the web (see &lt;a href=&quot;https://www.raymondcamden.com/2026/04/25/another-game-my-little-mortal-combat&quot;&gt;yesterday&apos;s post&lt;/a&gt; as an example), and this little toy from Wes Bos is just that. &lt;a href=&quot;https://tab.wesbos.com/&quot;&gt;Tab Snitch&lt;/a&gt; does one thing - set a custom title for the web page - but does so with silly and quite embarrassing titles. Although I have to be honest - a few of the fake titles listed there are one&apos;s I&apos;ve probably legitimately had on my screen at some point in time. You can guess which.&lt;/p&gt;

                        
                
				</content>

                
                <category term="links4you" />
                
                
                <category term="misc" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/25/another-game-my-little-mortal-combat</id>
                <title>Another Game: My Little Mortal Combat</title>
                <updated>2026-04-25T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/25/another-game-my-little-mortal-combat" rel="alternate" type="text/html" title="Another Game: My Little Mortal Combat"/>
                <content type="html">
				
                        &lt;p&gt;Hello awesome readers! I&apos;m happy to announce my latest web game, &lt;a href=&quot;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&quot;&gt;My Little Mortal Combat&lt;/a&gt;, a mashup of two epic franchises, My Little Pony and Mortal Kombat. This began as an idea, just the name, that I recorded in Microsoft To Do in September of 2019. Yes, almost seven years ago. It sat there, at the bottom of my &apos;idea&apos; list, until about a month ago when in the shower (not joking), it popped up in my head along with the basic mechanics of how the game would play.&lt;/p&gt;
&lt;p&gt;Right now the game is just missing one feature (I&apos;d rather not talk about until I figure out how I&apos;m going to do it) but definitely needs some balancing work. I enjoy playing games without knowing the details of how things work, so if&apos;s that you too, head over to the game now and good luck!&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&quot;&gt;https://cfjedimaster.github.io/webdemos/my_little_mortal_combat/&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;how-i-built-it&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#how-i-built-it&quot;&gt;How I Built It&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;As a web app, I kept it pretty simple. Just HTML, CSS, JavaScript, and Alpine.js. I used AI (Cursor&apos;s IDE specifically) to create the UI for the three phases of the game - into, main display, and combat. I also used AI to generate some of the strings used in the game. Opponents have random &amp;quot;evil&amp;quot;-ish titles and I wrote some and then asked AI to generate some more. Some examples:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Life Eater&lt;/li&gt;
&lt;li&gt;Hoof Smasher&lt;/li&gt;
&lt;li&gt;the Blood Soaked&lt;/li&gt;
&lt;li&gt;the Blood Drinker&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Opponents also have random annoying facts. Again, I wrote some, had AI generate some more.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;doesn&apos;t return library books on time.&lt;/li&gt;
&lt;li&gt;likes to ruin the end of movies.&lt;/li&gt;
&lt;li&gt;has been known to sneeze at the buffet.&lt;/li&gt;
&lt;li&gt;steals candy from babies and then throws the candy in the trash—in front of them!&lt;/li&gt;
&lt;li&gt;only speaks in passive-aggressive voice.&lt;/li&gt;
&lt;li&gt;will point out your least favorite body parts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The actual names of the opponents come directly from a My Little Pony API I found that was open source.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example of a randomly generated opponent:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pony1.png&quot; loading=&quot;lazy&quot; alt=&quot;Pony!&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Combat is basic &amp;quot;Rock Paper Scissors&amp;quot; style where you have 3 choices (Attack, Defend, Vogue) and the result is based on what your opponent does.&lt;/p&gt;
&lt;p&gt;Your character, and the opponents, have numerical value for Attack, Defend, and Vogue. When you win a round in a fight, the damage you do is based on that skill. Your total HP is based on level.&lt;/p&gt;
&lt;p&gt;As you can play, if you win or lose, you get gold and XP. Obviously you get a lot more when you win. You can use the gold to train skills. Your XP turns into your level which improves your HP.&lt;/p&gt;
&lt;p&gt;As I said, I definitely think the numbers need tweaking probably, so let me know. You can check out all the code here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/my_little_mortal_combat&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/my_little_mortal_combat&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Don&apos;t forget, I&apos;ve got this game and my others listed over on my my &lt;a href=&quot;https://www.raymondcamden.com/stuff&quot;&gt;Stuff&lt;/a&gt; page. Enjoy!&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@farvardin?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Felis Amafeles&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/five-small-cartoon-ponies-sitting-in-a-row-MI-KCy_foeU?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <category term="games" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/20/building-a-simple-markdown-pwa-app</id>
                <title>Building a Simple Markdown PWA App</title>
                <updated>2026-04-20T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/20/building-a-simple-markdown-pwa-app" rel="alternate" type="text/html" title="Building a Simple Markdown PWA App"/>
                <content type="html">
				
                        &lt;p&gt;While I didn&apos;t share it on the blog, last week I tasked Claude with using Electron to build a Markdown viewer app. It was part test (how well can Claude work with Electron) and part real need - I work with Markdown files all the time but didn&apos;t have a simple &amp;quot;view focused&amp;quot; application for it. I was sure there open source or paid app options out there, but I wanted my own. Claude did a pretty good job (you can see the source &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/mdviewer&quot;&gt;here&lt;/a&gt;) but one thing stood out to me - the size of the bundled app.&lt;/p&gt;
&lt;p&gt;I created both a Mac and Windows distribution and both were around 90 megs. That&apos;s not huge of course, but still felt like a lot for what could - in theory - just be a web app. But there was one crucial feature I wasn&apos;t sure I could replicate - double clicking on a MD file to have it open my app. Turns out - you certainly &lt;em&gt;can&lt;/em&gt; do it that.&lt;/p&gt;
&lt;p&gt;If you don&apos;t care how I built it, you can go to the app right now and install it: &lt;a href=&quot;https://mdviewerpwa.netlify.app/&quot;&gt;https://mdviewerpwa.netlify.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Alright, let&apos;s break it down.&lt;/p&gt;
&lt;h2 id=&quot;the-ui&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-ui&quot;&gt;The UI&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;When I had Claude design the application for me, it went with an incredibly simple UI. I felt no reason to add to that so when I began the web app, I copied over the generated HTML/CSS from the Electron app into my new folder. Here&apos;s an example of how it looks with no file selected:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa1.png&quot; loading=&quot;lazy&quot; alt=&quot;App with nothing loaded&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;And here&apos;s how it looks after a Markdown file is opened:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa2.png&quot; loading=&quot;lazy&quot; alt=&quot;App with MD loaded&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;Now let&apos;s look at the code a bit.&lt;/p&gt;
&lt;h2 id=&quot;markdown-support&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#markdown-support&quot;&gt;Markdown Support&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This normally would be the boring part. Just drop in &lt;a href=&quot;https://www.npmjs.com/package/marked&quot;&gt;marked&lt;/a&gt; and be done with it. But so many Markdown files I use have frontmatter I wanted to do something special for it. My fix was incredibly simple. If a file begins with three dashes and has another three dashes, replace them with backticks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const renderMarkdown = content =&amp;gt; {
    rawContent = content;
    /*
    Special tweak for frontmatter. If our content starts with &apos;---&apos; and 
    contains &apos;---&apos; again, we assume it&apos;s frontmatter and wrap it in and
    swap the --- to ```.
    */
    contentToRender = content.trim();
    // also, making a copy so we can keep the View Source version working
    if (contentToRender.startsWith(&apos;---&apos;)) {
        console.log(&apos;detected frontmatter, applying special formatting&apos;);
        const parts = content.split(&apos;---&apos;);
        if (parts.length &amp;gt;= 3) {
            const frontmatter = parts[1];
            const rest = parts.slice(2).join(&apos;---&apos;);
            contentToRender = `\`\`\`yaml${frontmatter}\`\`\`\n\n${rest}`;
            console.log(contentToRender);
        }
    }
    renderedEl.innerHTML = marked.parse(contentToRender);
    sourceEl.textContent = content;

    emptyState.style.display = &apos;none&apos;;
    renderedEl.style.display = &apos;block&apos;;
    sourceEl.style.display = &apos;none&apos;;
    toggleBtn.style.display = &apos;inline-block&apos;;
    showingSource = false;
    toggleBtn.textContent = &apos;View Source&apos;;

    document.title = `MD Viewer — ${fileNameEl.textContent}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Honestly most of that code is UI crap, but you can see the frontmatter support on top. I think it came out &lt;em&gt;perfect&lt;/em&gt; - it stands out and I think most folks will recognize it for what it represents, but in theory I could possibly add a small graphical label or something to the block.&lt;/p&gt;
&lt;p&gt;So again, there&apos;s UI handling code in here that&apos;s not that interesting, so let me turn to the real cool part. Yes, Virginia, a PWA can absolutely associate itself with files. I added a manifest.json and basic service worker. For bot of these I relied on Claude and it &lt;em&gt;mostly&lt;/em&gt; did a good job, I had to tweak a few things.&lt;/p&gt;
&lt;p&gt;After the basics worked, I did some Googling and came across this excellent MDN resource: &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Associate_files_with_your_PWA&quot;&gt;Associate files with your PWA&lt;/a&gt;. Adding file support took two steps.&lt;/p&gt;
&lt;p&gt;First, I added the following to my manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;&amp;quot;file_handlers&amp;quot;: [
{
    &amp;quot;action&amp;quot;: &amp;quot;/&amp;quot;, 
    &amp;quot;accept&amp;quot;: {
    &amp;quot;text/markdown&amp;quot;: [&amp;quot;.md&amp;quot;, &amp;quot;.markdown&amp;quot;]
    }
}
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;action&lt;/code&gt; step there tells my app what URL to go to when being opened via a file. As my app has one page/view, I just used &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The next step was to look for this in JavaScript. My application does this when starting up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if(&amp;quot;launchQueue&amp;quot; in window) {
    console.log(&apos;Launch Queue API is supported, setting up consumer&apos;);
    window.launchQueue.setConsumer(launchParams =&amp;gt; {
        if (!launchParams.files.length) {
            return;
        }
        const fileHandle = launchParams.files[0];
        console.log(&apos;File launched:&apos;, fileHandle);
        fileHandle.getFile().then(file =&amp;gt; {
            const reader = new FileReader();
            reader.onload = e =&amp;gt; {
                const content = e.target.result;
                fileNameEl.textContent = file.name;
                renderMarkdown(content);
            };
            reader.readAsText(file);
        }).catch(error =&amp;gt; {
            console.error(&apos;Error reading file:&apos;, error);
        });
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Basically, if I can use &lt;code&gt;launchQueue&lt;/code&gt;, it will consist of a list of files, each of which is a file handle. I&apos;ve used File objects in JavaScript before, but not file handles, but you can quickly go to a real file object using &lt;code&gt;getFile()&lt;/code&gt;. Once you have that, the regular &lt;code&gt;FileReader&lt;/code&gt; approach works to get the contents and render it.&lt;/p&gt;
&lt;p&gt;I deployed the app to Netlify, opened it in my browser, and clicked the install icon.&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa3.png&quot; loading=&quot;lazy&quot; alt=&quot;Install dialog&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;After I confirmed I had the application, I right clicked on a MD file, used open with, navigated to my PWA, and selected it:&lt;/p&gt;
&lt;p&gt;
&lt;img src=&quot;https://static.raymondcamden.com/images/2026/04/pwa4.png&quot; loading=&quot;lazy&quot; alt=&quot;Open file prompt&quot; class=&quot;imgborder imgcenter&quot;&gt;
&lt;/p&gt;
&lt;p&gt;I told it to remember my choice and that was literally it. So now I&apos;ve got a web-based app I can use locally, heck even offline, to render my Markdown files in a nice reading experience. (Well, nice to me anyway. ;) Oh, and the size is about 400k, of which most is one of the icons. Significantly smaller than the Electron app. (But to be fair, Electron was overkill for what I was doing.)&lt;/p&gt;
&lt;p&gt;Once again, the link to the site is here, &lt;a href=&quot;https://mdviewerpwa.netlify.app/&quot;&gt;https://mdviewerpwa.netlify.app/&lt;/a&gt;, and you can find all the code here: &lt;a href=&quot;https://github.com/cfjedimaster/webdemos/tree/master/mdviewerpwa&quot;&gt;https://github.com/cfjedimaster/webdemos/tree/master/mdviewerpwa&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Photo by &lt;a href=&quot;https://unsplash.com/@anniespratt?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Annie Spratt&lt;/a&gt; on &lt;a href=&quot;https://unsplash.com/photos/shelf-with-art-supplies-books-and-decorations-ModHj41WZhg?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText&quot;&gt;Unsplash&lt;/a&gt;&lt;/p&gt;

                        
                
				</content>

                
                <category term="javascript" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>