Skip to content

Rulesets

A ruleset is a YAML document describing your house style. Rulesets are identified by id. In local mode, adapters load them from a file path; in server mode, the server loads a whole directory and adapters reference the ID.

id: acme-brand-v1 # required, unique
name: ACME Brand Guide # optional display name
colors:
allowed: # hex palette; empty/absent disables the check
- "#1A2B3C"
- "#FFFFFF"
advisory: false # the default: violations block; true = report-only
audience: external # optional: only enforce this block for external checks
fonts:
allowed: # font-family allowlist; empty disables
- Inter
- Georgia
terms:
banned: # must not appear (case-insensitive, whole word)
- term: cheap
reason: use "affordable"
advisory: true # per-term: this one reports without blocking
- term: beta
audience: external # optional: only enforce this term for external checks
required: # must appear at least once per asset
- term: ACME
reason: brand name must appear in every asset
tone: # LLM-judged voice/tone; only runs where a judge is configured (see server.md)
description: Confident and helpful, never boastful.
good: ["ACME helps your team ship faster."]
bad: ["ACME is the world's most revolutionary product!!!"]
advisory: true # the default: reports without blocking (non-deterministic judge)
profiles: # optional named variants for different doc types
- name: email
match: emails/ # path substring for automatic selection
description: Warm, first-person, casual.
good: ["Hey there! Quick update on your order..."]
bad: ["Dear Valued Customer, we are writing to inform you..."]
outdated: # superseded brand elements (advisory by default)
logos:
- name: 2019 primary logo
hashes: # fingerprints from `stylist hash <image>`;
- "1818181818e5f6" # 16 hex = perceptual (raster)
- "8c7d…64 hex…2e5f" # 64 hex = SHA-256 (SVG, exact)
distance: 12 # optional match threshold, 0-64 bits (default 12)
match: logo-2019 # fallback substring for text references/filenames
reason: "superseded by logo-2024.svg"
- name: retired icon
hashes: ["9f2e1a4c3b2e5f60"]
advisory: false # this rule blocks instead of advising
colors:
- value: "#1a73e8"
reason: "old brand blue — use #0B57D0"
fonts:
- name: Proxima Nova
reason: "brand font is now Inter"
images: # minimum quality for raster image assets
min_width: 512 # 0/absent disables that dimension
min_height: 256
advisory: true # the default; set false to block

Quote reason: values containing #. An unquoted # starts a YAML comment and silently truncates the text.

