@sethyrung/tailshade 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/README.md +115 -0
- package/bin/cli.js +17 -0
- package/dist/cli.js +311 -0
- package/index.ts +8 -0
- package/package.json +65 -0
- package/src/cli.ts +104 -0
- package/src/core.ts +125 -0
- package/src/format.ts +64 -0
- package/src/name.ts +42 -0
- package/src/targets.ts +44 -0
- package/tsconfig.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# tailshade
|
|
2
|
+
|
|
3
|
+
Generate a full Tailwind CSS color palette (50–950) from a single base color.
|
|
4
|
+
Any CSS color in, Tailwind v4 `@theme` block out.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
$ tailshade '#ff0000'
|
|
8
|
+
@theme {
|
|
9
|
+
--color-red-50: oklch(0.977 0.011 29.234);
|
|
10
|
+
--color-red-100: oklch(0.945 0.027 29.234);
|
|
11
|
+
--color-red-200: oklch(0.892 0.056 29.234);
|
|
12
|
+
--color-red-300: oklch(0.815 0.104 29.234);
|
|
13
|
+
--color-red-400: oklch(0.712 0.181 29.234);
|
|
14
|
+
/* … */
|
|
15
|
+
--color-red-950: oklch(0.223 0.091 29.234);
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Your base color lands verbatim at `<name>-500`, and the ramp reads like one of
|
|
20
|
+
Tailwind's own: lightness targets and chroma taper derived from v4's real
|
|
21
|
+
palettes, every step gamut-mapped to sRGB.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
No install needed — run it directly:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bunx @sethyrung/tailshade '#ff0000'
|
|
29
|
+
npx @sethyrung/tailshade '#ff0000'
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or install globally:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add -g @sethyrung/tailshade # then: tailshade '#ff0000'
|
|
36
|
+
npm install -g @sethyrung/tailshade
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires [Bun](https://bun.com) >= 1.4 (or Node >= 18). For local development,
|
|
40
|
+
clone and `bun link` instead — edits stay live.
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
tailshade '<color>' [flags]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The base color accepts any CSS format — hex, `rgb()`, `hsl()`, named colors, or
|
|
49
|
+
`oklch()` (which passes through untouched).
|
|
50
|
+
|
|
51
|
+
| Flag | Effect |
|
|
52
|
+
| --------------------------------- | ------------------------------------------------------------------- |
|
|
53
|
+
| _(none)_ | Tailwind v4 `@theme` block, oklch values (default) |
|
|
54
|
+
| `--name <name>` | Override the auto-detected palette name |
|
|
55
|
+
| `--v3` | Export a `tailwind.config.js` snippet instead of the `@theme` block |
|
|
56
|
+
| `--format <oklch\|hex\|rgb\|hsl>` | Color notation of the emitted values (default: oklch) |
|
|
57
|
+
| `--preview` | Print an ANSI swatch strip above the output |
|
|
58
|
+
| `--help`, `-h` | Usage |
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
tailshade 'oklch(0.6 0.1 29)' --name brand
|
|
64
|
+
tailshade '#ff0000' --v3 --format hex
|
|
65
|
+
tailshade 'teal' --preview
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Palette naming
|
|
69
|
+
|
|
70
|
+
The name defaults to the nearest CSS named color (`#ff0000` → `red`). Watch
|
|
71
|
+
out: auto-names can shadow Tailwind's built-in colors inside `@theme` — pass
|
|
72
|
+
`--name brand` to choose your own.
|
|
73
|
+
|
|
74
|
+
### Tailwind v3 export
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
$ tailshade '#ff0000' --v3 --format hex
|
|
78
|
+
module.exports = {
|
|
79
|
+
theme: {
|
|
80
|
+
extend: {
|
|
81
|
+
colors: {
|
|
82
|
+
red: {
|
|
83
|
+
50: "#fff5f3",
|
|
84
|
+
100: "#ffe7e2",
|
|
85
|
+
/* … */
|
|
86
|
+
950: "#3c0000",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## How it works
|
|
95
|
+
|
|
96
|
+
- Parse any CSS color (via [culori](https://culorijs.org/)) and normalize to OKLCH.
|
|
97
|
+
- Anchor the base color verbatim at step 500; scale a v4-derived lightness
|
|
98
|
+
ladder around it — natural spacing near the archetypal base, stretched or
|
|
99
|
+
compressed for extreme bases, never two identical steps.
|
|
100
|
+
- Apply a v4-derived chroma taper (peak at 500, whisper at 50, moderate at 950),
|
|
101
|
+
then gamut-map every step so nothing clips.
|
|
102
|
+
|
|
103
|
+
Decisions are recorded in [docs/adr](docs/adr); the domain vocabulary lives in
|
|
104
|
+
[CONTEXT.md](CONTEXT.md).
|
|
105
|
+
|
|
106
|
+
## Development
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bun install
|
|
110
|
+
bun test # test suite (single seam: the CLI entry, in-process)
|
|
111
|
+
bun run typecheck # tsc --noEmit
|
|
112
|
+
bun run lint # oxlint
|
|
113
|
+
bun run fmt # oxfmt
|
|
114
|
+
bun run start '#ff0000'
|
|
115
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const bunEntry = join(here, "../index.ts");
|
|
8
|
+
const nodeEntry = join(here, "../dist/cli.js");
|
|
9
|
+
|
|
10
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
11
|
+
await import(pathToFileURL(bunEntry).href);
|
|
12
|
+
} else if (existsSync(nodeEntry)) {
|
|
13
|
+
await import(pathToFileURL(nodeEntry).href);
|
|
14
|
+
} else {
|
|
15
|
+
process.stderr.write("tailshade: Node needs a built CLI. Run `bun run build`.\n");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/core.ts
|
|
5
|
+
import { converter as converter2, displayable, parse as parse2 } from "culori";
|
|
6
|
+
|
|
7
|
+
// src/name.ts
|
|
8
|
+
import { colorsNamed, converter, differenceEuclidean, nearest, parse } from "culori";
|
|
9
|
+
var toOklch = converter("oklch");
|
|
10
|
+
var NAMED_COLORS = Object.entries(colorsNamed).flatMap(([name, int]) => {
|
|
11
|
+
const color = toOklch(parse("#" + Number(int).toString(16).padStart(6, "0")));
|
|
12
|
+
return color && Number.isFinite(color.l) ? [{ name, color: normalize(color) }] : [];
|
|
13
|
+
});
|
|
14
|
+
var findNearest = nearest(NAMED_COLORS, differenceEuclidean("oklch"), (entry) => ({
|
|
15
|
+
mode: "oklch",
|
|
16
|
+
...entry.color
|
|
17
|
+
}));
|
|
18
|
+
function normalize(color) {
|
|
19
|
+
return {
|
|
20
|
+
l: color.l ?? 0,
|
|
21
|
+
c: Number.isFinite(color.c) ? color.c : 0,
|
|
22
|
+
h: Number.isFinite(color.h) ? color.h : 0
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function kebabCase(input) {
|
|
26
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
27
|
+
}
|
|
28
|
+
function detectName(color) {
|
|
29
|
+
const [hit] = findNearest({ mode: "oklch", ...color });
|
|
30
|
+
if (!hit) {
|
|
31
|
+
throw new Error("no named colors available");
|
|
32
|
+
}
|
|
33
|
+
return kebabCase(hit.name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/targets.ts
|
|
37
|
+
var TARGETS = {
|
|
38
|
+
50: 0.9772,
|
|
39
|
+
100: 0.9504,
|
|
40
|
+
200: 0.9055,
|
|
41
|
+
300: 0.8405,
|
|
42
|
+
400: 0.7535,
|
|
43
|
+
500: 0.6827,
|
|
44
|
+
600: 0.5978,
|
|
45
|
+
700: 0.5149,
|
|
46
|
+
800: 0.4461,
|
|
47
|
+
900: 0.3946,
|
|
48
|
+
950: 0.2779
|
|
49
|
+
};
|
|
50
|
+
var L_MAX = 0.985;
|
|
51
|
+
var L_MIN = 0.02;
|
|
52
|
+
var L_TOO_LIGHT = 0.97;
|
|
53
|
+
var L_TOO_DARK = 0.03;
|
|
54
|
+
var CHROMA_RATIOS = {
|
|
55
|
+
50: 0.0867,
|
|
56
|
+
100: 0.2082,
|
|
57
|
+
200: 0.3993,
|
|
58
|
+
300: 0.6594,
|
|
59
|
+
400: 0.9036,
|
|
60
|
+
500: 1,
|
|
61
|
+
600: 0.9823,
|
|
62
|
+
700: 0.8606,
|
|
63
|
+
800: 0.7083,
|
|
64
|
+
900: 0.5679,
|
|
65
|
+
950: 0.4013
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/core.ts
|
|
69
|
+
var STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
70
|
+
var toOklch2 = converter2("oklch");
|
|
71
|
+
|
|
72
|
+
class PaletteError extends Error {
|
|
73
|
+
}
|
|
74
|
+
function target(step) {
|
|
75
|
+
const t = TARGETS[step];
|
|
76
|
+
if (t === undefined) {
|
|
77
|
+
throw new Error(`missing lightness target for step ${step}`);
|
|
78
|
+
}
|
|
79
|
+
return t;
|
|
80
|
+
}
|
|
81
|
+
function taperRatio(step) {
|
|
82
|
+
const ratio = CHROMA_RATIOS[step];
|
|
83
|
+
if (ratio === undefined) {
|
|
84
|
+
throw new Error(`missing chroma taper ratio for step ${step}`);
|
|
85
|
+
}
|
|
86
|
+
return ratio;
|
|
87
|
+
}
|
|
88
|
+
function parseBase(input) {
|
|
89
|
+
const color = parse2(input);
|
|
90
|
+
if (!color) {
|
|
91
|
+
throw new PaletteError(`could not parse color '${input}'`);
|
|
92
|
+
}
|
|
93
|
+
const oklch = toOklch2(color);
|
|
94
|
+
if (!oklch) {
|
|
95
|
+
throw new PaletteError(`could not convert color '${input}' to oklch`);
|
|
96
|
+
}
|
|
97
|
+
const h = Number.isFinite(oklch.h) ? oklch.h : 0;
|
|
98
|
+
const c = Number.isFinite(oklch.c) ? oklch.c : 0;
|
|
99
|
+
return { l: oklch.l ?? 0, c, h };
|
|
100
|
+
}
|
|
101
|
+
function lightnessLadder(baseL) {
|
|
102
|
+
if (baseL > L_TOO_LIGHT) {
|
|
103
|
+
throw new PaletteError("base color is too light to build a 50–950 ramp");
|
|
104
|
+
}
|
|
105
|
+
if (baseL < L_TOO_DARK) {
|
|
106
|
+
throw new PaletteError("base color is too dark to build a 50–950 ramp");
|
|
107
|
+
}
|
|
108
|
+
const t500 = target(500);
|
|
109
|
+
const upExtent = target(50) - t500;
|
|
110
|
+
const downExtent = target(950) - t500;
|
|
111
|
+
const end50 = Math.min(Math.max(baseL + upExtent, target(50)), L_MAX);
|
|
112
|
+
const end950 = Math.max(Math.min(baseL + downExtent, target(950)), L_MIN);
|
|
113
|
+
const scaleUp = (end50 - baseL) / upExtent;
|
|
114
|
+
const scaleDown = (end950 - baseL) / downExtent;
|
|
115
|
+
const ladder = new Map;
|
|
116
|
+
for (const step of STEPS) {
|
|
117
|
+
if (step === 500) {
|
|
118
|
+
ladder.set(step, baseL);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const scale = step < 500 ? scaleUp : scaleDown;
|
|
122
|
+
ladder.set(step, baseL + (target(step) - t500) * scale);
|
|
123
|
+
}
|
|
124
|
+
return ladder;
|
|
125
|
+
}
|
|
126
|
+
function ladderLightness(ladder, step) {
|
|
127
|
+
const l = ladder.get(step);
|
|
128
|
+
if (l === undefined) {
|
|
129
|
+
throw new Error(`missing ladder entry for step ${step}`);
|
|
130
|
+
}
|
|
131
|
+
return l;
|
|
132
|
+
}
|
|
133
|
+
function maxInGamutChroma(l, c, h) {
|
|
134
|
+
if (c <= 0)
|
|
135
|
+
return 0;
|
|
136
|
+
if (displayable({ mode: "oklch", l, c, h }))
|
|
137
|
+
return c;
|
|
138
|
+
let lo = 0;
|
|
139
|
+
let hi = c;
|
|
140
|
+
for (let i = 0;i < 24; i++) {
|
|
141
|
+
const mid = (lo + hi) / 2;
|
|
142
|
+
if (displayable({ mode: "oklch", l, c: mid, h })) {
|
|
143
|
+
lo = mid;
|
|
144
|
+
} else {
|
|
145
|
+
hi = mid;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return lo;
|
|
149
|
+
}
|
|
150
|
+
var round3 = (n) => Math.round(n * 1000) / 1000;
|
|
151
|
+
var floor3 = (n) => Math.floor(n * 1000) / 1000;
|
|
152
|
+
function generatePalette(input) {
|
|
153
|
+
const base = parseBase(input);
|
|
154
|
+
const ladder = lightnessLadder(base.l);
|
|
155
|
+
const steps = STEPS.map((step) => {
|
|
156
|
+
const l = round3(ladderLightness(ladder, step));
|
|
157
|
+
const h = round3(base.h);
|
|
158
|
+
const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step), h));
|
|
159
|
+
return { step, l, c, h };
|
|
160
|
+
});
|
|
161
|
+
return { name: detectName(base), base, steps };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/format.ts
|
|
165
|
+
import { converter as converter3, formatHex, formatHsl, formatRgb } from "culori";
|
|
166
|
+
var toRgb = converter3("rgb");
|
|
167
|
+
var fmt = (n) => n.toFixed(3);
|
|
168
|
+
var asColor = (s) => ({ mode: "oklch", l: s.l, c: s.c, h: s.h });
|
|
169
|
+
var NOTATIONS = {
|
|
170
|
+
oklch: (s) => `oklch(${fmt(s.l)} ${fmt(s.c)} ${fmt(s.h)})`,
|
|
171
|
+
hex: (s) => formatHex(asColor(s)),
|
|
172
|
+
rgb: (s) => formatRgb(asColor(s)),
|
|
173
|
+
hsl: (s) => formatHsl(asColor(s))
|
|
174
|
+
};
|
|
175
|
+
function valueStr(s, notation) {
|
|
176
|
+
return NOTATIONS[notation](s);
|
|
177
|
+
}
|
|
178
|
+
function toThemeCss(palette, notation = "oklch") {
|
|
179
|
+
const lines = palette.steps.map((s) => ` --color-${palette.name}-${s.step}: ${valueStr(s, notation)};`);
|
|
180
|
+
return ["@theme {", ...lines, "}"].join(`
|
|
181
|
+
`);
|
|
182
|
+
}
|
|
183
|
+
function toPreviewStrip(palette) {
|
|
184
|
+
const ESC = String.fromCharCode(27);
|
|
185
|
+
const blocks = palette.steps.map((s) => {
|
|
186
|
+
const rgb = toRgb(asColor(s));
|
|
187
|
+
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => Math.round((v ?? 0) * 255));
|
|
188
|
+
const label = s.step.toString().padStart(3);
|
|
189
|
+
return `${label} ${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`;
|
|
190
|
+
});
|
|
191
|
+
return blocks.join(" ");
|
|
192
|
+
}
|
|
193
|
+
function toTailwindV3(palette, notation = "oklch") {
|
|
194
|
+
const entries = palette.steps.map((s) => ` ${s.step}: "${valueStr(s, notation)}",`);
|
|
195
|
+
return [
|
|
196
|
+
"module.exports = {",
|
|
197
|
+
" theme: {",
|
|
198
|
+
" extend: {",
|
|
199
|
+
" colors: {",
|
|
200
|
+
` ${palette.name}: {`,
|
|
201
|
+
...entries,
|
|
202
|
+
" },",
|
|
203
|
+
" },",
|
|
204
|
+
" },",
|
|
205
|
+
" },",
|
|
206
|
+
"};"
|
|
207
|
+
].join(`
|
|
208
|
+
`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/cli.ts
|
|
212
|
+
var NOTATIONS2 = ["oklch", "hex", "rgb", "hsl"];
|
|
213
|
+
var USAGE = `Usage: tailshade '<color>' [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
|
|
214
|
+
|
|
215
|
+
Generate a Tailwind v4 palette from a base color (any CSS color format).
|
|
216
|
+
|
|
217
|
+
The palette name defaults to the nearest CSS color name, which can shadow
|
|
218
|
+
Tailwind's built-in colors (red, teal, ...) inside @theme — pass --name to
|
|
219
|
+
choose your own. Pass --v3 to export a tailwind.config.js snippet instead of
|
|
220
|
+
the v4 @theme block. Values default to oklch; pass --format to switch the
|
|
221
|
+
notation (hex, rgb, hsl). Pass --preview to print an ANSI swatch strip above
|
|
222
|
+
the output.
|
|
223
|
+
|
|
224
|
+
Example: tailshade '#ff0000'
|
|
225
|
+
tailshade '#ff0000' --name brand
|
|
226
|
+
tailshade '#ff0000' --v3 --format hex --preview`;
|
|
227
|
+
function main(argv) {
|
|
228
|
+
let color;
|
|
229
|
+
let name;
|
|
230
|
+
let v3 = false;
|
|
231
|
+
let preview = false;
|
|
232
|
+
let notation;
|
|
233
|
+
for (let i = 0;i < argv.length; i++) {
|
|
234
|
+
const arg = argv[i];
|
|
235
|
+
if (arg === "--help" || arg === "-h") {
|
|
236
|
+
return { output: USAGE, exitCode: 0 };
|
|
237
|
+
}
|
|
238
|
+
if (arg === "--name") {
|
|
239
|
+
const value = argv[++i];
|
|
240
|
+
if (value === undefined || value.startsWith("--")) {
|
|
241
|
+
return usage("--name requires a value");
|
|
242
|
+
}
|
|
243
|
+
name = value;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (arg === "--preview") {
|
|
247
|
+
preview = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (arg === "--format") {
|
|
251
|
+
const value = argv[++i];
|
|
252
|
+
if (value === undefined) {
|
|
253
|
+
return usage("--format requires a value");
|
|
254
|
+
}
|
|
255
|
+
if (!NOTATIONS2.includes(value)) {
|
|
256
|
+
return usage(`--format '${value}' expects one of: ${NOTATIONS2.join(", ")}`);
|
|
257
|
+
}
|
|
258
|
+
notation = value;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (arg === "--v3") {
|
|
262
|
+
v3 = true;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (arg.startsWith("--")) {
|
|
266
|
+
return usage(`unknown flag '${arg}'`);
|
|
267
|
+
}
|
|
268
|
+
if (color !== undefined) {
|
|
269
|
+
return usage("expected exactly one color argument");
|
|
270
|
+
}
|
|
271
|
+
color = arg;
|
|
272
|
+
}
|
|
273
|
+
if (color === undefined) {
|
|
274
|
+
return usage("expected exactly one color argument");
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const palette = generatePalette(color);
|
|
278
|
+
if (name !== undefined) {
|
|
279
|
+
const kebab = kebabCase(name);
|
|
280
|
+
if (!kebab) {
|
|
281
|
+
return usage(`--name '${name}' must contain letters or digits`);
|
|
282
|
+
}
|
|
283
|
+
palette.name = kebab;
|
|
284
|
+
}
|
|
285
|
+
const fmt = notation ?? "oklch";
|
|
286
|
+
let output = v3 ? toTailwindV3(palette, fmt) : toThemeCss(palette, fmt);
|
|
287
|
+
if (preview) {
|
|
288
|
+
output = `${toPreviewStrip(palette)}
|
|
289
|
+
|
|
290
|
+
${output}`;
|
|
291
|
+
}
|
|
292
|
+
return { output, exitCode: 0 };
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof PaletteError) {
|
|
295
|
+
return usage(error.message);
|
|
296
|
+
}
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function usage(message) {
|
|
301
|
+
return { output: `tailshade: ${message}
|
|
302
|
+
|
|
303
|
+
${USAGE}`, exitCode: 1 };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// index.ts
|
|
307
|
+
var { output, exitCode } = main(process.argv.slice(2));
|
|
308
|
+
if (output) {
|
|
309
|
+
console.log(output);
|
|
310
|
+
}
|
|
311
|
+
process.exitCode = exitCode;
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sethyrung/tailshade",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate a full Tailwind CSS palette (50-950) from a single base color",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"colors",
|
|
8
|
+
"oklch",
|
|
9
|
+
"palette",
|
|
10
|
+
"tailwind",
|
|
11
|
+
"tailwindcss"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "Sethy Rung",
|
|
16
|
+
"url": "https://sethyrung.com"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/SethyRung/tailshade.git"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"tailshade": "bin/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"index.ts",
|
|
30
|
+
"tsconfig.json"
|
|
31
|
+
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"module": "index.ts",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"start": "bun run index.ts",
|
|
39
|
+
"dev": "bun --hot index.ts",
|
|
40
|
+
"build": "bun build index.ts --target=node --outfile=dist/cli.js --packages=external",
|
|
41
|
+
"prepack": "bun run build",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"lint": "oxlint",
|
|
45
|
+
"lint:fix": "oxlint --fix",
|
|
46
|
+
"fmt": "oxfmt",
|
|
47
|
+
"fmt:check": "oxfmt --check"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"culori": "^4.0.2"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"@types/culori": "^4.0.1",
|
|
55
|
+
"oxfmt": "^0.70.0",
|
|
56
|
+
"oxlint": "^1.85.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"typescript": "^7"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"bun": ">=1.4.0",
|
|
63
|
+
"node": ">=18"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { generatePalette, PaletteError } from "@/core";
|
|
2
|
+
import { toPreviewStrip, toTailwindV3, toThemeCss, type Notation } from "@/format";
|
|
3
|
+
import { kebabCase } from "@/name";
|
|
4
|
+
|
|
5
|
+
const NOTATIONS: Notation[] = ["oklch", "hex", "rgb", "hsl"];
|
|
6
|
+
|
|
7
|
+
export type CliResult = { output: string; exitCode: number };
|
|
8
|
+
|
|
9
|
+
const USAGE = `Usage: tailshade '<color>' [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
|
|
10
|
+
|
|
11
|
+
Generate a Tailwind v4 palette from a base color (any CSS color format).
|
|
12
|
+
|
|
13
|
+
The palette name defaults to the nearest CSS color name, which can shadow
|
|
14
|
+
Tailwind's built-in colors (red, teal, ...) inside @theme — pass --name to
|
|
15
|
+
choose your own. Pass --v3 to export a tailwind.config.js snippet instead of
|
|
16
|
+
the v4 @theme block. Values default to oklch; pass --format to switch the
|
|
17
|
+
notation (hex, rgb, hsl). Pass --preview to print an ANSI swatch strip above
|
|
18
|
+
the output.
|
|
19
|
+
|
|
20
|
+
Example: tailshade '#ff0000'
|
|
21
|
+
tailshade '#ff0000' --name brand
|
|
22
|
+
tailshade '#ff0000' --v3 --format hex --preview`;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* CLI entry and the single test seam: pure — takes an argv array, returns
|
|
26
|
+
* the output and exit code instead of writing to stdout.
|
|
27
|
+
*/
|
|
28
|
+
export function main(argv: string[]): CliResult {
|
|
29
|
+
let color: string | undefined;
|
|
30
|
+
let name: string | undefined;
|
|
31
|
+
let v3 = false;
|
|
32
|
+
let preview = false;
|
|
33
|
+
let notation: Notation | undefined;
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const arg = argv[i]!;
|
|
37
|
+
if (arg === "--help" || arg === "-h") {
|
|
38
|
+
return { output: USAGE, exitCode: 0 };
|
|
39
|
+
}
|
|
40
|
+
if (arg === "--name") {
|
|
41
|
+
const value = argv[++i];
|
|
42
|
+
if (value === undefined || value.startsWith("--")) {
|
|
43
|
+
return usage("--name requires a value");
|
|
44
|
+
}
|
|
45
|
+
name = value;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === "--preview") {
|
|
49
|
+
preview = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg === "--format") {
|
|
53
|
+
const value = argv[++i];
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return usage("--format requires a value");
|
|
56
|
+
}
|
|
57
|
+
if (!NOTATIONS.includes(value as Notation)) {
|
|
58
|
+
return usage(`--format '${value}' expects one of: ${NOTATIONS.join(", ")}`);
|
|
59
|
+
}
|
|
60
|
+
notation = value as Notation;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === "--v3") {
|
|
64
|
+
v3 = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg.startsWith("--")) {
|
|
68
|
+
return usage(`unknown flag '${arg}'`);
|
|
69
|
+
}
|
|
70
|
+
if (color !== undefined) {
|
|
71
|
+
return usage("expected exactly one color argument");
|
|
72
|
+
}
|
|
73
|
+
color = arg;
|
|
74
|
+
}
|
|
75
|
+
if (color === undefined) {
|
|
76
|
+
return usage("expected exactly one color argument");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const palette = generatePalette(color);
|
|
81
|
+
if (name !== undefined) {
|
|
82
|
+
const kebab = kebabCase(name);
|
|
83
|
+
if (!kebab) {
|
|
84
|
+
return usage(`--name '${name}' must contain letters or digits`);
|
|
85
|
+
}
|
|
86
|
+
palette.name = kebab;
|
|
87
|
+
}
|
|
88
|
+
const fmt = notation ?? "oklch";
|
|
89
|
+
let output = v3 ? toTailwindV3(palette, fmt) : toThemeCss(palette, fmt);
|
|
90
|
+
if (preview) {
|
|
91
|
+
output = `${toPreviewStrip(palette)}\n\n${output}`;
|
|
92
|
+
}
|
|
93
|
+
return { output, exitCode: 0 };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof PaletteError) {
|
|
96
|
+
return usage(error.message);
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function usage(message: string): CliResult {
|
|
103
|
+
return { output: `tailshade: ${message}\n\n${USAGE}`, exitCode: 1 };
|
|
104
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { converter, displayable, parse } from "culori";
|
|
2
|
+
import { detectName, type Oklch } from "@/name";
|
|
3
|
+
import { CHROMA_RATIOS, L_MAX, L_MIN, L_TOO_DARK, L_TOO_LIGHT, TARGETS } from "@/targets";
|
|
4
|
+
|
|
5
|
+
export const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
|
|
6
|
+
export type Step = (typeof STEPS)[number];
|
|
7
|
+
|
|
8
|
+
const toOklch = converter("oklch");
|
|
9
|
+
|
|
10
|
+
export type PaletteEntry = { step: Step; l: number; c: number; h: number };
|
|
11
|
+
export type Palette = { name: string; base: Oklch; steps: PaletteEntry[] };
|
|
12
|
+
|
|
13
|
+
export class PaletteError extends Error {}
|
|
14
|
+
|
|
15
|
+
function target(step: Step): number {
|
|
16
|
+
const t = TARGETS[step];
|
|
17
|
+
if (t === undefined) {
|
|
18
|
+
throw new Error(`missing lightness target for step ${step}`);
|
|
19
|
+
}
|
|
20
|
+
return t;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function taperRatio(step: Step): number {
|
|
24
|
+
const ratio = CHROMA_RATIOS[step];
|
|
25
|
+
if (ratio === undefined) {
|
|
26
|
+
throw new Error(`missing chroma taper ratio for step ${step}`);
|
|
27
|
+
}
|
|
28
|
+
return ratio;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse any CSS color and normalize to OKLCH. Achromatic colors have no
|
|
33
|
+
* meaningful hue, so missing/NaN channels normalize to 0.
|
|
34
|
+
*/
|
|
35
|
+
function parseBase(input: string): Oklch {
|
|
36
|
+
const color = parse(input);
|
|
37
|
+
if (!color) {
|
|
38
|
+
throw new PaletteError(`could not parse color '${input}'`);
|
|
39
|
+
}
|
|
40
|
+
const oklch = toOklch(color);
|
|
41
|
+
if (!oklch) {
|
|
42
|
+
throw new PaletteError(`could not convert color '${input}' to oklch`);
|
|
43
|
+
}
|
|
44
|
+
const h = Number.isFinite(oklch.h) ? (oklch.h as number) : 0;
|
|
45
|
+
const c = Number.isFinite(oklch.c) ? (oklch.c as number) : 0;
|
|
46
|
+
return { l: oklch.l ?? 0, c, h };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lightness ladder for the 11 steps, scaled around the base's L. Clamping
|
|
51
|
+
* each endpoint keeps archetypal bases on the universal ladder and stretches
|
|
52
|
+
* or compresses extreme bases to span the range without duplicate steps.
|
|
53
|
+
*/
|
|
54
|
+
export function lightnessLadder(baseL: number): Map<Step, number> {
|
|
55
|
+
if (baseL > L_TOO_LIGHT) {
|
|
56
|
+
throw new PaletteError("base color is too light to build a 50–950 ramp");
|
|
57
|
+
}
|
|
58
|
+
if (baseL < L_TOO_DARK) {
|
|
59
|
+
throw new PaletteError("base color is too dark to build a 50–950 ramp");
|
|
60
|
+
}
|
|
61
|
+
const t500 = target(500);
|
|
62
|
+
const upExtent = target(50) - t500;
|
|
63
|
+
const downExtent = target(950) - t500;
|
|
64
|
+
|
|
65
|
+
const end50 = Math.min(Math.max(baseL + upExtent, target(50)), L_MAX);
|
|
66
|
+
const end950 = Math.max(Math.min(baseL + downExtent, target(950)), L_MIN);
|
|
67
|
+
const scaleUp = (end50 - baseL) / upExtent;
|
|
68
|
+
const scaleDown = (end950 - baseL) / downExtent;
|
|
69
|
+
|
|
70
|
+
const ladder = new Map<Step, number>();
|
|
71
|
+
for (const step of STEPS) {
|
|
72
|
+
if (step === 500) {
|
|
73
|
+
ladder.set(step, baseL);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const scale = step < 500 ? scaleUp : scaleDown;
|
|
77
|
+
ladder.set(step, baseL + (target(step) - t500) * scale);
|
|
78
|
+
}
|
|
79
|
+
return ladder;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ladderLightness(ladder: Map<Step, number>, step: Step): number {
|
|
83
|
+
const l = ladder.get(step);
|
|
84
|
+
if (l === undefined) {
|
|
85
|
+
throw new Error(`missing ladder entry for step ${step}`);
|
|
86
|
+
}
|
|
87
|
+
return l;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Largest chroma at fixed L and H that stays inside the sRGB gamut. */
|
|
91
|
+
function maxInGamutChroma(l: number, c: number, h: number): number {
|
|
92
|
+
if (c <= 0) return 0;
|
|
93
|
+
if (displayable({ mode: "oklch", l, c, h })) return c;
|
|
94
|
+
let lo = 0;
|
|
95
|
+
let hi = c;
|
|
96
|
+
for (let i = 0; i < 24; i++) {
|
|
97
|
+
const mid = (lo + hi) / 2;
|
|
98
|
+
if (displayable({ mode: "oklch", l, c: mid, h })) {
|
|
99
|
+
lo = mid;
|
|
100
|
+
} else {
|
|
101
|
+
hi = mid;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lo;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const round3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
108
|
+
const floor3 = (n: number) => Math.floor(n * 1000) / 1000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate the full 50–950 palette from a base color. Chroma follows the
|
|
112
|
+
* v4 taper ratios, is gamut-mapped at the rounded L/H and floored so the
|
|
113
|
+
* printed triple can't round its way out of the sRGB gamut.
|
|
114
|
+
*/
|
|
115
|
+
export function generatePalette(input: string): Palette {
|
|
116
|
+
const base = parseBase(input);
|
|
117
|
+
const ladder = lightnessLadder(base.l);
|
|
118
|
+
const steps: PaletteEntry[] = STEPS.map((step) => {
|
|
119
|
+
const l = round3(ladderLightness(ladder, step));
|
|
120
|
+
const h = round3(base.h);
|
|
121
|
+
const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step), h));
|
|
122
|
+
return { step, l, c, h };
|
|
123
|
+
});
|
|
124
|
+
return { name: detectName(base), base, steps };
|
|
125
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { converter, formatHex, formatHsl, formatRgb, type Color } from "culori";
|
|
2
|
+
import type { Palette, PaletteEntry } from "@/core";
|
|
3
|
+
|
|
4
|
+
export type Notation = "oklch" | "hex" | "rgb" | "hsl";
|
|
5
|
+
|
|
6
|
+
const toRgb = converter("rgb");
|
|
7
|
+
|
|
8
|
+
const fmt = (n: number) => n.toFixed(3);
|
|
9
|
+
|
|
10
|
+
const asColor = (s: { l: number; c: number; h: number }): Color =>
|
|
11
|
+
({ mode: "oklch", l: s.l, c: s.c, h: s.h }) as Color;
|
|
12
|
+
|
|
13
|
+
const NOTATIONS: Record<Notation, (s: PaletteEntry) => string> = {
|
|
14
|
+
oklch: (s) => `oklch(${fmt(s.l)} ${fmt(s.c)} ${fmt(s.h)})`,
|
|
15
|
+
hex: (s) => formatHex(asColor(s)),
|
|
16
|
+
rgb: (s) => formatRgb(asColor(s)),
|
|
17
|
+
hsl: (s) => formatHsl(asColor(s)),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Stringify one palette step in the given color notation. */
|
|
21
|
+
export function valueStr(s: PaletteEntry, notation: Notation): string {
|
|
22
|
+
return NOTATIONS[notation](s);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A Tailwind v4 @theme block; values in the given notation (oklch default). */
|
|
26
|
+
export function toThemeCss(palette: Palette, notation: Notation = "oklch"): string {
|
|
27
|
+
const lines = palette.steps.map(
|
|
28
|
+
(s) => ` --color-${palette.name}-${s.step}: ${valueStr(s, notation)};`,
|
|
29
|
+
);
|
|
30
|
+
return ["@theme {", ...lines, "}"].join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An ANSI truecolor swatch strip — one labeled block per step, 50→950 —
|
|
35
|
+
* rendered from the palette's own colors. Labels stay plain text.
|
|
36
|
+
*/
|
|
37
|
+
export function toPreviewStrip(palette: Palette): string {
|
|
38
|
+
const ESC = String.fromCharCode(27);
|
|
39
|
+
const blocks = palette.steps.map((s) => {
|
|
40
|
+
const rgb = toRgb(asColor(s))!;
|
|
41
|
+
const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => Math.round((v ?? 0) * 255));
|
|
42
|
+
const label = s.step.toString().padStart(3);
|
|
43
|
+
return `${label} ${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`;
|
|
44
|
+
});
|
|
45
|
+
return blocks.join(" ");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A paste-ready Tailwind v3 config snippet for the same palette. */
|
|
49
|
+
export function toTailwindV3(palette: Palette, notation: Notation = "oklch"): string {
|
|
50
|
+
const entries = palette.steps.map((s) => ` ${s.step}: "${valueStr(s, notation)}",`);
|
|
51
|
+
return [
|
|
52
|
+
"module.exports = {",
|
|
53
|
+
" theme: {",
|
|
54
|
+
" extend: {",
|
|
55
|
+
" colors: {",
|
|
56
|
+
` ${palette.name}: {`,
|
|
57
|
+
...entries,
|
|
58
|
+
" },",
|
|
59
|
+
" },",
|
|
60
|
+
" },",
|
|
61
|
+
" },",
|
|
62
|
+
"};",
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
package/src/name.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { colorsNamed, converter, differenceEuclidean, nearest, parse } from "culori";
|
|
2
|
+
|
|
3
|
+
const toOklch = converter("oklch");
|
|
4
|
+
|
|
5
|
+
export type Oklch = { l: number; c: number; h: number };
|
|
6
|
+
|
|
7
|
+
type NamedColor = { name: string; color: Oklch };
|
|
8
|
+
|
|
9
|
+
const NAMED_COLORS: NamedColor[] = Object.entries(colorsNamed).flatMap(([name, int]) => {
|
|
10
|
+
const color = toOklch(parse("#" + Number(int).toString(16).padStart(6, "0")));
|
|
11
|
+
return color && Number.isFinite(color.l) ? [{ name, color: normalize(color) }] : [];
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const findNearest = nearest(NAMED_COLORS, differenceEuclidean("oklch"), (entry) => ({
|
|
15
|
+
mode: "oklch",
|
|
16
|
+
...entry.color,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
function normalize(color: { l?: number; c?: number; h?: number }): Oklch {
|
|
20
|
+
return {
|
|
21
|
+
l: color.l ?? 0,
|
|
22
|
+
c: Number.isFinite(color.c) ? (color.c as number) : 0,
|
|
23
|
+
h: Number.isFinite(color.h) ? (color.h as number) : 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Kebab-case: lowercase; anything not [a-z0-9] collapses to a single dash. */
|
|
28
|
+
export function kebabCase(input: string): string {
|
|
29
|
+
return input
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/(^-|-$)/g, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Closest CSS named color to the given color in OKLCH space, kebab-cased. */
|
|
36
|
+
export function detectName(color: Oklch): string {
|
|
37
|
+
const [hit] = findNearest({ mode: "oklch", ...color });
|
|
38
|
+
if (!hit) {
|
|
39
|
+
throw new Error("no named colors available");
|
|
40
|
+
}
|
|
41
|
+
return kebabCase(hit.name);
|
|
42
|
+
}
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step lightness targets: mean L across the 17 chromatic families of
|
|
3
|
+
* Tailwind v4's theme.css.
|
|
4
|
+
*/
|
|
5
|
+
export const TARGETS: Record<number, number> = {
|
|
6
|
+
50: 0.9772,
|
|
7
|
+
100: 0.9504,
|
|
8
|
+
200: 0.9055,
|
|
9
|
+
300: 0.8405,
|
|
10
|
+
400: 0.7535,
|
|
11
|
+
500: 0.6827,
|
|
12
|
+
600: 0.5978,
|
|
13
|
+
700: 0.5149,
|
|
14
|
+
800: 0.4461,
|
|
15
|
+
900: 0.3946,
|
|
16
|
+
950: 0.2779,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Ramp endpoint bounds: the 50 and 950 steps clamp into these. */
|
|
20
|
+
export const L_MAX = 0.985;
|
|
21
|
+
export const L_MIN = 0.02;
|
|
22
|
+
|
|
23
|
+
/** Bases beyond this cannot fit 4 distinct steps above/below. */
|
|
24
|
+
export const L_TOO_LIGHT = 0.97;
|
|
25
|
+
export const L_TOO_DARK = 0.03;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Chroma taper ratios per step (C_step / C_500): mean ratio across the 17
|
|
29
|
+
* chromatic families of Tailwind v4's theme.css. Chroma peaks at the base
|
|
30
|
+
* step and tapers hard toward 50, gently toward 950.
|
|
31
|
+
*/
|
|
32
|
+
export const CHROMA_RATIOS: Record<number, number> = {
|
|
33
|
+
50: 0.0867,
|
|
34
|
+
100: 0.2082,
|
|
35
|
+
200: 0.3993,
|
|
36
|
+
300: 0.6594,
|
|
37
|
+
400: 0.9036,
|
|
38
|
+
500: 1,
|
|
39
|
+
600: 0.9823,
|
|
40
|
+
700: 0.8606,
|
|
41
|
+
800: 0.7083,
|
|
42
|
+
900: 0.5679,
|
|
43
|
+
950: 0.4013,
|
|
44
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
|
|
12
|
+
"paths": {
|
|
13
|
+
"@/*": ["./src/*"]
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
// Bundler mode
|
|
17
|
+
"moduleResolution": "bundler",
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"verbatimModuleSyntax": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
|
|
22
|
+
// Best practices
|
|
23
|
+
"strict": true,
|
|
24
|
+
"skipLibCheck": true,
|
|
25
|
+
"noFallthroughCasesInSwitch": true,
|
|
26
|
+
"noUncheckedIndexedAccess": true,
|
|
27
|
+
"noImplicitOverride": true,
|
|
28
|
+
|
|
29
|
+
// Some stricter flags (disabled by default)
|
|
30
|
+
"noUnusedLocals": false,
|
|
31
|
+
"noUnusedParameters": false,
|
|
32
|
+
"noPropertyAccessFromIndexSignature": false
|
|
33
|
+
}
|
|
34
|
+
}
|