UI.MD

Changelog

Every change to the kit, newest first. The current version is 2026-10-22.2: the newest entry's date, and its count for that day.

Check for updates

Run this in your project to list every change since the version you have:

$
node scripts/verify-ui.mjs --updates

Line tabs for tabs inside a header

v2026-10-22.2

Tabs takes variant="line": plain labels over a 3px accent underline that slides like the pill, with no track around them. It is for tabs that sit inside other chrome, such as a DashboardCardHeader's action slot, where the framed pill filled the 48px band. A line strip fills its parent's height and the underline's last pixel covers the rule below, so in a card header it sits on the border. DashboardCardHeader's action slot now spans the band's height to allow that; its actions stay centred. Same sizes and counts; the selected label is the foreground colour, and its count chip and bar are the plain accent in both themes. Horizontal only; a vertical strip keeps the pill.


Segmented control and tab highlights sit evenly

v2026-10-22.1

The sliding highlight in SegmentedControl and Tabs is now measured to the fraction of a pixel. It read its option's position and width as whole pixels, which round apart, so the highlight landed up to half a pixel off the option it covers and the gap on one side read wider than the other three. It is most visible at 2x on the last option.

Moves on screen

  • The selected option's highlight in SegmentedControl and Tabs shifts or resizes by up to half a pixel to match its option exactly.

A week schedule can fill its space

v2026-10-21.5

WeekSchedule takes fill: in a flex column with flex-1, or under a set height, the hours stretch to fill what the page leaves, never shorter than their default height, and the grid scrolls past that. Without it nothing changes. The hour lines and labels are now placed as a share of the day, as the events already were, so they stay in step with a stretched grid.


Select menus fit their options

v2026-10-21.4

A Select's menu is now as wide as its longest option, never narrower than its trigger and never wider than the screen. It was pinned to the trigger's width, so a short value such as "Share" squeezed "Own workflow" onto two lines, and apps patched each menu with w-max min-w-(--anchor-width). That patch can come out. A new warning, select-item-hand-rolled-trailing, flags side text (a tag, a count, a muted second word) built into a SelectItem's children, where it joins the label and runs into the check mark: use trailing.

Moves on screen

  • Select menus whose options are longer than the trigger come out wider, with each option on one line. Menus whose options fit the trigger do not move.

New build failures

  • None. select-item-hand-rolled-trailing lands as a warning.

A dropzone for uploads

v2026-10-21.3

New: Dropzone, a dashed area that takes dropped files or opens the file picker on a click, Enter or Space. It checks each file against accept, maxSize and maxFiles, hands back the ones that pass (onFiles) and the ones turned away with why (onReject), and never uploads: the app does. Under it, DropzoneFiles and DropzoneFile list what the app holds, each with its picture or a file icon, its name and size, a spinner while it uploads, why it failed, and a remove button. Apps were hand-building this around a hidden file input. AddTile stays for the small "add one" square at the end of a row of thumbnails.


Confetti can burst up out of an element

v2026-10-21.2

