Files
markdown-converter/docs/superpowers/plans/2026-09-14-ascii-art-upgrade.md
T
amitwh b8c772269c docs(plans): add ascii-art-upgrade implementation plan
12-task TDD plan for the full-fledged ASCII Art Generator upgrade:

* T1: add figlet npm dep
* T2: extract + extend 17 hand-coded font tables + snapshot tests
* T3: extract 19 templates + snapshot tests
* T4: figlet adapter (lazy-load + cache + structured error)
* T5: pure AsciiArt orchestrator (generate/listFonts/getFontMeta)
* T6: IPC handler wiring in main.js via existing JSON store helper
* T7: preload allow-list cleanup (drop dead show-ascii-generator*
      channels, add new ascii:* invoke channels)
* T8: preload allow-list assertion test
* T9: renderer controller (src/renderer/ascii-controller.js)
* T10: standalone window HTML rewrite with searchable font picker,
       Copy/Save buttons, controller script tag
* T11: delete in-app modal (#ascii-art-dialog, renderer.js:5942-6736,
       dead asciiModal, dead preload receive channels)
* T12: README + final lint/format/test/build sweep

Spec gap handled inline: §5 headless Electron integration test deferred
to manual smoke check (spec itself says skip if no display available).

Amit Haridas
2026-09-14 19:14:30 +05:30

1224 lines
53 KiB
Markdown

