@duro-app/cli 5.16.0 → 5.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +12 -5
  2. package/dist/bin.js +3 -2
  3. package/dist/bin.js.map +1 -1
  4. package/dist/commands/hook.d.ts.map +1 -1
  5. package/dist/commands/hook.js +11 -2
  6. package/dist/commands/hook.js.map +1 -1
  7. package/dist/commands/list.d.ts.map +1 -1
  8. package/dist/commands/list.js +23 -2
  9. package/dist/commands/list.js.map +1 -1
  10. package/dist/commands/lookup.d.ts.map +1 -1
  11. package/dist/commands/lookup.js +15 -1
  12. package/dist/commands/lookup.js.map +1 -1
  13. package/dist/commands/mcp.d.ts +12 -0
  14. package/dist/commands/mcp.d.ts.map +1 -1
  15. package/dist/commands/mcp.js +51 -36
  16. package/dist/commands/mcp.js.map +1 -1
  17. package/dist/commands/skill.d.ts +6 -0
  18. package/dist/commands/skill.d.ts.map +1 -1
  19. package/dist/commands/skill.js +168 -24
  20. package/dist/commands/skill.js.map +1 -1
  21. package/dist/format.d.ts.map +1 -1
  22. package/dist/format.js +7 -0
  23. package/dist/format.js.map +1 -1
  24. package/dist/hook-script.d.ts +2 -2
  25. package/dist/hook-script.d.ts.map +1 -1
  26. package/dist/hook-script.js +1 -1
  27. package/dist/manifest.d.ts.map +1 -1
  28. package/dist/manifest.js +10 -4
  29. package/dist/manifest.js.map +1 -1
  30. package/dist/registry-types.d.ts +5 -0
  31. package/dist/registry-types.d.ts.map +1 -1
  32. package/dist/registry.d.ts +20 -0
  33. package/dist/registry.d.ts.map +1 -1
  34. package/dist/registry.js +38 -0
  35. package/dist/registry.js.map +1 -1
  36. package/package.json +6 -5
  37. package/registry.json +35 -0
  38. package/{LICENSE → skills/colors/LICENSE} +1 -1
  39. package/skills/colors/NOTICE.md +36 -0
  40. package/skills/colors/SKILL.md +99 -0
  41. package/skills/colors/color-formats.md +90 -0
  42. package/skills/colors/color-usage.md +109 -0
  43. package/skills/colors/contrast.md +70 -0
  44. package/skills/colors/palette-generation.md +108 -0
  45. package/skills/colors/palette-structure.md +72 -0
  46. package/skills/colors/token-naming.md +99 -0
  47. package/skills/typography/LICENSE +21 -0
  48. package/skills/typography/NOTICE.md +36 -0
  49. package/skills/typography/SKILL.md +169 -0
  50. package/skills/typography/choosing-fonts.md +53 -0
  51. package/skills/typography/css-cheat-sheet.md +67 -0
  52. package/skills/typography/details-and-accessibility.md +84 -0
  53. package/skills/typography/spacing-and-sizing.md +87 -0
  54. package/skills/typography/variable-fonts-and-opentype.md +100 -0
  55. package/skills/typography/wrapping-and-punctuation.md +23 -0
  56. package/skills/ui/LICENSE +21 -0
  57. package/skills/ui/NOTICE.md +37 -0
  58. package/skills/ui/SKILL.md +139 -0
  59. package/skills/ui/animations.md +170 -0
  60. package/skills/ui/enter-exit.md +157 -0
  61. package/skills/ui/icon-transitions.md +72 -0
  62. package/skills/ui/icons.md +105 -0
  63. package/skills/ui/performance.md +63 -0
  64. package/skills/ui/surfaces.md +166 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/cli",
3
- "version": "5.16.0",
3
+ "version": "5.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Machine-queryable docs for the Duro design system: components, props, recipes, tokens and rules as CLI output, JSON, or an MCP server.",
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "files": [
40
40
  "dist",
41
+ "skills",
41
42
  "registry.json"
42
43
  ],
43
44
  "engines": {
@@ -45,7 +46,7 @@
45
46
  },
46
47
  "peerDependencies": {
47
48
  "@modelcontextprotocol/sdk": ">=1.0.0",
48
- "@duro-app/ui": "^5.16.0"
49
+ "@duro-app/ui": "^5.18.0"
49
50
  },
50
51
  "peerDependenciesMeta": {
51
52
  "@duro-app/ui": {
@@ -60,9 +61,9 @@
60
61
  "@types/node": "^24.0.0",
61
62
  "ts-morph": "^26.0.0",
62
63
  "typescript": "^5.7.0",
63
- "@duro-app/eslint-plugin": "^5.16.0",
64
- "@duro-app/ui": "^5.16.0",
65
- "@duro-app/tokens": "^5.16.0"
64
+ "@duro-app/eslint-plugin": "^5.18.0",
65
+ "@duro-app/tokens": "^5.18.0",
66
+ "@duro-app/ui": "^5.18.0"
66
67
  },
67
68
  "scripts": {
68
69
  "registry": "node scripts/build-registry.mjs",
package/registry.json CHANGED
@@ -8893,6 +8893,41 @@
8893
8893
  "sourcePath": "packages/ui/src/components/Toast/ToastProvider.tsx"
8894
8894
  }
8895
8895
  },
