Skip to content

onsystem@0.7.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 04:01
· 106 commits to main since this release
1d54d1e

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_component return 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-baseline once after upgrading, and pin the GitHub Action to a commit SHA if you need a stable check: @v0 now 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 --fix leaves 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-baseline drops fixed findings without accepting new ones, and check, check_ui and inspect now 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 9ecb46d Thanks @dgesteves! - Zero config now finds color tokens kept in a shared Tailwind config package, as Dub keeps them in packages/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 through exports, 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 with rgb().

    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, so check exits 1 there. Run onsystem check . --update-baseline once after upgrading to accept them; in Dub the 1,052 findings 0.6.0 recorded still match, and check exits 0 again. The warnings are colors whose hue has no token in the theme, and they do not fail check unless you set --max-warnings (or fail-on: warning in the Action). The GitHub Action at @v0 moves 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 57aef3b Thanks @dgesteves! - check --prune-baseline drops the baseline entries that no longer occur and accepts nothing new, and it is the command check now 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-system and --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-baseline creates the baseline's folder, and with --format json, sarif or github prints 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, check says they are new to the baseline rather than to the code, and how many other new findings --update-baseline would accept with them. A baseline without these fields is read as before, and older versions of onsystem read the new ones without complaint.

  • #147 a19115d Thanks @dgesteves! - no-hardcoded-color no longer offers two kinds of fix that changed what a color means, which eslint --fix and applyFixes applied 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 blue primary became bg-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 with hover: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 rose brand next to a red destructive, bg-[#ef4444] on a payment-failed alert became bg-brand, so the error would follow the next rebrand. The message now offers both (bg-destructive if the color shows a status, otherwise bg-brand). The brand's own color, and reds that destructive matches, are still fixed.

  • #141 a83de7c Thanks @dgesteves! - no-hardcoded-color counts 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%) became bg-destructive, rgba(255, 255, 255, 0) became the opaque background, and in shadcn/ui's dark theme dark:border-[#ffffff] became dark: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 7e11d9b Thanks @dgesteves! - no-hardcoded-color reads tokens whose names start with on- (Material's) as text colors, and those that start with content- (Dub's) too, unless the name goes on to say border or background (content-border, content-background). text-neutral-400 now matches Dub's content-muted, and border-*, ring-* and bg-* 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-600 to a token holding v3's red-600) keep their fix. Other close tokens are still suggested, without a fix, so eslint --fix leaves them, and the message says why and what to do: content-default for text-neutral-600 is one shade darker (neutral-700), and a token at neutral-500 for text-slate-500 is closest to neutral-500, not slate-500.

  • #157 efb3888 Thanks @dgesteves! - In an app whose design-system package publishes only compiled dist/, not its component sources, no-unknown-component no 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 sets importPath, as init writes it for a package apps import by one name. Those errors blocked the Claude Code hook and failed CI on real components, while check said 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 and icon-button-accessible-name on native elements still run. To check the package's components in those apps, add src/ to files in its package.json.

  • #131 43b66c9 Thanks @dgesteves! - npm install --save-dev onsystem no longer fails with ERESOLVE in a project on ESLint 8.57. The optional eslint peer 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 2633d3f Thanks @dgesteves! - no-unknown-prop and no-unknown-variant no longer fix a guess that changes what the code means. eslint --fix, fix-on-save and the agent tools applied them: <Button href="/docs"> became ref="/docs", <Input onValueChange={…}> became onVolumeChange, as="a" and component={Link} became asChild, onClose became onOpenChange (which also runs on open), and size="xl" on a button with an xs size became xs. 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 (isDisabled to disabled, tone to variant), the same name in other case (onclick to onClick) and an own prop one letter off, handlers included (varient to variant), are still fixed.
    • href, as and component get how the component composes instead: asChild with a <Link> child on Radix, render on Base UI. On a component that composes neither way, they get no guess (no ref for href, no is for as).
    • onClose, onDismiss, onOpen and onToggle point at onOpenChange with 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 (xl from xs, icon-xl from icon-xs). Only another name for the same size is fixed (xlarge to xl, small to sm), and so is the same step spelled the way the scale spells it (xxl to 2xl or 2xl to xxl, xxs to 2xs, XL to xl). A size one step past the end of the scale is named next to that end without a fix: xxs next to xs, 2xl or xxl next to xl, 3xl next to 2xl, and xs next to sm or small on a scale without xs, which was fixed before. A prefixed step is named the same way (icon-2xl next to icon-xl, icon-xs next to icon-sm).
  • #124 5a4b66c Thanks @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 682acef Thanks @dgesteves! - The Claude Code hook now honours the config and baseline at a monorepo root. init at the root writes both, and check . there reads them, but the hook ran check from the edited file's project folder (the nearest package.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 as check . 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, as check . 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 sets components, or it has a components.json or a components/ui folder), which check there 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 6e98f82 Thanks @dgesteves! - The Claude Code hook no longer waits for the npm registry on every edit. In a project without its own install, it ran npx --yes onsystem@<version>, and npx asks the registry about the package on every run, even with the exact version in its cache: offline, each .tsx or .jsx edit waited 55 seconds and was still not checked. The hook now runs npx --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 b3bec0a Thanks @dgesteves! - icon-button-accessible-name no longer fixes a label that names the wrong action, which eslint --fix and the agent tools applied: an expand chevron in shadcn's sidebar blocks (<CollapsibleTrigger asChild><SidebarMenuAction><ChevronRight /></SidebarMenuAction></CollapsibleTrigger>) got aria-label="Next", and X buttons that call removeAssertion(index) or onRemove got aria-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; not Binary, Copyright or FilterX, 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 its onClick calls. Elsewhere it is named by what its onClick (or onPress) calls: "Remove" for remove(index), onRemove or onDelete, "Clear" for clearSearch() or form.reset(), "Close" for onClose, setOpen(false) or toast.dismiss(id), and fixed only when every call says the same action (clearTimeout does 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-state or data-[state=open]: classes, is named "Toggle" instead of "Next" or "Previous".
  • #125 5255d5d Thanks @dgesteves! - init in 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 write import { 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). If init wrote such an importPath for you, delete it from onsystem.config.json.

  • #149 f2d580e Thanks @dgesteves! - init in a design-system package now tells its team what apps that install the package from a registry get. When files leaves out the component sources (a package that publishes only dist/), it counts component and token files apart, and says that those apps get no component, prop, variant or native-element checks and that check --require-design-system fails 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 until files publishes the sources. A token file the config names outside the package, such as ../tailwind-config/theme.css in a sibling Tailwind config package, is not read in an app that installs the package, and init now says so, also when package.json lists no files. init --force in an app that takes on a package's config now writes the package's importPath with 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 2d314eb Thanks @dgesteves! - A JavaScript app is now checked through its jsconfig.json. Without a tsconfig.json, onsystem read no path aliases, so in a shadcn/ui app set up with "tsx": false (its @/* alias in jsconfig.json, as create-next-app writes it for JavaScript) components.json was not followed, and every component imported through @/components/ui/... counted as external: <Button variant="primary"> and a misspelled <Cardd /> passed, and check printed "No problems" with exit code 0. onsystem now reads jsconfig.json when there is no tsconfig.json, as TypeScript does, so those imports resolve and the component rules run on them. A jsconfig.json without an exclude takes 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 --explain names the file it read, and so does its note when the ui alias does not resolve through it. A change to the file rebuilds the cached model, which the running MCP server picks up.

  • #172 4261f4c Thanks @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 their node_modules. A cloned repository could have get_component return any file on the machine: a component named Credentials and a committed link components/ui/credentials.md to ~/.aws/credentials put 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 did docs or tokens in onsystem.config.json naming an absolute or ../ path, and a token stylesheet that @imports a linked one. Such files are now skipped, and inspect and 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.json that declares workspaces. 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 run onsystem init in it. A file found through two paths, such as through a folder that links back to its parent, is read once, and check lints it once.

  • #132 7b7f230 Thanks @dgesteves! - no-hardcoded-color reports 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 no success, warning or info token. The message no longer names the nearest token, a gray or another hue's (muted, or chart-2 in 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, and onsystem check exits 0 for them unless --max-warnings is 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-600 next to a green --success), are still errors.

  • #128 3ee0148 Thanks @dgesteves! - A pnpm-workspace.yaml that lists no packages no 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-pnpm writes one with only allowBuilds), and in such an app inspect printed "workspace 0 packages", init wrote 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 a packages list that names only the root (- '.'). Next to workspaces in package.json, such a file no longer hides them, and a packages key that is quoted or follows a byte order mark is now read.

  • cfdc430 Thanks @dgesteves! - eslint --fix no 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 (Table from @tanstack/react-table, an app's own Label) or declares only as a type does not count.

  • #171 37c768b Thanks @dgesteves! - check, check_ui and inspect now 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), check and check_ui now 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-system prints it with the failure. inspect lists each such library on its library line, and inspect --explain under how the design system was found. Without node_modules, the props of components that wrap other packages do not resolve and no-unknown-prop skips those components: the demo checked before npm install gave 7 errors instead of 8, with nothing to say why. check and check_ui now add a notice when the dependencies a project declares are not installed, with how many components no-unknown-prop skips, and plain inspect prints 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 c598cae Thanks @dgesteves! - The skill now says what to run when the onsystem MCP tools are not available, as when it is added on its own with npx 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 names npx -y onsystem inspect for the components, npx -y onsystem export --format design-md for the tokens and npx -y onsystem check <file> --format json after each change. Agents that have the tools use them as before.

  • #153 482a38f Thanks @CRYPTONIKAV! - no-hardcoded-spacing no longer reports or fixes values of 1px or less that the spacing scale has no exact step for. A -1px margin is a common way to overlap borders, and on a scale without a 1px step the rule fixed mt-[-1px] to -mt-xs, turning -1px into -4px, and mt-[0.5px] to mt-xs; eslint --fix applied 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 b06d065 Thanks @CRYPTONIKAV! - no-hardcoded-color no longer flags white and black gradient stops inside an SVG <mask>, or in a gradient a mask references through fill="url(#id)": there they set the mask's opacity, not a color.

  • #122 efe9644 Thanks @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 as no-unknown-variant errors ("Allowed: single") once the Radix types were installed, and get_component listed only "single". They now pass, and get_component shows type: "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 03482b8 Thanks @dgesteves! - A variant prop that TypeScript types as a set of strings keeps every value in that set when its cva() or tv() literal lists fewer. With variant: { ...base, brand: "…" } and VariantProps, variant took only "brand", so <Button variant="default"> was a no-unknown-variant error and the fix rewrote it to variant="brand"; with tv's extend, size took only the values of its own literal, so size="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's extend), the prop accepts any value instead of only the ones listed; a { true, false } variant stays a boolean.