<?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-18T17:20:36+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/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>
        
            <entry>
                <id>https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai</id>
                <title>Summarizing Docs with Built-in AI</title>
                <updated>2026-04-17T18:00:00+00:00</updated>
                <link href="https://www.raymondcamden.com/2026/04/17/summarizing-docs-with-built-in-ai" rel="alternate" type="text/html" title="Summarizing Docs with Built-in AI"/>
                <content type="html">
				
                        &lt;p&gt;Back in January of this year, I blogged about on-device summarization of PDFs: &lt;a href=&quot;https://www.raymondcamden.com/2026/01/28/summarizing-pdfs-with-on-device-ai&quot;&gt;Summarizing PDFs with On-Device AI
&lt;/a&gt;. In that post, I made use of Chrome&apos;s &lt;a href=&quot;https://developer.chrome.com/docs/ai/summarizer-api&quot;&gt;Summary API&lt;/a&gt; and &lt;a href=&quot;https://mozilla.github.io/pdf.js/&quot;&gt;PDF.js&lt;/a&gt; to create summaries of PDFs completely within the browser. I thought I&apos;d take a look at extending that demo into more document types, specifically Office. And even more specifically - Word, Excel, and PowerPoint. Here&apos;s what I came up with.&lt;/p&gt;
&lt;h2 id=&quot;officeparser-ftw&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#officeparser-ftw&quot;&gt;officeParser FTW&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;So here comes the fun part. Last weekend I had this demo completely done using a few different libraries. Then - earlier this week one of the developer newsletters I subscribe to shared &lt;a href=&quot;https://officeparser.harshankur.com/&quot;&gt;officeParser&lt;/a&gt;. This nifty library handles Office, PDF, even Open Office formats. It also includes the metadata for files which is handy as heck. I forked my initial demo and removed all the extra libraries, leaving only officeParser.&lt;/p&gt;
&lt;p&gt;The library can return incredibly detailed information about the structure of the your doc as well as a plain text view. What I found in my testing is that the plain text view didn&apos;t seem like it would work well in my demo. For example, an XLS file was kinda glommed all together. I reached out to the developer and he is planning on a &lt;code&gt;toMarkdown&lt;/code&gt; feature that will make this easier, but for now what I did was get the complex data and write my own custom code to &apos;shape&apos; it well for AI.&lt;/p&gt;
&lt;p&gt;Generally speaking the first part was stupid easy - and I got this from the docs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const getAST = async (file, config) =&amp;gt; (await OfficeParser.parseOffice(file, config));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now let&apos;s dig into the code a bit.&lt;/p&gt;
&lt;h2 id=&quot;working-with-docs&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#working-with-docs&quot;&gt;Working with Docs&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I&apos;m going to skip over the DOM manipulation aspects as that&apos;s not terribly interesting. My code basically has a file input field and when you select a file, a process is fired off to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;get the text, again, with formatting to hopefully make it better for AI&lt;/li&gt;
&lt;li&gt;pass it to the Summary API for... summarization. :)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let&apos;s focus on the &amp;quot;get text&amp;quot; aspect. My file input handler has this logic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if(file.name.toLowerCase().endsWith(&apos;.doc&apos;) || file.name.toLowerCase().endsWith(&apos;.docx&apos;)) {
    summary = await processDoc(file);
} else if(file.name.toLowerCase().endsWith(&apos;.pdf&apos;)) {
    summary = await processPDF(file);
} else if(file.name.toLowerCase().endsWith(&apos;.ppt&apos;) || file.name.toLowerCase().endsWith(&apos;.pptx&apos;)) {
    summary = await processPPT(file);
    // i add a flag to powerpoint so my summary func knows it has to deal with the text
    summary.powerpoint = true;
} else if(file.name.toLowerCase().endsWith(&apos;.xls&apos;) || file.name.toLowerCase().endsWith(&apos;.xlsx&apos;)) {
    summary = await processXLS(file);
    // i add a flag to powerpoint so my summary func knows it has to deal with the text
    summary.excel = true;
} else {
    // in theory we can&apos;t get here, so just a return is fine
    return;
}

doSummary(summary);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My expectation is that the &lt;code&gt;summary&lt;/code&gt; will be an object with two fields: &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt;. I also use a flag for PowerPoint and Excel to help direct the Summary API to more properly handle the text.&lt;/p&gt;
&lt;p&gt;Now let&apos;s break down these functions. Doc and PDF are the easiest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processDoc(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	
	return {
		text: data.toText(), 
		title:data.metadata?.title ?? &apos;No Title&apos;
	}
	
}

