brignano 2.0.1 → 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 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
- // Brand palette — matches brignano.io's electric indigo/violet accent
5
- // (violet-700 → violet-500 → violet-400). Reads well on a dark terminal.
6
- const BRAND = ['#6d28d9', '#8b5cf6', '#a78bfa'];
7
- // A longer ramp with a bright crest (violet-300) used for the shimmer sweep.
8
- const SHIMMER = [
9
- '#5b21b6',
10
- '#6d28d9',
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
- function bannerArt(text) {
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
- return figlet.textSync(text, { font: 'ANSI Shadow' });
18
+ lines = choice
19
+ ? figlet.textSync(text, { font: choice.font }).split('\n')
20
+ : [text.toUpperCase()];
23
21
  }
24
22
  catch {
25
- return text.toUpperCase();
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
- function paint(colors, art) {
29
- try {
30
- return gradient(colors).multiline(art);
31
- }
32
- catch {
33
- return art;
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
- // Static gradient banner (used for the non-animated path).
37
- export function renderBanner(text = 'brignano') {
38
- return paint(BRAND, bannerArt(text));
69
+ function subtitle(text, color = tokens.inkSoft) {
70
+ return chalk.hex(color).italic(text);
39
71
  }
40
- // Animated splash: a violet highlight sweeps across the letters, then settles on
41
- // the static brand gradient. Redraws in place via ANSI cursor moves, so it must
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
- const lineCount = art.split('\n').length;
46
- for (let i = 0; i < frames; i++) {
47
- const offset = i % SHIMMER.length;
48
- const rotated = SHIMMER.slice(offset).concat(SHIMMER.slice(0, offset));
49
- if (i > 0)
50
- process.stdout.write(`\u001b[${lineCount}A`);
51
- process.stdout.write(`\r${paint(rotated, art)}\n`);
52
- await sleep(frameMs);
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('Note: --download is deprecated; opening the resume instead.');
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(chalk.gray(` ${profile.website}\n`));
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: an animated violet shimmer when we can animate, otherwise the
64
- // static gradient.
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 animateBanner();
67
- await sleep(120);
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
- // Card, enriched with whatever the (bounded) live fetch returned.
73
- const live = await livePromise;
74
- const tagline = pick(taglines);
75
- await reveal(renderCard({ live, tagline }), 30, animate);
76
- console.log(`Tip: ${chalk.cyanBright.bold('cmd/ctrl + click')} the links above.`);
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
- const action = await promptForAction();
81
- await action();
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
- console.log('\nHave a great day!\n');
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 = 10;
5
+ const LABEL_WIDTH = 8;
6
6
  function label(text) {
7
- return chalk.white.bold(text.padStart(LABEL_WIDTH) + ':');
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, tagline }) {
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
- accent.bold(profile.fullName) + ' ' + chalk.gray(profile.handle),
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
- `${label('GitHub')} ${chalk.gray('https://github.com/')}${chalk.green(profile.username)}`,
22
- `${label('LinkedIn')} ${chalk.gray('https://linkedin.com/in/')}${chalk.blue(profile.username)}`,
23
- `${label('Website')} ${chalk.cyan(profile.website)}`,
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
- `${chalk.yellow('★')} ${chalk.bold(live.followers)} followers`,
29
- `${chalk.bold(live.publicRepos)} repos`,
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(`latest: ${chalk.green(live.latestRepo.name)}`);
36
+ parts.push(`${soft('latest')} ${link(live.latestRepo.name)}`);
33
37
  }
34
- lines.push(`${label('Live')} ${chalk.gray(parts.join(' · '))}`);
38
+ lines.push('', row('Stats', parts.join(slate(' · '))));
35
39
  }
36
- lines.push('', `${label('Card')} ${chalk.red('npx')} ${chalk.white('brignano')}`, '', chalk.italic(tagline));
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: 'gray',
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 occasionally below the card.
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
- 'Tip: scan the QR with your phone to save my contact instantly.',
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/live.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { profile } from './data.js';
2
2
  const API = 'https://api.github.com';
3
3
  const HEADERS = {
4
- 'User-Agent': 'npx-brignano',
4
+ 'User-Agent': 'brignano',
5
5
  Accept: 'application/vnd.github+json',
6
6
  };
7
7
  // Fetches live GitHub stats with a hard timeout. ANY failure (timeout, offline,
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${chalk.green('Copied')} ${profile.email} to your clipboard.\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 ${chalk.green(profile.email)} — drop me a line!\n`);
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('\nOpened your mail client — see you in the inbox.\n');
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(`Failed to open mail client: ${message}`);
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(`\nOpened ${label}: ${url}\n`);
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(`Failed to open ${label}: ${message}`);
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: `Open my ${chalk.green.bold('GitHub')}`,
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: `Open my ${chalk.blue.bold('LinkedIn')}`,
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: `Save my ${chalk.cyan.bold('contact')} (.vcf)`,
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: 'Just quit',
69
- value: () => {
70
- console.log('\nHave a great day!\n');
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: 'What would you like to do?',
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.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -22,14 +22,14 @@
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "git+https://github.com/brignano/npx-brignano.git"
25
+ "url": "git+https://github.com/brignano/card.git"
26
26
  },
27
27
  "author": "Anthony Brignano <anthonybrignano@gmail.com> (https://brignano.io/)",
28
28
  "license": "ISC",
29
29
  "bugs": {
30
- "url": "https://github.com/brignano/npx-brignano/issues"
30
+ "url": "https://github.com/brignano/card/issues"
31
31
  },
32
- "homepage": "https://github.com/brignano/npx-brignano#readme",
32
+ "homepage": "https://github.com/brignano/card#readme",
33
33
  "keywords": [
34
34
  "npx",
35
35
  "business-card",
@@ -38,20 +38,21 @@
38
38
  "resume",
39
39
  "vcard"
40
40
  ],
41
- "description": "A personal card for Anthony Brignano (@brignano)",
41
+ "description": "An interactive terminal business card for Anthony Brignano — run `npx brignano`",
42
42
  "publishConfig": {
43
43
  "access": "public",
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
@@ -1,11 +1,11 @@
1
- # npx-brignano
1
+ # npx brignano
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/brignano?style=flat-square&logo=npm&color=8b5cf6)](https://www.npmjs.com/package/brignano)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/brignano?style=flat-square&color=8b5cf6)](https://www.npmjs.com/package/brignano)
5
- [![CI](https://img.shields.io/github/actions/workflow/status/brignano/npx-brignano/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/brignano/npx-brignano/actions/workflows/ci.yml)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/brignano/card/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/brignano/card/actions/workflows/ci.yml)
6
6
  [![license](https://img.shields.io/npm/l/brignano?style=flat-square&color=8b5cf6)](LICENSE)
7
7
 
8
- An interactive terminal business card for Anthony Brignano. Run it with `npx` to get a gradient 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.
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
- - **Gradient ASCII banner** (`figlet` + `gradient-string`) with a smooth line-by-line reveal animation.
21
- - **Styled business card** (`boxen` + `chalk`) with name, role, and links.
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`) with quick actions:
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.
@@ -68,8 +84,8 @@ npx brignano --no-prompt # card only (useful in CI/scripts)
68
84
  ## Local development
69
85
 
70
86
  ```bash
71
- git clone https://github.com/brignano/npx-brignano.git
72
- cd npx-brignano
87
+ git clone https://github.com/brignano/card.git
88
+ cd card
73
89
  npm install
74
90
  npm run build
75
91
  node dist/card.js
@@ -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 ~8 kB, so `npx brignano` stays a fast
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