Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Changed¶
- Every field on the
models.pycontracts now carries an explicitpydantic.Fieldwith a description, validation bounds, and (where useful) examples. Numeric fields gained range constraints that the contracts always satisfied but never enforced —Colorcoordinates (lightness/alphain[0, 1],huein[0, 360),chroma >= 0), area/probability/weight fields in[0, 1],Viewportdimensions>= 1, and so on. Well-formed data is unaffected; out-of-range values that previously slipped through now raiseValidationError. The field descriptions also surface in JSON Schema (model_json_schema()). - Renamed the internal
Rectvalue type toBoundingBoxand its field onHarvestedElementfromrecttobounding_box, for self-documenting clarity ahead of the major version bump.RenderSession.consent_rects/media_rectslikewise becomeconsent_boxes/media_boxes. All three names are internal (excluded from__all__), so the publicAnalysisResulttree and JSON output are unchanged. - BREAKING (enums): all
StrEnummembers renamed to PEP 8UPPER_CASE. Members ofPropertyFamily,UsageRole,TokenSemanticRole,ComponentType,Theme,TokenOrigin, andMatchTypenow use screaming-snake-case identifiers (e.g.UsageRole.cta→UsageRole.CTA,Theme.light→Theme.LIGHT). The string values are unchanged (UsageRole.CTA == "cta"), so serialized output, JSON, the YAML config, and any code that compares against the string values are unaffected — only attribute access on the enum classes needs updating. Part of the PEP 8/PEP 257 cleanup ahead of the major version bump.
Fixed¶
-
Dark CTA buttons are no longer buried in the near-black page/footer color. A small dark CTA/secondary-button background could sit OKLab-near a large near-black screenshot bin yet be perceptually distinct from it, so its vote was absorbed into the bin and the button color never surfaced in the
cta/actionroles. On disconetwork the dark CTA anchors paint#030711, which is OKLab 0.029 from the#050505footer bin (inside the join radius) but CIEDE2000 4.33 away (plainly different):#030711was missing fromctaand#050505showed up there as noise instead. A near-black guard now keeps two near-black background colors (lightness ≤ 0.15) apart when they are CIEDE2000-distinct (> 3.0), but only when a CTA/action component is part of the vote — page/surface/banner clustering keeps the pure OKLab radius, whose coarseness is the load-bearing denoiser for the dense near-black surface-variant region. The scoping is per-component: when the classifier leaves one near-black element carrying bothpage_bgandcta_bgmass (a dark clickable panel), the element's vote is split so only the CTA/action share is diverted and the page/surface share still merges into its surface bin — the guard never pulls a page color off its bin. (The mirror-image near-white version was prototyped and rejected: the near-white surface cloud fragments into noise; near-white remains a text/border-only concern.) Measured on the offline quality panel: disconetwork'sctarecovers#030711and drops#050505as noise; no other site changes. -
Near-transparent hairline borders no longer out-vote the divider that structures the page. The
borderusage role could be won by a near-invisible color: on vercel.com 48 icon-container outlines painted atalpha 0.08accumulated more border vote mass than the single opaque#ebebebdivider, so theborderrole reported#000000. The background channel already scaled each vote by the fill's opacity (a faint tint should vote faintly); the border channel did not. It now does — a border votes in proportion to how much it actually paints — so the 48alpha 0.08hairlines collapse to ~8% of their former weight and the opaque divider wins. The text channel is deliberately left unscaled (a low-opacity glyph still reads as that text color). Measured on the offline quality panel: vercel'sborderwinner corrects from#000000to#ebebeb, and linear's corrects the same way — its old winner was 71 white hairline borders at alpha ≈ 0.06, now demoted below the opaque dark border (#24282c). Borders that are genuinely semi-transparent rank on the same basis and keep winning where they should: github's#484f58divider (alpha 0.70) and notion's#000000borders (alpha 0.10) are unaffected. The one cost is a single recall point: stripe drops#4834db, a lone alpha-0.70 clickable button-outline whose 30%-reduced vote falls under the prune threshold — the mechanism down-weights every translucent border, not only near-invisible swarms. Modern utility-CSS sites (e.g. shadcn, Tailwind's own site) leave the document root withbackground: transparentand paint the page color on a single full-viewport<div>instead. That div's onlypage_bgsignal was a couple of weak class-token votes, which the geometryhero_bgvote and the repetitioncard_bgvote buried in the per-channel softmax — so the real page color never reached thepagerole (it scored empty, and a quantizer phantom could win instead). When (and only when) the canonical canvas paints no opaque background, the classifier now treats the largest viewport-spanning opaque-background element near the top of the page whose color matches the independently-derived page color as the canvas: it injects apage_bgprior and clears the competing hero/card votes on that one element. The color match is the safety gate — it forces the fallback to pick the element actually painting the page color, never a brand-colored hero or banner that merely happens to be the largest. Opaque-<body>sites are untouched. Measured on the offline quality panel: shadcn and Tailwind recover a correctpagewinner. - Small circular chips no longer leak their accent into
surface, and recurring clickable ones are recognized as badges. The pill/badge shape test deliberately excludes perfect circles (it requires width > height), so a smallrounded-fullelement fell through both the badge rule and the repetition card-detector's pill exclusion, and got swept intocard_bg— a decorative status dot leaking into thesurfacepalette (e.g. Vercel's#ff990aamber dot), and a grid of black corner badge-chips landing insurfaceinstead ofaction(e.g. Supabase, Disco). A newis_circle_shapepredicate (the square counterpart tois_pill_shape, with a 1px tolerance) now drives two rules: a small circle is never a repetition "card" (so a decorative dot leaks nowhere), and a small circle that is clickable, paints a fill, and recurs as a structurally-similar group is promoted tobadge(→action). The recurrence gate is the discriminator: a lone clickable status dot keeps winninglinkfrom its text channel rather than being mis-promoted. Measured on the offline quality panel: Supabase's black badges winaction, Vercel's amber dot leaves the palette, and Disco's corner chips route correctly. - Genuine low-mass colors no longer get diluted out of a crowded role. The usage view
prunes entries below a relative share threshold (
MIN_PROBABILITY_SHARE, 2%), so when a role accumulates many colors — for example after the near-white text fix below splits one near-white cluster into several — every entry's share shrinks and a real, low-mass color could fall below the threshold purely from dilution rather than from being any less real (e.g. Resend's#46fea5neon-green accent text). Element-color entries (text/link/CTA/ action/border) whose raw in-role vote mass clears a new absolute floor (MIN_EXEMPT_VOTE_MASS, ≈ one element's worth of confident vote) are now exempt from the share prune — an absolute evidence floor is immune to entry-count dilution. The area-ranked structural-surface roles (page/surface/banner) rank by screenshot area and are unaffected. Measured on the offline quality panel: one genuine accent recovered, no role winners changed. - Near-white text/links no longer vanish into a neighboring near-white. Inventory clustering
measures color distance in OKLab, which is badly non-uniform near white — its 0.05 radius spans
~6.5–8.5 ΔE2000 up there — so a page's dominant white text could be absorbed into a near-white
neighbor and never surface in the
text/linkroles (e.g. GitHub's#ffffffbody text collapsing into Primer's#f0f6fc, which is a clearly-distinct 4.0 ΔE2000 away). The text and border pools now apply a CIEDE2000 guard in the near-white regime (lightness ≥ 0.90): two near-white colors merge only if also within 3.0 ΔE2000 — a denoising radius that still collapses anti-alias variants but keeps genuinely-distinct near-whites apart. The background pool is unchanged (OKLab's coarseness there usefully denoises quantized screenshot bins). Measured on the offline quality panel: white text/link recall recovers with no change to noise or role winners.
Changed¶
- BREAKING (no behavior change): the two free functions that mapped a usage role / component
type to "which CSS property paints the color" are replaced by properties on the enums:
family_of(role)becomesUsageRole.property_familyand the internalchannel_for(component)becomesComponentType.property_family. The internal stringly-typed"bg"/"text"/"border""channel" taxonomy used as dict keys inclassify/components.pyandpalette/inventory.pyis unified onto the existing publicPropertyFamilyenum (background/text/border), so there is now a single type for this axis. The public API shrinks by one name (colorsense.family_ofis removed; the.property_familyproperties need no separate export). This is a pure rename/retype — the pipeline output (eval scorecard and golden snapshots) is unchanged. - Internal refactor (no behavior change): three more hand-duplicated idioms now share named
helpers. The confusable "paints anything" (
alpha > 0.0) vs "fully opaque" (alpha >= 1.0) background checks are nowcolorsense.color.primitives.is_painting/is_opaque; the order-preserving dedupe-by-key loop (colors, gradient stops, token names) is nowcolorsense._util.dedupe_by(with an optional unique-itemlimit); and the stadium/pill geometry test duplicated across the harvest↔classify boundary is now the single sharedcolorsense.models.is_pill_shape. All internal (not part of the publiccolorsenseAPI); pipeline output (eval scorecard and golden snapshots) is unchanged. - Internal refactor (no behavior change): the hand-written nearest-color / any-within-ΔE
match loops in
palette/inventory.pyandpalette/reconcile.pynow share helpers in a new internalcolorsense.color.matchmodule (any_within/nearest_within/first_within), centralizing the validated OKLabdelta_ematching convention and its<=tie-break. Pipeline output (eval scorecard and golden snapshots) is unchanged. - Internal rename for clarity (no behavior change): the declared-token "usage prior"
concept is now uniformly called usage intent — a distribution over usage roles
expressing where a token's color is expected to be used, inferred from its name before the
page is measured. The YAML key
token_vocabulary.role_to_usage_prioris nowsemantic_role_to_usage_intent_or_channel; the config modelsDistributionPrior/ChannelPrior/PriorRoware nowUsageIntent/ChannelRoute/UsageIntentOrChannel(the last describing what each semantic role maps to — a usage intent or a channel route); andClassifiedToken.role_distributionis nowClassifiedToken.usage_intent. These are internal (not part of the publiccolorsenseAPI); analysis output is unchanged. - Non-anchor CTA-button labels no longer leak into the
linkusage role. The harvester now records each element's composited effective background (the first fully-opaque background up its ancestor chain) and whether that background is painted by a clickable ancestor (HarvestedElement.effective_bg/effective_bg_from_clickable). Using this theme/contrast-relative signal, the classifier relabels a non-anchor clickable'slinkvote tocta_textwhen its text sits on a distinct interactive fill it is legible on (WCAG contrast ≥ 4.5) but illegible on the page canvas — the signature of a button label rather than a genuine inline link. Brand-colored links on tinted clickable cards, links inside passive dark sections, and ordinary dark-on-light text are all protected. This completes the "A2" attribution work begun in 0.6.0 (which handled button-styled anchors). Golden snapshots were regenerated (ds_site,legacy_site: button labels left thelinkrole).
Removed¶
- Internal dead-code cleanup (no behavior or public-API change): removed four unused
color.primitiveshelpers (composite_over,is_neutral,to_hex,nudge_lightness) and the unusedConfig.strip_namespacemethod, none of which were re-exported from the publiccolorsenseAPI or called anywhere in the library. Removed thecomponent_classifier.component_typesconfig key (and its model field): it duplicated theComponentTypeenum inmodels.pyand was loaded but never read, so it tuned nothing.
Security¶
- Bumped the
examplesgroup's Starlette floor to>=1.3.1to close GHSA-82w8-qh3p-5jfq (high severity): a request-body DoS whererequest.form()size limits were silently ignored forapplication/x-www-form-urlencoded. This also clears the low-severity GHSA-jp82-jpqv-5vv3 (patched in 1.3.0). The FastAPI floor moves to>=0.133— the lowest release whose Starlette pin admits 1.3.1 — so the declared floors stay jointly satisfiable. Starlette is a transitive dependency used only byexamples/webservice/; the library's own runtime dependencies are unaffected.
0.6.0 - 2026-06-14¶
A breaking release that redesigns the measured "usage" view. The old 4-value
UsageCategory (surface/text/interactive/border) is replaced by the 8-value developer-facing
UsageRole (page, surface, banner, cta, action, text, link, border) with a
first-class PropertyFamily rollup, each ThemePalette gains a new color-keyed colors
index, and the derived 60/30/10 composition view (ThemePalette.roles/fit_score,
RoleResults, PaletteRole, PaletteCandidate, palette/roles.py) is removed entirely —
see the migration notes in the breaking entry below. Riding the redesign are three
attribution-accuracy fixes that make the per-role colors correct: button-styled anchors' label
text no longer leaks into the link role, the cta/action roles rank by DOM vote mass
instead of screenshot area (so brand CTAs surface instead of the page background, now
deterministic across OSes), and inventory clustering is segregated by PropertyFamily so a
background color can no longer be reported in a text or border slot. Golden snapshots were
regenerated.
Changed¶
- Button-styled anchors' labels no longer leak into the
linkrole. A clickable<a>that paints a button surface (a solid background, a border, or a gradient-pill fill) is a CTA whose label text is routed tocta_text— an emitted-but-unrouted button-label sink — rather thanlink; a bare anchor (no such surface; a box-shadow alone does not count, so a focus-ringed inline link stays a link) keepslink. Previously every clickable anchor's text-color vote was the sole occupant of its text channel, so a filled CTA's label color (e.g. a white button label) surfaced in thelinkusage role. Anchor routing moved from the semantic-tag family to a fill-gated pair of interactivity rules; the vote is relabeled within the text channel (not deleted), so the per-channel recombination is unchanged. -
CTA/action usage roles are ranked by vote mass, not screenshot area. The
ctaandactionroles name element colors (button fills), not structural surfaces, so they now rank candidate colors bylog1pof in-role vote mass — the same DOM-derived signal already used fortext/link/border— whilepage/surface/bannerkeep ranking by screenshot area. This fixes two problems with one change. (1) Correctness: the old area ranking let the page background (which out-areas every button) win thecta/actionrole outright, so the actual brand CTA was pruned and the role reported the page-background hex — e.g. github.com'sctareported its dark page background instead of the green "Sign up" button; the brand CTAs of stripe/supabase/notion/disco and others now surface. White/neutral "ghost" buttons still rank first where they genuinely dominate; the brand colors are no longer dropped. (2) Determinism: a brand CTA paints near-zero screenshot area, so which of two hero buttons won the area-ranked role depended on which one Pillow's median-cut quantizer happened to give its own bin — a coin flip that flipped between macOS and Linux (theds_sitecta golden was masked with a co-dominant pair to absorb it). Vote mass is computed from the DOM, not rendered pixels, so the winner — the higher-mass primary button — is now identical across operating systems. Theds_sitecta golden is back to a single hex, verified on macOS and in the Linux Playwright container. No public type changed; the cta/actionproperty_familystaysbackground. -
Family-aware color inventory. Inventory clustering is now segregated by
PropertyFamily: background, text, and border colors are attributed and clustered in separate pools, and each pool's representative is chosen by what is authoritative for that family (background by screenshot area, text/border by in-family vote mass). Previously a low-area text or border color could union with a high-area background bin of a near-identical hue and then be reported as the bin's hex — so a page-background color could surface in theborderslot, or a card surface in thelinkslot. Now theusage/colorsviews report a real color of the right family (e.g. a site's actual light--bordergray instead of#ffffff; near-black body text instead of the page background). The color-keyedcolorsindex groups same-hex clusters across families into one atom, exact-hex so family-distinct hexes (a near-white border vs the white page) stay separate. No public type changed; the measured text/link/border colors are more accurate. Validated on a live multi-site panel (family-bleed eliminated across the panel). -
BREAKING — usage-role payload redesign. The measured "usage" view was re-cut to separate the two axes the old 4-value
UsageCategory(surface/text/interactive/border) conflated — which CSS property paints the color versus what kind of element it is.UsageCategoryis deleted and replaced by the 8-value developer-facingUsageRole(page,surface,banner,cta,action,text,link,border), with a first-classPropertyFamily(background/text/border) rollup and a code-levelfamily_of(role)mapping. The worst symptom this fixes: link text and CTA button backgrounds no longer share one slot, so "what color are the CTA buttons?" is answerable directly (usage.mapping[UsageRole.CTA]). The role taxonomy and the colored-CTA-retention behavior were validated against 12 real sites.
Each ThemePalette now carries:
- colors — a new canonical, color-keyed index: a ColorUsage tuple (each with
a prominence ranking, area, and the Usage roles the color appears in — role,
property_family, weight, and per-ComponentType components). Answers "how is each
color used?". Third-party-dominated colors are excluded (they remain on
AnalysisResult.third_party_colors).
- usage — the role-keyed projection (today's UsagePalette/UsageEntry, re-keyed
from UsageCategory to UsageRole). Answers "which colors paint each role?".
- DivergenceItem.category: UsageCategory was renamed to DivergenceItem.role: UsageRole.
The 60/30/10 composition view was removed entirely. Previously a ThemePalette.roles
(RoleResults) mapping plus a fit_score, it is a consumer-side re-categorization rather
than the library's job, and keeping it let 60/30/10-shaped scoring leak into how the
primary views are tuned. ThemePalette.roles/fit_score, the RoleResults,
PaletteRole, and PaletteCandidate types, and palette/roles.py are all gone — the
per-theme result now focuses on the color-keyed colors index and the role-keyed usage
projection (plus divergence and opt-in tokens).
Public API: added UsageRole, PropertyFamily, family_of, Usage, ColorUsage;
removed UsageCategory, RoleResults, PaletteRole, and PaletteCandidate. The
role-keyed view keeps the existing prominence/prune math; the color-index prominence is a
documented first-cut
blend (PROMINENCE_AREA_WEIGHT, area-primary, vote-mass-secondary) worth later empirical
tuning. The bundled palette_config.yaml token usage priors were remapped from the 4
categories to the 8 roles (splitting each old category's mass across the roles it became).
Golden snapshots were regenerated.
0.5.0 - 2026-06-13¶
A palette-quality release. These changes affect the extracted colors (golden snapshots
were regenerated) but make no breaking change to the public API. Three rendering-accuracy
improvements: gradient call-to-action buttons — which paint a transparent computed
background-color — now contribute their gradient's brand color stops, and semi-transparent
solid backgrounds are weighted by their opacity; component evidence is attributed one color
channel at a time (text, background, border) instead of in a single shared pool, so a filled
button's strong background vote no longer starves its border or text color; and raster
photographic content is masked out of the area-weighted screenshot palette so a site's
photography no longer drowns its design colors (CSS gradients and inline <svg> are kept).
Changed¶
-
Gradient call-to-action colors are now captured. A button painted with a CSS gradient (
background-image: linear-gradient(...)) has a transparent computedbackground-color, so its brand colors used to be invisible to the palette. The harvester now records the gradient's opaque color stops for clickable, pill-shaped CTAs — decorative gradient panels and dividers are left out — and attributes them to the palette. Separately, a semi-transparent solid background now counts in proportion to its opacity, so a faint tint no longer votes its fully-saturated color at full strength. -
Element colors are attributed per channel. The component classifier now turns each element's evidence into a distribution one color channel at a time — text, background, and border — instead of in a single pool, so an element's own colors no longer compete. A filled, clickable button's strong background vote no longer crowds out the evidence for its border or text color, surfacing border and interactive colors that the previous scoring could starve.
-
Photographic content no longer drowns out design colors. Raster images —
<img>,<video>,<canvas>,<picture>, and elements with aurl(...)background image — are masked out of the area-weighted screenshot palette, the same way cookie-consent banners already were, so a product photo's colors don't outweigh a site's actual design palette. CSS gradients and inline<svg>are deliberately kept, since they are brand and design content.
0.4.1 - 2026-06-13¶
A palette-quality release (no API change). The 60/30/10 roles view now scores component
evidence by magnitude rather than mix purity — a lone tiny element can no longer
outrank the real page surface — excludes the primary anchor from the secondary role,
and area-gates the primary boost so a tiny chip bearing a near-zero layout-noise page_bg
vote cannot evict a high-area surface. The component classifier gains geometric
badge/chip detection, so status and category pills route their accent colors to the
interactive/accent palette instead of flooding card_bg.
Changed¶
- Roles view (60/30/10): component evidence is now scored from raw
component_mass(log1p-damped, normalized to the per-bucket maximum across clusters) instead of the normalizedcomponent_mix. Mix purity carried no cross-cluster magnitude, so a cluster evidenced by a single tiny element could outrank clusters with 100x the vote mass (e.g. a lone badge chip winningsecondaryover the actual page surface). Thesecondaryrole additionally excludes the primary-anchor cluster, so the dominant page surface no longer wins both roles and the genuine ~30% structural color (hero/header band) surfaces; a single-color page now reports an emptysecondary. The same lone-cluster trap on thepage_bg/primary axis is closed by scaling the primary boost by painted area, so a tiny chip bearing only a near-zero layout-noisepage_bgvote can no longer evict a high-area surface as the primary. Role rankings andfit_scorevalues change; no API change. - Component classifier: harvested elements now carry
min_corner_radius(the smallest of the four computed corner radii), and the classifier uses it to label pill/chip shapes asbadge— a fully-rounded element (all four corners, via the min-corner test), wider than tall, that paints a fill and carries direct text on a single text line (height ≤36px). Pills are excluded from the repetition card detector, so repeated status pills / category chips no longer floodcard_bgwith their accent colors — their colors land in the interactive/accent palette where they belong. The bundled config gains abadge_max_h_pxgeometry threshold.
0.4.0 - 2026-06-12¶
Re-keys the public result contract around usage (what colors paint surfaces / text /
interactive elements / borders) with the 60/30/10 roles taxonomy as a derived view —
a breaking release — plus a documentation site, async request_filter predicates
with off-loop guard DNS resolution, and two pre-release hardening passes (harvest payload
caps, bounded post-navigation operations, render-lifecycle leak fixes, and load-time
config validation).
Breaking — usage-keyed result contract¶
The public result is re-keyed around usage (what colors paint surfaces / text / interactive elements / borders); the 60/30/10 roles taxonomy becomes a derived, per-theme view. On neutral-layered designs (e.g. a GitHub repo page) the old shape lost the design's actual structure — the gray text/border hierarchy appeared nowhere and the "secondary" slot collected noise. The library has no consumers yet; breaking now is deliberate.
Migration table (old → new):
| Old | New |
|---|---|
ThemePalette.roles (reconciled) |
ThemePalette.usage — the primary, reconciled view: UsagePalette mapping each UsageCategory (surface/text/interactive/border) to ranked UsageEntrys (color, probability, area, components). ThemePalette.roles remains, but as a measured-only derived 60/30/10 view, no longer reconciled against tokens. |
AnalysisResult.fit_score |
ThemePalette.fit_score (per theme; descriptive "how 60/30/10-like", not a quality score) |
AnalysisResult.divergence |
ThemePalette.divergence (per theme) |
DivergenceItem.role: PaletteRole |
DivergenceItem.category: UsageCategory |
AnalysisResult.tokens: tuple[ClassifiedToken, ...] |
ThemePalette.tokens: tuple[DesignToken, ...] \| None — opt-in via analyze(..., include_tokens=True); None = not requested, () = requested but no usable color tokens found (none declared, or every declaration filtered as non-color / ignore-classified / zero-weight). DesignToken carries name, resolved color, semantic_role. |
AnalysisResult.status_colors |
Removed. Status tokens stay excluded from the palette views and surface in the opt-in token list with semantic_role=status. |
PaletteCandidate.evidence |
Removed (internal scoring-term names are not contract). color/probability/area remain. |
RunMetadata.single_theme |
Removed; use len(metadata.themes_analyzed) == 1. |
ClassifiedToken, TokenRecord (public exports) |
Internal-only; removed from the public API. The public token projection is DesignToken. |
| — | New public exports: UsageCategory, UsageEntry, UsagePalette, DesignToken, ComponentType (keys UsageEntry.components). |
Other changes riding the redesign:
- Divergence noise fix: declared-but-unused items are now gated to high-intent tokens (classified by an explicit name rule or relational pattern). Unused shades of numbered color scales, alias followers, and fallbacks no longer fire — on token-heavy sites the old report was 100% noise (54/54 items on github.com).
analyze()gains keyword-onlyinclude_tokens: bool = False; the CLI gains a matching--tokensflag. The flag gates only output assembly — classification and reconciliation always run, so all other fields are identical either way.- The CLI's human-readable output (unstable by design) now leads with the usage view per
theme, then the roles summary + fit score, divergence, and (with
--tokens) the token list. - Config YAML — custom
config_path=files from 0.3.0 must be updated (the loader fails loudly on each of these, in this order):role_to_palette_prioris renamedrole_to_usage_priorand its distributions are now over the four usage categories. The token classifier's neutral light/dark special-case is gone (the usage taxonomy has no light/dark neutral split).- The
border_presenceandtext_presencefeature families (see the measurement-layer fixes below) are new required keys undercomponent_classifier. scale_detection.base_weightis a new required key: the numbered-scale classifier's base weight (previously hard-coded at3.0inclassify/tokens.py, contradicting the "all weights come from the YAML" design) now lives in the YAML alongside the other classifier weights.- The dead
has_focus_ring/consent_masked_regionknobs are now rejected (see the dead-knobs entry below).
The simplest migration is to re-copy the bundled data/palette_config.yaml and re-apply
your tuning.
- Inventory channel routing: link component mass now routes to the element's text
color (a link paints its typography, not its usually-transparent background), and
fully-transparent (alpha == 0) channel colors no longer donate vote mass (previously
they piled votes onto a phantom #000000 zero-area cluster).
- Component classifier calibration: the input semantic rule's border vote is raised
2.0 → 2.5 so input borders survive softmax pruning (at 2.0 the usage view's border
category was structurally empty on input-bearing pages). (Superseded in the same
release by the border_presence family below, which generalizes the vote to every
element that actually paints a border.)
Fixed — pre-release review follow-up¶
-
Motion-neutralizing CSS injection no longer aborts a render. The transition/animation-disabling
<style>injected after navigation could fail the wholeanalyze()withRenderError: Page.add_style_tag: Connecting to '…' violates the following Content Security Policy directive: "connect-src …"— intermittently, on sites whose own third-party trackers trip their CSP (seen live on stripe.com). Playwright'sadd_style_tagraces its evaluation against any console error mentioning "Content Security Policy" (Frame._raceWithCSPError), so an unrelated page-side violation landing in that window spuriously rejects the call. The injection is stabilization, not harvesting: it is now retried once and, if it still fails (e.g. a CSP that genuinely forbids inline styles), skipped with aRuntimeWarningso the render continues — computed colors may then be read mid-transition, which is degraded but never fatal. -
Dead data path removed:
ClassifiedToken.text_on_base. The relational classifier resolved each--on-<base>/--<base>-foregroundtoken's base surface to a semantic role and threaded it through the classification tuple and alias inheritance — but nothing consumed it after the 0.4.0 contract change madeClassifiedTokeninternal-only (the publicDesignTokenprojection never carried it, and reconciliation's relational divergence pass uses only origin, resolved color, weight, and name). The field, its threading, and the base-role lookup are gone; the relational classification itself (text_onrole, weight,relationalorigin) is unchanged, as is the YAMLrelational_modifiersschema — patterns still capturebase, since the name match depends on it. The bundled YAML's stale comment claiming the pairing "is surfaced on the classified token for consumers" is corrected. No behavior change (golden snapshots untouched). -
Dead classifier knobs removed; the config loader now rejects unknown dispatch names. The bundled YAML shipped two knobs the classifier could never act on: the
has_focus_ringinteractivity rule (no focus-ring signal exists on harvested elements) and theconsent_masked_regionsuppressor (consent rects are consumed by screenshot masking and never reach the classification layer) — both hard-returned False inclassify/components.py, contradicting the "nothing hard-coded, the YAML is the single source of truth" contract and inviting consumers to tune values that did nothing. Both entries and their dead code branches are gone. To keep removed (or misspelled) names from becoming silent no-ops in customconfig_path=YAMLs,Confignow validates dispatch names against the closed sets the classifier implements: unknown interactivity/geometrywhen:predicates, suppressor keys, and suppressorapplies_toscopes fail loudly at load time. Bundled-config behavior is unchanged (golden snapshots untouched). -
The egress gate now covers WebSockets and service workers. The
request_filterroute handler is installed via Playwright'scontext.route, which never sees WebSocket opening handshakes and (by default) service-worker-originated requests — so a hostile rendered page could issuenew WebSocket('ws://169.254.169.254/'), a real blind GET to an internal host the filter never vetted, despite the docs claiming coverage of "every URL the browser requests". Both unrouted paths are now closed outright rather than filtered: browser contexts are always created withservice_workers="block"(service workers are irrelevant to color harvesting), and when arequest_filteris configured acontext.route_web_sockethandler refuses every WebSocket connection — it never connects upstream, so no handshake leaves the browser and the page just observes a dead socket, harmless for palette extraction. The declared playwright floor rises>=1.40→>=1.48(whereroute_web_socketlanded; the lockfile already resolved 1.60.0).SECURITY.md§1 and theblock_private_networksdocs now state the coverage precisely. -
Relational and status tokens are visible to divergence again. Empty-prior tokens (relational
--on-primary-style foregrounds; status tokens excluded from the palette) were invisible to reconciliation: a page rendering exactly its declared--on-primarycolor was falsely reported "used but undeclared" (the canonical shadcn/Material*-foregroundpattern), and the documented relational arm of the declared-but-unused gate was unreachable. Used-but-undeclared membership now tests against every resolved declared color, and unused relational tokens report declared-but-unused undertextvia a dedicated pass. -
Reconciliation pooling no longer vetoes undeclared colors. The log-linear pool's intent factor is now uniform-smoothed (
+ 1/Kover the category's K measured entries) instead of floored atEPS = 1e-9: lacking a token match costs a bounded, universe-scaled penalty ((K + 1)^alpha, ~1.6x at K=2 for the defaultalpha=0.4) rather than a ~4000x multiplier. Previously, on a partially-tokenized page, one minor declared color could erase a 95%-dominant undeclared color fromusageentirely (a 95%-white page whose surface palette contained no white). - The "
componentsis never empty" guarantee is now structural. The pooling universe is restricted to the measured usage entries; declared-only colors never enter the posterior in any category (previously they were injected and only pruned by a numeric coincidence ofEPS/alpha/MIN_POSTERIOR_PROB, and the reconcile docstring contradicted the documented guarantee). Declared-but-unused intent surfaces throughdivergence, as before. -
Guard DNS lookups no longer share the loop's default executor. Each
block_private_networks()predicate now owns a small dedicated thread pool (GUARD_RESOLVER_MAX_WORKERS = 8, created lazily, living as long as the predicate) instead of dispatching resolutions viaasyncio.to_thread. Single-flight coalescing is per-host, so a hostile page fanning requests at many distinct slow hostnames could previously pin one default-executor thread per hostname — the same pool the pipeline's per-theme CPU phase and any embedding application use, a cross-request DoS vector in multi-tenant deployments. Excess distinct-host lookups now queue inside the guard's own bounded pool. Each lookup is additionally capped by a new fail-closedresolve_timeoutparameter (defaultDEFAULT_GUARD_RESOLVE_TIMEOUT_SECONDS = 10.0); on expiry the URL is rejected and the negative verdict cached like any other. Also fixes a marginal cache detail: the TTL expiry is now stamped after resolution completes, so a lookup slower than the TTL no longer produces a born-expired entry. -
One shared prune/renormalize/argmax step; reconciliation's argmax tie-break is now deterministic by hex. The normalize → prune-below-threshold → renormalize-survivors → keep-argmax-if-pruning-emptied pipeline was implemented independently in
palette/usage.py,palette/roles.py, andpalette/reconcile.py, with three different argmax tie-breaks: usage preferred the smallest hex (the documented convention), roles preferred the largest, and reconciliation used a baremax()(input position wins). All three now callprune_distributionin the newpalette/_pruning.py, which breaks exact-probability ties by smallest hex everywhere. Behavior is unchanged outside those degenerate exact-tie fallbacks (golden snapshots untouched).classify/components.py's similar-looking softmax-prune block stays local by design — it ranksComponentTypekeys, not colors, so the hex convention has no analogue there (andclassify/does not depend onpalette/).
Fixed — measurement-layer gaps (live-probe follow-up)¶
A live acceptance probe of the usage-keyed redesign against github.com exposed
measurement gaps the fixtures had masked; all are now encoded as offline fixture tests
(tests/fixtures/repo_probe_site.html):
- Empty-category gate in reconciliation: a usage category with zero measured
candidates now yields an empty posterior instead of a near-uniform flood of token-only
colors (github.com's
usage.borderwas 16 never-rendered theme tokens, every entry with emptycomponents). Honest emptiness beats intent-only noise; declared intent for an unmeasured category can still surface throughdivergence(when its color has no perceptual match among measured usage). border_presencefeature family (config YAML): any element whose harvested border is genuinely painted (width-gated) now votesborder. Previously only the<input>semantic rule votedborder, so pages without classified inputs measured zero border mass. Theinputrule's own border vote moved into this family, andborderjoined the third-party-dampedbrand_componentsso vendor widgets don't feed the border palette.text_presencefeature family (config YAML) +HarvestedElement.has_text: non-clickable elements with direct (non-descendant) text content now votepage_text, so plain<p>/<span>typography is measured (github.com's muted#59636ewas absent fromusage.text). Clickable elements are excluded — their typography is interactive and already routed via the link rules. Relatedly, the repetition detector'sdistinct_bg_from_parentproxy no longer counts fully-transparent (alpha == 0) backgrounds, which had turned repeated text spans into false-positive "cards" whose votes crushed the new text votes.- Per-channel inventory join radii: element text/border colors now match existing
entries at the tight cluster radius (0.05 deltaEOK) instead of the loose background
radius (0.10), so a near-black body text (
#1f2328) forms its own usage entry instead of being absorbed into an adjacent dark surface bin. - Log-damped vote-mass prominence in the usage view: text/interactive/border entries
are ranked by
log1p(vote mass)rather than raw mass. Ordering is unchanged (monotonic), but element count no longer drowns high-confidence single-element evidence — github.com's lone green CTA (#1f883d) survives against ~200 link votes instead of pruning below the share floor. - Measured-vs-declared match radius in reconciliation: a measured usage entry now matches a declared token color within the inventory's background join radius (0.10 deltaEOK) instead of the tight 0.08 used for grouping declared colors with each other. A measured entry's representative is a screenshot-quantizer bin whenever the cluster matched one, and an element joins a bin up to 0.10 away — at 0.08 a pixel-perfect rendered token could fail its own intent match purely from quantizer blending (platform-dependent anti-aliasing), flipping posterior winners across OSes and emitting false "declared unused in render" / "used but undeclared" divergence pairs.
input[submit]no longer matches every<input>: the harvester now captures the input's lowercasedtypeattribute (new internalHarvestedElement.input_typefield;Nonefor non-inputs and untyped inputs), and both theinput[submit]semantic rule and theinput[submit|button]interactivity predicate match only button-like input types (submit/button/image/reset). Search/text inputs — and text inputs styled withcursor: pointer— no longer receive spuriouscta_bgvotes that leaked their backgrounds into theinteractiveusage category. Aligning the harvest with that set,<input type="image">(a graphical submit button) is now also harvested asclickable.
Fixed — second pre-release review (hardening pass)¶
- The DOM-element and design-token harvest payloads are now bounded (10,000 element
records / 5,000 token declarations per render). Both channels serialize one record per
visible element / declared custom property into the host Python process, where each
materializes as a pydantic model — container limits bound the renderer, not the
consumer — so a hostile page synthesizing millions of divs or
--propscould force a multi-GB allocation in the embedding service (the same vector the screenshot capture/decode caps closed for images). Over budget, the element harvest keeps the largest-area records (area dominates every downstream signal) in document order; the token harvest stops at the cap in stylesheet order. Both caps sit far above any genuine page. - Oversized-page screenshot fallback now succeeds at retina scale factors. The capture
clip was clamped in CSS pixels but the decompression-bomb decode cap counts device
pixels, so the very pages the clip fallback was built to survive still failed with
RenderErroratdevice_scale_factor=2(e.g. a >20,000px-tall page at the default 1280-wide viewport → 102.4M device px against the 90M cap), or whenever both dimension caps bound at once. The clip is now additionally shrunk (height first) to fit the decode budget at the session's scale factor; the decode check remains as a pure backstop. - Post-navigation harvest operations are bounded even without a deadline. Playwright
applies no timeout to
page.evaluateor CDP sends, so under the defaultmax_total_seconds=Nonea page whose JS wedged the renderer main thread after the load event hunganalyze()forever. Essential evaluates (DOM walk, token enumeration, document-size probe) are now bounded at 30s and surface asRenderError; best-effort stabilization steps (step-scroll, consent detection, motion-disabling CSS) are bounded at 10s and degrade as before; the hover-probe pass has a single 30s wall-clock bound and keeps whatever it probed (CDP detach is bounded too). - Hover probes can no longer read a different element than the one harvested.
Generated selectors used bare
#ideven for duplicate ids (invalid but common in the wild) and capped un-anchored descendant chains at 8 parts, so CDPDOM.querySelector(first document-order match) could land on a sibling/clone and misattributehas_hover_color_change/hover_bg. Selectors are now unique by construction —#idonly when the id is unique on the page, otherwise a child-combinator chain anchored at a unique-id ancestor or the document root with:nth-childdisambiguation — and elements with pathologically deep nesting (>32 levels) are skipped rather than probed ambiguously. RenderSession.__aenter__no longer leaks the Playwright driver on setup failure. A launch/context/page failure partway through enter (Chromium not installed, sandbox refusal, badbrowser_args) propagated without__aexit__ever running, leaking the already-started driver subprocess (and possibly the launched browser) on every failed attempt — one leaked node process per retry in a long-running server. Unreachable viaanalyze()(which always passes aSharedBrowser), reachable via directPolitenessPolicy.fetch/harvest_pageuse. Enter now tears down what it started before re-raising.SharedBrowserteardown now serializes with in-flight launches.__aexit__did not take the launch lock, so under unstructured concurrency aget()suspended inchromium.launchcould assign and return a fresh browser after teardown closed everything — a Chromium nobody would ever close. Teardown now waits for the in-flight launch and closes its result. (analyze()was never affected: it joins all renders before teardown.)robots.txtfetches are single-flighted. Concurrent cache-missing callers for the same robots URL (e.g. the light and dark fetch leaders of one two-themeanalyze, which have distinct render-cache keys) each issued their own robots GET when the first fetch outlasted the per-host throttle interval. They now coalesce onto one GET via the same future-per-key pattern (and cancellation semantics) as the render single-flight.- The config loader now validates numeric ranges, not just dispatch names. Previously
a hand-authored custom YAML could load successfully and then fail far from the cause —
or worse, silently corrupt results: a negative
role_to_usage_priorweight survived normalization and produced complex numbers inside reconciliation's** alphapooling;softmax_temperature: 0crashed withZeroDivisionErrorat classify time while a negative temperature silently inverted the component ranking; a grouplessscale_detection.number_patternraisedIndexErroronly when a numbered token first appeared; arelational_modifierspattern without the documentedbasegroup became a rule that silently never fired. All of these are now load-timeValidationErrors (weights/boosts/factors>= 0, temperature> 0,min_component_probin[0, 1], patterns must compile with their required groups). The component softmax is also max-shifted (matchingpalette/roles.py), so stacked vote weights can no longer overflowmath.exp— probabilities are mathematically unchanged. - Divergence "declared … unused in render" is now tested against the full measured
inventory. Membership was tested against the post-prune usage entries, so a declared
color that genuinely rendered — just below every category's 2% prune threshold — was
misreported as unused. The pipeline now passes the pre-prune cluster colors to
reconcile, so "unused in render" means exactly that. - The roles view's primary-anchor tie-break now follows the documented smallest-hex
convention. On an exact (score, area) tie the
max()over the key tuple picked the lexicographically largest hex, contradicting the module's own design notes and the sharedprune_distributionconvention used everywhere else inpalette/(the chosen anchor feeds accent contrast scoring). Output is unchanged except on exact float ties. - Token harvesting covers
document.adoptedStyleSheets. Constructed stylesheets adopted at the document level — the standard token-shipping mechanism for web-component-heavy design systems (Lit, etc.) — are not part ofdocument.styleSheetsand were silently skipped, so their declared tokens vanished from the token list and reconciliation saw usage with no declared intent. Sheets adopted inside shadow roots are still not visited; the harvest docstrings now state the scope precisely. - The hover-probe pass no longer fetches the entire DOM tree over CDP.
DOM.getDocumentwas called withdepth: -1(serializing the complete node tree into the host process on every render) when only the rootnodeIdis consumed; it now requests the default depth. docs.ymlpins its actions to commit SHAs, matching the discipline already applied to the publish/CI workflows (the deploy job runs withpages: write+id-token: write).
Added¶
- Documentation site at https://cassidyhhaas.github.io/colorsense/ — MkDocs Material
- mkdocstrings, built from the existing guides plus a generated API reference, deployed
to GitHub Pages from
main(.github/workflows/docs.yml; PRs get a strict build check). Build locally withuv sync --group docsanduv run mkdocs serve. - "How it works" documentation page (
docs/how-it-works.md) — a plain-prose walkthrough of every pipeline stage with the actual logic and calculations (harvesting, classification, inventory clustering and the ΔE join radii, usage-keyed prominence scoring, log-linear reconciliation, the 60/30/10 roles view, concurrency/safety guarantees, and performance notes). The incident narratives and worked calibration derivations formerly embedded in source comments and the bundled YAML now live there; the code keeps one-line invariants pointing at the page. request_filterseams accept async predicates.PolitenessPolicy,harvest_page, andRenderSessionnow take a synchronous or asynchronousurl -> boolpredicate; the new publicRequestFiltertype alias (exported from the package root andcolorsense.harvest) names the union. Sync predicates keep working unchanged but run inline on the event loop, so they must not block; async predicates are awaited, and raising — sync or async — still fails closed.
Changed¶
- Breaking:
block_private_networks()now returns an async predicate (await guard(url); only usable under a running event loop, as therequest_filterseams are). Its blocking DNS resolution runs off the event loop on a worker thread viaasyncio.to_threadon a cache miss, with per-host single-flight coalescing (N concurrent requests to one slow novel hostname dispatch one lookup, not N) — so a slow nameserver no longer stalls the whole loop, notably on the robots-fetch redirect path, where attacker-influenced redirect hostnames could previously trigger up to 21 on-loop lookups per fetch. The injectableResolverseam stays synchronous (it now runs inside the worker thread); the TTL+LRU verdict cache and the DNS-rebinding caveat are unchanged. block_private_networks()predicates now document and enforce (best-effort) a single-event-loop-at-a-time contract. Sequential reuse across loops — e.g. back-to-backasyncio.runcalls — keeps working as before: the predicate re-binds to the new loop when idle and keeps its verdict cache across runs. Concurrent use from multiple event loops, which was never supported, now raisesRuntimeErrorinstead of corrupting the loop-bound single-flight state; through therequest_filterseam the raise is swallowed fail-closed (requests from the other loop are aborted), so only direct callers see the error. Create a separate predicate per event loop for concurrent use.- Docstring cross-references converted from Sphinx reST roles (
:class:,:func:, ...) — which the docs site rendered as literal text — to mkdocstrings autorefs links for public API objects and plain code for internal ones. Duplicated rationale across comments/docstrings collapsed to one canonical home per topic. No behavior change; analysis output is byte-identical. - The
examples/webservice/reference implementation is restructured from a singleapp.pyinto an idiomatic mini FastAPI layout (main.py,settings.py,policy.py,schemas.py,routes.py;url_guard.pyunchanged). No behavior or security-control change. The uvicorn entry point is nowexamples.webservice.main:app(wasexamples.webservice.app:app).
0.3.0 - 2026-06-10¶
Ships the safe-consumption controls for server-side use — the block_private_networks()
egress filter, render-concurrency cap, and overall deadline — plus the colorsense CLI,
and hardens the policy's own robots.txt fetch (per-hop egress filtering, redirect and
body-size caps).
Added¶
colorsensecommand-line interface — a Typer-based console script wrappinganalyze: one or more URLs analyzed sequentially through a sharedPolitenessPolicy, with flags for dark mode, viewport/scale, config path, overall deadline, rate limiting, User-Agent, theblock_private_networks()egress filter, robots opt-out (warned on stderr), and--jsonoutput (theAnalysisResultschema; the human-readable summary is not a stable format). Adds a runtime dependency ontyper>=0.12.block_private_networks()— a library-shipped egressrequest_filterfactory (new public export) that resolves each hostname and rejects URLs resolving to any non-public address (loopback, RFC 1918, link-local/cloud-metadata, CGNAT, multicast, reserved, and IPv6 equivalents), failing closed, with a per-hostname TTL+LRU verdict cache and an optional narrowingallowed_hostsallowlist. Implements the SECURITY.md §1 egress-filter item; does not fully defeat DNS rebinding — network isolation remains the primary control.PolitenessPolicy(max_concurrent_renders=...)— an opt-in semaphore bounding simultaneous renders through a policy (unbounded by default). Cache hits and coalesced duplicate fetches never take a slot, and rate-limit waits happen outside the slot.analyze(..., max_total_seconds=...)— an opt-in overall deadline on the whole call (renders plus classification) viaasyncio.timeout; expiry cancels in-flight renders, closes the shared browser, and raises the newAnalysisTimeoutError(a public export subclassing the builtinTimeoutError, carrying the url and budget).analyze(..., browser_args=...)/--browser-arg— extra Chromium launch arguments appended to the library's own and passed verbatim to every render of the call (the themes share one browser launched with them);harvest_page,SharedBrowser, andRenderSessionaccept the same knob for direct use. Canonical use case:browser_args=("--js-flags=--max-old-space-size=512",)caps each renderer's V8 heap (JS heap only — hard per-render memory/CPU caps stay container-level by design; see SECURITY.md §2). Non-string entries raiseTypeErrorbefore any render.
Security¶
- The policy's
robots.txtfetch now applies the configuredrequest_filterto the robots URL and every redirect hop (redirects are followed manually, capped at 20 hops, eachLocationvetted before being requested). This closes a server-side SSRF bypass where a hostilerobots.txtredirect could reach private/metadata addresses unfiltered — the robots GET ishttpx, not the browser, so the browser-route filter never saw it. A rejected hop aborts the fetch, which fails open as "no rules" while the navigation stays gated browser-side. - The policy's
robots.txtfetch now caps the response body at 512 KiB (Google's documented robots.txt processing limit), read in a streaming fashion: a declaredContent-Lengthover the cap aborts before the body is read, and a body streaming past the cap aborts mid-read. Previously the entire body was materialized in memory and the httpx timeout is per-read (not total), so a hostile or misconfigured server could stream an arbitrarily large body to the server-side loader — outside the browser's resource caps. An oversized body is treated like any other fetch failure (no rules; fails open). block_private_networks()now classifies IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) by their embedded IPv4 address, so a resolver returning the mapped form of a private address is rejected like the bare IPv4 form.
Changed¶
- The
examples/webservice/reference implementation now uses the new library knobs (block_private_networks,max_concurrent_renders,max_total_seconds) instead of hand-rolled equivalents; only the pre-call navigation-URL validation remains app code. - Breaking for injected loaders: the
RobotsLoaderseam now receives the policy'srequest_filteras a third argument —(robots_url, user_agent, request_filter). Custom loaders must adopt the new signature and are responsible for applying the filter to the robots URL and every redirect hop they follow. DEFAULT_USER_AGENT's version token now reflects the installed package version (was hardcodedcolorsense/0.1, two releases stale), matching how the CLI's UA is already derived.- The webservice example parses
COLORSENSE_BROWSER_ARGSwithshlex.split(whitespace-separated, shell-style quoting; was comma-split), so flags containing commas or spaces are expressible by quoting — unbalanced quotes raiseValueErrorat startup. ItsAnalyzeRequest.urlis now bounded to 2083 characters.
Fixed¶
- Single-flight render coalescing no longer propagates a cancelled leader's
CancelledErrorto concurrent callers of the same URL (triggered by e.g. the leader'sanalyze(max_total_seconds=...)deadline expiring, or its HTTP client disconnecting in a server). Followers now re-elect a new leader and re-render instead; a caller's own cancellation still raises normally. - The robots loader's failure handling now also catches
httpx.InvalidURL(which subclasses neitherHTTPErrornorValueError), so a redirectLocationthat a stricter future httpx refuses to parse fails open as "no rules" instead of propagating out of the loader. Not reachable with the current httpx (it parses leniently); pinned by a regression test via the transport seam.
0.2.0 - 2026-06-09¶
First release to include the SSRF hardening work; also the first to ship the restructured documentation set.
Added¶
- SSRF controls. A URL scheme gate — only
http(s)is fetched by default;file://requires an explicitPolitenessPolicy(allow_file_urls=True)opt-in, and every other scheme raises the newUnsupportedSchemeError(now a public export). A newrequest_filterpredicate gates every request the browser makes (the navigation and the page's own sub-resources), the in-library defense against sub-resource SSRF. (#14) SECURITY.mddocumenting the threat model — SSRF, resource exhaustion / DoS, and the fail-openrobots.txtgate — and the controls consumers must enforce. (#13)- Capped
robots.txtCrawl-delayhonoring via a newmax_crawl_delaypolicy knob (30 s default) so a hostile or typo'd directive cannot stall the pipeline. (#14) - Screenshot capture safeguards: dimension caps and a decode pixel cap that rejects decompression-bomb captures. (#14)
- Dependabot for GitHub Actions and Python dependencies. (#12)
Changed¶
- Themes now render concurrently through a single shared headless Chromium launch instead of one browser per theme. (#14)
- The configured User-Agent is now sent on the page render itself, not just the
robots.txtGET, so the render is attributable to the same identity. (#14) - Documentation restructured into a slim README plus
docs/usage.md,docs/advanced.md,CONTRIBUTING.md, and this changelog. (#15)
Removed¶
- Dead code paths in the harvest / screenshot layers. (#14)
0.1.0 - 2026-06-09¶
Initial public release.
analyze(url)— async pipeline that renders a page in headless Chromium, harvests design tokens and computed element colors, and classifies them into a 60/30/10 palette with ranked, scored candidates per role.- Typed, frozen Pydantic result (
AnalysisResult) with per-theme palettes, OKLCH-bearing colors, declared-vs-rendered token divergence, status-color filtering, fit scoring, and run metadata. - Optional dark-mode analysis (
themes=LIGHT_AND_DARK) with single-theme collapse for sites that ignoreprefers-color-scheme. PolitenessPolicy— configurable User-Agent,robots.txtgate, per-host rate limiting, and an LRU render cache.- Bundled, overridable palette configuration (
config_path=/load_config). - Fully typed (
py.typed), Python 3.12+.