<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bryan Hamilton</title>
    <description>The latest articles on DEV Community by Bryan Hamilton (@bryanhamiltondev).</description>
    <link>https://dev.to/bryanhamiltondev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4169505%2Fcd835a1a-5b9d-44f8-b958-5b28e0732b4a.jpg</url>
      <title>DEV Community: Bryan Hamilton</title>
      <link>https://dev.to/bryanhamiltondev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bryanhamiltondev"/>
    <language>en</language>
    <item>
      <title>The Equalizer That Actually Listens: A Web Audio Analyser Deep-Dive</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Sat, 17 Oct 2026 15:22:33 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/the-equalizer-that-actually-listens-a-web-audio-analyser-deep-dive-b3</link>
      <guid>https://dev.to/bryanhamiltondev/the-equalizer-that-actually-listens-a-web-audio-analyser-deep-dive-b3</guid>
      <description>&lt;p&gt;An equalizer that doesn't listen to the music is just a screensaver with good branding.&lt;/p&gt;

&lt;p&gt;If the bars are animating on a timer and the music isn't driving them, your users can tell. They may never say it out loud, but they feel it - the same way they feel a fake progress bar. Sound-reactive visuals are one of the rare places on the web where honesty is literally measurable: the waveform either agrees with the audio or it doesn't.&lt;/p&gt;

&lt;p&gt;This is the deep-dive on how I built a real one. Not CSS bars with a random keyframe animation - an actual AnalyserNode reading the frequency spectrum of a playing track and painting eight bars on a canvas, every frame, in sync with the sound. It runs on the hover-preview cards of The DJ Calendar, an electronic music event discovery platform I build and run. When you hover a DJ card, a 30-second preview starts playing and the equalizer on the card starts dancing to it. Zero dependencies. All vanilla JS.&lt;/p&gt;

&lt;p&gt;Here's everything I learned building it, including the two failure modes nobody warns you about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline in sixty seconds
&lt;/h2&gt;

&lt;p&gt;Web Audio gives you the whole chain for free, built into every browser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You take an audio element - in my case playing a preview stream from the iTunes Search API - and you wrap it in a MediaElementAudioSourceNode.&lt;/li&gt;
&lt;li&gt;You pipe that through an AnalyserNode, which keeps a running Fourier transform of whatever is playing.&lt;/li&gt;
&lt;li&gt;You pipe the analyser to the destination so the sound keeps playing.&lt;/li&gt;
&lt;li&gt;Every animation frame, you call getByteFrequencyData and get back an array describing how much energy is in each frequency band, from 0 to 255.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last array is the entire magic trick. Everything else is drawing.&lt;/p&gt;

&lt;p&gt;Two details in that chain will bite you if nobody tells you, so let me tell you now. First: createMediaElementSource can only ever be called once per audio element. Call it twice and you get an exception, so keep your wiring in one place. Second, and worse: if the audio source is cross-origin and the server doesn't send CORS headers, the analyser doesn't error. It returns all zeros. The music plays fine, the animation runs, and the bars just... sit there, dead flat, forever. I lost real time to this in the demo build before adding a diagnostic that detects the taint. An analyser that fails silently is the cruelest kind of bug, because every part of your pipeline looks like it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fftSize 64 is the right amount of wrong
&lt;/h2&gt;

&lt;p&gt;The default fftSize is 2048, which gives you 1024 frequency bins. That sounds impressive and is mostly useless for a visualizer: you would be drawing bars for frequency detail the human eye cannot parse at animation speed, at a per-frame cost you pay for nothing.&lt;/p&gt;

&lt;p&gt;I use fftSize 64. That is 32 bins. And it turns out that for a music visualizer, 32 is not a compromise - it is close to the correct answer, because most of the perceptual energy in a dance track lives in the bottom of the spectrum. Kick drums, basslines, the sub region: that is what makes an electronic track feel alive, and it all sits in the low bins.&lt;/p&gt;

&lt;p&gt;With 32 bins I need 8 bars, and I could just average the bins into 8 groups. Instead I sample with a stride of 2 - bin 0, bin 2, bin 4 and so on. The reason is character. Averaging smooths away the differences between neighboring bands, and those differences are exactly what makes a visualizer look like music instead of a metronome. Stride sampling keeps each bar pointing at a specific, stable frequency region, so when the kick hits, the left bars spike and the recovery ripples rightward. The image reads as a wave moving through the spectrum, because it is one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smoothing is the difference between data and a strobe light
&lt;/h2&gt;

&lt;p&gt;Raw FFT frames flicker. Music has sharp transients - a hi-hat is a spike that exists for a few milliseconds - and if you paint raw frames your equalizer turns into a strobe that triggers anyone with photosensitivity issues and looks broken for everyone else.&lt;/p&gt;

&lt;p&gt;The AnalyserNode has a smoothingTimeConstant property, and I set it to 0.8. That is a heavily damped value: each frame, the analyser blends the new spectrum with the old one at 80/20, so the bars rise fast enough to feel percussive and fall slowly enough to feel like they have mass. Go lower and the bars twitch; go higher and they miss the beat entirely. 0.8 sits in the pocket for dance music specifically, where transients are big and regular.&lt;/p&gt;

&lt;p&gt;If you only steal one number from this article, steal that one. It is doing more visible work than anything else in the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drawing eight bars that belong to the card
&lt;/h2&gt;

&lt;p&gt;The visual style has one hard rule: the equalizer is part of the card, not an overlay floating on it. On the production cards it is a full-width strip anchored flush to the bottom edge - eight bars at 31.5 pixels with 4-pixel gaps, which comes out to exactly 280 pixels on a 280-pixel card, 80 pixels tall, with a cyan gradient fading to transparent toward the bottom of each bar.&lt;/p&gt;

&lt;p&gt;That flush-to-the-edge geometry is a small thing that reads as a big thing. A floating EQ looks like a widget was pasted on top of a photo. An EQ that runs edge to edge and dies exactly at the card border looks manufactured - like the card was designed around it. Same code, different margins, completely different perceived quality.&lt;/p&gt;

