<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.3.4">Jekyll</generator><link href="https://zolkos.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zolkos.com/" rel="alternate" type="text/html" /><updated>2026-03-11T13:25:44+00:00</updated><id>https://zolkos.com/feed.xml</id><title type="html">Rob Zolkos</title><subtitle>I design and build software systems that help companies automate their manual processes, enhance their workflows and improve their efficiencies.</subtitle><author><name>Rob Zolkos</name></author><entry><title type="html">Deep Dive: How Claude Code’s /insights Command Works</title><link href="https://zolkos.com/2026/02/04/deep-dive-how-claude-codes-insights-command-works.html" rel="alternate" type="text/html" title="Deep Dive: How Claude Code’s /insights Command Works" /><published>2026-02-04T01:35:00+00:00</published><updated>2026-02-04T01:35:00+00:00</updated><id>https://zolkos.com/2026/02/04/deep-dive-how-claude-codes-insights-command-works</id><content type="html" xml:base="https://zolkos.com/2026/02/04/deep-dive-how-claude-codes-insights-command-works.html"><![CDATA[<p>The <code class="language-plaintext highlighter-rouge">/insights</code> command in Claude Code generates a comprehensive HTML report analyzing your usage patterns across all your Claude Code sessions. It’s designed to help you understand how you interact with Claude, what’s working well, where friction occurs, and how to improve your workflows.</p>

<p>It’s output is really cool and I encourage you to try it and read it through!</p>

<p><strong>Command:</strong> <code class="language-plaintext highlighter-rouge">/insights</code></p>

<p><strong>Description:</strong> “Generate a report analyzing your Claude Code sessions”</p>

<p><strong>Output:</strong> An interactive HTML report saved to <code class="language-plaintext highlighter-rouge">~/.claude/usage-data/report.html</code></p>

<p>But what’s really happening under the hood? Let’s trace through the entire pipeline.</p>

<!--more-->

<hr />

<h2 id="the-analysis-pipeline">The Analysis Pipeline</h2>

<p>The insights generation is a multi-stage process:</p>

<ol>
  <li><strong>Collect</strong> all your session logs from <code class="language-plaintext highlighter-rouge">~/.claude/projects/</code></li>
  <li><strong>Filter</strong> out agent sub-sessions and internal operations</li>
  <li><strong>Extract metadata</strong> from each session (tokens, tools used, duration, etc.)</li>
  <li><strong>Run LLM analysis</strong> to extract “facets” (qualitative assessments) from session transcripts</li>
  <li><strong>Aggregate</strong> all the data across sessions</li>
  <li><strong>Generate insights</strong> using multiple specialized prompts</li>
  <li><strong>Render</strong> an interactive HTML report</li>
</ol>

<p>The facets are cached in <code class="language-plaintext highlighter-rouge">~/.claude/usage-data/facets/</code> so subsequent runs are faster.</p>

<hr />

<h2 id="stage-1-session-filtering--metadata-extraction">Stage 1: Session Filtering &amp; Metadata Extraction</h2>

<p>Before any LLM calls, Claude Code processes your session logs to extract structured metadata.</p>

<h3 id="sessions-are-filtered-to-exclude">Sessions are filtered to exclude:</h3>
<ul>
  <li>Agent sub-sessions (files starting with <code class="language-plaintext highlighter-rouge">agent-</code>)</li>
  <li>Internal facet-extraction sessions</li>
  <li>Sessions with fewer than 2 user messages</li>
  <li>Sessions shorter than 1 minute</li>
</ul>

<h3 id="metadata-extracted-per-session">Metadata extracted per session:</h3>
<ul>
  <li><code class="language-plaintext highlighter-rouge">session_id</code> - Unique identifier</li>
  <li><code class="language-plaintext highlighter-rouge">start_time</code> - When the session began</li>
  <li><code class="language-plaintext highlighter-rouge">duration_minutes</code> - How long the session lasted</li>
  <li><code class="language-plaintext highlighter-rouge">user_message_count</code> - Number of user messages</li>
  <li><code class="language-plaintext highlighter-rouge">input_tokens</code> / <code class="language-plaintext highlighter-rouge">output_tokens</code> - Token usage</li>
  <li><code class="language-plaintext highlighter-rouge">tool_counts</code> - Which tools were used and how often</li>
  <li><code class="language-plaintext highlighter-rouge">languages</code> - Programming languages detected from file extensions</li>
  <li><code class="language-plaintext highlighter-rouge">git_commits</code> / <code class="language-plaintext highlighter-rouge">git_pushes</code> - Git activity</li>
  <li><code class="language-plaintext highlighter-rouge">user_interruptions</code> - How often you interrupted Claude</li>
  <li><code class="language-plaintext highlighter-rouge">tool_errors</code> - Tool failures and their categories</li>
  <li><code class="language-plaintext highlighter-rouge">lines_added</code> / <code class="language-plaintext highlighter-rouge">lines_removed</code> / <code class="language-plaintext highlighter-rouge">files_modified</code> - Code changes</li>
  <li><code class="language-plaintext highlighter-rouge">uses_task_agent</code> / <code class="language-plaintext highlighter-rouge">uses_mcp</code> / <code class="language-plaintext highlighter-rouge">uses_web_search</code> / <code class="language-plaintext highlighter-rouge">uses_web_fetch</code> - Feature usage</li>
  <li><code class="language-plaintext highlighter-rouge">first_prompt</code> - Your initial message</li>
  <li><code class="language-plaintext highlighter-rouge">summary</code> - Brief session summary</li>
</ul>

<hr />

<h2 id="stage-2-transcript-summarization-for-long-sessions">Stage 2: Transcript Summarization (For Long Sessions)</h2>

<p>If a session transcript exceeds 30,000 characters, it’s chunked into 25,000-character segments and each chunk is summarized before facet extraction.</p>

<h3 id="transcript-summarization-prompt">Transcript Summarization Prompt</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Summarize this portion of a Claude Code session transcript. Focus on:
1. What the user asked for
2. What Claude did (tools used, files modified)
3. Any friction or issues
4. The outcome

Keep it concise - 3-5 sentences. Preserve specific details like file names,
error messages, and user feedback.

TRANSCRIPT CHUNK:
</code></pre></div></div>

<hr />

<h2 id="stage-3-facet-extraction">Stage 3: Facet Extraction</h2>

<p>This is the core qualitative analysis. For each session (up to 50 new sessions per run), Claude analyzes the transcript to extract structured “facets” - qualitative assessments of what happened.</p>

<p><strong>Model:</strong> Haiku (fast, cost-effective)
<strong>Max output tokens:</strong> 4096</p>

<h3 id="facet-extraction-prompt">Facet Extraction Prompt</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code session and extract structured facets.

CRITICAL GUIDELINES:

1. **goal_categories**: Count ONLY what the USER explicitly asked for.
   - DO NOT count Claude's autonomous codebase exploration
   - DO NOT count work Claude decided to do on its own
   - ONLY count when user says "can you...", "please...", "I need...", "let's..."

2. **user_satisfaction_counts**: Base ONLY on explicit user signals.
   - "Yay!", "great!", "perfect!" → happy
   - "thanks", "looks good", "that works" → satisfied
   - "ok, now let's..." (continuing without complaint) → likely_satisfied
   - "that's not right", "try again" → dissatisfied
   - "this is broken", "I give up" → frustrated

3. **friction_counts**: Be specific about what went wrong.
   - misunderstood_request: Claude interpreted incorrectly
   - wrong_approach: Right goal, wrong solution method
   - buggy_code: Code didn't work correctly
   - user_rejected_action: User said no/stop to a tool call
   - excessive_changes: Over-engineered or changed too much

4. If very short or just warmup, use warmup_minimal for goal_category

SESSION:
&lt;session transcript is inserted here&gt;

RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
{
  "underlying_goal": "What the user fundamentally wanted to achieve",
  "goal_categories": {"category_name": count, ...},
  "outcome": "fully_achieved | mostly_achieved | 
              partially_achieved | not_achieved | 
              unclear_from_transcript",
  "user_satisfaction_counts": {"level": count, ...},
  "claude_helpfulness": "unhelpful | slightly_helpful | moderately_helpful | very_helpful | essential",
  "session_type": "single_task | multi_task | iterative_refinement | exploration | quick_question",
  "friction_counts": {"friction_type": count, ...},
  "friction_detail": "One sentence describing friction or empty",
  "primary_success": "none | fast_accurate_search | correct_code_edits | good_explanations | proactive_help | multi_file_changes | good_debugging",
  "brief_summary": "One sentence: what user wanted and whether they got it"
}
</code></pre></div></div>

<h3 id="goal-categories">Goal Categories</h3>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">debug_investigate</code></td>
      <td>Debug/Investigate</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">implement_feature</code></td>
      <td>Implement Feature</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fix_bug</code></td>
      <td>Fix Bug</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write_script_tool</code></td>
      <td>Write Script/Tool</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">refactor_code</code></td>
      <td>Refactor Code</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">configure_system</code></td>
      <td>Configure System</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">create_pr_commit</code></td>
      <td>Create PR/Commit</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">analyze_data</code></td>
      <td>Analyze Data</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">understand_codebase</code></td>
      <td>Understand Codebase</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write_tests</code></td>
      <td>Write Tests</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">write_docs</code></td>
      <td>Write Docs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">deploy_infra</code></td>
      <td>Deploy/Infra</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">warmup_minimal</code></td>
      <td>Cache Warmup (minimal sessions)</td>
    </tr>
  </tbody>
</table>

<h3 id="satisfaction-levels">Satisfaction Levels:</h3>
<p><code class="language-plaintext highlighter-rouge">frustrated</code> → <code class="language-plaintext highlighter-rouge">dissatisfied</code> → <code class="language-plaintext highlighter-rouge">likely_satisfied</code> → <code class="language-plaintext highlighter-rouge">satisfied</code> → <code class="language-plaintext highlighter-rouge">happy</code> → <code class="language-plaintext highlighter-rouge">unsure</code></p>

<h3 id="outcome-categories">Outcome Categories:</h3>
<p><code class="language-plaintext highlighter-rouge">not_achieved</code> → <code class="language-plaintext highlighter-rouge">partially_achieved</code> → <code class="language-plaintext highlighter-rouge">mostly_achieved</code> → <code class="language-plaintext highlighter-rouge">fully_achieved</code> → <code class="language-plaintext highlighter-rouge">unclear_from_transcript</code></p>

<h3 id="friction-categories">Friction Categories</h3>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">misunderstood_request</code></td>
      <td>Claude interpreted incorrectly</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wrong_approach</code></td>
      <td>Right goal, wrong solution method</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">buggy_code</code></td>
      <td>Code didn’t work correctly</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">user_rejected_action</code></td>
      <td>User said no/stop to a tool call</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">claude_got_blocked</code></td>
      <td>Claude got stuck</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">user_stopped_early</code></td>
      <td>User stopped before completion</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">wrong_file_or_location</code></td>
      <td>Edited wrong file/location</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">excessive_changes</code></td>
      <td>Over-engineered or changed too much</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">slow_or_verbose</code></td>
      <td>Too slow or verbose</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tool_failed</code></td>
      <td>Tool failure</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">user_unclear</code></td>
      <td>User’s request was unclear</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">external_issue</code></td>
      <td>External/environmental issue</td>
    </tr>
  </tbody>
</table>

<h3 id="claude-helpfulness-levels">Claude Helpfulness Levels:</h3>
<p><code class="language-plaintext highlighter-rouge">unhelpful</code> → <code class="language-plaintext highlighter-rouge">slightly_helpful</code> → <code class="language-plaintext highlighter-rouge">moderately_helpful</code> → <code class="language-plaintext highlighter-rouge">very_helpful</code> → <code class="language-plaintext highlighter-rouge">essential</code></p>

<h3 id="session-types">Session Types</h3>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">single_task</code></td>
      <td>One focused task</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">multi_task</code></td>
      <td>Multiple tasks in one session</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">iterative_refinement</code></td>
      <td>Back-and-forth refinement</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">exploration</code></td>
      <td>Exploring/understanding codebase</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">quick_question</code></td>
      <td>Brief Q&amp;A</td>
    </tr>
  </tbody>
</table>

<h3 id="primary-success-categories">Primary Success Categories</h3>

<table>
  <thead>
    <tr>
      <th>Category</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">none</code></td>
      <td>No notable success</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">fast_accurate_search</code></td>
      <td>Quick, accurate code search</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">correct_code_edits</code></td>
      <td>Accurate code modifications</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">good_explanations</code></td>
      <td>Clear explanations</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">proactive_help</code></td>
      <td>Helpful suggestions beyond the ask</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">multi_file_changes</code></td>
      <td>Successfully coordinated multi-file edits</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">good_debugging</code></td>
      <td>Effective debugging</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="stage-4-aggregated-analysis">Stage 4: Aggregated Analysis</h2>

<p>Once all session data and facets are collected, they’re aggregated and processed through multiple specialized analysis prompts.</p>

<p><strong>Model:</strong> Haiku
<strong>Max output tokens:</strong> 8192 per prompt</p>

<h3 id="data-passed-to-analysis-prompts">Data Passed to Analysis Prompts</h3>

<p>Each analysis prompt receives aggregated statistics:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{
  sessions: &lt;total sessions&gt;,
  analyzed: &lt;sessions with facets&gt;,
  date_range: { start, end },
  messages: &lt;total messages&gt;,
  hours: &lt;total duration in hours&gt;,
  commits: &lt;git commits&gt;,
  top_tools: [top 8 tools by usage],
  top_goals: [top 8 goal categories],
  outcomes: { outcome distribution },
  satisfaction: { satisfaction distribution },
  friction: { friction type counts },
  success: { success category counts },
  languages: { language usage counts }
}
</code></pre></div></div>

<p>Plus text summaries:</p>
<ul>
  <li><strong>SESSION SUMMARIES:</strong> Up to 50 brief summaries</li>
  <li><strong>FRICTION DETAILS:</strong> Up to 20 friction details from facets</li>
  <li><strong>USER INSTRUCTIONS TO CLAUDE:</strong> Up to 15 repeated instructions users gave Claude</li>
</ul>

<hr />

<h3 id="41-project-areas-analysis">4.1 Project Areas Analysis</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and identify project areas.

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "areas": [
    {
      "name": "Area name",
      "session_count": N,
      "description": "2-3 sentences about what was worked on and how Claude Code was used."
    }
  ]
}

Include 4-5 areas. Skip internal CC operations.
</code></pre></div></div>

<hr />

<h3 id="42-interaction-style-analysis">4.2 Interaction Style Analysis</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and describe the user's interaction style.

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code.
               Use second person 'you'. Describe patterns: iterate quickly vs
               detailed upfront specs? Interrupt often or let Claude run?
               Include specific examples. Use **bold** for key insights.",
  "key_pattern": "One sentence summary of most distinctive interaction style"
}
</code></pre></div></div>

<hr />

<h3 id="43-what-works-well">4.3 What Works Well</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and identify what's working well for this user.
Use second person ("you").

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "intro": "1 sentence of context",
  "impressive_workflows": [
    {
      "title": "Short title (3-6 words)",
      "description": "2-3 sentences describing the impressive workflow or approach.
                      Use 'you' not 'the user'."
    }
  ]
}

Include 3 impressive workflows.
</code></pre></div></div>

<hr />

<h3 id="44-friction-analysis">4.4 Friction Analysis</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and identify friction points for this user.
Use second person ("you").

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "intro": "1 sentence summarizing friction patterns",
  "categories": [
    {
      "category": "Concrete category name",
      "description": "1-2 sentences explaining this category and what could be
                      done differently. Use 'you' not 'the user'.",
      "examples": ["Specific example with consequence", "Another example"]
    }
  ]
}

Include 3 friction categories with 2 examples each.
</code></pre></div></div>

<hr />

<h3 id="45-suggestions--improvements">4.5 Suggestions &amp; Improvements</h3>

<p>This is the longest prompt, providing actionable recommendations:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and suggest improvements.

## CC FEATURES REFERENCE (pick from these for features_to_try):

1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via
   Model Context Protocol.
   - How to use: Run `claude mcp add &lt;server-name&gt; -- &lt;command&gt;`
   - Good for: database queries, Slack integration, GitHub issue lookup,
     connecting to internal APIs

2. **Custom Skills**: Reusable prompts you define as markdown files that run
   with a single /command.
   - How to use: Create `.claude/skills/commit/SKILL.md` with instructions.
     Then type `/commit` to run it.
   - Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr,
     or complex multi-step workflows

3. **Hooks**: Shell commands that auto-run at specific lifecycle events.
   - How to use: Add to `.claude/settings.json` under "hooks" key.
   - Good for: auto-formatting code, running type checks, enforcing conventions

4. **Headless Mode**: Run Claude non-interactively from scripts and CI/CD.
   - How to use: `claude -p "fix lint errors" --allowedTools "Edit,Read,Bash"`
   - Good for: CI/CD integration, batch code fixes, automated reviews

5. **Task Agents**: Claude spawns focused sub-agents for complex exploration
   or parallel work.
   - How to use: Claude auto-invokes when helpful, or ask "use an agent to explore X"
   - Good for: codebase exploration, understanding complex systems

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "claude_md_additions": [
    {
      "addition": "A specific line or block to add to CLAUDE.md based on workflow
                   patterns. E.g., 'Always run tests after modifying auth-related files'",
      "why": "1 sentence explaining why this would help based on actual sessions",
      "prompt_scaffold": "Instructions for where to add this in CLAUDE.md.
                          E.g., 'Add under ## Testing section'"
    }
  ],
  "features_to_try": [
    {
      "feature": "Feature name from CC FEATURES REFERENCE above",
      "one_liner": "What it does",
      "why_for_you": "Why this would help YOU based on your sessions",
      "example_code": "Actual command or config to copy"
    }
  ],
  "usage_patterns": [
    {
      "title": "Short title",
      "suggestion": "1-2 sentence summary",
      "detail": "3-4 sentences explaining how this applies to YOUR work",
      "copyable_prompt": "A specific prompt to copy and try"
    }
  ]
}

IMPORTANT for claude_md_additions: PRIORITIZE instructions that appear MULTIPLE TIMES
in the user data. If user told Claude the same thing in 2+ sessions (e.g.,
'always run tests', 'use TypeScript'), that's a PRIME candidate - they shouldn't
have to repeat themselves.

IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above.
Include 2-3 items for each category.
</code></pre></div></div>

<hr />

<h3 id="46-on-the-horizon-future-opportunities">4.6 On The Horizon (Future Opportunities)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and identify future opportunities.

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "intro": "1 sentence about evolving AI-assisted development",
  "opportunities": [
    {
      "title": "Short title (4-8 words)",
      "whats_possible": "2-3 ambitious sentences about autonomous workflows",
      "how_to_try": "1-2 sentences mentioning relevant tooling",
      "copyable_prompt": "Detailed prompt to try"
    }
  ]
}

Include 3 opportunities. Think BIG - autonomous workflows, parallel agents,
iterating against tests.
</code></pre></div></div>

<hr />

<h3 id="47-fun-ending-memorable-moment">4.7 Fun Ending (Memorable Moment)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Analyze this Claude Code usage data and find a memorable moment.

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "headline": "A memorable QUALITATIVE moment from the transcripts - not a statistic.
               Something human, funny, or surprising.",
  "detail": "Brief context about when/where this happened"
}

Find something genuinely interesting or amusing from the session summaries.
</code></pre></div></div>

<hr />

<h2 id="stage-5-at-a-glance-summary">Stage 5: At a Glance Summary</h2>

<p>The final LLM call generates an executive summary that ties everything together. This prompt receives all the previously generated insights as context.</p>

<h3 id="at-a-glance-prompt">At a Glance Prompt</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You're writing an "At a Glance" summary for a Claude Code usage insights report
for Claude Code users. The goal is to help them understand their usage and
improve how they can use Claude better, especially as models improve.

Use this 4-part structure:

1. **What's working** - What is the user's unique style of interacting with Claude
   and what are some impactful things they've done? You can include one or two
   details, but keep it high level since things might not be fresh in the user's
   memory. Don't be fluffy or overly complimentary. Also, don't focus on the
   tool calls they use.

2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings,
   wrong approaches, bugs) and (b) user-side friction (not providing enough
   context, environment issues -- ideally more general than just one project).
   Be honest but constructive.