async function processPDF(f) {
	const arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	console.log(data, data.toText());

	return {
		text: data.toText(), 
		title:data.metadata?.title ?? &apos;No Title&apos;
	}

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For Excel, as I mentioned earlier I got the raw data and examined it, and then wrote some utility bits to turn my sheets into (roughly) CSV form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processXLS(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});

	const getCSV = s =&amp;gt; {
		let result = &apos;&apos;;
		let rows = s.children.filter(c =&amp;gt; c.type === &apos;row&apos;);
		console.log(rows[0]);
		rows.forEach(r =&amp;gt; {
			let data = [];
			let cells = r.children.filter(c =&amp;gt; c.type === &apos;cell&apos;);
			cells.forEach(c =&amp;gt; data.push(c.text));
			result += data.join(&apos;, &apos;) + &apos;\n&apos;;

		});
		return result;
	};
	
	let result = {
		text:&apos;&apos;,
		title:data.metadata?.title ?? &apos;No Title&apos;
	}

	let sheets = data.content.filter(c =&amp;gt; c.type === &apos;sheet&apos;);
	sheets.forEach(s =&amp;gt; {
		result.text += getCSV(s);
		result.text += &apos;\n\n&apos;;
	});
	
	return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;PowerPoint was a bit more complex as I had to create a separation between slides, and get deeply nested text nodes. I ignored anything that wasn&apos;t text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function processPPT(f) {
	let arrayBuffer = await f.arrayBuffer();
	let data = await getAST(arrayBuffer, {});
	let result = {
		text:&apos;&apos;,
		title:data.metadata?.title ?? &apos;No Title&apos;
	}
	
	const getText = c =&amp;gt; {
		let result = &apos;&apos;;

		c.forEach(kid =&amp;gt; {
			if(kid.text) {
				result += kid.text;
				if(kid.type === &apos;paragraph&apos;) result += &apos;\n&apos;;
				else result += &apos; &apos;;
			}
			if(kid.children) {
				kid.children.forEach(gk =&amp;gt; {
					if(gk.text) {
						result += gk.text;
						if(gk.type === &apos;paragraph&apos;) result += &apos;\n&apos;;
						else result += &apos; &apos;;
					}
				});
			}
		});
		return result;
	}

	let content = data.content.filter(c =&amp;gt; c.type === &apos;slide&apos;);
	result.text = content.reduce((prev, cur) =&amp;gt; {
		return prev += getText(cur.children) + &apos;\n-------------\n&apos;;
	}, &apos;&apos;);

	return result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing special here is that sometimes items on the same line were two nodes, hence me only adding newlines after paragraphs.&lt;/p&gt;
&lt;h2 id=&quot;the-fancy-ai&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;header-anchor&quot; href=&quot;#the-fancy-ai&quot;&gt;The Fancy AI&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;The code to do summarization is something I&apos;ve shown before, the only thing I did unique here was try to warn the system about my PowerPoint and Excel data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function doSummary(summaryOb) {
	console.log(summaryOb);
	$output.innerHTML = &amp;quot;&amp;lt;i&amp;gt;File text extracted, working on the summary.&amp;quot;;
	let sharedContext = null;
	
	if(summaryOb.powerpoint) {
		sharedContext = &apos;This is extracted text from a Powerpoint file. Slides are separated by ----&apos;;
	} else if(summaryOb.excel) {
		sharedContext = &apos;This is extracted text from a Excel file in CSV format.&apos;
	}
	
	let summarizer = await window.Summarizer.create({
		type:&apos;tldr&apos;,
		length:&apos;long&apos;,
		sharedContext, 
		monitor(m) {
            m.addEventListener(&amp;quot;downloadprogress&amp;quot;, e =&amp;gt; {
                /*
                why this? the download event _always_ runs at
                least once, so this prevents the msg showing up
                when its already done. I&apos;ve seen it report 0 and 1
                in this case, so we skip both
                */
                if(e.loaded === 0 || e.loaded === 1) return;
                $output.innerHTML = `Downloading the Summary model, currently at ${Math.floor(e.loaded * 100)}%`;
            });
		}
	});


	try {
		let summary = await summarizer.summarize(summaryOb.text);
		$output.innerHTML = `&amp;lt;h3&amp;gt;Summary for ${summaryOb.title}&amp;lt;/h3&amp;gt;${marked.parse(summary)}`;
	} catch(e) {
		if(e.name === &apos;QuotaExceededError&apos;) {
			$output.innerHTML = &apos;Unfortunately this document was too large!&apos;;
		} else {
			$output.innerHTML = `Some other error was thrown: ${e}`;
		}
		console.log(e);
	}
	
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All in all, it worked well. It did feel like I filled the context window with Excel pretty quickly. My initial text file had 1000 rows and that threw a quota error. I had to get it down to 200 to properly parse.&lt;/p&gt;
&lt;p&gt;If you are on the latest Chrome, in theory, this will work for you, but as always, let me know!&lt;/p&gt;
&lt;p class=&quot;codepen&quot; data-theme-id=&quot;dark&quot; data-height=&quot;600&quot; data-pen-title=&quot;Chrome AI, Doc Summaries (V2)&quot; data-preview=&quot;true&quot; data-default-tab=&quot;result&quot; data-slug-hash=&quot;MYjxbrv&quot; data-user=&quot;cfjedimaster&quot; style=&quot;height:600px; 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/cfjedimaster/pen/MYjxbrv&quot;&gt;
  Chrome AI, Doc Summaries (V2)&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;
                        
                
				</content>

                
                <category term="javascript" />
                
                <category term="generative ai" />
                
                
                <category term="development" />
                
                <author>
                    <name>Raymond Camden</name>
                    <email>raymondcamden@gmail.com</email>
                </author>
            </entry>
        
</feed>