&lt;p&gt;The canvas is cleared and redrawn every frame with the bar heights scaled from the byte data. Eight fillRect calls per frame is nothing; the entire render loop would run comfortably on hardware from a decade ago. Performance was never the hard part of this feature. Correctness was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race condition nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Here is the bug that ate my afternoon. The cards are hover-activated. A user dragging a cursor across a row of ten DJ cards fires ten hover events in about a second. Each one wants to: start a preview, wire a new analyser, and start a requestAnimationFrame loop.&lt;/p&gt;

&lt;p&gt;The problem: those operations do not complete in order. Audio loading is async. So hover event for card B can arrive while card A's animation loop is still running, and now you have two loops, two audio sources, and one canvas being painted by both. The visual result is bars flickering between two different songs' spectra, and it looks exactly like the broken-random-bars antipattern this whole feature exists to avoid.&lt;/p&gt;

&lt;p&gt;The fix is a generation counter. Every time a new preview starts, I increment a global generation id and capture it in the closure of the new animation loop. Every frame, the loop checks whether its generation is still current; if it is not, the loop exits without drawing and without rescheduling. One line of state, and stale loops kill themselves politely. No cancellations to manage, no listeners to track, no way to leak.&lt;/p&gt;

&lt;p&gt;If you build any hover-activated media feature, build the generation counter on day one. Do it after the bug and you will spend an afternoon watching two ghosts fight over one canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autoplay gate, and why I stopped fighting it
&lt;/h2&gt;

&lt;p&gt;Browsers will not start an AudioContext without a user gesture. That is not a bug, it is policy, and it is non-negotiable. My first instinct - and the first instinct of everyone who builds this - was to treat it as an obstacle and try to route around it.&lt;/p&gt;

&lt;p&gt;The better move is to design for it. On The DJ Calendar, the first hover on a card arms the system: it preloads the audio and warms up the context, and the card shows its "you are about to hear this" state. The first click anywhere on the page unlocks audio for the whole session, permanently. From the user's point of view nothing was ever blocked - hover previews just start working after the first interaction, which is the exact moment they have expressed interest anyway.&lt;/p&gt;

&lt;p&gt;I learned this one the hard way in May, rebuilding this exact feature, before I internalized that the browser is not asking permission. It is telling you the user has not asked yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Touch and reduced motion get real states, not broken ones
&lt;/h2&gt;

&lt;p&gt;Two accessibility rules that shape the whole interaction model:&lt;/p&gt;

&lt;p&gt;On touch devices there is no hover. hover: none media queries detect this, and the interaction becomes click-to-toggle: tap the card, preview plays, EQ dances; tap again, it stops. Same system, different trigger, and nobody on a phone ever sees a dead hover state.&lt;/p&gt;

&lt;p&gt;For prefers-reduced-motion, the equalizer does not animate at all - it renders a static, honest state instead of dancing. A user who has told their OS they get motion sick has not asked for an animated frequency display, and the respectful fallback costs about ten lines.&lt;/p&gt;

&lt;p&gt;And because the previews are real playing media, the card registers mediaSession metadata, so the track name shows up correctly in OS-level audio UI, and the page title swaps to the playing track. Small details. They are also the details that make a feature feel shipped rather than demoed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why zero dependencies matters here
&lt;/h2&gt;

&lt;p&gt;None of this needed a library. The Web Audio API, canvas 2D context, and requestAnimationFrame are all in the platform, and the whole visualizer is a few hundred lines of readable JavaScript. For code that runs on every keystroke-equivalent event - here, every audio frame - being able to read the entire pipeline in one sitting is not sentimentality. It is how you catch the silent CORS failure, the double-wired source node, the stale animation loop. Dependencies do not just add bytes; they add distance between you and the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Search Engine With Zero Server Calls - the other side of the card system: instant client-side search from a prebuilt JSON index&lt;/li&gt;
&lt;li&gt;I Built My Portfolio Like a Piece of Rack Gear - the design philosophy underneath all of it&lt;/li&gt;
&lt;li&gt;The repo: github.com/bryanhamiltondev/dj-card-preview, with a live demo that plays a real 30-second preview through the real analyser&lt;/li&gt;
&lt;li&gt;And eq-visualizer, the pure-CSS sibling: same visual language, zero JavaScript, for when you have no audio to analyse&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build something with this, the two things I would beg you not to skip: the generation counter and the honest analyser. Fake bars are easy. Bars that agree with the music are the feature.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>webaudio</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The Search Engine With Zero Server Calls</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Fri, 16 Oct 2026 15:19:28 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/the-search-engine-with-zero-server-calls-34oa</link>
      <guid>https://dev.to/bryanhamiltondev/the-search-engine-with-zero-server-calls-34oa</guid>
      <description>&lt;p&gt;Every search box you've ever shipped came with the same tax: a search endpoint, a spinner, a payload, and - if you took the SaaS route - a service that bills monthly to index what is usually a few hundred names the page could have shipped with in the first place.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt;, a site of roughly 1,000 pages covering DJs, venues and shows. Its site search has never had a server. No endpoint, no spinner waiting on a round-trip, no monthly bill. It's a JSON index rendered into the page at deploy time and a small scoring function that runs in the browser on every keystroke.&lt;/p&gt;

&lt;p&gt;I've now extracted that whole pattern into a standalone, MIT-licensed repo: &lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;instant-search-index&lt;/a&gt;. Zero dependencies. One JS file. This post is how it works and, because that's where the real lessons live, what broke along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opinion underneath it
&lt;/h2&gt;

&lt;p&gt;Most search boxes are infrastructure cosplay. A request to a search endpoint, a spinner, a payload - for a catalog of a few hundred records.&lt;/p&gt;

&lt;p&gt;My stance: &lt;strong&gt;if the catalog fits in the page, the search belongs in the page.&lt;/strong&gt; Ship the index as JSON, score it in the browser on every keystroke, and the search feels instant because it &lt;em&gt;is&lt;/em&gt; instant. There is no network left to wait on.&lt;/p&gt;

