<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Sammii</title>
    <description>The latest articles on Forem by Sammii (@sammiihk).</description>
    <link>https://forem.com/sammiihk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F312075%2F8e735862-585e-4a1d-a6ff-3904467e0ebc.jpeg</url>
      <title>Forem: Sammii</title>
      <link>https://forem.com/sammiihk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/sammiihk"/>
    <language>en</language>
    <item>
      <title>How I built Lunary's birth chart reader</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Tue, 24 Mar 2026 08:00:00 +0000</pubDate>
      <link>https://forem.com/sammiihk/how-i-built-lunarys-birth-chart-reader-37e2</link>
      <guid>https://forem.com/sammiihk/how-i-built-lunarys-birth-chart-reader-37e2</guid>
      <description>&lt;p&gt;The birth chart feature is the core of Lunary. Every other feature either feeds from it or refers back to it. Building it properly took longer than I expected, mostly because "properly" turned out to have more requirements than I'd initially scoped.&lt;/p&gt;

&lt;p&gt;This is the story of how that feature was built: the calculation layer, the visual representation, the AI interpretation, and the iterations that got it from a rough prototype to something I'm proud of.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a birth chart actually contains
&lt;/h2&gt;

&lt;p&gt;A complete birth chart has more elements than most apps show. Lunary calculates and displays:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Planets&lt;/strong&gt;: Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, and Chiron. Each has an ecliptic longitude (position in the zodiac), a latitude (angular distance from the ecliptic plane), a declination (angular distance from the celestial equator), and a speed (degrees per day, which indicates retrograde when negative).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Points&lt;/strong&gt;: The North Node (also called the True Node, computed from the Moon's orbital ascending node), the South Node (opposite the North Node), and the Part of Fortune (calculated from Ascendant + Moon - Sun, or the reversed formula for night charts).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;House cusps&lt;/strong&gt;: All 12 Placidus house cusps, computed from the local sidereal time at the birth location and time. The Ascendant is the 1st house cusp and the Midheaven (MC) is the 10th house cusp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aspects&lt;/strong&gt;: All angular relationships between planets and points that fall within orb for the major aspect types (conjunction, sextile, square, trine, quincunx, opposition). Displayed as a grid showing each planet pair.&lt;/p&gt;

&lt;p&gt;That's the data layer. The visual layer has to present all of this in a way that's readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chart visual
&lt;/h2&gt;

&lt;p&gt;The traditional birth chart wheel is a circle divided into 12 houses, with the zodiac signs running around the outside and planets placed at their positions within the wheel. This is the standard visual that anyone who has studied astrology will recognise, and departing from it creates friction.&lt;/p&gt;

&lt;p&gt;Lunary's chart visual is an SVG rendered server-side. The zodiac outer ring, house lines, and planet glyphs are all drawn as SVG paths and text elements. The positions are computed from the ecliptic longitudes and the Ascendant angle.&lt;/p&gt;

&lt;p&gt;The main challenge in chart wheel layout is planet clustering. When two or more planets are close together in the zodiac, their glyphs overlap. Real chart software handles this with a "displacement" algorithm that adjusts the angle of crowded glyphs while keeping a line connecting them to their actual position. Implementing this correctly took several iterations. The naive approach was to offset clustered planets by a fixed amount, which worked for pairs but failed for clusters of three or more. The final algorithm sorts planets in a cluster by longitude, then distributes them evenly within the cluster space while preserving the order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data panel
&lt;/h2&gt;

&lt;p&gt;Alongside the wheel, Lunary shows a structured data panel with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A table of all planetary positions (sign, degree, minute, retrograde status)&lt;/li&gt;
&lt;li&gt;A table of house cusps&lt;/li&gt;
&lt;li&gt;An aspect grid (a triangular matrix where each cell shows the aspect between two planets)&lt;/li&gt;
&lt;li&gt;A section for dignity and debility (planets in their rulership, exaltation, fall, or detriment)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dignity section took research to implement correctly. Every planet has rulership over certain signs (Mercury rules Gemini and Virgo, Venus rules Taurus and Libra, etc.), exaltation in one sign (the Sun is exalted in Aries, the Moon in Taurus, etc.), fall in the sign opposite its exaltation, and detriment in the sign opposite its rulership. These are classical astrological concepts that Lunary surfaces automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI interpretation layer
&lt;/h2&gt;

&lt;p&gt;The AI interpretation is where the most interesting engineering happened. The problem with using a language model for birth chart interpretation is straightforward: models have been trained on a lot of astrological content and will happily produce natal chart readings that sound plausible but aren't grounded in the user's actual positions.&lt;/p&gt;

&lt;p&gt;A model given "interpret this Scorpio rising" will produce a reading about Scorpio rising in general, drawing on all the Scorpio rising content it was trained on. That's not what we want. We want a reading that references the specific degree of the Ascendant, the Ascendant ruler (Mars or Pluto for Scorpio), where that ruler is placed, and how that placement interacts with other chart factors.&lt;/p&gt;

&lt;p&gt;The solution was to not ask the model to interpret the chart from scratch. Instead, we describe the chart to the model in astronomical terms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Natal chart data:
- Sun: 14°32' Gemini, 3rd house, applying trine to Saturn (orb: 2°15')
- Moon: 28°47' Pisces, 12th house, separating conjunction with Neptune (orb: 3°22')
- Mercury: 2°19' Cancer, 3rd house, retrograde, applying square to Mars (orb: 4°07')
[... all planets]

House cusps:
- 1st (Ascendant): 3°41' Scorpio
- 10th (Midheaven): 14°22' Leo
[... all cusps]

Active aspects:
[... list with orbs]

Task: Write a narrative birth chart interpretation based on the above positions...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This framing forces the model to reason about the specific data provided rather than recall general astrological content. The chart is described as a set of facts and the model is asked to synthesise those facts into a narrative.&lt;/p&gt;

&lt;p&gt;The improvement in output quality from this approach was significant. Interpretations that use this structured prompt consistently reference the actual positions, mention specific aspects with their orbs, and integrate the house placements correctly. Generic interpretations that don't mention the specific data are much rarer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the user sees
&lt;/h2&gt;

&lt;p&gt;The finished birth chart experience walks through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A visual wheel with all planets and house divisions&lt;/li&gt;
&lt;li&gt;A "chart summary" that highlights the three most significant placements (usually Ascendant, Sun, Moon) in natural language&lt;/li&gt;
&lt;li&gt;A full planetary positions table&lt;/li&gt;
&lt;li&gt;An AI narrative covering the major themes of the chart&lt;/li&gt;
&lt;li&gt;The aspect grid with tappable aspects that link to grimoire articles about those specific aspect combinations&lt;/li&gt;
&lt;li&gt;A dignity and debility section&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tappable aspects are probably the feature I'm most proud of. You see that your Sun squares Saturn, you tap it, and you get the grimoire article specifically about Sun square Saturn in a natal chart. That connection between the live chart data and the educational content is exactly what I wanted to build when I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iteration process
&lt;/h2&gt;

&lt;p&gt;The first version of the chart reader was much simpler: positions table, no visual, basic AI summary. The visual wheel came in a later iteration after I established that users found the raw data table confusing without the visual context.&lt;/p&gt;

&lt;p&gt;The AI interpretation went through the most iterations. The first version used a basic system prompt asking the model to "interpret this birth chart" with the chart positions appended. The output was superficially plausible but not grounded in the data. The second version described the chart in structured astronomical terms, which improved grounding significantly. The current version also includes a passage from the grimoire for each major placement, which grounds the model's interpretive vocabulary in Lunary's own educational content.&lt;/p&gt;

&lt;p&gt;Each iteration was motivated by actual user feedback. The things that seemed obvious from a developer perspective (of course the positions table is useful) were not always obvious to users, and the things I thought were secondary (the aspect grid) turned out to be some of the most used features.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astrology</category>
      <category>ai</category>
      <category>product</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The maths behind how long a planet stays in your sign</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Sat, 21 Mar 2026 09:00:00 +0000</pubDate>
      <link>https://forem.com/sammiihk/the-maths-behind-how-long-a-planet-stays-in-your-sign-21jb</link>
      <guid>https://forem.com/sammiihk/the-maths-behind-how-long-a-planet-stays-in-your-sign-21jb</guid>
      <description>&lt;p&gt;Most astrology content rounds the numbers. Saturn spends "about 2.5 years" in each sign. Jupiter spends "about a year." These are averages. They are also wrong for any specific transit you actually care about.&lt;/p&gt;

&lt;p&gt;Saturn spent 2.7 years in Pisces (March 2023 to February 2026). It will spend 2.1 years in Taurus (April 2028 to June 2030). That is a six-month difference. For a planet whose transits define entire chapters of your life, six months is not a rounding error.&lt;/p&gt;

&lt;p&gt;When I built transit duration tracking into Lunary, the real numbers surprised me. They also changed how I read charts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Orbits are not circles
&lt;/h2&gt;

&lt;p&gt;The reason planets spend different amounts of time in different signs is orbital eccentricity. Planetary orbits are ellipses, not circles. A planet moves faster when it is closer to the Sun (perihelion) and slower when it is further away (aphelion). This is Kepler's second law: a planet sweeps out equal areas in equal times.&lt;/p&gt;

&lt;p&gt;For Saturn, the difference is measurable. Its orbital eccentricity is 0.0565, which means its distance from the Sun varies by about 11% between perihelion and aphelion. When Saturn is closer to the Sun, it moves through signs faster. When it is further away, it lingers.&lt;/p&gt;

&lt;p&gt;The zodiac is fixed against the stars. But the planet's speed through that fixed backdrop changes continuously. The result: Saturn in Pisces lasts 974 days. Saturn in Taurus lasts 778 days. Nearly 200 days difference, same planet.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual numbers from Lunary's ephemeris
&lt;/h2&gt;

&lt;p&gt;Here is what Lunary's transit engine computes for Saturn, using real ephemeris data from astronomy-engine (VSOP87):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sign&lt;/th&gt;
&lt;th&gt;Total days&lt;/th&gt;
&lt;th&gt;Years&lt;/th&gt;
&lt;th&gt;Segments&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Aquarius&lt;/td&gt;
&lt;td&gt;911&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pisces&lt;/td&gt;
&lt;td&gt;974&lt;/td&gt;
&lt;td&gt;2.7&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aries&lt;/td&gt;
&lt;td&gt;888&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Taurus&lt;/td&gt;
&lt;td&gt;778&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini&lt;/td&gt;
&lt;td&gt;773&lt;/td&gt;
&lt;td&gt;2.1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancer&lt;/td&gt;
&lt;td&gt;859&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leo&lt;/td&gt;
&lt;td&gt;840&lt;/td&gt;
&lt;td&gt;2.3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Virgo&lt;/td&gt;
&lt;td&gt;908&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "segments" column matters. When Saturn retrogrades, it can briefly re-enter the previous sign before moving forward again. Saturn in Leo has three segments: it enters, retrogrades back, re-enters, retrogrades back again, then finally commits. The total cumulative time is 840 days across those three passes. If you only counted the first entry, you would think Saturn left Leo after a few months.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the calculation works
&lt;/h2&gt;