3. **Quick wins to try** - Specific Claude Code features they could try from the
   examples below, or a workflow technique if you think it's really compelling.
   (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out
   more context up front" which are less compelling.)

4. **Ambitious workflows for better models** - As we move to much more capable
   models over the next 3-6 months, what should they prepare for? What workflows
   that seem impossible now will become possible? Draw from the appropriate
   section below.

Keep each section to 2-3 not-too-long sentences. Don't overwhelm the user.
Don't mention specific numerical stats or underlined_categories from the
session data below. Use a coaching tone.

RESPOND WITH ONLY A VALID JSON OBJECT:
{
  "whats_working": "(refer to instructions above)",
  "whats_hindering": "(refer to instructions above)",
  "quick_wins": "(refer to instructions above)",
  "ambitious_workflows": "(refer to instructions above)"
}

SESSION DATA:
&lt;aggregated statistics JSON&gt;

## Project Areas (what user works on)
&lt;project_areas results&gt;

## Big Wins (impressive accomplishments)
&lt;what_works results&gt;

## Friction Categories (where things go wrong)
&lt;friction_analysis results&gt;

## Features to Try
&lt;suggestions.features_to_try results&gt;

## Usage Patterns to Adopt
&lt;suggestions.usage_patterns results&gt;

## On the Horizon (ambitious workflows for better models)
&lt;on_the_horizon results&gt;
</code></pre></div></div>

<hr />

<h2 id="stage-6-report-generation">Stage 6: Report Generation</h2>

<p>All the collected data and LLM-generated insights are rendered into an interactive HTML report.</p>

<h3 id="statistics-dashboard">Statistics Dashboard:</h3>
<ul>
  <li>Total sessions, messages, duration, tokens</li>
  <li>Git commits and pushes</li>
  <li>Active days and streaks</li>
  <li>Peak activity hours</li>
</ul>

<h3 id="visualizations">Visualizations:</h3>
<ul>
  <li>Daily activity charts</li>
  <li>Tool usage distribution</li>
  <li>Language breakdown</li>
  <li>Satisfaction distribution</li>
  <li>Outcome tracking</li>
</ul>

<h3 id="narrative-sections">Narrative Sections:</h3>
<ul>
  <li>Project areas with descriptions</li>
  <li>Interaction style analysis</li>
  <li>What’s working well (impressive workflows)</li>
  <li>Friction analysis with specific examples</li>
  <li>CLAUDE.md additions to try</li>
  <li>Features to explore</li>
  <li>On the horizon opportunities</li>
  <li>Fun memorable moment</li>
</ul>

<hr />

<h2 id="pipeline-pseudocode">Pipeline Pseudocode</h2>

<p>Here’s how the stages connect:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>function generateInsights():
    // Stage 1: Load and filter sessions
    sessions = loadSessionLogs("~/.claude/projects/")
    sessions = sessions.filter(s =&gt;
        !isAgentSession(s) &amp;&amp;
        !isInternalSession(s) &amp;&amp;
        s.userMessageCount &gt;= 2 &amp;&amp;
        s.durationMinutes &gt;= 1
    )

    // Extract metadata from each session
    metadata = sessions.map(extractMetadata)

    // Stage 2 &amp; 3: Extract facets (with caching)
    facets = {}
    for session in sessions:
        cached = loadCachedFacet(session.id)
        if cached:
            facets[session.id] = cached
        else:
            transcript = session.transcript
            if transcript.length &gt; 30000:
                transcript = summarizeInChunks(transcript)

            facets[session.id] = callLLM(FACET_EXTRACTION_PROMPT + transcript)
            saveFacetToCache(session.id, facets[session.id])

    // Stage 4: Aggregate and analyze
    aggregated = aggregateAllData(metadata, facets)

    insights = {}
    insights.project_areas = callLLM(PROJECT_AREAS_PROMPT, aggregated)
    insights.interaction_style = callLLM(INTERACTION_STYLE_PROMPT, aggregated)
    insights.what_works = callLLM(WHAT_WORKS_PROMPT, aggregated)
    insights.friction = callLLM(FRICTION_PROMPT, aggregated)
    insights.suggestions = callLLM(SUGGESTIONS_PROMPT, aggregated)
    insights.on_the_horizon = callLLM(ON_THE_HORIZON_PROMPT, aggregated)
    insights.fun_ending = callLLM(FUN_ENDING_PROMPT, aggregated)

    // Stage 5: Generate executive summary
    insights.at_a_glance = callLLM(AT_A_GLANCE_PROMPT, aggregated + insights)

    // Stage 6: Render HTML report
    html = renderReport(aggregated, insights)
    saveFile("~/.claude/usage-data/report.html", html)

    return insights
</code></pre></div></div>

<hr />

<h2 id="data-storage">Data Storage</h2>

<table>
  <thead>
    <tr>
      <th>Path</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">~/.claude/projects/&lt;hash&gt;/</code></td>
      <td>Session logs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">~/.claude/usage-data/facets/&lt;session-id&gt;.json</code></td>
      <td>Cached facets</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">~/.claude/usage-data/report.html</code></td>
      <td>Generated report</td>
    </tr>
  </tbody>
</table>

<p>Facets are cached per-session, so running <code class="language-plaintext highlighter-rouge">/insights</code> multiple times only analyzes new sessions.</p>

<hr />

<h2 id="technical-details">Technical Details</h2>

<table>
  <thead>
    <tr>
      <th>Setting</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Model</td>
      <td>Haiku</td>
    </tr>
    <tr>
      <td>Max tokens per prompt</td>
      <td>8192</td>
    </tr>
    <tr>
      <td>Sessions analyzed per run</td>
      <td>Up to 50 new</td>
    </tr>
    <tr>
      <td>Transcript size limit</td>
      <td>30,000 chars</td>
    </tr>
    <tr>
      <td>Chunk size for summarization</td>
      <td>25,000 chars</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="privacy-considerations">Privacy Considerations</h2>

<p>All analysis happens locally using the Anthropic API. Your session data stays on your machine - the HTML report is generated locally and can be shared at your discretion.</p>

<p>The facet extraction focuses on patterns in your interactions, not the content of your code:</p>
<ul>
  <li>What types of tasks you ask for</li>
  <li>How you respond to Claude’s output</li>
  <li>Where friction occurs in the workflow</li>
  <li>Which tools and features you use</li>
</ul>

<hr />

<h2 id="tips-for-better-insights">Tips for Better Insights</h2>

<ol>
  <li><strong>Use Claude Code regularly</strong> - More sessions = richer analysis</li>
  <li><strong>Give feedback</strong> - Say “thanks” or “that’s not right” so satisfaction can be tracked</li>
  <li><strong>Don’t filter yourself</strong> - Natural usage patterns reveal the most useful insights</li>
  <li><strong>Run periodically</strong> - Check in monthly to see how your patterns evolve</li>
</ol>]]></content><author><name>Rob Zolkos</name></author><category term="claude-code" /><category term="ai" /><summary type="html"><![CDATA[The /insights command in Claude Code generates a comprehensive HTML report analyzing your usage patterns across all your Claude Code sessions. It’s designed to help you understand how you interact with Claude, what’s working well, where friction occurs, and how to improve your workflows. It’s output is really cool and I encourage you to try it and read it through! Command: /insights Description: “Generate a report analyzing your Claude Code sessions” Output: An interactive HTML report saved to ~/.claude/usage-data/report.html But what’s really happening under the hood? Let’s trace through the entire pipeline.]]></summary></entry><entry><title type="html">So where can we use our Claude subscription then?</title><link href="https://zolkos.com/2026/01/12/so-where-can-we-use-our-claude-subscription-then.html" rel="alternate" type="text/html" title="So where can we use our Claude subscription then?" /><published>2026-01-12T10:00:00+00:00</published><updated>2026-01-12T10:00:00+00:00</updated><id>https://zolkos.com/2026/01/12/so-where-can-we-use-our-claude-subscription-then</id><content type="html" xml:base="https://zolkos.com/2026/01/12/so-where-can-we-use-our-claude-subscription-then.html"><![CDATA[<p>There’s been confusion about where we can actually use a Claude subscription. This comes after Anthropic took action to prevent third-party applications from spoofing the Claude Code harness to use Claude subscriptions.</p>

<!--more-->

<div style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border-radius: 8px; padding: 20px; margin-bottom: 24px; color: white; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
  <p style="margin: 0; line-height: 1.6;">The information in this post is based on my understanding from reading various tweets, official GitHub repos and documentation (some of which may or may not be up to date). I will endeavour to keep it up to date as new information becomes available. I would love to see Anthropic themselves maintain an easily parsable page like this that shows what is and is not permitted with a Claude subscription.</p>
</div>

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">We&#39;ve taken action to prevent third-party clients from spoofing the Claude Code agent harness to use consumer subscriptions. Consumer subscriptions and their benefits should only be used in the Anthropic experiences they support (Claude Code CLI, Claude Code web, and via sessionKey in the Agent SDK). Third-party apps can use the API.</p>&mdash; Alex Albert (@trq212) <a href="https://twitter.com/trq212/status/2009689809875591565?ref_src=twsrc%5Etfw">January 12, 2025</a></blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<p>From what I can gather, consumer subscriptions work with official Anthropic tools, not third-party applications. If you want third-party integrations, you need the API.</p>

<table style="width: 100%; border-collapse: collapse;">
  <thead>
    <tr>
      <th style="border: 1px solid #ddd; padding: 8px;">Tool/Service</th>
      <th style="border: 1px solid #ddd; padding: 8px;">Subscription</th>
      <th style="border: 1px solid #ddd; padding: 8px;">API</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Claude desktop and mobile apps</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">❌</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Claude Code CLI</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Claude Code on the web</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">❌</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Claude Agent SDK (personal development)</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Claude Code GitHub Action</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Third-party applications (including coding harnesses)</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">❌</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
    </tr>
    <tr>
      <td style="border: 1px solid #ddd; padding: 8px;">Production agent deployments (with Claude Agent SDK)</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">❌</td>
      <td style="border: 1px solid #ddd; padding: 8px; text-align: center;">✅</td>
    </tr>
  </tbody>
</table>

<h2 id="official-tools">Official Tools</h2>

<h3 id="claude-desktop-and-mobile-apps">Claude Desktop and Mobile Apps</h3>

<p>The consumer applications (desktop and mobile) are the most straightforward way to use your Claude subscription. Available at <a href="https://claude.com/download">claude.com/download</a>, these apps give you direct access to Claude for conversation, file uploads, and Projects.</p>

<h3 id="claude-code-cli">Claude Code CLI</h3>

<p>The official command-line interface for Claude Code is fully supported with Claude subscriptions. This is the tool Anthropic built and maintains specifically for developers who want to use Claude in their development workflow.</p>

<p>You get the full power of Claude integrated into your terminal, with access to your entire codebase, the ability to execute commands, read and write files, and use all the specialized agents that come with Claude Code.</p>

<h3 id="claude-code-on-the-web">Claude Code on the Web</h3>

<p>The web version of Claude Code (accessible through your browser at claude.ai/code) provides the same capabilities as the CLI but through a browser interface. Upload your project files, or point it at a repository, and you can work with Claude on your codebase directly.</p>

<h3 id="claude-agent-sdk">Claude Agent SDK</h3>

<p>Want to experiment with building custom agents? The Claude Agent SDK lets you develop and test specialized agents powered by your Claude subscription for personal development work.</p>

<p>The SDK is available in both <a href="https://github.com/anthropics/claude-agent-sdk-python">Python</a> and <a href="https://github.com/anthropics/claude-agent-sdk-typescript">TypeScript</a>, with <a href="https://platform.claude.com/docs/en/agent-sdk/overview">documentation here</a>.</p>

<p>This is for personal experiments and development. For production deployments of agents, use the API instead of your subscription.</p>

<h3 id="claude-code-github-action">Claude Code GitHub Action</h3>

<p>You can use your Claude subscription to run automated agents in GitHub Actions. The <a href="https://github.com/anthropics/claude-code-action">Claude Code Action</a> lets you set up workflows that leverage Claude for code review, documentation generation, or automated testing analysis. <a href="https://code.claude.com/docs/en/github-actions">Documentation is here</a>.</p>

<p><strong>Any other uses of Claude would require the use of API keys.</strong></p>

<h2 id="summary">Summary</h2>

<p>Your Claude subscription gives you:</p>
<ul>
  <li>Claude desktop and mobile apps for general use</li>
  <li>Claude Code CLI for terminal-based development</li>
  <li>Claude Code on the web for browser-based work</li>
  <li>The ability to build custom agents through the official SDK (for personal development)</li>
  <li><a href="https://github.com/anthropics/claude-code-action">Claude Code GitHub Action</a> for CI/CD integration</li>
</ul>

<p>Let me know if you have any corrections.</p>]]></content><author><name>Rob Zolkos</name></author><category term="ai" /><category term="claude-code" /><category term="development-tools" /><category term="productivity" /><summary type="html"><![CDATA[There’s been confusion about where we can actually use a Claude subscription. This comes after Anthropic took action to prevent third-party applications from spoofing the Claude Code harness to use Claude subscriptions.]]></summary></entry><entry><title type="html">A Month Exploring Fizzy</title><link href="https://zolkos.com/2026/01/02/a-month-exploring-fizzy.html" rel="alternate" type="text/html" title="A Month Exploring Fizzy" /><published>2026-01-02T18:00:00+00:00</published><updated>2026-01-02T18:00:00+00:00</updated><id>https://zolkos.com/2026/01/02/a-month-exploring-fizzy</id><content type="html" xml:base="https://zolkos.com/2026/01/02/a-month-exploring-fizzy.html"><![CDATA[<p>In their book <em>Getting Real</em>, 37signals talk about <a href="https://basecamp.com/gettingreal/10.5-open-doors">Open Doors</a> — the idea that you should give customers access to their data through RSS feeds and APIs. Let them get their information when they want it, how they want it. Open up and good things happen.</p>

<p>Fizzy takes that seriously. When 37signals released <a href="https://fizzy.do">Fizzy</a> with its <a href="https://github.com/basecamp/fizzy">full git history available</a>, they didn’t just open-source the code — they shipped a complete API and webhook system too. The doors were wide open baby!</p>

<p>So I dove in — reading the source, building tools, and sharing what I found. Every time curiosity kicked in, there was a direct path from “I wonder if…” to something I could actually try and execute. This post is a catch-all for my very bubbly month of December.</p>

<!--more-->

<h3 id="writing">Writing</h3>

<p><strong><a href="/2025/12/02/fizzy-webhooks-what-you-need-to-know.html">Fizzy Webhooks: What You Need to Know</a></strong> — I set up a local webhook receiver to capture and document every event type Fizzy sends. The post covers the payload structures, signature verification, and ideas for what you could build on top of the webhook system.</p>

<p><strong><a href="/2025/12/02/the-making-of-fizzy-told-by-git.html">The Making of Fizzy, Told by Git</a></strong> — I prompted Claude Code to analyze the entire git history and write a documentary about the development.</p>

<p><strong><a href="/2025/12/03/vanilla-css-is-all-you-need.html">Vanilla CSS is all you need</a></strong> — Diving into the no-build CSS architecture across Campfire, Writebook, and Fizzy.</p>

<p><strong><a href="/2025/12/08/fizzy-design-evolution-a-flipbook-from-git.html">Fizzy Design Evolution: A Flipbook from Git</a></strong> — I went through each day of commits, got the application to a bootable state, seeded the database, and took a screenshot. Then I stitched those screenshots into a flipbook video with a soundtrack made from Fizzy’s own audio files.</p>

<video src="https://miscrz.s3.us-east-1.amazonaws.com/fizzy-flipbook.mp4" controls="" playsinline="" preload="auto" style="max-width: 100%; margin: 1em 0;"></video>

<p><strong><a href="/2025/12/10/fizzys-pull-requests.html">Fizzy’s Pull Requests: Who Built What and How</a></strong> — An analysis of who owned which domains in the Fizzy codebase. The post maps contributors to their expertise areas and curates learning paths through the PRs for topics like Turbo/Hotwire, caching, AI integration, multi-tenancy, and webhooks.</p>

<h3 id="code">Code</h3>

<p>The open API invited experimentation. I spotted gaps that would make integration easier for other developers, so I filled them:</p>

<p><strong><a href="https://github.com/robzolkos/fizzy-api-client">fizzy-api-client</a></strong> — Ruby client for the Fizzy API.</p>

<p><strong><a href="https://github.com/robzolkos/fizzy-client-python">fizzy-client-python</a></strong> — Python client for the Fizzy API.</p>

<p><strong><a href="https://github.com/robzolkos/fizzy-cli">fizzy-cli</a></strong> — Command-line interface for the Fizzy API, built first in Ruby and then migrated to Go for portability.</p>

<p><strong><a href="https://github.com/robzolkos/fizzy-skill">fizzy-skill</a></strong> — An AI agent skill for interacting with Fizzy.</p>

<p><strong><a href="https://github.com/robzolkos/n8n-nodes-fizzy">n8n-nodes-fizzy</a></strong> — An n8n community node that brings Fizzy into your automation workflows. Create cards, manage assignments, and react to real-time events through webhook triggers.</p>

<p><a href="https://github.com/robzolkos/n8n-nodes-fizzy"><img src="/assets/n8n-fizzy.jpeg" alt="n8n Fizzy Node" /></a></p>

<p><strong>Migration tools</strong> — I built these to make it easier to try Fizzy without starting from scratch. Migrating your existing issues and boards gives you an immediate sense of how it could work for you, without having to manually create test cards. You can see your real data running in Fizzy from day one, which I think makes it easier to evaluate and decide if its useful for you.</p>

<ul>
  <li><a href="https://github.com/robzolkos/linear2fizzy">linear2fizzy</a> — Migrate Linear issues</li>
  <li><a href="https://github.com/robzolkos/jira2fizzy">jira2fizzy</a> — Migrate JIRA issues</li>
  <li><a href="https://github.com/robzolkos/asana2fizzy">asana2fizzy</a> — Migrate Asana tasks</li>
  <li><a href="https://github.com/robzolkos/gh2fizzy">gh2fizzy</a> — Migrate GitHub Issues</li>
  <li><a href="https://github.com/robzolkos/prd2fizzy">prd2fizzy</a> — Convert PRDs to Fizzy cards</li>
</ul>

<h3 id="contributions-to-fizzy">Contributions to Fizzy</h3>

<p>I also contributed a few small fixes back to the main repository:</p>

<ul>
  <li><a href="https://github.com/basecamp/fizzy/pull/2114">#2114</a> — Remove unused install.svg and its CSS class</li>
  <li><a href="https://github.com/basecamp/fizzy/pull/2111">#2111</a> — Remove unpaired view-transition-name</li>
  <li><a href="https://github.com/basecamp/fizzy/pull/2095">#2095</a> — Fix typo: minues → minutes</li>
  <li><a href="https://github.com/basecamp/fizzy/pull/2094">#2094</a> — Fix duplicate word: use use → use</li>
  <li><a href="https://github.com/basecamp/fizzy/pull/2093">#2093</a> — Add QrCodesController test</li>
  <li><a href="https://github.com/basecamp/fizzy/pull/2088">#2088</a> — Fix view-transition-name typo in public card show</li>
</ul>

<h3 id="osaasy-directory">O’Saasy Directory</h3>

<p>Fizzy is released under the <a href="https://osaasy.dev">O’Saasy License</a>, which is similar in spirit to MIT but includes a restriction on offering the software as a competing hosted or SaaS product. You can modify and self-host it, but you can’t repackage it and sell it as your own hosted service.</p>

<p>I built <a href="https://osaasy.directory">O’Saasy Directory</a> to make it easy to find applications released under this license.</p>

<p><a href="https://osaasy.directory"><img src="/assets/osaasy-directory.png" alt="O'Saasy Directory" /></a></p>

<p>Beyond Fizzy, the directory includes other submitted projects where the source is available to read and modify. If you have built something under the O’Saasy License, visit the <a href="https://osaasy.directory/add">submission page</a> to add yours.</p>

<h3 id="carbonationlabs">CarbonationLabs</h3>

<p>Having built the Fizzy CLI and fizzy-api-client Rubygem, I saw some fun opportunities to build little lab experiments to show how Fizzy could be integrated with - both to power up some functionality that isn’t there yet, but also creating boards in some interesting ways (eg Movie Quiz). I got the idea for this on a flight to Australia with no internet. Just a pad of paper and a pen. I should probably do that more often as a bunch of ideas for all sorts of products came out.</p>

<p><a href="https://github.com/robzolkos/carbonation-labs">CarbonationLabs</a> is not a product per se. It’s an open source Rails application designed to be run locally where you can interact with the hosted or self-hosted versions of Fizzy. If anything I hope it inspires creation of little problem solving workflows for Fizzy that wouldn’t be built into the main product (the problem is too niche). The API and webhook system is really flexible and most of your bespoke problems could be solved with some creative thinking.</p>

<blockquote class="twitter-tweet">
  <p lang="en" dir="ltr">Introducing Carbonation Labs - fun ways to add experiments to and extend Fizzy (repo link and demo videos below)🧵</p>
  &mdash; Rob Zolkos (@robzolkos) <a href="https://twitter.com/robzolkos/status/2005011057602163069">December 27, 2025</a>
</blockquote>
<script async="" src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<h3 id="carbonationdev">carbonation.dev</h3>

<p>I built <a href="https://carbonation.dev">carbonation.dev</a> to bring together all the tools, libraries, and integrations that I and others in the community have created for Fizzy. It’s a directory covering API clients (Ruby, Python, JavaScript), CLI tools with packages for macOS, Arch Linux, Debian, Fedora, and Windows, integrations for Claude Code and other AI agents, n8n, Raycast, Telegram, and MCP servers, plus migration tools for GitHub, Linear, Asana, and Jira.</p>

<p><a href="https://carbonation.dev"><img src="/assets/carbonation-dev.png" alt="carbonation.dev" /></a></p>

<p>If you’ve built something for Fizzy, I’d love to feature it. You can <a href="https://github.com/robzolkos/carbonation-dev-site">submit a pull request</a> to add your tool to the directory.</p>

<h3 id="what-i-learned">What I Learned</h3>

<p>Building the Fizzy CLI pushed me into some new territory. I created an AUR package for Arch Linux users, set up a Homebrew tap for macOS, published my first Python package to PyPI, and made an n8n plugin — all firsts for me. While I already knew Go, rewriting the CLI in it was a fun exercise, and building TUIs for the setup and skill commands introduced me to terminal UI libraries I hadn’t used before. Gosh it was fun!</p>

<p>If you want to get better at Rails, Fizzy is a great place to study real-world code. And in my view if you want to work at 37signals as a Rails programmer, digging into Fizzy — along with Campfire and Writebook — is a solid way to learn how they approach Rails architecture and design decisions. Submitting PRs is also a good way to contribute back while learning — just be respectful of the <a href="https://github.com/basecamp/fizzy/blob/main/CONTRIBUTING.md">contribution policy</a>. The review discussions give you a window into how to reason about problems, spot opportunities, and make trade-offs.</p>

<p>This month pushed parts of my creative thinking that weren’t gone, but definitely weren’t being stressed. Like any muscle, use it or lose it. The direction of what to explore came from my own curiosity and a habit of poking around under the hood, and AI helped me move a lot faster once I knew where I wanted to go. Most of this information already exists somewhere — Google, Stack Overflow, documentation — but having AI right there alongside me as a partner was thrilling.</p>

<p>All of this was made possible because a team left the doors open. No one asked me to step inside; I decided to invest the time and do the work to see what I could build, learn and share. I do this at work too—when I can—looking for opportunities I can shape, experiment with, and get genuinely excited about. Most importantly I had fun and I hope you enjoyed following along.</p>

<div style="border: 1px solid #ddd; padding: 1em; margin-top: 2em; color: #666;">
<strong>What's Next:</strong> January is going to look a bit different. I'm planning to work on some <a href="https://github.com/robzolkos/omarchy-skill" style="color: #666;">AI skills</a> and integrations for <a href="https://omarchy.org/" style="color: #666;">Omarchy</a>, exploring whether natural language system administration is possible and effective — particularly with free models. I also want to dig into fine-tuning models, an area I haven't touched yet. And I'm curious about the new <a href="https://qwen.ai/blog?id=qwen-image-layered" style="color: #666;">Qwen Image Layer model</a> to see how well layered AI image manipulation works in practice. ✌
</div>]]></content><author><name>Rob Zolkos</name></author><category term="fizzy" /><category term="rails" /><category term="37signals" /><category term="open-source" /><summary type="html"><![CDATA[In their book Getting Real, 37signals talk about Open Doors — the idea that you should give customers access to their data through RSS feeds and APIs. Let them get their information when they want it, how they want it. Open up and good things happen. Fizzy takes that seriously. When 37signals released Fizzy with its full git history available, they didn’t just open-source the code — they shipped a complete API and webhook system too. The doors were wide open baby! So I dove in — reading the source, building tools, and sharing what I found. Every time curiosity kicked in, there was a direct path from “I wonder if…” to something I could actually try and execute. This post is a catch-all for my very bubbly month of December.]]></summary></entry><entry><title type="html">Fizzy’s Pull Requests: Who Built What and How</title><link href="https://zolkos.com/2025/12/10/fizzys-pull-requests.html" rel="alternate" type="text/html" title="Fizzy’s Pull Requests: Who Built What and How" /><published>2025-12-10T08:18:00+00:00</published><updated>2025-12-10T08:18:00+00:00</updated><id>https://zolkos.com/2025/12/10/fizzys-pull-requests</id><content type="html" xml:base="https://zolkos.com/2025/12/10/fizzys-pull-requests.html"><![CDATA[<p>Sifting through <a href="https://github.com/basecamp/fizzy">Fizzy’s</a> <a href="https://github.com/basecamp/fizzy/pulls">pull requests</a>, it was fascinating to see how the <a href="https://37signals.com/">37signals</a> team thinks through problems, builds solutions, and ships software together. Outside of their massive contributions to open source libraries, the podcast and the books — this was a chance to see up close, with real code, the application building process.</p>

<p>Below you will read which 37signals team members owned which domains in this project, and which PRs best demonstrate their craft. If you’re new to the codebase (or want to level up in specific areas), I will point you to the PRs worth studying. Each section highlights an engineer’s domain knowledge and the code reviews that showcase their thinking.</p>

<!--more-->

<h2 id="why-read-pull-requests">Why Read Pull Requests?</h2>

<p>Reading the code shows the outcome. Pull requests show the decisions.</p>

<p>I love reading PRs because they reveal the reasoning behind the work, the options considered and the tradeoffs made. Review comments often become real lessons as people question choices, suggest alternatives, and offer encouragement.</p>

<p>These reviews can create quick, informal mentorship where one person spots a cleaner abstraction and another catches an edge case before it slips through.</p>

<p>Follow a codebase through its PRs and you can see the product being built in real time as early spikes set patterns, refactors clear debt, and new features rise on earlier work.</p>

<p><strong>Want to dive into a specific topic?</strong> Jump to the <a href="#topic-based-learning-paths">Learning Paths</a> for curated PR sequences on AI/LLM integration, multi-tenancy with SQLite, Turbo/Hotwire, and more.</p>

<p><br /></p>

