@nbtca/prompt 1.3.2 → 1.4.1

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.
Files changed (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +24 -6
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -98
  47. package/dist/features/docs.js +258 -55
  48. package/dist/features/links.js +7 -4
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +41 -30
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +149 -6
  58. package/dist/i18n/locales/zh.json +149 -6
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
@@ -0,0 +1,42 @@
1
+ import { useUnicodeIcons } from './icons.js';
2
+ import { resolveColorMode } from '../config/preferences.js';
3
+ export function deriveReducedMotion(o) {
4
+ const env = o.env ?? process.env;
5
+ if (!o.isTTY)
6
+ return true;
7
+ if (env['NBTCA_NO_MOTION'])
8
+ return true;
9
+ if (env['CI'])
10
+ return true;
11
+ if ((env['TERM'] || '').toLowerCase() === 'dumb')
12
+ return true;
13
+ if (!o.color)
14
+ return true;
15
+ if (!o.unicode)
16
+ return true;
17
+ return false;
18
+ }
19
+ function detectColor() {
20
+ if (process.env['NO_COLOR'])
21
+ return false;
22
+ const mode = resolveColorMode();
23
+ if (mode === 'off')
24
+ return false;
25
+ if (mode === 'on')
26
+ return true;
27
+ return !!process.stdout.isTTY;
28
+ }
29
+ let cached = null;
30
+ export function getCapabilities() {
31
+ if (cached)
32
+ return cached;
33
+ const isTTY = !!process.stdout.isTTY && !!process.stdin.isTTY;
34
+ const unicode = useUnicodeIcons();
35
+ const color = detectColor();
36
+ const reducedMotion = deriveReducedMotion({ isTTY, color, unicode });
37
+ cached = { isTTY, unicode, color, reducedMotion };
38
+ return cached;
39
+ }
40
+ export function resetCapabilities() {
41
+ cached = null;
42
+ }
@@ -0,0 +1,75 @@
1
+ import { startRawInput } from './input-session.js';
2
+ import { setVimKeysActive } from '../vim-keys.js';
3
+ import { glyph, type, space } from '../theme.js';
4
+ import { createPainter } from './painter.js';
5
+ export function parseConfirmData(data) {
6
+ const s = data.toString();
7
+ if (s === 'y' || s === 'Y')
8
+ return 'yes';
9
+ if (s === 'n' || s === 'N')
10
+ return 'no';
11
+ if (s === '\t' || s === '\x1b[C' || s === '\x1b[D')
12
+ return 'toggle';
13
+ if (s === '\r' || s === '\n')
14
+ return 'submit';
15
+ if (s === '\x03' || s === '\x1b')
16
+ return 'cancel';
17
+ return 'none';
18
+ }
19
+ export function renderConfirm(opts) {
20
+ const cursor = glyph.cursor();
21
+ const gap = ' '.repeat(cursor.length);
22
+ const yes = opts.value ? `${type.active(cursor)} ${type.active('Yes')}` : `${gap} ${type.body('Yes')}`;
23
+ const no = opts.value ? `${gap} ${type.body('No')}` : `${type.active(cursor)} ${type.active('No')}`;
24
+ return [
25
+ space.indent + type.label(opts.message),
26
+ `${space.indent}${yes} ${no}`,
27
+ ].join('\n');
28
+ }
29
+ export function runConfirm(config) {
30
+ return new Promise((resolve) => {
31
+ let value = config.initial ?? true;
32
+ const frame = () => renderConfirm({ message: config.message, value });
33
+ const paint = createPainter(frame);
34
+ const finish = (result) => {
35
+ handle?.stop();
36
+ setVimKeysActive(true);
37
+ process.stdout.write('\n');
38
+ resolve(result);
39
+ };
40
+ const onData = (data) => {
41
+ const ev = parseConfirmData(data);
42
+ if (ev === 'cancel') {
43
+ finish(null);
44
+ return;
45
+ }
46
+ if (ev === 'submit') {
47
+ finish(value);
48
+ return;
49
+ }
50
+ if (ev === 'yes' && value !== true) {
51
+ value = true;
52
+ paint();
53
+ return;
54
+ }
55
+ if (ev === 'no' && value !== false) {
56
+ value = false;
57
+ paint();
58
+ return;
59
+ }
60
+ if (ev === 'toggle') {
61
+ value = !value;
62
+ paint();
63
+ return;
64
+ }
65
+ };
66
+ setVimKeysActive(false);
67
+ const handle = startRawInput(onData);
68
+ if (!handle) {
69
+ setVimKeysActive(true);
70
+ resolve(null);
71
+ return;
72
+ }
73
+ paint();
74
+ });
75
+ }
@@ -0,0 +1,24 @@
1
+ import { ansi, ensureCursorRestored } from '../canvas.js';
2
+ export function startRawInput(onData) {
3
+ const stdin = process.stdin;
4
+ if (!stdin.isTTY || !process.stdout.isTTY)
5
+ return null;
6
+ let stopped = false;
7
+ ensureCursorRestored();
8
+ stdin.setRawMode(true);
9
+ stdin.resume();
10
+ process.stdout.write(ansi.hideCursor);
11
+ stdin.on('data', onData);
12
+ return {
13
+ stop() {
14
+ if (stopped)
15
+ return;
16
+ stopped = true;
17
+ stdin.removeListener('data', onData);
18
+ if (stdin.isTTY)
19
+ stdin.setRawMode(false);
20
+ stdin.pause();
21
+ process.stdout.write(ansi.showCursor);
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,122 @@
1
+ import { glyph, type, space } from '../theme.js';
2
+ import { visualWidth, padEndV, wrapAnsiToVisualWidth } from '../text.js';
3
+ import { ansi, ensureCursorRestored } from '../canvas.js';
4
+ import { createPainter } from './painter.js';
5
+ import { t } from '../../i18n/index.js';
6
+ export function parseKey(data) {
7
+ const s = data.toString();
8
+ switch (s) {
9
+ case '\x1b[A': return 'up';
10
+ case '\x1b[B': return 'down';
11
+ case '\x1b[H': return 'home';
12
+ case '\x1b[F': return 'end';
13
+ case '\r':
14
+ case '\n': return 'enter';
15
+ case '\x03':
16
+ case '\x1b': return 'cancel';
17
+ default: return 'none';
18
+ }
19
+ }
20
+ export function nextIndex(current, key, len) {
21
+ if (len <= 0)
22
+ return 0;
23
+ switch (key) {
24
+ case 'up': return (current - 1 + len) % len;
25
+ case 'down': return (current + 1) % len;
26
+ case 'home': return 0;
27
+ case 'end': return len - 1;
28
+ default: return current;
29
+ }
30
+ }
31
+ function normalizedWidth(cols) {
32
+ return Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
33
+ }
34
+ function renderIndentedText(label, cols, style) {
35
+ const width = normalizedWidth(cols);
36
+ const indent = visualWidth(space.indent) < width ? space.indent : '';
37
+ const contentWidth = Math.max(1, width - visualWidth(indent));
38
+ return wrapAnsiToVisualWidth(style(label), contentWidth).map((line) => `${indent}${line}`);
39
+ }
40
+ export function renderMenuOption(option, selected, labelWidth = visualWidth(option.label), cols = Number.POSITIVE_INFINITY) {
41
+ const width = normalizedWidth(cols);
42
+ const cursor = glyph.cursor();
43
+ const gap = ' '.repeat(visualWidth(cursor));
44
+ const marker = selected ? type.active(cursor) : gap;
45
+ const prefixes = [`${space.indent}${marker} `, `${marker} `, marker, ''];
46
+ const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? '';
47
+ const continuation = ' '.repeat(visualWidth(prefix));
48
+ const contentWidth = Math.max(1, width - visualWidth(prefix));
49
+ const hintWidth = option.hint ? 2 + visualWidth(option.hint) : 0;
50
+ const paddedWidth = labelWidth + hintWidth <= contentWidth ? labelWidth : visualWidth(option.label);
51
+ const padded = padEndV(option.label, paddedWidth);
52
+ const label = selected ? type.active(padded) : type.body(padded);
53
+ const hint = option.hint ? ` ${type.hint(option.hint)}` : '';
54
+ return wrapAnsiToVisualWidth(`${label}${hint}`, contentWidth)
55
+ .map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
56
+ }
57
+ export function renderMenu(state, cols = Number.POSITIVE_INFINITY) {
58
+ const labelWidth = state.options.reduce((width, option) => Math.max(width, visualWidth(option.label)), 0);
59
+ const lines = renderIndentedText(state.title, cols, type.heading);
60
+ lines.push('');
61
+ state.options.forEach((option, index) => {
62
+ lines.push(...renderMenuOption(option, index === state.selectedIndex, labelWidth, cols));
63
+ });
64
+ if (state.footer) {
65
+ lines.push('');
66
+ lines.push(...renderIndentedText(state.footer, cols, type.hint));
67
+ }
68
+ return lines.join('\n');
69
+ }
70
+ /** Standard navigation keyhint footer shared by every menu surface. */
71
+ export function menuFooter() {
72
+ const m = t().menu;
73
+ return `${glyph.updown()} ${m.hintMove} ${glyph.enter()} ${m.hintOpen} q ${m.hintQuit}`;
74
+ }
75
+ // Note: runMenu relies on ambient vim-key translation (j/k/l/g/G/q) being ACTIVE.
76
+ // Callers must not invoke it with setVimKeysActive(false) still in effect.
77
+ export function runMenu(config) {
78
+ return new Promise((resolve) => {
79
+ const stdin = process.stdin;
80
+ if (!stdin.isTTY || !process.stdout.isTTY) {
81
+ resolve(null);
82
+ return;
83
+ }
84
+ let index = config.initialIndex ?? 0;
85
+ const paint = createPainter(() => renderMenu({
86
+ title: config.title,
87
+ options: config.options,
88
+ selectedIndex: index,
89
+ footer: config.footer,
90
+ }));
91
+ const cleanup = () => {
92
+ stdin.removeListener('data', onData);
93
+ if (stdin.isTTY)
94
+ stdin.setRawMode(false);
95
+ process.stdout.write('\n' + ansi.showCursor);
96
+ };
97
+ const onData = (data) => {
98
+ const key = parseKey(data);
99
+ if (key === 'cancel') {
100
+ cleanup();
101
+ resolve(null);
102
+ return;
103
+ }
104
+ if (key === 'enter') {
105
+ cleanup();
106
+ resolve(config.options[index]?.value ?? null);
107
+ return;
108
+ }
109
+ const next = nextIndex(index, key, config.options.length);
110
+ if (next !== index) {
111
+ index = next;
112
+ paint();
113
+ }
114
+ };
115
+ ensureCursorRestored();
116
+ stdin.setRawMode(true);
117
+ stdin.resume();
118
+ process.stdout.write(ansi.hideCursor);
119
+ paint();
120
+ stdin.on('data', onData);
121
+ });
122
+ }
@@ -0,0 +1,16 @@
1
+ import { c, space } from '../theme.js';
2
+ import { pickIcon } from '../icons.js';
3
+ const MARKERS = {
4
+ success: { icon: () => pickIcon('✓', '+'), color: c.success },
5
+ error: { icon: () => pickIcon('✕', 'x'), color: c.error },
6
+ warn: { icon: () => pickIcon('⚠', '!'), color: c.warn },
7
+ info: { icon: () => pickIcon('›', '>'), color: c.accent },
8
+ };
9
+ export function renderMessage(kind, msg) {
10
+ const m = MARKERS[kind];
11
+ return `${space.indent}${m.color(m.icon())} ${msg}`;
12
+ }
13
+ export function success(msg) { console.log(renderMessage('success', msg)); }
14
+ export function error(msg) { console.log(renderMessage('error', msg)); }
15
+ export function warning(msg) { console.log(renderMessage('warn', msg)); }
16
+ export function info(msg) { console.log(renderMessage('info', msg)); }
@@ -0,0 +1,18 @@
1
+ import { glyph, type, space } from '../theme.js';
2
+ import { visualWidth } from '../text.js';
3
+ export function renderNote(message, title) {
4
+ const bodyLines = message.split('\n');
5
+ const lines = [];
6
+ if (title) {
7
+ const width = Math.max(visualWidth(title), ...bodyLines.map((l) => visualWidth(l)));
8
+ lines.push(space.indent + type.heading(title));
9
+ lines.push(space.indent + type.hint(glyph.rule().repeat(width)));
10
+ }
11
+ for (const line of bodyLines) {
12
+ lines.push(space.indent + line);
13
+ }
14
+ return lines.join('\n');
15
+ }
16
+ export function note(message, title) {
17
+ console.log(renderNote(message, title));
18
+ }
@@ -0,0 +1,26 @@
1
+ import { ansi } from '../canvas.js';
2
+ import { visualWidth } from '../text.js';
3
+ /** Visual row count for a frame, accounting for terminal soft-wrap. */
4
+ export function frameRows(frame, cols) {
5
+ return frame.split('\n').reduce((n, line) => {
6
+ const w = visualWidth(line);
7
+ return n + Math.max(1, Math.ceil(w / cols));
8
+ }, 0);
9
+ }
10
+ /**
11
+ * Returns a paint() closure that redraws a fixed-line-count frame in place,
12
+ * erasing the previous frame first. `write` is injectable for tests.
13
+ */
14
+ export function createPainter(frame, write = (s) => { process.stdout.write(s); }) {
15
+ let painted = 0;
16
+ return () => {
17
+ const f = frame();
18
+ const cols = process.stdout.columns || 80;
19
+ const rows = frameRows(f, cols);
20
+ if (painted > 0) {
21
+ write(ansi.cursorUp(painted - 1) + ansi.cursorToCol0 + ansi.eraseDown);
22
+ }
23
+ write(f);
24
+ painted = rows;
25
+ };
26
+ }
@@ -0,0 +1,18 @@
1
+ import { glyph, type, space } from '../theme.js';
2
+ export function screenWidth() {
3
+ return Math.min(process.stdout.columns || 80, 64);
4
+ }
5
+ export function renderScreen(opts) {
6
+ const width = opts.width ?? screenWidth();
7
+ const lines = [];
8
+ if (opts.title) {
9
+ lines.push(space.indent + type.heading(opts.title));
10
+ lines.push(space.indent + type.hint(glyph.rule().repeat(width)));
11
+ }
12
+ lines.push(opts.body);
13
+ if (opts.footer) {
14
+ lines.push('');
15
+ lines.push(space.indent + type.hint(opts.footer));
16
+ }
17
+ return lines.join('\n');
18
+ }
@@ -0,0 +1,47 @@
1
+ import { getCapabilities } from '../capabilities.js';
2
+ import { ansi, ensureCursorRestored } from '../canvas.js';
3
+ import { renderMessage } from './messages.js';
4
+ import { pickIcon } from '../icons.js';
5
+ import { c, space } from '../theme.js';
6
+ const FRAMES_UNICODE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
+ const FRAMES_ASCII = ['|', '/', '-', '\\'];
8
+ export function renderSpinnerFrame(frame, msg) {
9
+ return `${space.indent}${c.accent(frame)} ${msg}`;
10
+ }
11
+ export function startSpinner(msg = '', opts = {}) {
12
+ const write = opts.write ?? ((s) => { process.stdout.write(s); });
13
+ const reduced = opts.reducedMotion ?? getCapabilities().reducedMotion;
14
+ const frames = pickIcon('u', 'a') === 'u' ? FRAMES_UNICODE : FRAMES_ASCII;
15
+ let current = msg;
16
+ let timer = null;
17
+ let i = 0;
18
+ const clearLine = () => write(ansi.cursorToCol0 + ansi.eraseDown);
19
+ const paint = () => {
20
+ clearLine();
21
+ write(renderSpinnerFrame(frames[i % frames.length], current));
22
+ i++;
23
+ };
24
+ if (!reduced) {
25
+ ensureCursorRestored();
26
+ write(ansi.hideCursor);
27
+ paint();
28
+ timer = setInterval(paint, 80);
29
+ }
30
+ const finish = (line) => {
31
+ if (timer) {
32
+ clearInterval(timer);
33
+ timer = null;
34
+ }
35
+ if (!reduced) {
36
+ clearLine();
37
+ write(ansi.showCursor);
38
+ }
39
+ if (line)
40
+ write(line + '\n');
41
+ };
42
+ return {
43
+ message: (m) => { current = m; },
44
+ stop: (m) => finish(m ? renderMessage('success', m) : null),
45
+ error: (m) => finish(m ? renderMessage('error', m) : null),
46
+ };
47
+ }
@@ -0,0 +1,98 @@
1
+ import { glyph, type, space } from '../theme.js';
2
+ import { startRawInput } from './input-session.js';
3
+ import { setVimKeysActive } from '../vim-keys.js';
4
+ import { createPainter } from './painter.js';
5
+ import { visualWidth, wrapAnsiToVisualWidth } from '../text.js';
6
+ export function parseInputData(data) {
7
+ const s = data.toString();
8
+ if (s === '\r' || s === '\n')
9
+ return { type: 'enter' };
10
+ if (s === '\x03')
11
+ return { type: 'cancel' }; // ctrl-c
12
+ if (s === '\x1b')
13
+ return { type: 'cancel' }; // bare esc
14
+ if (s === '\x7f' || s === '\b')
15
+ return { type: 'backspace' };
16
+ if (s.startsWith('\x1b'))
17
+ return { type: 'none' }; // escape sequence (arrows, etc.)
18
+ // printable run: drop control chars, keep the rest (supports paste / batched keys)
19
+ const text = [...s].filter((ch) => ch >= ' ' && ch !== '\x7f').join('');
20
+ return text.length > 0 ? { type: 'char', ch: text } : { type: 'none' };
21
+ }
22
+ export function applyInputEvent(value, ev) {
23
+ if (ev.type === 'char')
24
+ return value + ev.ch;
25
+ if (ev.type === 'backspace')
26
+ return [...value].slice(0, -1).join('');
27
+ return value;
28
+ }
29
+ export function renderInput(opts) {
30
+ const width = Number.isFinite(opts.cols)
31
+ ? Math.max(1, Math.floor(opts.cols ?? Number.POSITIVE_INFINITY))
32
+ : Number.POSITIVE_INFINITY;
33
+ const indent = visualWidth(space.indent) < width ? space.indent : '';
34
+ const messageWidth = Math.max(1, width - visualWidth(indent));
35
+ const messageLines = wrapAnsiToVisualWidth(type.label(opts.message), messageWidth)
36
+ .map((line) => `${indent}${line}`);
37
+ const visibleValue = opts.secret
38
+ ? (opts.mask ?? '*').repeat([...opts.value].length)
39
+ : opts.value;
40
+ const shown = opts.value.length > 0
41
+ ? type.body(visibleValue)
42
+ : type.hint(opts.placeholder ?? '');
43
+ const cursor = type.active(glyph.cursor());
44
+ const prefixes = [`${space.indent}${cursor} `, `${cursor} `, cursor, ''];
45
+ const prefix = prefixes.find((candidate) => visualWidth(candidate) < width) ?? '';
46
+ const continuation = ' '.repeat(visualWidth(prefix));
47
+ const inputWidth = Math.max(1, width - visualWidth(prefix));
48
+ const inputLines = wrapAnsiToVisualWidth(shown, inputWidth)
49
+ .map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
50
+ return [...messageLines, ...inputLines].join('\n');
51
+ }
52
+ export function runTextInput(config) {
53
+ return new Promise((resolve) => {
54
+ let value = '';
55
+ const frame = () => renderInput({
56
+ message: config.message,
57
+ value,
58
+ placeholder: config.placeholder,
59
+ secret: config.secret,
60
+ mask: config.mask,
61
+ });
62
+ const paint = createPainter(frame);
63
+ const onData = (data) => {
64
+ const ev = parseInputData(data);
65
+ if (ev.type === 'cancel') {
66
+ finish(null);
67
+ return;
68
+ }
69
+ if (ev.type === 'enter') {
70
+ if (value.length > 0 || config.allowEmpty !== false)
71
+ finish(value);
72
+ return;
73
+ }
74
+ const next = applyInputEvent(value, ev);
75
+ if (next !== value) {
76
+ value = next;
77
+ paint();
78
+ }
79
+ };
80
+ const finish = (result) => {
81
+ handle?.stop();
82
+ setVimKeysActive(true);
83
+ process.stdout.write('\n');
84
+ resolve(result);
85
+ };
86
+ setVimKeysActive(false);
87
+ const handle = startRawInput(onData);
88
+ if (!handle) {
89
+ setVimKeysActive(true);
90
+ resolve(null);
91
+ return;
92
+ }
93
+ paint();
94
+ });
95
+ }
96
+ export function runSecretInput(config) {
97
+ return runTextInput({ ...config, secret: true });
98
+ }
package/dist/core/logo.js CHANGED
@@ -7,17 +7,12 @@ import { readFileSync } from 'fs';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
9
  import chalk from 'chalk';
10
- import gradient from 'gradient-string';
11
10
  import { useUnicodeIcons } from './icons.js';
12
11
  import { APP_INFO } from '../config/data.js';
12
+ import { typeReveal, materializeBraille } from './motion.js';
13
+ import { brandGradient as brand } from './theme.js';
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const TAGLINE = 'To be at the intersection of technology and liberal arts.';
15
- // Brand gradient: emblem blue -> sky -> cyan.
16
- const brand = gradient([
17
- { color: '#124689', pos: 0 },
18
- { color: '#0ea5e9', pos: 0.55 },
19
- { color: '#06b6d4', pos: 1 },
20
- ]);
21
16
  function readArt(file) {
22
17
  try {
23
18
  return readFileSync(join(__dirname, '../logo', file), 'utf-8').replace(/\s+$/, '');
@@ -26,6 +21,24 @@ function readArt(file) {
26
21
  return null;
27
22
  }
28
23
  }
24
+ // Three braille dot-matrix tiers of the same emblem (all rendered from the
25
+ // same text-ring-stripped SVG, so none of them reintroduce the illegible-blob
26
+ // problem -- see ca-dotmatrix.txt's own history). Picking a tier is not just
27
+ // "shrink to fit": a narrow terminal gets a purpose-built lower-detail render
28
+ // rather than a squashed version of the big one, mirroring how the schedule
29
+ // grid swaps in a whole different layout below its own width floor instead
30
+ // of cramming columns.
31
+ const TIERS = [
32
+ { file: 'ca-dotmatrix-large.txt', minCols: 60, minRows: 34 },
33
+ { file: 'ca-dotmatrix.txt', minCols: 44, minRows: 24 },
34
+ { file: 'ca-dotmatrix-small.txt', minCols: 0, minRows: 0 },
35
+ ];
36
+ function dotmatrixFile() {
37
+ const cols = process.stdout.columns ?? 0;
38
+ const rows = process.stdout.rows ?? 0;
39
+ const tier = TIERS.find((t) => cols >= t.minCols && rows >= t.minRows);
40
+ return tier?.file ?? 'ca-dotmatrix-small.txt';
41
+ }
29
42
  function paint(text, color) {
30
43
  if (!color)
31
44
  return text;
@@ -36,15 +49,27 @@ function paint(text, color) {
36
49
  ? fn.multiline(text)
37
50
  : text.split('\n').map((line) => brand(line)).join('\n');
38
51
  }
39
- export function printLogo() {
52
+ function loadArt() {
53
+ const art = useUnicodeIcons() ? readArt(dotmatrixFile()) : readArt('ascii-logo.txt');
54
+ return art ?? 'NBTCA';
55
+ }
56
+ export function buildLogoLines() {
57
+ const color = !process.env['NO_COLOR'];
58
+ const paintedArt = paint(loadArt(), color).split('\n');
59
+ return [
60
+ '',
61
+ ...paintedArt,
62
+ '',
63
+ color ? brand(TAGLINE) : TAGLINE,
64
+ chalk.dim(`@nbtca/prompt v${APP_INFO.version}`),
65
+ '',
66
+ ];
67
+ }
68
+ export async function runStartup() {
40
69
  if (!process.stdout.isTTY)
41
70
  return;
42
71
  const color = !process.env['NO_COLOR'];
43
- const art = useUnicodeIcons() ? readArt('ca-dotmatrix.txt') : readArt('ascii-logo.txt');
44
- console.log();
45
- console.log(paint(art ?? 'NBTCA', color));
46
- console.log();
47
- console.log(color ? brand(TAGLINE) : TAGLINE);
48
- console.log(chalk.dim(`@nbtca/prompt v${APP_INFO.version}`));
49
- console.log();
72
+ process.stdout.write('\n');
73
+ await materializeBraille(loadArt(), (s) => paint(s, color));
74
+ await typeReveal(['', color ? brand(TAGLINE) : TAGLINE, chalk.dim(`@nbtca/prompt v${APP_INFO.version}`), '']);
50
75
  }
package/dist/core/menu.js CHANGED
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * Minimalist menu system
3
3
  */
4
- import { select, isCancel, outro } from '@clack/prompts';
5
- import chalk from 'chalk';
4
+ import { runMenu } from './components/menu.js';
5
+ import { type, space, glyph } from './theme.js';
6
+ import { clearScreen } from './ui.js';
6
7
  import { showCalendar } from '../features/calendar.js';
7
8
  import { showDocsMenu } from '../features/docs.js';
8
9
  import { showServiceStatus } from '../features/status.js';
9
10
  import { showLinksMenu } from '../features/links.js';
10
11
  import { showSettingsMenu } from '../features/settings.js';
12
+ import { showStudentTimetableMenu } from '../features/student-timetable.js';
13
+ import { showSchedule } from '../features/schedule-view.js';
11
14
  import { t } from '../i18n/index.js';
12
15
  function getMainMenuOptions() {
13
16
  const trans = t();
14
17
  return [
15
18
  { value: 'events', label: trans.menu.events, hint: trans.menu.eventsDesc || undefined },
19
+ { value: 'schedule', label: t().timetable.menuEntry },
20
+ { value: 'timetable', label: trans.menu.timetable, hint: trans.menu.timetableDesc || undefined },
16
21
  { value: 'docs', label: trans.menu.docs, hint: trans.menu.docsDesc || undefined },
17
22
  { value: 'status', label: trans.menu.status, hint: trans.menu.statusDesc || undefined },
18
23
  { value: 'links', label: trans.menu.links, hint: trans.menu.linksDesc || undefined },
@@ -20,13 +25,20 @@ function getMainMenuOptions() {
20
25
  ];
21
26
  }
22
27
  export async function showMainMenu() {
28
+ let first = true;
23
29
  while (true) {
24
- const action = await select({
25
- message: 'nbtca',
30
+ if (!first)
31
+ clearScreen();
32
+ first = false;
33
+ const trans = t();
34
+ const footer = `${glyph.updown()} ${trans.menu.hintMove} ${glyph.enter()} ${trans.menu.hintOpen} q ${trans.menu.hintQuit}`;
35
+ const action = await runMenu({
36
+ title: trans.menu.chooseAction,
26
37
  options: getMainMenuOptions(),
38
+ footer,
27
39
  });
28
- if (isCancel(action)) {
29
- outro(chalk.dim(t().common.goodbye));
40
+ if (action === null) {
41
+ console.log(space.indent + type.hint(t().common.goodbye));
30
42
  process.exit(0);
31
43
  }
32
44
  await runMenuAction(action);
@@ -37,6 +49,12 @@ export async function runMenuAction(action) {
37
49
  case 'events':
38
50
  await showCalendar();
39
51
  break;
52
+ case 'schedule':
53
+ await showSchedule();
54
+ break;
55
+ case 'timetable':
56
+ await showStudentTimetableMenu();
57
+ break;
40
58
  case 'docs':
41
59
  await showDocsMenu();
42
60
  break;