<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Om Rajguru — Dev Notes</title>
<link>https://www.omrajguru.com/devnotes</link>
<description>Technical notes and experiments.</description>
<language>en-us</language>
<atom:link href="https://www.omrajguru.com/devnotes/feed.xml" rel="self" type="application/rss+xml" />
<item>
<title>Page Transitions That Read Their Direction From the URL</title>
<link>https://www.omrajguru.com/devnotes/page-transitions</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/page-transitions</guid>
<pubDate>Mon, 05 Oct 2026 13:30:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>Every navigation on this site now animates, and the direction of the animation comes from the address. Most of the work went into finding why the first version blinked, and most of those causes were in how the browser captures pages rather than in the animation.</description>
<content:encoded>&lt;p&gt;Every page change on this site now animates, and the direction of the animation comes from the address bar. Opening a post from its archive goes one level in, so the archive recedes slightly while the post rises into place over it. Going back to the archive comes one level out and reverses the motion. Moving between Writings and Dev Notes from the navbar is a plain cross-fade, because neither section sits above the other. None of the links on the site know about any of this; one component in the root layout compares the old path with the new one and decides.&lt;/p&gt;
&lt;p&gt;Most of the work went into a version that blinked, and in finding out why. The causes turned out to sit almost entirely in how the browser captures a page for a transition, which is worth writing down, because none of them showed up in the code that defines the animation.&lt;/p&gt;
&lt;h2&gt;Why a navigation needed motion at all&lt;/h2&gt;
&lt;p&gt;The site is a set of archives (Writings, Dev Notes, Quick Ships, Poetry) and the posts inside them, and the most common thing anyone does on it is open a post and then go back. Before this change, every one of those navigations replaced the page instantly. That is fast, but it gives no indication of where the new page sits in relation to the old one: a post and a sibling archive arrive in exactly the same way.&lt;/p&gt;
&lt;p&gt;React&apos;s canary builds include a &lt;code&gt;ViewTransition&lt;/code&gt; component, Next.js ships those builds, and it can wrap navigations in the browser&apos;s View Transitions API with an experimental flag. Under the hood, the browser takes a snapshot of the old page, applies the change, takes a snapshot of the new page, and animates between the two snapshots while the real page waits underneath. That model is what makes the effect cheap, and it is also where every bug in this note came from.&lt;/p&gt;
&lt;p&gt;I started from a published set of recipes for this API. Its directional slide fades the old page out in 150 ms and only then fades the new one in, with a blur, and I paired it with a morph that carried the post title from its archive row into the article heading. It looked right in isolation. On the real site, people saw a blink, and on long titles the morph looked strange.&lt;/p&gt;
&lt;h2&gt;Depth, worked out from the address&lt;/h2&gt;
&lt;p&gt;The model I ended up with treats the site as a set of levels. A path that extends the current one, such as &lt;code&gt;/writings&lt;/code&gt; to &lt;code&gt;/writings/postmortem&lt;/code&gt;, is deeper. A path that the current one extends is shallower. Anything else is compared by the number of segments, and two paths at the same depth are siblings.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function depthBetween(from: string, to: string): DepthDirection {
    if (to.startsWith(from === &quot;/&quot; ? &quot;/&quot; : `${from}/`)) return &quot;in&quot;;
    if (from.startsWith(to === &quot;/&quot; ? &quot;/&quot; : `${to}/`)) return &quot;out&quot;;
    const depth = segments(to).length - segments(from).length;
    return depth &gt; 0 ? &quot;in&quot; : depth &amp;#x3C; 0 ? &quot;out&quot; : &quot;across&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each direction has its own motion, and every value lives in one module that the site, this note, and the design page all import. Going in, the old page scales to 98% about its top edge and fades over 200 ms while the new page rises 14 px and fades in over 380 ms, starting 40 ms in. Coming out, the old page drops 14 px and fades while the new page settles from 98% to full size. Across, the old page holds for 60 ms and fades over 180 ms while the new one fades in over 220 ms, with no movement at all. The specimen below runs those exact keyframes on a miniature of the site, and slow motion stretches them by five.&lt;/p&gt;
&lt;p&gt;Two things stay out of the transition entirely. The navbar is the same on both pages, so it is pulled out of the snapshot and drawn still on top. Search, filters, pagination, and &lt;code&gt;#&lt;/code&gt; anchors change the query string or the hash but not the path, so they never trigger anything.&lt;/p&gt;
&lt;h2&gt;How every page gets it without touching a link&lt;/h2&gt;
&lt;h3&gt;One wrapper, keyed by the path&lt;/h3&gt;
&lt;p&gt;React only animates a &lt;code&gt;ViewTransition&lt;/code&gt; when it mounts or unmounts inside a transition, and layouts in Next.js persist across navigations, so a wrapper in the layout would normally never fire. Keying it by the current path changes that: every navigation to a new path unmounts the old wrapper and mounts a new one, which is exactly an exit and an enter.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export function PageTransition({ children }: { children: React.ReactNode }) {
    const pathname = usePathname();
    prepareNavigation(pathname);
    useEffect(() =&gt; commitNavigation(pathname), [pathname]);

    return (
        &amp;#x3C;ViewTransition key={pathname} enter=&quot;page-nav&quot; exit=&quot;page-nav&quot; default=&quot;none&quot; onEnter={onEnter} onExit={onExit}&gt;
            &amp;#x3C;div&gt;{children}&amp;#x3C;/div&gt;
        &amp;#x3C;/ViewTransition&gt;
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;default=&quot;none&quot;&lt;/code&gt; matters more than it looks. Without it, every transition on the page, including ones started by unrelated state updates, would cross-fade. With it, the wrapper animates on exactly two occasions: when a page leaves and when one arrives.&lt;/p&gt;
&lt;h3&gt;Deciding the direction before the commit&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;prepareNavigation&lt;/code&gt; runs while the new page renders, compares the committed path with the new one, and stores the direction. &lt;code&gt;onExit&lt;/code&gt; and &lt;code&gt;onEnter&lt;/code&gt; then read it and animate the old and new snapshots with the Web Animations API. The stored direction is only replaced by the next navigation; I explain why in the section on what went wrong.&lt;/p&gt;
&lt;h3&gt;Every animation ends where the stylesheet rests&lt;/h3&gt;
&lt;p&gt;The stylesheet gives each snapshot a resting state, which is also the final frame of every animation: the outgoing page hidden, the incoming page in place. The animations use &lt;code&gt;fill: &quot;backwards&quot;&lt;/code&gt;, so once they finish they stop applying anything, and the snapshot shows the stylesheet&apos;s state, which already matches the last frame.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;::view-transition-old(.page-nav) {
  animation: none;
  opacity: 0;
}
::view-transition-new(.page-nav) {
  animation: none;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This also covers every navigation that has no direction. If something reaches the wrapper without one, the outgoing page is simply hidden and the incoming page is in place, which is an instant swap rather than a broken frame.&lt;/p&gt;
&lt;h2&gt;What the numbers show&lt;/h2&gt;
&lt;p&gt;The chart below is computed from the real keyframes and timing curves rather than drawn by hand. It plots the opacity of the old page, the new page, and the two together over the first 480 ms of a navigation. On a site with a black background, any moment where the two together fall well below full reads as a dark frame.&lt;/p&gt;
&lt;p&gt;The recipe on the left takes the combined opacity to zero at 150 ms, and for 200 ms less than 60% of either page is visible. That is the blink people saw. The depth transition on the right overlaps the two pages, so the combined opacity never falls below 93% in the computed curves.&lt;/p&gt;
&lt;p&gt;I also measured the live site in headless Chromium at 1440 by 900, recording the computed opacity and transform of every snapshot on every animation frame of a real navigation.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Navigation&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Lowest combined opacity&lt;/th&gt;
&lt;th&gt;Transition length&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Home to Writings&lt;/td&gt;
&lt;td&gt;in&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;td&gt;503 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writings to a post&lt;/td&gt;
&lt;td&gt;in&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;474 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post back to Writings&lt;/td&gt;
&lt;td&gt;out&lt;/td&gt;
&lt;td&gt;96%&lt;/td&gt;
&lt;td&gt;515 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writings to Dev Notes&lt;/td&gt;
&lt;td&gt;across&lt;/td&gt;
&lt;td&gt;no dip below 100%&lt;/td&gt;
&lt;td&gt;334 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typing in the archive search&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;not animated&lt;/td&gt;
&lt;td&gt;no transition&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The live numbers sit slightly above the computed ones because frames are sampled every 16 ms or so and rarely land on the exact lowest point.&lt;/p&gt;
&lt;h2&gt;The post that kept blinking&lt;/h2&gt;
&lt;p&gt;The bug that took longest only appeared on posts. I could open &quot;What Went Wrong With ibbe&quot; from the archive, and the archive would recede correctly, but the post simply appeared at full opacity instead of rising. Going back, the post disappeared on the first frame instead of dropping away.&lt;/p&gt;
&lt;p&gt;My first suspicion was timing. I had been clearing the stored direction in an effect after the commit, and React calls &lt;code&gt;onExit&lt;/code&gt; and &lt;code&gt;onEnter&lt;/code&gt; once the transition is ready, which can be after those effects. Keeping the direction until the next navigation is the safer rule, so I changed it, but the post behaved exactly as before. Logging every &lt;code&gt;animate()&lt;/code&gt; call then showed that both animations were being created with the right keyframes. The incoming animation targeted a snapshot named &lt;code&gt;_t_1_&lt;/code&gt;, and reading that snapshot&apos;s computed style returned an opacity of 1 and no transform on every frame, as though the animation did nothing.&lt;/p&gt;
&lt;p&gt;The reason was in the markup. A post page renders two &lt;code&gt;&amp;#x3C;script type=&quot;application/ld+json&quot;&gt;&lt;/code&gt; tags for structured data before the &lt;code&gt;&amp;#x3C;article&gt;&lt;/code&gt;. React gives each top-level node inside a &lt;code&gt;ViewTransition&lt;/code&gt; its own snapshot name, so &lt;code&gt;_t_1_&lt;/code&gt; belonged to the first script tag, which is never rendered, and the article was captured under a different name with no animation at all. Wrapping each page in a single &lt;code&gt;&amp;#x3C;div&gt;&lt;/code&gt; gives React exactly one node to name, and the post started rising like every other page. No stylesheet depended on pages being direct children of &lt;code&gt;&amp;#x3C;main&gt;&lt;/code&gt;, so the wrapper changed nothing about the layout.&lt;/p&gt;
&lt;h2&gt;What went wrong, and what prevents it now&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The recipe left the screen empty.&lt;/strong&gt; Exiting fully before entering is a reasonable choice on a light page with a lot of chrome, but on this site the gap is a black frame. The depth transition starts the new page 40 ms after the old one begins to leave, and the across fade holds the old page for 60 ms while the new one is already coming in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A minified duration was read as 0.44 ms.&lt;/strong&gt; The title morph read its duration from a CSS custom property, &lt;code&gt;--vt-title-duration: 440ms&lt;/code&gt;. The build minified it to &lt;code&gt;.44s&lt;/code&gt;, and &lt;code&gt;parseFloat&lt;/code&gt; returned 0.44, which the Web Animations API treats as milliseconds. Every title animation finished instantly, and what people saw was the browser&apos;s default movement of the full-size heading. The timings now live in a TypeScript module and are never read back from CSS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Finished animations could leak into the next transition.&lt;/strong&gt; With &lt;code&gt;fill: &quot;both&quot;&lt;/code&gt;, an animation keeps applying its last frame after it ends, and because a snapshot is addressed by name, a later transition that reuses the name can pick that frame up, for example a title held at opacity 0 when returning to the same post. Ending every track on the stylesheet&apos;s resting state and using &lt;code&gt;fill: &quot;backwards&quot;&lt;/code&gt; means a finished animation applies nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Long titles could not line up.&lt;/strong&gt; A title on one line in the archive and on three lines in the article cannot be matched glyph for glyph, and every attempt to morph between them showed one of the two layouts breaking apart mid-flight. I removed the title morph rather than keep tuning it. The depth motion makes the relationship between the two pages clear without asking the type to do something it cannot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The browser refused to run transitions while I tested.&lt;/strong&gt; For a while every transition I triggered in the browser pane I was testing in failed with &quot;Transition was aborted because of invalid state&quot;. The pane was hidden, and browsers do not run view transitions in a hidden document. The site was fine; I moved the measurements to headless Chromium, where the page counts as visible.&lt;/p&gt;
&lt;h2&gt;Snapshots are the thing being animated&lt;/h2&gt;
&lt;p&gt;The general lesson is that a view transition animates pictures of the page, not the page, so the questions worth asking are about the pictures. Which elements get their own snapshot, what each snapshot shows when no animation is applying to it, and what state the real page is in when the snapshots are released. Every bug above was an answer to one of those questions that I had assumed rather than checked.&lt;/p&gt;
&lt;p&gt;A few practices came out of it. Give the transition exactly one element to capture per page. Make the stylesheet&apos;s resting state identical to the final frame, so the end of an animation is never a visible event. Keep timings in code rather than reading them back from CSS that a build may rewrite. Measure the combined opacity of the two pages, because a transition can look correct frame by frame and still leave the screen empty between them.&lt;/p&gt;
&lt;h2&gt;Adding it to a Next.js site&lt;/h2&gt;
&lt;p&gt;The pieces are small. Turn on &lt;code&gt;experimental.viewTransition&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt;, render one wrapper around &lt;code&gt;{children}&lt;/code&gt; in the root layout keyed by &lt;code&gt;usePathname()&lt;/code&gt;, give it &lt;code&gt;default=&quot;none&quot;&lt;/code&gt;, and animate &lt;code&gt;instance.old&lt;/code&gt; and &lt;code&gt;instance.new&lt;/code&gt; in &lt;code&gt;onExit&lt;/code&gt; and &lt;code&gt;onEnter&lt;/code&gt;. Wrap the page content in a single element, pull persistent chrome such as the navbar out with its own &lt;code&gt;view-transition-name&lt;/code&gt;, set resting states in the stylesheet, and return a static swap under &lt;code&gt;prefers-reduced-motion&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The browser&apos;s own back and forward buttons do not animate yet. They fire a synchronous &lt;code&gt;popstate&lt;/code&gt;, which the View Transitions API cannot wrap, so returning that way swaps the page instantly. The back link at the top of every post navigates to its archive instead, which does animate. If browsers start allowing transitions on history navigation, the same wrapper will pick it up without any change to the rest of the site.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Redesigning My Homepage Without Changing a Word</title>
<link>https://www.omrajguru.com/devnotes/homepage</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/homepage</guid>
<pubDate>Sun, 04 Oct 2026 09:30:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>How I rebuilt the homepage around a live publishing record and ruled sections, while every word and the section order stayed under the control of the CMS.</description>
<content:encoded>&lt;p&gt;I redesigned the homepage of this site this week, and the words on it are exactly the same as they were before. The hero headline and bio come from my author profile in Sanity, each section&apos;s title and description come either from page settings or from the strings the old components already used, and the order of the sections is still whatever I arrange in Sanity Studio. Only the layout changed, along with what the hero shows and how much of my work is visible on each screen.&lt;/p&gt;
&lt;p&gt;Keeping the text fixed was a deliberate constraint. If I had rewritten the copy while changing the layout, I would not have been able to tell which of the two had improved the page, so the design had to be the only variable. It also meant the redesign could not take control of the content away from the CMS, which was the main thing I wanted to protect.&lt;/p&gt;
&lt;p&gt;The old homepage was centred from top to bottom: an avatar with a glow, the headline, a long paragraph, a status card with rotating roles, and then ten sections of identical height, each revealed with the same fade as it scrolled into view. Each section also showed its own name twice, once as a small label and once as a heading directly below it. None of this was broken, but the first screen showed very little of the work itself, and the uniform sections made a writing archive, a poetry archive, and a contribution graph all feel like the same kind of thing.&lt;/p&gt;
&lt;p&gt;The new hero is left-aligned, which gives the bio a comfortable reading measure and frees the right side for something that is not decoration. That space now holds a publishing record. Each column is one week of the last 52, each lit dot is one writing, dev note, or quick ship published that week, and the months are marked underneath. Below the graph are the number of pieces in that period and a link to the most recent one, with its date.&lt;/p&gt;
&lt;p&gt;I chose the record because it is the only thing in the hero that changes on its own and can be checked. A visitor can follow the latest link, count the dots against the archive, or notice that a month is empty. The status card it replaced rotated through four roles every 2.4 seconds, which said something about me but nothing about the work, so it is gone entirely.&lt;/p&gt;
&lt;p&gt;A graph like this is only honest if it never goes stale, so the most important part is the code that builds it. It reads writings, dev notes, and quick ships, drops anything dated in the future, and groups the rest into weeks that start on Monday in UTC. I kept that logic in a module with no data loading in it, so it can be tested directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function buildPublishingRecord(sources, now: Date): PublishingActivity {
    const isPublished = (piece) =&gt; new Date(piece.date).getTime() &amp;#x3C;= now.getTime();
    const pieces = [
        ...toPieces(sources.writings, &quot;/writings&quot;),
        ...toPieces(sources.devNotes, &quot;/devnotes&quot;),
        ...toPieces(sources.quickShips, &quot;/quick-ships&quot;),
    ].filter(isPublished);

    // The window is the 52 weeks ending with the week that contains `now`.
    const firstWeek = new Date(startOfWeekUtc(now).getTime() - 51 * 7 * DAY_MS);
    for (const piece of pieces) {
        const index = Math.floor((startOfWeekUtc(new Date(piece.date)).getTime() - firstWeek.getTime()) / (7 * DAY_MS));
        if (index &gt;= 0 &amp;#x26;&amp;#x26; index &amp;#x3C; 52) weeks[index].pieces.push(piece);
    }
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because the window is calculated from the current date each time the page is generated and is not stored anywhere, the graph moves on its own: old weeks fall off the left edge as new ones appear on the right, and a post I schedule for next Monday stays out of the count until Monday arrives. The tests cover exactly those cases, along with a new piece landing in the current week and becoming the latest. The homepage regenerates every 60 seconds, and the Sanity queries it depends on revalidate on the same interval, so a new piece reaches the graph within about a minute without a deploy.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// src/app/page.tsx
export const revalidate = 60;

export default async function Home() {
    return &amp;#x3C;HomePage content={await getHomeContent()} /&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The grid itself had to fit a panel whose width changes with the screen. My first instinct was a fixed pitch of 9 pixels per week, but 52 columns at 9 pixels needs 468 pixels, and the panel is often narrower than that. The grid now divides whatever width it has evenly between its columns and keeps each dot square with &lt;code&gt;aspect-ratio&lt;/code&gt;, so the full year always fits. On phones it shows the most recent 26 weeks, and the month labels switch with it, so the axis never describes dots that are not on the screen.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.hp-dots__grid {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
}
.hp-dots__dot {
    width: 100%;
    aspect-ratio: 1;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Below the hero, every section is a ruled row with its heading on the left and a dense list on the right. The heading column is sticky on wide screens, so the name of a long section stays visible while you read through its entries. Each list shows six entries where the old cards showed three, with the date in a fixed mono column so the titles line up into a single scannable edge. Quick ships without a title use their opening text as the title, so every title is capped at three lines; otherwise one long post could stretch a row to several times the height of its neighbours.&lt;/p&gt;
&lt;p&gt;The numbers above each heading are set in Geist Pixel Line, the scanline variant of the pixel typeface I use for Raster. It is the only pixel type on the homepage, and I kept it at 40 pixels because scanline letters stop reading as letters at small sizes. Its metrics are unusual: the glyphs rise 0.71em above the baseline and nothing falls below it, while the font&apos;s own box extends 1.01em up and 0.30em down. Setting &lt;code&gt;line-height: 0.71&lt;/code&gt; makes the line box end exactly on the baseline, so the number sits flush on the label beneath it with no empty space reserved for descenders the font does not have.&lt;/p&gt;
&lt;p&gt;The section order still comes entirely from Studio. The loader reads the order, removes the sections that do not belong on the homepage, and inserts one new section when it has not been placed yet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const order = (homeConfig?.sectionOrder?.map((entry) =&gt; entry.section) ?? DEFAULT_SECTION_ORDER)
    .filter((section) =&gt; !EXCLUDED_SECTIONS.has(section));

// The design section is new: until it is placed in Studio, it sits right after About.
if (!order.includes(&quot;designPrinciples&quot;)) {
    const about = order.indexOf(&quot;about&quot;);
    order.splice(about &gt;= 0 ? about + 1 : Math.min(2, order.length), 0, &quot;designPrinciples&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That new section links the homepage to my &lt;a href=&quot;https://www.omrajguru.com/design&quot;&gt;design principles&lt;/a&gt; page, which previously had no route in from the homepage at all. It shows three of the mockups from that page on their generated halftone backgrounds, followed by every principle as a link to its anchor. Its title and description come from the same Sanity document as the design page, so editing the page edits the homepage section too, and nothing about it is written twice.&lt;/p&gt;
&lt;p&gt;The links under the hero were the last thing that looked unfinished. GitHub and X were plain text, the terminal link sat in its own bordered box with a pulsing green dot, and REPRINT had a pill beside it, so the row had three different visual weights across four items. They are now the same height and padding, separated by one-pixel dividers, with every item at least 44 pixels tall so it is easy to tap. The terminal&apos;s dot is still there, but it no longer pulses, because nothing about it is live enough to justify the motion.&lt;/p&gt;
&lt;p&gt;The GitHub contribution graph scrolls sideways on narrow screens, and it was the one place where the browser&apos;s default scrollbar still appeared, grey arrows included. It now uses a thin bar drawn in the site&apos;s border colour with the arrow buttons removed, through &lt;code&gt;scrollbar-width&lt;/code&gt; and &lt;code&gt;scrollbar-color&lt;/code&gt; where they are supported and the WebKit scrollbar pseudo-elements elsewhere.&lt;/p&gt;
&lt;p&gt;The old homepage was assembled from ten section components and the status card, and nothing else on the site imported them. Once the new layout replaced them, keeping them would have left a second, unused homepage in the codebase that anyone, including me, could mistake for the real one, so I deleted them. The new page is one component that renders every section from a single content object, which makes it straightforward to see where each piece of text on the homepage comes from.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Letting Jev Choose the Related Articles</title>
<link>https://www.omrajguru.com/devnotes/related-articles-with-jev</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/related-articles-with-jev</guid>
<pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>Using Jev&apos;s relevance scores and confidence to choose related reading, with interactive examples of the decisions.</description>
<content:encoded>&lt;p&gt;I wanted Jev to choose the related articles beneath a new piece when I published it. The useful part was the judgment: given what someone had just read, which of my other articles would continue that thought?&lt;/p&gt;
&lt;p&gt;After seeing &lt;a href=&quot;https://x.com/CompleteSkeptic/status/2099925682726002904?s=20&quot;&gt;the introduction of Jev&lt;/a&gt;, I wanted to try that kind of decision in my own site. Related reading gave it a specific responsibility. The articles already existed, their links were known, and I could correct the selection whenever I disagreed.&lt;/p&gt;
&lt;h2&gt;Asking for a decision&lt;/h2&gt;
&lt;p&gt;Jev is TypeSafe&apos;s System One model. It returns typed decisions with probabilities, which makes it suitable for a feature where the application needs to select existing content. I use it through &lt;a href=&quot;https://vercel.com/docs/ai-gateway/modalities/evaluation&quot;&gt;Vercel AI Gateway&apos;s evaluation API&lt;/a&gt;, with the model ID &lt;code&gt;typesafe-ai/jev&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Its question types cover different judgments. Boolean, called Noul by TypeSafe, estimates the probability that a condition is true. Choice selects among named options and returns their probability distribution plus confidence. Score places an answer on an ordered rubric and returns the score, distribution, and confidence.&lt;/p&gt;
&lt;p&gt;For related articles, Score lets each candidate stand on its own. Several articles can be relevant, and sometimes none should be selected. Each candidate receives a judgment against the same rubric:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score level&lt;/th&gt;
&lt;th&gt;What it means here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;There is no useful connection, or only an incidental keyword in common.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;They share a meaningful topic, reader interest, technique, or approach.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;The candidate offers complementary ideas, transferable lessons, or an adjacent problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;It is a direct continuation, prerequisite, companion, or contrasting treatment.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two pieces mentioning React can still serve very different readers. Someone reading about cache invalidation may benefit from an explanation of publishing webhooks. An unrelated animation article needs more than a shared framework to earn that place.&lt;/p&gt;
&lt;p&gt;The model receives titles, excerpts, tags, and bounded passages from the articles. The question asks whether reading the candidate immediately afterward would be useful. Instructions also distinguish article text from commands, so a sentence inside an article cannot redefine the selection rule.&lt;/p&gt;
&lt;h2&gt;Confidence is separate from relevance&lt;/h2&gt;
&lt;p&gt;A confidently unrelated answer is still unrelated. That distinction is why the implementation checks both relevance and uncertainty.&lt;/p&gt;
&lt;p&gt;A candidate must put at least 80% probability on levels 1 to 3 and have at least 80% confidence. The score floor is 0.8, which allows a confident broader connection to qualify. The top 3 passing candidates are selected. If fewer qualify, the section stays shorter.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const passes =
  score &gt;= 0.8 &amp;#x26;&amp;#x26;
  relevantProbability &gt;= 0.8 &amp;#x26;&amp;#x26;
  confidence &gt;= 0.8;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.typesafe.ai/confidence&quot;&gt;TypeSafe describes confidence&lt;/a&gt; as a measure derived from the distribution of its answers. A concentrated distribution means a more certain decision. It is not a guarantee that the decision is correct.&lt;/p&gt;
&lt;p&gt;In the Gateway response, score and probabilities arrive in the answer, while the separate confidence value is in &lt;code&gt;providerMetadata.typesafe.confidence&lt;/code&gt;, keyed by question ID. Missing confidence stops automatic selection.&lt;/p&gt;
&lt;p&gt;An initial live check compared a cache-invalidation example with an article about publishing webhooks and an unrelated example about sourdough. The companion article scored 2.96 with 96% confidence and 99% probability on the relevant levels. The bread example scored 0.24, with only 9% probability on those levels. It was rejected.&lt;/p&gt;
&lt;p&gt;The demo below uses those relevance values. Adjusting confidence is a local simulation that runs the same selection policy as the application. It does not call Jev.&lt;/p&gt;
&lt;p&gt;Try the unrelated example at 100% confidence. It remains unselected because confidence cannot compensate for low relevance. Then bring the related example below 80% confidence to see the other condition take effect.&lt;/p&gt;
&lt;h2&gt;Making the judgment reusable&lt;/h2&gt;
&lt;p&gt;Jev runs when an eligible new article is published. A production build also handles new MDX articles. Its decisions are stored, so opening the page does not ask the model to repeat the comparison.&lt;/p&gt;
&lt;p&gt;Candidates are evaluated in bounded groups, with each candidate appearing once. For the current archive, a new publication needs 4 requests. The model compares every eligible candidate; a keyword filter does not remove possible matches beforehand.&lt;/p&gt;
&lt;p&gt;Repeated publication events reuse the existing job. Manual selections take priority over the stored automatic result. The next demo illustrates those rules locally, including what happens when a publication event arrives again.&lt;/p&gt;
&lt;p&gt;I kept this limited to new articles. Older articles remain available as candidates, but enabling the feature does not rewrite their related sections. That gives the model a useful archive to work with while leaving existing editorial choices intact.&lt;/p&gt;
&lt;h2&gt;Where I still want control&lt;/h2&gt;
&lt;p&gt;The evidence sent to Jev is bounded. A connection explained only late in a long article can be missed. The rubric can also produce a defensible suggestion that does not match the continuation I intended for the reader. I can edit, reorder, or remove the saved links.&lt;/p&gt;
&lt;p&gt;The Gateway key has a $1 monthly quota, and the implementation makes no automatic retries after an inference failure. There is no daily request cap. Each request still has bounded evidence and a timeout, and repeated publication events reuse the saved job. A failed or uncertain selection can wait for editorial review.&lt;/p&gt;
&lt;p&gt;The live example verifies that the response fields and threshold rule work together. It does not measure accuracy across all my writing. That will require reviewing actual suggestions over time, especially borderline pairs where the subjects overlap but the reader&apos;s intent differs.&lt;/p&gt;
&lt;p&gt;For now, Jev has a small, inspectable job: compare the articles, report its uncertainty, and let the application select only the results that pass the rule.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;&lt;p&gt;The initial evaluation of eight candidates produced no qualifying links under the original, narrower rubric. The current rubric also accepts meaningful shared interests and approaches, while retaining the 80% confidence requirement. Existing saved results are not automatically rerun.&lt;/p&gt;&lt;p&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Physics of Fluid Cursors</title>
<link>https://www.omrajguru.com/devnotes/cursor</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/cursor</guid>
<pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>A technical look at implementing macOS-style cursors on the web, including high-DPI cursor assets, frame-independent smoothing, velocity-based tilt, text selection behaviour, and accessibility.</description>
<content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 5 October 2026:&lt;/strong&gt; I have discontinued the custom cursor. The follower, the click ripple, and the macOS cursor images are gone from the site, which now uses your system&apos;s own cursor everywhere. I am leaving this note up as a record of how the cursor worked and what building it taught me.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I have been working on a custom cursor system for my website, largely because the cursor occupies an unusual position in an interface. It is technically outside the page, yet it is also the part of the interface that follows a person continuously. Small changes to its movement therefore remain perceptible even when nobody is consciously paying attention to them.&lt;/p&gt;
&lt;p&gt;I was particularly interested in the cursor movement used in macOS product demonstrations and applications such as Screen Studio. The pointer does not simply reproduce the mouse coordinates on every frame. Its movement is slightly softened, horizontal motion influences its rotation, and pressing produces a small change in scale. Text is treated differently again, since an I-beam needs to remain upright and stable against the typography around it.&lt;/p&gt;
&lt;p&gt;I wanted to reproduce those characteristics on my website without allowing the cursor to become an attraction of its own. It still needed to behave like a cursor first. The additional motion should make interaction feel more considered without making ordinary pointing, clicking, or selecting text less precise.&lt;/p&gt;
&lt;p&gt;Doing that involved considerably more than replacing the CSS cursor image. The implementation eventually included extracting usable frames from macOS .cur files, handling browser size restrictions, writing frame-rate-independent movement, calculating rotation from velocity, preserving text-selection state, and providing sensible fallbacks for devices where the effect should not run.&lt;/p&gt;
&lt;h2&gt;Sourcing the Cursor Geometry&lt;/h2&gt;
&lt;p&gt;Before working on movement, I needed cursor artwork that already had the proportions I wanted. Recreating the macOS pointer approximately would have introduced small differences in its angle, outline, hotspot, and dimensions, all of which become fairly noticeable when the cursor is visible throughout an entire interface.&lt;/p&gt;
&lt;p&gt;I used the Sierra and newer macOS cursor set from &lt;a href=&quot;https://github.com/antiden/macOS-cursors-for-Windows&quot;&gt;antiden/macOS-cursors-for-Windows&lt;/a&gt;. The repository contains cursor assets covering macOS Sierra through Sonoma, including the standard arrow, link pointer, text I-beam, grab cursors, unavailable state, and resize indicators.&lt;/p&gt;
&lt;p&gt;The original files were useful as a source, although using the .cur files directly exposed a browser limitation that was not immediately apparent.&lt;/p&gt;
&lt;h2&gt;The 128-Pixel Browser Limit and High-DPI Cursors&lt;/h2&gt;
&lt;p&gt;My first implementation used the cursor file directly through CSS with &lt;code&gt;cursor: url(&apos;/cursors/Normal.cur&apos;), auto;&lt;/code&gt;. The declaration was valid, but the browser ignored the custom cursor and displayed the normal system pointer instead. There was no corresponding console error, which made the failure look more like an incorrect path or unsupported file than a dimension problem.&lt;/p&gt;
&lt;p&gt;I inspected the directory entries inside the .cur file to see which image frames it contained:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fs = require(&apos;fs&apos;);
const buffer = fs.readFileSync(&apos;public/cursors/Normal.cur&apos;);
const count = buffer.readUInt16LE(4);

for (let i = 0; i &amp;#x3C; count; i++) {
  const offset = 6 + i * 16;
  const width = buffer[offset] === 0 ? 256 : buffer[offset];
  const height = buffer[offset + 1] === 0 ? 256 : buffer[offset + 1];
  console.log(`Frame ${i}: ${width}x${height}`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file contained several resolutions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Frame 0: 256x256
Frame 1: 128x128
Frame 2: 96x96
Frame 3: 64x64
Frame 4: 48x48
Frame 5: 32x32
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Desktop browsers place limits on the dimensions of custom CSS cursors. Chromium, WebKit, and Gecko restrict unusually large cursor images, with 128 by 128 pixels generally acting as the upper boundary and considerably smaller assets being preferable for ordinary pointers.&lt;/p&gt;
&lt;p&gt;That mattered because the first entry in &lt;code&gt;Normal.cur&lt;/code&gt; was the 256 by 256 master image. Chromium encountered that oversized frame and rejected the resource rather than simply choosing one of the smaller frames contained later in the file.&lt;/p&gt;
&lt;h2&gt;Extracting 32px and 64px Assets&lt;/h2&gt;
&lt;p&gt;Instead of depending on the browser to interpret the multi-resolution cursor file, I extracted the resolutions that the website actually needed. Each cursor received a 32 by 32 standard image and a 64 by 64 version for high-density displays, together with rebuilt 32 by 32 .cur fallbacks.&lt;/p&gt;
&lt;p&gt;The resulting assets were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;normal.png&lt;/code&gt; (32x32) and &lt;code&gt;normal@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;link.png&lt;/code&gt; (32x32) and &lt;code&gt;link@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;text.png&lt;/code&gt; (32x32) and &lt;code&gt;text@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pan.png&lt;/code&gt; (32x32) and &lt;code&gt;pan@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;closehand.png&lt;/code&gt; (32x32) and &lt;code&gt;closehand@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;unavailable.png&lt;/code&gt; (32x32) and &lt;code&gt;unavailable@2x.png&lt;/code&gt; (64x64)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fallback stylesheet uses &lt;code&gt;-webkit-image-set&lt;/code&gt; to provide the appropriate bitmap density where supported. A Retina display can therefore use the 2x asset while the standard image remains available at 1x.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media (pointer: fine) {
  :root,
  body {
    cursor: -webkit-image-set(
      url(&quot;/cursors/normal.png&quot;) 1x,
      url(&quot;/cursors/normal@2x.png&quot;) 2x
    ) 0 0, url(&quot;/cursors/normal.png&quot;) 0 0, auto;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point the static cursor assets behaved reliably. I could then treat movement as a separate concern rather than trying to solve rendering and animation at the same time.&lt;/p&gt;
&lt;h2&gt;Building the Follower with Delta-Time Smoothing&lt;/h2&gt;
&lt;p&gt;A CSS cursor follows the operating system&apos;s pointer position directly. That is appropriate for normal interaction, but it does not provide the slight inertia I was trying to reproduce. For that, I added a visual cursor layer whose position is updated with &lt;code&gt;requestAnimationFrame&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The important distinction is that the hardware pointer remains the source of truth. Pointer events establish the target coordinates, while the rendered follower approaches those coordinates over time. This allows the visual cursor to retain a small amount of movement after the physical mouse has changed direction or stopped.&lt;/p&gt;
&lt;h2&gt;Keeping the Motion Independent of Refresh Rate&lt;/h2&gt;
&lt;p&gt;A straightforward way to smooth a follower is to move it by a fixed fraction of the remaining distance on every rendered frame:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Flawed: frame-rate dependent
pos.x += (target.x - pos.x) * 0.15;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem is that a frame is not a stable unit of time. A 60Hz display evaluates that movement approximately 60 times each second, while a 120Hz display may evaluate it twice as often. The same interpolation constant can therefore produce noticeably different movement depending on the refresh rate of the display.&lt;/p&gt;
&lt;p&gt;I instead calculate the elapsed time between frames and derive the interpolation amount using exponential decay:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const posLerp = 1 - Math.exp(-24 * dt);
currentPos.current.x += (targetPos.current.x - currentPos.current.x) * posLerp;
currentPos.current.y += (targetPos.current.y - currentPos.current.y) * posLerp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, 24 controls the smoothing frequency in inverse seconds, while &lt;code&gt;dt&lt;/code&gt; represents the time elapsed since the previous rendered frame. I also clamp &lt;code&gt;dt&lt;/code&gt; to 50 milliseconds so returning to a tab after an interruption does not cause the cursor to make an unusually large correction in a single frame.&lt;/p&gt;
&lt;p&gt;This makes the timing of the movement substantially more consistent across displays. The cursor still trails the hardware position slightly, but that distance remains small enough that interaction does not feel detached from the mouse itself.&lt;/p&gt;
&lt;h2&gt;Deriving Tilt from Horizontal Velocity&lt;/h2&gt;
&lt;p&gt;Position smoothing provided the basic movement, but the pointer still looked somewhat rigid while changing direction. I therefore allowed the arrow cursor to rotate slightly according to its horizontal velocity.&lt;/p&gt;
&lt;p&gt;The calculation is deliberately restrained:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const targetTilt = cursorType === &quot;text&quot; ? 0 : Math.max(-6.5, Math.min(6.5, velocity.current.x * 2.8));
const tiltLerp = 1 - Math.exp(-14 * dt);
rotation.current += (targetTilt - rotation.current) * tiltLerp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Horizontal velocity is multiplied by a gain of 2.8, after which the result is constrained between -6.5 and +6.5 degrees. Rotation then approaches that target using its own exponential smoothing value rather than immediately matching it.&lt;/p&gt;
&lt;p&gt;The limits matter more than the effect itself. A pointer can tolerate a small amount of rotation because its silhouette already implies direction, but too much movement begins to alter the apparent position of its tip. Keeping the range narrow preserves the usefulness of the pointer while giving directional movement a little more visual continuity.&lt;/p&gt;
&lt;h2&gt;Press Feedback Through Scale&lt;/h2&gt;
&lt;p&gt;Clicks use the same principle of adding movement without changing the meaning of the cursor. While the pointer is pressed, its scale is reduced slightly and then restored after release.&lt;/p&gt;
&lt;p&gt;The scale values are intentionally close to one another:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Resting state: 1.0&lt;/li&gt;
&lt;li&gt;Interactive link state: 1.04&lt;/li&gt;
&lt;li&gt;Active press: 0.90&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const targetScale = isPressed.current ? 0.90 : cursorType === &quot;pointer&quot; ? 1.04 : 1.0;
const scaleLerp = 1 - Math.exp(-26 * dt);
scale.current += (targetScale - scale.current) * scaleLerp;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The change is enough to make a press visible without making the cursor appear to jump between unrelated sizes. Because the transition is interpolated rather than switched immediately, releasing the mouse restores the pointer with the same movement characteristics as the rest of the system.&lt;/p&gt;
&lt;h2&gt;Treating the Text I-Beam Separately&lt;/h2&gt;
&lt;p&gt;Text exposed a different set of problems. The arrow cursor could tolerate smoothing and rotation, but an I-beam sits directly against lines, glyphs, and selection boundaries. Even a small angle becomes apparent when the cursor is surrounded by otherwise upright typography.&lt;/p&gt;
&lt;p&gt;There was also a detection problem. My initial cursor logic did not recognise every piece of article typography as selectable text, so the arrow could remain visible over paragraphs where the expected cursor was an I-beam.&lt;/p&gt;
&lt;p&gt;I ended up handling text through three related decisions.&lt;/p&gt;
&lt;h3&gt;1. Detecting Text Across the Article&lt;/h3&gt;
&lt;p&gt;Text detection is not limited to form fields. The cursor checks the target and its ancestors for the semantic elements that make up article content:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const isText = target.closest(
  &quot;p, h1, h2, h3, h4, h5, h6, article, blockquote, li, pre, code, kbd, samp, var, mark, strong, em, b, i, u, s, small, sub, sup, dt, dd, th, td, figcaption, time, .prose&quot;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Interactive elements are checked before this rule. That ordering is important because a link may sit inside a paragraph while still requiring the pointer cursor rather than the surrounding text cursor. Buttons and elements using &lt;code&gt;role=&quot;button&quot;&lt;/code&gt; receive the same precedence.&lt;/p&gt;
&lt;p&gt;The cursor type is therefore determined by what the element does, not merely by the type of content surrounding it.&lt;/p&gt;
&lt;h3&gt;2. Locking the I-Beam to Zero Degrees&lt;/h3&gt;
&lt;p&gt;The velocity-based rotation used for the arrow does not apply to text. Whenever the detected cursor type is text, its target rotation is explicitly set to zero:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const targetTilt = cursorType === &quot;text&quot; ? 0 : Math.max(-6.5, Math.min(6.5, velocity.current.x * 2.8));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps the I-beam vertical regardless of how quickly the mouse is moving horizontally. It is a small exception in the animation system, but one that makes the cursor considerably easier to read against text.&lt;/p&gt;
&lt;h3&gt;3. Preserving the I-Beam During Selection&lt;/h3&gt;
&lt;p&gt;Selecting text introduces another edge case because the pointer does not necessarily remain above the text being selected. A person may begin dragging inside a paragraph and continue into the margin or another piece of empty space.&lt;/p&gt;
&lt;p&gt;If cursor detection were performed independently at every position, leaving the paragraph would immediately change the I-beam back into an arrow even though a text selection was still in progress. The cursor would then alternate between states according to whatever happened to sit beneath it.&lt;/p&gt;
&lt;p&gt;I keep a small piece of selection state for the duration of the pointer interaction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const onPointerDown = (e: PointerEvent) =&gt; {
  isPressed.current = true;
  const detected = detectCursorType(e.target);
  if (detected === &quot;text&quot;) {
    isSelectingText.current = true;
  }
  setCursorType(detected);
};

const onPointerUp = (e: PointerEvent) =&gt; {
  isPressed.current = false;
  isSelectingText.current = false;
  setCursorType(detectCursorType(e.target));
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a pointer-down event begins on text, &lt;code&gt;isSelectingText&lt;/code&gt; remains active until the corresponding release. The visual cursor can therefore remain an I-beam while the pointer temporarily passes outside the text element during a drag.&lt;/p&gt;
&lt;h3&gt;Interactive Cursor Lab&lt;/h3&gt;
&lt;p&gt;I also made a small sandbox for testing the cursor independently of the rest of the article. It exposes the individual cursor presets, hotspot behaviour, smoothing, and tilt so the values can be examined without repeatedly changing the implementation itself.&lt;/p&gt;
&lt;h2&gt;Adding Click Ripples&lt;/h2&gt;
&lt;p&gt;The final movement detail was a small ripple at the location of a pointer press. Its purpose is simply to make the exact click position visible for a moment, particularly when the cursor itself is moving or changing scale.&lt;/p&gt;
&lt;p&gt;The ripple is implemented as a lightweight DOM element created at the pointer coordinates on pointerdown. CSS handles its expansion and disappearance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@keyframes mac-cursor-pulse {
  0% {
    transform: scale(0.2);
    opacity: 0.7;
  }
  100% {
    transform: scale(1.6);
    opacity: 0;
  }
}

.mac-cursor-ripple {
  position: fixed;
  top: 0;
  left: 0;
  width: 36px;
  height: 36px;
  margin-top: -18px;
  margin-left: -18px;
  border-radius: 50%;
  border: 1.5px solid rgba(255, 255, 255, 0.45);
  background: radial-gradient(circle, rgba(255, 255, 255, 0.22) 0%, transparent 70%);
  pointer-events: none;
  animation: mac-cursor-pulse 380ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
  z-index: 999998;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both the ripple and the cursor follower use &lt;code&gt;pointer-events: none&lt;/code&gt;. They remain visual layers only, so neither can intercept clicks, interfere with text selection, or alter native drag-and-drop behaviour.&lt;/p&gt;
&lt;h2&gt;Accessibility and Device Boundaries&lt;/h2&gt;
&lt;p&gt;A custom cursor is useful only on devices where a cursor actually makes sense. There is little reason to initialise an animated pointer layer on a touchscreen, and motion preferences should take precedence over the visual effect.&lt;/p&gt;
&lt;p&gt;The implementation therefore checks &lt;code&gt;window.matchMedia(&quot;(pointer: fine)&quot;)&lt;/code&gt; before mounting the follower. Phones, tablets, and other devices without a fine pointing device retain their normal touch behaviour without carrying cursor-specific logic that they cannot use.&lt;/p&gt;
&lt;p&gt;It also checks &lt;code&gt;window.matchMedia(&quot;(prefers-reduced-motion: reduce)&quot;)&lt;/code&gt;. When reduced motion is enabled at the operating-system level, the animated follower is disabled and the website falls back to the static high-resolution cursor assets.&lt;/p&gt;
&lt;p&gt;These are not secondary additions to the effect. They define where the effect is appropriate in the first place. A cursor system designed for precise mouse input should not assume that every visitor has that form of input or wants the additional movement.&lt;/p&gt;
&lt;h2&gt;What the Cursor Actually Changes&lt;/h2&gt;
&lt;p&gt;None of this changes what the website allows someone to do. Articles remain readable with an ordinary system cursor, links behave the same way, text can still be selected, and the underlying interface does not depend on the follower.&lt;/p&gt;
&lt;p&gt;The difference is mostly in how those interactions are expressed. Position smoothing makes movement less abrupt, velocity gives the arrow a restrained directional response, press scaling acknowledges clicks, and the text-specific rules prevent those same effects from interfering with typography.&lt;/p&gt;
&lt;p&gt;That distinction became the useful part of working on this. Small interface details do not necessarily need to add functionality to justify their existence. Sometimes their purpose is to make existing behaviour more coherent, provided they remain subordinate to the interaction they are meant to support.&lt;/p&gt;
&lt;p&gt;The cursor is particularly suited to that kind of work because it accompanies almost every desktop interaction without needing additional space in the interface. Once its assets, motion, state detection, and accessibility boundaries are treated as parts of the same system, it can carry a little more character without asking the reader to think about the cursor at all.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Pages: A Smaller Way to Publish</title>
<link>https://www.omrajguru.com/devnotes/pages</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/pages</guid>
<pubDate>Thu, 17 Sep 2026 05:39:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>I wrote about Pages, a small publishing system on Sanity that turns documents into standalone pages with a four-character URL.</description>
<content:encoded>&lt;p&gt;I have been working on Pages, a small publishing system for turning documents into standalone pages that live outside my main website. It came from wanting somewhere to put a document, a short note, or a project explanation, apart from either scaffolding a new route on my main site or leaning on a third-party workspace tool I did not fully control.&lt;/p&gt;&lt;p&gt;I wrote a case study on how it works: the editor, the four-character URLs, and the architecture behind keeping Sanity as the single source of truth while the public application handles presentation on its own.&lt;/p&gt;&lt;p&gt;You can read the full piece here: &lt;a href=&quot;https://pages.omrajguru.com/WhtS&quot;&gt;Pages, Minus the Page Builder&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>The Craft of Small Details</title>
<link>https://www.omrajguru.com/devnotes/polish</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/polish</guid>
<pubDate>Wed, 16 Sep 2026 00:00:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>A deep dive into the quiet design engineering details that make a personal website feel grounded, from history state pagination and server component boundaries to stroke weights, card geometry, and render-safe audio.</description>
<content:encoded>&lt;p&gt;When a personal website has been running for a while, you naturally grow blind to its rough edges. You know which areas are clickable, you understand how each section transitions, and you excuse minor layout shifts or awkward hitboxes because you built them. Recently, after spending more time studying user experience and design engineering, I decided to walk through my entire site with fresh eyes. Software communicates its care through subtle qualities that most people never consciously dissect. If an icon feels too faint on a mobile screen, if an archive card is cluttered with redundant labels, or if navigating backward resets your position, the interface feels slightly ungrounded. I wanted to systematically audit these quiet flaws and rebuild them properly.&lt;/p&gt;
&lt;h2&gt;The Skills and Standards Behind the Audit&lt;/h2&gt;
&lt;p&gt;To guide this audit systematically, I relied on structured skills rather than casual intuition. I deployed my own HIG compliance auditor skill, which I created to benchmark web interfaces against Apple Human Interface Guidelines. My skill focuses specifically on touch target ergonomics, accessibility, and translating platform-specific paradigms to the open web. Having a formalized set of checks made it possible to spot physical boundary violations that standard visual inspection misses. You can install and run this auditor directly into any project:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx skills add omrajguru05/skills --skill hig-compliance-auditor
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alongside my HIG auditor, I incorporated &lt;a href=&quot;https://github.com/emilkowalski/skills&quot;&gt;Emil Kowalski&lt;/a&gt;&apos;s design engineering skills. His framework emphasizes that unseen details compound into an aggregate feeling of quality. From direct pointer-down feedback and active press scaling to optical stroke compensation across dark glass surfaces, these skills provided the motion and styling vocabulary needed to solve each issue cleanly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx skills@latest add emilkowalski/skills
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combining my HIG compliance auditor with these design engineering principles transformed subjective aesthetic impressions into concrete, testable engineering tasks.&lt;/p&gt;
&lt;p&gt;The interactive labs and code examples below demonstrate each problem I uncovered, the code that resolved it, and the reasoning behind each decision.&lt;/p&gt;
&lt;h2&gt;Touch Targets and Optical Weight&lt;/h2&gt;
&lt;p&gt;A common trap in modern web design is prioritizing how an interface looks on a desktop monitor while ignoring how it behaves under a thumb on glass. My top navigation bar included search triggers that looked neat visually, but their hitboxes were tiny. On mobile devices, tap targets measured barely thirty-two pixels. Users tapping quickly on a phone frequently missed the hit box, resulting in unresponsive taps and subtle frustration.&lt;/p&gt;
&lt;p&gt;According to Apple Human Interface Guidelines and the rules codified in my HIG compliance auditor skill, the minimum reliable touch target for handheld devices is forty-four by forty-four pixels. Even when an icon is optically small, its invisible interactive boundary must expand to meet this forty-four pixel threshold. Furthermore, the icon stroke weight in my dark glassmorphic navigation bar was only one point five pixels. Against a translucent dark backdrop with blur effects, that thin line lost optical contrast and appeared washed out.&lt;/p&gt;
&lt;p&gt;I upgraded the icon to a rounded Apple Finder stroke icon, boosted its stroke weight to two point two pixels for crisp contrast on OLED displays, wrapped it in a forty-four pixel transparent touch boundary, and added physical press feedback using active scale ninety-five.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;button
  type=&quot;button&quot;
  onClick={onOpenSearch}
  aria-label=&quot;Search articles and notes&quot;
  className=&quot;flex items-center justify-center text-foreground/80 hover:text-foreground hover:bg-muted/50 active:bg-muted/70 active:scale-95 transition-all touch-target-44 rounded-full&quot;
&gt;
  &amp;#x3C;AppleFinder
    size={16}
    strokeWidth={2.2}
    className=&quot;transition-transform duration-100&quot;
  /&gt;
&amp;#x3C;/button&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the interactive lab below, you can tap both buttons side by side to compare the raw thirty-two pixel hitbox against the engineered forty-four pixel target with optical stroke tuning.&lt;/p&gt;
&lt;h2&gt;Card Geometry and Cognitive Noise&lt;/h2&gt;
&lt;p&gt;Consistency in visual geometry is what separates an intentional design system from an ad-hoc collection of assembled cards. While reviewing the archive feeds across my writings and dev notes pages, I noticed an incongruity in how metadata badges were placed. The cards previously used a floating rounded pill badge for the publication date. Because rounded pills have curved semicircular caps on both sides, the text inside floats without a hard geometric anchor. When stacked vertically above the card headline, the pill caused visual wobble against the clean left edge of the title and summary paragraph.&lt;/p&gt;
&lt;p&gt;To solve this, I replaced the rounded pill with an architectural four-pixel rectangular date badge. The subtle four-pixel corners lock the date flush into the vertical typography grid alongside the reading duration indicator.&lt;/p&gt;
&lt;p&gt;Alongside locking the date geometry to the margin, I audited the interactive prompts on each card. Every archive entry previously ended with an explicit text link that said Read article with an arrow pointing right. While this seems harmless at first glance, it is completely redundant. An archive card that already features an index tag, a reading duration, a bold title, and a descriptive excerpt is unmistakably an article waiting to be read.&lt;/p&gt;
&lt;p&gt;Adding a repetitive action label to every card increases cognitive noise and forces the reader to filter out visual clutter. Removing the label clarifies the hierarchy, giving the card room to breathe and letting the typography speak for itself. In the interactive preview below, toggle between Before and After to watch the date lock into the blue vertical grid line, and see the redundant action prompt dissolve through a particle vanish effect.&lt;/p&gt;
&lt;h2&gt;Homepage Architecture and the Docked Notch Tag&lt;/h2&gt;
&lt;p&gt;While archive feeds benefit from flush vertical margin alignment, the homepage grid required a different architectural solution. On the homepage, featured cards for Dev Notes, Poetry, and Quick Ships previously housed their date and category labels inside the card body as an internal skeleton bar above the title. This pushed the headline downward and created an awkward visual block inside the card canvas.&lt;/p&gt;
&lt;p&gt;To establish a stronger physical hierarchy, I elevated the date completely out of the card interior into a docked notch tag. Centered directly across the top border line of the card, the notch tag acts as an external index marker. It uses gentle six-pixel rounded corners, a solid card-matching background to mask the underlying border line without subpixel bleed, and category-coded accent strokes.&lt;/p&gt;
&lt;p&gt;The category color system uses distinct hues across the site: vibrant blue for Dev Notes and recent writing, regal purple for Poetry, and warm orange for Quick Ships. On hover, the docked notch tag illuminates with an accent glow while the card background lifts cleanly into focus.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Docked notch tag centered directly on the top card border
&amp;#x3C;div className=&quot;group relative flex flex-col h-full pt-3&quot;&gt;
  &amp;#x3C;div className=&quot;absolute top-0 left-5 sm:left-6 z-10 flex items-center&quot;&gt;
    &amp;#x3C;div className=&quot;inline-flex items-center px-2.5 py-0.5 font-mono text-xs font-medium rounded-md border border-accent-blue/40 bg-bg-2 text-accent-blue group-hover:bg-bg-3 group-hover:border-accent-blue/70&quot;&gt;
      {date}
    &amp;#x3C;/div&gt;
  &amp;#x3C;/div&gt;
  &amp;#x3C;div className=&quot;relative flex flex-col flex-1 rounded-xl border border-border-1 bg-bg-2 p-6 pt-7 group-hover:bg-bg-3&quot;&gt;
    {children}
  &amp;#x3C;/div&gt;
&amp;#x3C;/div&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the interactive lab below, test the homepage card across all three category colorways. Compare the internal skeleton bar in Before against the docked notch tag and illumination in After.&lt;/p&gt;
&lt;h2&gt;Preserving Pagination State with Clean URLs&lt;/h2&gt;
&lt;p&gt;One of the most frustrating user experience bugs on content-heavy websites happens during archive browsing. Suppose you are looking through writings, and you paginate to page seven. You find an interesting article, click to read it, and then click the browser back button or the breadcrumb link to return to the list. On many websites, doing this unceremoniously dumps you back onto page one. You lose your browsing position entirely and must click next six times to resume where you left off.&lt;/p&gt;
&lt;p&gt;The naive solution to this problem is appending a query parameter like question mark page equals seven to the URL. While functional, that clutters the address bar with temporary state that does not belong there. A visitor sharing a link to my writings index should share a clean address, not an arbitrary pagination offset from their session.&lt;/p&gt;
&lt;p&gt;To solve this cleanly, I turned to the browser history state combined with a session storage fallback. When the user paginates, the application records the current page into window history state using replaceState. Because this state is attached directly to the history entry, the browser address bar remains completely clean without any query strings.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;// Updating history state without polluting the address bar
const updatePaginationState = (page: number) =&gt; {
  if (typeof window === &quot;undefined&quot;) return;
  
  sessionStorage.setItem(&quot;writings_page&quot;, String(page));
  
  const currentState = window.history.state || {};
  window.history.replaceState(
    { ...currentState, writingsPage: page },
    &quot;&quot;,
    window.location.pathname
  );
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the reader clicks into an article and subsequently navigates backward, the archive component reads the saved page index from the history state and immediately renders page seven. Furthermore, when a user clicks the writings link in the top navigation bar, the application treats that as an intentional fresh visit, clears the session storage, and resets the view to page one.&lt;/p&gt;
&lt;p&gt;Try the simulated archive below. Paginate to page seven, open an article, and use either the back link or simulated browser back button to see your position preserved.&lt;/p&gt;
&lt;h2&gt;Server Component Boundaries and Event Handlers&lt;/h2&gt;
&lt;p&gt;Modern React and Next.js applications draw a sharp architectural boundary between Server Components and Client Components. Server Components execute exclusively on the server, generating static HTML and streaming UI without shipping unnecessary JavaScript to the client. Client Components handle interactivity, event listeners, and browser APIs.&lt;/p&gt;
&lt;p&gt;When building the breadcrumb navigation for article headers, I wanted the back link to be smart. If the visitor arrived from an archive page, clicking back should trigger browser history navigation to preserve their scroll and pagination position. If they arrived directly from an external link or search engine, it should fall back to standard anchor navigation to the main archive.&lt;/p&gt;
&lt;p&gt;In my initial implementation, I passed an onClick event handler into a link inside the article page shell. Because the shell was a Server Component, React immediately threw a runtime serialization error. Props passed across the server and client boundary must be serializable JSON values. Functions and event listeners cannot cross that boundary.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&quot;use client&quot;;

import { useRouter } from &quot;next/navigation&quot;;
import Link from &quot;next/link&quot;;
import { ArrowLeft } from &quot;@/icons&quot;;

export function ArticleBackLink({ href, label }: { href: string; label: string }) {
  const router = useRouter();

  const handleClick = (e: React.MouseEvent&amp;#x3C;HTMLAnchorElement&gt;) =&gt; {
    if (typeof window !== &quot;undefined&quot; &amp;#x26;&amp;#x26; window.history.length &gt; 1) {
      e.preventDefault();
      router.back();
    }
  };

  return (
    &amp;#x3C;Link
      href={href}
      onClick={handleClick}
      className=&quot;inline-flex items-center gap-2 hover:text-text-1 transition-colors&quot;
    &gt;
      &amp;#x3C;ArrowLeft className=&quot;h-4 w-4&quot; /&gt;
      &amp;#x3C;span&gt;{label}&amp;#x3C;/span&gt;
    &amp;#x3C;/Link&gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The proper solution was isolating the interactive behavior into a dedicated client component called ArticleBackLink. The Server Component remains entirely pure, rendering structural HTML, while the isolated client component encapsulates the interactive history check. This keeps client bundle sizes minimal while maintaining crisp architectural separation.&lt;/p&gt;
&lt;h2&gt;Render-Safe Audio Synchronization&lt;/h2&gt;
&lt;p&gt;The media player on my site allows listeners to stream spoken audio recordings of long articles. While auditing component performance, I discovered an anti-pattern in how the audio player tracked time elapsed and track duration.&lt;/p&gt;
&lt;p&gt;The component was reading mutable DOM properties from the audio element reference directly during the React render phase. Reading current time and duration from a mutable ref during render violates React purity. Because ref mutations do not schedule re-renders, the player used an awkward mounting flag to force updates, leading to rendering inconsistencies and potential layout thrashing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Event-driven, pure React state synchronization
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);

const handleTimeUpdate = (e: React.SyntheticEvent&amp;#x3C;HTMLAudioElement&gt;) =&gt; {
  setCurrentTime(e.currentTarget.currentTime);
};

const handleLoadedMetadata = (e: React.SyntheticEvent&amp;#x3C;HTMLAudioElement&gt;) =&gt; {
  setDuration(e.currentTarget.duration);
};

const handleEnded = () =&gt; {
  setIsPlaying(false);
  setCurrentTime(0);
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I refactored the player to rely entirely on native HTML audio event handlers. By binding onLoadedMetadata, onTimeUpdate, and onEnded directly to React state setters, time and duration updates flow through predictable React render cycles. The component becomes deterministic, eliminates side effects during render, and synchronizes the playback bar cleanly at sixty frames per second.&lt;/p&gt;
&lt;h2&gt;Why Small Details Matter&lt;/h2&gt;
&lt;p&gt;When you step back from these changes individually, none of them sound radical in isolation. A two-pixel wider touch target, an eight-line history state handler, a four-pixel badge radius, and an event-driven audio hook will not make headlines. Yet in software engineering, the sum of these quiet decisions is precisely what defines user trust.&lt;/p&gt;
&lt;p&gt;Great design engineering is not about flashy decoration. It is about removing friction that people feel even when they cannot name it. When an interface respects thumb ergonomics, maintains visual geometry, preserves your location across navigation, and responds instantly to your touch, it feels grounded and dependable. Building software with that level of care takes patience, but it is the only kind of craftsmanship worth pursuing.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Reprint: Building a Daily Newspaper for My Archive</title>
<link>https://www.omrajguru.com/devnotes/reprint</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/reprint</guid>
<pubDate>Mon, 14 Sep 2026 05:30:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>Why I turned my archive into a daily broadsheet called Reprint, how the edition pipeline and atomic archiving work, and why broadsheet layouts beat infinite feeds for reading old essays.</description>
<content:encoded>&lt;p&gt;Over the last few years, I have written dozens of long essays, short development notes, poetry, and quick product updates. Most personal websites organize this material into a reverse chronological list where the newest entry sits at the very top and everything else sinks toward oblivion. That design works fine for a news wire, but it does a disservice to evergreen writing. An article written two years ago about system design or software architecture is often just as useful today as something published yesterday morning, yet a typical blog feed buries it under endless pagination that nobody clicks.&lt;/p&gt;
&lt;p&gt;I wanted a different way to experience my own archive. Instead of asking readers to scroll through infinite lists or search through arbitrary category pages, I decided to treat my collection like a real daily broadsheet newspaper. Every morning, a new edition titled Reprint is assembled, printed, and published. It contains twelve stories, drawn from across my essays, dev notes, quick ships, research notes, and Substack dispatches. Once a day ends, that edition is frozen forever into an immutable archive.&lt;/p&gt;
&lt;p&gt;The visual design of Reprint came from a desire for contrast and restraint. The rest of my website uses a dark glassmorphic interface with deep charcoal backgrounds, neon emerald accents, and fluid backdrop filters. For Reprint, I stepped away from that language entirely and built a clean white editorial surface that feels like sitting down with a printed morning paper. It uses Playfair Display for dramatic editorial headlines, Georgia for readable body text, and Relative Mono for datelines, volume numbers, and section kickers.&lt;/p&gt;
&lt;p&gt;Building a physical newspaper layout on the web usually tempts developers to reach for heavy JavaScript grid libraries like Masonry or Packery. I avoided that complexity altogether. Modern CSS column layout handles multi-column broadsheets natively with column-count and column-gap rules. On desktop monitors, stories flow naturally across three columns with an editorial full-width lead story commanding the top. On tablets, the layout contracts gracefully to two columns, and on phones, it collapses into a single column. Reading order runs down each column cleanly without layout shifts or script execution overhead.&lt;/p&gt;
&lt;p&gt;Behind this clean editorial front page sits an automated publication engine that handles aggregation, sanitization, selection, archiving, and distribution. The first challenge was source aggregation. My writings live across multiple data stores: long-form essays are stored as local MDX files and Sanity documents, short updates live in Contentful and Sanity, and syndicated essays publish to Substack. Rather than having the newspaper trigger HTTP requests back to my own website feeds, the ingestion engine imports internal data loaders directly in memory. This eliminates unnecessary round trips and keeps page rendering nearly instantaneous.&lt;/p&gt;
&lt;p&gt;External feeds, such as the Substack Atom feed, pass through a strictly hardened XML parser. I configured fast-xml-parser with a six-second timeout, a one-megabyte response limit, and an HTTPS allowlist that prevents arbitrary URL requests. Entity expansions and XML doctype declarations are completely rejected to protect the server from parser exploits. If an external feed fails or responds slowly, the pipeline catches the error and drops that source cleanly without interrupting the rest of the edition.&lt;/p&gt;
&lt;p&gt;Incoming stories pass through a normalization pass that standardizes their attributes into a single data shape. This pass strips marketing tracking parameters such as UTM tags, Facebook click identifiers, and Google click parameters. It generates clean excerpt previews, validates publication dates against the edition cutoff, and deduplicates stories using both normalized canonical paths and title hashes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export function safeHref(value: string, origin: string) {
  try {
    const url = new URL(value, origin);
    if (!/^https?:$/.test(url.protocol) || url.username || url.password) return null;
    const isOurDomain = [&quot;omrajguru.com&quot;, &quot;www.omrajguru.com&quot;, &quot;localhost&quot;].includes(url.hostname);
    if (isOurDomain) {
      url.host = new URL(origin).host;
      url.protocol = new URL(origin).protocol;
    }
    url.hash = &quot;&quot;;
    for (const key of [...url.searchParams.keys()]) {
      if (/^(utm_|fbclid$|gclid$)/i.test(key)) url.searchParams.delete(key);
    }
    const originHost = new URL(origin).hostname;
    const isInternal = isOurDomain || (url.origin === new URL(origin).origin &amp;#x26;&amp;#x26; [&quot;omrajguru.com&quot;, &quot;www.omrajguru.com&quot;, &quot;localhost&quot;].includes(originHost));
    return isInternal ? url.pathname.replace(/\/$/, &quot;&quot;) + url.search : url.href;
  } catch { return null; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the candidate stories are gathered and cleaned, the selection algorithm builds the day&apos;s front page. A naive random picker would feel disjointed, while a pure recency sort would just recreate the exact blog feed I was trying to escape. The selection engine strikes a balance through three rules. First, any work published within the preceding seven days receives a recency score boost, guaranteeing that genuine fresh updates lead the edition. Second, older backlog items receive a deterministic daily score using a hash of the edition date and the story identifier, so the back catalogue rotates evenly every morning without shifting unexpectedly throughout the day. Third, stories are distributed across sections using round-robin balancing so essays, quick ships, and technical notes each get fair representation, capped at exactly twelve stories.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;export function selectStories(stories: Story[], date: string, limit = 12): Story[] {
  const seenUrls = new Set&amp;#x3C;string&gt;();
  const seenTitles = new Set&amp;#x3C;string&gt;();
  const end = Date.parse(`${date}T23:59:59+05:30`);
  const pool = stories.filter((story) =&gt; {
    const title = story.title.toLowerCase().replace(/[^\p{L}\p{N}]/gu, &quot;&quot;);
    if (story.date &amp;#x26;&amp;#x26; Date.parse(story.date) &gt; end) return false;
    if (seenUrls.has(story.href) || seenTitles.has(title)) return false;
    seenUrls.add(story.href); seenTitles.add(title);
    return true;
  });
  pool.sort((a, b) =&gt; {
    const score = (s: Story) =&gt; {
      const age = s.date ? (end - Date.parse(s.date)) / 86400000 : Infinity;
      return (age &amp;#x3C;= 7 ? 2 - age / 7 : 0) + hash(`${date}:${s.id}`) / 4294967296;
    };
    return score(b) - score(a) || a.id.localeCompare(b.id);
  });
  const bySection = new Map&amp;#x3C;string, Story[]&gt;();
  for (const story of pool) {
    if (!bySection.has(story.section)) bySection.set(story.section, []);
    bySection.get(story.section)!.push(story);
  }
  const picked: Story[] = [];
  const pickedIds = new Set&amp;#x3C;string&gt;();
  let addedInRound = true;
  while (picked.length &amp;#x3C; limit &amp;#x26;&amp;#x26; addedInRound) {
    addedInRound = false;
    for (const [, sectionStories] of bySection) {
      const next = sectionStories.find((s) =&gt; !pickedIds.has(s.id));
      if (next) {
        picked.push(next);
        pickedIds.add(next.id);
        addedInRound = true;
        if (picked.length &gt;= limit) break;
      }
    }
  }
  return picked;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The heart of the newspaper is its concept of persistence. A printed newspaper is permanent: once ink meets paper, the words on that morning broadsheet do not change when the journalist writes a correction next week. I wanted that exact guarantee for Reprint. When a day&apos;s edition is generated, the entire front page layout, including headlines, excerpts, ordering, and rendered HTML reading copies, is packaged into an atomic snapshot document and stored in Sanity. The document ID is deterministic, taking the form reprint-edition followed by the date in Indian Standard Time.&lt;/p&gt;
&lt;p&gt;By using Sanity createIfNotExists, the system handles concurrent first visits gracefully. If two readers open the newspaper at the exact same second after midnight, both attempt atomic creation, the database elects a single winner, and both readers view the identical frozen snapshot. Once stored, that edition is immutable. If I edit a typo in an essay three months later, the original essay updates, but the historical newspaper edition from September 2026 remains preserved exactly as it was printed. Furthermore, past editions are never fabricated from current feeds: if a date was never printed, the archive refuses to reconstruct it and informs the reader that the edition was not published.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-typescript&quot;&gt;const token = process.env.SANITY_REPRINT_WRITE_TOKEN;
if (token &amp;#x26;&amp;#x26; storageAvailable &amp;#x26;&amp;#x26; edition.stories.length) {
  try {
    const writer = reader.withConfig({ token, useCdn: false });
    await writer.createIfNotExists({
      _id: snapshotId(date),
      _type: &quot;newspaperSnapshot&quot;,
      date,
      payload: JSON.stringify({ ...edition, archived: true }),
    });
    const stored = await reader.fetch&amp;#x3C;{ payload: string }&gt;(
      `*[_id == $id][0]{payload}`,
      { id: snapshotId(date) },
      { cache: &quot;no-store&quot; }
    );
    return { ...JSON.parse(stored.payload), archived: true } as Edition;
  } catch {
    console.warn(&quot;[REPRINT] Could not preserve edition. Serving today&apos;s preview only.&quot;);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When someone wants to read a story, navigating away to a separate full-page URL would break the immersive feeling of holding a newspaper. Instead, clicking any story opens a native HTML dialog element as an editorial reading sheet. The URL updates using window history pushState with a story fragment, so refreshing the page or sharing the link reopens that exact reading modal. Closing the sheet restores focus to the broadsheet headline and preserves the user previous scroll position without jarring jumps.&lt;/p&gt;
&lt;p&gt;To keep the initial front page load lightweight, the newspaper loads only story metadata on initial render. The full sanitized reading HTML is either rendered on demand or retrieved from the cached snapshot. External stories from Substack or other publications provide only an excerpt and an outgoing link, preventing third-party scripts or tracking code from executing inside my site.&lt;/p&gt;
&lt;p&gt;Another feature I care deeply about is the PDF export. Many websites implement PDF generation by spinning up headless Chromium instances inside serverless functions. That approach introduces massive cold start delays, inflates cloud infrastructure bills, and regularly crashes due to memory limits. I took the opposite approach and solved it entirely through CSS print style rules.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media print {
  @page {
    size: A4 portrait;
    margin: 14mm 12mm 16mm 12mm;
  }
  body {
    background: #fff !important;
    color: #111 !important;
  }
  .reprint-toolbar,
  .reprint-editions,
  .reprint-skip,
  .reprint-read {
    display: none !important;
  }
  .reprint-stories {
    column-count: 2 !important;
    column-gap: 18mm !important;
  }
  .reprint-story {
    break-inside: avoid !important;
    page-break-inside: avoid !important;
    margin-bottom: 8mm !important;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a reader clicks Export PDF in the toolbar, the client waits for the web fonts to finish loading and then opens the standard browser print dialog. Because the page is styled with dedicated print rules, the browser immediately renders an A4 document with two clean columns, elegant serif headlines, restrained dividers, and running headers. The server executes zero additional compute, latency is virtually instantaneous, and the reader gets a tangible document they can save or send to an e-reader.&lt;/p&gt;
&lt;p&gt;The entire publication cycle is tied together with a daily Vercel cron job that executes at 18:35 UTC, corresponding to 00:05 in India. The endpoint validates a secure bearer token, initiates the day edition build, triggers the atomic Sanity snapshot, and dispatches an editorial morning dispatch email to my inbox through Mailgun. If the cron job ever fails or is delayed, the system falls back safely: the first reader who visits the site in the morning triggers the print run automatically.&lt;/p&gt;
&lt;p&gt;Building Reprint gave my writing collection a sense of rhythm and finish that standard blog feeds lack. It values old work alongside new experiments, imposes a healthy limit on daily reading, and delivers an experience that honors the tradition of editorial print while taking advantage of modern web standards.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>I Turned My Navbar Into a Dynamic Island</title>
<link>https://www.omrajguru.com/devnotes/building-my-dynamic-island</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/building-my-dynamic-island</guid>
<pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Building a Dynamic Island into my navbar with GPT 6 Astra in Codex. A complete journey through spring physics, glass masking, and state orchestration, with interactive demos and reference code.</description>
<content:encoded>&lt;p&gt;I wanted a small circle to separate from my navbar when someone reached the end of an article. Inside that circle, a green tick would appear. After a moment, the circle would return to the navbar.&lt;/p&gt;
&lt;p&gt;My navbar already expanded and contracted. It already had a mobile drawer, an audio player, and a reading-progress outline on writings and dev notes. I liked that design. I wanted to give it another useful behavior without losing what already worked.&lt;/p&gt;
&lt;p&gt;The finished version does more than acknowledge the end of an article. It can split out a copied-link confirmation or expand to show contact-form progress, success, failure, and code or RSS-copy feedback. It still feels like part of the same website.&lt;/p&gt;
&lt;p&gt;Getting there involved two reference repositories, several rounds of testing in the browser, a floating table of contents that I removed, and a surprisingly persistent semicircle. I built it iteratively with GPT 6 Astra in Codex: I brought the references, tried the implementation in the dev server, pointed out what felt wrong, and kept asking for the whole interaction to be checked again after each fix.&lt;/p&gt;
&lt;p&gt;This post is that journey, including the parts that did not survive.&lt;/p&gt;
&lt;p&gt;The examples below are real React animations. The split and expanded-feedback previews reuse components from the shipped navbar. Their controls run locally inside the article. The progress sliders and message outcomes are demonstrations: they do not submit a form, copy a link, or alter the real navbar&apos;s state. The real navbar still follows your actual position in this article.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;&lt;p&gt;Note: The interactive demos below use real SVG filters, spring physics, and dynamic backdrop blurring. These interactions are GPU-intensive and are optimized to perform best on Apple silicon and Safari, where hardware-accelerated compositing handles multiple glass surfaces effortlessly.&lt;/p&gt;&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;What I wanted to keep&lt;/h2&gt;
&lt;p&gt;Before adding anything, I had to be clear about what was already valuable.&lt;/p&gt;
&lt;p&gt;At the top of a page, my navbar gives its links more room. After scrolling, it becomes a smaller pill. Hovering on a laptop can reveal the expanded navigation again. On mobile, a menu opens beneath it. Audio playback can take over the available space without requiring another permanent player on the screen.&lt;/p&gt;
&lt;p&gt;On article pages, a gradient travels around the navbar as reading progresses. The outline is drawn as two SVG paths. Each starts at the top center, follows one side of the pill, and ends at the bottom center.&lt;/p&gt;
&lt;p&gt;These behaviors were already connected. Replacing the navbar wholesale would have meant rebuilding and retesting all of them. I decided to add a small number of states around the existing structure.&lt;/p&gt;
&lt;p&gt;My constraints were concrete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep the current expand/contract behavior and mobile drawer.&lt;/li&gt;
&lt;li&gt;Preserve the audio controls and their state.&lt;/li&gt;
&lt;li&gt;Keep Relative typography, the dark surfaces, and the site&apos;s existing spacing.&lt;/li&gt;
&lt;li&gt;Make the split work at narrow mobile widths as well as on a laptop.&lt;/li&gt;
&lt;li&gt;Keep navigation usable before, during, and after a confirmation.&lt;/li&gt;
&lt;li&gt;Let temporary feedback disappear without requiring a reload.&lt;/li&gt;
&lt;li&gt;Respect reduced-motion preferences.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also wanted the motion to come from a reference I could inspect. A screenshot can show a destination, but it cannot explain the spring, the delay before an icon appears, or the way a connecting shape disappears.&lt;/p&gt;
&lt;h2&gt;The two repositories I started from&lt;/h2&gt;
&lt;p&gt;I asked for both codebases to be cloned, rather than relying on a visual approximation from memory:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;gh repo clone amelie-schlueter/dynamic-island-web
gh repo clone jhaemin/dynamic-island
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They helped in different ways.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reference&lt;/th&gt;
&lt;th&gt;What I studied&lt;/th&gt;
&lt;th&gt;What carried into the build&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/amelie-schlueter/dynamic-island-web/tree/d71aaaaac35f2b76d9e5d6f1e315eca5e8466be3&quot;&gt;Amélie Schlüter&apos;s dynamic-island-web&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A compact React implementation with a layout-animated island and different content states&lt;/td&gt;
&lt;td&gt;The treatment of the island as a changing surface, plus an early top-origin layout morph during the contents-panel experiment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/jhaemin/dynamic-island/tree/4c7b2960653f01804033ae6e53c10671c7a13245&quot;&gt;Jhaemin&apos;s dynamic-island&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Split geometry, spring configurations, blur timing, and a larger expanded state&lt;/td&gt;
&lt;td&gt;The liquid separation, its 42px travel, and the motion values behind the expanded confirmation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Amélie&apos;s implementation uses Framer Motion. Jhaemin&apos;s uses &lt;code&gt;react-spring&lt;/code&gt;. My site already had &lt;code&gt;framer-motion&lt;/code&gt;, so I kept the existing dependency and translated the reference&apos;s spring configuration into that system.&lt;/p&gt;
&lt;p&gt;I did not bring over the phone-call content, caller photos, app icons, or demo fonts. Those belonged to the reference experience. My content was article completion, copying, and contact feedback.&lt;/p&gt;
&lt;p&gt;That distinction mattered throughout the build: I could preserve the motion&apos;s geometry and timing while making the resulting interface belong to my website.&lt;/p&gt;
&lt;p&gt;I also had to be honest about precision. Matching spring parameters across two animation engines is not a guarantee that every frame will be identical. I preserved the relevant source values, then checked the result in the browser.&lt;/p&gt;
&lt;h2&gt;The split is more than a circle moving sideways&lt;/h2&gt;
&lt;p&gt;The detail I wanted was the brief connection between the main pill and the smaller shape. It stretches, narrows, and breaks as the satellite moves away.&lt;/p&gt;
&lt;p&gt;Jhaemin&apos;s implementation builds that effect with a small SVG. Its original coordinate space is 90 by 52. An anchor circle sits near the main island, and a rounded rectangle moves horizontally to become the detached satellite.&lt;/p&gt;
&lt;p&gt;The reference values were small enough to understand directly:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SVG coordinate space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;90 × 52&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anchor center&lt;/td&gt;
&lt;td&gt;&lt;code&gt;26, 26&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anchor radius&lt;/td&gt;
&lt;td&gt;&lt;code&gt;15&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Satellite size&lt;/td&gt;
&lt;td&gt;&lt;code&gt;36 × 32&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Satellite corner radius&lt;/td&gt;
&lt;td&gt;&lt;code&gt;16&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Horizontal travel&lt;/td&gt;
&lt;td&gt;&lt;code&gt;42px&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blur at the joined state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delay before sharpening&lt;/td&gt;
&lt;td&gt;&lt;code&gt;250ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharpening duration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The satellite is slightly wider than it is tall. I kept that geometry instead of silently replacing it with a mathematically perfect circle.&lt;/p&gt;
&lt;p&gt;Here is the basic arrangement, before the filtering and glass-edge masking are added:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;svg width=&quot;90&quot; height=&quot;52&quot; viewBox=&quot;0 0 90 52&quot;&gt;
  &amp;#x3C;circle cx=&quot;26&quot; cy=&quot;26&quot; r=&quot;15&quot; fill=&quot;black&quot; /&gt;
  &amp;#x3C;motion.rect
    x=&quot;6&quot;
    y=&quot;10&quot;
    width=&quot;36&quot;
    height=&quot;32&quot;
    rx=&quot;16&quot;
    fill=&quot;black&quot;
    style={{ x: offset }}
  /&gt;
&amp;#x3C;/svg&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SVG is positioned partly over the edge of the navbar. Its internal &lt;code&gt;x&lt;/code&gt; coordinate is therefore not the same as the page&apos;s horizontal position. Understanding those two coordinate systems became essential when I fixed the overlap later.&lt;/p&gt;
&lt;h2&gt;How the liquid connection works&lt;/h2&gt;
&lt;p&gt;The two shapes are blurred together, then their alpha channel is pushed through a color matrix. Blurring creates a soft overlap. The matrix turns part of that overlap back into a firmer silhouette.&lt;/p&gt;
&lt;p&gt;The relevant filter looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;filter
  id={filterId}
  width=&quot;400%&quot;
  x=&quot;-150%&quot;
  height=&quot;400%&quot;
  y=&quot;-150%&quot;
&gt;
  &amp;#x3C;motion.feGaussianBlur
    in=&quot;SourceGraphic&quot;
    result=&quot;blur&quot;
    initial={{ stdDeviation: 10 }}
    animate={{ stdDeviation: open ? 0 : 10 }}
    transition={{
      duration: open ? 0.4 : 0.1,
      delay: open ? 0.25 : 0,
    }}
  /&gt;
  &amp;#x3C;feColorMatrix
    in=&quot;blur&quot;
    type=&quot;matrix&quot;
    values=&quot;1 0 0 0 0
            0 1 0 0 0
            0 0 1 0 0
            0 0 0 25 -10&quot;
  /&gt;
&amp;#x3C;/filter&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final row is the interesting one. In this matrix, the new alpha is calculated from &lt;code&gt;25 × alpha - 10&lt;/code&gt;, with the output constrained to its valid range. That steep change makes a blurred connection look much more defined. The first three rows leave the color channels unchanged. &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/feColorMatrix&quot;&gt;MDN documents how the color matrix transforms each channel&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I kept the filter region larger than the original shapes so the blur had room to extend. Later, I added a separate clip to control where the finished effect could actually paint. Those two operations serve different purposes: the filter needs space to calculate the shape; the interface needs boundaries around where that shape is visible.&lt;/p&gt;
&lt;h2&gt;Tuning the movement without inventing a new animation&lt;/h2&gt;
&lt;p&gt;I started with the reference&apos;s spring values instead of choosing an arbitrary duration.&lt;/p&gt;
&lt;p&gt;In the port, &lt;code&gt;react-spring&lt;/code&gt; tension became Motion stiffness, friction became damping, and mass stayed mass:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const splitSpring = {
  type: &quot;spring&quot; as const,
  stiffness: 250,
  damping: 26,
  mass: 2,
};

export const mergeSpring = {
  type: &quot;spring&quot; as const,
  stiffness: 300,
  damping: 26,
  mass: 0.1,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The outward split feels weighted. The return is much quicker. That difference is useful: the outward movement introduces something worth noticing, while the return clears space so I can continue reading.&lt;/p&gt;
&lt;p&gt;I find spring values easier to understand by changing one at a time. In the demo, stiffness controls how strongly the shape is pulled toward its destination. Damping removes oscillation. Mass changes the response of the moving shape.&lt;/p&gt;
&lt;p&gt;The initial values match the shipped split. The controls below are a local experiment; changing them does not retune the real navbar.&lt;/p&gt;
&lt;p&gt;I did not expose these settings on the actual website. Readers should get one considered animation. The sliders belong in an explanation like this one, where seeing the consequences is useful.&lt;/p&gt;
&lt;h2&gt;My first big mistake: the tick arrived before its circle&lt;/h2&gt;
&lt;p&gt;One early version looked acceptable when I inspected only its final state. The circle was in the right place. The tick was centered. The spacing was reasonable.&lt;/p&gt;
&lt;p&gt;Watching it move told a different story.&lt;/p&gt;
&lt;p&gt;The tick appeared at the destination while the background shape was still coming out of the navbar. For a moment, the icon looked detached from the thing that was supposed to contain it.&lt;/p&gt;
&lt;p&gt;I initially had separate decisions for the circle and the icon: one spring moved the SVG, while another set of animation properties revealed the tick. Matching delays made it less obvious, but did not give the two elements a single source of position.&lt;/p&gt;
&lt;p&gt;The fix was to share the moving value.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const offset = useMotionValue(0);
const iconOffset = useTransform(offset, value =&gt; value - 42);

useEffect(() =&gt; {
  const animation = animate(
    offset,
    open ? 42 : 0,
    reducedMotion
      ? { duration: 0 }
      : open
        ? splitSpring
        : mergeSpring,
  );

  return () =&gt; animation.stop();
}, [offset, open, reducedMotion]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SVG uses &lt;code&gt;offset&lt;/code&gt;. The icon uses the same value, adjusted for the position of its wrapper:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;motion.rect style={{ x: offset }} /&gt;
&amp;#x3C;motion.span style={{ x: iconOffset }}&gt;
  &amp;#x3C;Check /&gt;
&amp;#x3C;/motion.span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The subtraction is a coordinate correction, not an additional animation. The icon&apos;s wrapper is already placed at the detached position. Subtracting 42 makes it travel from the navbar edge to that position alongside the satellite.&lt;/p&gt;
&lt;p&gt;Motion values can update rendered styles without triggering a React render for every frame, and the same value can drive multiple elements. That made them a good fit for this relationship. &lt;a href=&quot;https://motion.dev/docs/react-motion-value&quot;&gt;Motion&apos;s documentation explains shared and derived motion values&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Try the problem mode below. It deliberately places the tick at its destination too early. Then apply the fix and replay the split.&lt;/p&gt;
&lt;p&gt;I still animate the icon&apos;s opacity separately. It begins appearing after 250ms and fades in over 400ms, alongside the background sharpening. Position, however, has one clock. That is what keeps the icon inside the moving shape when the animation starts, reverses, or is interrupted.&lt;/p&gt;
&lt;h2&gt;A temporary confirmation needs an exit&lt;/h2&gt;
&lt;p&gt;The next problem was obvious once I used the page normally: the confirmation stayed there until I reloaded.&lt;/p&gt;
&lt;p&gt;I had implemented the arrival more carefully than the departure.&lt;/p&gt;
&lt;p&gt;I needed two distinct pieces of state:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Whether a confirmation is currently active.&lt;/li&gt;
&lt;li&gt;Whether the animation&apos;s elements still need to be mounted while they return.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If I removed the SVG the moment the confirmation ended, there would be no merge animation. If I left it mounted indefinitely, I had more opportunities for a faint residual shape to remain visible.&lt;/p&gt;
&lt;p&gt;The final sequence is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Mount the split elements and show the confirmation.&lt;/li&gt;
&lt;li&gt;Keep the confirmation active for 2.4 seconds.&lt;/li&gt;
&lt;li&gt;Change the target back to the joined position.&lt;/li&gt;
&lt;li&gt;Fade the returning elements.&lt;/li&gt;
&lt;li&gt;Remove the SVG after a 400ms return window.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This shortened excerpt shows the distinction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;setHasSplit(true);
setConfirmation(kind);

closeTimer = setTimeout(() =&gt; {
  setConfirmation(null); // starts the merge

  removeTimer = setTimeout(() =&gt; {
    setHasSplit(false);  // removes the returned SVG
    scheduleProgressCheck();
  }, 400);
}, 2400);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before starting another confirmation, I clear both timers. Repeated copies should restart the visible confirmation, not leave an older timer waiting to remove a newer one.&lt;/p&gt;
&lt;p&gt;I also clear timers and observers when the article component unmounts. The article island is keyed by pathname, so visiting another article starts with that article&apos;s own state.&lt;/p&gt;
&lt;h2&gt;The semicircle that survived the first fixes&lt;/h2&gt;
&lt;p&gt;Even after automatic retraction worked, I could sometimes see a small semicircle near the navbar&apos;s edge. During the return, it could also appear to pass over the Connect button.&lt;/p&gt;
&lt;p&gt;There were several overlapping issues, and treating them as a single timing problem did not solve them.&lt;/p&gt;
&lt;p&gt;First, the SVG filter covered more area than the visible satellite. Second, its anchor was allowed to paint inside the main pill. Third, my navbar was translucent glass, while the SVG silhouette was solid black.&lt;/p&gt;
&lt;p&gt;That last detail explained why matching the nominal background color was not enough.&lt;/p&gt;
&lt;p&gt;The navbar&apos;s appearance depends on the content behind it. A solid patch painted over that surface changes the result. I could see the difference as a dark semicircle moving into the glass.&lt;/p&gt;
&lt;p&gt;I first tightened the effect&apos;s bounds and placed it behind the navigation controls. I then made the boundary stricter: the solid split effect could paint only outside the navbar&apos;s glass surface.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;clipPath id={edgeClipId}&gt;
  &amp;#x3C;rect x=&quot;42&quot; y=&quot;0&quot; width=&quot;48&quot; height=&quot;52&quot; /&gt;
&amp;#x3C;/clipPath&gt;

&amp;#x3C;g clipPath={`url(#${edgeClipId})`}&gt;
  &amp;#x3C;g filter={`url(#${filterId})`}&gt;
    {/* anchor and moving satellite */}
  &amp;#x3C;/g&gt;
&amp;#x3C;/g&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why &lt;code&gt;42&lt;/code&gt;? The 90px-wide SVG sits 48px beyond the navbar&apos;s right edge. Its left edge is therefore 42px inside the navbar. In the SVG&apos;s coordinate space, &lt;code&gt;x = 42&lt;/code&gt; is the navbar boundary.&lt;/p&gt;
&lt;p&gt;I apply another clip to the satellite wrapper, which also contains the icon:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.reading-completion {
  position: absolute;
  right: 0;
  top: 50%;
  width: 0;
  height: 32px;
  transform: translateY(-50%);
  clip-path: inset(-10px -60px -10px 0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The negative insets leave room outside the navbar. The zero left inset prevents the returning content from painting across the glass interior. The effect also sits behind navigation content, and the entire SVG is removed after the merge.&lt;/p&gt;
&lt;p&gt;The following comparison uses the same split primitive. Problem mode removes the protective clipping in this local preview so the solid shape can paint over the glass again. Watch the return as well as the arrival.&lt;/p&gt;
&lt;p&gt;I did not turn the SVG into a physically identical glass material. I stopped it from painting over the glass in the first place. That was the boundary the design needed.&lt;/p&gt;
&lt;h2&gt;The table of contents I built and then removed&lt;/h2&gt;
&lt;p&gt;My original idea included another detached shape below the navbar. When an article had well-formed headings, it would show the current section and expand into a table of contents.&lt;/p&gt;
&lt;p&gt;I supplied a recording and screenshots showing the kind of compact reading navigation I had in mind. The first implementation discovered heading anchors, followed the current section, allowed section jumps, bounded the list to the viewport, and closed after a selection on mobile.&lt;/p&gt;
&lt;p&gt;It was functional. I still did not like the result on my website.&lt;/p&gt;
&lt;p&gt;The floating panel added another persistent surface above the article. Expanded, it covered too much of what I was reading. It also changed the visual balance of a navbar I had explicitly wanted to preserve.&lt;/p&gt;
&lt;p&gt;I asked for it to be removed.&lt;/p&gt;
&lt;p&gt;That decision belongs in this story because it was part of making the feature better. Passing interaction tests did not make the panel the right product choice. I had to use it in context and decide whether I wanted it there.&lt;/p&gt;
&lt;p&gt;The final navbar does not have a floating table of contents. The pre-existing article-footer contents disclosure remains part of the article layout. I did not need a second persistent navigation surface above the prose.&lt;/p&gt;
&lt;h2&gt;Defining what completion actually means&lt;/h2&gt;
&lt;p&gt;The green tick is a scroll-position acknowledgment. It cannot know whether someone read, understood, or agreed with the article.&lt;/p&gt;
&lt;p&gt;For this interface, completion means the reader has reached the end of the article&apos;s measured range. I wanted that range to end with the article, rather than requiring a trip through the entire site footer.&lt;/p&gt;
&lt;p&gt;I extracted a shared progress calculation so the outline and the completion feedback could agree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export function getReadingProgress(article: HTMLElement): number {
  const scrollY = window.scrollY;
  const viewport = window.innerHeight;
  const maxScroll = Math.max(
    0,
    document.documentElement.scrollHeight - viewport,
  );
  const rect = article.getBoundingClientRect();
  const start = Math.max(0, rect.top + scrollY - 80);
  const seam = Math.min(100, Math.max(50, viewport * 0.1));
  const end = Math.min(
    maxScroll,
    rect.bottom + scrollY - viewport + seam,
  );

  if (end &amp;#x3C;= start + 1) {
    return rect.bottom &amp;#x3C;= viewport ? 1 : 0;
  }

  return Math.max(0, Math.min(1, (scrollY - start) / (end - start)));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The offsets account for the floating navigation and the point at which the article&apos;s bottom is considered reached. The end is clamped to the page&apos;s maximum scroll position so the target remains reachable. Compact articles get a separate check instead of a division by a tiny range.&lt;/p&gt;
&lt;p&gt;This measures the marked article frame, which includes the article&apos;s own footer controls. It is a UI definition of progress, and I keep that definition consistent rather than pretending it measures attention.&lt;/p&gt;
&lt;p&gt;Scroll events are passive and scheduled through &lt;code&gt;requestAnimationFrame&lt;/code&gt;. Resize observers let the calculation respond when the layout changes. This matters when images load, the viewport changes size, or another article element changes height.&lt;/p&gt;
&lt;h2&gt;The outline needed to hand over to the tick&lt;/h2&gt;
&lt;p&gt;At first, reaching the end left the full gradient around the navbar while the tick appeared beside it. I wanted the completion acknowledgment to take over instead of competing with the completed outline.&lt;/p&gt;
&lt;p&gt;The solution is small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;svgRef.current.style.opacity = progress &gt;= 1 ? &quot;0&quot; : &quot;1&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SVG fades over 200ms. Scrolling back into the article restores it, and the path offsets continue to reflect the current position.&lt;/p&gt;
&lt;p&gt;The progress demo uses a simpler single-color outline to make the state change easier to see. The shipped navbar retains its original two-sided gradient path.&lt;/p&gt;
&lt;p&gt;Move to 100%, wait for the tick to return, move below 95%, and then reach the end again. That second pass was another behavior I had to fix.&lt;/p&gt;
&lt;h2&gt;Completion should be repeatable without flickering&lt;/h2&gt;
&lt;p&gt;The first completion flag meant “this article has completed once during this visit.” After it became true, later attempts to trigger the animation were ignored.&lt;/p&gt;
&lt;p&gt;That protected against repeated scroll events at the bottom, but it also prevented the animation from playing when I scrolled back and finished again.&lt;/p&gt;
&lt;p&gt;I changed the flag into something that could rearm:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const progress = getReadingProgress(article);

if (progress &amp;#x3C; 0.95) {
  completed = false;
  return;
}

if (completed || progress &amp;#x3C; 1) return;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The two thresholds create a small gap between “ready again” and “complete.” If I used 100% for both, tiny changes around the end could repeatedly trigger feedback. Requiring a return below 95% gives the interaction a more deliberate reset.&lt;/p&gt;
&lt;p&gt;I do not persist this reading flag in storage. It belongs to the current article interaction. A new article starts fresh.&lt;/p&gt;
&lt;h2&gt;A review caught a race between copying and completion&lt;/h2&gt;
&lt;p&gt;The PR review identified a case that normal one-action-at-a-time testing had missed.&lt;/p&gt;
&lt;p&gt;What if someone copied the article link and reached the end while the copy confirmation was still visible?&lt;/p&gt;
&lt;p&gt;The earlier code did this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Earlier version: completion can be consumed without being shown.
completed = true;
if (!copying) show(&quot;completed&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That order was wrong. If &lt;code&gt;copying&lt;/code&gt; was true, the tick was suppressed, but completion had already been marked. A later update would think there was nothing left to show.&lt;/p&gt;
&lt;p&gt;I moved the copy check before the completion flag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;if (completed || progress &amp;#x3C; 1) return;
if (copying) return;

completed = true;
show(&quot;completed&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That change alone would still depend on another scroll or resize event arriving after the copy disappeared. I also schedule a fresh progress check when the copy has finished merging. If the reader is still at the end, the completion acknowledgment can then appear.&lt;/p&gt;
&lt;p&gt;If the reader has moved away, the current progress calculation wins. I do not replay a stale completion simply because it was relevant a few seconds earlier.&lt;/p&gt;
&lt;p&gt;In this demonstration, the button simulates a successful copy and then moves the local progress value to 100%. Watch the status move from copied to completed. Try moving the slider back while copying to cancel the pending completion.&lt;/p&gt;
&lt;p&gt;I added regression coverage for that sequence, pushed the fix, replied with the implementation and test result, and resolved the review thread. The useful part of the review was the behavioral question it raised, not just the line of code it pointed at.&lt;/p&gt;
&lt;h2&gt;Expanding the existing navbar for richer feedback&lt;/h2&gt;
&lt;p&gt;Once the split felt good, I wanted the navbar to respond to other meaningful actions too.&lt;/p&gt;
&lt;p&gt;A small tick works for a brief acknowledgment. Contact submission needs more context: verification, sending, success, or failure. A copied code block also benefits from a short explanation of what happened.&lt;/p&gt;
&lt;p&gt;I returned to Jhaemin&apos;s larger state. The reference uses a 72px-high shape with a different spring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const expandedIslandSpring = {
  type: &quot;spring&quot; as const,
  stiffness: 250,
  damping: 26,
  mass: 1.5,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I kept the 72px height and adapted the width to my existing navbar. The feedback target is at most 560px wide, bounded by the available viewport. At the top of a desktop page, my normal navigation can be wider than that, so the feedback state becomes taller and more focused rather than always growing horizontally.&lt;/p&gt;
&lt;p&gt;The content follows the shape with the reference&apos;s short delayed reveal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;&amp;#x3C;motion.div
  initial={{ opacity: 0, scale: 0.9, filter: &quot;blur(5px)&quot; }}
  animate={{ opacity: 1, scale: 1, filter: &quot;blur(0px)&quot; }}
  exit={{ opacity: 0, scale: 0.9, filter: &quot;blur(5px)&quot; }}
  transition={{ duration: 0.2, delay: 0.1 }}
&gt;
  {/* status icon, title, description, dismiss button */}
&amp;#x3C;/motion.div&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reduced motion removes the scale and blur movement. The implementation also keeps a close control available.&lt;/p&gt;
&lt;p&gt;These buttons simulate an outcome locally. On the real contact page, the success state only appears after the submission action returns success.&lt;/p&gt;
&lt;h2&gt;The message has to be as accurate as the motion&lt;/h2&gt;
&lt;p&gt;I did not want a satisfying animation to announce something that had not happened.&lt;/p&gt;
&lt;p&gt;The feedback distinguishes the actual action states:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action or result&lt;/th&gt;
&lt;th&gt;Feedback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Contact verification begins&lt;/td&gt;
&lt;td&gt;Checking your message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery begins&lt;/td&gt;
&lt;td&gt;Sending your message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission succeeds&lt;/td&gt;
&lt;td&gt;Message sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submission fails&lt;/td&gt;
&lt;td&gt;Message not sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct-email link is pressed&lt;/td&gt;
&lt;td&gt;Continue in your email app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code is successfully copied&lt;/td&gt;
&lt;td&gt;Code copied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS URL is successfully copied&lt;/td&gt;
&lt;td&gt;Feed link copied&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Opening a &lt;code&gt;mailto:&lt;/code&gt; link is an invitation to continue elsewhere. I cannot confirm that an email application opened successfully, much less that a message was sent. The copy reflects that limit.&lt;/p&gt;
&lt;p&gt;Pending contact feedback stays until the result arrives or the user dismisses it. Success and informational feedback return to navigation after three seconds; errors remain for five. The form&apos;s inline error remains available as well.&lt;/p&gt;
&lt;p&gt;I also avoid announcing “Message not sent” just because a security widget reports a problem before the visitor has attempted a submission. The notification is tied to the attempted action.&lt;/p&gt;
&lt;p&gt;While expanded feedback is present, the normal navigation content is hidden and inert. That prevents links underneath the confirmation from remaining accidentally clickable or keyboard-focusable. The audio state is preserved, so dismissing feedback restores the appropriate navbar content.&lt;/p&gt;
&lt;h2&gt;Keeping feedback tied to the right page&lt;/h2&gt;
&lt;p&gt;The contact form, RSS menu, and code-copy button are not children of the navbar. I needed a small way for those actions to publish feedback without coupling their markup to navigation.&lt;/p&gt;
&lt;p&gt;I used a typed event and a hook that captures the action&apos;s pathname:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;type NavbarFeedback = {
  title: string;
  description: string;
  tone: &quot;pending&quot; | &quot;success&quot; | &quot;error&quot; | &quot;info&quot;;
};

window.dispatchEvent(new CustomEvent(&quot;navbar-feedback&quot;, {
  detail: { ...feedback, pathname },
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The receiver ignores feedback for another pathname, replaces the current notification, and clears the previous dismissal timer. Route changes clear the current feedback. An action that finishes after I navigate away should not announce itself over an unrelated page.&lt;/p&gt;
&lt;p&gt;This is the scope I needed for a small website. It is not a general notification queue. Concurrent actions replace the expanded message, while the article&apos;s copy/completion interaction has its own explicit priority handling.&lt;/p&gt;
&lt;p&gt;That boundary helps keep the implementation understandable. I do not need every button on the website to create a navbar event. Navigation links can continue to navigate. I reserve the feedback for actions where a result needs acknowledgment.&lt;/p&gt;
&lt;h2&gt;A layout regression after the feedback was added&lt;/h2&gt;
&lt;p&gt;Another screenshot showed the expanded feedback in a broken layout. “Code copied” and its description were wrapping vertically, and the close control had fallen outside the intended pill.&lt;/p&gt;
&lt;p&gt;Fresh loads rendered correctly during inspection. The feedback&apos;s critical styles were still coupled to the article island&apos;s stylesheet, which made that dependency an unnecessary weak point while components and styles were changing in the dev server.&lt;/p&gt;
&lt;p&gt;I could not prove every detail of the transient failure from a single screenshot. I could improve the ownership of the styles and verify the layout more directly.&lt;/p&gt;
&lt;p&gt;I moved the expanded-feedback rules into a CSS module imported by the feedback component itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;import styles from &quot;./NavbarFeedback.module.css&quot;;

&amp;#x3C;motion.div className={styles.feedback}&gt;
  &amp;#x3C;span className={styles.icon}&gt;{/* icon */}&amp;#x3C;/span&gt;
  &amp;#x3C;div className={styles.copy}&gt;{/* message */}&amp;#x3C;/div&gt;
  &amp;#x3C;button className={styles.dismiss}&gt;{/* close */}&amp;#x3C;/button&gt;
&amp;#x3C;/motion.div&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important layout rules are straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.feedback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 12px 8px 20px;
  border-radius: inherit;
}

.copy {
  flex: 1;
  min-width: 0;
}

.dismiss {
  width: 44px;
  height: 44px;
  flex: 0 0 44px;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The copy can take the remaining space without forcing the other elements out. The icon and close control keep their intended dimensions. The component now owns the styles it needs to render correctly.&lt;/p&gt;
&lt;p&gt;I added a browser assertion that checks every direct child&apos;s bounds against the expanded pill. Checking that a success message exists would not catch a close button sitting below it.&lt;/p&gt;
&lt;h2&gt;Responsiveness is also about where the circle can go&lt;/h2&gt;
&lt;p&gt;On desktop, a small satellite can sit outside a centered navbar with plenty of room around it. On a narrow phone, that same position can leave the viewport.&lt;/p&gt;
&lt;p&gt;The implementation reserves horizontal space while a split is active on smaller viewports. The navbar temporarily becomes narrower and shifts left so the combined navbar-and-satellite group remains within the screen.&lt;/p&gt;
&lt;p&gt;The relevant adjustment is deliberately bounded:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@media (max-width: 1100px) {
  .site-nav-shell:has([data-reading-complete]) {
    width: calc(100% - 48px);
    transform: translateX(-24px);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That attribute represents an active split confirmation in this implementation, including copying. When the confirmation returns, the shell returns too.&lt;/p&gt;
&lt;p&gt;I checked the original mobile drawer at 320px, as well as the mobile, tablet, and desktop test presets. I also checked the feedback text inside the larger pill. Those are related but separate layout problems: a circle can remain on-screen while the text inside its neighboring navbar still overflows.&lt;/p&gt;
&lt;h2&gt;What smooth meant, and what I could actually measure&lt;/h2&gt;
&lt;p&gt;I asked for smooth motion across mobile and desktop, including high-refresh-rate devices. I also had to distinguish the goal from the evidence available in a local browser test.&lt;/p&gt;
&lt;p&gt;The frame budgets are easy to calculate:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refresh rate&lt;/th&gt;
&lt;th&gt;Approximate time per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;60Hz&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000 / 60 = 16.67ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;120Hz&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1000 / 120 = 8.33ms&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I measured a short local Chromium sample during the expanded animation:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Viewport width&lt;/th&gt;
&lt;th&gt;Median frame interval&lt;/th&gt;
&lt;th&gt;95th percentile&lt;/th&gt;
&lt;th&gt;Sampled frames over 34ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;390px&lt;/td&gt;
&lt;td&gt;16.4ms&lt;/td&gt;
&lt;td&gt;18.2ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1280px&lt;/td&gt;
&lt;td&gt;16.4ms&lt;/td&gt;
&lt;td&gt;19.6ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That was encouraging evidence for the tested setup. It was a short viewport-based measurement on one machine. It did not certify performance on every physical phone, under thermal throttling, or on a 120Hz display.&lt;/p&gt;
&lt;p&gt;I also avoid pretending the entire effect is free. A small translated icon is inexpensive compared with repeatedly resizing a blurred surface. This implementation uses SVG filtering, a backdrop, and width/height changes during expanded feedback. Those choices need a limited scope and actual checking.&lt;/p&gt;
&lt;p&gt;The practical decisions were to keep the filtered area small, share position values, avoid React state updates solely to move the icon every frame, remove temporary elements after use, and avoid continuous decorative animation while nothing is happening.&lt;/p&gt;
&lt;p&gt;The demos in this article are event-driven too. They do not run an endless animation loop while I am reading another section.&lt;/p&gt;
&lt;h2&gt;Reduced motion is part of the interaction&lt;/h2&gt;
&lt;p&gt;I want the confirmation to remain understandable when movement is reduced. The state still changes; the large positional transitions, scale changes, and blur reveals can be removed.&lt;/p&gt;
&lt;p&gt;Each demo offers a local reduced-movement control. If the device already requests reduced motion, the demo respects that preference and does not provide a way to override it with more movement. The real navbar uses Motion&apos;s reduced-motion hook and a CSS fallback for its shell transitions.&lt;/p&gt;
&lt;p&gt;The browser preference is exposed through &lt;code&gt;prefers-reduced-motion&lt;/code&gt;. &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion&quot;&gt;MDN describes the preference and its CSS behavior&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I kept the semantic feedback separate from the visual effect. Decorative SVGs are hidden from assistive technology. Meaningful status text is announced through a live region. Expanded feedback can be dismissed using its button or Escape. I do not move the reader&apos;s focus to a completion tick.&lt;/p&gt;
&lt;h2&gt;The tests that made the fixes harder to lose&lt;/h2&gt;
&lt;p&gt;I repeatedly found problems by watching the dev server. Once a problem was clear, I wanted a test that expressed the behavior I expected to keep.&lt;/p&gt;
&lt;p&gt;By the end of the original build, the combined suite had 30 passing checks across mobile, tablet, and desktop. The later stylesheet correction also passed its focused layout checks on all three presets.&lt;/p&gt;
&lt;p&gt;The useful coverage included:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Completion appears and the progress outline hides at the end.&lt;/li&gt;
&lt;li&gt;Scrolling upward restores the outline.&lt;/li&gt;
&lt;li&gt;The split SVG is removed after returning.&lt;/li&gt;
&lt;li&gt;Another completion can play after the reading state rearms.&lt;/li&gt;
&lt;li&gt;A copy confirmation does not permanently consume completion.&lt;/li&gt;
&lt;li&gt;Failed clipboard writes do not announce success.&lt;/li&gt;
&lt;li&gt;Contact success and failure produce different messages.&lt;/li&gt;
&lt;li&gt;The original narrow mobile drawer remains usable.&lt;/li&gt;
&lt;li&gt;The expanded feedback&apos;s children remain inside the pill.&lt;/li&gt;
&lt;li&gt;The icon stays centered in the satellite throughout entry and merge.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For the contact tests, Turnstile and delivery responses are mocked. I can test verification, sending, success, and failure without sending a real message or bypassing the production verification service.&lt;/p&gt;
&lt;p&gt;The alignment test samples the bounds of the SVG satellite and icon across animation frames. Its core assertion is more meaningful than checking one final screenshot:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const satelliteCenter = satellite.x + satellite.width / 2;
const iconCenter = icon.x + icon.width / 2;

differences.push(Math.abs(satelliteCenter - iconCenter));

// After sampling entry and return:
expect(Math.max(...differences)).toBeLessThan(1);
await expect(page.locator(&quot;.reading-split&quot;)).toHaveCount(0);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I used screenshots to inspect the visual result and assertions to protect specific behavior. Neither replaced the other. A clean typecheck could not tell me that the contents panel felt intrusive. A screenshot of the settled tick could not tell me that it had arrived too early.&lt;/p&gt;
&lt;h2&gt;A map of the implementation&lt;/h2&gt;
&lt;p&gt;I kept the responsibilities small enough to revisit without opening one enormous animation file.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IslandSplit.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reference SVG geometry, blur/alpha filter, and glass-edge clip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavbarArticleIsland.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Article completion, copy priority, replay, timers, and the shared position value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavbarReadingProgress.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The original gradient outline and its visibility at completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reading-progress.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The shared article-range calculation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavbarFeedback.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expanded status content and its reveal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NavbarFeedback.module.css&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The expanded content&apos;s layout and responsive sizing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useNavbarFeedback.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Route-scoped publishing, dismissal, and lifetime of action feedback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Navbar.tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Integration with existing navigation, audio, and drawer behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Separating the SVG geometry in &lt;code&gt;IslandSplit&lt;/code&gt; from the lifecycle timers and coordinate math in &lt;code&gt;NavbarArticleIsland&lt;/code&gt; kept each part of the interaction easy to reason about and test independently.&lt;/p&gt;
&lt;p&gt;The snippets throughout this post are focused excerpts highlighting the core mechanics. The production implementation wraps these primitives with passive scroll listeners, observer cleanups, reduced-motion fallbacks, and the surrounding layout. The interactive demos in this article reuse those same visual components, but run with their own local controls and explanatory state so they do not interfere with the live page.&lt;/p&gt;
&lt;h2&gt;What I would carry into the next interface&lt;/h2&gt;
&lt;p&gt;I started by asking for a split animation that felt like the reference. I ended up learning more from the things around that animation: its relationship to an icon, its collision with a glass surface, its priority over another confirmation, and its return to an ordinary navbar.&lt;/p&gt;
&lt;p&gt;The reference code gave me a strong starting point. The website supplied the constraints. Repeated browser testing exposed the gaps between the two.&lt;/p&gt;
&lt;p&gt;Removing the floating contents panel gave the article room again, and checking the return animation made the whole interaction feel more consistent. Turning the copy/completion race into a test also meant I could keep working without relying on my memory of every earlier problem.&lt;/p&gt;
&lt;p&gt;The feature I wanted was a brief, useful acknowledgment inside a familiar part of my website. Every refinement had to earn its place against that goal.&lt;/p&gt;
&lt;p&gt;When I reach the end now, I get a brief acknowledgment and the navbar returns to its normal state. That is the experience I wanted to build.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;&lt;p&gt;A note on the stack: This piece was drafted, styled, and published through my own bespoke CMS. Building my own publishing engine has let me shape longform interactive posts exactly the way I want them to feel.&lt;/p&gt;&lt;p&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Building Fix My India: A Platform to Bridge Citizens and Government</title>
<link>https://www.omrajguru.com/devnotes/fixmyindia</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/fixmyindia</guid>
<pubDate>Wed, 19 Aug 2026 13:57:56 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>A look at why I built a platform for citizens to report civic issues and track government response, and what it does so far.</description>
<content:encoded>&lt;p&gt;I have been building something for the past few weeks called Fix My India.&lt;/p&gt;&lt;p&gt;The idea is simple. Citizens report civic issues like potholes, water supply problems, broken streetlights, or sanitation complaints. Government departments see these reports, respond, and update the status until the issue is resolved.&lt;/p&gt;&lt;p&gt;I wanted a place where nothing filed just disappears. Every request goes on a public feed. You can search it, filter it by category or status, and click into any request to see a live status tracker: Submitted, Under Review, In Progress, Resolved. If a department responds officially, that response shows up on the request too.&lt;/p&gt;&lt;p&gt;On the citizen side, signing up takes seconds. Filing a request is one form: title, category, description, exact location.&lt;/p&gt;&lt;p&gt;On the government side, I built in a verification gate. Staff accounts only work with authentic .gov.in or .nic.in emails, and an admin has to approve the account before dashboard access opens up. This was important to me. I did not want this to be a platform that works against government, I wanted it to help government departments see what citizens are dealing with and respond faster.&lt;/p&gt;&lt;p&gt;Once verified, staff get a dashboard scoped to their department, with the request queue sorted by status. Updating a request lets them change its status, post an official response, and add internal notes. Every change is logged, so the record stays accurate for everyone looking at it.&lt;/p&gt;&lt;p&gt;This is still early. I am building it in public, phase by phase, and there is a lot more to add.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Bringing Development and Reliability Together</title>
<link>https://www.omrajguru.com/devnotes/sdlc</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/sdlc</guid>
<pubDate>Tue, 09 Jun 2026 14:49:46 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>I recently started a private initiative called SDLC, a framework that combines software development and reliability practices into a single lifecycle. It is my attempt to create a clearer and more consistent way of building and maintaining software from start to finish.</description>
<content:encoded>&lt;p&gt;Today, I wanted to share a small initiative I have been working on called SDLC. SDLC stands for Software Development Life Cycle, but for me, it has become more than the standard definition of planning, designing, building, testing, deploying, and maintaining software. It started from a simple observation. Building software that feels consistent, pays attention to detail, and remains reliable over time often requires a lot of deliberate effort. As both a developer and researcher, I wanted a clearer set of standards that could guide not just how software is built, but how it should evolve throughout its entire life.&lt;/p&gt;&lt;p&gt;For a long time, I relied on my own Site Reliability Engineering standards for the projects I built. They helped define expectations around reliability, operations, and maintenance, and they worked well once software was already running. The problem was that they did not cover the earlier parts of the journey. Stages such as idea validation, planning, architecture, and design existed outside that framework. Over time, I felt the need for a single process that could connect the entire journey rather than focusing on only one part of it.&lt;/p&gt;&lt;p&gt;That led to the creation of this SDLC framework. It brings together traditional software development practices and reliability principles into one continuous lifecycle: Vision, Discovery, Planning, Architecture, Design, Build, Verify, Release, Operate, Scale, Evolve, and Retire. The goal is not to introduce a new methodology or claim that existing approaches are insufficient. It is simply a framework that reflects how I want to approach software creation going forward. By having a consistent set of standards from the first idea to the final retirement of a product, I can make decisions more deliberately and build systems that remain maintainable over time. I am sharing it today because it has already helped bring more structure to how I think about software, and I expect it will continue to evolve as I learn and build more.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Shelf</title>
<link>https://www.omrajguru.com/devnotes/shelf</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/shelf</guid>
<pubDate>Tue, 19 May 2026 12:14:00 GMT</pubDate>
<dc:creator>Om</dc:creator>
<description>I built Shelf because my ideas and thoughts were scattered across every note-taking app I had ever tried. Every time I sat down with Claude to think through an important decision, all of that context stayed behind in those apps, invisible to the conversation. I wanted one place that Claude could actually reach into.</description>
<content:encoded>&lt;p&gt;Every developer I know has the same problem. The knowledge that matters, the opinions formed after three failed approaches, the architectural decisions made at 11pm, the preferences refined over years of building, lives in a graveyard of note-taking apps. Notion pages. Apple Notes. Obsidian vaults. Bear drafts. The ideas are there. The context is there. But when you open a conversation with Claude and need that context to make a real decision, you are starting from zero every single time. I got tired of that. So I built Shelf.&lt;/p&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/omrajguru/shelf/&quot;&gt;Repo&lt;/a&gt;&lt;/p&gt;&lt;p&gt;Shelf is a self-hostable web app and MCP server for personal knowledge. Self-hostable means you run it on your own infrastructure, either on your laptop or on a cloud service you control. You own the data, the server, and the database. Shelf has no accounts to log into, no subscription, and zero LLM calls inside the system itself. It is purely storage and retrieval, with an MCP server on top that gives Claude direct access to everything you have saved.&lt;/p&gt;&lt;p&gt;MCP stands for Model Context Protocol. It is an open standard that defines how AI clients like Claude communicate with external tools and data sources during a conversation. Think of it as a structured bridge. On one side is Claude. On the other side is your data. MCP defines the exact language they speak so Claude can call your tools, receive structured responses, and weave that information into its answers. Shelf implements this protocol so your stored knowledge becomes part of every conversation you have.&lt;/p&gt;&lt;p&gt;Shelf exposes five tools over MCP. The first is &lt;code&gt;shelf_search&lt;/code&gt;, which runs a full-text search across all your entries. Full-text search means the system scans the actual words in your content and returns entries that match your query. The second tool is &lt;code&gt;shelf_dump&lt;/code&gt;, which saves a new entry and optionally tags it with a source label so you know where it came from. The third is &lt;code&gt;shelf_rules&lt;/code&gt;, which returns every entry you have marked as a rule. Rules are standing instructions for Claude, the things you want it to always factor in when talking to you. The fourth is &lt;code&gt;shelf_pinned&lt;/code&gt;, which returns your highest-priority reference entries. The fifth is &lt;code&gt;shelf_list&lt;/code&gt;, which shows your entries in reverse chronological order with pinned items at the top. These five tools cover every meaningful way an AI interacts with a personal knowledge store.&lt;/p&gt;&lt;p&gt;The storage layer is built to be swappable. By default, Shelf uses SQLite through Node&apos;s built-in &lt;code&gt;node:sqlite&lt;/code&gt; module. SQLite is a file-based database that lives entirely on your machine as a single file. There is no database server to run, no network connection involved, and no external account to create. You run &lt;code&gt;npm run db:init&lt;/code&gt; once and a file called &lt;code&gt;shelf.db&lt;/code&gt; appears in your project directory. That file is your entire database. For cloud deployments, Shelf ships with a full Supabase adapter. Supabase is a hosted Postgres database service with a generous free tier. Postgres is a production-grade relational database used by large-scale applications worldwide. You switch one environment variable, &lt;code&gt;SHELF_STORAGE_ADAPTER=supabase&lt;/code&gt;, and Shelf routes all storage operations through Supabase instead of your local file.&lt;/p&gt;&lt;p&gt;There are two setup paths and I designed them to serve genuinely different people. The local path is for developers comfortable in a terminal. You clone the repository, run four commands, and Shelf is live at &lt;code&gt;localhost:3000&lt;/code&gt; with an MCP server ready for Claude Desktop. The whole process takes about three minutes. The cloud path is for anyone who wants Shelf reachable from every device, including the browser version of Claude.ai. You fork the repository on GitHub, create a free Supabase project, paste one SQL migration file into Supabase&apos;s query editor, and deploy to Vercel. Vercel is a hosting platform that builds and serves your Next.js application for free on their infrastructure. That path takes about ten minutes with zero terminal commands.&lt;/p&gt;&lt;p&gt;The cloud setup works because Vercel hosts both the web app and the MCP server under the same URL. Your web interface lives at &lt;code&gt;https://your-shelf.vercel.app&lt;/code&gt; and the MCP endpoint lives at &lt;code&gt;https://your-shelf.vercel.app/api/mcp&lt;/code&gt;. When you add that endpoint as a custom connector in Claude.ai&apos;s settings under Connectors, Claude gains access to your Shelf tools from any browser on any device. You save an entry from your phone on the way to a meeting. You reference it from your laptop that afternoon. Supabase keeps both in sync because both point to the same database.&lt;/p&gt;&lt;p&gt;The technical foundation is Next.js 14, a React framework built and maintained by Vercel. React is a JavaScript library for building user interfaces. Shelf uses Next.js in a deliberate way: the web UI and standard API routes live in the App Router, which is Next.js&apos;s newer file-based routing system, and the MCP server lives in the Pages Router, which is the older routing system. The MCP server requires the Pages Router because it needs direct access to Node&apos;s raw HTTP request and response objects. The App Router abstracts those away in a way that breaks the MCP transport layer. Both routing systems coexist in the same project without conflict. The MCP SDK handles two transport modes from the same tool registration code: stdio for Claude Desktop, where the process communicates through standard input and output streams, and Streamable HTTP for Claude.ai, where communication happens over a standard web request.&lt;/p&gt;&lt;p&gt;Every storage adapter in Shelf implements a shared TypeScript interface called &lt;code&gt;StorageAdapter&lt;/code&gt;. TypeScript is a version of JavaScript that adds type annotations, meaning you declare the exact shape your data takes and the TypeScript compiler verifies that your code respects those shapes. The &lt;code&gt;StorageAdapter&lt;/code&gt; interface declares the methods every adapter must implement: saving entries, retrieving them, searching them, updating them, and deleting them. The web app and MCP server write code against this interface and have zero knowledge of which database sits underneath. The adapter handles every database-specific detail in isolation. Adding a new adapter for any database means implementing this one interface and registering it in the factory file. The architecture makes contribution a well-scoped task.&lt;/p&gt;&lt;p&gt;I made one architectural decision that I consider the most important one in the entire project. Shelf has zero LLM calls internally. There is no summarization on ingest, no embeddings generation, and no AI-powered relevance ranking. An embedding is a numerical representation of text used to measure semantic similarity between documents, the approach many AI-powered knowledge tools use to find conceptually related content. Shelf skips all of that. The search is full-text, meaning it matches the exact words you type against the exact words in your entries. This makes Shelf fast, free to run, and completely predictable. What you write is exactly what Claude receives. Your knowledge stays in your words, in your phrasing, with your original meaning intact.&lt;/p&gt;&lt;p&gt;One setup detail worth calling out is the Supabase migration. A database migration is a SQL script that creates the tables and indexes your application needs. SQL is the language used to define and query relational databases. Shelf ships the migration as a single file at &lt;code&gt;supabase/migrations/0001_init.sql&lt;/code&gt;. You open Supabase&apos;s SQL editor in the browser, paste the file contents, and run it. The editor confirms success with &quot;No rows returned.&quot; That one step creates everything the Supabase adapter needs. There is no migration CLI to install, no local Postgres to run, and no version tracking to set up for a first deployment.&lt;/p&gt;&lt;p&gt;The project is fully open source under the MIT license. MIT means you can use Shelf, modify it, host it, fork it, and build on top of it for any purpose, personal or commercial, as long as you include the original license text. The codebase is organized so that the entry points for contributors are obvious. The adapter directory holds all storage implementations alongside the shared interface they implement. The MCP tools directory holds each tool as a separate file. The web components directory separates layout, shelf-specific UI, and generic UI elements. Every layer is a standalone concern. You can read one directory and understand it fully without needing to hold the entire codebase in your head.&lt;/p&gt;&lt;p&gt;Shelf is for the kind of person whose thinking happens in fragments across many places. The entries you store do not need to be polished. They can be half-formed opinions, preferences you have refined over time, decisions you made and the reasoning behind them, rules you want Claude to always apply when talking with you. The more you put in, the richer every Claude conversation becomes. A shelf with fifty entries is a meaningfully better thinking partner than one with five. The tool is straightforward to run. The habit of dumping your knowledge into it, the same way you would leave yourself a quick note, is what compounds into something genuinely useful over time.&lt;/p&gt;&lt;p&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>How I Would Serve Exam Results to 14 Lakh Students at Once Without Breaking a Sweat</title>
<link>https://www.omrajguru.com/devnotes/scaling</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/scaling</guid>
<pubDate>Sat, 02 May 2026 13:40:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>A deep-dive into a static-first, multi-CDN system design that serves 14,00,000 concurrent exam result requests at sub-200ms latency with zero database dependency in the hot path.</description>
<content:encoded>&lt;p&gt;Imagine fourteen lakh students, all opening the same URL at the same second. They have been waiting months for this result. The anxiety is real, the traffic spike is guaranteed, and the margin for error is zero. I designed this system knowing that the worst possible outcome is a student seeing someone else&apos;s marksheet, and the second worst is the system going down entirely. Every architectural choice I made flows from those two constraints. Scale is a solved problem if you pick the right abstraction. The real question is what that abstraction should be, and in this case, the answer is deceptively straightforward: treat every exam result as a static file that already exists before the first student ever makes a request. This shifts the entire conversation from &quot;how do I scale my database&quot; to &quot;how do I make sure my CDN is healthy.&quot;&lt;/p&gt;
&lt;p&gt;The standard approach to building a result portal looks like this: a student submits a roll number, the request hits a load balancer, the load balancer routes it to an application server, the application server queries a database, the database returns a row, and the server formats and returns a response. That pipeline works fine at ten thousand requests per second. At five lakh requests per second, the database becomes the wall. No amount of connection pooling or read replicas changes the fundamental physics of a relational database under that kind of write-read pressure. I have seen systems built exactly this way collapse within ninety seconds of results going live, with engineers scrambling to add read replicas while students are getting timeout errors. The architecture I am describing here makes that entire failure mode irrelevant by removing the database from the path that students ever touch.&lt;/p&gt;
&lt;p&gt;The core idea is pre-generation. A pre-generation pipeline is a process that runs before results go public and converts every student record stored in a database into a static JSON file and a PDF stored in object storage. In this case, that object storage is Amazon S3, which is a managed file storage service that can serve files at effectively unlimited scale. For fourteen lakh students, the JSON files total about 2.8 gigabytes and the PDFs total about 168 gigabytes. Those numbers sound large but S3 handles them without any configuration changes. Once these files exist in S3, the database has already done its job. It contributed its data to the files. Its work is complete. What remains is purely a file-serving problem, and file-serving is something content delivery networks were purpose-built to handle.&lt;/p&gt;
&lt;p&gt;A content delivery network, or CDN, is a globally distributed network of servers called edge nodes. Each edge node is a server located physically close to groups of users. When a student in Pune requests their result, the CDN serves that file from an edge node in Mumbai instead of routing the request all the way to a data center. This reduces latency, which is the time a packet takes to travel from the user to the server and back. It also means that the origin server, which is the server where the file actually lives, receives only a fraction of the total requests. The CDN caches the file after the first fetch and serves it from memory for every subsequent request. At the scale I am designing for, the CDN absorbs ninety-five to ninety-nine percent of all traffic. The backend infrastructure barely registers the load.&lt;/p&gt;
&lt;p&gt;I use three CDN providers in this design: Akamai as the primary, Cloudflare as the secondary, and Fastly as the backup. Running multiple CDN providers in parallel is called a multi-CDN strategy. DNS-based traffic steering, handled through AWS Route 53 or NS1, directs requests to the appropriate CDN based on health checks. If Akamai&apos;s health checks fail, Route 53 automatically redirects traffic to Cloudflare within seconds. If Cloudflare also fails, traffic moves to Fastly. Each CDN independently serves from S3 as its origin. This removes the single point of failure that a single-CDN architecture introduces. A single CDN provider going down on result day is a real risk. It has happened to production systems at scale. Running three providers means I need all three to fail simultaneously before students notice anything.&lt;/p&gt;
&lt;p&gt;The pre-generation pipeline follows a specific timeline relative to result publication. At twelve hours before results go live, the source database, which is an Aurora PostgreSQL instance, enters a data freeze. No further writes are permitted. This guarantees that the data I generate files from is identical to the data students will expect. At eight hours before, I load every result record into DynamoDB. DynamoDB is a fully managed NoSQL database that scales horizontally without any configuration. It serves as the fast-query fallback layer in case a student hits the API directly rather than the CDN. At six hours before, I preload Redis, which is an in-memory data store, with the complete dataset of 2.8 gigabytes. Redis is fast enough to return a result in under one millisecond. At four hours before, the PDF generation job runs, converting every result record into a formatted PDF and uploading it to S3. At two hours before, I optionally warm the CDN cache by prefetching files from S3 to edge nodes, so the first student request hits cache rather than origin. At T=0, I flip a feature flag that makes results visible and purge the CDN cache to force a fresh fetch of the live flag state.&lt;/p&gt;
&lt;p&gt;The file structure on S3 follows a predictable pattern: /results//.json for the result data and /results//.pdf for the marksheet. When a student enters their roll number and date of birth on the frontend, the JavaScript running in their browser computes the exact S3 path and requests it through the CDN. The CDN checks its cache. On a hit, it returns the file instantly. On a miss, it fetches from S3, stores the response in cache, and returns it. The date of birth validation happens on the client side for the CDN path and on the server side only if the student ends up hitting the fallback API. This is an important security consideration: the CDN path serves pre-signed URLs for PDFs, meaning each PDF URL is cryptographically signed with an expiry time and is tied to specific access parameters. A student who tries to guess another student&apos;s roll number will get a file, but accessing the PDF requires a valid signature that the frontend only generates after verifying the date of birth.&lt;/p&gt;
&lt;p&gt;Caching strategy varies by resource type. The root HTML page at &quot;/&quot; gets a 300-second TTL, which means edge nodes serve it from cache for five minutes before checking for updates. Static assets like JavaScript and CSS bundles get a one-year TTL because I use content-addressed filenames that change when the file changes. Result JSON files get a 60-second TTL with stale-while-revalidate behavior. Stale-while-revalidate means the CDN serves the cached version immediately to the user while fetching a fresh copy from origin in the background. This keeps latency low even when the cache is technically expired. PDF files get a 24-hour TTL because they are static and will never change once generated. Together, these TTLs mean the CDN serves almost every request from memory while still refreshing fast enough to reflect any emergency corrections.&lt;/p&gt;
&lt;p&gt;The API layer exists as a fallback and for internal operations, but it does not sit in the hot path. I run it on ECS Fargate, which is a serverless container execution environment on AWS. Fargate scales horizontally based on load without me provisioning individual servers. The API accepts requests only from the CDN&apos;s IP ranges, meaning students cannot bypass the CDN and hit the API directly. When a request does reach the API layer because of a CDN miss or a validation step, it first checks Redis. If Redis has the result, it returns it immediately. If Redis misses, it queries DynamoDB. If DynamoDB misses, it falls back to Aurora PostgreSQL. This three-tier fallback ensures no student request returns an error as long as at least one storage layer is functional. In practice, the Redis layer serves everything. Its entire dataset fits in memory and has been preloaded before result time.&lt;/p&gt;
&lt;p&gt;The Redis cluster runs on ElastiCache Serverless, which is AWS&apos;s managed Redis offering that scales capacity automatically. The full 2.8-gigabyte dataset of result records fits comfortably in memory. In-memory storage means retrieval time stays consistently below one millisecond at the ninety-ninth percentile. I preload Redis at T-6 hours by running a batch job that reads every record from DynamoDB and writes it into Redis in pipeline mode. Pipeline mode batches multiple Redis write commands into a single network round trip, making the preload fast enough to complete in under thirty minutes even for fourteen lakh records. DynamoDB runs in on-demand capacity mode, meaning AWS automatically allocates read and write units as needed without me setting a fixed capacity ceiling. This ensures DynamoDB never throttles requests even if Redis has a cold start and pushes unexpected load down the chain.&lt;/p&gt;
&lt;p&gt;Security in this system operates at multiple layers simultaneously. TLS 1.3, which is the latest version of the transport layer security protocol that encrypts data in transit, is enforced across every endpoint. Backend services run inside private subnets within a Virtual Private Cloud. A Virtual Private Cloud, or VPC, is an isolated network segment on AWS where resources are invisible to the public internet unless explicitly exposed. The Application Load Balancer, which sits between the CDN and the API layer, uses security group rules that accept connections only from known CDN IP ranges. IAM policies follow least-privilege rules, meaning each service has permission to do exactly what it needs and nothing more. Secrets like database credentials and signing keys live in AWS Secrets Manager rather than environment variables. No personally identifiable information appears in log outputs. PDF access requires a signed URL with an expiry window of fifteen minutes, making it time-limited and tied to a specific request context.&lt;/p&gt;
&lt;p&gt;The waiting room is the last line of defense before backend overload, and in this architecture it only activates if the CDN fails. I implement it using Cloudflare Waiting Room, which holds users in a virtual queue and admits them at a configurable rate. I set the initial active user limit to two to three lakh and tune the admission rate dynamically based on observed backend load. Cloudflare Waiting Room works at the edge, meaning it intercepts requests before they reach any origin server and holds overflow users on Cloudflare&apos;s infrastructure. The waiting room protects the API layer in the rare scenario where CDN traffic suddenly redirects to origin at scale. Without it, a CDN failure would translate directly into five lakh simultaneous requests hitting Fargate containers, which would cascade into Redis and then DynamoDB. The waiting room acts as a pressure valve that prevents that cascade.&lt;/p&gt;
&lt;p&gt;The multi-region strategy uses AWS ap-south-1 in Mumbai as the primary region and ap-south-2 in Hyderabad as the disaster recovery region. All data is replicated to the secondary region continuously. Aurora PostgreSQL uses Global Database, which replicates across regions with a replication lag under one second. DynamoDB uses Global Tables, which provide active-active replication meaning both regions can serve reads and writes simultaneously. Route 53 runs health checks against both regions every ten seconds. If the primary region fails, Route 53 detects the failure within thirty seconds and updates DNS records to point to the secondary region. CDN origins also have regional fallback configured, so the CDN switches origin endpoints without requiring DNS propagation to complete. My target recovery time objective is sixty seconds, meaning students experience at most one minute of degraded service during a full regional failure.&lt;/p&gt;
&lt;p&gt;Observability in this system centers on three things: CDN cache hit ratio, latency percentiles, and error rates. Cache hit ratio tells me whether the CDN is doing its job. If that number drops below ninety percent, it means a large portion of requests are hitting origin, which puts pressure on the API layer. I track latency at the fiftieth, ninety-fifth, and ninety-ninth percentiles. The ninety-fifth percentile target is 200 milliseconds and the ninety-ninth percentile target is 500 milliseconds. Anything above those thresholds triggers an alert. Error rates are segmented by status code: 4xx errors indicating client-side issues like invalid roll numbers and 5xx errors indicating server-side failures. I use Datadog for application-level metrics and AWS CloudWatch for infrastructure metrics. CDN logs from both Akamai and Cloudflare stream into a central log aggregation pipeline so I can correlate events across providers in real time.&lt;/p&gt;
&lt;p&gt;Failure scenarios map cleanly to the layered architecture. If Akamai goes down, Route 53 moves traffic to Cloudflare within seconds and students see a brief delay at most. If Redis fails entirely, every request falls to DynamoDB, which runs in on-demand mode and absorbs the load. The latency increases from sub-millisecond to single-digit milliseconds but the system keeps responding. If DynamoDB has a regional failure, Aurora PostgreSQL takes over as the source of truth. Aurora is not in the hot path by design, so this path is slow but functional. If the entire Mumbai region fails, Route 53 and CDN origin failover redirect traffic to Hyderabad within sixty seconds. The system is designed so that each failure degrades performance gradually rather than causing a hard outage. Students may notice slower responses in a cascading failure scenario. They will still get their results.&lt;/p&gt;
&lt;p&gt;The thing I find most satisfying about this architecture is what result day looks like for the engineering team. The CDN cache hit ratio sits at ninety-seven percent. The API layer processes a few hundred requests per second instead of five lakh. Redis memory usage holds steady at the preloaded 2.8 gigabytes. Aurora&apos;s connection count barely moves because it serves no student traffic. The Fargate containers handling the fallback API run at fifteen percent CPU utilization. Every metric is green. Fourteen lakh students get their results in under 200 milliseconds. The system is, in every meaningful sense, bored. That is the design goal. The system should handle the biggest traffic spike of the year the same way it handles a Tuesday afternoon. When you build around static assets and CDN-first delivery, peak load becomes a configuration concern rather than an engineering emergency.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>I Built a Claude Skill That Audits Your Code for Edge Cases Before They Reach Production</title>
<link>https://www.omrajguru.com/devnotes/interruptions</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/interruptions</guid>
<pubDate>Fri, 01 May 2026 04:21:16 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>interruptions is a Claude skill that walks your code through 12 failure categories, writes a full audit, and fixes issues in order of severity.</description>
<content:encoded>&lt;p&gt;Every product I shipped had edge cases I missed. I found them the hard way: through user reports, midnight rollbacks, and refund spirals that should have been caught during review. The pattern repeated across projects: the happy path worked, but the real world found gaps I had overlooked. A phone call mid-payment. A network drop during an upload. Two tabs racing each other to submit the same form. These are the moments that define whether a product feels reliable or fragile. I kept thinking there had to be a structured way to find these failures before users did. That question became interruptions.&lt;/p&gt;
&lt;p&gt;interruptions is a Claude skill. A Claude skill is a set of instructions and references you install into your Claude environment, and it activates when you ask Claude specific questions. Once installed, this skill triggers whenever you ask Claude to audit a flow, find what could go wrong, or stress test a feature. Claude then walks your code through 12 structured categories of failure modes, writes a comprehensive audit report to a Markdown file called &lt;code&gt;interruptions-audit.md&lt;/code&gt;, and waits for you to read it and confirm before touching anything. After you confirm, Claude helps you fix each issue in order of severity. The installation takes one command: &lt;code&gt;npx skills add omrajguru05/interruptions&lt;/code&gt;. From that point, the skill is available across Claude Code, Claude Desktop, and Claude.ai.&lt;/p&gt;
&lt;p&gt;The 12 categories exist because failure modes cluster into recognizable patterns. I pulled these patterns from years of shipping and watching the same classes of bugs appear across different products and teams. UX and user psychology covers how users behave irrationally, accidentally, or in ways your design overlooked. Security covers replay attacks, insecure direct object references, frontend tampering, and weak authentication. An insecure direct object reference happens when a user can guess or modify a URL parameter to access data that belongs to another user. Design and visual state covers how the UI communicates, or fails to communicate, what happened after an action. System flow and logic design covers breakdowns in workflow sequencing and state transitions.&lt;/p&gt;
&lt;p&gt;The remaining categories go deeper into the infrastructure and data layers. State and data integrity covers situations where the frontend, backend, and cache hold different versions of the same truth at the same time. Network and infrastructure covers timeouts, lost responses, and partial requests that leave the system in an ambiguous state. Concurrency and race conditions covers two operations happening simultaneously and producing a result that neither operation intended in isolation. Payment and transaction integrity covers the class of errors where money is involved and the cost of a mistake is real. Device and environment covers actual behavior differences across operating systems, browsers, and screen sizes that synthetic testing often overlooks. Synthetic testing means running automated scripts against your code in a controlled environment, which produces different results from a real user on a real device with a slow connection and five background tabs open.&lt;/p&gt;
&lt;p&gt;The final three categories address the boundaries of your inputs and your error paths. Accessibility and inclusion covers screen reader compatibility, keyboard navigation, right-to-left language support, zoom behavior, and color contrast ratios. Data validation and input handling covers malformed, extreme, or intentionally hostile input that reaches your backend. Error handling and recovery covers what happens after a failure and whether a user has a path forward, or is left with a generic toast message and a phone call to support. Each category has its own set of questions, red flags, and example fixes stored inside a references file that Claude loads during the audit. These references come from real production failures observed across multiple product types and team sizes. The checklist in &lt;code&gt;references/checklist.md&lt;/code&gt; is the canonical source the skill reads from during each audit run. It is also the file where community contributions make the biggest difference.&lt;/p&gt;
&lt;p&gt;When you trigger the skill, the audit phase runs first. Claude greets you, asks which flow or file you want audited, and then walks the 12 categories against your code one by one. The output is a structured audit written to &lt;code&gt;interruptions-audit.md&lt;/code&gt;, a Markdown file that lives in your project directory. Each finding in that file includes a severity rating, a file and line reference pointing to the exact location in your code, and a concrete fix with enough context to understand the change. Severity ratings follow a consistent scale so you can prioritize what to address first. The audit covers every category regardless of which flow you asked about, because failures in one area often trace back to a gap in another. After the audit is complete, the skill pauses and waits for you to read it.&lt;/p&gt;
&lt;p&gt;This waiting behavior is intentional and the most important design decision in the entire skill. Edge case fixes touch payment paths, authentication logic, and state machines. A state machine is a structure that tracks every possible state your application can be in, and which transitions between those states are valid. If Claude starts implementing changes before you read the audit, you lose the understanding of why each change is being made. So the skill holds at step four and asks you to read the audit and confirm in plain words before a single fix is applied. This confirmation gate is the difference between a tool that helps you ship better code and one that rewrites your codebase in ways you have to reverse engineer afterward. You stay in control of what gets changed and when.&lt;/p&gt;
&lt;p&gt;The fix phase runs under a strict set of safety rules I designed to prevent the skill from creating problems while solving them. Before any fix is applied, the skill checks that you are on a working branch, inventories your test, typecheck, and lint commands, and glances at your CI configuration to flag branches that auto-deploy to production. CI, which stands for continuous integration, is an automated system that runs your tests and checks whenever you push code. For each individual fix, the skill produces the smallest possible diff. A diff is a record of exactly which lines changed in a file and how they changed. The skill touches the function in question and its direct callers, shows you the planned change before applying it on high-risk paths, and runs your test suite after each edit. When a fix causes an existing test to fail, the skill stops entirely rather than rewriting the test to make the failure disappear.&lt;/p&gt;
&lt;p&gt;The project accepts contributions, and the contribution bar is high by design. The edge case checklist is a production tool, and the quality of its output depends on the quality of what goes into it. You can add new edge case scenarios across any of the 12 categories, improve clarity on existing ones, or propose an entirely new category if you find a recurring failure pattern that the current structure misses. Every contribution needs three things: a concrete scenario describing the failure, an impact section describing what goes wrong for the user or the system, and a suggested safeguard describing how to prevent or handle the failure. Vague descriptions belong outside the checklist. Specific, reproducible failure paths belong inside it. The process is: fork the repository, create a branch, make your changes, and submit a pull request with a clear explanation of what you changed and why it improves the project.&lt;/p&gt;
&lt;p&gt;I built interruptions because asking a developer to hold twelve distinct failure categories in their head during a focused review is an exercise in futility. A structured checklist handed to Claude and run against every flow is fundamentally more reliable than human memory and far more objective than a self-review written by the person who already &quot;knows&quot; the code works. This skill surfaces exactly what you missed, documents it clearly, and waits for your confirmation before helping you address each finding in order of priority. To ensure stability, the fix phase executes only one change at a time, followed by immediate test verification so you can see the impact of every individual tweak before moving to the next.&lt;/p&gt;
&lt;p&gt;If you run this on a flow and it flags a bug you were seconds away from shipping to production, then it is doing exactly what I built it to do.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>The Persistence of Intent</title>
<link>https://www.omrajguru.com/devnotes/intent</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/intent</guid>
<pubDate>Wed, 29 Apr 2026 12:41:13 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Software should remember what the user intended, even when the connection fails or the page refreshes. We are rethinking how state survives reality.</description>
<content:encoded>&lt;p&gt;Imagine you have spent several minutes refining a complex dashboard. You have set the date ranges, toggled the specific filters, and drilled down into the exact data points that tell your story. You copy the link from your browser address bar and send it to a colleague to show them what you found. When they click that link, they see a blank slate. The filters are gone. The context is lost. You realize that your application only existed in that specific moment on your specific screen.&lt;/p&gt;
&lt;p&gt;Now consider the person using your app while moving through a city. They fill out a detailed form, providing thoughtful input. They tap the button to submit their work just as their device loses its connection to the network. Instead of the data being preserved, it simply vanishes. A generic notification tells them to try again later. These are not just minor inconveniences. They are moments where the technology fails to respect the time and effort the user has invested.&lt;/p&gt;
&lt;p&gt;We have reached a point where we treat these issues as expected behaviors. Developers spend hours writing repetitive logic to synchronize the interface with the URL or to manage the fragile state of a network request. We write the same lines of code for every new project, trying to bridge the gap between what the user sees and what the system remembers. We have inadvertently accepted that application state is a fleeting thing that lives and dies in temporary memory.&lt;/p&gt;
&lt;p&gt;The URL is one of the most fundamental structures of the web. It was designed to be a permanent reference to a specific resource. Yet in many modern applications, the URL has become an afterthought. We allow important navigation and filter states to exist only in the memory of the current session. When a user interacts with the back button or refreshes their browser, the interface breaks because we stopped treating the URL as a reliable source of truth.&lt;/p&gt;
&lt;p&gt;The challenge of working offline is often treated with the same lack of permanence. Most tools assume a perfect, constant connection to the internet. When that connection falters, the application stops functioning. We have grown accustomed to building software for ideal conditions, testing our work on high speed networks while our users navigate a world of tunnels, basements, and rural gaps. We lack a standard way to ensure that a user’s data is safe regardless of their signal strength.&lt;/p&gt;
&lt;p&gt;These two problems are usually treated as separate technical hurdles. One is seen as a routing issue and the other as a data synchronization issue. Because they are handled by different tools and different libraries, they never communicate. We continue to build fragmented systems where the user’s intent is easily lost between the layers. We have not yet unified the way an application understands where it is and what it is holding.&lt;/p&gt;
&lt;p&gt;A better experience is possible. It is an experience where you define your data once and the system handles the rest. In this environment, every meaningful change in the interface is automatically reflected in the URL, making every state shareable and persistent. In this environment, the application is inherently resilient. It waits for the network to return and ensures that no piece of information is ever discarded. The developer does not write complex synchronization logic because the system is designed to be stable by default.&lt;/p&gt;
&lt;p&gt;This is a difficult problem to solve correctly. It requires a fundamental shift in how we think about the relationship between the user, the browser, and the server. We have been exploring a way to make this level of persistence feel natural and effortless for everyone involved. We are building a foundation that treats user intent as something that should never be lost. If these challenges represent the friction you feel in your daily work, there is more to discuss soon. Something new is being prepared.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>How to stop Claude from scanning your entire codebase every session</title>
<link>https://www.omrajguru.com/devnotes/claude</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/claude</guid>
<pubDate>Tue, 28 Apr 2026 10:39:40 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>WebDNA generates a single manifest file at build time. Claude reads that instead of crawling your files. Here is how to set it up.</description>
<content:encoded>&lt;p&gt;If you use Claude or Cursor on a real project, you have probably hit the point where it starts reading every file just to understand what the codebase looks like. It is not doing anything wrong. It just has no other way to get context. So it scans, and that scan costs tokens every single session.&lt;/p&gt;
&lt;p&gt;I wrote about WebDNA a while back. The short version is that it is a Next.js plugin that generates a JSON file at build time describing your routes, components, API endpoints, and brand tokens. AI agents read that file first instead of crawling your source.&lt;/p&gt;
&lt;p&gt;Since the first post I have added a two-tier manifest system so you can exclude private routes and sensitive components from what the AI sees, a fallback API route at /api/webdna for agents that cannot access .well-known directories, and a linter that catches missing descriptions and undeclared auth scopes before they become a problem.&lt;/p&gt;
&lt;p&gt;If you want to try it on your project, the full setup guide is at &lt;a href=&quot;https://webdna.omraj.guru/&quot;&gt;webdna.omraj.guru&lt;/a&gt;. It takes about five minutes and works with any Next.js project using App Router or Pages Router.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Building a Home for My Work</title>
<link>https://www.omrajguru.com/devnotes/home</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/home</guid>
<pubDate>Mon, 27 Apr 2026 16:45:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>I needed a space that proved I could build what I promised. This is how I moved my projects into a dedicated, high-standard home.</description>
<content:encoded>&lt;p&gt;Building projects.omrajguru.com was a necessity born from a shift in my professional trajectory. My previous website was essentially a digital forest where many different ideas lived together under one roof. It housed my software engineering thoughts, product design musings, and unpolished poetry alongside raw daily logs and technical tutorials. While that variety represented my curiosity, it created a diluted impression for potential clients. When I decided to pursue freelancing seriously as my primary income source, I realized my projects needed a dedicated space where they wouldn&apos;t be buried under &quot;quickships&quot; or &quot;dev notes.&quot; People needed to see my capabilities clearly so they could trust me with their own visions and products.&lt;/p&gt;
&lt;p&gt;The shift into freelancing meant that my digital presence was no longer just a hobby but the very engine that would fund my life. This pressure forced me to sit down and explore tech stacks with a level of scrutiny I had never applied to my personal work before. My primary requirement was that this new home must align perfectly with my personal design language. Not many people realize that I spent an entire year developing a specific visual system that reflects exactly who I am. I eventually converted this entire philosophy into a specialized skill for Claude so I could deploy it consistently across different web projects. It was non-negotiable that the new site felt like an extension of this identity.&lt;/p&gt;
&lt;p&gt;Beyond aesthetics, I needed a system that was incredibly functional and flexible while remaining portable and quick to manage. There is a specific kind of irony in being a developer who wants things to be fast and frictionless, but I have learned that hurdles in the workflow are where creative ideas go to die. If I spend all my time maintaining the infrastructure or fighting with a rigid CMS, I lose the energy required to actually document the work. I wanted a portal that felt like a professional tool but functioned with the ease of a modern application. This required a stack that could handle complexity without imposing a tax on my daily productivity or my creative flow.&lt;/p&gt;
&lt;p&gt;I have always been an admirer of the OpenAI website, particularly after exploring how they utilized Contentful to manage their complex structure. I loved their use of components and the way their design felt both structured and easy to use across different content types. As a developer, I felt a strong urge to master this platform because I knew that someday a client might demand it for their own high-scale product. To satisfy this curiosity, I actually built a smaller research website to house my findings. However, Contentful had one major drawback that I could not ignore which was the pricing model. The jump from a free trial to three hundred dollars a month was not reasonable for my current needs.&lt;/p&gt;
&lt;p&gt;The cost barrier of Contentful eventually led me to land on Sanity as my primary choice for content management. I had been attempting to learn Sanity for a long time, but I struggled to pull it off according to my specific Site Reliability Engineering standards (fn1). Despite the initial steep learning curve, I decided that I would sit down and not get up until I had truly finished this implementation. There is a specific mindset required to build a product of this caliber, and I had to accept that greatness is an iterative process. You simply cannot expect to reach the level of a company like Apple on your very first day of development.&lt;/p&gt;
&lt;p&gt;During this process, a close friend told me that you cannot become Apple without doing exactly what they did in the first place. This piece of advice resonated deeply with me and shaped my final decision regarding the technology stack. I chose Next.js because it provides the perfect balance between high performance interactivity and superior SEO, which is absolutely critical for a professional portfolio. By leveraging features like Static Site Generation and Incremental Static Regeneration, the site delivers near-instant page loads for complex project case studies. This ensures that the content remains fresh and easily indexable by search engines while maintaining a premium and cinematic user experience for every visitor.&lt;/p&gt;
&lt;p&gt;Next.js also allowed me to implement smooth Framer Motion animations and rich media blocks without compromising on core web vitals. To host this architecture, Vercel was the only logical choice because it is the native home for the framework. Its global edge network ensures that high-resolution project images and interactive components are delivered with ultra-low latency to users all around the world. Features like Preview Deployments allow for frictionless testing of new &quot;Premium Components&quot; before they are pushed to the live production environment. By automatically handling SSL and global CDN distribution, Vercel let me focus entirely on the design and storytelling aspects of my work.&lt;/p&gt;
&lt;p&gt;Sanity became the backbone of the project because it treats content as data rather than just static text. This distinction is essential for a portfolio that relies on complex and interactive components rather than basic paragraphs. Unlike traditional CMS platforms that limit users to a basic rich text editor, Sanity’s Structured Content approach allowed me to define highly specialized schemas. I could build out specific blocks for interactive charts, comparison sliders, and live code sandboxes. This flexibility is powered by Portable Text, which provides a JSON-based format that my Next.js frontend can easily parse and render into the custom-styled React components I had already built.&lt;/p&gt;
&lt;p&gt;The editorial experience within Sanity Studio is another reason why this stack is so superior for a solo freelancer. The studio is seamlessly embedded directly into the application at a specific route, providing a real-time collaborative environment. I can manage my work posts, adjust SEO metadata, and preview my changes instantly without ever having to leave my own domain. When combined with the power of GROQ, which is Sanity’s query language, and the next-sanity client, the project can fetch only the specific data needed for each page. This keeps the site lightning-fast and responsive even as my body of work grows more complex and detailed over the coming years.&lt;/p&gt;
&lt;p&gt;Once the tech stack was finalized, I used my own Site Reliability Engineering book (fn2) to handle the scaffolding of the project. This book is a Claude skill I developed over five years to ensure quality and strict design adherence. I used the Claude Opus 4.7 model to create a detailed manifest file (fn3) that served as the blueprint for the entire application. I then connected all the essential tools and credentials into a local environment file (fn4) to prepare for the build. Claude Opus 4.7 was selected because it represents a landmark leap in AI coding ability with significantly higher resolution rates on production tasks.&lt;/p&gt;
&lt;p&gt;The performance of Opus 4.7 is backed by impressive benchmarks, showing a thirteen percent improvement over previous versions. It can run autonomously for hours, catching its own mistakes and performing proofs on systems code before even starting the writing process. I found it incredibly impressive that it could handle a full million-token context window, making it perfect for working on large and integrated codebases. It took roughly thirty minutes to create the initial version of the app, and it actually got everything right on the very first try. This success was a combination of the model’s capabilities and the specific skills I had pre-installed to guide it.&lt;/p&gt;
&lt;p&gt;The most important part of the build was the creation of custom components that power the storytelling aspect of my project case studies. I sat down and explored the OpenAI website again, opening my Obsidian vault to write down twenty-eight specific components I wanted to implement. These components needed to be tailored to my specific use cases and aligned with my love for the Apple design language. I organized these ideas with the help of Gemini 3.1 Pro, which helped me categorize them and explain their functions in simple sentences. Then I sat down without the internet to manually iterate on the design tokens and refine the visual identity.&lt;/p&gt;
&lt;p&gt;My storytelling components include things like the ChartBlock, which renders dynamic and interactive charts to prove performance gains with visual evidence. I also implemented a SandboxBlock that allows users to interact with a live code environment directly on the page. This makes my technical expertise tangible and verifiable for any potential client who wants to see the code in action. The ComparisonBlock provides an interactive before-and-after slider for images, which is the most effective way to showcase the impact of a redesign. I also added a VersusBlock to highlight winners and trade-offs between two different technical solutions to show my reasoning.&lt;/p&gt;
&lt;p&gt;Core feature components like the FeaturedGrid and ProjectCard handle the main logic and display of the portfolio&apos;s structure. The ProjectCard serves as the visual entry point for each project, using optimized thumbnails to hook the user and encourage them to read more. I also built a SearchFilter that provides real-time filtering for categories and tech tags so recruiters can find specific skills instantly. The PortableTextRenderer acts as the central orchestrator that maps the Sanity data to my React components. This system ensures that everything I write in the CMS renders perfectly as a premium and custom-styled element on the frontend.&lt;/p&gt;
&lt;p&gt;The structural foundation of the site includes components like the Navbar, which I designed to function almost like a dynamic island. This reinforces the professional and polished feel of the entire application, ensuring that the brand identity is consistent on every page. I also included a NewsletterForm to turn passive visitors into a community by allowing me to push updates directly to those interested in my work. Every part of the UI, from the buttons to the containers, follows the same structural rules to prevent any design drift. This cohesive system makes the entire site feel like a single, well-thought-out product.&lt;/p&gt;
&lt;p&gt;Once all the components were defined, I gave the final manifest to Claude and asked it to build the entire library. This process took over forty-five minutes because of the complexity of the skills involved, but the output was exceptionally good. The resulting site is not just a collection of links but a high-performance engine that showcases my work with the precision it deserves. I am proud to share this story because it represents the culmination of years of learning and a commitment to high engineering standards. It is a testament to what is possible when you combine the right tools with a clear and focused vision.&lt;/p&gt;
&lt;p&gt;Building this platform has taught me that the tools we choose are just as important as the code we write. By selecting a stack that rewards structure and performance, I have created a home for my projects that can grow alongside my career. I no longer feel the friction of a cluttered website, and I can now present my work with the same level of care that I put into the projects themselves. This journey from a messy digital garden to a streamlined project portal has been one of the most rewarding experiences of my professional life. It proves that with enough focus, you can build a digital presence that truly reflects your standards.&lt;/p&gt;
&lt;p&gt;I hope this detailed breakdown of my process provides some insight into how modern portfolios can be engineered for both beauty and utility. The combination of Next.js, Sanity, and advanced AI models has allowed me to reach a level of quality that would have been impossible just a few years ago. By treating my portfolio as a product, I have ensured that I am ready for the next chapter of my career as a freelancer. This site is now the definitive home for my vision, and I look forward to seeing how it evolves as I continue to build and document new things. Thank you for following along with this little story of how I built my new home.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Footnotes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(fn1)&lt;/strong&gt; SRE standards refer to my personal Site Reliability Engineering benchmarks for uptime, code modularity, and deployment safety.&lt;br&gt;
&lt;strong&gt;(fn2)&lt;/strong&gt; The SRE book is a custom knowledge base and Claude skill developed over five years to enforce best practices.&lt;br&gt;
&lt;strong&gt;(fn3)&lt;/strong&gt; The Claude.md file serves as a comprehensive system prompt and project blueprint for AI-assisted development.&lt;br&gt;
&lt;strong&gt;(fn4)&lt;/strong&gt; The .env.local file stores the necessary API keys and environment variables required to link the frontend with Sanity and Vercel.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Better typography, built automatically</title>
<link>https://www.omrajguru.com/devnotes/typesnap</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/typesnap</guid>
<pubDate>Fri, 24 Apr 2026 18:52:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Eliminate font-induced layout shifts and manual preloading with a build tool that automatically detects, matches, and optimizes your typography.</description>
<content:encoded>&lt;p&gt;Web performance is often a game of managing trade-offs, but typography should not be one of them. For years, developers have manually balanced the visual appeal of custom fonts against the technical debt of layout shifts. To solve this properly, you generally have to coordinate three distinct parts: early preloading, font-display strategies, and metric-matched fallback stacks. Getting these right is a repetitive and error-prone process that persists even in modern frameworks.&lt;/p&gt;
&lt;p&gt;TypeSnap is a new command-line tool and build plugin designed to handle this entire workflow through static analysis. It scans your project to identify every font you use, whether they are hosted locally, pulled from Google Fonts, or declared in your Tailwind configuration. Once it understands your font landscape, it generates the necessary assets to ensure your site loads efficiently and remains visually stable while files are being fetched.&lt;/p&gt;
&lt;p&gt;The tool focuses on three specific outputs. It creates preload tags so the browser knows to prioritize font files the moment a user lands on your site. It also ensures every font declaration includes the appropriate display rules to prevent invisible text. Finally, it generates a CSS fallback stack using a database of font metrics. This adjusts the size and spacing of system fonts so they occupy the same space as your custom brand fonts, effectively eliminating the jump in layout when the primary font finishes loading.&lt;/p&gt;
&lt;p&gt;TypeSnap is not a runtime library or a font hosting service. It operates entirely during your build process, meaning it adds no weight to your production bundle. It is built to work seamlessly with Vite and Next.js, and it can even be integrated into your continuous integration pipeline to prevent new font issues from reaching your users. By automating the technical requirements of web typography, it lets you focus on design without worrying about the performance cost.&lt;/p&gt;
&lt;p&gt;This project is entering its final stages of preparation and will be available for general use in three days. By shifting the burden of font optimization from the developer to the build tool, TypeSnap makes a stable and fast web the default state for your project.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Building My Own AI-Powered iCloud</title>
<link>https://www.omrajguru.com/devnotes/foundationdb</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/foundationdb</guid>
<pubDate>Mon, 20 Apr 2026 00:52:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Tired of juggling multiple free-tier accounts, I engineered a custom, hybrid-cloud backend-as-a-service using Apple’s database of choice, FoundationDB, and integrated it directly into my AI coding workflow via a private MCP server.</description>
<content:encoded>&lt;p&gt;As a developer with an ever-growing portfolio, I reached a breaking point with &quot;Free Tier sprawl.&quot; Juggling a dozen Supabase accounts for a dozen different projects wasn&apos;t just messy—it was inefficient. To solve this, I decided to build my own unified backend UI inspired by Supabase but powered by FoundationDB (FDB). Choosing FDB was a nod to my love for Apple’s engineering; it’s the same distributed transactional engine they use to power iCloud. By building my own &quot;layer&quot; on top of FDB, I’ve gained the ability to scale my projects indefinitely without ever hitting a subscription ceiling.&lt;/p&gt;
&lt;p&gt;The infrastructure is where this project truly breathes, utilizing a sophisticated dual-hosted hybrid cloud model. I host the core data storage and heavy-duty processing in my home lab to maintain total ownership and zero costs, while leveraging AWS to host the public-facing UI and API gateway. This setup gives me the best of both worlds: the raw privacy and economy of a self-hosted environment combined with the global uptime and low-latency edge presence of a major cloud provider. It’s a rock-solid foundation that feels as snappy as a native Apple service.&lt;/p&gt;
&lt;p&gt;The real &quot;secret sauce&quot; of this build is the integration of a custom Model Context Protocol (MCP) server. I’ve developed specific &quot;skills&quot; for my private MCP server that allow Claude Code to interact directly with my FoundationDB instance. While I’m coding, the AI isn’t just guessing—it has the power to read schemas, push updates, and perform live read/write operations directly to my database. This creates a seamless loop where the AI understands my data layer as deeply as I do, turning my IDE into a command center for my entire project ecosystem.&lt;/p&gt;
&lt;p&gt;This &quot;massive win&quot; is more than just a way to save on hosting fees; it’s a customized developer experience tailored to how I think and build. By combining the legendary reliability of FoundationDB with a hybrid AWS-home setup and AI-driven automation, I’ve built a platform that can grow with me for years. I no longer spend time configuring infrastructure for new ideas—I just tell my AI what to build, and the foundation is already there, waiting.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Introducing the HIG Compliance Auditor for Claude Code</title>
<link>https://www.omrajguru.com/devnotes/auditor</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/auditor</guid>
<pubDate>Tue, 07 Apr 2026 09:20:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>The HIG Compliance Auditor is a custom skill for Claude Code designed to automate the technical review of software projects against Apple’s Human Interface Guidelines.</description>
<content:encoded>&lt;p&gt;The HIG Compliance Auditor is a custom skill for Claude Code designed to automate the technical review of software projects against Apple’s Human Interface Guidelines. This skill enables Claude to perform deep scans of local repositories to identify design violations, generate structured compliance reports, and apply remediation changes directly within the terminal environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Functional Overview of the Skill&lt;/h3&gt;
&lt;p&gt;The HIG Compliance Auditor functions as a specialized extension for Claude Code that focuses on design system integrity. By adding this skill to your development environment, you provide Claude with the specific logic and documentation required to audit Swift, SwiftUI, UIKit, and web technologies. The tool identifies inconsistencies in typography, color systems, and layout by cross-referencing your code against official Apple standards.&lt;/p&gt;
&lt;p&gt;This skill covers the full spectrum of Apple platforms, including iOS, iPadOS, macOS, watchOS, tvOS, and visionOS. It also incorporates checks for App Store Review Guidelines and AI transparency requirements. By integrating these checks directly into the Claude Code workflow, the auditor removes the guesswork associated with manual compliance checks and reduces the risk of rejection during the App Store review process.&lt;/p&gt;
&lt;h3&gt;Technical Capabilities and Remediation&lt;/h3&gt;
&lt;p&gt;When initiated, the auditor generates a detailed report that categorizes compliance issues into specific severity tiers. This allows developers to prioritize critical failures that could impact user experience or platform approval. The skill enables Claude to analyze complex interface elements, such as Liquid Glass materials and spatial layout principles, to ensure a high-fidelity implementation of the Apple ecosystem&apos;s visual language.&lt;/p&gt;
&lt;p&gt;A primary feature of this skill is the ability to perform automated remediation. Claude suggests multiple fix options and, upon user approval, applies these changes directly to the source code. To maintain technical transparency, every modification includes an inline attribution that references the exact section of the Human Interface Guidelines being addressed.&lt;/p&gt;
&lt;h3&gt;Installation and Integration&lt;/h3&gt;
&lt;p&gt;I developed this skill to transition design auditing from a manual documentation search to an automated workflow within Claude Code. Because the tool is a custom skill, it allows Claude to act as a persistent reference guide for maintaining platform-specific standards throughout the development lifecycle.&lt;/p&gt;
&lt;p&gt;The HIG Compliance Auditor is available for installation via the repository at &lt;a href=&quot;https://github.com/omrajguru05/hig-compliance-auditor&quot;&gt;github.com/omrajguru05/hig-compliance-auditor&lt;/a&gt;. By cloning the repository and adding the skill to your Claude Code configuration, you can begin running comprehensive audits directly inside your terminal to ensure your project meets Apple’s rigorous design and accessibility standards. You can also view more of my projects and tools at &lt;a href=&quot;https://om.bearblog.dev/projects/&quot;&gt;https://om.bearblog.dev/projects/&lt;/a&gt;.&lt;/p&gt;</content:encoded>
</item>
<item>
<title>WebDNA: A structured interface for AI agents</title>
<link>https://www.omrajguru.com/devnotes/interface</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/interface</guid>
<pubDate>Mon, 06 Apr 2026 11:27:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Every website currently forces AI tools to guess how they work. WebDNA provides a zero-configuration standard for Next.js projects to share their architecture, design tokens, and component logic in a machine-readable format.</description>
<content:encoded>&lt;p&gt;When I watch an AI agent or a coding tool interact with a website today, the process feels inefficient. These models are essentially forced to squint at raw HTML and minified JavaScript bundles while attempting to reverse-engineer the original intent of the developer. They miss the relationship between components, they lose the nuances of brand colors, and they struggle to understand the shape of underlying APIs. I built WebDNA to solve this by creating a bridge between the way humans write code and the way machines need to consume it.&lt;/p&gt;
&lt;p&gt;The core idea is a manifest that lives at a predictable location on your server. By integrating directly into the Next.js build process, WebDNA automatically scans your project and generates a blueprint of your site. It looks at your route tree, extracts design tokens from your Tailwind configuration, and maps out your component hierarchy. This happens without requiring any manual documentation from the developer. It turns the implicit knowledge stored in your codebase into an explicit, structured briefing for any AI that visits your site.&lt;/p&gt;
&lt;p&gt;This system moves beyond existing solutions like sitemaps or basic text files. While those tools offer a list of URLs or high-level summaries, they do not explain the design system or the specific data requirements of a dynamic route. WebDNA provides that missing context. It allows a developer to define which parts of a site are private and which components serve specific roles, ensuring that when an AI tool accesses the site, it does so with a full understanding of the constraints and the brand guidelines.&lt;/p&gt;
&lt;p&gt;Security and privacy are foundational to this standard. The manifest is a static JSON file that cannot execute code and is read-only by design. I have included features like element-level exclusion, where a simple attribute can hide specific sections of a page from the AI manifest. This ensures that developers remain in total control of what information is shared. It is about providing consent-based, structured access rather than leaving AI tools to scrape whatever they can find.&lt;/p&gt;
&lt;p&gt;My goal with WebDNA is to make the web more accessible to the next generation of software tools. By making the architecture of a site discoverable, we reduce the friction and errors that occur when AI tries to navigate or modify web projects. It is a simple addition to a configuration file that provides a significant upgrade in how machines perceive and interact with the work we build. Information about the package and the specification is available on GitHub and through the official documentation.&lt;/p&gt;
&lt;p&gt;Official documentation: &lt;a href=&quot;https://webdna.omraj.guru/&quot;&gt;webdna.omraj.guru&lt;/a&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>I Am Building WebDNA: A Zero-Config AI Context Standard for the Web</title>
<link>https://www.omrajguru.com/devnotes/webdna</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/webdna</guid>
<pubDate>Wed, 01 Apr 2026 18:37:00 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Every AI agent that touches your website is flying blind. It squints at raw HTML, guesses your component structure, and forces you to re-explain your own project from scratch every single session. I got tired of that problem, so I decided to build the fix.</description>
<content:encoded>&lt;h2&gt;The Problem I Could Not Ignore&lt;/h2&gt;
&lt;p&gt;I spend a significant amount of my development time working with agentic tools. Claude Code, Cursor, Windsurf -- these tools are genuinely useful, but they share a fundamental flaw: they have no structured understanding of the website they are working on. Every new session, I find myself doing the same thing. Explaining the route structure. Pasting in component trees. Describing the brand language. Pointing out which API endpoints exist and what they expect.&lt;/p&gt;
&lt;p&gt;This is not a workflow problem. It is an infrastructure problem. The web has no standard way to hand an AI agent a structured briefing about a website before it starts working. And that missing layer is costing developers real time, every single day.&lt;/p&gt;
&lt;p&gt;The existing standards do not solve this. &lt;code&gt;llms.txt&lt;/code&gt;, proposed as an AI-friendly content layer, operates closer to &lt;code&gt;robots.txt&lt;/code&gt; -- it tells AI what it can read, but gives it no understanding of the site&apos;s structure, components, or design system. &lt;code&gt;sitemap.xml&lt;/code&gt; handles page discovery for search crawlers, not semantic understanding. MCP (Model Context Protocol) is a powerful protocol that has seen strong adoption across Claude, OpenAI, and Google Gemini in 2025 and into 2026, but it requires a custom server per project -- a setup cost that is simply too high for most developers building standard websites and apps. OpenAPI specs cover API surfaces but say nothing about UI, brand, or layout.&lt;/p&gt;
&lt;p&gt;There is a gap. A significant one. And I want to fill it.&lt;/p&gt;
&lt;h2&gt;What WebDNA Is&lt;/h2&gt;
&lt;p&gt;WebDNA is a build-time generated, framework-native manifest served at a standardised URL that gives any AI agent a complete, structured briefing about a website before it touches anything.&lt;/p&gt;
&lt;p&gt;The analogy I keep coming back to: when a new designer joins your team, you hand them a brand guidelines document before they open Figma. You do not make them reverse-engineer the design system from the live site. Right now, every AI that works on your codebase has to do exactly that -- reverse-engineer everything from raw HTML and scattered source files. WebDNA is that brand guidelines document, auto-written by your framework on every single build.&lt;/p&gt;
&lt;p&gt;The core principles I am designing around:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero config by default.&lt;/strong&gt; It works out of the box. No setup, no YAML, no configuration file required to get started.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Always in sync.&lt;/strong&gt; Because it is generated directly from your build process, it cannot go stale. The moment your code changes, your WebDNA changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Framework-native.&lt;/strong&gt; This is not a file you write manually. It is a file your framework produces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consent-first.&lt;/strong&gt; Private routes, auth-gated content, and dynamic data are never exposed without explicit developer consent and user-initiated authorization flows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read-only, always.&lt;/strong&gt; AI agents can read WebDNA. They cannot write to it. This is a core security principle, not a limitation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open standard.&lt;/strong&gt; No vendor lock-in. Any framework should be able to implement it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What the Output Looks Looks Like&lt;/h2&gt;
&lt;p&gt;WebDNA is a JSON file served at &lt;code&gt;/.well-known/webdna.json&lt;/code&gt;. Here is a representative example of what it looks like for a portfolio site:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;meta&quot;: {
    &quot;name&quot;: &quot;My Portfolio&quot;,
    &quot;description&quot;: &quot;Personal portfolio and blog by Om Rajguru&quot;,
    &quot;version&quot;: &quot;1.0.0&quot;,
    &quot;generatedAt&quot;: &quot;2026-04-01T12:00:00Z&quot;,
    &quot;tier&quot;: &quot;custom&quot;
  },
  &quot;brand&quot;: {
    &quot;colors&quot;: {
      &quot;primary&quot;: &quot;#0F172A&quot;,
      &quot;accent&quot;: &quot;#6366F1&quot;,
      &quot;background&quot;: &quot;#FFFFFF&quot;,
      &quot;text&quot;: &quot;#1E293B&quot;
    },
    &quot;typography&quot;: {
      &quot;heading&quot;: &quot;Cal Sans&quot;,
      &quot;body&quot;: &quot;Inter&quot;
    },
    &quot;tone&quot;: &quot;Professional, minimal, direct&quot;
  },
  &quot;routes&quot;: {
    &quot;/&quot;: {
      &quot;type&quot;: &quot;static&quot;,
      &quot;description&quot;: &quot;Homepage with hero section and featured work grid&quot;
    },
    &quot;/blog/[slug]&quot;: {
      &quot;type&quot;: &quot;dynamic&quot;,
      &quot;dataSource&quot;: &quot;/api/posts&quot;,
      &quot;schema&quot;: {
        &quot;title&quot;: &quot;string&quot;,
        &quot;body&quot;: &quot;richtext&quot;,
        &quot;author&quot;: &quot;string&quot;,
        &quot;publishedAt&quot;: &quot;date&quot;,
        &quot;tags&quot;: &quot;string[]&quot;
      }
    },
    &quot;/dashboard&quot;: {
      &quot;type&quot;: &quot;private&quot;,
      &quot;requiresAuth&quot;: true,
      &quot;auth&quot;: &quot;oauth2&quot;,
      &quot;scopes&quot;: [&quot;read:profile&quot;, &quot;read:orders&quot;],
      &quot;exclude&quot;: true
    }
  },
  &quot;components&quot;: {
    &quot;Navbar&quot;: &quot;Top navigation bar with logo and 4 page links&quot;,
    &quot;HeroSection&quot;: &quot;Full-width hero with headline, subtext, and CTA button&quot;,
    &quot;BlogCard&quot;: &quot;Card showing post title, date, tag, and excerpt&quot;,
    &quot;Footer&quot;: &quot;Links, social icons, and copyright notice&quot;
  },
  &quot;api&quot;: {
    &quot;/api/posts&quot;: {
      &quot;method&quot;: &quot;GET&quot;,
      &quot;description&quot;: &quot;Returns all published blog posts&quot;,
      &quot;example_request&quot;: &quot;/api/posts?limit=10&amp;#x26;tag=design&quot;,
      &quot;example_response&quot;: {
        &quot;title&quot;: &quot;Building a Design System&quot;,
        &quot;author&quot;: &quot;Om Rajguru&quot;,
        &quot;publishedAt&quot;: &quot;2026-03-01&quot;,
        &quot;slug&quot;: &quot;building-a-design-system&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a single file, an AI agent gets the site name and description, the complete color palette and typography, every route and its type (static, dynamic, or private), the data schema for dynamic routes, every component with a plain-English description, and API endpoints with real usage examples.&lt;/p&gt;
&lt;p&gt;This is everything I would tell a new developer on my first day pairing with them. Structured, machine-readable, and always current.&lt;/p&gt;
&lt;h2&gt;Two Tiers, One Principle&lt;/h2&gt;
&lt;p&gt;WebDNA has two operating modes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WebDNA Auto&lt;/strong&gt; is the default. No configuration required. It generates on every build, captures everything public, and gets served at &lt;code&gt;/.well-known/webdna.json&lt;/code&gt;. For portfolios, blogs, landing pages, and marketing sites, this is the only thing you ever need.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WebDNA Custom&lt;/strong&gt; is for developers who need granular control. You get page-level exclusions through a config file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// webdna.config.js
module.exports = {
  webdna: {
    exclude: [&quot;/pricing&quot;, &quot;/internal&quot;],
    privateRoutes: [&quot;/dashboard&quot;, &quot;/settings&quot;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And element-level exclusions through a single HTML attribute:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-jsx&quot;&gt;&amp;#x3C;p data-webdna=&quot;exclude&quot;&gt;
  This paragraph is completely invisible to any AI reading WebDNA.
&amp;#x3C;/p&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Custom output lives at &lt;code&gt;/.well-known/webdna-custom.json&lt;/code&gt;. AI agents check for the Custom version first and fall back to Auto if it does not exist. The fallback logic is simple, predictable, and requires nothing from the developer.&lt;/p&gt;
&lt;h2&gt;Static File or Live API Route&lt;/h2&gt;
&lt;p&gt;One decision I spent considerable time thinking through is how WebDNA behaves on sites where content is dynamic. A blog that pulls posts from a CMS cannot produce a complete picture at build time. A SaaS app with per-tenant route structures cannot serve a single static file for all users.&lt;/p&gt;
&lt;p&gt;The solution I landed on mirrors how Next.js handles &lt;code&gt;sitemap.xml&lt;/code&gt;. WebDNA is static by default. When the framework detects dynamic data sources, it automatically becomes a live API route that reflects the current state of the site on every request. For Next.js ISR projects, it revalidates on the configured interval. For full SaaS apps with multi-tenant architectures, it becomes a tenant-scoped endpoint served per subdomain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;acme.yourapp.com/.well-known/webdna.json
google.yourapp.com/.well-known/webdna.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each tenant sees only their own routes and data. Cross-tenant leakage is impossible by design, because each endpoint is scoped to the authenticated session.&lt;/p&gt;
&lt;p&gt;The developer never selects the mode manually. The framework detects it.&lt;/p&gt;
&lt;h2&gt;Private Routes and the Auth Model&lt;/h2&gt;
&lt;p&gt;One of the harder problems in this space is how AI agents should handle auth-gated content. The two approaches people use today are both bad: either share credentials with the AI tool (insecure) or block AI entirely from private routes (unusable).&lt;/p&gt;
&lt;p&gt;WebDNA takes a different path. Private routes declare their OAuth 2.0 scopes directly in the manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;/dashboard&quot;: {
  &quot;type&quot;: &quot;private&quot;,
  &quot;requiresAuth&quot;: true,
  &quot;auth&quot;: &quot;oauth2&quot;,
  &quot;scopes&quot;: [&quot;read:profile&quot;, &quot;read:orders&quot;]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When an AI agent encounters a private route, it initiates a consent prompt. The user explicitly approves access. The agent receives a scoped, time-limited token. The token expires. No persistent access without re-consent. No credentials stored in or transmitted through the WebDNA file.&lt;/p&gt;
&lt;p&gt;This is the same pattern as granting a third-party app access to your Google Calendar or GitHub repositories. It is well-understood, widely implemented, and secure. I am not inventing a new auth model. I am applying an established one to a problem where it is currently absent.&lt;/p&gt;
&lt;h2&gt;The Read-Only Guarantee&lt;/h2&gt;
&lt;p&gt;I want to address this directly because it will come up: WebDNA is read-only, and that is permanent.&lt;/p&gt;
&lt;p&gt;Allowing AI agents to write back to WebDNA would introduce prompt injection risk (a malicious agent could embed instructions that manipulate other AI agents reading the file later), silent drift (descriptions diverge from actual code without the developer knowing), and a fundamental collapse of trust in the standard itself. The entire value of WebDNA rests on it being a reliable, developer-authored source of truth. Write access destroys that.&lt;/p&gt;
&lt;p&gt;If AI feedback on WebDNA quality is ever desirable, it will go through a separate, opt-in suggestions endpoint that requires explicit developer approval before anything changes -- surfaced as a pull request, never auto-merged. The Dependabot model. AI opens a PR, developer merges it. Feedback yes, write access no.&lt;/p&gt;
&lt;h2&gt;The Validator&lt;/h2&gt;
&lt;p&gt;WebDNA ships with a linter from day one. Run it with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx webdna lint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It catches issues like routes marked private with no auth scope declared, components with no description, dynamic routes with no schema, and &lt;code&gt;data-webdna&lt;/code&gt; attributes on non-existent elements. Think ESLint, but for your WebDNA configuration. It will integrate with VS Code for inline squiggles, CI/CD pipelines for build-time enforcement, and pre-commit hooks.&lt;/p&gt;
&lt;h2&gt;How WebDNA Compares to What Exists Today&lt;/h2&gt;
&lt;p&gt;I want to be honest about the landscape here. There are real solutions trying to solve adjacent problems. This is how I see the current state:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sitemap.xml&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;MCP&lt;/th&gt;
&lt;th&gt;WebDNA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;Page discovery&lt;/td&gt;
&lt;td&gt;AI content permissions&lt;/td&gt;
&lt;td&gt;Structured data access&lt;/td&gt;
&lt;td&gt;Full AI website briefing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup required&lt;/td&gt;
&lt;td&gt;Auto in Next.js&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Server per project&lt;/td&gt;
&lt;td&gt;Zero config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Component info&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand and design tokens&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic content schema&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live API examples&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth and private routes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant support&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Element-level exclusion&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validator&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always in sync&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (manual)&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write access&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No (by design)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;MCP is genuinely good at what it does, and its adoption has been rapid across 2025. But it is designed for structured, permissioned data access at an infrastructure level. WebDNA is designed for the much simpler, much more common case: a developer who wants their AI coding tools to understand their website without setting up a server. These are complementary tools, not competing ones.&lt;/p&gt;
&lt;h2&gt;The Roadmap&lt;/h2&gt;
&lt;p&gt;Development is starting now. Here is the plan:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1&lt;/strong&gt; is the Next.js Plugin MVP. I am building &lt;code&gt;next-webdna&lt;/code&gt; as a standalone NPM package with a single activation flag (&lt;code&gt;webdna: true&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;), auto-generation from the route tree and Tailwind tokens at build time, automatic static/dynamic mode detection, and the published open spec for the JSON format.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2&lt;/strong&gt; adds WebDNA Custom: the config file, element-level exclusions, and the separate custom output endpoint.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 3&lt;/strong&gt; introduces live API route mode for dynamic sites, OAuth scope declaration per private route, and CMS adapter plugins for Contentful, Sanity, and Notion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 4&lt;/strong&gt; handles multi-tenant support with subdomain and tenant-ID based scoping and full per-session data isolation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 5&lt;/strong&gt; ships the validator: the CLI tool, VS Code extension, and CI/CD integration guide.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 6&lt;/strong&gt; is the push for broader adoption: an RFC to Next.js and Vercel for native integration, adapters for SvelteKit, Astro, and Nuxt, and direct outreach to the teams building Cursor, Windsurf, Claude Code, and GitHub Copilot to build native WebDNA reading into their context engines.&lt;/p&gt;
&lt;h2&gt;What Comes Next&lt;/h2&gt;
&lt;p&gt;The project is private during development. I am building in the open in the sense that I am documenting the thinking here, but the repository will go public when Phase 1 reaches a usable state. If you want to follow along, this blog is the place to do it.&lt;/p&gt;
&lt;p&gt;I am also genuinely interested in feedback on the spec. If you work on an agentic tool and have thoughts on what context you wish you had when working on a website, I would like to hear from you. If you are building on Next.js and have a use case this does not cover, same thing. The spec is a draft. The best time to shape it is before the first line of plugin code ships.&lt;/p&gt;
&lt;p&gt;The web deserves a proper AI context layer. I am going to build one.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;WebDNA is an original concept. Development started April 2026.&lt;/em&gt;&lt;/p&gt;</content:encoded>
</item>
<item>
<title>Is Your Project Ready to Launch? I Built a Skill to Find Out</title>
<link>https://www.omrajguru.com/devnotes/launch</link>
<guid isPermaLink="true">https://www.omrajguru.com/devnotes/launch</guid>
<pubDate>Wed, 01 Apr 2026 03:35:25 GMT</pubDate>
<dc:creator>Om Rajguru</dc:creator>
<description>Shipping fast is great. Shipping ready is better. Here&apos;s how I stopped guessing and started knowing.</description>
<content:encoded>&lt;p&gt;Shipping fast is great. Shipping ready is better. Here&apos;s how I stopped guessing and started knowing.&lt;/p&gt;
&lt;p&gt;I used to think I had a solid launch checklist. It lived in a doc somewhere, it had checkboxes, and I told myself I would actually use it. Spoiler: I did not always use it. Features would go out missing a monitoring alert, or a README that said &quot;TODO,&quot; or a rollback plan that existed only in my head. Every time, the feeling was the same. That quiet dread of realizing something got missed after it was already live.&lt;/p&gt;
&lt;p&gt;So I built something to fix that. Launch Readiness is a skill I created to make pre-launch checks feel less like homework and more like a conversation. Instead of digging through a doc I inevitably forgot to update, I can just ask. It walks through the things that actually matter before a release: documentation, monitoring, rollback strategy, stakeholder sign-off, and more. It does not let me skip ahead. It asks, it listens, and it flags what is missing.&lt;/p&gt;
&lt;p&gt;What I love most about it is that it changed how my team thinks about launches. We stopped treating readiness as a formality and started treating it as a real conversation. The skill is not a gatekeeper. It is more like a thoughtful colleague who asks the right questions at the right time, and genuinely wants the launch to go well.&lt;/p&gt;
&lt;p&gt;The best part is that you can try it yourself right now. Setup is straightforward, and within a few minutes you will have a launch process that actually holds up under pressure.&lt;/p&gt;
&lt;p&gt;Launching something you built is exciting. I want that excitement to last past day one. This is my small contribution to making that happen.&lt;/p&gt;</content:encoded>
</item>
</channel>
</rss>