burst(element) throws the flakes up out of that element (the button just clicked) in a fan, before they fall back and land. Two new settings tune it: --confetti-rise (how high, default 320) and --confetti-spread (the fan's half angle in degrees, default 30). Thrown flakes lose their sideways speed to air drag. A --confetti-hold of Infinity now keeps the flakes and stops the animation once they settle, instead of repainting a still picture.


A rule for dates and times

v2026-10-21.1

docs/ui-md-writing.md gains "Dates and times": numerals, short month first ("Oct 21"), the year only when it is not this year or the date is a record, ranges with "to", "3:05 PM" with its zone, the weekday spelled out, and a word in front saying what the date means ("Expected Oct 21 to 22"). It also asks for a fixed locale and time zone, since the viewer's own prints a different format per reader and can disagree between server and browser.


Footer column titles bind to their links

v2026-10-20.5

A SiteFooterColumn title now sits 8px above its first link's text, the label rung, down from 14px. At 14px the title was about one link pitch away and read as another row instead of the heading of its list. The space between links is unchanged: it is the links' tap area.

Moves on screen

In SiteFooterColumn: every link under a title moves up 6px.


Code under a lead-in gets more air

v2026-10-20.4

A paragraph directly above a code block or table is now 24px from it, up from 16px. --rhythm-figure-bind is its own value (1.5rem) instead of reading --rhythm-base: at 16px the ringed box still sat tight on its lead-in. The gap after a figure stays 32px, so the code still binds to the line above it.

Moves on screen

In Prose: a figure under a paragraph, and everything after it, moves down 8px.


Prose figure gaps retuned

v2026-10-20.3

Prose spaces code blocks and tables by what sits beside them. A paragraph directly above a figure is now 16px from it (--rhythm-figure-bind reads --rhythm-base), up from 8px in the last release: 8px crammed the code against its lead-in. The gap after a figure is a new token, --rhythm-figure-after (32px), down from 48px, so a break inside one step no longer reads like a new section. A heading directly above a figure keeps its own below gap (40px under an h1, 16px under the rest) so it still breathes more above than below. A list or anything else above a figure keeps --rhythm-figure (48px).

Moves on screen

In Prose: a figure under a paragraph moves down 8px, everything after a figure moves up 16px, and a figure right under a heading moves up (32px under h2 to h6, 8px under an h1).


A display size for PageHeader; a lead-in paragraph binds to its code block

v2026-10-20.2

PageHeader takes size="display", for an article's or essay's masthead: the title at text-display over a text-lg summary. The gap between them grows with the title, 8px plus a quarter of its size (17px under a 36px title), since a flat 12px reads cramped at that size. It reads a new rung, --spacing-rhythm-trim-pair-display. The gap to children is the new --spacing-rhythm-heading-display (40px), also on Stack as heading-display. A new titleClassName prop takes a project's own headline face; it styles the face, never the size. If you hand-built a display masthead on trim-pair, replace it with <PageHeader size="display" titleClassName="...">.

In Prose, a paragraph directly above a pre or table now sits 8px from it instead of 48px, on the new --rhythm-figure-bind token, which reads the bind rung. That paragraph introduces the figure, so it binds to it. The gap below a figure stays 48px.

Moves on screen

In Prose, every code block or table that follows a paragraph moves up 40px.

New build failures

None. trim-rung-mismatch also reads --spacing-rhythm-trim-pair-display now, and flags it in a file that trims nothing, as a warning like the rest of that rule.


Brand marks: the real provider logos

v2026-10-20.1

New part, brand-marks: GoogleMark, GitHubMark, MicrosoftMark and AppleMark, the real logos for a sign-in or "continue with" button. They used to live privately inside AuthForm, so every site drew its own Google G and no two matched. Import them instead: iconStart={<GoogleMark />} on a Button. Google and Microsoft keep their brand colors; GitHub and Apple follow the text color. There is no one-color Google mark, on purpose: Google's sign-in rules require the four-color G, so on a colored button the fix is a neutral button. kit installs it, and AuthForm now imports its marks from it.


Carousel arrows: one style

v2026-10-19.5

Carousel arrows have one style now, the round chip with a ring edge. The plain chevron is gone: at half opacity it went hard to read whenever an arrow sat on a picture, and on a phone it always did. variant on the arrows and on CarouselControls is still accepted so nothing fails to compile, but it does nothing. tone still picks the fill.

Moves on screen

  • Every carousel arrow is now a 28px round button with a 16px chevron, a card fill and a ring edge, at full opacity. Arrows that were already chips shrink from 32px with a 20px chevron. Each chevron sits 1px toward its point, which centres it by eye.
  • CarouselControls no longer pulls itself 12px past the end of its row. That pull lined up a plain chevron's ink with the edge; a chip's edge is its shape, so it sits flush.

New build failures

  • carousel-arrow-variant-removed (warning): names a variant="bare" or variant="chip" left on CarouselPrevious, CarouselNext or CarouselControls. Delete it.

Lockup body; spacing and line-number fixes

v2026-10-19.4

LockupBody holds content that belongs to a lockup's message, between the description and the actions: an install command, an email field. It takes the same space above it as the action row and follows the lockup's alignment.

Moves on screen

  • A SearchTrigger with a shortcut moves its keycap 7px toward the end, so it sits as far from the end as from the top and bottom.
  • A card holding only a CardHeader gains 20px under the header, the same as above it. Its text used to sit on the card's bottom edge. If you added pb-5 to work around it, nothing moves and you can delete it.
  • CodeBlock showLineNumbers now numbers its lines. Every line read 0 before, because the counter classes compiled to nothing.
  • A Lockup no longer grows past its container when one line in it cannot wrap (an install command). The page stops scrolling sideways on a phone and that line scrolls inside its own box.

Header icon button, search trigger, carousel dots

v2026-10-19.3

Three new parts for site chrome and carousels. HeaderIconButton is the bare icon control for a header (a GitHub link, a menu toggle), and ThemeToggle is now drawn on it, so the two match. SearchTrigger is the field-shaped button that opens search, with its shortcut in a keycap. CarouselDots draws one dot per slide position inside a Carousel. CopyButton gains reveal="always" for a box with no group/code parent, and onCopy, called after a copy succeeds. PopoverContent gains flush, which drops its inset for a list that pads its own rows, in place of className="p-0".


A large Lockup in a Section stops padding itself

v2026-10-19.2

Lockup size="lg" added 64px above and below itself and 16px at each side. That suits a Lockup alone on an empty page, but inside a Section the band already spaces it, so a hero got both and every one needed px-0 py-0! to undo it. A large Lockup inside a Section now adds no space of its own. Anywhere else it pads as before, and a px-* or py-* you pass still wins.

Moves on screen

  • A Lockup size="lg" inside a Section loses 64px above and below and 16px at each side. If you had px-0 py-0! on it, nothing moves; you can delete that now.

Kit-look no longer ships the page review

v2026-10-19.1

kit-look no longer ships ui-md-review.mjs, the script that asked a judgment model whether a page rebuilds a kit part or skips its empty or error state. It needed its own paid key and was never part of the kit's checks. A pull does not delete a copy you already have; remove scripts/ui-md-review.mjs yourself if you do not use it. ui-md-look.mjs is unchanged.


The page scrolls over a vertical ScrollFade

v2026-10-18.1

The mouse wheel stopped moving the page while the cursor was over a vertical ScrollFade with a max height, such as WeekSchedule's hour grid. Its overscroll-none kept the scroll from ever reaching the page. The region now scrolls itself first, then hands the scroll to the page at its top or bottom. Sideways still does not bounce or chain. The 2026-10-09 fix did the same for horizontal strips.

Moves on screen

  • The page scrolls when the wheel reaches the end of a vertical ScrollFade.
  • In Safari, a vertical region may bounce a little at its ends.

Combobox trigger is a combobox to assistive tech

v2026-10-17.1

The Combobox trigger was a plain button carrying aria-invalid, which a button role does not support, so jsx-a11y/role-supports-aria-props warned in every app that used it. The trigger now has role="combobox" with aria-expanded and an aria-controls that points at its popup, which is the select-only combobox pattern and makes aria-invalid valid. The popup keeps Base UI's aria-haspopup="dialog". Props and styling are unchanged, and the aria-invalid: border and ring still apply.


CardMedia, a still Skeleton, a MediaCard description, and a blog recipe

v2026-10-16.17

New part, CardMedia: an image inside a card's edge, above its header, with a hairline under it. aspect takes landscape (default), wide or square. Use it for a linked card led by a picture where the text sits on the card, not over a faded image.

Skeleton takes still, which drops the pulse, for a block that is a drawing rather than a loading state. MediaCard takes description, a line bound to the title at the pair rung. Copy belongs there, not in meta, which sits a cluster rung away.

New recipe, copperleaf: a blog index (a lead post and a grid of posts) and an article page.


CardLinkRow, and the dashboard recipe is now a store admin

v2026-10-16.16

New part, CardLinkRow: a row in a card that links somewhere, with an optional line under the label and a count of what is waiting there. It is the "needs attention" list on a home screen (4 orders to fulfill, 3 refund requests), which the kit had no part for. kit installs it.

The dashboard recipe that get_recipe("dashboard") serves was rebuilt as a store admin with three pages in one AppShell. Home holds the numbers for one date range, a setup checklist, what needs attention and the newest orders. Orders holds the list tools: status tabs, search, a date range, more filters, paging and an order panel. Calendar holds the week. The old single page carried two tab strips and a toolbar of filters on a home screen, which is a pattern worth not copying.


AppShell: an app page's frame that works on a phone

v2026-10-16.15

New part, AppShell: the sidebar of destinations, the top bar and the page, in one frame. From md up the sidebar sits on the left at the screen's full height; below it the sidebar hides and a menu button in the top bar opens the same Nav in a drawer, which closes when a link is followed. Before this the kit had no answer for an app sidebar on a phone, so a hand-built one stayed 224px wide on a 390px screen and pushed the page sideways. kit installs it.


A scrolling Nav shows the current page

v2026-10-16.14

A horizontal Nav with more links than fit (a FloatingBar on a phone, a narrow header) scrolls sideways. It used to open scrolled to the start, so the current page could sit out of sight. It now scrolls itself so the current item is centred, once when it opens and again when the page changes. Only the Nav scrolls, never the page, and it leaves the row alone if the current item is already in view or you have scrolled it yourself.

Moves on screen

A horizontal Nav that overflows opens scrolled to its current item instead of to its first.


Pointer cursor on everything clickable

v2026-10-16.13

Anything you can click now shows the hand cursor: links, buttons, tabs, menu items, options, radios, checkboxes, switches, selects and summaries. Tailwind v4 leaves buttons on the arrow, and only some kit parts set the hand themselves, so Tabs and Nav items showed the arrow or a text cursor. One rule in the token file's base layer now covers them all. It has no specificity, so a component's own cursor (a disabled control's not-allowed) still wins, and it skips anything disabled.

Moves on screen

The cursor over Tabs, Nav items, radios, switches, menu items and options is the hand. Nothing moves.


A floating bottom bar

v2026-10-16.12

New: FloatingBar, a bar that floats at the bottom of the screen (or the top, with side="top") over a band that blurs what scrolls under it. Put a Nav or a few actions inside. Its inset is the chrome gutter plus the phone's safe area, and its height and corner follow size, which you match to the controls inside. Pages under it put floatingBarReserve({ side, size }) on their outer element so their content is not stuck behind it. It comes with kit, or install it alone: npx shadcn@latest add https://ui.mattdowney.com/r/floating-bar.json.


A quote part

v2026-10-16.11

New: Quote, a quote with who said it, built from QuoteText and QuoteAuthor (name, role, an optional avatar and company logo). plain stands on any surface, variant="card" is one testimonial in a row of cards with the names lined up, and size="lg" is a featured pull quote. mark adds a quiet quote mark. It comes with kit, or install it alone: npx shadcn@latest add https://ui.mattdowney.com/r/quote.json.


A full-screen sign-in layout

v2026-10-16.10

New: AuthLayout, the whole sign-in screen. The brand sits at the top left, the form is centred in a narrow column on the page, and an optional panel beside it sells the product: a crop of the product, a quote, a row of logos. The panel is inset with rounded corners on a neutral fill, and hides below lg, where the form takes the screen. Without a panel the form centres on the whole page. It comes with kit, or install it alone: npx shadcn@latest add https://ui.mattdowney.com/r/auth-layout.json.

AuthForm takes surface="bare" to drop its Card and stand on the page, as it does inside AuthLayout. card stays the default.


A sign-in form for a page of its own

v2026-10-16.9

New: AuthForm, the sign-in form standing on a page instead of in a dialog. It sits in a Card, its title is the page's h1, and it adds two things the dialog lacked: forgotPassword, a link beside the Password label, and error, for a failure your auth client returned instead of threw. It comes with kit, or install it alone: npx shadcn@latest add https://ui.mattdowney.com/r/auth-form.json.

AuthDialog now renders AuthForm inside its dialog, so the two cannot drift apart, and it takes the same two new props.

Moves on screen

  • An AuthDialog that showed an error and was closed now opens clean the next time, instead of still showing the old message.

Tables written in a server page get their labels and widths

v2026-10-16.8

Fixed: a Table written in a Server Component (an App Router page, by default) lost two things in silence. With stack, the phone layout showed values with no column names beside them. With layout="fixed", the declared column widths were ignored. Both now work from server and client pages alike, and the labels are in the first HTML, so a phone never sees them pop in. Toast had the same bug finding its ToastPanel, and is fixed too.

Moves on screen

  • A stack table in a server page now shows each value's column name on a phone.
  • A layout="fixed" table in a server page now honours each column's width, and its firstColumnWidth / lastColumnWidth defaults.

A dashed add tile

v2026-10-16.7

New: AddTile, the dashed "add one" tile at the end of a row or grid of thumbnails. It is a plain button, so it can open a file picker or trigger a menu. On hover, focus and while a file is dragged over it (dragOver), the dashed edge, the icon and the label turn accent with no fill. Apps were reshaping a ghost Button for this, and its hover painted an accent wash.


MCP made private

v2026-10-16.6

The UI.MD MCP server now answers only calls that carry its owner's key, so installs no longer connect it. An upgrade removes the ui-md entry an earlier pull added to your .mcp.json and leaves every other server alone. Nothing else changes: uimd-build and the AGENTS.md block use the rules file when the MCP is not connected, which they already did.


Look check

v2026-10-16.5

A new optional item, kit-look, checks a running page the way a person sees it. It renders the page at phone and desktop width, in light and dark, saves a screenshot of each, and reports a page off the kit background, stacked blocks a few pixels out of line, sideways scroll, and clipped text. Install it with npx shadcn@latest add https://ui.mattdowney.com/r/kit-look.json and run node scripts/ui-md-look.mjs <url>. It uses the Chrome on your machine, through playwright-core. The same item ships ui-md-review.mjs, which asks a judgment model whether a file hand-builds a kit part, or shows a list with no empty state or a form with no error state. It runs only on your own TypeSafe key and skips without one. The uimd-build skill runs both before calling a page done when they are installed.

Moves on screen

The default SiteFooter strip's links lose their 8px inset, so they line up with the fine print when the row wraps on a phone. On a wide screen the gap between the fine print and the first link closes from 32px to the row's 24px.


Page background, section heading edge

v2026-10-16.4

Two fixes from pages agents built with the kit.

Moves on screen

  • The page takes the kit's background and text colour. The token file now sets background-color and color on body, so a project that never added bg-background text-foreground to its own body stops rendering on plain white. A project that already sets them sees nothing change.
  • Lockup at size="section" and size="page" loses its 16px side inset, so a section heading lines up with the cards, list or FAQ under it instead of sitting 16px in from them. A className="px-0" you added to fix that is no longer needed. The empty-state sizes (sm, md, lg) keep the inset.

MCP wired into installs

v2026-10-16.3

The kit now connects its MCP server, https://ui.mattdowney.com/mcp, which tells an agent which part, recipe and rung fits the screen it is building. An upgrade run by docs/ui-md-upgrade.md adds one ui-md entry to your .mcp.json (every other server in the file is left alone) and one line about it to the AGENTS.md block. Claude Code asks once before it trusts the server; approve ui-md. The uimd-build skill now asks the MCP first and falls back to docs/ui-md-rules.md when it is not connected.


Save check, one-file checking, section padding rule

v2026-10-16.2

verify-ui takes file paths: node scripts/verify-ui.mjs app/pricing/page.tsx reports on just those files, offline, in about a second. A new optional item, kit-hooks, uses it as a Claude Code hook: each component file an agent saves is checked and the findings go straight back to the agent. Install it with npx shadcn@latest add https://ui.mattdowney.com/r/kit-hooks.json, then node scripts/ui-md-save-check.mjs --install, which adds one entry to .claude/settings.json and leaves the rest of the file alone.

New build failures

raw-section-frozen-padding lands as a warning: a plain <section> padded with a numbered vertical class of 32px or more (pb-16, pt-12). Swap it for <Section>, whose pad and padTop read the rhythm rungs, or read one rung on one side (pb-(--spacing-rhythm-section)). --strict fails on it now.


Week schedule event menu

v2026-10-16.1

WeekSchedule takes eventMenu: a function that returns menu items for an event, opened by right-click, a long press on touch, or the keyboard's menu key. It is also the touch path for moving an event, since touch does not drag. week-schedule now installs dropdown-menu.


Video controls end padding

v2026-10-15.10

Video controls with no speaker (showSound={false}) get 12px of padding on the right, so the total time sits as far from the edge as the play icon does on the left. It sat 4px from the edge before.

Moves on screen

The total time on a soundless player moves 8px in from the pill's right edge.


Video controls

v2026-10-15.9

New part, video-controls, in kit: a floating pill of controls over a video or animation player, with play, the elapsed time, a seek bar, the total and an optional speaker. It shows when the pointer moves, while paused, while seeking and while a control has focus, and hides after two idle seconds. A mouse click on the video plays or pauses; on touch the first tap only shows the pill. White on a dark scrim in both themes.

Pass a <video> ref as media, or an adapter for any other player (play, pause, seek, currentTime, duration, paused, muted, setMuted, subscribe). hotkeys turns on Space and M for a player that owns the page.


A column width for short list pages

v2026-10-15.8

A new container width, --container-column (36rem, 576px), so max-w-column exists. It is for a page that is a short list under a title, one row per item: max-w-lg (512px) reads too narrow for those rows and max-w-xl (672px) too wide. Prose still caps at max-w-measure; a page's main content column is still max-w-xl.


Lockup title and eyebrow read tokens you can retune

v2026-10-15.7

LockupTitle now reads its size and weight from tokens, one pair per Lockup size: --lockup-title-sm, -md, -section, -page and -lg, each with a -weight, and lg adds --lockup-title-lg-leading and -tracking. LockupEyebrow reads --eyebrow-case, --eyebrow-tracking and --eyebrow-weight. Set them in your own :root to match your type ramp (a smaller hero, a display weight, an eyebrow in sentence case), instead of forcing the classes with !. The defaults are the values these used before.


A centred PageHeader

v2026-10-15.6

PageHeader gains align="center" for a masthead that opens a page you browse, a blog or a shop, with no action. It centres the title and description and caps the description at the reading measure. A centred heading with a call to action is still Lockup, so align="center" does not accept action.


CardFan: photo cards and a fan inside one link

v2026-10-15.5

CardFan gains two props. variant="image" is for cards that are all picture: the card drops its outside ring for a hairline inside its edge (pure black or white at 10%) and keeps its shadow, so a photo carries one line, not two. decorative is for a fan inside a larger link: mark the link data-card-fan-trigger, and hovering anywhere on it, or tabbing to it, fans the covers. The covers take no pointer, focus or name. If you wrote your own trigger rule for this, delete it.

Moves on screen

With reduced motion turned on, the fan now opens and closes without moving between poses. Before, the open rule outranked the reduced-motion one, so the cards still slid.


A trailing value on a Select row

v2026-10-15.4

SelectItem gains trailing: a short value, such as a count, on the row's right edge. On a row that has it the check moves to the left edge to free that side, as macOS menus do. The value is muted, one size below the label, uses tabular numbers and brightens with the highlighted row. It renders outside the label, so the trigger and typeahead show only the label.


A version, a public changelog, and an update check

v2026-10-15.3

The kit now has a version: the newest changelog entry's date and its count for that day (2026-10-15.3 is the third entry on the 15th). It is written at the top of this file, so the version you are on is the one in your installed copy. Nothing to keep in step by hand: it is read off the entries.

node scripts/verify-ui.mjs --updates compares your version with the live changelog and lists every newer entry with its two cost sections, before you pull. It needs network access, changes no file, and runs no style checks. An install from before this entry has no version line; the flag matches your newest heading instead.

Every entry is also public at https://ui.mattdowney.com/docs/changelog.


Moving events on a week schedule

v2026-10-15.2

WeekSchedule gains onEventDrop(dragged, target), and events gain draggable and droppable. Drag a draggable event onto a droppable one with the mouse, or focus it, press Space, choose a spot with the arrow keys and press Space or Enter (Escape cancels). Each step is read out to screen readers. The component moves nothing itself: update events in the callback. Touch does not drag, so keep another way to move an event on touch.


A soft destructive button

v2026-10-15.1

Button gains variant="soft-destructive": soft's tinted fill in the destructive hue, for the one delete in a row of soft peers. A ghost delete beside soft buttons read as bare red text where its neighbours carried a fill. It holds its hover fill while loading and takes the solid destructive count chip, as ghost-destructive does.


Footer year, and a starter page

v2026-10-14.2

SiteFooter's copyright year now comes from a small client part, site-footer-year.ts, installed with site-footer. Reading the date during the prerender failed next build in any Next.js app with cacheComponents on, which a fresh create-next-app now turns on by default.

New starter item: a placeholder home page (site header, the project name and one line centred on the page, footer) for a brand new project. It installs to app/page.tsx, so only add it to an empty app.

Moves on screen

  • The footer's year is filled in by the browser just after the page loads, so the server HTML ends at "©". It sits at the end of the line, so nothing beside it moves.

Icon beside text

v2026-10-14.1

New rule and token for an icon you place beside text outside a kit component: it takes the box Button already pairs with that text size (11px text, 12px icon; 12-13px, 14px; 14px, 16px). The common case ships as --spacing-icon (14px) with its own utility, size-icon. See "An icon you place beside text" in the rules.

No default rule ships with it, because a CSS width beats Tabler's size prop and would shrink every icon sized that way. If you already set a default icon size in your own CSS, point it at var(--spacing-icon) and drop your own copy of the value. The rules show the one-line opt-in.


TextareaGroup

v2026-10-13.3

New TextareaGroup, TextareaGroupControl and TextareaGroupFooter in textarea: a text field with its own actions or a character count in a footer row, under a full-bleed rule, inside the field's edge. The group draws the field and its states, so the control inside renders bare. If you pinned buttons or a count over a Textarea by hand, this is the part for it.


SkeletonText

v2026-10-13.2

New SkeletonText beside Skeleton: <SkeletonText lines={2} /> holds the place of lines of text. Each line is one line of the surrounding type, so it is exactly as tall as the text that replaces it. Set the type on it or its parent the way the real text sets it. If you built text placeholders from stacked Skeleton bars with a gap, switch: those never match the text's height.


Cancel on the left

v2026-10-13.1

The Cancel button in a dialog, sheet and alert dialog footer now sits at the far left of the row, apart from the actions, so the way out never reads as one of the choices. On a phone the footer still stacks, Cancel at the bottom.

Moves on screen

  • DialogFooter showCancelButton and SheetFooter showCancelButton: Cancel moves from beside the actions to the left edge from sm up.
  • AlertDialogCancel: the same, when it is the first child of AlertDialogFooter. Placed after the action, it now pushes the action left instead, so put Cancel first.

Palette edges and key hints

v2026-10-12.5

New KbdHint and KbdGroup for a palette footer: <KbdHint keys={["↑", "↓"]}>Navigate</KbdHint> shows one cap per key, 4px apart, and puts 6px between the keys and the word. That 6px is Button's icon-to-label gap at the same text size. If you wrote a keycap and a word with a space between them, switch to KbdHint: a space is a different width in every font.

Moves on screen

  • In CommandDialog, the search field and the footer are inset 16px on both sides, the same as the rows' content. They were 12px. The search icon is 20px (was 16px), so it lines up with row icons and CommandItemMedia. The footer's last hint now ends where CommandItemMeta ends.

Palette rows: media, meta, keycaps in buttons

v2026-10-12.4

Two new parts for a result row. CommandItemMedia is a 20px square image at the start of a row (pass src, or your own image as children), with a muted fill and a hairline while it loads; alt is empty by default. CommandItemMeta is words at the end of a row, such as a topic: muted, one size below the label, with normal letter spacing. Use CommandShortcut only for key glyphs. If you built a thumbnail span or a muted end-of-row span by hand, switch to these.

The rules file now says how both are sized. Nested radius has a floor: when the parent's radius minus its padding is 0 or less, use rounded-xs. Media inside a padded row keeps the row's 8px gap rather than the 16px media rung, and is no taller than the label's line.

Moves on screen

  • In Command, a highlighted row's CommandShortcut turns foreground along with its label. It was muted.
  • Kbd inside a Button: the keycap's edge now follows the button's label colour instead of a neutral grey ring, and its text is 80% of the label (was 70%). Outside a Button nothing changes.

Command palette: top position, footer, keycaps, grey highlight

v2026-10-12.3

What a search palette needs, taken from a real one. CommandDialog takes position="top", which pins the window 12% down the screen and lets it grow downward only, so the input no longer jumps when the result count changes. It takes a footer for key hints (hidden on touch, which has no keys) and commandProps, which reaches the inner Command: pass commandProps={{ shouldFilter: false }} when a server supplies the results. CommandList takes height="tall" (60% of the screen instead of 300px), and is tall by default inside a top-positioned dialog. New Kbd is a keycap for those hints and for a shortcut inside a button label; inside a Button it takes the label's colour. If you hand-built any of these (a Dialog + Command shell to pass shouldFilter, a top offset on DialogContent, a plain <kbd>), switch to the parts.

Moves on screen

  • The highlighted row in DropdownMenu, Select, Command and Combobox is a soft neutral grey (bg-secondary) under foreground text, in both modes. It was the accent tint with accent text. Destructive menu rows are unchanged.
  • Every overlay backdrop (Dialog, AlertDialog, Sheet, Drawer) now fades on the motion roles: 200ms in and 150ms out, as before, but a retune of the large and small roles now reaches it too.

Metric card tiles

v2026-10-12.2

MetricCard takes layout="tile" for a row of KPIs that are each one number, a status and one line. It drops the header band and its divider: the label sits on top in small muted type, the badge moves to the top-right corner, then the value and the goal line. A badge in the corner does not change the row's height, so tiles with and without one line up. trend, delta, unit and children work as before. Keep the default layout for a metric with a chart or more than one line under it.


Chart

v2026-10-12.1

New Chart: one series over time that you read, where Sparkline is one you glance at. It shows a labelled scale with a faint line at each step, labelled periods, an optional dashed target line named in a key above the plot, and each period's value on hover. A null value is a gap, never a zero. Bars measure from zero and lines from the range the data covers. Marks take the text colour (text-accent by default), so one class on the root recolours a series. The plot is at least 192px tall and the labels sit outside it; a height on the root grows the plot.


Cards in a carousel keep their edges

v2026-10-11.4

CarouselContent takes ringRoom. Turn it on when the slides are Cards: the carousel used to clip each card's ring and shadow at the slide's edge, so the first and last cards lost their side edges. It gives 12px of room on every side without moving a slide or changing a snap. If you made room by hand (padding on the track, a negative margin on the viewport, a px-px wrapper in each slide), remove it and use ringRoom instead.


Media card

v2026-10-11.3

New MediaCard: a linked card led by its image. The image dissolves into the page at its foot and the title sits over the fade, with an optional meta line (a Badge) under it. Props set the image shape, how much of it fades, the title size, and whether the title and meta pin to the card's foot so meta lines up across a row.


Carousel arrows in a title row

v2026-10-11.2

Carousel arrows take placement="inline" to sit in normal flow, and a new CarouselControls part renders both of them in a row, 8px apart, for a heading's action slot. No more overriding the arrows' positioning with className. If you did (static translate-y-0 sm:left-auto), swap it for <CarouselControls />. Inline arrows stay bare chevrons on a phone, where floating ones switch to a filled chip.


Page header action no longer pushes content down

v2026-10-11.1

A PageHeader action used to set the height of the title row, so a 32px button or a pair of carousel arrows beside a short title pushed the content below about 18px further down than in a section without one. Beside the title, the action now sits centred on the title's first line and adds no height. When the row is too narrow and it wraps under the title, it takes its own space as before.

Moves on screen

  • Every PageHeader with an action and children: the content moves up to the same distance below the title as a header without an action.
  • The action itself moves from the title's cap top to centred on the title's first line.

Card fan is a gallery deck

v2026-10-09.17

The card fan now behaves one way: a calm gallery deck. The card you point at comes forward, turns most of the way level and rises; the others darken. It runs on the large motion role with no overshoot, where it used to spring open. The default fan is three portrait cards fanned sideways in a 180px box.

Moves on screen

  • Every card fan: no bounce on hover, the hovered card straightens and lifts, the others darken.
  • A fan using the default size or default cards is now 180px, with 96 by 128px cards fanned sideways, where it was 120px with 78px squares fanned upward. Pass your own slots and size settings to keep the old shape.

Card fan: settings for the card under the pointer

v2026-10-09.16

Three new card fan settings, all off by default. --card-fan-focus-straighten (0 to 1) turns the hovered card back toward level, --card-fan-focus-lift raises it, and --card-fan-dim (0 to 1) darkens the other cards while one is hovered. With the existing ease and duration settings, a fan can now behave like a calm gallery deck instead of a springy stack.


A hovered card fan card comes to the front

v2026-10-09.15

Each card's stacking order was set inline, which outranks the stylesheet, so the rule that lifts a hovered or focused card to the front never applied: the card grew, but stayed behind the cards stacked over it. The order now rides a variable the stylesheet reads, so hover and focus win.

Moves on screen

  • Hovering or focusing a card in a card fan brings it in front of the others.

Card fan cards can be links, and resize

v2026-10-09.14

A card fan's card can now be a link: give its slot a render, such as <Link href="/posts/one" />, and it opens that page. Without one it stays a button. The fan also resizes: --card-fan-size sets the box, and --card-fan-card-width and --card-fan-card-height set each card, so a fan of portrait covers no longer has to be a 78px square. The defaults are the old sizes.

Moves on screen

  • A card clips its content to its own corners, so an image inside one is rounded. A card with no content looks the same as before.
  • A focused card shows a focus ring and comes to the front.

Confetti lands on more than one thing

v2026-10-09.13

useConfetti takes a list of elements as its third argument, as well as one. Flakes land on the first one they fall onto. An element with a box of its own (a button, a card, an image) catches them on its top edge, rounded only as much as its own corners are. Text catches them on the letters: each letter's top outline, so flakes rest on the shapes and drop through the gaps between them.

Moves on screen

  • A square-cornered target catches flakes on a flat top. It used to be read as a pill whatever its corners, so a tall image caught flakes inside itself. A fully rounded button lands them exactly as before.
  • A heading or text target catches flakes on its letters, not on the empty top of its box.

A tab strip that scrolls moves as one piece

v2026-10-09.12

When an overflowing tab strip brought the new tab into view, it scrolled at the browser's smooth-scroll speed while the pill slid on its own, faster spring. The two fought: the labels slid under a pill that barely moved, and the strip kept drifting after the pill had landed. The strip now scrolls on the pill's spring, so pill, labels and strip land together.

Moves on screen

  • Switching tabs on a strip wider than its space: the strip and the pill now move as one and stop at the same moment. A wheel or touch on the strip during the glide stops it. A strip that fits is unchanged.

Lockup side by side, placed

v2026-10-09.11

Lockup takes align="end", and layout="side" now reads align: it places the media and copy together at the start, centre or end of the lockup's width. Beside the media the copy stays left-aligned; once it stacks, it follows align. A side lockup with no align still starts. In the stack, end right-aligns the text and the action row.


Lockup side by side

v2026-10-09.10

Lockup takes layout="side": the media in its own column, and the eyebrow, title, description and actions together in a column beside it. Built for a success screen with art beside the message. sideAlign="center" centres the copy on the media; the default lines it up with the top. Under 24rem of lockup width it stacks, media on top and copy under it, left-aligned. The stack stays the default.


Tabs with more tabs than fit

v2026-10-09.9

A tab strip wider than its space now scrolls cleanly, fades its edges, and keeps the active tab in view.

Moves on screen

  • The first tabs of an overflowing strip are no longer cut off past the left edge where no scroll could reach them.
  • An overflowing strip fades at each edge that has more tabs past it: the right edge at rest, both once you scroll, the left alone at the end. A strip that fits shows no fade.
  • When the active tab changes, the strip scrolls it toward the middle.
  • TabsList now renders a wrapper (data-slot="tabs-list-track") that holds the border, fill and corners, with the scrolling list inside it. Your className on TabsList goes on the wrapper. If you styled [data-slot="tabs-list"] directly for its border or fill, move that to the new slot.

PageTitleRow fits narrow rows

v2026-10-09.8

PageTitleRow now reacts to its own width instead of the screen's, so a row in a narrow pane behaves like a row on a phone.

Moves on screen

  • A search field and a button in action no longer run past the row's edge when space is short. Under 42rem of row width the actions take the room the title leaves, and a search field grows to fill it beside the button. When they do not fit beside the title, they drop to the next line together.
  • With center (a tab set between title and actions), the row switches to three columns at 32rem of row width rather than at the sm screen width. Below that, the title and actions share the first line and the tabs take the next. Above it, the outer columns split what the tabs leave, so the tabs stay centered and no longer overlap a long title.

The gap under a page title row

v2026-10-09.7

The app page rule put PageTitleRow and the page body in one <Stack gap="region">, which left about 70px between the title and the first thing under it. The gap now depends on what the row titles. Over one surface, a single table or list, it is cluster. Over a group of blocks, a metric grid or several sections, it is the heading step: heading-lg under a page row, heading-md under a section row. The body keeps region between its own blocks. "Page composition" in the rules shows it.


A pinned table column keeps its row lines

v2026-10-09.6

On a narrow screen, a Table that scrolls sideways pins its first column. That column's fill could cover the line between rows, so on some tables the row lines stopped short of the first column. They now run the full width.

Moves on screen

  • Row lines under a pinned first column now show on every table.

The page scrolls over a table

v2026-10-09.5

The mouse wheel did nothing while the cursor was over a table, because the table's sideways scroller swallowed up and down scrolling. It now passes those through to the page. Sideways scrolling still does not bounce or chain. A vertical ScrollFade is unchanged.

Moves on screen

  • The page now scrolls when the wheel is over a table or a horizontal ScrollFade.

`PageTitleRow` gets a section size

v2026-10-09.4

PageTitleRow takes size="section" for a titled group inside an app page, such as a review queue's "Pending" and "Done", each over its own table. It is an h2 at text-base in a 32px row, one step under the page title's text-lg in a 40px row, with the same slots: the count goes in titleBadge and never changes the row's height. "Page composition" in the rules says how to lay it out: a lone search beside the action, a set of filters in a TableToolbar under the row, cluster from the row to its table, peer from one section to the next, and no Card.


Button loses the `outline` variant

v2026-10-09.3

Button no longer has variant="outline". It was an accent border on a transparent fill, and it sat next to soft and subtle as a third way to say "not the primary". Replace every outline Button with soft. There is no alias and no grace period. Badge keeps its own outline variant; that is a different component and did not change.

Moves on screen

  • WeekSchedule's Today, previous week and next week buttons change from outline to subtle: a neutral tinted fill instead of an accent border.
  • The Button docs page, the examples and the pricing rule in the rules file now show soft and subtle where they showed outline.

New build failures

  • variant="outline" on Button no longer exists, so TypeScript fails on it, and the same goes for buttonVariant="outline" on a Calendar or any component of yours that forwards the variant to Button. Replace it with soft. Run node scripts/verify-ui.mjs to list every call site.
  • New warning button-outline-removed: names each outline Button, buttonVariants({ variant: "outline" }) and Calendar buttonVariant, including the ones TypeScript cannot see. Badge's outline is not flagged.

Expressive motion, and nine recipes

v2026-10-09.2

New: a third motion role for moments, the rare times something finishes, arrives or gets celebrated. Five tokens: --ease-expressive, --ease-expressive-spring, --ease-expressive-pop, --transition-duration-expressive-in (350ms) and --transition-duration-expressive-out (250ms), as the classes ease-expressive* and duration-expressive-*. "Expressive motion" in the rules says when a moment earns them, and no kit component uses them.

Also new: nine finished recipes that read those tokens, as optional items outside kit: spinner-to-check, dot-field-reveal, image-zoom, dissolve, confetti, card-fan, fan-menu, shimmer-surface and gradient-text. Install one by name, or all nine with kit-motion.json. Each lands in components/motion/ with its own stylesheet.


`brand`, a second colour family

v2026-10-09.1

New: a brand colour family for expressive moments: gradient text, an upgrade button's glow, a premium badge, illustration, confetti. It is built like accent, with eleven steps (bg-brand-50 to bg-brand-950) at the same lightness and chroma per step, on its own hue, --color-brand-h (306.54, a violet). --brand is the 500 step in both modes, and --brand-foreground picks a black or white label the way --accent-foreground does. Accent stays the action colour. "Two colours: accent acts, brand expresses" in the rules says which goes where. If you already declare your own --brand in your CSS, yours still wins, since it sits below the kit import.

New build failures

  • None. New warning brand-on-control: a brand colour in the className of a kit control (Button, Input, Textarea, Select and Combobox triggers, Checkbox, Switch, Radio, Slider, Tabs, Nav items, Segmented control). A shadow-brand-* glow is not flagged. Escape hatch: ui-md-allow-brand-control.

A gap log, and the screen before the parts

v2026-10-08.1

When the kit has no part, variant or rule for what a screen needs, your agent now writes it down in docs/design-gaps.md, along with every ui-md-allow-* or motion-exempt comment it adds. The kit never ships or overwrites that file. Read it now and then: it is the list of what the kit should add next, and sending it upstream is how it gets added. "When the rules do not cover it" in the rules gives the entry format.

The uimd-build skill also now picks the whole screen (its recipe and skeleton) before the parts, states that plan before writing code, and ends with a short report: the parts used, anything built by hand, what was logged, and decisions for you.


Page composition rules; `SiteHeader` text actions stop wrapping

v2026-10-05.8

docs/ui-md-rules.md gains a "Page composition" section: the skeleton for a marketing page, an app page and a reading page, built from parts the kit ships, plus rules for content width, centring, the hero, feature rows, pricing, logo rows and app list pages. The job router sends marketing pages and product screens to it.

Moves on screen

  • Items in SiteHeader's right-hand slot no longer wrap. A crowded bar used to squeeze a two-word link like "Log in" onto two lines at phone width; it now stays on one line, and a bar that still does not fit overflows instead. If yours does, hide the text actions below md (hidden md:inline-flex) and move them into SiteNav, which Page composition in the rules now says. In the default navPlacement="start" layout the slot's children now sit inside one wrapper div, with the same 16px gaps as before.

Display type carries its own letter-spacing

v2026-10-05.7

Moves on screen

  • Your own text-display and text-jumbo text is set slightly tighter: -0.025em instead of the body's -0.01em. Kit parts look the same (Lockup's large title already used that value). If you added tracking-tight next to text-display or text-jumbo, you can delete it.

Surfaces keep their edge in high contrast mode

v2026-10-05.6

Moves on screen

  • Only in Windows high contrast mode: cards, popovers, menus, dialogs, sheets, drawers, badges with an edge, and code blocks in Prose now show a 1px outline. Before, their edge was a box-shadow, which high contrast mode removes, so they had no edge at all. Normal rendering is unchanged.

Motion roles: small and large

v2026-10-05.5

New tokens: --transition-duration-small, --ease-small, --transition-duration-large and --ease-large. Set one in your own CSS to speed up or slow down the whole kit at once.


Combobox can be disabled or invalid; read-only fields look read-only

v2026-10-05.4

Moves on screen

  • A read-only Input or Textarea now sits flat on the muted fill with no shadow. It used to look exactly like an editable field.
  • Combobox takes disabled (dims to 70% and will not open) and aria-invalid (destructive border and ring, as on Input and Select). Nothing changes until you pass them.

Focus rings and switch tracks you can see

v2026-10-05.3

Moves on screen

  • The focus ring is darker in light mode and lighter in dark mode. --ring-focus moved from neutral-300 (light) and neutral-700 (dark) to neutral-500 in both. The old ring was under 2:1 against the page and failed the 3:1 a keyboard user needs to find focus.
  • An off Switch has a darker track in light mode and a lighter one in dark mode, for the same reason: neutral-500 in both, up from neutral-300 / neutral-700.
  • If you set --ring-focus in your own CSS, keep it at 3:1 against --background and --card in both modes.

The token file lands in `src/app` when that is where your app is

v2026-10-05.2

A project that keeps its app in src/ got ui-md-tokens.css written to a new app/ folder at the project root on every pull, beside the real one in src/app/, which was never updated. The kit now writes it to src/app/ in a src project and app/ otherwise. If a pull left an app/ui-md-tokens.css at your project root and your app lives in src/app/, delete it, and pull once more so src/app/ui-md-tokens.css is current.


A scroll region no longer shears a ring at its edge

v2026-10-05.1

Moves on screen

  • A vertical ScrollFade no longer cuts off a ring drawn outside a child's box. A Badge (its 1px edge), a focused Button (its 3px ring) or a focused field sitting flush against the region's left or top edge used to lose that side. The region now reaches 4px past its box and pads it back, so the content does not move and the ring shows whole. Seen first on a Badge at the top of a Sheet body.
  • The vertical scroll thumb sits where it did. A horizontal ScrollFade (a Table, a CodeBlock) is unchanged.
  • If you added padding inside a ScrollFade to clear a ring yourself, delete it.

Phone widths

v2026-10-02.3

Every example in the kit was checked at 390px and 320px wide. Five components changed how they behave when there is not much room.

New: Table takes stack. Below 32rem of table width each row becomes a title line plus one labelled line per cell, labelled from the header row; for lists of records. ScrollFade now publishes --sf-scroll-left / --sf-scroll-top, how far the region has scrolled (capped at the skim).

Moves on screen

  • PageHeader: with an action, the action drops under the description when the title would get narrower than 240px beside it. It used to stay on the right and squeeze the title to a word or two per line.
  • Pagination: when the numbered row does not fit, it turns into a compact row: previous, "Page 3 of 10", next. The switch point depends on siblings and reads the pager's own width, so a pager in a narrow card switches too. The pager now always fills its row (w-full), which is what it measures: in a flex row it no longer hugs its content, and with no count or page size it centres in the space it takes.
  • Carousel: below sm, the default (bare) arrows sit inside the slide, so they now take the chip look there: a round filled button. Half-opacity chevrons on top of artwork could not be seen.
  • Table: a narrow table fits before it scrolls. Below the xl container width (42rem) the column minimums drop from 160 / 128px to 112 / 80px, so a short three-column table on a phone no longer scrolls. A table that still does not fit scrolls inside a horizontal ScrollFade with its first column pinned: the cut edge fades, and a hairline appears on the pinned column only while content slides under it. table-container no longer scrolls itself. A stickyHeader table, and a surface="bare" one for the pinning, are unchanged. Installing table now also installs scroll-fade.
  • TableToolbar: below the xl container width (42rem), the controls wrap with the rest of the row, and a search field grows from a 224px floor instead of keeping its fixed width. It used to squeeze to a cut-off placeholder beside a select.

A button that swaps its face at one width

v2026-10-02.2

New: SwapButton, a button whose icon and label change with a state. Every face is stacked in one cell and centred, so the button is as wide as its widest face and never changes width. The new face fades in; the old one drops out at once. CopyLabelButton is now built on it.

Moves on screen

  • CopyLabelButton: the shorter face is now centred in the button instead of sitting against the left edge. Its gap and icon-side padding are unchanged: SwapButton reads both from the same tables Button uses (buttonGap and buttonIconSidePadding, now exported from button-variants).

A copy button with a label

v2026-10-02.1

New: CopyLabelButton, a labelled button that copies text and confirms in place. The copy icon fades to a check and the label changes to Copied, then both return. The button holds the wider label's width, so nothing beside it moves. CopyButton is unchanged and stays the icon-only control for code samples.


One name per prop idea

v2026-10-01.5

Eight props had two names for the same idea across components. Each now has one. The old names still work and are marked deprecated, so your editor will point at them. They will be removed in a later release.

  • EditPopover: avoidCollisions is now collisionAvoidance, the same object Popover and DropdownMenu take. avoidCollisions={false} still maps to { side: "none", align: "none" }.
  • Button: badge is now count, and badgeClassName is now countClassName, matching Nav and Tabs.
  • PageHeader: as="h2" is now level={2}, matching Lockup and Faq. It takes any level from 1 to 6.
  • PageTitleRow: actions is now action, matching Card, PageHeader and MetricCard.
  • SiteHeader: hairline is now divider, matching Section and SiteFooterRow.
  • SegmentedControl: options is now items, matching Combobox.
  • SelectionCardGroup: type is now mode, matching Calendar.
  • showToast options: dismissAfter is now duration, matching Sonner.

Tables on a phone scroll instead of squeezing

v2026-10-01.4

Moves on screen

  • A Table text column no longer narrows to one word per line on a small screen. Text columns keep at least 128px, and the first column at least 160px, so the table scrolls sideways instead. On a wide screen nothing changes, since columns are already wider than that.
  • Numbers, centred columns, checkbox columns, an empty actions header, and any column with its own width keep their old sizing.
  • Default-density cells have 16px of side padding, down from 24px, when the table itself is under 576px wide. A table in a narrow sheet gets this too, not just a phone.

Code blocks scroll sideways under the kit's own scrollbar

v2026-10-01.3

ScrollFade takes a new orientation prop ("vertical" default, or "horizontal") and fade accepts "start" and "end". Vertical behavior is unchanged. code-block now depends on scroll-fade.

Moves on screen

  • A CodeBlock line that runs past the edge now scrolls under the kit's overlay thumb (fades in on scroll, out after a second, draggable) instead of the browser's scrollbar, with no reserved gutter.
  • The text fades out toward the right edge so it no longer shows behind the copy button, and a left fade appears once scrolled. At the end of the scroll the right fade is gone.
  • Every CodeBlock has 48px of right padding, up from 16px (on touch it was already 48px). The last characters scroll clear of the button.

Week schedule: event details, its own scrollbar, even gaps

v2026-10-01.2

Clicking an event opens its details in a popover beside it: the title, the full day and time, meta, and the new description field. A popover, not a dialog, because it is detail about one thing on the calendar, and the calendar should stay in view and usable around it. eventDetails={false} turns it off for a page that opens its own Sheet from onEventClick. With neither, events stay plain content rather than buttons that do nothing.

Moves on screen

  • Every timed event in WeekSchedule sits 2px clear of the hour line above AND below it. It used to touch the line under it, and a neutral event touched both, because its edge ring painted outside the box. The ring is now drawn inside.
  • All-day bars are 20px, the height of a one-line timed event.
  • The hour grid scrolls with the kit's ScrollFade thumb instead of the browser's scrollbar.

The kit ships a week schedule

v2026-10-01.1

WeekSchedule is new, and it is in kit. A calendar week: seven days side by side on an hour grid, with each event placed and sized by its times, an all-day row across the top and a line at the current time. A meeting calendar, a content calendar, bookings or shifts. Calendar is a date picker and was never the answer to this.

<WeekSchedule
  week={week}
  events={[{ id: "1", title: "Design review", start: new Date(2026, 9, 6, 10, 30), end: new Date(2026, 9, 6, 12) }]}
  onEventClick={(e) => setSelected(e.id)}
  onWeekChange={setWeek}
/>

Events that overlap split the column side by side. Under 768px of its own width it becomes an agenda list by day, so a phone never scrolls sideways. Pass onWeekChange and the header gets Today, previous and next. selectedId is an accent ring on the event, so opening its detail moves nothing.

Pass timeZone on any server-rendered page. Without it the server draws the week in its own zone and the browser in the viewer's, and React throws the first render away.


A link styled with buttonVariants loses its side padding

v2026-09-30.4

Moves on screen

  • A text link painted with buttonVariants({ variant: "link" }) loses 16px of side padding. <Link className={buttonVariants({ variant: "link" })}>, the idiom for a link in a Server Component, kept the default size's px-4 because the class list was joined, not merged, and Tailwind emits px-4 after p-0. A "Forgot password?" link beside a label therefore sat 16px short of the field's right edge. It now lines up with the edge. Button was never affected. If you added px-0 or -mr-4 to compensate, delete it.
  • buttonVariants now returns the same merged class list Button builds, so a link painted this way matches a Button of the same variant and size. Where two classes used to fight (the link rule's height, padding and corner against the size's, or a stray text-xs under the size's own text size), only the winner is left. If a link painted this way changes size, that is the cause. A className you pass still wins over the variant.

Clearer component index lines

v2026-09-30.3

New build failures

None.

The component index in docs/ui-md-rules.md now says when to reach for seven parts it described too loosely: SiteHeader (a marketing or docs top bar, not an app shell) and Nav (the sidebar of an app shell), SiteNav (the phone menu, not a bare Drawer), Brand (mattdowney.com projects only), Toaster (mount once, for showToast), Toast, Hr and Separator (each names the other), and Badge. No component code changed.


A form in a card gets room, and field icons grow

v2026-09-30.2

Moves on screen

  • A form in a card sits 8px lower. CardContent directly under a borderless CardHeader used to start 8px below the description, whatever it held. That is right for prose, and it put a form's first label on the description as if it were one more line. When CardContent holds a field (an input, textarea, select, switch, checkbox, radio group, Label, or a Select/Combobox trigger), the gap is now 16px, the cluster rung. Prose still gets 8px, and a bordered header still gets the full 20px. If you reopened this gap yourself with [[data-slot=card-header]:not(.border-b)+&]:pt-*, the kit's rule now outranks it for a form: delete yours.
  • Input icons follow Button's size ramp. iconStart and iconEnd were a flat 12px at every size. They are now 12 / 14 / 14 / 16px across xs / sm / default / lg, the same as a Button of that size. A glyph inside actionEnd (a reveal-password or copy button) is resized to match. Text next to an icon moves over to keep the 8px gap: 32px (unchanged) at xs, 34px (was
    1. at sm and default, 36px (was 32) at lg. Text beside actionEnd does not move.

A copy rules file, a job router, and screen recipes

v2026-09-30.1

New build failures

None.

Three additions to the rules an agent reads. No component or token changed.

  • docs/ui-md-writing.md is new. It holds the portable rules for interface copy: verb-first button labels, sentence case, how to word an error, an empty state with one next action, and a confirmation that repeats the consequence instead of asking Yes or No. If your project has a docs/voice.md, it wins for wording. The AGENTS.md block gains two lines pointing at both, and it is replaced on this pull like any other.
  • docs/ui-md-rules.md gains a "Where to start, by job" table under "Start here", and a "When the rules do not cover it" section. The uimd-build skill now sends an agent to the table instead of a fixed list, and asks it to name any call no rule covered.
  • The component index now lists whole-screen recipes (settings, dashboard, landing), each linking to a working page on ui.mattdowney.com and naming the kit parts it is built from.

`CardFooter` takes a `size`

v2026-09-25.1

New build failures

None.

CardFooter gains size="compact": a bordered footer with 12px above and below its actions instead of 20px, so the band is 52px with a sm button rather than 68px. It is for a dense grid of cards such as a dashboard, where that band repeats under every card. Reach for it instead of overriding the footer's padding through className.


Bar `Sparkline` keeps thin bars on a short series

v2026-09-24.3

Moves on screen

  • A bar Sparkline (including a MetricCard trend) with fewer than 12 points no longer stretches its bars across the card. Each bar keeps the width it would have at 12 points, and the bars sit against the right edge, newest last. Before, a short series drew wide bars whose rounded ends turned into ovals. Series of 12 or more points look the same.

`MetricCard` goal line stays on one line

v2026-09-24.2

Moves on screen

  • A MetricCard (or MetricCardValue) goal line too long for its card now ends in an ellipsis instead of wrapping to a second line. Short goal lines look the same.
  • If you shortened a long goal line yourself with truncate, remove it: its overflow-hidden cut the bottoms off letters like p and g, because the line is cap-trimmed. The kit now does the shortening without that clip.

`SelectionCard` gains a `media` variant

v2026-09-24.1


Autofilled fields stop turning blue

v2026-09-17.2

Moves on screen

  • An autofilled input, textarea, or select no longer shows Chrome's pale blue fill on a non-white surface. The token file clips the browser's forced background to the text (-webkit-background-clip: text) and fills the glyphs and caret with --foreground, so the field keeps its own bg-input-bg, shadow-xs, and focus ring. This is not the classic box-shadow: inset autofill hack: that hack paints over an element's own box-shadow, and Input carries one (shadow-xs) plus a focus ring (also a box-shadow in Tailwind v4), so it would have silently deleted both on autofill.

`Lockup` gains a `page` size

v2026-09-17.1


Text colour follows the text's job

v2026-09-15.1


A code block or table inside a list item in `Prose` gets room

v2026-09-11.1

Moves on screen

A code block or table nested in a list item now sits 12px clear of the text above and below it. It had no gap at all: the block's spacing only reached figures that are direct children of the Prose root, so a fenced block under a bullet drew its ringed edge flush against the bullet's line. Top-level code blocks and tables, and the 8px between list items, are unchanged.


`SegmentedControl` joins the control ramp

v2026-09-08.1

Moves on screen

Every SegmentedControl is shorter, squarer, and its selected option is accent-tinted instead of a raised white thumb. The widget now measures the same as a Button of the same size name, 28 / 32 / 40, where it measured 38 / 42 / 50 before. It used to size its options to the rung and let the track add padding on top, the way Tabs does, so a sm control stood 10px taller than the sm Button in the same title row. Corners come off the action ramp (rounded-action-sm / rounded-action / rounded-action-lg, options one step in) rather than rounded-full, so it reads as the same shape family as the button beside it. The thumb is now Tabs' indicator, accent-100 in light and accent-800 in dark under an accent label, with no shadow; the track is the same neutral well a TabsList sits in. iconOnly options are no longer forced square, since that rule only existed to keep a circle from reading as an oval. The onMedia tone keeps its black and white but takes the same radius and loses the thumb shadow. If a control was placed with a hand-tuned offset to line up with its neighbours, that offset is now wrong: delete it.


Inline code in `Prose` is the docs' accent chip

v2026-09-04.2

Moves on screen

Inline code inside Prose now renders as the same chip the docs site uses for prop names and class names: accent tint (accent-100, accent-900 in dark), accent label, medium weight, 0.85em of the surrounding text. It was a neutral grey chip that inherited the sentence's size, so a code word in rendered markdown and the same word in an API table on the same page read as two systems. A monospace face also sets wider and taller than the sans at the same nominal size, so the inherited chip read a step large. Code blocks are untouched; they set their own size.


Vertical Stepper rail fills from `value`, not item state

v2026-09-04.1

Moves on screen

A vertical Stepper's connector below a step now fills whenever that step is below value, matching the horizontal rail. It used to fill only when the item's own state resolved to completed, so the documented state override (re-reading a finished step as active) greyed the segment under that step while later segments stayed green, which read as the flow having skipped one. A stepper that never passes state renders exactly as before.


ThemeToggle stops drawing its own icons

v2026-09-03.4

Moves on screen

ThemeToggle no longer animates, and it no longer draws its own glyphs. It imports IconSunHighFilled, IconMoonFilled and IconKeyboardFilled and swaps between them instantly. Same box, same sizes, same cycle.

It used to paint one circle that scaled up while a masking circle slid across it to carve a crescent, with eight rays retracting into it. That drawing was a hand-traced copy of Tabler's path data, and a copy has to be re-traced every time the set moves. Nothing gates one against the real glyph: it had already drifted into a visibly different sun with typecheck, verify-ui and the build all green. Importing the icons removes the whole class of problem.

Two of the three glyphs also changed. The sun was IconSunFilled, whose rays are short stubs running r=7 to r=9; it is now IconSunHighFilled, whose rays run r=7 to r=11, so the sun reads larger at the 12-16px sizes a menu row and a segmented pill actually use. system was IconDeviceDesktopFilled and is now IconKeyboardFilled: the row means "whatever this machine is set to", and half the machines reading it have no monitor to draw.

The system dot is gone. When the preference is system the button now shows the keyboard, the same glyph the segmented and menu forms put on that row. The dot only existed because a two-ended animation had nowhere to put a third state, and the docs already called it weak signage.

resolvedTheme is now optional. The glyph is a function of the stored preference alone, so nothing breaks if you drop it. Keep passing it and the accessible name still reports "system (dark)" instead of just "system". Existing call sites need no change.

theme-toggle now declares @tabler/icons-react, which is already in the kit's universal set, so it stays in kit and pulls in nothing new.

If you built the settings or menu form from the docs, swap those two imports too, or a stale IconSunFilled sits beside the updated button.


Where the theme control goes when it is not in the header

v2026-09-03.3

The surface picks the theme control, and there are three of them. The kit shipped ThemeToggle and said only that it switches light and dark, so the one question every app actually asks, where does it go, had no answer anywhere. A header is the easy case. An app with a sign-in puts the control in the account menu instead, and nothing said what it should look like there.

  • Header or other bare chrome: ThemeToggle. One tap, no label, the glyph is the state.
  • Inside a menu (account, overflow): a DropdownMenuSub labelled "Theme" holding a radio group of Light, Dark and System. Not ThemeToggle. A menu is a column of named rows, so an icon-only cycle button is the one thing in it with no name, sitting under "Billing" and over "Sign out".
  • A settings surface: a SegmentedControl over the same three values.

Ship one of the three per app, not two. If there is an account menu, the theme lives in it and not also in the header: two visible copies of one preference is the defect, not the convenience.

A correction, and it is the reason this entry exists. The doc on DropdownMenuSwitchItem called a "Dark mode" switch its canonical case. That was wrong for most apps and is now fixed in the source. A switch is a boolean and a theme preference usually is not: the moment someone flips it the value is a side, and no row is left that hands the choice back to the OS. If you built a "Dark mode" switch row on that advice and your app supports system, that row is a one-way door. The switch is right only where the app stores light and dark and never asks the OS at all.

All three forms use the same glyphs, and they are the filled ones: IconSunFilled, IconMoonFilled, IconDeviceDesktopFilled. This is the one place the kit leaves its outline icon set, and it is forced rather than chosen: ThemeToggle carves its moon by masking a disc, and masking a stroked circle leaves an open-ended arc instead of a crescent. The component draws those two icons rather than importing them, because it animates between them, but it now paints their exact geometry at both ends. If you built the settings form on the earlier docs it used the outline IconSun and IconMoon; swap them for the filled pair, or a hollow sun sits next to a solid one on the same screen.

The rules file carries the ruling under Component conventions, the component index entry for ThemeToggle now names the surface it belongs to, and the Theme Toggle page has an "In a menu" example built from the shipped submenu parts. No new component: every part of all three forms already shipped.

Moves on screen

ThemeToggle's sun and moon are redrawn to Tabler's exact geometry, and this is the one thing in the entry you will see without touching your code.

Its rays were eight stroked lines that, with round caps, reached from the disc's edge out to r=9, so they touched the disc and the sun read as one solid spiky star. IconSunFilled draws detached stubs from r=7 to r=9, with a clear gap. The two were visibly different suns the moment anyone put the segmented form next to the button, which is how this was caught. The moon grew to match too: its disc now scales to IconMoonFilled's r=10 outer circle rather than to 7.5, cut by the same 6.5 circle Tabler uses.

The sun therefore reads slightly lighter and the moon noticeably larger. Nothing about the box, the sizes, the animation or the API changed.


The kit ships a theme control

v2026-09-03.2

ThemeToggle is new, and it is in kit, not kit-extras. The kit has shipped a light and dark token layer since the beginning and nothing to drive it, so every project built its own switch. This is that switch.

It is drawn as ONE shape rather than two. Almost every theme toggle stacks a sun and a moon in one box and crossfades them, which shows you both glyphs at once halfway through. Here a single circle grows while a masking circle slides across it to carve the crescent, and the rays retract into the disc: a sun and a moon are the two ends of one animation. Transform and opacity only, 200ms, and it holds still under prefers-reduced-motion.

It is controlled, and that is why it is in the core. It takes theme and resolvedTheme and hands you the next value through onThemeChange, so it declares no theme library and forces no package on you. Wiring it to next-themes is about six lines, and the doc page has them.

<ThemeToggle theme={theme} resolvedTheme={resolvedTheme} onThemeChange={setTheme} />

Pass null for both until the client has read the stored preference. Every theme library reads it on the client, so the server renders one theme and the first client render has another. Guess, and the wrong glyph paints and then morphs on load, which is a visible flip on every page view in the losing theme. null gives you the same full-size box with no glyph and the button inert: no layout shift, no flip, and no placeholder for you to build.

system is opt in, through themes. The default is the plain light/dark swap. Pass themes={["light", "dark", "system"]} and the same button gains a third stop, marked with a dot on the glyph's corner. This is worth taking if your app has a system default: with a binary toggle, someone who clicks once can never hand the choice back to the OS again.

Be honest about what that dot can carry, though. A cycle button cannot say "System" out loud, and a dot is weak signage for a word. Where the third choice matters (a settings page, an appearance panel), compose SegmentedControl instead and let each option name itself. Same three values, same setter, and nothing new to install:

<SegmentedControl
  value={theme}
  onValueChange={setTheme}
  options={[
    { value: "light", label: "Light" },
    { value: "dark", label: "Dark" },
    { value: "system", label: "System" },
  ]}
  aria-label="Theme"
/>

The button is the chrome form, the pill is the settings form. Both are on the doc page.

SegmentedControl options now take an icon, and the control takes iconOnly. That is what makes the pill above work at toolbar width. An icon renders before the label; iconOnly drops the visible labels and keeps each one as that option's accessible name and hover title, so a bare control is never a nameless one.

<SegmentedControl
  value={view}
  onValueChange={setView}
  options={[
    { value: "grid", label: "Grid", icon: <Grid /> },
    { value: "list", label: "List", icon: <List /> },
  ]}
  iconOnly
  aria-label="View"
/>

iconOnly is a GROUP-level prop, unlike Tabs, which derives icon-only from a trigger having no children. A segmented control is read as one row of peers, so a mix of labelled and bare options is a defect rather than a layout to support.

Icon options also part from Tabs in one measurement: they take an explicit width equal to their height (28 / 32 / 40) rather than symmetric padding. A Tabs trigger is rounded-md, where a slightly oblong icon pill reads fine; these are rounded-full, where anything but a square renders as a visible oval sitting in a row of circles.

Every other value on an option is the one a Tabs trigger uses at the same rung, so a segmented control and a tab strip in one toolbar stay the same object at the same scale.

The BOX reads the icon-only rungs of the action ramp, so size="sm", default and lg are 24 / 32 / 40px and line up with a Button of the same rung beside them. The hit area grows past 44px on a coarse pointer through a pseudo-element, so a phone gets its target without the bar reflowing.

The GLYPH deliberately does not read Button's ramp. Button paints a fill, so its 12 / 14 / 16 sits inside a visible pill and the space around it is that pill's padding. This control paints no fill, so the box is invisible and the glyph carries the whole read: it runs 16 / 20 / 24, about 62% of the box, which is where the kit's other bare icon controls already sit. If you are building your own fill-less icon control, that ratio is the one to copy, not Button's.

Ray stroke comes from --stroke-icon, so retuning that token moves this glyph with every other one. Painted ink is strokeWidth * box / 24, which is why a hand-set number here would silently stop matching its neighbours the moment the token changed.

Three motions, three easings, and they are not interchangeable. The sun-to-moon morph is ease-in-out: the glyph is already on screen and both ends of the move are visible, the same call Progress makes for its bar. The system dot is ease-out, because it arrives and leaves rather than travelling. The press is transform inside the transition list at 150ms, so it eases exactly as the Button beside it does, at the kit's 0.98 press depth. Under prefers-reduced-motion the morph and the dot stop dead, the press scale is removed outright, and only the colour fade survives, which is the split the rule asks for: colour carries meaning, movement does not.

Moves on screen

Nothing, in practice. ThemeToggle is a new component and no existing markup renders it, and the SegmentedControl additions below are opt in: an option with no icon renders exactly the classes it did before.

One edge worth naming rather than burying. A segmented option now sizes any svg inside it (size-3 / size-3.5 / size-4 by rung, skipped where the icon sets its own size-*). label is typed string, so this reaches you only if you were passing an element through it from untyped code. If you were, give that icon an explicit size-* or move it to the new icon field.


The horizontal stepper gets a measure, and its rail gets one colour

v2026-09-03.1

Moves on screen

A horizontal Stepper label now wraps inside a fixed measure, 128px at the default size and 112px at size="sm", instead of sitting in a zero-width box that could hold only one short line. The box still gives back exactly as much margin as it takes in width, so it contributes nothing to the row and the dots stay evenly spaced with the first flush left and the last flush right. Nothing about your markup changes.

Take this one if you have ever put more than one word in a horizontal StepperTitle, because until now it did not render the way it looked in the code. whitespace-nowrap on the label box and text-pretty on StepperTitle are both shorthands over the same longhand, text-wrap-mode. The box set it to nowrap, the title set it back to wrap, and a declaration on an element beats the value it inherits: the title was free to wrap, and in a zero-width box it wrapped once per space. A three-word title came out as three stacked lines. Under the measure it wraps where a reader would put the break.

The measure is fixed and does not grow with the container, so a label longer than a phrase still belongs in orientation="vertical". That was always true; it just used to fail by overflowing into the next step rather than by wrapping.

The completed step's ring, its check, and the connector behind it are now one colour, success-500, in both themes. The ring used to be a success-300 around a success-500 check, which is two greens in one 32px circle for no reason a reader could name, and the 300 was also the weakest mark on the light page at 1.66:1. If you had themed --color-success-300 to tune that ring, it no longer reaches the stepper; theme the 500 instead. Completed steps still recede, because they are still an outline on the page background with no fill: the quiet comes from how little ink they use, not from how little contrast.

Worth knowing if you are auditing: the 500 measures 2.85:1 against the light background, just under the 3:1 that WCAG 1.4.11 asks of a graphic carrying meaning. That is a chosen trade, and the state does not rest on colour alone. A completed step swaps its numeral for a check, which is a shape change, and the indicator's screen-reader text says "Completed:" outright. If you need the light rail to clear 3:1 on its own, override all three parts to success-600 together.

The connector running into the active step no longer blends from green to the accent. Every filled connector is the one success colour end to end. A filled connector means the segment is behind you, and everything behind you is done; the blend said the segment itself was in progress, which it never was, the step was. The junction it was smoothing does not need smoothing, since the active indicator carries a 4px accent halo outside its border and the line ends underneath it.


The rules file gets an index, because reading it was not enough

v2026-08-31.5

What is new

docs/ui-md-rules.md now opens with a component index, under "Reach for the component first": one row per component, keyed by the SITUATION you are building rather than by the component's name.

It exists because the rule it enforces was already in the file and was being broken by agents that had read it. The recorded case is a consumer's sign-in page built out of a Stack and four rhythm rungs, by an agent that had just read every section the uimd-build skill names. Lockup ships that page whole, and appeared in the rules file's other 85,000 characters exactly twice, both times incidentally, inside rules about other things.

The failure is retrieval, not comprehension, and that is why more prose was not the fix. An agent reaches for what it can name, so it reaches for Button and Stack and hand-builds the rest. The file was organised by rule (spacing, type, radius), which is the wrong axis for the question actually being asked, and at 85,000 characters adding another rule section makes retrieval worse rather than better.

The index is GENERATED from a @use-when marker in each component's own source, so the description lives beside the thing it describes and a second place to describe every component never exists. All 60 are seeded, and the generator FAILS on a component that carries no marker: an index with holes is worse than no index, because a miss reads as "the kit has nothing for this".

Nothing to do on your side. The index arrives with the rules file on your next pull.

New build failures

One new rule, hand-rolled-composite, and like every new rule it lands as a warning first. --strict promotes it now.

It fires on a heading, then a paragraph, then an action, as adjacent siblings in one container. That shape is Lockup rebuilt by hand: the kit's stack for a prompt standing in for content, an empty result, an error, a success screen, a sign-in gate. Lockup owns that whole rhythm and ramps all four gaps by size.

The rule is deliberately narrow and will not catch every hand-built composite. Only whitespace and JSX comments may sit between the three parts, and each of the first two must hold plain text, because the same heading-then-paragraph shape is what ordinary prose looks like and a style rule that over-fires gets switched off in every repo that pulls it. A draft without those limits matched across sibling boundaries, walking from one component's heading to the next component's button and reporting a composite nobody wrote. A heading, paragraph and link inside real prose carries a ui-md-allow-hand-rolled comment.


Stepper completed steps go soft

v2026-08-31.4

Moves on screen

stepper completed indicators drop from solid success-500 with a white check to an outline: the circle keeps the theme background, with a 2px success-300 edge matching the connector's color and width and a success-500 check (in dark mode edge and connector both hold the check's 500; muted rungs read as murk on a near-black canvas). The connector fill lightens with them (success-300 in light), including the gradient end that blends into the active step. A long run of finished steps now recedes so the active step carries the weight; before, ten solid green discs were the loudest thing on screen. The active step's halo doubles its alpha in dark (ring-accent-500/30); 15% was invisible there.

The check's stroke is heavier, and the reason is worth knowing if you set icon stroke anywhere. A Tabler glyph paints strokeWidth * box / 24 pixels of ink, so the nominal 2 it shipped with painted only 1.33px inside the 32px indicator and 1.0px at size="sm", against a ring and connector that both paint a true 2px. The check read thin and small next to its own rail. It now sets a stroke that inverts the formula (3 at the 16px box, 4 at 12px) to land exactly 2px at both sizes, on whole device pixels at 2x. The box is unchanged.

That stroke is pinned to the ring rather than reading --stroke-icon, and deliberately so: that token is matched to a font weight, so retuning it for labels would drift this check off the 2px ring beside it.


A current-page marker for the footer

v2026-08-31.3


The accent label picks its own contrast

v2026-08-31.2

Moves on screen

Nothing, unless you retune the accent. --accent-foreground (the label on a solid accent fill, e.g. the primary Button) is no longer hardcoded white. On browsers that support OKLCH relative color (Chrome 119+, Safari 16.4+, Firefox 128+) it reads the accent's own lightness and snaps to black or white at a threshold, so a light accent gets a dark label instead of an unreadable white one. The shipped accent is dark, so its label stays white and nothing moves. Retune --accent to a light color and the label flips to black on its own, with no per-color hand-tuning. Older browsers keep the white fallback.

This pairs the label to the fill wherever the fill is --accent (the primary Button's light-theme rest, and any consumer surface using bg-accent text-accent-foreground). The primary Button's DARK rest still uses the tuned accent-600 plate, which is dark, so its white label is correct and unchanged; if you retune the dark fill to a light color, point that plate at --accent so the paired label follows.


An AGENTS.md pointer and a "Start here" gate

v2026-08-31.1

What is new

An agent that runs the install now wires one managed block into your AGENTS.md. It is fenced with <!-- ui-md:begin --> / <!-- ui-md:end --> markers and holds a two-line pointer: read docs/ui-md-rules.md before building UI, and follow docs/ui-md-upgrade.md to upgrade. This is the one hook Codex, Cursor, and other agents read, and it is what stops a fresh project from hand-rolling components while the kit's rules sit unread on disk.

It is safe by construction. The kit only ever touches what is between its two markers: on a first install it appends the block (or creates AGENTS.md if you have none), and on every later pull it replaces just that block to keep the pointer current. Everything you wrote above or below it is never touched, and the file is never overwritten. If you would rather it stay out, delete the block; keep the markers empty and a pull leaves them empty.

The rules file opens with a short "Start here" gate. The top of docs/ui-md-rules.md now leads with the six rules an agent breaks by reaching for another project's defaults (use the kit's components, space with the rhythm rungs, size type from the scale, elevation is shadow-ring-*, no alpha on semantic tokens, run the checker), so an agent that opens the file reads the mandate before the detail.


Icons get crisp again, and the icon rename hazard

v2026-08-28.1

Also

--icon-optical-scale is now in the rules doc, under the icon stroke ladder, where it should have been when it shipped. That ladder derives stroke 2 from Geist's stem (24 x 0.084 = 2.02) and tells you to retune the token rather than a call site, and it was silent about a third token that multiplies both rungs by 1.1.

The entry now carries the painted-pixel table, because the token values alone lead you to the wrong conclusion. The ladder is stated at icon box == font size, and no real control is built that way: Button runs 12/14/14/16px of icon against 11/12/13/14px of type. Compared in painted ink against a semibold label, the action rung reaches 79-85% of the label's stem on its own and 87-94% with the scale, so the scale closes part of an existing shortfall rather than overshooting, and turning it off makes icons lighter than the words beside them rather than better matched.

What it does cost is the pixel grid, and that is the real reason to turn it off. A tenth is the worst multiplier available: at 2x, scale: 1 paints Button's icon boxes at exactly 24 / 28 / 28 / 32 device pixels with lg's stroke at exactly 3, while 1.1 paints 26.4 / 30.8 / 30.8 / 35.2 with every stroke fractional, so both edges of every line land mid-pixel and the glyph renders soft. Largest is worst, because the fringe is the same width on a bigger drawing. --icon-optical-scale: 1 takes that trade.

Two things the scale does NOT touch, so nobody compensates for it twice: the counter ceiling is a ratio and a uniform scale moves gap and stroke together, and the property is the individual scale rather than transform, so it still composes with a chevron's rotate.

Moves on screen

Every icon in the kit is about a tenth smaller and renders crisply. --icon-optical-scale ships at 1 where it shipped at 1.1 the day before. No per-component opt out; set the token to 1.1 in your own CSS to put it back.

The 1.1 was there to fill the box the way lucide did, and it does. What it also does is multiply every icon size by a tenth, and a tenth is the worst multiplier available on a pixel grid. At 2x, scale 1 paints Button's four icon boxes at exactly 24 / 28 / 28 / 32 device pixels with lg's stroke at exactly 3; at 1.1 they are 26.4 / 30.8 / 30.8 / 35.2 with every stroke fractional, so both edges of every line land mid-pixel and the glyph renders soft. The biggest button looked worst, because the same fringe covers a bigger drawing.

This is a trade and the entry says so, because the case for 1.1 is real. Measured in painted ink against a font-semibold label's stem, the action stroke reaches 79-85% of it at scale 1 and 87-94% at 1.1: the icons really are lighter than the words beside them now. The reasoning for shipping crisp anyway is that a weight 8 points light is something you notice if you go looking, where a soft icon is something you notice immediately. Raising --stroke-icon-action instead does not work: holding lg's stroke on the grid allows only 2.25 (what ships) and 3.0, and 3.0 closes the trash lid's counter to 0.63px, under the ceiling in the rules file.

auth-dialog is the other component edited, and its output is identical: the error copy it keeps on screen while the panel collapses moved from a ref written and read during render to state adjusted during render, React's own documented pattern for exactly this. The ref version drew two react-hooks/refs errors in any project linting with the React hooks plugin, which this repo does not, so it shipped clean from here and failed in a consumer. It was also a real correctness risk rather than a lint technicality: a ref write is invisible to React, so a render that gets thrown away could leave the retained message set to one the user never saw.

New build failures

One new rule, icon-name-in-copy, and like every new rule it lands as a warning first. --strict promotes it now.

It exists because of what the previous entry costs you when you follow it. Moving your OWN icon imports from lucide to Tabler means renaming Inbox to IconInbox across your app, and the obvious way to do that is a find and replace on the word. A word replace cannot tell an identifier from the same letters inside a label, a placeholder, an aria-label, or a comment. The consumer this came from shipped 47 of them across 22 files: a nav reading IconInbox and IconCalendar, search fields placeheld with IconSearch…, a Save button labelled IconDeviceFloppy, and an aria-label reading IconSearch archived captures. Every check they had passed, because the code is correct: it type-checks, it lints, and it renders. Only the words are wrong.

The rule reads strings and JSX text, and deliberately not comments, because a comment is not copy and legitimately names types like IconProp. A string that holds a code sample is skipped, so a docs site showing <IconStar /> is quiet. A picker or a table that lists icon names on purpose carries a ui-md-allow-icon-name comment.

If you are about to do this migration, rename by identifier rather than by word: your editor's rename symbol, or a replace anchored on the import statement. Then run the checker and fix whatever copy the rename reached anyway.


The icon set is Tabler, not lucide

v2026-08-27.1

Moves on screen

Every icon in every kit component is now a Tabler glyph (@tabler/icons-react) where it was a lucide one. This is a whole-kit rendering change with no per-component opt out: buttons, inputs, menus, the dialog close, table sorters, every place the kit draws an icon now draws Tabler's version of it.

The measurable parts are unchanged on purpose, which is why Tabler was the choice. Both sets are stroked inside a 24-unit viewBox at a default stroke of 2, so the two icon-stroke tokens (--stroke-icon 2, --stroke-icon-action 2.25) carry over untouched and the button label still pulls its glyph weight up to Semibold the same way. Alignment does not move either: it rests on Geist's cap band, a fact about the font, not the icon set. What changes is the drawing: Tabler's joins are rounder and several glyphs are simply shaped differently, so expect a friendlier line and, in a handful of spots, a different picture for the same idea.

A few icons have no same-name twin and were mapped to the closest Tabler glyph: a trash can, a "more" affordance (Tabler draws it as three dots), a lightning bolt, a globe (Tabler's is world), an info circle, a hamburger, and the sort/select chevron pair. If you passed your own icon elements into a kit component nothing here touches them; only the kit's built-in icons changed.

Icons render a touch larger than the raw Tabler glyph. Tabler draws inside a wider margin than lucide did, so at the same box every icon read about a tenth small, worst in menus. The tokens file now ships one rule, .tabler-icon { scale: var(--icon-optical-scale) }, with --icon-optical-scale: 1.1, so every Tabler icon fills its box the way lucide's did. It also lifts the stroke by the same tenth (a 2 reads as ~2.2). Set --icon-optical-scale: 1 in your own CSS to turn it off; it is a normal token, so it survives the next pull.

New build failures

None. No new verify-ui rule ships with this entry.

One dependency note that is not a rule but can still break a build: the kit now declares @tabler/icons-react instead of lucide-react. A fresh add installs Tabler for you. If your OWN code imported lucide-react and was relying on the kit to pull it in transitively, add it to your project directly; it is no longer a kit dependency.


A selected row stops being blue

v2026-08-26.2

Moves on screen

A selected TableRow is neutral now, in both modes. It was bg-accent-50 / dark:bg-accent-950, the quietest rung at each end of the accent ramp. Quiet in lightness, not in chroma: the dark step is C 0.0753 at the accent hue, and a select-all across a 39 row table painted a wall of navy. That is emphasis, and a selection is a state. The fill is now a neutral alpha tint, bg-neutral-900/5 in light and bg-neutral-100/[3.5%] in dark, at C 0.0017 and C 0.0024, which cannot accumulate into brand colour at any row count.

It is a tint rather than a scale step because no solid rung is free. bg-card and bg-muted are adjacent neutral steps in both modes (L 0.9886 / 0.9725 light, 0.1651 / 0.2242 dark), and bg-muted is the header and the footer, so a selected row landing on it reads as chrome. The new values clear all three grounds by roughly the same 0.02 the old pair claimed: light lands at L 0.9531, 0.0355 under card and 0.0194 under muted; dark lands at L 0.2019, 0.0368 over card and 0.0223 under muted. Both also clear the striped fill (L 0.9776 light, 0.1824 dark) by about 0.02, which the accent pair did not: accent-950 sat 0.0026 off the dark stripe and was told apart by hue alone, as was accent-50 from muted in light at 0.0025.

Nothing else moves. The menu-row recipe (accent-100 / accent-800 + text-accent) is untouched everywhere it belongs: Dropdown, Select, Command, Combobox, Calendar, Tabs, the Nav pill, badges, ghost hovers. A popup row is one transient highlight in a narrow column; a table row is full-width, persistent, and multi-select, and that is the whole reason these two differ.


A count that sits on the title, not in it

v2026-08-26.1

Moves on screen

Nothing moves on its own. One addition to page-title-row:

New optional titleBadge prop. The slot for a count or a status pill beside the page title, a Badge reading "39 items" or "Live". Until now the only inline slot was subtitle, and a Badge passed there renders broken: that wrapper is an inline <span> carrying truncate, and overflow does not apply to a non-replaced inline box, so the pill comes out unclipped and hanging low against the letterforms. A consumer hit exactly that with a row count, which is what the prop exists to stop. titleBadge is a direct child of the title cluster, centred on the title's cap band rather than seated on its baseline, in a zero-height wrapper so it contributes nothing to the row's height: a page carrying a count and a page without one put their titles on the same line. Controls are untouched by this and still belong in actions.


The neighbour arrives already posed

v2026-08-24.5

Moves on screen

Nothing moves on its own. One addition to carousel (a kit extra — pull it by name):

New export carouselTweenRestStyle from carousel-tween.ts. A server-rendered coverflow paints its raw HTML — every slide full size — before the tween's first frame, so the neighbours visibly snap down after hydration. This returns the pose the tween will compute for a slide at a given distance from centre, as an inline style for the markup, so the pre-hydration HTML already matches and the handoff is invisible. Pass it the same scaleMin/opacityMin given to the hook; hand-copying the numbers instead is the drift this export exists to prevent. It fixes the pose, not the track: when the start slide is dynamic or mid-list, Embla still paints the strip left-aligned until init, and the right remedy there is fading the whole carousel in on the api's arrival (the "Coverflow scale" docs example does this). The hook's header comment now documents both remedies.


An arrow you can read over artwork

v2026-08-24.4

Moves on screen

Nothing moves on its own. The carousel arrows (CarouselPrevious / CarouselNext, a kit extra) gain two opt-in props:

variant: "bare" (the default, today's naked chevron at half opacity) or "chip", a filled circular button (bg-card, ring edge) at full opacity, for arrows sitting over artwork, where a half-opacity chevron goes illegible.

tone: "default" (the theme's neutrals) or "accent", which reuses Button's accent recipe. A bare accent arrow is an accent-coloured chevron; a chip accent arrow is a solid accent fill with Button's exact rest and hover pair, so it matches an accent button beside it.


The neighbour dissolves, not guillotined

v2026-08-24.3

Moves on screen

Nothing moves on its own. One addition to carousel (a kit extra):

New opt-in edgeFade prop on CarouselContent dissolves the slides at the viewport's edges instead of clipping them on a hard line. It is for peeking layouts (a fractional basis-*, a basis-auto strip, the coverflow tween), where the neighbour slide is otherwise cut dead at the clip edge. The gradient's stops trace a smoothstep, zero slope at both ends, so the fade neither snaps off at the clip edge nor leaves a findable line where it lands on full opacity. The width is a fixed pixel band, not a viewport percentage (a percentage read as a kiss on one layout and swallowed the whole neighbour on another): pass a number to tune it, true means 64px, and it should stay comfortably inside the neighbour's visible peek. Dimming the neighbour is the coverflow tween's job, and its opacityMin should stay near its default when both run. It is position-driven, like the coverflow tween: each edge fades only while content actually extends past it, ramping in over one slide of travel, so at rest on the first slide the leading edge stays a hard line (a loop carousel keeps both edges faded). A mask rather than an overlay, so it works over any page background, and it follows orientation. If you hand rolled this as a static mask utility on viewportClassName, the prop replaces it and fixes that resting edge.


Coverflow, without the pop

v2026-08-24.2

Moves on screen

Nothing moves on its own. Two additions to carousel (a kit extra — pull it by name):

New opt-in hook useCarouselTween (its own shipped file, components/ui/carousel-tween.ts — a hook cannot leave the "use client" module and still reach a Server Component as a value) gives a peek carousel the coverflow treatment: slides scale and fade with their live distance from centre, on a smoothstep curve. The curve is the point — a piecewise-linear ramp puts a velocity corner at each end and the arriving card visibly pops; smoothstep's zero slope at the centre also absorbs Embla's settle jitter, so no dead band is needed. Position-driven, so it tracks a drag 1:1 and inherits the carousel's reduced-motion handling.

The duration default now documents its range. 14 is tuned for short hops; over a ~500px slide it overshoots the snap and creeps back — a double bounce. Measured: 18 lands with 0.7px overshoot and is still by ~430ms. Long-slide carousels should pass 18-25.


A quiet delete is still a delete

v2026-08-24.1


A dialog breathes under its heading

v2026-08-20.6

Moves on screen

Dialog, AlertDialog and Sheet space their heading from the body on the heading-md rung (24px), up from 16px. The title carries cap-trim, which removes the font's top space, so a 16px gap measured from the cap edge read tight against the body, most visibly when there was no description to soften it. 24px is the same rung PageHeader already gives a text-lg heading, so a modal heading and a page heading now clear their content by the same amount. Drawer is unchanged: its content spaces a short form, not a modal header.

The BreadcrumbBack chevron now recolors and nudges left on hover. It was meant to from the start; the hover state was wired to a group that was not there, so nothing fired. If you relied on the chevron staying still on hover, this is the change that starts it moving.

A <Card animateHeight> mounted on a non-card host no longer clips a focus ring at rest. The shell used to lean on the Card base class to hide content mid-animation, which also clipped a non-card host permanently. It now clips only while a height animation is running. A plain Card is unaffected.


Code blocks stop where the copy stops

v2026-08-20.5

Moves on screen

Nothing, unless you reach for the new token. This is an addition.

New: --container-content and its max-w-content utility. It caps a block that is READ, where max-w-measure caps a line of copy. Capping only the paragraphs leaves the code blocks between them running to whatever width the layout gives them, so the two edges keep no relationship at all: on this kit's own docs a paragraph sat at 60ch while the code block under it ran to 976px, half again the width of the sentence introducing it.

It resolves to var(--container-measure), so a code block and the copy above it share one edge and retuning the measure moves both.

Put it on the blocks, not on the column that holds them. Some blocks in a document are read and some are examined, and only the first kind answers to a measure. A data table, a chart or a component specimen takes the width its content needs; capping those makes a table unreadable in service of a rule that was never about it. Capping the column was tried first and caught everything, and no column width is right for both a sentence and a twelve-column table.

A block a little wider than its copy was also tried and is the worst of the three: it reads as a near-miss rather than as hierarchy. 60ch is not a squeeze for code either, which is the usual objection to going flush: it renders 636px in Geist, about 81 monospace characters at 13px.

It is the one cap that carries no text-wrap pairing: text-wrap inherits, and the copy inside a block already brings its own.


A table selection is a fill, and only a fill

v2026-08-20.4

Moves on screen

A selected TableRow is much quieter, and its cells stop turning blue. The row used to stack two effects: an accent-100 fill (accent-700 in dark) plus text-accent forced onto every td. Across a full-width table with several rows selected that reads as a slab of brand colour rather than as a selection, and the text recolor carried most of it, since accent-500 is C 0.2489 against the fill's 0.0342. It also applied unevenly: a cell that set its own text-foreground kept it while its neighbours turned blue, so the row rendered half-applied.

Selection is now a fill and nothing else, at the quietest rung on each end of the ramp: bg-accent-50 in light, bg-accent-950 in dark. Both are the same ~0.02 lightness step off bg-card, which is the ground a Table paints for itself. The dark step is not the accent-900 the menu-row recipe mirrors to: its L 0.2407 sits 0.016 from bg-muted (0.2242), and bg-muted is the header and footer, so a selected row would have read as chrome.

If you were relying on the blue text to find the selected row, pair the fill with the cue that should have been carrying it: a checked checkbox, or a count in the toolbar above the table.

A selected row in a striped table is now actually painted. The stripe and the selection are both backgrounds on the same tr, and the stripe's selector descends from the table element, so it scored (0,2,2) against the row's own (0,2,0) and won the cascade on every even row. Selection was invisible on half the table, and had been since striping shipped. The stripe now excludes a selected row outright, so the two no longer compete. Only striped tables are affected, and only their even rows.

Nothing else changed. The menu-row recipe (accent-100 / accent-900 + text-accent) is untouched everywhere it belongs: Dropdown, Select, Command, Combobox, Calendar. A popup row is one transient highlight in a narrow column; a table row is full-width, persistent, and multi-select, and that is the whole reason these two now differ.


The rules now point at themselves

v2026-08-20.3

New build failures

Nothing. No rule was added and no severity was promoted.

What arrives is a second skill and two procedure changes, aimed at one gap: the rules shipped, and nothing read them.

uimd-build installs alongside uimd-pull at .claude/skills/uimd-build/SKILL.md. uimd-pull fires on "update the kit", which happens a few times a year. uimd-build fires on building a page or a component, changing spacing, or touching typography, which is every day, and it routes to the Page rhythm, Spacing and layout, Type scale and Hard rules sections of docs/ui-md-rules.md BEFORE class strings get written rather than after. Reading them afterwards makes them a cleanup pass, and a cleanup pass preserves pixel counts and loses relationships.

The upgrade procedure now wires the checker into package.json during the pull, as "verify:ui", and calls it from the build or CI script. It used to say "wire it into the build/CI if it is not there", which was a suggestion at the end of a long checklist and was skipped. A checker that ships as a file and is never run enforces nothing.

The AGENTS.md snippet now covers building, not just upgrading. For Codex, Cursor, or anything else outside Claude Code, that is the only pointer at the rules that exists, and it used to mention the kit only when installing it. The kit still does not write to your instructions files.

Nothing here is required. If a project already runs the checker in CI and its agents already read the rules, this changes nothing for it.


AuthDialog stops reserving a line for an error

v2026-08-20.2

Moves on screen

  • AuthDialog is 32px shorter when there is no error, which is nearly every open. It used to hold an empty line under the description so an error could appear without moving the panel. That reserved line is gone: the description now sits 16px above the first provider button instead of 48px. On a short panel (two providers, no passkey) the hole between the description and the buttons is what this closes.
  • An error now grows the panel instead of filling a slot. The message region animates open over 200ms, the same timing as a disclosure, and the panel grows from its centre because it is centred vertically. Under prefers-reduced-motion: reduce it appears with no transition.
  • The gap above the error message is 4px, not 12px. Neither the description nor the message is cap-trimmed, so both still supply their own half-leading; the 12px was the header's trim-compensated rung spilling down from the title and painted the pair about 8px loose. With an error showing, the panel is now 24px taller than its resting height rather than 32px.

Nothing about the public API changed, and the message is still announced. The announcement moved to a permanently mounted screen-reader-only region whose text tracks the error exactly, so clearing and re-setting the same message is still announced as a repeat. data-slot="auth-dialog-error" still names the visible message.


A completed step reads as success

v2026-08-20.1

Moves on screen

  • A completed step in Stepper is now green, not accent. The circle, its border and its check mark read --success, and the connector behind it fills green to match. The active step is unchanged: it stays accent with its ring, so the flow now separates three states by hue rather than two, and "you are here" is still the only thing wearing your brand colour.
  • The connector that lands on the active step blends from success to accent. Only that one. A line between two completed steps stays solid green, because a connector's two ends match the two circles it touches; gradienting every line that leaves a completed step would fade it to accent and land it on a green circle.

This is why --success reads "confirmation and completion" in the token table rather than "confirmation" alone. If you had leaned on the accent-coloured completed state to carry your brand through a checkout or onboarding flow, the active step still does that, and StepperItem's state prop still overrides any step's treatment by hand.

A new token ships with this: --success-foreground, the text and icon colour that pairs with a --success fill. It mirrors --destructive-foreground and defaults to the same near-white in both themes. Success was the only intent colour shipping a fill with no partner, so anything of your own painting on green had to borrow another token's foreground; it no longer does.


Latest: new component, `segmented-control`

v2026-08-18.8

Moves on screen

  • Nothing. segmented-control is a new component and changes nothing you already have. It arrives with kit, since its only npm dependency is motion, which is already in the universal set.

SegmentedControl is a compact in-place switch for one of a small set of options, drawn as a pill group with a sliding thumb: a unit toggle, a view mode, a billing period. It is a radiogroup, not a tablist, so reach for it when the choice changes a value and for Tabs when it swaps a panel of content. Arrow keys move the selection with wrap-around, an aria-label is required, and the thumb is a single persistent element rather than a per-option shared layout, so it cannot stretch across the track mid-slide. size reads the same control ramp as Button and Tabs (28 / 32 / 40px of option, plus 4px of track each side); tone="onMedia" swaps the palette for fixed black and white with a backdrop blur, for sitting over a photo.


Stacked toasts read as a deck

v2026-08-18.7

Moves on screen

  • A toast behind the newest one is now a clean blank card edge instead of cut-off text. Fire two toasts and the older one used to stay fully painted while being squashed to the new one's height, so its words and buttons showed through, clipped. It now fades its contents the way sonner's own deck does, and hovering the stack still expands it to a readable list.
  • A toast whose panel had been opened no longer stands out below the deck. Opening a panel releases the card's height so it can grow; once that card was pushed to the back it kept the taller height and hung past the stack.

Both fixes have the same cause, worth knowing if you have restyled toasts: toast.custom() bodies are always marked data-styled="false" by sonner, and sonner's collapsed-deck CSS is split across that attribute. The rule that hides a back toast's contents is gated on it; the rule that clamps a back toast's height is not. The toast item now ships the missing half.


Titles wrap with `text-pretty`, not `text-balance`

v2026-08-18.6

Moves on screen

  • Every left-aligned title in the kit now wraps with text-pretty instead of text-balance, so a title that runs to two or more lines gets wider. CardTitle, DialogTitle, AlertDialogTitle, SheetTitle, DrawerTitle, ToastTitle, StepperTitle, PageHeader's heading and prose's h1-h6 are affected. text-wrap: balance resolves to the narrowest width that still fits the text in the same number of lines, so a balanced two-line title stopped short of its container's right edge while the description under it ran all the way, and the two left-aligned blocks finished in different places. A single-line title does not move at all: neither property does anything until the text wraps. A wrapped one keeps the same line count in almost every case and simply fills the measure, which can move the break point by a word.
  • Titles longer than about six lines change behaviour more than that. Chrome stops applying balance past that many lines, so those titles were already wrapping normally and now gain orphan protection they did not have.
  • TooltipContent keeps text-balance, and so do the max-w-measure-heading and max-w-measure-heading-relaxed caps that Lockup reads. Nothing about tooltips, lockups, or centred hero headings moves. The rule is the box, not the element: balance stays where the box hugs the text (w-fit) or the text is centred, because that is where there is no sibling edge to mismatch and the equalised lines read as a deliberate shape.

Card-header actions default to flush, not outdented

v2026-08-18.5

Additive

  • DashboardCardHeader takes actionInset ("icon" | "flush", default flush). The old blanket outdent conflated two things the slot carries: an icon button, whose box extends past its glyph and wants the glyph seated on the inset, and a badge/count/caption, whose visible edge is its box edge and must not be pulled off the inset. Splitting them makes the common drop-in case correct with no flag and asks the button case, the one that knows it has chrome to compensate, to declare it.

Moves on screen

  • A DashboardCardHeader action that is not an icon button moves 6px away from the right edge, onto the content inset. The header used to outdent every action by 6px, a correction meant only for an icon button's transparent chrome. A Badge, a count, or a caption passed as action was pulled 6px too close to the edge and read lopsided against the title. Those now sit flush. An icon Button or Kebab action needs actionInset="icon" added, or its glyph moves 6px inboard and reads adrift. The kit's own two examples that use an icon action (Compact Header, Tabbed Filter) carry the flag now.

Button icons now match Geist's weight

v2026-08-18.4

Worth knowing if you swap the typeface

  • Icon alignment rests on a property of the font, and Geist happens to satisfy it exactly. Icons sit in items-center rows, so flexbox centres them on the text's line box, whose centre is (ascent - descent)/2 above the baseline and cannot be moved by line-height. The eye reads them against the cap band, centred capHeight/2 above the baseline. For Geist both are 355 per 1000 em, so icons land dead centre and no component needs a nudge. Point --font-sans-custom at a face that misses this and every icon in the kit shifts at once with no class change, no CSS change and nothing failing. docs/ui-md-rules.md has the check and the one-token correction.

Additive

  • Two new tokens, --stroke-icon (2) and --stroke-icon-action (2.25). They ship from base into :root, so your own unlayered :root overrides them. They exist because icon stroke is a property of the typeface, not of a component: if you swap Geist for a face with a different stem, measure its stem in em, multiply by 24, and set these two rather than touching call sites. The full derivation, including the counter ceiling that caps the stroke, is in docs/ui-md-rules.md.

Moves on screen

  • Every button icon gets heavier and most get bigger. Button glyphs were rendering at Lucide's stock stroke of 2, which measures out to Geist Regular, while button labels are font-semibold: two weight steps apart. Icons now read --stroke-icon-action (2.25), and the icon box steps 12 / 14 / 14 / 16px across xs / sm / default / lg, up from a near-flat 12 / 12 / 12 / 14. The stroke could not move on its own, because at a 12px box the tightest counter in the icon set (Trash2's lid) already had only 0.97px of clearance, so a heavier stroke with no extra size fills counters in rather than adding weight.
  • A button with an icon gets ~2px wider wherever its glyph grew, so a tight row of iconned buttons can rewrap. Icon-only buttons keep their box and only the glyph inside changes.
  • variant="link" is deliberately excluded. It is the one variant that sets font-normal, so its glyph stays at stroke 2 and would otherwise have gone heavier than the word it sits in.
  • Input, Badge and prose icons are unchanged. They sit beside regular-weight text, where the stock stroke was already the right answer.
  • An iconned button's icon-side padding now scales with its size. It was a flat 12px on the icon side at every size, which balanced only at sm: at lg the icon was crammed to the edge and the button read right-heavy, at xs the asymmetry was reversed. The icon side is now 2px tighter than the label side at each size (6/10/14/18px across xs/sm/default/lg). Any button with a leading or trailing icon shifts its contents slightly, and a tight row can rewrap.

Cn() could not override the kit's own tokens

v2026-08-18.3

Additive

  • cn() is extended, because tailwind-merge cannot read a theme. It ships a fixed map of Tailwind's stock scales, so a utility built from a token the kit adds falls outside every class group it knows. Two such classes both survive the merge and land at equal specificity, so stylesheet order decides which one paints. That is not the call site, and it is not stable.

    Measured: 13 of 18 custom utilities failed to override. Two failed worse than failing. text-display and text-jumbo look like colour names, so they were sorted into text-color, and cn("text-muted-foreground", "text-display") dropped the colour and put nothing in its place.

    registry/lib/utils.ts now teaches it the kit's container sizes, font sizes and control radii. Spacing is deliberately not taught: the named form (h-site-header) is banned because Tailwind inlines a spacing value at compile time, and the rung form h-(--spacing-site-header) is an arbitrary property that merged correctly all along. Teaching the frozen spelling would make a banned one behave.

  • DashboardCardHeader takes inset. A card header and the body under it share one left edge, so their side padding is one decision, and until now the header was pinned at 16px with no way to say otherwise except a className override that moves one edge and leaves the other. default (16px) is the row tier and stays the default, so nothing moves. loose (24px) is the prose tier, for a card whose body is paragraphs rather than rows, where 16px reads thin once the card is wide. Set the body to match in the same edit. No third value, since a third inset is a third left edge to keep in step.

Moves on screen

  • Anywhere a caller was already overriding one of 13 custom utilities, the override now takes. Those call sites were shipping both classes and getting whichever Tailwind emitted later, so some of them were painting the component's default and will change. Check any use of max-w-measure, max-w-chrome, text-display, text-jumbo, or the rounded-action* ramp passed through a className.
  • <Prose className="max-w-none"> now actually removes the cap. That is the opt-out Prose has always documented, and it never worked, so a consumer relying on it has been rendering capped prose and will see it go full width.

New build failures

  • verify:registry gains one check, and it fails the build rather than warning, because it cannot fire on existing code: it only triggers when a token is added to --container-*, --text-* or --radius-* without being added to the list in registry/lib/utils.ts. Nothing already in the kit trips it.

Smoothing belongs to the consumer, not to Sparkline

v2026-08-18.2

Additive

  • A band scale has no floor, so smooth the series, never the component. The chart rule already said how a baseline is chosen and stopped there. What it never said is that the band making a line honest will stretch any range to the full height, so it cannot tell a five-point drift from five points of sampling noise. A consumer hit both halves at once on newsletter tiles: an open rate spanning 5.7 points across twenty sends, and a click rate where one click moved the number by a point, drawn as a sawtooth that read as violent movement in a series that was flat.

    The fix is a rolling mean in the consumer, over a window measured against its own data. That consumer counted direction changes to pick one: raw ran 12 on open rate and 10 on click rate, a 3-send window gave 8 and 6, a 4-send window 5 and 4, and a 5-send window went back up to 7 on click rate, because over-smoothing starts beating real turns against their neighbours.

    Sparkline takes no smooth prop, and that is the rule's point. A drawing component that quietly averages what it was handed is the same lie as a band-scaled bar. The prop would also let one card print a number while the line beside it ends on another, which is exactly what smoothing costs: name it in the accessible label, and prefer area over line, because a filled band reads as a level where a line invites you to take a point off it.


A rule for icons on text buttons

v2026-08-18.1

Additive

  • A rule for when a text button gets a leading icon. The kit documented the mechanics thoroughly (element vs bare component and the server/client boundary, iconSize, the reduced padding on the icon side, the heavier in-button stroke) and never said when to reach for one. So consumers drifted, and the tell is a control row where the create button has a +, the destructive one has a trash can, and the two beside them have nothing, with no reason available for any of it.

    Two halves. A glyph is allowed only where it is the conventional symbol for that exact action, tested by hiding the label and asking whether the action can still be named: + is add, a trash can is delete, and a check mark is approve and save and confirm and done, so it is decoration. And inside one button group it is all-or-nothing, because a single iconned button beside plain ones reads as the important one, which is an emphasis signal the variants already own.

    One override: a destructive action keeps its icon even in an otherwise plain group, since the button is red and the glyph is what keeps the meaning off colour alone (WCAG 1.4.1).

    Icon-only buttons are untouched. There the glyph is the label and carries an aria-label; this rule is about a glyph sitting next to a word.

    Deliberately prose, not a checker rule. Whether a glyph is conventional for an action is a judgment about meaning, so a checker would fail honest call sites and pass dishonest ones. The group half looks checkable and is not: a group is a visual cluster, not a DOM shape.


Two new rules

v2026-08-17.4

New build failures

  • rung-as-interior-padding (warning). A rhythm rung read into padding on opposing sides: p-, px-, py-, or pt- and pb- together in one class string. A rung measures the space between two blocks, and painting one as padding is correct where something sits between them that makes gap impossible: a border-t above a footer row, or reopening the gap under a borderless CardHeader. The kit ships both, and a single side is never flagged. Opposing sides are not a gap between anything, they are the block's own inset, and the ladder does not measure interiors. peer is 40px because that is what two peer blocks need between them, which says nothing about what a page shell should hold inside it.

    This rule exists because a consumer read "every vertical gap between blocks comes from a rung" as "every vertical space comes from a rung" and padded every page shell with peer and section. Each page opened about 120px taller than the rest of their site and nothing in the kit said it was wrong. Use the numbered spacing scale for an inset. Where the padding really is a gap a boundary stops you writing as a gap, carry a ui-md-allow-rung-padding comment and name the boundary.

  • offsystem-color-on-kit-component (warning). A raw Tailwind hue (bg-pink-500, text-violet-400) in the className of a kit component. It repaints the component from outside the kit's colour system, and cn() is tailwind-merge, so your class wins the base colour while every state the component paints for itself - hover, disabled, open, checked, the focus ring - keeps the colour it was built with. That is a control which looks right at rest and wrong the moment it is used, and it is also the one colour on the page that does not flip with the theme.

    This is not a ban on Tailwind hues. They are yours to use in your own markup, exactly as the colours page says, and are only flagged inside a kit component. Kit tokens are not flagged there either: <TableCell className="text-foreground"> and <CardHeader className="border-b border-border"> are ordinary composition. Names the kit ships its own scale for (neutral, accent, destructive, success, warning, chart) are read out of the token file and resolve to the kit ramp, so they are never off-system. Where the component has a variant, that is the fix and it moves every state together. Where it does not, re-point the token for the scope (--accent, --muted, --card on a wrapper), or colour a wrapper instead of the component. Deliberate exception: ui-md-allow-kit-recolor.

    The part names come from the installed kit's own data-slot attributes rather than a list in the checker, so a component added or renamed upstream is covered without an edit here.

  • carousel no longer reads autoHeight in CarouselItem. It was destructured and never used, which trips @typescript-eslint/no-unused-vars in a project that lints its components/ui. Nothing about the rendered slide changes, and it was not fixable on your side: the next add -o overwrites the file.


Carousel plugins

v2026-08-17.3

Moves on screen

  • carousel now installs embla-carousel-autoplay and embla-carousel-fade alongside embla-carousel-react. Both are first-party plugins from the same monorepo and maintainer as the engine you already had, pinned to the same 8.6.0, MIT, with zero runtime dependencies of their own and a peer on embla-carousel that your install already satisfies. So the new surface is two packages and nothing transitive.

    They are declared rather than left to you because the alternative is a docs page whose examples do not run until you notice a missing import. carousel already lives in kit-extras precisely because it brings a runtime package; this does not change which bundle it is in.

  • A vertical Carousel's arrows start inside the viewport and move outside at sm, which is what the horizontal arrows already did. Vertical had only the outside position, at 48px above and below its own box, so any container that did not happen to leave 48px of room clipped them. Under an overflow-hidden ancestor that produces a control you can see part of and cannot click, with nothing in the console. A vertical carousel with room to spare is unchanged at sm and up; below that its arrows now sit over the slide the same way the horizontal ones do on a phone.

  • Carousel takes an autoHeight prop. Off by default, so nothing moves unless you ask for it. On, the viewport sizes itself to whichever slide is in view, so slides of different heights each get their own instead of every one being stretched to the tallest.

    Horizontal only, and ignored when vertical. That is structural, not a missing feature. Embla measures its scroll bounds once at init. On the x-axis the viewport's height is not the scroll axis, so changing it afterwards costs nothing. On the y-axis it is the scroll axis: a viewport sized after init leaves Embla believing there is nothing to scroll, which disables both arrows and renders every slide stacked for a frame first. A vertical carousel needs a bounded height from CSS, before Embla measures. CarouselContent takes a new viewportClassName for exactly that, and the distinction matters: className lands on the flex track, so a height there does not bound the element that clips and every slide renders stacked. viewportClassName="aspect-square" bounds the viewport and derives the number from the carousel's own width. Same split, and the same reason, as ScrollFade's viewportClassName.

    Two things it gets right that a hand-rolled version usually does not. It measures the slide, never the viewport it resizes, because reading the element you are sizing feeds your own value back in. And it keeps a ResizeObserver on the slides, because slides change size after mount constantly: an image with no intrinsic size finishing its load, a font swap, text reflowing. Without that the viewport keeps whatever it measured before the image landed. It runs in a layout effect, so the first height lands before paint rather than as a visible jump.

    That observer is the reason this is opt-in rather than the default: a gallery of one aspect ratio does not need it. Under prefers-reduced-motion the height is still set and only the 200ms transition drops, because here the height is structural, not decoration.

  • A CardHeader followed straight by a CardFooter no longer collides with it. The header carries no bottom padding on purpose: whatever follows owns that gap, which is why CardContent supplies its own top padding. A footer cannot do the same. Its padding sits below its own border-t, and the missing space is above that line, so a card with a header and a bordered footer and nothing between them drew the rule across the description's descenders. Borderless footers had the same hole with no line to make it obvious: they were told to borrow 20px from the content above, and there was no content above.

    CardHeader now supplies its own 20px when a CardFooter is its next sibling. Any card built that way grows 20px taller. A card with a CardContent between the two is unchanged.

  • Every cap-trimmed title sits 4px further from its description. --spacing-rhythm-heading-pair goes 8px to 12px, which moves PageHeader and the Header part of Dialog, AlertDialog, Sheet and Drawer. Nothing else reads that rung.

    8px was derived to paint what the plain 4px pair rung paints on an untrimmed box, and that arithmetic was right as far as it went. What it missed: cap-trim puts the title's bottom edge at the baseline, and a descender still paints below the baseline. So a title with a p or a j in it spends 0.254em of the gap on ink, and a title without one does not. "Delete this project?" read cramped while "Terms of service" read fine, at the same token.

    It gets worse as the title gets bigger, because that 0.254em is a fraction of the font size: about 3px under a 12px heading and about 7px under a 28px one. 12px covers the descender case at every size the kit ships and leaves the clean case a pair rather than a separation.

    If you override this token, you are unaffected. If you set a heading group by hand on the plain pair rung, you are also unaffected, and trim-rung-mismatch already warns about that.

  • A DialogTitle, AlertDialogTitle, SheetTitle or DrawerTitle that wraps to two lines is now readable. All four carried leading-none and text-balance, which contradict each other: text-balance only does anything when the text wraps, so a title carrying it expects two lines, and at a line-height of 1.0 the second line's ascenders collide with the first line's descenders. CardTitle had already reached the other answer and says so in a comment. All four now read leading-snug, the same as CardTitle.

    A single-line title does not move. cap-trim collapses the box to cap-to-baseline whatever the line-height is, so the change reaches only a title long enough to wrap. If yours wrap, those overlays get taller by the added leading.

  • A MetricCard badge no longer pushes the line under it down. The value row is baseline-aligned, so its height is max(above-baseline) + max(below-baseline). Cap-trimming the value took its below-baseline extent to zero, which is the point, but it also handed that edge to whatever else sat in the row: a sm Badge hangs about 5.7px below the baseline, so a card with a badge grew that much taller than one without and its goal line sat lower. In a row of cards that meant second lines at different heights.

    The badge now renders in a zero-height wrapper, so it cannot set the row's height. It overflows the row instead, centred on the number's cap box, which is where a pill beside a number wants to sit. Nothing clips: the overflow is about 3px each way inside 20px of padding.

    The slot this uses (MetricCardValue's children) is therefore for a short inline annotation, not a control. Something 32px tall would now overflow 16px each way and reach the line below. If you were passing a control there, move it under the value block.

  • MetricCard pins its trend chart to the bottom of the card. Metric cards live in a grid, a grid row stretches every card to the tallest, and the text block is the part that varies: a card with a delta line is one line taller than one without. Packed from the top, that difference fell as dead space under the chart, so three cards in a row showed three charts at three different heights and the one with the least text got the biggest hole beneath it. The slack now sits above the chart instead, where it reads as breathing room, and a row of cards shares one chart baseline to scan across.

    A card that is not stretched has no free space to absorb, so a standalone MetricCard keeps its natural height and looks the same.

  • New rung --spacing-rhythm-lines (12px), and MetricCardValue reads it. The ladder jumped from 8px (bind, heading-pair, label, help) straight to 16px (cluster), and a stat block sits between them. lines is for three or more short lines that read as one unit: a value and the lines that qualify it, a label over two facts. pair is the two-line case; cluster is where they stop being one unit.

    It assumes the lines are cap-trimmed. An untrimmed stack already gets this much air from its own dead space, which is exactly the trap it came out of: MetricCardValue asked for 4px and painted 15.4px between its value and its goal and 14.1px between goal and delta. Not just far more than the number it set, but uneven, because a 20px value sheds more dead space than the 13px lines under it. Its three lines are now trimmed and the gaps paint an even 12px. If you use MetricCardValue, expect the block to tighten by about 3px and stop drifting between its own rows.

  • Your own headings are no longer cap-trimmed, and go back to how they looked before today's earlier pull. The trim shipped this morning as a blanket h1, h2, h3, h4, h5, h6 rule in @layer base, which reached every heading in your app. It is now an opt-in cap-trim utility that the kit applies inside the components that also own the gap beside the heading: PageHeader, the Title part of Dialog, AlertDialog, Sheet and Drawer, and headings inside prose-flow. Lockup is deliberately not among them: its title-to-description step is four frozen per-size classes rather than a rung, so it renders exactly as it did before this morning. Those render exactly as they did after this morning's pull. Everything else, including every heading you wrote yourself, reverts.

    Why it was pulled back. A blanket trim changes every heading at once, while the gap that compensates for it can only change one container at a time. The two sets never line up. Trimmed headings sat on gaps nobody had adjusted, and adjusted gaps landed next to text that was never trimmed. Neither failure is visible in code review, and finding them one at a time is unbounded work. Tying the trim to the component that owns the gap makes it one decision in one file, and leaves your own markup alone.

    You can still opt any heading in: add cap-trim, and set its container's gap to a rung chosen for a trimmed box (see the next item).

  • --spacing-rhythm-pair goes back to 4px, and the compensated gap gets its own rung. This morning's pull raised pair from 4px to 8px. That was wrong, because pair was doing two jobs: binding a trimmed heading to its description in PageHeader, and binding an untrimmed label to its value everywhere else. No single number is right for both. The result was 4px of unwanted air under every metric value, swatch caption, form help line and card title in the system, none of which had a heading anywhere near them.

    Token This morning Now
    --spacing-rhythm-pair 8px 4px (back to yesterday)
    --spacing-rhythm-heading-pair did not exist 8px

    Use pair for an untrimmed tight pair and heading-pair where the title carries cap-trim. The two numbers differ so the two gaps paint the same. The other three rungs re-tuned this morning (heading-sm 20px, heading-md 24px, heading-lg 32px) are unchanged and still correct: every one of their uses sits under a trimmed heading.

    If you overrode --spacing-rhythm-pair in your own CSS to undo this morning's change, delete the override.

  • DialogHeader, AlertDialogHeader, SheetHeader and DrawerHeader read a rung instead of a frozen gap-2. Same 8px on screen. It is now overridable, which it was not before: a gap-2 class is inlined at compile time and no token could reach it.

New build failures

  • --spacing-rhythm-heading-pair is now --spacing-rhythm-trim-pair, and the Stack gap value "heading-pair" is now "trim-pair". The old names are gone. There is no alias and no deprecation window: a var() on the old token resolves to nothing, and <Stack gap="heading-pair"> stops type-checking. Nothing moves on screen. The value is the same 12px it was, so a project that only renames its references renders exactly what it rendered before.

    The name was wrong, and it was wrong in a way that cost a real call site. The rung means "the tight pair relationship rendered on a cap-trimmed box", and nothing about it needs a heading. MetricCardValue wanted precisely that relationship and could not reach for it, because a token with heading in the name reads as off limits next to a number. A name that turns people away from the right rung is worse than a long one.

    Renaming a shipped token is normally not worth the churn. This one is cheap today and will not be tomorrow: it landed earlier in this same day's entries, so the only projects it can reach are ones that pulled within hours of it going up. That window is why the rename is happening now rather than living with the wrong name forever.

    Two rungs deliberately keep their names: --spacing-rhythm-heading-sm, -md and -lg are the gap below a heading group, ramped by heading size, so heading is accurate there and they are untouched.

    Fix it with a find and replace across your project, both spellings, then run node scripts/verify-ui.mjs. If you overrode --spacing-rhythm-heading-pair in your own CSS, rename your override too: an override under the old name is silently dead, since nothing reads it any more.

  • New rule trim-rung-mismatch, severity warn. Flags a gap rung that disagrees with whether its heading is trimmed: the plain --spacing-rhythm-pair sitting beside a cap-trim heading, or --spacing-rhythm-heading-pair in a file that trims nothing at all. Both are invisible in review, because the class is real, the token exists, and only the pixels are wrong.

    The fix is usually not to swap the token but to use the component that encodes the pairing: PageHeader, DialogHeader, SheetHeader, DrawerHeader, AlertDialogHeader. A component cannot pick the wrong rung.

    Where the rungs themselves are the subject (a spacing specimen, a demo showing both side by side), opt the line out with a ui-md-allow-trim-rung comment.

    It will not fail your build yet. If your build script runs verify-ui.mjs --strict, it will: --strict promotes every warning to an error, so check your own script before assuming a new warning is safe.


Later

v2026-08-17.2

Moves on screen

  • Four rungs got bigger, because cap-trim exposed what they were really worth. Take this pull together with the trim entry below it: that change tightened every gap next to a heading, and this one re-tunes the gaps that were tuned by eye before the trim.

    Token Was Now
    --spacing-rhythm-pair 4px 8px
    --spacing-rhythm-heading-sm 16px 20px
    --spacing-rhythm-heading-md 16px 24px
    --spacing-rhythm-heading-lg 24px 32px

    Why they were wrong: an untrimmed heading box carried dead space proportional to its own font-size, roughly 0.31em below the baseline at --leading-snug. That is about 9px under a 28px title, 6px under an 18px one, 4px under a 12px label. So one flat number rendered as three different gaps, wider under the bigger title, which is exactly what made it look right. None of that was in a token. Trimming the box deleted the subsidy and left the flat number exposed, so these four now state outright what they used to get for free.

    heading-sm and heading-md were both 16px and could not stay that way: their titles are 14px and 18px, so they lost different amounts.

    The page-scale rungs (peer, region, section) were left alone even though the trim does reach them: a Stack around a PageHeader puts one directly above a trimmed heading. A 40px peer above a lg PageHeader used to paint about 47.5px and now paints 40. They were not re-tuned because the same rung also sits between two ordinary blocks where nothing is trimmed and the value is already right, and the difference is 10-19% on gaps big enough to absorb it. If one reads tight on your page, raise it there.

    No other rung moved. cluster, field, label, help, peer, region and section never sit next to a heading, so the trim never reached them.

    Where you will see it: any title over its description gains 4px (PageHeader, CardTitle over CardDescription, and anywhere you used pair yourself), and a heading group clears its content by 4-8px more. If you had already re-tuned these tokens in your own CSS to recover from the trim, your values still win, and this is the moment to delete them and check the defaults instead.

    One rough edge, stated rather than hidden: p is not trimmed, only headings are. So a rung sitting under a description still paints about 6px looser than the same rung under a bare heading. Both are consistent per shape; they are not consistent with each other. Trimming paragraphs too would fix it and would move text inside every padded overlay, so it is not in this release. Padding is not a gap: no token re-tune reaches it, because those numbers are shared with icons, inputs and rows that have no text box to trim.

  • prose-flow's four heading gaps gained 8px each. Its other two did not move. Same correction as the rungs above, applied to the long-form utility. It owns top-margin on the following element, so these four each sit next to a heading and each lost that heading's dead space: roughly 6px next to an h3, 9px next to an h1.

    Token Was Now
    --rhythm-above-h3 28px 36px
    --rhythm-above-h2 48px 56px
    --rhythm-above-h1 64px 72px
    --rhythm-below-h1 32px 40px

    --rhythm-base (block to block, nothing trimmed on either side) and --rhythm-figure (a table or pre, which has no text box to trim) render exactly as they always did and were left alone. If you re-tune any of this yourself, that is the rule to copy: compensate only where a trimmed box is actually adjacent. Raising a gap that never lost anything just makes it loose.

    Two things this changes that you may have relied on. --rhythm-below-h1 is no longer literally half of --rhythm-above-h1; that was only ever true of the token values, never of the painted gaps, because each carried a different amount of dead space. And an h1 followed directly by an h2 lost dead space on both sides, so it stays about 8px tighter than before. That pairing has no content between the two headings and is rare enough that it did not earn a token of its own.

  • Watch your gaps against the line-height now, not just against each other. Untrimmed boxes supplied half-leading on both sides of every block, which quietly kept the gap between two paragraphs larger than the gap between two lines inside one. Trim removes that guarantee. A 16px gap between blocks of 24px-leading text now reads as no break at all, so if you have hand-set a gap between text blocks anywhere, compare it to the leading before deciding it is still right.

New build failures

  • heading-hand-tuned-margin (warning, not yet an error). Fails on a numbered mt-*, mb-*, my-* or space-y-* in the className of an h1-h6 or a kit *Title part.

    This exists because of the trap in the change above. Gaps under headings got tighter, and the tempting fix is a margin on the heading. That margin is frozen at compile time and sits outside the ladder, where no token, theme, or override can reach it. The fix is the rung: put the heading and its description in a group on --spacing-rhythm-pair and let the group clear its content on --spacing-rhythm-heading-sm|md|lg, or use PageHeader, which encodes both.

    A negative margin is the one to look for hardest. -mt-1 on a heading is almost always a nudge that was compensating for the untrimmed box, so it now double-counts: delete it rather than re-tune it.

    Two things it deliberately leaves alone. mt-0 is a reset of an inherited prose margin and is correct. So is the rung-reading form mb-(--spacing-rhythm-heading-md), which follows an override the way a frozen number cannot. A line that needs the margin anyway can carry a ui-md-allow-heading-margin comment.


Changes

v2026-08-17.1

Additive

  • Superseded, see above. The kit no longer asserts this globally; the trim travels with the components that own the gap beside the heading.

  • The kit now asserts that headings are cap-trimmed without owning how they look. Size, face, weight, tracking and any .h*-display recipes stay yours; the trim is the one thing hoisted here, because it is the only part that decides whether a spacing token renders honestly. A project that had been applying the trim in its own CSS can delete that rule after this pull.

Moves on screen

  • Superseded the same day by the entry above: this blanket trim was pulled back to an opt-in cap-trim utility. If you are reading both entries in one pull, follow the newer one and skip the opt-out recipe below, which no longer applies because there is nothing global to opt out of. Kept for the record.

  • Every heading is cap-trimmed, so every gap touching a heading tightens. h1-h6 now carry text-box-trim: trim-both; text-box-edge: cap alphabetic in @layer base. A heading's line box used to be taller than its letters, so every gap above or below one rendered looser than the token that set it, and the token was never the number you saw.

    How much depends on your heading face and size. Measured on the face that found this (ascender 0.975em, cap 0.721em) at 28px with --leading-snug: 8.8px of dead space above the cap and the same below the baseline. So --spacing-rhythm-heading-lg (24px) painted 32.8px. The offset is fixed, so the smaller the gap the more it lied: that 24px was off by 37%, a 64px section pad by 14%. Expect small gaps to tighten most.

    Two knock-ons worth looking for. Headings sitting in a flex row with items-center next to an icon, badge, or arrow will re-center, because the box they are centered against just changed height; items-baseline is the fix and may already be there. And any nudge someone added to compensate for the untrimmed box is now double-counting: delete it rather than re-tune it.

    Descenders hang into the gap below a heading now. That is what alphabetic means and it is intended, not a clipping bug.

    Firefox does not support this yet (Chrome/Edge 133+, Safari 18.2+; Firefox has it behind layout.css.text-box.enabled in Nightly as of 2026-08). An unsupporting browser ignores both properties and renders the untrimmed boxes it rendered before, so this degrades to the old look rather than to a broken one. Shipped with that known, because the alternative is gap tokens that never mean what they say in any browser.

    To opt out, re-declare it in your own CSS below the kit import:

    h1, h2, h3, h4, h5, h6 { text-box-trim: none; }
    

    No longer needed. The blanket rule is gone; see the newer entry.


Later

v2026-08-14.2

Additive

  • SiteHeader is finally responsive, and it needed no new prop. The bar height, the sticky offset and the scroll margin are now read as h-(--spacing-site-header), top-(--spacing-site-header) and scroll-mt-(--spacing-site-header) instead of the h-site-header family.

    This was a real bug, not a tidy-up. Tailwind v4 inlines a named spacing token when it compiles, so h-site-header shipped as height: 4rem with no var() in it and your override of --spacing-site-header could never reach it. The only way out was restating the compiled utility in an unlayered block, which is what at least one site was doing. The paren form compiles to height: var(--spacing-site-header) and stays live.

    A compact mobile bar and a taller desktop one is now two lines of your own CSS, and the sticky offsets, scroll margins and hero scrim all follow from that one number:

    :root { --spacing-site-header: 3.5rem }
    @media (width >= 48rem) { :root { --spacing-site-header: 5rem } }
    

    If you were patching .h-site-header or .top-site-header directly, delete that block and set the token instead.

  • Drawer has a second layout for application panels. <DrawerContent layout="panel"> turns the drawer into a cart, an inspector or a filter rail: DrawerHeader becomes a fixed 64px bar with the title and its metadata left, an inline close right, and a hairline running wall to wall; the new DrawerBody scrolls under it; DrawerFooter pins to the bottom with its own hairline. The popup owns no padding in this layout, so each region pads its own contents and the rules reach both edges.

    The default prose layout is untouched, so nothing changes unless you ask for panel. If you were building this shape by hand with p-0 gap-0 showCloseButton={false} plus your own <header> and an absolutely positioned hairline, that all comes out; the layout is one prop on DrawerContent and the parts read it.

  • DrawerHeader takes a close slot under layout="panel". Pass your own control (<DrawerClose render={<MyButton />} />) when the bar's close needs a different accessible name, icon, or an analytics attribute. Omit it and you get the stock X.

  • DrawerBody is new. Use it for the scrolling middle of any drawer. It carries the min-h-0 that flex-1 overflow-y-auto needs to actually scroll instead of growing the popup past the bottom of the screen.

Moves on screen

  • The site header is 80px, up from 64px. --spacing-site-header moved from 4rem to 5rem. Every SiteHeader on your site gets 16px taller, and anything you pinned to it (a sticky sub-nav, an anchor's scroll margin) moves with it.

    If 80px is wrong for you, it is now genuinely one line to change, which is the other half of this release. See below.

  • The hero scrim tracks the bar instead of being its own length. --spacing-site-header-scrim is now calc(var(--spacing-site-header) * 2). It was 8rem, which was exactly twice the old bar and stopped being twice anything the moment the bar moved. Only affects surface="hero".


Changes

v2026-08-14.1

Also in this release

  • Pull the extras you already have. kit does not list the eight kit-extras components, so add -o kit.json has never updated them. If you installed one of these, it has been frozen at the release you installed it on, through every pull since:

    calendar  carousel  code-block  combobox  command  rich-text-editor  sonner  toast
    

    It type checks and it renders. It is just an older release of the same component sitting beside newer ones, which shows up as a fix that looks half-applied rather than as a missed pull.

    node scripts/verify-ui.mjs --drift is what catches it: the file differs from what is published, so it gets listed. Read that listing carefully, because it uses one label, kit-file-differs, for two opposite problems. On a kit component it usually means you edited the file and the pull is about to revert it. On one of the eight above it usually means the file is just behind, and the remedy is to pull it by name.

    Add the ones you have to a single call, by name. This installs nothing new, and their packages are already in your package.json:

    npx shadcn@latest add -o https://ui.mattdowney.com/r/command.json \
                            https://ui.mattdowney.com/r/combobox.json
    

    docs/ui-md-upgrade.md step 2 now carries this, so it happens on every pull from here. Do not install one you do not already have: each forces a third-party runtime package.

  • verify-ui now says when it could not reach the registry. It fetches kit.json and kit-extras.json on every run, which is the only way it can tell an installed kit file apart from your own code. That fetch failing has always been safe: everything under a ui/ directory is treated as kit and skipped, so a network blip can never fail your build. It was also silent, and the counts read identically either way.

    That matters most if you have wired this into npm run build, because a clean offline run checked strictly less than a clean online one at exactly the moment it decides whether a deploy ships. It now prints a line saying so. Nothing about the exit code changed.

  • Badge grew a display size, for a featured stat in a chip on a marketing surface: a subscriber count in a hero, a founding year beside a wordmark.

    <Badge size="display" icon={<Star />}>12,480 subscribers</Badge>
    

    It is a different job, not the next step of the annotation ramp: 16px type, deliberately skipping body size, with padding and icon scaled to match. The annotation ceiling still holds everywhere a badge annotates something (lg, 13px, remains the largest of those). If you were faking this size through className, the size-faked-with-classname listing goes away when you move to the prop.

  • ProgressRing takes a weight. The stroke has always been an eighth of the diameter, which is tuned to the 16-48px rings the kit ships; perceived weight grows with area, so a ring sized well past that reads the same ratio as a donut. weight="thin" halves the stroke to a sixteenth:

    <ProgressRing value={72} weight="thin" className="size-20">72%</ProgressRing>
    

    Use it when you size the ring past the shipped three with a className, instead of forking the component to redraw the arc. Rings without the prop are unchanged.

  • NavItem takes a count, the same counter chip a TabsTrigger carries, and the same recipe: solid accent fill on the active item, neutral tint on the rest, tabular-nums so a live number does not jitter.

    <NavItem href="/inbox" icon={<Inbox />} count={12} active>Inbox</NavItem>
    

    Horizontal, the chip sits inline after the label. Vertical, it is pushed to the trailing edge so a column of numbers reads as a column, which needs the nav to have a width; an item without a count leaves the slot empty rather than reflowing its label. Cap large counts at "99+" yourself, and add an aria-label where a bare number needs context. Items without the prop render exactly as before.

  • ProgressRing, the same progress as a ring, shipped from the progress item beside the bar.

    <ProgressRing value={72} size="lg" aria-label="Storage used">72%</ProgressRing>
    

    For the places a full-width bar does not fit: a row, a card corner, beside a label. Three sizes (16 / 24 / 48px), and the stroke is an eighth of the diameter at each, so all three read as one ring rather than three weights, and that holds if you size it past those three yourself. Children sit in the middle, out of the flow, so the box stays the size it was asked for, and lg is sized to hold a two-digit percentage without it touching the arc.

    It never spins. A ring is a quantity; a spinning ring is a wait, and the two are only a shape apart. With no value it draws an empty track and holds still. The bar keeps its indeterminate state.

  • Pagination, a pager for a table, a card grid, or any list.

    <Pagination
      page={page}
      pageCount={12}
      onPageChange={setPage}
      total={240}
      pageSize={20}
      pageSizeOptions={[20, 50, 100]}
      onPageSizeChange={setPageSize}
    />
    

    Give it total and pageSize and it renders the "1-20 of 240" count; give it pageSizeOptions and it renders the rows-per-page select. With neither, it is only the pager, and it centers under whatever it belongs to rather than sitting against one edge.

    It works from a Server Component. Pass renderPage={(page) => <Link href={?page=${page}} />} and the pages draw as links instead of buttons, which is the one shape a server page can do: it can build a link, but it cannot hand a click handler to a button. The component carries no "use client" so that stays true.

    The window of page numbers is always the same length. The usual pager drops its ellipsis near either end and renders fewer controls there, so the row changes width as you page through it and everything beside it shifts.

  • MetricCard, the dashboard card for one number: title, value, goal, a delta badge, and the metric's recent trend, in one component.

    <MetricCard
      title="Subscribers"
      value="183"
      goal="Goal: 262"
      delta={{ value: 79 }}
      trend={{ data: subscribers, type: "bar" }}
    />
    

    It is a composition, not a new surface. Card, DashboardCardHeader, MetricCardValue, and Stack all still ship on their own, and what this adds is the rhythm, which is the part that was being retyped and quietly getting different at every call site: the goal line and anything you pass as children bind to the value at 8px, and the chart sits under that whole block at 16px as a peer. When your layout differs, reach past it to those parts. That is what they are for, and it is not a fork.

    Sparkline ships from the same file, and draws the chart. Three shapes on one type prop: bar for a count per period, line for a trend, area for volume under a trend. Three heights on size (24 / 40 / 64), and the width always fills its container. MetricCard draws it for you through trend, but it is reachable on its own for a table row or a list item, where a card would be the wrong box entirely.

    A bar chart is drawn, not laid out. The bars were a row of CSS boxes, and a row of boxes cannot be evenly spaced at an arbitrary width: thirty columns in a 203px card is a 6.77px pitch, and a browser snaps a box to whole device pixels, so the gaps land 3px and 4px apart in whatever pattern the fraction falls in. The bars measure identical and the row still reads uneven, because the eye takes the whitespace as part of the rhythm. They are <rect>s in an SVG now, where geometry is antialiased instead of snapped, so every bar and every gap carries the same ink at any container width.

    The chart fills its container. The bar row used to stop at a maximum width per point and center itself, so a wide card drew a short chart adrift in the middle of it. A bar is 60% of its column at every size instead, which means a wide card gets wider bars rather than the same thin ones in bigger gutters.

    It renders on the server with no JavaScript and no third-party package, so a wall of metric cards costs nothing at runtime. Every mark paints currentColor off a text-accent root, so it follows your accent automatically and one class retargets the whole chart: <Sparkline data={churn} className="text-destructive-500" />.

    The scale is not the same for every shape, on purpose. bar measures from zero, because a bar's length is the value and a band-scaled bar claims a 98 is a fraction of a 100. line and area measure the min-to-max band, because a zero-based line flattens any series whose values sit far from zero. A series that crosses zero belongs in line; bars clamp negatives to the baseline.

    A bar is always half its column, so bar and gap hold the same ratio at any card width and any number of points. An empty data array still renders at full height with a hairline on the baseline, so a card does not jump when its numbers arrive.

Moves on screen

  • Dark mode: every accent-tinted selected state got one step lighter, and its label one step brighter. The pair went from accent-900 under text-accent to accent-800 under text-accent-400. Light mode is unchanged.

    It was backwards. On the old pair the selected label sat at 3.22:1 while the unselected labels beside it sat at 7.32:1, so the item you had chosen was the hardest one in the row to read, and it failed AA while its neighbours passed. Tabs is where it shows worst, because the active tab sets its colour on the button and lets the label inherit.

    You get this on: Tabs, Nav (and the mobile site nav), Badge, the tinted Buttons (outline, soft, ghost) and their count chip, Select, Command, Combobox, DropdownMenu (every row state, icons included), Toast's action rows, and Lockup's tinted tone. Nothing to change. If you re-declared --color-accent-* you keep your own hues, since these are scale steps.

    Three of those are kit-extras items, so add -o kit.json will not bring them: Command, Combobox, and Toast. If you have them installed, pull them by name in the same step or they keep the old colours while everything around them changes, which reads as the fix being half-done. See "Pull the extras you already have" below.

  • Dark mode: the primary Button rests a step down, and its hover reverses. default now rests on accent-600 and hovers up to accent-500; light still rests on 500 and hovers to 600.

    A saturated accent-500 plate is the loudest thing on a near-black page. The step down quiets it against the page (3.94:1 to 2.78:1) and at the same time raises its label (5.0:1 to 7.07:1). The hover had to flip with it: "one stop deeper" only means more presence when the page is light, so on dark the old hover walked the fill toward the background and the button receded under the pointer. destructive is unchanged.

  • Pagination's rows-per-page popup now opens flush with its left edge instead of centered on it. The popup is sized to its trigger with a 128px floor, and a trigger reading "10 per page" is under that floor, so the popup came out wider than the trigger and centering hung the extra off both sides. The select sits at the left edge of the pager row, so that left overhang broke the edge the count and the table above it line up on. Nothing to change; if you were passing align yourself, you still win.

New build failures

  • bare-element-inherited-property, a warning. It does not fail your build today. It will be promoted to an error in a later release, and that promotion gets its own line here.

    It flags a rule in your own CSS whose selector names a bare element with no class, id, or attribute anywhere in it, and which sets a property that inherits: color, font-family, font-size, font-weight, line-height, letter-spacing, text-transform.

    /* flagged */
    span { color: var(--color-muted-foreground); }
    
    /* fine: scoped, so it cannot cross a component boundary */
    .prose span { color: var(--color-muted-foreground); }
    

    An inherited value has nothing to defend itself with. Kit components set colour and type on their root and let the text inside take it, so any rule that names the inner element directly wins, no matter how specific the component's own selector was. A bare span { color } silently repaints the label of every component whose label is a <span>.

    The case this came from cost a long debugging session and looked like a bad port. It was not one. Tabs sets data-[active]:text-accent on the button and lets the label inherit, so the button really was accent while the word on it stayed grey. The component file matched the registry byte for byte, the token resolved identically, SSR emitted data-active on the right element, and the variant compiled into the bundle at winning specificity. Every static check passed. It was visible only by reading computed styles on the live DOM, which is why it is worth a rule.

    Only the generic wrappers are flagged: span, div, li, a, button, label. p, h1-h6 and blockquote are left alone, since those carry meaning and components rarely emit them structurally, so styling them is describing your own prose rather than reaching into a component. Minified and compiled CSS is skipped. A rule that is deliberately global opts out with a ui-md-allow-bare-element comment on it or in the three lines above it.


Changes

v2026-08-13.1

Also

  • The upgrade procedure now ships to docs/ui-md-upgrade.md: the whole sequence (drift check before the pull, pull, changelog diff, the stale-token and import-order traps, the rung conversion, verification) as one tool-neutral checklist any agent or person can run. Point Codex, Cursor, or any AGENTS.md-reading tool at it with one line, which the file's last section spells out. Claude Code users get it wired as a command: the kit also ships a thin skill to .claude/skills/uimd-pull/SKILL.md, so /uimd-pull reads and runs the same file. Not using Claude Code? Delete the skill file; the procedure is not in it.
  • verify-ui.mjs gained rule severities and a --strict flag, which is what lets a new rule warn instead of breaking your build on the day it ships.
  • Fixed: the checker's class-string scanner did not understand regex literals, so a regex containing an odd number of backticks opened a string it never closed and left the rest of that file unchecked. Five rules shared the blind spot, silently. If a file of yours was quietly exempt, it is not any more, and you may see violations appear in code you did not touch.
  • verify-ui.mjs gained --all, which lifts the 20-findings-per-rule display cap; the first consumer conversion had to copy the script out of the kit just to see its whole list. The upgrade procedure also now scopes step 5 to the errors a pull introduced: a pre-existing backlog is its own piece of work, not an upgrade's cost.
  • Fixed: the checker now skips every hidden directory (.design-sync, .cursor, and friends), not just a named list. The first consumer run linted a design tool's generated previews as if the project had written them; a generator's output answers to the generator, not the style rules.
  • Fixed: the rules file said --spacing-rhythm-heading-sm is 8px. It ships 16px.
  • The rules table in the Checking your work section was two rules out of date.

Moves on screen

  • SiteFooter renders 8px airier. The footer shell's gap between rows, and the vertical gap in its column grid, were a frozen 32px. Both now read the peer rung (40px), so they follow the ladder and answer to a token override. SiteFooterRow's divider padding moved with them, since that padding and the shell gap are one measurement written twice.

New build failures

  • kit-typeface-unwired, as a warning. The kit now installs the geist package: Geist, stylistic sets and all, is the system's typeface, and the token file's letterform tuning is pointed at it. A font cannot wire itself, so this warning fires when a project has neither the two-line Geist wiring in its root layout (import { GeistSans } from "geist/font/sans", then GeistSans.variable on the <html> className) nor a --font-sans-custom override. Un-wired, every page silently renders system-ui. Projects with their own brand face via --font-sans-custom are already clear and change nothing.
  • family-without-feature-settings, as a warning. A CSS rule that sets a non-kit font-family must set font-feature-settings in the same block. The kit's Geist-tuned list inherits from body into every element, and stylistic set numbers are per-family: the recorded case was a display face whose same-numbered set substituted a wide space and tripled every word gap, while every static check passed. normal is the safe reset; a face verified to want the inherited list opts out with a ui-md-allow-feature-inherit comment. Minified/compiled CSS is skipped.
  • frozen-page-scale-gap, as a warning. Flags a numbered vertical gap of 32px or more: space-y-8, gap-y-10, mt-12, or a bare gap-* on a non-wrapping flex-col. Tailwind inlines that value when it compiles, so the number is frozen and no token override can reach it afterwards. Convert to a rung (<Stack gap="peer">, or the class form gap-(--spacing-rhythm-peer)). Below 32px nothing is flagged. Where the pixel count is the subject, opt the line out with a ui-md-allow-frozen-gap comment. This will become an error in a later release.