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}