&lt;p&gt;There's a second property I care about even more than speed: the index is &lt;strong&gt;generated, not queried&lt;/strong&gt;. It's built at deploy time by the same PHP that renders the artist pages, so the search index and the page content come from the same data. A search index that drifts from the pages it links to is worse than no search at all - this one &lt;em&gt;can't&lt;/em&gt; drift, because it's rendered from the source, not synced to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index contract
&lt;/h2&gt;

&lt;p&gt;One JSON array per page. Three searchable fields, two display-only ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Carl Cox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subtitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Techno / House"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"techno house icon ibiza space"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"badge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Artist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/artists/carl-cox"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt; - the primary match surface, weighted heaviest&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subtitle&lt;/code&gt; - displayed right-aligned, lightly searchable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;keywords&lt;/code&gt; - the synonym field: genre names, city names, whatever users type that isn't literally in the title&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;badge&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; - display only; &lt;code&gt;url&lt;/code&gt; is where Enter navigates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;keywords&lt;/code&gt; field is the quiet hero. Users type "edm", the record says "Tiësto". The keyword field is where you encode everything a human might type that the title will never contain - and it's also where accent folding happens, because folding happens once at index-build time, in PHP, and the client stays ASCII-simple forever. That's why "tiesto" finds Tiësto and "kolsch" finds Kölsch without a single line of Unicode normalization in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scoring: AND semantics, weighted by where the match lands
&lt;/h2&gt;

&lt;p&gt;Every keystroke scores every record. A record must match &lt;strong&gt;every&lt;/strong&gt; query token somewhere, or it drops out - AND semantics, not OR. Type "house mafia" and you get Swedish House Mafia and nothing else.&lt;/p&gt;

&lt;p&gt;Where a match lands decides the rank:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Title token, exact&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;"cox" hits Carl Cox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Title token, prefix&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;"ame" hits Ame*&lt;em&gt;lie&lt;/em&gt;* Lens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword, exact&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;"techno" hits the genre field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword, prefix&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;"tech" hits "techno"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subtitle, exact / prefix&lt;/td&gt;
&lt;td&gt;12 / 8&lt;/td&gt;
&lt;td&gt;useful, not the point&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loose substring&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;the safety net&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-phrase hit&lt;/td&gt;
&lt;td&gt;+25&lt;/td&gt;
&lt;td&gt;"black coffee" beats token soup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The core of the scorer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;titleExact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;titlePrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywordExact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;keywordPrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtitleExact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subtitlePrefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phraseBonus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;queryTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bestFieldScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queryTokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="cm"&gt;/* one miss kills the record */&lt;/span&gt;
    &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;W&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phraseBonus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="cm"&gt;/* Shorter titles win ties */&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is my favorite trick in the file. Two sort priorities - score, then shorter title - get packed into a single integer: multiply the score by 1000, subtract the title length. No comparator object, no tie-break loop, no second pass. It's arithmetic doing the work of a sort policy.&lt;/p&gt;

&lt;p&gt;Ties after that fall to index order, which keeps results stable while you type - the list doesn't reshuffle under your fingers mid-word, and mid-word stability matters more than people think. Type "ame" and watch the list: if entries jump around per keystroke, users stop trusting it.&lt;/p&gt;

&lt;p&gt;Tokens are precomputed once at load - per-record token arrays for title, subtitle and keywords - so a keystroke scores prepared arrays and never re-splits a string. On the demo's 99 records that's microseconds per keystroke. You'd need six figures of records before this design complains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The combobox: keyboard is first-class, not an afterthought
&lt;/h2&gt;

&lt;p&gt;The input is a real ARIA combobox, wired properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;combobox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-expanded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-controls&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-autocomplete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results live in a &lt;code&gt;role="listbox"&lt;/code&gt;; arrow keys move an &lt;code&gt;aria-activedescendant&lt;/code&gt; pointer; &lt;code&gt;Enter&lt;/code&gt; goes to the active option - and here's the detail I'm proudest of: &lt;strong&gt;if a query produces exactly one result, Enter goes straight there.&lt;/strong&gt; No need to arrow down to the only item on screen. The keyboard path is as short as the mouse path.&lt;/p&gt;

&lt;p&gt;Two more details that took iteration:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escape everything before rendering.&lt;/strong&gt; Matched text gets wrapped in a mark tag, but the title is escaped &lt;em&gt;first&lt;/em&gt; - index entries are data, not markup. If an artist were ever named like a script tag, the search box would display it, not run it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The debounce is 80ms, not the tutorial's 300.&lt;/strong&gt; Debounce in most search boxes exists to protect a server you're about to annoy. There's no server here. The 80ms is only there to batch keystrokes into one render; the scoring itself is cheap enough to run on every input event.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The dropdown that vanished.&lt;/strong&gt; The search widget was perfect in isolation and invisible in production: the results list rendered inside hero containers with &lt;code&gt;overflow: hidden&lt;/code&gt;, which quietly amputated everything below the fold of the header. The fix was &lt;code&gt;overflow: visible&lt;/code&gt; on every hero and header parent - and a rule I've repeated since: the search box is rarely where the bug is. The page &lt;em&gt;around&lt;/em&gt; the search box is the bug farm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AND decision I almost reversed.&lt;/strong&gt; The instinct is to fall back to OR matching when AND finds nothing: "you searched for X, maybe you meant these loosely related things." I left it out. AND plus an honest "No matches." beats OR plus a wall of tangential results, because the top of the list is the product - a confident zero communicates more than a desperate eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The payload, stated honestly.&lt;/strong&gt; The index ships inside the page, which means the page carries it. Hundreds of records is trivially fine - this is a few kilobytes of JSON. But this pattern has a boundary, and the boundary is not where you'd panic: it's when your catalog stops being "names a human can scan" and starts being "a dataset". At tens of thousands of records, close the laptop and use a real engine. Most sites - including most sites that paid for one - are nowhere near that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extracting it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;repo&lt;/a&gt; ships the engine, the CSS, and a demo where you can search the real DJ Mag Top 100 the way the production site does - including a "Show how each result scored" toggle that exposes the actual math behind each ranking. No stand-ins; the demo runs the same engine, the same weights, the same defaults as production.&lt;/p&gt;

