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
+
+```
+
+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.