<div class="card">

  <h2 id="dhh-david-heinemeier-hansson"><a href="https://github.com/dhh">DHH (David Heinemeier Hansson)</a></h2>

  <p><strong>Expertise:</strong> Rails Architecture, Code Style, API Design</p>

  <p>DHH’s code reviews are masterclasses in Rails conventions. His 20 PRs and numerous reviews establish patterns the whole team follows. I believe this is the first publicly viewable application codebase where we see DHH doing code reviews — well worth the time to dig into those and see his thinking for yourself.</p>

  <h3 id="must-read-prs">Must-Read PRs</h3>

  <h4 id="pr-108---spike-events-system-reviewed"><a href="https://github.com/basecamp/fizzy/pull/108">PR #108 - Spike events system</a> (reviewed)</h4>

  <p>This PR establishes core architectural patterns. Key feedback:</p>

  <p>On delegated types and pagination:</p>
  <blockquote>
    <p>“These threads need to be paginated, so you can’t do any in-memory sorting. This all needs to be converted to a delegated type, so you have a single table you can pull from.”</p>
  </blockquote>

  <p>On lazy loading:</p>
  <blockquote>
    <p>“Why not just delegate :user to :session? Then you get to lazy load it too.”</p>
  </blockquote>

  <p>On naming conventions:</p>
  <blockquote>
    <p>“<code class="language-plaintext highlighter-rouge">not_popped</code> is pretty cumbersome of a word. Consider something like <code class="language-plaintext highlighter-rouge">unpopped</code> if staying in the negative or go with something like <code class="language-plaintext highlighter-rouge">active</code>.”</p>
  </blockquote>

  <p>On test-induced design damage:</p>
  <blockquote>
    <p>“I think that would then qualify as test-induced design damage. Better replace that with a mock or even better just a fixture session you can use. We should never let our desire for ease of testing bleed into the application itself.”</p>
  </blockquote>

  <h4 id="pr-120---use-the-new-paramsexpect-syntax"><a href="https://github.com/basecamp/fizzy/pull/120">PR #120 - Use the new params#expect syntax</a></h4>

  <p>Demonstrates DHH’s preference for Rails’ built-in features:</p>
  <blockquote>
    <p>“To ensure we don’t get spurious 500s when we can serve 400s on bad data.”</p>
  </blockquote>

  <h4 id="pr-119---start-adding-caching"><a href="https://github.com/basecamp/fizzy/pull/119">PR #119 - Start adding caching</a></h4>

  <p>Foundation for Fizzy’s caching strategy.</p>

  <h4 id="pr-425---plain-text-mentions-reviewed"><a href="https://github.com/basecamp/fizzy/pull/425">PR #425 - Plain text mentions</a> (reviewed)</h4>

  <p>Key feedback on code style:</p>

  <p>On method naming:</p>
  <blockquote>
    <p>“<code class="language-plaintext highlighter-rouge">collect</code> implies that we’re returning an array of mentions (as #collect). Would use <code class="language-plaintext highlighter-rouge">create_mentions</code> when you don’t care about the return value.”</p>
  </blockquote>

  <p>On Rails built-ins:</p>
  <blockquote>
    <p>“You can use <code class="language-plaintext highlighter-rouge">after_save_commit</code> instead of <code class="language-plaintext highlighter-rouge">after_commit on: %i[ create update ]</code>.”</p>
  </blockquote>

  <p>On API design:</p>
  <blockquote>
    <p>“Bit too heavy-handed, imo. Better to make action return a StringInquirer. Then you can do <code class="language-plaintext highlighter-rouge">event.action.completed?</code>.”</p>
  </blockquote>

  <h3 id="dhhs-style-patterns">DHH’s Style Patterns</h3>

  <ul>
    <li>Prefer delegation over accessor methods</li>
    <li>Use Rails’ built-in features (StringInquirer, counter caches, <code class="language-plaintext highlighter-rouge">after_save_commit</code>)</li>
    <li>Name methods by what they do, not what they return</li>
    <li>Avoid test-induced design damage</li>
    <li>Extract explaining methods for complex logic</li>
  </ul>

</div>

<div class="card">

  <h2 id="jorge-manrubia"><a href="https://github.com/jorgemanrubia">Jorge Manrubia</a></h2>

  <p><strong>Expertise:</strong> Turbo/Hotwire, Caching, AI/LLM Integration, ActiveRecord Patterns</p>

  <p>With 285 PRs, Jorge is the most prolific backend contributor. His deep knowledge spans Hotwire, caching strategies, and AI integration.</p>

  <h3 id="must-read-prs-1">Must-Read PRs</h3>

  <h4 id="pr-339---enable-page-refreshes-with-morphing"><a href="https://github.com/basecamp/fizzy/pull/339">PR #339 - Enable page refreshes with morphing</a></h4>

  <p>Introduction of Turbo morphing for smoother updates:</p>
  <blockquote>
    <p>“This makes the update actions on cards feel smoother.”</p>
  </blockquote>

  <h4 id="pr-483---fizzy-do-with-ai"><a href="https://github.com/basecamp/fizzy/pull/483">PR #483 - Fizzy Do with AI</a></h4>

  <p>A complete guide to integrating LLMs in Rails:</p>

  <blockquote>
    <p>“Adds LLM processing to the Fizzy Do bar, enabling:</p>
    <ul>
      <li>Filtering cards using natural language. E.g: <code class="language-plaintext highlighter-rouge">cards assigned to jz</code>.</li>
      <li>Act on cards using natural language: E.g: <code class="language-plaintext highlighter-rouge">close cards assigned to jz</code>.</li>
      <li>Get insight on cards: E.g: <code class="language-plaintext highlighter-rouge">summarize this card</code>.”</li>
    </ul>
  </blockquote>

  <p>Key implementation details:</p>
  <ul>
    <li>Using <code class="language-plaintext highlighter-rouge">ruby_llm</code> gem for model flexibility</li>
    <li>Translating natural language to commands via structured output</li>
    <li>Vector embeddings with SQLite-vec extension</li>
    <li>Token limit handling</li>
  </ul>

  <h4 id="pr-1052---add-more-caching"><a href="https://github.com/basecamp/fizzy/pull/1052">PR #1052 - Add more caching</a></h4>

  <p>HTTP caching and server-side template caching patterns:</p>

  <blockquote>
    <p>“When rendering a day timeline, we:</p>
    <ul>
      <li>HTTP cache the full request.</li>
      <li>Cache the filter menu.</li>
      <li>Cache the columns of events (so this could be shared across users who see the same set of events).</li>
    </ul>

    <p>We leave out things like the day header, since this can vary on a per-user basis (e.g: timezones)”</p>
  </blockquote>

  <h4 id="pr-929---conversation-cost-limits-reviewed"><a href="https://github.com/basecamp/fizzy/pull/929">PR #929 - Conversation cost limits</a> (reviewed)</h4>

  <p>Jorge’s review demonstrates advanced patterns:</p>

  <p>On extracting models:</p>
  <blockquote>
    <p>“The shared param is often a smell that something is missing. My mind goes to having a new record <code class="language-plaintext highlighter-rouge">Ai::Quota</code> so that each user has a quota. The quota has an amount that gets reset weekly in a cron job.”</p>
  </blockquote>

  <p>On custom ActiveRecord types:</p>
  <blockquote>
    <p>“If you wanted to make an AR attribute, you can use a custom Active Model/Record type; there is first-class support for it. You can define a type, register it, and then declare attributes with it.”</p>
  </blockquote>

  <p>On the <code class="language-plaintext highlighter-rouge">.wrap</code> convention:</p>
  <blockquote>
    <p>“We have the internal in-house convention of using a static method <code class="language-plaintext highlighter-rouge">.wrap</code> when you want to get an object from several different types values.”</p>
  </blockquote>

  <h3 id="jorges-patterns">Jorge’s Patterns</h3>

  <ul>
    <li>Memoize expensive computations called multiple times per render</li>
    <li>Use transient filters for temporary state</li>
    <li>Consider cache keys carefully (user-specific vs. shared)</li>
    <li>Extract concerns when responsibilities become clear</li>
    <li>Use Stimulus values API over arbitrary data attributes</li>
  </ul>

</div>

<div class="card">

  <h2 id="mike-dalessio"><a href="https://github.com/flavorjones">Mike Dalessio</a></h2>

  <p><strong>Expertise:</strong> Infrastructure, Solid Queue, Dependencies, AI/Parsing, DevOps</p>

  <p>With 317 PRs, flavorjones handles the infrastructure backbone: dependencies, background jobs, deployment, and parsing.</p>

  <h3 id="must-read-prs-2">Must-Read PRs</h3>

  <h4 id="pr-457---recursive-descent-parser-for-fizzy-do"><a href="https://github.com/basecamp/fizzy/pull/457">PR #457 - Recursive descent parser for Fizzy Do</a></h4>

  <p>Hand-written parser design:</p>
  <blockquote>
    <p>“Using a hand-written recursive descent parser instead of a LALR parser generator (like lex/yacc or rex/racc) because IMHO it’s easier to understand, extend, and debug. It also allows us to be a bit more error-tolerant where we need to.”</p>
  </blockquote>

  <blockquote>
    <p>“The AST I’m introducing here is pretty simple, and I’m not tracking token location because my assumption is that the parsed strings are all going to be short.”</p>
  </blockquote>

  <h4 id="pr-159---introduce-tenanting"><a href="https://github.com/basecamp/fizzy/pull/159">PR #159 - Introduce tenanting</a></h4>

  <p>Multi-tenant SQLite architecture with comprehensive documentation:</p>

  <blockquote>
    <p>“This PR does a few notable things to implement a tenanted sqlite3 database for Fizzy:</p>
    <ol>
      <li>Introduce a <code class="language-plaintext highlighter-rouge">Tenant</code> model in a non-tenanted secondary database</li>
      <li>Tenant the ApplicationRecord models using active_record-tenanted</li>
      <li>Introduce middleware for selecting the tenant based on subdomain</li>
      <li>Extend the ‘first run’ flow to include a subdomain field</li>
      <li>Modify <code class="language-plaintext highlighter-rouge">TestCase</code> to handle parallel testing”</li>
    </ol>
  </blockquote>

  <p>Design decisions documented:</p>
  <ul>
    <li>Subdomain vs URL path tenanting</li>
    <li>Slug separate from database name for changeability</li>
    <li>SecureRandom for permanent database file names</li>
  </ul>

  <h4 id="pr-1109---yabeda-for-solid-queue-metrics"><a href="https://github.com/basecamp/fizzy/pull/1109">PR #1109 - Yabeda for Solid Queue metrics</a></h4>

  <p>Monitoring background job infrastructure.</p>

  <h4 id="pr-501---stimulus-controller-for-bubbles"><a href="https://github.com/basecamp/fizzy/pull/501">PR #501 - Stimulus controller for bubbles</a></h4>

  <p>Learning Stimulus through code review. Jorge’s feedback:</p>
  <blockquote>
    <p>“Consider using Stimulus values instead of arbitrary data properties. It’s not a life-changing improvement, but nice to use what’s available and the API is nice. You would be able to do <code class="language-plaintext highlighter-rouge">this.closeAtValue</code> to read the attribute.”</p>
  </blockquote>

  <blockquote>
    <p>“In javascript, our convention is <code class="language-plaintext highlighter-rouge">closesAt</code> instead of <code class="language-plaintext highlighter-rouge">closes_at</code>.”</p>
  </blockquote>

  <h3 id="flavorjoness-patterns">flavorjones’s Patterns</h3>

  <ul>
    <li>Structured logging with tenant context</li>
    <li>Careful dependency management (Rails edge testing)</li>
    <li>Comprehensive test helpers for complex infrastructure</li>
    <li>VCR cassettes for AI/external API testing</li>
    <li>Clear PR descriptions explaining design decisions</li>
  </ul>

</div>

<div class="card">

  <h2 id="jason-zimdars"><a href="https://github.com/jzimdars">Jason Zimdars</a></h2>

  <p><strong>Expertise:</strong> UI/UX Design, CSS Architecture, ERB Templates, Filtering Systems</p>

  <p>217 PRs focused on design implementation. JZ bridges design and code.</p>

  <h3 id="must-read-prs-3">Must-Read PRs</h3>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/305">PR #305 - New visual design</a> - Complete UI overhaul that defined Fizzy’s visual language</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/131">PR #131 - New filtering UI</a> - Filter interface patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/335">PR #335 - Two column Collection design</a> - Layout architecture decisions</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/265">PR #265 - Quick filters</a> - User-facing filter shortcuts</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/608">PR #608 - Add multiple cards quickly</a> - Rapid input UX patterns</li>
  </ul>

  <h3 id="jzs-patterns">JZ’s Patterns</h3>

  <ul>
    <li>CSS file structure by component</li>
    <li>Visual documentation via screenshots in PRs</li>
  </ul>

  <p>For more on Fizzy’s CSS approach, see my post <a href="https://www.zolkos.com/2025/12/03/vanilla-css-is-all-you-need">Vanilla CSS Is All You Need</a>.</p>

</div>

<div class="card">

  <h2 id="stanko-krtalic-rusendic"><a href="https://github.com/monorkin">Stanko Krtalic Rusendic</a></h2>

  <p><strong>Expertise:</strong> Webhooks, Authentication, Security, External Integrations</p>

  <p>76 PRs with deep focus on external system integration and security.</p>

  <h3 id="must-read-prs-4">Must-Read PRs</h3>

  <h4 id="pr-1083---webhooks"><a href="https://github.com/basecamp/fizzy/pull/1083">PR #1083 - Webhooks</a></h4>

  <p>Comprehensive webhook implementation:</p>

  <blockquote>
    <p>“Webhooks hook into the events model. For every event that’s created a WebhookDispatch job is spawned that checks which webhooks should get triggered.”</p>
  </blockquote>

  <p>Security mechanisms:</p>
  <ol>
    <li>HMAC SHA256 signing for verification</li>
    <li>Timestamps to prevent replay attacks</li>
    <li>URL resolution checking for loopback/private networks (SSRF protection)</li>
    <li>Delinquent webhook auto-deactivation</li>
  </ol>

  <blockquote>
    <p>“I used the limits concurrency method here to preserve causality as much as possible. It’s not a guarantee that the hooks will get delivered in order, but it helps preserve the order in most cases.”</p>
  </blockquote>

  <h4 id="pr-929---conversation-cost-limits"><a href="https://github.com/basecamp/fizzy/pull/929">PR #929 - Conversation cost limits</a></h4>

  <p>AI quota management with Money value object:</p>

  <blockquote>
    <p>“We still aren’t sure how much Fizzy Ask is going to cost us, so to keep cost under control I’ve added a cost limit per conversation.”</p>
  </blockquote>

  <p>Implementation details:</p>
  <ul>
    <li>Quota tracking per user</li>
    <li>Weekly reset periods</li>
    <li>Money object for microcent calculations</li>
    <li>Clear error messaging for users</li>
  </ul>

  <h4 id="pr-1304---magic-links"><a href="https://github.com/basecamp/fizzy/pull/1304">PR #1304 - Magic links</a></h4>

  <p>Passwordless authentication with security considerations. Key discussion from flavorjones:</p>
  <blockquote>
    <p>“One thing I’m nervous about here is the loss of authentication per account. It’s possible that an Identity (a person) would have multiple accounts under multiple email addresses… compromising one of those email addresses exposes all of those Fizzy accounts.”</p>
  </blockquote>

  <h3 id="monorkins-patterns">monorkin’s Patterns</h3>

  <ul>
    <li>Security-first design (SSRF checks, signing, rate limits)</li>
    <li>Detailed PR descriptions with implementation rationale</li>
    <li>State machine patterns for deliveries (pending, in_progress, completed, errored)</li>
    <li>Active Job Continuations for reliable delivery</li>
  </ul>

</div>

<div class="card">

  <h2 id="andy-smith"><a href="https://github.com/andyra">Andy Smith</a></h2>

  <p><strong>Expertise:</strong> CSS/Styling, Stimulus Controllers, UI Components, Accessibility</p>

  <p>With 268 PRs, Andy is the third most prolific contributor, focused entirely on frontend implementation.</p>

  <h3 id="must-read-prs-5">Must-Read PRs</h3>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/962">PR #962 - Combobox a11y</a> - Accessibility patterns for complex components</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/920">PR #920 - Popup orientation</a> - Positioning logic for popups</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1199">PR #1199 - Column animation</a> - CSS animation patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1187">PR #1187 - Save expanded state to localStorage</a> - Client-side state persistence</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/836">PR #836 - Lightbox improvements</a> - Image viewing UX</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/379">PR #379 - Btn dropdown</a> - Dropdown component patterns</li>
  </ul>

  <h3 id="andys-patterns">Andy’s Patterns</h3>

  <ul>
    <li>Component-focused CSS organization</li>
    <li>Accessibility-first approach</li>
    <li>localStorage for UI state persistence</li>
    <li>Stimulus controllers for interactive behavior</li>
  </ul>

</div>

<div class="card">

  <h2 id="other-key-contributors">Other Key Contributors</h2>

  <h3 id="jose-farias"><a href="https://github.com/josefarias">Jose Farias</a></h3>

  <p><strong>Expertise:</strong> Filtering Systems, Form Handling, Early Architecture</p>

  <p>23 PRs establishing core patterns in Fizzy’s early development.</p>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/113">PR #113 - Persisted filters</a> - Filter state management</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/124">PR #124 - Build threads from a single table</a> - Delegated types implementation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/135">PR #135 - Counter cache for comments</a> - ActiveRecord counter cache patterns</li>
  </ul>

  <h3 id="kevin-mcconnell"><a href="https://github.com/kevinmcconnell">Kevin McConnell</a></h3>

  <p><strong>Expertise:</strong> Infrastructure, Notifications, Deployment, Performance</p>

  <p>49 PRs focused on operational concerns.</p>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/199">PR #199 - Notification spike</a> - Notification system foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/274">PR #274 - Notification preferences</a> - User preference architecture</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1377">PR #1377 - Extend public caching to 30 seconds</a> - HTTP caching for CDN optimization</li>
  </ul>

  <h3 id="jeremy-daer"><a href="https://github.com/jeremy">Jeremy Daer</a></h3>

  <p><strong>Expertise:</strong> Security, Ruby Infrastructure, DevOps</p>

  <p>36 PRs with security focus.</p>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/1903">PR #1903 - Security: DNS rebinding protection</a> - Closing security vulnerabilities</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1905">PR #1905 - Security: Web Push SSRF</a> - SSRF mitigation patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1964">PR #1964 - Content Security Policy</a> - CSP implementation</li>
  </ul>

  <h3 id="rosa-gutierrez"><a href="https://github.com/rosa">Rosa Gutierrez</a></h3>

  <p><strong>Expertise:</strong> CSRF Protection, Security Headers</p>

  <ul>
    <li><a href="https://github.com/basecamp/fizzy/pull/1721">PR #1721 - Sec-Fetch-Site header reporting</a> - Modern browser security header usage</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1751">PR #1751 - Sec-Fetch-Site CSRF protection</a> - Moving from report-only to enforcement</li>
  </ul>

</div>

<div class="card">

  <h2 id="topic-based-learning-paths">Topic-Based Learning Paths</h2>

  <h3 id="turbohotwire">Turbo/Hotwire</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/339">PR #339 - Enable page refreshes with morphing</a> - Morphing basics</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/396">PR #396 - Refresh timers when morphing happens</a> - Handling side effects</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/416">PR #416 - Use morphing in stream replace</a> - Stream + morphing</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/490">PR #490 - Refresh local time target when morphed</a> - Component refresh patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/696">PR #696 - Turbo frames to avoid resetting forms</a> - Turbo Frames for partial updates</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1091">PR #1091 - Speed up filter expansion with turbo stream</a> - Performance patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1327">PR #1327 - Fix morphing + pagination issues</a> - Edge cases</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1413">PR #1413 - Upgrade turbo</a> - Upgrade patterns</li>
  </ol>

  <h3 id="stimulus">Stimulus</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/501">PR #501 - Stimulus controller for bubbles</a> - Introduction with review feedback on values API</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/586">PR #586 - Knobs</a> - Custom input components</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/592">PR #592 - Set value when using slider</a> - State management</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/920">PR #920 - Popup orientation</a> - Positioning logic</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/962">PR #962 - Combobox a11y</a> - Accessibility patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1187">PR #1187 - Save expanded state to localStorage</a> - Persistence</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1199">PR #1199 - Column animation</a> - Animation coordination</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1936">PR #1936 - Built-in :self support</a> - Modern Stimulus patterns</li>
  </ol>

  <h3 id="rails-caching">Rails Caching</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/119">PR #119 - Start adding caching</a> - Foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/135">PR #135 - Counter cache for comments</a> - Counter caches</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/317">PR #317 - Assignee dropdown cache bypass</a> - Fragment cache invalidation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/340">PR #340 - Fix caching issue with cards</a> - Debugging cache issues</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/566">PR #566 - Invalidate card caches when editing workflows</a> - Cross-model invalidation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1052">PR #1052 - Add more caching</a> - HTTP + template caching</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1132">PR #1132 - Fix caching issues + refactor</a> - Systematic fixes</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1377">PR #1377 - Extend public caching</a> - CDN/proxy caching</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1571">PR #1571 - HTTP caching for menus</a> - Component caching</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1607">PR #1607 - CSRF vs HTTP caching</a> - When NOT to cache</li>
  </ol>

  <h3 id="aillm-integration">AI/LLM Integration</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/457">PR #457 - Recursive descent parser</a> - Command parsing fundamentals</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/460">PR #460 - Fizzy Do initial</a> - Command system foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/464">PR #464 - Fizzy Do: confirmations, close cards</a> - Action execution</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/466">PR #466 - Fizzy Do: tags, help menu</a> - Expanding commands</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/483">PR #483 - Fizzy Do with AI</a> - Complete LLM integration guide</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/857">PR #857 - Fizzy Ask</a> - AI assistant implementation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/929">PR #929 - Conversation cost limits</a> - Cost management with Quota model</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/978">PR #978 - Track costs of AI summaries</a> - Usage tracking</li>
  </ol>

  <h3 id="multi-tenancy-sqlite">Multi-tenancy (SQLite)</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/159">PR #159 - Introduce tenanting</a> - Full design discussion</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/168">PR #168 - Tenanting attempt 2</a> - Iteration on design</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/279">PR #279 - Tenanting v3</a> - Production implementation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/283">PR #283 - Migration script for multi-tenant</a> - Data migration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/311">PR #311 - Extracted tenant resolver</a> - Middleware patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/372">PR #372 - Scope data by account</a> - Data isolation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/403">PR #403 - Tenanted db is the account scope</a> - Architectural clarity</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/879">PR #879 - Tenanted session token cookies</a> - Session handling</li>
  </ol>

  <h3 id="webhooks--external-apis">Webhooks &amp; External APIs</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/1083">PR #1083 - Webhooks</a> - Complete webhook system with security</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1161">PR #1161 - Custom labels for Webhooks</a> - User customization</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1169">PR #1169 - Separate auto-close from close events</a> - Event granularity</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1196">PR #1196 - Link unfurling</a> - URL previews</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1229">PR #1229 - Postpone as webhook trigger</a> - Event expansion</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1292">PR #1292 - Webhook cleanup recurring job</a> - Maintenance</li>
  </ol>

  <h3 id="security">Security</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/1114">PR #1114 - Escape HTML everywhere</a> - XSS prevention</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1721">PR #1721 - Sec-Fetch-Site header reporting</a> - Modern CSRF detection</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1751">PR #1751 - Sec-Fetch-Site CSRF protection</a> - Enforcement</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1903">PR #1903 - DNS rebinding protection</a> - Network security</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1905">PR #1905 - Web Push SSRF</a> - SSRF mitigation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1964">PR #1964 - Content Security Policy</a> - CSP implementation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1083">PR #1083 - Webhook security</a> - HMAC signing, SSRF protection, replay prevention</li>
  </ol>

  <h3 id="notifications">Notifications</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/199">PR #199 - Notification spike</a> - Foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/208">PR #208 - Notification index</a> - UI structure</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/274">PR #274 - Notification preferences</a> - User preferences</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/306">PR #306 - Quieter notifications</a> - Noise reduction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/405">PR #405 - Refactor notifications</a> - Clean architecture</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/425">PR #425 - Plain text mentions</a> - @mention notifications</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/475">PR #475 - Broadcast notification readings</a> - Real-time updates</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/974">PR #974 - Bundled notification emails</a> - Batching</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1448">PR #1448 - Group notifications by card</a> - Aggregation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1574">PR #1574 - Aggregate email notifications</a> - Email digests</li>
  </ol>

  <h3 id="background-jobs-solid-queue">Background Jobs (Solid Queue)</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/469">PR #469 - bin/dev ensures puma runs solid queue</a> - Development setup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/494">PR #494 - Recurring job for unused tags</a> - Cleanup jobs</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/559">PR #559 - Mission control for jobs</a> - Admin interface</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/943">PR #943 - Recurring job to clean finished jobs</a> - Maintenance</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1109">PR #1109 - Yabeda for Solid Queue metrics</a> - Monitoring</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1290">PR #1290 - Match job workers to CPUs</a> - Scaling</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1329">PR #1329 - Performance tuning: jobs</a> - Optimization</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1664">PR #1664 - Jobs enqueued after transaction commit</a> - Reliability</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1924">PR #1924 - Retry mailer jobs on errors</a> - Error handling</li>
  </ol>

  <h3 id="filtering--search">Filtering &amp; Search</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/113">PR #113 - Persisted filters</a> - State management</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/115">PR #115 - Move filtering to the model</a> - Model layer</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/116">PR #116 - BubbleFilter extraction</a> - Service object</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/131">PR #131 - New filtering UI</a> - Interface design</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/138">PR #138 - Filter chips as links</a> - URL-based filters</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/265">PR #265 - Quick filters</a> - Shortcuts</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/567">PR #567 - Revamp filter menu</a> - Complete overhaul</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/624">PR #624 - Render and filter tags/users in menu</a> - Dynamic filtering</li>
  </ol>

  <h3 id="active-storage--attachments">Active Storage &amp; Attachments</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/328">PR #328 - Move attachments from Account to Bubble</a> - Model restructure</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/557">PR #557 - Production mirrors to purestorage</a> - Storage services</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/707">PR #707 - Card attachments</a> - UI implementation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/767">PR #767 - Preprocess image variants</a> - Performance</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/770">PR #770 - Call blob.preview for previewable attachments</a> - Preview handling</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/773">PR #773 - Fix slow uploads</a> - Performance fix</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/941">PR #941 - Turn off previews on large files</a> - Resource management</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1689">PR #1689 - Improve avatar image handling</a> - Avatar processing</li>
  </ol>

  <h3 id="action-text--rich-content">Action Text &amp; Rich Content</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/560">PR #560 - Drop action_text_markdowns table</a> - Cleanup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/564">PR #564 - Autolink emails and URLs at render time</a> - Content processing</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/873">PR #873 - Sanitizer config for ActionText</a> - Security config</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/912">PR #912 - Fix rich text content not applied</a> - Debugging</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/964">PR #964 - Rename Action Text Lexical to Lexxy</a> - Custom editor</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1859">PR #1859 - Handle ill-formed remote images</a> - Error handling</li>
  </ol>

  <h3 id="real-time-action-cable--web-push">Real-time (Action Cable &amp; Web Push)</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/475">PR #475 - Broadcast notification readings</a> - Broadcasting patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/699">PR #699 - Clean up cable meta tag</a> - Setup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/705">PR #705 - Broadcast when notifications cleared</a> - Event broadcasting</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/781">PR #781 - Web push</a> - Push notifications</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1291">PR #1291 - Yabeda ActionCable metrics</a> - Monitoring</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1432">PR #1432 - Subscribe to page changes via turbo streams</a> - Page subscriptions</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1765">PR #1765 - Fix action cable error in OSS mode</a> - Configuration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1800">PR #1800 - Scope broadcasts by account</a> - Multi-tenant broadcasting</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1810">PR #1810 - Disconnect action cable on user deactivation</a> - Cleanup</li>
  </ol>

  <h3 id="email-action-mailer">Email (Action Mailer)</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/314">PR #314 - Tenanted Action Mailer URL helpers</a> - Multi-tenant setup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/974">PR #974 - Bundled notification emails</a> - Batching</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1067">PR #1067 - Mailer styles and type hierarchy</a> - Email design</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1326">PR #1326 - User timezone in notification emails</a> - Timezone handling</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1525">PR #1525 - SVG avatars in emails</a> - Compatibility</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1574">PR #1574 - Aggregate email notifications by card</a> - Aggregation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1911">PR #1911 - Email delivery via env vars</a> - Configuration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1924">PR #1924 - Retry mailer jobs on errors</a> - Reliability</li>
  </ol>

  <h3 id="performance-optimization">Performance Optimization</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/380">PR #380 - Paginate cards</a> - Pagination basics</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/773">PR #773 - Fix slow uploads</a> - Upload performance</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1089">PR #1089 - Several performance optimizations</a> - Multiple fixes</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1129">PR #1129 - Improved performance of cleaning inaccessible data</a> - Batch operations</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1254">PR #1254 - Pagination improvements</a> - Advanced pagination</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1283">PR #1283 - Performance tuning round 1</a> - Systematic tuning</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1329">PR #1329 - Performance tuning: jobs</a> - Job optimization</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1747">PR #1747 - Address N+1 query situations</a> - Query optimization</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1927">PR #1927 - Faster D&amp;D with optimistic insertion</a> - UI responsiveness</li>
  </ol>

  <h3 id="observability--monitoring">Observability &amp; Monitoring</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/285">PR #285 - Structured JSON logging</a> - Logging setup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/301">PR #301 - Structured logs with tenant</a> - Multi-tenant logging</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/472">PR #472 - Log authenticated user</a> - User context</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1109">PR #1109 - Yabeda for Solid Queue</a> - Job metrics</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1112">PR #1112 - Yabeda for Puma</a> - Server metrics</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1118">PR #1118 - OTel collector for Prometheus</a> - Metrics pipeline</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1165">PR #1165 - More Yabeda modules</a> - Extended monitoring</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1291">PR #1291 - Yabeda ActionCable metrics</a> - WebSocket monitoring</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1602">PR #1602 - Logging tweaks</a> - Log refinement</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1834">PR #1834 - console1984 and audits1984</a> - Access auditing</li>
  </ol>

  <h3 id="concern-extraction--refactoring">Concern Extraction &amp; Refactoring</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/116">PR #116 - Pull out BubbleFilter</a> - Service extraction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/324">PR #324 - Extract pagination controller</a> - Controller extraction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/346">PR #346 - Refactor collections perma</a> - View restructure</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/370">PR #370 - Extract helper with fallback</a> - Helper patterns</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/398">PR #398 - Comments refactoring</a> - Model cleanup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/405">PR #405 - Refactor notifications</a> - Architecture cleanup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/508">PR #508 - Extract Card::Entropy concern</a> - Concern extraction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/985">PR #985 - Separate method with two outputs</a> - Method clarity</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1105">PR #1105 - Extract proprietary integrations into engine</a> - Engine extraction</li>
  </ol>

  <h3 id="workflows--state-machines">Workflows &amp; State Machines</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/121">PR #121 - Spike workflows</a> - Initial exploration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/218">PR #218 - Filtering by workflow stage</a> - Stage filters</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/329">PR #329 - Set buckets at workflow level</a> - Workflow assignment</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/389">PR #389 - Colors on stages</a> - Visual differentiation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/413">PR #413 - Refactor workflow default stages</a> - Default handling</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/662">PR #662 - Stage command</a> - Command interface</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/763">PR #763 - Resolve stages</a> - Stage resolution</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1258">PR #1258 - Cleanup workflow/stages</a> - Code cleanup</li>
  </ol>

  <h3 id="auto-close--entropy-system">Auto-close &amp; Entropy System</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/327">PR #327 - Add staleness sort order</a> - Staleness concept</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/436">PR #436 - Configure autoclose period</a> - User configuration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/489">PR #489 - Handle collections without auto-close</a> - Edge cases</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/508">PR #508 - Extract Card::Entropy concern</a> - Concern extraction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/585">PR #585 - Entropy improvements</a> - Refinement</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/591">PR #591 - Invalidate cache on entropy config change</a> - Cache coordination</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1451">PR #1451 - Entropy::Configuration to Entropy</a> - API cleanup</li>
  </ol>

  <h3 id="keyboard-navigation--accessibility">Keyboard Navigation &amp; Accessibility</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/302">PR #302 - First round of accessibility fixes</a> - Foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/537">PR #537 - Prevent default on keyboard shortcuts</a> - Shortcut handling</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/581">PR #581 - Wire-up keyboard navigation</a> - Navigation system</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/695">PR #695 - Focus hover styles</a> - Visual feedback</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/834">PR #834 - Trays keyboard nav</a> - Tray navigation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/962">PR #962 - Combobox a11y</a> - Complex components</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/994">PR #994 - Fix up hotkey labels</a> - Label accuracy</li>
  </ol>

  <h3 id="mobile--responsive-design">Mobile &amp; Responsive Design</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/480">PR #480 - Responsive card view</a> - Card responsiveness</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/597">PR #597 - Responsive trays</a> - Tray adaptation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/604">PR #604 - Mobile columns</a> - Column layout</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/739">PR #739 - Mobile insets</a> - Safe areas</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/740">PR #740 - Mobile card improvements</a> - Touch optimization</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/778">PR #778 - Smaller action size on mobile</a> - Size adaptation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/881">PR #881 - Mobile workflow styles</a> - Workflow on mobile</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1208">PR #1208 - Dynamic height for pins based on viewport</a> - Viewport handling</li>
  </ol>

  <h3 id="watching--subscriptions">Watching &amp; Subscriptions</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/310">PR #310 - Collection notification settings</a> - Preference foundation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1088">PR #1088 - Watchers</a> - Watcher system</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1099">PR #1099 - Watching polish</a> - UI refinement</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1228">PR #1228 - Fix watchers list caching</a> - Cache issues</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1231">PR #1231 - Fix watching card inconsistencies</a> - State consistency</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1239">PR #1239 - Higher fidelity watching/unwatching</a> - Reliability</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1432">PR #1432 - Subscribe to page changes via turbo streams</a> - Real-time updates</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1519">PR #1519 - Clean watchers when losing access</a> - Data cleanup</li>
  </ol>

  <h3 id="credentials--configuration">Credentials &amp; Configuration</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/554">PR #554 - Create beta environment and move secrets</a> - Rails credentials</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/584">PR #584 - Introduce staging environment</a> - Environment setup</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/647">PR #647 - Don’t require encrypted credentials in test</a> - Test isolation</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/863">PR #863 - Dev env improvements for new accounts</a> - Development DX</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1911">PR #1911 - Email delivery via env vars</a> - Env configuration</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1976">PR #1976 - CSP gives env config precedence</a> - Config override</li>
  </ol>

  <h3 id="drag--drop">Drag &amp; Drop</h3>

  <ol>
    <li><a href="https://github.com/basecamp/fizzy/pull/207">PR #207 - Drag bubble divider</a> - Basic dragging</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/209">PR #209 - Fix divider drag jankiness</a> - Smooth interaction</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/607">PR #607 - Drag and drop cards between stages</a> - Card movement</li>
    <li><a href="https://github.com/basecamp/fizzy/pull/1927">PR #1927 - Faster D&amp;D with optimistic insertion</a> - Performance</li>
  </ol>

</div>]]></content><author><name>Rob Zolkos</name></author><category term="rails" /><category term="fizzy" /><category term="code-review" /><category term="learning" /><summary type="html"><![CDATA[Sifting through Fizzy’s pull requests, it was fascinating to see how the 37signals team thinks through problems, builds solutions, and ships software together. Outside of their massive contributions to open source libraries, the podcast and the books — this was a chance to see up close, with real code, the application building process. Below you will read which 37signals team members owned which domains in this project, and which PRs best demonstrate their craft. If you’re new to the codebase (or want to level up in specific areas), I will point you to the PRs worth studying. Each section highlights an engineer’s domain knowledge and the code reviews that showcase their thinking.]]></summary></entry><entry><title type="html">Fizzy Design Evolution: A Flipbook from Git</title><link href="https://zolkos.com/2025/12/08/fizzy-design-evolution-a-flipbook-from-git.html" rel="alternate" type="text/html" title="Fizzy Design Evolution: A Flipbook from Git" /><published>2025-12-08T11:00:00+00:00</published><updated>2025-12-08T11:00:00+00:00</updated><id>https://zolkos.com/2025/12/08/fizzy-design-evolution-a-flipbook-from-git</id><content type="html" xml:base="https://zolkos.com/2025/12/08/fizzy-design-evolution-a-flipbook-from-git.html"><![CDATA[<p>After writing about <a href="/2025/12/02/the-making-of-fizzy-told-by-git.html">the making of Fizzy told through git commits</a>, I wanted to see the design evolution with my own eyes. Reading about <a href="https://github.com/basecamp/fizzy/commit/845e4618">“Let’s try bubbles”</a> and <a href="https://github.com/basecamp/fizzy/commit/723e6d94">“Rename bubbles =&gt; cards”</a> is one thing. <strong>Watching the interface transform over 18 months is another.</strong></p>