# ASCII Art Generator Upgrade Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Promote the ASCII Art Generator from a partially-dual-implemented 5-font feature to a single-source-of-truth, 17 hand-coded + 400+ FIGlet font feature with insert / copy / save output destinations and full test coverage.
**Architecture:** Extract the algorithm from `renderer.js:5942-6736` into a pure `src/main/AsciiArt.js` module that mirrors the `DocQA`/`DailyNotes` pattern. Hand-coded font tables move into `src/main/AsciiArt.fonts.js`; templates into `src/main/AsciiArt.templates.js`; `figlet` becomes a lazy-loaded `src/main/AsciiArt.figlet-adapter.js`. A new `src/renderer/ascii-controller.js` drives the existing standalone `src/ascii-generator.html` window. The in-app modal (`#ascii-art-dialog`, `showASCIIGenerator*`, `preload.js` receive channels) is deleted; the standalone window remains the only path. `electron-store` `ascii:lastFont` (via the existing `store` JSON helper in `main.js:267-289`) remembers the last-used font.
**Tech Stack:** Electron 41.10.7 · electron-builder 26.15.3 · vanilla CommonJS · `figlet@^1.8.0` (pure JS) · Jest + jsdom · ESLint flat config · Prettier (2-space, single quotes, semicolons, 100-col).
**Spec:** docs/superpowers/specs/2026-09-14-ascii-art-upgrade-design.md
## Global Constraints
- Electron 41.10.7, electron-builder 26.15.3
- Vanilla JS, no bundler. Pure modules in `src/main/`, IPC wired in `src/main.js`
- Preload allow-list at `src/preload.js` for any new IPC channels (`invoke` reuses `ALLOWED_SEND_CHANNELS`; `on` uses `ALLOWED_RECEIVE_CHANNELS`)
- Tests: Jest + jsdom. Run `npm test`, `npm run lint`, `npm run format:check`
- Single ASCII art path: standalone `BrowserWindow` only; in-app modal `#ascii-art-dialog` and dead channels `show-ascii-generator*` deleted
- New dep: `figlet` npm package (latest 1.x, pure JS, ~400 KB unpacked)
- Hand-coded font count: 17 (5 existing + 12 new) — big, small, lean, slant, isometric1-4, 3-d, 3x5, ansi-shadow, calvin-s
- New settings key: `ascii:lastFont` (via the existing `store` JSON helper in `main.js:267-289`)
- Output destinations: insert at cursor (existing, fenced code block wrapper) + copy to clipboard + save to file
- The standalone `src/ascii-generator.html` (751 lines) keeps its layout but its controller moves to a new `src/renderer/ascii-controller.js`
- Snapshot tests normalize line endings to `\n` and trim trailing whitespace
- TDD discipline: every component starts with a failing test
## File Structure
### Created
- `src/main/AsciiArt.fonts.js` — 17 hand-coded font tables (`HAND_CODED_FONTS`).
- `src/main/AsciiArt.templates.js` — 19 named ASCII templates (`ASCII_TEMPLATES`).
- `src/main/AsciiArt.figlet-adapter.js` — Lazy `require('figlet')` + `fontsSync()` cache + `textSync(text, font)` wrapper + structured error.
- `src/main/AsciiArt.js` — Pure orchestrator: `generate({ text, font, options })`, `listFonts()`, `getFontMeta(id)`.
- `src/renderer/ascii-controller.js` — Renderer-side controller for the standalone window.
- `tests/main/ascii-art.test.js` — Unit tests for `generate` / `listFonts` / `getFontMeta` / figlet adapter.
- `tests/main/ascii-art.fonts.test.js` — Snapshot tests per hand-coded font (HELLO + edge cases).
- `tests/main/ascii-art.templates.test.js` — Snapshot tests per template.
- `tests/main/ascii-art.figlet-adapter.test.js` — Adapter tests with `figlet` mocked.
- `tests/preload-ascii.test.js` — Allow-list assertions for new `ascii:*` channels.
### Modified
- `package.json` — Add `"figlet": "^1.8.0"` to `dependencies`.
- `src/main.js:4005-4010` — Add `const AsciiArt = require('./main/AsciiArt');` after DocQA require.
- `src/main.js` (after line 5611, near other recent IPC handler blocks) — Register `ascii:generate`, `ascii:list-fonts`, `ascii:get-font-meta`, `ascii:save`, `ascii:copy`, `ascii:last-font` handlers.
- `src/preload.js:98-99,294-296` — Delete dead receive channels `show-ascii-generator` and `show-ascii-generator-window`. Add new invoke channels `ascii:generate`, `ascii:list-fonts`, `ascii:get-font-meta`, `ascii:save`, `ascii:copy`, `ascii:last-font` to `ALLOWED_SEND_CHANNELS`. Add `generators.ascii.*` namespace helpers.
- `src/renderer.js:2188` — Delete `const asciiModal = new ModalManager('#ascii-art-dialog');`.
- `src/renderer.js:2202` — Delete `asciiModal` entry from `window.modals`.
- `src/renderer.js:5942-6736` — Delete entire in-app modal block.
- `src/index.html:812-1028` — Delete entire `#ascii-art-dialog` modal markup.
- `src/ascii-generator.html` — Rewrite inline `<script>` block to call `src/renderer/ascii-controller.js` instead of inlining FONTS / TEMPLATES / BOX_STYLES; replace `font-style` `<select>` with a searchable font picker; add "Copy to Clipboard" and "Save to File" buttons.
- `README.md:56` — Update ASCII Art Generator row to mention 17 hand-coded + 400+ FIGlet fonts and copy/save/insert destinations.
- `tests/preload.test.js` — Add new `ascii:*` channels to `EXPECTED_SEND_CHANNELS` (or keep `preload-ascii.test.js` as the authoritative allow-list test).
### Unchanged
- `src/main.js:5584-5611` (`openAsciiGenerator`) — standalone window creation unchanged.
- `src/main.js:5609-5611` (`'open-ascii-generator'` ipcMain.on) — unchanged.
- `README.md:125` (Ctrl+Shift+A shortcut) — unchanged.
---
### Task 1: Add `figlet` dependency and verify install
**Files:**
- Modify: `package.json:58-95` (dependencies section)
**Interfaces:**
- Consumes: nothing (greenfield dep)
- Produces: `figlet` package resolvable via `require('figlet')` from any pure module.
- [ ] **Step 1: Add `figlet` to `package.json` dependencies.** Edit the `dependencies` object in `package.json` and insert `"figlet": "^1.8.0",` alphabetically (after `ffmpeg-static`). Run `npm install --save figlet@^1.8.0` so the lockfile updates too.
- [ ] **Step 2: Verify the install is pure JS.** Run `npm ls figlet` and confirm the output lists `figlet@1.x` with no native dependencies (no `node-gyp`-built modules). Run `node -e "const f = require('figlet'); console.log(typeof f.textSync, typeof f.fontsSync)"` from `/mnt/source/apps/parallel-git-branch-dev/markdown-converter__master` and confirm `function function` is printed.
- [ ] **Step 3: Commit.** `git add package.json package-lock.json && git commit -m "chore(deps): add figlet ^1.8.0 for ASCII art generator"`
---
### Task 2: Create hand-coded font tables module
**Files:**
- Create: `src/main/AsciiArt.fonts.js`
- Test: `tests/main/ascii-art.fonts.test.js`
**Interfaces:**
- Consumes: nothing (pure data module)
- Produces: `HAND_CODED_FONTS` — `{ [id]: { height, chars: { [A-Z0-9 ' ']: string[height] } } }` for the 5 existing + 12 new fonts.
- [ ] **Step 1: Write the failing snapshot test.** Create `tests/main/ascii-art.fonts.test.js` with the following content (it will fail because the module does not exist yet):
```js
/**
* @jest-environment node
*
* Per-font snapshot tests for the 17 hand-coded ASCII art fonts. Snapshots
* are the safety net that catches drift when the font tables are hand-edited.
* Line endings are normalized to \n and trailing whitespace is trimmed so
* editor auto-trim does not produce spurious diffs.
*/
const AsciiArtFonts = require('../../src/main/AsciiArt.fonts');
const FONT_IDS = Object.freeze([
// 5 existing
'standard',
'banner',
'block',
'bubble',
'digital',
// 12 new
'big',
'small',
'lean',
'slant',
'isometric1',
'isometric2',
'isometric3',
'isometric4',
'three-d',
'three-x-five',
'ansi-shadow',
'calvin-s',
]);
const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/[ \t]+\n/g, '\n');
describe('AsciiArt.fonts table shape', () => {
test.each(FONT_IDS)('%s has height and chars for A-Z 0-9 and space', (id) => {
const font = AsciiArtFonts.HAND_CODED_FONTS[id];
expect(font).toBeDefined();
expect(typeof font.height).toBe('number');
expect(font.height).toBeGreaterThanOrEqual(4);
expect(font.height).toBeLessThanOrEqual(8);
expect(typeof font.chars).toBe('object');
for (const ch of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '.split('')) {
expect(font.chars[ch]).toBeDefined();
expect(font.chars[ch]).toHaveLength(font.height);
for (const line of font.chars[ch]) {
expect(typeof line).toBe('string');
}
}
});
});
describe('AsciiArt.fonts HELLO snapshot per font', () => {
test.each(FONT_IDS)('%s', (id) => {
const font = AsciiArtFonts.HAND_CODED_FONTS[id];
const lines = Array(font.height).fill('');
for (const ch of 'HELLO') {
const glyph = font.chars[ch] || font.chars[' '];
for (let i = 0; i < font.height; i++) lines[i] += glyph[i];
}
expect(normalize(lines.join('\n'))).toMatchSnapshot();
});
});
describe('AsciiArt.fonts edge cases', () => {
test('unknown character falls back to space glyph (preserves column width)', () => {
const font = AsciiArtFonts.HAND_CODED_FONTS.standard;
const fallback = font.chars[' '];
const unknown = font.chars['é'];
expect(unknown).toEqual(fallback);
});
test('all digit glyphs exist for standard', () => {
const font = AsciiArtFonts.HAND_CODED_FONTS.standard;
for (const d of '0123456789') {
expect(font.chars[d]).toBeDefined();
expect(font.chars[d]).toHaveLength(font.height);
}
});
});
```
- [ ] **Step 2: Run test, verify it fails.** `npm test -- tests/main/ascii-art.fonts.test.js` — expect `Cannot find module '../../src/main/AsciiArt.fonts'`.
- [ ] **Step 3: Create the font tables module with the 5 existing fonts.** Create `src/main/AsciiArt.fonts.js` and export `HAND_CODED_FONTS` containing the 5 existing tables (`standard`, `banner`, `block`, `bubble`, `digital`) extracted from `src/renderer.js:6005-6474` and the 4-font subset that already exists in `src/ascii-generator.html:366-594`. Heights: standard=5, banner=7, block=6, bubble=5, digital=7. Use this header:
```js
/**
* Hand-coded ASCII art font tables — 17 fonts total.
*
* Each font is `{ height, chars }` where `chars[letter]` is a `string[]` of
* exactly `height` rows. Every A-Z, 0-9 and ' ' must be defined; missing
* glyphs fall back to space (see tests). The 5 existing fonts are extracted
* from renderer.js:6005-6474; the 12 new fonts mirror figlet 1.x reference
* output.
*
* @module AsciiArt.fonts
*/
'use strict';
const HAND_CODED_FONTS = {
standard: { /* paste from renderer.js:6008-6048 */ },
banner: { /* paste from renderer.js:6050-6386 */ },
block: { /* paste from renderer.js:6388-6401 */ },
bubble: { /* paste from renderer.js:6403-6410 */ },
digital: { /* paste from renderer.js:6412-6419 */ },
// 12 new fonts follow
};
module.exports = { HAND_CODED_FONTS };
```
- [ ] **Step 4: Run test for the 5 existing fonts.** `npm test -- tests/main/ascii-art.fonts.test.js` — all 5 snapshot tests should now pass (the 12 new ones still fail).
- [ ] **Step 5: Author the 12 new font tables.** For each of `big` (h=8), `small` (h=5), `lean` (h=6), `slant` (h=6), `isometric1`-`isometric4` (h=6), `three-d` (h=7), `three-x-five` (h=5), `ansi-shadow` (h=8), `calvin-s` (h=7), port the canonical figlet reference output. Add each to `HAND_CODED_FONTS` in the module. Every char must have exactly `height` rows. Use figlet's bundled `.flf` files as ground truth — fetch them with `node -e "const f=require('figlet'); console.log(f.textSync('HELLO',{font:'Big'}))"` and copy the output verbatim.
- [ ] **Step 6: Run full font tests.** `npm test -- tests/main/ascii-art.fonts.test.js` — expect all 17 snapshot tests plus the shape and edge-case tests to pass. Review the generated `tests/main/__snapshots__/ascii-art.fonts.test.js.snap` file and visually verify each block's first row looks correct. If any are wrong, fix the table and re-run.
- [ ] **Step 7: Commit.** `git add src/main/AsciiArt.fonts.js tests/main/ascii-art.fonts.test.js tests/main/__snapshots__/ && git commit -m "feat(ascii-art): 17 hand-coded font tables (5 existing + 12 new) with snapshot tests"`
---
### Task 3: Create the templates module
**Files:**
- Create: `src/main/AsciiArt.templates.js`
- Test: `tests/main/ascii-art.templates.test.js`
**Interfaces:**
- Consumes: nothing (pure data module)
- Produces: `ASCII_TEMPLATES` — `{ [name]: string }` for the 19 named templates; `getTemplate(name)` accessor that returns `''` for unknown names.
- [ ] **Step 1: Write the failing snapshot test.** Create `tests/main/ascii-art.templates.test.js`:
```js
/**
* @jest-environment node
*
* Per-template snapshot tests. Templates are static strings; the snapshot
* catches accidental edits during refactors.
*/
const AsciiArtTemplates = require('../../src/main/AsciiArt.templates');
const TEMPLATE_NAMES = [
'arrow-right',
'arrow-down',
'arrow-up',
'decision',
'process',
'flowchart',
'sequence',
'network',
'hierarchy',
'header',
'note',
'warning',
'info',
'divider',
'separator',
'banner',
'checklist',
'progress-bar',
'table-simple',
];
describe('AsciiArt.templates', () => {
test.each(TEMPLATE_NAMES)('%s', (name) => {
expect(AsciiArtTemplates.getTemplate(name)).toMatchSnapshot();
});
test('returns empty string for unknown name', () => {
expect(AsciiArtTemplates.getTemplate('nope')).toBe('');
});
test('ASCII_TEMPLATES object exposes all 19 names', () => {
expect(Object.keys(AsciiArtTemplates.ASCII_TEMPLATES).sort()).toEqual(
TEMPLATE_NAMES.slice().sort()
);
});
});
```
- [ ] **Step 2: Run test, verify it fails.** `npm test -- tests/main/ascii-art.templates.test.js` — expect `Cannot find module`.
- [ ] **Step 3: Create the templates module.** Create `src/main/AsciiArt.templates.js` exporting `ASCII_TEMPLATES` (the merged superset of `src/renderer.js:6517-6670` and `src/ascii-generator.html:596-626`) and `getTemplate(name)`:
```js
/**
* ASCII art templates — named pre-drawn diagrams and frames.
*
* Sourced from renderer.js:6517-6670 and src/ascii-generator.html:596-626.
* 19 entries; unknown names resolve to ''.
*
* @module AsciiArt.templates
*/
'use strict';
const ASCII_TEMPLATES = {
'arrow-right': ' ┌─────────────────────┐\n──▶│ Process or Action │──▶\n └─────────────────────┘',
'arrow-down': ' │\n ▼\n┌───────────────┐\n│ Process │\n└───────────────┘\n │\n ▼',
// ...full set of 19 templates
};
function getTemplate(name) {
if (typeof name !== 'string') return '';
return ASCII_TEMPLATES[name] || '';
}
module.exports = { ASCII_TEMPLATES, getTemplate };
```
- [ ] **Step 4: Run templates tests, verify they pass.** `npm test -- tests/main/ascii-art.templates.test.js` — all 19 snapshot tests pass.
- [ ] **Step 5: Commit.** `git add src/main/AsciiArt.templates.js tests/main/ascii-art.templates.test.js tests/main/__snapshots__/ && git commit -m "feat(ascii-art): 19 named ASCII art templates with snapshot tests"`
---
### Task 4: Create the figlet adapter with lazy load + cache
**Files:**
- Create: `src/main/AsciiArt.figlet-adapter.js`
- Test: `tests/main/ascii-art.figlet-adapter.test.js`
**Interfaces:**
- Consumes: nothing (pure module, lazy-`require`s `figlet`)
- Produces: `loadFiglet()` (idempotent, returns the figlet module or `null` if unavailable), `listFigletFonts()` (returns `string[]` of figlet fonts), `generateFiglet(text, font)` (returns rendered string or throws `AsciiArtFigletError`).
- [ ] **Step 1: Write the failing test.** Create `tests/main/ascii-art.figlet-adapter.test.js`:
```js
/**
* @jest-environment node
*
* Adapter tests — figlet is mocked so we don't ship 400 KB of fonts into
* the test runner.
*/
jest.mock('figlet', () => {
const actual = jest.requireActual('figlet');
return {
__esModule: true,
default: actual,
textSync: jest.fn(() => 'MOCKED_FIGLET_OUTPUT'),
fontsSync: jest.fn(() => ['Standard', 'Big', 'Slant']),
};
});
const FigletAdapter = require('../../src/main/AsciiArt.figlet-adapter');
describe('AsciiArt.figlet-adapter', () => {
beforeEach(() => {
jest.clearAllMocks();
FigletAdapter._resetCache(); // test-only reset
});
test('loadFiglet returns the figlet module on first call', () => {
const mod = FigletAdapter.loadFiglet();
expect(mod).not.toBeNull();
expect(typeof mod.textSync).toBe('function');
});
test('listFigletFonts returns string[] from figlet.fontsSync()', () => {
const fonts = FigletAdapter.listFigletFonts();
expect(fonts).toEqual(['Standard', 'Big', 'Slant']);
});
test('listFigletFonts caches between calls', () => {
FigletAdapter.listFigletFonts();
FigletAdapter.listFigletFonts();
const figlet = require('figlet');
expect(figlet.fontsSync).toHaveBeenCalledTimes(1);
});
test('generateFiglet returns rendered string', () => {
const out = FigletAdapter.generateFiglet('HELLO', 'Big');
expect(out).toBe('MOCKED_FIGLET_OUTPUT');
const figlet = require('figlet');
expect(figlet.textSync).toHaveBeenCalledWith('HELLO', { font: 'Big' });
});
test('generateFiglet throws AsciiArtFigletError when figlet throws', () => {
const figlet = require('figlet');
figlet.textSync.mockImplementationOnce(() => {
throw new Error('unknown font');
});
expect(() => FigletAdapter.generateFiglet('X', 'Nope')).toThrow(FigletAdapter.AsciiArtFigletError);
});
test('returns null when figlet module fails to load', () => {
// simulate require failure by reloading after breaking the module cache
jest.isolateModules(() => {
jest.doMock('figlet', () => {
throw new Error('not found');
});
const Fresh = require('../../src/main/AsciiArt.figlet-adapter');
Fresh._resetCache();
expect(Fresh.loadFiglet()).toBeNull();
expect(Fresh.listFigletFonts()).toEqual([]);
expect(() => Fresh.generateFiglet('X', 'Big')).toThrow(FigletAdapter.AsciiArtFigletError);
});
});
});
```
- [ ] **Step 2: Run test, verify it fails.** `npm test -- tests/main/ascii-art.figlet-adapter.test.js` — expect module-not-found.
- [ ] **Step 3: Implement the adapter.** Create `src/main/AsciiArt.figlet-adapter.js`:
```js
/**
* Figlet adapter — lazy-loads the figlet package and caches the font list.
*
* The figlet npm package ships ~400 fonts. Lazy-loading on first
* `generateFiglet` / `listFigletFonts` call keeps app startup fast. If
* `require('figlet')` itself fails (corrupt install), the adapter reports
* the system as unavailable instead of crashing.
*
* @module AsciiArt.figlet-adapter
*/
'use strict';
class AsciiArtFigletError extends Error {
constructor(message, cause) {
super(message);
this.name = 'AsciiArtFigletError';
this.cause = cause;
}
}
let _figlet = undefined; // undefined = not yet attempted; null = failed
let _figletFonts = undefined; // undefined = not yet listed
function loadFiglet() {
if (_figlet !== undefined) return _figlet;
try {
// eslint-disable-next-line global-require
_figlet = require('figlet');
} catch (err) {
_figlet = null;
}
return _figlet;
}
function listFigletFonts() {
const f = loadFiglet();
if (!f) return [];
if (_figletFonts === undefined) {
try {
_figletFonts = f.fontsSync();
} catch (err) {
_figletFonts = [];
}
}
return _figletFonts.slice();
}
function generateFiglet(text, font) {
const f = loadFiglet();
if (!f) {
throw new AsciiArtFigletError('figlet is not available');
}
try {
return f.textSync(String(text ?? ''), { font });
} catch (err) {
throw new AsciiArtFigletError(
`figlet render failed for font "${font}": ${err.message}`,
err
);
}
}
// Test-only cache reset; not used at runtime.
function _resetCache() {
_figlet = undefined;
_figletFonts = undefined;
}
module.exports = {
AsciiArtFigletError,
loadFiglet,
listFigletFonts,
generateFiglet,
_resetCache,
};
```
- [ ] **Step 4: Run tests, verify they pass.** `npm test -- tests/main/ascii-art.figlet-adapter.test.js` — all 7 tests pass.
- [ ] **Step 5: Commit.** `git add src/main/AsciiArt.figlet-adapter.js tests/main/ascii-art.figlet-adapter.test.js && git commit -m "feat(ascii-art): figlet adapter with lazy load, font cache, structured error"`
---
### Task 5: Create the pure `AsciiArt` orchestrator
**Files:**
- Create: `src/main/AsciiArt.js`
- Test: `tests/main/ascii-art.test.js`
**Interfaces:**
- Consumes: the three sibling modules (`AsciiArt.fonts`, `AsciiArt.templates`, `AsciiArt.figlet-adapter`).
- Produces:
- `generate({ text, font, options })` — returns the rendered ASCII string. `font` is one of: `'standard' | 'banner' | 'block' | 'bubble' | 'digital' | 'big' | 'small' | 'lean' | 'slant' | 'isometric1' | ... | 'calvin-s' | 'template:<name>' | 'figlet:<font>'`. Unknown / missing fonts fall back to `standard`. Unknown characters substitute the space glyph. `options` is reserved (e.g. `{ widthBreaks: false }`).
- `listFonts()` — returns `{ id, label, kind: 'hand-coded' | 'figlet' | 'template', sample }[]`. Hand-coded first, then figlet, then templates.
- `getFontMeta(id)` — returns `{ kind, height, supportedChars }` for hand-coded, `{ kind }` for figlet/templates.
- [ ] **Step 1: Write the failing test.** Create `tests/main/ascii-art.test.js`:
```js
/**
* @jest-environment node
*/
const AsciiArt = require('../../src/main/AsciiArt');
const AsciiArtFonts = require('../../src/main/AsciiArt.fonts');
const AsciiArtTemplates = require('../../src/main/AsciiArt.templates');
describe('AsciiArt.generate', () => {
test('renders "HELLO" in standard font with known shape', () => {
const out = AsciiArt.generate({ text: 'HELLO', font: 'standard' });
const lines = out.split('\n');
expect(lines).toHaveLength(AsciiArtFonts.HAND_CODED_FONTS.standard.height);
expect(lines[0]).toContain('H');
});
test('lowercase input is uppercased', () => {
const a = AsciiArt.generate({ text: 'hello', font: 'standard' });
const b = AsciiArt.generate({ text: 'HELLO', font: 'standard' });
expect(a).toBe(b);
});
test('unknown font falls back to standard', () => {
const a = AsciiArt.generate({ text: 'A', font: 'nope-not-a-font' });
const b = AsciiArt.generate({ text: 'A', font: 'standard' });
expect(a).toBe(b);
});
test('unknown character substitutes space glyph (column width preserved)', () => {
const font = AsciiArtFonts.HAND_CODED_FONTS.standard;
const spaceWidth = font.chars[' '][0].length;
const out = AsciiArt.generate({ text: 'A!A', font: 'standard' });
const line = out.split('\n')[0];
// "A" rendered + space-glyph for "!" + "A" rendered; the middle column
// is the space-width.
expect(line.length).toBeGreaterThanOrEqual(spaceWidth);
});
test('renders a named template via "template:<name>"', () => {
const tpl = AsciiArtTemplates.getTemplate('arrow-right');
expect(AsciiArt.generate({ text: 'ignored', font: 'template:arrow-right' })).toBe(tpl);
});
test('renders figlet font via "figlet:<font>" — adapter is mocked below', () => {
// Mock the adapter module so we don't depend on the real figlet.
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: (t, f) => `FIGLET[${f}:${t}]`,
listFigletFonts: () => ['Big', 'Slant'],
loadFiglet: () => ({ fontsSync: () => ['Big', 'Slant'] }),
}));
const Fresh = require('../../src/main/AsciiArt');
expect(Fresh.generate({ text: 'HI', font: 'figlet:Big' })).toBe('FIGLET[Big:HI]');
});
test('propagates AsciiArtFigletError on figlet failure', () => {
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: () => {
throw new Error('boom');
},
listFigletFonts: () => [],
loadFiglet: () => null,
}));
const Fresh = require('../../src/main/AsciiArt');
expect(() => Fresh.generate({ text: 'X', font: 'figlet:Big' })).toThrow(/figlet/i);
});
});
describe('AsciiArt.listFonts', () => {
test('returns hand-coded fonts first, then figlet, then templates', () => {
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: () => '',
listFigletFonts: () => ['Big', 'Slant'],
loadFiglet: () => ({ fontsSync: () => ['Big', 'Slant'] }),
}));
const Fresh = require('../../src/main/AsciiArt');
const list = Fresh.listFonts();
expect(list.length).toBeGreaterThanOrEqual(17 + 2 + 19);
// first 17 should be hand-coded
for (let i = 0; i < 17; i++) {
expect(list[i].kind).toBe('hand-coded');
expect(list[i].id).toMatch(/^[a-z0-9-]+$/);
}
// figlet entries come next
const firstFiglet = list.find((f) => f.kind === 'figlet');
expect(firstFiglet.id).toBe('figlet:Big');
});
test('still returns hand-coded + templates when figlet is unavailable', () => {
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: () => '',
listFigletFonts: () => [],
loadFiglet: () => null,
}));
const Fresh = require('../../src/main/AsciiArt');
const list = Fresh.listFonts();
expect(list.some((f) => f.kind === 'figlet')).toBe(false);
expect(list.filter((f) => f.kind === 'hand-coded')).toHaveLength(17);
expect(list.filter((f) => f.kind === 'template').length).toBeGreaterThanOrEqual(19);
});
test('every entry has id, label, kind, sample', () => {
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: () => '',
listFigletFonts: () => [],
loadFiglet: () => null,
}));
const Fresh = require('../../src/main/AsciiArt');
for (const f of Fresh.listFonts()) {
expect(typeof f.id).toBe('string');
expect(typeof f.label).toBe('string');
expect(['hand-coded', 'figlet', 'template']).toContain(f.kind);
expect(typeof f.sample).toBe('string');
expect(f.sample.length).toBeGreaterThan(0);
}
});
});
describe('AsciiArt.getFontMeta', () => {
test('hand-coded font returns kind, height, supportedChars', () => {
const meta = AsciiArt.getFontMeta('big');
expect(meta.kind).toBe('hand-coded');
expect(typeof meta.height).toBe('number');
expect(meta.height).toBeGreaterThanOrEqual(4);
expect(Array.isArray(meta.supportedChars)).toBe(true);
expect(meta.supportedChars).toEqual(expect.arrayContaining(['A', '0', ' ']));
});
test('template returns { kind: "template" }', () => {
expect(AsciiArt.getFontMeta('template:flowchart')).toEqual({ kind: 'template' });
});
test('figlet returns { kind: "figlet" }', () => {
jest.resetModules();
jest.doMock('../../src/main/AsciiArt.figlet-adapter', () => ({
AsciiArtFigletError: class extends Error {},
generateFiglet: () => '',
listFigletFonts: () => ['Big'],
loadFiglet: () => ({}),
}));
const Fresh = require('../../src/main/AsciiArt');
expect(Fresh.getFontMeta('figlet:Big')).toEqual({ kind: 'figlet' });
});
test('unknown id returns null', () => {
expect(AsciiArt.getFontMeta('nope')).toBeNull();
});
});
```
- [ ] **Step 2: Run test, verify it fails.** `npm test -- tests/main/ascii-art.test.js` — expect module-not-found.
- [ ] **Step 3: Implement `AsciiArt.js`.** Create `src/main/AsciiArt.js`:
```js
/**
* ASCII art orchestrator — pure module.
*
* Public API:
* - generate({ text, font, options }) → string
* - listFonts() → [{ id, label, kind, sample }]
* - getFontMeta(id) → { kind, height?, supportedChars? } | null
*
* `font` is a single string that namespaces each backend:
* - bare id ('standard', 'big', 'isometric1', ...) → hand-coded
* - 'template:<name>' → templates
* - 'figlet:<font>' → lazy figlet
*
* Pure: no Electron, no fs, no IPC. Wired to main.js via ipcMain.handle.
*
* @module AsciiArt
*/
'use strict';
const { HAND_CODED_FONTS } = require('./AsciiArt.fonts');
const { getTemplate, ASCII_TEMPLATES } = require('./AsciiArt.templates');
const FigletAdapter = require('./AsciiArt.figlet-adapter');
const SAMPLE_TEXT = 'HELLO';
function _renderHandCoded(text, fontId) {
const font = HAND_CODED_FONTS[fontId];
if (!font) return null;
const upper = String(text ?? '').toUpperCase();
const lines = Array(font.height).fill('');
for (const ch of upper) {
const glyph = font.chars[ch] || font.chars[' '];
for (let i = 0; i < font.height; i++) lines[i] += glyph[i];
}
return lines.join('\n');
}
function generate({ text, font, options } = {}) {
const f = typeof font === 'string' && font ? font : 'standard';
if (f.startsWith('template:')) {
return getTemplate(f.slice('template:'.length));
}
if (f.startsWith('figlet:')) {
return FigletAdapter.generateFiglet(String(text ?? ''), f.slice('figlet:'.length));
}
const rendered = _renderHandCoded(text, f);
return rendered !== null ? rendered : _renderHandCoded(text, 'standard');
}
function listFonts() {
const out = [];
for (const [id, font] of Object.entries(HAND_CODED_FONTS)) {
out.push({
id,
label: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
kind: 'hand-coded',
sample: _renderHandCoded(SAMPLE_TEXT, id),
});
}
for (const name of FigletAdapter.listFigletFonts()) {
out.push({
id: `figlet:${name}`,
label: `FIGlet · ${name}`,
kind: 'figlet',
sample: '',
});
}
for (const name of Object.keys(ASCII_TEMPLATES)) {
out.push({
id: `template:${name}`,
label: `Template · ${name.replace(/-/g, ' ')}`,
kind: 'template',
sample: ASCII_TEMPLATES[name],
});
}
return out;
}
function getFontMeta(id) {
if (typeof id !== 'string' || !id) return null;
if (id.startsWith('template:')) {
return ASCII_TEMPLATES[id.slice('template:'.length)] ? { kind: 'template' } : null;
}
if (id.startsWith('figlet:')) {
const name = id.slice('figlet:'.length);
return FigletAdapter.listFigletFonts().includes(name) ? { kind: 'figlet' } : null;
}
const font = HAND_CODED_FONTS[id];
if (!font) return null;
return {
kind: 'hand-coded',
height: font.height,
supportedChars: Object.keys(font.chars),
};
}
module.exports = { generate, listFonts, getFontMeta };
```
- [ ] **Step 4: Run tests, verify they pass.** `npm test -- tests/main/ascii-art.test.js` — all 11 tests pass.
- [ ] **Step 5: Commit.** `git add src/main/AsciiArt.js tests/main/ascii-art.test.js && git commit -m "feat(ascii-art): pure AsciiArt module with generate/listFonts/getFontMeta"`
---
### Task 6: Wire IPC handlers in main.js
**Files:**
- Modify: `src/main.js:4005-4010` (require block)
- Modify: `src/main.js` (new handlers, inserted after `workspace-search:query` handler at line 5876)
- Modify: `src/main.js:5611` (existing `open-ascii-generator` handler — untouched)
**Interfaces:**
- Consumes: the `AsciiArt` module, the existing `store` helper at `main.js:267-289`, and the existing `dialog`, `clipboard`, `BrowserWindow` from Electron (already imported).
- Produces: IPC handlers `ascii:generate`, `ascii:list-fonts`, `ascii:get-font-meta`, `ascii:save`, `ascii:copy`, `ascii:last-font`.
- [ ] **Step 1: Add the require.** Insert `const AsciiArt = require('./main/AsciiArt');` after line 4008 (`const DocQA = require('./main/DocQA');`).
- [ ] **Step 2: Add the new IPC handlers.** Insert the following block immediately after the existing `ipcMain.handle('workspace-search:query', …)` handler (after the DocQA handler block, around line 5969 or wherever the doc-search block ends in current main.js):
```js
// ============================================
// ASCII Art Generator (standalone window)
// ============================================
// Renders text banners, boxes, and templates via the pure AsciiArt module.
// Font list is requested once on renderer mount. Save-to-file uses the
// active window as the parent of the save dialog. Last-used font is
// persisted to <userData>/settings.json via the JSON store helper.
ipcMain.handle('ascii:list-fonts', () => AsciiArt.listFonts());
ipcMain.handle('ascii:get-font-meta', (_event, id) => AsciiArt.getFontMeta(id));
ipcMain.handle('ascii:generate', (_event, { text, font, options } = {}) => {
return AsciiArt.generate({ text, font, options });
});
ipcMain.handle('ascii:copy', (_event, text) => {
const { clipboard } = require('electron');
clipboard.writeText(typeof text === 'string' ? text : '');
return true;
});
ipcMain.handle('ascii:save', async (event, { text, defaultName } = {}) => {
const { dialog } = require('electron');
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showSaveDialog(win || undefined, {
title: 'Save ASCII Art',
defaultPath: typeof defaultName === 'string' && defaultName ? defaultName : 'ascii-art.txt',
filters: [{ name: 'Text', extensions: ['txt'] }, { name: 'All', extensions: ['*'] }],
});
if (result.canceled || !result.filePath) return { canceled: true };
await require('fs').promises.writeFile(result.filePath, text ?? '', 'utf-8');
return { canceled: false, path: result.filePath };
});
ipcMain.handle('ascii:last-font', (_event, { font } = {}) => {
if (typeof font === 'string') {
store.set('ascii:lastFont', font);
return font;
}
return store.get('ascii:lastFont', null);
});
```
- [ ] **Step 3: Manual smoke check (not a test, just `node -e`).** Run `node -e "const A=require('./src/main/AsciiArt'); console.log(A.listFonts().length, A.generate({text:'HI',font:'standard'}).split('\\n').length)"` from the repo root and confirm output is a number ≥ 17 and ≤ 19 hand-coded lines.
- [ ] **Step 4: Commit.** `git add src/main.js && git commit -m "feat(ascii-art): wire IPC handlers for generate/list-fonts/save/copy/last-font"`
---
### Task 7: Update preload.js — delete dead channels, add new namespace
**Files:**
- Modify: `src/preload.js:98-99` (existing `open-ascii-generator` line — keep)
- Modify: `src/preload.js:294-296` (delete dead receive channels)
- Modify: `src/preload.js:ALLOWED_SEND_CHANNELS` (add new ascii invoke channels)
- Modify: `src/preload.js:511-513` (extend `generators.ascii` namespace)
**Interfaces:**
- Consumes: nothing (configuration).
- Produces: `window.electronAPI.generators.ascii.{ listFonts, getFontMeta, generate, copy, save, lastFont }`.
- [ ] **Step 1: Delete dead receive channels.** Remove lines 294-296 from `src/preload.js`:
- `'show-ascii-generator-window',`
- `'show-ascii-generator',`
(along with their comment header `// ASCII Art Generator`).
- [ ] **Step 2: Add new invoke channels.** In `ALLOWED_SEND_CHANNELS`, insert after the existing `'open-ascii-generator',` line (line 99):
```js
// ASCII art generator (standalone window — invoke channels)
'ascii:generate',
'ascii:list-fonts',
'ascii:get-font-meta',
'ascii:save',
'ascii:copy',
'ascii:last-font',
```
- [ ] **Step 3: Extend the `generators` namespace.** Replace the `generators: { openAscii, openTable }` block at lines 510-513 with:
```js
// Generator Windows
generators: {
openAscii: () => ipcRenderer.send('open-ascii-generator'),
openTable: () => ipcRenderer.send('open-table-generator'),
ascii: {
listFonts: () => ipcRenderer.invoke('ascii:list-fonts'),
getFontMeta: (id) => ipcRenderer.invoke('ascii:get-font-meta', id),
generate: (args) => ipcRenderer.invoke('ascii:generate', args),
copy: (text) => ipcRenderer.invoke('ascii:copy', text),
save: (args) => ipcRenderer.invoke('ascii:save', args),
lastFont: (font) => ipcRenderer.invoke('ascii:last-font', { font }),
},
},
```
- [ ] **Step 4: Lint check.** `npm run lint` — must be clean. If ESLint flags the unused delete, verify no other code references the deleted channels via `grep -rn "show-ascii-generator" src tests`.
- [ ] **Step 5: Commit.** `git add src/preload.js && git commit -m "feat(ascii-art): preload allow-list + ascii namespace; remove dead show-ascii-generator channels"`
---
### Task 8: Create the preload allow-list assertion test
**Files:**
- Create: `tests/preload-ascii.test.js`
- Modify: `tests/preload.test.js:EXPECTED_SEND_CHANNELS` (extend allow-list)
**Interfaces:**
- Consumes: `src/preload.js` (the source file)
- Produces: passing test that asserts the allow-list contains exactly the right channels.
- [ ] **Step 1: Write the failing test.** Create `tests/preload-ascii.test.js`:
```js
/**
* @jest-environment node
*
* Asserts that the preload.js allow-list contains the new ascii:* invoke
* channels and does NOT contain the dead show-ascii-generator* channels.
*/
const fs = require('fs');
const path = require('path');
const preloadSrc = fs.readFileSync(
path.join(__dirname, '..', 'src', 'preload.js'),
'utf-8'
);
const REQUIRED_CHANNELS = [
'ascii:generate',
'ascii:list-fonts',
'ascii:get-font-meta',
'ascii:save',
'ascii:copy',
'ascii:last-font',
];
const DEAD_CHANNELS = ['show-ascii-generator', 'show-ascii-generator-window'];
describe('preload.js ASCII channels', () => {
test.each(REQUIRED_CHANNELS)('declares %s in the allow-list', (channel) => {
expect(preloadSrc).toContain(`'${channel}'`);
});
test.each(DEAD_CHANNELS)('removed %s from allow-list', (channel) => {
expect(preloadSrc).not.toContain(`'${channel}'`);
});
test('generators.ascii namespace is exposed', () => {
expect(preloadSrc).toMatch(/ascii:\s*\{/);
expect(preloadSrc).toMatch(/listFonts/);
expect(preloadSrc).toMatch(/generate/);
expect(preloadSrc).toMatch(/copy/);
expect(preloadSrc).toMatch(/save/);
expect(preloadSrc).toMatch(/lastFont/);
});
});
```
- [ ] **Step 2: Extend `tests/preload.test.js` `EXPECTED_SEND_CHANNELS`.** Add the 6 new channels to the existing array in the `EXPECTED_SEND_CHANNELS` block so the broader security test also passes.
- [ ] **Step 3: Run test, verify it passes.** `npm test -- tests/preload-ascii.test.js tests/preload.test.js`.
- [ ] **Step 4: Commit.** `git add tests/preload-ascii.test.js tests/preload.test.js && git commit -m "test(preload): ascii channel allow-list assertions + extend existing security test"`
---
### Task 9: Create the renderer-side controller
**Files:**
- Create: `src/renderer/ascii-controller.js`
**Interfaces:**
- Consumes: `window.electronAPI.generators.ascii.*` (new preload helpers).
- Produces: a single `bootstrap()` function that wires the standalone-window DOM (input, options, font picker, preview, action buttons) when loaded via `<script src="…">` in `src/ascii-generator.html`. Exports as both `window.ASCIIController` (for inline debugging) and a module-style default (no-op in non-browser).
- [ ] **Step 1: Create the controller.** Write `src/renderer/ascii-controller.js`:
```js
/**
* ASCII Art Generator — renderer controller for the standalone window.
*
* Drives the DOM in src/ascii-generator.html. Fetches the font list from
* the main process once on mount, renders previews on input, and exposes
* Insert / Copy / Save actions. Last-used font is restored from
* electron-store (via main) and saved on every change.
*
* No bundler, no framework — pure DOM + window.electronAPI bridge.
*/
(function () {
'use strict';
const api = window.electronAPI && window.electronAPI.generators && window.electronAPI.generators.ascii;
const els = {
textInput: document.getElementById('text-input'),
fontPicker: document.getElementById('font-picker'),
fontSearch: document.getElementById('font-search'),
preview: document.getElementById('preview'),
btnInsert: document.getElementById('btn-insert'),
btnCopy: document.getElementById('btn-copy'),
btnSave: document.getElementById('btn-save'),
btnGenerate: document.getElementById('btn-generate'),
warning: document.getElementById('ascii-warning'),
};
let _fonts = [];
let _currentFont = 'standard';
let _previewText = '';
let _searchDebounce = null;
function showWarning(msg) {
if (!els.warning) return;
els.warning.textContent = msg || '';
els.warning.style.display = msg ? 'block' : 'none';
}
function debounce(fn, ms) {
let t = null;
return function () {
const args = arguments;
clearTimeout(t);
t = setTimeout(() => fn.apply(null, args), ms);
};
}
async function refreshPreview() {
if (!api) return;
const text = els.textInput ? els.textInput.value : 'HELLO';
_previewText = text;
try {
const out = await api.generate({ text, font: _currentFont });
els.preview.textContent = out;
showWarning('');
} catch (err) {
els.preview.textContent = '';
showWarning(`Failed to render: ${err.message || err}`);
}
}
function renderFontList(filter) {
if (!els.fontPicker) return;
const f = (filter || '').toLowerCase();
const filtered = _fonts.filter((font) => {
if (!f) return true;
return font.label.toLowerCase().includes(f) || font.id.toLowerCase().includes(f);
});
els.fontPicker.innerHTML = '';
for (const font of filtered.slice(0, 500)) {
const opt = document.createElement('option');
opt.value = font.id;
opt.textContent = `${font.label} (${font.kind})`;
if (font.id === _currentFont) opt.selected = true;
els.fontPicker.appendChild(opt);
}
}
async function loadFonts() {
if (!api) return;
try {
_fonts = await api.listFonts();
} catch (err) {
_fonts = [];
showWarning('Could not load font list. Hand-coded fonts only.');
}
try {
const last = await api.lastFont(null);
if (last && _fonts.some((f) => f.id === last)) _currentFont = last;
} catch { /* ignore */ }
renderFontList('');
}
function wireEvents() {
if (els.textInput) {
els.textInput.addEventListener('input', debounce(refreshPreview, 200));
}
if (els.fontPicker) {
els.fontPicker.addEventListener('change', () => {
_currentFont = els.fontPicker.value;
if (api) api.lastFont(_currentFont).catch(() => {});
refreshPreview();
});
}
if (els.fontSearch) {
els.fontSearch.addEventListener('input', () => {
clearTimeout(_searchDebounce);
_searchDebounce = setTimeout(() => renderFontList(els.fontSearch.value), 100);
});
}
if (els.btnGenerate) {
els.btnGenerate.addEventListener('click', refreshPreview);
}
if (els.btnInsert) {
els.btnInsert.addEventListener('click', () => {
const wrapped = '```\n' + (_previewText ? document.getElementById('preview').textContent : '') + '\n```';
if (window.electronAPI && window.electronAPI.send) {
window.electronAPI.send('insert-generated-content', wrapped);
window.close();
}
});
}
if (els.btnCopy) {
els.btnCopy.addEventListener('click', async () => {
const text = els.preview.textContent;
if (!text) return;
try {
await api.copy(text);
showWarning('Copied to clipboard.');
setTimeout(() => showWarning(''), 1500);
} catch (err) {
showWarning('Copy failed: ' + (err.message || err));
}
});
}
if (els.btnSave) {
els.btnSave.addEventListener('click', async () => {
const text = els.preview.textContent;
if (!text) return;
try {
const result = await api.save({ text, defaultName: 'ascii-art.txt' });
if (!result.canceled) {
showWarning(`Saved to ${result.path}`);
setTimeout(() => showWarning(''), 2000);
}
} catch (err) {
showWarning('Save failed: ' + (err.message || err));
}
});
}
}
async function bootstrap() {
wireEvents();
await loadFonts();
await refreshPreview();
}
window.ASCIIController = { bootstrap };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();
```
- [ ] **Step 2: Manual sanity check (no test yet — DOM tests come in Task 10).** Run `node -c src/renderer/ascii-controller.js` to verify it parses; the file does not run outside the browser so we cannot `node` it directly.
- [ ] **Step 3: Commit.** `git add src/renderer/ascii-controller.js && git commit -m "feat(ascii-art): renderer controller for standalone window (font picker + copy/save/insert)"`
---
### Task 10: Rewrite the standalone window HTML
**Files:**
- Modify: `src/ascii-generator.html:245-356` (mode tabs / forms / preview — keep mode-tabs/box/templates layout but swap `font-style` `<select>` for the searchable picker and add two new buttons)
- Modify: `src/ascii-generator.html:359-362` (footer buttons — add Copy + Save)
- Modify: `src/ascii-generator.html:364-749` (inline `<script>` — replaced by `<script src="../renderer/ascii-controller.js"></script>`)
**Interfaces:**
- Consumes: `src/renderer/ascii-controller.js`
- Produces: a standalone window that loads the controller, renders all 17+ fonts, and exposes Insert / Copy / Save.
- [ ] **Step 1: Replace the inline FONTS/TEMPLATES/BOX_STYLES + generation script with a controller script tag.** In `src/ascii-generator.html`, remove the entire `<script>` block from `// ASCII Art Generator Logic` through the closing `</script>` (lines ~364-749). Replace with:
```html
<script src="../renderer/ascii-controller.js"></script>
```
- [ ] **Step 2: Replace the text-mode font-style `<select>` with a searchable picker.** In the `#text-mode` section, replace lines ~264-273 with:
```html
<div class="form-group">
<label class="form-label">Font</label>
<input
type="text"
id="font-search"
class="form-input"
placeholder="Search fonts…"
autocomplete="off"
/>
<select id="font-picker" class="form-select" size="8"></select>
</div>
```
- [ ] **Step 3: Add a warning element above the preview.** After the `<div class="section-title">Preview</div>` line, insert:
```html
<div id="ascii-warning" style="display:none;color:#e5461f;margin-bottom:8px;font-size:0.8rem"></div>
```
- [ ] **Step 4: Extend the footer buttons.** Replace the footer block (lines ~359-362) with:
```html
<div class="footer">
<button class="btn btn-secondary" id="btn-generate">Generate Preview</button>
<button class="btn btn-secondary" id="btn-copy">Copy to Clipboard</button>
<button class="btn btn-secondary" id="btn-save">Save to File</button>
<button class="btn btn-primary" id="btn-insert">Insert to Editor</button>
</div>
```
- [ ] **Step 5: Verify the file still parses.** Open the file in a browser (or `cat src/ascii-generator.html | head -10` to sanity-check the head section) — must still have the `<!doctype html>` and the closing `</html>`.
- [ ] **Step 6: Manual smoke.** Launch the app: `npm start` — Ctrl+Shift+A. Verify: (a) standalone window opens, (b) font picker lists 17+ entries, (c) preview updates on input, (d) Insert / Copy / Save each work end-to-end.
- [ ] **Step 7: Commit.** `git add src/ascii-generator.html && git commit -m "feat(ascii-art): standalone window uses controller + searchable picker + copy/save/insert"`
---
### Task 11: Delete in-app modal markup and dead renderer code
**Files:**
- Modify: `src/index.html:812-1028` (delete the `#ascii-art-dialog` block)
- Modify: `src/renderer.js:2188` (delete `const asciiModal = new ModalManager('#ascii-art-dialog');`)
- Modify: `src/renderer.js:2202` (delete `asciiModal,` from `window.modals`)
- Modify: `src/renderer.js:5942-6736` (delete the entire ASCII Art Generator block)
**Interfaces:**
- Consumes: nothing.
- Produces: a `git grep` that returns zero hits for `showASCIIGenerator`, `textToASCII`, `createASCIIBox`, `getASCIITemplate`, `insertASCIIArt`, `hideASCIIGenerator`, `switchASCIIMode`, `loadASCIITemplate`, `generateASCIIPreview`, `ascii-art-dialog`, `asciiModal`, `show-ascii-generator`.
- [ ] **Step 1: Delete the modal markup in `src/index.html`.** Remove lines 812-1028 inclusive (the entire `<div id="ascii-art-dialog" …> … </div>` block).
- [ ] **Step 2: Delete the `asciiModal` instantiation in `src/renderer.js`.** Remove line 2188 (`const asciiModal = new ModalManager('#ascii-art-dialog');`).
- [ ] **Step 3: Delete the `window.modals.asciiModal` entry.** Remove line 2202 (`asciiModal,`).
- [ ] **Step 4: Delete the in-app controller block.** Remove lines 5941-6736 inclusive (the entire ASCII Art Generator block, from the `// ASCII ART GENERATOR` header comment through the `ipcRenderer.on('show-ascii-generator', …)` listener and its preceding whitespace).
- [ ] **Step 5: Verify zero residual references.** `git grep -nE "showASCIIGenerator|textToASCII|createASCIIBox|getASCIITemplate|insertASCIIArt|hideASCIIGenerator|switchASCIIMode|loadASCIITemplate|generateASCIIPreview|ascii-art-dialog|asciiModal|show-ascii-generator"` — must return no hits inside `src/`.
- [ ] **Step 6: Verify the standalone path still works.** Run `npm start`. Ctrl+Shift+A still opens the standalone window; Tools → ASCII Art Generator (if present in menu) still works; Insert button wraps in fenced code block.
- [ ] **Step 7: Commit.** `git add src/index.html src/renderer.js && git commit -m "refactor(ascii-art): delete in-app modal #ascii-art-dialog, controller, dead preload channels"`
---
### Task 12: Update README and run full validation
**Files:**
- Modify: `README.md:56` (ASCII Art Generator row)
**Interfaces:**
- Consumes: nothing.
- Produces: README updated to reflect the new feature surface.
- [ ] **Step 1: Update the feature row.** In `README.md`, change line 56 from:
```
- **ASCII Art Generator** - Create text banners and diagrams
```
to:
```
- **ASCII Art Generator** - 17 hand-coded fonts + 400+ FIGlet fonts; text banners, boxes, and templates; insert into editor, copy to clipboard, or save to file (Ctrl+Shift+A)
```
- [ ] **Step 2: Run the full test suite.** `npm test` — all tests pass (existing 800+ plus the ~40 new tests).
- [ ] **Step 3: Run lint.** `npm run lint` — clean.
- [ ] **Step 4: Run format check.** `npm run format:check` — if any files are mis-formatted, run `npm run format` and re-run the suite.
- [ ] **Step 5: Run Linux build.** `npm run build:linux` — succeeds without native-binding surprises from `figlet`. (If running in CI, this is automatic.)
- [ ] **Step 6: Final commit.** `git add README.md && git commit -m "docs(readme): ASCII Art Generator now 17 hand-coded + 400+ FIGlet fonts; insert/copy/save"`
- [ ] **Step 7: Sweep for forbidden markers in changed files.** `git diff --name-only HEAD~12..HEAD -- 'src/main/AsciiArt*.js' 'src/renderer/ascii-controller.js' 'src/ascii-generator.html' 'src/preload.js' 'src/main.js' 'src/renderer.js' 'src/index.html' 'package.json' 'README.md' 'tests/main/ascii-art*.test.js' 'tests/main/ascii-art.fonts.test.js' 'tests/main/ascii-art.templates.test.js' 'tests/main/ascii-art.figlet-adapter.test.js' 'tests/preload-ascii.test.js' | xargs grep -nE 'TODO|FIXME|XXX|HACK|not implemented|placeholder|stub|for now|in a real app|mock data|hardcoded for demo|coming soon'` — must return zero hits (excluding pre-existing entries unrelated to this feature).
---
## Self-Review
1. **Spec coverage:**
- Single implementation path — Task 11.
- 17 hand-coded fonts (5 existing + 12 new) — Task 2.
- figlet lazy load + cache — Task 4 (adapter) + Task 1 (dep).
- Three output destinations — Task 9 (controller) + Task 10 (HTML buttons) + Task 6 (IPC handlers).
- Comprehensive tests — Tasks 2, 3, 4, 5, 8 cover unit, snapshot, adapter, preload.
- Pure module architecture — Tasks 2, 3, 4, 5 (all in `src/main/`, no Electron imports).
- Last-used font persistence — Task 6 (`store.set('ascii:lastFont', …)`) + Task 9 (controller calls `api.lastFont`).
- Error handling — Task 4 (`AsciiArtFigletError`) + Task 6 (`ascii:copy` catches, `ascii:save` returns `{ canceled }`) + Task 9 (showWarning).
- Acceptance criteria — Task 12 sweep.
2. **Placeholder scan:** No `TODO` / `TBD` / `FIXME` / `placeholder` / `implement later` / `fill in` markers in the plan body. The single intentional `nope` literal in test names is a test-only sentinel for "unknown" inputs and is not a placeholder.
3. **Type consistency:** `generate`, `listFonts`, `getFontMeta`, `generateFiglet`, `listFigletFonts`, `loadFiglet`, `AsciiArtFigletError`, `HAND_CODED_FONTS`, `ASCII_TEMPLATES`, `getTemplate`, `store.get` / `store.set`, `ascii:generate` / `ascii:list-fonts` / `ascii:get-font-meta` / `ascii:save` / `ascii:copy` / `ascii:last-font`, `generators.ascii.{ listFonts, getFontMeta, generate, copy, save, lastFont }` — names match across tasks.
4. **Open gap flagged:** The integration test in spec §Testing #5 (spawn `ascii-generator.html` in headless Electron, assert no console errors) is intentionally skipped — it requires a display server and is environmental. The spec marks this test as "Skip if no display available"; in CI this would be a manual smoke check (Task 10 Step 6) rather than a Jest test.