<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sinisa Kusic</title>
    <description>The latest articles on DEV Community by Sinisa Kusic (@ku5ic).</description>
    <link>https://dev.to/ku5ic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F268473%2Fc0812de5-8dcf-4c22-8e87-1c596b84d391.jpeg</url>
      <title>DEV Community: Sinisa Kusic</title>
      <link>https://dev.to/ku5ic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ku5ic"/>
    <language>en</language>
    <item>
      <title>After Knowledge, Discipline</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Tue, 28 Apr 2026 09:04:17 +0000</pubDate>
      <link>https://dev.to/ku5ic/after-knowledge-discipline-5bg1</link>
      <guid>https://dev.to/ku5ic/after-knowledge-discipline-5bg1</guid>
      <description>&lt;p&gt;&lt;em&gt;Anatomy of a Claude Code setup that pays for itself&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most common reaction when I show people my Claude Code workflow is some version of: "isn't that a lot of tokens?"&lt;/p&gt;

&lt;p&gt;It is. The flow front-loads context, plans before it implements, runs scripted checks after edits, and writes structured artifacts to disk for later steps to pick up. Compared to typing "build me a feature" into a fresh chat, it spends more.&lt;/p&gt;

&lt;p&gt;It also does the thing.&lt;/p&gt;

&lt;p&gt;That second sentence is the one most takes on AI-assisted development skip over. The cost objection treats tokens as the only line item on the invoice. The bigger line item, by a wide margin, is the cost of correcting an agent that drifted out of scope, hallucinated an API, edited the wrong file, or confidently produced something that has to be thrown away. Once you account for that, the calculus inverts. Structure is cheaper than chaos.&lt;/p&gt;

&lt;p&gt;This article is an anatomy of the structure I landed on. Everything described here lives in my dotfiles, public, at &lt;code&gt;github.com/ku5ic/dotfiles/tree/main/claude&lt;/code&gt;. I will link the actual files as I go so you can read or steal whatever is useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interface was already there
&lt;/h2&gt;

&lt;p&gt;Before walking through the parts, it is worth saying out loud what the parts are made of, because nobody had this on their 2026 bingo card.&lt;/p&gt;

&lt;p&gt;The interface that makes AI agents predictable and produces quality output is not a vector database. Not a fine-tuned model. Not a proprietary framework. Not an orchestration layer with a clever name.&lt;/p&gt;

&lt;p&gt;It is markdown files in a sensible folder structure.&lt;/p&gt;

&lt;p&gt;CLAUDE.md at the repo root. A docs folder the agent reads before it touches code. Command files that encode a workflow. Rules files that encode standards. Plain text. Version controlled. Diffable. Greppable. The exact tooling we have had for three decades.&lt;/p&gt;

&lt;p&gt;For a couple of years the industry poured capital into building new primitives for LLMs. New storage layers, new retrieval mechanisms, new agent protocols, new runtime abstractions. Most of it was solving a problem the models did not actually have.&lt;/p&gt;

&lt;p&gt;The models were trained on open source. Open source runs on markdown and folders. READMEs, contributing guides, architecture docs, ADRs, issue templates. That is the native format of the corpus. Of course the models respond to it. Of course structure in a repo produces structure in the output.&lt;/p&gt;

&lt;p&gt;The unlock was not technical. It was noticing that the interface was already there.&lt;/p&gt;

&lt;p&gt;Three things follow from that, and the rest of this article is mostly working out the implications.&lt;/p&gt;

&lt;p&gt;First, the quality of your output is bounded by the quality of your written context. A thin &lt;code&gt;CLAUDE.md&lt;/code&gt; produces thin work. A precise one produces precise work. Architecture documents, coding standards, and explicit constraints are no longer dead weight. They are executable.&lt;/p&gt;

&lt;p&gt;Second, folder structure is a contract. When the agent can infer where things belong from the tree alone, it stops guessing. When it cannot, it invents. The same property that makes a codebase readable to a new hire makes it legible to a model.&lt;/p&gt;

&lt;p&gt;Third, the skills that compound here are not prompt engineering. They are the unglamorous ones. Writing clearly. Structuring information. Keeping documentation close to the code it describes. The things senior engineers were already supposed to be doing.&lt;/p&gt;

&lt;p&gt;The dotfiles I am about to walk through are not exotic. They are markdown files in folders. Every guardrail, every workflow stage, every skill, every command is a &lt;code&gt;.md&lt;/code&gt; file with frontmatter, or a small shell script, or a JSON config. The whole repo is roughly 150KB of plain text. It works because the model was trained on plain text.&lt;/p&gt;

&lt;p&gt;The joke writes itself. After all the frameworks and all the infrastructure, the winning move was a folder of markdown files and the discipline to keep them current. The future of AI-assisted engineering looks a lot like good engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it propagated
&lt;/h2&gt;

&lt;p&gt;It did not start as a system. It started as one project.&lt;/p&gt;

&lt;p&gt;There was a particular repo where I stopped treating Claude Code as a faster autocomplete and started treating it as a junior engineer who needed an onboarding document. I wrote a &lt;code&gt;CLAUDE.md&lt;/code&gt; for it. The file captured the things I kept correcting Claude on: stop adding decorative comments, match the existing test style, do not edit lockfiles, do not invent file paths. It worked. The next project I touched, I copied it over. By the third repo I was diffing them against each other, and by the fourth I was tired of doing that.&lt;/p&gt;

&lt;p&gt;The pattern was obvious in retrospect. The same five-stage rhythm kept emerging in every project regardless of stack: figure out what is here, plan the change, implement it in small steps, test, review. The same handful of guardrails kept being needed: do not push to main, do not commit AI signatures, do not pipe curl into a shell, do not write to my zshrc directly. The same handful of mini-context-collectors kept being needed: what is the project root, what stack is this, what is the base branch, what does the diff look like.&lt;/p&gt;

&lt;p&gt;Once I saw the pattern, I extracted it. The dotfiles repo is the result. It is not a framework and not a methodology. It is just the parts of "use Claude Code well" that turned out to be project-independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;Four namespaces of slash commands, six skills, four hooks, a handful of helper scripts, a &lt;code&gt;settings.json&lt;/code&gt; that does real work, and a global &lt;code&gt;CLAUDE.md&lt;/code&gt; that ties it together. Top-level layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude/
  CLAUDE.md
  settings.json
  bin/
    detect-stack.sh
    project-name.sh
    project-root.sh
    git-base.sh
    run-checks.sh
  commands/
    flow/      preflight, plan, implement, test, review, fix, resume
    audit/     a11y, debt, doc-drift, perf, security
    meta/      feature, prompt, retro
    write/     commit, pr, release-notes, stakeholder
  hooks/
    inject-context.sh
    guard-bash.sh
    guard-edit.sh
    guard-commit.sh
    sanitize-output.sh
  skills/
    react-patterns, django-patterns, test-patterns,
    wcag-audit, security-patterns, markdown-report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cost argument cuts through all of it. Each piece exists because the alternative, having Claude figure it out fresh every time, was demonstrably more expensive in the only currency that matters: my time spent re-steering it.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md as the contract
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/CLAUDE.md" rel="noopener noreferrer"&gt;&lt;code&gt;claude/CLAUDE.md&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The global &lt;code&gt;CLAUDE.md&lt;/code&gt; is the document Claude Code reads on every session. Project-level &lt;code&gt;CLAUDE.md&lt;/code&gt; files extend it. Mine is roughly 2,500 words and covers: project boot protocol, output rules, output discipline, token discipline, code style, verification before acting, anti-fabrication, environment and stack, commands and side effects, git workflow, decision frameworks, scope and planning, principles, ambiguity handling, communication style, the failure mode playbook, scratch artifact naming, and the canonical command namespace.&lt;/p&gt;

&lt;p&gt;A few sections are worth singling out because they are the ones that pay for themselves the fastest.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;anti-fabrication&lt;/strong&gt; section is short and explicit. Do not invent file paths that have not been seen via Read or Glob. Do not invent API shapes. Do not invent version numbers; read the lockfile or &lt;code&gt;--version&lt;/code&gt;. Do not invent test results; if a test was not run, say "not run." This eliminates a specific failure mode that used to cost me real time: Claude confidently writing code against an imagined version of an API, me discovering it three steps later, both of us backing out the change.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;token discipline&lt;/strong&gt; section tells Claude how to read efficiently: prefer &lt;code&gt;rg&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; for locating, use &lt;code&gt;Read&lt;/code&gt; only for understanding, cap &lt;code&gt;git log&lt;/code&gt; to twenty entries unless justified, do not re-read a file in the same session unless an edit changed it, skip the obvious noise directories (&lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.next&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;coverage&lt;/code&gt;, &lt;code&gt;.turbo&lt;/code&gt;, &lt;code&gt;vendor&lt;/code&gt;, &lt;code&gt;target&lt;/code&gt;, &lt;code&gt;__pycache__&lt;/code&gt;, &lt;code&gt;.venv&lt;/code&gt;). This is direct token savings, but the more important effect is keeping Claude's context window populated with signal instead of noise.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;failure mode playbook&lt;/strong&gt; is the most underrated section. It tells Claude what to do when quality checks fail, when the plan does not match reality, when a tool is unavailable, when context is exhausted, when the user issues a correction. Without it, the default behavior is to hide problems, keep going, and hope. With it, the default behavior is to stop and surface. Stopping early is one of the highest-leverage things an agent can do, and it does not happen unless you ask for it explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow namespace
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/commands/flow" rel="noopener noreferrer"&gt;&lt;code&gt;claude/commands/flow/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Five commands form the main path of any feature work, plus two for off-ramp situations.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:preflight
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/preflight.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/preflight.md&lt;/code&gt;&lt;/a&gt;. Effort: medium. Read, do not write.&lt;/p&gt;

&lt;p&gt;Preflight establishes shared understanding before any code is touched. It reads the project root &lt;code&gt;CLAUDE.md&lt;/code&gt;, identifies the minimum file set the task will touch, reads those files, lists the CI checks the project actually defines (typecheck, lint, test, format) without running them, and notes uncommitted work and recent direction from &lt;code&gt;git status&lt;/code&gt; and &lt;code&gt;git log -5 --oneline&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The hard cap is twelve files read across preflight. If the minimum set exceeds twelve, the command stops and asks me to scope the task down. That cap is deliberate. A task that needs more than twelve files of context to understand is a task that needs to be split, not a task that needs more reading.&lt;/p&gt;

&lt;p&gt;The output is a short preflight report written to &lt;code&gt;~/.claude/scratch/preflight-&amp;lt;project-name&amp;gt;-&amp;lt;YYYYMMDD-HHMM&amp;gt;.md&lt;/code&gt;. Subsequent commands read it.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:plan
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/plan.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/plan.md&lt;/code&gt;&lt;/a&gt;. Effort: heavy.&lt;/p&gt;

&lt;p&gt;Plan turns a confirmed task into an ordered, atomic implementation plan with explicit tradeoffs. The procedure is constrained: consider two implementation approaches, score each on scope, risk, effort, and reversibility, pick one and justify why, break the chosen approach into phased steps where each step is independently committable and leaves the codebase working, identify the test strategy per step, identify the rollback path.&lt;/p&gt;

&lt;p&gt;Two approaches is the floor. It is not "consider many alternatives." It is "do not pick the first thing that comes to mind without comparing it to one other thing." That single constraint catches a surprising number of bad first instincts.&lt;/p&gt;

&lt;p&gt;The plan is written to &lt;code&gt;~/.claude/scratch/plan-&amp;lt;project-name&amp;gt;-&amp;lt;task-slug&amp;gt;-&amp;lt;YYYYMMDD-HHMM&amp;gt;.md&lt;/code&gt;. I read it, push back on it, edit it directly, or send it back for revision. Implementation does not start until I have approved a plan I trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:implement
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/implement.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/implement.md&lt;/code&gt;&lt;/a&gt;. Effort: heavy.&lt;/p&gt;

&lt;p&gt;Implement executes one step of an approved plan. The scope rules are strict: stay in the files the plan names, do not refactor unrelated code, do not upgrade or add dependencies unless the plan explicitly includes them, comments only where the code does not explain itself, explain why and not what. If the plan turns out to be wrong mid-implementation, stop and surface the mismatch. Do not silently expand.&lt;/p&gt;