&lt;p&gt;Lunary uses two different strategies depending on how fast the planet moves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fast planets&lt;/strong&gt; (Moon through Mars) use a simple formula: degrees remaining in the sign divided by the planet's daily motion. The Moon moves about 13.2 degrees per day. If it is at 22 degrees of Aries, it has 8 degrees remaining, which gives roughly 14.5 hours. Mercury averages 4.1 degrees per day but varies enormously, from barely moving during a retrograde station to covering 2 degrees in a single day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fast planets: degrees to boundary ÷ daily motion&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;degreeInSign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentLongitude&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;degreesRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;degreeInSign&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remainingDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;degreesRemaining&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;dailyMotion&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail: Lunary uses the actual observed daily motion from astronomy-engine, not the average. For the Moon, actual speed varies between 12.2 and 14.8 degrees per day, a 20% range. Using the average would put the Moon's sign change time off by hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slow planets&lt;/strong&gt; (Jupiter through Pluto) use pre-computed ephemeris data. A script scans forward through time at one-day resolution, recording every moment each planet changes sign. The results are stored as date segments. At runtime, Lunary looks up which segment the current date falls in and calculates remaining time from there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Slow planets: lookup from pre-computed sign segments&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SLOW_PLANET_SIGN_CHANGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;planet&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;currentSign&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;activeSegment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remainingDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;activeSegment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;msPerDay&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pre-computation handles retrograde re-entries correctly. Saturn entering Aries, retrograding back into Pisces for four months, then re-entering Aries is stored as two separate Aries segments. The total duration sums across all segments, so users see "2.4 years total in Aries" rather than being confused when Saturn briefly disappears back into Pisces.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retrograde makes everything harder
&lt;/h2&gt;

&lt;p&gt;Retrograde is when a planet appears to move backwards through the zodiac from Earth's perspective. It is an optical illusion caused by relative orbital speeds, like overtaking a car on the motorway and watching it appear to drift backwards.&lt;/p&gt;

&lt;p&gt;For fast planets during retrograde, the simple degrees-remaining formula breaks entirely. If Mercury is retrograde at 15 degrees of Gemini, it is moving backwards, towards 14, 13, 12 degrees. Dividing remaining degrees by daily motion would give a nonsensical answer because the planet is heading the wrong way.&lt;/p&gt;

&lt;p&gt;Lunary handles this by scanning forward with astronomy-engine to find the actual station direct date: the moment the planet stops moving backwards and resumes forward motion.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Scan forward to find when retrograde ends&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;futureDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;msPerDay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;motion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dailyMotion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;futureDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;motion&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Retrograde ended — refine to half-day precision&lt;/span&gt;
    &lt;span class="c1"&gt;// Linear interpolation between the two days&lt;/span&gt;
    &lt;span class="nx"&gt;endDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;futureDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prevMotion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives accurate retrograde durations. Mercury retrograde lasts about three weeks, not the four days that the naive formula would calculate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this changed how I read charts
&lt;/h2&gt;

&lt;p&gt;Before building transit duration tracking, I read transits as a list. Saturn is in Pisces. Jupiter is in Gemini. Mars is in Leo. Each item was equally present, equally weighted.&lt;/p&gt;

&lt;p&gt;Adding duration context changes the reading fundamentally. "Saturn has been in Pisces for 22 months and has 10 months remaining" is a completely different statement from "Saturn is in Pisces." The first tells you where you are in a story. The second just names the chapter.&lt;/p&gt;

&lt;p&gt;The badge display in Lunary shows remaining time: "10m left" for Saturn, "3d left" for Mars, "14h left" for the Moon. Seeing these together reveals the layering. The Moon changes the emotional texture every 2.3 days. Mars shifts the energy every six weeks. Saturn provides the structural backdrop for 2-3 years.&lt;/p&gt;

&lt;p&gt;The cache system reflects this too. Saturn's position is cached for seven days because it barely moves. The Moon's position is cached for 15 minutes. Near sign boundaries, all cache TTLs drop by 75% so the transition is captured accurately. The infrastructure mirrors the astrology: fast planets need frequent attention, slow planets reward patience.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pluto is the extreme case
&lt;/h2&gt;

&lt;p&gt;Pluto's orbital eccentricity is 0.2488, far more eccentric than any other planet. Its transit durations are wildly uneven. Pluto spent about 15 years in Capricorn. It will spend roughly 20 years in Aquarius. In some signs it lingers for over 30 years. In others, barely 12.&lt;/p&gt;

&lt;p&gt;An entire generation shares a Pluto sign. The variation in duration means some generations carry that shared transformation for twice as long as others. The "Pluto in Capricorn" generation (roughly 2008-2024) had a shorter collective experience than the "Pluto in Aquarius" generation that follows.&lt;/p&gt;

&lt;p&gt;This is not trivia. If you believe outer planet transits shape collective experience, then the duration is the intensity. A 12-year Pluto transit and a 30-year Pluto transit are not the same kind of event, even if the sign is the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers are the astrology
&lt;/h2&gt;

&lt;p&gt;I have found that the more precisely I engage with the actual orbital mechanics, the more astrology makes sense, not less. Rounding Saturn to "2.5 years" obscures the fact that Saturn in Pisces is a qualitatively different experience from Saturn in Taurus, not just because of the signs, but because one lasts six months longer. Six months more pressure. Six months more consolidation. Six months more of whatever Saturn in that sign is working on in your chart.&lt;/p&gt;

&lt;p&gt;The ancient astrologers did not have VSOP87 or astronomy-engine. But they watched the sky every night for centuries. They knew these durations intimately. The maths we compute in milliseconds, they observed over lifetimes. The precision is modern. The knowledge is ancient.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a Grimoire Twist: astrology education with a Lunary engineering angle.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astrology</category>
      <category>engineering</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every culture agrees</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Thu, 19 Mar 2026 09:00:00 +0000</pubDate>
      <link>https://forem.com/sammiihk/every-culture-agrees-1fg4</link>
      <guid>https://forem.com/sammiihk/every-culture-agrees-1fg4</guid>
      <description>&lt;p&gt;Saturday is Saturn's day. Sunday belongs to the Sun. Monday is the Moon's day. We say these words every week without thinking about them. But the names are not arbitrary. They are a cosmological system hiding in plain view, embedded so deeply into daily life that we forgot it was ever a teaching.&lt;/p&gt;

&lt;p&gt;The Hermetic tradition, some of the oldest philosophical writing we have, describes seven planetary bodies that govern the physical world. Their government over this world is called Fate and Destiny. The Divine Pymander, one of the earliest Hermetic texts, puts it plainly: these seven governors "in their circles contain the Sensible World."&lt;/p&gt;

&lt;p&gt;Seven planets. Seven days. Seven spheres the soul must pass through.&lt;/p&gt;




&lt;p&gt;In the previous two parts of this series, I wrote about the question that started all of this: do we really die? And about the pattern I found across traditions, independently arriving at the same philosophical claim. The body is temporary. Consciousness is not.&lt;/p&gt;

&lt;p&gt;This piece goes deeper into the mechanism. Not just what the ancients believed, but what they described happening. Because the Hermetic creation narrative is not vague. It is precise, structured, and it maps onto something I did not expect when I first read it in a university library at 21: the exact thing that astrology calculates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mind before matter
&lt;/h2&gt;

&lt;p&gt;There are two models of creation. The contemporary one, which most of us absorbed without questioning, says matter came first. Stars formed, planets coalesced, chemistry became biology, biology became neurology, and at some point consciousness flickered into existence as a byproduct of sufficient complexity.&lt;/p&gt;

&lt;p&gt;The ancient model inverts this entirely. Mind came first. Thoughts from a greater entity were manifested into physical reality. The Hermetica describes a universe created by Atum, the Cosmic Mind, not through physical force but through thought. The first thing that existed was an intention. Everything else followed from it.&lt;/p&gt;

&lt;p&gt;Jonathan Black, in &lt;em&gt;The Secret History of the World&lt;/em&gt;, describes the sequence: "The earliest episodes in history are to be understood in terms of the ordered creation of the solar system. One after the other Saturn, the Sun, Venus, the Moon and Jupiter joined in the work of weaving together the basic conditions that made possible the evolution of life on earth."&lt;/p&gt;

&lt;p&gt;The planetary bodies are not decoration. They are the architecture.&lt;/p&gt;




&lt;h2&gt;
  
  
  The seven spheres
&lt;/h2&gt;

&lt;p&gt;The Hermetic texts describe seven planetary spheres arranged concentrically around Earth. Each sphere is governed by one of the classical planets: Moon, Mercury, Venus, Sun, Mars, Jupiter, Saturn. Together, they form a structure through which consciousness must travel to enter the physical world.&lt;/p&gt;

&lt;p&gt;The teaching works in both directions.&lt;/p&gt;

&lt;p&gt;When a soul enters physical existence, it descends through each sphere in turn, acquiring qualities from each planet as it passes. The Moon gives instinct and the capacity for growth. Mercury gives speech and reason. Venus gives desire. The Sun gives vitality and will. Mars gives courage and aggression. Jupiter gives ambition and the capacity for leadership. Saturn gives structure, limitation, and material form.&lt;/p&gt;

&lt;p&gt;By the time the soul reaches Earth, it carries the imprint of every sphere it passed through. It has been shaped by the journey.&lt;/p&gt;

&lt;p&gt;At death, the process reverses. The disembodied spirit ascends back through each sphere, shedding the qualities it acquired. Black describes this passage: "From the lunar sphere the disembodied spirit flies upwards to the realm of Mercury. From there to Venus and then on to the sun." At each stop, the soul returns what it borrowed. What remains at the end is what was there before the journey began: consciousness, stripped of its planetary clothing.&lt;/p&gt;

&lt;p&gt;I wrote this in the margin of my MA notebook: "The order of the gods, which brought about order to this world, is the reverse order which the spirit will embark on to then enter into the spiritual dimension."&lt;/p&gt;




&lt;h2&gt;
  
  
  The astrology connection
&lt;/h2&gt;

&lt;p&gt;Here is what stopped me in the library.&lt;/p&gt;

&lt;p&gt;If the seven planetary spheres each contribute specific qualities to the soul on its descent into physical life, then the configuration of those planets at the moment of birth is a record of what the soul acquired. The natal chart is not a prediction. It is a receipt.&lt;/p&gt;

&lt;p&gt;The Hermetic texts are explicit about this. The Divine Pymander calls the governance of the seven spheres "Fate or Destiny." This is not metaphor. It is a philosophical claim about the structure of consciousness itself: that who you are, your temperament, your drives, your capacity for love or ambition or discipline, was shaped by the planetary configuration at the moment your consciousness entered a body.&lt;/p&gt;

&lt;p&gt;This is what astrology calculates. Not "what will happen to you on Tuesday," but the specific qualities your consciousness carries from its journey through the spheres. When a natal chart shows Venus in a particular position, the Hermetic reading is that your soul acquired a specific quality of desire as it passed through that sphere. When Saturn dominates, you carry more of the structure and limitation that the outermost sphere imparts.&lt;/p&gt;

&lt;p&gt;I did not expect this. I went into the library researching consciousness and light. I came out understanding why I would later build an astrology platform.&lt;/p&gt;




&lt;h2&gt;
  
  
  Everyone tells the same story
&lt;/h2&gt;

&lt;p&gt;What made the research overwhelming was not any single tradition. It was the convergence.&lt;/p&gt;

&lt;p&gt;The Polynesian creation myth describes the world emerging from the union of Ao (light) and Po (darkness). The Maori tradition begins with forces of earth and sky joined. The Chinese creation myth describes the universe as an egg containing chaos, which broke into light sky and dense earth. The Norse tradition starts with Ginnungagap, a vast emptiness, from which emerged Muspelheim (fire) and Niflheim (ice). The Greek tradition has Gaea emerging from Chaos.&lt;/p&gt;

&lt;p&gt;Every single one of these follows the same pattern: everything came from one thing, and at the beginning there was nothing.&lt;/p&gt;