&lt;p&gt;Usage is one line, either declarative (the index travels in the attribute, the widget builds itself):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;data-instant-search=&lt;/span&gt;&lt;span class="s"&gt;'[
  {"title": "Carl Cox", "subtitle": "Techno", "keywords": "techno house icon", "url": "/artists/carl-cox"}
]'&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or programmatic, for records that arrive at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;InstantSearch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; dependencies, &lt;strong&gt;0&lt;/strong&gt; server round-trips, &lt;strong&gt;1&lt;/strong&gt; JS file&lt;/li&gt;
&lt;li&gt;~60 lines of scoring core (320 with comments and the combobox)&lt;/li&gt;
&lt;li&gt;Microsecond scoring on hundreds of records, per keystroke&lt;/li&gt;
&lt;li&gt;ARIA combobox semantics, full keyboard navigation, escaped output&lt;/li&gt;
&lt;li&gt;Running in production across ~1,000 pages since 2015&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is &lt;a href="https://github.com/bryanhamiltondev/instant-search-index" rel="noopener noreferrer"&gt;instant-search-index&lt;/a&gt; on GitHub, with a &lt;a href="https://bryanhamiltondev.github.io/instant-search-index/demo/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; - type something, then put the mouse away and use the arrow keys.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>a11y</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built My Portfolio Like a Piece of Rack Gear - Zero Dependencies, All Vanilla JS</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/i-built-my-portfolio-like-a-piece-of-rack-gear-zero-dependencies-all-vanilla-js-347e</link>
      <guid>https://dev.to/bryanhamiltondev/i-built-my-portfolio-like-a-piece-of-rack-gear-zero-dependencies-all-vanilla-js-347e</guid>
      <description>&lt;p&gt;After 29 years of building software - most of it in PHP, all of it allergic to dependencies - I decided my portfolio should behave less like a resume and more like the gear I love: a &lt;strong&gt;production console&lt;/strong&gt;. Rack units. LEDs that flicker on. A VU meter that actually moves.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://bryanhamilton.info/" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt;, and its last PageSpeed run looked like this: &lt;strong&gt;99 Performance, 100 Accessibility, 100 Best Practices, 100 SEO, 104 KB transferred, 8 requests, 558 ms to load&lt;/strong&gt; - measured in the browser, not in a lab.&lt;/p&gt;

&lt;p&gt;No framework. No build step. No npm. Here's how each piece works, and - because that's where the real lessons live - what broke along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The rack: HTML that tells the truth
&lt;/h2&gt;

&lt;p&gt;Each project is a "rack unit" card with three things most portfolios hide: a status LED, an honest status word, and zero marketing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"rack-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"rack-led"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"rack-status"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;RUNS LIVE&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://github.com/..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;dj-card-preview&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LED color is contract law:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Green&lt;/strong&gt; (&lt;code&gt;RUNS LIVE&lt;/code&gt;): there is a URL you can click right now and it works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orange&lt;/strong&gt; (&lt;code&gt;DOCUMENTED&lt;/code&gt;): the code is real and CI-tested, but the live instance is retired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a tool's live deployment broke, I didn't take the card down or leave a green LED glowing next to a dead link. I flipped the LED to orange. A portfolio that lies to you once is a portfolio you stop trusting.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. LEDs that power on like real gear
&lt;/h2&gt;

&lt;p&gt;Real rack equipment doesn't fade in politely when it loads. It &lt;em&gt;boots&lt;/em&gt;. So when the rack scrolls into view, the LEDs flicker alive left-to-right, one time, then settle into a slow pulse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;rack-boot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.08&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;38&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2.6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;.35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;.8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="nl"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;brightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no-preference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.rack-boot&lt;/span&gt; &lt;span class="nc"&gt;.rack-card&lt;/span&gt; &lt;span class="nc"&gt;.rack-led&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rack-boot&lt;/span&gt; &lt;span class="m"&gt;.9s&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;70ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;rack-pulse&lt;/span&gt; &lt;span class="m"&gt;2.2s&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;70ms&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="m"&gt;.9s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;IntersectionObserver&lt;/code&gt; adds a &lt;code&gt;rack-boot&lt;/code&gt; class the first time the rack enters the viewport, sets a &lt;code&gt;--i&lt;/code&gt; index on each card, and the stagger is just arithmetic in &lt;code&gt;calc()&lt;/code&gt;. The handoff from boot animation to infinite pulse is scheduled by delay, not JS, so there's no race condition to manage.&lt;/p&gt;

&lt;p&gt;The part I care about most is the &lt;code&gt;@media (prefers-reduced-motion: no-preference)&lt;/code&gt; wrapper. Every animation on the site - this one, the terminal intro, the background drift - is gated the same way. Accessibility isn't a checklist item at the end; it's the container everything else lives inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A scroll progress bar disguised as a Venn diagram
&lt;/h2&gt;

&lt;p&gt;The page background is a fixed Venn diagram: one circle for engineering, one for the people-facing work. The overlap used to just sit there. Now it &lt;em&gt;fills&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.venn-core&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;97vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1040px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;0.30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;radial-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closest-side&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.17&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;210&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;58%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="m"&gt;72%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--vp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;venn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--vp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eleven lines of JS inside a &lt;code&gt;requestAnimationFrame&lt;/code&gt;-throttled, &lt;code&gt;passive&lt;/code&gt; scroll listener. As you descend the page, the intersection of the two circles glows brighter until, at the footer, the overlap is fully lit. The site's entire thesis - &lt;em&gt;the engineer who can also talk to humans&lt;/em&gt; - is rendered as an opacity value.&lt;/p&gt;

&lt;p&gt;That's the trick I'd hand to anyone building a personal site: &lt;strong&gt;if your brand has a central metaphor, make the mechanics of the page narrate it.&lt;/strong&gt; Nobody consciously notices a scroll progress bar inside a Venn diagram. Everybody feels it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The equalizer that actually listens
&lt;/h2&gt;

