<?xml version="1.0" encoding="utf-8" standalone="yes"?><?xml-stylesheet type="text/xsl" href="/pretty-feed-v3.xsl"?>
<rss
  version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:source="https://source.scripting.com/"
>
  <channel>
    <title>Paul Tibbetts</title>
    <link>https://paultibbetts-uk-feat-cooks.preview.lab.paultibbetts.uk/2026/03/18/</link>
    <description>Posts by Paul Tibbetts published on Wednesday, March 18, 2026</description>
    <generator>Hugo</generator>
    <language>en-gb</language>
    <atom:link href="https://paultibbetts-uk-feat-cooks.preview.lab.paultibbetts.uk/2026/03/18/feed.xml" rel="self" type="application/rss+xml" /><item>
      <title>Yes, I&#39;m going to Homebrew Website Club London tonight. I get inspired by many …</title>
      <link>https://paultibbetts-uk-feat-cooks.preview.lab.paultibbetts.uk/2026/03/18/2026-03-18-153902-i-m-going-to-homebrew-website-club-london-tonight/</link>
      <pubDate>Wed, 18 Mar 2026 16:00:00 +0000</pubDate>
      <guid>https://paultibbetts-uk-feat-cooks.preview.lab.paultibbetts.uk/2026/03/18/2026-03-18-153902-i-m-going-to-homebrew-website-club-london-tonight/</guid>
      <category>Homebrew Website Club</category>
      <description>Yes, I&amp;#39;m going to Homebrew Website Club London tonight.</description>
      <content:encoded><![CDATA[<p class="p-summary">
	<span class="hidden"><span class="p-rsvp">Yes</span>, </span>I'm going to  
	<a href="https://events.indieweb.org/2026/03/homebrew-website-club-europe-london-0EMPkdPMzZKX" class="u-in-reply-to">Homebrew Website Club London tonight</a>.
</p>
<p>I get inspired by many things mentioned at <a href="https://indieweb.org/Homebrew_Website_Club">HWC</a>
, but somehow the first to make it to my blog, based on a comment from the last meetup, is this:</p>
<style>
  .h-entry.notes [data-morse-start],
  .h-entry.notes [data-morse-stop] {
    cursor: pointer;
  }

  .h-entry.notes [data-morse-start]:disabled,
  .h-entry.notes [data-morse-stop]:disabled {
    cursor: default;
    opacity: 0.5;
  }

  .h-entry.notes pre:has(code.language-morse),
  .h-entry.notes pre:has(code[data-lang="morse"]) {
    overflow-x: hidden;
  }

  .h-entry.notes pre code.language-morse,
  .h-entry.notes pre code[data-lang="morse"] {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    word-break: break-word;
  }
</style>
<div class="mt-2">
    <div class="flex items-center rounded-md bg-white/5 pl-3 outline-1 -outline-offset-1 outline-gray-600 has-[input:focus-within]:outline-2 has-[input:focus-within]:-outline-offset-2 has-[input:focus-within]:outline-indigo-500">
		<textarea
		    id="morse-input"
		    data-morse-input
		    rows="2"
		    style="width: 100%; margin-top: 0.25rem;"
		    class="block min-w-0 grow py-1.5 pr-3 pl-1 text-base text-white placeholder:text-gray-500 focus:outline-none sm:text-sm/6"
	>Hello, World!</textarea>
	</div>
</div>
<pre><code class="language-morse"></code></pre>
<p>
  <button type="button" data-morse-start>📡 Broadcast</button>
</p>
<p>
  <button type="button" data-morse-stop>⏹️ Stop</button>
</p>
<script type="text/javascript">
// It would be cool to say I wrote all of this myself
// but I asked an LLM to do it
// and then I realised it could have been a fun little project.
//
// I will learn from this, and choose my LLM usage
// more carefully in the future.
//
// This is the LLM code.
// I take no credit for writing it, other than
// writing the prompts that resulted in this.
//
// It seems to have come from one or two examples
// that one or two people have made in the past.
// There is one line where the LLM seems to argue with
// itself in the comments (#315).
(() => {
  // International Morse (letters+digits; extend as needed)
  const MORSE = {
    A: ".-",
    B: "-...",
    C: "-.-.",
    D: "-..",
    E: ".",
    F: "..-.",
    G: "--.",
    H: "....",
    I: "..",
    J: ".---",
    K: "-.-",
    L: ".-..",
    M: "--",
    N: "-.",
    O: "---",
    P: ".--.",
    Q: "--.-",
    R: ".-.",
    S: "...",
    T: "-",
    U: "..-",
    V: "...-",
    W: ".--",
    X: "-..-",
    Y: "-.--",
    Z: "--..",
    0: "-----",
    1: ".----",
    2: "..---",
    3: "...--",
    4: "....-",
    5: ".....",
    6: "-....",
    7: "--...",
    8: "---..",
    9: "----.",
    ".": ".-.-.-",
    ",": "--..--",
    "?": "..--..",
    "'": ".----.",
    "!": "-.-.--",
    "/": "-..-.",
    "(": "-.--.",
    ")": "-.--.-",
    "&": ".-...",
    ":": "---...",
    ";": "-.-.-.",
    "=": "-...-",
    "+": ".-.-.",
    "-": "-....-",
    "_": "..--.-",
    '"': ".-..-.",
    "$": "...-..-",
    "@": ".--.-."
  };

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  function getMorseCodeElement() {
    return document.querySelector("code.language-morse, code[data-lang='morse']");
  }

  function getMorseInputElement() {
    return document.querySelector("[data-morse-input]");
  }

  function getMorseSourceText() {
    const inputEl = getMorseInputElement();
    if (inputEl) {
      const inputText = (inputEl.value || "").trim();
      return inputText || null;
    }

    const codeEl = getMorseCodeElement();
    if (!codeEl) return null;

    if (!codeEl.dataset.morseSource) {
      codeEl.dataset.morseSource = (codeEl.textContent || "").trim();
    }

    return codeEl.dataset.morseSource || null;
  }

  function renderMorseExample() {
    const codeEl = getMorseCodeElement();
    const sourceText = getMorseSourceText();
    if (!codeEl || !sourceText) return null;

    codeEl.textContent = encodeMorse(sourceText);
    return sourceText;
  }

  function encodeMorse(text) {
    const up = text.toUpperCase();
    // Words separated by " / ", letters by spaces
    const words = up.split(/\s+/).filter(Boolean);
    return words.map(word => {
      return word.split("").map(ch => MORSE[ch] ?? "").filter(Boolean).join(" ");
    }).join(" / ");
  }

  function makeBeepEngine() {
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    const gain = ctx.createGain();
    gain.gain.value = 0; // start silent
    gain.connect(ctx.destination);

    const osc = ctx.createOscillator();
    osc.type = "sine";
    osc.frequency.value = 650; // Hz
    osc.connect(gain);
    osc.start();

    return {
      ctx,
      async ensureRunning() {
        if (ctx.state !== "running") await ctx.resume();
      },
      on() { gain.gain.setTargetAtTime(0.12, ctx.currentTime, 0.005); },
      off() { gain.gain.setTargetAtTime(0.0, ctx.currentTime, 0.005); },
      stop() {
        try { osc.stop(); } catch {}
        ctx.close();
      }
    };
  }

  /**
   * Tap Morse live.
   * Timing is based on "unit" length (dot = 1u, dash = 3u).
   * - intra-symbol gap: 1u
   * - letter gap: 3u
   * - word gap: 7u (we approximate with "/" token)
   */
  async function tapMorse(morse, {
    unitMs = 90,
    useAudio = true,
    useVibrate = false,
    log = true,
    signal = undefined
  } = {}) {
    const engine = useAudio ? makeBeepEngine() : null;
    try {
      if (engine) await engine.ensureRunning();

      const tokens = morse.split(" "); // includes ".", "-", "/" as tokens
      if (log) console.log("[morse] tapping:", morse);

      for (let i = 0; i < tokens.length; i++) {
        if (signal?.aborted) throw new DOMException("Aborted", "AbortError");

        const t = tokens[i];

        if (t === ".") {
          engine?.on();
          if (useVibrate && navigator.vibrate) navigator.vibrate(unitMs);
          await sleep(unitMs);
          engine?.off();
          await sleep(unitMs); // intra-symbol gap
          continue;
        }

        if (t === "-") {
          engine?.on();
          if (useVibrate && navigator.vibrate) navigator.vibrate(unitMs * 3);
          await sleep(unitMs * 3);
          engine?.off();
          await sleep(unitMs); // intra-symbol gap
          continue;
        }

        if (t === "/") {
          // word gap: 7 units (we already likely had 1 unit after prior symbol)
          await sleep(unitMs * 6);
          continue;
        }

        // Letter boundary: when we see a multi-symbol chunk like ".-.." we won't,
        // because we tokenized by spaces. But we *do* need letter gaps.
        // Our encoding already put spaces between letters, so the token stream
        // is just symbols and "/" — the letter gap is handled by a longer pause
        // when the next token is a symbol but we are at a letter boundary.
        //
        // Easiest: detect original morse string boundaries by using the fact
        // that encodeMorse uses single spaces between letters, and "/" is separate.
        //
        // We already include the 1u intra-symbol gap after each dot/dash, so for
        // letter gap (3u) we add 2u extra when the *next* token is a symbol and
        // we are between letters. We can approximate by looking at the raw string.
      }
    } finally {
      engine?.stop();
    }
  }

  // Slightly better timing: tap by parsing the original morse string
  async function tapMorsePrecise(morse, opts = {}) {
    const {
      unitMs = 90, useAudio = true, useVibrate = false, log = true, signal
    } = opts;

    const engine = useAudio ? makeBeepEngine() : null;
    try {
      if (engine) await engine.ensureRunning();
      if (log) console.log("[morse] tapping:", morse);

      // Parse words split by " / "
      const words = morse.split(" / ").map(w => w.trim()).filter(Boolean);

      for (let w = 0; w < words.length; w++) {
        const letters = words[w].split(" ").map(l => l.trim()).filter(Boolean); // each letter is like ".-"
        for (let l = 0; l < letters.length; l++) {
          if (signal?.aborted) throw new DOMException("Aborted", "AbortError");

          const symbols = letters[l].split("");
          for (let s = 0; s < symbols.length; s++) {
            const sym = symbols[s];
            if (sym === ".") {
              engine?.on();
              if (useVibrate && navigator.vibrate) navigator.vibrate(unitMs);
              await sleep(unitMs);
              engine?.off();
            } else if (sym === "-") {
              engine?.on();
              if (useVibrate && navigator.vibrate) navigator.vibrate(unitMs * 3);
              await sleep(unitMs * 3);
              engine?.off();
            }
            // intra-symbol gap (1u) between parts of same letter
            if (s < symbols.length - 1) await sleep(unitMs);
          }
          // letter gap: 3u (but we've already waited 1u after last symbol? no)
          if (l < letters.length - 1) await sleep(unitMs * 3);
        }
        // word gap: 7u
        if (w < words.length - 1) await sleep(unitMs * 7);
      }
    } finally {
      engine?.stop();
    }
  }

  const START_BUTTON_SELECTOR = "[data-morse-start]";
  const STOP_BUTTON_SELECTOR = "[data-morse-stop]";
  const startButton = document.querySelector(START_BUTTON_SELECTOR);
  const stopButton = document.querySelector(STOP_BUTTON_SELECTOR);

  function setPlayingState(isPlaying) {
    if (startButton) startButton.disabled = isPlaying;
    if (stopButton) stopButton.disabled = !isPlaying;
  }

  // Controller so user can stop it
  let current = null;

  async function playMorse({
    unitMs = 90,
    useAudio = true,
    useVibrate = false,
    log = true
  } = {}) {
    const text = getMorseSourceText();

    if (!text) {
      console.warn("[morse] No morse source code block found.");
      return;
    }

    const morse = encodeMorse(text);

    // Abort previous run if any
    if (current?.controller) current.controller.abort();
    const controller = new AbortController();
    current = { controller };
    setPlayingState(true);

    try {
      await tapMorsePrecise(morse, { unitMs, useAudio, useVibrate, log, signal: controller.signal });
    } catch (e) {
      if (e?.name === "AbortError") {
        if (log) console.log("[morse] stopped.");
        return;
      }
      throw e;
    } finally {
      setPlayingState(false);
    }
  }

  function morseStop() {
    if (current?.controller) current.controller.abort();
    setPlayingState(false);
  }

  const sourceText = renderMorseExample();
  if (!sourceText) {
    console.warn("[morse] Could not render morse example from code block.");
  }

  const inputEl = getMorseInputElement();
  if (inputEl) {
    inputEl.addEventListener("input", () => {
      renderMorseExample();
    });
  }

  setPlayingState(false);

  if (startButton) {
    startButton.addEventListener("click", () => {
      playMorse();
    });
  } else {
    console.warn(`[morse] Start button not found (${START_BUTTON_SELECTOR}).`);
  }

  if (stopButton) {
    stopButton.addEventListener("click", morseStop);
  } else {
    console.warn(`[morse] Stop button not found (${STOP_BUTTON_SELECTOR}).`);
  }
})();
</script>
<p>To make this I was going to need to look up the Morse code alphabet, and then use it as part of a script.</p>
<p>I was feeling lazy so I used an LLM to do that for me, and it ended up writing the whole thing, including turning it into sound, which I hadn&rsquo;t even asked it to do.</p>
<p>It didn&rsquo;t just make it all, in seconds, it ruined any fun I might have had experimenting on a new thing, maybe even learning a thing or two.</p>
<p>So I present it here begrudgingly.</p>
<hr>
<p>I am using this note to test I can send an <a href="https://indieweb.org/rsvp">RSVP</a>
.</p>
<p>If you see my name <a href="https://events.indieweb.org/2026/03/homebrew-website-club-europe-london-0EMPkdPMzZKX#rsvps">on this list</a>
 then it worked.</p>]]></content:encoded><source:markdown>
