omakit 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 +21 -0
- package/README.md +229 -0
- package/bin/omakit +3 -0
- package/package.json +40 -0
- package/skills/omarchy-plugin-submit/SKILL.md +87 -0
- package/skills/omarchy-plugin-validation-watch/SKILL.md +62 -0
- package/tests/parity/corpus.mjs +62 -0
- package/tests/parity/run.mjs +194 -0
- package/tools/marketplace/README.md +64 -0
- package/tools/marketplace/agent-control.mjs +72 -0
- package/tools/marketplace/banner.mjs +307 -0
- package/tools/marketplace/cli.mjs +283 -0
- package/tools/marketplace/completion.mjs +263 -0
- package/tools/marketplace/doctor.mjs +156 -0
- package/tools/marketplace/effect.mjs +115 -0
- package/tools/marketplace/form.mjs +209 -0
- package/tools/marketplace/github.mjs +206 -0
- package/tools/marketplace/issue.mjs +80 -0
- package/tools/marketplace/local-transport.mjs +162 -0
- package/tools/marketplace/parity-output.mjs +26 -0
- package/tools/marketplace/paths.mjs +10 -0
- package/tools/marketplace/pin.mjs +203 -0
- package/tools/marketplace/plugin.mjs +64 -0
- package/tools/marketplace/preflight.mjs +164 -0
- package/tools/marketplace/progress.mjs +96 -0
- package/tools/marketplace/registry.mjs +216 -0
- package/tools/marketplace/report.mjs +192 -0
- package/tools/marketplace/run-baseline.mjs +72 -0
- package/tools/marketplace/setup.mjs +136 -0
- package/tools/marketplace/style.mjs +443 -0
- package/tools/marketplace/submit.mjs +341 -0
- package/tools/marketplace/tree.mjs +50 -0
- package/tools/marketplace/upgrade.mjs +179 -0
- package/tools/marketplace/usage.mjs +191 -0
- package/tools/marketplace/verify.mjs +70 -0
- package/tools/marketplace/watch.mjs +262 -0
- package/tools/marketplace/yaml.mjs +164 -0
- package/tools/subject/resolve.mjs +124 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// The visual system, defined once. Every command draws with what is here and
|
|
2
|
+
// with nothing of its own; tests/unit/style.test.mjs reads every source file
|
|
3
|
+
// and fails if a status mark, a glyph or a column width is typed anywhere else.
|
|
4
|
+
// The decisions and their trade-offs are written up in docs/TUI.md.
|
|
5
|
+
//
|
|
6
|
+
// The palette, and why it is only sixteen colours.
|
|
7
|
+
//
|
|
8
|
+
// Every colour here is an ANSI palette index, never a 24-bit or 256-colour
|
|
9
|
+
// escape. That is not a limitation, it is the point: the terminal decides what
|
|
10
|
+
// "cyan" looks like, so an Omarchy theme, or any other theme, recolours this
|
|
11
|
+
// tool by changing the terminal and nothing else. A hard-coded `38;2;57;197;207`
|
|
12
|
+
// would look the same on every theme, which means looking wrong on most of them.
|
|
13
|
+
// tests/unit/style.test.mjs fails if a truecolor or 256-colour escape appears
|
|
14
|
+
// anywhere in the sources.
|
|
15
|
+
//
|
|
16
|
+
// Colour is named by role, never by hue, at every call site: `c("typeable",
|
|
17
|
+
// text)`, not `c("cyan", text)`. ROLES below is the one table that says which
|
|
18
|
+
// palette index a role gets, and tests/unit/style.test.mjs fails on a hue
|
|
19
|
+
// name used anywhere else. The index for each role was chosen by measuring
|
|
20
|
+
// every theme installed on an Omarchy machine (32 of them, 5 light; the
|
|
21
|
+
// method, the table and the reasoning are in docs/PALETTE.md), against the
|
|
22
|
+
// worst case rather than the average:
|
|
23
|
+
//
|
|
24
|
+
// typeable blue (34). What you could type: a command, a flag, the remedy
|
|
25
|
+
// arrow, the `omakit` word. The one role a reader most needs to
|
|
26
|
+
// spot. Cyan was pixel-identical to the foreground on Matte
|
|
27
|
+
// Black and unreadable in three themes; blue is at least 21
|
|
28
|
+
// CIELAB units from the foreground in every chromatic theme,
|
|
29
|
+
// and on Matte Black it is the theme's own amber accent.
|
|
30
|
+
// placeholder yellow (33). What you replace, in <angle brackets>, and the
|
|
31
|
+
// advisory advisory and unknown marks. Unreadable in four themes, and
|
|
32
|
+
// unknown still the least bad slot: every alternative fails more
|
|
33
|
+
// themes or collapses into blue or green. The brackets and the
|
|
34
|
+
// block glyph carry it where the hue does not.
|
|
35
|
+
// pass green (32). Also an environment variable name, a setting in
|
|
36
|
+
// variable the same register.
|
|
37
|
+
// fail red (31), bold. At least 28 from the foreground everywhere.
|
|
38
|
+
// label dim (2). A label beside a value, punctuation, grouping, a
|
|
39
|
+
// punctuation rule, the info mark. Grey (90) was under 3:1 in 23 of 32
|
|
40
|
+
// info themes, 1.5:1 on Matte Black; dim is the foreground scaled
|
|
41
|
+
// by the terminal (measured: 0.66 in Alacritty, foot and
|
|
42
|
+
// kitty, 0.74 in Ghostty) and is readable in all 32.
|
|
43
|
+
// heading bold. A heading, a name: a check id, a commit you should read.
|
|
44
|
+
// name
|
|
45
|
+
// prose the foreground (39). The sentence itself.
|
|
46
|
+
//
|
|
47
|
+
// Hue is never the only carrier. Omarchy's Matte Black theme resolves every
|
|
48
|
+
// ANSI hue to nearly the same grey (measured: green reads orange, yellow reads
|
|
49
|
+
// red, cyan reads grey), and five installed themes are monochrome by design,
|
|
50
|
+
// so anything said by colour alone is not said there. Every distinction is
|
|
51
|
+
// therefore also carried by something a monochrome terminal has to honour:
|
|
52
|
+
// the density of a block glyph, the case of a word, the column a line starts
|
|
53
|
+
// in, or the blank line around a block.
|
|
54
|
+
//
|
|
55
|
+
// A sentence a person has to read is never grey and never dim. On a
|
|
56
|
+
// low-contrast theme grey on near-black is a line nobody can see, and the fix
|
|
57
|
+
// is not a brighter grey, it is not dimming prose in the first place. A label
|
|
58
|
+
// is one word, and it is the only thing that is ever dim. Emphasis inside a
|
|
59
|
+
// sentence comes from bold, or from tinting the one word you could type.
|
|
60
|
+
//
|
|
61
|
+
// Colour is applied only when stdout is a terminal, and never under NO_COLOR or
|
|
62
|
+
// a dumb TERM. The words never change: a piped run and a watched run say the
|
|
63
|
+
// same thing, and a test asserts that stripping the colour from one gives the
|
|
64
|
+
// other back character for character.
|
|
65
|
+
|
|
66
|
+
const SGR = {
|
|
67
|
+
reset: 0,
|
|
68
|
+
bold: 1,
|
|
69
|
+
dim: 2,
|
|
70
|
+
red: 31,
|
|
71
|
+
green: 32,
|
|
72
|
+
yellow: 33,
|
|
73
|
+
blue: 34,
|
|
74
|
+
magenta: 35,
|
|
75
|
+
cyan: 36,
|
|
76
|
+
white: 37,
|
|
77
|
+
default: 39,
|
|
78
|
+
grey: 90,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The roles, and the SGR each resolves to. This is the only place a hue is
|
|
83
|
+
* named; every call site names a role. `styler` accepts a dotted chain of
|
|
84
|
+
* roles and SGR names, so "fail" is "red.bold" and "typeable.bold" is a bold
|
|
85
|
+
* blue, but a source file outside this one may only use the role names.
|
|
86
|
+
*/
|
|
87
|
+
export const ROLES = Object.freeze({
|
|
88
|
+
typeable: "blue",
|
|
89
|
+
placeholder: "yellow",
|
|
90
|
+
advisory: "yellow",
|
|
91
|
+
unknown: "yellow",
|
|
92
|
+
pass: "green",
|
|
93
|
+
variable: "green",
|
|
94
|
+
fail: "red.bold",
|
|
95
|
+
label: "dim",
|
|
96
|
+
punctuation: "dim",
|
|
97
|
+
info: "dim",
|
|
98
|
+
heading: "bold",
|
|
99
|
+
name: "bold",
|
|
100
|
+
prose: "default",
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export const PALETTE = Object.freeze({ ...SGR })
|
|
104
|
+
|
|
105
|
+
/** The SGR parameter string for a role, for the two places that write an escape by hand (the wordmark, the progress line). */
|
|
106
|
+
export function code(role) {
|
|
107
|
+
return String(role)
|
|
108
|
+
.split(".")
|
|
109
|
+
.flatMap((part) => (ROLES[part] || part).split("."))
|
|
110
|
+
.map((part) => SGR[part])
|
|
111
|
+
.filter((n) => n !== undefined)
|
|
112
|
+
.join(";")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- geometry ---------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The width everything is composed for. Eighty columns is the contract: a
|
|
119
|
+
* check's why-paragraph, a remedy, a refusal and the help all wrap inside it,
|
|
120
|
+
* and tests/unit/style.test.mjs renders every report and measures. The one
|
|
121
|
+
* thing exempt is the marketplace's own baseline report, which is printed
|
|
122
|
+
* verbatim because rewrapping somebody else's attestation would be editing it.
|
|
123
|
+
*
|
|
124
|
+
* The rule is over what omakit composes, not over every word it is handed.
|
|
125
|
+
* `overflows` below is the measurement, and it is the one both test files use.
|
|
126
|
+
*/
|
|
127
|
+
export const COLUMNS = 80
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The indent scale, in columns. Three stops, and every line in the tool starts
|
|
131
|
+
* at one of them:
|
|
132
|
+
*
|
|
133
|
+
* 0 a status line, a key, a heading, a verdict
|
|
134
|
+
* GUTTER the body of a check: its detail, its paths, its remedy, its reason
|
|
135
|
+
* LABEL the value beside a key, and a continuation of that value
|
|
136
|
+
*
|
|
137
|
+
* GUTTER is the width of a status mark plus two spaces, so a check body sits
|
|
138
|
+
* exactly under the check's name. LABEL is the width of the longest key the
|
|
139
|
+
* tool prints ("current HEAD") plus two spaces, so every key/value line in
|
|
140
|
+
* every command aligns to the same column. Both are asserted, not assumed.
|
|
141
|
+
*/
|
|
142
|
+
export const GUTTER = 8
|
|
143
|
+
export const LABEL = 14
|
|
144
|
+
|
|
145
|
+
/** The indent unit under a heading: a command listed in the help, a note under a step. */
|
|
146
|
+
export const STEP = 2
|
|
147
|
+
|
|
148
|
+
// --- the block ramp -----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* One family of glyphs, used for everything that is not a letter. The wordmark,
|
|
152
|
+
* the progress track, the rule under a heading and the status marks all come
|
|
153
|
+
* from this ramp, so the tool has one motif rather than a collection of icons.
|
|
154
|
+
*
|
|
155
|
+
* Density is the carrier that survives a monochrome theme. A full block next to
|
|
156
|
+
* a floor line is a difference in ink, not in hue, and ink is the one thing
|
|
157
|
+
* every terminal draws.
|
|
158
|
+
*/
|
|
159
|
+
export const DENSITY = Object.freeze({
|
|
160
|
+
full: "█", // █ the heaviest mark: a blocking failure, the scanner's head, the tool's own name
|
|
161
|
+
dark: "▓", // ▓ an advisory: heavy, but not solid
|
|
162
|
+
medium: "▒", // ▒ unknown: neither here nor there
|
|
163
|
+
light: "░", // ░ information: present, weightless
|
|
164
|
+
floor: "▁", // ▁ settled: a pass, a rule, the track the scanner runs on
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
/** The glyph that starts the one line that fixes things. */
|
|
168
|
+
export const ARROW = "→"
|
|
169
|
+
|
|
170
|
+
// --- the status vocabulary ---------------------------------------------------
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Five states, and only five. Each has one glyph, one word and one tint, and
|
|
174
|
+
* every command prints them through `mark()` below. `pass` and `fail` are
|
|
175
|
+
* verdicts; `advisory` is a failure that does not block; `info` is a fact with
|
|
176
|
+
* no verdict; `unknown` is a check that could not be made.
|
|
177
|
+
*
|
|
178
|
+
* The glyph is the density ramp read as severity: the more ink, the more it
|
|
179
|
+
* matters. The word is the same thing in letters, and its case carries it too:
|
|
180
|
+
* FAIL is the only upper-case mark, so it is the one the eye lands on in a
|
|
181
|
+
* column of lower-case ones, with or without colour.
|
|
182
|
+
*/
|
|
183
|
+
export const STATUS = Object.freeze({
|
|
184
|
+
pass: Object.freeze({ glyph: DENSITY.floor, word: "ok", tint: "pass" }),
|
|
185
|
+
fail: Object.freeze({ glyph: DENSITY.full, word: "FAIL", tint: "fail" }),
|
|
186
|
+
advisory: Object.freeze({ glyph: DENSITY.dark, word: "note", tint: "advisory" }),
|
|
187
|
+
info: Object.freeze({ glyph: DENSITY.light, word: "info", tint: "info" }),
|
|
188
|
+
unknown: Object.freeze({ glyph: DENSITY.medium, word: "?", tint: "unknown" }),
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
/** The width of the widest mark, "█ FAIL"; every mark is padded to it so the names beside them align. */
|
|
192
|
+
export const MARK_WIDTH = Math.max(...Object.values(STATUS).map((s) => `${s.glyph} ${s.word}`.length))
|
|
193
|
+
|
|
194
|
+
// --- motion ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every animation has a stated budget. The wordmark scan is over in one glance,
|
|
198
|
+
* because what follows it is the point and the scan has to be finished before
|
|
199
|
+
* it is in the way; the first version took 1.4 seconds. The
|
|
200
|
+
* progress line has no duration of its own, it lasts as long as the work does,
|
|
201
|
+
* so its budget is a frame rate: one redraw per 70ms is smooth on a terminal
|
|
202
|
+
* and cheap on a pty.
|
|
203
|
+
*
|
|
204
|
+
* The one text effect, `ttfx` over the wordmark, has a frame
|
|
205
|
+
* rate the arguments are frozen to and a budget that is a hard timeout, after
|
|
206
|
+
* which the process is killed and the wordmark is drawn at once. Measured on
|
|
207
|
+
* this machine, the pinned effect at 60 frames a second: 42 frames, 713ms on a
|
|
208
|
+
* pipe and 720ms on a pty, so the budget is twice that. It plays where the
|
|
209
|
+
* wordmark is drawn, a bare `omakit` and `setup`; without `ttfx` the 220ms
|
|
210
|
+
* scan runs there instead.
|
|
211
|
+
*/
|
|
212
|
+
export const MOTION = Object.freeze({
|
|
213
|
+
bannerBudgetMs: 220,
|
|
214
|
+
progressFrameMs: 70,
|
|
215
|
+
effectFrameRate: 60,
|
|
216
|
+
effectBudgetMs: 1500,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// --- enabling -----------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export function colourEnabled(stream = process.stdout, env = process.env) {
|
|
222
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true
|
|
223
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== "") return false
|
|
224
|
+
if (env.TERM === "dumb") return false
|
|
225
|
+
return Boolean(stream && stream.isTTY)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Can this stream hold something that redraws in place? A terminal can. A pipe
|
|
230
|
+
* cannot, and a dumb terminal cannot move its cursor. NO_COLOR is deliberately
|
|
231
|
+
* not consulted: it turns colour off, and a wordmark drawn in the terminal's
|
|
232
|
+
* own foreground or a progress line without a tint is exactly what it asks
|
|
233
|
+
* for. There is no opt-out of omakit's own: the tool reads no environment
|
|
234
|
+
* variable, and a pipe or TERM=dumb is how a person who wants no motion
|
|
235
|
+
* says so.
|
|
236
|
+
*/
|
|
237
|
+
export function motionEnabled(stream, env = process.env) {
|
|
238
|
+
if (env.TERM === "dumb") return false
|
|
239
|
+
return Boolean(stream && stream.isTTY)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {boolean} enabled
|
|
244
|
+
* @returns {(name: string, text: string) => string}
|
|
245
|
+
*/
|
|
246
|
+
export function styler(enabled) {
|
|
247
|
+
if (!enabled) return (_name, text) => String(text)
|
|
248
|
+
return (name, text) => {
|
|
249
|
+
const codes = code(name)
|
|
250
|
+
if (!codes) return String(text)
|
|
251
|
+
return `[${codes}m${text}[0m`
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Prose is prose: it keeps the terminal's own foreground colour, because a
|
|
257
|
+
* sentence a person has to read is not context to be dimmed. Only what they
|
|
258
|
+
* could type is tinted, and a name in `backticks` is exactly that.
|
|
259
|
+
*
|
|
260
|
+
* The backticks themselves are dropped: they are markup for a reader of the
|
|
261
|
+
* source, and a terminal that can colour the word does not need them.
|
|
262
|
+
*/
|
|
263
|
+
export function paintProse(line, c) {
|
|
264
|
+
// A line with nothing to type in it is written as it is: the terminal's
|
|
265
|
+
// foreground needs no escape to be the foreground.
|
|
266
|
+
if (!String(line).includes("`")) return String(line)
|
|
267
|
+
return String(line)
|
|
268
|
+
.split(/`([^`]+)`/)
|
|
269
|
+
.map((part, index) => (index % 2 ? c("typeable", part) : c("prose", part)))
|
|
270
|
+
.join("")
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Strip every SGR sequence, so a width calculation counts characters a person sees. */
|
|
274
|
+
export function plain(text) {
|
|
275
|
+
return String(text).replace(/\[[0-9;]*m/g, "")
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Visible width of a line, escapes excluded. */
|
|
279
|
+
export function width(text) {
|
|
280
|
+
return plain(text).length
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Whether a finished line breaks the eighty-column rule. This is the
|
|
285
|
+
* measurement the tests make over the binary's own output, so it is defined
|
|
286
|
+
* here, next to the rule, rather than in each test.
|
|
287
|
+
*
|
|
288
|
+
* A line is over width when it is wider than COLUMNS and omakit had a choice
|
|
289
|
+
* about it. A line whose whole content, after its indent, is one word is a line
|
|
290
|
+
* `wrap()` was handed a word wider than the room and put on a line of its own,
|
|
291
|
+
* which is the rule: a path, a URL or a sha is never broken and never elided.
|
|
292
|
+
* Measured before this was decided: `doctor` prints the pinned checkout's
|
|
293
|
+
* absolute path, and from a checkout at a 91-column path the suite was red
|
|
294
|
+
* while from a 60-column one it was green, so the rule depended on where the
|
|
295
|
+
* repository was cloned. Eliding the path instead (`~/`, or a middle ellipsis)
|
|
296
|
+
* was the other option and was rejected because stdout is an API: an agent
|
|
297
|
+
* reads that line for the path, `~` is not a path it can open, and an
|
|
298
|
+
* ellipsis is not a path at all. The same
|
|
299
|
+
* holds for the missing-pin message, the `pin.size` remedy and the upgrade
|
|
300
|
+
* refusal, all of which name a directory.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} line one line, escapes allowed
|
|
303
|
+
* @param {number} [total]
|
|
304
|
+
*/
|
|
305
|
+
export function overflows(line, total = COLUMNS) {
|
|
306
|
+
const text = plain(line)
|
|
307
|
+
if (text.length <= total) return false
|
|
308
|
+
return /\s/.test(text.trim())
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- composition ------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Wrap prose to the contract width, and paint it.
|
|
315
|
+
*
|
|
316
|
+
* `indent` is the column the text starts in and is part of the width, which is
|
|
317
|
+
* the bug the earlier version had: it wrapped at 78 and then indented by 7, and
|
|
318
|
+
* a check's why-paragraph reached column 85. `first` is the indent of the first
|
|
319
|
+
* line when it differs, for a line whose label is already on it. A word longer
|
|
320
|
+
* than the room it has (a URL, a sha) is left whole on a line of its own rather
|
|
321
|
+
* than broken, because a broken URL is worse than a long one.
|
|
322
|
+
*
|
|
323
|
+
* A `backticked span` is one word: it is the thing you could type, it is never
|
|
324
|
+
* split across lines, and `paintProse` tints it and drops the backticks. The
|
|
325
|
+
* width is measured on what a person will see, so escapes and backticks cost
|
|
326
|
+
* nothing.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} text
|
|
329
|
+
* @param {{ indent?: number, width?: number, first?: number }} [options]
|
|
330
|
+
* @param {(name: string, text: string) => string} [c]
|
|
331
|
+
* @returns {string[]} lines, indented and painted
|
|
332
|
+
*/
|
|
333
|
+
export function wrap(text, { indent = 0, width: total = COLUMNS, first = indent } = {}, c = styler(false)) {
|
|
334
|
+
// A word is a run of non-spaces, or a backticked span with whatever
|
|
335
|
+
// punctuation clings to it: "(`omakit pin`)." is one word.
|
|
336
|
+
const words = String(text).match(/[^\s`]*`[^`]*`[^\s`]*|\S+/g) || []
|
|
337
|
+
const visible = (word) => width(word.replace(/`/g, ""))
|
|
338
|
+
const lines = []
|
|
339
|
+
let line = ""
|
|
340
|
+
let used = 0
|
|
341
|
+
let room = total - first
|
|
342
|
+
for (const word of words) {
|
|
343
|
+
const cost = visible(word)
|
|
344
|
+
if (line && used + 1 + cost > room) {
|
|
345
|
+
lines.push(line)
|
|
346
|
+
line = word
|
|
347
|
+
used = cost
|
|
348
|
+
room = total - indent
|
|
349
|
+
} else {
|
|
350
|
+
line = line ? `${line} ${word}` : word
|
|
351
|
+
used += line === word ? cost : 1 + cost
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (line) lines.push(line)
|
|
355
|
+
return lines.map((entry, index) => `${" ".repeat(index === 0 ? first : indent)}${paintProse(entry, c)}`)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A status mark, padded to MARK_WIDTH and followed by two spaces, so what comes
|
|
360
|
+
* after it starts at GUTTER. This is the only place a status is turned into
|
|
361
|
+
* characters.
|
|
362
|
+
*
|
|
363
|
+
* @param {"pass"|"fail"|"advisory"|"info"|"unknown"} state
|
|
364
|
+
* @param {(name: string, text: string) => string} c
|
|
365
|
+
*/
|
|
366
|
+
export function mark(state, c) {
|
|
367
|
+
const status = STATUS[state]
|
|
368
|
+
if (!status) throw new Error(`style: no status named ${state}`)
|
|
369
|
+
const text = `${status.glyph} ${status.word}`
|
|
370
|
+
return `${c(status.tint, text)}${" ".repeat(GUTTER - text.length)}`
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A verdict line: the closing word of a command, in the register of the state
|
|
375
|
+
* it reports. "█ REFUSED", "▁ Ready", "█ VALIDATION STALE". The word takes the tint
|
|
376
|
+
* and the weight; what follows it is a sentence and keeps the foreground.
|
|
377
|
+
*/
|
|
378
|
+
export function verdict(state, word, text, c) {
|
|
379
|
+
const status = STATUS[state]
|
|
380
|
+
if (!status) throw new Error(`style: no status named ${state}`)
|
|
381
|
+
const head = `${status.glyph} ${word}`
|
|
382
|
+
const tint = (ROLES[status.tint] || status.tint).includes("bold") ? status.tint : `${status.tint}.bold`
|
|
383
|
+
const lines = text ? wrap(text, { indent: head.length + 2 }, c) : []
|
|
384
|
+
return lines.length ? [`${c(tint, head)} ${lines[0].trimStart()}`, ...lines.slice(1)] : [c(tint, head)]
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A key/value line. The key is a label, so it is grey; the value starts at
|
|
389
|
+
* LABEL and wraps under itself. A key longer than the column would break the
|
|
390
|
+
* alignment, so it throws rather than shift everything below it.
|
|
391
|
+
*/
|
|
392
|
+
export function field(key, value, c, { wrapValue = true } = {}) {
|
|
393
|
+
if (key.length > LABEL - 2) throw new Error(`style: key "${key}" is wider than the label column`)
|
|
394
|
+
const label = `${c("label", key)}${" ".repeat(LABEL - key.length)}`
|
|
395
|
+
const lines = wrapValue ? wrap(value, { indent: LABEL }, c) : [String(value)]
|
|
396
|
+
return [`${label}${lines[0].trimStart()}`, ...lines.slice(1)]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A continuation of the last field's value, at the same column. */
|
|
400
|
+
export function continuation(value, c) {
|
|
401
|
+
return wrap(value, { indent: LABEL }, c)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The one line that fixes things: an arrow in the gutter, cyan text, wrapped
|
|
406
|
+
* under itself. There is exactly one of these under any failure, and it is the
|
|
407
|
+
* only line in the tool that starts with an arrow, so it can be found by shape.
|
|
408
|
+
*/
|
|
409
|
+
export function action(text, c, { indent = GUTTER } = {}) {
|
|
410
|
+
// Painted after wrapping, and all of it cyan: the whole line is the thing
|
|
411
|
+
// to do, so a backticked word inside it has nothing to stand out from.
|
|
412
|
+
const lines = wrap(text, { indent: indent + 2 })
|
|
413
|
+
return lines.map((line, index) => (index === 0
|
|
414
|
+
? `${" ".repeat(indent)}${c("typeable.bold", ARROW)} ${c("typeable", line.trimStart())}`
|
|
415
|
+
: `${" ".repeat(indent + 2)}${c("typeable", line.trimStart())}`))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* A labelled body line: a short label in grey ("why"), two spaces, then prose
|
|
420
|
+
* in the foreground, wrapped under the prose so the label stands alone in its
|
|
421
|
+
* own column and can be skipped by a reader who has already read it once.
|
|
422
|
+
*/
|
|
423
|
+
export function labelled(label, text, c, { indent = GUTTER } = {}) {
|
|
424
|
+
const gap = label.length + 2
|
|
425
|
+
const lines = wrap(text, { indent: indent + gap }, c)
|
|
426
|
+
return lines.map((line, index) => (index === 0
|
|
427
|
+
? `${" ".repeat(indent)}${c("label", label)} ${line.trimStart()}`
|
|
428
|
+
: line))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The one separator: a heading on its own line, then a floor rule under it in
|
|
433
|
+
* grey. It is the same shape the wordmark uses (the name, then its rule), so a
|
|
434
|
+
* section of a report and the front door of the tool are drawn by one idea.
|
|
435
|
+
*/
|
|
436
|
+
export function section(title, c, { width: total = COLUMNS } = {}) {
|
|
437
|
+
return [c("heading", title), c("punctuation", DENSITY.floor.repeat(total))]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A rule with no heading, the same floor, for a wordmark or a block that names itself. */
|
|
441
|
+
export function rule(c, { width: total = COLUMNS, tint = "punctuation" } = {}) {
|
|
442
|
+
return c(tint, DENSITY.floor.repeat(total))
|
|
443
|
+
}
|