&lt;p&gt;The signature piece is an 8-bar equalizer that reacts to &lt;em&gt;real audio&lt;/em&gt; - not a CSS keyframe fake. When you hover a demo card, it plays a 30-second track preview through the Web Audio API and drives eight canvas bars from the analyser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAnalyser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fftSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;smoothingTimeConstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 8 bars, sampling every other bin of the low half&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByteFrequencyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;bars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lessons worth the tuition:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Autoplay policy is a feature, not a bug.&lt;/strong&gt; Browsers won't let you play audio until the user interacts. The demo shows a one-time "click anywhere to activate" gate. Fighting this is a losing game; designing around it is a UX opportunity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CSS "fake" EQ is detectable in about half a second.&lt;/strong&gt; If the bars don't move &lt;em&gt;with the kick drum&lt;/em&gt;, people feel the lie immediately. &lt;code&gt;fftSize: 64&lt;/code&gt; with &lt;code&gt;smoothingTimeConstant: 0.8&lt;/code&gt; gives you chunky, musical bars instead of noise.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  5. The search index I'm proudest of
&lt;/h2&gt;

&lt;p&gt;The site has instant search with keyboard navigation and ARIA combobox semantics, built on a &lt;strong&gt;prebuilt JSON index&lt;/strong&gt; and a tiny client-side scoring function. No server round-trips, no search service. At build time the content gets flattened into one JSON file; at runtime, ~60 lines of JS score and match against it.&lt;/p&gt;

&lt;p&gt;I extracted the whole pattern into a standalone repo (&lt;a href="https://github.com/bryanhamiltondev" rel="noopener noreferrer"&gt;instant-search-index&lt;/a&gt;) because it's the most reusable thing I've built in years: prebuilt index + scored matching + proper &lt;code&gt;combobox&lt;/code&gt; ARIA roles, and it works in anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What broke (and what it taught me)
&lt;/h2&gt;

&lt;p&gt;No tutorial-style post is honest without the blood.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 16 KB wall.&lt;/strong&gt; My host's FTP silently truncates uploads at exactly 16,384 bytes. My resume-builder PHP file is 24,084 bytes. The deploy "succeeded" and produced a corrupt PDF. Diagnosis took byte-counting and MD5 comparisons; the fix was a chunked-upload kit (split the file, base64 the parts, assemble server-side, verify MD5 before writing). The real lesson: &lt;em&gt;when a deploy fails weirdly, compare bytes, not screenshots.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cache header that ate my deploys.&lt;/strong&gt; I shipped &lt;code&gt;cache-control: max-age=2592000, immutable&lt;/code&gt; on my &lt;em&gt;HTML&lt;/em&gt;. That header is meant for fingerprinted assets like &lt;code&gt;app.abc123.js&lt;/code&gt; - not for a homepage I edit daily. Result: my browser happily served me a week-old page while I debugged code that was already live. I only proved the code worked by loading the site in headless Chrome, which had no cache. The fix is one &lt;code&gt;.htaccess&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.html$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="nc"&gt;Header&lt;/span&gt; &lt;span class="ss"&gt;set&lt;/span&gt; Cache-Control "public, max-age=0, must-revalidate"
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you take one operational lesson from this post: &lt;strong&gt;&lt;code&gt;immutable&lt;/code&gt; is for hashed assets. HTML gets &lt;code&gt;max-age=0, must-revalidate&lt;/code&gt;.&lt;/strong&gt; Write that on a sticky note before it costs you an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The half-leading PDF.&lt;/strong&gt; My PDF generator advanced the Y cursor by 48% of the line height, stacking lines on top of each other. I didn't see it for weeks because a separate bug (a missing &lt;code&gt;%PDF-1.4&lt;/code&gt; header) made the file unopenable, masking the layout bug underneath. Fix one bug, meet its friends.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Why zero dependencies, seriously
&lt;/h2&gt;

&lt;p&gt;I've been doing this since before frameworks were the default, so take this with the appropriate salt. But the math on this site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;104 KB total.&lt;/strong&gt; The median framework starter ships more than that before it renders anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 requests.&lt;/strong&gt; No font carousel, no analytics tag parade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;99-100 across every Lighthouse category&lt;/strong&gt; - including the one most portfolios fail on purpose: Accessibility, because animations respect reduced-motion and the search is a real ARIA combobox.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero-dependency isn't nostalgia. It's a debugging philosophy: when everything in the stack is code you wrote, &lt;em&gt;nothing is someone else's mystery&lt;/em&gt;. When the PDF breaks, I can read every line that produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Come kick the tires
&lt;/h2&gt;

&lt;p&gt;The site is live at &lt;a href="https://bryanhamilton.info/" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt; - scroll slowly, let the rack boot, watch the overlap glow as you reach the bottom. The code is on &lt;a href="https://github.com/bryanhamiltondev" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, ten repos deep, each one CI-badged and extracted from production code rather than written for show.&lt;/p&gt;

&lt;p&gt;And if you build something with any of these patterns - the LED boot, the analyser EQ, the instant search - tell me in the comments. I want to see the racks other people bolt together.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>css</category>
      <category>performance</category>
    </item>
    <item>
      <title>How I Took ~1,000 Pages From Hundreds of Rich Results Warnings to Zero</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:28:49 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/how-i-took-1000-pages-from-hundreds-of-rich-results-warnings-to-zero-2mkl</link>
      <guid>https://dev.to/bryanhamiltondev/how-i-took-1000-pages-from-hundreds-of-rich-results-warnings-to-zero-2mkl</guid>
      <description>&lt;p&gt;&lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt; has about 1,000 pages of structured data: artist pages, tour dates, venues and FAQs. For a while, Google flagged hundreds of Rich Results warnings across them. Today the count is zero, and a single 178-line PHP file keeps it there.&lt;/p&gt;

&lt;p&gt;This post covers the bug behind almost all of those warnings, why it's so easy to ship without noticing, and the small auditor I built to catch it, including a CI job that proves the auditor still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that looks perfectly fine
&lt;/h2&gt;

