@lewishowles/cli-style 0.9.0 → 0.10.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.
@@ -0,0 +1,209 @@
1
+ import { foreground } from "../formatters/ansi.js";
2
+ import { getToneColour } from "../theme/colours.js";
3
+
4
+ // Sparklines balance useful resolution with compact terminal output.
5
+ const defaultWidth = 20;
6
+
7
+ // Success gives an unconfigured trend a familiar positive semantic tone.
8
+ const defaultTone = "success";
9
+
10
+ // Unicode levels show eight graduated values without needing colour.
11
+ const unicodeSymbols = "▁▂▃▄▅▆▇█";
12
+
13
+ // ASCII levels provide the same eight-step visual fallback.
14
+ const asciiSymbols = "._-:=+*#";
15
+
16
+ /**
17
+ * Render a compact trend with stable numeric context.
18
+ *
19
+ * @param {object} options
20
+ * Rendering options.
21
+ * @param {boolean} options.colour
22
+ * Whether ANSI colour should be applied.
23
+ * @param {string} options.label
24
+ * Optional trend label.
25
+ * @param {string} options.tone
26
+ * Semantic colour tone for the trend.
27
+ * @param {boolean} options.unicode
28
+ * Whether graduated Unicode characters should be used.
29
+ * @param {number[]} options.values
30
+ * Finite numeric values in time order.
31
+ * @param {number} options.width
32
+ * Maximum number of plotted samples.
33
+ * @returns {string}
34
+ * Rendered sparkline, or an empty string for unusable values.
35
+ */
36
+ export function sparkline(options = {}) {
37
+ const values = normaliseValues(options.values);
38
+
39
+ if (values.length === 0) {
40
+ return "";
41
+ }
42
+
43
+ const range = resolveRange(values);
44
+ const width = normaliseWidth(options.width);
45
+ const sampledValues = sampleValues(values, width, range);
46
+ const symbols = options.unicode === false ? asciiSymbols : unicodeSymbols;
47
+ const trend = sampledValues.map((value) => renderValue(value, range, symbols)).join("");
48
+ const numericText = `latest=${values.at(-1)} min=${range.minimum} max=${range.maximum}`;
49
+
50
+ const label =
51
+ typeof options.label === "string" && options.label !== "" ? `${options.label}: ` : "";
52
+
53
+ const renderedTrend = renderTrend(trend, options);
54
+
55
+ return `${label}${renderedTrend} ${numericText}`;
56
+ }
57
+
58
+ /**
59
+ * Return finite numeric values when the input is usable.
60
+ *
61
+ * @param {*} values
62
+ * Requested trend values.
63
+ * @returns {number[]}
64
+ * Finite values, or an empty array.
65
+ */
66
+ function normaliseValues(values) {
67
+ if (
68
+ !Array.isArray(values) ||
69
+ values.length === 0 ||
70
+ values.some((value) => !Number.isFinite(value))
71
+ ) {
72
+ return [];
73
+ }
74
+
75
+ return values;
76
+ }
77
+
78
+ /**
79
+ * Return a usable positive integer plot width.
80
+ *
81
+ * @param {number} width
82
+ * Requested plot width.
83
+ * @returns {number}
84
+ * Positive integer width.
85
+ */
86
+ function normaliseWidth(width) {
87
+ if (!Number.isFinite(width)) {
88
+ return defaultWidth;
89
+ }
90
+
91
+ return Math.max(Math.floor(width), 1);
92
+ }
93
+
94
+ /**
95
+ * Resolve the shared scale and extrema indexes for a trend.
96
+ *
97
+ * @param {number[]} values
98
+ * Finite trend values.
99
+ * @returns {object}
100
+ * Trend range and first extrema indexes.
101
+ */
102
+ function resolveRange(values) {
103
+ let minimum = values[0];
104
+ let maximum = values[0];
105
+ let minimumIndex = 0;
106
+ let maximumIndex = 0;
107
+
108
+ for (let index = 1; index < values.length; index += 1) {
109
+ if (values[index] < minimum) {
110
+ minimum = values[index];
111
+ minimumIndex = index;
112
+ }
113
+
114
+ if (values[index] > maximum) {
115
+ maximum = values[index];
116
+ maximumIndex = index;
117
+ }
118
+ }
119
+
120
+ return {
121
+ maximum,
122
+ maximumIndex,
123
+ minimum,
124
+ minimumIndex,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Reduce values to deterministic plotted samples while retaining key points.
130
+ *
131
+ * @param {number[]} values
132
+ * Finite trend values.
133
+ * @param {number} width
134
+ * Maximum number of plotted samples.
135
+ * @param {object} range
136
+ * Shared trend range and extrema indexes.
137
+ * @returns {number[]}
138
+ * Sampled values in their original order.
139
+ */
140
+ function sampleValues(values, width, range) {
141
+ if (values.length <= width) {
142
+ return values;
143
+ }
144
+
145
+ const latestIndex = values.length - 1;
146
+ const requiredIndexes = [...new Set([latestIndex, range.minimumIndex, range.maximumIndex])];
147
+ const selectedIndexes = new Set(requiredIndexes.slice(0, width));
148
+
149
+ const evenlySpacedIndexes = Array.from({ length: width }, (_, index) =>
150
+ Math.round((index * (values.length - 1)) / Math.max(width - 1, 1)),
151
+ );
152
+
153
+ for (const index of evenlySpacedIndexes) {
154
+ if (selectedIndexes.size >= width) {
155
+ break;
156
+ }
157
+
158
+ selectedIndexes.add(index);
159
+ }
160
+
161
+ for (let index = 0; selectedIndexes.size < width; index += 1) {
162
+ selectedIndexes.add(index);
163
+ }
164
+
165
+ return [...selectedIndexes].sort((left, right) => left - right).map((index) => values[index]);
166
+ }
167
+
168
+ /**
169
+ * Map one value onto the active graduated character set.
170
+ *
171
+ * @param {number} value
172
+ * Value to render.
173
+ * @param {object} range
174
+ * Full trend range used for the shared scale.
175
+ * @param {string} symbols
176
+ * Graduated characters.
177
+ * @returns {string}
178
+ * One trend character.
179
+ */
180
+ function renderValue(value, range, symbols) {
181
+ if (range.minimum === range.maximum) {
182
+ return symbols[Math.floor((symbols.length - 1) / 2)];
183
+ }
184
+
185
+ const ratio = (value - range.minimum) / (range.maximum - range.minimum);
186
+ const symbolIndex = Math.round(ratio * (symbols.length - 1));
187
+
188
+ return symbols[symbolIndex];
189
+ }
190
+
191
+ /**
192
+ * Apply a semantic tone only when colour output is explicitly enabled.
193
+ *
194
+ * @param {string} trend
195
+ * Uncoloured trend text.
196
+ * @param {object} options
197
+ * Rendering options.
198
+ * @returns {string}
199
+ * Trend text with optional ANSI colour.
200
+ */
201
+ function renderTrend(trend, options) {
202
+ if (options.colour !== true) {
203
+ return trend;
204
+ }
205
+
206
+ const tone = getToneColour(options.tone, options) === null ? defaultTone : options.tone;
207
+
208
+ return foreground(trend, tone, options);
209
+ }
@@ -1,16 +1,107 @@
1
- // Semantic colour tokens for terminal output and generated previews.
1
+ // Semantic colour roles used by renderers and public integrations.
2
2
  export const colourTokens = {
3
- accent: "#c58cff",
4
- background: "#070b0d",
5
- border: "#2a3a42",
6
- danger: "#ff7272",
7
- info: "#8bbdff",
8
- muted: "#7a8a92",
9
- success: "#8fdf72",
10
- surface: "#0f181b",
11
- surfaceRaised: "#132024",
12
- text: "#e8eef0",
13
- warning: "#f4bd5f",
3
+ accent: "accent",
4
+ background: "background",
5
+ border: "border",
6
+ danger: "danger",
7
+ diffAddedBackground: "diffAddedBackground",
8
+ diffAddedText: "diffAddedText",
9
+ diffRemovedBackground: "diffRemovedBackground",
10
+ diffRemovedText: "diffRemovedText",
11
+ info: "info",
12
+ muted: "muted",
13
+ panelText: "panelText",
14
+ success: "success",
15
+ surface: "surface",
16
+ surfaceRaised: "surfaceRaised",
17
+ text: "text",
18
+ warning: "warning",
19
+ };
20
+
21
+ // Resolved theme names accepted by the public rendering options.
22
+ export const themes = {
23
+ AUTO: "auto",
24
+ DARK: "dark",
25
+ LIGHT: "light",
26
+ };
27
+
28
+ const defaultBackground = "default-background";
29
+ const defaultForeground = "default-foreground";
30
+
31
+ // Theme palettes keep normal text on the user's terminal foreground.
32
+ const palettes = {
33
+ [themes.AUTO]: {
34
+ accent: defaultForeground,
35
+ background: defaultBackground,
36
+ border: defaultForeground,
37
+ danger: defaultForeground,
38
+ diffAddedBackground: defaultBackground,
39
+ diffAddedText: defaultForeground,
40
+ diffRemovedBackground: defaultBackground,
41
+ diffRemovedText: defaultForeground,
42
+ info: defaultForeground,
43
+ muted: defaultForeground,
44
+ panelText: defaultForeground,
45
+ success: defaultForeground,
46
+ surface: defaultBackground,
47
+ surfaceRaised: defaultBackground,
48
+ text: defaultForeground,
49
+ warning: defaultForeground,
50
+ chipAgent: defaultBackground,
51
+ chipDanger: defaultBackground,
52
+ chipInfo: defaultBackground,
53
+ chipNeutral: defaultBackground,
54
+ chipSuccess: defaultBackground,
55
+ chipWarning: defaultBackground,
56
+ },
57
+ [themes.DARK]: {
58
+ accent: "ansi-256:183",
59
+ background: defaultBackground,
60
+ border: "ansi-256:239",
61
+ danger: "ansi-256:210",
62
+ diffAddedBackground: "ansi-256:22",
63
+ diffAddedText: "ansi-256:114",
64
+ diffRemovedBackground: "ansi-256:52",
65
+ diffRemovedText: "ansi-256:210",
66
+ info: "ansi-256:117",
67
+ muted: "ansi-256:246",
68
+ panelText: "ansi-256:252",
69
+ success: "ansi-256:114",
70
+ surface: "ansi-256:234",
71
+ surfaceRaised: "ansi-256:236",
72
+ text: defaultForeground,
73
+ warning: "ansi-256:215",
74
+ chipAgent: "ansi-256:58",
75
+ chipDanger: "ansi-256:52",
76
+ chipInfo: "ansi-256:17",
77
+ chipNeutral: "ansi-256:236",
78
+ chipSuccess: "ansi-256:22",
79
+ chipWarning: "ansi-256:58",
80
+ },
81
+ [themes.LIGHT]: {
82
+ accent: "ansi-256:25",
83
+ background: defaultBackground,
84
+ border: "ansi-256:250",
85
+ danger: "ansi-256:160",
86
+ diffAddedBackground: "ansi-256:194",
87
+ diffAddedText: "ansi-256:22",
88
+ diffRemovedBackground: "ansi-256:224",
89
+ diffRemovedText: "ansi-256:124",
90
+ info: "ansi-256:25",
91
+ muted: "ansi-256:242",
92
+ panelText: "ansi-256:238",
93
+ success: "ansi-256:28",
94
+ surface: "ansi-256:255",
95
+ surfaceRaised: "ansi-256:254",
96
+ text: defaultForeground,
97
+ warning: "ansi-256:94",
98
+ chipAgent: "ansi-256:229",
99
+ chipDanger: "ansi-256:224",
100
+ chipInfo: "ansi-256:153",
101
+ chipNeutral: "ansi-256:254",
102
+ chipSuccess: "ansi-256:194",
103
+ chipWarning: "ansi-256:229",
104
+ },
14
105
  };
15
106
 
16
107
  // Terminal frame colours for gallery-style previews.
@@ -19,50 +110,63 @@ export const terminalColours = {
19
110
  border: "border",
20
111
  title: "muted",
21
112
  trafficLights: {
22
- close: "#ff7272",
23
- minimise: "#f4bd5f",
24
- zoom: "#8fdf72",
113
+ close: "danger",
114
+ minimise: "warning",
115
+ zoom: "success",
25
116
  },
26
117
  };
27
118
 
28
119
  // Chip and pill colour pairs for speaker labels and compact states.
29
120
  export const chipColours = {
30
121
  agent: {
31
- background: "#3a2815",
122
+ background: "chipAgent",
32
123
  foreground: "warning",
33
124
  },
34
125
  danger: {
35
- background: "#3c1d22",
126
+ background: "chipDanger",
36
127
  foreground: "danger",
37
128
  },
38
129
  info: {
39
- background: "#14273b",
130
+ background: "chipInfo",
40
131
  foreground: "info",
41
132
  },
42
133
  neutral: {
43
- background: "#172126",
134
+ background: "chipNeutral",
44
135
  foreground: "muted",
45
136
  },
46
137
  success: {
47
- background: "#18351f",
138
+ background: "chipSuccess",
48
139
  foreground: "success",
49
140
  },
50
141
  user: {
51
- background: "#1f3b1f",
142
+ background: "chipSuccess",
52
143
  foreground: "success",
53
144
  },
54
145
  warning: {
55
- background: "#3a2815",
146
+ background: "chipWarning",
56
147
  foreground: "warning",
57
148
  },
58
149
  };
59
150
 
151
+ // Diff row colours pair forced backgrounds with explicit readable text tokens.
152
+ export const diffColours = {
153
+ added: {
154
+ background: "diffAddedBackground",
155
+ foreground: "diffAddedText",
156
+ },
157
+ removed: {
158
+ background: "diffRemovedBackground",
159
+ foreground: "diffRemovedText",
160
+ },
161
+ };
162
+
60
163
  // Panel colours for grouped reports and agent responses.
61
164
  export const panelColours = {
62
165
  background: "surface",
63
166
  border: "border",
64
167
  danger: "danger",
65
168
  info: "info",
169
+ body: "panelText",
66
170
  success: "success",
67
171
  warning: "warning",
68
172
  };
@@ -77,7 +181,7 @@ export const tableColours = {
77
181
 
78
182
  // Chart colours for simple bars and positive/negative trend segments.
79
183
  export const chartColours = {
80
- barNegative: "#c48745",
184
+ barNegative: "warning",
81
185
  barPositive: "success",
82
186
  barWarning: "warning",
83
187
  label: "muted",
@@ -101,41 +205,73 @@ export const toneColours = {
101
205
  };
102
206
 
103
207
  /**
104
- * Resolve a colour token by name.
208
+ * Resolve a colour token by name for the active theme.
105
209
  *
106
210
  * @param {string} colourName
107
211
  * Colour token name.
212
+ * @param {object} options
213
+ * Rendering options.
214
+ * @param {string} options.theme
215
+ * Resolved terminal theme.
108
216
  * @returns {string|null}
109
- * Hex colour value when supported.
217
+ * ANSI-256, terminal-default, or null when unsupported.
110
218
  */
111
- export function getColourToken(colourName) {
112
- return colourTokens[colourName] ?? null;
219
+ export function getColourToken(colourName, options = {}) {
220
+ return palettes[resolveTheme(options.theme)][colourName] ?? null;
113
221
  }
114
222
 
115
223
  /**
116
224
  * Resolve a tone to a colour token value.
117
225
  *
118
226
  * @param {string} tone
119
- * Tone name to resolve.
227
+ * Semantic tone name.
228
+ * @param {object} options
229
+ * Rendering options.
120
230
  * @returns {string|null}
121
- * Hex colour value when supported.
231
+ * ANSI-256, terminal-default, or null when unsupported.
122
232
  */
123
- export function getToneColour(tone) {
124
- return getColourToken(toneColours[tone]);
233
+ export function getToneColour(tone, options = {}) {
234
+ return getColourToken(toneColours[tone], options);
125
235
  }
126
236
 
127
237
  /**
128
- * Resolve a direct hex colour or colour token reference.
238
+ * Resolve a colour token or direct hex value.
129
239
  *
130
240
  * @param {string} colour
131
- * Hex colour value or colour token name.
241
+ * Colour token name or hex colour.
242
+ * @param {object} options
243
+ * Rendering options.
132
244
  * @returns {string|null}
133
- * Hex colour value when supported.
245
+ * Resolved formatter colour value when supported.
134
246
  */
135
- export function resolveColourValue(colour) {
247
+ export function resolveColourValue(colour, options = {}) {
136
248
  if (colour?.startsWith("#")) {
137
249
  return colour;
138
250
  }
139
251
 
140
- return getColourToken(colour);
252
+ return getColourToken(colour, options);
253
+ }
254
+
255
+ /**
256
+ * Check whether a value is a recognised theme name.
257
+ *
258
+ * @param {string} theme
259
+ * Candidate theme name.
260
+ * @returns {boolean}
261
+ * Whether the theme can select a palette.
262
+ */
263
+ export function isTheme(theme) {
264
+ return Object.values(themes).includes(theme);
265
+ }
266
+
267
+ /**
268
+ * Resolve an unknown theme to the safe automatic palette.
269
+ *
270
+ * @param {string} theme
271
+ * Candidate theme name.
272
+ * @returns {string}
273
+ * Recognised theme name.
274
+ */
275
+ function resolveTheme(theme) {
276
+ return isTheme(theme) ? theme : themes.DARK;
141
277
  }