&lt;p class=&#34;p-summary&#34;&gt;
	&lt;span class=&#34;hidden&#34;&gt;&lt;span class=&#34;p-rsvp&#34;&gt;Yes&lt;/span&gt;, &lt;/span&gt;I&#39;m going to  
	&lt;a href=&#34;https://events.indieweb.org/2026/03/homebrew-website-club-europe-london-0EMPkdPMzZKX&#34; class=&#34;u-in-reply-to&#34;&gt;Homebrew Website Club London tonight&lt;/a&gt;.
&lt;/p&gt;

&lt;!--more--&gt;

I get inspired by many things mentioned at [HWC](https://indieweb.org/Homebrew_Website_Club), but somehow the first to make it to my blog, based on a comment from the last meetup, is this:

&lt;style&gt;
  .h-entry.notes [data-morse-start],
  .h-entry.notes [data-morse-stop] {
    cursor: pointer;
  }

  .h-entry.notes [data-morse-start]:disabled,
  .h-entry.notes [data-morse-stop]:disabled {
    cursor: default;
    opacity: 0.5;
  }

  .h-entry.notes pre:has(code.language-morse),
  .h-entry.notes pre:has(code[data-lang=&#34;morse&#34;]) {
    overflow-x: hidden;
  }

  .h-entry.notes pre code.language-morse,
  .h-entry.notes pre code[data-lang=&#34;morse&#34;] {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    word-break: break-word;
  }
&lt;/style&gt;

&lt;div class=&#34;mt-2&#34;&gt;
    &lt;div class=&#34;flex items-center rounded-md bg-white/5 pl-3 outline-1 -outline-offset-1 outline-gray-600 has-[input:focus-within]:outline-2 has-[input:focus-within]:-outline-offset-2 has-[input:focus-within]:outline-indigo-500&#34;&gt;
		&lt;textarea
		    id=&#34;morse-input&#34;
		    data-morse-input
		    rows=&#34;2&#34;
		    style=&#34;width: 100%; margin-top: 0.25rem;&#34;
		    class=&#34;block min-w-0 grow py-1.5 pr-3 pl-1 text-base text-white placeholder:text-gray-500 focus:outline-none sm:text-sm/6&#34;
	&gt;Hello, World!&lt;/textarea&gt;
	&lt;/div&gt;
&lt;/div&gt;

```morse

```

&lt;p&gt;
  &lt;button type=&#34;button&#34; data-morse-start&gt;📡 Broadcast&lt;/button&gt;
&lt;/p&gt;
&lt;p&gt;
  &lt;button type=&#34;button&#34; data-morse-stop&gt;⏹️ Stop&lt;/button&gt;
&lt;/p&gt;

&lt;script type=&#34;text/javascript&#34;&gt;
// It would be cool to say I wrote all of this myself
// but I asked an LLM to do it
// and then I realised it could have been a fun little project.
//
// I will learn from this, and choose my LLM usage
// more carefully in the future.
//
// This is the LLM code.
// I take no credit for writing it, other than
// writing the prompts that resulted in this.
//
// It seems to have come from one or two examples
// that one or two people have made in the past.
// There is one line where the LLM seems to argue with
// itself in the comments (#315).
(() =&gt; {
  // International Morse (letters+digits; extend as needed)
  const MORSE = {
    A: &#34;.-&#34;,
    B: &#34;-...&#34;,
    C: &#34;-.-.&#34;,
    D: &#34;-..&#34;,
    E: &#34;.&#34;,
    F: &#34;..-.&#34;,
    G: &#34;--.&#34;,
    H: &#34;....&#34;,
    I: &#34;..&#34;,
    J: &#34;.---&#34;,
    K: &#34;-.-&#34;,
    L: &#34;.-..&#34;,
    M: &#34;--&#34;,
    N: &#34;-.&#34;,
    O: &#34;---&#34;,
    P: &#34;.--.&#34;,
    Q: &#34;--.-&#34;,
    R: &#34;.-.&#34;,
    S: &#34;...&#34;,
    T: &#34;-&#34;,
    U: &#34;..-&#34;,
    V: &#34;...-&#34;,
    W: &#34;.--&#34;,
    X: &#34;-..-&#34;,
    Y: &#34;-.--&#34;,
    Z: &#34;--..&#34;,
    0: &#34;-----&#34;,
    1: &#34;.----&#34;,
    2: &#34;..---&#34;,
    3: &#34;...--&#34;,
    4: &#34;....-&#34;,
    5: &#34;.....&#34;,
    6: &#34;-....&#34;,
    7: &#34;--...&#34;,
    8: &#34;---..&#34;,
    9: &#34;----.&#34;,
    &#34;.&#34;: &#34;.-.-.-&#34;,
    &#34;,&#34;: &#34;--..--&#34;,
    &#34;?&#34;: &#34;..--..&#34;,
    &#34;&#39;&#34;: &#34;.----.&#34;,
    &#34;!&#34;: &#34;-.-.--&#34;,
    &#34;/&#34;: &#34;-..-.&#34;,
    &#34;(&#34;: &#34;-.--.&#34;,
    &#34;)&#34;: &#34;-.--.-&#34;,
    &#34;&amp;&#34;: &#34;.-...&#34;,
    &#34;:&#34;: &#34;---...&#34;,
    &#34;;&#34;: &#34;-.-.-.&#34;,
    &#34;=&#34;: &#34;-...-&#34;,
    &#34;+&#34;: &#34;.-.-.&#34;,
    &#34;-&#34;: &#34;-....-&#34;,
    &#34;_&#34;: &#34;..--.-&#34;,
    &#39;&#34;&#39;: &#34;.-..-.&#34;,
    &#34;$&#34;: &#34;...-..-&#34;,
    &#34;@&#34;: &#34;.--.-.&#34;
  };

  const sleep = (ms) =&gt; new Promise(r =&gt; setTimeout(r, ms));

  function getMorseCodeElement() {
    return document.querySelector(&#34;code.language-morse, code[data-lang=&#39;morse&#39;]&#34;);
  }

  function getMorseInputElement() {
    return document.querySelector(&#34;[data-morse-input]&#34;);
  }

  function getMorseSourceText() {
    const inputEl = getMorseInputElement();
    if (inputEl) {
      const inputText = (inputEl.value || &#34;&#34;).trim();
      return inputText || null;
    }

    const codeEl = getMorseCodeElement();
    if (!codeEl) return null;

    if (!codeEl.dataset.morseSource) {
      codeEl.dataset.morseSource = (codeEl.textContent || &#34;&#34;).trim();
    }

    return codeEl.dataset.morseSource || null;
  }

  function renderMorseExample() {
    const codeEl = getMorseCodeElement();
    const sourceText = getMorseSourceText();
    if (!codeEl || !sourceText) return null;

    codeEl.textContent = encodeMorse(sourceText);
    return sourceText;
  }

  function encodeMorse(text) {
    const up = text.toUpperCase();
    // Words separated by &#34; / &#34;, letters by spaces
    const words = up.split(/\s+/).filter(Boolean);
    return words.map(word =&gt; {
      return word.split(&#34;&#34;).map(ch =&gt; MORSE[ch] ?? &#34;&#34;).filter(Boolean).join(&#34; &#34;);
    }).join(&#34; / &#34;);
  }

  function makeBeepEngine() {
    const ctx = new (window.AudioContext || window.webkitAudioContext)();
    const gain = ctx.createGain();
    gain.gain.value = 0; // start silent
    gain.connect(ctx.destination);

    const osc = ctx.createOscillator();
    osc.type = &#34;sine&#34;;
    osc.frequency.value = 650; // Hz
    osc.connect(gain);
    osc.start();

    return {
      ctx,
      async ensureRunning() {
        if (ctx.state !== &#34;running&#34;) await ctx.resume();
      },
      on() { gain.gain.setTargetAtTime(0.12, ctx.currentTime, 0.005); },
      off() { gain.gain.setTargetAtTime(0.0, ctx.currentTime, 0.005); },
      stop() {
        try { osc.stop(); } catch {}
        ctx.close();
      }
    };
  }

  /**
   * Tap Morse live.
   * Timing is based on &#34;unit&#34; length (dot = 1u, dash = 3u).
   * - intra-symbol gap: 1u
   * - letter gap: 3u
   * - word gap: 7u (we approximate with &#34;/&#34; token)
   */
  async function tapMorse(morse, {
    unitMs = 90,
    useAudio = true,
    useVibrate = false,
    log = true,
    signal = undefined
  } = {}) {
    const engine = useAudio ? makeBeepEngine() : null;
    try {
      if (engine) await engine.ensureRunning();

      const tokens = morse.split(&#34; &#34;); // includes &#34;.&#34;, &#34;-&#34;, &#34;/&#34; as tokens
      if (log) console.log(&#34;[morse] tapping:&#34;, morse);

      for (let i = 0; i &lt; tokens.length; i++) {
        if (signal?.aborted) throw new DOMException(&#34;Aborted&#34;, &#34;AbortError&#34;);

        const t = tokens[i];

        if (t === &#34;.&#34;) {
          engine?.on();
          if (useVibrate &amp;&amp; navigator.vibrate) navigator.vibrate(unitMs);
          await sleep(unitMs);
          engine?.off();
          await sleep(unitMs); // intra-symbol gap
          continue;
        }

        if (t === &#34;-&#34;) {
          engine?.on();
          if (useVibrate &amp;&amp; navigator.vibrate) navigator.vibrate(unitMs * 3);
          await sleep(unitMs * 3);
          engine?.off();
          await sleep(unitMs); // intra-symbol gap
          continue;
        }

        if (t === &#34;/&#34;) {
          // word gap: 7 units (we already likely had 1 unit after prior symbol)
          await sleep(unitMs * 6);
          continue;
        }

        // Letter boundary: when we see a multi-symbol chunk like &#34;.-..&#34; we won&#39;t,
        // because we tokenized by spaces. But we *do* need letter gaps.
        // Our encoding already put spaces between letters, so the token stream
        // is just symbols and &#34;/&#34; — the letter gap is handled by a longer pause
        // when the next token is a symbol but we are at a letter boundary.
        //
        // Easiest: detect original morse string boundaries by using the fact
        // that encodeMorse uses single spaces between letters, and &#34;/&#34; is separate.
        //
        // We already include the 1u intra-symbol gap after each dot/dash, so for
        // letter gap (3u) we add 2u extra when the *next* token is a symbol and
        // we are between letters. We can approximate by looking at the raw string.
      }
    } finally {
      engine?.stop();
    }
  }

  // Slightly better timing: tap by parsing the original morse string
  async function tapMorsePrecise(morse, opts = {}) {
    const {
      unitMs = 90, useAudio = true, useVibrate = false, log = true, signal
    } = opts;

    const engine = useAudio ? makeBeepEngine() : null;
    try {
      if (engine) await engine.ensureRunning();
      if (log) console.log(&#34;[morse] tapping:&#34;, morse);

      // Parse words split by &#34; / &#34;
      const words = morse.split(&#34; / &#34;).map(w =&gt; w.trim()).filter(Boolean);

      for (let w = 0; w &lt; words.length; w++) {
        const letters = words[w].split(&#34; &#34;).map(l =&gt; l.trim()).filter(Boolean); // each letter is like &#34;.-&#34;
        for (let l = 0; l &lt; letters.length; l++) {
          if (signal?.aborted) throw new DOMException(&#34;Aborted&#34;, &#34;AbortError&#34;);

          const symbols = letters[l].split(&#34;&#34;);
          for (let s = 0; s &lt; symbols.length; s++) {
            const sym = symbols[s];
            if (sym === &#34;.&#34;) {
              engine?.on();
              if (useVibrate &amp;&amp; navigator.vibrate) navigator.vibrate(unitMs);
              await sleep(unitMs);
              engine?.off();
            } else if (sym === &#34;-&#34;) {
              engine?.on();
              if (useVibrate &amp;&amp; navigator.vibrate) navigator.vibrate(unitMs * 3);
              await sleep(unitMs * 3);
              engine?.off();
            }
            // intra-symbol gap (1u) between parts of same letter
            if (s &lt; symbols.length - 1) await sleep(unitMs);
          }
          // letter gap: 3u (but we&#39;ve already waited 1u after last symbol? no)
          if (l &lt; letters.length - 1) await sleep(unitMs * 3);
        }
        // word gap: 7u
        if (w &lt; words.length - 1) await sleep(unitMs * 7);
      }
    } finally {
      engine?.stop();
    }
  }

  const START_BUTTON_SELECTOR = &#34;[data-morse-start]&#34;;
  const STOP_BUTTON_SELECTOR = &#34;[data-morse-stop]&#34;;
  const startButton = document.querySelector(START_BUTTON_SELECTOR);
  const stopButton = document.querySelector(STOP_BUTTON_SELECTOR);

  function setPlayingState(isPlaying) {
    if (startButton) startButton.disabled = isPlaying;
    if (stopButton) stopButton.disabled = !isPlaying;
  }

  // Controller so user can stop it
  let current = null;

  async function playMorse({
    unitMs = 90,
    useAudio = true,
    useVibrate = false,
    log = true
  } = {}) {
    const text = getMorseSourceText();

    if (!text) {
      console.warn(&#34;[morse] No morse source code block found.&#34;);
      return;
    }

    const morse = encodeMorse(text);

    // Abort previous run if any
    if (current?.controller) current.controller.abort();
    const controller = new AbortController();
    current = { controller };
    setPlayingState(true);

    try {
      await tapMorsePrecise(morse, { unitMs, useAudio, useVibrate, log, signal: controller.signal });
    } catch (e) {
      if (e?.name === &#34;AbortError&#34;) {
        if (log) console.log(&#34;[morse] stopped.&#34;);
        return;
      }
      throw e;
    } finally {
      setPlayingState(false);
    }
  }

  function morseStop() {
    if (current?.controller) current.controller.abort();
    setPlayingState(false);
  }

  const sourceText = renderMorseExample();
  if (!sourceText) {
    console.warn(&#34;[morse] Could not render morse example from code block.&#34;);
  }

  const inputEl = getMorseInputElement();
  if (inputEl) {
    inputEl.addEventListener(&#34;input&#34;, () =&gt; {
      renderMorseExample();
    });
  }

  setPlayingState(false);

  if (startButton) {
    startButton.addEventListener(&#34;click&#34;, () =&gt; {
      playMorse();
    });
  } else {
    console.warn(`[morse] Start button not found (${START_BUTTON_SELECTOR}).`);
  }

  if (stopButton) {
    stopButton.addEventListener(&#34;click&#34;, morseStop);
  } else {
    console.warn(`[morse] Stop button not found (${STOP_BUTTON_SELECTOR}).`);
  }
})();
&lt;/script&gt;

To make this I was going to need to look up the Morse code alphabet, and then use it as part of a script.

I was feeling lazy so I used an LLM to do that for me, and it ended up writing the whole thing, including turning it into sound, which I hadn&#39;t even asked it to do.

It didn&#39;t just make it all, in seconds, it ruined any fun I might have had experimenting on a new thing, maybe even learning a thing or two.

So I present it here begrudgingly.

---

I am using this note to test I can send an [RSVP](https://indieweb.org/rsvp).

If you see my name [on this list](https://events.indieweb.org/2026/03/homebrew-website-club-europe-london-0EMPkdPMzZKX#rsvps) then it worked.
</source:markdown></item>
  </channel>
</rss>