&lt;p&gt;JSON-LD lets one node point at another by &lt;code&gt;@id&lt;/code&gt; instead of repeating it. An event can say "my venue is &lt;code&gt;#venue&lt;/code&gt;" and "my performer is &lt;code&gt;#group&lt;/code&gt;", and those nodes are defined once elsewhere in the &lt;code&gt;@graph&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failure is simple: the event points at a node that was never actually put on the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@graph"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MusicEvent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/event/2#event"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Nova Kade at The Fillmore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"startDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-21T21:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/venue/ghost#venue"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON is valid. The page renders normally. A person reading it sees a venue in the HTML. But in the graph Google reads, &lt;code&gt;#venue&lt;/code&gt; points at nothing, so the event has no location, and it isn't eligible for event rich results.&lt;/p&gt;

&lt;p&gt;On a site generated from templates, this happens quietly: a partial that renders the venue node gets skipped for one kind of page, or a refactor renames an &lt;code&gt;@id&lt;/code&gt; on one side but not the other. Nothing errors. Traffic just doesn't show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Rich Results Test wasn't enough
&lt;/h2&gt;

&lt;p&gt;Google's &lt;a href="https://search.google.com/test/rich-results" rel="noopener noreferrer"&gt;Rich Results Test&lt;/a&gt; is the right tool for one page. It will tell you a property is missing or that a reference goes nowhere. But it had three problems at my scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's one page at a time.&lt;/strong&gt; I had about 1,000 pages built from a handful of templates. Pasting URLs in one by one doesn't tell you which template is the root cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shows the symptom, not the source.&lt;/strong&gt; A warning on an event page doesn't say which partial failed to emit the venue node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing stops it coming back.&lt;/strong&gt; Fix it today, and next month's refactor can reintroduce it. I'd only find out when the warnings reappeared, weeks later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't need a better validator. I needed something I could point at a whole folder of rendered pages, that would fail loudly, and that could run on every push.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks in one pass
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/bryanhamiltondev/jsonld-schema-audit" rel="noopener noreferrer"&gt;jsonld-schema-audit&lt;/a&gt; is one PHP file with no dependencies. It pulls every &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block out of each HTML file and runs three checks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Required properties.&lt;/strong&gt; Each type has properties it can't do without. The list lives in one constant, so adding a type is a one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;KNOWN_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'MusicEvent'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'startDate'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'location'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'performer'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'MusicGroup'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Place'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Organization'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'url'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'WebPage'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'FAQPage'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'mainEntity'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'BreadcrumbList'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'itemListElement'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'Person'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A property counts as missing if it's absent, an empty string or an empty array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt; resolution.&lt;/strong&gt; Every internal &lt;code&gt;#fragment&lt;/code&gt; reference has to point at a node that is defined in the same graph. More on this below, because it's the interesting part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Parse errors.&lt;/strong&gt; A JSON-LD block with a trailing comma is invisible to Google, and most tools skip it silently. Here, a block that &lt;code&gt;json_decode&lt;/code&gt; can't read becomes an error instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$blocks&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="s1"&gt;'__parse_error__'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'unparsable JSON-LD block'&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code ties it together: &lt;code&gt;0&lt;/code&gt; when clean, &lt;code&gt;1&lt;/code&gt; when there are errors, &lt;code&gt;2&lt;/code&gt; for bad usage. That single number is what lets it guard a pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt; check needs two passes
&lt;/h2&gt;

&lt;p&gt;A reference can appear before the node it points to. An event near the top of the graph can point at a venue defined at the bottom. Check references top to bottom in one pass, and every forward reference looks broken.&lt;/p&gt;

