@sethyrung/tailshade 0.1.0 → 0.3.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 CHANGED
@@ -16,9 +16,9 @@ $ tailshade '#ff0000'
16
16
  }
17
17
  ```
18
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.
19
+ Your base color lands verbatim at `<name>-500` (or your chosen `--step`),
20
+ and the ramp reads like one of Tailwind's own: lightness targets and chroma
21
+ taper derived from v4's real palettes, every step gamut-mapped to sRGB.
22
22
 
23
23
  ## Install
24
24
 
@@ -51,6 +51,7 @@ The base color accepts any CSS format — hex, `rgb()`, `hsl()`, named colors, o
51
51
  | Flag | Effect |
52
52
  | --------------------------------- | ------------------------------------------------------------------- |
53
53
  | _(none)_ | Tailwind v4 `@theme` block, oklch values (default) |
54
+ | `--step <50..950>` | Anchor base color to a specific shade (default: 500) |
54
55
  | `--name <name>` | Override the auto-detected palette name |
55
56
  | `--v3` | Export a `tailwind.config.js` snippet instead of the `@theme` block |
56
57
  | `--format <oklch\|hex\|rgb\|hsl>` | Color notation of the emitted values (default: oklch) |
@@ -60,6 +61,7 @@ The base color accepts any CSS format — hex, `rgb()`, `hsl()`, named colors, o
60
61
  Examples:
61
62
 
62
63
  ```bash
64
+ tailshade '#111410' --step 700
63
65
  tailshade 'oklch(0.6 0.1 29)' --name brand
64
66
  tailshade '#ff0000' --v3 --format hex
65
67
  tailshade 'teal' --preview