&lt;p&gt;After each step the command runs the narrow verification the plan prescribed (one file's tests, one type check), then pauses and reports what was done, what was verified, and what is left in the step. The pause is enforced by the global rule in &lt;code&gt;CLAUDE.md&lt;/code&gt;: pause after each &lt;code&gt;/flow:*&lt;/code&gt; step and wait for user approval before continuing.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:test
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/test.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/test.md&lt;/code&gt;&lt;/a&gt;. Effort: medium.&lt;/p&gt;

&lt;p&gt;Test adds or updates tests for the recent implementation work. It loads the &lt;code&gt;test-patterns&lt;/code&gt; skill, scopes testing to the diff via &lt;code&gt;git diff HEAD&lt;/code&gt; and &lt;code&gt;git status&lt;/code&gt;, mirrors source paths for new test files, and writes tests that verify behavior rather than implementation. It runs the new tests narrowly first (single file), then the adjacent suite. After narrow tests pass, it runs the full check script (&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/run-checks.sh" rel="noopener noreferrer"&gt;&lt;code&gt;run-checks.sh&lt;/code&gt;&lt;/a&gt;, more on this below).&lt;/p&gt;

&lt;p&gt;The discipline is in what it will not do: it will not introduce a new test framework, will not add snapshot tests if the project does not use them, and will not silently change implementation if a test reveals the implementation was wrong. That last one is important. A failing test is a signal, not a problem to suppress.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:review
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/review.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/review.md&lt;/code&gt;&lt;/a&gt;. Effort: heavy.&lt;/p&gt;

&lt;p&gt;Review is the senior pass before handoff. It runs &lt;code&gt;run-checks.sh&lt;/code&gt; to establish baseline check state (so pre-existing failures are noted before review begins), loads the relevant patterns skills for the stack, and reviews in eight ordered categories: correctness, types, accessibility, security, design principles, performance, maintainability, tests. Sections with no findings are skipped, not padded.&lt;/p&gt;

&lt;p&gt;Two rules in this command matter more than the categories. First, an empty review is a valid result. Second, do not flag personal style unless it violates the project's lint config. Both push back against the agent's natural drift toward "find something to say." Reviews that invent findings to look thorough are reviews I have to spend tokens disagreeing with.&lt;/p&gt;

&lt;p&gt;The review output goes to &lt;code&gt;~/.claude/scratch/review-&amp;lt;project-name&amp;gt;-&amp;lt;scope-slug&amp;gt;-&amp;lt;YYYYMMDD-HHMM&amp;gt;.md&lt;/code&gt; using the &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/markdown-report/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;markdown-report&lt;/code&gt;&lt;/a&gt; skill format with a severity rubric.&lt;/p&gt;

&lt;h3&gt;
  
  
  /flow:fix and /flow:resume
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/fix.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/fix.md&lt;/code&gt;&lt;/a&gt; is for surgical fixes from a failing signal: test failure, type error, runtime error, lint error. Hypothesis stated in one sentence before changing anything. Smallest change that addresses the root cause. Stop conditions: if the hypothesis requires a refactor, stop and propose a separate &lt;code&gt;/flow:plan&lt;/code&gt;. If the fix touches more than three files, stop and surface; this is no longer a fix. If the fix changes a public API, stop and ask for a plan.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/flow/resume.md" rel="noopener noreferrer"&gt;&lt;code&gt;flow/resume.md&lt;/code&gt;&lt;/a&gt; reorients against a partially executed plan. It diffs the plan against the current code state, reports which steps are done, partial, or pending, and recommends the next concrete action. It does not implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit namespace
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/commands/audit" rel="noopener noreferrer"&gt;&lt;code&gt;claude/commands/audit/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Five audits, all stack-aware via the injected &lt;code&gt;&amp;lt;repo-context&amp;gt;&lt;/code&gt; (described below).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/audit/a11y.md" rel="noopener noreferrer"&gt;&lt;code&gt;audit/a11y.md&lt;/code&gt;&lt;/a&gt; runs a WCAG 2.2 AA audit using the &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/wcag-audit/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;wcag-audit&lt;/code&gt;&lt;/a&gt; skill. Bails if no frontend surface is detected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/audit/debt.md" rel="noopener noreferrer"&gt;&lt;code&gt;audit/debt.md&lt;/code&gt;&lt;/a&gt; surfaces technical debt and architectural drift. Be direct and skeptical. Do not list minor style preferences.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/audit/doc-drift.md" rel="noopener noreferrer"&gt;&lt;code&gt;audit/doc-drift.md&lt;/code&gt;&lt;/a&gt; detects divergence between code and the docs that describe it. Comparison task, does not rewrite docs. Routed to Haiku via &lt;code&gt;model: haiku&lt;/code&gt; in the frontmatter, because the work is mechanical comparison rather than judgment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/audit/perf.md" rel="noopener noreferrer"&gt;&lt;code&gt;audit/perf.md&lt;/code&gt;&lt;/a&gt; is static analysis. It does not run benchmarks. It flags what is likely slow and names what to measure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/audit/security.md" rel="noopener noreferrer"&gt;&lt;code&gt;audit/security.md&lt;/code&gt;&lt;/a&gt; is defensive review. Assume any input from outside the process boundary is hostile. Loads &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/security-patterns/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;security-patterns&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Audits are not on the main path. They run when scope warrants.&lt;/p&gt;

&lt;h2&gt;
  
  
  The meta namespace
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/commands/meta" rel="noopener noreferrer"&gt;&lt;code&gt;claude/commands/meta/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/meta/feature.md" rel="noopener noreferrer"&gt;&lt;code&gt;meta/feature.md&lt;/code&gt;&lt;/a&gt; shapes a fuzzy feature request into a structured brief before planning. It is the step before &lt;code&gt;/flow:plan&lt;/code&gt; for tasks that are not yet sharp enough to plan against. Heavy effort, because the work is thinking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/meta/prompt.md" rel="noopener noreferrer"&gt;&lt;code&gt;meta/prompt.md&lt;/code&gt;&lt;/a&gt; turns a fuzzy ask into a sharp Claude Code prompt with context and acceptance criteria. Light effort, routed to Haiku. The work is rewriting with structure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/meta/retro.md" rel="noopener noreferrer"&gt;&lt;code&gt;meta/retro.md&lt;/code&gt;&lt;/a&gt; is a structured retrospective for an incident, sprint, or completed feature. Routed to Haiku because it is structure-driven rather than analysis-heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The write namespace
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/commands/write" rel="noopener noreferrer"&gt;&lt;code&gt;claude/commands/write/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;All four are routed to Haiku via frontmatter, all four are light effort, all four are pure transformation tasks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/write/commit.md" rel="noopener noreferrer"&gt;&lt;code&gt;write/commit.md&lt;/code&gt;&lt;/a&gt; generates a commit message from the staged diff, matching project style. Reads &lt;code&gt;git log --oneline -20&lt;/code&gt; to detect the project's convention (Conventional Commits, ticket prefix, plain). Does not run &lt;code&gt;git commit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/write/pr.md" rel="noopener noreferrer"&gt;&lt;code&gt;write/pr.md&lt;/code&gt;&lt;/a&gt; generates a PR description from the current diff. No codebase reading beyond the diff provided.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/write/release-notes.md" rel="noopener noreferrer"&gt;&lt;code&gt;write/release-notes.md&lt;/code&gt;&lt;/a&gt; groups and rewrites commits unique to the current branch versus its base.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/commands/write/stakeholder.md" rel="noopener noreferrer"&gt;&lt;code&gt;write/stakeholder.md&lt;/code&gt;&lt;/a&gt; reframes a technical finding for a non-technical audience.&lt;/p&gt;

&lt;p&gt;These are the clearest case for model selection. Generating a commit message from a diff does not require Sonnet's reasoning. Haiku does it as well, faster, and at a fraction of the cost. The model choice is in the command's frontmatter, not in my head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Effort tags and model routing
&lt;/h2&gt;

&lt;p&gt;Every command starts with an effort tag: light, medium, or heavy. The tags are advisory for me when picking what to run, but they map cleanly to the model selection in frontmatter.&lt;/p&gt;

&lt;p&gt;Light effort, mechanical transformation work runs on Haiku: the four &lt;code&gt;write/*&lt;/code&gt; commands, &lt;code&gt;audit:doc-drift&lt;/code&gt;, &lt;code&gt;meta:prompt&lt;/code&gt;, and &lt;code&gt;meta:retro&lt;/code&gt;. Everything else runs on the default model (Sonnet for me) because the work is judgment-heavy: planning, implementing, reviewing, debt audits, security audits, performance audits, feature briefs.&lt;/p&gt;

&lt;p&gt;The cost argument is concrete here. Generating a PR description on Sonnet is a small overpayment. Doing it across every PR for a year is not. Routing it to Haiku via one line in frontmatter, &lt;code&gt;model: haiku&lt;/code&gt;, recovers the difference without changing anything about how I invoke the command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills as on-demand expertise
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/skills" rel="noopener noreferrer"&gt;&lt;code&gt;claude/skills/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Six skills, each a markdown file with frontmatter that tells Claude when to load it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/react-patterns/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;react-patterns&lt;/code&gt;&lt;/a&gt;: React and Next.js patterns, anti-patterns, and review checklist&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/django-patterns/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;django-patterns&lt;/code&gt;&lt;/a&gt;: Django patterns and review checklist&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/test-patterns/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;test-patterns&lt;/code&gt;&lt;/a&gt;: conventions for Vitest, Jest, RTL, Playwright, pytest&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/wcag-audit/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;wcag-audit&lt;/code&gt;&lt;/a&gt;: WCAG 2.2 AA checklist and severity rubric&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/security-patterns/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;security-patterns&lt;/code&gt;&lt;/a&gt;: security checklist for frontend and backend&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/skills/markdown-report/SKILL.md" rel="noopener noreferrer"&gt;&lt;code&gt;markdown-report&lt;/code&gt;&lt;/a&gt;: consistent format for audit and review artifacts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skills are loaded on demand by the commands that need them. &lt;code&gt;/flow:plan&lt;/code&gt; loads the patterns skill matching the detected stack. &lt;code&gt;/flow:test&lt;/code&gt; loads &lt;code&gt;test-patterns&lt;/code&gt;. &lt;code&gt;/audit:a11y&lt;/code&gt; loads &lt;code&gt;wcag-audit&lt;/code&gt;. &lt;code&gt;/audit:security&lt;/code&gt; loads &lt;code&gt;security-patterns&lt;/code&gt;. &lt;code&gt;/flow:review&lt;/code&gt; loads multiple skills depending on what the diff touches.&lt;/p&gt;

&lt;p&gt;This is the design choice that does the most for the cost argument: skills are not in the system prompt by default. They are pulled in only when relevant. The token budget for "everything Claude could possibly know about React" is paid only on tasks that actually involve React.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks and the permission system
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/hooks" rel="noopener noreferrer"&gt;&lt;code&gt;claude/hooks/&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/settings.json" rel="noopener noreferrer"&gt;&lt;code&gt;claude/settings.json&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is the layer that takes the agent from "useful most of the time" to "I trust it to operate in my repos." Five hooks, registered in &lt;code&gt;settings.json&lt;/code&gt; against tool events.&lt;/p&gt;

&lt;h3&gt;
  
  
  inject-context.sh
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/hooks/inject-context.sh" rel="noopener noreferrer"&gt;&lt;code&gt;hooks/inject-context.sh&lt;/code&gt;&lt;/a&gt;. Fires on &lt;code&gt;UserPromptSubmit&lt;/code&gt;. Runs once per session, gated by a session marker file in &lt;code&gt;~/.claude/scratch/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It calls &lt;code&gt;project-name.sh&lt;/code&gt; and &lt;code&gt;project-root.sh&lt;/code&gt;, looks for a cached stack report at &lt;code&gt;~/.claude/cache/stack/&amp;lt;project-name&amp;gt;.txt&lt;/code&gt;, invalidates the cache if any stack sentinel file (&lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;pyproject.toml&lt;/code&gt;, &lt;code&gt;Gemfile&lt;/code&gt;, &lt;code&gt;Cargo.toml&lt;/code&gt;, &lt;code&gt;go.mod&lt;/code&gt;) is newer than the cache, regenerates by running &lt;code&gt;detect-stack.sh&lt;/code&gt; if needed, and prepends a &lt;code&gt;&amp;lt;repo-context&amp;gt;&lt;/code&gt; block to the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;repo-context&amp;gt;&lt;/span&gt;
root: /Users/me/Code/some-project
js: yes (typescript, react, vitest, eslint) [pnpm]
node: 20.11.1
python: no
ruby: no
rust: no
&lt;span class="nt"&gt;&amp;lt;/repo-context&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache is the part that matters for cost. Stack detection is not free. It runs &lt;code&gt;jq&lt;/code&gt; over &lt;code&gt;package.json&lt;/code&gt;, greps Python config files, checks for monorepo signals. Doing it on every prompt would be wasteful. Doing it once per session and reusing it across the conversation is cheap. Doing it once per project until a sentinel file changes is cheaper still.&lt;/p&gt;

&lt;p&gt;The detection itself lives in &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/detect-stack.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/detect-stack.sh&lt;/code&gt;&lt;/a&gt;. Output is terse on purpose; each line is meant to be scanned in under a few hundred tokens of context.&lt;/p&gt;

&lt;p&gt;The flip side of inject-context is that the global &lt;code&gt;CLAUDE.md&lt;/code&gt; requires it. The very first line of the project boot protocol is: "Check the injected &lt;code&gt;&amp;lt;repo-context&amp;gt;&lt;/code&gt; block. If absent, surface the issue and stop. The inject-context.sh hook did not fire." If the hook fails silently, Claude does not silently proceed without context. It stops and tells me.&lt;/p&gt;

&lt;h3&gt;
  
  
  guard-bash.sh
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/hooks/guard-bash.sh" rel="noopener noreferrer"&gt;&lt;code&gt;hooks/guard-bash.sh&lt;/code&gt;&lt;/a&gt;. Fires on &lt;code&gt;PreToolUse&lt;/code&gt; for &lt;code&gt;Bash&lt;/code&gt;. Reads the proposed command from stdin and blocks patterns that the permission system cannot reliably express.&lt;/p&gt;

&lt;p&gt;What it blocks: fork bombs, piping network downloads into a shell interpreter, writes to raw disk devices (&lt;code&gt;/dev/sd*&lt;/code&gt;, &lt;code&gt;/dev/nvme*&lt;/code&gt;, &lt;code&gt;/dev/disk*&lt;/code&gt;), direct writes to shell rc files, redundant shell redirects (&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; and &lt;code&gt;&amp;amp;&amp;gt;&lt;/code&gt;, which the Bash tool doesn't need and which trigger permission prompts). Per-segment, after splitting on &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, and newlines: &lt;code&gt;rm -rf&lt;/code&gt; against root or home or cwd, low-level disk tools (&lt;code&gt;dd&lt;/code&gt;, &lt;code&gt;shred&lt;/code&gt;, &lt;code&gt;wipefs&lt;/code&gt;, &lt;code&gt;mkfs&lt;/code&gt;), &lt;code&gt;chmod 777&lt;/code&gt;, broad &lt;code&gt;chmod +x&lt;/code&gt; against root or home, &lt;code&gt;git push --force&lt;/code&gt; without &lt;code&gt;--force-with-lease&lt;/code&gt;, force push to protected branches, &lt;code&gt;git reset --hard&lt;/code&gt; on protected branches, &lt;code&gt;--no-verify&lt;/code&gt; on commit/push/merge/rebase, &lt;code&gt;git config --global&lt;/code&gt; from a project session, destructive SQL via &lt;code&gt;psql -c&lt;/code&gt;, destructive &lt;code&gt;redis-cli&lt;/code&gt; (FLUSHALL, FLUSHDB, CONFIG SET), &lt;code&gt;find -delete&lt;/code&gt;, &lt;code&gt;find -exec rm&lt;/code&gt;, keychain deletion, global package installs.&lt;/p&gt;

&lt;p&gt;The contract is simple: exit 0 to allow, exit 2 to block with a reason shown to Claude. Any other non-zero exit is a soft failure that does not block. The hook fails open on its own errors. That last detail matters: a buggy guardrail that blocks legitimate commands is its own kind of damage.&lt;/p&gt;

&lt;h3&gt;
  
  
  guard-edit.sh
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/hooks/guard-edit.sh" rel="noopener noreferrer"&gt;&lt;code&gt;hooks/guard-edit.sh&lt;/code&gt;&lt;/a&gt;. Fires on &lt;code&gt;PreToolUse&lt;/code&gt; for &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;MultiEdit&lt;/code&gt;. Blocks edits to lockfiles (&lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;, &lt;code&gt;yarn.lock&lt;/code&gt;, &lt;code&gt;bun.lockb&lt;/code&gt;, &lt;code&gt;Gemfile.lock&lt;/code&gt;, &lt;code&gt;Cargo.lock&lt;/code&gt;, &lt;code&gt;composer.lock&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt;, &lt;code&gt;uv.lock&lt;/code&gt;), edits inside &lt;code&gt;.git/&lt;/code&gt;, and direct edits to shell rc files.&lt;/p&gt;

&lt;p&gt;If the edit targets a CI workflow file (&lt;code&gt;.github/workflows/*.yml&lt;/code&gt;), the hook logs a warning to stderr but does not block. CI workflow edits are sometimes legitimate but always worth surfacing.&lt;/p&gt;

&lt;h3&gt;
  
  
  guard-commit.sh
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/hooks/guard-commit.sh" rel="noopener noreferrer"&gt;&lt;code&gt;hooks/guard-commit.sh&lt;/code&gt;&lt;/a&gt;. Fires on &lt;code&gt;PreToolUse&lt;/code&gt; for &lt;code&gt;Bash&lt;/code&gt;, but only acts on &lt;code&gt;git commit&lt;/code&gt; commands.&lt;/p&gt;

&lt;p&gt;It blocks AI signatures (&lt;code&gt;Co-Authored-By: Claude&lt;/code&gt;, &lt;code&gt;Generated by Claude&lt;/code&gt;, robot emoji generation tags) and AI-tell phrasing in the commit subject (&lt;code&gt;Certainly:&lt;/code&gt;, &lt;code&gt;Here is:&lt;/code&gt;, &lt;code&gt;In this commit:&lt;/code&gt;, etc., even when prefixed with a Conventional Commits type). This is the hook that exists because I forgot, more than once, to strip the AI signature from commits before pushing.&lt;/p&gt;

&lt;h3&gt;
  
  
  sanitize-output.sh
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/hooks/sanitize-output.sh" rel="noopener noreferrer"&gt;&lt;code&gt;hooks/sanitize-output.sh&lt;/code&gt;&lt;/a&gt;. Fires on &lt;code&gt;PostToolUse&lt;/code&gt; for &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;MultiEdit&lt;/code&gt;. Strips typographic punctuation from written files: em dashes, en dashes, smart quotes, ellipsis characters, Unicode arrows. Replaces them with their ASCII equivalents.&lt;/p&gt;

&lt;p&gt;This sounds petty. It is petty. It also catches the case where Claude, despite the explicit rule in &lt;code&gt;CLAUDE.md&lt;/code&gt;, writes an em dash anyway. Belt and suspenders. The rule exists in &lt;code&gt;CLAUDE.md&lt;/code&gt; so the model usually does the right thing. The hook exists so the model cannot ship the wrong thing even when it slips.&lt;/p&gt;

&lt;h3&gt;
  
  
  The permission split: allow / ask / deny
&lt;/h3&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/settings.json" rel="noopener noreferrer"&gt;&lt;code&gt;claude/settings.json&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The hooks handle the cases where I want a hard block. The permission system in &lt;code&gt;settings.json&lt;/code&gt; handles the gradient of "this is fine," "ask me first," and "never."&lt;/p&gt;

&lt;p&gt;Examples from the &lt;strong&gt;allow&lt;/strong&gt; list: &lt;code&gt;git status *&lt;/code&gt;, &lt;code&gt;git diff *&lt;/code&gt;, &lt;code&gt;git log *&lt;/code&gt;, &lt;code&gt;git switch *&lt;/code&gt;, &lt;code&gt;git stash *&lt;/code&gt;, &lt;code&gt;git worktree *&lt;/code&gt;, &lt;code&gt;npm run *&lt;/code&gt;, &lt;code&gt;pnpm test *&lt;/code&gt;, &lt;code&gt;pnpm exec *&lt;/code&gt;, &lt;code&gt;npx *&lt;/code&gt;, &lt;code&gt;vitest *&lt;/code&gt;, &lt;code&gt;tsc *&lt;/code&gt;, &lt;code&gt;prettier *&lt;/code&gt;, &lt;code&gt;eslint *&lt;/code&gt;, &lt;code&gt;rg *&lt;/code&gt;, &lt;code&gt;grep *&lt;/code&gt;, &lt;code&gt;fd *&lt;/code&gt;, &lt;code&gt;find *&lt;/code&gt;, &lt;code&gt;gh pr view *&lt;/code&gt;, &lt;code&gt;gh pr diff *&lt;/code&gt;, &lt;code&gt;gh run list *&lt;/code&gt;, &lt;code&gt;Read(**)&lt;/code&gt;, &lt;code&gt;Edit(**)&lt;/code&gt;, &lt;code&gt;Write(**)&lt;/code&gt;, plus the bin scripts. Plus all my skills explicitly listed by name.&lt;/p&gt;

&lt;p&gt;Examples from the &lt;strong&gt;ask&lt;/strong&gt; list: &lt;code&gt;npm install *&lt;/code&gt;, &lt;code&gt;pnpm add *&lt;/code&gt;, &lt;code&gt;brew install *&lt;/code&gt;, &lt;code&gt;cargo install *&lt;/code&gt;, &lt;code&gt;git push *&lt;/code&gt;, &lt;code&gt;git reset *&lt;/code&gt;, &lt;code&gt;git rebase *&lt;/code&gt;, &lt;code&gt;git commit --amend*&lt;/code&gt;, &lt;code&gt;gh pr merge *&lt;/code&gt;, &lt;code&gt;gh release create *&lt;/code&gt;, &lt;code&gt;rm *&lt;/code&gt;, &lt;code&gt;rmdir *&lt;/code&gt;, &lt;code&gt;curl *&lt;/code&gt;, &lt;code&gt;wget *&lt;/code&gt;, edits to config files (&lt;code&gt;.env*&lt;/code&gt;, &lt;code&gt;tsconfig*.json&lt;/code&gt;, &lt;code&gt;next.config.*&lt;/code&gt;, &lt;code&gt;vite.config.*&lt;/code&gt;, &lt;code&gt;eslint.config.*&lt;/code&gt;, &lt;code&gt;biome.json&lt;/code&gt;, &lt;code&gt;prettier.config.*&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Examples from the &lt;strong&gt;deny&lt;/strong&gt; list: &lt;code&gt;sudo *&lt;/code&gt;, &lt;code&gt;su *&lt;/code&gt;, &lt;code&gt;rm -rf&lt;/code&gt; against root or home or cwd, &lt;code&gt;chmod 777&lt;/code&gt;, &lt;code&gt;dd *&lt;/code&gt;, &lt;code&gt;mkfs *&lt;/code&gt;, &lt;code&gt;shutdown *&lt;/code&gt;, &lt;code&gt;reboot *&lt;/code&gt;, system configuration commands (&lt;code&gt;launchctl&lt;/code&gt;, &lt;code&gt;defaults&lt;/code&gt;, &lt;code&gt;nvram&lt;/code&gt;, &lt;code&gt;scutil&lt;/code&gt;, &lt;code&gt;pfctl&lt;/code&gt;), reading secrets (&lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;*.pem&lt;/code&gt;, &lt;code&gt;*.key&lt;/code&gt;, SSH private keys, AWS credentials, gh hosts.yml, netrc, pgpass, npmrc, macOS keychains).&lt;/p&gt;

&lt;p&gt;The split exists so the agent can move at speed when the operation is safe and stops to ask when the operation is not. Allow-listing read and search tools, scoped git read commands, build commands, and the project's own scripts means the agent does not get stuck asking for permission on routine work. Ask-listing destructive or scope-changing commands means I see them before they happen. Deny-listing things I will never approve under any circumstance means I never have to read another permission prompt about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Helper scripts
&lt;/h2&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude/bin" rel="noopener noreferrer"&gt;&lt;code&gt;claude/bin/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Five small scripts, each doing exactly one thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/project-root.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/project-root.sh&lt;/code&gt;&lt;/a&gt; returns the git repo root, falling back to &lt;code&gt;$PWD&lt;/code&gt; outside a working tree.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/project-name.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/project-name.sh&lt;/code&gt;&lt;/a&gt; returns a slug-safe project identifier used in scratch artifact filenames. Lowercased, leading dots stripped, non-alphanumeric replaced with dashes, collapsed dashes, trimmed. &lt;code&gt;$HOME&lt;/code&gt; becomes &lt;code&gt;home&lt;/code&gt;. &lt;code&gt;/&lt;/code&gt; becomes &lt;code&gt;root&lt;/code&gt;. Empty becomes &lt;code&gt;unknown&lt;/code&gt;. The slug stability is what makes "the most recent plan for this project" a reliable thing to ask for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/git-base.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/git-base.sh&lt;/code&gt;&lt;/a&gt; prints the base branch for the current checkout. Detection order: explicit argument, upstream tracking branch, remote HEAD, common defaults (main, master, develop, trunk). Used by &lt;code&gt;/write:release-notes&lt;/code&gt; and elsewhere to compute "commits unique to this branch."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/detect-stack.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/detect-stack.sh&lt;/code&gt;&lt;/a&gt; emits the compact stack report consumed by &lt;code&gt;inject-context.sh&lt;/code&gt;. Detects JS/TS (with framework, package manager, Node version), Python (with framework, in &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;backend/&lt;/code&gt;, &lt;code&gt;server/&lt;/code&gt;, &lt;code&gt;api/&lt;/code&gt;), Ruby (with Rails detection), Rust, and monorepo signals (pnpm workspaces, Turbo, Nx, Lerna).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ku5ic/dotfiles/blob/main/claude/bin/run-checks.sh" rel="noopener noreferrer"&gt;&lt;code&gt;bin/run-checks.sh&lt;/code&gt;&lt;/a&gt; detects and runs the project's typecheck, lint, format-check, and tests. Each section is independent; failures are reported, not aborted. It is the script &lt;code&gt;/flow:test&lt;/code&gt; and &lt;code&gt;/flow:review&lt;/code&gt; shell out to.&lt;/p&gt;

&lt;p&gt;These are all intentionally boring. They are the parts of "context Claude needs about this project" that should never be regenerated by the agent itself. Boring shell scripts produce stable, slug-safe output. Stable output is what makes scratch artifact naming work, which is what makes &lt;code&gt;/flow:resume&lt;/code&gt; work, which is what makes the whole flow recoverable when context runs out mid-task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scratch artifacts and the project boundary
&lt;/h2&gt;

&lt;p&gt;The whole flow is held together by a naming convention for artifacts written to &lt;code&gt;~/.claude/scratch/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.claude/scratch/&amp;lt;kind&amp;gt;-&amp;lt;project-name&amp;gt;-&amp;lt;scope-slug&amp;gt;-&amp;lt;YYYYMMDD-HHMM&amp;gt;.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kind&lt;/code&gt; is &lt;code&gt;preflight&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;feature&lt;/code&gt;, &lt;code&gt;retro&lt;/code&gt;, etc. &lt;code&gt;project-name&lt;/code&gt; is the output of &lt;code&gt;project-name.sh&lt;/code&gt;. &lt;code&gt;scope-slug&lt;/code&gt; is the task slug or, for some kinds, omitted.&lt;/p&gt;

&lt;p&gt;Any command that needs "the most recent X" filters by current project name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; ~/.claude/scratch/&amp;lt;kind&amp;gt;-&amp;lt;project-name&amp;gt;-&lt;span class="k"&gt;*&lt;/span&gt;.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never read across projects. If no artifact exists for the current project, run the predecessor command first.&lt;/p&gt;

&lt;p&gt;This is what makes the flow durable. A plan written for project A in the morning is still findable by &lt;code&gt;/flow:resume&lt;/code&gt; in project A in the afternoon, even after a half-dozen unrelated sessions in other projects in between. The naming convention is the bridge between sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost argument, restated
&lt;/h2&gt;

&lt;p&gt;Add it up: a CLAUDE.md that pre-loads the contract. A flow namespace that gates work behind preflight, plan, implement, test, review. An audit namespace that loads heavy checklists only when invoked. A meta namespace for shaping fuzzy work into structured input. A write namespace routed to Haiku for transformation tasks. Skills loaded on demand instead of bundled into the system prompt. A &lt;code&gt;&amp;lt;repo-context&amp;gt;&lt;/code&gt; block injected once per session and cached per project. Effort tags that map to model selection. Hooks that block the destructive operations that cost the most to undo. A permission split that lets the agent move on safe work and stop on dangerous work. Scratch artifacts that survive across sessions.&lt;/p&gt;

&lt;p&gt;Every one of those is a markdown file, a shell script, or a JSON entry. None of it is a framework. None of it is a vendor primitive. The whole thing is the kind of structure any senior engineer would recognize from a well-run open source project, applied to the agent instead of to a new hire.&lt;/p&gt;

&lt;p&gt;Yes, all of this spends tokens. It also prevents the much larger token spend of correcting an under-context, over-confident agent that drifted, hallucinated, or did the wrong thing in the right file. And it prevents the largest cost of all, the one that does not show up on any token meter: my time spent re-steering, re-explaining, and rolling back work that should not have happened.&lt;/p&gt;

&lt;p&gt;The constraint was never knowledge. The constraint, once you let an agent into your editor, is discipline at runtime. Discipline does not scale through willpower. It scales through tooling. The dotfiles are what that scaling looks like for me. Yours will look different. The point is that you should have one.&lt;/p&gt;

&lt;p&gt;I made &lt;a href="https://dev.to/ku5ic/the-constraint-was-never-knowledge-4opc"&gt;a related argument&lt;/a&gt; about planning a while back: AI removed the time constraint that used to prevent proper architectural work upstream. This is the downstream version of the same argument. Once planning is cheap, runtime discipline becomes the next thing worth engineering.&lt;/p&gt;

&lt;p&gt;Source for everything in this article: &lt;a href="https://github.com/ku5ic/dotfiles/tree/main/claude" rel="noopener noreferrer"&gt;&lt;code&gt;github.com/ku5ic/dotfiles/tree/main/claude&lt;/code&gt;&lt;/a&gt;. MIT licensed. Copy what is useful, ignore what is not, and tell me what you would change.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>Accessibility-first looks different from accessibility-compliant</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Sat, 11 Apr 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ku5ic/accessibility-first-looks-different-from-accessibility-compliant-39n0</link>
      <guid>https://dev.to/ku5ic/accessibility-first-looks-different-from-accessibility-compliant-39n0</guid>
      <description>&lt;p&gt;Passing an axe-core audit is not the same thing as designing for accessibility. The outputs can look identical. The process that produced them is not.&lt;/p&gt;

&lt;p&gt;Most component libraries retrofit accessibility. The ARIA roles get added, the keyboard navigation gets wired, the contrast ratios get verified. It passes. But the architecture was not designed around the constraint. It was designed first, then adjusted to meet it.&lt;/p&gt;

&lt;p&gt;nuka-ui was built with WCAG 2.2 AA as a hard requirement from the first commit. Not a goal. A constraint. That distinction is in the README. This article is what that sentence means in practice: nine decisions from the codebase where accessibility as a non-negotiable constraint produced an outcome that a retrofit would not.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The &lt;code&gt;hidden&lt;/code&gt; attribute instead of conditional rendering
&lt;/h2&gt;

&lt;p&gt;The default React pattern for a dropdown is &lt;code&gt;{open &amp;amp;&amp;amp; &amp;lt;SelectContent /&amp;gt;}&lt;/code&gt;. It is clean, idiomatic, and wrong for an accessible combobox.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;SelectTrigger&lt;/code&gt; has &lt;code&gt;aria-controls&lt;/code&gt; pointing to the listbox ID. If the listbox is conditionally rendered, &lt;code&gt;aria-controls&lt;/code&gt; references an element that does not exist when the dropdown is closed. The ARIA 1.2 spec technically permits this, but it creates a second problem: &lt;code&gt;SelectItem&lt;/code&gt; components register their labels on mount so the trigger can display the selected option's label. If the listbox is not in the DOM, that registration never happens. The trigger has no label data on first render.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;hidden={!open}&lt;/code&gt; on the listbox. Always in the DOM, removed from the accessibility tree when not needed, &lt;code&gt;aria-controls&lt;/code&gt; always resolves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"listbox"&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;listboxId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This decision cascades. Because the listbox is always mounted, the label registry must exist before the listbox is visible. &lt;code&gt;SelectItem&lt;/code&gt; registers via &lt;code&gt;useLayoutEffect&lt;/code&gt; into a &lt;code&gt;Map&lt;/code&gt; stored in a ref. A &lt;code&gt;registryVersion&lt;/code&gt; state counter increments on each registration, included in a &lt;code&gt;useMemo&lt;/code&gt; dependency so &lt;code&gt;SelectTrigger&lt;/code&gt; re-renders when items register.&lt;/p&gt;

&lt;p&gt;One accessibility requirement. Three implementation consequences. The simpler approach would have worked visually. It would not have worked for screen readers.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. &lt;code&gt;aria-controls&lt;/code&gt; and &lt;code&gt;aria-expanded&lt;/code&gt; are not redundant
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SelectTrigger&lt;/code&gt; carries both &lt;code&gt;aria-controls&lt;/code&gt; and &lt;code&gt;aria-expanded&lt;/code&gt;. They answer different questions.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aria-expanded&lt;/code&gt; tells the user whether the popup is currently open. &lt;code&gt;aria-controls&lt;/code&gt; tells the user which element this control manages. Both are required by the ARIA combobox pattern. Together they allow screen readers to announce the relationship between the trigger and the listbox without the user exploring the DOM. Removing either one degrades the experience even if the visual interface is correct.&lt;/p&gt;

&lt;p&gt;The third ARIA attribute on the trigger is &lt;code&gt;aria-activedescendant&lt;/code&gt;. When keyboard navigation highlights an option, this attribute points to that option's ID. Focus stays on the trigger. The screen reader announces which option is highlighted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"combobox"&lt;/span&gt;
  &lt;span class="na"&gt;aria-haspopup&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"listbox"&lt;/span&gt;
  &lt;span class="na"&gt;aria-expanded&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-controls&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listboxId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-activedescendant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ariaActiveDescendant&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One test from the suite worth noting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-activedescendant is undefined when no option is highlighted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderSelect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;combobox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-activedescendant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test checks for &lt;code&gt;undefined&lt;/code&gt;, not empty string. &lt;code&gt;aria-activedescendant=""&lt;/code&gt; is invalid. The attribute must be absent when nothing is highlighted. TypeScript strictness helps: the value is typed as &lt;code&gt;string | undefined&lt;/code&gt;, and &lt;code&gt;undefined&lt;/code&gt; attributes are not rendered to the DOM.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. &lt;code&gt;nameFrom: author&lt;/code&gt; and the accessible name fallback chain
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;role="combobox"&lt;/code&gt; has &lt;code&gt;nameFrom: author&lt;/code&gt; in the ARIA spec. This means the visible text inside the trigger does not contribute to the accessible name. A screen reader will not announce the displayed value unless an explicit accessible name is provided.&lt;/p&gt;

&lt;p&gt;This surprises developers who test visually. The trigger displays the selected value. It looks labelled. It is not labelled from the screen reader's perspective.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SelectTrigger&lt;/code&gt; handles this with a fallback chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If the trigger is inside a &lt;code&gt;FormField&lt;/code&gt;, the &lt;code&gt;Label&lt;/code&gt; component provides &lt;code&gt;aria-labelledby&lt;/code&gt; pointing to the label's ID. This takes priority and &lt;code&gt;aria-label&lt;/code&gt; is omitted.&lt;/li&gt;
&lt;li&gt;If no &lt;code&gt;FormField&lt;/code&gt; is present, &lt;code&gt;SelectTrigger&lt;/code&gt; derives &lt;code&gt;aria-label&lt;/code&gt; from: the selected option label (from the registry), then the &lt;code&gt;placeholder&lt;/code&gt; prop, then a hardcoded fallback of &lt;code&gt;"Select"&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ariaLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ariaLabelledBy&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;  &lt;span class="c1"&gt;// aria-labelledby from FormField takes over&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;displayLabel&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;placeholder&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Select&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ariaLabelledBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;formCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;labelId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback to &lt;code&gt;"Select"&lt;/code&gt; is a last resort that ensures no combobox ships without an accessible name, even if the consumer forgets to provide a label or placeholder. This chain exists because of a specific ARIA rule about this specific role. You only design it if you know the rule before you start.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Skeleton's hardcoded &lt;code&gt;aria-hidden&lt;/code&gt; that cannot be overridden
&lt;/h2&gt;

&lt;p&gt;Most components in nuka-ui accept spread props, including arbitrary ARIA overrides. &lt;code&gt;Skeleton&lt;/code&gt; deliberately does not allow &lt;code&gt;aria-hidden&lt;/code&gt; to be removed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Skeleton&lt;/code&gt; is purely visual decoration. It conveys no information. Loading state announcements belong on the parent container via &lt;code&gt;aria-busy="true"&lt;/code&gt;, not on the skeleton elements. If a consumer passes &lt;code&gt;aria-hidden={false}&lt;/code&gt;, they create a situation where a screen reader announces a meaningless animated rectangle as content.&lt;/p&gt;

&lt;p&gt;The enforcement mechanism is prop ordering. &lt;code&gt;aria-hidden="true"&lt;/code&gt; is placed after the &lt;code&gt;...props&lt;/code&gt; spread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Skeleton&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forwardRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SkeletonProps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
        &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;  &lt;span class="c1"&gt;// after spread: cannot be overridden&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;skeletonVariants&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct pattern puts &lt;code&gt;aria-busy&lt;/code&gt; on the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;aria-busy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Loading posts"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Skeleton&lt;/span&gt; &lt;span class="na"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-4 w-3/4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Skeleton&lt;/span&gt; &lt;span class="na"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-4 w-1/2"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most component libraries give consumers maximum flexibility on the assumption they know what they are doing. The tradeoff is that consumers can also do the wrong thing. &lt;code&gt;Skeleton&lt;/code&gt; has one job. The API reflects that.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Toast live region urgency tied to intent
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Toast&lt;/code&gt; uses &lt;code&gt;role="status"&lt;/code&gt; with &lt;code&gt;aria-live&lt;/code&gt; set dynamically based on &lt;code&gt;intent&lt;/code&gt;. Default, success, and warning toasts use &lt;code&gt;aria-live="polite"&lt;/code&gt;. Danger toasts use &lt;code&gt;aria-live="assertive"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is not a visual distinction. It changes when and how a screen reader interrupts the user.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;aria-live="polite"&lt;/code&gt; waits for the user to finish what they are doing, then announces. &lt;code&gt;aria-live="assertive"&lt;/code&gt; interrupts immediately. A confirmation toast ("Profile saved") should not interrupt a user who is mid-sentence in a form. A danger toast ("Payment failed") should.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;
  &lt;span class="na"&gt;aria-live&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;toastItem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assertive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;polite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-atomic&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests that enforce the split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;has aria-live=assertive for danger intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Toast&lt;/span&gt; &lt;span class="na"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;baseToast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onDismiss&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDismiss&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assertive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;has aria-live=polite for success intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Toast&lt;/span&gt; &lt;span class="na"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;baseToast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onDismiss&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDismiss&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toHaveAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;polite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most toast implementations use one &lt;code&gt;aria-live&lt;/code&gt; setting for everything. The distinction here is tied directly to the semantic meaning of &lt;code&gt;intent&lt;/code&gt;, which is also used to drive visual styling. The accessibility behavior and the visual behavior are derived from the same prop, which means they stay in sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Banner's required &lt;code&gt;aria-label&lt;/code&gt; enforced at the TypeScript level
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Banner&lt;/code&gt; uses &lt;code&gt;role="region"&lt;/code&gt;. A region landmark without an accessible name is worse than no landmark: screen reader users navigating by landmarks encounter an anonymous region with no context for what it contains.&lt;/p&gt;

&lt;p&gt;The fix is obvious: require &lt;code&gt;aria-label&lt;/code&gt;. The interesting part is how nuka-ui enforces it.&lt;/p&gt;

&lt;p&gt;Rather than documenting the requirement or adding a runtime warning, &lt;code&gt;aria-label&lt;/code&gt; is required at the TypeScript type level. &lt;code&gt;BannerProps&lt;/code&gt; uses &lt;code&gt;Omit&lt;/code&gt; to strip the optional &lt;code&gt;aria-label&lt;/code&gt; from &lt;code&gt;React.HTMLAttributes&lt;/code&gt; and re-declares it as required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;BannerProps&lt;/span&gt;
  &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nb"&gt;Omit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTMLAttributes&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;BannerVariantProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;aria-label&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// required, not optional&lt;/span&gt;
  &lt;span class="nl"&gt;onDismiss&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A consumer who omits &lt;code&gt;aria-label&lt;/code&gt; gets a TypeScript error at build time, not a failed audit at review time. The accessibility requirement became a compile-time contract.&lt;/p&gt;

&lt;p&gt;This is distinct from &lt;code&gt;Alert&lt;/code&gt;, which uses &lt;code&gt;role="alert"&lt;/code&gt;. &lt;code&gt;Alert&lt;/code&gt; is an assertive live region for urgent, transient feedback. &lt;code&gt;Banner&lt;/code&gt; is a persistent contextual landmark. The semantic distinction between the two is reflected in the APIs: &lt;code&gt;Alert&lt;/code&gt; has no &lt;code&gt;aria-label&lt;/code&gt; requirement because its role carries the identity. &lt;code&gt;Banner&lt;/code&gt; does, because &lt;code&gt;role="region"&lt;/code&gt; does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Tooltip vs. Popover: the semantic boundary in the API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Tooltip&lt;/code&gt; content has &lt;code&gt;pointer-events-none&lt;/code&gt; and &lt;code&gt;role="tooltip"&lt;/code&gt;. &lt;code&gt;Popover&lt;/code&gt; content has &lt;code&gt;role="dialog"&lt;/code&gt; and receives focus on open.&lt;/p&gt;

&lt;p&gt;These are not stylistic choices. They reflect a fundamental ARIA distinction between two types of supplemental content.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;role="tooltip"&lt;/code&gt; is for non-interactive supplementary information. Triggered by hover and focus. The trigger describes itself with &lt;code&gt;aria-describedby&lt;/code&gt;. Focus stays on the trigger. &lt;code&gt;role="dialog"&lt;/code&gt; is for interactive regions. Triggered by click. Focus moves into the panel on open.&lt;/p&gt;

&lt;p&gt;The structural difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tooltip: focus stays on trigger, content is non-interactive&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useHover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;close&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tooltip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// trigger gets aria-describedby, content has pointer-events-none&lt;/span&gt;

&lt;span class="c1"&gt;// Popover: focus moves into panel on open&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;click&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dialog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// trigger gets aria-expanded + aria-controls&lt;/span&gt;
&lt;span class="c1"&gt;// PopoverContent focuses first focusable child on open&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;PopoverContent&lt;/code&gt; focus management:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focusable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contentRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;querySelector&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;focusable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;contentRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;cancelAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;requestAnimationFrame&lt;/code&gt; is necessary because the portal has just been added to the DOM and layout has not completed. Attempting &lt;code&gt;focus()&lt;/code&gt; synchronously inside &lt;code&gt;useEffect&lt;/code&gt; is unreliable.&lt;/p&gt;

&lt;p&gt;One additional constraint from WCAG 1.4.13: &lt;code&gt;Tooltip&lt;/code&gt; opens on focus with no delay. The hover open has a configurable delay (default 600ms). Focus-triggered content must be immediate because the user has already made an explicit navigation action. Applying the hover delay to focus-open would violate that criterion.&lt;/p&gt;

&lt;p&gt;The compound component pattern enforces the semantic separation structurally. A consumer cannot accidentally put interactive content inside a &lt;code&gt;Tooltip&lt;/code&gt; because the content element has &lt;code&gt;pointer-events-none&lt;/code&gt;. The boundary is not just documented. It is built in.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. RadioGroup roving tabindex: one tab stop, arrow key navigation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RadioGroup&lt;/code&gt; implements the roving tabindex pattern. Only one radio in the group is in the tab order at a time (&lt;code&gt;tabindex="0"&lt;/code&gt;). All others have &lt;code&gt;tabindex="-1"&lt;/code&gt;. Arrow keys move between radios and shift the &lt;code&gt;tabindex="0"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the correct keyboard interaction model for radio groups per the ARIA Authoring Practices Guide. Tab navigates between form controls. Arrow keys navigate within a group. A group with three radios should cost the user one Tab key press, not three.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowDown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowRight&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;moveFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowUp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ArrowLeft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;moveFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;moveFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;End&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;moveFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;last&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test that verifies Tab does not cycle within the group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tab does not cycle within the group&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderGroup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;defaultValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;green&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radios&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAllByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;radio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;focused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unfocused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;radios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabindex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;focused&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeDefined&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unfocused&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visual radio indicator uses &lt;code&gt;sr-only&lt;/code&gt; on the native &lt;code&gt;&amp;lt;input type="radio"&amp;gt;&lt;/code&gt; rather than &lt;code&gt;display: none&lt;/code&gt; or &lt;code&gt;visibility: hidden&lt;/code&gt;. The native input remains in the accessibility tree and receives focus. The visual ring is a custom &lt;code&gt;&amp;lt;span aria-hidden="true"&amp;gt;&lt;/code&gt; that mirrors the state. Hiding the input entirely would break keyboard navigation. This is the kind of decision that only comes up if you are thinking about accessibility before you start writing styles.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. The color token rule: adjust lightness only, never chroma or hue
&lt;/h2&gt;

&lt;p&gt;This one is about the design token system, not component code. It is one of the most direct examples of how the accessibility constraint changed an architectural decision.&lt;/p&gt;

&lt;p&gt;nuka-ui uses &lt;code&gt;oklch()&lt;/code&gt; for all color tokens. An &lt;code&gt;oklch()&lt;/code&gt; value has three components: L (lightness), C (chroma), H (hue). Contrast ratio is determined by luminance, which is primarily a function of lightness.&lt;/p&gt;

&lt;p&gt;When a color token fails a contrast check, the rule is: adjust L only. Never adjust C or H.&lt;/p&gt;

&lt;p&gt;Adjusting chroma or hue to fix contrast changes the color. You get something that passes the checker but is no longer the color you chose. Fixing contrast by adjusting lightness keeps the hue and saturation intent intact.&lt;/p&gt;

&lt;p&gt;The primary accent is &lt;code&gt;oklch(44% 0.043 257)&lt;/code&gt;: hue 257 (a blue-grey direction), chroma 0.043 (intentionally low for a muted slate aesthetic), lightness 44%. That lightness value produces 7.74:1 contrast on white, which is WCAG AAA. The lightness was chosen to clear that bar. The hue and chroma were never touched for contrast reasons.&lt;/p&gt;

&lt;p&gt;This rule only works cleanly in &lt;code&gt;oklch()&lt;/code&gt;. In &lt;code&gt;hsl()&lt;/code&gt;, saturation and lightness interact in non-perceptually-uniform ways. Equal changes in L do not produce equal perceived changes in lightness across hues. &lt;code&gt;oklch()&lt;/code&gt; is perceptually uniform: adjusting L produces consistent perceived results regardless of hue. The color space was chosen in part because it makes the accessibility constraint easier to enforce consistently.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual cost of the constraint
&lt;/h2&gt;

&lt;p&gt;These are not exotic edge cases. Every dropdown, every loading state, every notification system, every color token in any component library runs into exactly these decisions. The difference is when you encounter them.&lt;/p&gt;

&lt;p&gt;If accessibility is a constraint from the start, you design the registry pattern before you wire up the combobox because you know you will need it. You make &lt;code&gt;aria-label&lt;/code&gt; required on &lt;code&gt;Banner&lt;/code&gt; before any consumer uses the component. You choose &lt;code&gt;oklch()&lt;/code&gt; before you write the first token because you know you will need to adjust contrast without changing color identity.&lt;/p&gt;

&lt;p&gt;If accessibility is a retrofit, you discover these things in an audit and patch them. The patches are usually correct. But the architecture was not designed around them. Some of the decisions above cannot be cleanly retrofitted because they affect the component API or the token system structure. By the time you find them, you already have consumers.&lt;/p&gt;

&lt;p&gt;nuka-ui's README says WCAG 2.2 AA is "a hard constraint, not a goal." This article is what that sentence costs.&lt;/p&gt;




&lt;p&gt;The live Storybook is at &lt;a href="https://ku5ic.github.io/nuka-ui/" rel="noopener noreferrer"&gt;https://ku5ic.github.io/nuka-ui/&lt;/a&gt;.&lt;br&gt;
Source is at &lt;a href="https://github.com/ku5ic/nuka-ui" rel="noopener noreferrer"&gt;https://github.com/ku5ic/nuka-ui&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>react</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The Constraint Was Never Knowledge</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Thu, 09 Apr 2026 07:52:39 +0000</pubDate>
      <link>https://dev.to/ku5ic/the-constraint-was-never-knowledge-4opc</link>
      <guid>https://dev.to/ku5ic/the-constraint-was-never-knowledge-4opc</guid>
      <description>&lt;p&gt;There is a category of work that every experienced engineer knows matters and almost nobody does consistently. Not because they lack the skill. Not because they do not value it. Because the deadline is already here.&lt;/p&gt;

&lt;p&gt;Modeling state before writing a line of it. Stress-testing an architecture before committing to it. Understanding a system before modifying it. The preflight work. The thinking that separates a codebase that holds from one that becomes someone else's archaeology project six months later.&lt;/p&gt;

&lt;p&gt;This is the work that creates The Janitor Pattern when it gets skipped. And it gets skipped constantly, not out of laziness or ignorance, but because time was the binding constraint. There was never enough of it to think before acting.&lt;/p&gt;

&lt;p&gt;That constraint is largely gone now. Most teams have not noticed what that actually means.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI Actually Unlocks
&lt;/h2&gt;

&lt;p&gt;The dominant narrative around AI-assisted development is about throughput. Write code faster. Generate boilerplate. Autocomplete your way through implementation. That is real, but it is the least interesting part of what changed.&lt;/p&gt;

&lt;p&gt;The more significant unlock is cognitive. When implementation is fast, the bottleneck shifts back to where it always belonged: decisions. What are we building and why. What state actually needs to exist. Where the boundaries go. What happens when requirements change.&lt;/p&gt;

&lt;p&gt;These are not questions that get easier with faster typing. They get easier with time and focused thinking. AI did not make senior engineers faster at coding. It gave them back the time to actually do the architectural work they were already qualified to do but never had runway for.&lt;/p&gt;

&lt;p&gt;That is a different claim than most people are making, and it has different implications.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Seniority Multiplier
&lt;/h2&gt;

&lt;p&gt;There is a mistake a lot of organizations are making right now.&lt;/p&gt;

&lt;p&gt;They noticed that a junior developer with AI tooling can produce output that looks closer to senior work than it used to. That part is true. The gap in raw implementation quality has narrowed.&lt;/p&gt;

&lt;p&gt;What they missed is the more important implication: a senior engineer with the same tools can produce output closer to five or ten seniors working without them. That is not a marginal improvement. That is a structural change in what one person can accomplish.&lt;/p&gt;

&lt;p&gt;The reason is not code generation. A junior with AI increases coding throughput. A senior with AI improves the entire decision system behind the code.&lt;/p&gt;

&lt;p&gt;That means defining the right problem before it gets expensive. Cutting bad scope before it ships. Choosing architecture that survives contact with production. Directing AI toward useful output instead of accepting confident noise. Catching wrong assumptions before they compound into something expensive. Keeping long-term maintainability intact while still moving fast.&lt;/p&gt;

&lt;p&gt;None of that is something AI does on its own. It requires the judgment to know what questions to ask, recognize a wrong answer when you see one, and understand the tradeoffs well enough to make real decisions rather than defer to whatever the tool suggests.&lt;/p&gt;

&lt;p&gt;The cost of building fast in the wrong direction is higher now, not lower, because the speed is higher. A team generating confident, well-structured code aimed at the wrong problem ships a very polished failure. That failure arrives faster than it used to.&lt;/p&gt;

&lt;p&gt;That is why the value of genuine seniority did not go down when AI tooling arrived. In most real-world contexts, it went up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow
&lt;/h2&gt;

&lt;p&gt;A common objection to structured workflows is that they slow you down. The opposite is true once the system is in place. Output speed increases because there is no time lost to wrong assumptions, scope creep, or rework caused by starting before the problem was understood. The quality bar stays high by default rather than by heroic effort. And the dividends compound: the longer you use it, the more the constraint system knows about your codebase, the less friction each new task carries. The first task is the most expensive. The fiftieth is fast and clean.&lt;/p&gt;

&lt;p&gt;The workflow follows a strict phase sequence: preflight, plan, implement, verify, review. Each phase is a named command. None are optional. None can be skipped. The sequence is the discipline.&lt;/p&gt;

&lt;p&gt;But the sequence is only half of it. The other half is what the AI knows before the first command runs.&lt;/p&gt;

&lt;p&gt;The workflow in the &lt;a href="https://github.com/ku5ic/battleship" rel="noopener noreferrer"&gt;Battleship&lt;/a&gt; project is built on two layers of encoded knowledge. First, CLAUDE.md, the engineering standards document that defines every architectural constraint: layer contracts, state design rules, coordinate handling, TypeScript requirements, accessibility rules, testing strategy. Second, a set of skills in &lt;code&gt;.claude/skills/&lt;/code&gt; that encode domain expertise for specific concerns: &lt;code&gt;react-architecture&lt;/code&gt; for layer separation and state design, &lt;code&gt;wcag-react&lt;/code&gt; for accessibility implementation patterns, &lt;code&gt;vitest-react&lt;/code&gt; for testing strategy. The skills load automatically in the right context. The AI does not re-discover these rules each session. They are written down, versioned, and available every time.&lt;/p&gt;

&lt;p&gt;This is the part most AI workflow descriptions miss. The commands are the trigger. The standards document and skills are the constraint system. Without them, the workflow is a prompting pattern. With them, it is a system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preflight&lt;/strong&gt; is the first phase and the most important one. The command instructs the AI to read CLAUDE.md in full, search the existing codebase for relevant types, utilities, and logic, identify the correct layer for each piece of work, check for duplication, and state the delta: one sentence per file to be created or modified. If it cannot state the delta clearly, the scope is not clear enough. Stop and ask.&lt;/p&gt;

&lt;p&gt;No code is generated in this phase. That constraint is deliberate.&lt;/p&gt;

&lt;p&gt;This step works in two directions at once. When the preflight report matches your mental model, you have a verified shared baseline before a single line changes. When it surfaces something you had not fully articulated, an existing utility you had forgotten, an ambiguous layer placement, an assumption you had not examined, you learn something about your own system. Both outcomes are useful. Neither is accidental. The surface area that comes up unexpectedly is exactly where bugs live and refactors go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan&lt;/strong&gt; produces a reviewable artifact before any implementation begins. Not a vague description. A concrete specification. Every new type with its name, shape, and rationale. Every new function with its signature, layer placement, purity, and test strategy. Every state change with what gets persisted, what gets derived, and why. Every component with its props interface and what it emits. Accessibility impact. Risk flags. One sentence per file to be created or modified.&lt;/p&gt;

&lt;p&gt;The plan is a checkpoint, not a suggestion. It requires explicit approval before anything is written. If the plan is wrong, you catch it here, before it is encoded in code that needs to be undone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implement&lt;/strong&gt; writes the approved code, one complete file at a time. No scope expansion. No reinventing what already exists. The approved plan is the boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify&lt;/strong&gt; runs before human review, not as part of it. The command runs through a checklist: architecture boundaries, TypeScript correctness, component composition rules, accessibility compliance, test coverage, CI simulation. Architecture pass, TypeScript pass, accessibility pass, tests pass: each is an explicit gate. If anything fails, it gets fixed before the handoff. The AI does not ask for review with known violations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Review&lt;/strong&gt; surfaces the implementation with decisions, tradeoffs, and anything requiring human judgment explicitly flagged. Things noticed during implementation but out of scope for the current change go here as deferred observations rather than silently expanding the diff.&lt;/p&gt;

&lt;p&gt;The framing from the project documentation captures it precisely: the same discipline you would apply working with a capable but unsupervised junior engineer. No implementation before the design is agreed. No review request before verification passes. No unexplained decisions in the final output.&lt;/p&gt;

&lt;p&gt;Most problems in software do not come from writing bad code. They come from starting before the problem was understood.&lt;/p&gt;

&lt;p&gt;Here is what a task prompt actually looks like. This is the document used to implement the CLI runner in the &lt;a href="https://github.com/ku5ic/battleship" rel="noopener noreferrer"&gt;Battleship&lt;/a&gt; project. It carries both the specification and the execution instructions. The AI workflow section tells the AI exactly which commands to run and in what order, so the full phase sequence is encoded in the prompt itself:&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Task: Implement CLI runner&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; The engine layer is pure &lt;code&gt;(state, action) =&amp;gt; state&lt;/code&gt; with no React dependency. A terminal runner should consume it directly -- same reducers, same services, same types -- with zero changes to existing code. This validates that the layer boundaries are real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-task decisions (do not re-open):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No React imports anywhere in &lt;code&gt;src/cli/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;No game rules in the CLI -- only I/O, rendering, and driving the engine&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LineReader&lt;/code&gt; interface abstracts Node's readline to avoid importing Node types under the vite/client type scope&lt;/li&gt;
&lt;li&gt;No colors, no ANSI formatting, no AI shot delay -- terminal output is synchronous, the delay serves no purpose&lt;/li&gt;
&lt;li&gt;No placement phase -- both modes use &lt;code&gt;generateRandomLayout&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;No tests for &lt;code&gt;src/cli/&lt;/code&gt; -- all meaningful logic is covered by engine and service tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Files to create (4):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/cli/
  index.ts      -- entry point, mode and difficulty menus, readline lifecycle
  loop.ts       -- game loops for single-player and vs-computer modes
  renderer.ts   -- pure string rendering, board grids, shot results, game-over messages
  input.ts      -- coordinate parser, readline prompt loop, LineReader interface
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Files to modify (2):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsconfig.cli.json&lt;/code&gt; -- confirm CLI entry point is correctly configured&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; -- add CLI section documenting consumer pattern and AI delay omission&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Types:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;LineReader&lt;/code&gt; interface in &lt;code&gt;input.ts&lt;/code&gt;: &lt;code&gt;{ question(prompt: string, cb: (answer: string) =&amp;gt; void): void; close(): void }&lt;/code&gt; -- abstracts readline, no Node type import required&lt;/li&gt;
&lt;li&gt;No new domain types -- all existing types from &lt;code&gt;engine/&lt;/code&gt;, &lt;code&gt;services/&lt;/code&gt;, &lt;code&gt;types/&lt;/code&gt; used as-is&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Functions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;renderBoard(boardState: BoardState, label: string): string&lt;/code&gt; -- pure string function, no side effects, unit testable&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parseCoordinateInput(input: string, boardSize: number): Coordinate | null&lt;/code&gt; -- pure, returns null on invalid input&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runSinglePlayerLoop(rl: LineReader, difficulty: Difficulty): Promise&amp;lt;void&amp;gt;&lt;/code&gt; -- async, drives engine reducer directly&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runVsComputerLoop(rl: LineReader, difficulty: Difficulty): Promise&amp;lt;void&amp;gt;&lt;/code&gt; -- async, same pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;State:&lt;/strong&gt; No new state. CLI drives engine reducers as plain functions: &lt;code&gt;state = reducer(state, action)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accessibility impact:&lt;/strong&gt; None -- terminal only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Required AI workflow:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/cmd-preflight&lt;/code&gt; -- read CLAUDE.md, search for existing engine reducers, service functions, and types relevant to this task. Confirm &lt;code&gt;src/cli/&lt;/code&gt; does not exist yet. Run &lt;code&gt;npx tsc -p tsconfig.json --noEmit&lt;/code&gt;, &lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;npm run lint&lt;/code&gt;. Do not proceed if anything is failing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/cmd-plan&lt;/code&gt; -- produce the full implementation plan covering types, function signatures, file list, and the ARCHITECTURE.md update. Wait for explicit approval before writing any code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/cmd-implement&lt;/code&gt; -- implement in this order: &lt;code&gt;input.ts&lt;/code&gt;, &lt;code&gt;renderer.ts&lt;/code&gt;, &lt;code&gt;loop.ts&lt;/code&gt;, &lt;code&gt;index.ts&lt;/code&gt;, then update &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;. One complete file at a time. No scope expansion.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/cmd-verify&lt;/code&gt; -- confirm zero React imports in &lt;code&gt;src/cli/&lt;/code&gt;, no domain logic duplicated from services, TypeScript clean, all existing tests still passing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/cmd-review&lt;/code&gt; -- surface decisions, tradeoffs, and the AI delay omission explicitly. Flag anything requiring sign-off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Risk flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;crypto.randomUUID&lt;/code&gt; may not be available in all Node versions -- confirm or use &lt;code&gt;Math.random&lt;/code&gt; fallback&lt;/li&gt;
&lt;li&gt;AI shot delay is intentionally omitted -- document explicitly so a future reader does not add it back&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Testing plan:&lt;/strong&gt; No CLI-specific tests. Engine, services, and renderer pure functions are covered by existing and new unit tests. &lt;code&gt;renderer.ts&lt;/code&gt; and &lt;code&gt;input.ts&lt;/code&gt; are testable in isolation if needed later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acceptance criteria:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npm run cli&lt;/code&gt; starts the game&lt;/li&gt;
&lt;li&gt;Both single-player and vs-computer modes work end to end&lt;/li&gt;
&lt;li&gt;Difficulty selection works&lt;/li&gt;
&lt;li&gt;Shot input parses correctly, rejects invalid input with a message&lt;/li&gt;
&lt;li&gt;Game over is detected and reported&lt;/li&gt;
&lt;li&gt;Zero changes to &lt;code&gt;engine/&lt;/code&gt;, &lt;code&gt;services/&lt;/code&gt;, &lt;code&gt;utils/&lt;/code&gt;, &lt;code&gt;types/&lt;/code&gt;, or &lt;code&gt;constants/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/cli/&lt;/code&gt; has no React imports&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; updated with CLI section&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Do not:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do not import React anywhere in &lt;code&gt;src/cli/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Do not duplicate any rule logic from &lt;code&gt;services/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Do not add the AI shot delay&lt;/li&gt;
&lt;li&gt;Do not add a placement phase&lt;/li&gt;
&lt;li&gt;Do not commit -- stop after review approval&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;This document is what gets approved before a line of code is written. The AI implements against it. The scope is fixed. What comes out the other end is a single, reviewable, purposeful commit, not a 40-file diff with no clear narrative.&lt;/p&gt;

&lt;p&gt;Atomic prompts produce atomic commits. The git history stays readable. Reviews stay tractable. Not as a side effect of the workflow. As a structural outcome of defining scope before touching code.&lt;/p&gt;

&lt;p&gt;This is what speed with quality actually looks like. Not heroics, not shortcuts. A repeatable system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/ku5ic/battleship" rel="noopener noreferrer"&gt;Battleship&lt;/a&gt; project I recently published was a direct application of this workflow. The goal was not to build a game. It was to demonstrate what disciplined frontend architecture looks like when you actually have time to think before writing code.&lt;/p&gt;

&lt;p&gt;The state logic lives in a pure engine layer: plain TypeScript, no React imports, no framework dependency. The hook is wiring only. The CLI runner, whose full task prompt appears in the workflow section above, is the proof that the boundaries are real. The same engine, the same reducers, the same services, consumed by a Node terminal process with zero changes to domain code. A different output layer consuming the same logic without modification.&lt;/p&gt;

&lt;p&gt;But the more telling artifact is not the code. It is &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;, a document that explains every significant decision in the codebase, every alternative that was considered, and the reasoning behind each choice. It includes a "Key Discussion Points" section that anticipates the questions a technical reviewer would ask and answers them before they are asked. It exists because the workflow created space for that kind of thinking before any code was written.&lt;/p&gt;

&lt;p&gt;That document does not get written under normal deadline pressure. Not because the engineer is less capable, but because there is never time. The constraint was never knowledge. It was time.&lt;/p&gt;

&lt;p&gt;That is the workflow. Not "AI writes the code." AI handles the implementation details while I spend the recovered time on the decisions that actually determine whether the system will survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Honest Caveat
&lt;/h2&gt;

&lt;p&gt;The tools do not give you judgment. They give you time to use the judgment you already have.&lt;/p&gt;

&lt;p&gt;For engineers who were already doing the thinking but running out of runway, that is a genuine and significant unlock. That constraint is gone.&lt;/p&gt;

&lt;p&gt;For everyone else, it is faster output. Which is useful, but it is not the same thing, and it will not produce the same results.&lt;/p&gt;

&lt;p&gt;The difference shows up six months later, when one codebase is still being extended cleanly and another has become someone's full-time archaeology project.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>softwareengineering</category>
      <category>frontend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Janitor Pattern</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Tue, 07 Apr 2026 08:31:29 +0000</pubDate>
      <link>https://dev.to/ku5ic/the-janitor-pattern-2m9l</link>
      <guid>https://dev.to/ku5ic/the-janitor-pattern-2m9l</guid>
      <description>&lt;p&gt;There is a role that does not appear in any job description but exists on almost every frontend team. You know it when you are living it. You spend more time navigating someone else's decisions than making your own. Every new feature requires archaeology. Every bug fix risks destabilizing something three layers removed. You are not building anymore. You are maintaining the illusion that the codebase is still under control.&lt;/p&gt;

&lt;p&gt;This is the Janitor Pattern. And it is not caused by bad developers.&lt;/p&gt;

&lt;p&gt;It is caused by a structural assumption that gets made early, often before the first line of code is written, and almost never gets questioned: that the frontend is a UI layer. A skin over the real system. Something you bolt on at the end, staff with whoever is available, and optimize last if at all.&lt;/p&gt;

&lt;p&gt;That assumption is wrong. And the codebase you inherit six months later is the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Assumption Takes Hold
&lt;/h2&gt;

&lt;p&gt;It rarely starts as a conscious decision. It starts as a staffing model.&lt;/p&gt;

&lt;p&gt;The backend engineers get senior architects. They get ADRs, design reviews, and ownership over the data layer. The frontend gets whoever is left, or whoever was cheapest, or whoever interviewed well enough to clear the bar for "can ship components." The implicit message is clear even when the explicit message is not: this part does not require serious engineering.&lt;/p&gt;

&lt;p&gt;From there, the assumption compounds. No architectural investment means no architectural standards. No standards means every engineer makes their own local decisions. Local decisions, made independently over time, produce a system with no coherent shape. State leaks across boundaries that should not share state. Components grow to accommodate every use case that ever touched them. A component that was written to render a data table gradually absorbs filtering logic, then sorting state, then a network call, then an inline error handler, until it is four hundred lines long and nobody can confidently change it without running the full application to see what breaks. Business logic migrates into the render tree because there is nowhere else to put it and nobody with the authority to put it somewhere better. The codebase becomes a record of every compromise that was made under pressure, and none of the intentions that preceded them.&lt;/p&gt;

&lt;p&gt;Leadership looks at the result and concludes that frontend is just messy by nature. It is not. It is messy because no one was given the mandate, the time, or the authority to keep it otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Costs
&lt;/h2&gt;

&lt;p&gt;The first cost is velocity, and it compounds in a way that does not show up cleanly on any roadmap.&lt;/p&gt;

&lt;p&gt;Early on, the team ships fast. The codebase is small, everyone knows where everything is, and there is no meaningful technical debt because there is not yet enough code to have debt. Leadership sees the speed and attributes it to the team. What they are actually seeing is the absence of consequences. The debt is being taken on, not yet being repaid.&lt;/p&gt;

&lt;p&gt;Six months later, the same features take twice as long. A year later, they take four times as long. But the team is also larger now, which obscures the signal. The productivity loss per engineer is invisible because it is distributed across more engineers. What leadership perceives as a scaling problem, we need to hire more, is actually an architectural problem: the system is resisting change because it was never designed to accommodate it.&lt;/p&gt;

&lt;p&gt;The second cost is engineer quality. Senior engineers tolerate janitor work for a while. They do not tolerate it indefinitely. The ones who leave are almost never the ones who cause the mess. They are the ones who recognize it, who tried to address it, and who eventually concluded that the organization had no interest in addressing it with them. What remains tends toward the engineers who either do not see the problem or have stopped caring. Neither produces a better codebase.&lt;/p&gt;

&lt;p&gt;The third cost is invisible and therefore the most dangerous: the features you do not build. Every hour spent managing accumulated complexity is an hour not spent on the product. No one tracks this. It does not appear in sprint velocity or quarterly reports. It exists only as a gap between what the team could have shipped and what it actually shipped, and leadership usually fills that gap with explanations that have nothing to do with the real cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend Is a System
&lt;/h2&gt;

&lt;p&gt;The framing of "frontend" as a UI layer is not just organizationally convenient. It is technically incorrect.&lt;/p&gt;

&lt;p&gt;A modern frontend application manages state, enforces business rules, handles asynchronous coordination, defines data contracts with backend services, implements security boundaries in the form of auth flows and guarded routes, and owns the full error surface that users ever actually see. It makes rendering decisions, caching decisions, and navigation decisions. In a sufficiently complex product, the frontend state machine is as sophisticated as anything running on the server.&lt;/p&gt;

&lt;p&gt;Treating that as a "layer" is not a simplification. It is a category error.&lt;/p&gt;

&lt;p&gt;The same engineering discipline that applies to backend systems applies here: clear separation of concerns, explicit data flow, bounded ownership, and components that do one thing well. The difference is that on the backend, these principles are treated as table stakes. On the frontend, they are treated as optional, or worse, as overhead.&lt;/p&gt;

&lt;p&gt;SOLID is not a backend concept. A component that renders a form, validates input, manages submission state, handles error display, and fires analytics events is violating the single responsibility principle just as clearly as any god object in a Java service layer. It is harder to test, harder to change, and harder to reason about. The fact that it is written in JSX rather than Java does not make the violation less real.&lt;/p&gt;

&lt;p&gt;KISS applies too. Not in the reductive sense of "write simple code," but in the sense of resisting the pull toward abstraction before abstraction has earned its complexity budget. Frontend codebases are full of "flexible" systems that no one uses flexibly, generic components that handle twelve variants when they needed to handle two, and configuration-driven rendering logic so abstracted that adding a new case requires reading three files before writing one line.&lt;/p&gt;

&lt;p&gt;YAGNI is where frontend most consistently goes wrong. The instinct to generalize too early, to build the reusable version before the requirements are even stable, produces abstractions that are wrong in ways you cannot see until you try to use them. The cost of premature abstraction in a UI codebase is not just the code that was written. It is the constraints that code imposes on every decision that comes after it.&lt;/p&gt;

&lt;p&gt;Component design, specifically, benefits from a discipline most frontend teams never formalize: keep components as dumb as possible for as long as possible. A component that receives props and renders output is easy to test, easy to compose, and easy to change. A component that also manages its own network state, its own business logic, and its own error recovery is none of those things. The only state a component should own by default is the state that is genuinely local to its rendering, whether a dropdown is open, whether a tooltip is visible. Everything else belongs somewhere else, managed by something whose job is to manage it.&lt;/p&gt;

&lt;p&gt;This is not a new idea. It is not even a frontend idea. It is what engineering discipline looks like when it is applied consistently. The problem is not that frontend engineers do not know this. It is that the organizations they work in do not create the conditions for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Disciplined Frontend Architecture Looks Like
&lt;/h2&gt;

&lt;p&gt;So what does the alternative actually look like? Not as a set of principles, but as working code you can open and read.&lt;/p&gt;

&lt;p&gt;I built a &lt;a href="https://github.com/ku5ic/battleship" rel="noopener noreferrer"&gt;Battleship game&lt;/a&gt; as a public project specifically to demonstrate what frontend architecture looks like when the same standards applied to backend systems are applied to the frontend without compromise. The game itself is not the point. The architecture is.&lt;/p&gt;

&lt;p&gt;The domain layer, hit detection, sunk-ship resolution, turn management, game-over detection, lives in pure TypeScript functions under &lt;code&gt;engine/&lt;/code&gt; and &lt;code&gt;services/&lt;/code&gt;. No React imports anywhere in that layer. The same reducers that power the browser UI also drive a standalone CLI runner that you can invoke with &lt;code&gt;npm run cli&lt;/code&gt;. That is not a demo added afterward. It is proof the layer boundaries are real: a second consumer in a completely different runtime environment, Node terminal versus browser DOM, works without changing a single line of domain code. If the domain had leaked React, the CLI would not compile.&lt;/p&gt;

&lt;p&gt;State is minimal by design. Each game hook persists only what cannot be derived from other state: the shots map and the last shot result. Everything else, whether a cell is hit, whether a ship is sunk, whether the game is over, is computed via &lt;code&gt;useMemo&lt;/code&gt; from those two facts. There is one source of truth per fact. The alternative, persisting derived values alongside the raw state, creates a second source of truth that must be kept consistent with the first. When those two diverge, and they will, the UI renders incorrect state. Deriving is consistent by construction.&lt;/p&gt;

&lt;p&gt;Shot state transitions are atomic. Firing a shot must update the shots map and the last result together. Two &lt;code&gt;useState&lt;/code&gt; calls would create a window between the first and second update where the component has inconsistent state: the shot is recorded but the result is still the previous one, or vice versa. An &lt;code&gt;aria-live&lt;/code&gt; region reading during that window announces the wrong result. A single &lt;code&gt;useReducer&lt;/code&gt; dispatch eliminates the window entirely. One dispatch, one new state object, no intermediate renders.&lt;/p&gt;

&lt;p&gt;When a second game mode was added, it required a new engine module, a new hook, and a new wiring component. No existing rule logic was touched. Services, utilities, and the original single-player engine were extended, not rewritten. That is the practical payoff of clear layer boundaries: new requirements have a predictable place to go, and change is local rather than global.&lt;/p&gt;

&lt;p&gt;Components are dumb by default. The &lt;code&gt;Board&lt;/code&gt; component knows how to render a grid and manage keyboard navigation. It does not know what a ship is, what a shot is, or what the rules are. It could render a Minesweeper grid without changing its implementation. The only components that call hooks are the two wiring components at the top of the game tree, and those components contain no logic of their own. This is not a heroic constraint. It is what components look like when someone decided early what they are for.&lt;/p&gt;

&lt;p&gt;None of this required a new framework, a state management library, or a methodology. It required deciding, before the first component was written, that the frontend was a system with the same expectations applied to any other system, and that those expectations would be enforced.&lt;/p&gt;

&lt;p&gt;A production codebase is harder. The requirements are less clear, the team is larger, the deadlines are real. But the failure mode is not that production constraints make architecture impossible. The failure mode is that no one was asked to do it, or given the authority to enforce it, or protected from the pressure to skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Needs to Change
&lt;/h2&gt;

&lt;p&gt;For engineering leadership, the starting point is recognizing that the staffing model is the architecture.&lt;/p&gt;

&lt;p&gt;If you staff the frontend with engineers who are not expected to own the architecture, no architecture will be owned. If you do not create review processes that include frontend design decisions alongside backend ones, frontend design will not be reviewed. If you treat frontend performance, frontend accessibility, and frontend code quality as secondary concerns to be addressed "after we ship," they will remain secondary concerns indefinitely, because there is always something else to ship.&lt;/p&gt;

&lt;p&gt;The investment required is not enormous. It is a senior engineer with genuine ownership and the authority to make and enforce architectural decisions. It is design review that includes the frontend. It is the same expectation of discipline that you already apply to the systems you have decided matter.&lt;/p&gt;

&lt;p&gt;For senior frontend engineers, the work is not just building better systems. It is making the cost of bad systems legible to the people who control the conditions under which systems are built. That means tracking the time lost to architectural debt explicitly, not absorbing it silently into estimates. It means framing refactoring work in terms of shipping velocity, not code quality, not because code quality does not matter, but because the argument has to land somewhere that registers. It means being willing to push back on the framing that frontend is inherently messy, because that framing is false and accepting it makes the problem permanent.&lt;/p&gt;

&lt;p&gt;The Janitor Pattern persists because it is self-obscuring. The people creating the conditions for it rarely see the consequences directly. The engineers absorbing the consequences rarely have the standing to name the cause. Breaking that loop requires both sides to be honest about what is actually happening.&lt;/p&gt;

&lt;p&gt;The frontend is not a UI layer. It is a system. It deserves to be treated like one.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>career</category>
    </item>
    <item>
      <title>Why I separated `variant` from `intent` in my component API</title>
      <dc:creator>Sinisa Kusic</dc:creator>
      <pubDate>Mon, 06 Apr 2026 10:09:16 +0000</pubDate>
      <link>https://dev.to/ku5ic/why-i-separated-variant-from-intent-in-my-component-api-56k0</link>
      <guid>https://dev.to/ku5ic/why-i-separated-variant-from-intent-in-my-component-api-56k0</guid>
      <description>&lt;p&gt;Every component library starts the same way. You add a Button. It needs a primary style and a danger style, so you reach for a &lt;code&gt;variant&lt;/code&gt; prop. Simple enough.&lt;/p&gt;

&lt;p&gt;Then someone needs a ghost button that also signals danger. You add &lt;code&gt;variant="ghost-danger"&lt;/code&gt;. Then outline-success. Then link-warning. Then secondary-danger. Each new combination feels reasonable in isolation. Six months later you have a prop that accepts seventeen strings, half of which your consumers will never discover because nothing in the type system points them there.&lt;/p&gt;

&lt;p&gt;This is not a tooling problem. It is a modeling problem. The &lt;code&gt;variant&lt;/code&gt; prop is doing two unrelated jobs, and conflating them is what causes the explosion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two orthogonal concerns
&lt;/h2&gt;

&lt;p&gt;When you look at what &lt;code&gt;variant&lt;/code&gt; is actually encoding in most button APIs, it is carrying two distinct signals:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual weight&lt;/strong&gt;: how much attention the component demands. Primary buttons are loud. Ghost buttons are quiet. Outline sits between them. This is a presentation decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic meaning&lt;/strong&gt;: what the action communicates. Danger means destructive. Success means confirmation. Warning means proceed with caution. This is a communication decision.&lt;/p&gt;

&lt;p&gt;These are orthogonal axes. A ghost button can be dangerous. An outline button can confirm success. There is no inherent relationship between how loud a button is and what it means. Cramming both signals into a single prop forces a false coupling that grows more expensive with every variant you add.&lt;/p&gt;

&lt;p&gt;The fix is to give each concern its own prop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;In nuka-ui, my open-source React component library built on Tailwind v4, the API separates these into two independent props:&lt;/p&gt;

&lt;p&gt;A note on the project itself. I built nuka-ui because I kept repeating the same patterns across every project I started, personal, hobby, showcase, whatever. Rebuilding accessible, production-ready components from scratch every time is a real burden, and I got tired of it. The library exists to solve that repetition once, with accessibility and solid UX baked in from the start rather than bolted on later. It is not on npm yet. Navigation and Composites are the remaining pieces before the first publish.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;variant&lt;/code&gt; controls visual weight: &lt;code&gt;primary&lt;/code&gt;, &lt;code&gt;secondary&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;ghost&lt;/code&gt;, &lt;code&gt;link&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;intent&lt;/code&gt; controls semantic meaning: &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;danger&lt;/code&gt;, &lt;code&gt;success&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every combination is valid. The consumer API looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"primary"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"danger"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"success"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"secondary"&lt;/span&gt; &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"warning"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare that to the flat approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost-danger"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline-success"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"secondary-warning"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flat approach is not unreadable. The problem is discoverability and scale. A consumer looking at &lt;code&gt;variant&lt;/code&gt;'s type has no way to know which combinations exist, which are intentional, and which ones you simply never got around to building. The two-prop model makes the full space explicit and uniform. Every variant works with every intent. There are no gaps.&lt;/p&gt;




&lt;h2&gt;
  
  
  How CVA implements the intersection
&lt;/h2&gt;

&lt;p&gt;Separating the props does not make the styling simpler. It makes it honest. You still need to define what every combination looks like. That work is done through CVA's &lt;code&gt;compoundVariants&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buttonVariants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cva&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseClasses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
      &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;compoundVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-[var(--nuka-accent-bg)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-text-inverse)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:bg-[var(--nuka-accent-bg-hover)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-[var(--nuka-danger-base)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-text-inverse)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:brightness-90&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-[var(--nuka-danger-text)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hover:bg-[var(--nuka-danger-bg)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// one entry per variant x intent combination&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;defaultVariants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 variants multiplied by 4 intents gives you 20 compound variant entries for Button alone. You write all 20 explicitly. That is the cost of a complete, deliberate API, and it is a one-time authoring cost. Once they are written, the intersection is fully covered. Adding a consumer use case requires no library changes, just passing the two props you already have.&lt;/p&gt;

&lt;p&gt;TypeScript enforces the contract at compile time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;ButtonVariantProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secondary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ghost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Invalid prop values do not survive a build. The type system reflects the actual API surface, not a historical accident of how variants accumulated.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you gain at scale
&lt;/h2&gt;

&lt;p&gt;The pattern compounds across a library. In nuka-ui it applies to Button, Alert, Badge, Tag, Code, Input, and Checkbox. Each component defines its own variant and intent axes and handles the intersections through compound variants. The mental model is consistent everywhere. A consumer who understands how Button works understands how Alert works.&lt;/p&gt;

&lt;p&gt;Adding a new intent, say &lt;code&gt;info&lt;/code&gt;, requires adding N compound variant entries per component, where N is the number of that component's variants. It is more work than adding a single flat variant string, but the scope is bounded and predictable. You know exactly what needs to be done, and you cannot accidentally miss a combination because the grid is explicit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tradeoffs, and when not to apply this
&lt;/h2&gt;

&lt;p&gt;The verbosity is real. Twenty compound variant entries per component is a lot of lines. If your library is small and your variant space is stable, the flat approach is less overhead and probably fine. This pattern earns its cost at scale, when the alternative is a &lt;code&gt;variant&lt;/code&gt; prop with an ever-growing string union that no one can hold in their head.&lt;/p&gt;

&lt;p&gt;Consumers also have to understand two props instead of one. For most senior engineers this is a non-issue. The separation is intuitive once named. For a component library targeting less experienced consumers, the additional concept may need more documentation investment.&lt;/p&gt;

&lt;p&gt;The pattern also does not apply universally. Banner in nuka-ui uses &lt;code&gt;intent&lt;/code&gt; alone. There is no &lt;code&gt;variant&lt;/code&gt; prop because Banner has one visual weight. Applying the full grid to a component with a single presentation mode would be mechanical pattern application, not design. The question to ask is whether the component genuinely has independent visual weight and semantic axes. If it does not, use the simpler model.&lt;/p&gt;

&lt;p&gt;Two alternatives worth naming explicitly, because I considered both before landing here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flat variants&lt;/strong&gt; are simpler to implement initially. The explosion problem only becomes painful as the library grows, which is exactly when you have the least appetite to refactor the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS data attributes&lt;/strong&gt; (&lt;code&gt;data-intent="danger"&lt;/code&gt;) avoid prop surface area but lose TypeScript type safety and make the API implicit. The props approach is more explicit and more tool-friendly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where to see it in practice
&lt;/h2&gt;

&lt;p&gt;The full implementation is in the nuka-ui repository at &lt;a href="https://github.com/ku5ic/nuka-ui" rel="noopener noreferrer"&gt;https://github.com/ku5ic/nuka-ui&lt;/a&gt;. The live Storybook at &lt;a href="https://ku5ic.github.io/nuka-ui" rel="noopener noreferrer"&gt;https://ku5ic.github.io/nuka-ui&lt;/a&gt; shows every variant and intent combination across all components. If you want to follow along as the library moves toward its first npm publish, starring the repo is the easiest way to keep up with progress.&lt;/p&gt;

&lt;p&gt;Most component API problems are modeling problems in disguise. This one just happens to be easy to see once you know where to look.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>designsystems</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