&lt;p&gt;So the auditor walks the graph twice. Pass one only builds an index of what's &lt;em&gt;defined&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// First pass: index every node carrying an @id (these are definitions).&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nodes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'@id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'@id'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$nodesById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'@id'&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass two looks at every value nested inside each node. Anything shaped like an internal reference (an optional URL, then &lt;code&gt;#&lt;/code&gt; and a fragment) has to exist in that index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$copy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'@id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// a node's own @id is a definition, not a reference&lt;/span&gt;
&lt;span class="nb"&gt;array_walk_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$copy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$nodesById&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$typeLabel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'@context'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^(?:https?:\/\/[^\s#]+)?#[A-Za-z0-9_-]+$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nodesById&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$errors&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'['&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$typeLabel&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'] property "'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;
            &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'" references unresolved @id "'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;unset&lt;/code&gt; before the walk.&lt;/strong&gt; Without it, every node's own &lt;code&gt;@id&lt;/code&gt; would be checked as if it were a reference to itself. That's a definition, not a pointer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Defined", not "mentioned".&lt;/strong&gt; A URL appearing somewhere in the graph doesn't count. The target has to be a node that actually carries that &lt;code&gt;@id&lt;/code&gt;. That's exactly the distinction Google makes, and the one my templates kept getting wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run against the broken example from earlier, the output points straight at the cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php audit.php tests/fixtures/broken.html
=== SCHEMA AUDIT: 2 error(s) across 1 block(s) ===

tests/fixtures/broken.html  [JSON-LD block 1]
  [MusicEvent] missing required property "performer" on https://example.com/event/2#event
  [MusicEvent] property "@id" references unresolved @id "https://example.com/venue/ghost#venue"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The reported property is &lt;code&gt;@id&lt;/code&gt; because &lt;code&gt;array_walk_recursive&lt;/code&gt; reports the innermost key, the &lt;code&gt;@id&lt;/code&gt; inside &lt;code&gt;location&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A CI job that proves the tool still catches the bug
&lt;/h2&gt;

&lt;p&gt;Most test suites only prove the happy path. For a checker, that's the wrong half. A checker that passes everything also passes your good fixture.&lt;/p&gt;

&lt;p&gt;So the repo's GitHub Actions workflow tests both directions: the good fixture &lt;strong&gt;must pass&lt;/strong&gt;, and the broken fixture &lt;strong&gt;must fail&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Schema Audit CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;self-test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shivammathur/setup-php@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;php-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;8.2'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Good fixture must pass&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php audit.php tests/fixtures/good.html&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Broken fixture must fail&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;if php audit.php tests/fixtures/broken.html; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "ERROR: broken fixture passed audit"; exit 1&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;echo "OK: broken fixture correctly rejected"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second step inverts the exit code. If a future change makes the auditor too lenient, the broken fixture sneaks through, and CI goes red. The tool can't silently stop catching the exact bug it was written for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it on your own site
&lt;/h2&gt;

&lt;p&gt;It needs PHP 7.4 or newer and nothing else: no extensions, no Composer. Drop &lt;code&gt;audit.php&lt;/code&gt; anywhere and point it at rendered HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php audit.php path/to/html/            &lt;span class="c"&gt;# audit a directory (*.html, *.php)&lt;/span&gt;
php audit.php page.html                &lt;span class="c"&gt;# audit one file&lt;/span&gt;
php audit.php ./ &lt;span class="nt"&gt;--report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;out.txt      &lt;span class="c"&gt;# write errors to a file&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean pages stay quiet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ php audit.php tests/fixtures/good.html
=== SCHEMA AUDIT: clean. 0 errors across 1 file(s). ===
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To guard your own site, render your pages to a folder during the build and add one step to your pipeline: &lt;code&gt;php audit.php build/&lt;/code&gt;. A non-zero exit fails the job.&lt;/p&gt;

&lt;p&gt;If you want to see it work before installing anything, the &lt;a href="https://bryanhamilton.info/projects/jsonld-schema-audit/" rel="noopener noreferrer"&gt;project page&lt;/a&gt; has an in-browser version of the same three checks. Paste your own HTML or JSON-LD, or load the broken fixture and watch it fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Defined" beats "mentioned."&lt;/strong&gt; The bug was never invalid JSON. It was a graph that pointed at nodes that weren't there. Check the relationships, not just the syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the failure, not just the success.&lt;/strong&gt; A checker's most important test is the one where it's supposed to say no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small tools get used.&lt;/strong&gt; It covers eight types on purpose, and only checks nodes at the top level of each graph. One file with no dependencies means there's no excuse to leave it out of CI. I'd rather have a narrow check that runs on every push than a thorough one that runs when I remember.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The structured data on &lt;a href="https://bryanhamilton.info/" rel="noopener noreferrer"&gt;bryanhamilton.info&lt;/a&gt; is held to the same standard. Every reference between its Person, projects, FAQ and breadcrumbs resolves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code (MIT): &lt;a href="https://github.com/bryanhamiltondev/jsonld-schema-audit" rel="noopener noreferrer"&gt;github.com/bryanhamiltondev/jsonld-schema-audit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Case study and live demo: &lt;a href="https://bryanhamilton.info/projects/jsonld-schema-audit/" rel="noopener noreferrer"&gt;bryanhamilton.info/projects/jsonld-schema-audit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The site it keeps clean: &lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been bitten by a dangling &lt;code&gt;@id&lt;/code&gt;, or you check structured data some other way in CI, I'd like to hear how in the comments.&lt;/p&gt;

</description>
      <category>php</category>
      <category>seo</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building a Real Audio Hover Preview with Zero Dependencies</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Wed, 07 Oct 2026 17:15:41 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/building-a-real-audio-hover-preview-with-zero-dependencies-2mgp</link>
      <guid>https://dev.to/bryanhamiltondev/building-a-real-audio-hover-preview-with-zero-dependencies-2mgp</guid>
      <description>&lt;p&gt;I run &lt;a href="https://thedjcalendar.com" rel="noopener noreferrer"&gt;The DJ Calendar&lt;/a&gt;, a real-time EDM tour date platform. The homepage has 99 artist cards. I wanted you to be able to hover any of them and hear their music instantly — without navigating away, without a page load, without a framework.&lt;/p&gt;

&lt;p&gt;Here's how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;Zero. No React, no Vue, no jQuery, no audio player library. Just vanilla JavaScript and two browser APIs that ship with every modern browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iTunes Search API&lt;/strong&gt; — free 30-second audio previews&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Audio API&lt;/strong&gt; — real frequency analysis for the equalizer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Getting the Audio
&lt;/h2&gt;

&lt;p&gt;Apple's iTunes Search API is a public endpoint that doesn't need authentication. A single request with &lt;code&gt;entity=musicTrack&amp;amp;limit=12&amp;amp;media=music&lt;/code&gt; returns preview URLs for any artist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://itunes.apple.com/search?term=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;entity=musicTrack&amp;amp;limit=12&amp;amp;media=music`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;previewMiss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The preview URL gets cached on the card's &lt;code&gt;data-&lt;/code&gt; attribute. First hover does the lookup. Every hover after that is instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The Analyser — Real Frequencies, Not a Fake
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of. The 8-bar equalizer isn't animated CSS pretending to be sound. It reads actual frequency data from the audio.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Audio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrigin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anonymous&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAnalyser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fftSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;smoothingTimeConstant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bufferLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;frequencyBinCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataArray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bufferLength&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;analyser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByteFrequencyData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataArray&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// Clear canvas, draw 8 bars from dataArray[i*2]&lt;/span&gt;
  &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;fftSize&lt;/code&gt; of 64 gives us 32 frequency bins. Reading every other one (&lt;code&gt;dataArray[i*2]&lt;/code&gt;) gives us exactly 8 bars. Each bar represents a real slice of the audio spectrum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The Race Condition Guard
&lt;/h2&gt;

&lt;p&gt;Sweep your cursor across a row of cards fast and three things race: the audio load, the canvas draw loop, and your mouse leaving. Here's the fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_djcPreviewGen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_djcPreviewGen&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_djcPreviewGen&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Inside every async callback:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gen&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_djcPreviewGen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// stale, abort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New hover bumps the counter. Every previous hover's callbacks check before touching the DOM. The orphaned callbacks just die silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Consent-First Design
&lt;/h2&gt;

&lt;p&gt;Most hover previews break on the devices where hover doesn't exist. This one treats every "no" as a first-class answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Touch devices&lt;/strong&gt; (&lt;code&gt;hover: none, pointer: coarse&lt;/code&gt;): hover never fires. A tap on the photo toggles play/stop. The tap also satisfies the autoplay policy — no extra click needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reduced motion&lt;/strong&gt; (&lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt;): no hover preview at all. Motion-reactive feedback is exactly what these visitors asked the OS to skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No preview found&lt;/strong&gt;: the card gets &lt;code&gt;data-preview-miss&lt;/code&gt; and is silently skipped forever. No repeated failing lookups on every hover.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;coarse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.dj-card&lt;/span&gt; &lt;span class="nc"&gt;.card-image-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.dj-card&lt;/span&gt; &lt;span class="nc"&gt;.card-image-overlay&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: The Extras
&lt;/h2&gt;

&lt;p&gt;A few things that came for free once the audio pipeline was in place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Document title swap&lt;/strong&gt;: while a preview plays, the tab reads "David Guetta - Titanium (feat. Sia)". Restores on stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Media Session API&lt;/strong&gt;: the track shows up in OS media controls with artwork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-origin audio&lt;/strong&gt;: &lt;code&gt;audio.crossOrigin = 'anonymous'&lt;/code&gt; is what makes the analyser legal on Apple's CDN. Without it, the analyser returns all zeros.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;A hover preview that works on every device, respects every accessibility setting, handles race conditions, and drives a real audio-reactive equalizer — all in vanilla JS, zero dependencies.&lt;br&gt;
[Try it Live] &lt;a href="https://bryanhamiltondev.github.io/dj-card-preview/" rel="noopener noreferrer"&gt;https://bryanhamiltondev.github.io/dj-card-preview/&lt;/a&gt; — click the page once, then hover the card.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/bryanhamiltondev/dj-card-preview" rel="noopener noreferrer"&gt;bryanhamiltondev/dj-card-preview&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series extracting production patterns from The DJ Calendar. Also check out the &lt;a href="https://github.com/bryanhamiltondev/eq-visualizer" rel="noopener noreferrer"&gt;eq-visualizer&lt;/a&gt; (pure-CSS decorative EQ) and &lt;a href="https://github.com/bryanhamiltondev/next-show-radar" rel="noopener noreferrer"&gt;next-show-radar&lt;/a&gt; (consent-first tour map).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>webaudio</category>
    </item>
    <item>
      <title>Extracting a Production Hover Preview: Audio, Canvas EQ, and Consent-First Design</title>
      <dc:creator>Bryan Hamilton</dc:creator>
      <pubDate>Wed, 07 Oct 2026 17:00:58 +0000</pubDate>
      <link>https://dev.to/bryanhamiltondev/extracting-a-production-hover-preview-audio-canvas-eq-and-consent-first-design-2lac</link>
      <guid>https://dev.to/bryanhamiltondev/extracting-a-production-hover-preview-audio-canvas-eq-and-consent-first-design-2lac</guid>
      <description>&lt;p&gt;&lt;strong&gt;1. The Problem&lt;/strong&gt;&lt;br&gt;
99 artist cards on The DJ Calendar homepage&lt;br&gt;
Users needed a way to preview music without clicking through to each artist page&lt;br&gt;
Requirements: must work on hover AND touch, must be zero-dependency, must handle browser autoplay policy&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The iTunes Search API — Free Audio&lt;/strong&gt;&lt;br&gt;
No audio hosting needed — Apple's 30-second previews are publicly accessible&lt;br&gt;
entity=musicTrack&amp;amp;limit=12&amp;amp;media=music — grab the first result with a preview URL&lt;br&gt;
Cache it on the card's data-preview-url so it's a one-time lookup per card&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Web Audio API Analyser — Real EQ, Not a Fake&lt;/strong&gt;&lt;br&gt;
createMediaElementSource feeds real audio into an analyserNode&lt;br&gt;
fftSize: 64, smoothing: 0.8 — 8 bars reading their own frequency bins&lt;br&gt;
Canvas draws each frame from dataArray[i*2]&lt;br&gt;
This is the key line: "This is not an animation pretending to be sound"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The Race Condition Guard&lt;/strong&gt;&lt;br&gt;
Sweep across cards fast and audio load, canvas draw, and mouseleave all race&lt;br&gt;
Each card has a _djcPreviewGen counter&lt;br&gt;
New hover bumps the counter, previous async callbacks check before touching DOM&lt;br&gt;
Simple pattern, saves a ton of edge-case bugs&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Consent-First Design — The "No" States&lt;/strong&gt;&lt;br&gt;
Touch devices (hover: none, pointer: coarse): tap toggles play/stop&lt;br&gt;
Reduced motion (prefers-reduced-motion: reduce): no hover preview&lt;br&gt;
Conflicting audio: card previews pause the favorites player, resume it&lt;br&gt;
No preview found: data-preview-miss, silently skipped forever&lt;br&gt;
Most hover widgets break on these — this one treats them as first-class&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Extras That Come Free&lt;/strong&gt;&lt;br&gt;
document.title swaps to "Artist - Track" while playing&lt;br&gt;
navigator.mediaSession for OS media controls&lt;br&gt;
crossOrigin: anonymous makes the analyser legal on cross-origin audio&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. The Extraction — From Production to Repo&lt;/strong&gt;&lt;br&gt;
This code was running on thedjcalendar.com for months&lt;br&gt;
Extracted verbatim into a standalone repo with a GitHub Pages demo&lt;br&gt;
Same code, no build step, no framework — just a script tag and some HTML&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Live Demo&lt;/strong&gt;&lt;br&gt;
bryanhamiltondev.github.io/dj-card-preview&lt;br&gt;
David Guetta — Titanium — click once, hover the card, hear it play&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Sibling Repos&lt;/strong&gt;&lt;br&gt;
eq-visualizer — the decorative pure-CSS EQ&lt;br&gt;
next-show-radar — the consent-first tour map&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>webaudio</category>
    </item>
  </channel>
</rss>
