/** * The shell's left icon rail: one entry per page (image pages, then the audio pages), * the canvas tools and settings. Entries open pages through `owner.goTo(page)`; * ui_rail_tools.js fills `owner.railTools` (scan, doctor, current node) and * `owner.railFoot` (settings). Old ids are kept (`#anomalous-models-btn` …) so tours * and other modules still find them. */ import { translate as t } from './locales.js'; import { TOOL_ICONS } from './tool_registry.js'; const icon = paths => ``; export const PAGE_ICONS = { home: icon(''), activity: icon(''), ai: icon(''), models: icon(''), gallery: icon(''), recipes: icon(''), combos: TOOL_ICONS.MATERIALS, prompts: icon(''), voices: icon(''), // Not rail entries (Voice-over and Audio are views of Voices); Home's voice-over card uses this one. script: icon(''), 'audio-gallery': icon(''), }; /** Pages on the rail, top to bottom: no more than the nine number keys. `audio`: the page belongs to the audio domain. */ export const RAIL_PAGES = Object.freeze([ { page: 'home', id: 'anomalous-home-btn', labelKey: 'shellHome' }, { page: 'activity', id: 'anomalous-activity-btn', labelKey: 'shellActivity' }, { page: 'ai', id: 'anomalous-ai-btn', labelKey: 'shellAi', railKey: 'shellAiRail' }, // the AI workbench (ui_ai_workbench.js) { page: 'models', id: 'anomalous-models-btn', labelKey: 'models' }, { page: 'gallery', id: 'anomalous-gallery-btn', labelKey: 'gallery' }, { page: 'recipes', id: 'anomalous-notebook-btn', labelKey: 'recipeTitle' }, { page: 'combos', id: 'anomalous-combos-btn', labelKey: 'shellCombos' }, { page: 'prompts', id: 'anomalous-prompts-btn', labelKey: 'shellPrompts' }, // Prompt Studio as a page { page: 'voices', id: 'anomalous-voices-btn', labelKey: 'shellVoices', audio: true }, // with Voice-over and Audio as its views ]); const div = className => Object.assign(document.createElement('div'), { className }); /** * Builds the rail; its logo is `.anomalous-rail-brand`. * Returns `{ root, items: Map(page → button), setActive(page | null), refreshLanguage() }`. */ export function createShellRail(owner) { const root = document.createElement('nav'); root.id = 'anomalous-rail'; root.setAttribute('aria-label', 'Anomalous'); const brand = document.createElement('button'); brand.type = 'button'; brand.className = 'anomalous-rail-brand'; brand.textContent = 'A'; brand.tabIndex = -1; brand.setAttribute('aria-hidden', 'true'); const nav = div('anomalous-rail-nav'); const marker = div('anomalous-rail-marker'); nav.appendChild(marker); const items = new Map(); for (const entry of RAIL_PAGES) { if (entry.audio && !nav.querySelector('.anomalous-rail-sep')) nav.appendChild(div('anomalous-rail-sep')); const button = document.createElement('button'); button.type = 'button'; button.id = entry.id; button.className = 'anomalous-rail-item'; button.dataset.page = entry.page; button.innerHTML = PAGE_ICONS[entry.page]; // static markup only button.appendChild(Object.assign(document.createElement('span'), { className: 'anomalous-rail-label' })); button.onclick = () => owner.goTo(entry.page, { fromRail: true }); items.set(entry.page, button); nav.appendChild(button); } owner.railTools = div('anomalous-rail-tools'); owner.railFoot = div('anomalous-rail-foot'); root.append(brand, nav, div('anomalous-rail-sep'), owner.railTools, div('anomalous-rail-spacer'), owner.railFoot); // The tooltip names the entry and its number key (1-9 from the top, ui_shell_nav.js). const refreshLanguage = () => { RAIL_PAGES.forEach((entry, index) => { const button = items.get(entry.page); const text = t(entry.labelKey); button.querySelector('.anomalous-rail-label').textContent = t(entry.railKey || entry.labelKey); button.setAttribute('aria-label', text); // Only the first nine have a number key. button.dataset.tooltip = index < 9 ? t('shellRailKey', { name: text, key: index + 1 }) : text; button.dataset.tooltipPos = 'right'; }); syncCompact(); }; const moveMarker = () => { const current = [...items.values()].find(button => button.classList.contains('active')); marker.classList.toggle('is-shown', !!current); if (!current) return; marker.style.translate = `0 ${current.offsetTop}px`; marker.style.height = `${current.offsetHeight}px`; }; // A short window drops the labels under the icons; the tooltips still name the entries. const syncCompact = () => { root.classList.remove('is-compact'); root.classList.toggle('is-compact', root.clientHeight > 0 && root.scrollHeight > root.clientHeight + 1); moveMarker(); }; if (typeof ResizeObserver === 'function') new ResizeObserver(syncCompact).observe(root); refreshLanguage(); return { root, items, setActive(page) { for (const [key, button] of items) { button.classList.toggle('active', key === page); if (key === page) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current'); } moveMarker(); }, refreshLanguage, }; }