qr-terminal 1.0.0 → 1.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/index.js CHANGED
@@ -16,10 +16,26 @@ import { printHeader, log } from './src/ui.js';
16
16
  import { runMainFlow } from './src/prompts.js';
17
17
  import { detectTerminalBgColor } from './src/luma.js';
18
18
 
19
- // Ensure stdout supports TrueColor
20
- process.env.FORCE_COLOR = process.env.FORCE_COLOR ?? '3';
19
+ // ── CLI flags ────────────────────────────────────────────────────────────────
20
+ const args = process.argv.slice(2);
21
+ const isPlain = args.includes('--plain') || args.includes('--no-color');
22
+
23
+ if (isPlain) {
24
+ process.env._QR_PLAIN = '1';
25
+ process.env.FORCE_COLOR = '0';
26
+ } else {
27
+ // Ensure stdout supports TrueColor
28
+ process.env.FORCE_COLOR = process.env.FORCE_COLOR ?? '3';
29
+ }
21
30
 
22
31
  async function main() {
32
+ if (isPlain) {
33
+ // Plain mode: minimal header, no color
34
+ console.log('QR Terminal — plain/no-color mode\n');
35
+ await runMainFlow();
36
+ return;
37
+ }
38
+
23
39
  // ── Splash ──────────────────────────────────────────────────────────────
24
40
  process.stdout.write('\x1Bc'); // Full terminal clear (preserves scroll buffer)
25
41
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "qr-terminal",
3
- "version": "1.0.0",
4
- "description": "Cinematic terminal QR code generator — half-block, braille, particle fly-in, matrix rain, device handshake",
3
+ "version": "1.1.0",
4
+ "description": "Cinematic terminal QR code generator — half-block, braille, particle fly-in, matrix rain, device handshake, history, PDF/GIF export, shareable links",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -15,7 +15,19 @@
15
15
  "engines": {
16
16
  "node": ">=18.0.0"
17
17
  },
18
- "keywords": ["qr", "qr-code", "terminal", "cli", "tui", "unicode", "half-block", "braille", "gradient", "websocket", "generator"],
18
+ "keywords": [
19
+ "qr",
20
+ "qr-code",
21
+ "terminal",
22
+ "cli",
23
+ "tui",
24
+ "unicode",
25
+ "half-block",
26
+ "braille",
27
+ "gradient",
28
+ "websocket",
29
+ "generator"
30
+ ],
19
31
  "author": "Boweii22",
20
32
  "license": "MIT",
21
33
  "repository": {
@@ -38,9 +50,11 @@
38
50
  "chalk": "^5.3.0",
39
51
  "clipboardy": "^4.0.0",
40
52
  "figlet": "^1.7.0",
53
+ "gif-encoder-2": "^1.0.5",
41
54
  "gradient-string": "^2.0.2",
42
55
  "localtunnel": "^2.0.2",
43
56
  "ora": "^8.1.0",
57
+ "pdfkit": "^0.18.0",
44
58
  "qrcode": "^1.5.4",
45
59
  "ws": "^8.18.0"
46
60
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * GIF export — two modes:
3
+ * exportStaticGIF — single-frame colored QR (like PNG but as GIF)
4
+ * exportParticleGIF — animated fly-in: modules scatter → assemble
5
+ *
6
+ * Uses gif-encoder-2 (CJS) via createRequire.
7
+ * Renders pixel data directly without a canvas context.
8
+ */
9
+
10
+ import { createRequire } from 'module';
11
+ import { writeFile } from 'fs/promises';
12
+ import { GRADIENT_PRESETS, computeColor } from './engine.js';
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ const MODULE_PX = 8; // pixels per QR module
17
+
18
+ // ── Helpers ───────────────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Fill a MODULE_PX × MODULE_PX square into a Uint8ClampedArray RGBA buffer.
22
+ */
23
+ function drawModule(pixels, width, col, row, r, g, b) {
24
+ const px = col * MODULE_PX;
25
+ const py = row * MODULE_PX;
26
+ for (let dy = 0; dy < MODULE_PX; dy++) {
27
+ for (let dx = 0; dx < MODULE_PX; dx++) {
28
+ const idx = ((py + dy) * width + (px + dx)) * 4;
29
+ if (idx < 0 || idx + 3 >= pixels.length) continue;
30
+ pixels[idx] = r;
31
+ pixels[idx + 1] = g;
32
+ pixels[idx + 2] = b;
33
+ pixels[idx + 3] = 255;
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Build a white RGBA pixel buffer with the QR matrix drawn into it.
40
+ */
41
+ function buildStaticFrame(modules, preset, quietZone) {
42
+ const { size, data } = modules;
43
+ const total = size + quietZone * 2;
44
+ const width = total * MODULE_PX;
45
+ const height = total * MODULE_PX;
46
+ const pixels = new Uint8ClampedArray(width * height * 4).fill(255);
47
+
48
+ for (let r = 0; r < total; r++) {
49
+ for (let c = 0; c < total; c++) {
50
+ const mr = r - quietZone;
51
+ const mc = c - quietZone;
52
+ const isDark = (mr >= 0 && mr < size && mc >= 0 && mc < size) && data[mr * size + mc];
53
+ if (!isDark) continue;
54
+
55
+ const tx = total > 1 ? c / (total - 1) : 0;
56
+ const ty = total > 1 ? r / (total - 1) : 0;
57
+ const [cr, cg, cb] = computeColor(preset, tx, ty);
58
+ drawModule(pixels, width, c, r, cr, cg, cb);
59
+ }
60
+ }
61
+
62
+ return { pixels, width, height };
63
+ }
64
+
65
+ /**
66
+ * Build particle descriptors at module level (one particle per dark module).
67
+ */
68
+ function buildModuleParticles(modules, preset, quietZone) {
69
+ const { size, data } = modules;
70
+ const total = size + quietZone * 2;
71
+
72
+ const particles = [];
73
+ for (let r = 0; r < total; r++) {
74
+ for (let c = 0; c < total; c++) {
75
+ const mr = r - quietZone;
76
+ const mc = c - quietZone;
77
+ const isDark = (mr >= 0 && mr < size && mc >= 0 && mc < size) && data[mr * size + mc];
78
+ if (!isDark) continue;
79
+
80
+ const tx = total > 1 ? c / (total - 1) : 0;
81
+ const ty = total > 1 ? r / (total - 1) : 0;
82
+ const color = computeColor(preset, tx, ty);
83
+ particles.push({ targetRow: r, targetCol: c, color });
84
+ }
85
+ }
86
+ return { particles, total };
87
+ }
88
+
89
+ // ── Public API ────────────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Export a single-frame GIF (same visual as PNG, GIF container).
93
+ */
94
+ export async function exportStaticGIF(modules, filePath, { gradient = 'none' } = {}) {
95
+ const GIFEncoder = require('gif-encoder-2');
96
+ const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
97
+ const quietZone = 4;
98
+
99
+ const { pixels, width, height } = buildStaticFrame(modules, preset, quietZone);
100
+
101
+ const encoder = new GIFEncoder(width, height);
102
+ encoder.start();
103
+ encoder.setRepeat(0);
104
+ encoder.setDelay(100);
105
+ encoder.addFrame(pixels);
106
+ encoder.finish();
107
+
108
+ await writeFile(filePath, encoder.out.getData());
109
+ }
110
+
111
+ /**
112
+ * Export an animated particle fly-in GIF.
113
+ *
114
+ * @param {object} modules - QR modules from createMatrix()
115
+ * @param {string} filePath - Output path (e.g. "qr-anim.gif")
116
+ * @param {object} opts
117
+ * @param {string} opts.gradient - Color theme
118
+ * @param {number} opts.frames - Number of animation frames (default 30)
119
+ * @param {number} opts.delay - ms per frame (default 50)
120
+ */
121
+ export async function exportParticleGIF(modules, filePath, {
122
+ gradient = 'none',
123
+ frames = 30,
124
+ delay = 50,
125
+ } = {}) {
126
+ const GIFEncoder = require('gif-encoder-2');
127
+ const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
128
+ const quietZone = 4;
129
+
130
+ const { particles, total } = buildModuleParticles(modules, preset, quietZone);
131
+ const width = total * MODULE_PX;
132
+ const height = total * MODULE_PX;
133
+
134
+ // Randomise start positions and stagger delays
135
+ const seeded = particles.map(p => ({
136
+ ...p,
137
+ startRow: Math.random() * total,
138
+ startCol: (Math.random() - 0.5) * total * 2 + total / 2,
139
+ delay: Math.random() * 0.30,
140
+ }));
141
+
142
+ const encoder = new GIFEncoder(width, height, 'neuquant', false, frames + 1);
143
+ encoder.start();
144
+ encoder.setRepeat(0);
145
+ encoder.setDelay(delay);
146
+ encoder.setQuality(10);
147
+
148
+ for (let f = 0; f <= frames; f++) {
149
+ const globalT = f / frames;
150
+ const pixels = new Uint8ClampedArray(width * height * 4).fill(255);
151
+
152
+ for (const p of seeded) {
153
+ const localT = Math.max(0, (globalT - p.delay) / (1 - p.delay));
154
+ const e = 1 - Math.pow(1 - localT, 3); // cubic ease-out
155
+
156
+ const row = Math.round(p.startRow + (p.targetRow - p.startRow) * e);
157
+ const col = Math.round(p.startCol + (p.targetCol - p.startCol) * e);
158
+
159
+ if (row < 0 || row >= total || col < 0 || col >= total) continue;
160
+
161
+ const [r, g, b] = p.color;
162
+ drawModule(pixels, width, col, row, r, g, b);
163
+ }
164
+
165
+ encoder.addFrame(pixels);
166
+ }
167
+
168
+ encoder.finish();
169
+ await writeFile(filePath, encoder.out.getData());
170
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * PDF export — embed a high-res QR code in an A4 PDF document.
3
+ * Uses pdfkit (CJS) via createRequire, with qrcode's PNG buffer as the image source.
4
+ */
5
+
6
+ import { createRequire } from 'module';
7
+ import { writeFile } from 'fs/promises';
8
+ import QRCode from 'qrcode';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ /**
13
+ * @param {string} text - Data to encode
14
+ * @param {string} filePath - Output path (e.g. "qr-code.pdf")
15
+ * @param {object} opts
16
+ * @param {string} opts.errorLevel - QR error correction level (default 'M')
17
+ * @param {string} opts.label - Human-readable label printed below the QR
18
+ * @param {string} opts.darkColor - Hex color for dark modules (default '#000000')
19
+ * @param {string} opts.lightColor - Hex color for light modules (default '#ffffff')
20
+ */
21
+ export async function exportPDF(text, filePath, {
22
+ errorLevel = 'M',
23
+ label = '',
24
+ darkColor = '#000000',
25
+ lightColor = '#ffffff',
26
+ } = {}) {
27
+ const PDFDocument = require('pdfkit');
28
+
29
+ // Generate the QR as a PNG buffer
30
+ const pngBuffer = await QRCode.toBuffer(text, {
31
+ type: 'png',
32
+ width: 600,
33
+ margin: 4,
34
+ errorCorrectionLevel: errorLevel,
35
+ color: { dark: darkColor, light: lightColor },
36
+ });
37
+
38
+ return new Promise((resolve, reject) => {
39
+ const doc = new PDFDocument({ size: 'A4', margin: 50 });
40
+ const chunks = [];
41
+
42
+ doc.on('data', chunk => chunks.push(chunk));
43
+ doc.on('error', reject);
44
+ doc.on('end', async () => {
45
+ try {
46
+ await writeFile(filePath, Buffer.concat(chunks));
47
+ resolve();
48
+ } catch (e) {
49
+ reject(e);
50
+ }
51
+ });
52
+
53
+ const pageW = doc.page.width;
54
+ const qrSize = 340;
55
+ const x = (pageW - qrSize) / 2;
56
+ const y = 80;
57
+
58
+ doc.image(pngBuffer, x, y, { width: qrSize });
59
+
60
+ if (label) {
61
+ doc
62
+ .moveDown()
63
+ .fontSize(14)
64
+ .fillColor('#222222')
65
+ .text(label, 50, y + qrSize + 20, { align: 'center', width: pageW - 100 });
66
+ }
67
+
68
+ doc
69
+ .fontSize(9)
70
+ .fillColor('#999999')
71
+ .text(
72
+ `Generated by QR Terminal · ${new Date().toLocaleDateString()}`,
73
+ 50,
74
+ doc.page.height - 60,
75
+ { align: 'center', width: pageW - 100 },
76
+ );
77
+
78
+ doc.end();
79
+ });
80
+ }
package/src/history.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * History — persist and recall previously generated QR codes.
3
+ * Stored in ~/.qr-history.json, newest first, max 20 entries.
4
+ */
5
+
6
+ import { readFile, writeFile } from 'fs/promises';
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
9
+
10
+ const HISTORY_PATH = join(homedir(), '.qr-history.json');
11
+ const MAX_ENTRIES = 20;
12
+
13
+ export async function loadHistory() {
14
+ try {
15
+ const raw = await readFile(HISTORY_PATH, 'utf8');
16
+ return JSON.parse(raw);
17
+ } catch {
18
+ return { entries: [] };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Save a generated QR to history.
24
+ * Secrets are stored without their payload (dataLabel only).
25
+ */
26
+ export async function saveToHistory({ inputType, dataLabel, qrData, gradient, errorLevel, renderMode }) {
27
+ const history = await loadHistory();
28
+
29
+ const entry = {
30
+ id: Date.now().toString(36),
31
+ timestamp: new Date().toISOString(),
32
+ inputType,
33
+ dataLabel,
34
+ qrData: inputType === 'secret' ? null : qrData,
35
+ gradient,
36
+ errorLevel,
37
+ renderMode,
38
+ };
39
+
40
+ history.entries.unshift(entry);
41
+ if (history.entries.length > MAX_ENTRIES) {
42
+ history.entries = history.entries.slice(0, MAX_ENTRIES);
43
+ }
44
+
45
+ await writeFile(HISTORY_PATH, JSON.stringify(history, null, 2), 'utf8');
46
+ return entry;
47
+ }
48
+
49
+ /**
50
+ * Format a history entry as an @inquirer/prompts choice object.
51
+ */
52
+ export function historyChoice(entry, index) {
53
+ const d = new Date(entry.timestamp);
54
+ const dateStr = d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
55
+ const preview = entry.qrData
56
+ ? (entry.qrData.slice(0, 48) + (entry.qrData.length > 48 ? '…' : ''))
57
+ : '[secret — payload not stored]';
58
+
59
+ return {
60
+ name: ` ${String(index + 1).padStart(2)} · ${entry.dataLabel.padEnd(22)}${preview}`,
61
+ value: entry,
62
+ description: `${dateStr} · theme: ${entry.gradient} · EC: ${entry.errorLevel} · engine: ${entry.renderMode}`,
63
+ disabled: !entry.qrData ? '(secret)' : false,
64
+ };
65
+ }
package/src/prompts.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Orchestrates inquirer prompts → live preview → QR generation → export.
4
4
  */
5
5
 
6
- import { select, input, confirm, checkbox, password } from '@inquirer/prompts';
6
+ import { select, input, confirm, password } from '@inquirer/prompts';
7
7
  import chalk from 'chalk';
8
8
  import ora from 'ora';
9
9
  import clipboard from 'clipboardy';
@@ -12,6 +12,8 @@ import { createMatrix, renderHalfBlock, renderASCII } from './engine.js';
12
12
  import { liveInput } from './live-preview.js';
13
13
  import { formatWifi, formatVCard, ERROR_LEVELS, GRADIENT_OPTIONS } from './formatters.js';
14
14
  import { exportPNG, exportSVG, exportTXT } from './export.js';
15
+ import { exportPDF } from './export-pdf.js';
16
+ import { exportStaticGIF, exportParticleGIF } from './export-gif.js';
15
17
  import { shortenUrl, isValidUrl, estimateSavings } from './shortlink.js';
16
18
  import { printQR, revealQR, printDivider, detectDarkMode, log } from './ui.js';
17
19
  import { animateParticleFlyIn } from './particle.js';
@@ -19,6 +21,10 @@ import { matrixRainQR } from './matrix-rain.js';
19
21
  import { renderBraille } from './braille.js';
20
22
  import { startHandshakeServer } from './handshake.js';
21
23
  import { createDestructQR, showExplosion } from './destruct.js';
24
+ import { loadHistory, saveToHistory, historyChoice } from './history.js';
25
+ import { buildShareableLink } from './shareable.js';
26
+
27
+ const isPlain = process.env._QR_PLAIN === '1';
22
28
 
23
29
  export async function runMainFlow() {
24
30
  // ──────────────────────────────────────────────
@@ -47,9 +53,43 @@ export async function runMainFlow() {
47
53
  value: 'secret',
48
54
  description: 'Tokens, keys, passwords, or any sensitive payload',
49
55
  },
56
+ {
57
+ name: ' 🕐 View History',
58
+ value: 'history',
59
+ description: 'Recall and re-render a previously generated QR code',
60
+ },
50
61
  ],
51
62
  });
52
63
 
64
+ // ──────────────────────────────────────────────
65
+ // HISTORY — recall a past QR
66
+ // ──────────────────────────────────────────────
67
+ if (inputType === 'history') {
68
+ const history = await loadHistory();
69
+ if (!history.entries.length) {
70
+ log.info('No history yet — generate a QR first.');
71
+ return;
72
+ }
73
+
74
+ const entry = await select({
75
+ message: chalk.bold('Pick a previous QR to re-render:'),
76
+ choices: history.entries.map((e, i) => historyChoice(e, i)),
77
+ pageSize: 10,
78
+ });
79
+
80
+ // Re-run with saved settings
81
+ await renderAndExport({
82
+ inputType: entry.inputType,
83
+ qrData: entry.qrData,
84
+ dataLabel: entry.dataLabel,
85
+ gradient: entry.gradient,
86
+ errorLevel: entry.errorLevel,
87
+ renderMode: entry.renderMode,
88
+ fromHistory: true,
89
+ });
90
+ return;
91
+ }
92
+
53
93
  // ──────────────────────────────────────────────
54
94
  // STEP 2 — Error correction level
55
95
  // ──────────────────────────────────────────────
@@ -66,13 +106,13 @@ export async function runMainFlow() {
66
106
  // ──────────────────────────────────────────────
67
107
  // STEP 3 — Visual gradient theme
68
108
  // ──────────────────────────────────────────────
69
- const gradient = await select({
109
+ const gradient = isPlain ? 'none' : await select({
70
110
  message: chalk.bold('Color theme for the QR code?'),
71
111
  choices: GRADIENT_OPTIONS,
72
112
  });
73
113
 
74
114
  // ── Render engine selection ─────────────────────────────────────────────
75
- const renderMode = await select({
115
+ const renderMode = isPlain ? 'halfblock' : await select({
76
116
  message: chalk.bold('Render engine?'),
77
117
  choices: [
78
118
  { name: ' ▀▄ Half-Block — Standard high-density (default)', value: 'halfblock' },
@@ -93,11 +133,12 @@ export async function runMainFlow() {
93
133
 
94
134
  // ── URL ─────────────────────────────────────
95
135
  case 'url': {
96
- console.log(chalk.dim('\n Live preview active — QR updates as you type\n'));
136
+ if (!isPlain) console.log(chalk.dim('\n Live preview active — QR updates as you type\n'));
97
137
 
98
- let rawUrl = await liveInput({ label: 'URL', errorLevel, gradient });
138
+ let rawUrl = isPlain
139
+ ? await input({ message: 'URL:' })
140
+ : await liveInput({ label: 'URL', errorLevel, gradient });
99
141
 
100
- // Auto-prepend protocol if missing
101
142
  if (rawUrl && !rawUrl.match(/^https?:\/\//i)) {
102
143
  rawUrl = 'https://' + rawUrl;
103
144
  }
@@ -107,22 +148,17 @@ export async function runMainFlow() {
107
148
  qrData = rawUrl;
108
149
  dataLabel = 'URL';
109
150
 
110
- // Optional URL shortening
111
- if (isValidUrl(qrData) && qrData.length > 40) {
151
+ if (!isPlain && isValidUrl(qrData) && qrData.length > 40) {
112
152
  const shouldShorten = await confirm({
113
153
  message: `Shorten via TinyURL? ${chalk.dim('(reduces QR complexity)')}`,
114
154
  default: false,
115
155
  });
116
156
 
117
157
  if (shouldShorten) {
118
- const spinner = ora({
119
- text: 'Connecting to TinyURL…',
120
- spinner: 'dots',
121
- }).start();
122
-
158
+ const spinner = ora({ text: 'Connecting to TinyURL…', spinner: 'dots' }).start();
123
159
  try {
124
- const short = await shortenUrl(qrData);
125
- const savings = estimateSavings(qrData.length, short.length);
160
+ const short = await shortenUrl(qrData);
161
+ const savings = estimateSavings(qrData.length, short.length);
126
162
  spinner.succeed(chalk.green('Shortened: ') + chalk.white(short) + chalk.dim(` (${savings})`));
127
163
  qrData = short;
128
164
  } catch (e) {
@@ -153,8 +189,8 @@ export async function runMainFlow() {
153
189
  let wifiPassword = '';
154
190
  if (security !== 'nopass') {
155
191
  wifiPassword = await password({
156
- message: chalk.cyan('Password:'),
157
- mask: '●',
192
+ message: chalk.cyan('Password:'),
193
+ mask: '●',
158
194
  validate: (v) => v.length > 0 || 'Password cannot be empty',
159
195
  });
160
196
  }
@@ -173,11 +209,11 @@ export async function runMainFlow() {
173
209
  case 'vcard': {
174
210
  console.log();
175
211
  const firstName = await input({ message: chalk.cyan('First name:') });
176
- const lastName = await input({ message: chalk.cyan('Last name:'), default: '' });
177
- const phone = await input({ message: chalk.cyan('Phone:'), default: '' });
178
- const email = await input({ message: chalk.cyan('Email:'), default: '' });
212
+ const lastName = await input({ message: chalk.cyan('Last name:'), default: '' });
213
+ const phone = await input({ message: chalk.cyan('Phone:'), default: '' });
214
+ const email = await input({ message: chalk.cyan('Email:'), default: '' });
179
215
  const org = await input({ message: chalk.cyan('Organization:'), default: '' });
180
- const url = await input({ message: chalk.cyan('Website:'), default: '' });
216
+ const url = await input({ message: chalk.cyan('Website:'), default: '' });
181
217
 
182
218
  if (!firstName && !lastName) { log.error('Name is required.'); return; }
183
219
 
@@ -190,8 +226,8 @@ export async function runMainFlow() {
190
226
  case 'secret': {
191
227
  console.log();
192
228
  const secret = await password({
193
- message: chalk.cyan('Secret payload:'),
194
- mask: '●',
229
+ message: chalk.cyan('Secret payload:'),
230
+ mask: '●',
195
231
  validate: (v) => v.length > 0 || 'Cannot encode an empty secret',
196
232
  });
197
233
 
@@ -204,6 +240,20 @@ export async function runMainFlow() {
204
240
 
205
241
  if (!qrData) return;
206
242
 
243
+ await renderAndExport({ inputType, qrData, dataLabel, gradient, errorLevel, renderMode });
244
+ }
245
+
246
+ // ── Core render + export pipeline ────────────────────────────────────────────
247
+
248
+ async function renderAndExport({
249
+ inputType,
250
+ qrData,
251
+ dataLabel,
252
+ gradient,
253
+ errorLevel,
254
+ renderMode,
255
+ fromHistory = false,
256
+ }) {
207
257
  // ──────────────────────────────────────────────
208
258
  // STEP 5 — Generate & render the QR code
209
259
  // ──────────────────────────────────────────────
@@ -221,9 +271,7 @@ export async function runMainFlow() {
221
271
  return;
222
272
  }
223
273
 
224
- // ──────────────────────────────────────────────
225
- // STEP 5b — Render with chosen engine
226
- // ──────────────────────────────────────────────
274
+ // ── Render ──────────────────────────────────────────────────────────────
227
275
 
228
276
  // Special case: handshake mode
229
277
  if (renderMode === 'handshake') {
@@ -237,7 +285,6 @@ export async function runMainFlow() {
237
285
  return;
238
286
  }
239
287
 
240
- // Generate QR for the server URL
241
288
  const linkModules = createMatrix(server.url, { errorLevel: 'M' });
242
289
  const linkLines = renderHalfBlock(linkModules, { gradient: 'neon' });
243
290
 
@@ -258,12 +305,10 @@ export async function runMainFlow() {
258
305
  console.log(chalk.cyan('\n Phone is now linked. Messages will appear below.\n'));
259
306
  console.log(chalk.dim(' Press Ctrl+C to close the connection.\n'));
260
307
 
261
- // Stream phone messages to terminal
262
308
  server.onMessage((text) => {
263
309
  console.log(chalk.bold.cyanBright('\n 📱 From phone: ') + chalk.white(text) + '\n');
264
310
  });
265
311
 
266
- // Keep alive until Ctrl+C
267
312
  await new Promise((_, reject) => {
268
313
  process.on('SIGINT', () => {
269
314
  server.close();
@@ -274,6 +319,14 @@ export async function runMainFlow() {
274
319
  return;
275
320
  }
276
321
 
322
+ // ── Plain mode — print raw ASCII, no color ──────────────────────────────
323
+ if (isPlain) {
324
+ console.log();
325
+ console.log(renderASCII(modules));
326
+ console.log();
327
+ return;
328
+ }
329
+
277
330
  const qrLines = renderMode === 'braille'
278
331
  ? renderBraille(modules, { gradient })
279
332
  : renderHalfBlock(modules, { gradient });
@@ -312,40 +365,49 @@ export async function runMainFlow() {
312
365
  console.log();
313
366
 
314
367
  // ──────────────────────────────────────────────
315
- // STEP 6 — Post-generation actions
368
+ // STEP 6 — Save to history
369
+ // ──────────────────────────────────────────────
370
+ if (!fromHistory) {
371
+ try {
372
+ await saveToHistory({ inputType, dataLabel, qrData, gradient, errorLevel, renderMode });
373
+ } catch {
374
+ // history is non-critical — fail silently
375
+ }
376
+ }
377
+
378
+ // ──────────────────────────────────────────────
379
+ // STEP 7 — Post-generation actions (looped select)
316
380
  // ──────────────────────────────────────────────
317
381
  printDivider('Export');
318
382
 
319
- const actions = await checkbox({
320
- message: chalk.bold('What would you like to do with this QR code?'),
321
- choices: [
322
- {
323
- name: ' 📋 Copy ASCII to clipboard (paste into Slack, README, terminal)',
324
- value: 'clipboard',
325
- checked: true,
326
- },
327
- {
328
- name: ' 🖼 Save as PNG (high-res image)',
329
- value: 'png',
330
- },
331
- {
332
- name: ' 📐 Save as SVG (scalable vector)',
333
- value: 'svg',
334
- },
335
- {
336
- name: ' 📄 Save as TXT (plain Unicode half-blocks)',
337
- value: 'txt',
338
- },
339
- {
340
- name: ' ☠ Self-destruct share (host file via public QR, auto-destroy on download)',
341
- value: 'destruct',
342
- },
343
- ],
344
- });
383
+ const exportChoices = [
384
+ { name: ' 📋 Copy ASCII to clipboard', value: 'clipboard' },
385
+ { name: ' 🖼 Save as PNG', value: 'png' },
386
+ { name: ' 📐 Save as SVG', value: 'svg' },
387
+ { name: ' 📄 Save as PDF', value: 'pdf' },
388
+ { name: ' 🎞 Save as GIF', value: 'gif' },
389
+ { name: ' 📝 Save as TXT', value: 'txt' },
390
+ ...(inputType !== 'secret'
391
+ ? [{ name: ' 🔗 Get shareable link', value: 'share' }]
392
+ : []),
393
+ { name: ' ☠ Self-destruct share', value: 'destruct' },
394
+ { name: ' ✓ Done', value: 'done' },
395
+ ];
345
396
 
346
- console.log();
397
+ // Reset terminal state after animations before prompting
398
+ process.stdout.write('\x1b[?25h\x1b[0m');
399
+
400
+ // eslint-disable-next-line no-constant-condition
401
+ while (true) {
402
+ const action = await select({
403
+ message: chalk.bold('What would you like to do? (pick one, repeat as needed)'),
404
+ choices: exportChoices,
405
+ });
406
+
407
+ if (action === 'done') break;
408
+
409
+ console.log();
347
410
 
348
- for (const action of actions) {
349
411
  switch (action) {
350
412
 
351
413
  case 'clipboard': {
@@ -390,6 +452,44 @@ export async function runMainFlow() {
390
452
  break;
391
453
  }
392
454
 
455
+ case 'pdf': {
456
+ const fileName = await input({
457
+ message: 'PDF filename:',
458
+ default: sanitizeFilename(dataLabel) + '.pdf',
459
+ });
460
+ const sp = ora({ text: 'Rendering PDF…', spinner: 'arc' }).start();
461
+ try {
462
+ await exportPDF(qrData, fileName, { errorLevel, label: dataLabel });
463
+ sp.succeed(`PDF saved → ${chalk.underline(fileName)}`);
464
+ } catch (e) {
465
+ sp.fail('PDF export failed: ' + e.message);
466
+ }
467
+ break;
468
+ }
469
+
470
+ case 'gif': {
471
+ const animated = renderMode === 'particle' || renderMode === 'matrix';
472
+ const fileName = await input({
473
+ message: 'GIF filename:',
474
+ default: sanitizeFilename(dataLabel) + (animated ? '-anim' : '') + '.gif',
475
+ });
476
+ const sp = ora({
477
+ text: animated ? 'Rendering animated GIF…' : 'Rendering GIF…',
478
+ spinner: 'arc',
479
+ }).start();
480
+ try {
481
+ if (animated) {
482
+ await exportParticleGIF(modules, fileName, { gradient, frames: 30, delay: 50 });
483
+ } else {
484
+ await exportStaticGIF(modules, fileName, { gradient });
485
+ }
486
+ sp.succeed(`GIF saved → ${chalk.underline(fileName)}${animated ? chalk.dim(' (animated)') : ''}`);
487
+ } catch (e) {
488
+ sp.fail('GIF export failed: ' + e.message);
489
+ }
490
+ break;
491
+ }
492
+
393
493
  case 'txt': {
394
494
  const fileName = await input({
395
495
  message: 'TXT filename:',
@@ -405,6 +505,27 @@ export async function runMainFlow() {
405
505
  break;
406
506
  }
407
507
 
508
+ case 'share': {
509
+ const link = buildShareableLink(qrData, { errorLevel });
510
+ console.log(
511
+ chalk.bold.cyanBright(' 🔗 Shareable link:\n') +
512
+ ' ' + chalk.underline.white(link) + '\n'
513
+ );
514
+ const copyLink = await confirm({
515
+ message: 'Copy link to clipboard?',
516
+ default: true,
517
+ });
518
+ if (copyLink) {
519
+ try {
520
+ await clipboard.write(link);
521
+ log.success('Link copied to clipboard.');
522
+ } catch {
523
+ log.warn('Could not write to clipboard.');
524
+ }
525
+ }
526
+ break;
527
+ }
528
+
408
529
  case 'destruct': {
409
530
  const filePath = await input({ message: 'File to share (path):' });
410
531
  const sp = ora({ text: 'Creating tunnel…', spinner: 'arc' }).start();
@@ -429,9 +550,10 @@ export async function runMainFlow() {
429
550
  break;
430
551
  }
431
552
  }
553
+
554
+ console.log();
432
555
  }
433
556
 
434
- console.log();
435
557
  printDivider();
436
558
  log.tip('Hold your phone camera over the QR code — no scanner app needed on iOS 11+ / Android 10+.');
437
559
  log.info(`Encoded ${qrData.length} characters with ${errorLevel}-level error correction.`);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shareable link — build a browser-openable URL that renders the QR in an image.
3
+ * Uses QuickChart.io's free public QR API (no auth required, rate-limited generously).
4
+ *
5
+ * Opening the link in any browser shows a 400×400 QR code PNG inline.
6
+ */
7
+
8
+ export function buildShareableLink(qrData, { errorLevel = 'M', size = 400 } = {}) {
9
+ const params = new URLSearchParams({
10
+ text: qrData,
11
+ size: size.toString(),
12
+ margin: '4',
13
+ ecLevel: errorLevel,
14
+ format: 'png',
15
+ });
16
+ return `https://quickchart.io/qr?${params.toString()}`;
17
+ }
package/src/ui.js CHANGED
@@ -22,7 +22,7 @@ export function printHeader() {
22
22
  }
23
23
 
24
24
  const coloredArt = gradient(['#FF416C', '#FF4B2B', '#F7971E', '#FFD200', '#4ECDC4', '#556270'])(art);
25
- const subtitle = chalk.dim(' High-Density Half-Block Rendering Engine · v1.0.0');
25
+ const subtitle = chalk.dim(' High-Density Half-Block Rendering Engine · v1.1.0');
26
26
  const badges = [
27
27
  chalk.bgRgb(40,40,80).rgb(100,180,255)(' URL '),
28
28
  chalk.bgRgb(40,40,80).rgb(100,255,160)(' WiFi '),