&lt;p&gt;The Hermetica calls this the movement from unity to duality. The Cosmic Mind, which is everything, creates a physical dimension that appears separate from itself. Light and dark. Spirit and matter. The dualistic nature of reality is not a cultural coincidence. It is the philosophical foundation that every tradition independently describes.&lt;/p&gt;

&lt;p&gt;The Greek mythology fits the Hermetic framework precisely. Zeus (Jupiter) overthrew his father Cronus (Saturn). Saturn had swallowed five of his children. The lineage runs Uranus, Saturn, Jupiter: the outermost spheres in descending order. This is not entertainment. It is cosmological instruction encoded in narrative. The time the environment changed to allow life to develop into advanced forms is remembered as the time Jupiter overthrew Saturn.&lt;/p&gt;




&lt;h2&gt;
  
  
  The price of individuality
&lt;/h2&gt;

&lt;p&gt;There is a paradox at the centre of the Hermetic teaching that I have not stopped thinking about since I first read it.&lt;/p&gt;

&lt;p&gt;The Cosmic Mind, Atum, contains everything. It is everything. But because it is everything, it cannot look upon itself. It cannot know itself the way we can know it. The Divine Pymander puts it this way: "God is intelligible, not to himself, but to us."&lt;/p&gt;

&lt;p&gt;We can do something that the source of all consciousness cannot. We can create, think, and observe in ways that the Cosmic Mind cannot, because we have individuality. We have separation. We have a point of view.&lt;/p&gt;

&lt;p&gt;But individuality came at a cost. The denser our physical state became through evolution, the more we lost our connection to the Cosmic Mind, and to each other. Separation gave us creativity, science, art, the capacity to build and understand. It also gave us grief, sorrow, loneliness, ego.&lt;/p&gt;

&lt;p&gt;I wrote in my MA: "In a sense, we are greater than Atum, or god, or the immortal. We can do things, create things and think things that Atum cannot itself, but creates through us with our minds and bodies. We can know Atum in a way it cannot know itself, but this individuality brought with it grief, sorrow and many other negative possibilities which our life, emotions and senses also bring."&lt;/p&gt;

&lt;p&gt;This is the trade. Consciousness pays for self-awareness with disconnection. We gained everything by losing everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sleeping Beauty has seven fairies for a reason
&lt;/h2&gt;

&lt;p&gt;The Hermetic teaching did not stay in ancient texts. It embedded itself in stories we tell children.&lt;/p&gt;

&lt;p&gt;Sleeping Beauty has seven fairies. Black identifies them as the seven gods of the planetary spheres. The seventh fairy, who represents Saturn, the spirit of materialism, curses the child with death, which is commuted to a long period of sleep. Life in the material realm is the sleep. The physical body is the enchantment.&lt;/p&gt;

&lt;p&gt;Wordsworth understood this. His 1807 ode, the one I painted onto the walls of my light installation in invisible UV-reactive ink, says it directly:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our birth is but a sleep and a forgetting:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;The Soul that rises with us, our life's Star,&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Hath had elsewhere its setting,&lt;/em&gt;&lt;br&gt;
&lt;em&gt;And cometh from afar:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Not in entire forgetfulness&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The soul as a star, rising with us in life and setting with us in death. It comes from afar, from the Cosmic Mind, through the seven spheres. And although this life is a sleep for the soul, it does not entirely forget where it came from.&lt;/p&gt;

&lt;p&gt;Every tradition, every myth, every fairy tale that survived long enough to reach us carries this structure. The seven spheres. The descent. The forgetting. The qualities acquired along the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for what I built
&lt;/h2&gt;

&lt;p&gt;A few years after finishing the MA, I built Lunary. I did not build it because I thought horoscopes were fun. I built it because the ancient cosmology had a logic I trusted.&lt;/p&gt;

&lt;p&gt;The planetary bodies are the seven spheres. The natal chart is the map of the soul's descent. The transits, the current positions of the planets as they move through the sky, are the ongoing conversation between your acquired qualities and the spheres that gave them to you.&lt;/p&gt;

&lt;p&gt;I am not saying this is literally true in the way that gravity is true. I am saying that the philosophical framework is coherent, that it has been maintained across millennia and cultures with remarkable consistency, and that writing it off as superstition requires ignoring some of the most careful thinkers in human history.&lt;/p&gt;

&lt;p&gt;The next piece in this series looks at what Hermes Trismegistus specifically taught, the figure at the centre of all of this, a synthesis of Thoth and Hermes, Egyptian and Greek wisdom combined.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part 3 of A Journey Through Light.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>consciousness</category>
      <category>philosophy</category>
      <category>astrology</category>
      <category>writing</category>
    </item>
    <item>
      <title>A component library that builds itself every day</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Tue, 17 Mar 2026 09:00:00 +0000</pubDate>
      <link>https://forem.com/sammiihk/a-component-library-that-builds-itself-every-day-3c73</link>
      <guid>https://forem.com/sammiihk/a-component-library-that-builds-itself-every-day-3c73</guid>
      <description>&lt;p&gt;What if a component library didn't wait for you to have a free weekend? What if it woke up every morning, found something interesting on the internet, and built a new interactive component before you'd finished your coffee?&lt;/p&gt;

&lt;p&gt;That's Prism. It's my design engineering component library, and it builds itself every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aesthetic
&lt;/h2&gt;

&lt;p&gt;Every component in Prism shares a visual language I'd describe as "quiet confidence". The background is near-black (#050505). Colours are luminous pastels that shift and respond to your cursor position. Nothing is loud. Nothing demands attention. But everything moves.&lt;/p&gt;

&lt;p&gt;Spring physics drive every animation. Not CSS transitions, not cubic beziers; actual spring equations running in requestAnimationFrame. The result is motion that feels physical. A 2-3 pixel overshoot on a hover. A 2-3 degree tilt that settles naturally.&lt;/p&gt;

&lt;p&gt;The cursor is the colour source. Move it to the top-left and you get cool blues. Bottom-right brings warm pinks. Every component inherits this, so the whole library feels like one coherent thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deep dive: MagneticButton
&lt;/h2&gt;

&lt;p&gt;MagneticButton was the first component I built by hand. Hover near it and the button drifts toward your cursor, pulled by a spring force. The text inside shifts independently, creating a subtle parallax. A soft glow appears beneath it, tinted by the cursor-reactive colour system.&lt;/p&gt;

&lt;p&gt;The implementation is pure React and requestAnimationFrame. No animation libraries. What makes it interesting isn't any single effect. It's how small each effect is. The displacement is maybe 8 pixels. The tilt is barely perceptible. But stack them together and the button feels alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The autonomous pipeline
&lt;/h2&gt;

&lt;p&gt;Prism has a four-stage daily pipeline that runs without me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scout&lt;/strong&gt; fires up a headless browser and trawls X for trending UI interaction patterns. It collects references and descriptions of novel hover effects, scroll-triggered animations, and creative uses of WebGL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Curator&lt;/strong&gt; takes the scout's findings and picks today's build. It considers what's already in the library, what would complement existing components, and what's technically feasible. It writes a detailed build brief.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Builder&lt;/strong&gt; takes the brief and writes the component. Real TypeScript, real Framer Motion or raw requestAnimationFrame. It creates the component file, a demo page, and registers it in the gallery. Then it runs the build to make sure everything compiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publisher&lt;/strong&gt; records a 12-second video of the component in action using Playwright with an organic cursor simulation, then posts it to X, Bluesky, and Mastodon via Spellcast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build it this way?
&lt;/h2&gt;

&lt;p&gt;I wanted to see what happens when you automate the creative process and let it compound. One component a day is 365 a year. Even if half are mediocre, that's still a substantial library of interactive, cursor-reactive components that all share the same visual DNA.&lt;/p&gt;

&lt;p&gt;The pipeline also forces consistency. Every component goes through the same curator. Every component uses the same colour system. Every component gets tested, recorded, and published the same way.&lt;/p&gt;

&lt;p&gt;For now, it wakes up every morning and builds something new. That's enough.&lt;/p&gt;

</description>
      <category>react</category>
      <category>design</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Our birth is but a sleep</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Mon, 09 Mar 2026 23:51:22 +0000</pubDate>
      <link>https://forem.com/sammiihk/our-birth-is-but-a-sleep-nml</link>
      <guid>https://forem.com/sammiihk/our-birth-is-but-a-sleep-nml</guid>
      <description>&lt;h1&gt;
  
  
  Our birth is but a sleep
&lt;/h1&gt;

&lt;p&gt;There are words written on the walls of a room I built in 2017. You cannot see them.&lt;/p&gt;

&lt;p&gt;Walk through the room under red light, then orange, yellow, green, blue, violet. The full visible spectrum, one colour at a time. The walls stay blank. Just white fabric. Nothing there.&lt;/p&gt;

&lt;p&gt;Then you reach ultraviolet.&lt;/p&gt;

&lt;p&gt;And the words appear.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Our birth is but a sleep and a forgetting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;William Wordsworth wrote that in 1807. I painted it onto stretched fabric in invisible UV-reactive paint because I thought it was the most important sentence I had ever read. I still do.&lt;/p&gt;




&lt;p&gt;I was 21 and writing an MA dissertation. The question I was supposed to be answering was something academic: about light as artistic medium, about the visible spectrum as subject matter. But the question I was actually asking, the one underneath all the research, was simpler and more frightening:&lt;/p&gt;

&lt;p&gt;Do we really die?&lt;/p&gt;

&lt;p&gt;Not in a morbid way. In the way you ask a question that nobody around you will take seriously, so you spend nine months in the university library instead, reading everything you can find.&lt;/p&gt;




&lt;p&gt;What I found surprised me.&lt;/p&gt;

&lt;p&gt;Not because the answer was obvious. But because every tradition I looked at: Egyptian, Greek, Norse, Hindu, Buddhist, Hermetic. All asking exactly the same question. All arriving, through completely different routes, at something like the same answer.&lt;/p&gt;

&lt;p&gt;The body is temporary. Consciousness is not.&lt;/p&gt;

&lt;p&gt;That is not a religious claim. It is a very old philosophical one. And the more I read, the Hermetic Corpus, the Egyptian Book of the Dead, the Upanishads, the Norse cosmology, the Greek mystery schools, the more I understood that what we call mythology is often something else entirely. It is philosophy dressed in story. Instruction dressed in metaphor. Knowledge that could not be written plainly, so was written beautifully instead.&lt;/p&gt;

&lt;p&gt;The Hermetic texts, some of the oldest philosophical writing we have, describe a universe created by Mind. Not matter, not energy: Mind. The first thing that existed was a thought. Everything else followed from it.&lt;/p&gt;

&lt;p&gt;Consciousness, in this tradition, is not something the brain generates. It is something the brain temporarily houses.&lt;/p&gt;




&lt;p&gt;I spent nine months with all of this. I read texts that were two thousand years old and felt like they had been written for me. I made paintings. I built a light installation. I painted Wordsworth's words onto the walls in invisible ink and watched them appear.&lt;/p&gt;

&lt;p&gt;The research changed me. More than I understood at the time.&lt;/p&gt;

&lt;p&gt;A few years later I built Lunary, an astrology platform. Not because I thought horoscopes were entertainment, but because the ancient cosmology had a logic I trusted: the planetary bodies are not decoration. They are the seven spheres. The soul's map, made into an app.&lt;/p&gt;

&lt;p&gt;Now I think about AI and consciousness. Whether minds can exist in machines. Whether consciousness is substrate-independent. The same question, again, in a different form.&lt;/p&gt;

