From 32c80b39123a7a6a9fb035b7c611f182708199bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 09:59:36 +0000 Subject: [PATCH 1/3] Add string-brand agent skill documenting colors, fonts, and logo usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packages the brand guide as a Claude Code skill so agents building anything with String's identity get the real values instead of guessing. The palette, logo pairings, and font list are read straight from the source assets: the four core colors come from the fill values in the vector masters and separate SVGs, and the six approved logo-on-background pairings come from the twelve JPEG boards. #C0F4FB was only present in the master SVG and the board backgrounds, so it was previously undocumented anywhere in text. Beyond the raw values, the skill covers the thing most likely to go wrong here: three of the four core colors are light, so #75F8CC and #C0F4FB are fills rather than text colors (1.31:1 and 1.19:1 against white). Ramps supply AA-passing alternatives for cases like green link text. Contents: - SKILL.md — palette, type, logo pairings - references/ — color ramps and contrast matrix, logo files and sizing, type scale and font loading - assets/tokens.{css,json} — drop-in tokens with light/dark themes - scripts/contrast.py — WCAG checker that suggests on-brand substitutes Derived material (ramps, type scale, the Space Grotesk display / Montserrat body split) is marked as convention rather than presented as guide content, since the source guide names the two typefaces without assigning roles. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGLW9iKmNJHtDiHBBLRGbq --- .claude/skills/string-brand/SKILL.md | 148 ++++++++++++ .claude/skills/string-brand/assets/tokens.css | 138 +++++++++++ .../skills/string-brand/assets/tokens.json | 129 ++++++++++ .../skills/string-brand/references/colors.md | 216 +++++++++++++++++ .../skills/string-brand/references/logo.md | 169 ++++++++++++++ .../string-brand/references/typography.md | 207 ++++++++++++++++ .../skills/string-brand/scripts/contrast.py | 220 ++++++++++++++++++ README.md | 52 +++++ 8 files changed, 1279 insertions(+) create mode 100644 .claude/skills/string-brand/SKILL.md create mode 100644 .claude/skills/string-brand/assets/tokens.css create mode 100644 .claude/skills/string-brand/assets/tokens.json create mode 100644 .claude/skills/string-brand/references/colors.md create mode 100644 .claude/skills/string-brand/references/logo.md create mode 100644 .claude/skills/string-brand/references/typography.md create mode 100755 .claude/skills/string-brand/scripts/contrast.py diff --git a/.claude/skills/string-brand/SKILL.md b/.claude/skills/string-brand/SKILL.md new file mode 100644 index 0000000..446b9a7 --- /dev/null +++ b/.claude/skills/string-brand/SKILL.md @@ -0,0 +1,148 @@ +--- +name: string-brand +description: The String brand system — the official color palette (#75F8CC mint, #33373B dark, #C0F4FB sky), the Montserrat + Space Grotesk typefaces, and the logo/mark asset files with their approved background pairings. Use this skill whenever you are producing anything that carries String's identity or picking colors, fonts, or a logo file for it: websites, landing pages, web apps, UI components, CSS/Tailwind themes, design tokens, slide decks, PDFs, social images, README banners, favicons, email templates, diagrams, or charts. Trigger it even when the request never says "brand" or "style guide" — "build a landing page for String", "make the dashboard match our colors", "what's our green?", "add the logo to the header", and "set up the fonts" all need it. Consult it before inventing a hex value, choosing a font stack, or referencing a logo path, so the output matches the real guide instead of a plausible guess. +--- + +# String Brand System + +This skill is the machine-readable version of the String brand guide that lives in this +repository. Its job is to keep anything you build on-brand without you having to open the +JPEG boards or eyedropper a PNG. + +Everything here was read directly out of the source assets (`1. Vector Masters/Svg.svg`, +the six separate SVGs, and the twelve JPEG boards), so the values are the real ones rather +than approximations. Where the source guide is silent and this skill establishes a +convention instead, it says so explicitly — that distinction matters, because a convention +is something you may reasonably deviate from with a reason, while a palette value is not. + +## The 30-second version + +| | Value | Use for | +|---|---|---| +| **String Green** | `#75F8CC` | The brand color. Logos, accents, highlights, CTAs, large fills. | +| **String Dark** | `#33373B` | Ink and dark surfaces. All body text. The default logo color on light backgrounds. | +| **String Sky** | `#C0F4FB` | Secondary accent. Section backgrounds, cards, chart fills. | +| **White** | `#FFFFFF` | Primary light surface. | +| **Display type** | Space Grotesk | Headings, logotype-adjacent text, numerals, UI labels. | +| **Body type** | Montserrat | Paragraphs, long-form copy, dense UI. | + +The single most important rule: **`#75F8CC` and `#C0F4FB` are not text colors on white.** +They sit at 1.31:1 and 1.19:1 against white, which is effectively invisible. They are fill +colors. Text is `#33373B` on light, `#FFFFFF` or `#75F8CC` on dark. + +## Choosing a logo file + +Assets live at the repo root. Two shapes, three colorways, four formats: + +- **Primary** — the full `String` logotype with the mark. `viewBox="0 0 371 135"`. Default choice. +- **Mark** — the standalone knot glyph, square. `viewBox="0 0 264 264"`. Use when space is + tight or square: favicons, avatars, app icons, watermarks. + +``` +4. Svg Separate Files/{primary,mark}_{green,dark,white}.svg ← use these for web/UI +3. Png Transparent Files/{primary,mark}_{green,dark,white}.png ← raster, transparent +1. Vector Masters/{Ai.ai,Eps.eps,Pdf.pdf,Svg.svg} ← print + handoff to designers +2. Jpegs/1..12.jpg ← the rendered guide boards +``` + +Pick the colorway from the background, not from taste. The guide sanctions exactly these +six pairings (boards 1–12 are these six, each shown as mark then logotype): + +| Background | Logo file | Contrast | +|---|---|---| +| `#FFFFFF` white | `*_green.svg` | 1.31 — sanctioned, but see below | +| `#FFFFFF` white | `*_dark.svg` | 12.0 | +| `#33373B` dark | `*_green.svg` | 9.17 | +| `#33373B` dark | `*_white.svg` | 12.0 | +| `#C0F4FB` sky | `*_dark.svg` | 10.04 | +| `#75F8CC` green | `*_dark.svg` | 9.17 | + +Green-on-white is in the official guide, so it is not wrong — but at 1.31:1 it disappears +at small sizes and on projectors. Reach for it only at generous scale on screens you +control (a hero, a splash), and use `primary_dark.svg` for favicons, headers, docs, and +anything printed. When you are unsure, dark-on-light and green-on-dark are the two pairings +that always work. + +The combinations the guide does *not* show — white on green, white on sky, green on sky — +are unsanctioned and all fail contrast anyway. Don't improvise them. + +Clear space: keep at least the height of the mark's stroke (roughly 1/8 of the mark's +height) free on all sides. Never recolor, rotate, stretch, add effects to, or re-letter the +logo; if you need a color that isn't in the three colorways, you need a different design, +not a recolored logo. + +## Using the type + +Both families ship as TTFs under `5. Fonts/`. Montserrat has the full Thin→Black range with +matching italics (18 files); Space Grotesk has Light, Regular, Medium, SemiBold, and Bold, +with **no italics** — so never fake an italic Space Grotesk, switch to Montserrat Italic or +find another emphasis. + +Both are on Google Fonts, so for web work prefer linking or self-hosting from there over +converting the bundled TTFs. + +```css +font-family: "Space Grotesk", "Montserrat", system-ui, sans-serif; /* display */ +font-family: "Montserrat", system-ui, -apple-system, sans-serif; /* body */ +``` + +The display/body split is this skill's convention, not something the source guide states — +the guide only names the two families. The reasoning: the String logotype is geometric and +monoline, and Space Grotesk's squared terminals and tight apertures echo it, so headings set +in it feel continuous with the mark, while Montserrat's wider, rounder forms are the more +comfortable reader at paragraph sizes. If a project has already committed to the opposite +split, follow the project — consistency within a product beats consistency with this file. + +## Reaching for more detail + +The tables above cover most work. Read further when the task needs it: + +- **`references/colors.md`** — every color in RGB/HSL, the full contrast matrix, tints and + shades for hover/border/disabled states, dark-mode and light-mode surface recipes, and + chart palettes. Read this whenever you need a color that is *not* one of the four, since + inventing one off-palette is the most common way work drifts off-brand. +- **`references/logo.md`** — per-file inventory, favicon and social-card sizing, embedding + SVG inline vs. ``, and the misuse list in full. +- **`references/typography.md`** — the type scale, weight pairings, `@font-face` blocks for + self-hosting the bundled TTFs, Google Fonts links, and the exact font files present. + +Two drop-in files save you from retyping values: + +- **`assets/tokens.css`** — CSS custom properties plus a light/dark theme block. Copy it into + a project and reference `var(--string-green)` rather than pasting hex codes around. +- **`assets/tokens.json`** — the same values as data, for Tailwind configs, design-token + pipelines, or any script that needs to read the palette. + +And one tool: + +- **`scripts/contrast.py`** — checks any foreground/background pair against WCAG. Run it + before shipping a color combination you invented: + + ```bash + python3 .claude/skills/string-brand/scripts/contrast.py '#33373B' '#C0F4FB' + python3 .claude/skills/string-brand/scripts/contrast.py --matrix # whole palette + ``` + +## Working on-brand + +A few habits that keep output consistent, in rough order of how often they matter: + +**Let dark and white carry the layout; let green punctuate it.** The guide's boards are +overwhelmingly one flat background with one logo — the brand's character comes from +restraint and a lot of empty space, not from spreading `#75F8CC` everywhere. Green reads as +brand precisely because it is scarce. A page that is 90% white or dark with green on the one +thing that matters looks like String; a page washed in mint does not. + +**Never invent a hex value silently.** If you need a hover state, a border, or a fifth chart +series, take it from `references/colors.md` rather than nudging a channel by eye. If nothing +there fits, derive it from a palette color, say in your response which one you derived and +why, and check it with `contrast.py`. + +**Check contrast on any pairing not in the table above.** The palette has two very light +accents, which makes it unusually easy to produce something that looks fine on your screen +and is unreadable on someone else's. + +**Prefer SVG.** The separate SVGs are small, single-path, and already have their fill baked +in as a class — they scale to any size and drop straight into HTML. Use PNG only where SVG +isn't accepted, and reach for the vector masters only for print or when handing off to a +designer. diff --git a/.claude/skills/string-brand/assets/tokens.css b/.claude/skills/string-brand/assets/tokens.css new file mode 100644 index 0000000..7f123a0 --- /dev/null +++ b/.claude/skills/string-brand/assets/tokens.css @@ -0,0 +1,138 @@ +/* String brand design tokens + * Source: github.com/string-dxd/string-brand-guide + * + * The four core values (--string-green, --string-dark, --string-sky, --string-white) + * are read from the official logo vectors. Everything else is derived — see + * references/colors.md for how, and for the contrast ratio of every value. + * + * Usage: drop this in, then reference var(--string-green) instead of pasting hex codes. + */ + +:root { + /* ---- Core palette ---- */ + --string-green: #75f8cc; + --string-dark: #33373b; + --string-sky: #c0f4fb; + --string-white: #ffffff; + + /* ---- Green ramp ---- */ + --string-green-50: #eafef7; + --string-green-100: #cffded; + --string-green-200: #a5fade; + --string-green-300: #8af9d4; + --string-green-500: #75f8cc; /* base */ + --string-green-600: #69d5b2; + --string-green-700: #5db398; + --string-green-800: #4b7c6f; /* 4.76:1 on white — green text on light */ + --string-green-900: #44675f; /* 6.27:1 on white */ + + /* ---- Neutral / ink ramp (base at 600, see colors.md) ---- */ + --string-ink-50: #f7f7f7; + --string-ink-100: #ebebeb; + --string-ink-200: #d2d3d4; + --string-ink-300: #b1b3b5; + --string-ink-400: #8d8f91; + --string-ink-500: #64676a; /* 5.69:1 on white — secondary text */ + --string-ink-600: #33373b; /* base */ + --string-ink-700: #26292c; + --string-ink-800: #1a1c1e; + --string-ink-900: #0e0f11; + + /* ---- Sky ramp (base at 300) ---- */ + --string-sky-50: #e3fafd; + --string-sky-100: #d3f7fc; + --string-sky-300: #c0f4fb; /* base */ + --string-sky-400: #a1cad1; + --string-sky-500: #819fa5; + --string-sky-800: #556469; /* 6.15:1 on white */ + + /* ---- Typography ---- */ + --string-font-display: "Space Grotesk", "Montserrat", ui-sans-serif, system-ui, sans-serif; + --string-font-body: "Montserrat", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + + --string-text-display: 3.8rem; /* 61px */ + --string-text-h1: 3.05rem; /* 49px */ + --string-text-h2: 2.44rem; /* 39px */ + --string-text-h3: 1.95rem; /* 31px */ + --string-text-h4: 1.56rem; /* 25px */ + --string-text-body-lg: 1.25rem; /* 20px */ + --string-text-body: 1rem; /* 16px */ + --string-text-sm: 0.875rem; /* 14px */ + --string-text-xs: 0.75rem; /* 12px */ + + --string-leading-display: 1.05; + --string-leading-heading: 1.2; + --string-leading-body: 1.65; + --string-tracking-display: -0.02em; + --string-tracking-overline: 0.12em; + + /* ---- Semantic: light theme (default) ---- */ + --string-bg: var(--string-white); + --string-bg-surface: var(--string-ink-50); + --string-bg-accent: var(--string-sky-300); + --string-border: var(--string-ink-200); + --string-text: var(--string-ink-600); + --string-text-muted: var(--string-ink-500); + --string-text-disabled: var(--string-ink-400); + --string-text-accent: var(--string-green-800); + --string-focus: var(--string-ink-600); + + --string-btn-bg: var(--string-ink-600); + --string-btn-fg: var(--string-white); + --string-btn-bg-hover: var(--string-ink-700); + --string-btn-bg-active: var(--string-ink-800); +} + +/* ---- Semantic: dark theme ---- + * Green on dark is the signature pairing (9.17:1) and the palette's strongest look. + * Applies on system preference; [data-theme] overrides win in both directions. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --string-bg: var(--string-ink-600); + --string-bg-surface: var(--string-ink-700); + --string-bg-accent: var(--string-ink-700); + --string-border: var(--string-green-800); + --string-text: var(--string-white); + --string-text-muted: var(--string-ink-300); + --string-text-disabled: var(--string-ink-400); + --string-text-accent: var(--string-green-500); + --string-focus: var(--string-green-500); + + --string-btn-bg: var(--string-green-500); + --string-btn-fg: var(--string-ink-600); + --string-btn-bg-hover: var(--string-green-300); + --string-btn-bg-active: var(--string-green-600); + } +} + +:root[data-theme="dark"] { + --string-bg: var(--string-ink-600); + --string-bg-surface: var(--string-ink-700); + --string-bg-accent: var(--string-ink-700); + --string-border: var(--string-green-800); + --string-text: var(--string-white); + --string-text-muted: var(--string-ink-300); + --string-text-disabled: var(--string-ink-400); + --string-text-accent: var(--string-green-500); + --string-focus: var(--string-green-500); + + --string-btn-bg: var(--string-green-500); + --string-btn-fg: var(--string-ink-600); + --string-btn-bg-hover: var(--string-green-300); + --string-btn-bg-active: var(--string-green-600); +} + +/* ---- Optional base styles ---- */ +body { + background: var(--string-bg); + color: var(--string-text); + font-family: var(--string-font-body); + font-size: var(--string-text-body); + line-height: var(--string-leading-body); +} + +h1, h2, h3, h4 { + font-family: var(--string-font-display); + line-height: var(--string-leading-heading); + letter-spacing: var(--string-tracking-display); +} diff --git a/.claude/skills/string-brand/assets/tokens.json b/.claude/skills/string-brand/assets/tokens.json new file mode 100644 index 0000000..c3f275a --- /dev/null +++ b/.claude/skills/string-brand/assets/tokens.json @@ -0,0 +1,129 @@ +{ + "$comment": "String brand tokens. Core values are read from the official logo vectors; ramps and semantic mappings are derived — see references/colors.md. Source: github.com/string-dxd/string-brand-guide", + "brand": "String", + "color": { + "core": { + "green": { "hex": "#75F8CC", "rgb": [117, 248, 204], "hsl": [160, 90, 72], "role": "Primary brand color" }, + "dark": { "hex": "#33373B", "rgb": [51, 55, 59], "hsl": [210, 7, 22], "role": "Ink and dark surfaces" }, + "sky": { "hex": "#C0F4FB", "rgb": [192, 244, 251], "hsl": [187, 88, 87], "role": "Secondary accent" }, + "white": { "hex": "#FFFFFF", "rgb": [255, 255, 255], "hsl": [0, 0, 100], "role": "Primary light surface" } + }, + "green": { + "50": "#EAFEF7", + "100": "#CFFDED", + "200": "#A5FADE", + "300": "#8AF9D4", + "500": "#75F8CC", + "600": "#69D5B2", + "700": "#5DB398", + "800": "#4B7C6F", + "900": "#44675F" + }, + "ink": { + "50": "#F7F7F7", + "100": "#EBEBEB", + "200": "#D2D3D4", + "300": "#B1B3B5", + "400": "#8D8F91", + "500": "#64676A", + "600": "#33373B", + "700": "#26292C", + "800": "#1A1C1E", + "900": "#0E0F11" + }, + "sky": { + "50": "#E3FAFD", + "100": "#D3F7FC", + "300": "#C0F4FB", + "400": "#A1CAD1", + "500": "#819FA5", + "800": "#556469" + }, + "chart": ["#33373B", "#75F8CC", "#819FA5", "#4B7C6F", "#C0F4FB", "#B1B3B5"], + "status": { + "$comment": "Outside the brand guide — the guide defines no status colors. Do not use brand green for success; it reads as 'String', not 'succeeded'.", + "error": { "fg": "#D64545", "bg": "#FDECEC" }, + "warning": { "fg": "#C77A16", "bg": "#FDF3E3" }, + "success": { "fg": "#2E9E6B", "bg": "#E8F6EF" }, + "info": { "fg": "#3A7CA5", "bg": "#E9F2F8" } + } + }, + "semantic": { + "light": { + "bg": "#FFFFFF", + "bgSurface": "#F7F7F7", + "bgAccent": "#C0F4FB", + "border": "#D2D3D4", + "text": "#33373B", + "textMuted": "#64676A", + "textDisabled": "#8D8F91", + "textAccent": "#4B7C6F", + "buttonBg": "#33373B", + "buttonFg": "#FFFFFF" + }, + "dark": { + "bg": "#33373B", + "bgSurface": "#26292C", + "bgAccent": "#26292C", + "border": "#4B7C6F", + "text": "#FFFFFF", + "textMuted": "#B1B3B5", + "textDisabled": "#8D8F91", + "textAccent": "#75F8CC", + "buttonBg": "#75F8CC", + "buttonFg": "#33373B" + } + }, + "typography": { + "$comment": "The guide names both families but assigns no roles; the display/body split is this skill's convention.", + "families": { + "display": { + "name": "Space Grotesk", + "stack": "\"Space Grotesk\", \"Montserrat\", ui-sans-serif, system-ui, sans-serif", + "weights": [300, 400, 500, 600, 700], + "italics": false, + "source": "5. Fonts/Space Grotesk/" + }, + "body": { + "name": "Montserrat", + "stack": "\"Montserrat\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif", + "weights": [100, 200, 300, 400, 500, 600, 700, 800, 900], + "italics": true, + "source": "5. Fonts/Montserrat/" + } + }, + "scale": { + "display": { "family": "display", "px": 61, "rem": "3.8rem", "weight": 700, "lineHeight": 1.05, "tracking": "-0.02em" }, + "h1": { "family": "display", "px": 49, "rem": "3.05rem", "weight": 700, "lineHeight": 1.1, "tracking": "-0.02em" }, + "h2": { "family": "display", "px": 39, "rem": "2.44rem", "weight": 600, "lineHeight": 1.15, "tracking": "-0.015em" }, + "h3": { "family": "display", "px": 31, "rem": "1.95rem", "weight": 600, "lineHeight": 1.25, "tracking": "-0.01em" }, + "h4": { "family": "display", "px": 25, "rem": "1.56rem", "weight": 500, "lineHeight": 1.3, "tracking": "0" }, + "bodyLarge":{ "family": "body", "px": 20, "rem": "1.25rem", "weight": 400, "lineHeight": 1.6, "tracking": "0" }, + "body": { "family": "body", "px": 16, "rem": "1rem", "weight": 400, "lineHeight": 1.65, "tracking": "0" }, + "small": { "family": "body", "px": 14, "rem": "0.875rem", "weight": 400, "lineHeight": 1.5, "tracking": "0" }, + "caption": { "family": "body", "px": 12, "rem": "0.75rem", "weight": 500, "lineHeight": 1.4, "tracking": "0.01em" }, + "overline": { "family": "display", "px": 12, "rem": "0.75rem", "weight": 600, "lineHeight": 1.2, "tracking": "0.12em", "textTransform": "uppercase" } + } + }, + "logo": { + "shapes": { + "primary": { "viewBox": "0 0 371 135", "aspectRatio": 2.7481, "minWidthPx": 100, "minWidthMm": 25 }, + "mark": { "viewBox": "0 0 264 264", "aspectRatio": 1.0, "minWidthPx": 24, "minWidthMm": 8 } + }, + "colorways": ["green", "dark", "white"], + "paths": { + "svg": "4. Svg Separate Files/{shape}_{colorway}.svg", + "png": "3. Png Transparent Files/{shape}_{colorway}.png", + "masters": "1. Vector Masters/" + }, + "approvedPairings": [ + { "background": "#FFFFFF", "colorway": "green", "contrast": 1.31, "note": "In the guide, but very low contrast — large decorative use only" }, + { "background": "#FFFFFF", "colorway": "dark", "contrast": 12.0 }, + { "background": "#33373B", "colorway": "green", "contrast": 9.17, "note": "Signature pairing" }, + { "background": "#33373B", "colorway": "white", "contrast": 12.0 }, + { "background": "#C0F4FB", "colorway": "dark", "contrast": 10.04 }, + { "background": "#75F8CC", "colorway": "dark", "contrast": 9.17 } + ], + "clearSpace": "One mark stroke-weight (~1/8 of mark height) on all sides" + } +} diff --git a/.claude/skills/string-brand/references/colors.md b/.claude/skills/string-brand/references/colors.md new file mode 100644 index 0000000..2e8ceb8 --- /dev/null +++ b/.claude/skills/string-brand/references/colors.md @@ -0,0 +1,216 @@ +# String color reference + +Everything in this file is either read directly from the source assets (the four core +colors) or derived from them by straight-line mixing toward white/dark (the ramps). Every +contrast number was computed with the WCAG 2.1 relative-luminance formula and is repeatable +via `scripts/contrast.py`. + +## Contents + +- [The four core colors](#the-four-core-colors) +- [Contrast matrix](#contrast-matrix) +- [Green ramp](#green-ramp) +- [Neutral / ink ramp](#neutral--ink-ramp) +- [Sky ramp](#sky-ramp) +- [Semantic recipes](#semantic-recipes) — light theme, dark theme, states +- [Charts and data viz](#charts-and-data-viz) +- [Status colors](#status-colors) +- [Where the values came from](#where-the-values-came-from) + +## The four core colors + +| Name | Hex | RGB | HSL | Role | +|---|---|---|---|---| +| String Green | `#75F8CC` | `117, 248, 204` | `160°, 90%, 72%` | The brand color | +| String Dark | `#33373B` | `51, 55, 59` | `210°, 7%, 22%` | Ink, dark surfaces | +| String Sky | `#C0F4FB` | `192, 244, 251` | `187°, 88%, 87%` | Secondary accent | +| White | `#FFFFFF` | `255, 255, 255` | `0°, 0%, 100%` | Primary light surface | + +String Dark is a *cool* near-black — 210° hue, 7% saturation. That slight blue cast is what +makes it sit correctly next to the mint and the sky. Substituting `#333` or `#000` breaks +the relationship subtly but visibly, especially in large flat fills, so use the real value. + +## Contrast matrix + +| | Green | Dark | Sky | White | +|---|---|---|---|---| +| **Green** `#75F8CC` | — | **9.17** | 1.09 | 1.31 | +| **Dark** `#33373B` | **9.17** | — | **10.04** | **12.00** | +| **Sky** `#C0F4FB` | 1.09 | **10.04** | — | 1.19 | +| **White** `#FFFFFF` | 1.31 | **12.00** | 1.19 | — | + +Bold = passes WCAG AA for body text (≥4.5:1). The pattern is simple and worth internalizing: +**Dark is the only core color that pairs with anything.** Green, sky, and white are all +light, so they never pair with each other for text. Three of the four core colors are light, +which is exactly why off-brand-looking, unreadable output is easy to produce here if you +pick by eye. + +Thresholds, for reference: 4.5:1 body text (AA), 3:1 large text ≥24px or bold ≥18.66px (AA) +and UI components/graphics, 7:1 body text (AAA). + +## Green ramp + +Derived by mixing `#75F8CC` toward white (lighter) and toward `#33373B` (darker), so every +step stays in the brand's hue family rather than drifting toward a generic teal. + +| Token | Hex | On white | On dark | Use | +|---|---|---|---|---| +| `green-50` | `#EAFEF7` | 1.05 | 11.44 | Faintest tint — hover rows, subtle section fills | +| `green-100` | `#CFFDED` | 1.11 | 10.81 | Badge and callout backgrounds | +| `green-200` | `#A5FADE` | 1.21 | 9.90 | Borders on light, chart fills | +| `green-300` | `#8AF9D4` | 1.27 | 9.47 | Hover state for a green surface | +| **`green-500`** | **`#75F8CC`** | **1.31** | **9.17** | **Base — the brand color** | +| `green-600` | `#69D5B2` | 1.79 | 6.72 | Pressed/active state for a green surface | +| `green-700` | `#5DB398` | 2.51 | 4.78 | Borders on dark, secondary green | +| `green-800` | `#4B7C6F` | 4.76 | — | **Green text on light backgrounds** (AA) | +| `green-900` | `#44675F` | 6.27 | — | Green text needing near-AAA, green icons on white | + +`green-800` is the one to reach for when a designer asks for "the green" in a link or a +label on a white page. `green-500` in that position is unreadable; `green-800` keeps the hue +relationship and passes AA. + +## Neutral / ink ramp + +Built from `#33373B`, so the whole scale carries the same cool cast. Note the base sits at +**600**, not 500 — `#33373B` is darker than a conventional 500 step, and shifting it to keep +the usual numbering would have meant either lying about the brand color or compressing the +light end into uselessness. + +| Token | Hex | On white | On dark | Use | +|---|---|---|---|---| +| `ink-50` | `#F7F7F7` | 1.07 | 11.20 | Page background alternate, table stripes | +| `ink-100` | `#EBEBEB` | 1.19 | 10.06 | Card backgrounds, dividers on white | +| `ink-200` | `#D2D3D4` | 1.50 | 8.00 | Borders, input outlines | +| `ink-300` | `#B1B3B5` | 2.10 | 5.70 | Disabled text on dark, placeholder on dark | +| `ink-400` | `#8D8F91` | 3.25 | 3.70 | Disabled text, large secondary text | +| `ink-500` | `#64676A` | 5.69 | 2.11 | **Secondary/muted text on light** (AA) | +| **`ink-600`** | **`#33373B`** | **12.00** | — | **Base — body text on light, dark surfaces** | +| `ink-700` | `#26292C` | 14.62 | — | Elevated dark surface, dark-mode cards | +| `ink-800` | `#1A1C1E` | 17.09 | — | Deepest dark surface | +| `ink-900` | `#0E0F11` | 19.18 | — | Near-black, use sparingly | + +On dark surfaces, layer *upward* from `ink-600`: page at `#33373B`, cards at `ink-700` +`#26292C`, and so on. The guide's dark boards are flat `#33373B`, so keep elevation +differences small — a heavy multi-layer dark UI reads as someone else's brand. + +## Sky ramp + +| Token | Hex | On white | On dark | Use | +|---|---|---|---|---| +| `sky-50` | `#E3FAFD` | 1.03 | 11.06 | Faint section wash | +| `sky-100` | `#D3F7FC` | 1.09 | 10.56 | Callout and info backgrounds | +| **`sky-300`** | **`#C0F4FB`** | **1.19** | **10.04** | **Base — section backgrounds, cards** | +| `sky-400` | `#A1CAD1` | 1.77 | 6.79 | Borders on sky fills | +| `sky-500` | `#819FA5` | 2.82 | 4.25 | Icons on dark, secondary marks | +| `sky-800` | `#556469` | 6.15 | — | **Sky-tinted text on light** (AA) | + +Sky is the least-used of the three brand colors in the source guide — it appears as a +background on two boards and nowhere else. Treat it as a supporting surface color that gives +a page a second "mood" without introducing a new hue, not as a co-equal accent to green. + +## Semantic recipes + +Rather than picking from the ramps every time, start from these and adjust. + +### Light theme + +``` +page background #FFFFFF +surface / card #F7F7F7 (ink-50) +surface alternate #C0F4FB (sky-300) — for a section that should feel different +border #D2D3D4 (ink-200) +text primary #33373B (ink-600) 12.00:1 +text secondary #64676A (ink-500) 5.69:1 +text disabled #8D8F91 (ink-400) 3.25:1 — large text only +link / accent text #4B7C6F (green-800) 4.76:1 +focus ring #33373B (ink-600) — or green-800 on a dark control +``` + +### Dark theme + +``` +page background #33373B (ink-600) +surface / card #26292C (ink-700) +border #4B7C6F (green-800) or a 12% white overlay +text primary #FFFFFF 12.00:1 +text secondary #B1B3B5 (ink-300) 5.70:1 +text disabled #8D8F91 (ink-400) 3.70:1 +link / accent text #75F8CC (green-500) 9.17:1 +focus ring #75F8CC (green-500) +``` + +Dark theme is where the brand is most itself: `#75F8CC` on `#33373B` is both the highest- +impact and highest-contrast pairing in the system. When a project could go either way, +dark-first is the more on-brand choice. + +### Primary button + +| | Light theme | Dark theme | +|---|---|---| +| Background | `#33373B` | `#75F8CC` | +| Label | `#FFFFFF` | `#33373B` | +| Hover bg | `#26292C` | `#8AF9D4` | +| Active bg | `#1A1C1E` | `#69D5B2` | +| Disabled bg | `#D2D3D4` | `#4B7C6F` | + +A green button with dark text is the loudest thing the palette can do — one per screen. On a +light page, the dark button is the primary and green is reserved for the one action that +matters most, if any. + +### Secondary / ghost button + +Transparent background, `#33373B` text and 1px `#D2D3D4` border on light; `#FFFFFF` text and +a 20% white border on dark. Hover fills with `ink-50` / `ink-700`. + +## Charts and data viz + +The palette only supplies three hues, which is not enough for a categorical series. Extend by +walking the green and sky ramps at spaced luminance steps, which keeps everything on-brand +and stays distinguishable in grayscale: + +``` +1 #33373B ink-600 2 #75F8CC green-500 +3 #819FA5 sky-500 4 #4B7C6F green-800 +5 #C0F4FB sky-300 6 #B1B3B5 ink-300 +``` + +Beyond six categories, prefer a different chart form (small multiples, a sorted bar chart) +over inventing a seventh color — the palette has no seventh color that stays on-brand. + +For sequential scales, run `#EAFEF7 → #75F8CC → #4B7C6F → #33373B`. For diverging, use sky +on one end and green on the other with `ink-100` at the midpoint, and label it clearly since +mint and cyan are close in hue and easy to confuse at small sizes. + +Gridlines `ink-200` on light / 10% white on dark; axis labels `ink-500` on light / +`ink-300` on dark. + +## Status colors + +The brand guide has no error/warning/success colors, so these are outside the brand and you +should not pretend otherwise. Two things worth knowing when you need them: + +**Do not use green for success.** `#75F8CC` is the brand color and reads as "String," not as +"this worked." Using it for success states means every success message shouts brand and +every brand accent implies success. Use a separate, clearly different green (or better, a +checkmark plus neutral text). + +Pick status colors tuned to sit next to `#33373B` — cool and slightly desaturated rather than +pure hues. Reasonable defaults, contrast-checked against both white and `#33373B`: + +``` +error #D64545 (light bg #FDECEC) warning #C77A16 (light bg #FDF3E3) +success #2E9E6B (light bg #E8F6EF) info #3A7CA5 (light bg #E9F2F8) +``` + +Flag in your response that these are outside the brand guide so the choice can be reviewed. + +## Where the values came from + +- `#75F8CC`, `#33373B`, `#FFFFFF` — the `fill` values in `4. Svg Separate Files/*.svg` and + the `.st*` classes in `1. Vector Masters/Svg.svg`. +- `#C0F4FB` — the fourth class in `1. Vector Masters/Svg.svg`, confirmed as the dominant + color of boards `2. Jpegs/9.jpg` and `10.jpg` (JPEG compression renders it `#BFF4FA`; the + vector value is authoritative). +- Ramps, semantic recipes, chart palettes, and status colors are derived, not from the guide. + They are here so that work stays consistent instead of each project inventing its own + hover state — but they are conventions, and a project with a good reason may differ. diff --git a/.claude/skills/string-brand/references/logo.md b/.claude/skills/string-brand/references/logo.md new file mode 100644 index 0000000..3cd5aa2 --- /dev/null +++ b/.claude/skills/string-brand/references/logo.md @@ -0,0 +1,169 @@ +# String logo reference + +## Contents + +- [Two shapes](#two-shapes) +- [File inventory](#file-inventory) +- [Which file for which background](#which-file-for-which-background) +- [Sizing and clear space](#sizing-and-clear-space) +- [Embedding on the web](#embedding-on-the-web) +- [Favicons, app icons, social cards](#favicons-app-icons-social-cards) +- [Misuse](#misuse) + +## Two shapes + +**Primary** — the interlocking mark followed by the `String` logotype, set as one locked-up +group. `viewBox="0 0 371 135"`, aspect ratio 2.75:1. This is the default; use it anywhere +the brand needs to be named. + +**Mark** — the standalone glyph: two interlocking hooks forming an S/knot, drawn as a single +monoline stroke with rounded caps. `viewBox="0 0 264 264"`, square. Use it when the name is +already present or when the space is square and small — favicons, avatars, app icons, +loading states, watermarks, a repeated pattern. + +The logotype is drawn as outlines, not set in a font, so there is no "type the logo" option. +When you need the word String in running text, set it in Space Grotesk and do not attempt to +mimic the logotype's custom `S`. + +## File inventory + +``` +1. Vector Masters/ + Ai.ai Adobe Illustrator master — hand off to designers + Eps.eps EPS master (5.7 MB) — legacy print workflows + Pdf.pdf PDF master — print, embedding in documents + Svg.svg Full guide board, 1280×1024 — not a logo file, it's a layout + +2. Jpegs/ + 1.jpg … 12.jpg The rendered guide boards, 1280×1024 each. + Odd = mark, even = logotype. See the pairing table below. + +3. Png Transparent Files/ + mark_dark.png mark_green.png mark_white.png 264×264 RGBA + primary_dark.png primary_green.png primary_white.png 372×136 RGBA + +4. Svg Separate Files/ + mark_dark.svg mark_green.svg mark_white.svg viewBox 0 0 264 264 + primary_dark.svg primary_green.svg primary_white.svg viewBox 0 0 371 135 +``` + +The separate SVGs are the ones to use for essentially all screen work. They are small +(≈2–5 KB), have no root `width`/`height` so they scale to whatever box you give them, and +carry their fill as a `.cls-1` class inside a ` +``` + +Setting `fill: currentColor` lets one copy of the markup serve light and dark themes, which +is usually cleaner than shipping two files and toggling them. + +**``** when the logo is static and you'd rather keep the markup small: + +```html +String +``` + +Always give `width` and `height` (or an aspect-ratio box) to avoid layout shift, and keep +them in the file's ratio — 371:135 for primary, 1:1 for mark. + +**Alt text**: `alt="String"` when the logo is the site's name or a link home. `alt=""` when +the wordmark sits next to the word "String" in text, so screen readers don't say it twice. + +**Theme switching**: prefer one inline SVG with `currentColor` over two `` tags. If you +do ship both, `` with `media="(prefers-color-scheme: dark)"` handles it without JS. + +## Favicons, app icons, social cards + +Use the **mark**, not the primary — the logotype is illegible below about 100px. + +| Output | Source | Notes | +|---|---|---| +| `favicon.svg` | `mark_dark.svg` | Modern browsers; add a `prefers-color-scheme` media query inside the SVG to swap to `#75F8CC` on dark | +| `favicon.ico` | `mark_dark.png` | 32×32 and 16×16; check the strokes still read at 16 | +| `apple-touch-icon.png` | `mark_dark.svg` | 180×180 on a solid `#FFFFFF` background — iOS doesn't honor transparency | +| Android / maskable | `mark_green.svg` | 512×512 on `#33373B`, with the mark at ~66% to survive the safe-zone crop | +| Social card (OG) | `primary_green.svg` | 1200×630 on `#33373B`, logo about 1/3 the width, centered | +| Avatar | `mark_green.svg` | On `#33373B`, mark at ~60% of the frame | + +The mark on `#33373B` is the strongest small-size treatment — the dark field gives the +monoline strokes something to separate from, which a white field does not. + +## Misuse + +Each of these breaks something specific rather than just being against the rules: + +- **Recoloring** outside the three colorways — the three exist because they're the ones that + hold contrast against the sanctioned backgrounds. A custom color is untested against all of + them. +- **Stretching or squashing** — the monoline is a constant stroke weight; non-uniform scaling + makes it variable, which reads as a rendering bug. +- **Rotating** — the mark's asymmetry is directional; rotated, it stops reading as an S. +- **Effects** — drop shadows, glows, gradients, bevels, outlines. The mark is defined by flat + fill and negative space; anything added competes with the counters. +- **Reconstructing the lockup** — moving the mark relative to the logotype, changing the gap, + or re-setting `String` in a font. Use `primary_*` as shipped. +- **Placing on a busy background** without a solid backing shape. +- **Boxing it in** — a border or container inside the clear-space margin. +- **Using the mark as a letter** in a word, or the logotype inside a sentence. + +When a request genuinely can't be met within these constraints, say so and propose the +closest sanctioned alternative rather than quietly bending one. diff --git a/.claude/skills/string-brand/references/typography.md b/.claude/skills/string-brand/references/typography.md new file mode 100644 index 0000000..5f9fc51 --- /dev/null +++ b/.claude/skills/string-brand/references/typography.md @@ -0,0 +1,207 @@ +# String typography reference + +## Contents + +- [The two families](#the-two-families) +- [Font files in this repo](#font-files-in-this-repo) +- [Roles](#roles) +- [Type scale](#type-scale) +- [Weights and pairings](#weights-and-pairings) +- [Loading the fonts](#loading-the-fonts) +- [Type on brand colors](#type-on-brand-colors) +- [Non-web output](#non-web-output) + +## The two families + +**Space Grotesk** — a geometric grotesque with squared terminals, tight apertures, and +distinctive alternates. Designed as a proportional companion to Space Mono, so it carries a +slightly technical feel. Five weights, **no italics**. Variable font available upstream. +Open Font License, on Google Fonts. + +**Montserrat** — a geometric sans based on Buenos Aires signage. Wide, round, generous +x-height, very readable at text sizes. Nine weights with matching italics. Open Font +License, on Google Fonts. + +Both are free to use and embed, including commercially, under the OFL. The one restriction +worth remembering: if you modify a font file, the modified version can't be sold on its own +and must keep the OFL — which never comes up in practice for normal use. + +## Font files in this repo + +`5. Fonts/Space Grotesk/` — 5 files, no italics: + +``` +SpaceGrotesk-Light.ttf 300 +SpaceGrotesk-Regular.ttf 400 +SpaceGrotesk-Medium.ttf 500 +SpaceGrotesk-SemiBold.ttf 600 +SpaceGrotesk-Bold.ttf 700 +``` + +`5. Fonts/Montserrat/` — 18 files, 9 weights × roman/italic: + +``` +Thin 100 ExtraLight 200 Light 300 Regular 400 Medium 500 +SemiBold 600 Bold 700 ExtraBold 800 Black 900 +…each with a matching -Italic +``` + +Space Grotesk having no italic is a real constraint, not an oversight in the bundle — the +family genuinely ships without one. Never let a renderer synthesize an oblique from it; the +sheared result fights the squared terminals badly. For emphasis in display text, change +weight or color instead, or set the emphasized run in Montserrat Italic. + +## Roles + +**Space Grotesk for display**: headings (h1–h3), the logotype's typographic neighbors, +large numerals and stats, buttons, nav items, labels, tables of figures, code-adjacent UI. + +**Montserrat for body**: paragraphs, long-form articles, documentation, captions, form +fields, anything a person reads more than a sentence of. + +This split is a convention this skill establishes — the source brand guide names both +families but assigns no roles. The reasoning: the String logotype is geometric and monoline +with squared, tightly-spaced forms, and Space Grotesk echoes that, so a heading set in it +feels continuous with the mark. Montserrat is wider and rounder with a taller x-height, +which makes it the more comfortable reader at 16px over many lines. Following the reverse +split would work too, just less well — so if a project has already committed to it, stay +consistent with the project. + +A single-family fallback, when only one font can be loaded: use **Montserrat throughout** +and get display feel from weight (700–900) and tight tracking. Montserrat covers both jobs +adequately; Space Grotesk alone at body sizes is tiring. + +## Type scale + +A 1.25 (major third) scale off a 16px base. Space Grotesk runs slightly small on the body, +so display sizes are set generously. + +| Role | Family | Size | Weight | Line height | Tracking | +|---|---|---|---|---|---| +| Display | Space Grotesk | 61px / 3.8rem | 700 | 1.05 | −0.02em | +| H1 | Space Grotesk | 49px / 3.05rem | 700 | 1.1 | −0.02em | +| H2 | Space Grotesk | 39px / 2.44rem | 600 | 1.15 | −0.015em | +| H3 | Space Grotesk | 31px / 1.95rem | 600 | 1.25 | −0.01em | +| H4 | Space Grotesk | 25px / 1.56rem | 500 | 1.3 | 0 | +| Body large | Montserrat | 20px / 1.25rem | 400 | 1.6 | 0 | +| Body | Montserrat | 16px / 1rem | 400 | 1.65 | 0 | +| Small | Montserrat | 14px / 0.875rem | 400 | 1.5 | 0 | +| Caption | Montserrat | 12px / 0.75rem | 500 | 1.4 | 0.01em | +| Overline | Space Grotesk | 12px / 0.75rem | 600 | 1.2 | 0.12em, uppercase | +| Button | Space Grotesk | 16px / 1rem | 500 | 1 | 0.01em | + +Negative tracking on the large sizes matters more than usual here. Both families are +geometric, and geometric letterforms at display size open up visibly; tightening pulls them +back toward the density of the logotype. + +Measure: 60–75 characters for Montserrat body. Wider than that and the generous x-height +that makes it readable starts working against you on the line return. + +## Weights and pairings + +Use the extremes sparingly. Montserrat Thin/ExtraLight (100/200) are display-only and +disappear below 32px; Black (900) is heavy enough to look like a different brand at body +sizes. The working range is 400–700 for Montserrat and 400–700 for Space Grotesk. + +Reliable combinations: + +- **Space Grotesk 700 + Montserrat 400** — the default. Editorial, clear hierarchy. +- **Space Grotesk 500 + Montserrat 400** — quieter, good for dense product UI. +- **Montserrat 700 + Montserrat 400** — the single-family fallback. +- **Space Grotesk 600 uppercase, 0.12em tracking** — eyebrows, labels, nav. + +Avoid mixing weights closer than 200 apart in the same hierarchy (600 next to 700 reads as a +rendering inconsistency, not a distinction), and avoid Montserrat headings directly above +Space Grotesk body — the roles inverted looks like a mistake rather than a choice. + +## Loading the fonts + +### Google Fonts (preferred for web) + +Both families are on Google Fonts, so linking or self-hosting from there gives you WOFF2, +subsetting, and the variable versions — all better than the bundled TTFs. + +```html + + + +``` + +`display=swap` matters: both families are geometric with unusual proportions, so a blocking +load produces a long invisible-text flash on slow connections. + +### Self-hosting the bundled TTFs + +Use this when the project can't reach Google Fonts. Convert to WOFF2 first — TTF is roughly +4× the size over the wire: + +```bash +# pip install fonttools brotli +fonttools ttLib.woff2 compress "5. Fonts/Space Grotesk/SpaceGrotesk-Bold.ttf" +``` + +```css +@font-face { + font-family: "Space Grotesk"; + src: url("/fonts/SpaceGrotesk-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Montserrat"; + src: url("/fonts/Montserrat-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +``` + +Declare one `@font-face` per weight with the correct `font-weight`, rather than one block +with `font-weight: 100 900`, unless you're using the variable versions. Ship only the weights +the design actually uses — five weights of Montserrat plus three of Space Grotesk is already +a meaningful chunk of a page budget. + +### Stacks + +```css +--font-display: "Space Grotesk", "Montserrat", ui-sans-serif, system-ui, sans-serif; +--font-body: "Montserrat", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +``` + +Space Grotesk falls back to Montserrat before the system font, so a partial load degrades +within the brand rather than jumping to Helvetica. + +## Type on brand colors + +Text color follows from the background, and the palette's three light values make this +easy to get wrong. See `colors.md` for the full matrix; the short version: + +| Background | Body text | Heading | Accent text | +|---|---|---|---| +| `#FFFFFF` | `#33373B` | `#33373B` | `#4B7C6F` (green-800) | +| `#33373B` | `#FFFFFF` | `#FFFFFF` or `#75F8CC` | `#75F8CC` | +| `#C0F4FB` | `#33373B` | `#33373B` | `#33373B` | +| `#75F8CC` | `#33373B` | `#33373B` | `#33373B` | + +`#75F8CC` and `#C0F4FB` are never text colors on white — 1.31:1 and 1.19:1. When a design +calls for "green text" on a light background, use `#4B7C6F`. + +At weights below 400, bump one contrast step: light-weight text at the AA minimum reads worse +than the ratio suggests because the strokes are thinner than what the formula assumes. + +## Non-web output + +**Slides, PDFs, documents** — install both families locally from the `5. Fonts/` TTFs. +Embed fonts when exporting a PDF; both licenses permit it. + +**Email** — neither family is web-safe and most clients block webfonts. Use +`font-family: Montserrat, 'Helvetica Neue', Helvetica, Arial, sans-serif` and design for the +fallback, since that's what most recipients will see. + +**Images and social cards** — render text into the image rather than relying on the viewer +having the fonts. + +**Terminal, code blocks, monospace** — the guide specifies no mono. Space Mono is the natural +choice, since Space Grotesk was drawn as its proportional companion and they share +proportions and terminals. Flag it as outside the guide when you use it. diff --git a/.claude/skills/string-brand/scripts/contrast.py b/.claude/skills/string-brand/scripts/contrast.py new file mode 100755 index 0000000..9e9aad2 --- /dev/null +++ b/.claude/skills/string-brand/scripts/contrast.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""WCAG contrast checker for the String palette. + +Three of String's four core colors are light (#75F8CC, #C0F4FB, #FFFFFF), which makes it +unusually easy to produce a pairing that looks fine on a bright laptop and is unreadable +elsewhere. Run this before shipping any combination that isn't already in +references/colors.md. + + contrast.py '#33373B' '#C0F4FB' ratio + pass/fail for one pair + contrast.py --matrix the full core-palette matrix + contrast.py --on '#33373B' every named token against one background + contrast.py --find '#75F8CC' --on '#FFFFFF' + nearest on-brand substitute that passes AA + +No dependencies beyond the standard library. +""" + +import argparse +import sys + +# Core palette, read from the official logo vectors. +CORE = { + "green": "#75F8CC", + "dark": "#33373B", + "sky": "#C0F4FB", + "white": "#FFFFFF", +} + +# Derived ramps — see references/colors.md. +TOKENS = { + "green-50": "#EAFEF7", "green-100": "#CFFDED", "green-200": "#A5FADE", + "green-300": "#8AF9D4", "green-500": "#75F8CC", "green-600": "#69D5B2", + "green-700": "#5DB398", "green-800": "#4B7C6F", "green-900": "#44675F", + "ink-50": "#F7F7F7", "ink-100": "#EBEBEB", "ink-200": "#D2D3D4", + "ink-300": "#B1B3B5", "ink-400": "#8D8F91", "ink-500": "#64676A", + "ink-600": "#33373B", "ink-700": "#26292C", "ink-800": "#1A1C1E", + "ink-900": "#0E0F11", + "sky-50": "#E3FAFD", "sky-100": "#D3F7FC", "sky-300": "#C0F4FB", + "sky-400": "#A1CAD1", "sky-500": "#819FA5", "sky-800": "#556469", + "white": "#FFFFFF", +} + + +def parse_hex(value): + """Accept '#abc', 'abc', '#aabbcc', 'aabbcc', or a token/core name.""" + key = value.strip().lower() + if key in TOKENS: + value = TOKENS[key] + elif key in CORE: + value = CORE[key] + h = value.strip().lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + if len(h) != 6: + raise ValueError(f"not a color: {value!r}") + try: + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) + except ValueError: + raise ValueError(f"not a color: {value!r}") from None + + +def to_hex(rgb): + return "#%02X%02X%02X" % rgb + + +def luminance(rgb): + """WCAG 2.1 relative luminance.""" + def channel(c): + c = c / 255 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + r, g, b = (channel(c) for c in rgb) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def ratio(fg, bg): + a, b = luminance(fg), luminance(bg) + hi, lo = max(a, b), min(a, b) + return (hi + 0.05) / (lo + 0.05) + + +def grade(r): + """Which WCAG thresholds this ratio clears.""" + return { + "AAA body (7:1)": r >= 7, + "AA body (4.5:1)": r >= 4.5, + "AA large / UI (3:1)": r >= 3, + } + + +def cmd_pair(fg_arg, bg_arg): + fg, bg = parse_hex(fg_arg), parse_hex(bg_arg) + r = ratio(fg, bg) + print(f"\n {to_hex(fg)} on {to_hex(bg)} ratio {r:.2f}:1\n") + for label, ok in grade(r).items(): + print(f" {'PASS' if ok else 'FAIL'} {label}") + print() + if r < 4.5: + print(" Below AA for body text. See references/colors.md for on-brand") + print(" alternatives, or run with --find to search the token set:") + print(f" contrast.py --find '{to_hex(fg)}' --on '{to_hex(bg)}'\n") + return 0 if r >= 4.5 else 1 + + +def cmd_matrix(): + names = list(CORE) + width = max(len(n) for n in names) + 2 + print("\n Core palette contrast matrix (* = 4.5:1 or better, passes AA body)\n") + print(" " * (width + 2) + "".join(f"{n:>10}" for n in names)) + for a in names: + row = f" {a:<{width}}" + for b in names: + if a == b: + row += f"{'—':>10}" + else: + r = ratio(parse_hex(CORE[a]), parse_hex(CORE[b])) + mark = "*" if r >= 4.5 else " " + row += f"{r:>9.2f}{mark}" + print(row) + print("\n " + " ".join(f"{n}={CORE[n]}" for n in names)) + print("\n Dark is the only core color that pairs with anything — the other") + print(" three are all light, so they never pair with each other for text.\n") + return 0 + + +def cmd_on(bg_arg): + bg = parse_hex(bg_arg) + print(f"\n Every token against {to_hex(bg)}\n") + rows = sorted( + ((n, h, ratio(parse_hex(h), bg)) for n, h in TOKENS.items()), + key=lambda t: -t[2], + ) + for name, hexv, r in rows: + if r >= 7: + tag = "AAA" + elif r >= 4.5: + tag = "AA " + elif r >= 3: + tag = "AA-large" + else: + tag = "-" + print(f" {name:<11} {hexv} {r:6.2f}:1 {tag}") + print() + return 0 + + +def cmd_find(fg_arg, bg_arg, threshold): + """Nearest token by hue family that clears the threshold against bg.""" + fg, bg = parse_hex(fg_arg), parse_hex(bg_arg) + current = ratio(fg, bg) + print(f"\n {to_hex(fg)} on {to_hex(bg)} is {current:.2f}:1", end="") + print(" — already passes.\n" if current >= threshold else f" — below {threshold}:1.\n") + if current >= threshold: + return 0 + + def distance(a, b): + return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5 + + options = [] + for name, hexv in TOKENS.items(): + rgb = parse_hex(hexv) + r = ratio(rgb, bg) + if r >= threshold: + options.append((distance(rgb, fg), name, hexv, r)) + if not options: + print(" No token in the palette clears that threshold against this background.") + print(" Change the background instead — see the semantic recipes in colors.md.\n") + return 1 + options.sort() + print(" Closest on-brand substitutes:\n") + for _, name, hexv, r in options[:5]: + print(f" {name:<11} {hexv} {r:6.2f}:1") + print() + return 0 + + +def main(argv=None): + p = argparse.ArgumentParser( + description="WCAG contrast checker for the String brand palette.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="Colors may be hex ('#75F8CC', '75f8cc', '#abc') or token names " + "('green-800', 'ink-500', 'dark').", + ) + p.add_argument("colors", nargs="*", metavar="COLOR", + help="foreground and background, e.g. '#33373B' '#FFFFFF'") + p.add_argument("--matrix", action="store_true", + help="print the core palette contrast matrix") + p.add_argument("--on", metavar="BG", + help="rank every token against this background") + p.add_argument("--find", metavar="FG", + help="find on-brand substitutes for FG that pass against --on") + p.add_argument("--threshold", type=float, default=4.5, + help="target ratio for --find (default 4.5, AA body text)") + args = p.parse_args(argv) + + try: + if args.matrix: + return cmd_matrix() + if args.find: + if not args.on: + p.error("--find requires --on BACKGROUND") + return cmd_find(args.find, args.on, args.threshold) + if args.on and not args.colors: + return cmd_on(args.on) + if len(args.colors) == 2: + return cmd_pair(args.colors[0], args.colors[1]) + p.print_help() + return 2 + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + except BrokenPipeError: + # Piping into `head` closes stdout early; that's not an error. + try: + sys.stdout.close() + finally: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/README.md b/README.md index 126d888..0e8f185 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,55 @@ What is included: ## 5 Fonts Montserrat Space Grotesk + +## 6 Agent Skill + +`.claude/skills/string-brand/` packages this guide as a [Claude Code skill](https://code.claude.com/docs/en/skills), +so coding agents apply the brand correctly without being told the hex codes every time. +It covers the palette, the typefaces, and which logo file goes on which background. + +**Quick reference** + +| | | +|---|---| +| String Green | `#75F8CC` | +| String Dark | `#33373B` | +| String Sky | `#C0F4FB` | +| Display type | Space Grotesk | +| Body type | Montserrat | + +Note that `#75F8CC` and `#C0F4FB` are fill colors, not text colors — both sit near 1.2:1 +against white. Body text is `#33373B` on light and `#FFFFFF` or `#75F8CC` on dark. + +**Using it** + +Working inside this repo, Claude Code picks the skill up automatically. To use it elsewhere: + +```bash +# for one project +cp -r path/to/string-brand-guide/.claude/skills/string-brand your-project/.claude/skills/ + +# or for every project on your machine +cp -r path/to/string-brand-guide/.claude/skills/string-brand ~/.claude/skills/ +``` + +**What's inside** + +``` +SKILL.md palette, type, logo pairings — the everyday reference +references/colors.md ramps, contrast matrix, themes, chart palettes +references/logo.md file inventory, sizing, favicons, misuse +references/typography.md type scale, weights, @font-face, loading +assets/tokens.css CSS custom properties, light + dark themes +assets/tokens.json the same values as data, for Tailwind/token pipelines +scripts/contrast.py WCAG checker for any pair, plus on-brand substitutes +``` + +```bash +python3 .claude/skills/string-brand/scripts/contrast.py --matrix +python3 .claude/skills/string-brand/scripts/contrast.py --find '#75F8CC' --on '#FFFFFF' +``` + +The palette values, logo pairings, and font list come straight from the assets in this repo. +The tint/shade ramps, type scale, and the Space Grotesk / Montserrat display-body split are +conventions the skill adds on top, and it flags them as such where they appear. From 7770246fe54689ece316dcbc64f1c71570395559 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:11:23 +0000 Subject: [PATCH 2/3] Fix YAML truncation in skill description and three doc inconsistencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's review on #1. The frontmatter issue was real and severe: in a YAML plain scalar a " #" begins a comment, so the unquoted description truncated at the first hex value. yaml.safe_load returned 67 of 914 characters — everything after "the official color palette (#75F8CC mint," was discarded, including the entire "when to use this" half that drives triggering. The truncation was also directly visible in this session's loaded-skills listing. Switching to a folded block scalar (>-) keeps all 914 characters; the eval harness's own runner uses a block scalar for the same reason. Also from the review: - tokens.css collapsed h1-h4 to one line-height and one tracking value, which contradicted the per-level scale in typography.md and tokens.json. Split into per-level custom properties and wired the base styles to them, so copying tokens.css now reproduces the documented scale. - SKILL.md listed Vector Masters/Svg.svg alongside the logo masters. It's the 1280x1024 guide board, not a logo asset — an agent picking it for a print handoff would get a page layout. Broken out with a note; logo.md already described it correctly, so the two files now agree. - tokens.json was missing the button role present in typography.md's scale. Verified by parsing the frontmatter, parsing tokens.json, and cross-checking that the type scale in tokens.css, tokens.json, and typography.md agree. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGLW9iKmNJHtDiHBBLRGbq --- .claude/skills/string-brand/SKILL.md | 25 +++++++++-- .claude/skills/string-brand/assets/tokens.css | 42 +++++++++++++++++-- .../skills/string-brand/assets/tokens.json | 3 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/.claude/skills/string-brand/SKILL.md b/.claude/skills/string-brand/SKILL.md index 446b9a7..f78f792 100644 --- a/.claude/skills/string-brand/SKILL.md +++ b/.claude/skills/string-brand/SKILL.md @@ -1,6 +1,19 @@ --- name: string-brand -description: The String brand system — the official color palette (#75F8CC mint, #33373B dark, #C0F4FB sky), the Montserrat + Space Grotesk typefaces, and the logo/mark asset files with their approved background pairings. Use this skill whenever you are producing anything that carries String's identity or picking colors, fonts, or a logo file for it: websites, landing pages, web apps, UI components, CSS/Tailwind themes, design tokens, slide decks, PDFs, social images, README banners, favicons, email templates, diagrams, or charts. Trigger it even when the request never says "brand" or "style guide" — "build a landing page for String", "make the dashboard match our colors", "what's our green?", "add the logo to the header", and "set up the fonts" all need it. Consult it before inventing a hex value, choosing a font stack, or referencing a logo path, so the output matches the real guide instead of a plausible guess. +# Folded block scalar, not a plain scalar. A bare " #" in YAML starts a comment, so an +# unquoted description containing hex values silently truncates at the first one. +description: >- + The String brand system — the official color palette (#75F8CC mint, #33373B dark, + #C0F4FB sky), the Montserrat + Space Grotesk typefaces, and the logo/mark asset files + with their approved background pairings. Use this skill whenever you are producing + anything that carries String's identity or picking colors, fonts, or a logo file for it: + websites, landing pages, web apps, UI components, CSS/Tailwind themes, design tokens, + slide decks, PDFs, social images, README banners, favicons, email templates, diagrams, + or charts. Trigger it even when the request never says "brand" or "style guide" — + "build a landing page for String", "make the dashboard match our colors", "what's our + green?", "add the logo to the header", and "set up the fonts" all need it. Consult it + before inventing a hex value, choosing a font stack, or referencing a logo path, so the + output matches the real guide instead of a plausible guess. --- # String Brand System @@ -39,12 +52,18 @@ Assets live at the repo root. Two shapes, three colorways, four formats: tight or square: favicons, avatars, app icons, watermarks. ``` -4. Svg Separate Files/{primary,mark}_{green,dark,white}.svg ← use these for web/UI +4. Svg Separate Files/{primary,mark}_{green,dark,white}.svg ← use these for web/UI 3. Png Transparent Files/{primary,mark}_{green,dark,white}.png ← raster, transparent -1. Vector Masters/{Ai.ai,Eps.eps,Pdf.pdf,Svg.svg} ← print + handoff to designers +1. Vector Masters/{Ai.ai,Eps.eps,Pdf.pdf} ← print + handoff to designers +1. Vector Masters/Svg.svg ← NOT a logo, see below 2. Jpegs/1..12.jpg ← the rendered guide boards ``` +`1. Vector Masters/Svg.svg` is a 1280×1024 guide board — a page layout containing a logo, not +an asset you can drop into a design. Reaching for it because the name looks right is the easy +mistake here. For print or a designer handoff use `Ai.ai`, `Eps.eps`, or `Pdf.pdf`; for +anything on screen use the separate SVGs. + Pick the colorway from the background, not from taste. The guide sanctions exactly these six pairings (boards 1–12 are these six, each shown as mark then logotype): diff --git a/.claude/skills/string-brand/assets/tokens.css b/.claude/skills/string-brand/assets/tokens.css index 7f123a0..2456939 100644 --- a/.claude/skills/string-brand/assets/tokens.css +++ b/.claude/skills/string-brand/assets/tokens.css @@ -60,10 +60,20 @@ --string-text-sm: 0.875rem; /* 14px */ --string-text-xs: 0.75rem; /* 12px */ + /* Line height tightens as type grows — these mirror the scale in + * references/typography.md and tokens.json exactly. */ --string-leading-display: 1.05; - --string-leading-heading: 1.2; + --string-leading-h1: 1.1; + --string-leading-h2: 1.15; + --string-leading-h3: 1.25; + --string-leading-h4: 1.3; --string-leading-body: 1.65; + --string-tracking-display: -0.02em; + --string-tracking-h1: -0.02em; + --string-tracking-h2: -0.015em; + --string-tracking-h3: -0.01em; + --string-tracking-h4: 0; --string-tracking-overline: 0.12em; /* ---- Semantic: light theme (default) ---- */ @@ -133,6 +143,32 @@ body { h1, h2, h3, h4 { font-family: var(--string-font-display); - line-height: var(--string-leading-heading); - letter-spacing: var(--string-tracking-display); +} + +h1 { + font-size: var(--string-text-h1); + font-weight: 700; + line-height: var(--string-leading-h1); + letter-spacing: var(--string-tracking-h1); +} + +h2 { + font-size: var(--string-text-h2); + font-weight: 600; + line-height: var(--string-leading-h2); + letter-spacing: var(--string-tracking-h2); +} + +h3 { + font-size: var(--string-text-h3); + font-weight: 600; + line-height: var(--string-leading-h3); + letter-spacing: var(--string-tracking-h3); +} + +h4 { + font-size: var(--string-text-h4); + font-weight: 500; + line-height: var(--string-leading-h4); + letter-spacing: var(--string-tracking-h4); } diff --git a/.claude/skills/string-brand/assets/tokens.json b/.claude/skills/string-brand/assets/tokens.json index c3f275a..53b4555 100644 --- a/.claude/skills/string-brand/assets/tokens.json +++ b/.claude/skills/string-brand/assets/tokens.json @@ -102,7 +102,8 @@ "body": { "family": "body", "px": 16, "rem": "1rem", "weight": 400, "lineHeight": 1.65, "tracking": "0" }, "small": { "family": "body", "px": 14, "rem": "0.875rem", "weight": 400, "lineHeight": 1.5, "tracking": "0" }, "caption": { "family": "body", "px": 12, "rem": "0.75rem", "weight": 500, "lineHeight": 1.4, "tracking": "0.01em" }, - "overline": { "family": "display", "px": 12, "rem": "0.75rem", "weight": 600, "lineHeight": 1.2, "tracking": "0.12em", "textTransform": "uppercase" } + "overline": { "family": "display", "px": 12, "rem": "0.75rem", "weight": 600, "lineHeight": 1.2, "tracking": "0.12em", "textTransform": "uppercase" }, + "button": { "family": "display", "px": 16, "rem": "1rem", "weight": 500, "lineHeight": 1, "tracking": "0.01em" } } }, "logo": { From 5a74661e1fc3a04de99469330bda2fffa848099c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:35:57 +0000 Subject: [PATCH 3/3] Fix status colors that failed AA, and add --audit to catch that class of error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output evals caught a real defect in this skill. Six subagent runs (landing page, dashboard theme, logo placement — each with the skill and with repo access but no skill) were graded, then every rendered text node was measured for computed contrast in a real browser. The dashboard built *with* the skill had more contrast failures than the one without, because it faithfully used the status colors this skill prescribes. All four were too light for the tint backgrounds they were paired with: error #D64545 on #FDECEC 3.83:1 warning #C77A16 on #FDF3E3 3.07:1 success #2E9E6B on #E8F6EF 3.03:1 info #3A7CA5 on #E9F2F8 4.02:1 Three of the four also failed on plain white. Replaced with darkened values that clear 4.5:1 against their own tint, against #FFFFFF, and against ink-50 #F7F7F7. Added dark-theme variants — previously missing, which is why the dashboard agent invented its own. Also documented that the ramps' "On white" column means #FFFFFF exactly: the margin is thin, and green-800 drops from 4.76 on white to 4.44 on ink-50 and 3.99 on ink-100, so green link text on a card quietly falls below AA. Callers are pointed at green-900 for non-white light surfaces. The durable fix is contrast.py --audit, which re-derives every foreground/ background pairing the skill documents — semantic themes, status colors, and the stated logo pairing ratios — and exits non-zero on drift. Verified it catches the regression by re-injecting the original #2E9E6B (3.03:1), a green-on-white accent, and a falsified logo ratio. These ratios are hand-written prose; without a check they will drift again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BGLW9iKmNJHtDiHBBLRGbq --- .claude/skills/string-brand/SKILL.md | 6 ++ .../skills/string-brand/assets/tokens.json | 10 +-- .../skills/string-brand/references/colors.md | 35 ++++++++-- .../skills/string-brand/scripts/contrast.py | 67 +++++++++++++++++++ README.md | 1 + 5 files changed, 109 insertions(+), 10 deletions(-) diff --git a/.claude/skills/string-brand/SKILL.md b/.claude/skills/string-brand/SKILL.md index f78f792..7e97b46 100644 --- a/.claude/skills/string-brand/SKILL.md +++ b/.claude/skills/string-brand/SKILL.md @@ -140,8 +140,14 @@ And one tool: ```bash python3 .claude/skills/string-brand/scripts/contrast.py '#33373B' '#C0F4FB' python3 .claude/skills/string-brand/scripts/contrast.py --matrix # whole palette + python3 .claude/skills/string-brand/scripts/contrast.py --audit # re-verify these docs ``` + `--audit` re-checks every foreground/background pairing this skill documents and exits + non-zero if any has drifted. Run it after editing a color anywhere in the skill — the + ratios in these files are hand-written, and an earlier version shipped status colors that + sat at 3.0:1 on their own tint backgrounds. + ## Working on-brand A few habits that keep output consistent, in rough order of how often they matter: diff --git a/.claude/skills/string-brand/assets/tokens.json b/.claude/skills/string-brand/assets/tokens.json index 53b4555..57a1f05 100644 --- a/.claude/skills/string-brand/assets/tokens.json +++ b/.claude/skills/string-brand/assets/tokens.json @@ -41,11 +41,11 @@ }, "chart": ["#33373B", "#75F8CC", "#819FA5", "#4B7C6F", "#C0F4FB", "#B1B3B5"], "status": { - "$comment": "Outside the brand guide — the guide defines no status colors. Do not use brand green for success; it reads as 'String', not 'succeeded'.", - "error": { "fg": "#D64545", "bg": "#FDECEC" }, - "warning": { "fg": "#C77A16", "bg": "#FDF3E3" }, - "success": { "fg": "#2E9E6B", "bg": "#E8F6EF" }, - "info": { "fg": "#3A7CA5", "bg": "#E9F2F8" } + "$comment": "Outside the brand guide — the guide defines no status colors. Do not use brand green for success; it reads as 'String', not 'succeeded'. Light fg clears 4.5:1 on its own tint, on #FFFFFF and on #F7F7F7; dark fg clears 4.5:1 on #33373B and #26292C.", + "error": { "fg": "#C13E3E", "bg": "#FDECEC", "fgOnDark": "#D98989" }, + "warning": { "fg": "#9F6212", "bg": "#FDF3E3", "fgOnDark": "#C19965" }, + "success": { "fg": "#247D55", "bg": "#E8F6EF", "fgOnDark": "#73AC92" }, + "info": { "fg": "#367399", "bg": "#E9F2F8", "fgOnDark": "#7CA4BD" } } }, "semantic": { diff --git a/.claude/skills/string-brand/references/colors.md b/.claude/skills/string-brand/references/colors.md index 2e8ceb8..362cdd1 100644 --- a/.claude/skills/string-brand/references/colors.md +++ b/.claude/skills/string-brand/references/colors.md @@ -69,6 +69,13 @@ step stays in the brand's hue family rather than drifting toward a generic teal. label on a white page. `green-500` in that position is unreadable; `green-800` keeps the hue relationship and passes AA. +One caveat on reading the "On white" column: it means `#FFFFFF` exactly, and the margin is +thin enough that a tinted light surface eats it. `green-800` measures 4.76 on white but 4.44 +on `ink-50 #F7F7F7` and 3.99 on `ink-100 #EBEBEB` — so green link text sitting on a card +rather than the page background quietly drops below AA. On any surface that isn't pure white, +step down to `green-900 #44675F`. The same thinness applies to `ink-500` and `sky-800`; when +the background isn't `#FFFFFF`, check the real pair with `scripts/contrast.py`. + ## Neutral / ink ramp Built from `#33373B`, so the whole scale carries the same cool cast. Note the base sits at @@ -195,12 +202,30 @@ every brand accent implies success. Use a separate, clearly different green (or checkmark plus neutral text). Pick status colors tuned to sit next to `#33373B` — cool and slightly desaturated rather than -pure hues. Reasonable defaults, contrast-checked against both white and `#33373B`: +pure hues. These defaults are darker than the usual web palette on purpose: a status color is +almost always set on its own pale tint, and the familiar mid-tone versions +(`#D64545`, `#2E9E6B`, and friends) land around 3:1 there, which fails AA for the small text +status messages are usually set in. -``` -error #D64545 (light bg #FDECEC) warning #C77A16 (light bg #FDF3E3) -success #2E9E6B (light bg #E8F6EF) info #3A7CA5 (light bg #E9F2F8) -``` +Every foreground below clears 4.5:1 against its own tint, against `#FFFFFF`, and against +`ink-50 #F7F7F7`, so it holds up on any light surface in this system: + +| | Foreground | Tint background | On tint | On white | +|---|---|---|---|---| +| Error | `#C13E3E` | `#FDECEC` | 4.57 | 5.22 | +| Warning | `#9F6212` | `#FDF3E3` | 4.52 | 4.96 | +| Success | `#247D55` | `#E8F6EF` | 4.56 | 5.07 | +| Info | `#367399` | `#E9F2F8` | 4.55 | 5.16 | + +On dark surfaces those foregrounds are far too dark (all around 2.3:1 on `#33373B`), so flip +to the light variants. Each clears 4.5:1 on both the `ink-600` page and the `ink-700` card: + +| | Foreground | On `#33373B` | On `#26292C` | +|---|---|---|---| +| Error | `#D98989` | 4.51 | 5.49 | +| Warning | `#C19965` | 4.58 | 5.58 | +| Success | `#73AC92` | 4.60 | 5.60 | +| Info | `#7CA4BD` | 4.51 | 5.50 | Flag in your response that these are outside the brand guide so the choice can be reviewed. diff --git a/.claude/skills/string-brand/scripts/contrast.py b/.claude/skills/string-brand/scripts/contrast.py index 9e9aad2..555d31f 100755 --- a/.claude/skills/string-brand/scripts/contrast.py +++ b/.claude/skills/string-brand/scripts/contrast.py @@ -17,6 +17,7 @@ import argparse import sys +from pathlib import Path # Core palette, read from the official logo vectors. CORE = { @@ -173,6 +174,68 @@ def distance(a, b): return 0 +def cmd_audit(): + """Re-verify every fg/bg pairing this skill documents. + + The docs are hand-written and the ratios in them are easy to get wrong or to + let drift — an earlier version shipped status colors that sat at 3.0:1 on + their own tint backgrounds. Run this after editing any color. + """ + import json + here = Path(__file__).resolve().parent.parent + tokens = json.loads((here / "assets" / "tokens.json").read_text()) + failures = [] + print("\n Auditing documented pairings\n") + + def check(label, fg, bg, need): + r = ratio(parse_hex(fg), parse_hex(bg)) + ok = r >= need + if not ok: + failures.append(f"{label}: {fg} on {bg} = {r:.2f} (needs {need})") + print(f" {'PASS' if ok else 'FAIL'} {label:<34} {r:5.2f}:1 (needs {need})") + + sem = tokens["semantic"] + for theme in ("light", "dark"): + s = sem[theme] + check(f"{theme}: body text on page", s["text"], s["bg"], 4.5) + check(f"{theme}: body text on surface", s["text"], s["bgSurface"], 4.5) + check(f"{theme}: muted text on page", s["textMuted"], s["bg"], 4.5) + check(f"{theme}: accent text on page", s["textAccent"], s["bg"], 4.5) + check(f"{theme}: button label on button", s["buttonFg"], s["buttonBg"], 4.5) + + for name, v in tokens["color"]["status"].items(): + if name.startswith("$"): + continue + check(f"status {name} on tint", v["fg"], v["bg"], 4.5) + check(f"status {name} on white", v["fg"], "#FFFFFF", 4.5) + check(f"status {name} on ink-50", v["fg"], "#F7F7F7", 4.5) + if "fgOnDark" in v: + check(f"status {name} on ink-600", v["fgOnDark"], "#33373B", 4.5) + check(f"status {name} on ink-700", v["fgOnDark"], "#26292C", 4.5) + + for pair in tokens["logo"]["approvedPairings"]: + stated = pair["contrast"] + r = ratio(parse_hex("#" + {"green": "75F8CC", "dark": "33373B", + "white": "FFFFFF"}[pair["colorway"]]), + parse_hex(pair["background"])) + ok = abs(r - stated) < 0.05 + if not ok: + failures.append(f"logo {pair['colorway']} on {pair['background']}: " + f"stated {stated}, actual {r:.2f}") + print(f" {'PASS' if ok else 'FAIL'} logo {pair['colorway']:<5} on " + f"{pair['background']:<9} stated {stated:>5} actual {r:5.2f}") + + print() + if failures: + print(f" {len(failures)} FAILED:") + for f in failures: + print(f" - {f}") + print() + return 1 + print(" All documented pairings verified.\n") + return 0 + + def main(argv=None): p = argparse.ArgumentParser( description="WCAG contrast checker for the String brand palette.", @@ -184,6 +247,8 @@ def main(argv=None): help="foreground and background, e.g. '#33373B' '#FFFFFF'") p.add_argument("--matrix", action="store_true", help="print the core palette contrast matrix") + p.add_argument("--audit", action="store_true", + help="re-verify every fg/bg pairing documented in this skill") p.add_argument("--on", metavar="BG", help="rank every token against this background") p.add_argument("--find", metavar="FG", @@ -193,6 +258,8 @@ def main(argv=None): args = p.parse_args(argv) try: + if args.audit: + return cmd_audit() if args.matrix: return cmd_matrix() if args.find: diff --git a/README.md b/README.md index 0e8f185..222633b 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ scripts/contrast.py WCAG checker for any pair, plus on-brand substitutes ```bash python3 .claude/skills/string-brand/scripts/contrast.py --matrix python3 .claude/skills/string-brand/scripts/contrast.py --find '#75F8CC' --on '#FFFFFF' +python3 .claude/skills/string-brand/scripts/contrast.py --audit # re-verify the docs ``` The palette values, logo pairings, and font list come straight from the assets in this repo.