brignano 2.0.2 → 2.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/dist/banner.js +114 -44
- package/dist/card.js +41 -19
- package/dist/cardBox.js +29 -23
- package/dist/data.js +4 -3
- package/dist/menu.js +68 -23
- package/dist/tokens.js +36 -0
- package/dist/ui.js +136 -0
- package/package.json +4 -3
- package/readme.md +27 -6
package/dist/banner.js
CHANGED
|
@@ -1,57 +1,127 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import figlet from 'figlet';
|
|
2
|
-
import gradient from 'gradient-string';
|
|
3
3
|
import { sleep } from './reveal.js';
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
const
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'#7c3aed',
|
|
12
|
-
'#8b5cf6',
|
|
13
|
-
'#a78bfa',
|
|
14
|
-
'#c4b5fd',
|
|
15
|
-
'#a78bfa',
|
|
16
|
-
'#8b5cf6',
|
|
17
|
-
'#7c3aed',
|
|
18
|
-
'#6d28d9',
|
|
4
|
+
import { center, mix, paintGradient, sample, termWidth, tokens } from './ui.js';
|
|
5
|
+
// Fonts in order of preference, with the terminal width each one needs. The
|
|
6
|
+
// widest that fits wins; on a very narrow terminal we fall back to plain text so
|
|
7
|
+
// nothing ever wraps mid-letter.
|
|
8
|
+
const FONTS = [
|
|
9
|
+
{ font: 'ANSI Shadow', minColumns: 72 },
|
|
10
|
+
{ font: 'Small', minColumns: 44 },
|
|
19
11
|
];
|
|
20
|
-
|
|
12
|
+
// Figlet output as a clean rectangle: trailing blank lines dropped, every line
|
|
13
|
+
// padded to the same width so per-column effects line up.
|
|
14
|
+
function bannerArt(text, columns = termWidth()) {
|
|
15
|
+
const choice = FONTS.find((f) => columns >= f.minColumns);
|
|
16
|
+
let lines;
|
|
21
17
|
try {
|
|
22
|
-
|
|
18
|
+
lines = choice
|
|
19
|
+
? figlet.textSync(text, { font: choice.font }).split('\n')
|
|
20
|
+
: [text.toUpperCase()];
|
|
23
21
|
}
|
|
24
22
|
catch {
|
|
25
|
-
|
|
23
|
+
lines = [text.toUpperCase()];
|
|
26
24
|
}
|
|
25
|
+
while (lines.length > 0 && (lines.at(-1) ?? '').trim() === '')
|
|
26
|
+
lines.pop();
|
|
27
|
+
const width = Math.max(...lines.map((l) => l.length), 1);
|
|
28
|
+
return lines.map((l) => l.padEnd(width));
|
|
27
29
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
const BAND = 4; // width of the bright leading edge, in columns
|
|
31
|
+
const GLINT = 3; // half-width of the glint, in columns
|
|
32
|
+
// The banner is the monogram: identity, inked with `--mark`. The sweep's crest
|
|
33
|
+
// is the mark pushed most of the way to the page's ink; letters the light hasn't
|
|
34
|
+
// reached yet sit at the strong-border neutral, like an unlit surface.
|
|
35
|
+
const CREST = mix(tokens.mark[2], tokens.ink, 0.75);
|
|
36
|
+
const GHOST = tokens.lineStrong;
|
|
37
|
+
// Paints one frame of the art. Pure: same inputs, same string.
|
|
38
|
+
function paintFrame(art, { revealEdge, glintAt } = {}) {
|
|
39
|
+
const width = art[0]?.length ?? 1;
|
|
40
|
+
const last = Math.max(1, width - 1);
|
|
41
|
+
return art
|
|
42
|
+
.map((line) => {
|
|
43
|
+
let out = '';
|
|
44
|
+
for (let x = 0; x < line.length; x++) {
|
|
45
|
+
const ch = line[x] ?? ' ';
|
|
46
|
+
if (ch === ' ') {
|
|
47
|
+
out += ch;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
let color = sample(tokens.mark, x / last);
|
|
51
|
+
if (revealEdge !== undefined) {
|
|
52
|
+
const behind = revealEdge - x; // >0 once the edge has passed this column
|
|
53
|
+
if (behind <= 0)
|
|
54
|
+
color = GHOST;
|
|
55
|
+
else if (behind < BAND)
|
|
56
|
+
color = mix(color, CREST, 1 - behind / BAND);
|
|
57
|
+
}
|
|
58
|
+
if (glintAt !== undefined) {
|
|
59
|
+
const dist = Math.abs(x - glintAt);
|
|
60
|
+
if (dist < GLINT)
|
|
61
|
+
color = mix(color, CREST, 0.85 * (1 - dist / GLINT));
|
|
62
|
+
}
|
|
63
|
+
out += chalk.hex(color)(ch);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
})
|
|
67
|
+
.join('\n');
|
|
35
68
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return paint(BRAND, bannerArt(text));
|
|
69
|
+
function subtitle(text, color = tokens.inkSoft) {
|
|
70
|
+
return chalk.hex(color).italic(text);
|
|
39
71
|
}
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
// only be called on a TTY (the caller gates this behind `shouldAnimate`).
|
|
43
|
-
export async function animateBanner(text = 'brignano', frames = 14, frameMs = 70) {
|
|
72
|
+
// Static, centered banner with the tagline beneath (non-animated path).
|
|
73
|
+
export function renderBanner(tagline, text = 'brignano') {
|
|
44
74
|
const art = bannerArt(text);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
return center([paintGradient(art.join('\n'), tokens.mark), '', subtitle(tagline)].join('\n'));
|
|
76
|
+
}
|
|
77
|
+
// Writes a block of lines in place, overwriting the previous block of the same
|
|
78
|
+
// height when `redraw` is set. Every line is erased to its end first so a
|
|
79
|
+
// shorter line never leaves debris from the one it replaces.
|
|
80
|
+
function draw(lines, redraw) {
|
|
81
|
+
const out = lines.map((l) => `\r\u001b[2K${l}`).join('\n');
|
|
82
|
+
process.stdout.write(`${redraw ? `\u001b[${lines.length}A` : ''}${out}\n`);
|
|
83
|
+
}
|
|
84
|
+
// Animated splash. A light sweeps across the lettering from left to right,
|
|
85
|
+
// revealing the brand gradient behind it, a quick glint follows, and the tagline
|
|
86
|
+
// fades in underneath. Redraws in place via ANSI cursor moves, so it must only
|
|
87
|
+
// be called on a TTY (the caller gates this behind `shouldAnimate`).
|
|
88
|
+
export async function animateBanner(tagline, text = 'brignano') {
|
|
89
|
+
const columns = termWidth();
|
|
90
|
+
const art = bannerArt(text, columns);
|
|
91
|
+
const width = art[0]?.length ?? 1;
|
|
92
|
+
const pad = ' '.repeat(Math.max(0, Math.floor((columns - width) / 2)));
|
|
93
|
+
const frame = (body, sub = '') => [
|
|
94
|
+
...body.split('\n').map((l) => pad + l),
|
|
95
|
+
'',
|
|
96
|
+
center(sub, columns),
|
|
97
|
+
];
|
|
98
|
+
let drawn = false;
|
|
99
|
+
const show = (body, sub) => {
|
|
100
|
+
draw(frame(body, sub), drawn);
|
|
101
|
+
drawn = true;
|
|
102
|
+
};
|
|
103
|
+
// 1. Sweep: the edge travels from just before the first column to just past
|
|
104
|
+
// the last, so the final frame is fully lit.
|
|
105
|
+
const sweepSteps = 22;
|
|
106
|
+
for (let i = 0; i <= sweepSteps; i++) {
|
|
107
|
+
const edge = -1 + ((width + BAND + 1) * i) / sweepSteps;
|
|
108
|
+
show(paintFrame(art, { revealEdge: edge }));
|
|
109
|
+
await sleep(24);
|
|
110
|
+
}
|
|
111
|
+
// 2. Glint: a fast, narrow highlight skims across the settled gradient.
|
|
112
|
+
const glintSteps = 16;
|
|
113
|
+
for (let i = 0; i <= glintSteps; i++) {
|
|
114
|
+
const at = -GLINT + ((width + 2 * GLINT) * i) / glintSteps;
|
|
115
|
+
show(paintFrame(art, { glintAt: at }));
|
|
116
|
+
await sleep(18);
|
|
117
|
+
}
|
|
118
|
+
// 3. Settle, then fade the tagline in.
|
|
119
|
+
const settled = paintFrame(art);
|
|
120
|
+
show(settled);
|
|
121
|
+
await sleep(80);
|
|
122
|
+
const fade = [0.25, 0.55, 0.8, 1];
|
|
123
|
+
for (const t of fade) {
|
|
124
|
+
show(settled, subtitle(tagline, mix(GHOST, tokens.inkSoft, t)));
|
|
125
|
+
await sleep(70);
|
|
53
126
|
}
|
|
54
|
-
// Settle on the canonical brand gradient.
|
|
55
|
-
process.stdout.write(`\u001b[${lineCount}A`);
|
|
56
|
-
process.stdout.write(`${renderBanner(text)}\n`);
|
|
57
127
|
}
|
package/dist/card.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import chalk from 'chalk';
|
|
3
2
|
import { animateBanner, renderBanner } from './banner.js';
|
|
4
3
|
import { renderCard } from './cardBox.js';
|
|
5
|
-
import { funFacts, pick, profile, taglines } from './data.js';
|
|
4
|
+
import { funFacts, liveFact, pick, profile, taglines } from './data.js';
|
|
6
5
|
import { isInteractive, parseFlags, readVersion, shouldAnimate, shouldFetchLive, unknownFlags, } from './env.js';
|
|
7
6
|
import { fetchLive } from './live.js';
|
|
8
|
-
import { promptForAction, sendEmail, viewResume } from './menu.js';
|
|
7
|
+
import { promptForAction, quit, sendEmail, viewResume } from './menu.js';
|
|
9
8
|
import { renderQr } from './qrcode.js';
|
|
10
9
|
import { reveal, sleep } from './reveal.js';
|
|
10
|
+
import { attention, center, glyph, ink, link, mark, soft, withHiddenCursor, } from './ui.js';
|
|
11
11
|
import { writeAndOpenVCard } from './vcard.js';
|
|
12
12
|
function printHelp() {
|
|
13
13
|
console.log('\nUsage: npx brignano [options]\n');
|
|
@@ -22,6 +22,9 @@ function printHelp() {
|
|
|
22
22
|
console.log(' --no-prompt Show the card only (non-interactive)');
|
|
23
23
|
console.log(' -d, --download Deprecated alias for --view\n');
|
|
24
24
|
}
|
|
25
|
+
function farewell() {
|
|
26
|
+
console.log(`\n${center(`${mark(glyph.star)} ${ink('Thanks for stopping by — have a great day!')}`)}\n`);
|
|
27
|
+
}
|
|
25
28
|
async function main() {
|
|
26
29
|
const argv = process.argv.slice(2);
|
|
27
30
|
// Reject typos loudly rather than silently rendering the default card.
|
|
@@ -42,7 +45,7 @@ async function main() {
|
|
|
42
45
|
}
|
|
43
46
|
if (flags.view || flags.download) {
|
|
44
47
|
if (flags.download) {
|
|
45
|
-
console.warn(
|
|
48
|
+
console.warn(`${attention(`${glyph.warning} Note:`)} --download is deprecated; opening the resume instead.`);
|
|
46
49
|
}
|
|
47
50
|
return viewResume();
|
|
48
51
|
}
|
|
@@ -50,7 +53,7 @@ async function main() {
|
|
|
50
53
|
return sendEmail();
|
|
51
54
|
if (flags.qr) {
|
|
52
55
|
console.log(await renderQr(profile.website));
|
|
53
|
-
console.log(
|
|
56
|
+
console.log(` ${link(profile.website)}\n`);
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
59
|
if (flags.vcard)
|
|
@@ -60,30 +63,49 @@ async function main() {
|
|
|
60
63
|
// Kick off the live fetch early (don't await yet) so its timeout budget
|
|
61
64
|
// overlaps the banner render. Skipped entirely when non-interactive/CI.
|
|
62
65
|
const livePromise = shouldFetchLive(flags) ? fetchLive() : Promise.resolve(null);
|
|
63
|
-
// Banner first:
|
|
64
|
-
//
|
|
66
|
+
// Banner first: a light-sweep reveal when we can animate, otherwise the static
|
|
67
|
+
// gradient. Both are centered on the terminal, like the card below them.
|
|
68
|
+
const tagline = pick(taglines);
|
|
69
|
+
console.log();
|
|
70
|
+
let live;
|
|
65
71
|
if (animate) {
|
|
66
|
-
await
|
|
67
|
-
|
|
72
|
+
live = await withHiddenCursor(async () => {
|
|
73
|
+
await animateBanner(tagline);
|
|
74
|
+
// Card, enriched with whatever the (bounded) live fetch returned.
|
|
75
|
+
const data = await livePromise;
|
|
76
|
+
await reveal(renderCard({ live: data }), 30, true);
|
|
77
|
+
return data;
|
|
78
|
+
});
|
|
68
79
|
}
|
|
69
80
|
else {
|
|
70
|
-
console.log(renderBanner());
|
|
81
|
+
console.log(renderBanner(tagline));
|
|
82
|
+
live = await livePromise;
|
|
83
|
+
console.log(renderCard({ live }));
|
|
71
84
|
}
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
console.log(
|
|
77
|
-
console.log(chalk.gray(` ${pick(funFacts)}\n`));
|
|
85
|
+
// The "live stats" fact only makes sense when the live line is on the card.
|
|
86
|
+
const facts = live ? [...funFacts, liveFact] : funFacts;
|
|
87
|
+
console.log(center(`${soft('Tip:')} ${ink.bold('cmd/ctrl + click')} ${soft('the links above.')}`));
|
|
88
|
+
console.log(center(soft(pick(facts))));
|
|
89
|
+
console.log();
|
|
78
90
|
if (!interactive)
|
|
79
91
|
return;
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
// The menu comes back after every action, so visitors can copy an email, open
|
|
93
|
+
// a link and grab the QR in one sitting. "Quit" (or Ctrl+C) ends it.
|
|
94
|
+
let message = 'What would you like to do?';
|
|
95
|
+
for (;;) {
|
|
96
|
+
const action = await promptForAction(message);
|
|
97
|
+
if (action === quit)
|
|
98
|
+
break;
|
|
99
|
+
await action();
|
|
100
|
+
message = 'Anything else?';
|
|
101
|
+
await sleep(150);
|
|
102
|
+
}
|
|
103
|
+
farewell();
|
|
82
104
|
}
|
|
83
105
|
main().catch((err) => {
|
|
84
106
|
// Ctrl+C / Esc at the menu is a normal way to leave, not a crash.
|
|
85
107
|
if (err instanceof Error && err.name === 'ExitPromptError') {
|
|
86
|
-
|
|
108
|
+
farewell();
|
|
87
109
|
return;
|
|
88
110
|
}
|
|
89
111
|
const message = err instanceof Error ? err.message : String(err);
|
package/dist/cardBox.js
CHANGED
|
@@ -1,44 +1,50 @@
|
|
|
1
1
|
import boxen from 'boxen';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
2
|
import { profile } from './data.js';
|
|
3
|
+
import { glyph, ink, link, slate, soft, tokens } from './ui.js';
|
|
4
4
|
// Right-aligns labels to a common width so the values line up cleanly.
|
|
5
|
-
const LABEL_WIDTH =
|
|
5
|
+
const LABEL_WIDTH = 8;
|
|
6
6
|
function label(text) {
|
|
7
|
-
return
|
|
7
|
+
return slate(text.padStart(LABEL_WIDTH));
|
|
8
|
+
}
|
|
9
|
+
function row(name, value) {
|
|
10
|
+
return `${label(name)} ${value}`;
|
|
11
|
+
}
|
|
12
|
+
// A URL where only the handle is emphasised: the scheme and host stay regular,
|
|
13
|
+
// so the eye lands on the part that is actually mine. Cobalt throughout,
|
|
14
|
+
// because the whole thing is clickable.
|
|
15
|
+
function url(prefix, handle) {
|
|
16
|
+
return `${link(prefix)}${link.bold(handle)}`;
|
|
8
17
|
}
|
|
9
18
|
// Builds the boxen business card. `live` is optional — when absent the live
|
|
10
19
|
// line is simply omitted, so the card looks intentional with or without network.
|
|
11
|
-
export function renderCard({ live
|
|
12
|
-
// Violet accent matches brignano.io (violet-400 #a78bfa on dark).
|
|
13
|
-
const accent = chalk.hex('#a78bfa');
|
|
20
|
+
export function renderCard({ live }) {
|
|
14
21
|
const lines = [
|
|
15
|
-
|
|
16
|
-
''
|
|
17
|
-
`${label('Work')} ${profile.title} ${chalk.gray('at')} ${chalk
|
|
18
|
-
.hex('#3A5A78')
|
|
19
|
-
.bold(profile.org)}`,
|
|
22
|
+
ink.bold(profile.fullName),
|
|
23
|
+
`${ink(profile.title)} ${slate('·')} ${ink.bold(profile.org)}`,
|
|
20
24
|
'',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
row('GitHub', url('https://github.com/', profile.username)),
|
|
26
|
+
row('LinkedIn', url('https://linkedin.com/in/', profile.username)),
|
|
27
|
+
row('Website', link(profile.website)),
|
|
28
|
+
row('Email', link(profile.email)),
|
|
24
29
|
];
|
|
25
30
|
if (live) {
|
|
26
|
-
lines.push('');
|
|
27
31
|
const parts = [
|
|
28
|
-
`${
|
|
29
|
-
`${
|
|
32
|
+
`${slate(glyph.star)} ${ink.bold(live.followers)} ${soft('followers')}`,
|
|
33
|
+
`${ink.bold(live.publicRepos)} ${soft('repos')}`,
|
|
30
34
|
];
|
|
31
35
|
if (live.latestRepo) {
|
|
32
|
-
parts.push(
|
|
36
|
+
parts.push(`${soft('latest')} ${link(live.latestRepo.name)}`);
|
|
33
37
|
}
|
|
34
|
-
lines.push(
|
|
38
|
+
lines.push('', row('Stats', parts.join(slate(' · '))));
|
|
35
39
|
}
|
|
36
|
-
lines.push('', `${
|
|
40
|
+
lines.push('', row('Card', `${slate('$')} ${ink('npx')} ${ink.bold('brignano')}`));
|
|
37
41
|
return boxen(lines.join('\n'), {
|
|
38
|
-
margin: 1,
|
|
42
|
+
margin: { top: 1, bottom: 1, left: 0, right: 0 },
|
|
39
43
|
float: 'center',
|
|
40
|
-
padding: 1,
|
|
44
|
+
padding: { top: 1, bottom: 1, left: 3, right: 3 },
|
|
41
45
|
borderStyle: 'round',
|
|
42
|
-
borderColor:
|
|
46
|
+
borderColor: tokens.lineStrong,
|
|
47
|
+
title: soft(profile.handle),
|
|
48
|
+
titleAlignment: 'center',
|
|
43
49
|
});
|
|
44
50
|
}
|
package/dist/data.js
CHANGED
|
@@ -22,12 +22,13 @@ export const taglines = [
|
|
|
22
22
|
'Self-hoster, spec-driven, and unreasonably fond of a tidy terminal.',
|
|
23
23
|
'Good engineering is mostly good taste applied consistently. Say hello.',
|
|
24
24
|
];
|
|
25
|
-
// Small easter-egg facts, shown
|
|
25
|
+
// Small easter-egg facts, shown below the card.
|
|
26
26
|
export const funFacts = [
|
|
27
27
|
'This card is open source — `npx brignano --help` shows every trick.',
|
|
28
|
-
'
|
|
29
|
-
'The live stats above are pulled fresh from GitHub (with a strict timeout).',
|
|
28
|
+
'Scan the contact QR with your phone to save my details instantly.',
|
|
30
29
|
];
|
|
30
|
+
// Only makes sense when the card actually shows the live GitHub line.
|
|
31
|
+
export const liveFact = 'The live stats above are pulled fresh from GitHub (with a strict timeout).';
|
|
31
32
|
// Deterministic-enough pick without Math.random (kept simple + dependency-free).
|
|
32
33
|
// The `?? items[0]` fallback is unreachable for a NonEmpty list, but it keeps the
|
|
33
34
|
// return type honest under `noUncheckedIndexedAccess` without an assertion.
|
package/dist/menu.js
CHANGED
|
@@ -1,86 +1,131 @@
|
|
|
1
1
|
import { select } from '@inquirer/prompts';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
2
|
import clipboard from 'clipboardy';
|
|
4
3
|
import open from 'open';
|
|
5
4
|
import { profile } from './data.js';
|
|
6
5
|
import { renderQr } from './qrcode.js';
|
|
6
|
+
import { danger, glyph, ink, link, soft, success } from './ui.js';
|
|
7
7
|
import { buildVCard, writeAndOpenVCard } from './vcard.js';
|
|
8
|
+
// Every state ships with an icon and a word (design system §3): forest for
|
|
9
|
+
// done, rust for gone wrong.
|
|
10
|
+
const done = (word) => success(`${glyph.tick} ${word}`);
|
|
11
|
+
const failed = (word) => danger(`${glyph.cross} ${word}`);
|
|
8
12
|
// --- Action helpers (kept as named functions so flags and the menu share them) ---
|
|
9
13
|
export async function copyEmail() {
|
|
10
14
|
try {
|
|
11
15
|
await clipboard.write(profile.email);
|
|
12
|
-
console.log(`\n${
|
|
16
|
+
console.log(`\n${done('Copied')} ${link(profile.email)} to your clipboard.\n`);
|
|
13
17
|
}
|
|
14
18
|
catch {
|
|
15
19
|
// Clipboard access fails in headless/SSH environments — degrade gracefully.
|
|
16
|
-
console.log(`\nMy email is ${
|
|
20
|
+
console.log(`\nMy email is ${link(profile.email)} — drop me a line!\n`);
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
export async function sendEmail() {
|
|
20
24
|
try {
|
|
21
25
|
await open(`mailto:${profile.email}`);
|
|
22
|
-
console.log('
|
|
26
|
+
console.log(`\n${done('Opened')} your mail client — see you in the inbox.\n`);
|
|
23
27
|
}
|
|
24
28
|
catch (err) {
|
|
25
29
|
const message = err instanceof Error ? err.message : String(err);
|
|
26
|
-
console.error(
|
|
27
|
-
console.error(`Reach me directly at ${profile.email}`);
|
|
30
|
+
console.error(`\n${failed('Failed')} to open your mail client: ${message}`);
|
|
31
|
+
console.error(`Reach me directly at ${link(profile.email)}\n`);
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
export async function openUrl(url, label) {
|
|
31
35
|
try {
|
|
32
36
|
await open(url);
|
|
33
|
-
console.log(`\
|
|
37
|
+
console.log(`\n${done('Opened')} ${label}: ${link(url)}\n`);
|
|
34
38
|
}
|
|
35
39
|
catch (err) {
|
|
36
40
|
const message = err instanceof Error ? err.message : String(err);
|
|
37
|
-
console.error(
|
|
38
|
-
console.error(`Here it is: ${url}`);
|
|
41
|
+
console.error(`\n${failed('Failed')} to open ${label}: ${message}`);
|
|
42
|
+
console.error(`Here it is: ${link(url)}\n`);
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
export async function viewResume() {
|
|
42
|
-
await openUrl(profile.resume, 'resume');
|
|
46
|
+
await openUrl(profile.resume, 'my resume');
|
|
43
47
|
}
|
|
44
48
|
export async function showContactQr() {
|
|
45
49
|
const qr = await renderQr(buildVCard());
|
|
46
50
|
console.log('\nScan to save my contact:\n');
|
|
47
51
|
console.log(qr);
|
|
48
52
|
}
|
|
53
|
+
// Sentinel action: choosing it ends the session instead of bringing the menu back.
|
|
54
|
+
export const quit = () => { };
|
|
55
|
+
// Names stay unstyled on purpose: the active row is highlighted as a whole, and
|
|
56
|
+
// embedded colour resets would break that highlight mid-line.
|
|
49
57
|
export function buildChoices() {
|
|
50
58
|
return [
|
|
51
|
-
{ name: `Copy my ${chalk.green.bold('email')} to clipboard`, value: copyEmail },
|
|
52
|
-
{ name: `Send me an ${chalk.green.bold('email')}`, value: sendEmail },
|
|
53
59
|
{
|
|
54
|
-
name:
|
|
60
|
+
name: 'Copy my email to your clipboard',
|
|
61
|
+
description: profile.email,
|
|
62
|
+
value: copyEmail,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'Send me an email',
|
|
66
|
+
description: `Opens a new message to ${profile.email}`,
|
|
67
|
+
value: sendEmail,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'Open my GitHub',
|
|
71
|
+
description: profile.github,
|
|
55
72
|
value: () => openUrl(profile.github, 'GitHub'),
|
|
56
73
|
},
|
|
57
74
|
{
|
|
58
|
-
name:
|
|
75
|
+
name: 'Open my LinkedIn',
|
|
76
|
+
description: profile.linkedin,
|
|
59
77
|
value: () => openUrl(profile.linkedin, 'LinkedIn'),
|
|
60
78
|
},
|
|
61
|
-
{ name: `View my ${chalk.magentaBright.bold('resume')}`, value: viewResume },
|
|
62
79
|
{
|
|
63
|
-
name:
|
|
80
|
+
name: 'View my resume',
|
|
81
|
+
description: profile.resume,
|
|
82
|
+
value: viewResume,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'Save my contact card',
|
|
86
|
+
description: 'Writes a .vcf and opens it in your contacts app',
|
|
64
87
|
value: writeAndOpenVCard,
|
|
65
88
|
},
|
|
66
|
-
{ name: `Show a ${chalk.yellow.bold('contact QR')} code`, value: showContactQr },
|
|
67
89
|
{
|
|
68
|
-
name: '
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
name: 'Show a contact QR code',
|
|
91
|
+
description: 'Scan it with your phone to save my details',
|
|
92
|
+
value: showContactQr,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'Quit',
|
|
96
|
+
description: 'Thanks for stopping by',
|
|
97
|
+
value: quit,
|
|
72
98
|
},
|
|
73
99
|
];
|
|
74
100
|
}
|
|
75
101
|
// Shows the action menu and returns the chosen handler. Ctrl+C / Esc throws an
|
|
76
102
|
// `ExitPromptError`, which the entry point treats as a normal exit.
|
|
77
|
-
export async function promptForAction() {
|
|
103
|
+
export async function promptForAction(message) {
|
|
78
104
|
const choices = buildChoices();
|
|
79
105
|
return select({
|
|
80
|
-
message
|
|
106
|
+
message,
|
|
81
107
|
choices,
|
|
82
108
|
// Default page size is 7, which would hide the last entry behind a scroll.
|
|
83
109
|
// Show every action at once — the list is short and fixed.
|
|
84
110
|
pageSize: choices.length,
|
|
111
|
+
// Cobalt is the "you can act on this" hue; the selected row and the final
|
|
112
|
+
// answer wear it. The done prefix is forest, like every other confirmation.
|
|
113
|
+
theme: {
|
|
114
|
+
prefix: { idle: link(glyph.pointer), done: success(glyph.tick) },
|
|
115
|
+
icon: { cursor: glyph.pointer },
|
|
116
|
+
style: {
|
|
117
|
+
message: (text) => ink.bold(text),
|
|
118
|
+
highlight: (text) => link.bold(text),
|
|
119
|
+
answer: (text) => link(text),
|
|
120
|
+
description: (text) => soft(` ${text}`),
|
|
121
|
+
// inquirer hardcodes ↑↓ and ⏎ here; swap in whatever this console can draw.
|
|
122
|
+
keysHelpTip: (keys) => keys
|
|
123
|
+
.map(([key, action]) => {
|
|
124
|
+
const shown = key.replace('↑↓', glyph.arrows).replace('⏎', glyph.enter);
|
|
125
|
+
return `${ink.bold(shown)} ${soft(action)}`;
|
|
126
|
+
})
|
|
127
|
+
.join(soft(' • ')),
|
|
128
|
+
},
|
|
129
|
+
},
|
|
85
130
|
});
|
|
86
131
|
}
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Colour tokens, copied from @brignano/design 0.2.0 (tokens.css) and named after
|
|
2
|
+
// the CSS custom properties they mirror. A terminal can't load a stylesheet, so
|
|
3
|
+
// this is the one file allowed to hold a hex: everything else styles by role.
|
|
4
|
+
//
|
|
5
|
+
// The design system's rule is "one hue, one meaning": the mark inks a graphic
|
|
6
|
+
// (the banner), cobalt means "you can act on this" (links, the menu), forest
|
|
7
|
+
// means done, amber means attention, rust means something went wrong, and the
|
|
8
|
+
// rest is a cool neutral ramp.
|
|
9
|
+
// Dark theme (`:root:not([data-theme='light'])`). The default — most terminals
|
|
10
|
+
// people run `npx` in are dark.
|
|
11
|
+
export const dark = {
|
|
12
|
+
ink: '#f2f2f0',
|
|
13
|
+
inkSoft: '#a8a8b0',
|
|
14
|
+
slate: '#8b8b93',
|
|
15
|
+
lineStrong: '#3a3a41',
|
|
16
|
+
link: '#60a5fa',
|
|
17
|
+
success: '#8fd4ae',
|
|
18
|
+
attention: '#e0a44f',
|
|
19
|
+
danger: '#ec9880',
|
|
20
|
+
// --mark-ink (light) → --mark → --larch-deep (dark): deep larch to bright larch.
|
|
21
|
+
mark: ['#b4741f', '#e0a44f', '#f0bd74'],
|
|
22
|
+
};
|
|
23
|
+
// Light theme (`:root`). Picked only when the terminal advertises a light
|
|
24
|
+
// background (see `isLightTerminal` in ui.ts).
|
|
25
|
+
export const light = {
|
|
26
|
+
ink: '#111111',
|
|
27
|
+
inkSoft: '#52525b',
|
|
28
|
+
slate: '#8a8a8f',
|
|
29
|
+
lineStrong: '#cbcbce',
|
|
30
|
+
link: '#1d4ed8',
|
|
31
|
+
success: '#245740',
|
|
32
|
+
attention: '#8a5714',
|
|
33
|
+
danger: '#8a3722',
|
|
34
|
+
// --mark-ink → --mark → --mark-ink: the text-safe step at the edges.
|
|
35
|
+
mark: ['#8a5714', '#b4741f', '#8a5714'],
|
|
36
|
+
};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Shared terminal/layout primitives: theme-aware styles built on the design
|
|
2
|
+
// tokens, glyphs with legacy-console fallbacks, width-aware centering, tiny
|
|
3
|
+
// colour math for gradients, and cursor hiding during animations.
|
|
4
|
+
import figures from '@inquirer/figures';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import stringWidth from 'string-width';
|
|
7
|
+
import { dark, light } from './tokens.js';
|
|
8
|
+
// --- Theme --------------------------------------------------------------------
|
|
9
|
+
// Terminals don't expose their theme, but several (iTerm2, Konsole, rxvt, some
|
|
10
|
+
// tmux setups) export COLORFGBG as "<fg>;<bg>" using ANSI palette indexes.
|
|
11
|
+
// Index 7 and 15 are the two whites; everything else we treat as dark.
|
|
12
|
+
export function isLightTerminal(env = process.env) {
|
|
13
|
+
const spec = env.COLORFGBG;
|
|
14
|
+
if (!spec)
|
|
15
|
+
return false;
|
|
16
|
+
const bg = Number.parseInt(spec.split(';').at(-1) ?? '', 10);
|
|
17
|
+
return bg === 7 || bg === 15;
|
|
18
|
+
}
|
|
19
|
+
export const tokens = isLightTerminal() ? light : dark;
|
|
20
|
+
// Styles by role, so no module outside tokens.ts ever names a hex.
|
|
21
|
+
export const ink = chalk.hex(tokens.ink);
|
|
22
|
+
export const soft = chalk.hex(tokens.inkSoft);
|
|
23
|
+
export const slate = chalk.hex(tokens.slate);
|
|
24
|
+
export const link = chalk.hex(tokens.link);
|
|
25
|
+
export const success = chalk.hex(tokens.success);
|
|
26
|
+
export const attention = chalk.hex(tokens.attention);
|
|
27
|
+
export const danger = chalk.hex(tokens.danger);
|
|
28
|
+
export const mark = chalk.hex(tokens.mark[1]);
|
|
29
|
+
export const tier = figures.pointer === '❯' ? 'full' : process.platform === 'win32' ? 'basic' : 'ascii';
|
|
30
|
+
const GLYPHS = {
|
|
31
|
+
pointer: { full: '❯', basic: '›', ascii: '>' },
|
|
32
|
+
tick: { full: '✔', basic: '√', ascii: '√' },
|
|
33
|
+
cross: { full: '✖', basic: '×', ascii: '×' },
|
|
34
|
+
// ▲ is the design system's own "degraded" symbol, and it is in every console font.
|
|
35
|
+
warning: { full: '▲', basic: '▲', ascii: '!' },
|
|
36
|
+
star: { full: '★', basic: '★', ascii: '*' },
|
|
37
|
+
// Key names for the menu's help line (inquirer hardcodes ↑↓ and ⏎).
|
|
38
|
+
arrows: { full: '↑↓', basic: '↑↓', ascii: 'up/down' },
|
|
39
|
+
enter: { full: '⏎', basic: 'enter', ascii: 'enter' },
|
|
40
|
+
};
|
|
41
|
+
export const glyph = Object.fromEntries(Object.entries(GLYPHS).map(([name, byTier]) => [name, byTier[tier]]));
|
|
42
|
+
// --- Layout -------------------------------------------------------------------
|
|
43
|
+
// Mirrors boxen's fallback order so our centering agrees with its `float: 'center'`.
|
|
44
|
+
export function termWidth() {
|
|
45
|
+
if (process.stdout.columns)
|
|
46
|
+
return process.stdout.columns;
|
|
47
|
+
if (process.stderr.columns)
|
|
48
|
+
return process.stderr.columns;
|
|
49
|
+
if (process.env.COLUMNS)
|
|
50
|
+
return Number.parseInt(process.env.COLUMNS, 10) || 80;
|
|
51
|
+
return 80;
|
|
52
|
+
}
|
|
53
|
+
// Centers every line of `text` on the terminal. Lines wider than the terminal
|
|
54
|
+
// are left alone (never negative padding, never truncation).
|
|
55
|
+
export function center(text, width = termWidth()) {
|
|
56
|
+
return text
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((line) => {
|
|
59
|
+
if (line === '')
|
|
60
|
+
return line;
|
|
61
|
+
const pad = Math.max(0, Math.floor((width - stringWidth(line)) / 2));
|
|
62
|
+
return ' '.repeat(pad) + line;
|
|
63
|
+
})
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
function hexToRgb(hex) {
|
|
67
|
+
const n = Number.parseInt(hex.slice(1), 16);
|
|
68
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
69
|
+
}
|
|
70
|
+
function rgbToHex([r, g, b]) {
|
|
71
|
+
const part = (v) => Math.round(Math.min(255, Math.max(0, v)))
|
|
72
|
+
.toString(16)
|
|
73
|
+
.padStart(2, '0');
|
|
74
|
+
return `#${part(r)}${part(g)}${part(b)}`;
|
|
75
|
+
}
|
|
76
|
+
// Linear blend from `from` to `to`; `t` is clamped to [0, 1].
|
|
77
|
+
export function mix(from, to, t) {
|
|
78
|
+
const k = Math.min(1, Math.max(0, t));
|
|
79
|
+
const a = hexToRgb(from);
|
|
80
|
+
const b = hexToRgb(to);
|
|
81
|
+
return rgbToHex([
|
|
82
|
+
a[0] + (b[0] - a[0]) * k,
|
|
83
|
+
a[1] + (b[1] - a[1]) * k,
|
|
84
|
+
a[2] + (b[2] - a[2]) * k,
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
// Samples a multi-stop gradient at position `t` in [0, 1].
|
|
88
|
+
export function sample(stops, t) {
|
|
89
|
+
const first = stops[0];
|
|
90
|
+
if (first === undefined)
|
|
91
|
+
return tokens.ink;
|
|
92
|
+
if (stops.length === 1)
|
|
93
|
+
return first;
|
|
94
|
+
const k = Math.min(1, Math.max(0, t)) * (stops.length - 1);
|
|
95
|
+
const i = Math.min(stops.length - 2, Math.floor(k));
|
|
96
|
+
const a = stops[i] ?? first;
|
|
97
|
+
const b = stops[i + 1] ?? a;
|
|
98
|
+
return mix(a, b, k - i);
|
|
99
|
+
}
|
|
100
|
+
// Paints a horizontal gradient across each line of a multi-line string.
|
|
101
|
+
export function paintGradient(text, stops) {
|
|
102
|
+
return text
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map((line) => {
|
|
105
|
+
const last = Math.max(1, line.length - 1);
|
|
106
|
+
let out = '';
|
|
107
|
+
for (let x = 0; x < line.length; x++) {
|
|
108
|
+
const ch = line[x] ?? '';
|
|
109
|
+
out += ch === ' ' ? ch : chalk.hex(sample(stops, x / last))(ch);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
})
|
|
113
|
+
.join('\n');
|
|
114
|
+
}
|
|
115
|
+
// --- Cursor -------------------------------------------------------------------
|
|
116
|
+
const HIDE = '\u001b[?25l';
|
|
117
|
+
const SHOW = '\u001b[?25h';
|
|
118
|
+
// Runs `fn` with the cursor hidden and guarantees it comes back — including when
|
|
119
|
+
// the user hits Ctrl+C mid-animation, which would otherwise leave their shell
|
|
120
|
+
// without a cursor.
|
|
121
|
+
export async function withHiddenCursor(fn) {
|
|
122
|
+
const restore = () => process.stdout.write(SHOW);
|
|
123
|
+
const onSigint = () => {
|
|
124
|
+
restore();
|
|
125
|
+
process.exit(130);
|
|
126
|
+
};
|
|
127
|
+
process.stdout.write(HIDE);
|
|
128
|
+
process.once('SIGINT', onSigint);
|
|
129
|
+
try {
|
|
130
|
+
return await fn();
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
process.removeListener('SIGINT', onSigint);
|
|
134
|
+
restore();
|
|
135
|
+
}
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brignano",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20"
|
|
@@ -44,14 +44,15 @@
|
|
|
44
44
|
"provenance": true
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
+
"@inquirer/figures": "^2.0.9",
|
|
47
48
|
"@inquirer/prompts": "^8.6.0",
|
|
48
49
|
"boxen": "^8.0.1",
|
|
49
50
|
"chalk": "^6.0.0",
|
|
50
51
|
"clipboardy": "^5.3.2",
|
|
51
52
|
"figlet": "^1.11.4",
|
|
52
|
-
"gradient-string": "^3.0.0",
|
|
53
53
|
"open": "^11.0.1",
|
|
54
|
-
"qrcode-terminal": "^0.12.0"
|
|
54
|
+
"qrcode-terminal": "^0.12.0",
|
|
55
|
+
"string-width": "^7.2.0"
|
|
55
56
|
},
|
|
56
57
|
"devDependencies": {
|
|
57
58
|
"@biomejs/biome": "^2.5.10",
|
package/readme.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/brignano/card/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
An interactive terminal business card for Anthony Brignano. Run it with `npx` to get
|
|
8
|
+
An interactive terminal business card for Anthony Brignano. Run it with `npx` to get an animated, centered ASCII-art intro, a styled card with **live GitHub stats**, and quick actions — copy my email, open my socials, save my contact, or grab a scannable QR code.
|
|
9
9
|
|
|
10
10
|
## Quick start
|
|
11
11
|
|
|
@@ -17,20 +17,36 @@ npx brignano
|
|
|
17
17
|
|
|
18
18
|
## What it does
|
|
19
19
|
|
|
20
|
-
- **
|
|
21
|
-
- **Styled business card** (`boxen` + `chalk`) with name, role, and
|
|
20
|
+
- **Animated splash** (`figlet` + `chalk`): a light sweeps across the lettering to reveal the larch-amber monogram, a glint follows, and a tagline fades in beneath. Everything is centered on your terminal, and the banner steps down to a smaller font on narrow windows.
|
|
21
|
+
- **Styled business card** (`boxen` + `chalk`) with name, role, links and email, revealed line by line under the banner.
|
|
22
22
|
- **Live GitHub stats** — follower/repo counts and my latest repo, fetched fresh with a strict ~1.5s timeout. If the network is slow or offline, the card renders instantly and just omits the live line. It never hangs.
|
|
23
|
-
- **Interactive menu** (`inquirer`)
|
|
23
|
+
- **Interactive menu** (`inquirer`) that comes back after each action, so you can do several things in one sitting:
|
|
24
24
|
- Copy my email to your clipboard
|
|
25
25
|
- Send me an email (`mailto:`)
|
|
26
26
|
- Open my GitHub / LinkedIn
|
|
27
27
|
- View my resume
|
|
28
28
|
- Save my contact as a `.vcf` (opens in Contacts)
|
|
29
29
|
- Show a scannable contact **QR code**
|
|
30
|
-
- Quit
|
|
30
|
+
- Quit (or press Ctrl+C at any time)
|
|
31
31
|
|
|
32
32
|
The animation, network fetch, and prompt are all skipped automatically when output isn't a TTY (piped, CI, or `--no-prompt`), so it stays fast and never blocks scripts.
|
|
33
33
|
|
|
34
|
+
## Design
|
|
35
|
+
|
|
36
|
+
Colours come from [`@brignano/design`](https://github.com/brignano/design), the
|
|
37
|
+
shared design system behind `brignano.io`, and follow its "one hue, one meaning"
|
|
38
|
+
rule: the banner is the monogram and takes the larch-amber **mark**; anything you
|
|
39
|
+
can act on (links, the selected menu row) is **cobalt**; confirmations are
|
|
40
|
+
**forest**, failures are **rust**, and everything else sits on the neutral ramp.
|
|
41
|
+
The hex values live in one place, `src/tokens.ts`, named after the CSS custom
|
|
42
|
+
properties they mirror; the dark set is the default and the light set is picked
|
|
43
|
+
when the terminal advertises a light background via `COLORFGBG`.
|
|
44
|
+
|
|
45
|
+
Icons come in three tiers so nothing ever renders as an empty box: full Unicode
|
|
46
|
+
where the terminal supports it; a set drawn from the blocks the legacy Windows
|
|
47
|
+
console's font does cover (it draws `★`, `›` and arrows fine, just not `❯` or
|
|
48
|
+
`✔`); and plain ASCII on the Linux kernel console.
|
|
49
|
+
|
|
34
50
|
## CLI
|
|
35
51
|
|
|
36
52
|
Run `npx brignano --help` to see this list.
|
|
@@ -81,12 +97,17 @@ node dist/card.js
|
|
|
81
97
|
> TypeScript directly, no build step), or `npm run build` first. To exercise the
|
|
82
98
|
> published package instead, run `npx brignano` from any other directory.
|
|
83
99
|
|
|
100
|
+
The recording in `demo.gif` is produced by `vhs/demo.tape`. Re-record it from
|
|
101
|
+
the Actions tab (**Demo GIF → Run workflow**) after a visual change lands; the
|
|
102
|
+
workflow renders it on a Linux runner and pushes the new file back, so VHS
|
|
103
|
+
doesn't need to be installed locally.
|
|
104
|
+
|
|
84
105
|
Source lives in `src/` (split into small modules — `card.ts` is the entry/`bin`
|
|
85
106
|
target) and compiles to `dist/` via `tsc`. `npm run dev` runs the TypeScript
|
|
86
107
|
directly through `tsx`; `npm run lint` and `npm run format` use
|
|
87
108
|
[Biome](https://biomejs.dev).
|
|
88
109
|
|
|
89
|
-
Only `dist/` is published — the tarball is ~
|
|
110
|
+
Only `dist/` is published — the tarball is ~13 kB, so `npx brignano` stays a fast
|
|
90
111
|
cold start.
|
|
91
112
|
|
|
92
113
|
## Prerequisites
|