diff --git a/.claude/skills/string-brand/SKILL.md b/.claude/skills/string-brand/SKILL.md new file mode 100644 index 0000000..7e97b46 --- /dev/null +++ b/.claude/skills/string-brand/SKILL.md @@ -0,0 +1,173 @@ +--- +name: string-brand +# 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 + +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} ← 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): + +| 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 + 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: + +**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..2456939 --- /dev/null +++ b/.claude/skills/string-brand/assets/tokens.css @@ -0,0 +1,174 @@ +/* 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 */ + + /* 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-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) ---- */ + --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); +} + +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 new file mode 100644 index 0000000..57a1f05 --- /dev/null +++ b/.claude/skills/string-brand/assets/tokens.json @@ -0,0 +1,130 @@ +{ + "$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'. 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": { + "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" }, + "button": { "family": "display", "px": 16, "rem": "1rem", "weight": 500, "lineHeight": 1, "tracking": "0.01em" } + } + }, + "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..362cdd1 --- /dev/null +++ b/.claude/skills/string-brand/references/colors.md @@ -0,0 +1,241 @@ +# 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. + +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 +**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. 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. + +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. + +## 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..555d31f --- /dev/null +++ b/.claude/skills/string-brand/scripts/contrast.py @@ -0,0 +1,287 @@ +#!/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 +from pathlib import Path + +# 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 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.", + 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("--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", + 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.audit: + return cmd_audit() + 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..222633b 100644 --- a/README.md +++ b/README.md @@ -17,3 +17,56 @@ 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' +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. +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.