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.
Schema (v0)
Section titled “Schema (v0)”id: acme-brand-v1 # required, uniquename: 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 blockQuote
reason:values containing#. An unquoted#starts a YAML comment and silently truncates the text.
Check semantics
Section titled “Check semantics”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.
Outdated-asset rules
Section titled “Outdated-asset rules”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.
Advisory rules in general
Section titled “Advisory rules in general”Every configurable rule accepts an advisory flag; only the default
differs by intent:
- Enforcement rules block unless you opt out —
colorsandfontstake a block-leveladvisory: 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 in —
outdatedandimagesrules default to advisory and takeadvisory: falseper 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 matchesmatch:. - 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, catchingsrcattributes, CSSurl()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:
$ stylist hash brand/logo-2019.png brand/logo-2019.svg1818181818e5f607 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.
Audience-scoped rules
Section titled “Audience-scoped rules”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.requireditems andoutdated.logos/outdated.colors/outdated.fontsitems (the same slotadvisorysits in). Use this for “this specific term/outdated rule only matters for one audience.” - Block-level, on
colors,fonts, andimagesas 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.
Requiring audience to be declared
Section titled “Requiring audience to be declared”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 failingWith 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).
Per-project rule overrides
Section titled “Per-project rule overrides”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 thisoutdated.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.
Checking PowerPoint decks (.pptx)
Section titled “Checking PowerPoint decks (.pptx)”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.allowedandoutdated.colors, fonts againstfonts.allowedandoutdated.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.
Checking Word documents (.docx)
Section titled “Checking Word documents (.docx)”.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.pptxthemes use), checked againstcolors.allowed/outdated.colorsandfonts.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.
Serving rulesets centrally
Section titled “Serving rulesets centrally”stylist serve -rulesets ./rulesetsEvery *.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.