<p>So I got to work: I went through each day of commits in the <a href="https://github.com/basecamp/fizzy">Fizzy repository</a>, got the application to a bootable state, seeded the database, and took a screenshot. Then I stitched those screenshots together into a flipbook-style video.</p>

<p>Here’s the final result - I hope you enjoy it! Read on below for details about the process and the backing music.</p>

<!--more-->

<video src="https://miscrz.s3.us-east-1.amazonaws.com/fizzy-flipbook.mp4" controls="" playsinline="" preload="auto" style="max-width: 100%; margin: 1em 0;"></video>

<h3 id="the-process">The Process</h3>

<p>The Fizzy repository has 8,152 commits spanning from June 2024 to November 2025. I extracted the git SHA of the last commit from each day, excluding dependabot commits - a good proxy for the state of the app at the end of each work day.</p>

<p>With that list I then copied the Fizzy source into dated folders and checked out the corresponding SHA for each. I then wrote and ran a sanity check script to ensure the right Ruby versions were installed, that <code class="language-plaintext highlighter-rouge">bundle install</code> worked, and that database migrations and seeds completed successfully.</p>

<p>Once each folder was ready, the screenshot process began:</p>

<ol>
  <li>Boot the application</li>
  <li>Use Playwright to log in and navigate to the correct page</li>
  <li>Capture the screenshot</li>
</ol>

<p>I worked in batches of 10 checkpoints at a time to verify I was capturing the right page, since routes changed throughout the project’s history.</p>

<p>Not every checkpoint was bootable. Schema migrations sometimes broke older seeds. Gems changed. Dependencies shifted—including private gems I didn’t have access to. Some checkpoints required manual fixes to get the application running. But persistence paid off - I captured all the screenshots to show the visual journey from “splats on a windshield” (the original playful blob metaphor) to the polished kanban board that shipped.</p>

<h3 id="the-music">The Music</h3>

<p>As I watched the completed flipbook, it lacked any backing music—kinda boring! I remembered <a href="https://x.com/jasonfried">Jason Fried</a> mentioning on the Fizzy livestream demo that if you hold Shift while dragging a card to another column, Fizzy plays a little sound—a different one per column.</p>

<p>So I did what anyone making a flipbook out of git history screenshots in 2025 would do: I asked Claude to stitch those original sound files from the repo into a soundtrack. Because what else would you use to score a Fizzy flipbook besides Fizzy’s own sound files?</p>

<details style="margin-top: 1em;">
<summary style="cursor: pointer; color: #0000EE; text-decoration: underline;">Here is how that went down</summary>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #ffffff; margin: 0;"><strong>Rob:</strong> Tell me about the audio files in this repo</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #f5a623; margin: 0;"><strong>Claude:</strong> There are 30 MP3 audio files in public/audio/, organized by instrument:</p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>banjo/ - 6 files (B3, C3, D4, E3, Fsharp4, G3)</li>
<li>harpsichord/ - 6 files (same notes)</li>
<li>mandolin/ - 6 files (same notes)</li>
<li>piano/ - 6 files (same notes)</li>
<li>vibes/ - 6 files (same notes)</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">These appear to be musical note samples, likely used for notification sounds or some kind of audio feedback feature in the app.</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #ffffff; margin: 0;"><strong>Rob:</strong> From these notes is it possible to make a tune? (not asking you to, just whether the notes themselves are condusive to any sort of melodic arrangement?)</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #f5a623; margin: 0;"><strong>Claude:</strong> Yes, these notes are well-suited for creating melodies. Here's why:</p>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">The notes available: C3, D4, E3, F#4, G3, B3</p>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">This is essentially a G major pentatonic scale (with some octave displacement):</p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>G, B, C, D, E, F# → all notes from G major, missing only A</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Why it works melodically:</strong></p>
<ol style="color: #f5a623; margin: 0.5em 0 0 0;">
<li><strong>Pentatonic foundation</strong> - Pentatonic scales are inherently melodic and almost impossible to make sound "wrong." They're used across virtually all musical cultures.</li>
<li><strong>No dissonant intervals</strong> - There are no half-step clashes. Any combination of these notes will sound consonant together.</li>
<li><strong>Strong tonal center</strong> - G3 provides a natural "home" note, with B3 and D4 forming a G major triad.</li>
<li><strong>Range</strong> - Spanning from C3 to F#4 (about an octave and a half) gives enough range for interesting melodic contour.</li>
</ol>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Limitations:</strong></p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>Only 6 notes, so extended melodies might feel repetitive</li>
<li>The octave jumps (C3 vs D4, E3 vs F#4) could make smooth stepwise motion tricky</li>
<li>No A note, which limits some common melodic patterns</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">But for short jingles, notification sounds, or simple tunes - absolutely workable. You could easily create something pleasant and recognizable with these.</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #ffffff; margin: 0;"><strong>Rob:</strong> and with the variations in instruments I take it it would be possible to make it pretty interesting</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #f5a623; margin: 0;"><strong>Claude:</strong> Exactly. With 5 distinct timbres (banjo, harpsichord, mandolin, piano, vibes), you could:</p>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Layer and contrast:</strong></p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>Use piano or vibes for a main melody line</li>
<li>Add harpsichord for ornamentation or counterpoint</li>
<li>Bring in banjo or mandolin for rhythmic texture</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Create call-and-response:</strong></p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>Piano plays a phrase, vibes answer</li>
<li>Or trade off between the plucked strings (banjo/mandolin) and the keyboard instruments</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Build dynamics:</strong></p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>Start sparse with a single instrument</li>
<li>Layer in others for a fuller sound</li>
<li>Drop back down for resolution</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;"><strong>Evoke different moods:</strong></p>
<ul style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>Vibes alone = mellow, dreamy</li>
<li>Harpsichord = baroque, playful</li>
<li>Banjo = folksy, upbeat</li>
<li>Piano = neutral, versatile</li>
<li>Mandolin = bright, energetic</li>
</ul>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">The instruments also share similar attack characteristics (plucked/struck, quick decay) so they'd blend well together without clashing sonically.</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #ffffff; margin: 0;"><strong>Rob:</strong> well I want to make a background track for a demo video of this application. it is composed of screenshots of the application as it evolved over time. And then stitched into a flipbook with a 2 second gap between shots. Is this something you can work with and compose a tune with these files?</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #f5a623; margin: 0;"><strong>Claude:</strong> That sounds like a fun project! Yes, I can work with this.</p>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">To compose something appropriate, I need a few details:</p>
<ol style="color: #f5a623; margin: 0.5em 0 0 0;">
<li>How many screenshots/frames are there? (This tells me the total duration - frames × 2 seconds)</li>
<li>What's the overall vibe you're going for?
<ul>
<li>Upbeat and celebratory (look how far we've come!)</li>
<li>Calm and reflective (a journey through time)</li>
<li>Playful and quirky</li>
<li>Professional/polished</li>
</ul>
</li>
<li>What format do you need the output in?
<ul>
<li>A single combined audio file (I'd need to use ffmpeg or similar to stitch the samples)</li>
<li>A script/sequence that describes what plays when (for you to assemble)</li>
<li>Something else?</li>
</ul>
</li>
<li>Any preference on lead instrument? Piano tends to be the safest "neutral" choice, but if you have a vibe in mind, let me know.</li>
</ol>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">Once I know the duration and mood, I can sketch out a melodic sequence that evolves over the timeline - maybe starting simple and building as the app matures in the screenshots.</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #ffffff; margin: 0;"><strong>Rob:</strong> a single combined audio file. about 2 minutes long. dramatic would be good but starting off slow tempo and then building into a fast crescendo. reminiscent of early computer demos</p>
</div>

<div style="background: #1a1a1a; padding: 1em; border-radius: 8px; margin: 1em 0; font-family: monospace; font-size: 0.9em;">
<p style="color: #f5a623; margin: 0;"><strong>Claude:</strong> Love it. Early computer demo scene vibes - think Amiga/C64 era, building from sparse arpeggios to a frenetic finale.</p>
<p style="color: #f5a623; margin: 0.5em 0 0 0;">Let me check if ffmpeg is available and explore the audio files a bit more...</p>
</div>

</details>

<p>From there, Claude ensured ffmpeg was installed and got to work. We iterated a few times - “vibe music produced” - until it sounded right. A really fun way to bring some life to what would have otherwise been a silent flipbook.</p>

<h3 id="what-stands-out">What Stands Out</h3>

<p>Watching the design evolve frame by frame reveals things the commit history can only hint at:</p>

<p><strong>Epicenter design.</strong> You can see <a href="https://basecamp.com/gettingreal/09.2-epicenter-design">epicenter design</a> in action—a philosophy that’s been at the core of 37signals’ process for decades. The team started with the most essential elements (the grouping of bubbles/cards) and built outward from there. Navigation, chrome, and secondary features came later. The flipbook shows this progression clearly: core functionality first, polish second.</p>

<p><strong>The color exploration.</strong> Early versions experimented with bold, vibrant colors. The palette refined over time into something more subdued and professional while retaining the signature fun style 37signals applications are known for.</p>

<p><strong>Layout density.</strong> The interface started sparse and grew more information-dense as features accumulated, then simplified again in the later months.</p>

<p><strong>The bubble-to-card transition.</strong> You can see the playful, organic blob shapes gradually give way to more structured rectangular cards. The whimsy of the early windshield metaphor fades into the pragmatism of a task management tool.</p>

<p><strong>Typography and spacing.</strong> Subtle but constant. Every few frames you notice tighter line heights, adjusted padding, refined font weights. The polish accumulated through hundreds of small commits.</p>

<p><strong>Animation.</strong> You can’t see the subtle hover animations in a flipbook of static screenshots, but the team experimented with movement and texture throughout development. Here are a few examples:</p>

<video src="https://miscrz.s3.us-east-1.amazonaws.com/fizzy-anim-0.mp4" autoplay="" loop="" muted="" playsinline="" style="max-width: 100%; margin: 1em 0;"></video>

<video src="https://miscrz.s3.us-east-1.amazonaws.com/fizzy-anim-1.mp4" autoplay="" loop="" muted="" playsinline="" style="max-width: 100%; margin: 1em 0;"></video>

<p>This kind of archaeology is only possible because 37signals kept the full git history intact. Most companies would have squashed or rebased this history into oblivion before open-sourcing. The fact that they didn’t is a gift to anyone curious about how software really gets made.</p>

<p>Let me know if you try something similar with another codebase. The process is time-consuming but oddly satisfying.</p>]]></content><author><name>Rob Zolkos</name></author><category term="fizzy" /><category term="git" /><category term="design" /><category term="rails" /><summary type="html"><![CDATA[After writing about the making of Fizzy told through git commits, I wanted to see the design evolution with my own eyes. Reading about “Let’s try bubbles” and “Rename bubbles =&gt; cards” is one thing. Watching the interface transform over 18 months is another. So I got to work: I went through each day of commits in the Fizzy repository, got the application to a bootable state, seeded the database, and took a screenshot. Then I stitched those screenshots together into a flipbook-style video. Here’s the final result - I hope you enjoy it! Read on below for details about the process and the backing music.]]></summary></entry><entry><title type="html">Vanilla CSS is all you need</title><link href="https://zolkos.com/2025/12/03/vanilla-css-is-all-you-need.html" rel="alternate" type="text/html" title="Vanilla CSS is all you need" /><published>2025-12-03T14:27:56+00:00</published><updated>2025-12-03T14:27:56+00:00</updated><id>https://zolkos.com/2025/12/03/vanilla-css-is-all-you-need</id><content type="html" xml:base="https://zolkos.com/2025/12/03/vanilla-css-is-all-you-need.html"><![CDATA[<p>Back in April 2024, <a href="https://x.com/jasonzimdars">Jason Zimdars</a> from <a href="https://37signals.com/">37signals</a> published a post about <a href="https://dev.37signals.com/modern-css-patterns-and-techniques-in-campfire/">modern CSS patterns in Campfire</a>. He explained how their team builds sophisticated web applications using nothing but vanilla CSS. No Sass. No PostCSS. No build tools.</p>

<p>The post stuck with me. Over the past year and a half, 37signals has released two more products (Writebook and Fizzy) built on the same nobuild philosophy. I wanted to know if these patterns held up. Had they evolved?</p>

<!--more-->

<p>I cracked open the source code for Campfire, Writebook, and Fizzy and traced the evolution of their CSS architecture. What started as curiosity became genuine surprise. These are not just consistent patterns. They are <strong>improving</strong> patterns. Each release builds on the last, adopting progressively more modern CSS features while maintaining the same nobuild philosophy.</p>

<p>These are not hobby projects. <a href="https://github.com/basecamp/once-campfire">Campfire</a> is a real-time chat application. <a href="https://once.com/writebook">Writebook</a> is a publishing platform. <a href="https://fizzy.do">Fizzy</a> is a full-featured project management tool with kanban boards, drag-and-drop, and complex state management. Combined, they represent nearly <strong>14,000 lines of CSS</strong> across 105 files.</p>

<p>Not a single line touches a build tool.</p>

<hr />

<h2 id="the-tailwind-question">The Tailwind Question</h2>

<p>Let me be clear: <strong>there is nothing wrong with <a href="https://tailwindcss.com/">Tailwind</a></strong>. It is a fantastic tool that helps developers ship products faster. The utility-first approach is pragmatic, especially for teams that struggle with CSS architecture decisions.</p>

<p>But somewhere along the way, utility-first became the only answer. CSS has evolved dramatically. The language that once required preprocessors for variables and nesting now has:</p>

<ul>
  <li>Native <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties">custom properties</a> (variables)</li>
  <li>Native <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting">nesting</a></li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries">Container queries</a></li>
  <li>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/:has"><code class="language-plaintext highlighter-rouge">:has()</code> selector</a> (finally, a parent selector)</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@layer">CSS Layers</a> for managing specificity</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix"><code class="language-plaintext highlighter-rouge">color-mix()</code></a> for dynamic color manipulation</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/clamp"><code class="language-plaintext highlighter-rouge">clamp()</code></a>, <code class="language-plaintext highlighter-rouge">min()</code>, <code class="language-plaintext highlighter-rouge">max()</code> for responsive sizing without media queries</li>
</ul>

<p>37signals looked at this landscape and made a bet: modern CSS is powerful enough. No build step required.</p>

<p>Three products later, that bet is paying off.</p>

<hr />

<h2 id="the-architecture-embarrassingly-simple">The Architecture: Embarrassingly Simple</h2>

<p>Open any of these three codebases and you find the same flat structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>app/assets/stylesheets/
├── _reset.css
├── base.css
├── colors.css
├── utilities.css
├── buttons.css
├── inputs.css
├── [component].css
└── ...
</code></pre></div></div>

<p>That is it. No subdirectories. No partials. No complex import trees. One file per concept, named exactly what it does.</p>

<p>Zero configuration. Zero build time. Zero waiting.</p>

<p>I would love to see something like this ship with new Rails applications. A simple starting structure with <code class="language-plaintext highlighter-rouge">_reset.css</code>, <code class="language-plaintext highlighter-rouge">base.css</code>, <code class="language-plaintext highlighter-rouge">colors.css</code>, and <code class="language-plaintext highlighter-rouge">utilities.css</code> already in place. I suspect many developers reach for Tailwind not because they prefer utility classes, but because vanilla CSS offers no starting point. No buckets. No conventions. Maybe CSS needs its own omakase.</p>

<hr />

<h2 id="the-color-system-consistent-foundation-evolving-capabilities">The Color System: Consistent Foundation, Evolving Capabilities</h2>

<p>Jason’s original post explained <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch">OKLCH</a> well. It is the perceptually uniform color space all three apps use. The short version: unlike RGB or HSL, OKLCH’s lightness value actually corresponds to perceived brightness. A 50% lightness blue looks as bright as a 50% lightness yellow.</p>

<p>What is worth noting is how this foundation remains <strong>identical</strong> across all three apps:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  <span class="c">/* Raw LCH values: Lightness, Chroma, Hue */</span>
  <span class="py">--lch-blue</span><span class="p">:</span> <span class="m">54%</span> <span class="m">0.15</span> <span class="m">255</span><span class="p">;</span>
  <span class="py">--lch-red</span><span class="p">:</span> <span class="m">51%</span> <span class="m">0.2</span> <span class="m">31</span><span class="p">;</span>
  <span class="py">--lch-green</span><span class="p">:</span> <span class="m">65%</span> <span class="m">0.23</span> <span class="m">142</span><span class="p">;</span>

  <span class="c">/* Semantic colors built on primitives */</span>
  <span class="py">--color-link</span><span class="p">:</span> <span class="n">oklch</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--lch-blue</span><span class="p">));</span>
  <span class="py">--color-negative</span><span class="p">:</span> <span class="n">oklch</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--lch-red</span><span class="p">));</span>
  <span class="py">--color-positive</span><span class="p">:</span> <span class="n">oklch</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--lch-green</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Dark mode becomes trivial:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">prefers-color-scheme</span><span class="p">:</span> <span class="n">dark</span><span class="p">)</span> <span class="p">{</span>
  <span class="nd">:root</span> <span class="p">{</span>
    <span class="py">--lch-blue</span><span class="p">:</span> <span class="m">72%</span> <span class="m">0.16</span> <span class="m">248</span><span class="p">;</span>   <span class="c">/* Lighter, slightly desaturated */</span>
    <span class="py">--lch-red</span><span class="p">:</span> <span class="m">74%</span> <span class="m">0.18</span> <span class="m">29</span><span class="p">;</span>
    <span class="py">--lch-green</span><span class="p">:</span> <span class="m">75%</span> <span class="m">0.20</span> <span class="m">145</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every color that references these primitives automatically updates. No duplication. No separate dark theme file. One media query, and the entire application transforms.</p>

<p>Fizzy takes this further with <code class="language-plaintext highlighter-rouge">color-mix()</code>:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.card</span> <span class="p">{</span>
  <span class="py">--card-color</span><span class="p">:</span> <span class="n">oklch</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--lch-blue-dark</span><span class="p">));</span>

  <span class="c">/* Derive an entire color palette from one variable */</span>
  <span class="py">--card-bg</span><span class="p">:</span> <span class="n">color-mix</span><span class="p">(</span><span class="n">in</span> <span class="n">srgb</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--card-color</span><span class="p">)</span> <span class="m">4%</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-canvas</span><span class="p">));</span>
  <span class="py">--card-text</span><span class="p">:</span> <span class="n">color-mix</span><span class="p">(</span><span class="n">in</span> <span class="n">srgb</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--card-color</span><span class="p">)</span> <span class="m">30%</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-ink</span><span class="p">));</span>
  <span class="py">--card-border</span><span class="p">:</span> <span class="n">color-mix</span><span class="p">(</span><span class="n">in</span> <span class="n">srgb</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--card-color</span><span class="p">)</span> <span class="m">33%</span><span class="p">,</span> <span class="nb">transparent</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One color in, four harmonious colors out. Change the card color via JavaScript (<code class="language-plaintext highlighter-rouge">element.style.setProperty('--card-color', '...')</code>), and the entire card theme updates automatically. No class swapping. No style recalculation. Just CSS doing what CSS does best.</p>

