@gusnips/tokens 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @gusnips/tokens
2
+
3
+ The colour names a Tailwind 4 app uses, and the dark-mode switch behind them. You bring the
4
+ colours.
5
+
6
+ ```bash
7
+ bun add @gusnips/tokens
8
+ ```
9
+
10
+ ```css
11
+ @import "tailwindcss";
12
+ @import "@gusnips/tokens/index.css";
13
+ ```
14
+
15
+ Two lines, and you can write `bg-card text-card-foreground` anywhere. It renders in light mode
16
+ and in dark mode, and no component ever writes a `dark:` variant.
17
+
18
+ ## The mechanism
19
+
20
+ Twenty names — `background`, `foreground`, `card`, `popover`, `primary`, `secondary`, `muted`,
21
+ `accent`, `destructive`, their `-foreground` pairs, plus `border`, `input`, `ring` and `scrim`.
22
+
23
+ Dark mode rebinds **the same names** under a `.dark` class. `bg-card` is one utility that
24
+ resolves to a different colour below a `.dark` ancestor. That is the whole trick.
25
+
26
+ ## Your colours
27
+
28
+ The values that ship are a plain grey scale — readable, and deliberately characterless, so the
29
+ app looks like nothing until you decide what it looks like. Override after the import, and set
30
+ both halves:
31
+
32
+ ```css
33
+ @theme {
34
+ --color-primary: #7c3aed;
35
+ }
36
+ .dark {
37
+ --color-primary: #a78bfa;
38
+ }
39
+ ```
40
+
41
+ Skip the second line and your daytime purple stays on screen at night.
42
+
43
+ ## Two names you cannot pick freely
44
+
45
+ A build check measures both, so a bad value fails your build instead of reaching someone.
46
+
47
+ - **`--color-input` needs 3:1 against `--color-background`.** It is a field border, and WCAG
48
+ 1.4.11 asks 3:1 of anything that outlines a control. This is the one people miss: a grey
49
+ picked to sit nicely next to `--color-border` will not clear it. One codebase this came from
50
+ pointed `input` at its divider grey — 1.6:1 — so every text field in two apps had a border
51
+ some people cannot see.
52
+ - **`--color-primary` needs its own dark value.** Holding one brand colour across both modes is
53
+ the trap. A fill chosen against a white page is dark, and on a near-black card it lands _on_
54
+ the 3:1 line instead of clear of it. Lift it for dark, and flip `--color-primary-foreground`
55
+ with it — which is why nothing in this family writes `text-white`.
56
+
57
+ ## What is not here
58
+
59
+ Fonts, shadows, radii, type scale, motion. Radii and type sizes already have Tailwind's own
60
+ `--radius-*` and `--text-*` names, so retune those in your `@theme` and every `rounded-lg` in
61
+ every component follows. The rest is what your product looks like, and it stays in your product.
62
+
63
+ One trap worth knowing before you write your own `@theme`: **`--duration-*` is not a Tailwind
64
+ namespace.** `--duration-standard: 250ms` compiles to no utility at all, so `duration-standard`
65
+ in a `className` is dead text and the transition quietly runs at Tailwind's default. `--ease-*`
66
+ beside it _does_ work, which is what hides it — the easing lands, the duration does not. Write
67
+ `duration-250`, or keep the token and put `transition-duration: var(--duration-standard)` in a
68
+ real rule. One codebase had 61 of these.
69
+
70
+ ## The base rules
71
+
72
+ `index.css` also brings a handful of rules that every app needs and nobody remembers:
73
+
74
+ - one `:focus-visible` outline, drawn with `--color-ring`, so it follows your theme into dark
75
+ - `cursor: pointer` on buttons, which browsers do not give you
76
+ - `color-scheme`, which stops a two-tone seam where a phone toolbar retracts
77
+ - reduced motion clamped to `0.01ms` rather than `none`, so animations still land on their end
78
+ pose instead of never appearing — including the two delay properties, which are the half
79
+ people forget
80
+ - a thin scrollbar whose thumb reads `--color-input`, and `.scrollbar-none` for a tab strip
81
+
82
+ Want them separately? `@gusnips/tokens/theme.css` is the names with no rules,
83
+ `@gusnips/tokens/base.css` the rules with no names.
84
+
85
+ MIT · part of [frontkit](https://github.com/gusnips/frontkit)
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@gusnips/tokens",
3
+ "version": "0.1.0",
4
+ "description": "One Tailwind 4 @theme file: the semantic token contract, the dark-mode mechanism, and the base rules every app needs. Colours stay per-brand.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Salomé",
8
+ "homepage": "https://github.com/gusnips/frontkit/tree/main/tokens#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/gusnips/frontkit/issues"
11
+ },
12
+ "exports": {
13
+ "./theme.css": "./src/theme.css",
14
+ "./base.css": "./src/base.css",
15
+ "./index.css": "./src/index.css"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "sideEffects": [
23
+ "*.css"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "keywords": [
29
+ "tailwindcss",
30
+ "tailwind-v4",
31
+ "design-tokens",
32
+ "theme",
33
+ "dark-mode",
34
+ "css"
35
+ ],
36
+ "scripts": {
37
+ "build": "bun run scripts/compile-check.ts",
38
+ "typecheck": "tsc --noEmit -p tsconfig.json",
39
+ "test": "bun run scripts/compile-check.ts",
40
+ "lint": "eslint scripts",
41
+ "sync:docs": "cp ../LICENSE .",
42
+ "prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run sync:docs",
43
+ "release:patch": "bun pm version patch && bun publish --access public",
44
+ "release:minor": "bun pm version minor && bun publish --access public",
45
+ "release:major": "bun pm version major && bun publish --access public"
46
+ },
47
+ "devDependencies": {
48
+ "@tailwindcss/cli": "^4.3.3",
49
+ "@types/node": "^24.12.0",
50
+ "tailwindcss": "^4.3.3",
51
+ "typescript": "^5.9.3"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/gusnips/frontkit.git",
56
+ "directory": "tokens"
57
+ }
58
+ }
package/src/base.css ADDED
@@ -0,0 +1,121 @@
1
+ /*
2
+ * The base rules every app needs and nobody remembers to write.
3
+ *
4
+ * Each rule below is here because a product shipped without it and something broke. No
5
+ * colours and no brand: every value reads a token from theme.css, so import that first —
6
+ * or import index.css, which does both in the right order.
7
+ */
8
+
9
+ @layer base {
10
+ /* The browser paints two areas itself: the overscroll rubber-band, and the strip a
11
+ 100svh surface leaves uncovered when a mobile toolbar retracts. With no explicit
12
+ `color-scheme` it paints them its own default near-black, a hair off our surface —
13
+ a hard two-tone seam across every full-height loading and error screen. Painting
14
+ `html` (not just `body`) and declaring the scheme makes those UA-painted areas match.
15
+ `color-scheme` also fixes form controls, scrollbars and the caret, which is the
16
+ second half of the same win. */
17
+ html {
18
+ color-scheme: light;
19
+ background-color: var(--color-background);
20
+ color: var(--color-foreground);
21
+ }
22
+
23
+ /* Matches wherever the class lives. Products put it on <html>, which is what makes the
24
+ canvas follow; on <body> it still corrects controls and scrollbars in the subtree. */
25
+ .dark {
26
+ color-scheme: dark;
27
+ }
28
+
29
+ /* A browser's default cursor for <button> is the arrow — links get the hand for free,
30
+ buttons never do — and Tailwind's preflight does not say otherwise. So nothing built
31
+ out of a Button primitive read as clickable on hover. Saying it once here beats
32
+ sprinkling `cursor-pointer` across every primitive and every call site.
33
+ In `@layer base` on purpose: cursor utilities live in the utilities layer, which the
34
+ cascade puts after base, so a component that deliberately asks for `cursor-default`
35
+ or `cursor-not-allowed` on an enabled button still wins. `:not(:disabled)` leaves the
36
+ disabled cursor alone, since that element sets its own. */
37
+ button:not(:disabled),
38
+ [role="button"]:not([aria-disabled="true"]) {
39
+ cursor: pointer;
40
+ }
41
+
42
+ /* One focus ring for the whole app, drawn with the ring token so it follows the theme
43
+ into dark mode with no second rule. No `border-radius` here: an outline already
44
+ traces the element's own radius, and forcing a value squares a pill or a segmented
45
+ control back into a rectangle. */
46
+ :focus-visible {
47
+ outline: 2px solid var(--color-ring);
48
+ outline-offset: 2px;
49
+ }
50
+
51
+ /* The ring reaches text fields too, and nothing here strips it from them. That is
52
+ deliberate, and it is the one donor rule that had to INVERT on the way into a package.
53
+ The app it came from follows this with `outline: none` on every input, textarea, select
54
+ and combobox, because its own fields each draw a border-plus-ring on focus and the
55
+ global outline landed on top as a doubled, heavy frame. There, removing it is a fix.
56
+ Here it would be the reverse: we add a ring the adopter never had, then take it away
57
+ again from exactly the elements that most need one. An app with plain `<input>`s would
58
+ import this file and come out with no keyboard focus indicator on any field — a WCAG
59
+ 2.4.7 failure introduced by us, in an app that was fine before. A shared layer does not
60
+ get to make that trade, and a comment telling adopters to put the ring back is not a
61
+ fix, because it only works for the ones who read it.
62
+ A control that genuinely draws its own focus treatment opts out on itself, with
63
+ `focus-visible:outline-none` on the element. Utilities sit in a later cascade layer
64
+ than base, so it wins with no specificity fight, and the opt-out stays local to the one
65
+ component that earned it. */
66
+
67
+ /* A thin scrollbar, because the OS default is a wide light bar that stays light on a
68
+ dark app. The thumb reads `--color-input`, the one token defined to clear 3:1 against
69
+ the surface behind it — exactly what a scrollbar needs — and it is rebound in `.dark`,
70
+ so the thumb follows the theme without a second rule.
71
+ 6px is a choice, not a contract — override the width in your own stylesheet if your
72
+ app wants a chunkier bar. What is load-bearing is the token the thumb reads.
73
+ WebKit pseudo-elements only, deliberately: setting the standard `scrollbar-width`
74
+ makes Chrome ignore these rules entirely, so asking for both gets neither. */
75
+ ::-webkit-scrollbar {
76
+ width: 6px;
77
+ height: 6px;
78
+ }
79
+
80
+ ::-webkit-scrollbar-track {
81
+ background: transparent;
82
+ }
83
+
84
+ ::-webkit-scrollbar-thumb {
85
+ background: var(--color-input);
86
+ border-radius: 3px;
87
+ }
88
+ }
89
+
90
+ @layer utilities {
91
+ /* Hide the bar, keep the scroll — for a tab strip or filter row that scrolls sideways,
92
+ where the scrollbar is chrome nobody drags. Here `scrollbar-width` is the right tool
93
+ and it deliberately overrides the rule above; the WebKit line covers Safari before
94
+ 18.2, which does not support the standard property. */
95
+ .scrollbar-none {
96
+ scrollbar-width: none;
97
+ }
98
+
99
+ .scrollbar-none::-webkit-scrollbar {
100
+ display: none;
101
+ }
102
+ }
103
+
104
+ /* Reduced motion. Clamped to 0.01ms rather than `none` so every animation still FIRES and
105
+ lands on its end pose — an element whose visible state is a keyframe's `to` block would
106
+ otherwise never appear at all.
107
+ The two delay properties are the half that is easy to miss: without them a staggered
108
+ list still waits out its full stagger before anything shows up, which is the delay with
109
+ none of the motion that explained it. One of the three donor themes clamped the
110
+ durations and forgot the delays. */
111
+ @media (prefers-reduced-motion: reduce) {
112
+ *,
113
+ *::before,
114
+ *::after {
115
+ animation-duration: 0.01ms !important;
116
+ animation-iteration-count: 1 !important;
117
+ animation-delay: 0.01ms !important;
118
+ transition-duration: 0.01ms !important;
119
+ transition-delay: 0.01ms !important;
120
+ }
121
+ }
package/src/index.css ADDED
@@ -0,0 +1,46 @@
1
+ /*
2
+ * The tokens and the base rules together. Import this unless you have a reason not to.
3
+ *
4
+ * An app entry is three lines:
5
+ *
6
+ * @import "tailwindcss";
7
+ * @import "@gusnips/tokens/index.css";
8
+ * @source "../../../packages/ui/src"; // one per package Tailwind must scan for classes
9
+ *
10
+ * Then your own colours, after those. Override a name in `@theme` for light and in `.dark`
11
+ * for dark — both halves, every time, or the light value stays on screen at night:
12
+ *
13
+ * @theme { --color-primary: #7c3aed; }
14
+ * .dark { --color-primary: #a78bfa; }
15
+ *
16
+ * That is two lines, and it changes every component that ever wrote `bg-primary`.
17
+ *
18
+ * Keep this import AFTER `@import "tailwindcss"`. It uses `@theme`, `@layer` and
19
+ * `@custom-variant`, and Tailwind only understands those once it has been imported.
20
+ *
21
+ * You do not need to declare `@custom-variant dark` yourself — theme.css ships it.
22
+ *
23
+ * Take the halves separately if you need to: `@gusnips/tokens/theme.css` is the tokens
24
+ * with no rules, `@gusnips/tokens/base.css` the rules with no tokens.
25
+ *
26
+ * Not here, on purpose: fonts, shadows, radii, a type scale, motion, keyframes. Radii,
27
+ * type sizes and easings already have Tailwind's own `--radius-*`, `--text-*` and
28
+ * `--ease-*` names — retune those in your `@theme` and every `rounded-lg` in every
29
+ * component follows. The rest is what your product looks like, and it stays in your
30
+ * product.
31
+ *
32
+ * Migrating a theme that has its own radii: keep `--radius-sm/md/lg/xl` and retune the
33
+ * values — they are already Tailwind's names. Drop `--radius-full` and `--radius-pill`.
34
+ * `rounded-full` is a built-in utility that needs no token at all, and it resolves to
35
+ * `calc(infinity * 1px)`, which stays round on an element taller than the `9999px` both
36
+ * donor themes redefined it to. A theme that spells it `pill` renames every
37
+ * `rounded-pill` to `rounded-full` and deletes the token.
38
+ *
39
+ * One trap worth knowing before you write your own `@theme`: `--duration-*` is NOT a
40
+ * Tailwind namespace. `duration-standard` compiles to nothing at all, silently, and the
41
+ * transition falls back to 150ms. `duration-250` works; so does `var(--duration-standard)`
42
+ * inside a hand-written rule. The compile check pins this so nobody rediscovers it.
43
+ */
44
+
45
+ @import "./theme.css";
46
+ @import "./base.css";
package/src/theme.css ADDED
@@ -0,0 +1,117 @@
1
+ /*
2
+ * The colour contract, and the dark-mode mechanism.
3
+ *
4
+ * Twenty names. Nineteen of them were already defined, independently, in three separate
5
+ * product themes — that agreement is what makes this a contract rather than a preference.
6
+ * A component writes `bg-card text-card-foreground` once and renders correctly in both
7
+ * modes, in every product that imports this file.
8
+ *
9
+ * Dark mode is class-strategy: the SAME names are rebound under `.dark`, so `bg-card`
10
+ * resolves differently below a `.dark` ancestor and no component ever writes a `dark:`
11
+ * variant. That is the entire mechanism, and it is the reason the names are worth sharing.
12
+ * The alternative one product tried — a parallel `--color-dark-*` namespace — makes every
13
+ * component spell out both halves, which is a `dark:` variant with extra steps.
14
+ *
15
+ * The VALUES are placeholders: a plain grey scale, accessible and deliberately
16
+ * characterless. A product overrides them in its own `@theme` block AFTER importing this
17
+ * one. Colour is where a product lives; it does not belong in a shared package.
18
+ *
19
+ * Two traps, both of which cost real time to find:
20
+ * - Do NOT change this to `@theme inline`. Inline bakes each value into the utility, and
21
+ * the `.dark` rebinding below silently stops working.
22
+ * - A name added here must be added to `.dark` too. Forgetting leaves one colour stuck at
23
+ * its light value in dark mode, on one screen, which is how a theme rots. The compile
24
+ * check fails on that.
25
+ */
26
+
27
+ /* `:where()` keeps the variant at zero specificity, so `dark:bg-card` and `bg-card` weigh
28
+ the same and source order decides — the ordinary Tailwind behaviour every other variant
29
+ has. Without it the dark variant would quietly outrank hover and focus styles. */
30
+ @custom-variant dark (&:where(.dark, .dark *));
31
+
32
+ @theme {
33
+ /* ─── Light ───
34
+ Contrast measured, not guessed: foreground on background 17.2:1, muted-foreground on
35
+ background 7.5:1 and on muted 7.2:1, every `-foreground` on its own surface above
36
+ 14:1. Two of those are floors the compile check enforces — see `input` below. */
37
+ --color-background: #fafafa;
38
+ --color-foreground: #171717;
39
+ --color-card: #ffffff;
40
+ --color-card-foreground: #171717;
41
+ --color-popover: #ffffff;
42
+ --color-popover-foreground: #171717;
43
+ --color-primary: #171717;
44
+ --color-primary-foreground: #fafafa;
45
+ --color-secondary: #f5f5f5;
46
+ --color-secondary-foreground: #171717;
47
+ --color-muted: #f5f5f5;
48
+ --color-muted-foreground: #525252;
49
+ /* `accent` is the NEUTRAL highlight — a menu row under the cursor, a hovered list item.
50
+ Brand tinting is always explicit, never the ambient default. Two of the three donor
51
+ themes say this in their own words, and the third had to fix it later. */
52
+ --color-accent: #e5e5e5;
53
+ --color-accent-foreground: #171717;
54
+ /* The one placeholder that is not grey. `destructive` is a MEANING, not a brand choice:
55
+ a grey delete button is indistinguishable from a secondary one, so a product that
56
+ never overrides this still gets a warning that reads as a warning. 4.8:1 under white. */
57
+ --color-destructive: #dc2626;
58
+ --color-destructive-foreground: #ffffff;
59
+ --color-border: #e5e5e5;
60
+ /* `input` is a control BOUNDARY, not decoration, so 3:1 against `--color-background` is
61
+ a FLOOR and not a preference (WCAG 1.4.11). The compile check fails below it, in both
62
+ modes. If you override this token, measure it: a grey picked to sit nicely beside
63
+ `border` will not clear 3:1, which is exactly how one donor ended up pointing `input`
64
+ at the same rung it uses for a passive divider — 1.6:1 in light, 2.2:1 in dark, so
65
+ every field border in two of its apps is invisible to somebody.
66
+ `border` has no floor. It is a hairline between two surfaces, and nothing depends on
67
+ seeing it; that is why the two tokens exist separately and why this one is so much
68
+ darker. Here: 3.2:1 on background, 3.4:1 on card. */
69
+ --color-input: #8c8c8c;
70
+ --color-ring: #171717;
71
+ /* What a dialog dims the page with. A token rather than `black/40`, because the two
72
+ modes need different scrims: over a light ground a wash of ink reads as shade, and
73
+ over a dark ground the same wash reads as nothing at all. */
74
+ --color-scrim: rgb(23 23 23 / 0.5);
75
+ }
76
+
77
+ /*
78
+ * Dark. Stays after `@theme` on purpose: `@theme` compiles to a `:root` rule, and `:root`
79
+ * and `.dark` carry equal specificity, so source order is the only thing making the dark
80
+ * values win.
81
+ *
82
+ * Elevation here is lightness, not shadow — a drop shadow barely reads on a near-black
83
+ * ground. background → card → accent climb in even steps so a popover lifts off the page
84
+ * without needing one.
85
+ */
86
+ .dark {
87
+ --color-background: #0a0a0a;
88
+ --color-foreground: #fafafa;
89
+ --color-card: #171717;
90
+ --color-card-foreground: #fafafa;
91
+ --color-popover: #171717;
92
+ --color-popover-foreground: #fafafa;
93
+ /* Primary LIFTS for dark, and its label flips dark with it. Copy the strategy, not the
94
+ greys. The tempting version — hold one brand colour across both modes, keep a white
95
+ label — is the trap: a fill chosen to look right on a white page is dark, and on a
96
+ near-black card it lands ON the 3:1 a UI component needs rather than clear of it. One
97
+ donor's held violet measures 3.08:1 against its own dark card, which passes with 0.08
98
+ to spare; the donor that lifts the same kind of hue for dark measures 6.6:1. The
99
+ margin is the whole difference, and it disappears the first time someone lightens a
100
+ card. The label must flip because a lifted fill is light — white on it is unreadable. */
101
+ --color-primary: #fafafa;
102
+ --color-primary-foreground: #171717;
103
+ --color-secondary: #262626;
104
+ --color-secondary-foreground: #fafafa;
105
+ --color-muted: #262626;
106
+ --color-muted-foreground: #a3a3a3;
107
+ --color-accent: #333333;
108
+ --color-accent-foreground: #fafafa;
109
+ /* Inverted, not darkened: a saturated red on a near-black ground is unreadable, so the
110
+ fill lifts and the label goes dark. 5.8:1. */
111
+ --color-destructive: #f87171;
112
+ --color-destructive-foreground: #450a0a;
113
+ --color-border: #262626;
114
+ --color-input: #6b6b6b; /* 3.7:1 on background, 3.4:1 on card */
115
+ --color-ring: #fafafa;
116
+ --color-scrim: rgb(0 0 0 / 0.65);
117
+ }