Repository navigation
onsystem@0.7.0
onsystem 0.7.0 reads more of your theme, makes its fixes safer and closes a file-read hole. It is a minor release: read the upgrade note under Minor Changes before you update.
- Security: component, token and docs files are read only when their real path is in the project, its workspace or git repository, or an installed package. A cloned repository can no longer make
get_componentreturn a file elsewhere on the machine through a committed link or a../path. - Zero config now finds color tokens kept in a shared Tailwind config package, as Dub keeps them, so such projects get color findings for the first time. Run
onsystem check . --update-baselineonce after upgrading, and pin the GitHub Action to a commit SHA if you need a stable check:@v0now runs 0.7.0. - A color whose hue has no token in your theme is now a warning that names two ways out, not an error that pushed agents to a gray or a chart token.
- Fixes no longer change what code means:
eslint --fixleaves files that compile, color fixes keep hover states, opacity and status colors, guessed props and variant values are suggested, not applied, icon-button labels are fixed only when the icon says what the button does, and 1px hairlines are left alone. check --prune-baselinedrops fixed findings without accepting new ones, andcheck,check_uiandinspectnow say when a component library is not read yet or the dependencies are not installed. The Claude Code hook holds an Edit to the lines it changed and uses a monorepo root's config and baseline. Thanks to @CRYPTONIKAV for the SVG mask fix (#110) and the hairline spacing fix (#153).
Minor Changes
-
#107
9ecb46dThanks @dgesteves! - Zero config now finds color tokens kept in a shared Tailwind config package, as Dub keeps them inpackages/tailwind-config: it follows the@imports of the stylesheets it finds, by a path or into a workspace package (@import "@acme/theme"in Tailwind v4), reads presets that a package exposes only throughexports, takes the stylesheets of a shared preset's package that set the colors its config maps, and reads bare RGB channels (--content-error: 220 38 38) as colors where the config reads them withrgb().Upgrading from 0.6.0: a project that keeps its color tokens in such a package got no color findings from 0.6.0, which said "No color tokens found: no-hardcoded-color did not run". Now the rule runs and reports the colors it skipped: in Dub's
apps/web, 10,024 errors and 164 warnings that a baseline written with 0.6.0 does not hold, socheckexits 1 there. Runonsystem check . --update-baselineonce after upgrading to accept them; in Dub the 1,052 findings 0.6.0 recorded still match, andcheckexits 0 again. The warnings are colors whose hue has no token in the theme, and they do not failcheckunless you set--max-warnings(orfail-on: warningin the Action). The GitHub Action at@v0moves to each release when it is published, so pin it to a commit SHA if you need a stable check, and move the pin when you update the baseline.
Patch Changes
-
#170
57aef3bThanks @dgesteves! -check --prune-baselinedrops the baseline entries that no longer occur and accepts nothing new, and it is the commandchecknow prints when baselined findings get fixed. The command it printed before,--update-baseline, also recorded every new finding of the run, so following it next to a new error accepted the error. The printed command keeps--baseline,--root,--config,--components,--tokens,--docs,--include-design-systemand--include-tests, so it rewrites the same file from the same findings; before, with--baseline <file>it wrote a second baseline at the root.--update-baselinecreates the baseline's folder, and with--format json,sariforgithubprints its summary on stderr, so stdout stays in the format.The baseline now records the onsystem version that wrote it and the rules that ran. When a rule that did not run then reports findings, as when an upgrade finds tokens an older version missed,
checksays they are new to the baseline rather than to the code, and how many other new findings--update-baselinewould accept with them. A baseline without these fields is read as before, and older versions of onsystem read the new ones without complaint. -
#147
a19115dThanks @dgesteves! -no-hardcoded-colorno longer offers two kinds of fix that changed what a color means, whicheslint --fixandapplyFixesapplied unasked. A class under a condition (hover:,focus-visible:,md:) is not fixed to the token the same class list has or gets without it, nor the other way round, unless the two already have exactly the same color:bg-[#2563eb] hover:bg-[#1d4ed8]with a blueprimarybecamebg-primary hover:bg-primary, and the button lost its hover. A slight change counts too:bg-[#fff] hover:bg-[#fafafa]keeps its hover. The base class is still fixed, and the message suggests keeping the two apart, such as withhover:bg-primary/90;dark:classes still get the same token, and so do two hardcoded classes of exactly the same color (border-red-500 focus:border-red-500), whose condition changed nothing before either, and classes for a pseudo-element or other elements (placeholder:,prose-a:,[&_svg]:). A fix to a token that is not a status color is held back when a status color of the color's hue is close too and at most ΔE 0.03 further: with a rosebrandnext to a reddestructive,bg-[#ef4444]on a payment-failed alert becamebg-brand, so the error would follow the next rebrand. The message now offers both (bg-destructiveif the color shows a status, otherwisebg-brand). The brand's own color, and reds thatdestructivematches, are still fixed. -
#141
a83de7cThanks @dgesteves! -no-hardcoded-colorcounts opacity when it matches a color to a token. It used to compare only lightness, chroma and hue, so a translucent color matched its opaque token and the fix made it opaque:bg-[#e7000b33](the red at 20%) becamebg-destructive,rgba(255, 255, 255, 0)became the opaque background, and in shadcn/ui's dark themedark:border-[#ffffff]becamedark:border-border, which is 10% white. Now a token more than 0.02 apart in opacity is never a fix, and the message gives both opacities (destructive is opaque, and this color is 20% opaque.), so the color can keep its opacity with a modifier (bg-destructive/20). Colors and tokens of the same opacity still match (dark:border-[rgba(255,255,255,0.1)]→dark:border-border, and 15% white →dark:border-input), an opacity modifier still stays on the token (bg-[#ffffff]/40→bg-background/40), and "same value as" lists only tokens of the same opacity. -
#126
7e11d9bThanks @dgesteves! -no-hardcoded-colorreads tokens whose names start withon-(Material's) as text colors, and those that start withcontent-(Dub's) too, unless the name goes on to say border or background (content-border,content-background).text-neutral-400now matches Dub'scontent-muted, andborder-*,ring-*andbg-*classes are no longer fixed to a text color. When the only close token is made for another role, the message names it ("content-subtle has this color, but it is meant for text") instead of saying that no token is close. A Tailwind palette class is now fixed only to a token that matches it (ΔE under 0.02), or to one that is nearest the class's own shade and within ΔE 0.037 of it, the most Tailwind v3 and v4 differ on one shade, so tokens that copy v3's palette (text-red-600to a token holding v3's red-600) keep their fix. Other close tokens are still suggested, without a fix, soeslint --fixleaves them, and the message says why and what to do:content-defaultfortext-neutral-600is one shade darker (neutral-700), and a token at neutral-500 fortext-slate-500is closest to neutral-500, not slate-500. -
#157
efb3888Thanks @dgesteves! - In an app whose design-system package publishes only compileddist/, not its component sources,no-unknown-componentno longer reports every component the app imports from the package ("Button" is not a design-system component (imported from "@acme/ui")) when the package's config setsimportPath, asinitwrites it for a package apps import by one name. Those errors blocked the Claude Code hook and failed CI on real components, whilechecksaid in the same run that the component rules did not run. With no component to check against, imports from the package are now left alone, by the CLI, the MCP server, the ESLint plugin, the hook and the Action. The token rules andicon-button-accessible-nameon native elements still run. To check the package's components in those apps, addsrc/tofilesin itspackage.json. -
#131
43b66c9Thanks @dgesteves! -npm install --save-dev onsystemno longer fails with ERESOLVE in a project on ESLint 8.57. The optionaleslintpeer now admits ESLint 8.57 and later, where it asked for 9: the ESLint plugin runs on ESLint 8.57 with a flat config. On an older ESLint, npm still stops the install with ERESOLVE: upgrade ESLint to 8.57, or, since the CLI and the MCP server need no ESLint, install with--legacy-peer-deps. -
#138
2633d3fThanks @dgesteves! -no-unknown-propandno-unknown-variantno longer fix a guess that changes what the code means.eslint --fix, fix-on-save and the agent tools applied them:<Button href="/docs">becameref="/docs",<Input onValueChange={…}>becameonVolumeChange,as="a"andcomponent={Link}becameasChild,onClosebecameonOpenChange(which also runs on open), andsize="xl"on a button with anxssize becamexs. Now:- A prop guessed by spelling from the attributes a component forwards is named in the message but not fixed (
disable: Did you mean "disabled"?).ref, whether the component forwards it or takes it as its own prop, and forwarded event handlers are never guessed by spelling. Equivalents and synonyms (isDisabledtodisabled,tonetovariant), the same name in other case (onclicktoonClick) and an own prop one letter off, handlers included (varienttovariant), are still fixed. href,asandcomponentget how the component composes instead:asChildwith a<Link>child on Radix,renderon Base UI. On a component that composes neither way, they get no guess (norefforhref, noisforas).onClose,onDismiss,onOpenandonTogglepoint atonOpenChangewith the check to add (onOpenChange={(open) => { if (!open) … }}), without a fix.- A variant value guessed by spelling is named but not fixed, and a size is never guessed from another size (
xlfromxs,icon-xlfromicon-xs). Only another name for the same size is fixed (xlargetoxl,smalltosm), and so is the same step spelled the way the scale spells it (xxlto2xlor2xltoxxl,xxsto2xs,XLtoxl). A size one step past the end of the scale is named next to that end without a fix:xxsnext toxs,2xlorxxlnext toxl,3xlnext to2xl, andxsnext tosmorsmallon a scale withoutxs, which was fixed before. A prefixed step is named the same way (icon-2xlnext toicon-xl,icon-xsnext toicon-sm).
- A prop guessed by spelling from the attributes a component forwards is named in the message but not fixed (
-
#124
5a4b66cThanks @dgesteves! - The Claude Code hook no longer blocks an Edit on findings that were already in the file. It holds an Edit to the lines it changed: those Claude Code's own diff of the edit adds, or else where its new text is in the file, without the unchanged lines it repeats around the change, in files with Windows line endings too. When it cannot tell which lines an Edit changed, because the new text is in the file more than once (a lone}) or a formatter rewrote it, the findings go to Claude as a note that does not block, where they used to block the edit with the whole file's older errors. An Edit that only removes text is not held to older findings either, and a short new text in a long file no longer slows the hook down. -
#148
682acefThanks @dgesteves! - The Claude Code hook now honours the config and baseline at a monorepo root.initat the root writes both, andcheck .there reads them, but the hook rancheckfrom the edited file's project folder (the nearestpackage.json), where neither is read: it blocked Claude on findings the root baseline accepts, whenever Claude wrote a whole file, and on rules the root config turns off, on any edit. When the nearest workspace root has an onsystem config or baseline, the hook now runs the check from there, so a file is checked ascheck .at the root checks it: by its own project, with the root's rules and baseline. Every project without a config of its own takes the root config on, so the hook now also checks a project without design-system components there, by the accessibility rule for one, ascheck .at the root does. A project that keeps a baseline of its own, as a CI job per app does, is still checked from its own folder, and so is every project when the root is one project with a design system of its own (its config setscomponents, or it has acomponents.jsonor acomponents/uifolder), whichcheckthere would hold every file to. A project's own install of onsystem is still the one that runs. The hook now passes the file to the CLI relative to the folder it runs in, so that from the root, a path through a symlink (macOS's/tmp) still lands in the file's project. -
#142
6e98f82Thanks @dgesteves! - The Claude Code hook no longer waits for the npm registry on every edit. In a project without its own install, it rannpx --yes onsystem@<version>, and npx asks the registry about the package on every run, even with the exact version in its cache: offline, each.tsxor.jsxedit waited 55 seconds and was still not checked. The hook now runsnpx --prefer-offline, which uses the cached package without asking the registry, so it checks offline too and makes no request once the package is cached. If npm itself fails with its cache, as when npm's cached list of versions predates a new plugin release, the hook runs once more with--prefer-online; a failure of the CLI, such as an invalid config, is reported at once, without a second run. When npx does not finish within 55 seconds, the notice now adds that npm may not have reached the registry (offline?). When npm could not reach the registry, or npx did not finish in time, the hook does not start npx again for ten minutes in that session, so an offline session waits once instead of on every edit. A project install (npm install --save-dev onsystem) runs as before. The plugin docs no longer say that npx only reaches the network the first time: the MCP server's npx asks the registry each time it starts. -
#160
b3bec0aThanks @dgesteves! -icon-button-accessible-nameno longer fixes a label that names the wrong action, whicheslint --fixand the agent tools applied: an expand chevron in shadcn's sidebar blocks (<CollapsibleTrigger asChild><SidebarMenuAction><ChevronRight /></SidebarMenuAction></CollapsibleTrigger>) gotaria-label="Next", and X buttons that callremoveAssertion(index)oronRemovegotaria-label="Close". Now:- The label is fixed only for an icon that means one thing on a button, named plainly: a trash can ("Delete"), pencil ("Edit"), magnifier ("Search"), gear ("Settings"), copy, download, upload, share or filter icon (
Trash2,TrashIcon; notBinary,CopyrightorFilterX, which only start like one). A plus, a chevron or arrow, a menu icon and the rest are named in the message without a fix. - "More options" is fixed on a menu's trigger (
DropdownMenuTrigger,Menu.Trigger), and named without a fix elsewhere. - An X in a close part (
DialogClose,Dialog.Close) is "Close", fixed, whatever itsonClickcalls. Elsewhere it is named by what itsonClick(oronPress) calls: "Remove" forremove(index),onRemoveoronDelete, "Clear" forclearSearch()orform.reset(), "Close" foronClose,setOpen(false)ortoast.dismiss(id), and fixed only when every call says the same action (clearTimeoutdoes not count). A handler that does two (form.reset(); setOpen(false)) is named "Close" without a fix. A name with words of two actions or with "cancel" (closeDeleteDialog,onCancelDelete), a state setter (setDeleteTarget(null)) and a callback inside the handler (onSuccess) say nothing, so the X is named "Close" without a fix. - A chevron or arrow inside a collapsible's or accordion's trigger, or on a button with
aria-expanded,data-stateordata-[state=open]:classes, is named "Toggle" instead of "Next" or "Previous".
- The label is fixed only for an icon that means one thing on a button, named plainly: a trash can ("Delete"), pencil ("Edit"), magnifier ("Search"), gear ("Settings"), copy, download, upload, share or filter icon (
-
#125
5255d5dThanks @dgesteves! -initin a design-system package that exports its components by a pattern ("./components/*": "./src/components/*.tsx", as shadcn/ui's monorepo template does) no longer writes"importPath": "@acme/ui/components/*". Every app took that on:check, the MCP server and the hook told agents to writeimport { Button } from "@acme/ui/components/*", which does not compile, and a component imported by its own subpath was not checked against its props and variants. Apps now get each component's own subpath (@acme/ui/components/button). Ifinitwrote such animportPathfor you, delete it fromonsystem.config.json. -
#149
f2d580eThanks @dgesteves! -initin a design-system package now tells its team what apps that install the package from a registry get. Whenfilesleaves out the component sources (a package that publishes onlydist/), it counts component and token files apart, and says that those apps get no component, prop, variant or native-element checks and thatcheck --require-design-systemfails in them. Its next steps no longer say that every app now finds the package: a workspace app does, and one that installs it gets no component checks untilfilespublishes the sources. A token file the config names outside the package, such as../tailwind-config/theme.cssin a sibling Tailwind config package, is not read in an app that installs the package, andinitnow says so, also whenpackage.jsonlists nofiles.init --forcein an app that takes on a package's config now writes the package'simportPathwith its components, so imports from the package are still checked against them, and says that the package's rules and later changes to its config no longer reach the app; when the config it writes does not know the package's components, it says so, and how to take on the package's config again. -
#139
2d314ebThanks @dgesteves! - A JavaScript app is now checked through itsjsconfig.json. Without atsconfig.json, onsystem read no path aliases, so in a shadcn/ui app set up with"tsx": false(its@/*alias injsconfig.json, as create-next-app writes it for JavaScript)components.jsonwas not followed, and every component imported through@/components/ui/...counted as external:<Button variant="primary">and a misspelled<Cardd />passed, andcheckprinted "No problems" with exit code 0. onsystem now readsjsconfig.jsonwhen there is notsconfig.json, as TypeScript does, so those imports resolve and the component rules run on them. Ajsconfig.jsonwithout anexcludetakes in every JavaScript file under it, so the build output (dist,build,out,coverage,public) is left out, as it is without a config: an old bundle's MUI theme is not read as the app's.inspect --explainnames the file it read, and so does its note when theuialias does not resolve through it. A change to the file rebuilds the cached model, which the running MCP server picks up. -
#172
4261f4cThanks @dgesteves! - The design system's component, token and docs files are read only when their real path, links resolved, is in the project, the workspace or git repository it is in, or a package installed in theirnode_modules. A cloned repository could haveget_componentreturn any file on the machine: a component namedCredentialsand a committed linkcomponents/ui/credentials.mdto~/.aws/credentialsput that file in the component's description, with zero config and also in a project the server found from a tool call's path, where it reads only JSON configs. So diddocsortokensinonsystem.config.jsonnaming an absolute or../path, and a token stylesheet that@imports a linked one. Such files are now skipped, andinspectand the server's log name them:Not read: components/ui/credentials.md leads outside the project.The home folder and the folders above it never count as the workspace or repository, even with a stray~/package.jsonthat declaresworkspaces. Workspace packages (../../packages/ui), pnpm's links into its store and design-system packages that declare their config read as before. A package linked from elsewhere on the machine without a declared config (npm link) is no longer read: make it a workspace package, or runonsystem initin it. A file found through two paths, such as through a folder that links back to its parent, is read once, andchecklints it once. -
#132
7b7f230Thanks @dgesteves! -no-hardcoded-colorreports a color as a warning, not an error, when the theme has no token of its hue: a green, yellow or blue status color in a stock shadcn/ui theme, which has nosuccess,warningorinfotoken. The message no longer names the nearest token, a gray or another hue's (muted, orchart-2in a chart), or says to pick one, since that led agents to swap a status color for a token that drops its meaning. It gives the two ways out instead: add a token for the hue to the theme (such as--success), or keep the color with// onsystem-disable-next-line no-hardcoded-color -- <reason>on the line above (between JSX children,{/* onsystem-disable-next-line no-hardcoded-color -- <reason> */}, since a//line there is text). The Claude Code hook no longer blocks on these findings, andonsystem checkexits 0 for them unless--max-warningsis set. The ESLint plugin sets severity per rule, so there they stay errors. Grays with no gray token, and colors whose nearest token is a status token of another hue (bg-emerald-600next to a green--success), are still errors. -
#128
3ee0148Thanks @dgesteves! - Apnpm-workspace.yamlthat lists nopackagesno longer makes a single app a monorepo root. pnpm 11 keeps its settings in that file in a single app too (create-next-app --use-pnpmwrites one with onlyallowBuilds), and in such an appinspectprinted "workspace 0 packages",initwrote a config with only$schema("This is a monorepo root: 0 of 0 workspace packages have a design system"), and a design-system package the app installs, which declares its config, was ignored without a word: no components, no tokens, no component findings. The app is now one project, as it is without the file and as pnpm reads it. The same holds for apackageslist that names only the root (- '.'). Next toworkspacesin package.json, such a file no longer hides them, and apackageskey that is quoted or follows a byte order mark is now read. -
cfdc430Thanks @dgesteves! -eslint --fixno longer leaves files that do not compile. The ESLint plugin used to apply every fix, so a rename to a component the file does not import (<button>to<Button>,<Card.Header>to<CardHeader>) broke the build under lint-staged or fix-on-save. Such a rename is now an ESLint suggestion, which the editor offers and you apply, and the message still names the import to add. Renames to a component the file imports from the design system are fixed as before; a name it imports from elsewhere (Tablefrom @tanstack/react-table, an app's ownLabel) or declares only as a type does not count. -
#171
37c768bThanks @dgesteves! -check,check_uiandinspectnow say what they could not read. An app built on Chakra UI or Mantine, with no components of its own, got "No problems" and the generic "No design system found" notice, which pointed to the configuration docs: nothing said that onsystem does not read those libraries yet. When a project without components of its own depends on Chakra UI (@chakra-ui/react), Mantine (@mantine/core), HeroUI (@heroui/react), Radix Themes (@radix-ui/themes) or Ant Design (antd),checkandcheck_uinow add a notice that names the library, says that only MUI is read so far, and suggests wrapping its components in your own;check --require-design-systemprints it with the failure.inspectlists each such library on itslibraryline, andinspect --explainunder how the design system was found. Withoutnode_modules, the props of components that wrap other packages do not resolve andno-unknown-propskips those components: the demo checked beforenpm installgave 7 errors instead of 8, with nothing to say why.checkandcheck_uinow add a notice when the dependencies a project declares are not installed, with how many componentsno-unknown-propskips, and plaininspectprints it on stderr. At a monorepo root, one notice names every project it applies to. Neither notice changes the exit code or the findings. -
#129
c598caeThanks @dgesteves! - The skill now says what to run when the onsystem MCP tools are not available, as when it is added on its own withnpx skills add dgesteves/onsystem. It named only the tools, so an agent without them had nothing to look the design system up or check its work with. It now namesnpx -y onsystem inspectfor the components,npx -y onsystem export --format design-mdfor the tokens andnpx -y onsystem check <file> --format jsonafter each change. Agents that have the tools use them as before. -
#153
482a38fThanks @CRYPTONIKAV! -no-hardcoded-spacingno longer reports or fixes values of 1px or less that the spacing scale has no exact step for. A-1pxmargin is a common way to overlap borders, and on a scale without a 1px step the rule fixedmt-[-1px]to-mt-xs, turning -1px into -4px, andmt-[0.5px]tomt-xs;eslint --fixapplied both and changed the layout. A negative margin of 1px or less (mt-[-1px],-mt-[1px],marginTop: -1) is left alone even when the scale has the step, since the overlap is deliberate. A positive value on the scale is still fixed (py-[1px]→py-px). -
#110
b06d065Thanks @CRYPTONIKAV! -no-hardcoded-colorno longer flags white and black gradient stops inside an SVG<mask>, or in a gradient a mask references throughfill="url(#id)": there they set the mask's opacity, not a color. -
#122
efe9644Thanks @dgesteves! - A prop typed differently in each member of a union now takes what any member allows. Radix types Accordion and ToggleGroup as(SingleProps | MultipleProps), so stock shadcn/ui<Accordion type="multiple">and<ToggleGroup type="multiple">were reported asno-unknown-varianterrors ("Allowed: single") once the Radix types were installed, andget_componentlisted only"single". They now pass, andget_componentshowstype: "single" | "multiple",value?: string | string[]. A member that rules a prop out (size?: never, the usual way to make props mutually exclusive) adds nothing to it, so the other members' values are still checked. -
#133
03482b8Thanks @dgesteves! - A variant prop that TypeScript types as a set of strings keeps every value in that set when itscva()ortv()literal lists fewer. Withvariant: { ...base, brand: "…" }andVariantProps,varianttook only"brand", so<Button variant="default">was ano-unknown-varianterror and the fix rewrote it tovariant="brand"; with tv'sextend,sizetook only the values of its own literal, sosize="sm"from the base was an error. The literal now only sets the order of the values. When nothing types the prop (class-variance-authority or tailwind-variants is not installed) and the literal does not list every value (a spread, a key that is not a literal, or tv'sextend), the prop accepts any value instead of only the ones listed; a{ true, false }variant stays a boolean.