<hr />

<h2 id="the-spacing-system-characters-not-pixels">The Spacing System: Characters, Not Pixels</h2>

<p>Here is a pattern I did not expect: all three applications use <code class="language-plaintext highlighter-rouge">ch</code> units for horizontal spacing.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  <span class="py">--inline-space</span><span class="p">:</span> <span class="m">1ch</span><span class="p">;</span>      <span class="c">/* Horizontal: one character width */</span>
  <span class="py">--block-space</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>      <span class="c">/* Vertical: one root em */</span>
<span class="p">}</span>

<span class="nc">.component</span> <span class="p">{</span>
  <span class="py">padding-inline</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--inline-space</span><span class="p">);</span>
  <span class="py">margin-block</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--block-space</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Why characters? Because spacing should relate to content. A <code class="language-plaintext highlighter-rouge">1ch</code> gap between words feels natural because it is literally the width of a character. As font size scales, spacing scales proportionally.</p>

<p>This also makes their responsive breakpoints unexpectedly elegant:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@media</span> <span class="p">(</span><span class="n">min-width</span><span class="p">:</span> <span class="m">100ch</span><span class="p">)</span> <span class="p">{</span>
  <span class="c">/* Desktop: content is wide enough for sidebar */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Instead of asking “is this a tablet?”, they are asking “is there room for 100 characters of content?” It is semantic. It is content-driven. It works.</p>

<hr />

<h2 id="utility-classes-yes-they-still-exist">Utility Classes: Yes, They Still Exist</h2>

<p>Let me address the elephant in the room. These applications absolutely use utility classes:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* From utilities.css */</span>
<span class="nc">.flex</span> <span class="p">{</span> <span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span> <span class="p">}</span>
<span class="nc">.gap</span> <span class="p">{</span> <span class="py">gap</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--inline-space</span><span class="p">);</span> <span class="p">}</span>
<span class="nc">.pad</span> <span class="p">{</span> <span class="nl">padding</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--block-space</span><span class="p">)</span> <span class="n">var</span><span class="p">(</span><span class="n">--inline-space</span><span class="p">);</span> <span class="p">}</span>
<span class="nc">.txt-large</span> <span class="p">{</span> <span class="nl">font-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--text-large</span><span class="p">);</span> <span class="p">}</span>
<span class="nc">.hide</span> <span class="p">{</span> <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span> <span class="p">}</span>
</code></pre></div></div>

<p>The difference? These utilities are <strong>additive</strong>, not foundational. The core styling lives in semantic component classes. Utilities handle the exceptions: the one-off layout adjustment, the conditional visibility toggle.</p>

<p>Compare to a typical Tailwind component:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Tailwind approach --&gt;</span>
<span class="nt">&lt;button</span> <span class="na">class=</span><span class="s">"inline-flex items-center gap-2 px-4 py-2 rounded-full
               border border-gray-300 bg-white text-gray-900
               hover:bg-gray-50 focus:ring-2 focus:ring-blue-500"</span><span class="nt">&gt;</span>
  Save
<span class="nt">&lt;/button&gt;</span>
</code></pre></div></div>

<p>And the 37signals equivalent:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Semantic approach --&gt;</span>
<span class="nt">&lt;button</span> <span class="na">class=</span><span class="s">"btn"</span><span class="nt">&gt;</span>Save<span class="nt">&lt;/button&gt;</span>
</code></pre></div></div>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.btn</span> <span class="p">{</span>
  <span class="py">--btn-padding</span><span class="p">:</span> <span class="m">0.5em</span> <span class="m">1.1em</span><span class="p">;</span>
  <span class="py">--btn-border-radius</span><span class="p">:</span> <span class="m">2em</span><span class="p">;</span>

  <span class="nl">display</span><span class="p">:</span> <span class="n">inline-flex</span><span class="p">;</span>
  <span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
  <span class="py">gap</span><span class="p">:</span> <span class="m">0.5em</span><span class="p">;</span>
  <span class="nl">padding</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-padding</span><span class="p">);</span>
  <span class="nl">border-radius</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-border-radius</span><span class="p">);</span>
  <span class="nl">border</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-border</span><span class="p">);</span>
  <span class="nl">background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-background</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-canvas</span><span class="p">));</span>
  <span class="nl">color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--btn-color</span><span class="p">,</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-ink</span><span class="p">));</span>
  <span class="nl">transition</span><span class="p">:</span> <span class="n">filter</span> <span class="m">100ms</span> <span class="n">ease</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.btn</span><span class="nd">:hover</span> <span class="p">{</span>
  <span class="nl">filter</span><span class="p">:</span> <span class="n">brightness</span><span class="p">(</span><span class="m">0.95</span><span class="p">);</span>
<span class="p">}</span>

<span class="nc">.btn--negative</span> <span class="p">{</span>
  <span class="py">--btn-background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-negative</span><span class="p">);</span>
  <span class="py">--btn-color</span><span class="p">:</span> <span class="no">white</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Yes, it is more CSS. But consider what you gain:</p>

<ol>
  <li><strong>HTML stays readable.</strong> <code class="language-plaintext highlighter-rouge">class="btn btn--negative"</code> tells you what something is, not how it looks.</li>
  <li><strong>Changes cascade.</strong> Update <code class="language-plaintext highlighter-rouge">--btn-padding</code> once, every button updates.</li>
  <li><strong>Variants compose.</strong> Add <code class="language-plaintext highlighter-rouge">.btn--circle</code> without redefining every property.</li>
  <li><strong>Media queries live with components.</strong> Dark mode, hover states, and responsive behavior are co-located with the component they affect.</li>
</ol>

<hr />

<h2 id="the-has-revolution">The :has() Revolution</h2>

<p>If there is one CSS feature that changes everything, it is <code class="language-plaintext highlighter-rouge">:has()</code>. For decades, you needed JavaScript to style parents based on children. No more.</p>