Colors — scans for CSS hex colors (#abc, #aabbcc, with-alpha variants) and rgb()/rgba() calls. Values are normalized (case, shorthand expansion, alpha stripped, rgb→hex) before comparison, so the palette only needs 6-digit hex entries. Each off-palette color is reported once per asset.

Fonts — parses font-family declarations in stylesheets and inline style attributes. Comparison is case-insensitive. Generic CSS families (serif, sans-serif, monospace, system-ui, …) are always allowed, so allowlisting Inter doesn’t flag its fallback stack.

Terms — banned terms match case-insensitively on whole words (cheap does not flag cheapest); word boundaries are only applied at ends that are word characters, so terms with symbols (ACME (TM)) work. Required terms produce a violation when absent from the asset.

Tone — an LLM judges the asset’s content against description/ good/bad (or a matched profiles[] entry, see below) and reports a concise, actionable reason when it doesn’t fit. The judge only runs when the server is started with -anthropic-api-key (see Server); without it, or when nothing resolves to judge against, verdicts list tone under skipped_checks instead so coverage stays honest. stylist check in local mode never runs the judge; it needs a server to hold the API key. It still resolves and reports -tone-profile/path-match selection, so agents get the same nudges offline. Advisory by default, like outdated/images: a judge is non-deterministic, so a false positive shouldn’t hard-fail a commit unless the ruleset sets advisory: false.

tone.profiles[] lets one ruleset carry different voice guidance for different doc types (emails vs. legal copy vs. marketing). Each profile has a required, unique name (for the CLI’s -tone-profile flag or an agent hook’s explicit override) and an optional match substring (for automatic path-based selection, first match in list order wins, same convention as outdated.logos[].match). Precedence: an explicit -tone-profile override beats an automatic path match, which beats the ruleset’s flat description/good/bad.

Where colors.allowed/fonts.allowed are allowlists (“only these may appear”), outdated: rules are targeted denylists with migration reasons: this specific logo/color/font has been superseded. They exist so a rebrand’s long tail is visible and trackable, without necessarily blocking anyone. The dashboard counts affected assets per category.

Advisory by default. Outdated and image-quality violations are reported everywhere (CLI output, hook output, JSON, dashboard) with severity advisory, but they never fail a verdict: stylist check exits 0, commits go through (the committer still sees the report), and the Claude Code hook doesn’t force a rewrite. Set advisory: false on a rule to make it block like any other violation.

Every configurable rule accepts an advisory flag; only the default differs by intent:

  • Enforcement rules block unless you opt outcolors and fonts take a block-level advisory: true (a violation is a value not in the list, so there is no per-entry rule to flag); banned/required terms take it per term.
  • Migration rules advise unless you opt inoutdated and images rules default to advisory and take advisory: false per rule.

In verdicts this surfaces as the violation’s severity: "error" (blocks) or "advisory" (reports only). Those are the only two values. Advisory severity is useful for phased adoption: start a new palette as advisory: true, watch the dashboard, flip it to blocking when the numbers are low.

Note that a ruleset marking everything advisory means nothing pushes back at generation time: the Claude Code hook never asks the agent to fix its output, and commits never block. Checks still feed the dashboard, so this is a legitimate “observe only” mode, but choose it deliberately.

Outdated logos match hash-first:

  • Raster images (.png, .jpg, .jpeg, .gif) are matched by a 64-bit perceptual hash compared by Hamming distance (distance, default 12 ≈ 80% similarity). Rescaled, recompressed, and re-exported copies of the logo still match; a renamed file is still caught. The hash verdict is authoritative: a decodable image that doesn’t hash-match is not flagged even if its filename matches match:.
  • SVGs (and undecodable images) have no pixels to hash perceptually; they match by exact SHA-256, with match: as a filename fallback.
  • Text assets (HTML, CSS, …) are scanned for match: as a case-insensitive substring, catching src attributes, CSS url()s, and CDN paths that reference the old logo.

Cropping is the honest limit of perceptual hashing: a lightly trimmed copy still matches, a real crop (e.g. icon cut out of a lockup) will not. List the known variants (full lockup, icon-only, dark mode) as multiple hashes entries instead.

Generate fingerprints with:

Terminal window
$ stylist hash brand/logo-2019.png brand/logo-2019.svg
1818181818e5f607 brand/logo-2019.png (perceptual)
8c7d…64 hex…2e5f brand/logo-2019.svg (sha-256, exact match only)

Outdated colors/fonts reuse the same extraction and normalization as the allowlist checks, so #1a73e8 also matches rgb(26, 115, 232) and #1A73E8FF, and font matching is case-insensitive.

Image resolution (images:) reads dimensions from the image header (no full decode) and flags rasters below min_width/min_height.

Image assets (.png, .jpg, .jpeg, .gif, .webp, .svg) are checked wherever text assets are: stylist check, directory walks, the git hook (staged blobs), and the Claude Code hook. Raster images get only the image checks; SVGs additionally get the text checks, since palette and font rules apply to their markup.

Any rule can be restricted to one audience with audience: internal or audience: external; leaving it unset (the default) applies the rule regardless. It’s available at two grains, matching what each rule type already looks like:

  • Per-entry, on individual terms.banned/terms.required items and outdated.logos/outdated.colors/outdated.fonts items (the same slot advisory sits in). Use this for “this specific term/outdated rule only matters for one audience.”
  • Block-level, on colors, fonts, and images as a whole. These are a flat allowlist or a single min-resolution setting, not repeated entries, so the only grain that makes sense is “this whole check only applies to one audience.”

tone: doesn’t take audience: — it already has its own profiles[] mechanism for varying guidance by doc type, and folding audience scoping into the same LLM-judged check is a separate, unbuilt decision.

When a check’s own audience is unset (the common case for a loose asset that never got -audience declared), every audience-scoped rule still applies. An undeclared audience is treated as “could be either,” so declaring audience only ever narrows what’s enforced, never widens it: you can’t accidentally get less coverage by skipping -audience, only more noise until you set it. A mismatched block is disabled the same way an empty/absent one already is (colors.allowed effectively empty, images.min_width/min_height effectively 0), so a filtered-out rule behaves identically to a rule that was never configured.

By default, an undeclared check-time audience is a legitimate state (see above): it just means every audience-scoped rule still applies. A ruleset can opt out of that by setting audience: { require: true }:

audience:
require: true
advisory: false # optional; nil/true (the default) reports without failing

With require: true, any asset checked with no resolved audience (no -audience flag, STYLIST_AUDIENCE, .stylist.yaml audience:, or in-content <!-- stylist: audience=... --> marker) gets a real audience violation instead of silently falling through. Like every other enforcement flag in this file, it follows the nil-means-advisory convention (outdated.*, tone): unset or true reports without failing the verdict, advisory: false makes it blocking. This runs for every asset, project or loose, unlike the CLI’s own audience nudge (which only fires for loose assets, since project assets can already declare audience: in their .stylist.yaml).

A centrally-governed ruleset can mark specific rules as ones a project is allowed to skip via its own .stylist.yaml. This gives case-by-case exceptions (“grandfather this old deck’s logo”) without weakening the ruleset for everyone else. This only ever relaxes: a project can never add rule content the ruleset doesn’t already have, and it can only skip a rule the ruleset itself opted in.

Add overridable: true to the same slot audience/advisory already sit in, at the same two grains:

colors:
allowed: ["#1A2B3C"]
overridable: true # a project may disable this whole block
terms:
banned:
- term: legacy-slogan
overridable: true # a project may skip just this term
- term: forbidden-word # no overridable: false is the default — no project can skip this

outdated.logos/outdated.colors/outdated.fonts entries and the fonts/images blocks take overridable: the same way.

A project requests a skip in its own .stylist.yaml (see Rule overrides), identifying block-level rules by name and per-entry rules by the same value already shown in the YAML above (a term’s own text, a logo/font’s name, a color’s value). Requesting a skip on a rule that isn’t overridable: true (a typo, or a genuine attempt to relax something the ruleset didn’t authorize) is a no-op: the check still runs exactly as if the request had never been made. Both what took effect and what didn’t are reported back rather than left silent, in the CLI’s human output (overridden: … / override ignored (not overridable): … lines), in -json’s applied_overrides/ignored_overrides fields, and recorded per check in the dashboard’s check-store (an audit trail of what’s being relaxed where), so a project can tell whether its override actually took.

A .pptx file is a zip of XML parts. stylist reads the standard layout that PowerPoint and LibreOffice produce and pulls out everything it can check:

  • Slide text — every text run on every slide, checked for banned and required terms.
  • Colors and fonts — read from the slide XML and the theme, then checked the same way as CSS: colors against colors.allowed and outdated.colors, fonts against fonts.allowed and outdated.fonts.
  • Embedded images — every file under ppt/media/ is checked like any other image: outdated-logo matching and minimum resolution both apply.

Violations report deck.pptx for slide/theme problems, and deck.pptx#ppt/media/imageN.png for a problem found in one embedded image, so you can tell which part of the deck to fix.

A .pptx that isn’t a valid package (corrupt zip, or a zip with no slides) fails the check with a single pptx violation instead of passing silently.

Line numbers on .pptx violations point into stylist’s internal text extraction, not a line in the original file. PowerPoint files don’t have source lines the way HTML or Markdown do.

.docx works the same way as .pptx: it is also a zip of XML parts, and stylist reads it the same way.

  • Body text — every paragraph in word/document.xml, checked for banned and required terms.
  • Colors and fonts — direct formatting in the body plus the document’s theme (word/theme/themeN.xml, the same color/font schema .pptx themes use), checked against colors.allowed/outdated.colors and fonts.allowed/outdated.fonts.
  • Embedded images — every file under word/media/ is checked like any other image: outdated-logo matching and minimum resolution both apply.

Violations report report.docx for body/theme problems, and report.docx#word/media/imageN.png for a problem in one embedded image. As with .pptx, line numbers point into stylist’s internal text extraction, not a line in the source file, and a corrupt or invalid .docx fails with a single docx violation instead of passing silently.

Terminal window
stylist serve -rulesets ./rulesets

Every *.yaml/*.yml in the directory is loaded into a registry keyed by id (duplicate IDs are rejected at startup). The directory is re-read on a TTL (-reload, default 30s), so editing a ruleset propagates to all adapters without restarts. If an edit is broken YAML, the server keeps serving the last good version rather than failing checks.

This directory-based registry is the self-host path. Hosted/multi-org deployments publish rulesets per org through the control plane instead, stored in Postgres rather than a directory.