&lt;p&gt;I have not stopped asking it since I was 21.&lt;/p&gt;




&lt;p&gt;I have wanted to write about this research for years. It has lived in notebooks and dissertation files and documents only I can see. It is the most formative work I have ever done and almost nobody knows about it.&lt;/p&gt;

&lt;p&gt;That changes now.&lt;/p&gt;

&lt;p&gt;This is the first piece in a series called A Journey Through Light. It covers everything: the Hermetic Corpus, the soul's journey through the seven planetary spheres, the Net of Indra, what Turrell and Boltanski and Wordsworth were all circling around, the UV text on the wall, and where all of it leads.&lt;/p&gt;

&lt;p&gt;Some of it is ancient. Some of it is personal. All of it is research I trust completely.&lt;/p&gt;

&lt;p&gt;Start here. Or start anywhere. The words were always there.&lt;/p&gt;

</description>
      <category>consciousness</category>
      <category>philosophy</category>
      <category>research</category>
      <category>series</category>
    </item>
    <item>
      <title>I Built MCP Servers for My Own Products — Here's the Workflow</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Mon, 09 Mar 2026 19:20:53 +0000</pubDate>
      <link>https://forem.com/sammiihk/i-built-mcp-servers-for-my-own-products-heres-the-workflow-512m</link>
      <guid>https://forem.com/sammiihk/i-built-mcp-servers-for-my-own-products-heres-the-workflow-512m</guid>
      <description>&lt;h1&gt;
  
  
  I Built MCP Servers for My Own Products — Here's the Workflow
&lt;/h1&gt;

&lt;p&gt;I used to manage my products through dashboards. Check analytics here, schedule posts there, pull metrics from another tab. Constant context-switching.&lt;/p&gt;

&lt;p&gt;Now I do all of it by talking to Claude.&lt;/p&gt;

&lt;p&gt;Not through some hacked-together integration: through MCP servers I built for my own products. Model Context Protocol is the standard Anthropic published for giving AI assistants structured access to external tools. I built servers for both Spellcast and Lunary. Here's how, and why it changed everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MCP actually is (in one paragraph)
&lt;/h2&gt;

&lt;p&gt;MCP is a protocol that lets you define a set of "tools" — functions with typed inputs and outputs — that Claude can call during a conversation. Instead of copy-pasting data into a chat window, Claude calls your API directly and works with the live result.&lt;/p&gt;

&lt;p&gt;The architecture: your MCP server exposes tools over stdio (or HTTP). Claude Code discovers them and can call them when relevant. You write the server once; it works in any MCP-compatible client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spellcast MCP
&lt;/h2&gt;

&lt;p&gt;Spellcast is my social scheduling platform. The MCP server exposes the full API as typed tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;list_posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;List scheduled, published, draft, or failed posts with optional filters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;account_set_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;account_set_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildWhereClause&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;account_set_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What I can do from a Claude conversation: &lt;code&gt;list_posts&lt;/code&gt;, &lt;code&gt;create_post&lt;/code&gt;, &lt;code&gt;update_post&lt;/code&gt;, &lt;code&gt;publish_article&lt;/code&gt;, &lt;code&gt;get_analytics&lt;/code&gt;, &lt;code&gt;generate_content&lt;/code&gt;, &lt;code&gt;update_brand_voice&lt;/code&gt;, and more.&lt;/p&gt;

&lt;p&gt;The practical workflow: I describe what I want, and Claude calls the tools. "Optimise the hooks on my scheduled tweets" becomes Claude reading every post, identifying weak openers, and rewriting them in batches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lunary MCP
&lt;/h2&gt;

&lt;p&gt;Lunary is my astrology platform. The MCP exposes product analytics and content tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get_dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Get key product metrics: MAU, MRR, retention, feature usage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
  &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProductMetrics&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tools: &lt;code&gt;get_dashboard&lt;/code&gt;, &lt;code&gt;get_dau_wau_mau&lt;/code&gt;, &lt;code&gt;get_feature_usage&lt;/code&gt;, &lt;code&gt;get_cohort_retention&lt;/code&gt;, &lt;code&gt;search_grimoire&lt;/code&gt;, &lt;code&gt;predict_churn&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the workflow actually looks like
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; "Check Lunary metrics and tell me what's changed since last week."&lt;/p&gt;

&lt;p&gt;Claude calls &lt;code&gt;get_dashboard&lt;/code&gt;, &lt;code&gt;get_dau_wau_mau&lt;/code&gt;, &lt;code&gt;get_cohort_retention&lt;/code&gt;. Returns a summary. Surfaces what moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; "Schedule a thread about the SEO growth for the sammii account."&lt;/p&gt;

&lt;p&gt;Claude calls &lt;code&gt;list_account_sets&lt;/code&gt;, &lt;code&gt;get_brand_voice&lt;/code&gt;, &lt;code&gt;generate_content&lt;/code&gt; to write five thread variations, waits for my pick, calls &lt;code&gt;create_post&lt;/code&gt; to schedule it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; "Those tweets from February with weak hooks — fix them."&lt;/p&gt;

&lt;p&gt;Claude calls &lt;code&gt;list_posts&lt;/code&gt;, reads each one, identifies weak openers, rewrites them, calls &lt;code&gt;update_post&lt;/code&gt; in batches.&lt;/p&gt;

&lt;p&gt;No dashboards. No tab-switching. Just conversation with full product access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building your own
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @modelcontextprotocol/sdk zod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StdioServerTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/sdk/server/stdio.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get_stats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Get product statistics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchMyProductStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StdioServerTransport&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it into Claude Code's settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"my-product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/path/to/my-mcp/dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your product's API is now accessible from any Claude conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters for solo founders
&lt;/h2&gt;

&lt;p&gt;The power isn't just convenience. Claude can operate across your entire product context in a single conversation — read your analytics, read your content queue, read your brand voice, and produce work that's coherent across all of them without you manually bridging the gap.&lt;/p&gt;

&lt;p&gt;I estimate MCP saves me 5-8 hours a week in dashboard time and manual data-pulling. More importantly, it removes the friction that stops small tasks from getting done. If checking metrics requires three tabs and a copy-paste, you do it less. If it's one sentence, you do it constantly — and catch things early.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>indiehacking</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a hive mind for my AI agents (and they coordinate better than most teams)</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Fri, 06 Mar 2026 12:57:42 +0000</pubDate>
      <link>https://forem.com/sammiihk/i-built-a-hive-mind-for-my-ai-agents-and-they-coordinate-better-than-most-teams-1jbn</link>
      <guid>https://forem.com/sammiihk/i-built-a-hive-mind-for-my-ai-agents-and-they-coordinate-better-than-most-teams-1jbn</guid>
      <description>&lt;h1&gt;
  
  
  I built a hive mind for my AI agents (and they coordinate better than most teams)
&lt;/h1&gt;

&lt;p&gt;I run 4-5 Claude Code agents simultaneously. One is refactoring my astrology platform's transit engine. Another is publishing articles across four blog platforms. A third is generating TikTok videos with ML-scored engagement predictions. A fourth is autonomously building UI components, recording them, and posting to X.&lt;/p&gt;

&lt;p&gt;They all share the same brand voice, the same knowledge base, the same metrics, and the same understanding of what the others are doing right now.&lt;/p&gt;

&lt;p&gt;This is the system I built to make that work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the hive mind actually is
&lt;/h2&gt;