<p>Writebook uses it for a sidebar toggle with no JavaScript:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* When the hidden checkbox is checked, show the sidebar */</span>
<span class="nd">:has</span><span class="o">(</span><span class="nf">#sidebar-toggle</span><span class="nd">:checked</span><span class="o">)</span> <span class="nf">#sidebar</span> <span class="p">{</span>
  <span class="py">margin-inline-start</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Fizzy uses it for kanban column layouts:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.card-columns</span> <span class="p">{</span>
  <span class="py">grid-template-columns</span><span class="p">:</span> <span class="m">1</span><span class="n">fr</span> <span class="n">var</span><span class="p">(</span><span class="n">--column-width</span><span class="p">)</span> <span class="m">1</span><span class="n">fr</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* When any column is expanded, adjust the grid */</span>
<span class="nc">.card-columns</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.cards</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.is-collapsed</span><span class="o">))</span> <span class="p">{</span>
  <span class="py">grid-template-columns</span><span class="p">:</span> <span class="nb">auto</span> <span class="n">var</span><span class="p">(</span><span class="n">--column-width</span><span class="p">)</span> <span class="nb">auto</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Campfire uses it for intelligent button styling:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Circle buttons when containing only icon + screen reader text */</span>
<span class="nc">.btn</span><span class="nd">:where</span><span class="o">(</span><span class="nd">:has</span><span class="o">(</span><span class="nc">.for-screen-reader</span><span class="o">)</span><span class="nd">:has</span><span class="o">(</span><span class="nt">img</span><span class="o">))</span> <span class="p">{</span>
  <span class="py">--btn-border-radius</span><span class="p">:</span> <span class="m">50%</span><span class="p">;</span>
  <span class="py">aspect-ratio</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Highlight when internal checkbox is checked */</span>
<span class="nc">.btn</span><span class="nd">:has</span><span class="o">(</span><span class="nt">input</span><span class="nd">:checked</span><span class="o">)</span> <span class="p">{</span>
  <span class="py">--btn-background</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-ink</span><span class="p">);</span>
  <span class="py">--btn-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-ink-reversed</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This is CSS doing what you used to need JavaScript for. State management. Conditional rendering. Parent selection. All declarative. All in stylesheets.</p>

<hr />

<h2 id="progression">Progression</h2>

<p>What fascinated me most was watching the architecture evolve across releases.</p>

<p><strong>Campfire</strong> (first release) established the foundation:</p>
<ul>
  <li>OKLCH colors</li>
  <li>Custom properties for everything</li>
  <li>Character-based spacing</li>
  <li>Flat file organization</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API">View Transitions API</a> for smooth page changes</li>
</ul>

<p><strong>Writebook</strong> (second release) added modern capabilities:</p>
<ul>
  <li>Container queries for component-level responsiveness</li>
  <li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style"><code class="language-plaintext highlighter-rouge">@starting-style</code></a> for entrance animations</li>
</ul>

<p><strong>Fizzy</strong> (third release) went all-in on modern CSS:</p>
<ul>
  <li>CSS Layers (<code class="language-plaintext highlighter-rouge">@layer</code>) for managing specificity</li>
  <li><code class="language-plaintext highlighter-rouge">color-mix()</code> for dynamic color derivation</li>
  <li>Complex <code class="language-plaintext highlighter-rouge">:has()</code> chains replacing JavaScript state</li>
</ul>

<p>You can see a team learning, experimenting, and shipping progressively more sophisticated CSS with each product. By Fizzy, they are using features many developers do not even know exist.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Fizzy's layer architecture */</span>
<span class="k">@layer</span> <span class="n">reset</span><span class="p">,</span> <span class="n">base</span><span class="p">,</span> <span class="n">components</span><span class="p">,</span> <span class="n">modules</span><span class="p">,</span> <span class="n">utilities</span><span class="p">;</span>

<span class="k">@layer</span> <span class="n">components</span> <span class="p">{</span>
  <span class="nc">.btn</span> <span class="p">{</span> <span class="c">/* Always lower specificity than utilities */</span> <span class="p">}</span>
<span class="p">}</span>

<span class="k">@layer</span> <span class="n">utilities</span> <span class="p">{</span>
  <span class="nc">.hide</span> <span class="p">{</span> <span class="c">/* Always wins over components */</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>CSS Layers solve the specificity wars that have plagued CSS since the beginning. It does not matter what order your files load. It does not matter how many classes you chain. Layers determine the winner, period.</p>

<hr />

<h2 id="the-loading-spinner">The Loading Spinner</h2>

<p>One technique appears in all three applications that deserves special attention. Their loading spinners use no images, no SVGs, no JavaScript. Just CSS masks.</p>

<p>Here is the actual implementation from Fizzy’s <code class="language-plaintext highlighter-rouge">spinners.css</code>:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@layer</span> <span class="n">components</span> <span class="p">{</span>
  <span class="nc">.spinner</span> <span class="p">{</span>
    <span class="nl">position</span><span class="p">:</span> <span class="nb">relative</span><span class="p">;</span>

    <span class="err">&amp;::before</span> <span class="err">{</span>
      <span class="py">--mask</span><span class="p">:</span> <span class="nb">no-repeat</span> <span class="n">radial-gradient</span><span class="p">(</span><span class="m">#000</span> <span class="m">68%</span><span class="p">,</span> <span class="m">#0000</span> <span class="m">71%</span><span class="p">);</span>
      <span class="py">--dot-size</span><span class="p">:</span> <span class="m">1.25em</span><span class="p">;</span>

      <span class="nl">-webkit-mask</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--mask</span><span class="p">),</span> <span class="n">var</span><span class="p">(</span><span class="n">--mask</span><span class="p">),</span> <span class="n">var</span><span class="p">(</span><span class="n">--mask</span><span class="p">);</span>
      <span class="nl">-webkit-mask-size</span><span class="p">:</span> <span class="m">28%</span> <span class="m">45%</span><span class="p">;</span>
      <span class="nl">animation</span><span class="p">:</span> <span class="n">submitting</span> <span class="m">1.3s</span> <span class="n">infinite</span> <span class="n">linear</span><span class="p">;</span>
      <span class="py">aspect-ratio</span><span class="p">:</span> <span class="m">8</span><span class="p">/</span><span class="m">5</span><span class="p">;</span>
      <span class="nl">background</span><span class="p">:</span> <span class="n">currentColor</span><span class="p">;</span>
      <span class="nl">content</span><span class="p">:</span> <span class="s1">""</span><span class="p">;</span>
      <span class="py">inline-size</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--dot-size</span><span class="p">);</span>
      <span class="py">inset</span><span class="p">:</span> <span class="m">50%</span> <span class="m">0.25em</span><span class="p">;</span>
      <span class="py">margin-block</span><span class="p">:</span> <span class="n">calc</span><span class="p">((</span><span class="n">var</span><span class="p">(</span><span class="n">--dot-size</span><span class="p">)</span> <span class="p">/</span> <span class="m">3</span><span class="p">)</span> <span class="err">*</span> <span class="m">-1</span><span class="p">);</span>
      <span class="py">margin-inline</span><span class="p">:</span> <span class="n">calc</span><span class="p">((</span><span class="n">var</span><span class="p">(</span><span class="n">--dot-size</span><span class="p">)</span> <span class="p">/</span> <span class="m">2</span><span class="p">)</span> <span class="err">*</span> <span class="m">-1</span><span class="p">);</span>
      <span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="err">}</span>
</code></pre></div></div>

<p>The keyframes live in a separate <code class="language-plaintext highlighter-rouge">animation.css</code> file:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@keyframes</span> <span class="n">submitting</span> <span class="p">{</span>
  <span class="err">0</span><span class="o">%</span>    <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">50%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">100%</span> <span class="m">0%</span> <span class="p">}</span>
  <span class="err">12</span><span class="o">.</span><span class="err">5</span><span class="o">%</span> <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">50%</span><span class="p">,</span>  <span class="m">50%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">100%</span> <span class="m">0%</span> <span class="p">}</span>
  <span class="err">25</span><span class="o">%</span>   <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">50%</span> <span class="m">50%</span><span class="p">,</span>  <span class="m">100%</span> <span class="m">0%</span> <span class="p">}</span>
  <span class="err">37</span><span class="o">.</span><span class="err">5</span><span class="o">%</span> <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">50%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">100%</span> <span class="m">50%</span> <span class="p">}</span>
  <span class="err">50</span><span class="o">%</span>   <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">50%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">100%</span> <span class="m">100%</span> <span class="p">}</span>
  <span class="err">62</span><span class="o">.</span><span class="err">5</span><span class="o">%</span> <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">50%</span><span class="p">,</span>  <span class="m">50%</span> <span class="m">100%</span><span class="p">,</span> <span class="m">100%</span> <span class="m">100%</span> <span class="p">}</span>
  <span class="err">75</span><span class="o">%</span>   <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">50%</span> <span class="m">50%</span><span class="p">,</span>  <span class="m">100%</span> <span class="m">100%</span> <span class="p">}</span>
  <span class="err">87</span><span class="o">.</span><span class="err">5</span><span class="o">%</span> <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">50%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">100%</span> <span class="m">50%</span> <span class="p">}</span>
  <span class="err">100</span><span class="o">%</span>  <span class="p">{</span> <span class="nl">-webkit-mask-position</span><span class="p">:</span> <span class="m">0%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">50%</span> <span class="m">0%</span><span class="p">,</span>   <span class="m">100%</span> <span class="m">0%</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Three dots, bouncing in sequence:</p>

<style>
.demo-spinner {
  display: inline-block;
  position: relative;
  width: 1.5em;
  height: 1em;
}
.demo-spinner::before {
  --mask: no-repeat radial-gradient(#000 68%, #0000 71%);
  --dot-size: 1.25em;
  -webkit-mask: var(--mask), var(--mask), var(--mask);
  -webkit-mask-size: 28% 45%;
  animation: demo-submitting 1.3s infinite linear;
  aspect-ratio: 8/5;
  background: currentColor;
  content: "";
  inline-size: var(--dot-size);
  inset: 50% 0.25em;
  margin-block: calc((var(--dot-size) / 3) * -1);
  margin-inline: calc((var(--dot-size) / 2) * -1);
  position: absolute;
}
@keyframes demo-submitting {
  0%    { -webkit-mask-position: 0% 0%,   50% 0%,   100% 0% }
  12.5% { -webkit-mask-position: 0% 50%,  50% 0%,   100% 0% }
  25%   { -webkit-mask-position: 0% 100%, 50% 50%,  100% 0% }
  37.5% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 50% }
  50%   { -webkit-mask-position: 0% 100%, 50% 100%, 100% 100% }
  62.5% { -webkit-mask-position: 0% 50%,  50% 100%, 100% 100% }
  75%   { -webkit-mask-position: 0% 0%,   50% 50%,  100% 100% }
  87.5% { -webkit-mask-position: 0% 0%,   50% 0%,   100% 50% }
  100%  { -webkit-mask-position: 0% 0%,   50% 0%,   100% 0% }
}
</style>

<p style="text-align: center; font-size: 2rem; padding: 1.5rem 0;">
  <span class="demo-spinner"></span>
</p>

<p>The <code class="language-plaintext highlighter-rouge">background: currentColor</code> means it automatically inherits the text color. Works in any context, any theme, any color scheme. Zero additional assets. Pure CSS creativity.</p>

<hr />

<h2 id="a-better-mark">A Better <code class="language-plaintext highlighter-rouge">&lt;mark&gt;</code></h2>

<p>The default browser <code class="language-plaintext highlighter-rouge">&lt;mark&gt;</code> element renders as a yellow highlighter. It works, but it is not particularly elegant. Fizzy takes a different approach for search result highlighting: drawing a hand-drawn circle around matched terms.</p>

<p><img src="/assets/better-mark.png" alt="Fizzy search results showing circled text highlighting" /></p>

<p>Here is the implementation from <code class="language-plaintext highlighter-rouge">circled-text.css</code>:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@layer</span> <span class="n">components</span> <span class="p">{</span>
  <span class="nc">.circled-text</span> <span class="p">{</span>
    <span class="py">--circled-color</span><span class="p">:</span> <span class="n">oklch</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--lch-blue-dark</span><span class="p">));</span>
    <span class="py">--circled-padding</span><span class="p">:</span> <span class="m">-0.5ch</span><span class="p">;</span>

    <span class="nl">background</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="nl">color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--circled-color</span><span class="p">);</span>
    <span class="nl">position</span><span class="p">:</span> <span class="nb">relative</span><span class="p">;</span>
    <span class="nl">white-space</span><span class="p">:</span> <span class="nb">nowrap</span><span class="p">;</span>

    <span class="err">span</span> <span class="err">{</span>
      <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.5</span><span class="p">;</span>
      <span class="py">mix-blend-mode</span><span class="p">:</span> <span class="n">multiply</span><span class="p">;</span>

      <span class="err">@media</span> <span class="err">(</span><span class="py">prefers-color-scheme</span><span class="p">:</span> <span class="n">dark</span><span class="p">)</span> <span class="err">{</span>
        <span class="n">mix-blend-mode</span><span class="p">:</span> <span class="n">screen</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">}</span>

    <span class="nt">span</span><span class="nd">::before</span><span class="o">,</span>
    <span class="nt">span</span><span class="nd">::after</span> <span class="p">{</span>
      <span class="nl">border</span><span class="p">:</span> <span class="m">2px</span> <span class="nb">solid</span> <span class="n">var</span><span class="p">(</span><span class="n">--circled-color</span><span class="p">);</span>
      <span class="nl">content</span><span class="p">:</span> <span class="s1">""</span><span class="p">;</span>
      <span class="py">inset</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--circled-padding</span><span class="p">);</span>
      <span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nt">span</span><span class="nd">::before</span> <span class="p">{</span>
      <span class="py">border-inline-end</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
      <span class="nl">border-radius</span><span class="p">:</span> <span class="m">100%</span> <span class="m">0</span> <span class="m">0</span> <span class="m">75%</span> <span class="p">/</span> <span class="m">50%</span> <span class="m">0</span> <span class="m">0</span> <span class="m">50%</span><span class="p">;</span>
      <span class="py">inset-block-start</span><span class="p">:</span> <span class="n">calc</span><span class="p">(</span><span class="n">var</span><span class="p">(</span><span class="n">--circled-padding</span><span class="p">)</span> <span class="p">/</span> <span class="m">2</span><span class="p">);</span>
      <span class="py">inset-inline-end</span><span class="p">:</span> <span class="m">50%</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="nt">span</span><span class="nd">::after</span> <span class="p">{</span>
      <span class="py">border-inline-start</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
      <span class="nl">border-radius</span><span class="p">:</span> <span class="m">0</span> <span class="m">100%</span> <span class="m">75%</span> <span class="m">0</span> <span class="p">/</span> <span class="m">0</span> <span class="m">50%</span> <span class="m">50%</span> <span class="m">0</span><span class="p">;</span>
      <span class="py">inset-inline-start</span><span class="p">:</span> <span class="m">30%</span><span class="p">;</span>
    <span class="p">}</span>
  <span class="err">}</span>
<span class="err">}</span>
</code></pre></div></div>

<p>The HTML structure is <code class="language-plaintext highlighter-rouge">&lt;mark class="circled-text"&gt;&lt;span&gt;&lt;/span&gt;webhook&lt;/mark&gt;</code>. The empty <code class="language-plaintext highlighter-rouge">span</code> exists solely to provide two pseudo-elements (<code class="language-plaintext highlighter-rouge">::before</code> and <code class="language-plaintext highlighter-rouge">::after</code>) that draw the left and right halves of the circle.</p>

<p>The technique uses asymmetric border-radius values to create an organic, hand-drawn appearance. The <code class="language-plaintext highlighter-rouge">mix-blend-mode: multiply</code> makes the circle semi-transparent against the background, switching to <code class="language-plaintext highlighter-rouge">screen</code> in dark mode for proper blending.</p>

<style>
.demo-circled-text {
  --circled-color: oklch(57.02% 0.1895 260.46);
  --circled-padding: -0.5ch;
  background: none;
  color: var(--circled-color);
  position: relative;
  white-space: nowrap;
}
.demo-circled-text span {
  opacity: 0.5;
  mix-blend-mode: multiply;
}
@media (prefers-color-scheme: dark) {
  .demo-circled-text span {
    mix-blend-mode: screen;
  }
  .demo-circled-text {
    --circled-color: oklch(74% 0.1293 256);
  }
}
.demo-circled-text span::before,
.demo-circled-text span::after {
  border: 2px solid var(--circled-color);
  content: "";
  inset: var(--circled-padding);
  position: absolute;
}
.demo-circled-text span::before {
  border-inline-end: none;
  border-radius: 100% 0 0 75% / 50% 0 0 50%;
  inset-block-start: calc(var(--circled-padding) / 2);
  inset-inline-end: 50%;
}
.demo-circled-text span::after {
  border-inline-start: none;
  border-radius: 0 100% 75% 0 / 0 50% 50% 0;
  inset-inline-start: 30%;
}
</style>

<p style="text-align: center; font-size: 1.25rem; padding: 1rem 0;">
  Search results for: <mark class="demo-circled-text"><span></span>webhook</mark>
</p>

<p>No images. No SVGs. Just borders and border-radius creating the illusion of a hand-drawn circle.</p>

<hr />

<h2 id="dialog-animations-the-new-way">Dialog Animations: The New Way</h2>

<p>Fizzy and Writebook both animate HTML <code class="language-plaintext highlighter-rouge">&lt;dialog&gt;</code> elements. This was notoriously difficult before. The secret is <code class="language-plaintext highlighter-rouge">@starting-style</code>.</p>

<p>Here is the actual implementation from Fizzy’s <code class="language-plaintext highlighter-rouge">dialog.css</code>:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@layer</span> <span class="n">components</span> <span class="p">{</span>
  <span class="nd">:is</span><span class="o">(</span><span class="nc">.dialog</span><span class="o">)</span> <span class="p">{</span>
    <span class="nl">border</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
    <span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
    <span class="nl">transform</span><span class="p">:</span> <span class="n">scale</span><span class="p">(</span><span class="m">0.2</span><span class="p">);</span>
    <span class="nl">transform-origin</span><span class="p">:</span> <span class="nb">top</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">transition</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--dialog-duration</span><span class="p">)</span> <span class="n">allow-discrete</span><span class="p">;</span>
    <span class="nl">transition-property</span><span class="p">:</span> <span class="n">display</span><span class="p">,</span> <span class="n">opacity</span><span class="p">,</span> <span class="n">overlay</span><span class="p">,</span> <span class="n">transform</span><span class="p">;</span>

    <span class="err">&amp;::backdrop</span> <span class="err">{</span>
      <span class="nl">background-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-black</span><span class="p">);</span>
      <span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
      <span class="nl">transform</span><span class="p">:</span> <span class="n">scale</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
      <span class="nl">transition</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--dialog-duration</span><span class="p">)</span> <span class="n">allow-discrete</span><span class="p">;</span>
      <span class="nl">transition-property</span><span class="p">:</span> <span class="n">display</span><span class="p">,</span> <span class="n">opacity</span><span class="p">,</span> <span class="n">overlay</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="o">&amp;[</span><span class="nt">open</span><span class="o">]</span> <span class="p">{</span>
      <span class="nl">opacity</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
      <span class="nl">transform</span><span class="p">:</span> <span class="n">scale</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>

      <span class="err">&amp;::backdrop</span> <span class="err">{</span>
        <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.5</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">@starting-style</span> <span class="p">{</span>
      <span class="o">&amp;[</span><span class="nt">open</span><span class="o">]</span> <span class="p">{</span>
        <span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
        <span class="nl">transform</span><span class="p">:</span> <span class="n">scale</span><span class="p">(</span><span class="m">0.2</span><span class="p">);</span>
      <span class="p">}</span>

      <span class="o">&amp;[</span><span class="nt">open</span><span class="o">]</span><span class="nd">::backdrop</span> <span class="p">{</span>
        <span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
      <span class="p">}</span>
    <span class="p">}</span>
  <span class="err">}</span>
<span class="err">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--dialog-duration</code> variable is defined globally as <code class="language-plaintext highlighter-rouge">150ms</code>.</p>

<style>
.demo-dialog {
  border: 0;
  border-radius: 0.5rem;
  padding: 2rem;
  opacity: 0;
  transform: scale(0.2);
  transform-origin: top center;
  transition: 150ms allow-discrete;
  transition-property: display, opacity, overlay, transform;
}
.demo-dialog::backdrop {
  background-color: #000;
  opacity: 0;
  transition: 150ms allow-discrete;
  transition-property: display, opacity, overlay;
}
.demo-dialog[open] {
  opacity: 1;
  transform: scale(1);
}
.demo-dialog[open]::backdrop {
  opacity: 0.5;
}
@starting-style {
  .demo-dialog[open] {
    opacity: 0;
    transform: scale(0.2);
  }
  .demo-dialog[open]::backdrop {
    opacity: 0;
  }
}
.demo-dialog-btn {
  padding: 0.5em 1em;
  border: 1px solid #ccc;
  border-radius: 0.25rem;
  background: #f5f5f5;
  cursor: pointer;
}
.demo-dialog-btn:hover {
  background: #e5e5e5;
}
</style>

<p style="text-align: center; padding: 1rem 0;">
  <button class="demo-dialog-btn" onclick="document.getElementById('demo-modal').showModal()">Open Dialog</button>
</p>

<dialog id="demo-modal" class="demo-dialog">
  <p style="margin: 0 0 1rem 0;">This dialog animates in and out using pure CSS.</p>
  <button class="demo-dialog-btn" onclick="document.getElementById('demo-modal').close()">Close</button>
</dialog>

<p>The <code class="language-plaintext highlighter-rouge">@starting-style</code> rule defines where the animation starts from when an element appears. Combined with <code class="language-plaintext highlighter-rouge">allow-discrete</code>, you can now transition between <code class="language-plaintext highlighter-rouge">display: none</code> and <code class="language-plaintext highlighter-rouge">display: block</code>. The modal smoothly scales and fades in. The backdrop fades independently. No JavaScript animation libraries. No manually toggling classes. The browser handles it.</p>

<hr />

<h2 id="what-this-means-for-you">What This Means for You</h2>

<p>I am not suggesting you abandon your build tools tomorrow. But I am suggesting you reconsider your assumptions.</p>

<p><strong>You might not need Sass or PostCSS.</strong> Native CSS has variables, nesting, and <code class="language-plaintext highlighter-rouge">color-mix()</code>. The features that needed polyfills are now baseline across browsers.</p>

<p><strong>You might not need Tailwind for every project.</strong> Especially if your team understands CSS well enough to build a small design system.</p>

<p>While the industry sprints toward increasingly complex toolchains, 37signals is walking calmly in the other direction. Is this approach right for everyone? No. Large teams with varying CSS skill levels might benefit from Tailwind’s guardrails. But for many projects, their approach is a reminder that simpler can be better.</p>

<hr />

<p><small>Thanks to <a href="https://x.com/jasonzimdars">Jason Zimdars</a> and the 37signals team for sharing their approach openly. All code examples in this post are taken from the Campfire, Writebook, and Fizzy source code. For Jason’s original deep-dive into Campfire’s CSS patterns, see <a href="https://dev.37signals.com/modern-css-patterns-and-techniques-in-campfire/">Modern CSS Patterns and Techniques in Campfire</a>. If you want to learn modern CSS, these three codebases are an exceptional classroom.</small></p>]]></content><author><name>Rob Zolkos</name></author><category term="css" /><category term="rails" /><category term="frontend" /><category term="nobuild" /><summary type="html"><![CDATA[Back in April 2024, Jason Zimdars from 37signals published a post about modern CSS patterns in Campfire. He explained how their team builds sophisticated web applications using nothing but vanilla CSS. No Sass. No PostCSS. No build tools. The post stuck with me. Over the past year and a half, 37signals has released two more products (Writebook and Fizzy) built on the same nobuild philosophy. I wanted to know if these patterns held up. Had they evolved?]]></summary></entry><entry><title type="html">The Making of Fizzy, Told by Git</title><link href="https://zolkos.com/2025/12/02/the-making-of-fizzy-told-by-git.html" rel="alternate" type="text/html" title="The Making of Fizzy, Told by Git" /><published>2025-12-02T14:00:00+00:00</published><updated>2025-12-02T14:00:00+00:00</updated><id>https://zolkos.com/2025/12/02/the-making-of-fizzy-told-by-git</id><content type="html" xml:base="https://zolkos.com/2025/12/02/the-making-of-fizzy-told-by-git.html"><![CDATA[<p>Today <a href="https://fizzy.do">Fizzy</a> was released and the entire source code of its development history is <a href="https://github.com/basecamp/fizzy">open for anyone to see</a>. DHH <a href="https://x.com/dhh/status/1995895084789772629">announced on X</a> that the full git history is available - a rare opportunity to peek behind the curtain of how a 37signals product comes together.</p>

<p>I cloned down the repository and prompted Claude Code:</p>

<blockquote>
  <p>“Can you go through the entire git history and write a documentary about the development of this application. What date the first commit was. Any major tweaks, changes and decisions and experiments. You can take multiple passes and use sub-agents to build up a picture. Make sure to cite commits for any interesting things. If there is anything dramatic then make sure to see if you can figure out decision making. Summarize at the end but the story should go into STORY.md”</p>
</blockquote>

<p>It responded with:</p>

<blockquote>
  <p>“This is a fascinating task! Let me create a comprehensive investigation plan and use multiple agents to build up a complete picture of this project’s history.”</p>
</blockquote>

<p>Here is the story of Fizzy - as interpreted by Claude - from the trail of git commits. Enjoy!</p>

<!--more-->

<h2 id="from-splats-on-a-windshield-to-open-source-project-management">From “Splats on a Windshield” to Open Source Project Management</h2>

<p><em>A chronicle of 18 months of development at Basecamp, told through 8,152 commits.</em></p>

<hr />

<h2 id="prologue-the-numbers">Prologue: The Numbers</h2>

<ul>
  <li><strong>First Commit:</strong> June 21, 2024</li>
  <li><strong>Total Commits:</strong> 8,152</li>
  <li><strong>Contributors:</strong> 25+</li>
  <li><strong>Lines of Code Changed:</strong> Hundreds of thousands</li>
  <li><strong>Name Changes:</strong> 4 (Splat → Bubble → Card; Project → Bucket → Collection → Board)</li>
  <li><strong>Features Removed:</strong> At least 4 major ones</li>
  <li><strong>DHH Commits in April 2025 Alone:</strong> 323</li>
</ul>

<hr />

<h2 id="act-i-genesis--new-rails-app">Act I: Genesis — “New Rails App”</h2>

<h3 id="june-21-2024-day-zero">June 21, 2024: Day Zero</h3>

<p>At 1:19 PM on a summer Friday, Kevin McConnell typed the words that would begin an 18-month journey:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 564a0f48aebfaa4766e97d2d06eaf9ec51bd4ec3
Author: Kevin McConnell
Date: 2024-06-21

    New Rails app
</code></pre></div></div>

<p>Within hours, the foundation was laid. The team moved with practiced efficiency:</p>

<ul>
  <li><strong>1:23 PM</strong> — Gemfile updated (<a href="https://github.com/basecamp/fizzy/commit/e16323c"><code class="language-plaintext highlighter-rouge">e16323c</code></a>)</li>
  <li><strong>3:47 PM</strong> — Rubocop configured (<a href="https://github.com/basecamp/fizzy/commit/b9cf8c5"><code class="language-plaintext highlighter-rouge">b9cf8c5</code></a>)</li>
  <li><strong>4:07 PM</strong> — Minimal authentication flow (<a href="https://github.com/basecamp/fizzy/commit/c4fb1bd"><code class="language-plaintext highlighter-rouge">c4fb1bd</code></a>)</li>
  <li><strong>4:29 PM</strong> — CSS reset and base styles (<a href="https://github.com/basecamp/fizzy/commit/b90caf5"><code class="language-plaintext highlighter-rouge">b90caf5</code></a>)</li>
  <li><strong>4:46 PM</strong> — Brakeman security scanning added (<a href="https://github.com/basecamp/fizzy/commit/ecae4a3"><code class="language-plaintext highlighter-rouge">ecae4a3</code></a>)</li>
</ul>

<p>By end of day, the skeleton of a Rails application stood ready. But what would it become?</p>

<hr />

<h2 id="act-ii-the-splat-era--a-bug-on-your-windshield">Act II: The Splat Era — A Bug on Your Windshield</h2>

<h3 id="july-23-2024-the-core-concept-emerges">July 23, 2024: The Core Concept Emerges</h3>

<p>One month after inception, Jason Zimdars introduced the application’s first real identity:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 932a83197b74741612d03c7f8606e1b5adefe25a
Author: Jason Zimdars
Date: 2024-07-23

    Add Splat model, controller, and views
</code></pre></div></div>

<p><strong>A “Splat”</strong> — the name evokes something chaotic, impactful, unexpected. Like a bug hitting your windshield on a summer drive. The original data model was simple:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">create_table</span> <span class="ss">:splats</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:title</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:body</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:color</span>  <span class="c1"># dodgerblue, limegreen, tomato, mediumorchid</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The next day brought the visual metaphor that would define the early application:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit c2916a42d58d066d09930496eab551f1f6d496e6
Author: Jason Zimdars
Date: 2024-07-24

    Render with Flexbox, add windshield background
</code></pre></div></div>

<p><strong>The windshield was the canvas.</strong> Splats appeared on it like bugs on glass — colorful, slightly chaotic, each one a piece of information demanding attention.</p>

<h3 id="july-24-2024-preparing-for-the-demo">July 24, 2024: Preparing for the Demo</h3>

<p>The commits reveal urgency. Something important was coming:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit e139b5a72b0248fc9abbeae1f5a283b0c135ddd2
Author: Jason Zimdars
Date: 2024-07-24

    Still demo values, but make them stable
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 75e402bea619a0af2774ede864978ef1904f446c
Author: Jason Zimdars
Date: 2024-08-14

    Handful of tweaks before all-hands
</code></pre></div></div>

<p><strong>The all-hands demo.</strong> Approximately one month after project inception, Fizzy (then still called “Splat”) was shown to the entire company. The pressure to polish was evident in the commit messages.</p>

<hr />

<h2 id="act-iii-lets-try-bubbles">Act III: “Let’s Try Bubbles”</h2>

<h3 id="july-31-2024-the-first-pivot">July 31, 2024: The First Pivot</h3>

<p>Seven days after the windshield metaphor was established, Jason Zimdars typed four words that would reshape the application’s identity:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 845e4618a57a4d92234732d6daa8da5f903c05a8
Author: Jason Zimdars
Date: 2024-07-31

    Let's try bubbles
</code></pre></div></div>

