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
background: # optional carve-out for background-position colors
allowed: ["#0F766E"] # a background color is checked against this instead
advisory: false # own advisory flag, independent of the block above
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 Check semantics below)
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
logo: # require the current logo to appear somewhere
required:
- name: company logomark
hashes: ["1818181818e5f607"] # same fingerprint format as outdated.logos
reason: "every deck/doc must carry the current brand mark"
# advisory: false is the default (an enforcement rule, like terms.required)

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.

Background carve-out: colors.background.allowed, when set, reroutes colors found in the background/background-color CSS properties (in stylesheets, inline style attributes, or SVG markup) to that palette instead of the block’s own, reported as a colors.background violation (background color #ff0000 is not in the allowed background palette) distinct from a plain colors one. Every other color-bearing property (color, border*, fill, stroke, outline*, *-shadow, …) stays on the general colors.allowed list, unaffected. Leaving background unset (or empty) means background-position colors fall back to the general palette, exactly as before this field existed, so nothing changes for a ruleset that doesn’t opt in. It applies to HTML/CSS/SVG’s background/background-color properties, and to a PowerPoint slide’s own <p:bg> fill or a Word document’s <w:background> (see the PowerPoint/Word sections below), but not to a theme’s dk/lt color-scheme slots, which stay ambiguous without resolving per-slide layout inheritance.

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. A symbol-only term (an em dash, —, for example) has no word-character end, so it matches as a plain substring, which is handy for flagging AI-writing tells that aren’t words at all.

Seeing what’s actually allowed: when colors or fonts fails, the verdict also reports that check’s full allowlist once per asset: a [colors] allowed: … / [fonts] allowed: … line in CLI/hook output (capped at 20 entries with a +N more, see ruleset tail, so an unusually large palette can’t flood a hook’s output), and an uncapped allowed_values object in -json, keyed by check name. This only appears for checks that actually failed. A clean pass reports nothing extra, and terms/tone/outdated/images have no such list, since they aren’t allowlist-shaped checks.

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 runs either when the server is started with -anthropic-api-key (see Server), or, in local/embedded mode (stylist check, stylist hook claude-code, stylist hook codex, none of which involve a server), when tone_agent: claude-cli is set (.stylist.yaml, -tone-agent flag, or STYLIST_TONE_AGENT). Without either, or when nothing resolves to judge against, verdicts list tone under skipped_checks instead so coverage stays honest. It always resolves and reports -tone-profile/path-match selection regardless, so agents get the same nudges offline even with no judge configured. 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_agent: claude-cli shells out to a locally-installed, already-authenticated claude CLI instead of calling the hosted Anthropic API - free, no API key, no server - for anyone who already runs stylist via the Claude Code hook. Requires claude on PATH and logged in; if it’s missing or fails, the judge fails open like any other unreachable provider (a skip note, never a block). Not supported from stylist serve, which only uses -anthropic-api-key.

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.

logo.required is the inverse of outdated.logos: a denylist there (“flag if an image matches a retired logo”) becomes a presence check here (“flag if nothing matches an approved logo”). Like terms.required it blocks by default, unlike outdated/images, which advise by default.

Each entry names one logo identity. Any one of its hashes matching counts as present, so list known variants (full lockup, icon-only, dark mode) as multiple hashes on the same entry. That’s the same convention outdated.logos.hashes uses, generated the same way (stylist hash <image>). Unlike outdated.logos, there’s no match: filename fallback: a filename substring proves nothing was actually drawn, so presence has to be shown by real image content.

This is a whole-asset check, not a per-image one. A PowerPoint deck or Word document with ten embedded images and one required logo produces at most one violation for the whole asset when the logo is absent from all ten, never one violation per image that isn’t it.

Current coverage. PowerPoint (every file under ppt/media/) and Word (every file under word/media/) already have embedded image bytes in hand, so no extraction work was needed to check them. For HTML/Markdown, every <img src="..."> the asset references is resolved against the local filesystem (relative to the asset’s own directory, just like a browser resolves a relative src, so .. reaching a shared assets folder works normally) and hashed; data: URIs are decoded directly, no file needed. Remote (http(s)://, protocol-relative //) and absolute-filesystem sources are never fetched or opened. This only runs for local checks (the CLI, git hook, agent hooks): the hosted API server never resolves local images from submitted content, because a client-supplied asset label there has no guaranteed relationship to a real file, and resolving it anyway would let submitted content make the server read its own disk. A check submitted to the server reports the rule skipped instead, the same as before this feature existed.

Standalone raster/SVG image assets checked on their own aren’t evaluated: when the asset is one image, there’s no “somewhere in this asset” left to search, a different question than this check answers. It reports a skipped_checks note explaining why, rather than passing silently as if verified.

placement narrows where the logo must be found instead of anywhere the asset kind can see. Leaving it unset searches every image the asset kind can see, as before this field existed. A placement value an asset kind can’t resolve is reported skipped, never silently passed or falsely flagged.

  • header. For Word, a header is a genuinely separate document part (word/header1.xml/header2.xml/header3.xml). OOXML never embeds an image path directly: every picture references a relationship ID (<a:blip r:embed="rId3"/>), resolved through that part’s own .rels file (word/_rels/header1.xml.rels) to the real word/media/*.png path. That’s a structural fact, so a logo sitting anywhere else in the document (body, a shape, a footer) doesn’t satisfy the rule. For HTML/Markdown it’s a DOM-ancestry check instead: is the <img> a descendant of a <header> element, parsed with a real HTML5 parser so comments and <script> string literals are correctly excluded rather than text-scanned. That’s a weaker guarantee than Word’s, since HTML authors aren’t forced to use <header> correctly the way OOXML structurally separates a header part. It checks whether the declared convention holds, not whether the logo visually renders near the top of the page. PowerPoint has no header-part or landmark-element equivalent, so placement: header there is always reported skipped.
  • top-left/top-right/bottom-left/bottom-right. PowerPoint only, classified from a picture shape’s real XML geometry (<p:pic>’s <a:xfrm>/<a:off>/<a:ext>) against the deck’s own canvas size (<p:sldSz> in ppt/presentation.xml): which quadrant the shape’s center point falls into, from actual coordinates rather than a heuristic. This resolves through that slide’s own .rels file, the same relationship-ID indirection Word’s header images use. A deck with no resolvable <p:sldSz> reports every quadrant-scoped rule skipped rather than a false negative. Word and HTML have no notion of these values, so a quadrant placement checked against either is also reported skipped.

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

  • Enforcement rules block unless you opt out: colors 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 in: outdated 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, so 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.
  • Slide background: a slide’s own <p:bg> fill is read separately and checked against colors.background.allowed when set (see Background carve-out). This doesn’t cover the theme’s dk/lt color-scheme slots, which don’t unambiguously resolve to “the background” without also resolving the slide’s layout/master inheritance.
  • Embedded images: every file under ppt/media/ is checked like any other image: outdated-logo matching and minimum resolution both apply.
  • Required logo: logo.required is checked once across every file under ppt/media/ together (see Required logo), not per image, so a deck with the logo in any one of its media files passes. placement: top-left/top-right/bottom-left/bottom-right reads each slide’s picture shapes for their <a:xfrm> geometry, resolves them through that slide’s own relationship file to the actual media path, and classifies each by which quadrant of the deck’s <p:sldSz> canvas the shape’s center point falls into.

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.
  • Document background: <w:background w:color="...">, when present, is read separately and checked against colors.background.allowed when set. This is the page/web-layout background Word exposes. Paragraph, table, and run shading (<w:shd>) is not treated as background; it marks a highlighted patch of content, not the document’s own background, so it stays on the general colors.allowed list.
  • Embedded images: every file under word/media/ is checked like any other image: outdated-logo matching and minimum resolution both apply.
  • Required logo: logo.required is checked once across every file under word/media/ together (see Required logo), not per image. placement: header reads header parts (word/header1.xml/header2.xml/header3.xml) separately, and resolves their <a:blip r:embed="..."> picture references through that header’s own relationship file to the actual media path, so only the files a header part actually references count, not every file under word/media/.

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.