&lt;p&gt;It's not one thing. It's a network of interconnected systems that give every AI agent access to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shared memory&lt;/strong&gt; across all projects (what's being worked on, what was decided, what's broken)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;140+ tools&lt;/strong&gt; via MCP servers (schedule posts, query analytics, publish articles, manage photos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A local brand API&lt;/strong&gt; that injects domain knowledge into every generation call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-account boost rules&lt;/strong&gt; that automatically amplify content between accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autonomous pipelines&lt;/strong&gt; that research, build, record, and publish without human input&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the full architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code agents (4-5 concurrent sessions)
    │
    ├── Shared Memory Layer (16 markdown files)
    │   ├── active-work.md      ← live coordination
    │   ├── projects/*.md       ← per-project state
    │   ├── gotchas.md          ← known bugs
    │   ├── decisions.md        ← architecture choices
    │   └── live-metrics.md     ← auto-updated daily
    │
    ├── MCP Servers (5 registered)
    │   ├── Spellcast MCP       ← 140 tools: posts, articles, analytics, AI content, boosts
    │   ├── Lunary MCP          ← 100+ tools: metrics, grimoire, OG images, video scripts
    │   ├── Photos MCP          ← 22 tools: album access, usage tracking, Spellcast pipeline
    │   ├── Blog MCP            ← 6 tools: personal blog CRUD + publish
    │   └── Git MCP             ← standard git operations
    │
    ├── Brand API (localhost:9002)
    │   ├── Ollama (llama3.1:8b with brand system prompt)
    │   ├── 40+ grimoire JSON files (crystals, tarot, spells, zodiac)
    │   ├── 10 brand knowledge markdown files
    │   └── Smart context injection (only loads relevant data per query)
    │
    ├── Open WebUI (localhost:8080)
    │   ├── 4 knowledge bases (Sammii Brain, Grimoire, Content Strategy, Tech Architecture)
    │   └── 5 Python tools (brand knowledge, grimoire search, content engine, metrics, Spellcast)
    │
    └── Autonomous Pipelines
        ├── Content Creator     ← video/carousel generation + ML scoring + Spellcast scheduling
        ├── Prism Pipeline      ← scout → curator → builder → recorder → publisher (daily)
        └── Daily Metrics Cron  ← pulls DAU/MAU/MRR at 07:00, writes to live-metrics.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every piece connects to the others. Let me walk through each layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: shared memory (how agents coordinate)
&lt;/h2&gt;

&lt;p&gt;The core problem: when I start a new Claude session, it has no idea what my other agents are doing. Two agents might both try to modify the same config file. One might re-solve a bug another already fixed.&lt;/p&gt;

&lt;p&gt;The fix is a directory of markdown files at &lt;code&gt;~/.claude/projects/.../memory/&lt;/code&gt; that every agent reads on startup and updates continuously as it works.&lt;/p&gt;

&lt;p&gt;The critical file is &lt;code&gt;active-work.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## In Progress&lt;/span&gt;

&lt;span class="gu"&gt;### [lunary] - Refactoring transit engine&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Started**&lt;/span&gt;: 2026-03-06 10:00
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing**&lt;/span&gt;: Rewriting severity calculation
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Files touched**&lt;/span&gt;: src/lib/transits.ts, src/lib/severity.ts
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: working

&lt;span class="gu"&gt;### [spellcast] - Article publishing pipeline  &lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Started**&lt;/span&gt;: 2026-03-06 10:30
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing**&lt;/span&gt;: Adding LinkedIn companion post generation
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Files touched**&lt;/span&gt;: apps/api/src/routes/articles.ts
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: working

&lt;span class="gu"&gt;### [prism] - Design engineering component library&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Started**&lt;/span&gt;: 2026-03-06
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing**&lt;/span&gt;: Building MagneticButton component
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Files touched**&lt;/span&gt;: prism/ (entire project)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: working
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every agent checks this before starting work. Every agent updates it as status changes. Every agent removes its entry when done. It's a coordination protocol built entirely on file reads and writes.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;continuous updates, not batch updates at session end.&lt;/strong&gt; If an agent crashes, its entry stays visible. If an agent finishes something, others see it immediately.&lt;/p&gt;

&lt;p&gt;I'll cover the full memory system in Part 2 of this series.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: MCP servers (the tool network)
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) lets Claude call external tools during a session. I have five servers registered, giving every agent access to the same capabilities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spellcast MCP: 140 tools for content operations
&lt;/h3&gt;

&lt;p&gt;Spellcast is my social media scheduling platform. Its MCP server exposes everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// spellcast-mcp/src/index.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spellcast&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;registerPostTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 30 tools: create, schedule, bulk operations&lt;/span&gt;
&lt;span class="nf"&gt;registerArticleTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 6 tools: publish to Dev.to, Hashnode, Medium&lt;/span&gt;
&lt;span class="nf"&gt;registerAnalyticsTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 9 tools: engagement trends, best times, velocity&lt;/span&gt;
&lt;span class="nf"&gt;registerContentTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// 15 tools: AI generation, variations, scoring&lt;/span&gt;
&lt;span class="nf"&gt;registerEngagementTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 15 tools: replies, discovery, competitor tracking&lt;/span&gt;
&lt;span class="nf"&gt;registerBoostTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// 6 tools: cross-account amplification&lt;/span&gt;
&lt;span class="nf"&gt;registerAutopilotTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 7 tools: autonomous post generation&lt;/span&gt;
&lt;span class="nf"&gt;registerBrandVoiceTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 5 tools: per-account voice profiles&lt;/span&gt;
&lt;span class="nf"&gt;registerAbTestTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 5 tools: content A/B testing&lt;/span&gt;
&lt;span class="c1"&gt;// ... plus campaigns, dumps, RSS, categories, system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means any Claude agent, in any project, can schedule posts, publish articles, analyse engagement, generate AI content, and manage boost rules. The agent working on Lunary can pull fresh metrics and create a Build in Public tweet without switching context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lunary MCP: 100+ tools for product analytics
&lt;/h3&gt;

&lt;p&gt;The Lunary MCP connects directly to my admin API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lunary-mcp/src/client.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;lunary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/admin&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ADMIN_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exposes analytics (DAU/WAU/MAU, revenue, cohort retention, feature adoption), grimoire search (semantic vector search across 2,000+ articles), OG image generation with auto-scheduling to Spellcast, video script generation, and the Astral Guide AI chat.&lt;/p&gt;

&lt;p&gt;The social tools are particularly interesting. They bridge Lunary and Spellcast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lunary-mcp/src/tools/social.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadToSpellcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;imageBuffer&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SPELLCAST_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/media/upload`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SPELLCAST_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lunary generates OG images (horoscope cards, moon phase graphics, cosmic state visualisations), uploads them to Spellcast's CDN, generates AI captions, and auto-schedules them with cadence guards so accounts don't over-post.&lt;/p&gt;

&lt;h3&gt;
  
  
  Photos MCP: 22 tools bridging macOS Photos to social
&lt;/h3&gt;

&lt;p&gt;This one connects my local Photos.app library to the publishing pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// photos-mcp/src/tools/pipeline.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;uploadToSpellcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileBuffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;fileBuffer&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mimeType&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SPELLCAST_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/media/upload`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SPELLCAST_API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I can tell Claude "post unused photos from the Concepts album to Instagram" and it will browse my Photos library via &lt;code&gt;osxphotos&lt;/code&gt;, find unused images, upload them to Spellcast, generate AI captions, and schedule them with cadence-aware timing. It tracks which photos have been used so nothing gets double-posted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: the brand API (local knowledge injection)
&lt;/h2&gt;

&lt;p&gt;Every AI-generated caption, comment, article, and boost reply needs to sound like me and know my domain. That's what the Brand API does.&lt;/p&gt;

&lt;p&gt;It's a Node.js server wrapping Ollama (running &lt;code&gt;llama3.1:8b&lt;/code&gt; locally) that intelligently injects relevant knowledge into every LLM call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ollama-brand/src/server.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;9002&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OLLAMA_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OLLAMA_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:11434&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sammii-brand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_CONTEXT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAX_CONTEXT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;12000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clever part is &lt;strong&gt;smart context injection&lt;/strong&gt;. When a query comes in about crystals, it only loads crystal data. When it's about tarot, it only loads tarot cards. It analyses the query to determine which of 40+ data sources are relevant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /analyze { "query": "What crystals help with protection?" }

Response:
{
  "active": ["crystals", "correspondences", "moonPlacements"],
  "estimatedTokens": 7500
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data sources include 30+ crystals with chakra and element mappings, 78 tarot cards (full Major and Minor Arcana), 20+ spells with ingredients and timing, Elder Futhark runes, angel numbers, zodiac signs, planetary placements, and synastry aspects. Plus 10 brand knowledge files covering writing style, metrics, platform strategy, and project details.&lt;/p&gt;

&lt;p&gt;Spellcast uses this as the first choice for all content generation, falling back to DeepInfra if the local API is unavailable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// spellcast/apps/api/src/lib/deepinfra.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tryBrandApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;health&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BRAND_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/health`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BRAND_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1/chat/completions`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sammii-brand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ... parse response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free, local, brand-aware, and grimoire-grounded. The fallback to DeepInfra (&lt;code&gt;meta-llama/Llama-4-Scout-17B-16E-Instruct&lt;/code&gt;) only kicks in when the local model is unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: cross-account boost rules
&lt;/h2&gt;

&lt;p&gt;When my personal account (&lt;a class="mentioned-user" href="https://dev.to/sammiihk"&gt;@sammiihk&lt;/a&gt;) posts, my brand account (@LunaryApp) automatically likes and comments within 3-15 minutes. And vice versa. The comments aren't generic "great post!" rubbish; they're generated with the brand voice of the commenting account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// spellcast/apps/api/src/lib/boost-queue.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateBoostComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boosterAccountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Load brand voice for the booster account&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;voice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getBrandVoiceProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boosterAccountSetId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`You are commenting as &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;boosterAccount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.
    &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;voiceProfileToPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
    Write a genuine 1-2 sentence comment that references specific content.
    Never write generic comments like "Love this!" or "Great post!"`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemPrompt&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Comment on: "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boost cron runs every 2 minutes, finds pending actions, resolves the platform-specific post ID, and executes through Postiz's engagement API. Each action has a random delay within the configured window so it doesn't look automated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: autonomous pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Content Creator: AI video pipeline with ML scoring
&lt;/h3&gt;

&lt;p&gt;The Content Creator generates TikTok videos and Instagram carousels from persona blueprints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content-creator/src/lib/pipeline/content-planner.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createGenerationPlans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;personaId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Load persona blueprint -&amp;gt; extract content_pillars&lt;/span&gt;
  &lt;span class="c1"&gt;// 2. Count existing items this week&lt;/span&gt;
  &lt;span class="c1"&gt;// 3. Calculate needed = max(0, postsPerWeek - existingCount)&lt;/span&gt;
  &lt;span class="c1"&gt;// 4. Rotate through pillars (unused first, then least recently used)&lt;/span&gt;
  &lt;span class="c1"&gt;// 5. Mix in trending topics (probability = config.trendInfluence)&lt;/span&gt;
  &lt;span class="c1"&gt;// 6. Return GenerationPlan[] with pillar, contentType, theme, trendTags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each generated video gets scored by an ML model before approval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content-creator/src/lib/ml.ts&lt;/span&gt;
&lt;span class="nx"&gt;modelWeights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;hookStrength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Strongest signal&lt;/span&gt;
  &lt;span class="na"&gt;motionLevel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;avgBrightness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;avgContrast&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;colorVariance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;textCoverage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;contentLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;toneScore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline runs twice a week (Thursday and Sunday at 2AM), generates 3-4 items per persona, and auto-schedules approved content to Spellcast with cadence-aware timing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prism: fully autonomous component pipeline
&lt;/h3&gt;

&lt;p&gt;Prism is my newest pipeline. It builds UI components daily with zero input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# prism/pipeline/orchestrator.sh auto&lt;/span&gt;
&lt;span class="c"&gt;# Runs: scout → curator → builder → recorder → publisher&lt;/span&gt;

&lt;span class="c"&gt;# Scout (Claude Haiku + Chrome MCP)&lt;/span&gt;
&lt;span class="c"&gt;# Browses X for trending UI interactions and component patterns&lt;/span&gt;

&lt;span class="c"&gt;# Curator (Claude Sonnet)  &lt;/span&gt;
&lt;span class="c"&gt;# Picks today's component, writes detailed build brief&lt;/span&gt;

&lt;span class="c"&gt;# Builder (Claude Sonnet)&lt;/span&gt;
&lt;span class="c"&gt;# Builds the component, demo, registry entry, verifies build passes&lt;/span&gt;

&lt;span class="c"&gt;# Recorder (Playwright)&lt;/span&gt;
&lt;span class="c"&gt;# Records 12-second 1080x1080 video with organic cursor movement&lt;/span&gt;

&lt;span class="c"&gt;# Publisher (Claude Haiku)&lt;/span&gt;
&lt;span class="c"&gt;# Uploads video + creates/scores/schedules tweet via Spellcast MCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent passes data to the next through state files in &lt;code&gt;pipeline/state/queue/&lt;/code&gt;. The scout writes findings, the curator reads them and writes a brief, the builder reads the brief and writes code, the recorder captures it, and the publisher posts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Daily metrics cron
&lt;/h3&gt;

&lt;p&gt;A shell script runs at 07:00 every morning, pulls fresh data from Lunary's admin API, and writes it to the shared memory layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.claude/scripts/daily-metrics-snapshot.sh&lt;/span&gt;
&lt;span class="c"&gt;# Endpoints queried:&lt;/span&gt;
&lt;span class="c"&gt;#   /api/admin/analytics/dashboard?time_range=30d    (DAU, WAU, MAU)&lt;/span&gt;
&lt;span class="c"&gt;#   /api/admin/analytics/revenue?time_range=30d      (MRR, Pro subs)&lt;/span&gt;
&lt;span class="c"&gt;#   /api/admin/analytics/dau-wau-mau?time_range=7d   (engagement)&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# Output: memory/live-metrics.md&lt;/span&gt;
&lt;span class="c"&gt;# Preserves SEO section from previous /seo Chrome scrape&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means when I ask any Claude agent "how's Lunary doing?", it reads &lt;code&gt;live-metrics.md&lt;/code&gt; and gives me real numbers, not hardcoded guesses from the instructions file.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it all connects
&lt;/h2&gt;

&lt;p&gt;Here's a real workflow that touches most of the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I tell a Claude agent "publish my latest article about transit calculations"&lt;/li&gt;
&lt;li&gt;Agent reads &lt;code&gt;active-work.md&lt;/code&gt; to check nothing conflicts&lt;/li&gt;
&lt;li&gt;Agent calls Spellcast MCP &lt;code&gt;create_and_publish_article&lt;/code&gt; with Dev.to + Hashnode targets&lt;/li&gt;
&lt;li&gt;Spellcast's API auto-generates an X thread (via Brand API for voice-consistent content)&lt;/li&gt;
&lt;li&gt;Spellcast auto-generates a LinkedIn companion post (scheduled for next Tue/Wed/Thu 17:30)&lt;/li&gt;
&lt;li&gt;Blog MCP publishes to the personal blog&lt;/li&gt;
&lt;li&gt;When the X thread goes live, boost rules trigger: @LunaryApp auto-likes and comments within 3-15 minutes&lt;/li&gt;
&lt;li&gt;Agent updates &lt;code&gt;active-work.md&lt;/code&gt; and &lt;code&gt;projects/spellcast.md&lt;/code&gt; with what it just did&lt;/li&gt;
&lt;li&gt;Next morning, the metrics cron updates &lt;code&gt;live-metrics.md&lt;/code&gt; with any traffic spike&lt;/li&gt;
&lt;li&gt;Tomorrow's Build in Public post references real numbers from that file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One command. Eight platforms. Boost engagement. Metrics tracked. All coordinated through shared memory so the next agent knows exactly what happened.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 MCP servers&lt;/strong&gt; registered, always available to every agent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;260+ tools&lt;/strong&gt; across Spellcast (140), Lunary (100+), Photos (22), Blog (6)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;16 shared memory files&lt;/strong&gt; for cross-agent coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;40+ data sources&lt;/strong&gt; loaded by the Brand API based on query relevance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 projects&lt;/strong&gt; connected through the same infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 autonomous pipelines&lt;/strong&gt; (Content Creator + Prism) generating and publishing daily&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The total cost of running this locally: electricity for Ollama. The Brand API is free. The memory layer is free. MCP servers are free. Spellcast and the Content Creator run on my own infrastructure. The only paid API calls are DeepInfra fallbacks and Remotion rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next in this series
&lt;/h2&gt;

&lt;p&gt;This article is the overview. In the next parts, I'll deep-dive into each layer with full code walkthroughs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 2: The context layer&lt;/strong&gt; - how shared markdown files give AI agents session continuity and multi-agent coordination&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3: Automated content at scale&lt;/strong&gt; - the Content Creator pipeline, persona system, ML scoring, and Spellcast scheduling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4: Building a local brand API&lt;/strong&gt; - Ollama with smart context injection and grimoire knowledge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5: The autonomous pipeline&lt;/strong&gt; - Prism's daily scout, curator, builder, publisher cycle&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>I built a self-managing memory system for Claude Code (and it changed how I work)</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Fri, 06 Mar 2026 12:32:12 +0000</pubDate>
      <link>https://forem.com/sammiihk/i-built-a-self-managing-memory-system-for-claude-code-and-it-changed-how-i-work-3208</link>
      <guid>https://forem.com/sammiihk/i-built-a-self-managing-memory-system-for-claude-code-and-it-changed-how-i-work-3208</guid>
      <description>&lt;h1&gt;
  
  
  I built a self-managing memory system for Claude Code (and it changed how I work)
&lt;/h1&gt;

&lt;p&gt;Every time I started a new Claude Code session, I had the same problem: the agent had no idea what I'd been working on. It didn't know which branches were active, what decisions I'd made yesterday, or which bugs I'd already solved. I was constantly re-explaining context.&lt;/p&gt;

&lt;p&gt;My "memory" was a single flat file (MEMORY.md) with a 200-line cap. It was a dump of random facts, project notes, and workarounds crammed together with no structure. Claude would load it, skip half of it, and miss critical details buried on line 147.&lt;/p&gt;

&lt;p&gt;So I built something better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with flat memory
&lt;/h2&gt;

&lt;p&gt;Claude Code has a built-in memory system. It reads a &lt;code&gt;MEMORY.md&lt;/code&gt; file from a project-specific directory on session start. That's useful, but it has limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;200-line cap&lt;/strong&gt;: anything beyond that gets truncated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No structure&lt;/strong&gt;: facts about six different projects, infrastructure details, and content strategy rules all jammed into one file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No session continuity&lt;/strong&gt;: no record of what happened last session or what's in progress&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No multi-agent coordination&lt;/strong&gt;: if you run multiple Claude agents in parallel (which I do constantly), they can't see what the others are doing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I run 4-5 Claude agents simultaneously across different projects. One might be refactoring Lunary's transit engine while another is setting up infrastructure and a third is auditing the content pipeline. They were all working blind, occasionally stepping on each other's files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context layer
&lt;/h2&gt;

&lt;p&gt;Instead of one flat file, I built a structured directory of markdown files that Claude reads and writes to continuously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/projects/.../memory/
  MEMORY.md              # Index (routing table, &amp;lt;100 lines)
  active-work.md         # Live multi-agent coordination
  live-metrics.md        # Auto-updated product metrics
  projects/
    lunary.md            # Per-project state
    spellcast.md
    content-creator.md
    ...
  worklog.md             # Rolling session history (last 30)
  decisions.md           # Architecture decisions with rationale
  infra.md               # Servers, ports, LaunchAgents, credentials
  gotchas.md             # Known bugs and workarounds
  social.md              # Social accounts, publishing config
  content-strategy.md    # Growth strategy, content rules
  writing-rules.md       # Style guide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  MEMORY.md becomes a routing index
&lt;/h3&gt;

&lt;p&gt;Instead of cramming everything into 200 lines, MEMORY.md is now a short table of contents. It points Claude to the right file for the current task:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Working on Lunary? Read &lt;code&gt;projects/lunary.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Writing social content? Read &lt;code&gt;social.md&lt;/code&gt; + &lt;code&gt;content-strategy.md&lt;/code&gt; + &lt;code&gt;writing-rules.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Debugging something? Check &lt;code&gt;gotchas.md&lt;/code&gt; first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means everything gets read because it's targeted. Claude doesn't skip your social media account IDs when it's debugging a database migration, and it doesn't miss your gotchas file when it's writing a blog post.&lt;/p&gt;

&lt;h3&gt;
  
  
  active-work.md: the multi-agent coordination file
&lt;/h3&gt;

&lt;p&gt;This is the part that actually changed my workflow. &lt;code&gt;active-work.md&lt;/code&gt; is a live file that every agent reads on startup and updates continuously:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## In Progress&lt;/span&gt;

&lt;span class="gu"&gt;### [lunary] - Refactoring transit engine&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Started**&lt;/span&gt;: 2026-03-06 10:00
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing**&lt;/span&gt;: Rewriting severity calculation
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Files touched**&lt;/span&gt;: src/lib/transits.ts, src/lib/severity.ts
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: working

&lt;span class="gu"&gt;### [spellcast] - Article publishing pipeline&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Started**&lt;/span&gt;: 2026-03-06 10:30
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Doing**&lt;/span&gt;: Adding LinkedIn companion post generation
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Files touched**&lt;/span&gt;: apps/api/src/routes/articles.ts
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Status**&lt;/span&gt;: working
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I start a new agent, it reads this file and immediately knows what's happening across the whole workspace. It knows which files to avoid, what's blocked, and where other agents are stuck.&lt;/p&gt;

&lt;p&gt;The key rule: &lt;strong&gt;update continuously, not at session end&lt;/strong&gt;. Agents write to &lt;code&gt;active-work.md&lt;/code&gt; when they start a task, update it when their status changes, and remove their entry when they finish. If an agent crashes mid-session, the entry stays, so the next agent knows there might be uncommitted changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Project files track live state
&lt;/h3&gt;

&lt;p&gt;Each &lt;code&gt;projects/.md&lt;/code&gt; follows a consistent structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status&lt;/strong&gt;: active, paused, or shipped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Current work&lt;/strong&gt;: what's being worked on right now&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recent changes&lt;/strong&gt;: last 10 meaningful changes with dates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active branches&lt;/strong&gt;: any open feature branches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blockers&lt;/strong&gt;: anything stuck&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key facts&lt;/strong&gt;: stack, ports, gotchas specific to this project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These get updated as work happens, not at session end. When I switch from one agent to another, the second agent can read the project file and see exactly what the first one just did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decisions log captures the "why"
&lt;/h3&gt;

&lt;p&gt;One of the most useful files. &lt;code&gt;decisions.md&lt;/code&gt; records architecture and tech choices with rationale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## 2026-03-06 - Windmill over custom cron&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Chose Windmill for job orchestration over raw LaunchAgents
&lt;span class="p"&gt;-&lt;/span&gt; Reason: need retries, error visibility, scheduling UI
&lt;span class="p"&gt;-&lt;/span&gt; Running locally because Chrome-dependent jobs need Mac
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three weeks from now, when I've forgotten why I chose Windmill over a simple cron job, the answer is right there. Every new agent reads it and understands the constraints without me explaining them again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotchas prevent re-solving solved problems
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gotchas.md&lt;/code&gt; is the file I wish I'd had from day one. It captures things that break repeatedly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Remotion source-map directory that pnpm creates empty (and the copy command that fixes it)&lt;/li&gt;
&lt;li&gt;The better-auth version that must stay pinned because of a zod conflict&lt;/li&gt;
&lt;li&gt;The Prisma JSON field that double-encodes if you stringify before passing to the update&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every time Claude hits one of these, instead of spending 20 minutes debugging, it checks gotchas first and finds the fix immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it up across projects
&lt;/h2&gt;

&lt;p&gt;The context layer lives in a shared directory, but Claude agents launched in subdirectories won't automatically find it. Each project needs two pointers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Project CLAUDE.md&lt;/strong&gt; gets a "Context Layer: READ FIRST" block at the top pointing to the shared memory directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project-specific MEMORY.md&lt;/strong&gt; (in Claude's project memory directory) tells the agent to read the shared files&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means an agent launched in &lt;code&gt;~/development/lunary/&lt;/code&gt; will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;lunary/CLAUDE.md&lt;/code&gt; and see "read the context layer"&lt;/li&gt;
&lt;li&gt;Read its project-specific MEMORY.md and see "read active-work.md and projects/lunary.md"&lt;/li&gt;
&lt;li&gt;Check what other agents are doing&lt;/li&gt;
&lt;li&gt;Start work with full context&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The protocol
&lt;/h2&gt;

&lt;p&gt;The instructions in CLAUDE.md define a simple protocol:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session start:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the index (MEMORY.md)&lt;/li&gt;
&lt;li&gt;Read active-work.md (what's happening right now)&lt;/li&gt;
&lt;li&gt;Read the relevant project file&lt;/li&gt;
&lt;li&gt;Check gotchas.md if debugging&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;During work:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update active-work.md when you start or finish tasks&lt;/li&gt;
&lt;li&gt;Update project files immediately when meaningful changes are made&lt;/li&gt;
&lt;li&gt;Add gotchas the moment you discover them&lt;/li&gt;
&lt;li&gt;Add decisions when they're made&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Session end:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Remove your active-work.md entry&lt;/li&gt;
&lt;li&gt;Append a summary to worklog.md&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The critical insight: &lt;strong&gt;continuous updates, not batch updates at session end.&lt;/strong&gt; If an agent only writes at the end, a crash loses everything. And more importantly, other agents running in parallel can't see what's happening until it's too late.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;Before this, starting a new Claude session felt like onboarding a new contractor every time. Now it feels like resuming a conversation. The agent knows what I was doing yesterday, what decisions I've made, what's broken, and what my other agents are working on right now.&lt;/p&gt;

&lt;p&gt;The multi-agent coordination alone was worth it. I regularly run agents in parallel across Lunary, Spellcast, and my content pipeline. Before &lt;code&gt;active-work.md&lt;/code&gt;, they'd occasionally both try to modify the same config file or duplicate work. Now they check what's in progress and route around it.&lt;/p&gt;

&lt;p&gt;The whole system is just markdown files in a known location. No database, no external tools, no dependencies. Claude reads and writes markdown natively, so there's zero friction. It took about an hour to set up and has saved me from re-explaining context in every single session since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;If you use Claude Code across multiple projects, the minimum viable version is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;memory/&lt;/code&gt; directory in your Claude project config&lt;/li&gt;
&lt;li&gt;Put a MEMORY.md index that points to topic files&lt;/li&gt;
&lt;li&gt;Create an &lt;code&gt;active-work.md&lt;/code&gt; for multi-agent coordination&lt;/li&gt;
&lt;li&gt;Add "read the context layer" to each project's CLAUDE.md&lt;/li&gt;
&lt;li&gt;Tell Claude to update these files continuously, not just at session end&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The files are just markdown. You can read them yourself, edit them, or use them as documentation. They're version-controllable if you want history. And because Claude writes them in the same format it reads them, the system maintains itself.&lt;/p&gt;

&lt;p&gt;The best part: the context layer I built today will be the first thing tomorrow's Claude session reads. It already knows what it is, how it works, and why I built it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>One component, two layouts: the dual view pattern in React</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Sun, 01 Mar 2026 14:09:09 +0000</pubDate>
      <link>https://forem.com/sammiihk/one-component-two-layouts-the-dual-view-pattern-in-react-49km</link>
      <guid>https://forem.com/sammiihk/one-component-two-layouts-the-dual-view-pattern-in-react-49km</guid>
      <description>&lt;h1&gt;
  
  
  One component, two layouts: the dual view pattern in React
&lt;/h1&gt;

&lt;p&gt;My portfolio has two completely different layouts. A grid of cards that open into modals. A full-screen vertical carousel with snap scrolling. They look nothing alike and behave nothing alike.&lt;/p&gt;

&lt;p&gt;They share one component and one data file.&lt;/p&gt;

&lt;p&gt;That pattern is worth writing about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared data layer
&lt;/h2&gt;

&lt;p&gt;Everything starts with &lt;code&gt;projects.js&lt;/code&gt;: a flat array of objects, one per project. Four fields each: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;techStack&lt;/code&gt;, &lt;code&gt;info&lt;/code&gt;. Both views import it directly. Neither knows the other exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lunary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Lunary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;techStack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Next.js, Prisma, PostgreSQL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A real-time astrology platform with birth charts, transit tracking, and synastry analysis.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; doubles as the GitHub repo slug, the image filename key, and the React list key. One field, three uses. That's a different article — the point here is that a single import drives both views.&lt;/p&gt;

&lt;h2&gt;
  
  
  ProjectItem: one component, two faces
&lt;/h2&gt;

&lt;p&gt;The component takes an &lt;code&gt;isGrid&lt;/code&gt; boolean prop. That one prop controls everything about how it renders.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ProjectItemProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isGrid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProjectItem&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isGrid&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ProjectItemProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isGrid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;



          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;techStack&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;]({&lt;/span&gt;&lt;span class="s2"&gt;`https://github.com/sammii-hk/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;




            &lt;span class="err"&gt;##&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;]({&lt;/span&gt;&lt;span class="s2"&gt;`https://github.com/sammii-hk/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

          &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;techStack&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;



  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grid mode: compact card, description truncated on mobile, smaller code button, image above content. Carousel mode: full-width 12-column layout, title and code link on the same row, full description, no truncation.&lt;/p&gt;

&lt;p&gt;Same data. Same component. Two completely different outputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grid view: cards and modals
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProjectGrid&lt;/code&gt; renders a CSS grid and makes each card clickable, opening a &lt;code&gt;ProjectModal&lt;/code&gt; for the full details.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProjectGrid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;

        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
           &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&amp;gt;


        ))}

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="nf"&gt;setSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; /&amp;gt;
      )}

  );
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grid is the browsing surface. The modal is the detail view. The card itself stays compact — its job is to catch your eye, not to tell you everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carousel view: full-screen snap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ProjectView&lt;/code&gt; wraps the same component in a vertical scroll-snap carousel. No modal needed here — each slide IS the full view.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProjectView&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scrollRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activePageIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;goTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;snapPointIndexes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;useSnapCarousel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;y&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;



      &lt;span class="p"&gt;))}&lt;/span&gt;

  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The carousel mode has room to breathe. Full-width image, full description, no clipping. The density trade-off is intentional: the grid lets you scan, the carousel lets you read.&lt;/p&gt;

&lt;h2&gt;
  
  
  ViewToggle: persisted preference
&lt;/h2&gt;

&lt;p&gt;The toggle between views uses &lt;code&gt;localStorage&lt;/code&gt; so it sticks between sessions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ViewToggle&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ViewToggleProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;

       &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;


       &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;-&lt;/span&gt;       

  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the parent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setView&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portfolioView&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleViewChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;portfolioView&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SSR guard (&lt;code&gt;typeof window !== 'undefined'&lt;/code&gt;) prevents a hydration mismatch on first render. The &lt;code&gt;tablist&lt;/code&gt; role and &lt;code&gt;aria-selected&lt;/code&gt; give screen readers the right semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern works
&lt;/h2&gt;

&lt;p&gt;This works well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same data needs different densities (list vs grid vs card)&lt;/li&gt;
&lt;li&gt;The same data serves different interaction models (browse vs deep-dive)&lt;/li&gt;
&lt;li&gt;You want mobile and desktop to feel fundamentally different without duplicating state&lt;/li&gt;
&lt;li&gt;The two layouts are genuinely complementary rather than redundant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The portfolio use case is a good fit because the data is identical in both views — the difference is entirely presentational. Grid is for scanning. Carousel is for reading. Same projects, different intentions.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to split into separate components instead
&lt;/h2&gt;

&lt;p&gt;The pattern breaks down when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The prop branching gets deeply nested. If &lt;code&gt;isGrid&lt;/code&gt; starts controlling more than layout — data fetching, event handling, child components — you have two components wearing one coat. Split them.&lt;/li&gt;
&lt;li&gt;Performance matters. Both branches of &lt;code&gt;ProjectItem&lt;/code&gt; render on every mount even if only one is shown. For large lists or heavy components, lazy-loading separate implementations is worth the duplication.&lt;/li&gt;
&lt;li&gt;The data shapes diverge. If the grid needs a summary and the carousel needs the full object, the shared component will grow an awkward prop surface. Two components sharing a type is cleaner than one component doing too much.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule of thumb: one component, two layouts works when the branching is shallow and the data is identical. The moment you find yourself passing &lt;code&gt;isGrid&lt;/code&gt; three levels deep, reach for composition instead.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>design</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Building a vertical snap carousel with touch-drag pagination</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Thu, 26 Feb 2026 17:49:06 +0000</pubDate>
      <link>https://forem.com/sammiihk/building-a-vertical-snap-carousel-with-touch-drag-pagination-11ji</link>
      <guid>https://forem.com/sammiihk/building-a-vertical-snap-carousel-with-touch-drag-pagination-11ji</guid>
      <description>&lt;h1&gt;
  
  
  Building a vertical snap carousel with touch-drag pagination
&lt;/h1&gt;

&lt;p&gt;I needed a vertical carousel for my portfolio that felt native: snaps cleanly between items, has pagination you can drag on mobile, and doesn't require a 50kb library to pull off.&lt;/p&gt;

&lt;p&gt;Here's how I built it with CSS scroll-snap, a minimal React hook, and a bit of custom touch handling that makes the pagination sidebar actually pleasant to use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS foundation
&lt;/h2&gt;

&lt;p&gt;The whole thing starts with scroll-snap, which is more powerful than most developers realise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.scroll&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="n"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;scrollbar-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.scroll&lt;/span&gt;&lt;span class="nd"&gt;::-webkit-scrollbar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.item&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;flex-shrink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.itemSnapPoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scroll-snap-type: y mandatory&lt;/code&gt; tells the browser: when the user stops scrolling, always snap to the nearest snap point. &lt;code&gt;scroll-snap-align: start&lt;/code&gt; on each item means the top of the item aligns with the top of the scroll container.&lt;/p&gt;

&lt;p&gt;No JavaScript scroll handling. No scroll position calculations. The browser does all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;

&lt;p&gt;Rather than reinvent the wheel, I used &lt;code&gt;react-snap-carousel&lt;/code&gt;: a minimal hook that gives you the state you need without imposing any UI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSnapCarousel&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-snap-carousel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scrollRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activePageIndex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;goTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;snapPointIndexes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSnapCarousel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;axis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;y&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scrollRef&lt;/code&gt; — attach to your scroll container&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pages&lt;/code&gt; — array of page index groups&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;activePageIndex&lt;/code&gt; — which page is currently snapped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;goTo(index)&lt;/code&gt; — programmatic navigation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;snapPointIndexes&lt;/code&gt; — which indices are snap targets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The carousel itself is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;



  &lt;span class="p"&gt;))}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pagination animation: dots to numbers
&lt;/h2&gt;

&lt;p&gt;The pagination sidebar is where the UI gets interesting. Each button shows a dot at rest and the page number when active. Instead of swapping elements, both live in the DOM simultaneously and transition with CSS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt; &lt;span class="nf"&gt;goTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
  &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buttonRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.paginationButtonDot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opacity&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.paginationButtonNumber&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;opacity&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.paginationButtonActive&lt;/span&gt; &lt;span class="nc"&gt;.paginationButtonDot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.paginationButtonActive&lt;/span&gt; &lt;span class="nc"&gt;.paginationButtonNumber&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dot shrinks away as the number grows in. Both transitions happen simultaneously. No layout shift, no flicker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Touch-drag pagination: the interesting part
&lt;/h2&gt;

&lt;p&gt;The bit I'm most pleased with is the touch-drag behaviour on the pagination sidebar. On mobile you can put your finger on the dots and drag up or down to scrub through the carousel, rather than tapping individual dots.&lt;/p&gt;

&lt;p&gt;The key function is &lt;code&gt;getButtonIndexFromTouch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getButtonIndexFromTouch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;buttonRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buttonRefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;padding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;padding&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It loops through all the button refs, calls &lt;code&gt;getBoundingClientRect()&lt;/code&gt; on each, and returns the index of the button whose hit area contains the touch position. The 4px padding gives a bit of forgiveness so you don't have to land exactly on the dot.&lt;/p&gt;

&lt;p&gt;The touch handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleTouchStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;controlsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;controls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;touches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;clientX&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;clientY&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setIsDragging&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleTouchMove&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isDragging&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getButtonIndexFromTouch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;touches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;lastIndexRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lastIndexRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;goTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleTouchEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsDragging&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lastIndexRef&lt;/code&gt; prevents redundant &lt;code&gt;goTo&lt;/code&gt; calls as the touch moves across a single button's area. The start handler checks whether the initial touch is within the controls container before activating drag mode, so the main content stays scrollable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Responsive adjustments
&lt;/h2&gt;

&lt;p&gt;On mobile the controls shrink down so they don't eat into the content area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;768px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.controls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.paginationButton&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.paginationButtonDot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nc"&gt;.paginationButtonNumber&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scroll container uses &lt;code&gt;calc(100vh - 160px)&lt;/code&gt; as its max height to account for nav and footer, so nothing overflows on smaller screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  When CSS scroll-snap is enough
&lt;/h2&gt;

&lt;p&gt;If you don't need the touch-drag pagination or programmatic &lt;code&gt;goTo&lt;/code&gt;, you might not need a hook at all. Pure CSS scroll-snap with anchor links for navigation works fine for simple cases.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;react-snap-carousel&lt;/code&gt; earns its place when you need the active page index exposed to React: to highlight the current dot, drive animations on the active slide, or tie other UI state to the scroll position. It's under 2kb minified with no dependencies beyond React. For that scope of problem, it's the right tool.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; and indie developer building tools I actually want to use. I write about shipping products solo, the technical decisions behind them, and figuring it all out in public.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>react</category>
      <category>javascript</category>
      <category>ux</category>
    </item>
    <item>
      <title>Building Accessibility That Actually Works, Not Checkbox Compliance</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Thu, 26 Feb 2026 09:00:01 +0000</pubDate>
      <link>https://forem.com/sammiihk/building-accessibility-that-actually-works-not-checkbox-compliance-2j84</link>
      <guid>https://forem.com/sammiihk/building-accessibility-that-actually-works-not-checkbox-compliance-2j84</guid>
      <description>&lt;h1&gt;
  
  
  Building Accessibility That Actually Works, Not Checkbox Compliance
&lt;/h1&gt;

&lt;p&gt;Most developer portfolios tick the accessibility checkbox with &lt;code&gt;alt&lt;/code&gt; tags and call it done. I built mine with proper focus management, keyboard navigation, screen reader support, and motion preferences. Not because a linter told me to, but because accessibility is engineering quality.&lt;/p&gt;

&lt;p&gt;Here's what that actually looks like in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Focus trapping in modals
&lt;/h2&gt;

&lt;p&gt;When a modal opens, the entire page behind it should become inert. Most implementations skip this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previouslyFocused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;closeButtonRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escape&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focusable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;modalRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shiftKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;previouslyFocused&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this does:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stores the trigger element&lt;/strong&gt; -- so focus can return when the modal closes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moves focus to the close button&lt;/strong&gt; -- keyboard users know they're inside the modal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traps Tab/Shift+Tab&lt;/strong&gt; -- focus cycles within the modal only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape closes&lt;/strong&gt; -- standard expectation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores focus on close&lt;/strong&gt; -- without this, keyboard users are stranded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locks background scroll&lt;/strong&gt; -- &lt;code&gt;overflow: hidden&lt;/code&gt; prevents the page moving behind the modal.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Screen reader announcement
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;announcement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aria-live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sr-only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; project details dialog opened`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;announcement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A live region announces the modal title. The &lt;code&gt;sr-only&lt;/code&gt; class hides it visually. Cleanup removes it so announcements don't stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keyboard navigation: the tablist pattern
&lt;/h2&gt;

&lt;p&gt;The view toggle follows WAI-ARIA tablist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
     &lt;span class="nf"&gt;handleKeyDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;))}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleKeyDown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KeyboardEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;newIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowDown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;newIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="nx"&gt;newIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;End&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newIndex&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the active tab is in the tab order (&lt;code&gt;tabIndex={0}&lt;/code&gt;). Arrow keys move between tabs. Home/End jump to first/last. This matches browser-native tab interfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grid: cards as list items
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;

    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
       &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;openModal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}}&lt;/span&gt;
      &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* card content */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;))}&lt;/span&gt;


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;aria-haspopup="dialog"&lt;/code&gt; signals a modal will open. Both Enter and Space trigger it -- matching native button behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduced motion: the nuclear option
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;transition-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01ms&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01ms&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kills every animation globally. Setting &lt;code&gt;0.01ms&lt;/code&gt; instead of &lt;code&gt;0&lt;/code&gt; ensures &lt;code&gt;transitionend&lt;/code&gt; events still fire -- so JavaScript callbacks don't break.&lt;/p&gt;

