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 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.
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.
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.
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.
Required logo
Section titled “Required logo”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.relsfile (word/_rels/header1.xml.rels) to the realword/media/*.pngpath. 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, soplacement: headerthere 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>inppt/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.relsfile, 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.
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, so 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. - Slide background: a slide’s own
<p:bg>fill is read separately and checked againstcolors.background.allowedwhen 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.requiredis checked once across every file underppt/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-rightreads 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.
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. - Document background:
<w:background w:color="...">, when present, is read separately and checked againstcolors.background.allowedwhen 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 generalcolors.allowedlist. - Embedded images: every file under
word/media/is checked like any other image: outdated-logo matching and minimum resolution both apply. - Required logo:
logo.requiredis checked once across every file underword/media/together (see Required logo), not per image.placement: headerreads 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 underword/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.
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.