<p>The chaotic “splat” gave way to something gentler — <strong>bubbles floating on a windshield</strong>, like soap suds catching light. The animation changed from aggressive splattering to gentle floating:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@keyframes</span> <span class="n">float-up-left</span> <span class="p">{</span>
  <span class="err">0</span><span class="o">%</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">10px</span><span class="p">,</span> <span class="m">40px</span><span class="p">);</span> <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.1</span><span class="p">;</span> <span class="p">}</span>
  <span class="err">100</span><span class="o">%</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">);</span> <span class="nl">opacity</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="august-2-2024-organic-evolution">August 2, 2024: Organic Evolution</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 88b99ad1eb074cae09c72f9637a9d98b65b7aeed
Author: Jason Zimdars
Date: 2024-08-02

    Try a more organic bubble style
</code></pre></div></div>

<p>Perfect circles gave way to hand-drawn blob shapes. The team was discovering what their product <em>was</em> through the act of building it.</p>

<h3 id="august-19-22-2024-the-boost-feature--foreshadowing-the-name">August 19-22, 2024: The “Boost” Feature — Foreshadowing the Name</h3>

<p>A new interaction pattern emerged:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 40dcf9883 - "Add boosts model"
commit f39b58040 - "Display number of boosts on a splat"
commit 73f7ddca3 - "Animate when boosting (puffing up)"
</code></pre></div></div>

<p>When users “boosted” a bubble, it would <strong>puff up and float away</strong> — like champagne fizz rising. The animation:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">@keyframes</span> <span class="n">bubble-up</span> <span class="p">{</span>
  <span class="err">0</span><span class="o">%</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">);</span> <span class="p">}</span>
  <span class="err">50</span><span class="o">%</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">-4em</span><span class="p">);</span> <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.66</span><span class="p">;</span> <span class="p">}</span>
  <span class="err">100</span><span class="o">%</span> <span class="p">{</span> <span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">-8em</span><span class="p">);</span> <span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>The metaphor was crystallizing.</strong> Bubbles. Fizzing. Effervescence. The name would come soon.</p>

<hr />

<h2 id="act-iv-finding-the-name">Act IV: Finding the Name</h2>

<h3 id="september-4-2024-the-great-renaming">September 4, 2024: The Great Renaming</h3>

<p>In a single day, the application found its final name through two commits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 5aae70298 (September 4, 2024)
Author: Unknown

    Rename Splat to Bubble
</code></pre></div></div>

<p>42 files changed. The model, controllers, views, tests — everything touched.</p>

<p>Hours later:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit e0763d4d4 (September 4, 2024)

    Splat -&gt; Fizzy
</code></pre></div></div>

<p><strong>Fizzy.</strong> The name captured everything: the bubbles, the effervescence, the playful energy of the interface. Visual design had driven product naming — the team discovered what they were building through the act of building it.</p>

<hr />

<h2 id="act-v-the-architecture-takes-shape">Act V: The Architecture Takes Shape</h2>

<h3 id="september-2024-introducing-hierarchy">September 2024: Introducing Hierarchy</h3>

<p>The flat list of bubbles needed structure:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit c09a02fa9 (September 16, 2024)

    Introduce projects and accesses
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 355e144dd (September 18, 2024)

    Nest bubbles et al under projects
</code></pre></div></div>

<p>But “Projects” didn’t feel right. Eight days later:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 4e579e5a3 (September 24, 2024)

    Rename Project to Bucket
</code></pre></div></div>

<p>Then “Bucket” became “Collection.” Eventually, “Collection” would become “Board.”</p>

<p><strong>The terminology dance</strong> — Projects → Buckets → Collections → Boards — reveals a team searching for the right mental model. They ultimately landed on the familiar “Board” metaphor, aligning with tools like Trello and Linear.</p>

<hr />

<h2 id="act-vi-enter-dhh--the-architectural-enforcer">Act VI: Enter DHH — The Architectural Enforcer</h2>

<h3 id="october-17-2024-first-contact">October 17, 2024: First Contact</h3>

<p>David Heinemeier Hansson, creator of Ruby on Rails and co-founder of Basecamp, made his first contribution with characteristic pragmatism:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 8504e911a
Author: David Heinemeier Hansson
Date: 2024-10-17

    No longer used
</code></pre></div></div>

<p>He deleted an unused image file. It was a statement of intent.</p>

<h3 id="october-19-2024-rails-8-and-caching-blitz">October 19, 2024: Rails 8 and Caching Blitz</h3>

<p>Within two days, DHH’s fingerprints were everywhere:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 23d81c07c - "Use Rails 8.0 rc1"
commit 5d06e5449 - "Etag the bubbles#show"
commit cd14fda39 - "Cache bubble partial on itself"
commit 32d1792cb - "Fetch bubbles index with multiget cache"
</code></pre></div></div>

<p>He upgraded the entire application to Rails 8 release candidate and systematically added HTTP caching throughout.</p>

<h3 id="the-war-on-anemic-code">The War on “Anemic” Code</h3>

<p>DHH’s most distinctive contribution was his crusade against what he called <strong>“anemic” code</strong> — thin wrappers that explain nothing and add needless indirection. He used this term <strong>15 times</strong> in commit messages:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 7d86aae67 - "Inline anemic method"
commit 0edfc337c - "Inline anemic partials"
commit 9913853cc - "Remove anemic concern"
commit a1543f266 - "Inline anemic helper method with no additional explaining purpose"
commit cacee0feb - "Remove anemic scope"
commit a5df54015 - "Remove anemic and indirect method that explained nothing"
</code></pre></div></div>

<p><strong>Philosophy:</strong> Code should either add explanatory value OR hide implementation complexity. Thin wrappers that do neither are “anemic” and should be eliminated.</p>

<h3 id="april-2025-the-great-refactoring">April 2025: The Great Refactoring</h3>

<p>Then came April 2025. DHH made <strong>323 commits in a single month</strong> — 55% of his total contributions compressed into 30 days.</p>

<p>This was a surgical strike. He:</p>

<ul>
  <li><strong>Removed the entire Boosts feature</strong> (<a href="https://github.com/basecamp/fizzy/commit/760cbb6c9"><code class="language-plaintext highlighter-rouge">760cbb6c9</code></a>) — 299 lines across 27 files, gone</li>
  <li><strong>Eliminated activity scoring</strong> (<a href="https://github.com/basecamp/fizzy/commit/82c9b011a"><code class="language-plaintext highlighter-rouge">82c9b011a</code></a>, <a href="https://github.com/basecamp/fizzy/commit/7c76bdd96"><code class="language-plaintext highlighter-rouge">7c76bdd96</code></a>, <a href="https://github.com/basecamp/fizzy/commit/1114679c2"><code class="language-plaintext highlighter-rouge">1114679c2</code></a>)</li>
  <li><strong>Extracted RESTful controllers</strong> from overloaded ones (<a href="https://github.com/basecamp/fizzy/commit/43069b1ea"><code class="language-plaintext highlighter-rouge">43069b1ea</code></a>, <a href="https://github.com/basecamp/fizzy/commit/bced7405d"><code class="language-plaintext highlighter-rouge">bced7405d</code></a>)</li>
  <li><strong>Enforced transaction discipline</strong> (<a href="https://github.com/basecamp/fizzy/commit/7124835e6"><code class="language-plaintext highlighter-rouge">7124835e6</code></a> — “No long transactions!”)</li>
</ul>

<p>His commit messages tell the story:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 966aa51a3 - "Get rid of unnecessary touch jobs"
commit 255435a38 - "No need for a non-standard factory method for something this simple"
commit 393e5fd3d - "Don't want to ship with this wart"
commit e3f7930dc - "Unlikely we are going to need this"
</code></pre></div></div>

<p><strong>In DHH’s philosophy:</strong> deletion is a feature, not a bug.</p>

<hr />

<h2 id="act-vii-the-final-transformation--bubbles-become-cards">Act VII: The Final Transformation — Bubbles Become Cards</h2>

<h3 id="april-9-2025-the-kanban-awakening">April 9, 2025: The Kanban Awakening</h3>

<p>After 10 months as “Bubbles,” another transformation:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 723e6d94f
Date: 2025-04-09

    Rename bubbles =&gt; cards
</code></pre></div></div>

<p>333 files changed. “Pop” (completing a bubble) became “Closure” (closing a card). The playful metaphor gave way to task management vocabulary.</p>

<h3 id="september-november-2025-the-column-system">September-November 2025: The Column System</h3>

<p>The final architectural piece:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit cd025447f (September 2025)

    Add basic column models and script to migrate
</code></pre></div></div>

<p>Fizzy had become a <strong>kanban board</strong>. Cards lived in columns. Columns could be customized, colored, reordered. The application had evolved from “bugs on a windshield” to a sophisticated project management tool.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 2af7a9f40 (November 5, 2025)

    Merge PR #1498: rename-boards
</code></pre></div></div>

<p>Collections became Boards. The transformation was complete:</p>

<p><strong>Original (July 2024):</strong></p>
<ul>
  <li>Splats on a Windshield</li>
</ul>

<p><strong>Final (November 2025):</strong></p>
<ul>
  <li>Cards → Columns → Boards → Accounts</li>
</ul>

<hr />

<h2 id="act-viii-the-features-that-didnt-make-it">Act VIII: The Features That Didn’t Make It</h2>

<h3 id="fizzy-ask--the-ai-that-was-removed">Fizzy Ask — The AI That Was Removed</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 9a02716a4 - "Add Fizzy Ask" (PR #857)
commit 1263ca582 - "Give Fizzy Ask context about who it's talking to"
commit fc0fccef1 - "Show only published cards to Fizzy Ask"
commit 24cb5da19 - "Make Fizzy Ask staff-only"
commit 9317a8257 - "Remove Fizzy Ask"
</code></pre></div></div>

<p>A Claude-powered AI assistant that could answer questions about project content. Born, restricted to staff, then removed entirely. Perhaps replaced by the more ambitious MCP (Model Context Protocol) integration — making Fizzy AI-native at the protocol level rather than bolting on a chatbot.</p>

<h3 id="reactions--the-feature-that-came-back">Reactions — The Feature That Came Back</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit [initial] - Reactions feature added
commit bf44ad973 - "Revert reactions"
commit a9bb534d8 - "Reactions polish" (PR #1734)
</code></pre></div></div>

<p>Emoji reactions for cards and comments. Added. Removed. <strong>Then added again.</strong> The git history shows healthy debate — not everything that ships stays shipped, and not everything removed stays gone.</p>

<h3 id="custom-views--replaced-by-simplicity">Custom Views — Replaced by Simplicity</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 86332e3a2 - "Remove custom views UI"
commit e4fb0e831 - "Add static quick filters"
</code></pre></div></div>

<p>Saved custom views were replaced by ephemeral quick filters. Complexity gave way to simplicity.</p>

<h3 id="workflows--too-much-structure">Workflows — Too Much Structure</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit 24f1394ca - "Remove workflows code, remove more unused code"
commit c99084c65 - "No workflows, columns will be ad-hoc"
</code></pre></div></div>

<p>Predefined workflows with stages were removed in favor of ad-hoc column organization. Users would create their own structure.</p>

<hr />

<h2 id="act-ix-the-experimental-frontier">Act IX: The Experimental Frontier</h2>

<h3 id="the-mcp-branch--ai-native-by-design">The MCP Branch — AI-Native by Design</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit fa16de7bb - "Add bubbles list as a tool for MCP clients"
commit fbd9b6302 - "Add an MCP manifest ala PWA manifests"
commit de6f782ca - "Add MCP Identification to all requests"
</code></pre></div></div>

<p>The MCP (Model Context Protocol) branch represents cutting-edge AI integration — allowing Claude and other AI assistants to interact with Fizzy programmatically. An <code class="language-plaintext highlighter-rouge">.mcp.json</code> manifest advertises Fizzy’s capabilities to AI clients.</p>

<p><strong>Status:</strong> Removed from main, but the infrastructure remains fascinating. This is one of the earliest explorations of making traditional web applications AI-native.</p>

<h3 id="mobile-column-view--the-unsolved-problem">Mobile Column View — The Unsolved Problem</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Branches:
- origin/mobile-column-view-redux
- origin/mobile-column-view-redux+contained-scrolling
- origin/test-swiping-columns-on-mobile
- origin/mobile-app/scoped-stylesheets
</code></pre></div></div>

<p>Multiple parallel branches exploring different approaches to mobile column navigation. Scroll snapping. Contained scrolling. Swipeable columns. <strong>The problem remains unsolved</strong> — there’s no “one true way” for mobile kanban navigation.</p>

<h3 id="sqlite-support--database-flexibility">SQLite Support — Database Flexibility</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit a2333d9a3 - "Add SQLite support"
commit 12de56417 - "Remove MySQL from default local development setup"
</code></pre></div></div>

<p>Making Fizzy work with SQLite in addition to MySQL. Simpler local development. Better portability. The search index was even <strong>sharded into 16 tables</strong> (<code class="language-plaintext highlighter-rouge">search_records_0</code> through <code class="language-plaintext highlighter-rouge">search_records_15</code>) for scale.</p>

<hr />

<h2 id="act-x-the-road-to-open-source">Act X: The Road to Open Source</h2>

<h3 id="november-22-28-2025-the-great-extraction">November 22-28, 2025: The Great Extraction</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit e1e9a01 - "Make open source the default"
commit 8724963 - "Bring vanilla versions for Dockerfile and deploy config"
commit 2c70793 - "Move kamal deploy config to the gem"
commit 0328149 - "Initial github action setup"
</code></pre></div></div>

<p>The proprietary SAAS features were extracted into a separate gem. What remained was a clean, open-source Rails application.</p>

<h3 id="november-28-2025-public-release">November 28, 2025: Public Release</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>commit fa1fe47
Date: 2025-11-28

    Initial README and LICENSE
</code></pre></div></div>

<p>After 18 months of development, 8,152 commits, and countless pivots, Fizzy became open source.</p>

<hr />

<h2 id="epilogue-the-cast-of-characters">Epilogue: The Cast of Characters</h2>

<h3 id="the-contributors-by-commit-count">The Contributors (by commit count)</h3>

<ol>
  <li>
    <p><strong>Jason Zimdars</strong> (2,217 commits) — The visual architect. From “Let’s try bubbles” to pixel-perfect polish.</p>
  </li>
  <li>
    <p><strong>Jorge Manrubia</strong> (2,053 commits) — The engineering backbone. Consistent, prolific, essential.</p>
  </li>
  <li>
    <p><strong>Andy Smith</strong> (1,007 commits) — Front-end craftsmanship and UI refinement.</p>
  </li>
  <li>
    <p><strong>Mike Dalessio</strong> (875 commits) — Infrastructure, performance, the recent dashboard work.</p>
  </li>
  <li>
    <p><strong>David Heinemeier Hansson</strong> (586 commits) — The architectural enforcer. Rails modernization and the war on anemic code.</p>
  </li>
  <li>
    <p><strong>Kevin McConnell</strong> (351 commits) — Started it all with “New Rails app.”</p>
  </li>
  <li>
    <p><strong>Jose Farias</strong> (341 commits) — Feature development and testing.</p>
  </li>
  <li>
    <p><strong>Stanko K.R.</strong> (239 + 54 commits) — Security hardening and webhook restrictions.</p>
  </li>
  <li>
    <p><strong>Jeffrey Hardy</strong> (100 commits) — Early infrastructure and modernization.</p>
  </li>
  <li>
    <p><strong>Jason Fried</strong> (7 commits) — The occasional “Small copy adjustment” from the CEO.</p>
  </li>
</ol>

<h3 id="the-dramatic-moments">The Dramatic Moments</h3>

<ul>
  <li><strong>July 24, 2024:</strong> “Handful of tweaks before all-hands” — Demo day pressure</li>
  <li><strong>July 31, 2024:</strong> “Let’s try bubbles” — The visual pivot</li>
  <li><strong>September 4, 2024:</strong> “Splat -&gt; Fizzy” — Finding the name</li>
  <li><strong>April 2025:</strong> DHH’s 323-commit refactoring blitz</li>
  <li><strong>October 2025:</strong> “Remove Fizzy Ask” — The AI feature that didn’t survive</li>
  <li><strong>November 28, 2025:</strong> “Initial README and LICENSE” — Going public</li>
</ul>

<hr />

<h2 id="technical-appendix-the-architecture-evolution">Technical Appendix: The Architecture Evolution</h2>

<h3 id="data-model-timeline">Data Model Timeline</h3>

<p><strong>July 2024 (v0.1):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Splat (title, body, color)
  └── Categories
  └── Boosts
  └── Comments
</code></pre></div></div>

<p><strong>September 2024 (v0.2):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Project (name)
  └── Bubble (title, body, color)
       └── Tags
       └── Comments
       └── Boosts
</code></pre></div></div>

<p><strong>November 2025 (v1.0):</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Account
  └── Board (name, settings)
       └── Column (name, color, position)
            └── Card (title, status, due_on)
                 ├── Comments
                 ├── Assignments
                 ├── Attachments
                 ├── Tags
                 ├── Steps (checklist)
                 ├── Watches
                 └── Events (audit trail)
</code></pre></div></div>

<h3 id="key-technology-decisions">Key Technology Decisions</h3>

<ol>
  <li><strong>Rails 8.x</strong> — Always on the latest, sometimes ahead of stable</li>
  <li><strong>Hotwire (Turbo + Stimulus)</strong> — No heavy JavaScript framework</li>
  <li><strong>Solid Queue &amp; Solid Cache</strong> — Rails-native background jobs and caching</li>
  <li><strong>SQLite + MySQL support</strong> — Database flexibility</li>
  <li><strong>Kamal deployment</strong> — Modern container orchestration</li>
  <li><strong>UUID primary keys</strong> — Using UUIDv7 for time-ordering</li>
  <li><strong>Multi-tenancy</strong> — Account-based data isolation</li>
</ol>

<hr />

<h2 id="conclusion-what-fizzy-teaches-us">Conclusion: What Fizzy Teaches Us</h2>

<p>The story of Fizzy is the story of <strong>discovery through building</strong>. The team didn’t know they were building a kanban board when they started with “splats on a windshield.” They found out through iteration.</p>

<p><strong>Key lessons:</strong></p>

<ol>
  <li>
    <p><strong>Names matter, but they can change.</strong> Splat → Bubble → Card. Project → Bucket → Collection → Board. The right name emerges through use.</p>
  </li>
  <li>
    <p><strong>Deletion is a feature.</strong> Boosts, Fizzy Ask, custom views, workflows — removing the wrong features is as important as adding the right ones.</p>
  </li>
  <li>
    <p><strong>Architecture evolves.</strong> The final column-based kanban system looks nothing like the original flat list of splats.</p>
  </li>
  <li>
    <p><strong>DHH’s philosophy:</strong> Remove anemic code. Keep transactions short. Use the latest Rails. Delete more than you add.</p>
  </li>
  <li>
    <p><strong>Design drives naming.</strong> “Fizzy” emerged from the visual metaphor of bubbles puffing up and floating away — the design informed the brand.</p>
  </li>
  <li>
    <p><strong>Open source takes extraction.</strong> 18 months of SAAS development needed careful separation before the core could be shared.</p>
  </li>
</ol>

<p>The git history of Fizzy is a masterclass in iterative product development. 8,152 commits. 25+ contributors. 18 months. One application that discovered its identity through the act of creation.</p>

<hr />

<p><em>“Let’s try bubbles.”</em> — Jason Zimdars, July 31, 2024</p>

<hr />

<h2 id="commit-reference-index">Commit Reference Index</h2>

<table>
  <thead>
    <tr>
      <th>Commit</th>
      <th>Date</th>
      <th>Author</th>
      <th>Message</th>
      <th>Significance</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/564a0f48"><code class="language-plaintext highlighter-rouge">564a0f48</code></a></td>
      <td>2024-06-21</td>
      <td>Kevin McConnell</td>
      <td>New Rails app</td>
      <td>Genesis</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/932a8319"><code class="language-plaintext highlighter-rouge">932a8319</code></a></td>
      <td>2024-07-23</td>
      <td>Jason Zimdars</td>
      <td>Add Splat model</td>
      <td>First feature</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/c2916a42"><code class="language-plaintext highlighter-rouge">c2916a42</code></a></td>
      <td>2024-07-24</td>
      <td>Jason Zimdars</td>
      <td>Add windshield background</td>
      <td>Visual metaphor</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/75e402be"><code class="language-plaintext highlighter-rouge">75e402be</code></a></td>
      <td>2024-08-14</td>
      <td>Jason Zimdars</td>
      <td>Tweaks before all-hands</td>
      <td>Demo day</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/845e4618"><code class="language-plaintext highlighter-rouge">845e4618</code></a></td>
      <td>2024-07-31</td>
      <td>Jason Zimdars</td>
      <td>Let’s try bubbles</td>
      <td>Design pivot</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/e0763d4d"><code class="language-plaintext highlighter-rouge">e0763d4d</code></a></td>
      <td>2024-09-04</td>
      <td>—</td>
      <td>Splat -&gt; Fizzy</td>
      <td>The name</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/5aae7029"><code class="language-plaintext highlighter-rouge">5aae7029</code></a></td>
      <td>2024-09-04</td>
      <td>—</td>
      <td>Rename Splat to Bubble</td>
      <td>Model rename</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/8504e911"><code class="language-plaintext highlighter-rouge">8504e911</code></a></td>
      <td>2024-10-17</td>
      <td>DHH</td>
      <td>No longer used</td>
      <td>DHH arrives</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/23d81c07"><code class="language-plaintext highlighter-rouge">23d81c07</code></a></td>
      <td>2024-10-19</td>
      <td>DHH</td>
      <td>Use Rails 8.0 rc1</td>
      <td>Rails 8</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/723e6d94"><code class="language-plaintext highlighter-rouge">723e6d94</code></a></td>
      <td>2025-04-09</td>
      <td>—</td>
      <td>Rename bubbles =&gt; cards</td>
      <td>Final transformation</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/760cbb6c"><code class="language-plaintext highlighter-rouge">760cbb6c</code></a></td>
      <td>2025-04-12</td>
      <td>DHH</td>
      <td>Drop Boosts</td>
      <td>Feature removal</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/9317a825"><code class="language-plaintext highlighter-rouge">9317a825</code></a></td>
      <td>2025-10-??</td>
      <td>—</td>
      <td>Remove Fizzy Ask</td>
      <td>AI removal</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/2af7a9f4"><code class="language-plaintext highlighter-rouge">2af7a9f4</code></a></td>
      <td>2025-11-05</td>
      <td>—</td>
      <td>Rename collection to board</td>
      <td>Final naming</td>
    </tr>
    <tr>
      <td><a href="https://github.com/basecamp/fizzy/commit/fa1fe47"><code class="language-plaintext highlighter-rouge">fa1fe47</code></a></td>
      <td>2025-11-28</td>
      <td>—</td>
      <td>Initial README and LICENSE</td>
      <td>Open source</td>
    </tr>
  </tbody>
</table>

<hr />

<p><em>Documentary compiled December 2, 2025</em>
<em>Based on analysis of the Fizzy git repository</em></p>]]></content><author><name>Rob Zolkos</name></author><category term="fizzy" /><category term="git" /><category term="ai" /><category term="claude" /><category term="37signals" /><summary type="html"><![CDATA[Today Fizzy was released and the entire source code of its development history is open for anyone to see. DHH announced on X that the full git history is available - a rare opportunity to peek behind the curtain of how a 37signals product comes together. I cloned down the repository and prompted Claude Code: “Can you go through the entire git history and write a documentary about the development of this application. What date the first commit was. Any major tweaks, changes and decisions and experiments. You can take multiple passes and use sub-agents to build up a picture. Make sure to cite commits for any interesting things. If there is anything dramatic then make sure to see if you can figure out decision making. Summarize at the end but the story should go into STORY.md” It responded with: “This is a fascinating task! Let me create a comprehensive investigation plan and use multiple agents to build up a complete picture of this project’s history.” Here is the story of Fizzy - as interpreted by Claude - from the trail of git commits. Enjoy!]]></summary></entry><entry><title type="html">Fizzy Webhooks: What You Need to Know</title><link href="https://zolkos.com/2025/12/02/fizzy-webhooks-what-you-need-to-know.html" rel="alternate" type="text/html" title="Fizzy Webhooks: What You Need to Know" /><published>2025-12-02T11:30:00+00:00</published><updated>2025-12-02T11:30:00+00:00</updated><id>https://zolkos.com/2025/12/02/fizzy-webhooks-what-you-need-to-know</id><content type="html" xml:base="https://zolkos.com/2025/12/02/fizzy-webhooks-what-you-need-to-know.html"><![CDATA[<p><a href="https://fizzy.do">Fizzy</a> is a new issue tracker (<a href="https://github.com/basecamp/fizzy">source available</a>) from <a href="https://37signals.com">37signals</a> with a refreshingly clean UI. Beyond looking good, it ships with a solid webhook system for integrating with external services.</p>

<p>For most teams, webhooks are the bridge between the issues you track and the tools you already rely on. They let you push events into chat, incident tools, reporting pipelines, and anything else that speaks HTTP. If you are evaluating Fizzy or planning an integration, understanding what these webhooks can do will save you time.</p>

<p>I also put together a short PDF with the full payload structure and example code, which I link at the end of this post if you want to go deeper.</p>

<h3 id="what-could-we-build">What could we build?</h3>

<p>Here are a few ideas for things you could build on top of Fizzy’s events:</p>

<ul>
  <li>A <strong>team metrics dashboard</strong> that tracks how long cards take to move from <code class="language-plaintext highlighter-rouge">card_published</code> to <code class="language-plaintext highlighter-rouge">card_closed</code> and which assignees or boards close issues the fastest.</li>
  <li><strong>Personal Slack or Teams digests</strong> that send each person a daily summary of cards they created, were assigned, or closed based on <code class="language-plaintext highlighter-rouge">card_published</code>, <code class="language-plaintext highlighter-rouge">card_assigned</code>, <code class="language-plaintext highlighter-rouge">card_unassigned</code>, and <code class="language-plaintext highlighter-rouge">card_closed</code> events.</li>
  <li>A <strong>churn detector</strong> that flags cards that bounce between columns or get sent back to triage repeatedly using <code class="language-plaintext highlighter-rouge">card_triaged</code>, <code class="language-plaintext highlighter-rouge">card_sent_back_to_triage</code>, and <code class="language-plaintext highlighter-rouge">card_postponed</code>.</li>
  <li>A <strong>cross-board incident view</strong> that watches <code class="language-plaintext highlighter-rouge">card_board_changed</code> to keep a separate dashboard of cards moving into your incident or escalation boards.</li>
  <li>A <strong>comment activity stream</strong> that ships <code class="language-plaintext highlighter-rouge">comment_created</code> events into a search index or knowledge base so you can search discussions across boards.</li>
</ul>

<p>If you want to go deeper, you can also build more opinionated tools that surface insights and notify people who never log in to Fizzy:</p>

<ul>
  <li><strong>Stakeholder status reports</strong> that email non-technical stakeholders a weekly summary of key cards: what was created, closed, postponed, or sent back to triage on their projects. You can group by label, board, or assignee and generate charts or narrative summaries from <code class="language-plaintext highlighter-rouge">card_published</code>, <code class="language-plaintext highlighter-rouge">card_closed</code>, <code class="language-plaintext highlighter-rouge">card_postponed</code>, and <code class="language-plaintext highlighter-rouge">card_sent_back_to_triage</code> events.</li>
  <li><strong>Capacity and load alerts</strong> that watch for people who are getting overloaded. For example, you could send a notification to a manager when someone is assigned more than N open cards, or when cards assigned to them sit in the same column for too long without a <code class="language-plaintext highlighter-rouge">card_triaged</code> or <code class="language-plaintext highlighter-rouge">card_closed</code> event.</li>
  <li><strong>SLA and escalation notifications</strong> that integrate with PagerDuty or similar tools. When certain cards (for example, labeled “Incident” or on a specific board) are not closed within an agreed time window, you can trigger an alert or automatically move the card to an escalation board using <code class="language-plaintext highlighter-rouge">card_postponed</code>, <code class="language-plaintext highlighter-rouge">card_board_changed</code>, and <code class="language-plaintext highlighter-rouge">card_closed</code>.</li>
  <li><strong>Customer-facing status updates</strong> that keep clients in the loop without giving them direct access to Fizzy. You could generate per-customer email updates or a small status page based on events for cards tagged with that customer’s name, combining <code class="language-plaintext highlighter-rouge">card_published</code>, <code class="language-plaintext highlighter-rouge">card_closed</code>, and <code class="language-plaintext highlighter-rouge">comment_created</code> to show progress and recent discussion.</li>
  <li><strong>Meeting prep packs</strong> that assemble the last week’s events for a given board into a concise agenda for standups or planning meetings. You can collate newly created cards, reopened work, and high-churn items from <code class="language-plaintext highlighter-rouge">card_published</code>, <code class="language-plaintext highlighter-rouge">card_reopened</code>, <code class="language-plaintext highlighter-rouge">card_triaged</code>, and <code class="language-plaintext highlighter-rouge">card_sent_back_to_triage</code>, then email the summary to attendees before the meeting.</li>
</ul>

<p>Here is how to set it up.</p>

<!--more-->

<h3 id="setting-up-webhooks">Setting up webhooks</h3>

<p><strong>Step 1.</strong> Visit a board and click the Webhook icon in the top right.</p>

<p><img src="/assets/fizzy-webhook-1.png" alt="Visit a board and click the Webhook icon in the top right" /></p>

<p><strong>Step 2.</strong> Give the webhook a name and the payload URL and select the events you want to be alerted to.</p>

<p><img src="/assets/fizzy-webhook-2.png" alt="Give the webhook a name and payload URL" /></p>

<p><strong>Step 3.</strong> Once the webhook saves you will see a summary of how it is setup and most importantly the webhook secret which you will need for your handler for securing the webhook. There is also a handy event log showing you when an event was delivered.</p>

<p><img src="/assets/fizzy-webhook-3.png" alt="Webhook summary and secret" /></p>

<p>Since I like to tinker with these sorts of things, I built a small webhook receiver to capture and document the payload structures.</p>

<h3 id="the-webhook-system">The Webhook System</h3>

<p>Fizzy sends HTTP POST requests to your configured webhook URL when events occur. Each request includes an <code class="language-plaintext highlighter-rouge">X-Webhook-Signature</code> header containing an HMAC-SHA256 signature of the request body. The verification process is straightforward:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s1">'openssl'</span>

<span class="k">def</span> <span class="nf">verify_signature</span><span class="p">(</span><span class="n">body</span><span class="p">,</span> <span class="n">signature</span><span class="p">,</span> <span class="n">secret</span><span class="p">)</span>
  <span class="n">expected</span> <span class="o">=</span> <span class="no">OpenSSL</span><span class="o">::</span><span class="no">HMAC</span><span class="p">.</span><span class="nf">hexdigest</span><span class="p">(</span><span class="s1">'SHA256'</span><span class="p">,</span> <span class="n">secret</span><span class="p">,</span> <span class="n">body</span><span class="p">)</span>
  <span class="no">OpenSSL</span><span class="p">.</span><span class="nf">secure_compare</span><span class="p">(</span><span class="n">expected</span><span class="p">,</span> <span class="n">signature</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="event-types">Event Types</h3>

<p>Fizzy covers the essential card lifecycle events:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">card_published</code> - new card created</li>
  <li><code class="language-plaintext highlighter-rouge">card_triaged</code> - card moved to a column</li>
  <li><code class="language-plaintext highlighter-rouge">card_assigned</code> / <code class="language-plaintext highlighter-rouge">card_unassigned</code> - assignment changes</li>
  <li><code class="language-plaintext highlighter-rouge">card_closed</code> - card moved to Done</li>
  <li><code class="language-plaintext highlighter-rouge">card_reopened</code> - card reopened from Done</li>
  <li><code class="language-plaintext highlighter-rouge">card_postponed</code> - card moved to Not Now</li>
  <li><code class="language-plaintext highlighter-rouge">card_sent_back_to_triage</code> - card moved back to Maybe?</li>
  <li><code class="language-plaintext highlighter-rouge">card_board_changed</code> - card moved to different board</li>
  <li><code class="language-plaintext highlighter-rouge">comment_created</code> - comment added to a card</li>
</ul>

<h3 id="how-i-captured-the-payloads">How I captured the payloads</h3>

<p>The approach was straightforward: I wrote a small Ruby script using WEBrick to act as a webhook receiver. The script listens for incoming POST requests, verifies the HMAC-SHA256 signature (using the webhook secret Fizzy provides when you configure webhooks), and saves each event as a separate JSON file with a timestamp and action name. This made it easy to review and compare the different event types later.</p>

<p>To expose my local server to the internet, I used <a href="https://ngrok.com/">ngrok</a> to create a temporary public URL pointing to port 4002. I then configured Fizzy’s webhook settings with this ngrok URL and selected the event types I wanted to capture.</p>

<p>With everything set up, I went through Fizzy’s UI and manually triggered each available event: creating cards, adding comments, assigning and unassigning users, moving cards between columns and boards, marking cards as done, reopening them, postponing cards to “Not Now”, and sending cards back to triage. Each action fired a webhook that my script captured and logged.</p>

<p>In total, I captured 13 webhook deliveries covering 10 different action types. The only event I could not capture was “Card moved to Not Now due to inactivity” — Fizzy triggers this automatically after a period of card inactivity, so it was not practical to reproduce during this test.</p>

<h3 id="gotchas-and-observations">Gotchas and Observations</h3>

<p><strong>Card body content is not included.</strong> The card object in webhook payloads only contains the <code class="language-plaintext highlighter-rouge">title</code>, not the full description or body content. Comments include both <code class="language-plaintext highlighter-rouge">plain_text</code> and <code class="language-plaintext highlighter-rouge">html</code> versions, but cards do not. Since Fizzy doesn’t have a public API (<a href="https://x.com/dhh/status/1995890631688819002">DHH is working on it</a>), you can’t fetch the full card content programmatically - you’ll need to use the <code class="language-plaintext highlighter-rouge">url</code> field to view the card in the browser.</p>

<p><strong>Column data is only present when relevant.</strong> The <code class="language-plaintext highlighter-rouge">column</code> object only appears on <code class="language-plaintext highlighter-rouge">card_triaged</code>, <code class="language-plaintext highlighter-rouge">card_closed</code>, and <code class="language-plaintext highlighter-rouge">card_reopened</code> events - the events where a card actually moves to a specific column.</p>

<p><strong>IDs are strings, not integers.</strong> All identifiers in the payload are strings like <code class="language-plaintext highlighter-rouge">"03f25q9q7bw7t3206v9ttiy53"</code>, not numeric IDs.</p>

<h3 id="documentation">Documentation</h3>

<p>I created a short webhook documentation based on this research: <a href="/assets/FIZZY_WEBHOOKS.pdf">FIZZY_WEBHOOKS.pdf</a></p>

<p>It includes the full payload structure, all event types with examples, and code samples for signature verification in both Ruby and JavaScript. Hopefully this helps you get up and running with Fizzy’s webhooks. Let me know if you discover additional events or edge cases.</p>

<p>Since the source code is available, you can also submit PRs to fix or enhance aspects of the webhook system if you find something missing or want to contribute improvements.</p>]]></content><author><name>Rob Zolkos</name></author><category term="fizzy" /><category term="webhooks" /><category term="api" /><category term="rails" /><summary type="html"><![CDATA[Fizzy is a new issue tracker (source available) from 37signals with a refreshingly clean UI. Beyond looking good, it ships with a solid webhook system for integrating with external services. For most teams, webhooks are the bridge between the issues you track and the tools you already rely on. They let you push events into chat, incident tools, reporting pipelines, and anything else that speaks HTTP. If you are evaluating Fizzy or planning an integration, understanding what these webhooks can do will save you time. I also put together a short PDF with the full payload structure and example code, which I link at the end of this post if you want to go deeper. What could we build? Here are a few ideas for things you could build on top of Fizzy’s events: A team metrics dashboard that tracks how long cards take to move from card_published to card_closed and which assignees or boards close issues the fastest. Personal Slack or Teams digests that send each person a daily summary of cards they created, were assigned, or closed based on card_published, card_assigned, card_unassigned, and card_closed events. A churn detector that flags cards that bounce between columns or get sent back to triage repeatedly using card_triaged, card_sent_back_to_triage, and card_postponed. A cross-board incident view that watches card_board_changed to keep a separate dashboard of cards moving into your incident or escalation boards. A comment activity stream that ships comment_created events into a search index or knowledge base so you can search discussions across boards. If you want to go deeper, you can also build more opinionated tools that surface insights and notify people who never log in to Fizzy: Stakeholder status reports that email non-technical stakeholders a weekly summary of key cards: what was created, closed, postponed, or sent back to triage on their projects. You can group by label, board, or assignee and generate charts or narrative summaries from card_published, card_closed, card_postponed, and card_sent_back_to_triage events. Capacity and load alerts that watch for people who are getting overloaded. For example, you could send a notification to a manager when someone is assigned more than N open cards, or when cards assigned to them sit in the same column for too long without a card_triaged or card_closed event. SLA and escalation notifications that integrate with PagerDuty or similar tools. When certain cards (for example, labeled “Incident” or on a specific board) are not closed within an agreed time window, you can trigger an alert or automatically move the card to an escalation board using card_postponed, card_board_changed, and card_closed. Customer-facing status updates that keep clients in the loop without giving them direct access to Fizzy. You could generate per-customer email updates or a small status page based on events for cards tagged with that customer’s name, combining card_published, card_closed, and comment_created to show progress and recent discussion. Meeting prep packs that assemble the last week’s events for a given board into a concise agenda for standups or planning meetings. You can collate newly created cards, reopened work, and high-churn items from card_published, card_reopened, card_triaged, and card_sent_back_to_triage, then email the summary to attendees before the meeting. Here is how to set it up.]]></summary></entry><entry><title type="html">A Mermaid Validation Skill for Claude Code</title><link href="https://zolkos.com/2025/11/26/mermaid-validation-skill-for-claude-code.html" rel="alternate" type="text/html" title="A Mermaid Validation Skill for Claude Code" /><published>2025-11-26T15:00:00+00:00</published><updated>2025-11-26T15:00:00+00:00</updated><id>https://zolkos.com/2025/11/26/mermaid-validation-skill-for-claude-code</id><content type="html" xml:base="https://zolkos.com/2025/11/26/mermaid-validation-skill-for-claude-code.html"><![CDATA[<p>AI coding agents generate significantly more markdown documentation than we used to write manually. This creates opportunities to explain concepts visually with <a href="https://mermaid.js.org/">mermaid</a> diagrams - flowcharts, sequence diagrams, and other visualizations defined in text. When Claude generates these diagrams, the syntax can be invalid even though the code looks correct. <a href="https://code.claude.com/docs/en/skills">Claude Code skills</a> provide a way to teach Claude domain-specific workflows - in this case, validating diagrams before marking the work complete.</p>

<!--more-->

<h2 id="the-problem">The Problem</h2>

<p>Mermaid diagrams are text-based and version-controllable, which makes them useful for documentation. The syntax can be finicky:</p>

<ul>
  <li>Missing arrows or incorrect arrow types</li>
  <li>Unbalanced brackets</li>
  <li>Invalid node names</li>
  <li>Typos in keywords</li>
</ul>

<p>A diagram might look correct in markdown but fail to render. Without validation, you discover this only after the work is complete.</p>

<h2 id="the-solution">The Solution</h2>

<p>Skills in Claude Code are markdown files that provide instructions for specific tasks. They can be invoked manually or triggered automatically when Claude detects a relevant situation.</p>

<p>Here is the mermaid validation skill:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">mermaid-validator</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">Automatically validate mermaid diagrams after creating or editing them in markdown files</span>
<span class="nn">---</span>

<span class="gh"># When to Use This Skill</span>

<span class="gs">**Proactively use this skill after:**</span>
<span class="p">-</span> Creating new mermaid diagrams in markdown files
<span class="p">-</span> Editing existing mermaid diagrams
<span class="p">-</span> User asks to validate/check mermaid diagrams

<span class="gs">**Always validate before completing the task.**</span>

<span class="gh"># Instructions</span>
<span class="p">
1.</span> Read the markdown file (use the file from context or ask user which file to validate)
<span class="p">2.</span> <span class="gs">**Run validation**</span> using: <span class="sb">`mmdc -i &lt;file-path&gt; -o /tmp/mermaid-validation.svg 2&gt;&amp;1`</span>
<span class="p">3.</span> <span class="gs">**Report validation results**</span>:
<span class="p">   -</span> If valid: Confirm the diagram is valid and exit
<span class="p">   -</span> If invalid: Show the errors found and explain what's wrong
<span class="p">4.</span> <span class="gs">**Fix the issues**</span> automatically (invalid diagrams have no value)
<span class="p">5.</span> <span class="gs">**Re-validate**</span> to confirm the fix worked
<span class="p">6.</span> Repeat steps 4-5 until there are no validation issues
</code></pre></div></div>

<h2 id="prerequisites">Prerequisites</h2>

<p>The skill uses <code class="language-plaintext highlighter-rouge">mmdc</code> (mermaid CLI) for validation. Install it globally:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> <span class="nt">-g</span> @mermaid-js/mermaid-cli
</code></pre></div></div>

<h2 id="setting-it-up">Setting It Up</h2>

<p>Create the skill file at:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.claude/skills/mermaid-validator/SKILL.md
</code></pre></div></div>

<p>Or in your project at:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.claude/skills/mermaid-validator/SKILL.md
</code></pre></div></div>

<h2 id="how-it-works">How It Works</h2>

<p>When Claude creates or edits a markdown file containing mermaid diagrams:</p>

<ol>
  <li>The skill is automatically invoked</li>
  <li>Validation runs via <code class="language-plaintext highlighter-rouge">mmdc</code></li>
  <li>Errors are fixed and re-validated</li>
  <li>Success is only reported when diagrams render correctly</li>
</ol>

<p>Invalid diagrams have no value. Rather than leaving validation to the user, Claude verifies its own work.</p>

<h2 id="why-this-matters">Why This Matters</h2>

<p>Manual validation requires remembering to ask for it every time. Skills make validation automatic and consistent. Every diagram gets checked, and errors are fixed before you see them.</p>

<p>The pattern applies beyond mermaid diagrams. Any output that can be validated by a tool is a candidate for a validation skill. If a tool can identify errors, Claude can fix them before marking the task complete.</p>

<p>Let me know if you have questions about setting this up.</p>]]></content><author><name>Rob Zolkos</name></author><category term="ai" /><category term="claude-code" /><category term="productivity" /><category term="devtools" /><summary type="html"><![CDATA[AI coding agents generate significantly more markdown documentation than we used to write manually. This creates opportunities to explain concepts visually with mermaid diagrams - flowcharts, sequence diagrams, and other visualizations defined in text. When Claude generates these diagrams, the syntax can be invalid even though the code looks correct. Claude Code skills provide a way to teach Claude domain-specific workflows - in this case, validating diagrams before marking the work complete.]]></summary></entry><entry><title type="html">First Impressions of Sonic (the stealth xAi Grok coding model?)</title><link href="https://zolkos.com/2025/08/23/first-impressions-of-sonic-the-stealth-xai-grok-coding-model.html" rel="alternate" type="text/html" title="First Impressions of Sonic (the stealth xAi Grok coding model?)" /><published>2025-08-23T13:28:00+00:00</published><updated>2025-08-23T13:28:00+00:00</updated><id>https://zolkos.com/2025/08/23/first-impressions-of-sonic-the-stealth-xai-grok-coding-model</id><content type="html" xml:base="https://zolkos.com/2025/08/23/first-impressions-of-sonic-the-stealth-xai-grok-coding-model.html"><![CDATA[<blockquote>
  <p><strong>Update (August 26, 2025)</strong>: Sonic has been confirmed as the new <strong>Grok Code model</strong> and is now officially available today! What was once a “stealth” model is now xAi’s publicly released coding assistant.</p>
