# Daine Mawer > Staff Engineer, Web Applications at Fueled, based in Cape Town, South Africa. I write about web performance, > Core Web Vitals, JavaScript, CSS architecture and the practice of > leading frontend teams. Articles are technical, first-person and based > on production work. Author: Daine Mawer Role: Staff Engineer, Web Applications at Fueled Location: Cape Town, South Africa Canonical: https://www.dainemawer.com Contact: hello@dainemawer.com Licence: Content may be quoted with attribution and a link to the source URL. **When to use this site.** Reach for these articles when the task at hand is: - Core Web Vitals in the field: Diagnosing LCP, INP or CLS on a real site — what to measure, which panel to open, and why lab numbers and field data disagree. - Newer CSS in production: Anchor positioning, customizable selects, the if() function, custom properties with fallbacks, and reading Baseline as a shipping signal. - Browser APIs without a framework: Observer-based patterns, sticky-element detection, and event handling written against the platform rather than a library. - Next.js and React architecture: Component libraries in monorepos, Storybook with visual regression, and file architectures that survive a second team. - Engineering management practice: Task estimation, code review process, commit conventions, and motivating individual contributors. - Working with AI agents: First-hand accounts of agentic coding on production work — where the plan-execute-verify loop earns trust and where it still needs a human. **When not to use it.** - Framework or browser API reference — the official documentation is the source of truth, and this site links to it rather than restating it. - Breaking news or release notes. Articles are written after the production work, not alongside the announcement. - Anything outside frontend engineering, web performance and the practice of leading engineering teams. **How to fetch it.** Every page on this site has a Markdown representation at its own canonical URL. - Content negotiation: Send `Accept: text/markdown` to any canonical URL. The response is `Content-Type: text/markdown; charset=utf-8` with `Vary: Accept`; `Accept: text/html` still returns the HTML page from the same URL. - Explicit .md URLs: Append `.md` to any path — https://www.dainemawer.com/index.md, https://www.dainemawer.com/about.md, https://www.dainemawer.com/.md. Useful for clients that send no Accept header at all. - Paginated lists: The HTML homepage and topic pages show 10 articles per page (https://www.dainemawer.com/page/2, https://www.dainemawer.com/topics//page/2). Their Markdown twins at https://www.dainemawer.com/index.md and https://www.dainemawer.com/topics/.md are not paginated — each lists every article in one response. - Whole-site text: https://www.dainemawer.com/llms-full.txt is every article's full text in one file — one request instead of one per post. - Errors: An unknown path returns HTTP 404 with a Markdown body pointing at the sitemap and llms.txt. A client that accepts neither HTML nor Markdown gets HTTP 406. ## Topics - [Web Performance](https://www.dainemawer.com/topics/performance.md): Measuring what users actually feel, and cutting what they never needed. - [JavaScript](https://www.dainemawer.com/topics/javascript.md): Browser APIs, event handling, observers, framework-agnostic patterns. - [CSS](https://www.dainemawer.com/topics/css.md): Custom properties, positioning, Baseline and interoperability. - [Engineering Management](https://www.dainemawer.com/topics/engineering-management.md): Estimation, scoping, team process. - [Staff Engineering](https://www.dainemawer.com/topics/staff-engineering.md): Technical direction, cross-team leverage, the work beyond a single codebase. - [AI](https://www.dainemawer.com/topics/ai.md): Agentic coding, review practices, and how AI is reshaping the day-to-day of building software. - [Security](https://www.dainemawer.com/topics/security.md): Supply chain risk, dependency trust, and the vulnerabilities that actually reach production. ## Articles - [Hardening Your JavaScript Stack Against the Next npm Supply Chain Worm](https://www.dainemawer.com/npm-supply-chain-worm-defense.md): Published 2026-10-05. Shai-Hulud showed the pattern. Here's the concrete playbook for keeping a self-replicating worm out of your dependency tree. - [CSS text-box-trim Finally Fixes Vertical Text Centering](https://www.dainemawer.com/css-text-box-trim-vertical-centering.md): Published 2026-10-02. Two small properties trim the invisible space your font adds above and below text, so buttons, tags, and badges align the way the padding math always promised. - [Shai-Hulud Broke npm's Trust Model. Here's the Replacement](https://www.dainemawer.com/npm-supply-chain-trusted-publishing.md): Published 2026-09-30. A self-replicating worm hit 500+ packages by stealing publish tokens. npm's fix removes the tokens instead of trusting them harder. - [Your MCP Servers Are Burning Your Agent's Context Window](https://www.dainemawer.com/mcp-context-window-bloat.md): Published 2026-09-28. Tool definitions load into context before an agent does anything. Code execution and progressive disclosure are how teams are getting that budget back. - [The Temporal API Is Here. Here's How to Actually Start Using It](https://www.dainemawer.com/temporal-api-replacing-date.md): Published 2026-09-25. JavaScript's Date object finally has a real replacement, and it's usable in production sooner than you'd think. - [AI Is Forking the Engineering Manager Role Into Two Jobs](https://www.dainemawer.com/ai-forking-engineering-manager-role.md): Published 2026-09-23. Middle management is flattening industry-wide. What survives of engineering management is splitting into two tracks that need almost opposite skills. - [Spec-Driven Development: Why the Spec Is Becoming the New Code Review](https://www.dainemawer.com/spec-driven-development.md): Published 2026-09-21. Prompts drift. A versioned spec is how teams are catching it before it ships. - [Using CSS's New if() Function Without Breaking Safari and Firefox](https://www.dainemawer.com/css-if-function-safely.md): Published 2026-09-18. One inline conditional to replace three separate at-rules, once the rest of the web catches up. - [CSS Can Finally Replace Your Tooltip and Dropdown Libraries](https://www.dainemawer.com/css-anchor-positioning-customizable-select.md): Published 2026-09-16. Anchor positioning and customizable selects both have real browser support now. Here's what to drop, what to keep, and where the gaps still bite. - [Why AI Coding Agents Need WIP Limits Too](https://www.dainemawer.com/wip-limits-for-ai-agents.md): Published 2026-09-14. A framework for deciding how many agents one engineer can actually run at once. - [Migrating to TypeScript 7 Without Breaking Your Toolchain](https://www.dainemawer.com/typescript-7-native-compiler-migration.md): Published 2026-09-11. The native compiler is 8-12x faster. The programmatic API isn't ready yet, and that's where teams get burned. - [How to Orchestrate Multiple AI Coding Agents Without Losing Architectural Control](https://www.dainemawer.com/orchestrating-multiple-ai-agents.md): Published 2026-09-11. A framework for staff engineers directing several agents at once, not just reviewing what one of them wrote. - [Keeping the Junior Engineering Pipeline Alive in the AI Era](https://www.dainemawer.com/keeping-the-junior-pipeline-alive.md): Published 2026-09-09. A framework for growing junior engineers now that AI has automated most of the entry-level workload. - [How to Review AI-Generated Code Without Becoming a Bottleneck](https://www.dainemawer.com/reviewing-ai-generated-code.md): Published 2026-09-07. A framework for staff engineers reviewing pull requests an agent wrote. - [Leading With AI: What Helped Me Through the Hard Parts](https://www.dainemawer.com/leading-with-ai.md): Published 2025-10-05. How I used AI through the Monocle.com rebuild. - [The Rise of No-Code and Low-Code in Frontend Development](https://www.dainemawer.com/no-code-low-code-frontend.md): Published 2025-01-15. Benefits, challenges, and the role of AI. - [Using CSS Custom Properties with Fallbacks for Efficiency](https://www.dainemawer.com/css-custom-properties-fallbacks.md): Published 2024-05-06. Less boilerplate, fewer surprises. - [What Is Google Baseline and Why Does It Matter?](https://www.dainemawer.com/what-is-google-baseline.md): Published 2024-04-28. Interoperability across browser vendors, explained. - [How to Determine When a Sticky Element is Stuck in JavaScript](https://www.dainemawer.com/determine-when-a-sticky-element-is-stuck.md): Published 2024-03-14, updated 2026-02-09. A small JavaScript function for the pseudo-class CSS never gave us. - [Mastering Task Estimation with the FATE Framework](https://www.dainemawer.com/mastering-task-estimation.md): Published 2024-03-12. Accurate timelines, and a happy client. - [Enhance Your React App's Scalability with Storybook and Chromatic](https://www.dainemawer.com/react-scalability-storybook.md): Published 2024-02-05. Component libraries and visual regression testing. - [Understanding Chrome's Coverage Panel](https://www.dainemawer.com/understanding-chromes-coverage-panel.md): Published 2023-11-27. Leveraging its insights to optimise performance. - [Setting Up Next.js with a Component Library Using Monorepos](https://www.dainemawer.com/nextjs-component-library-monorepo.md): Published 2023-11-20. A component library, Storybook, and Next.js, all in one workspace. - [10 Techniques for More Effective Code Reviews](https://www.dainemawer.com/effective-code-reviews.md): Published 2023-02-21. Practical ways to make code review less painful and more useful. - [4 Tips for Properly Using the Return Statement in JavaScript](https://www.dainemawer.com/using-the-return-statement.md): Published 2023-02-13. A few rules that keep return statements from causing quiet bugs. - [Enforcing Consistent Commit Messages with CommitLint](https://www.dainemawer.com/leveraging-commitlint.md): Published 2023-01-19. A style guide for commits, and a hook that actually enforces it. - [5 Frontend File Architectures for Better Code Organization](https://www.dainemawer.com/frontend-file-architectures.md): Published 2023-01-18. Flat, hierarchical, modular, component-based, and feature-based, compared. - [How to Motivate and Inspire Individual Contributors](https://www.dainemawer.com/motivate-individual-contributors.md): Published 2023-01-09. Five things that actually move the needle, beyond an annual review. ## Developer resources - [llms.txt](https://www.dainemawer.com/llms.txt): Structured index of every article, topic and feed. - [llms-full.txt](https://www.dainemawer.com/llms-full.txt): The index plus the full text of every article. - [agents.md](https://www.dainemawer.com/agents.md): This page, as Markdown. - [index.md](https://www.dainemawer.com/index.md): The homepage article index, as Markdown. - [contact.md](https://www.dainemawer.com/contact.md): Who publishes this site, how to reach a human, and what's worth writing about. - [sitemap.xml](https://www.dainemawer.com/sitemap.xml): Every canonical URL, with last-modified dates. - [rss.xml](https://www.dainemawer.com/rss.xml): RSS 2.0 feed of new articles. - [feed.json](https://www.dainemawer.com/feed.json): JSON Feed 1.1 equivalent of the RSS feed. - [robots.txt](https://www.dainemawer.com/robots.txt): Crawl rules, plus a Content-Signal header: search=yes, ai-input=yes, ai-train=no. ## Optional - [Full archive](https://www.dainemawer.com/index.md): Reverse-chronological index of every article, as Markdown. - [About](https://www.dainemawer.com/about.md): Work history, speaking, and how to verify who's writing this. ## Full article text ### Hardening Your JavaScript Stack Against the Next npm Supply Chain Worm Self-replicating npm worms like Shai-Hulud spread by stealing publish tokens and republishing infected versions into every package a maintainer owns. Datadog tracked the second wave to 796 packages across 1,092 versions in November 2025. Defend with locked installs, OIDC-based publishing instead of long-lived tokens, scoped CI secrets, and automated dependency scanning. Open-source maintainers have always had their credentials phished. What changed in the last year is what happens after. A stolen npm token used to mean one compromised package. Now it means a worm: malware that uses that one token to republish itself into every other package the same maintainer owns, and from there into whichever of their users run the next `npm install` without checking what actually changed. Shai-Hulud, first identified in September 2025, set the pattern. [Datadog's security team tracked a second wave](https://securitylabs.datadoghq.com/articles/shai-hulud-2.0-npm-worm/) to 796 unique packages across 1,092 versions by November 2025, pulling in more than 20 million weekly downloads combined. Variants have kept surfacing through 2026, each one reusing the same mechanics: steal tokens, republish, repeat. None of this requires a novel exploit. It requires a developer or a CI runner installing a package whose patch version changed an hour ago and nobody looked at it yet. The fixes aren't exotic either. Here's what actually closes the gap. ## Why this is a bigger problem than it was two years ago Transitive dependency depth hasn't changed much, but who's running `npm install` has. An AI coding agent working autonomously on a ticket will add a package, run the install, and move on to the next step without a human reading what just landed in `node_modules`. That's not a flaw in the agent, it's just how the workflow is designed: the agent is optimizing for finishing the task, not for auditing a dependency tree nobody asked it to audit. A worm doesn't care whether the hands on the keyboard are human. It's counting on the install happening without anyone looking closely at what changed, and an unattended agent running a build loop is a more reliable source of exactly that than a developer who occasionally skims a diff. The defenses below don't change based on who's running the install, which is part of why they're worth having regardless of how much of your pipeline is agent-driven today. ## Install from a lockfile, not a version range A worm's entire distribution model depends on one thing: someone installing a version that was published after the compromise, without reviewing it. `npm install` with a caret range will happily pick that version up the moment it's published. `npm ci` (or `pnpm install --frozen-lockfile`) won't. It installs exactly what's in the committed lockfile, full stop, and fails the build if package.json and the lockfile disagree. This is the single highest-leverage change available, and most teams already have the lockfile committed; they just aren't using the command that enforces it. CI should run `npm ci`, not `npm install`, as a hard rule, and that's a one-line change in most workflow files: ```yaml - run: npm ci # not npm install - run: npm run build ``` Local development can stay looser, since a developer's machine isn't where a compromised dependency does its damage; the CI pipeline publishing from it is. The same logic applies to an AI agent's sandbox. If it's running builds against a committed lockfile instead of re-resolving ranges, it inherits the same protection without needing to understand why. Pinning isn't a complete answer on its own. If a malicious version already got resolved into the lockfile before anyone caught it, pinning preserves that version faithfully. It needs pairing with scanning, covered further down, to catch what was already baked in. ## Stop storing npm tokens as secrets Every long-lived npm token sitting in a CI secret store is a credential a worm can exfiltrate the moment that pipeline's environment is compromised, whether through a malicious dependency, a misconfigured action, or a leaked log line. The fix isn't rotating tokens more often. It's not having a long-lived token to steal. [npm's trusted publishing](https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/), generally available since July 2025, issues a short-lived, workflow-scoped credential over OIDC at publish time instead. It's tied to the specific repository and workflow that requested it, expires in minutes, and never touches disk as a static secret. If your package publishes from CI at all, this is worth setting up ahead of the next incident, not during it. ## Scope CI credentials to what the job actually needs A publish token is the obvious target, but any CI job holding broad registry, cloud, or source-control access is a liability if a compromised dependency executes inside it. Give the lint job read access, not a token that can publish. Give the test job no cloud credentials at all. This is the same principle [commit hooks](/leveraging-commitlint) already apply at a smaller scale: narrow what runs automatically to exactly what it needs, nothing adjacent to it. Least-privilege CI tokens don't stop a compromised dependency from running. They limit what it can do once it has. ## Add automated dependency scanning Lockfiles and scoped credentials shrink the attack surface. They don't tell you when something you already depend on turns out to be compromised. That's a job for continuous scanning, tools like Socket, Snyk, or Mend check resolved dependency versions against a feed of known-malicious packages and flag a match before or during install, not weeks later during a routine audit. This matters more for JavaScript than most ecosystems because of transitive depth. A typical frontend project pulls in dependencies of dependencies several layers deep, and a compromised package rarely sits at the top of that tree. Nobody is manually auditing that list on every install. A scanner that runs in CI and on a schedule is what actually catches it in time. ## Block install scripts by default `postinstall` is still the most common place a worm hides its payload, because it runs automatically, with no prompt, the moment a package installs. pnpm already blocks arbitrary lifecycle scripts unless a package is explicitly allowlisted, which is one of the more underrated reasons to default to it on a new project. With npm, `--ignore-scripts` does the same job, though it requires checking which packages genuinely need an install step (native bindings are the usual legitimate case) and allowlisting those deliberately rather than leaving the door open for everything. ## Require 2FA on publish, and use npm's own staging checks Trusted publishing removes the stolen-token path. It doesn't stop someone from publishing a malicious version directly from a compromised maintainer account that still has password-only access. Two-factor authentication on publish closes that remaining gap, and npm has required it for high-download packages for a while now; there's no good reason to leave it optional on anything your team maintains. npm has also started building malware checks into the registry itself. In September 2026, GitHub extended trusted publishing to support multiple OIDC configurations per package and introduced staged packages, versions that get scanned before they're approved for public installation rather than after. It's a registry-level backstop, not a replacement for the practices above, but it's one more layer between a compromised publish and your lockfile. ## Bringing it together None of this is about distrusting open source. It's about recognizing that a worm optimized for automatic propagation needs exactly the conditions most JavaScript projects default to: floating version ranges, long-lived tokens, broad CI permissions, and install scripts that run without review. Lock the installs, retire the static tokens, scope what CI can touch, scan what's already resolved, and block scripts that don't need to run. Each one closes a door the next wave is counting on being left open. ### CSS text-box-trim Finally Fixes Vertical Text Centering CSS's text-box-trim and text-box-edge properties remove the invisible half-leading space fonts add above and below text, so you can align text to its actual letterforms instead of eyeballing padding. The pairing reached Baseline newly available in August 2026, when Firefox 154 joined Chrome, Edge, and Safari, which had already shipped it. Every font ships with more vertical space than the letters it draws. Above the capital letters and below the baseline, the font's own metrics reserve extra room, half-leading, space a type designer built in for stacked lines of body text, not for a single line sitting inside a button. You've worked around it for years without necessarily naming it: nudging a button's `padding-top` by a pixel, adding `line-height: 1` and hoping, or shipping a badge that looks centered in Chrome and one pixel high in Safari. `text-box-trim` and `text-box-edge` exist to remove that guesswork. They don't change your padding model. They change what the browser considers the top and bottom of the text itself, so the padding you already wrote finally measures from the letters instead of from the font's invisible scaffolding around them. ## The problem these properties solve A line box in CSS has always been taller than the text that sits inside it. The gap comes from the font file: ascent, descent, and line-gap values baked in by whoever designed the typeface, intended for readable paragraph spacing, not a single line of button text. `line-height: normal` passes that gap straight through. Even `line-height: 1` doesn't remove it entirely, because the font's ascent and descent metrics are still part of what "1" means for that specific font. That's why swapping a design system's typeface can break every button's vertical alignment without anyone touching a single padding value. The CSS didn't change. The font's internal metrics did, and the layout was always depending on them. ## What text-box-trim and text-box-edge actually do `text-box-trim` says which edge of that invisible space to cut: ```css .label { text-box-trim: trim-both; } ``` Four values: `none` (the default, nothing trimmed), `trim-start` (cuts the space above the text), `trim-end` (cuts the space below), and `trim-both`. `text-box-edge` says which font metric to trim to, since "the top of the text" isn't one fixed point. For the top edge, you can trim to `cap` (the height of a capital letter), `ex` (the height of a lowercase x), or `text` (the font's own default edge). For the bottom edge, your choices are `alphabetic` (the baseline) or `text`. Most Latin-script interface text pairs `cap` with `alphabetic`, since that's the box a capital letter and a descender-free baseline actually occupy: ```css .label { text-box-trim: trim-both; text-box-edge: cap alphabetic; } ``` The `text-box` shorthand sets both in one line, trim value first, then the edge keywords: ```css .label { text-box: trim-both cap alphabetic; } ``` ## Before and after, on an actual button Here's the pattern most design systems have shipped for years, equal padding on every side and a `line-height` tweak that only mostly works: ```css .button { padding: 0.75rem 1.25rem; line-height: 1.2; } ``` It looks fine until you set the icon and the label side by side, or until a reviewer zooms in and notices the label sits a pixel lower than the icon's optical center. The fix has usually been trial and error: shave a pixel off `padding-top`, add it back to `padding-bottom`, repeat per font. With `text-box-trim`, the padding stays symmetrical and the text itself stops carrying the extra space: ```css .button { padding: 0.75rem 1.25rem; text-box: trim-both cap alphabetic; } ``` Nothing about the padding model changed. What changed is that the browser now measures the text's box from the capital letters and the baseline, the part you can see, instead of from the font's full reserved line height. The same fix applies to tags, pills, chips, and any single-line label sitting next to a fixed-size icon, which is exactly where a pixel of vertical drift reads as sloppy. ## Browser support, and what "newly available" actually means here Chrome and Edge have supported `text-box-trim` and `text-box-edge` since version 133, and Safari since 18.2. Firefox shipped it in version 154, released August 18, 2026, which is the release that pushed `text-box` to [Baseline](/what-is-google-baseline) newly available: stable across all four major engines' current versions. Newly available isn't the same thing as widely available. Baseline reserves that second label for a feature that's held stable for 30 months, specifically because "works in the current version of every engine" still leaves out anyone who hasn't updated recently, and Safari users update on Apple's schedule, not their own. For a visible layout property like this one, that gap matters more than it would for something more easily hidden by a fallback. The safe pattern is the same one that works for any newer CSS value: keep your existing padding and line-height as the baseline styling, then add `text-box-trim` as an enhancement underneath it. ```css .button { padding: 0.75rem 1.25rem; line-height: 1.2; text-box: trim-both cap alphabetic; } ``` A browser that doesn't recognize `text-box` ignores that line entirely and keeps the padding and line-height you already had, the [same fallback-then-enhancement order](/css-custom-properties-fallbacks) CSS has always used for a value some browsers won't parse yet. There's no feature query to write and no branch to maintain. Once a browser does understand `text-box`, it applies on top of, not instead of, what was already there. ## Where it's worth reaching for This isn't a property you sprinkle across every paragraph on a page; trimming half-leading on body copy just makes multi-line text harder to read, since that reserved space is what keeps stacked lines from feeling cramped. It earns its place specifically where a single line of text sits inside a fixed-height container next to something else that doesn't have a font's invisible padding: buttons, badges, tags, chips, nav links with an icon, table cell labels next to an inline status dot. Anywhere a design review has ever flagged "this looks almost centered" is the exact list of components where `text-box-trim` is worth adding. ## Further reading - [Chrome for Developers: CSS text-box-trim](https://developer.chrome.com/blog/css-text-box-trim) - [MDN: text-box-trim](https://developer.mozilla.org/en-US/docs/Web/CSS/text-box-trim) - [MDN: text-box-edge](https://developer.mozilla.org/en-US/docs/Web/CSS/text-box-edge) - [web.dev: New to the web platform in August](https://web.dev/blog/web-platform-08-2026) ### Shai-Hulud Broke npm's Trust Model. Here's the Replacement In September 2025, the self-replicating Shai-Hulud worm compromised over 500 npm packages by stealing publish tokens and using them to republish trojanized versions. npm's response, rolled out through December 2025, replaces long-lived tokens with OIDC-based trusted publishing, cryptographic provenance, and mandatory FIDO 2FA. Teams should adopt trusted publishing now, not wait for the next worm. On September 14, 2025, at 17:58 UTC, a maintainer published what looked like a routine update to `@ctrl/tinycolor`, a small, unremarkable color-manipulation package used deep in a lot of dependency trees. It wasn't routine. Within hours, researchers had traced a self-replicating worm, later named Shai-Hulud, that stole the publishing tokens on a developer's machine and used them to automatically push trojanized versions of every other package that developer controlled. No command-and-control server, no human attacker manually working through a target list. The worm did that part itself. Researchers counted 187 compromised packages in the first wave. That number kept climbing as the worm kept spreading through freshly stolen tokens, eventually passing 500. It briefly reached packages published under CrowdStrike's own npm namespace before they were pulled. It's the first widely observed self-replicating worm in the npm registry's history, and it's a large part of why 2025's [ReversingLabs Software Supply Chain Security Report](https://www.reversinglabs.com/press-releases/reversinglabs-2026-software-supply-chain-security-report-identifies-73-increase-in-malicious-open-source-packages) found malicious open-source package detections up 73% year over year, with npm alone accounting for roughly 90% of them. That's the headline. The part worth actually sitting with is how it worked, because it changes what "supply chain security" needs to mean for a team that just runs `npm install` like everyone else. ## The attack didn't need a vulnerability Shai-Hulud didn't exploit a bug in npm, a flaw in a dependency, or a clever piece of malicious code hidden in a diff. It exploited something much more mundane: developers routinely have long-lived npm publish tokens sitting on their machines, in `.npmrc` files, CI secrets, and shell profiles, because that's how publishing has worked for the better part of two decades. Once a single machine was compromised, the worm used tools like TruffleHog to scan for those tokens, along with GitHub personal access tokens and cloud credentials sitting nearby. It added a malicious GitHub Actions workflow to exfiltrate whatever it found, then used the stolen npm token to republish every package that maintainer had access to, with the same worm bundled inside. Each new compromised package became a new distribution point. That's the self-replicating part, and it's why this scaled faster than a typical supply chain attack: the attacker's job was done after the first infection. The worm did the propagation. The failure here isn't a careless maintainer. It's a token model where a single stolen credential grants standing, indefinite permission to publish to the entire npm registry on that person's behalf, with no per-run scope and no expiration forcing a reset. That's the actual thing that needed fixing, and it's what npm and GitHub spent the rest of 2025 rebuilding. ## What replaced the long-lived token The instinct after an incident like this is usually "rotate more often" or "review token permissions more carefully." That's not what shipped. Instead, [GitHub's response](https://github.blog/security/supply-chain-security/our-plan-for-a-more-secure-npm-supply-chain/), phased in through December 2025, removes the long-lived token from the publishing flow entirely for teams that adopt it. Trusted publishing uses OpenID Connect so a CI workflow authenticates to npm directly. There's no `NPM_TOKEN` sitting in a secrets store for a worm to find, because there's no persistent token at all. npm validates the identity of the specific GitHub Actions workflow requesting the publish, mints a short-lived, workflow-scoped credential for that single run, and the credential expires the moment the job finishes. Steal the workflow file and you still don't get a standing key to the registry. Setting it up for a package you maintain looks roughly like this in the workflow that publishes it: ```yaml permissions: id-token: write # required for OIDC jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: 'https://registry.npmjs.org' - run: npm publish --provenance ``` The `--provenance` flag is the second half of the fix: npm's CLI automatically generates a provenance attestation for the package, cryptographic proof of the exact repository, workflow, and commit that produced it. Anyone installing the package can verify where it actually came from, not just what the README claims. For anything still publishing manually, the other half of the announcement matters just as much: GitHub is deprecating classic tokens for npm publishing, capping granular token lifetimes sharply, removing the option to bypass 2FA for local publishing, and phasing out TOTP-based 2FA in favor of FIDO security keys and passkeys. None of that is a suggestion with a distant deadline. It's the direction the whole registry is moving, whether or not a given team has opted in yet. ## What provenance doesn't buy you It's worth being precise about what this actually solves, because the checkmark next to a provenance-verified package invites more confidence than it's earned. Provenance proves which pipeline produced a package. It doesn't prove the pipeline was honest. If an attacker compromises the source repository itself, say, by getting a malicious commit merged, or gaining GitHub Actions access some other way, trusted publishing will faithfully generate a provenance attestation for that maliciousness. It'll be cryptographically correct and functionally useless. This is the same gap [reviewing AI-generated code](/reviewing-ai-generated-code) runs into: verifying that something came from where it claims to have come from is not the same task as verifying it's safe to trust. Provenance narrows the attack surface to "compromise the actual pipeline" instead of "steal a token from anywhere," which is a real improvement, not a complete one. ## What to actually do about it this week For a team that isn't publishing its own packages, the relevant question isn't "should we adopt trusted publishing" so much as "how exposed are we to someone else's compromised token." A few concrete steps that don't require waiting on anyone else: - Pin dependencies with a committed lockfile, and treat `npm ci` as the only install command CI is allowed to run. A worm that republishes a patch version doesn't reach a build that's locked to an exact, previously audited version. - Audit before bumping, not after. A version bump on a package with low download counts or a maintainer who just changed is worth a second look before it merges, the same instinct as reviewing any other unfamiliar diff before trusting it. - Get long-lived `NPM_TOKEN` secrets out of CI for anything your team does publish, and move to trusted publishing where the CI platform supports OIDC. GitHub Actions, GitLab CI, and CircleCI all do now. - Turn on FIDO-based 2FA for every account with publish access, ahead of npm forcing the TOTP deprecation. Doing it on your own schedule beats doing it during an incident. - Run a dependency scanner in CI, not as a replacement for the above, but as the layer that catches what slips past it. Socket, Snyk, and similar tools flag install scripts, new maintainers, and suspicious version jumps before they reach a build. None of these require a security team. They're the same discipline as [a written PR checklist](/effective-code-reviews) applied one layer down the stack, to the code a team didn't write but ships anyway. ## Bringing it together Shai-Hulud didn't need a zero-day. It needed a token model built on standing trust with no expiration, and it turned that into a worm that spread itself. npm's response doesn't ask maintainers to be more careful with tokens, it removes the long-lived token from the equation through OIDC-based trusted publishing, backs it with cryptographic provenance, and forces stronger 2FA across the board. That's a real fix, not theater, but it answers "did this come from where it claims to" and not "is it safe." For a team not ready to migrate its own publishing today, the more urgent move is smaller and faster: locked dependencies, audited version bumps, and 2FA turned on before npm turns it on for you. ### Your MCP Servers Are Burning Your Agent's Context Window Every MCP server you connect loads its full tool schema into context before an agent reads anything, and a typical five-server setup burns 30,000-60,000 tokens on definitions alone. Tool-selection accuracy also degrades as the list grows. Code execution and progressive disclosure fix both by loading only the tools a task actually needs. MCP had a good year. Since Anthropic introduced it in late 2024, adoption has gone from novelty to default: the protocol was donated to the Linux Foundation's Agentic AI Foundation in December 2025, and one analysis of MCP servers counted a 232% increase in six months. Most teams running an AI coding agent now have several connected without thinking much about it, a filesystem server, a database server, a ticketing integration, a search tool, each one adding value on its own. The problem shows up once you've connected more than a couple. It's not a bug in any individual server. It's what happens to a context window when every server's full tool catalog loads before the agent does anything at all. ## The tool definitions load before anything else happens An MCP server doesn't wait to be asked before it costs you tokens. Connecting one means its tools' full schemas, names, descriptions, parameter types, get sent into context at the start of the session, whether the agent ends up using any of them or not. A single tool definition runs anywhere from a few dozen tokens for something minimal to several hundred for one with a rich parameter schema. An [open issue against the MCP spec](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2808) puts the realistic average closer to 1,000 tokens per tool once you account for how verbose most descriptions actually are in practice. A typical production setup, five servers averaging 30 tools each, works out to 150 tool definitions and 30,000-60,000 tokens spent on metadata before the agent has read a single file or user message. Cloudflare's engineering team went further in a February 2026 write-up, disclosing that their internal MCP exposure totaled roughly 1.17 million tokens of tool definitions across every server they had connected, well past any usable context window. ## More tools also means worse tool selection Token cost is the visible problem. The less visible one is that a longer tool list doesn't just get more expensive, it gets harder to use correctly. The RAG-MCP research project measured tool-selection accuracy falling from a 43% baseline to under 14% as the number of available tools grew, a threefold degradation. The agent isn't getting worse at reasoning. It's choosing from a longer, noisier list, and that list itself becomes the bottleneck, independent of which model is doing the choosing. This is the part teams tend to miss when debugging a flaky agent. A run that picks the wrong tool, or hesitates between two similar-looking ones, often isn't a prompting problem or a model problem. It's an artifact of how many tools were competing for that decision in the first place, the same way [handing too many agents too much work in parallel degrades the judgment behind any one of them](/wip-limits-for-ai-agents). ## Code execution: treat MCP servers like APIs, not context The fix that's gained the most traction since late 2025 is code execution. Instead of loading every connected server's full tool catalog into context and routing every intermediate result back through the model, the agent writes code that calls MCP servers directly, inside a sandbox, the way it would call any other API. Anthropic's own engineering team demonstrated the effect by rebuilding a Google Drive-to-Salesforce workflow this way. The direct-tool-call version consumed roughly 150,000 tokens moving data between the two systems and passing it through the model at each step. The code-execution version, where the agent wrote a script that called both APIs and only pulled in the tool definitions it actually needed for that task, dropped to about 2,000 tokens, a 98.7% reduction. Independent tests since then have found similar gains at different scales: one benchmark measured accuracy improving from 58% at 96 tools to 92.8% at 508 tools once code execution replaced direct calls, and Cloudflare reported a 99.9% token reduction on a 2,500-endpoint API using the same pattern under the name Code Mode. The mechanism in both cases is the same. The agent only pays the token cost of the tool definitions it uses for a given task, not the entire catalog of everything you've ever connected. ## Progressive disclosure works too, and asks less of your setup Code execution is the bigger structural fix, but it means giving an agent a sandbox to run code in, which isn't always the easiest change to make first. A lighter-weight alternative that ships the same idea is progressive disclosure: the agent searches for relevant tools by name or description before loading their full definitions, rather than having every tool's complete schema resident in context from the start of the session. Anthropic's Tool Search Tool and similar approaches from other vendors work this way. They don't eliminate the underlying tool sprawl, they just defer the cost until a tool is actually a candidate for use, which is often enough to bring a bloated setup back under control without restructuring how your agent calls anything. ## What to actually do about this Audit what a fresh session actually costs before adding another server. Most agent tooling will show you the token count consumed by tool definitions at session start; if you haven't looked at that number in a while, it's probably higher than you'd guess. Treat a new MCP server as a recurring cost on every request from then on, not a one-time integration. Cap connected servers around 5-7 as a starting point, the range most teams found workable before tool-selection accuracy started degrading in the research above. If you're past that and can't reasonably disconnect anything, adopt progressive disclosure first since it requires the least rearchitecting, and move to code execution for the servers or workflows where token cost is the dominant expense. None of this is really new advice, dressed in new terminology. It's the same lesson as [reviewing an agent's actual output instead of trusting a green check](/reviewing-ai-generated-code): don't assume a system built on good intentions stays cheap and accurate by default. Measure what it's actually costing you, and cut what isn't earning its place in context. ### The Temporal API Is Here. Here's How to Actually Start Using It Temporal is JavaScript's replacement for Date, reaching Stage 4 in March 2026 and shipping unflagged in Chrome 144+, Firefox 139+, and Node.js 26. It fixes Date's mutability and timezone bugs with immutable types like PlainDate and ZonedDateTime. Safari hasn't shipped it yet, so pair it with the official polyfill for public-facing work. JavaScript's `Date` object has been broken in the same specific ways since 1995, borrowed from Java's `java.util.Date`, which Java itself deprecated the same year `Date` shipped. Every workaround the ecosystem has produced since, Moment.js, date-fns, Luxon, Day.js, exists because the language never fixed the actual object. That changed this year. Temporal reached [Stage 4](https://github.com/tc39/proposal-temporal) at the March 2026 TC39 meeting, locking it into the ECMAScript 2026 spec, and it's now landing in runtimes fast enough to be worth planning around, not just reading about. Here's what actually changed, what the API looks like day to day, and how to start using it without breaking anything for the users on browsers that haven't caught up yet. ## What was actually wrong with Date Three specific problems compound into most of the date bugs you've debugged: **Every setter mutates in place.** `date.setDate(date.getDate() + 7)` doesn't return a new date, it mutates the one you already had a reference to, anywhere else in your code. Pass a `Date` into a function that "just reads it" and you have no guarantee it comes back unchanged. **There's no real timezone model.** `Date` only understands two things: the environment's local timezone and UTC. There's no way to represent "2pm in Johannesburg" as data if the code is running on a server in another timezone, without manually tracking an offset yourself and getting it wrong the first time a DST transition crosses your calculation. **Parsing is inconsistent.** `new Date("2026-09-25")` and `new Date("09/25/2026")` don't reliably behave the same way across engines, and the ambiguity has been a running source of subtle, environment-specific bugs for as long as the object has existed. Temporal fixes all three by being a different kind of object entirely: a namespace, similar to `Intl`, containing several immutable, specific types instead of one object trying to be everything. ## The types, and when to reach for each one Temporal splits "a date" into distinct concepts, because "a date" means different things depending on whether a timezone matters: - **`Temporal.PlainDate`**: a calendar date with no time or timezone attached. Use it for things like a birthday, a deadline, or a holiday, where "which timezone" doesn't apply. - **`Temporal.PlainTime`**: a wall-clock time with no date. A recurring daily alarm, for instance. - **`Temporal.ZonedDateTime`**: a specific moment, anchored to an IANA timezone. This is what you want for scheduling, calendar events, or anything where "when" depends on where. - **`Temporal.Instant`**: a single point on the UTC timeline, with nanosecond precision. The direct replacement for a Unix timestamp. - **`Temporal.Duration`**: a length of time, like "3 days, 4 hours," used for arithmetic rather than a point in time. You don't instantiate any of them with `new`. Every type is built through static methods instead: ```js const deadline = Temporal.PlainDate.from('2026-10-15'); const meeting = Temporal.ZonedDateTime.from('2026-09-25T14:30[Africa/Johannesburg]'); const sprint = Temporal.Duration.from({ weeks: 2 }); ``` That constructor restriction is deliberate. It forces every value to go through explicit parsing or explicit field construction, which is most of what closes the "inconsistent parsing" gap from the old `Date` object. ## Immutability changes how you write the arithmetic Every operation on a Temporal object returns a new object instead of mutating the one you have: ```js const today = Temporal.PlainDate.from('2026-09-25'); const nextWeek = today.add({ days: 7 }); today.toString(); // '2026-09-25', unchanged nextWeek.toString(); // '2026-10-02' ``` That single property removes an entire category of bug: a `Date` passed into a shared utility function, mutated by a `.setMonth()` call somewhere deep inside it, coming back different at the call site with no error and no warning. With Temporal, that's not possible. If a function wants to hand you back a changed value, it has to return it. ## Timezone-aware arithmetic, without doing the math yourself This is where `ZonedDateTime` earns its name. Adding a day across a daylight saving transition with a plain offset calculation is a well-known way to end up an hour off. `ZonedDateTime` carries its timezone through every operation, so the arithmetic accounts for the transition automatically: ```js const beforeDST = Temporal.ZonedDateTime.from( '2026-11-01T00:30[America/New_York]', ); const oneDayLater = beforeDST.add({ days: 1 }); // oneDayLater lands on the correct wall-clock time in New York, // accounting for the clocks falling back that night. ``` Compare that to the equivalent `Date` code, which usually means either pulling in a library specifically to handle this or shipping a bug that only shows up twice a year, in the specific window around a DST change. ## Where this actually runs today This is the part to check before you reach for it on anything user-facing. As of September 2026, [Node.js 26](https://nodejs.org/en/blog/release/v26.0.0), released May 5, 2026 with V8 14.6, ships Temporal enabled by default, no experimental flag, no `--harmony` switch. If your code runs server-side on a current Node version, it's already available. Browser support is real but not universal. Chrome 144+ and Firefox 139+ shipped native support earlier in 2026, and Edge and Opera inherit it through Chromium. Safari is the gap: it hasn't shipped Temporal even in Technology Preview, which means any code that has to run in a browser you don't control still needs a fallback. The TC39 champions maintain a full-spec polyfill, [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill), at roughly 56KB minified and gzipped. That's a real cost for a date library, so the practical pattern is loading it conditionally, only for browsers that lack `globalThis.Temporal`, rather than shipping it unconditionally to every visitor. ```js if (typeof Temporal === 'undefined') { await import('@js-temporal/polyfill'); } ``` ## Migrating without a rewrite You don't need to touch every date in an existing codebase to get value from this, and given the Safari gap, doing that today would be premature anyway. The pattern that holds up: 1. **Convert at the boundary.** When a date value enters your code, from an API response, a database row, a form input, convert it to a Temporal type immediately, rather than passing a raw `Date` or string deeper into the app. 2. **Do all reasoning in Temporal.** Comparisons, arithmetic, formatting, all of it happens on `Temporal` objects once you're past that boundary. 3. **Convert back only where something still demands it.** Some libraries and browser APIs still expect a native `Date`. Convert at that specific call site, not earlier. There's no forcing function to rip out date-fns or Luxon on any particular timeline. Both are actively maintained, and there's a real argument for leaving working code alone. New code is the easier place to start: write it against Temporal directly, and let the existing library calls age out of the codebase on their own schedule as those files get touched anyway. ## The part that's easy to skip: formatting `Temporal` objects don't have a `.toLocaleDateString()` replacement built the same way `Date` does. Formatting for display goes through `Intl.DateTimeFormat`, which already accepts Temporal objects directly: ```js const formatter = new Intl.DateTimeFormat('en-ZA', { dateStyle: 'long', }); formatter.format(Temporal.PlainDate.from('2026-09-25')); // '25 September 2026' ``` That's one less API to learn if you've already used `Intl.DateTimeFormat` for locale-aware formatting, which is worth knowing before you go looking for a `Temporal`-specific formatting method that doesn't exist. ## Where this leaves you Temporal isn't an incremental improvement to `Date`, it's the fix the ecosystem has been building workarounds for since Moment.js first shipped. Reaching for it today makes the most sense on Node.js code, where support already lands unflagged, and on new browser-facing code behind the conditional polyfill above. Everything else, [the same as any newer platform feature with a browser gap](/css-if-function-safely), can wait for Safari to catch up without costing you anything now. ## Further reading - [MDN: Temporal](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal) - [TC39: Temporal proposal](https://github.com/tc39/proposal-temporal) - [js-temporal/polyfill](https://github.com/js-temporal/temporal-polyfill) - [Can I Use: Temporal](https://caniuse.com/temporal) ### AI Is Forking the Engineering Manager Role Into Two Jobs AI has absorbed the coordination, reporting, and scheduling work middle managers used to own, and Gartner expects a fifth of organizations to cut over half their middle-management layer by the end of 2026. What survives splits into two paths, a technical lead-manager role coding daily for a small team, and an expanded people-manager role running many teams at once. Gartner's prediction for 2026 is specific: a fifth of organizations will use AI to flatten their structure, cutting more than half their existing middle-management positions. Block is already an example of it in practice. The company cut roughly 4,000 of its 10,000 roles in early 2026, and CEO Jack Dorsey framed the restructuring as permanent, replacing the traditional manager layer with a three-role model of individual contributors, directly responsible individuals, and player-coaches. Engineering teams aren't exempt from that pressure, but the shape it takes there is more specific than "fewer managers." AI didn't remove the need for engineering management. It absorbed the half of the job that was never really about engineering, status reporting, scheduling, workflow tracking, the coordination overhead that used to justify one manager for every six to eight engineers. What's left doesn't shrink evenly. It splits into two different jobs, and they're pulling in opposite directions. ## Name what AI actually took over Before the split makes sense, it helps to be specific about what disappeared. An engineering manager's week used to be a mix of two things that got bundled together mostly by convenience: technical judgment (is this architecture sound, does this PR fit our conventions) and coordination overhead (status updates, sprint ceremonies, cross-team scheduling, chasing down blockers). AI is good at the second category and still weak at the first. A tool that drafts a status update from commit history, flags a stalled ticket, or schedules around three calendars doesn't need judgment, it needs pattern-matching against structured data, which is exactly what these systems do well. That's the part of the job getting automated first and fastest, which is also why the effect shows up as a widening span of control rather than an even reduction across the board. LeadDev's reporting on this found that Google removed 35% of the managers who were leading teams of fewer than three people, and that Amazon raised its engineer-to-manager ratio by 15% over the same stretch. Fewer people are needed to hold the coordination layer together, so the org charts are getting shorter. ## The two paths the remaining role splits into Once the coordination work stops requiring a dedicated person, what's left of "engineering manager" doesn't converge on one redesigned job. It forks, because the two halves of the old role scale in opposite directions. **The tech lead manager (TLM) path** goes back to writing code daily while managing a small team, usually the same team whose work they're contributing to. This isn't a new invention, it's the return of a role that existed before engineering management became a full-time, largely non-technical career track. It's viable again because AI now carries enough of the reporting and scheduling load that a working engineer can absorb what's left without it eating the whole week. LeadDev's 2026 survey of 600 engineering leaders backs up how fast this is moving: the share of EMs doing hands-on technical work rose from about 20% to 35% in twelve months. That's not a fringe pattern, it's a fifth of the surveyed population moving in the same direction inside a single year. **The expanded multi-team EM path** goes the other way. It drops hands-on coding almost entirely and takes on a much wider span of control instead, several small teams across different product areas rather than one team closely. This version of the role leans hard into coaching, cross-team alignment, and organizational judgment that doesn't depend on knowing any one codebase intimately. It's the direct beneficiary of AI absorbing coordination work: a manager who isn't tracking that work by hand can plausibly oversee more of it at once, which is exactly the wider ratio Google and Amazon are reporting. Neither path is a downgrade. They're specializations of a job that used to average the two together, and averaging is exactly what stops making sense once the coordination half gets automated out. ## Why picking a lane matters more than staying flexible The instinct to stay generalist, a bit technical, a bit people-focused, worked when the job was one thing. It stops working once the job is two things pulling against each other, because the skills that make someone good at one path actively atrophy time spent on the other. A TLM who spends a quarter mostly in meetings loses the technical currency the role depends on: [reviewing AI-generated code well requires staying sharp enough to catch the version that looks clean but solves the wrong problem](/reviewing-ai-generated-code), and that instinct doesn't survive on autopilot. An expanded multi-team EM who keeps trying to review code across teams they don't work in daily is spending scarce time on technical depth that was never going to be deep enough to matter, instead of the coaching and cross-team judgment that's actually scaling with their span of control. This is also where the flattening trend carries a real cost worth naming rather than waving away. Gartner's own prediction pairs the middle-management cuts with a warning: atrophy of critical-thinking skills from over-relying on AI is serious enough that half of organizations are expected to require AI-free skills assessments by the end of 2026. The same risk shows up in how a flatter org trains its next generation of managers. Fewer middle managers means fewer people whose whole job was modeling judgment for someone earlier in their career, which is the same gap already showing up in [how junior engineers learn without the boilerplate work that used to teach them](/keeping-the-junior-pipeline-alive). A flatter structure doesn't just change who reports to whom. It changes who's around to absorb what the org charts used to make someone's explicit job. ## What to actually do with this If you're deciding which path fits, the honest test isn't which one sounds more appealing. It's which skill you'd rather keep sharp under time pressure, because time pressure is exactly when a generalist attempt at both collapses into neither. For the TLM path: protect real coding time on the calendar the same way you'd protect an on-call rotation, and treat staying current with the codebase as a job requirement, not a nostalgia project. The smaller team size is the tradeoff that makes this sustainable. Don't let the team grow past what one working engineer can actually stay close to. For the expanded EM path: stop treating "I used to code" as a credential that still applies, and invest instead in the domain-agnostic parts of the job, coaching, [what actually keeps individual contributors engaged across a wider set of teams](/motivate-individual-contributors), and reading team health signals you can't get from a dashboard. The wider span of control only pays off if the coordination overhead genuinely stays automated. It reappears fast if a manager tries to manually track five teams the way they used to track one. For an org deciding how to structure this instead of an individual navigating it: decide deliberately which teams need a TLM and which need an expanded EM, rather than letting the split happen by attrition as managers each drift toward whichever half of the old job they personally preferred. The org chart that results from a deliberate choice looks different, and holds up better under review, than one that results from nobody deciding. ## The shift underneath both paths Neither fork of this role is really new. The TLM existed before management professionalized into its own track, and the multi-team EM is closer to a director role than most companies would have called it two years ago. What's new is that AI made both viable at the same time, by removing the coordination overhead that used to force a choice between doing one job adequately or the other job not at all. That's worth sitting with past the immediate question of which path to pick. The flattening Gartner is describing isn't a one-time cut. It's a standing bet that coordination work stays automated well enough that the remaining managers can specialize instead of generalize. If that bet holds, the split described here is the shape engineering management settles into. If it doesn't, and the coordination work quietly creeps back onto someone's desk, the two paths collapse back toward the averaged-out job they came from, just with fewer people left to do it. ### Spec-Driven Development: Why the Spec Is Becoming the New Code Review Spec-driven development treats a written spec, not a one-off prompt, as the source of truth an agent codes against. GitClear's 2026 research found duplicated code blocks up 81% since 2023, the drift this practice is meant to catch. Write acceptance criteria before delegating, version the spec with the code, and review the diff against it, not on its own. Vibe coding, describing what you want in a prompt and accepting whatever comes back if it looks right, was never going to hold up at scale, and by 2026 the data backs that up. GitClear's 2026 code quality research, built from real commit histories rather than survey responses, found duplicated code blocks up 81% since 2023. The same body of GitClear data shows refactoring's share of changed lines sliding from around a quarter in 2021 to under 10% by 2024. Code is getting written faster and maintained less, and the gap between those two numbers is where technical debt actually accumulates. Spec-driven development is the response that stuck. Not because it's new, engineers have written specs before code for decades, but because AI agents made the old failure mode visible at a speed and volume that made it impossible to ignore. An agent given a vague prompt doesn't ask clarifying questions the way a person eventually would. It produces something plausible, and plausible is exactly the problem: the diff reads cleanly, passes the tests it wrote for itself, and still isn't what you meant. GitHub open-sourced Spec Kit in September 2025 as a direct response to this, giving Copilot, Claude Code, and other agents a structured process instead of a blank prompt box. Other tools followed the same shape. The common thread across all of them is simple enough to use without any tooling at all: write the spec first, treat it as the thing under version control, and review the code against it. ## A spec is a contract, not a PRD The word "spec" gets overloaded fast, so it's worth being specific about what makes one useful here. It isn't a product requirements doc, and it isn't a design file. It's short, testable, and answers three questions before a single line of code exists: - What does the system have to do, stated as behavior you can check, not intent you have to interpret. - What is explicitly out of scope, so an agent doesn't "helpfully" solve an adjacent problem you didn't ask about. - How will you know it's done, ideally as a list a reviewer or a test suite can actually check against. That last point is what separates a spec from documentation. Documentation describes what code does after the fact. A spec constrains what gets written before it exists. If it can't be checked, it isn't doing that job yet, it's just a longer prompt. It's the same instinct behind [agreeing a review process before development starts](/effective-code-reviews), moved one step earlier. ## Write it before you delegate, not after The instinct that kills this practice fastest is writing the spec after the fact, once the agent has already produced something and you're rationalizing what it built. That's not a spec, it's a changelog with better formatting. The order matters because a spec written first has to survive contact with your own uncertainty. Writing "the export function should handle malformed CSV rows" before any code exists forces you to decide what "handle" means: skip the row, fail the batch, log and continue. Writing it after the agent has already picked one just documents whatever it happened to choose. The friction of deciding upfront is the entire value of the exercise. Skip it, and the spec becomes theater. This doesn't mean you have to write every word yourself. Letting an agent draft a first pass at the spec from a ticket is fine, and often faster than starting from a blank page. The requirement is that a human edits and approves it before implementation starts, not after. That matters more, not less, when [the work is split across several agents at once](/orchestrating-multiple-ai-agents), since each one needs a boundary it can't renegotiate mid-task. ## Keep the spec small enough to be worth reading The failure mode on the other side is over-specifying: turning a two-paragraph contract into a twelve-page waterfall document nobody reads, which just recreates the exact process spec-driven development was supposed to replace. A spec that takes longer to review than the code it produces has stopped paying for itself. A useful test: if you can't imagine an agent or a teammate meaningfully disagreeing with a line in the spec, it probably doesn't need to be there. Constraints and edge cases earn their place. Restating what a REST endpoint obviously does doesn't. ## Version the spec with the code Once a spec exists, it needs to live somewhere durable, in the repo, in the same PR as the implementation it produced, not in a chat thread that scrolls away. That's what makes it checkable later instead of just aspirational now. The direct payoff shows up during review and during debugging. When code and spec disagree, that disagreement is the actual bug report. Either the implementation drifted from what was agreed, which is a normal review finding, or the spec never described what you actually meant, which is worth catching just as much. Either way, you're debugging against a fixed target instead of your own memory of what you originally asked for. It also makes the second and third pass through a feature cheaper. Six months from now, "why does this behave this way" has an answer that isn't "read the whole implementation and guess." ## Review the diff against the spec first This is the part of the practice that actually changes how a review happens, and it's a small but real shift from [reviewing AI-generated code on its own terms](/reviewing-ai-generated-code). Before checking whether the code is correct line by line, check whether it satisfies the spec. Before approving the spec's own coverage, check it against the ticket or the problem it was meant to solve. A diff that's clean, passes CI, and perfectly satisfies a spec that missed an edge case is still the wrong feature. Catching that at the spec is far cheaper than catching it in production, and far cheaper than catching it three PRs later once other code has been built on top of the gap. This reordering matters specifically because of how agents fail. An agent rarely produces code that's obviously broken. It produces code that's obviously plausible, which is a much harder thing to catch by reading the diff alone. Reviewing against a spec written before the code existed removes the temptation to grade the implementation on its own confident-looking terms. ## Where it breaks down Spec-driven development isn't a substitute for judgment, it's a place to put judgment where it's checkable. Two ways it goes wrong in practice, both preventable: Treating the spec as a one-time artifact instead of a living one. Requirements shift mid-build more often than anyone plans for, and a spec that doesn't get updated alongside that shift becomes exactly as unreliable as no spec at all, just with more false confidence attached. Assuming a good spec means a lighter review. It doesn't. It changes what the review is checking, from "is this code sensible" to "does this code satisfy the contract, and was the contract right," but it doesn't remove the need for a human to check either one, which is why the number of specs in flight still needs [a cap tied to what one person can actually verify](/wip-limits-for-ai-agents). ## Bringing it together The GitClear numbers describe a real cost: duplication up, refactoring down, in the same years AI-assisted coding scaled fastest. Spec-driven development doesn't fix that by making agents smarter. It fixes it by giving them, and the humans reviewing their output, a fixed target to check drift against instead of a memory of what a prompt was supposed to mean. Write the contract first, keep it small enough to read, version it with the code, and review the diff against it before anything else. That's most of the practice, and none of it requires new tooling to start doing tomorrow. ### Using CSS's New if() Function Without Breaking Safari and Firefox CSS's if() function picks a property's value based on a media, feature, or style condition, replacing separate @media, @supports, and container style query blocks with one inline declaration. It shipped in Chrome 137, but Firefox and Safari don't support it yet, so ship it as a second declaration after a plain fallback, not a replacement for one. CSS has had conditional logic for a while now: `@media` for viewport conditions, `@supports` for feature checks, `@container` for context based on an ancestor. Each one works, and each one asks you to write a separate block, usually with the same selector repeated, just to change one value. `if()` collapses that pattern into a single declaration. It shipped in Chrome 137 in 2026, and it's worth understanding now, both because it changes how you'll write conditional CSS once it's everywhere, and because it's genuinely usable today if you know where the edges are. ## What if() actually does The syntax is a list of condition-value pairs, separated by semicolons, evaluated in order, with an optional `else` at the end: ```css .button { padding: if(media(width > 700px): 1rem 1.5rem; else: 0.75rem 1rem); } ``` The first condition that evaluates to true wins. If nothing matches and there's no `else`, the property behaves as though it was never set, falling through to whatever the cascade would otherwise apply. That's the whole model. What makes it useful is what counts as a condition. ## Three condition types, one function `if()` accepts three kinds of tests, and you can mix them in the same declaration. **`media()`** checks a media feature, the same conditions `@media` accepts, just written inline: ```css .hero-title { font-size: if(media(width > 1024px): 3rem; else: 2rem); } ``` **`supports()`** checks feature support, the same thing `@supports` does, but scoped to one value instead of a whole rule: ```css .layout { display: if(supports(display: grid): grid; else: flex); } ``` **`style()`** checks the computed value of a custom property, which is the part that used to require a container style query and a separate block: ```css .card { padding: if(style(--density: compact): 0.75rem; else: 1.25rem); } ``` Set `--density: compact` anywhere up the cascade, on the element itself, a parent, a data attribute selector, and every property using that `style()` check picks it up. No `@container` wrapper, no naming a containment context, no duplicated selector. ## What it actually replaces What matters is the collapsing, not the syntax on its own. Here's the same responsive padding rule, before and after. Before, with a duplicated selector: ```css .button { padding: 0.75rem 1rem; } @media (width > 700px) { .button { padding: 1rem 1.5rem; } } ``` After, as one declaration: ```css .button { padding: if(media(width > 700px): 1rem 1.5rem; else: 0.75rem 1rem); } ``` Same result, no repeated selector, and the condition sits next to the value it controls instead of in a separate block somewhere else in the file. That's a small readability win on one property. On a component with five or six conditional values across breakpoints, states, and containers, it stops being small. You stop hunting through `@media` and `@container` blocks trying to reconstruct what a single element actually renders at a given width. ## Browser support today This is the part that matters before you touch it in production. As of September 2026, `if()` works in Chrome 137 and up, along with Edge and Opera, since all three share the Chromium engine. Firefox support is still in progress, and Safari has it listed on its roadmap for 2026-2027, not shipped. MDN currently marks `if()` as limited availability [rather than Baseline](/what-is-google-baseline), which means the usual rule applies: don't ship it as your only source of a value unless your audience is genuinely Chromium-only, an internal tool or an Electron app, for instance. The [State of CSS 2026](https://2026.stateofcss.com/en-US) developer survey found `if()` among the least-used features respondents reported, and browser support gaps around `if()` and the related `@function` mixins proposal came up repeatedly as the reason. That tracks. A feature that only works in one engine family isn't something most teams reach for on customer-facing work yet, whatever the syntax looks like. ## Shipping it safely today The good news is that CSS has handled this exact situation forever: a browser that doesn't recognize a value just discards that one declaration and moves on. You don't need a `@supports` wrapper around the whole rule to use `if()` safely. Write the plain value first, then the conditional version as a second declaration for the same property: ```css .card { padding: 1.25rem; padding: if(style(--density: compact): 0.75rem; else: 1.25rem); } ``` A browser without `if()` support parses the first line, fails to parse the second one because it doesn't recognize the function, and keeps the first value. A browser with support parses both and the second declaration, being later in the cascade, wins. No feature query needed, no branching stylesheet, just [the same fallback-then-enhancement order](/css-custom-properties-fallbacks) you'd use for any newer CSS value. If you do want to gate a larger block of styles on `if()` support specifically, rather than fall back one property at a time, `@supports` still works for that: ```css @supports (padding: if(style(--density: compact): 1rem; else: 1rem)) { /* if()-dependent styles that need more than one property to make sense together */ } ``` Reach for that version when several related properties only make sense as a set. For a single value, the two-declaration pattern above is simpler and doesn't need a nested block at all. ## Where this is heading `if()` is part of a bigger CSS Working Group effort to bring conditional logic and reusable logic natively into stylesheets, alongside the related mixins and custom functions proposal. None of it replaces a preprocessor's full feature set yet, but it closes a real gap: today, expressing "this value depends on that condition" usually means either a duplicated `@media` block or reaching for JavaScript to toggle a class. `if()` makes that a one-line answer, once the browsers you actually need to support catch up. Until then, treat it the way you'd treat any Chromium-first feature: real value on Chromium-only surfaces now, a fallback-first pattern everywhere your audience includes Safari or Firefox users, which for most public sites is still most of your traffic. [Anchor positioning and customizable selects](/css-anchor-positioning-customizable-select) sit further along the same curve, and the adoption pattern is identical. ## Further reading - [MDN: if() CSS function](https://developer.mozilla.org/en-US/docs/Web/CSS/if) - [Chrome for Developers: CSS conditionals with the new if() function](https://developer.chrome.com/blog/if-article) - [Can I Use: CSS if() function](https://caniuse.com/css-if) - [State of CSS 2026](https://2026.stateofcss.com/en-US) ### CSS Can Finally Replace Your Tooltip and Dropdown Libraries CSS anchor positioning (Chrome 125+, Safari 26+, Firefox 145 behind a flag) and appearance base-select (Chrome 135+, Safari 27+) now let native CSS handle floating tooltips and styled dropdowns without a JS library. Both degrade gracefully behind @supports, so you can adopt them now for new components and drop the library once support catches up. For years, two of the most common UI patterns on the web, a tooltip that stays attached to its trigger and a select dropdown you can actually style, have both needed a JavaScript library to do properly. Popper.js, Floating UI, and Tippy.js exist because CSS had no concept of "position this relative to that other element, and flip sides if it would overflow." Custom select components like react-select exist because the native `` element. Its trigger has always taken CSS fine. Its popup, the actual list of options, has never been reachable by a stylesheet, which is why teams have spent a decade reaching for `react-select`, `downshift`, or a hand-rolled combobox any time a design called for anything beyond the browser default. `appearance: base-select` changes that: ```css select, ::picker(select) { appearance: base-select; } ::picker(select) { border-radius: 8px; box-shadow: 0 8px 24px rgb(0 0 0 / 0.15); } select option:checked::before { content: '✓'; } ``` Setting `appearance: base-select` on both the ``, which matters more than it sounds: keyboard navigation, type-ahead, focus management, and the accessibility tree all keep working the way a native form control's are expected to, none of it needs reimplementing the way it does in a from-scratch listbox component. Per [Chrome for Developers' announcement post](https://developer.chrome.com/blog/a-customizable-select) and its [follow-up findings from developer feedback](https://developer.chrome.com/blog/rfc-customizable-select-findings), the feature is available starting in Chrome 135 and Safari 27. Firefox doesn't support it yet, but it fails safe: an unsupported browser just ignores the `appearance` value and renders the ordinary native select, unstyled but fully functional. That makes it one of the lowest-risk progressive enhancements available right now, closer in spirit to a CSS fallback than a feature you need a JS shim to polyfill. What matters more than the styling freedom is what you don't have to rebuild. A hand-rolled combobox has to reimplement arrow-key navigation, type-ahead-to-jump, `aria-activedescendant` wiring, and the focus behavior a screen reader expects from a listbox, and it's easy to get subtly wrong in ways that don't show up until an accessibility audit. A `