@@ -94,11 +96,12 @@ module.exports = {
94
96
  ## How it works
95
97
 
96
98
  - 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.
99
+ - Anchor the base color verbatim at step 500 (or the step given by `--step`);
100
+ scale a v4-derived lightness ladder around it — natural spacing near the
101
+ archetypal base, stretched or compressed for extreme bases, never two identical
102
+ steps.
100
103
  - Apply a v4-derived chroma taper (peak at 500, whisper at 50, moderate at 950),
101
- then gamut-map every step so nothing clips.
104
+ scaled relative to the anchor step, then gamut-map every step so nothing clips.
102
105
 
103
106
  Decisions are recorded in [docs/adr](docs/adr); the domain vocabulary lives in
104
107
  [CONTEXT.md](CONTEXT.md).
package/dist/cli.js CHANGED
@@ -49,8 +49,19 @@ var TARGETS = {
49
49
  };
50
50
  var L_MAX = 0.985;
51
51
  var L_MIN = 0.02;
52
- var L_TOO_LIGHT = 0.97;
53
- var L_TOO_DARK = 0.03;
52
+ var LIGHTNESS_BOUNDS = {
53
+ 50: { min: 0.04, max: 0.985 },
54
+ 100: { min: 0.035, max: 0.98 },
55
+ 200: { min: 0.03, max: 0.98 },
56
+ 300: { min: 0.03, max: 0.975 },
57
+ 400: { min: 0.03, max: 0.97 },
58
+ 500: { min: 0.03, max: 0.97 },
59
+ 600: { min: 0.03, max: 0.97 },
60
+ 700: { min: 0.025, max: 0.97 },
61
+ 800: { min: 0.025, max: 0.97 },
62
+ 900: { min: 0.025, max: 0.965 },
63
+ 950: { min: 0.02, max: 0.96 }
64
+ };
54
65
  var CHROMA_RATIOS = {
55
66
  50: 0.0867,
56
67
  100: 0.2082,
@@ -78,12 +89,13 @@ function target(step) {
78
89
  }
79
90
  return t;
80
91
  }
81
- function taperRatio(step) {
92
+ function taperRatio(step, anchorStep = 500) {
82
93
  const ratio = CHROMA_RATIOS[step];
83
- if (ratio === undefined) {
84
- throw new Error(`missing chroma taper ratio for step ${step}`);
94
+ const anchorRatio = CHROMA_RATIOS[anchorStep];
95
+ if (ratio === undefined || anchorRatio === undefined) {
96
+ throw new Error(`missing chroma taper ratio for step ${step} or anchor ${anchorStep}`);
85
97
  }
86
- return ratio;
98
+ return ratio / anchorRatio;
87
99
  }
88
100
  function parseBase(input) {
89
101
  const color = parse2(input);
@@ -98,28 +110,39 @@ function parseBase(input) {
98
110
  const c = Number.isFinite(oklch.c) ? oklch.c : 0;
99
111
  return { l: oklch.l ?? 0, c, h };
100
112
  }
101
- function lightnessLadder(baseL) {
102
- if (baseL > L_TOO_LIGHT) {
113
+ function lightnessLadder(baseL, anchorStep = 500) {
114
+ const bounds = LIGHTNESS_BOUNDS[anchorStep] ?? { min: 0.03, max: 0.97 };
115
+ if (baseL > bounds.max) {
103
116
  throw new PaletteError("base color is too light to build a 50–950 ramp");
104
117
  }
105
- if (baseL < L_TOO_DARK) {
118
+ if (baseL < bounds.min) {
106
119
  throw new PaletteError("base color is too dark to build a 50–950 ramp");
107
120
  }
108
- const t500 = target(500);
109
- const upExtent = target(50) - t500;
110
- const downExtent = target(950) - t500;
121
+ const tAnchor = target(anchorStep);
122
+ const upExtent = target(50) - tAnchor;
123
+ const downExtent = target(950) - tAnchor;
111
124
  const end50 = Math.min(Math.max(baseL + upExtent, target(50)), L_MAX);
112
125
  const end950 = Math.max(Math.min(baseL + downExtent, target(950)), L_MIN);
113
- const scaleUp = (end50 - baseL) / upExtent;
114
- const scaleDown = (end950 - baseL) / downExtent;
126
+ const scaleUp = upExtent === 0 ? 1 : (end50 - baseL) / upExtent;
127
+ const scaleDown = downExtent === 0 ? 1 : (end950 - baseL) / downExtent;
115
128
  const ladder = new Map;
116
129
  for (const step of STEPS) {
117
- if (step === 500) {
130
+ if (step === anchorStep) {
118
131
  ladder.set(step, baseL);
119
132
  continue;
120
133
  }
121
- const scale = step < 500 ? scaleUp : scaleDown;
122
- ladder.set(step, baseL + (target(step) - t500) * scale);
134
+ const scale = step < anchorStep ? scaleUp : scaleDown;
135
+ ladder.set(step, baseL + (target(step) - tAnchor) * scale);
136
+ }
137
+ const values = STEPS.map((s) => round3(ladder.get(s)));
138
+ for (let i = 1;i < values.length; i++) {
139
+ if (values[i] >= values[i - 1]) {
140
+ if (baseL > target(anchorStep)) {
141
+ throw new PaletteError("base color is too light to build a 50–950 ramp");
142
+ } else {
143
+ throw new PaletteError("base color is too dark to build a 50–950 ramp");
144
+ }
145
+ }
123
146
  }
124
147
  return ladder;
125
148
  }
@@ -149,16 +172,16 @@ function maxInGamutChroma(l, c, h) {
149
172
  }
150
173
  var round3 = (n) => Math.round(n * 1000) / 1000;
151
174
  var floor3 = (n) => Math.floor(n * 1000) / 1000;
152
- function generatePalette(input) {
175
+ function generatePalette(input, anchorStep = 500) {
153
176
  const base = parseBase(input);
154
- const ladder = lightnessLadder(base.l);
177
+ const ladder = lightnessLadder(base.l, anchorStep);
155
178
  const steps = STEPS.map((step) => {
156
179
  const l = round3(ladderLightness(ladder, step));
157
180
  const h = round3(base.h);
158
- const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step), h));
181
+ const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step, anchorStep), h));
159
182
  return { step, l, c, h };
160
183
  });
161
- return { name: detectName(base), base, steps };
184
+ return { name: detectName(base), base, anchorStep, steps };
162
185
  }
163
186
 
164
187
  // src/format.ts
@@ -210,23 +233,26 @@ function toTailwindV3(palette, notation = "oklch") {
210
233
 
211
234
  // src/cli.ts
212
235
  var NOTATIONS2 = ["oklch", "hex", "rgb", "hsl"];
213
- var USAGE = `Usage: tailshade '<color>' [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
236
+ var USAGE = `Usage: tailshade '<color>' [--step <50..950>] [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
214
237
 
215
238
  Generate a Tailwind v4 palette from a base color (any CSS color format).
216
239
 
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.
240
+ The base color anchors at step 500 by default — pass --step to anchor at any
241
+ shade (50, 100, 200, ..., 950). The palette name defaults to the nearest CSS
242
+ color name, which can shadow Tailwind's built-in colors (red, teal, ...) inside
243
+ @theme — pass --name to choose your own. Pass --v3 to export a tailwind.config.js
244
+ snippet instead of the v4 @theme block. Values default to oklch; pass --format
245
+ to switch the notation (hex, rgb, hsl). Pass --preview to print an ANSI swatch
246
+ strip above the output.
223
247
 
224
248
  Example: tailshade '#ff0000'
249
+ tailshade '#111410' --step 700
225
250
  tailshade '#ff0000' --name brand
226
251
  tailshade '#ff0000' --v3 --format hex --preview`;
227
252
  function main(argv) {
228
253
  let color;
229
254
  let name;
255
+ let step = 500;
230
256
  let v3 = false;
231
257
  let preview = false;
232
258
  let notation;
@@ -235,6 +261,18 @@ function main(argv) {
235
261
  if (arg === "--help" || arg === "-h") {
236
262
  return { output: USAGE, exitCode: 0 };
237
263
  }
264
+ if (arg === "--step") {
265
+ const value = argv[++i];
266
+ if (value === undefined || value.startsWith("--")) {
267
+ return usage("--step requires a value");
268
+ }
269
+ const num = Number(value);
270
+ if (!Number.isInteger(num) || !STEPS.includes(num)) {
271
+ return usage(`--step '${value}' expects one of: ${STEPS.join(", ")}`);
272
+ }
273
+ step = num;
274
+ continue;
275
+ }
238
276
  if (arg === "--name") {
239
277
  const value = argv[++i];
240
278
  if (value === undefined || value.startsWith("--")) {
@@ -274,7 +312,7 @@ function main(argv) {
274
312
  return usage("expected exactly one color argument");
275
313
  }
276
314
  try {
277
- const palette = generatePalette(color);
315
+ const palette = generatePalette(color, step);
278
316
  if (name !== undefined) {
279
317
  const kebab = kebabCase(name);
280
318
  if (!kebab) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sethyrung/tailshade",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Generate a full Tailwind CSS palette (50-950) from a single base color",
5
5
  "keywords": [
6
6
  "cli",
package/src/cli.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { generatePalette, PaletteError } from "@/core";
1
+ import { generatePalette, PaletteError, STEPS, type Step } from "@/core";
2
2
  import { toPreviewStrip, toTailwindV3, toThemeCss, type Notation } from "@/format";
3
3
  import { kebabCase } from "@/name";
4
4
 
@@ -6,18 +6,20 @@ const NOTATIONS: Notation[] = ["oklch", "hex", "rgb", "hsl"];
6
6
 
7
7
  export type CliResult = { output: string; exitCode: number };
8
8
 
9
- const USAGE = `Usage: tailshade '<color>' [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
9
+ const USAGE = `Usage: tailshade '<color>' [--step <50..950>] [--name <name>] [--v3] [--format <oklch|hex|rgb|hsl>] [--preview]
10
10
 
11
11
  Generate a Tailwind v4 palette from a base color (any CSS color format).
12
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.
13
+ The base color anchors at step 500 by default — pass --step to anchor at any
14
+ shade (50, 100, 200, ..., 950). The palette name defaults to the nearest CSS
15
+ color name, which can shadow Tailwind's built-in colors (red, teal, ...) inside
16
+ @theme — pass --name to choose your own. Pass --v3 to export a tailwind.config.js
17
+ snippet instead of the v4 @theme block. Values default to oklch; pass --format
18
+ to switch the notation (hex, rgb, hsl). Pass --preview to print an ANSI swatch
19
+ strip above the output.
19
20
 
20
21
  Example: tailshade '#ff0000'
22
+ tailshade '#111410' --step 700
21
23
  tailshade '#ff0000' --name brand
22
24
  tailshade '#ff0000' --v3 --format hex --preview`;
23
25
 
@@ -28,6 +30,7 @@ Example: tailshade '#ff0000'
28
30
  export function main(argv: string[]): CliResult {
29
31
  let color: string | undefined;
30
32
  let name: string | undefined;
33
+ let step: Step = 500;
31
34
  let v3 = false;
32
35
  let preview = false;
33
36
  let notation: Notation | undefined;
@@ -37,6 +40,18 @@ export function main(argv: string[]): CliResult {
37
40
  if (arg === "--help" || arg === "-h") {
38
41
  return { output: USAGE, exitCode: 0 };
39
42
  }
43
+ if (arg === "--step") {
44
+ const value = argv[++i];
45
+ if (value === undefined || value.startsWith("--")) {
46
+ return usage("--step requires a value");
47
+ }
48
+ const num = Number(value);
49
+ if (!Number.isInteger(num) || !STEPS.includes(num as Step)) {
50
+ return usage(`--step '${value}' expects one of: ${STEPS.join(", ")}`);
51
+ }
52
+ step = num as Step;
53
+ continue;
54
+ }
40
55
  if (arg === "--name") {
41
56
  const value = argv[++i];
42
57
  if (value === undefined || value.startsWith("--")) {
@@ -77,7 +92,7 @@ export function main(argv: string[]): CliResult {
77
92
  }
78
93
 
79
94
  try {
80
- const palette = generatePalette(color);
95
+ const palette = generatePalette(color, step);
81
96
  if (name !== undefined) {
82
97
  const kebab = kebabCase(name);
83
98
  if (!kebab) {
package/src/core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { converter, displayable, parse } from "culori";
2
2
  import { detectName, type Oklch } from "@/name";
3
- import { CHROMA_RATIOS, L_MAX, L_MIN, L_TOO_DARK, L_TOO_LIGHT, TARGETS } from "@/targets";
3
+ import { CHROMA_RATIOS, L_MAX, L_MIN, LIGHTNESS_BOUNDS, TARGETS } from "@/targets";
4
4
 
5
5
  export const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
6
6
  export type Step = (typeof STEPS)[number];
@@ -8,7 +8,7 @@ export type Step = (typeof STEPS)[number];
8
8
  const toOklch = converter("oklch");
9
9
 
10
10
  export type PaletteEntry = { step: Step; l: number; c: number; h: number };
11
- export type Palette = { name: string; base: Oklch; steps: PaletteEntry[] };
11
+ export type Palette = { name: string; base: Oklch; anchorStep: Step; steps: PaletteEntry[] };
12
12
 
13
13
  export class PaletteError extends Error {}
14
14
 
@@ -20,12 +20,13 @@ function target(step: Step): number {
20
20
  return t;
21
21
  }
22
22
 
23
- function taperRatio(step: Step): number {
23
+ function taperRatio(step: Step, anchorStep: Step = 500): number {
24
24
  const ratio = CHROMA_RATIOS[step];
25
- if (ratio === undefined) {
26
- throw new Error(`missing chroma taper ratio for step ${step}`);
25
+ const anchorRatio = CHROMA_RATIOS[anchorStep];
26
+ if (ratio === undefined || anchorRatio === undefined) {
27
+ throw new Error(`missing chroma taper ratio for step ${step} or anchor ${anchorStep}`);
27
28
  }
28
- return ratio;
29
+ return ratio / anchorRatio;
29
30
  }
30
31
 
31
32
  /**
@@ -51,31 +52,44 @@ function parseBase(input: string): Oklch {
51
52
  * each endpoint keeps archetypal bases on the universal ladder and stretches
52
53
  * or compresses extreme bases to span the range without duplicate steps.
53
54
  */
54
- export function lightnessLadder(baseL: number): Map<Step, number> {
55
- if (baseL > L_TOO_LIGHT) {
55
+ export function lightnessLadder(baseL: number, anchorStep: Step = 500): Map<Step, number> {
56
+ const bounds = LIGHTNESS_BOUNDS[anchorStep] ?? { min: 0.03, max: 0.97 };
57
+ if (baseL > bounds.max) {
56
58
  throw new PaletteError("base color is too light to build a 50–950 ramp");
57
59
  }
58
- if (baseL < L_TOO_DARK) {
60
+ if (baseL < bounds.min) {
59
61
  throw new PaletteError("base color is too dark to build a 50–950 ramp");
60
62
  }
61
- const t500 = target(500);
62
- const upExtent = target(50) - t500;
63
- const downExtent = target(950) - t500;
63
+ const tAnchor = target(anchorStep);
64
+ const upExtent = target(50) - tAnchor;
65
+ const downExtent = target(950) - tAnchor;
64
66
 
65
67
  const end50 = Math.min(Math.max(baseL + upExtent, target(50)), L_MAX);
66
68
  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
+ const scaleUp = upExtent === 0 ? 1 : (end50 - baseL) / upExtent;
70
+ const scaleDown = downExtent === 0 ? 1 : (end950 - baseL) / downExtent;
69
71
 
70
72
  const ladder = new Map<Step, number>();
71
73
  for (const step of STEPS) {
72
- if (step === 500) {
74
+ if (step === anchorStep) {
73
75
  ladder.set(step, baseL);
74
76
  continue;
75
77
  }
76
- const scale = step < 500 ? scaleUp : scaleDown;
77
- ladder.set(step, baseL + (target(step) - t500) * scale);
78
+ const scale = step < anchorStep ? scaleUp : scaleDown;
79
+ ladder.set(step, baseL + (target(step) - tAnchor) * scale);
78
80
  }
81
+
82
+ const values = STEPS.map((s) => round3(ladder.get(s)!));
83
+ for (let i = 1; i < values.length; i++) {
84
+ if (values[i]! >= values[i - 1]!) {
85
+ if (baseL > target(anchorStep)) {
86
+ throw new PaletteError("base color is too light to build a 50–950 ramp");
87
+ } else {
88
+ throw new PaletteError("base color is too dark to build a 50–950 ramp");
89
+ }
90
+ }
91
+ }
92
+
79
93
  return ladder;
80
94
  }
81
95
 
@@ -112,14 +126,14 @@ const floor3 = (n: number) => Math.floor(n * 1000) / 1000;
112
126
  * v4 taper ratios, is gamut-mapped at the rounded L/H and floored so the
113
127
  * printed triple can't round its way out of the sRGB gamut.
114
128
  */
115
- export function generatePalette(input: string): Palette {
129
+ export function generatePalette(input: string, anchorStep: Step = 500): Palette {
116
130
  const base = parseBase(input);
117
- const ladder = lightnessLadder(base.l);
131
+ const ladder = lightnessLadder(base.l, anchorStep);
118
132
  const steps: PaletteEntry[] = STEPS.map((step) => {
119
133
  const l = round3(ladderLightness(ladder, step));
120
134
  const h = round3(base.h);
121
- const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step), h));
135
+ const c = floor3(maxInGamutChroma(l, base.c * taperRatio(step, anchorStep), h));
122
136
  return { step, l, c, h };
123
137
  });
124
- return { name: detectName(base), base, steps };
138
+ return { name: detectName(base), base, anchorStep, steps };
125
139
  }
package/src/targets.ts CHANGED
@@ -20,10 +20,24 @@ export const TARGETS: Record<number, number> = {
20
20
  export const L_MAX = 0.985;
21
21
  export const L_MIN = 0.02;
22
22
 
23
- /** Bases beyond this cannot fit 4 distinct steps above/below. */
23
+ /** Bases beyond this cannot fit distinct steps above/below. */
24
24
  export const L_TOO_LIGHT = 0.97;
25
25
  export const L_TOO_DARK = 0.03;
26
26
 
27
+ export const LIGHTNESS_BOUNDS: Record<number, { min: number; max: number }> = {
28
+ 50: { min: 0.04, max: 0.985 },
29
+ 100: { min: 0.035, max: 0.98 },
30
+ 200: { min: 0.03, max: 0.98 },
31
+ 300: { min: 0.03, max: 0.975 },
32
+ 400: { min: 0.03, max: 0.97 },
33
+ 500: { min: 0.03, max: 0.97 },
34
+ 600: { min: 0.03, max: 0.97 },
35
+ 700: { min: 0.025, max: 0.97 },
36
+ 800: { min: 0.025, max: 0.97 },
37
+ 900: { min: 0.025, max: 0.965 },
38
+ 950: { min: 0.02, max: 0.96 },
39
+ };
40
+
27
41
  /**
28
42
  * Chroma taper ratios per step (C_step / C_500): mean ratio across the 17
29
43
  * chromatic families of Tailwind v4's theme.css. Chroma peaks at the base