framework-viz.js

  1/**
  2 * Periodic Table of Commands
  3 * Clean grid visualization showing all commands organized by category
  4 * Hover tooltips show description and relationships inline.
  5 */
  6
  7import { commandCategories, commandRelationships, alphaCommands } from '../data.js';
  8
  9const categoryColors = {
 10	create: { bg: 'var(--cat-create-bg)', border: 'var(--cat-create-border)', text: 'var(--cat-create-text)' },
 11	evaluate: { bg: 'var(--cat-evaluate-bg)', border: 'var(--cat-evaluate-border)', text: 'var(--cat-evaluate-text)' },
 12	refine: { bg: 'var(--cat-refine-bg)', border: 'var(--cat-refine-border)', text: 'var(--cat-refine-text)' },
 13	simplify: { bg: 'var(--cat-simplify-bg)', border: 'var(--cat-simplify-border)', text: 'var(--cat-simplify-text)' },
 14	harden: { bg: 'var(--cat-harden-bg)', border: 'var(--cat-harden-border)', text: 'var(--cat-harden-text)' },
 15	system: { bg: 'var(--cat-system-bg)', border: 'var(--cat-system-border)', text: 'var(--cat-system-text)' }
 16};
 17
 18const categoryLabels = {
 19	create: 'Create',
 20	evaluate: 'Evaluate',
 21	refine: 'Refine',
 22	simplify: 'Simplify',
 23	harden: 'Harden',
 24	system: 'System'
 25};
 26
 27const commandSymbols = {
 28	'impeccable': 'Im',
 29	'craft': 'Cf',
 30	'shape': 'Sh',
 31	'critique': 'Cr',
 32	'audit': 'Au',
 33	'typeset': 'Ty',
 34	'layout': 'La',
 35	'colorize': 'Co',
 36	'animate': 'An',
 37	'delight': 'De',
 38	'bolder': 'Bo',
 39	'quieter': 'Qu',
 40	'overdrive': 'Od',
 41	'distill': 'Di',
 42	'clarify': 'Cl',
 43	'adapt': 'Ad',
 44	'polish': 'Po',
 45	'optimize': 'Op',
 46	'harden': 'Ha',
 47	'onboard': 'On',
 48	'teach': 'Te',
 49	'document': 'Dc',
 50	'extract': 'Ex',
 51	'live': 'Li'
 52};
 53
 54const commandNumbers = {
 55	'impeccable': 1, 'craft': 2, 'shape': 3,
 56	'critique': 4, 'audit': 5,
 57	'typeset': 6, 'layout': 7, 'colorize': 8, 'animate': 9,
 58	'delight': 10, 'bolder': 11, 'quieter': 12, 'overdrive': 13,
 59	'distill': 14, 'clarify': 15, 'adapt': 16,
 60	'polish': 17, 'optimize': 18, 'harden': 19, 'onboard': 20,
 61	'teach': 21, 'document': 22, 'extract': 23, 'live': 24
 62};
 63
 64// After the v3.0 consolidation, all commands except the root "impeccable" are
 65// sub-commands of /impeccable. The renderer handles the display label directly
 66// (bare name for sub-commands, "/impeccable" for the root). This map is kept
 67// as an extension point for any future per-command display overrides.
 68const commandDisplay = {};
 69
 70export class PeriodicTable {
 71	constructor(container) {
 72		this.container = container;
 73		this.activeTooltip = null;
 74		this.activeElement = null;
 75		this.init();
 76	}
 77
 78	init() {
 79		this.container.innerHTML = '';
 80		this.container.style.cssText = `
 81			display: flex;
 82			flex-direction: column;
 83			gap: 16px;
 84			padding: 20px;
 85			height: 100%;
 86			box-sizing: border-box;
 87			position: relative;
 88		`;
 89
 90		this.renderTable();
 91	}
 92
 93	renderTable() {
 94		const groups = {};
 95		Object.entries(commandCategories).forEach(([cmd, cat]) => {
 96			if (!groups[cat]) groups[cat] = [];
 97			groups[cat].push(cmd);
 98		});
 99
100		const categoryOrder = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system'];
101
102		const grid = document.createElement('div');
103		grid.style.cssText = `
104			display: grid;
105			grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
106			gap: 16px;
107			flex: 1;
108		`;
109
110		categoryOrder.forEach(cat => {
111			const commands = groups[cat];
112			if (!commands) return;
113			const group = this.createCategoryGroup(cat, commands);
114			grid.appendChild(group);
115		});
116
117		this.container.appendChild(grid);
118	}
119
120	showTooltip(el, cmd) {
121		this.hideTooltip();
122
123		const rel = commandRelationships[cmd] || {};
124		const toArray = (val) => {
125			if (!val) return [];
126			if (Array.isArray(val)) return val;
127			return [val];
128		};
129
130		const pairs = toArray(rel.pairs);
131		const leadsTo = toArray(rel.leadsTo);
132		const combinesWith = toArray(rel.combinesWith);
133
134		// Build relationships line. Command names are shown bare (no slash)
135		// because they're names, not invocations — the invocation is /impeccable <name>.
136		let relParts = [];
137		if (pairs.length > 0) relParts.push(`pairs with ${pairs.join(', ')}`);
138		if (combinesWith.length > 0) relParts.push(`+ ${combinesWith.join(', ')}`);
139		if (leadsTo.length > 0) relParts.push(`then ${leadsTo.join(', ')}`);
140
141		// Strip category prefix from flow for cleaner display
142		const flow = (rel.flow || '').replace(/^[^:]+:\s*/, '');
143
144		const tooltip = document.createElement('div');
145		tooltip.className = 'ptable-tooltip';
146		tooltip.style.cssText = `
147			position: absolute;
148			z-index: 20;
149			background: var(--color-paper);
150			border: 1px solid var(--color-mist);
151			border-radius: 6px;
152			padding: 10px 14px;
153			box-shadow: 0 8px 24px -4px rgba(0,0,0,0.12);
154			pointer-events: none;
155			max-width: 280px;
156			opacity: 0;
157			transition: opacity 0.15s ease;
158		`;
159
160		tooltip.innerHTML = `
161			<div style="font-family: var(--font-body); font-size: 13px; color: var(--color-charcoal); line-height: 1.4; margin-bottom: ${relParts.length ? '6px' : '0'};">${flow}</div>
162			${relParts.length ? `<div style="font-family: var(--font-mono); font-size: 11px; color: var(--color-ash); line-height: 1.4;">${relParts.join(' · ')}</div>` : ''}
163		`;
164
165		this.container.appendChild(tooltip);
166
167		// Position relative to element
168		const elRect = el.getBoundingClientRect();
169		const containerRect = this.container.getBoundingClientRect();
170
171		const left = elRect.left - containerRect.left;
172		const top = elRect.bottom - containerRect.top + 6;
173
174		tooltip.style.left = `${Math.min(left, containerRect.width - 290)}px`;
175		tooltip.style.top = `${top}px`;
176
177		// Fade in
178		requestAnimationFrame(() => { tooltip.style.opacity = '1'; });
179
180		this.activeTooltip = tooltip;
181	}
182
183	hideTooltip() {
184		if (this.activeTooltip) {
185			this.activeTooltip.remove();
186			this.activeTooltip = null;
187		}
188	}
189
190	createCategoryGroup(category, commands) {
191		const colors = categoryColors[category];
192
193		const group = document.createElement('div');
194		group.style.cssText = `display: flex; flex-direction: column; gap: 6px;`;
195
196		const label = document.createElement('div');
197		label.style.cssText = `
198			font-family: var(--font-body);
199			font-size: 10px;
200			font-weight: 500;
201			text-transform: uppercase;
202			letter-spacing: 0.05em;
203			color: ${colors.text};
204			padding-left: 2px;
205		`;
206		label.textContent = categoryLabels[category];
207		group.appendChild(label);
208
209		const row = document.createElement('div');
210		row.style.cssText = `display: flex; flex-wrap: wrap; gap: 6px;`;
211
212		commands.forEach(cmd => {
213			const element = this.createElement(cmd, category);
214			row.appendChild(element);
215		});
216
217		group.appendChild(row);
218		return group;
219	}
220
221	createElement(cmd, category) {
222		const colors = categoryColors[category];
223		const display = commandDisplay[cmd];
224
225		const el = document.createElement('button');
226		el.type = 'button';
227		// Build accessible label with the full invocation
228		const invocation = cmd === 'impeccable'
229			? '/impeccable'
230			: cmd.startsWith('impeccable ')
231				? `/${cmd}`
232				: `/impeccable ${cmd}`;
233		el.setAttribute('aria-label', `${invocation} command - ${categoryLabels[category]}`);
234		el.style.cssText = `
235			width: 56px;
236			height: 64px;
237			background: ${colors.bg};
238			border: 1.5px solid ${colors.border};
239			border-radius: 5px;
240			display: flex;
241			flex-direction: column;
242			align-items: center;
243			justify-content: center;
244			cursor: pointer;
245			transition: transform 0.15s ease, box-shadow 0.15s ease;
246			position: relative;
247			font-family: inherit;
248			padding: 0;
249		`;
250
251		// Atomic number
252		const number = document.createElement('div');
253		number.style.cssText = `
254			position: absolute;
255			top: 3px;
256			left: 5px;
257			font-family: var(--font-mono);
258			font-size: 7px;
259			color: ${colors.text};
260			opacity: 0.5;
261		`;
262		number.textContent = commandNumbers[cmd];
263		el.appendChild(number);
264
265		// Symbol
266		const symbol = document.createElement('div');
267		symbol.style.cssText = `
268			font-family: var(--font-display);
269			font-size: 20px;
270			font-weight: 500;
271			color: ${colors.text};
272			line-height: 1;
273		`;
274		symbol.textContent = commandSymbols[cmd];
275		el.appendChild(symbol);
276
277		// Command name. The root "impeccable" is shown with its slash as the
278		// entry point. All other commands are sub-commands and show their
279		// bare name (the invocation is /impeccable <name>).
280		const name = document.createElement('div');
281		name.style.cssText = `
282			font-family: var(--font-mono);
283			font-size: 8px;
284			color: ${colors.text};
285			opacity: 0.7;
286			margin-top: 3px;
287			text-align: center;
288			max-width: 52px;
289			line-height: 1.3;
290			white-space: nowrap;
291		`;
292		if (cmd === 'impeccable') {
293			name.textContent = '/impeccable';
294		} else if (display) {
295			name.textContent = display.label;
296		} else {
297			name.textContent = cmd;
298		}
299		el.appendChild(name);
300
301		// Alpha badge
302		if (alphaCommands.includes(cmd)) {
303			const badge = document.createElement('div');
304			badge.style.cssText = `
305				position: absolute;
306				top: 2px;
307				right: 3px;
308				font-family: var(--font-mono);
309				font-size: 5px;
310				letter-spacing: 0.05em;
311				color: ${colors.text};
312				opacity: 0.45;
313				text-transform: uppercase;
314			`;
315			badge.textContent = 'α';
316			el.appendChild(badge);
317		}
318
319		// Hover/focus: show tooltip
320		const activate = () => {
321			el.style.transform = 'translateY(-2px)';
322			el.style.boxShadow = `0 4px 12px ${colors.border}40`;
323			this.showTooltip(el, cmd);
324
325			if (this.activeElement && this.activeElement !== el) {
326				this.activeElement.style.transform = 'translateY(0)';
327				this.activeElement.style.boxShadow = 'none';
328			}
329			this.activeElement = el;
330		};
331
332		const deactivate = () => {
333			el.style.transform = 'translateY(0)';
334			el.style.boxShadow = 'none';
335			this.hideTooltip();
336		};
337
338		el.addEventListener('mouseenter', activate);
339		el.addEventListener('mouseleave', deactivate);
340		el.addEventListener('focus', activate);
341		el.addEventListener('blur', deactivate);
342
343		el.addEventListener('touchstart', (e) => {
344			e.preventDefault();
345			activate();
346		}, { passive: false });
347
348		el.addEventListener('click', () => {
349			activate();
350			const scrollTarget = display ? display.scrollTo : cmd;
351
352			// Navigate the fisheye scroller to this command
353			const fisheyeList = document.getElementById('fisheye-list');
354			if (fisheyeList) {
355				const items = [...fisheyeList.querySelectorAll('.fisheye-item')];
356				const idx = items.findIndex(item => item.dataset.id === scrollTarget);
357				if (idx >= 0 && fisheyeList._scrollToCommand) {
358					// Scroll the commands section into view first
359					const section = document.querySelector('.commands-subsection');
360					if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
361					fisheyeList._scrollToCommand(idx);
362					return;
363				}
364			}
365
366			// Fallback: scroll to the spread element
367			const target = document.getElementById(`cmd-${scrollTarget}`);
368			if (target) {
369				target.scrollIntoView({ behavior: 'smooth', block: 'center' });
370			}
371		});
372
373		return el;
374	}
375}
376
377export function initFrameworkViz() {
378	const container = document.getElementById('framework-viz-container');
379	if (container) {
380		new PeriodicTable(container);
381	}
382}