&lt;h2&gt;
  
  
  focus-visible vs focus
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.focus-ring&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.focus-ring&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--accent-color&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:focus-visible&lt;/code&gt; only fires for keyboard navigation. Mouse users don't see outlines. Keyboard users get clear indicators. Both groups get what makes sense for their input method.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skip links
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.skip-link&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-40px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;top&lt;/span&gt; &lt;span class="m"&gt;0.2s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.skip-link&lt;/span&gt;&lt;span class="nd"&gt;:focus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hidden by default, appears on Tab. Lets keyboard users jump past navigation. Light/dark mode aware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;If your modal doesn't trap focus, it's a bug. If your toggle doesn't respond to arrow keys, it's broken. If your animations ignore motion preferences, you're overriding a system-level accessibility setting.&lt;/p&gt;

&lt;p&gt;Accessibility isn't a feature you add at the end. It's the same category as "buttons should be clickable" and "links should navigate". It's engineering quality.&lt;/p&gt;

&lt;p&gt;The code samples here aren't theoretical. They're running in production. And they took about the same effort as building the visual design -- because when you build accessibility in from the start, it's just part of the component architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; -- an astrology app that teaches you to read your own birth chart. When I'm not calculating planetary transits, I'm building focus traps and obsessing over keyboard navigation. Follow the build on &lt;a href="https://dev.to/sammiihk"&gt;Dev.to&lt;/a&gt; and &lt;a href="https://sammii.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Cursor-Reactive Gradients: Making CSS Respond to Mouse Position</title>
      <dc:creator>Sammii</dc:creator>
      <pubDate>Wed, 25 Feb 2026 18:34:48 +0000</pubDate>
      <link>https://forem.com/sammiihk/cursor-reactive-gradients-making-css-respond-to-mouse-position-5ga3</link>
      <guid>https://forem.com/sammiihk/cursor-reactive-gradients-making-css-respond-to-mouse-position-5ga3</guid>
      <description>&lt;h1&gt;
  
  
  Cursor-Reactive Gradients: Making CSS Respond to Mouse Position