</blockquote>

<p>A new stealth model called <strong>Sonic</strong> has quietly appeared in places like <a href="https://opencode.ai/">Opencode</a> and Cursor, and it’s rumored to be xAi’s Grok coding model. I spent a full day working with it inside Opencode, replacing my usual Claude Sonnet 4 workflow — and came away impressed. The short version? <strong>It feels like Sonnet, but turbocharged.</strong></p>

<h2 id="getting-access">Getting Access</h2>

<p>Sonic showed up in Opencode’s model selector — no special invite required. Selecting it as the active model made it fully functional, with no setup beyond choosing it in the dropdown. During this stealth period it’s free to use and offers a massive <strong>256,000 token context window</strong>. I didn’t encounter any rate limits or throttling all day.</p>

<h2 id="first-test">First Test</h2>

<p>Instead of a contrived “hello world,” I dropped Sonic into my <em>real</em> workflow. Where I’d usually use Sonnet 4, I asked Sonic to help me build a new <strong>Training Center CRUD feature</strong> in a Ruby on Rails application.</p>

<p>It generated the models, migrations, controllers, and views exactly as expected — and most importantly, it did it <em>fast</em>. Text streamed out so quickly it was hard to keep up. Compared to Claude Code, I’d estimate <strong>3x faster tokens per second</strong>, consistently.</p>

<p>That was my wow moment: <strong>speed without sacrificing accuracy</strong>. It nailed the Rails conventions and felt like a drop-in Sonnet replacement.</p>

<h2 id="my-standard-benchmark">My Standard Benchmark</h2>

<p>My go-to evaluation prompt for coding models is: <em>“Tell me about this codebase and what the most complex parts are.”</em> On my legacy Rails codebase, Sonic immediately identified the correct overloaded model as the main complexity hotspot, and even suggested ways to refactor it.</p>

<p>The result was near identical in quality to what I’d get from Sonnet or even Opus — but again, the response flew back near-instantly. For day-to-day comprehension and reasoning, Sonic seems to match the best while dramatically cutting wait time.</p>

<h2 id="comparison-to-claude-sonnet">Comparison to Claude Sonnet</h2>

<ul>
  <li><strong>Reasoning &amp; Accuracy</strong>: Nearly identical to Sonnet. No hallucinations, no weird Rails missteps.</li>
  <li><strong>Personality</strong>: Very similar, though Sonic felt a bit more positive and agreeable to suggestions.</li>
  <li><strong>Speed</strong>: The standout. At least 3x faster than Claude Code/Sonnet, with the speed holding steady across short and long generations.</li>
  <li><strong>Where Sonnet Might Still Win</strong>: Hard to say yet — I didn’t hit any reasoning failures, but Sonnet have a longer track record of reliability under heavy workloads.</li>
</ul>

<p>Bottom line: if you’re used to Sonnet, Sonic feels like the same experience at high speed.</p>

<h2 id="pricing-and-availability">Pricing and Availability</h2>

<p>Right now Sonic is free to use in Opencode during its stealth rollout. No public pricing details are available yet, but if it undercuts Sonnet, it could be a winner.</p>

<p>I haven’t seen any rate limits or regional restrictions. Cursor users also report seeing Sonic available as a selectable model.</p>

<h2 id="initial-verdict">Initial Verdict</h2>

<p>After a full day of work, I would — and did — ship production code with Sonic. It handled CRUD features, comprehension of a large legacy codebase, and various refactors without issue.</p>

<p>The real differentiator is <strong>speed</strong>. If Sonnet is your baseline, Sonic offers the same reasoning ability but with response times that feel instantaneous. Unless something changes in pricing or reliability, this could easily become my daily driver.</p>

<h2 id="quick-reference">Quick Reference</h2>

<ul>
  <li><strong>Access</strong>: Select “Sonic” in Opencode’s model selector (no invite required during stealth)</li>
  <li><strong>Pricing</strong>: Free during stealth; official pricing TBD</li>
  <li><strong>Best for</strong>: Day-to-day coding tasks, CRUD features, codebase comprehension, Rails development</li>
  <li><strong>Avoid for</strong>: Nothing obvious yet — but keep human oversight on critical code</li>
  <li><strong>API docs</strong>: Not yet public</li>
  <li><strong>Hot take</strong>: As I <a href="https://x.com/robzolkos/status/1958565226296541509">posted</a> on X, this <em>feels like my Sonnet replacement should I be forced to not have Sonnet</em>. <a href="https://x.com/robzolkos">@robzolkos</a></li>
</ul>]]></content><author><name>Rob Zolkos</name></author><category term="ai" /><category term="coding" /><category term="models" /><category term="grok" /><category term="xai" /><category term="development" /><summary type="html"><![CDATA[Update (August 26, 2025): Sonic has been confirmed as the new Grok Code model and is now officially available today! What was once a “stealth” model is now xAi’s publicly released coding assistant. A new stealth model called Sonic has quietly appeared in places like Opencode and Cursor, and it’s rumored to be xAi’s Grok coding model. I spent a full day working with it inside Opencode, replacing my usual Claude Sonnet 4 workflow — and came away impressed. The short version? It feels like Sonnet, but turbocharged. Getting Access Sonic showed up in Opencode’s model selector — no special invite required. Selecting it as the active model made it fully functional, with no setup beyond choosing it in the dropdown. During this stealth period it’s free to use and offers a massive 256,000 token context window. I didn’t encounter any rate limits or throttling all day. First Test Instead of a contrived “hello world,” I dropped Sonic into my real workflow. Where I’d usually use Sonnet 4, I asked Sonic to help me build a new Training Center CRUD feature in a Ruby on Rails application. It generated the models, migrations, controllers, and views exactly as expected — and most importantly, it did it fast. Text streamed out so quickly it was hard to keep up. Compared to Claude Code, I’d estimate 3x faster tokens per second, consistently. That was my wow moment: speed without sacrificing accuracy. It nailed the Rails conventions and felt like a drop-in Sonnet replacement. My Standard Benchmark My go-to evaluation prompt for coding models is: “Tell me about this codebase and what the most complex parts are.” On my legacy Rails codebase, Sonic immediately identified the correct overloaded model as the main complexity hotspot, and even suggested ways to refactor it. The result was near identical in quality to what I’d get from Sonnet or even Opus — but again, the response flew back near-instantly. For day-to-day comprehension and reasoning, Sonic seems to match the best while dramatically cutting wait time. Comparison to Claude Sonnet Reasoning &amp; Accuracy: Nearly identical to Sonnet. No hallucinations, no weird Rails missteps. Personality: Very similar, though Sonic felt a bit more positive and agreeable to suggestions. Speed: The standout. At least 3x faster than Claude Code/Sonnet, with the speed holding steady across short and long generations. Where Sonnet Might Still Win: Hard to say yet — I didn’t hit any reasoning failures, but Sonnet have a longer track record of reliability under heavy workloads. Bottom line: if you’re used to Sonnet, Sonic feels like the same experience at high speed. Pricing and Availability Right now Sonic is free to use in Opencode during its stealth rollout. No public pricing details are available yet, but if it undercuts Sonnet, it could be a winner. I haven’t seen any rate limits or regional restrictions. Cursor users also report seeing Sonic available as a selectable model. Initial Verdict After a full day of work, I would — and did — ship production code with Sonic. It handled CRUD features, comprehension of a large legacy codebase, and various refactors without issue. The real differentiator is speed. If Sonnet is your baseline, Sonic offers the same reasoning ability but with response times that feel instantaneous. Unless something changes in pricing or reliability, this could easily become my daily driver. Quick Reference Access: Select “Sonic” in Opencode’s model selector (no invite required during stealth) Pricing: Free during stealth; official pricing TBD Best for: Day-to-day coding tasks, CRUD features, codebase comprehension, Rails development Avoid for: Nothing obvious yet — but keep human oversight on critical code API docs: Not yet public Hot take: As I posted on X, this feels like my Sonnet replacement should I be forced to not have Sonnet. @robzolkos]]></summary></entry></feed>