8896
+ "guides": {
8897
+ "colors": {
8898
+ "LICENSE": "MIT License\n\nCopyright (c) 2026 Jakub Krehel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
8899
+ "NOTICE.md": "# Notice\n\nThis skill is a fork of `better-colors/` from the repository `jakubkrehel/skills`, at commit `4c66b1fa86e9ab70187b43d7da9b6fdb29f2ef63`, fetched 2026-10-16.\n\nLocal edits:\n\n- Renamed by `scripts/fork-interface-skills.sh` at dotfiles commit `5fbdc4b` (`better-colors` to `colors`).\n- Dropped `agents/openai.yaml`.\n- Added the precedence block after the introduction of `SKILL.md`.\n- Reconciled the clause in `ui/SKILL.md` that limited project tokens, so a project token always wins.\n\nUpdating: diff upstream from the pinned commit, have a frontier model port the changes over the local edits, then bump the pin.\n\nThe upstream license (MIT) applies; the text is in `LICENSE`:\n\n> MIT License\n>\n> Copyright (c) 2026 Jakub Krehel\n>\n> Permission is hereby granted, free of charge, to any person obtaining a copy\n> of this software and associated documentation files (the \"Software\"), to deal\n> in the Software without restriction, including without limitation the rights\n> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n> copies of the Software, and to permit persons to whom the Software is\n> furnished to do so, subject to the following conditions:\n>\n> The above copyright notice and this permission notice shall be included in all\n> copies or substantial portions of the Software.\n>\n> THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n> SOFTWARE.\n",
8900
+ "SKILL.md": "---\nname: colors\ndescription: Helps you build and check a color system for your project. It generates palettes, names semantic tokens, converts between formats and measures contrast.\n---\n\n# Colors\n\nThis skill builds and audits color systems. It generates ramps, maps their steps to roles and token names, converts notation and measures rendered contrast pairs.\n\nA decision of this repository (resolved at its applicable scope with `aval resolve`) outranks a value in this skill. Apply token requirements and gap routing only where the repository's decisions require them, keeping their named exemptions. On `duro-stack`, a non-exempt measure without a token is a design-system gap under ADR-0006/0027, never a local literal.\n\nWhere Duro defines the value, these win: the CLAUDE.md \"Color Semantics\" table, `packages/tokens/src/tokens/colors.css.ts` and the themes.\n\nNever report a contrast value you did not measure, and never estimate a color you could compute. Notation, a tinted neutral and a gradient's interpolation space are project choices, not findings. A broken role mapping, a failing pair and an out-of-gamut step are findings. Perceived lightness throughout means OKLCH `L`, from `0` to `1`.\n\nWhether a pair is required to pass belongs to `accessibility`. Surfaces, shadows and icon color belong to `ui`.\n\n## Match the project's color system\n\nReuse the project's tokens and notation. A consistent hex system beats hex with `oklch()` scattered through it.\n\nFor a new system, `oklch()` is the best default, because its numbers behave the way the ramp rules below describe. Everywhere else, a color library produces the same ramp in the project's own notation ([color-formats.md](color-formats.md)).\n\n## A system is ramps, not colors\n\nOne neutral ramp, one accent ramp and only the status ramps the product actually renders. A `warning` ramp nothing imports is maintenance for zero pixels. A second accent hue earns its place only when two things must be distinguishable at a glance. Otherwise use more steps of the one accent ramp.\n\n## Every step has a job\n\nA ramp is not a gradient to pick from by eye. Each step exists because a role needs it: page background, component hover, border, solid fill, body text. Do not generate a step no role consumes. Both the Tailwind `50`–`950` and Radix `1`–`12` conventions map to those roles ([palette-structure.md](palette-structure.md)).\n\n## Name primitives by hue, semantics by role\n\nPrimitives name a value (`--blue-600`) and are never applied in a component. Semantic tokens name a job (`--color-text-secondary`), point at a primitive and are the only tier components reference. That seam is what lets a theme repoint colors without touching components ([token-naming.md](token-naming.md)).\n\n## Use a token only in its role\n\nNever borrow a token because its value is right today. A separator used as a text color works until borders get lighter, and then the text goes with them. If a role has no token, add the token.\n\n## Hold the hue across the ramp\n\nA ramp holds one hue end to end, peaks in vividness mid-ramp and steps more finely at the light end. Build it with a color library, never by eye ([palette-generation.md](palette-generation.md)).\n\n## One color, one meaning\n\nUse a color for one purpose across the whole interface, treating anything within `15°` of OKLCH hue as the same color ([color-usage.md](color-usage.md)). Never let color be the only carrier of meaning. Pair it with an icon or a label, a requirement `accessibility` owns.\n\n## Fill exactly one action per view\n\nWhen filled color encodes primary emphasis, one primary action gets it and peers stay neutral. Put the color on the background, not the label. A filled button reads as primary across the room; accent-colored text on a neutral button reads as a link.\n\nKeep an established hierarchy that already signals emphasis another way. Several colored backgrounds are fine when they encode distinct states or categories rather than competing as peers.\n\n## Measure the rendered pair, then report\n\nMeasure a foreground against the background it actually renders on, not the page background. When a pair fails, report the pair, its measured value and the threshold it misses, then leave the colors alone. They are a design decision. Change them only when asked, and remeasure after ([contrast.md](contrast.md)).\n\n## Pick a gradient's interpolation space\n\nThe space is a look, not a correctness setting. Default to `in oklab`, and reach for `in oklch` when a two-hue gradient goes gray in the middle ([color-usage.md](color-usage.md)).\n\n## Before you finish\n\n| Pattern | Fix |\n| --- | --- |\n| A hex, `rgb(` or `oklch(` literal in a component file where a token exists | Reuse or add the role token, in the project's notation |\n| One `oklch(` value in a codebase otherwise written in hex | Write it in the established notation unless a migration is in scope |\n| `var(--blue-600)` or `bg-blue-600` in a component file | Point a semantic token at the primitive and use that |\n| A semantic name containing a hue or a component, as in `--color-blue-button` or `--color-sidebar-gray` | Rename it for its role: `--color-accent-solid`, `--color-bg-surface` |\n| `--color-primary` and `--color-text-primary` both defined | Rename the brand token `accent` |\n| A `border` or `separator` token inside `color:`, or a `text` token inside `background:` | Add a token for the missing role |\n| `hsl(` ramp values that differ only in lightness | Rebuild in OKLCH with a constant hue |\n| `50`–`200` stepping as far apart in OKLCH `L` as mid-ramp steps do | Tighten the light end to about `0.04`–`0.05` per step |\n| One chroma or saturation number copied across every hue's ramp | Use the same proportion of each hue's own maximum |\n| A status hue within `15°` of the accent hue | Move it at least `15°` away, or give the destructive action a distinct treatment |\n| Dark tokens equal to the light ramp in reverse step order | Lower the accent's chroma, widen the dark end and remeasure every pair |\n| Color tokens set under both `prefers-color-scheme` and a `.dark` class | Pick one switching mechanism and use it throughout |\n| A contrast fix that changes hue and leaves lightness | Change lightness, the channel contrast responds to |\n| `prefers-contrast: more` overriding a color for both appearances in one block | Give each appearance its own increased-contrast value |\n| Text on a color with alpha, such as `/50`, `rgba(` or `color-mix(` with `transparent` | Measure the rendered result, or use a solid token |\n| `text-white` on a Tailwind `500` fill | Measure it; white on most `500` hues fails 4.5:1, so move the fill to `600` |\n| `oklch()` chroma beyond sRGB with no sRGB value declared before it | Declare the sRGB value first, then the vivid one inside `@media (color-gamut: p3)` |\n\n## Reporting\n\n**Severity.** `HIGH` makes content unreadable or assigns a misleading semantic color. `MEDIUM` is a noticeable theme, token or gamut failure. `LOW` is isolated polish.\n\nThree symptoms are `HIGH` on sight whatever the surface, matching `interface`'s escalation triggers. They are body or control text whose rendered pair fails its required ratio, state or meaning carried by color alone and a semantic color used against its meaning.\n\n**Verification.** Without a browser: token values, the gamut of every declared color, both theme blocks present and contrast computed from the declared token pair. With one: the background actually rendered behind the text, including opacity and any image beneath it, measured in both light and dark. Report every check you could not run as `Not verified`.\n\n**Format.** Group findings under the principle each violates, ordered by severity, one row per root cause listing every location it appears in:\n\n| Severity | Location | Before | After | Why |\n| --- | --- | --- | --- | --- |\n\n`Location` is `path/to/file:line`. `Why` names the principle and the user impact.\n\nEnd with `Block` when any `HIGH` remains, `Approve` otherwise, leaving the rest in the table as work to do. Never `Approve` coverage you did not inspect. With nothing to report, state \"No actionable color findings\" and report verification.\n",
8901
+ "color-formats.md": "# Color formats\n\nWhich notation to write colors in, how to convert between them and what happens at the edges of a display's gamut. Every other rule in this skill is stated perceptually and holds whatever notation you write.\n\n## Choosing a notation\n\n| Notation | Good for | Weakness |\n| --- | --- | --- |\n| Hex | Universal support, compact, what design tools hand you | Opaque. No channel is readable or editable by hand |\n| `rgb()` | Same reach as hex, readable alpha | Channels do not correspond to anything a designer thinks about |\n| `hsl()` | Channels look like design controls | Its lightness is not perceptual and its hue drifts; a ramp built by varying lightness bunches at one end and shifts hue |\n| `oklch()` | Perceptually uniform lightness, stable hue, predictable ramps | Baseline 2023, so very old browser matrices need a fallback |\n\n**Match whatever the project already uses.** Notation is not a defect: a project on hex is not doing it wrong.\n\n**For a genuinely new color system, `oklch()` is the best default.** Even lightness steps stay even, and a fixed hue stays fixed. See [palette-generation.md](palette-generation.md).\n\n```css\noklch(L C H) /* lightness 0–1, chroma 0–~0.4, hue 0–360 */\noklch(L C H / alpha) /* alpha uses a slash, never a comma */\n```\n\n## Converting\n\nConvert when the user asks, when an agreed migration is in scope or when the project is standardizing on a notation and this value is the straggler. Never convert an isolated value in a project that deliberately uses something else and never because this skill happened to load.\n\nWhen conversion is in scope, change the values and nothing else:\n\n- Leave CSS keywords alone: `currentColor`, `inherit`, `transparent`, `initial`, `unset`.\n- Leave gradient functions alone. Convert the color stops inside them; do not touch the interpolation method.\n- Leave colors in third-party configs that expect a specific format.\n- Preserve comments and formatting.\n\n```css\n/* Before */\ncolor: #3b82f6;\nborder: 1px solid rgba(0, 0, 0, 0.1);\n\n/* After */\ncolor: oklch(0.623 0.188 259.815);\nborder: 1px solid oklch(0 0 0 / 0.1);\n```\n\nBulk conversion is a migration, not cleanup. It shifts every rendered color by a rounding margin and touches files nobody asked about, so it has to be the task rather than a side effect.\n\n## Gamut\n\nEvery sRGB color exists in Display P3, but not the reverse. The extra room is only at high chroma and varies by hue. At OKLCH `L` `0.65`, green gains about `0.07` of chroma while blue gains about `0.015`.\n\nA color more vivid than its display can render gets gamut-mapped, which in current browsers mostly means clipped. It flattens neighbouring steps into one rendered color, so the top of a ramp can lose its distinctions on an sRGB screen. Maximum vividness varies by hue and lightness. Cyans top out far lower than reds and purples, so a clipping ramp clips at some steps and not others.\n\nThe fix is to reduce vividness while holding hue and lightness. Generate ramps against sRGB unless the product is display-restricted, and add P3 as an enhancement:\n\n```css\n.success {\n background: #11ad32; /* oklch(0.65 0.2 145), inside sRGB */\n}\n\n@media (color-gamut: p3) {\n .success {\n background: oklch(0.65 0.27 145);\n }\n}\n```\n\nOrder matters. The sRGB value comes first, so an sRGB screen shows a color you chose rather than one the browser clipped. The P3 rule overrides only where it will render.\n\nFor browser matrices predating `oklch()` support, the same layering works with `@supports`:\n\n```css\n.accent {\n background: #3b82f6;\n}\n\n@supports (color: oklch(0 0 0)) {\n .accent {\n background: oklch(0.62 0.19 259);\n }\n}\n```\n\nCheck the project's actual browser matrix before adding this. On a modern baseline it is dead weight.\n\n## Modern CSS worth knowing\n\n- **`color-mix()`** derives one color from another, as in `color-mix(in oklab, var(--color-accent-solid) 15%, white)` for a tinted background. Useful for states, but keep generated values out of the token layer, since a mixed color cannot be inspected in a design tool.\n- **Relative color syntax** adjusts one channel of an existing color. `oklch(from var(--color-accent-solid) calc(l - 0.1) c h)` darkens the accent by hand. Powerful and easy to overuse, since a token defined by three chained derivations is unreadable.\n- **`light-dark()`** puts both appearances in one declaration. See [palette-generation.md](palette-generation.md).\n\nAll three resolve at render time. Compute the resolved value with a color library, or measure the rendered result, before reporting contrast.\n",
8902
+ "color-usage.md": "# Color usage\n\nDeploying color once the system exists: meaning, emphasis, gradients and appearance variants. For picking values see [palette-generation.md](palette-generation.md), for naming them [token-naming.md](token-naming.md), for checking pairs [contrast.md](contrast.md).\n\n## One color, one meaning\n\nUsers read a near-miss in hue as a slightly different shade, not as a different color.\n\n```css\n/* Bad: the accent means both \"link\" and \"decorative heading\" */\na { color: #3b82f6; }\n.section-title { color: #4f8ef7; }\n\n/* Good: interactive elements own the accent; headings stay neutral */\na { color: var(--color-accent-text); }\n.section-title { color: var(--color-text-primary); }\n```\n\nThe rule runs both ways. A color must not be *absent* where its meaning occurs. If the accent means interactive, an interactive element rendered neutral is just as misleading.\n\n## Use tokens in their role\n\nA value that happens to work in two roles stops working in both at the next theme change.\n\n```css\n/* Bad: separator token repurposed as a text color because it looked right */\n.caption { color: var(--color-border); }\n\n/* Bad: text token repurposed as a background */\n.tag { background: var(--color-text-secondary); }\n```\n\nThe role inventory in [token-naming.md](token-naming.md) is the list of roles a system needs.\n\n## One colored action per view\n\n```html\n<!-- Good: one filled primary action, neutral secondaries -->\n<button class=\"bg-accent-solid text-text-on-accent\">Save</button>\n<button class=\"text-text-secondary\">Cancel</button>\n\n<!-- Bad: every action colored, so nothing is primary -->\n<button class=\"bg-accent-solid text-text-on-accent\">Save</button>\n<button class=\"bg-accent-solid text-text-on-accent\">Duplicate</button>\n<button class=\"bg-accent-solid text-text-on-accent\">Export</button>\n```\n\nSelected states may use the accent on the glyph and label. An active tab or a checked segment is state, not emphasis.\n\n## Gradients\n\n**The interpolation space is a look, not a correctness setting.** Three are worth knowing, and the difference between them is most visible in the middle of the gradient:\n\n```css\n/* sRGB: the default and the classic. Midpoint darkens and mutes. */\nbackground: linear-gradient(#3b82f6, #ec4899);\n\n/* oklab: even brightness across the transition. The best default. */\nbackground: linear-gradient(in oklab, #3b82f6, #ec4899);\n\n/* oklch: travels around the hue wheel, staying vivid throughout. */\nbackground: linear-gradient(in oklch, #3b82f6, #ec4899);\n```\n\n`oklab` and sRGB are **rectangular**, interpolating in a straight line through the color space. `oklch` is **polar**, interpolating the hue angle, so it arcs around the wheel through every hue between the stops. That is why it stays saturated and why it can produce hues nobody asked for. A blue-to-pink gradient routes through purple, which is either the look or a surprise.\n\n**The gray dead zone is a rectangular-space problem.** Two hues on opposite sides of the wheel sit either side of the neutral axis. A straight line between them passes near gray, and the middle goes lifeless. Either switch to a polar space, which routes around the axis, or add a third stop between the two and keep the space you have.\n\nWith a polar space you also control which way it goes around:\n\n```css\n/* The short way round, usually what you want */\nbackground: linear-gradient(in oklch shorter hue, #3b82f6, #ec4899);\n\n/* The long way, sweeps most of the spectrum */\nbackground: linear-gradient(in oklch longer hue, #3b82f6, #ec4899);\n```\n\n**Banding shows up on large areas.** A gradient spanning a hero with little contrast between its stops steps visibly on 8-bit displays. Widen the contrast, shrink the area or overlay a subtle noise texture.\n\n**Keep text off gradients where you can.** Contrast varies continuously across one, so a single measurement does not describe it. Where text must sit on a gradient, measure the worst region rather than the average, or put a scrim behind it.\n\n## Color across cultures\n\nWhere a color carries meaning in finance, status or alerts, verify the meaning holds in every locale you ship to. Chinese financial UIs show gains in red and losses in green, the reverse of English ones. Where the product ships to such markets, make gain and loss per-locale tokens rather than hardcoded values.\n\n## Light, dark and increased contrast\n\nWhen the product ships a dark appearance, every custom color needs a dark variant, derived per [palette-generation.md](palette-generation.md). Users who enable increased contrast expect visibly stronger differentiation in each appearance:\n\n```css\n:root {\n --color-accent-solid: var(--blue-600);\n}\n\n@media (prefers-contrast: more) {\n :root { --color-accent-solid: var(--blue-900); }\n}\n\n@media (prefers-color-scheme: dark) {\n :root { --color-accent-solid: var(--blue-400); }\n}\n\n@media (prefers-color-scheme: dark) and (prefers-contrast: more) {\n :root { --color-accent-solid: var(--blue-200); }\n}\n```\n\nA single `prefers-contrast` block shared by both appearances darkens the color on a dark background too, lowering contrast there. The increased-contrast variant widens the gap to its background by at least `0.15` of OKLCH `L` over the default. Remeasure it, aiming for Lc 90 on body text. Widening the gap without remeasuring is not fixing it.\n",
8903
+ "contrast.md": "# Contrast\n\nContrast is measured between a **foreground color**, meaning text, an icon or a UI element, and the **background color** it actually renders against, usually the nearest ancestor that paints one. Identify that background first. Measuring against the page background when the element sits on a card gives the wrong answer.\n\n`accessibility` decides which requirement applies to a pair. The thresholds below are what a measured pair is reported against. Where neither that skill nor the project names a standard, report the WCAG 2 result and add the APCA value.\n\n## WCAG 2 thresholds\n\nWCAG 2 is the standard formal conformance claims are made against.\n\n| Content type | AA | AAA |\n| --- | --- | --- |\n| Text | 4.5:1 | 7:1 |\n| Large text, at least `24px` or `18.67px` bold | 3:1 | 4.5:1 |\n| UI components and graphical objects | 3:1 | n/a |\n\nDisabled or inactive controls and logos are exempt.\n\n## APCA thresholds\n\nLc, Lightness Contrast, is APCA's measure of perceived contrast. These levels simplify APCA's full font-size and weight lookup table:\n\n| Content type | Minimum |\n| --- | --- |\n| Body text, columns or blocks of text | Lc 75, preferred Lc 90 |\n| Non-body text such as labels and headlines | Lc 60 |\n| Large text, at least `36px` regular or `24px` bold | Lc 45 |\n| Fine-detail or outline icons | Lc 45 |\n| Solid icons and UI components | Lc 30 |\n| Non-text that only has to be discernible, such as dividers | Lc 15 |\n\nDisabled and placeholder text are not covered by a requirement. Aim for Lc 30 so they stay legible.\n\nLc is signed. Positive means dark text on a light background, negative means light text on a dark background. Compare the absolute value against the threshold.\n\n## Fix a failing pair only on request\n\n**Change lightness first.** It is the channel contrast responds to. Hue and saturation move the measured value far less.\n\nMove the foreground away from the background in perceived lightness, holding hue and saturation, then remeasure. Keeping hue fixed is what stops a contrast fix becoming a palette change.\n\n```css\n/* Failing: text too close to its background in lightness (Lc ≈ 50) */\ncolor: #7d93b0;\nbackground: #eef2f7;\n\n/* Fixed: darker text, same hue (Lc ≈ 88) */\ncolor: #2b3a4f;\nbackground: #eef2f7;\n```\n\nTwo constraints on the fix:\n\n- **Mid-lightness backgrounds cap what is achievable.** On a background near OKLCH `L` `0.75`, even pure black text reaches only about Lc 60. Body text needs a background near one extreme, so a mid-range background is the thing that has to change.\n- **Pushing lightness can push the color out of gamut.** Reduce saturation as needed to keep it renderable. See [color-formats.md](color-formats.md).\n\nAlways remeasure after changing a value. Do not assume a fix landed.\n\n## Pick text polarity from the background\n\nCompare black and white using the project's required contrast metric. APCA and WCAG 2 can favor different text colors on the same background. A higher score does not by itself mean the pair passes.\n\nWCAG 2's neutral crossover is near 56% OKLCH lightness. At `oklch(65% 0 0)`, black measures about `6.49:1` and white `3.23:1`. Only black passes AA for normal text. For colored backgrounds or other foregrounds, measure the actual pair rather than applying a crossover.\n\n## What to check\n\n- **Every pair, in every appearance.** APCA scores mirrored pairs differently, so a pair passing in light mode can fail in dark.\n- **Translucent surfaces.** A color on a `backdrop-filter` header or an overlay shifts with whatever scrolls behind it. Test against the lightest and darkest content it can sit over, or make the surface opaque enough that the shift cannot break the pair.\n- **Computed colors.** `color-mix()`, relative color syntax and opacity modifiers resolve at render time; measure the rendered result, not the declaration.\n- **Text over images.** There is no single background color. Measure the worst region, or guarantee one with a scrim.\n",
8904
+ "palette-generation.md": "# Palette generation\n\nProducing the values once the structure is decided. For which ramps to build and what each step is for, see [palette-structure.md](palette-structure.md).\n\n## Start from the brand color\n\nA brand color arrives as one value, usually a hex. Two decisions come before any ramp exists:\n\n**Which step does it occupy?** A brand color meant for buttons and links belongs on the solid-fill step, `600` in Tailwind and `9` in Radix. Then the primary button renders the actual brand color, not an approximation.\n\n**Is it pinned or snapped?** Pin a contractually fixed brand color. It stays exact, the ramp builds outward from it and that one step spaces slightly unevenly. Otherwise snap it onto the ramp so the spacing stays regular, which almost always looks better.\n\nA brand color that fails contrast behind white text is still the brand color, just not the solid-fill step. Put it where it lands and use a darker step for interactive fills. Never quietly darken the brand. In the example below, `#3b82f6` lands on `500` and fills use `600`, because white on `#3b82f6` measures 3.68:1.\n\n## What a correct ramp looks like\n\nProperties of the finished ramp, checkable against any output in any notation:\n\n- **Lightness changes steadily, measured in OKLCH `L`.** HSL lightness is not perceptual, and evenly spaced HSL values bunch at one end.\n- **Hue is constant end to end.** Every step is recognisably the same color. A wandering hue reads as two colors blended and will not sit correctly against a neutral built on a different hue.\n- **Vividness peaks in the middle and falls off at both ends.** The lightest and darkest steps are nearly neutral; the middle carries the color. Holding full vividness into the extremes gives a `50` that glows and a `950` like ink spilled on the brand.\n- **Steps are finer at the light end.** Light backgrounds need finer distinctions than dark ones. Keep `50`–`200` about `0.04`–`0.05` apart in OKLCH `L` and mid-ramp steps about `0.07`–`0.10`. Even spacing across the whole range leaves too few pale steps, so `100` is already too dark for a subtle surface.\n- **No two adjacent steps are indistinguishable.** Adjacent steps less than `0.03` apart in OKLCH `L` mean the ramp has more steps than decisions. Drop one.\n- **Both ends stop short of pure black and white.** A ramp that reaches them loses its identity exactly where the page background lives.\n\n## Use a color library\n\nNever compute these by hand or by eye. `culori`, `colorjs.io` and `chroma.js` all convert between notations, measure perceived lightness and interpolate perceptually. Read the brand color in whatever format it arrives, do the math in a perceptual space and emit the project's notation:\n\n```js\nimport { formatHex, interpolate, samples } from 'culori'\n\n// Perceptual interpolation, hex in and hex out.\nconst ramp = interpolate(['#eff6ff', '#3b82f6', '#172554'], 'oklab')\nconst steps = samples(11).map((t) => formatHex(ramp(t)))\n```\n\nThis is a starting point. Linear sampling spaces lightness evenly within each segment, so check the output against the properties above and tighten `50`–`200` by hand.\n\nThe output format is the project's choice. For a ramp the interpolation space is not, because sRGB interpolation produces muddy mid-steps. Decorative gradients are the opposite case, where the space is a deliberate look ([color-usage.md](color-usage.md)).\n\nFor comparison, Tailwind's blue ramp has the finer light end the generated one lacks:\n\n```css\n:root {\n --blue-50: #eff6ff;\n --blue-100: #dbeafe;\n --blue-200: #bfdbfe;\n --blue-300: #93c5fd;\n --blue-400: #60a5fa;\n --blue-500: #3b82f6;\n --blue-600: #2563eb;\n --blue-700: #1d4ed8;\n --blue-800: #1e40af;\n --blue-900: #1e3a8a;\n --blue-950: #172554;\n}\n```\n\n## Several hues at once\n\nWith an accent plus status ramps, the ramps must agree step for step. `red-500` and `blue-500` should read as equally bright and vivid, or a red button looks heavier than a blue one at the same step.\n\n- **Match perceived lightness exactly.** Same step, same brightness, across every hue.\n- **Match vividness relatively, not absolutely.** Hues do not share a maximum vividness. A saturated yellow and a saturated blue are not equally far from gray, and no format makes them so. Set each ramp to the same *proportion* of what its own hue reaches.\n\nYellows and cyans are the usual casualties, peaking much lower than reds and blues. Copy the numbers across and the warning color looks weak beside the danger one.\n\n## Dark mode\n\nA dark palette is not the light one reversed. Reversal is the starting point, not the output.\n\nSwap the semantic roles first, then tune the values:\n\n```css\n:root {\n --color-bg-page: var(--neutral-50);\n --color-text-primary: var(--neutral-950);\n}\n\n.dark {\n --color-bg-page: var(--neutral-950);\n --color-text-primary: var(--neutral-50);\n}\n```\n\nThree things almost always need hand-tuning after the swap:\n\n- **Vividness comes down.** A color that reads as confident on white reads as neon on near-black. Dark appearances need the accent a step or two less vivid.\n- **The dark end needs more separation.** Steps distinguishable as pale backgrounds collapse into each other as dark surfaces.\n- **Contrast does not survive the mirror.** APCA scores mirrored pairs differently, so recheck every pair in both appearances ([contrast.md](contrast.md)).\n\n### Choosing the switching mechanism\n\nUse one mechanism throughout:\n\n- **`prefers-color-scheme` alone** is correct with no theme toggle. Nothing to persist, nothing to hydrate.\n- **A `.dark` class** is required as soon as users can override the system setting. The media query then sets only the initial value.\n- **`light-dark()`** collapses both values into one declaration. It needs `color-scheme: light dark` and reads that property rather than a class, so a class-based toggle must set `color-scheme` too.\n\n```css\n:root {\n color-scheme: light dark;\n --color-bg: light-dark(#ffffff, #172554);\n}\n```\n\nA media query setting some tokens and a class setting others gives a half-themed interface the moment a user overrides their system preference.\n",
8905
+ "palette-structure.md": "# Palette structure\n\nWhat a color system is made of, before any values exist. For computing the values see [palette-generation.md](palette-generation.md); for what to call them see [token-naming.md](token-naming.md).\n\n## What a system needs\n\nMost products need three kinds of ramp and nothing else:\n\n| Ramp | How many | Notes |\n| --- | --- | --- |\n| Neutral | 1 | Carries 80–90% of the interface: backgrounds, borders, body text |\n| Accent | 1 | The brand hue. Interactive and selected states |\n| Status | 0–4 | `danger`, `warning`, `success`, `info`. Add one only when the product shows that state |\n\n## Every step has a job\n\nEach step maps to a role. Generate the steps the roles below call for and skip the rest.\n\n| Role | Tailwind | Radix |\n| --- | --- | --- |\n| Page background | `50` | `1` |\n| Subtle background | `50` | `2` |\n| Component background | `100` | `3` |\n| Component hover | `200` | `4` |\n| Component active / selected | `200` | `5` |\n| Subtle border, separator | `200` | `6` |\n| Border, focus ring | `300` | `7` |\n| Hovered border | `400` | `8` |\n| Solid fill | `600` | `9` |\n| Solid fill hover | `700` | `10` |\n| Low-contrast text | `700` | `11` |\n| High-contrast text | `900` | `12` |\n\nThe two conventions differ in kind, not only in numbering:\n\n- **Radix defines its 12 steps by role.** Step 9 is \"the solid fill\" in every ramp and appearance. The dark scale is a separate ramp reusing the same numbers, so `--accent-9` is the fill in both and component CSS never changes.\n- **Tailwind defines its 11 steps by lightness.** `50` is light, `950` is dark. The mapping above therefore holds in light mode and inverts in dark, with the page background at `950` and high-contrast text at `50`. Components either swap step numbers per appearance or read a semantic token that swaps once.\n\nMatch whichever the project uses. For a new system prefer Radix's model, because a role-defined step survives a theme change that a lightness-defined step does not. On Tailwind, keep `50`–`950` and put the role mapping in the semantic tier.\n\nSolid fill sits on `600` because white text on most Tailwind `500` hues fails 4.5:1. Where the table repeats a step, those roles render identically. A design that needs, say, component hover and subtle border to differ needs a 12-step ramp.\n\n## Neutrals\n\nA pure gray ramp is a perfectly good default. It sits under any accent hue without competing and never needs revisiting when the brand color changes.\n\nTinting the neutral toward the accent hue is a stylistic option, not a correction. OKLCH chroma `0.005`–`0.02` at the accent's hue puts the grays in the accent's family. Enough to measure, not enough to name.\n\nWarm neutrals, hue toward orange, read approachable and editorial; cool ones, toward blue, read technical and precise. Whichever you pick, including none, hold it across the whole ramp. A warm gray border on a cool gray background is visible even when neither color is nameable alone.\n\nNeutrals carry the most roles, so they need the most steps. Never generate fewer neutral steps than accent steps.\n\n## Status colors\n\nConvention constrains status hues before taste does. Red reads as danger, amber as warning, green as success. See the cultural exceptions in [color-usage.md](color-usage.md).\n\nTwo rules govern them:\n\n- **Keep every status hue at least `15°` of OKLCH hue from the accent.** If the brand is red, danger cannot also be red. Where convention leaves nowhere to move, give the destructive action a distinct treatment, such as an outlined button with an icon, or it reads as the primary action.\n- **Status ramps need fewer steps than the accent.** Most render four roles: a background, a border, a solid fill and text. Generate the full ramp only where the product styles status components across the whole range.\n\n## Auditing an existing palette\n\nBefore restructuring a system, inventory it. Most codebases hold several times more colors than the design has decisions.\n\n1. **Collect every literal.** Grep for hex, `rgb(`, `hsl(`, `oklch(` and the project's utility-class prefixes. Include SVG `fill` and `stroke`, chart configs and email templates.\n2. **Sort by perceived lightness within each hue family.** Duplicates surface immediately as near-identical neighbors.\n3. **Collapse near-duplicates.** Two colors within `0.02` of each other in OKLab ΔE are one color that drifted. Keep the one used most and retire the others. Never average them.\n4. **Assign each survivor a role** from the table above. A color matching no role is a missing token or a mistake. Decide which, and say so in the finding.\n5. **Count what is left.** More ramps than **What a system needs** lists means the palette outgrew its structure, not that the product needs more color.\n\nReport the inventory before changing anything. Consolidating a palette changes rendered output on screens nobody asked you to touch, so it stays a proposal until the user accepts it.\n",
8906
+ "token-naming.md": "# Token naming\n\nNaming is what makes a palette usable by anyone who did not build it. For which ramps exist and what each step does, see [palette-structure.md](palette-structure.md).\n\n## Two tiers\n\n**Primitives** name a value. They are the ramp, named by hue and step, as in `--blue-600` or `--neutral-200`. A primitive describes what the color *is*, so it never changes meaning between themes and is never applied directly in a component.\n\n**Semantics** name a job. They point at a primitive and take the name of the role they fill, as in `--color-text-secondary` or `--color-border-subtle`. Components only ever reference this tier.\n\n```css\n:root {\n /* Tier 1: primitives, named by hue and step. Never used directly. */\n --blue-600: #2563eb;\n --neutral-200: #e5e7eb;\n --neutral-700: #374151;\n\n /* Tier 2: semantics, named by role. This is what components use. */\n --color-accent-solid: var(--blue-600);\n --color-border: var(--neutral-200);\n --color-text-secondary: var(--neutral-700);\n}\n```\n\nThe tiering is what makes theming possible. Dark mode, a white-label theme and an increased-contrast variant all repoint the semantic tier, leaving the primitives and every component untouched. A codebase applying `--blue-600` directly in components has no theming seam. Adding one later means auditing every usage to work out which meant \"the accent\" and which just wanted blue.\n\nAdd a third, component-level tier (`--color-button-danger-bg`) only where a component genuinely and intentionally diverges from the system. One component token is a documented exception; twenty mean the semantic tier is missing roles.\n\n## The role inventory\n\nA system is complete when every role below has a token. Build against this list rather than adding tokens as components demand them, or the palette ends up shaped like whichever screen came first.\n\n| Group | Roles |\n| --- | --- |\n| Surfaces | page background, surface, raised (menus, popovers), sunken (inputs, wells), overlay scrim |\n| Text | primary, secondary, disabled, inverse, on-accent |\n| Borders | subtle, default, strong, focus ring, separator |\n| Accent | subtle background, border, solid, solid hover, text |\n| Status | per status shipped: subtle background, border, solid, text |\n\nSeparator and border are separate roles even when they share a value today. A separator divides content; a border encloses a control. They diverge the first time someone restyles inputs, and a system that conflated them gets untangled at that moment.\n\n## Naming grammar\n\nUse one shape and never deviate. Neutral roles lead with the property, as `--color-{bg|text|border}-{variant}-{state}`. Accent and status roles lead with the role, as `--color-{accent|danger|…}-{subtle|border|solid|text}-{state}`.\n\n```css\n--color-bg-surface\n--color-text-secondary\n--color-border-strong\n--color-accent-solid-hover\n--color-danger-subtle\n--color-accent-text\n```\n\nPick one word per concept and use only that word. Consistency matters more than the vocabulary. A reader who has seen `--color-text-primary` must be able to guess `--color-text-disabled` without looking:\n\n| Concept | Pick one | Never mix in |\n| --- | --- | --- |\n| Foreground | `text` | `fg`, `foreground`, `content`, `ink` |\n| Background | `bg` | `background`, `surface` as a synonym, `fill` |\n| Edge | `border` | `stroke`, `outline`, `line` |\n| Brand color | `accent` | `primary`, `brand`, `theme` used interchangeably |\n\nReserve `primary` for exactly one meaning. `--color-text-primary` for body text beside `--color-primary` for the brand is the most common naming collision there is, and it makes every `primary` token ambiguous until you open its definition. Use `accent` for the brand and let `primary` mean \"the most prominent of its group\".\n\n## Anti-patterns\n\n| Name | Problem | Instead |\n| --- | --- | --- |\n| `--color-blue-button` | Appearance at the semantic tier; lies the moment the brand changes | `--color-accent-solid` |\n| `--color-sidebar-gray` | Named for where it was used first; the second usage makes it nonsense | `--color-bg-surface` |\n| `--color-light-gray` | Lies in dark mode, where it is the dark one | `--neutral-200` as a primitive |\n| `--color-text-2` | Numbered semantics carry no meaning; nobody can guess what `3` would be | `--color-text-secondary` |\n| `--color-gray-hover` | Mixes a hue with a state and belongs to no tier | `--color-bg-surface-hover` |\n| `--blue-600` used in a component | Skips the semantic tier and removes the theming seam | Point a semantic token at it |\n\n## In Tailwind projects\n\nTailwind v4 generates utilities from `@theme`, so names declared there become the API. Declare primitives and semantics in the same block; the `--color-*` namespace is what produces `bg-*`, `text-*` and `border-*`:\n\n```css\n@theme {\n /* Primitives */\n --color-blue-50: #eff6ff;\n --color-blue-600: #2563eb;\n --color-blue-900: #1e3a8a;\n\n /* Semantics: what templates should use */\n --color-accent-solid: var(--color-blue-600);\n --color-text-secondary: var(--color-neutral-700);\n}\n```\n\nThat yields `bg-accent-solid` and `text-text-secondary` alongside `bg-blue-600`. Tailwind prefixes every `--color-*` name with the utility, so a `text-` token doubles. Accept the doubling, or follow the convention the project already uses. Both tiers are reachable, so the discipline is a convention rather than a constraint. Templates use the semantic utilities, and a raw `bg-blue-600` in a component is the thing to flag.\n\nRepoint semantics for dark mode in a `.dark` block outside `@theme`. Leave the block without `inline`, so utilities read the semantic variable at render time.\n\nOpacity modifiers work on either tier, as in `bg-accent-solid/50`. But a color carrying alpha cannot be contrast-checked against a static background, because what it renders depends on what sits behind it. Use solid tokens for anything with text on it.\n"
8907
+ },
8908
+ "typography": {
8909
+ "LICENSE": "MIT License\n\nCopyright (c) 2026 Jakub Krehel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
8910
+ "NOTICE.md": "# Notice\n\nThis skill is a fork of `better-typography/` from the repository `jakubkrehel/skills`, at commit `4c66b1fa86e9ab70187b43d7da9b6fdb29f2ef63`, fetched 2026-10-16.\n\nLocal edits:\n\n- Renamed by `scripts/fork-interface-skills.sh` at dotfiles commit `5fbdc4b` (`better-typography` to `typography`).\n- Dropped `agents/openai.yaml`.\n- Added the precedence block after the introduction of `SKILL.md`.\n- Reconciled the clause in `ui/SKILL.md` that limited project tokens, so a project token always wins.\n\nUpdating: diff upstream from the pinned commit, have a frontier model port the changes over the local edits, then bump the pin.\n\nThe upstream license (MIT) applies; the text is in `LICENSE`:\n\n> MIT License\n>\n> Copyright (c) 2026 Jakub Krehel\n>\n> Permission is hereby granted, free of charge, to any person obtaining a copy\n> of this software and associated documentation files (the \"Software\"), to deal\n> in the Software without restriction, including without limitation the rights\n> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n> copies of the Software, and to permit persons to whom the Software is\n> furnished to do so, subject to the following conditions:\n>\n> The above copyright notice and this permission notice shall be included in all\n> copies or substantial portions of the Software.\n>\n> THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n> SOFTWARE.\n",
8911
+ "SKILL.md": "---\nname: typography\ndescription: Sets and reviews how text renders in your product, from the type scale and spacing to font features, wrapping, truncation and punctuation.\n---\n\n# Typography\n\nThis skill sets and reviews how text renders, from the type scale and spacing to font loading, wrapping and punctuation. It writes every fix in the project's styling system, and the [cheat sheet](css-cheat-sheet.md) maps each declaration to its Tailwind utility.\n\nA decision of this repository (resolved at its applicable scope with `aval resolve`) outranks a value in this skill. Apply token requirements and gap routing only where the repository's decisions require them, keeping their named exemptions. On `duro-stack`, a non-exempt measure without a token is a design-system gap under ADR-0006/0027, never a local literal.\n\nWhere Duro defines the value, these win: the CLAUDE.md \"Typography Presets\" table, `typography.css.ts` and `type-presets.css.ts`.\n\nThe words belong to `writing`, semantic heading structure to `accessibility` and spatial RTL layout to `layout`. Measure contrast with `colors`; whether it passes is `accessibility`'s.\n\n## Measured, not preferred\n\nSome values here are exact. Unitless line-height, weight `400` or heavier below `18px`, a 60–75 character measure, `16px` inputs on iOS and `tabular-nums` on changing values are findings when missed.\n\nLetter-spacing, pairing and scale ratios are heuristics. Report them only where they break the project's own scale. Never propose a new typeface, paid or free, unless the task asks for a type change.\n\n## Fewer fonts, sizes and weights\n\nRarely use more than three fonts; marketing pages can carry more than apps. Weight and size carry hierarchy, so each extra one dilutes it. Pair for contrast. A serif headline over a sans body reads as deliberate, two near-identical sans-serifs read as a mistake.\n\nBelow `18px`, use weight `400` or heavier. Weights `100`–`300` belong at `28px` and up, checked against the background even there. Categories, `Display` and `Text` cuts, formats and fallback stacks are in [choosing-fonts.md](choosing-fonts.md).\n\n## Load the faces the design uses\n\nBrowsers synthesize a weight or style the family doesn't ship, distorting the face. Load every face the design uses. Disable synthesis only after verifying the fallback stack, and only the unwanted mode. The [synthesis longhands](variable-fonts-and-opentype.md#disable-synthesis-narrowly) show how.\n\n## Properties over raw tags\n\nWhen a CSS property exists, use it. `font-weight: 650`, not `font-variation-settings: \"wght\" 650`. `font-variant-numeric: tabular-nums`, not `font-feature-settings: \"tnum\" 1`. Properties keep working when a non-variable fallback renders.\n\nLeave `font-optical-sizing` at its default `auto`. Hard-code `\"opsz\"` only when the design deliberately decouples optical size from font size. Reserve raw tags for custom axes like `\"GRAD\" 80` and features with no property, like `\"ss01\" 1`. Axes, features and variable versus static files are in [variable-fonts-and-opentype.md](variable-fonts-and-opentype.md).\n\n## Use a type scale with semantic names\n\nDefine a small set of sizes and deviate from it as little as possible. Pair each size with its line-height and weight, so a role is one decision instead of three.\n\nSolo, default names like `text-sm` are fine when the usage rules are clear. On a team, name sizes by use (`text-body-sm`) so the rules survive other people. A [role-based starting scale](spacing-and-sizing.md#type-scale) is in the reference.\n\n## Heading sizes descend with level\n\nMap heading levels to descending steps of the scale, so a subordinate heading never overpowers its parent. Deep levels may share a size where the scale runs out of steps, as long as weight or letter-spacing keeps them distinct. A heading is never smaller than body text unless it is a deliberate overline. See the [level mapping](spacing-and-sizing.md#heading-hierarchy).\n\n## Line-height by role\n\nDisplay text `1.1`, headings `1.2`–`1.3`, body copy `1.5`–`1.6`. Use unitless values so line-height scales with the font size; a fixed `24px` does not.\n\nTight line-height is for short text. Anything that wraps to three or more lines needs at least `1.4`, even in a height-constrained row. See the [card description example](spacing-and-sizing.md#line-height-for-wrapping-text).\n\n## Letter-spacing by size\n\nHeadings at `24px` and up take `-0.01em` to `-0.02em`. Uppercase labels at `14px` or smaller take `0.05em`. Body copy stays at `0`. Use `em` so tracking scales with the size.\n\nKerning is built into the font and on by default. `font-kerning: none` is never a fix.\n\n## Cap the measure\n\nCap long-form text at 60–75 characters per line. Any unit works, as long as a cap exists and the line length lands in range. See [unit choices and the pixel equivalents](wrapping-and-punctuation.md#measure-line-length).\n\n## Wrap deliberately\n\n- `text-wrap: balance` evens out headings and short descriptions. Never on paragraphs, and Chromium ignores it past six lines.\n- `text-wrap: pretty` keeps a lone word off the last line. Safe on any paragraph, long-form included.\n- `overflow-wrap: break-word` where a long word, link or ID could escape the container.\n- `white-space: nowrap` on labels and badges where a line break looks broken.\n\nKeep interface text at `text-align: start`. `justify` belongs only in specific editorial layouts.\n\n## Tabular numbers on changing values\n\nApply `font-variant-numeric: tabular-nums` to timers, counters, prices and numeric table columns, so every digit keeps one width and nothing shifts. Leave figures in prose proportional.\n\n## Truncate with a way back\n\nFor one line, `overflow: hidden`, `text-overflow: ellipsis` and `white-space: nowrap`, which Tailwind's `truncate` sets together. For several, `display: -webkit-box`, `-webkit-box-orient: vertical`, `-webkit-line-clamp: 3` and `overflow: hidden`, which Tailwind's `line-clamp-3` emits.\n\nA truncated value needs a way back to the full text, which `layout` owns.\n\n## Natural case, typographic punctuation\n\nStore text in natural case and set case with `text-transform`, so a redesign never means rewriting copy.\n\nRendered text uses typographic characters, never their keyboard stand-ins. Curly quotes, en dashes, the ellipsis character and non-breaking spaces are in the [substitution table](wrapping-and-punctuation.md#smart-punctuation). Code keeps straight quotes.\n\n## Underlines from the font\n\nSet `text-underline-position: from-font` and `text-decoration-thickness: from-font`, or tune `text-underline-offset` and thickness by hand. A dotted `text-decoration-style` hints that a word carries extra information, such as an abbreviation or a defined term. See the [underline recipes](details-and-accessibility.md#underlines).\n\nColor is the only part of a real underline that animates reliably. Any other underline animation needs a separate element, and its motion is `ui`'s.\n\n## Inputs at 16px on mobile\n\niOS Safari zooms the whole page when an input's text is smaller than `16px`. Two fixes hold `16px` and look different, so ask which one the design wants:\n\n- Size the input up on mobile (`text-base sm:text-sm`). Changes how it looks on small screens.\n- Keep `font-size: 16px` and render the intended size with `transform: scale()`. Identical at every viewport, more code to maintain.\n\nBoth recipes, plus placeholder and caret styling, are in [details-and-accessibility.md](details-and-accessibility.md#forms-and-editable-text).\n\n## Size floors\n\nStart long-form body text at `16px`, the browser default. Move off it only for a reason you can name, such as a typeface that runs small, a narrow measure or a dense professional tool.\n\nUI text can go smaller. Start inputs and menus at `14px` and captions at `13px`, and rarely go below `12px`. Set `font-size` in [`rem`](spacing-and-sizing.md#units) so the reader's browser font size still applies.\n\n## Font smoothing on the root\n\nApply `-webkit-font-smoothing: antialiased` and `-moz-osx-font-smoothing: grayscale` once on the root, never per component. Tailwind's `antialiased` sets both.\n\n## Language and bidi behavior\n\nSet `lang` so browsers and assistive technology pick the right quotes, hyphenation and pronunciation. Set `dir` on the document and wherever direction changes. Wrap a mixed-direction value in `<bdi>` and never reorder digits by hand. How long paragraphs and numbers behave is in [wrapping-and-punctuation.md](wrapping-and-punctuation.md#internationalization).\n\n## Trim text boxes in tight containers\n\nIn buttons and badges, the space a font reserves above and below its letters makes text sit low. `text-box: trim-both cap alphabetic` trims it as a progressive enhancement. See the [edges, keywords and support](spacing-and-sizing.md#text-trimming-with-text-box).\n\n## Decorative text in CSS, not images\n\nBuild drop caps, gradient text, outlines and text shadows in CSS so the text stays selectable and searchable. The [properties and their support](details-and-accessibility.md#decorative-text) are in the reference.\n\n## Keep useful text selectable\n\nKeep text selectable by default. `::selection` can carry brand into the reading experience, as long as the selected combination stays legible. [Styling other ranges](details-and-accessibility.md#selection) such as search matches is in the reference.\n\n`user-select: none` belongs on a draggable or gesture-driven surface where accidental selection interferes. Never across the interface and never because a button label can be highlighted.\n\n## Before you finish\n\n| Pattern | Fix |\n| --- | --- |\n| `font-weight` value with no matching weight in the loaded `@font-face` files | Load that face or use a weight the family ships |\n| `font-synthesis: none` on body or interface text | Remove it; set only the unwanted longhand after checking the fallback stack |\n| `font-variation-settings: \"wght\"` or `\"opsz\"` | `font-weight`; drop `\"opsz\"` so `font-optical-sizing: auto` applies |\n| `font-feature-settings: \"tnum\"` or `\"zero\"` | `font-variant-numeric: tabular-nums` or `slashed-zero` |\n| `line-height` in `px` or `rem` | A unitless value |\n| `leading-none` or `leading-tight` on a description, card body or anything that can wrap | `1.4` or more |\n| `letter-spacing` in `px` | The same value in `em` |\n| Body `font-size` in `px` | `rem` |\n| Weight `100`–`300` or `font-thin`, `font-light` on text under `18px` | `400` or heavier |\n| `text-wrap: balance` on a paragraph | `text-wrap: pretty` |\n| `text-align: justify` in application UI | `text-align: start` |\n| `-webkit-line-clamp` without `display: -webkit-box` | Add `display: -webkit-box` and `-webkit-box-orient: vertical` |\n| A timer, counter, price or numeric column without `tabular-nums` | `font-variant-numeric: tabular-nums` |\n| `...`, `--` or a hyphenated range in rendered strings | `…`, an em dash or an en dash |\n| `text-decoration-skip-ink: none` | Remove it; set `text-underline-offset` or `from-font` instead |\n| `<input>` with `text-sm` or `14px` and no mobile override | One of the **Inputs at 16px on mobile** fixes |\n| `-webkit-font-smoothing` or `antialiased` inside a component | Move it to the root |\n| Mixed-direction value such as a name, number or URL without `<bdi>` or `dir` | Wrap it in `<bdi>` |\n| `user-select: none` or `select-none` on a layout root or text container | Remove it; keep it only on a drag or gesture surface |\n\n## Reporting\n\n**Severity.** `HIGH` makes text unreadable, truncates content with no way back to it or clips text at 320px width or 200% zoom. The last two are `interface` escalation triggers, so they stay `HIGH` however minor the surface. `MEDIUM` breaks the type system or the visual heading hierarchy. `LOW` is isolated polish.\n\n**Verification.** Without a browser, check declared size and weight per heading level, descending within each semantic section. Check declared line-height, units and measure. Check truncation rules against realistic string lengths. With one, compare computed values, then resize the viewport with real content to catch wrapping, lone last-line words and truncation. Report every check you could not run as `Not verified`.\n\n**Format.** Group findings under the principle each violates, ordered by severity, one row per root cause listing every location it appears in:\n\n| Severity | Location | Before | After | Why |\n| --- | --- | --- | --- | --- |\n\n`Location` is `path/to/file:line`. `Why` names the principle and the user impact.\n\nEnd with `Block` when any `HIGH` remains, `Approve` otherwise, leaving the rest in the table as work to do. Never `Approve` coverage you did not inspect. With nothing to report, state \"No actionable typography findings\" and report verification.\n",
8912
+ "choosing-fonts.md": "# Choosing fonts\n\nFont categories, fallback stacks for a type change, file formats and why fonts look the way they do.\n\n## Choosing a typeface\n\n| Category | Traits | Use for |\n| --- | --- | --- |\n| Serif | Small strokes at the ends of letters guide the eye along a line | Long passages, editorial reading |\n| Sans-serif | Clean, even shapes that stay crisp at small sizes | Default for most interfaces (Helvetica, Inter, Geist) |\n| Monospace | Every glyph the same width | Code and fixed-width identifiers; numeric columns use `tabular-nums` instead |\n| Display | Drawn for large headlines | Marketing headlines, hero text |\n| Script | Mimics handwriting | Rare, decorative moments |\n\n\"Display\" in a font's name does not make it a display font. SF Pro and Heldane ship a `Display` variant for large sizes and a `Text` variant for small ones. Use the variant matching the size you are setting.\n\n## Fallback stacks for a type change\n\nWhen a type change is asked for, two routes. `system-ui` gives each operating system's own interface face. A commercial face such as Helvetica Now is a brand decision and still needs a fallback stack.\n\n```css\n/* The platform's native interface face */\nhtml {\n font-family: system-ui, sans-serif;\n}\n\n/* Commercial brand face with safe fallbacks */\nhtml {\n font-family: \"Helvetica Now\", \"Helvetica Neue\", Arial, sans-serif;\n}\n```\n\n## Formats\n\n| Format | Notes |\n| --- | --- |\n| `.woff2` | Brotli compression, broadly supported. Use this on the web. |\n| `.woff` | Older compression. Fallback only for very old browsers. |\n| `.ttf` / `.otf` | Desktop formats with no built-in compression, so larger files. Only when there is no other option. |\n\nHow the files load is the project's concern.\n\n## Anatomy of a typeface\n\n| Term | Meaning |\n| --- | --- |\n| x-height | Height of a lowercase `x` |\n| Cap height | Height of uppercase letters |\n| Baseline | The invisible line letters sit on |\n| Ascender | Part of a letter rising above the x-height |\n| Descender | Part dropping below the baseline |\n\nThese measurements are why two fonts at the same `font-size` look like different sizes. A large x-height looks bigger.\n",
8913
+ "css-cheat-sheet.md": "# CSS cheat sheet\n\nOne-line lookup for every typography CSS declaration covered by this skill, with the Tailwind 4 equivalent. Where no utility exists, the arbitrary-value form is shown. Pick the column that matches the project: the declaration in plain CSS, CSS Modules, styled-components or StyleX codebases, the utility in Tailwind codebases.\n\n## Font\n\n| Declaration | What it does | Tailwind |\n| --- | --- | --- |\n| `font-family: sans-serif` | The sans family | `font-sans` |\n| `font-family: serif` | The serif family | `font-serif` |\n| `font-family: monospace` | The monospace family | `font-mono` |\n| `font-size` | Size from the type scale | `text-*` |\n| `font-weight` | Any value from 1 to 1000 | `font-*` |\n| `font-style: italic` | Switch to italic style | `italic` |\n| `-webkit-font-smoothing` + `-moz-osx-font-smoothing` | Smooth macOS font rendering; apply once at the root | `antialiased` |\n| `font-synthesis: none` | Disable all synthesized forms after verifying fallbacks and emphasis | `[font-synthesis:none]` |\n| `font-feature-settings` | Toggle OpenType features | `[font-feature-settings:\"ss01\"]` |\n| `font-variation-settings` | Tune variable font axes | `[font-variation-settings:\"GRAD\"_80]` |\n| `font-optical-sizing: auto` | Optical size follows font size; the default, so leave it | none needed |\n| `font-variant-caps` | Real small capitals | `[font-variant-caps:small-caps]` |\n| `font-variant-position` | Real super and subscripts | `[font-variant-position:super]` |\n| `font-variant-numeric: tabular-nums` | Equal-width digits | `tabular-nums` |\n| `font-variant-numeric: slashed-zero` | Tell 0 from O | `slashed-zero` |\n\n## Spacing and layout\n\n| Declaration | What it does | Tailwind |\n| --- | --- | --- |\n| `letter-spacing` | Space between letters | `tracking-*` |\n| `line-height` | Space between lines | `leading-*` |\n| `font-kerning` | Kerning on or off | `[font-kerning:none]` |\n| `text-box: trim-both` | Trim space above and below | `[text-box:trim-both_cap_alphabetic]` |\n| `max-width` on text columns | Cap at ~60–75 characters per line | `max-w-xl` / `max-w-2xl` / `max-w-prose` |\n| `text-align` | Where lines start and end | `text-start` / `text-center` |\n\n## Wrapping and overflow\n\n| Declaration | What it does | Tailwind |\n| --- | --- | --- |\n| `text-wrap: balance` | Even out heading lines | `text-balance` |\n| `text-wrap: pretty` | Keep a lone word off the last line | `text-pretty` |\n| `text-overflow: ellipsis` | Ellipsis for clipped text | `text-ellipsis` |\n| `overflow: hidden` + `text-overflow: ellipsis` + `white-space: nowrap` | Single-line truncation | `truncate` |\n| `-webkit-line-clamp` + `display: -webkit-box` + `-webkit-box-orient: vertical` + `overflow: hidden` | Cut off after N lines | `line-clamp-*` |\n| `overflow-wrap: break-word` | Break long strings | `break-words` |\n| `white-space: nowrap` | Stop wrapping | `whitespace-nowrap` |\n| `text-transform` | Change the casing | `uppercase` / `capitalize` |\n| `hyphens: auto` | Hyphenate by the element's `lang` | `hyphens-auto` |\n\n## Decoration and interaction\n\n| Declaration | What it does | Tailwind |\n| --- | --- | --- |\n| `text-decoration-line: underline` | Draw an underline | `underline` |\n| `text-decoration-color` | Underline color | `decoration-*` |\n| `text-decoration-thickness` | Underline thickness | `decoration-1` / `decoration-2` |\n| `text-underline-offset` | Push the line down | `underline-offset-*` |\n| `text-underline-position: from-font` | Underline position from the font | `[text-underline-position:from-font]` |\n| `text-decoration-style` | Dotted, dashed or wavy | `decoration-dotted` / `decoration-wavy` |\n| `text-decoration-thickness: from-font` | Underline set by the font | `decoration-from-font` |\n| `text-decoration-skip-ink` | Gaps around descenders; `auto` is the default | `[text-decoration-skip-ink:auto]` |\n| `caret-color` | Tint the text cursor | `caret-*` |\n| `user-select: none` | Suppress selection on a verified drag/gesture conflict only | `select-none` |\n| `text-shadow` | Shadow behind the letters | `text-shadow-*` |\n| `-webkit-text-stroke` | Outline the letters | `[-webkit-text-stroke:1px_black]` |\n| `background-clip: text` | Clip a background to the letters | `bg-clip-text` |\n| `initial-letter` | Size a drop cap | `[initial-letter:3]` |\n",
8914
+ "details-and-accessibility.md": "# Details and accessibility\n\nUnderlines, selection, form text and decorative text.\n\n## Underlines\n\nFrom the font's own metrics:\n\n```css\na {\n text-underline-position: from-font;\n text-decoration-thickness: from-font;\n}\n```\n\nA dotted underline on an abbreviation:\n\n```css\nabbr {\n text-decoration: underline dotted;\n}\n```\n\nOr tune manually:\n\n```css\na {\n text-decoration-thickness: 1px;\n text-underline-offset: 3px;\n text-decoration-color: var(--color-gray-1000);\n transition: text-decoration-color 200ms ease-out;\n}\n\na:hover {\n text-decoration-color: var(--color-gray-1200);\n}\n```\n\n## Selection\n\n- `::target-text` styles the phrase a shared link scrolls to.\n- The Custom Highlight API styles ranges you pick yourself, like search matches, without extra markup.\n\n## Forms and editable text\n\n- `::placeholder` styles the hint in an empty field.\n- `caret-color` colors the blinking insertion bar. A fully custom caret is hard to build and rarely worth it.\n\n### iOS input zoom\n\n**Size up on mobile.** The input renders at `16px` on small screens and drops to the design size from the `sm` breakpoint up. Nothing to compensate, but the mobile input no longer matches the desktop one.\n\n```tsx\n<input className=\"text-base sm:text-sm\" type=\"email\" />\n```\n\n**Scale the text down.** Keep `font-size` at `16px` so Safari never zooms, then render at the intended size with a transform. Widen the element by the inverse of the scale so it still fills its container once shrunk. Divide `line-height` by the same factor so the intended leading survives. `origin-left` pins the text to the start edge, `origin-right` under RTL. Above the breakpoint, drop the transform and set the real size.\n\n```tsx\n// 13px rendered from a 16px font-size: 13 / 16 = 0.8125\n<div className=\"flex h-10 items-center overflow-hidden rounded-[10px] bg-gray-300 px-2.5 focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-blue-500\">\n <input\n className=\"h-full w-[calc(100%/0.8125)] shrink-0 origin-left rtl:origin-right scale-[0.8125] bg-transparent text-base leading-[calc(1.125/0.8125)] outline-none sm:w-full sm:scale-100 sm:text-[13px]\"\n type=\"email\"\n />\n</div>\n```\n\n`shrink-0` preserves the compensated width inside the flex wrapper. The wrapper clips the oversized layout box and draws an unscaled focus outline. Use the project's focus color and verify its contrast against the surrounding surface.\n\nThe transform shrinks the whole box, not only the glyphs, so let a wrapper draw the field's surface and keep the input transparent. A background, border or ring on the scaled element shrinks with the text and misses the intended hit area.\n\n## Decorative text\n\n| Property | Effect |\n| --- | --- |\n| `::first-letter` | Drop cap, widely supported |\n| `::first-line` | Styles only the first line |\n| `initial-letter` | Sizes the drop cap; Safari needs `-webkit-initial-letter`, no Firefox yet |\n| `background-clip: text` | Clips a background or gradient to the letter shapes |\n| `-webkit-text-stroke` | Outlines the letters; works across modern browsers despite the prefix |\n| `text-shadow` | Like `box-shadow` but follows the character shapes |\n\nIf `-webkit-text-stroke` draws lines inside the letters, the font has overlapping contours. Variable fonts often keep them unmerged, so use the static cut for stroked text.\n",
8915
+ "spacing-and-sizing.md": "# Spacing and sizing\n\nUnits, a starting type scale, the heading mapping, line-height for wrapping text and text trimming.\n\n## Units\n\n| Unit | Behavior |\n| --- | --- |\n| `px` | Fixed |\n| `em` | Scales with the current font size |\n| `rem` | Scales with the root font size |\n| `%` on `font-size` | Relative to the parent's font size, behaves like `em` |\n\n## Type scale\n\nPick an existing scale or define one. Tailwind's `text-xs` through `text-9xl`, each pairing a size with a line height, is a solid ready-made choice.\n\nA starting point for a product interface, named by role:\n\n```css\n:root {\n --text-caption: 0.8125rem;\n --text-body: 1rem;\n --text-heading: 1.125rem;\n --text-title: 1.5rem;\n --text-display: 2.25rem;\n}\n```\n\n| Role | Size | Line-height | Weight |\n| --- | --- | --- | --- |\n| Display | `2.25rem` (36px) | `1.1` | `600` |\n| Title | `1.5rem` (24px) | `1.2` | `600` |\n| Heading | `1.125rem` (18px) | `1.3` | `600` |\n| Body | `1rem` (16px) | `1.5` | `400` |\n| Caption | `0.8125rem` (13px) | `1.4` | `400` |\n\nEmphasis within a role is one weight step up (`400` → `500`), not a size change.\n\n## Heading hierarchy\n\n```css\nh1 { font-size: var(--text-display); }\nh2 { font-size: var(--text-title); }\nh3 { font-size: var(--text-heading); }\n```\n\nIn Tailwind, centralize the per-level classes in a component or `@layer base` rather than repeating them inline.\n\n## Line-height for wrapping text\n\n```css\n/* Bad: card description at heading leading */\n.card-description { line-height: 1.1; }\n\n/* Good: it wraps to 3 lines, so it reads as body text */\n.card-description { line-height: 1.4; }\n```\n\n## Text trimming with text-box\n\n`text-box` takes which edges to trim (`trim-both`, `trim-start`, `trim-end`) and where:\n\n| Keyword | Trims at |\n| --- | --- |\n| `cap` | The cap height (top) |\n| `alphabetic` | The baseline (bottom) |\n| `text` | The font's own text edge, keeping room for descenders |\n\n```css\n/* trim top and bottom */\n.badge {\n text-box: trim-both cap alphabetic;\n}\n\n/* trim only the top */\n.heading {\n text-box: trim-start cap;\n}\n\n/* trim only the bottom */\n.label {\n text-box: trim-end alphabetic;\n}\n```\n\nSupported in Chromium 133+ and Safari 18.2+, not yet Firefox. Unsupported browsers keep the default leading.\n",
8916
+ "variable-fonts-and-opentype.md": "# Variable fonts and OpenType\n\nWhat a font file can do beyond drawing letters and how to reach those abilities from CSS.\n\n## Static vs variable\n\n- **Static font:** one weight and one style per file. Regular, medium and bold is three files.\n- **Variable font:** a whole range in one file. Any value in it works, such as `font-weight: 589`.\n\nA variable font is not automatically better. At one or two weights, static files can be smaller. At several weights, optical sizes or custom axes, a variable font usually wins.\n\n## Disable synthesis narrowly\n\n`font-synthesis: none` disables weight, style, small-cap and position synthesis together. Scope it to an isolated treatment you have verified across the fallback stack:\n\n```css\n.brand-wordmark {\n font-synthesis: none;\n}\n```\n\nTo disable one mode, use its longhand: `font-synthesis-weight`, `font-synthesis-style`, `font-synthesis-small-caps` or `font-synthesis-position`.\n\n## Axes\n\nVariable-font controls, each with a four-letter tag. A font supports only the axes its designer included.\n\n| Axis | Tag | Controls |\n| --- | --- | --- |\n| Weight | `wght` | Stroke thickness (like `font-weight`) |\n| Optical size | `opsz` | Details and spacing tuned for the display size |\n| Width | `wdth` | Glyph width |\n| Slant | `slnt` | Slant angle |\n| Custom | e.g. `GRAD` (Roboto Flex) | Whatever the designer built |\n\nInter's variable file exposes only `wght` and `opsz`.\n\nOptical sizes predate variable fonts. Many families still ship them as separate `Text` and `Display` files instead of an `opsz` axis.\n\n## Raw tags only for custom axes\n\n`font-variation-settings` silently does nothing on a non-variable fallback, where `font-weight` still picks the nearest face:\n\n```css\n/* Good: common axes use the properties */\n.heading {\n font-weight: 650;\n}\n\n/* Good: custom axis with no property of its own */\n.heading-grade {\n font-variation-settings: \"GRAD\" 80;\n}\n\n/* Bad: weight via raw tag breaks on fallback fonts */\n.heading {\n font-variation-settings: \"wght\" 650;\n}\n```\n\n## OpenType features\n\nUnlike axes, features work the same on static and variable fonts. A font ships only the features its designer included.\n\n| Tag | Feature |\n| --- | --- |\n| `tnum` | Tabular numbers: every digit the same width |\n| `zero` | Slashed zero: `0` distinct from `O` |\n| `liga` | Ligatures: joins pairs like \"fi\" into one shape |\n| `ss01`–`ss20` | Stylistic sets (numbered slots) |\n| `cv01`–`cv99` | Character variants (numbered slots) |\n\nCommon features have a `font-variant-*` property, and `font-feature-settings` is for the rest:\n\n```css\n/* Good: common features use the properties */\n.price {\n font-variant-numeric: tabular-nums;\n}\n\n.id {\n font-variant-numeric: slashed-zero;\n}\n\n/* Good: niche feature with no property of its own */\n.logo {\n font-feature-settings: \"ss01\" 1;\n}\n```\n\n## Small caps, superscripts, subscripts\n\n- **Small capitals:** uppercase letters drawn at a smaller size. Enable real ones with `font-variant-caps`.\n- **Superscripts** sit above the normal line, the 2 in x², and **subscripts** below it, as in H₂O. Enable proper glyphs with `font-variant-position`.\n\nBoth require the font to include the glyphs.\n\n## Stylistic sets and character variants\n\n`ss01` = stylistic set, slot 01. `cv11` = character variant, slot 11. What each slot does differs font to font, which is why they are numbered, not named. Check the font's docs. In Inter, `ss01` switches to open digits and `cv11` swaps in a single-story `a`.\n",
8917
+ "wrapping-and-punctuation.md": "# Wrapping and punctuation\n\nLine length, typographic characters and mixed-direction text.\n\n## Measure (line length)\n\nAny unit works. `65ch` measures characters directly, one `ch` being the width of the `0` in the current font, and a pixel or rem cap is just as good. At a `16px` body size the 60–75 character range lands roughly between `560px` and `680px` depending on the font. Tailwind's `max-w-xl` (`576px`), `max-w-2xl` (`672px`) and `max-w-prose` (`65ch`) all fit. Recheck the cap if the body font size changes.\n\n## Smart punctuation\n\n| Instead of | Use |\n| --- | --- |\n| Straight quotes `\"...\"` | Curly quotes that curve around the text (keep straight quotes in code) |\n| Hyphen in ranges | En dash: `2010–2020` |\n| Two hyphens for an aside | Em dash character |\n| Three periods `...` | The single ellipsis character `…` |\n| Regular space in `10 km` | `&nbsp;` so the value never breaks apart |\n| Uncontrolled word breaks | `&shy;` to mark where a word may break, or `hyphens: auto` with the correct `lang` |\n\n## Internationalization\n\n- **Long paragraphs align by their own language.** A one- or two-line snippet follows the surrounding UI's direction. A paragraph of three or more lines aligns to its own script instead, so an English paragraph stays start-aligned LTR even inside an RTL interface. `text-align: start` with the correct `lang` and `dir` on the paragraph element handles this.\n- **Digits keep their order.** A phone number or \"541\" reads identically in RTL, because the Unicode bidi algorithm keeps digit runs left to right. `<bdi>` repairs a value whose order adjacent RTL text disturbs.\n"
8918
+ },
8919
+ "ui": {
8920
+ "LICENSE": "MIT License\n\nCopyright (c) 2026 Jakub Krehel\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n",
8921
+ "NOTICE.md": "# Notice\n\nThis skill is a fork of `better-ui/` from the repository `jakubkrehel/skills`, at commit `4c66b1fa86e9ab70187b43d7da9b6fdb29f2ef63`, fetched 2026-10-16.\n\nLocal edits:\n\n- Renamed by `scripts/fork-interface-skills.sh` at dotfiles commit `5fbdc4b` (`better-ui` to `ui`).\n- Dropped `agents/openai.yaml`.\n- Added the precedence block after the introduction of `SKILL.md`.\n- Reconciled the clause in `ui/SKILL.md` that limited project tokens, so a project token always wins.\n- Extended the `description:` with animation triggers (menu or dialog enter and exit, staggered items, hover and press feedback, icon swaps), so `ui` wins those prompts over `motion-design`: 18/18 should, 0/12 near-miss on 2026-10-17, against 12/18 before.\n\nUpdating: diff upstream from the pinned commit, have a frontier model port the changes over the local edits, then bump the pin.\n\nThe upstream license (MIT) applies; the text is in `LICENSE`:\n\n> MIT License\n>\n> Copyright (c) 2026 Jakub Krehel\n>\n> Permission is hereby granted, free of charge, to any person obtaining a copy\n> of this software and associated documentation files (the \"Software\"), to deal\n> in the Software without restriction, including without limitation the rights\n> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n> copies of the Software, and to permit persons to whom the Software is\n> furnished to do so, subject to the following conditions:\n>\n> The above copyright notice and this permission notice shall be included in all\n> copies or substantial portions of the Software.\n>\n> THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n> SOFTWARE.\n",
8922
+ "SKILL.md": "---\nname: ui\ndescription: Polishes the surfaces, icons and motion in your project with exact values for border radius, optical alignment, shadows, icon states and animation. Use when adding or tuning a UI animation or transition, such as a menu or dialog enter and exit, staggered list items, hover and press feedback or an icon swap.\n---\n\n# UI polish\n\nThis skill holds the visual polish for surfaces, icons and motion, with the exact value each detail takes. It applies once the underlying interaction is sound, and a polish finding never outranks a broken interaction.\n\nA decision of this repository (resolved at its applicable scope with `aval resolve`) outranks a value in this skill. Apply token requirements and gap routing only where the repository's decisions require them, keeping their named exemptions. On `duro-stack`, a non-exempt measure without a token is a design-system gap under ADR-0006/0027, never a local literal.\n\nWhere Duro defines the value, these win: the `spacing.css.ts` radii, `shadows.css.ts` and `motion.css.ts`.\n\nThe ui values with no token (`300ms` enter, `100ms` stagger, `cubic-bezier(0.2, 0, 0, 1)`) are known design-system gaps tracked in one issue (https://github.com/fredericrous/duro-design-system/issues/120), so a review reports them once against that issue and not per use.\n\n## Exact values, optional polish\n\nThe values below are exact, not ranges to approximate. `cubic-bezier(0.2, 0, 0, 1)` is not `cubic-bezier(0.4, 0, 0.2, 1)`, and `0.96` is not `0.95`. The optical nudges and the concentric padding cutoff are the exceptions. They are starting points, judged by eye.\n\nKeep the project's component library, tokens and density. A project token always wins over a value here, and this skill's value applies only where the project has no token for it. Match the project's motion language wherever no rule here gives a value. A deliberate and consistent project convention, such as a style with no shadows, is a preference and not a finding. The same detail done two ways within the project is a finding.\n\nText wrapping, font rendering, tabular numbers and text spacing belong to `typography`. Hit areas, keyboard support, ARIA and the reduced-motion requirement belong to `accessibility`. Grouping, section spacing, breakpoints and spatial RTL belong to `layout`, except directional icon mirroring. Color tokens and contrast measurement belong to `colors`.\n\n## Outer radius equals inner radius plus padding\n\nWhere nested surfaces share a visible, even inset, the outer radius is the inner radius plus the padding plus any border width. Past `24px` of padding, or where the padding is deliberately asymmetric, treat the layers as separate surfaces and keep each one's radius token. Recipes are in [surfaces.md](surfaces.md).\n\n## Align optically where geometry looks off\n\nWhere geometric centering looks off, nudge by eye. Give a button `2px` less padding on its icon side, shift a play triangle toward its point and fix asymmetric glyphs in the SVG itself. Recipes are in [surfaces.md](surfaces.md#optical-alignment).\n\n## Shadows for elevation, borders for structure\n\nWhere a border exists only to create depth, replace it with layered transparent `box-shadow` values. Keep borders on dividers, separators, table cells and selected states. Keep them on form inputs too, whose boundary needs 3:1 non-text contrast under `accessibility`. Focus rings belong to `accessibility` as well.\n\nForced-colors mode removes every `box-shadow`. Keep `border: 1px solid transparent` under a shadow ring so that mode still draws an edge. Recipes are in [surfaces.md](surfaces.md#shadow-recipes).\n\n## Outline images in pure black or white\n\nGive content images a `1px` outline inset by `1px`. Use `oklch(0 0 0 / 0.1)` in light mode and `oklch(1 0 0 / 0.1)` in dark. Never use a palette near-black, a tinted neutral or the accent color, because the tint shows as a colored fringe on the image edge. Skip transparent artwork such as logos and illustrations. The recipe is in [surfaces.md](surfaces.md#image-outlines).\n\n## Transitions, not keyframes, for interactive state\n\nDrive interactive state changes with CSS transitions or a motion library's springs, which retarget when the user reverses mid-flight. Keyframes run a fixed timeline and cannot reverse, so reserve them for sequences that run once. See [animations.md](animations.md).\n\n## Press scales to 0.96\n\nA pressed button scales to `0.96` over `150ms` with `ease-out`, and a disabled one never scales. Give the button an opt-out where the motion would distract, through the component's existing variant API or a `static` prop. See [recipes for CSS, Tailwind and Motion](animations.md#scale-on-press).\n\n## High-frequency interactions get no animation\n\nKeystrokes, row hovers and tab switches get instant feedback, or a transition of `150ms` or less on `opacity` or `background-color`. Reserve expressive motion for infrequent moments such as a view's first load, a success state or an empty state.\n\nEvery animated state change also leaves a static cue in the form of a color, icon or label. Motion is never the only feedback channel.\n\n## Gate motion behind the reduced-motion preference\n\n`accessibility` owns the requirement, and no recipe here ships without it. Run movement, scale and blur only under `prefers-reduced-motion: no-preference`. Under reduced motion, replace them with an opacity cross-fade rather than removing the element instantly. Recipes for CSS, Tailwind and Motion are in [animations.md](animations.md#reduced-motion-fallback).\n\n## Stagger infrequent entrances by 100ms\n\nWhere sequence communicates hierarchy in a staged entrance, split the content into semantic chunks such as title, description and actions. Stagger them `100ms` apart, each entering with opacity, `4px` of blur and `12px` of `translateY` over `300ms` with `ease-out`. Never stagger routine interactions. See [enter-exit.md](enter-exit.md).\n\n## Exits are shorter and smaller than enters\n\nExit with opacity, `4px` of blur and a fixed `-12px` `translateY` over `150ms` with `ease-out`, never the full container height. Slide fully out only where the destination carries meaning, such as a drawer closing. Remove the element instantly where motion adds no information. See [enter-exit.md](enter-exit.md#exit-animations).\n\n## Skip state animations on first render\n\nSet `initial={false}` on an `AnimatePresence` that wraps a state swap, so the default state does not animate in on mount. Never set it around an intentional entrance such as a staggered hero. See [animations.md](animations.md#skip-animation-on-page-load).\n\n## Cross-fade contextual icons with exact values\n\nWhere an icon swaps on an infrequent state change, such as play to pause or copy to copied, cross-fade it. Scale runs `0.25` to `1`, opacity `0` to `1` and blur `4px` to `0px`. With a motion library, use `{ type: \"spring\", duration: 0.3, bounce: 0 }`, and bounce is always `0`. Without one, keep both icons in the DOM and cross-fade over `300ms` with `cubic-bezier(0.2, 0, 0, 1)`.\n\nA tab's icon swap and the actions a row reveals on hover fall under **High-frequency interactions get no animation**. Both recipes are in [icon-transitions.md](icon-transitions.md).\n\n## Suppress transitions on theme switch\n\nDisable every transition for the theme swap, force a style flush and restore transitions after the next frame. Otherwise every color transition fires at once and the switch smears. See the [recipe](animations.md#suppress-transitions-on-theme-switch).\n\n## Transition only what changes\n\nName the exact properties, as in `transition-property: scale, opacity`, and never `all`. See [performance.md](performance.md).\n\n## Name the animated property in will-change\n\nAdd `will-change` only after you see first-frame stutter, and name the property you animate: `scale` for `scale`, `transform` for `transform`. Limit it to transform properties, `opacity` and `filter`, and never use `all`. See [performance.md](performance.md#use-will-change-sparingly).\n\n## Hover effects only on hover-capable pointers\n\nPut hover-only styling behind `@media (hover: hover)`. On touch, `:hover` latches after a tap and reads as a stuck selected state. Tailwind 4's `hover:` variant already compiles under that query, and Tailwind 3's does not. Where a control draws its own pressed state, set `-webkit-tap-highlight-color: transparent` so the default gray flash does not double it.\n\n## Contain scroll inside overlays\n\nGive scrollable dialogs, drawers, menus and side panels `overscroll-behavior: contain`, so scrolling past their end never scrolls the page behind.\n\n## Match icon stroke to text weight\n\nAn icon's rendered stroke tracks the weight of the text beside it, from `1.5px` at 400 to `2.5px` at 700. Use one icon library per surface. The table, sizing and grid rules are in [icons.md](icons.md).\n\n## One SVG, recolored per state\n\nIcons use `currentColor` and take hover, selected and disabled states from CSS color and opacity, never from separate assets. Outline is the default variant, and fill marks the active state. Under `dir=\"rtl\"`, mirror only icons whose meaning follows reading direction. See [icons.md](icons.md#icons-in-rtl).\n\n## Before you finish\n\n| Detection | Fix |\n| --- | --- |\n| Padded parent and child with the same `rounded-*` or `border-radius` | Add the padding to the outer radius |\n| `box-shadow: 0 0 0 1px` ring with no `border` beside it | `border: 1px solid transparent` |\n| `outline-slate-*`, `outline-zinc-*` or a hex outline color on `img` | `outline-black/10` and `dark:outline-white/10` |\n| `transition-all` or `transition: all` | Name the changing properties |\n| `animation:` set on `:hover`, `.open` or another toggled class | A transition on the same properties |\n| `active:scale-95`, `scale-90` or `whileTap` below `0.96` | `0.96` |\n| `:active` scale with no `:disabled` exclusion | `enabled:active:` or `:not(:disabled):active` |\n| `bounce:` set to anything but `0` | `bounce: 0` |\n| `opacity: 0` in a base rule that only an `animation` reveals | Hidden state in the `from` keyframe, `animation-fill-mode: both` |\n| `translate`, `scale`, `filter` or `motion.*` animation with no `prefers-reduced-motion`, `motion-safe:` or `MotionConfig` guard | Gate it and cross-fade opacity under reduced motion |\n| Color transitions plus a theme toggle with no `disableTransitionOnChange` or override | The theme-switch suppression recipe |\n| `will-change: all`, or `will-change` on an element that never animates | Name the animated property or delete it |\n| `fill=\"#...\"` or `stroke=\"#...\"` inside an icon SVG | `currentColor` |\n| Plain-CSS `:hover` or Tailwind 3 `hover:` outside `@media (hover: hover)` | Wrap it in the query |\n| `overflow: auto` or `overflow-y-auto` on a dialog, drawer or menu with no `overscroll-behavior` | `overscroll-behavior: contain` |\n\n## Reporting\n\n**Severity.** `HIGH` breaks an interaction, as a keyframe toggle that cannot reverse or a hover state stuck on touch does. Two of `interface`'s escalation triggers land here and are `HIGH` on sight. One is motion that ignores `prefers-reduced-motion`, and the other is a state change carried by motion alone. `MEDIUM` is a visible inconsistency in surfaces, icons or motion. `LOW` is isolated polish.\n\n**Verification.** Without a browser, read every state the component defines from the code, such as hover, pressed, selected, loading and empty, with its durations and easings. With one, walk each state and replay motion at 10% speed in the browser's Animations panel. Report every check you could not run as `Not verified`.\n\n**Format.** Group findings under the principle each violates, ordered by severity, one row per root cause listing every location it appears in:\n\n| Severity | Location | Before | After | Why |\n| --- | --- | --- | --- | --- |\n\n`Location` is `path/to/file:line`. `Why` names the principle and the user impact.\n\nEnd with `Block` when any `HIGH` remains, `Approve` otherwise, leaving the rest in the table as work to do. Never `Approve` coverage you did not inspect. With nothing to report, state \"No actionable UI-polish findings\" and report verification.\n",
8923
+ "animations.md": "# Animations\n\nRecipes for interruptible transitions, press feedback, first-render behavior, theme switches and the reduced-motion fallback. Staged entrances and exits live in [enter-exit.md](enter-exit.md), and icon swaps in [icon-transitions.md](icon-transitions.md).\n\n## Interruptible animations\n\n| | CSS transitions | CSS keyframe animations |\n| --- | --- | --- |\n| **Behavior** | Interpolate toward the latest state | Run on a fixed timeline |\n| **Interrupted** | Retarget mid-flight and reverse | Snap or restart from the beginning |\n| **Use for** | Hover, toggle, open and close | Sequences that run once, such as enters and loading |\n\n```css\n/* Good: clicking again mid-animation reverses smoothly */\n.drawer {\n transform: translateX(-100%);\n transition: transform 200ms ease-out;\n}\n.drawer.open {\n transform: translateX(0);\n}\n\n/* Bad: closing mid-animation snaps */\n.drawer.open {\n animation: slideIn 200ms ease-out forwards;\n}\n```\n\n## Scale on press\n\n```css\n.button {\n transition-property: scale;\n transition-duration: 150ms;\n transition-timing-function: ease-out;\n}\n\n.button:not(:disabled):active {\n scale: 0.96;\n}\n```\n\n```tsx\n// Tailwind\n<button className=\"transition-transform duration-150 ease-out enabled:active:scale-[0.96]\">\n Click me\n</button>\n\n// Motion\n<motion.button whileTap={disabled ? undefined : { scale: 0.96 }} disabled={disabled}>\n Click me\n</motion.button>\n```\n\n### Static prop pattern\n\nWhere the button has no variant API to carry the opt-out, apply the scale class conditionally on a `static` prop:\n\n```tsx\nconst tapScale = \"enabled:active:scale-[0.96]\";\n\nfunction Button({ static: isStatic, className, children, ...props }) {\n return (\n <button\n className={cn(\n \"transition-transform duration-150 ease-out\",\n !isStatic && tapScale,\n className,\n )}\n {...props}\n >\n {children}\n </button>\n );\n}\n\n// Usage\n<Button>Click me</Button> {/* scales on press */}\n<Button static>Submit</Button> {/* no scale */}\n```\n\n## Skip animation on page load\n\nThe icon recipe in [icon-transitions.md](icon-transitions.md#motion) shows `initial={false}` used correctly. It suits icon swaps, toggles, tabs and segmented controls, anything with a default state on page load.\n\nSetting it around a first-time entrance skips that entrance entirely. Check the component on a full page refresh before adding it.\n\n```tsx\n// Bad: initial={false} skips the staggered page enter\n<AnimatePresence initial={false}>\n <motion.div initial=\"hidden\" animate=\"visible\" variants={...}>\n ...\n </motion.div>\n</AnimatePresence>\n```\n\n## Suppress transitions on theme switch\n\nInject a stylesheet that turns off every transition, force a style flush so the new colors commit while it applies, then remove it after the next frame:\n\n```tsx\n\"use client\";\n\nimport { useEffect } from \"react\";\n\nexport function DisableThemeTransitions() {\n useEffect(() => {\n const mql = window.matchMedia(\"(prefers-color-scheme: dark)\");\n\n const handleChange = () => {\n const style = document.createElement(\"style\");\n style.append(\n document.createTextNode(\n \"*,*::before,*::after{transition:none !important}\"\n )\n );\n document.head.append(style);\n\n void document.body.offsetHeight; // forces a synchronous style flush\n\n requestAnimationFrame(() => {\n requestAnimationFrame(() => style.remove());\n });\n };\n\n mql.addEventListener(\"change\", handleChange);\n return () => mql.removeEventListener(\"change\", handleChange);\n }, []);\n\n return null;\n}\n```\n\nThe new theme resolves while the override is still in the document, so no transition starts. The nested `requestAnimationFrame` removes the override only after that paint.\n\nThat covers the OS-level change. An in-app toggle needs the same steps around its own flip: apply the override, change the theme, flush and remove. `next-themes` ships this as `disableTransitionOnChange`.\n\n## High-frequency motion\n\n```css\n/* Good: high-frequency hover gets a minimal transition */\n.row:hover {\n background-color: var(--surface-hover);\n transition: background-color 100ms ease-out;\n}\n\n/* Bad: every hover replays a full entrance */\n.row:hover .row-icon {\n animation: bounceIn 500ms;\n}\n```\n\n## Reduced-motion fallback\n\nThe opacity fade runs for everyone, and movement, scale and blur join it only without the preference:\n\n```css\n.panel {\n transition: opacity 150ms ease-out;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .panel {\n transition-property: opacity, translate, scale, filter;\n transition-duration: 300ms;\n }\n}\n```\n\nIn Tailwind, prefix movement, scale and blur utilities with `motion-safe:`. In Motion, wrap the app in `<MotionConfig reducedMotion=\"user\">`. It turns off transform and layout animations under the preference and keeps opacity. Blur still runs there, so branch on `useReducedMotion()` where a recipe animates `filter`.\n",
8924
+ "enter-exit.md": "# Enter and exit animations\n\nRecipes for staged entrances and the exits that follow them. For interactive state feedback see [animations.md](animations.md), and for icon swaps see [icon-transitions.md](icon-transitions.md).\n\n## Enter animations\n\n1. **Split** into semantic groups such as title, description and actions.\n2. **Stagger** the groups `100ms` apart.\n3. **Split a short display headline** into words, staggered `80ms` apart, where it should read word by word.\n4. **Combine** `opacity`, `blur` and `translateY` for each group's enter.\n\n### Motion\n\n```tsx\nconst item = {\n hidden: { opacity: 0, y: 12, filter: \"blur(4px)\" },\n visible: {\n opacity: 1,\n y: 0,\n filter: \"blur(0px)\",\n transition: { duration: 0.3, ease: \"easeOut\" },\n },\n};\n\nfunction PageHeader() {\n return (\n <motion.div\n initial=\"hidden\"\n animate=\"visible\"\n variants={{ visible: { transition: { staggerChildren: 0.1 } } }}\n >\n <motion.h1 variants={item}>Welcome</motion.h1>\n <motion.p variants={item}>A description of the page.</motion.p>\n <motion.div variants={item}>\n <Button>Get started</Button>\n </motion.div>\n </motion.div>\n );\n}\n```\n\n### CSS\n\nThe hidden state lives in the `from` keyframe, so an item whose animation never runs stays visible. Reduced motion keeps only the opacity keyframe.\n\n```css\n.stagger-item {\n animation: fade-in 300ms ease-out both;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n .stagger-item {\n animation-name: fade-in-up;\n }\n}\n\n.stagger-item:nth-child(2) { animation-delay: 100ms; }\n.stagger-item:nth-child(3) { animation-delay: 200ms; }\n\n@keyframes fade-in {\n from { opacity: 0; }\n}\n\n@keyframes fade-in-up {\n from {\n opacity: 0;\n transform: translateY(12px);\n filter: blur(4px);\n }\n}\n```\n\n### Entering from display: none\n\nA popover, dialog or element toggled from `display: none` has no previous style to transition from. `@starting-style` supplies one, and `transition-behavior: allow-discrete` keeps it rendered through the exit. The open state's duration applies on enter and the closed state's on exit.\n\n```css\n[popover] {\n opacity: 0;\n translate: 0 12px;\n transition-property: opacity, translate, display, overlay;\n transition-duration: 150ms;\n transition-timing-function: ease-out;\n transition-behavior: allow-discrete;\n}\n\n[popover]:popover-open {\n opacity: 1;\n translate: 0 0;\n transition-duration: 300ms;\n}\n\n@starting-style {\n [popover]:popover-open {\n opacity: 0;\n translate: 0 12px;\n }\n}\n```\n\n`@starting-style` and discrete `display` transitions are Baseline 2024. `overlay` is Chromium only, and browsers without support show and hide instantly.\n\n## Exit animations\n\n### Subtle exit\n\n```tsx\n<motion.div\n exit={{\n opacity: 0,\n y: -12,\n filter: \"blur(4px)\",\n transition: { duration: 0.15, ease: \"easeOut\" },\n }}\n>\n {content}\n</motion.div>\n```\n\n### Full exit\n\nSlide fully out only where the destination carries meaning, such as a card returning to a list or a drawer closing:\n\n```tsx\n<motion.div\n exit={{\n opacity: 0,\n x: \"-100%\",\n transition: { duration: 0.2, ease: \"easeOut\" },\n }}\n>\n {content}\n</motion.div>\n```\n\n### Good and bad\n\n```css\n/* Good: subtle exit */\n.item-exit {\n opacity: 0;\n transform: translateY(-12px);\n transition: opacity 150ms ease-out, transform 150ms ease-out;\n}\n\n/* Bad: dramatic exit that steals focus */\n.item-exit {\n opacity: 0;\n transform: translateY(-100%) scale(0.5);\n transition: all 400ms ease-out;\n}\n\n/* Sometimes correct: remove at once when motion adds no information */\n.item-exit {\n display: none;\n}\n```\n",
8925
+ "icon-transitions.md": "# Icon transitions\n\nCross-fade recipes for an icon that swaps on a state change, with and without a motion library. Icon weight, color and direction live in [icons.md](icons.md).\n\n## Which library\n\nCheck the project's `package.json`. Import from `\"motion/react\"` when `motion` is installed, or `\"framer-motion\"` when that is. Where both exist, follow the imports the component or its nearest peers already use. Where neither is present, use the CSS cross-fade and never add a dependency just for icon transitions.\n\n## Motion\n\n```tsx\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction IconButton({ isActive, icon: Icon }) {\n return (\n <button>\n <AnimatePresence initial={false} mode=\"popLayout\">\n <motion.span\n key={isActive ? \"active\" : \"inactive\"}\n initial={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n exit={{ opacity: 0, scale: 0.25, filter: \"blur(4px)\" }}\n transition={{ type: \"spring\", duration: 0.3, bounce: 0 }}\n >\n <Icon />\n </motion.span>\n </AnimatePresence>\n </button>\n );\n}\n```\n\n`initial={false}` keeps the default icon from animating in on mount.\n\n## CSS, both icons in the DOM\n\nBoth icons stay mounted in one grid cell, so enter and exit both animate and the larger icon sets the size. The `motion-reduce:` classes leave only the opacity fade under reduced motion.\n\n```tsx\nconst layer = cn(\n \"[grid-area:1/1] transition-[opacity,filter,scale] duration-300\",\n \"ease-[cubic-bezier(0.2,0,0,1)]\",\n \"motion-reduce:scale-100 motion-reduce:blur-[0px]\"\n);\nconst shown = \"scale-100 opacity-100 blur-[0px]\";\nconst hidden = \"scale-[0.25] opacity-0 blur-[4px]\";\n\nfunction IconButton({ isActive, ActiveIcon, InactiveIcon }) {\n return (\n <button>\n <span className=\"grid place-items-center\">\n <span className={cn(layer, isActive ? shown : hidden)}>\n <ActiveIcon />\n </span>\n <span className={cn(layer, isActive ? hidden : shown)}>\n <InactiveIcon />\n </span>\n </span>\n </button>\n );\n}\n```\n\nTailwind 4's `scale-*` utilities write the `scale` property, so `transition-[...scale]` animates them. Tailwind 3's write `transform`, so name `transform` in the transition instead.\n\n## Which icons animate\n\n| Animate | Don't animate |\n| --- | --- |\n| Infrequent state swaps such as play to pause, like to liked or copy to copied | Hover-revealed actions in list rows |\n| Icons in a toolbar that appears on selection | Tab and navigation icon swaps |\n| Loading and success indicators | Decorative or always-visible icons |\n",
8926
+ "icons.md": "# Icons\n\nIcon stroke weight, sizing, state colors, variants and RTL mirroring.\n\n## Match icon stroke to text weight\n\n| Adjacent text | Rendered stroke width |\n| --- | --- |\n| Regular (400), 14–16px | `1.5px` |\n| Medium or semibold (500–600) | `2px` |\n| Bold (700), or an emphasized standalone icon | `2.5px` |\n\nThe widths are rendered pixels. A 24px-grid icon drawn at 16px scales its stroke by 16/24, so `stroke-width=\"2\"` paints about `1.33px`. Use the library's absolute-stroke option, such as Lucide's `absoluteStrokeWidth`, or set `vector-effect: non-scaling-stroke` on the SVG's shapes.\n\n```tsx\n// Good: stroke tuned to the semibold label\n<button className=\"flex items-center gap-2 font-semibold\">\n <Plus size={16} strokeWidth={2} absoluteStrokeWidth />\n New project\n</button>\n\n// Bad: 1.5px stroke against a bold label\n<button className=\"flex items-center gap-2 font-bold\">\n <Plus size={16} strokeWidth={1.5} absoluteStrokeWidth />\n New project\n</button>\n```\n\n- **One library per surface.** Never mix icon libraries on one toolbar. Where the library has no stroke variants, keep its native stroke and use size or color for emphasis.\n- **Size inline icons at `1em`–`1.25em`** of the adjacent font size, so the pair scales together.\n\n## One SVG, recolored per state\n\nDraw every icon with `currentColor` and let CSS state drive the color. Strip hardcoded fills such as `fill=\"#666\"` to `currentColor` when importing icons.\n\n```html\n<svg fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">…</svg>\n```\n\n```css\n/* Use the project's color tokens */\n.icon-button { color: var(--icon-muted); }\n.icon-button:hover { color: var(--icon-strong); }\n.icon-button[aria-pressed=\"true\"] { color: var(--icon-active); }\n.icon-button:disabled { opacity: 0.4; }\n```\n\n```html\n<!-- Tailwind, with the project's token names -->\n<button class=\"text-icon-muted hover:text-icon-strong aria-pressed:text-icon-active disabled:opacity-40\">\n <BookmarkIcon />\n</button>\n```\n\n## Outline default, fill active\n\nWhere an icon set offers outline and filled variants, use them as a state pair, never interchangeably:\n\n| Variant | Use for |\n| --- | --- |\n| Outline | Default state in toolbars, list rows and inline text |\n| Fill | Selected or active state, such as the active tab, a toggled bookmark or a liked heart |\n\n```tsx\n// Good: variant communicates state\n<TabIcon variant={isActive ? \"solid\" : \"outline\"} />\n\n// Bad: filled everywhere, so the active tab has no state signal\n<TabIcon variant=\"solid\" />\n```\n\nCross-fade the swap with the values in [icon-transitions.md](icon-transitions.md) only where the change is infrequent, such as a bookmark or a like. An active tab swaps instantly.\n\n## Design at render size\n\n- Test every icon at the smallest size it renders, often `16px`. It must stay recognizable there.\n- Prefer a simplified glyph for small contexts over scaling down detailed artwork.\n- Where the set ships per-size grids such as `16`, `20` and `24`, use the grid that matches the render size. A 24px drawing scaled to 16px lands off the pixel grid and renders soft.\n- Always SVG, never raster.\n\n## Icons in RTL\n\nUnder `dir=\"rtl\"`, flip icons whose meaning is tied to reading direction, and leave the rest alone:\n\n| Flip | Don't flip |\n| --- | --- |\n| Back and forward arrows, navigation chevrons | Logos and brand marks |\n| Text-block glyphs for alignment, lists and indent | Checkmarks |\n| Speaker and volume waves | Physical objects such as clocks, cups and pencils |\n| Directional glyphs such as \"send\" | Media playback, which stays LTR by convention |\n\nUse one mechanism per element. Two flips on one icon can compound back to unflipped.\n\n```css\n[dir=\"rtl\"] .icon-directional {\n scale: -1 1;\n}\n```\n\n```tsx\n// Tailwind\n<ChevronRightIcon className=\"rtl:-scale-x-100\" />\n```\n\nAnalyze composite icons part by part. A badge or slash overlay may keep its position while the base glyph flips. Accessible names for icon-only buttons belong to `accessibility`.\n",
8927
+ "performance.md": "# Performance\n\nRecipes for transition specificity and compositing hints.\n\n## Transition only what changes\n\n`transition: all` animates properties nobody meant to animate, such as colors on a theme flip. On layout properties such as `width` and `padding` it re-runs layout on every frame. Tailwind's bare `transition` is a curated list rather than `all`, and still covers more than most elements change.\n\n```css\n/* Good: only what changes */\n.button {\n transition-property: scale, background-color;\n transition-duration: 150ms;\n transition-timing-function: ease-out;\n}\n\n/* Bad: everything */\n.button {\n transition: all 150ms ease-out;\n}\n```\n\n```tsx\n// Good: explicit properties\n<button className=\"transition-[scale,background-color] duration-150 ease-out\">\n\n// Bad: transition all\n<button className=\"transition-all duration-150 ease-out\">\n```\n\nIn Tailwind 4, `transition-transform` covers `transform, translate, scale, rotate`, and in Tailwind 3 only `transform`. Use it when animating transforms alone. Mixing transform and other properties takes the bracket syntax, as in `transition-[scale,opacity,filter]`.\n\n## Use will-change sparingly\n\n`will-change` promotes an element to its own compositing layer ahead of time. Without it the browser promotes when the animation starts, and that one-time promotion can stutter on the first frame. Each extra layer costs memory, so never add it preemptively.\n\n```css\n/* Good: names the property that animates */\n.animated-card {\n will-change: scale, opacity;\n}\n\n/* Bad: never all */\n.animated-card {\n will-change: all;\n}\n\n/* Bad: these never composite */\n.animated-card {\n will-change: background-color, padding;\n}\n```\n\n| Property | Worth `will-change` |\n| --- | --- |\n| `transform`, `translate`, `scale`, `rotate` | Yes |\n| `opacity` | Yes |\n| `filter` | Yes |\n| `clip-path` | Rarely, since compositing it is not reliable across browsers |\n| `top`, `left`, `width`, `height` | No |\n| `background`, `border`, `color` | No |\n\n`will-change` on a transform property or `filter` makes the element the containing block for its `position: fixed` descendants, so a fixed modal or tooltip inside it stops tracking the viewport. Any of these values also creates a stacking context, which can reorder `z-index` against siblings.\n",
8928
+ "surfaces.md": "# Surfaces\n\nRecipes for concentric radius, optical alignment, shadow rings and image outlines.\n\n## Concentric border radius\n\n```\nouterRadius = innerRadius + padding + borderWidth\n```\n\n```css\n/* Good: concentric radii */\n.card {\n border-radius: 20px; /* 12 + 8 */\n padding: 8px;\n}\n.card-inner {\n border-radius: 12px;\n}\n\n/* Bad: same radius on both */\n.card {\n border-radius: 12px;\n padding: 8px;\n}\n.card-inner {\n border-radius: 12px;\n}\n```\n\n```tsx\n// Good: outer radius accounts for padding\n<div className=\"rounded-2xl p-2\"> {/* 16px radius, 8px padding */}\n <div className=\"rounded-lg\"> {/* 8px radius = 16 - 8 */}\n ...\n </div>\n</div>\n\n// Bad: same radius on both\n<div className=\"rounded-xl p-2\">\n <div className=\"rounded-xl\">\n ...\n </div>\n</div>\n```\n\n## Optical alignment\n\n### Buttons with text and an icon\n\nStart the icon side at `2px` less padding than the text side, then judge by eye.\n\n```css\n/* Good: less padding on the trailing icon side */\n.button-with-icon {\n padding-inline-start: 16px;\n padding-inline-end: 14px;\n}\n\n/* Bad: equal padding pushes the icon visually outward */\n.button-with-icon {\n padding-inline: 16px;\n}\n```\n\n```tsx\n// Tailwind\n<button className=\"ps-4 pe-3.5 flex items-center gap-2\">\n <span>Continue</span>\n <ArrowRightIcon />\n</button>\n```\n\n### Play button triangles\n\nA triangle's geometric center sits left of its visual center, so shift it toward its point:\n\n```css\n.play-button svg {\n transform: translateX(2px); /* physical on purpose: playback icons never mirror */\n}\n```\n\n### Asymmetric icons\n\nStars, arrows and carets carry uneven visual weight. Fix the `viewBox` or path in the SVG, so the component needs no extra offset. Where the asset cannot change, nudge its wrapper:\n\n```tsx\n<span className=\"translate-x-px\">\n <StarIcon />\n</span>\n```\n\n## Shadow recipes\n\n### Light mode\n\nThree layers. The first acts as a 1px ring, the second adds lift and the third ambient depth:\n\n```css\n:root {\n --shadow-border:\n 0px 0px 0px 1px oklch(0 0 0 / 0.06),\n 0px 1px 2px -1px oklch(0 0 0 / 0.06),\n 0px 2px 4px 0px oklch(0 0 0 / 0.04);\n --shadow-border-hover:\n 0px 0px 0px 1px oklch(0 0 0 / 0.08),\n 0px 1px 2px -1px oklch(0 0 0 / 0.08),\n 0px 2px 4px 0px oklch(0 0 0 / 0.06);\n}\n```\n\n### Dark mode\n\nDepth shadows vanish on dark backgrounds, so dark mode keeps one white ring:\n\n```css\n/* Redefine under the project's dark selector */\n--shadow-border: 0 0 0 1px oklch(1 0 0 / 0.08);\n--shadow-border-hover: 0 0 0 1px oklch(1 0 0 / 0.13);\n```\n\n### Usage\n\n```css\n.card {\n border: 1px solid transparent; /* forced-colors mode drops box-shadow and paints this border */\n box-shadow: var(--shadow-border);\n transition-property: box-shadow;\n transition-duration: 150ms;\n transition-timing-function: ease-out;\n}\n\n.card:hover {\n box-shadow: var(--shadow-border-hover);\n}\n```\n\nThe transparent border keeps the box the same size as the border it replaces.\n\n## Image outlines\n\n```css\n:root {\n --image-outline: oklch(0 0 0 / 0.1);\n}\n/* Redefine under the project's dark selector */\n--image-outline: oklch(1 0 0 / 0.1);\n\n.content-image {\n outline: 1px solid var(--image-outline);\n outline-offset: -1px;\n}\n```\n\n```tsx\n<img\n className=\"outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10\"\n src={src}\n alt={alt}\n/>\n```\n\nNever `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*` or a palette near-black such as `#111827`.\n\nUse `outline` rather than `border` because it never changes the image's size. At `outline-offset: -1px` it sits just inside the edge and follows the corner radius.\n"
8929
+ }
8930
+ },
8896
8931
  "icons": {
8897
8932
  "groups": [
8898
8933
  {
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Frederic Rousseau
3
+ Copyright (c) 2026 Jakub Krehel
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,36 @@
1
+ # Notice
2
+
3
+ This skill is a fork of `better-colors/` from the repository `jakubkrehel/skills`, at commit `4c66b1fa86e9ab70187b43d7da9b6fdb29f2ef63`, fetched 2026-10-16.
4
+
5
+ Local edits:
6
+
7
+ - Renamed by `scripts/fork-interface-skills.sh` at dotfiles commit `5fbdc4b` (`better-colors` to `colors`).
8
+ - Dropped `agents/openai.yaml`.
9
+ - Added the precedence block after the introduction of `SKILL.md`.
10
+ - Reconciled the clause in `ui/SKILL.md` that limited project tokens, so a project token always wins.
11
+
12
+ Updating: diff upstream from the pinned commit, have a frontier model port the changes over the local edits, then bump the pin.
13
+
14
+ The upstream license (MIT) applies; the text is in `LICENSE`:
15
+
16
+ > MIT License
17
+ >
18
+ > Copyright (c) 2026 Jakub Krehel
19
+ >
20
+ > Permission is hereby granted, free of charge, to any person obtaining a copy
21
+ > of this software and associated documentation files (the "Software"), to deal
22
+ > in the Software without restriction, including without limitation the rights
23
+ > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24
+ > copies of the Software, and to permit persons to whom the Software is
25
+ > furnished to do so, subject to the following conditions:
26
+ >
27
+ > The above copyright notice and this permission notice shall be included in all
28
+ > copies or substantial portions of the Software.
29
+ >
30
+ > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31
+ > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32
+ > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33
+ > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34
+ > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35
+ > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36
+ > SOFTWARE.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: colors
3
+ description: Helps you build and check a color system for your project. It generates palettes, names semantic tokens, converts between formats and measures contrast.
4
+ ---
5
+
6
+ # Colors
7
+
8
+ This skill builds and audits color systems. It generates ramps, maps their steps to roles and token names, converts notation and measures rendered contrast pairs.
9
+
10
+ A decision of this repository (resolved at its applicable scope with `aval resolve`) outranks a value in this skill. Apply token requirements and gap routing only where the repository's decisions require them, keeping their named exemptions. On `duro-stack`, a non-exempt measure without a token is a design-system gap under ADR-0006/0027, never a local literal.
11
+
12
+ Where Duro defines the value, these win: the CLAUDE.md "Color Semantics" table, `packages/tokens/src/tokens/colors.css.ts` and the themes.
13
+
14
+ Never report a contrast value you did not measure, and never estimate a color you could compute. Notation, a tinted neutral and a gradient's interpolation space are project choices, not findings. A broken role mapping, a failing pair and an out-of-gamut step are findings. Perceived lightness throughout means OKLCH `L`, from `0` to `1`.
15
+
16
+ Whether a pair is required to pass belongs to `accessibility`. Surfaces, shadows and icon color belong to `ui`.
17
+
18
+ ## Match the project's color system
19
+
20
+ Reuse the project's tokens and notation. A consistent hex system beats hex with `oklch()` scattered through it.
21
+
22
+ For a new system, `oklch()` is the best default, because its numbers behave the way the ramp rules below describe. Everywhere else, a color library produces the same ramp in the project's own notation ([color-formats.md](color-formats.md)).
23
+
24
+ ## A system is ramps, not colors
25
+
26
+ One neutral ramp, one accent ramp and only the status ramps the product actually renders. A `warning` ramp nothing imports is maintenance for zero pixels. A second accent hue earns its place only when two things must be distinguishable at a glance. Otherwise use more steps of the one accent ramp.
27
+
28
+ ## Every step has a job
29
+
30
+ A ramp is not a gradient to pick from by eye. Each step exists because a role needs it: page background, component hover, border, solid fill, body text. Do not generate a step no role consumes. Both the Tailwind `50`–`950` and Radix `1`–`12` conventions map to those roles ([palette-structure.md](palette-structure.md)).
31
+
32
+ ## Name primitives by hue, semantics by role
33
+
34
+ Primitives name a value (`--blue-600`) and are never applied in a component. Semantic tokens name a job (`--color-text-secondary`), point at a primitive and are the only tier components reference. That seam is what lets a theme repoint colors without touching components ([token-naming.md](token-naming.md)).
35
+
36
+ ## Use a token only in its role
37
+
38
+ Never borrow a token because its value is right today. A separator used as a text color works until borders get lighter, and then the text goes with them. If a role has no token, add the token.
39
+
40
+ ## Hold the hue across the ramp
41
+
42
+ A ramp holds one hue end to end, peaks in vividness mid-ramp and steps more finely at the light end. Build it with a color library, never by eye ([palette-generation.md](palette-generation.md)).
43
+
44
+ ## One color, one meaning
45
+
46
+ Use a color for one purpose across the whole interface, treating anything within `15°` of OKLCH hue as the same color ([color-usage.md](color-usage.md)). Never let color be the only carrier of meaning. Pair it with an icon or a label, a requirement `accessibility` owns.
47
+
48
+ ## Fill exactly one action per view
49
+
50
+ When filled color encodes primary emphasis, one primary action gets it and peers stay neutral. Put the color on the background, not the label. A filled button reads as primary across the room; accent-colored text on a neutral button reads as a link.
51
+
52
+ Keep an established hierarchy that already signals emphasis another way. Several colored backgrounds are fine when they encode distinct states or categories rather than competing as peers.
53
+
54
+ ## Measure the rendered pair, then report
55
+
56
+ Measure a foreground against the background it actually renders on, not the page background. When a pair fails, report the pair, its measured value and the threshold it misses, then leave the colors alone. They are a design decision. Change them only when asked, and remeasure after ([contrast.md](contrast.md)).
57
+
58
+ ## Pick a gradient's interpolation space
59
+
60
+ The space is a look, not a correctness setting. Default to `in oklab`, and reach for `in oklch` when a two-hue gradient goes gray in the middle ([color-usage.md](color-usage.md)).
61
+
62
+ ## Before you finish
63
+
64
+ | Pattern | Fix |
65
+ | --- | --- |
66
+ | A hex, `rgb(` or `oklch(` literal in a component file where a token exists | Reuse or add the role token, in the project's notation |
67
+ | One `oklch(` value in a codebase otherwise written in hex | Write it in the established notation unless a migration is in scope |
68
+ | `var(--blue-600)` or `bg-blue-600` in a component file | Point a semantic token at the primitive and use that |
69
+ | A semantic name containing a hue or a component, as in `--color-blue-button` or `--color-sidebar-gray` | Rename it for its role: `--color-accent-solid`, `--color-bg-surface` |
70
+ | `--color-primary` and `--color-text-primary` both defined | Rename the brand token `accent` |
71
+ | A `border` or `separator` token inside `color:`, or a `text` token inside `background:` | Add a token for the missing role |
72
+ | `hsl(` ramp values that differ only in lightness | Rebuild in OKLCH with a constant hue |
73
+ | `50`–`200` stepping as far apart in OKLCH `L` as mid-ramp steps do | Tighten the light end to about `0.04`–`0.05` per step |
74
+ | One chroma or saturation number copied across every hue's ramp | Use the same proportion of each hue's own maximum |
75
+ | A status hue within `15°` of the accent hue | Move it at least `15°` away, or give the destructive action a distinct treatment |
76
+ | Dark tokens equal to the light ramp in reverse step order | Lower the accent's chroma, widen the dark end and remeasure every pair |
77
+ | Color tokens set under both `prefers-color-scheme` and a `.dark` class | Pick one switching mechanism and use it throughout |
78
+ | A contrast fix that changes hue and leaves lightness | Change lightness, the channel contrast responds to |
79
+ | `prefers-contrast: more` overriding a color for both appearances in one block | Give each appearance its own increased-contrast value |
80
+ | Text on a color with alpha, such as `/50`, `rgba(` or `color-mix(` with `transparent` | Measure the rendered result, or use a solid token |
81
+ | `text-white` on a Tailwind `500` fill | Measure it; white on most `500` hues fails 4.5:1, so move the fill to `600` |
82
+ | `oklch()` chroma beyond sRGB with no sRGB value declared before it | Declare the sRGB value first, then the vivid one inside `@media (color-gamut: p3)` |
83
+
84
+ ## Reporting
85
+
86
+ **Severity.** `HIGH` makes content unreadable or assigns a misleading semantic color. `MEDIUM` is a noticeable theme, token or gamut failure. `LOW` is isolated polish.
87
+
88
+ Three symptoms are `HIGH` on sight whatever the surface, matching `interface`'s escalation triggers. They are body or control text whose rendered pair fails its required ratio, state or meaning carried by color alone and a semantic color used against its meaning.
89
+
90
+ **Verification.** Without a browser: token values, the gamut of every declared color, both theme blocks present and contrast computed from the declared token pair. With one: the background actually rendered behind the text, including opacity and any image beneath it, measured in both light and dark. Report every check you could not run as `Not verified`.
91
+
92
+ **Format.** Group findings under the principle each violates, ordered by severity, one row per root cause listing every location it appears in:
93
+
94
+ | Severity | Location | Before | After | Why |
95
+ | --- | --- | --- | --- | --- |
96
+
97
+ `Location` is `path/to/file:line`. `Why` names the principle and the user impact.
98
+
99
+ End with `Block` when any `HIGH` remains, `Approve` otherwise, leaving the rest in the table as work to do. Never `Approve` coverage you did not inspect. With nothing to report, state "No actionable color findings" and report verification.
@@ -0,0 +1,90 @@
1
+ # Color formats
2
+
3
+ Which notation to write colors in, how to convert between them and what happens at the edges of a display's gamut. Every other rule in this skill is stated perceptually and holds whatever notation you write.
4
+
5
+ ## Choosing a notation
6
+
7
+ | Notation | Good for | Weakness |
8
+ | --- | --- | --- |
9
+ | Hex | Universal support, compact, what design tools hand you | Opaque. No channel is readable or editable by hand |
10
+ | `rgb()` | Same reach as hex, readable alpha | Channels do not correspond to anything a designer thinks about |
11
+ | `hsl()` | Channels look like design controls | Its lightness is not perceptual and its hue drifts; a ramp built by varying lightness bunches at one end and shifts hue |
12
+ | `oklch()` | Perceptually uniform lightness, stable hue, predictable ramps | Baseline 2023, so very old browser matrices need a fallback |
13
+
14
+ **Match whatever the project already uses.** Notation is not a defect: a project on hex is not doing it wrong.
15
+
16
+ **For a genuinely new color system, `oklch()` is the best default.** Even lightness steps stay even, and a fixed hue stays fixed. See [palette-generation.md](palette-generation.md).
17
+
18
+ ```css
19
+ oklch(L C H) /* lightness 0–1, chroma 0–~0.4, hue 0–360 */
20
+ oklch(L C H / alpha) /* alpha uses a slash, never a comma */
21
+ ```
22
+
23
+ ## Converting
24
+
25
+ Convert when the user asks, when an agreed migration is in scope or when the project is standardizing on a notation and this value is the straggler. Never convert an isolated value in a project that deliberately uses something else and never because this skill happened to load.
26
+
27
+ When conversion is in scope, change the values and nothing else:
28
+
29
+ - Leave CSS keywords alone: `currentColor`, `inherit`, `transparent`, `initial`, `unset`.
30
+ - Leave gradient functions alone. Convert the color stops inside them; do not touch the interpolation method.
31
+ - Leave colors in third-party configs that expect a specific format.
32
+ - Preserve comments and formatting.
33
+
34
+ ```css
35
+ /* Before */
36
+ color: #3b82f6;
37
+ border: 1px solid rgba(0, 0, 0, 0.1);
38
+
39
+ /* After */
40
+ color: oklch(0.623 0.188 259.815);
41
+ border: 1px solid oklch(0 0 0 / 0.1);
42
+ ```
43
+
44
+ Bulk conversion is a migration, not cleanup. It shifts every rendered color by a rounding margin and touches files nobody asked about, so it has to be the task rather than a side effect.
45
+
46
+ ## Gamut
47
+
48
+ Every sRGB color exists in Display P3, but not the reverse. The extra room is only at high chroma and varies by hue. At OKLCH `L` `0.65`, green gains about `0.07` of chroma while blue gains about `0.015`.
49
+
50
+ A color more vivid than its display can render gets gamut-mapped, which in current browsers mostly means clipped. It flattens neighbouring steps into one rendered color, so the top of a ramp can lose its distinctions on an sRGB screen. Maximum vividness varies by hue and lightness. Cyans top out far lower than reds and purples, so a clipping ramp clips at some steps and not others.
51
+
52
+ The fix is to reduce vividness while holding hue and lightness. Generate ramps against sRGB unless the product is display-restricted, and add P3 as an enhancement:
53
+
54
+ ```css
55
+ .success {
56
+ background: #11ad32; /* oklch(0.65 0.2 145), inside sRGB */
57
+ }
58
+
59
+ @media (color-gamut: p3) {
60
+ .success {
61
+ background: oklch(0.65 0.27 145);
62
+ }
63
+ }
64
+ ```
65
+
66
+ Order matters. The sRGB value comes first, so an sRGB screen shows a color you chose rather than one the browser clipped. The P3 rule overrides only where it will render.
67
+
68
+ For browser matrices predating `oklch()` support, the same layering works with `@supports`:
69
+
70
+ ```css
71
+ .accent {
72
+ background: #3b82f6;
73
+ }
74
+
75
+ @supports (color: oklch(0 0 0)) {
76
+ .accent {
77
+ background: oklch(0.62 0.19 259);
78
+ }
79
+ }
80
+ ```
81
+
82
+ Check the project's actual browser matrix before adding this. On a modern baseline it is dead weight.
83
+
84
+ ## Modern CSS worth knowing
85
+
86
+ - **`color-mix()`** derives one color from another, as in `color-mix(in oklab, var(--color-accent-solid) 15%, white)` for a tinted background. Useful for states, but keep generated values out of the token layer, since a mixed color cannot be inspected in a design tool.
87
+ - **Relative color syntax** adjusts one channel of an existing color. `oklch(from var(--color-accent-solid) calc(l - 0.1) c h)` darkens the accent by hand. Powerful and easy to overuse, since a token defined by three chained derivations is unreadable.
88
+ - **`light-dark()`** puts both appearances in one declaration. See [palette-generation.md](palette-generation.md).
89
+
90
+ All three resolve at render time. Compute the resolved value with a color library, or measure the rendered result, before reporting contrast.