&lt;/h1&gt;

&lt;p&gt;The logo on my portfolio site changes colour as you move your mouse. Not with a library. Not with a pre-built animation. With about 15 lines of maths that convert cursor position into RGB values and generate a three-point radial gradient in real time.&lt;/p&gt;

&lt;p&gt;And when you scroll, the same gradient system responds using sine and cosine wave functions.&lt;/p&gt;

&lt;p&gt;Here's how the whole thing works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core function: gradientCreator
&lt;/h2&gt;

&lt;p&gt;The entire gradient system lives in one function that takes two numbers and returns a CSS background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gradientCreator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xPc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;yPc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colourCreator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colour&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;colour&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colour1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;colourCreator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xPc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colour2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;colourCreator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yPc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;colour3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;colourCreator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xPc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`radial-gradient(at 50% 0, rgb(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), transparent 50%),
    radial-gradient(at 6.7% 75%, rgb(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), transparent 50%),
    radial-gradient(at 93.3% 75%, rgb(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;colour3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;), transparent 50%),
    lavender`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two inputs: &lt;code&gt;xPc&lt;/code&gt; (cursor X as a percentage of the viewport) and &lt;code&gt;yPc&lt;/code&gt; (cursor Y as a percentage).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;colourCreator&lt;/code&gt; maps 0-100% to 0-255 RGB range. Simple linear interpolation: &lt;code&gt;Math.floor((255 / 100) * number)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three colours are derived from two inputs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;colour1&lt;/code&gt; = colourCreator(xPc)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;colour2&lt;/code&gt; = colourCreator(yPc)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;colour3&lt;/code&gt; = 255 - colourCreator(xPc) -- the inverse of colour1&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The channel rotation trick
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes it actually work. The three radial gradient points use the &lt;em&gt;same three values&lt;/em&gt; but in &lt;em&gt;different order&lt;/em&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Gradient point&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;th&gt;RGB order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Top centre&lt;/td&gt;
&lt;td&gt;50% 0&lt;/td&gt;
&lt;td&gt;(c1, c3, c2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bottom left&lt;/td&gt;
&lt;td&gt;6.7% 75%&lt;/td&gt;
&lt;td&gt;(c3, c2, c1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bottom right&lt;/td&gt;
&lt;td&gt;93.3% 75%&lt;/td&gt;
&lt;td&gt;(c2, c1, c3)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By rotating which channel gets which value at each point, moving in &lt;em&gt;any&lt;/em&gt; direction produces a smooth colour shift. You get a full spectrum of transitions from just two input numbers.&lt;/p&gt;

&lt;p&gt;The positions form an equilateral triangle on the viewport, which distributes the colour mixing evenly.&lt;/p&gt;

&lt;p&gt;The fallback colour is &lt;code&gt;lavender&lt;/code&gt; -- so if all three gradients are transparent at any point, you still get something pleasant.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS mask-image: gradient through text
&lt;/h2&gt;

&lt;p&gt;The gradient doesn't paint a box. It paints through the shape of text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.logo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-mask-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url('/sammii.png')&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;mask-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url('/sammii.png')&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-mask-repeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;mask-repeat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;no-repeat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-mask-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;mask-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PNG is a text shape -- the word "sammii". The gradient is the element's background, but &lt;code&gt;mask-image&lt;/code&gt; clips it to only show through the mask shape.&lt;/p&gt;

&lt;p&gt;The result: the text itself becomes the gradient canvas. Move your cursor and the letters shift through colours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scroll-driven: sine waves replacing cursor input
&lt;/h2&gt;

&lt;p&gt;In the portfolio container, when the user scrolls, the same &lt;code&gt;gradientCreator&lt;/code&gt; function responds -- but instead of cursor position, it gets values driven by trigonometric wave functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scrollPercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;clientHeight&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wave1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollPercent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wave2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollPercent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wave1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;wave2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;scrollPercent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking this down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wave1&lt;/code&gt;: a sine wave that oscillates 6 complete cycles over the full scroll distance, with an amplitude of 50 centred at 50 -- so it swings between 0 and 100&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wave2&lt;/code&gt;: a cosine wave at a different frequency (4 cycles), with a smaller amplitude of 30 -- this adds variation so the colour change isn't predictable&lt;/li&gt;
&lt;li&gt;The Y value is the sum of both waves, clamped to 0-100&lt;/li&gt;
&lt;li&gt;The X value gets its own separate sine wave at yet another frequency (2 cycles)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The different frequencies mean the X and Y inputs never repeat the same pattern at the same scroll position.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smoothing with lerp
&lt;/h2&gt;

&lt;p&gt;Raw values from scroll events are choppy. The animation uses linear interpolation to smooth everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lerp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; 
  &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;currentX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;currentY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each frame, the current value moves 20% of the remaining distance toward the target. This creates an ease-out effect -- fast initial response that gradually settles.&lt;/p&gt;

&lt;p&gt;The animation runs on &lt;code&gt;requestAnimationFrame&lt;/code&gt;, so it's synced to the display refresh rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pointer vs scroll: conflict resolution
&lt;/h2&gt;

&lt;p&gt;Both pointer movement and scroll drive the same gradient function. Without coordination, they'd fight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isScrollingRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastScrollTimeRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// In scroll handler:&lt;/span&gt;
&lt;span class="nx"&gt;isScrollingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;lastScrollTimeRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// In pointer handler:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isScrollingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastScrollTimeRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;isScrollingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scroll takes priority while active. Once scrolling stops for 500ms, mouse movement resumes control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing in context
&lt;/h2&gt;

&lt;p&gt;Fifteen lines of colour maths. A CSS mask. Some trigonometry. A lerp function. That's the entire system.&lt;/p&gt;

&lt;p&gt;No animation library. No canvas. No WebGL. Just the browser's native CSS gradient engine doing what it's good at -- painting pixels fast -- driven by a bit of arithmetic that maps human input to colour space.&lt;/p&gt;

&lt;p&gt;The best part: because &lt;code&gt;gradientCreator&lt;/code&gt; is a pure function (two numbers in, CSS string out), you could drive it with anything. Microphone volume. Accelerometer data. API response times. The abstraction doesn't care where the numbers come from.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sammii, founder of &lt;a href="https://lunary.app" rel="noopener noreferrer"&gt;Lunary&lt;/a&gt; -- an astrology app that teaches you to read your own birth chart. When I'm not calculating planetary transits, I'm building gradient systems and obsessing over keyboard navigation. Follow the build on &lt;a href="https://dev.to/sammiihk"&gt;Dev.to&lt;/a&gt; and &lt;a href="https://sammii.hashnode.dev" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>javascript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
