diff --git a/src/app/page.tsx b/src/app/page.tsx index 4627eb4..48c9767 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -17,8 +17,9 @@ import { VisualStage } from '@/components/ava/visual-stage' import { ControlPanel } from '@/components/ava/control-panel' import { FooterBar } from '@/components/ava/footer-bar' import { QueueBar } from '@/components/ava/queue-bar' -import { applyScriptText, tapBeat } from '@/components/ava/audio-source' +import { applyScriptText, setBootScriptSnapshot, tapBeat } from '@/components/ava/audio-source' import { STARTER_TEMPLATE } from '@/lib/ava/presets' +import { releaseWakeLock, syncWakeLock, watchWakeLockVisibility } from '@/lib/ava/wake-lock' import { lsGetString, readStoredBandConfig, @@ -105,6 +106,7 @@ function useInitialLoad() { // (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers // every beat's hue) → any Zai-Magic preset → template let text: string | null = lsGetString('ava.defaultScript') + const usedSavedDefault = text !== null && text.trim() !== '' let startupPreset: (typeof presetsList)[number] | undefined if (!text) { startupPreset = @@ -117,6 +119,12 @@ function useInitialLoad() { } if (!text) text = STARTER_TEMPLATE applyScriptText(text) + // v7.11: remember the untouched startup script — the Demo showcase + // only auto-engages while the editor still matches it (any difference + // is the visitor's own material). A SAVED default is the visitor's + // own material from the start, so it counts as their choice. + setBootScriptSnapshot(text) + if (usedSavedDefault) store.markManualChoice() if (startupPreset) store.setActivePresetId(startupPreset.id) // club mode: bring back the random-preset timer if it was on @@ -146,10 +154,28 @@ function useSpaceToTap() { }, []) } +/** + * v7.11 — keep the phone's screen on while music is playing. Any source but + * idle holds a screen wake lock (see lib/ava/wake-lock.ts); the moment the + * engine goes idle the lock is released and the device's normal inactivity + * dimming / sleep takes over again. Browsers silently drop the sentinel when + * the page is hidden, so a visibilitychange watcher re-acquires it on return + * while music is still going. Unsupported browsers degrade silently. + */ +function useWakeLockOnPlayback() { + const source = useAvaStore((s) => s.source) + useEffect(() => { + syncWakeLock(source !== 'idle') + }, [source]) + useEffect(() => watchWakeLockVisibility(), []) + useEffect(() => () => releaseWakeLock(), []) +} + export default function Home() { useInitialLoad() useBeatWatcher() useSpaceToTap() + useWakeLockOnPlayback() return (
diff --git a/src/components/ava/audio-source.ts b/src/components/ava/audio-source.ts index e6d43ae..bbc87cc 100755 --- a/src/components/ava/audio-source.ts +++ b/src/components/ava/audio-source.ts @@ -11,6 +11,7 @@ import { getEngine } from '@/lib/ava/engine' import type { TapResult } from '@/lib/ava/beat' import { validateScript } from '@/lib/ava/script' import type { ScriptError } from '@/lib/ava/script' +import { STARTER_TEMPLATE } from '@/lib/ava/presets' import { DEMO_TRACK_COUNT, demoTrackTitle } from '@/lib/ava/audio' import { useAvaStore } from '@/lib/store' import { toast, useToast } from '@/hooks/use-toast' @@ -325,16 +326,56 @@ function fileErrorMessage(err: unknown): string { /** The read-only folder that ships the hand-authored Zai-Magic presets. */ const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic' +// --------------------------------------------------------------------------- +// v7.11 — the visitor's own selection vs. the welcome showcase +// --------------------------------------------------------------------------- + +/** The script text this visit booted with (saved default / Bass Bloom / + * template). Any later difference = the visitor's own material. */ +let bootScriptSnapshot = '' + +/** Capture the startup script (page boot) — the "untouched editor" baseline. */ +export function setBootScriptSnapshot(text: string): void { + bootScriptSnapshot = text.trim() +} + +/** + * v7.11 — does this visitor have their OWN thing going? True when they + * loaded a preset (tree Load / double-click / Copy to my presets / created + * one), brought their own script material into the editor (typed, pasted, + * hash-imported, file-loaded — anything that differs from the untouched + * startup text), saved their own default startup script, or are running + * their own mix mode. The Demo showcase auto-engages ONLY while this is + * false; otherwise the Demo button just plays music and touches nothing — + * a custom script can be tested and debugged against the demo track + * without any fear of it being replaced. + */ +export function visitorHasOwnSelection(): boolean { + const s = useAvaStore.getState() + if (s.manualChoice) return true + // their own club-mode mix is running — the demo must not re-scope it + if (s.randomize || s.sequenceMode) return true + const cur = s.scriptText.trim() + if (!cur) return false + // the untouched starter template (fresh visit / New button) is not own material + if (cur === STARTER_TEMPLATE.trim()) return false + // anything else that differs from the untouched startup text is theirs + return cur !== bootScriptSnapshot +} + /** * Music just started but no preset is active (the visitor never picked one — * e.g. they are running their own untitled draft): load the default preset, * Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment * audio arrives. Engine-only — the editor's unsaved text is never touched - * (the same courtesy random switching pays). + * (the same courtesy random switching pays). v7.11: a visitor with their OWN + * material (custom draft / saved default) is left completely alone — the + * forced default used to replace their running script. */ function ensureDefaultPreset(): void { const s = useAvaStore.getState() if (s.activePresetId !== null) return + if (visitorHasOwnSelection()) return const preset = s.presets.find( (p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom', ) @@ -353,7 +394,8 @@ function ensureDefaultPreset(): void { * expand it so the visitor sees what is playing), then start the sequence — * v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping * around, for the one play-through (Sequence, not Random — the demo pass - * should walk the whole family, not repeat picks). + * should walk the whole family, not repeat picks). v7.11: only ever called + * for a visit that made no choice of their own (see visitorHasOwnSelection). */ function startShowcaseMix(): void { const s = useAvaStore.getState() @@ -370,7 +412,8 @@ function startShowcaseMix(): void { * Stop button, or switching to another source) unwind the showcase state: * the Demo flow force-checked the mix mode (Sequence), so it gets unchecked * again. The visitor's own selection (the last preset that was running) - * stays put. + * stays put. v7.11: only called when the showcase actually engaged — a + * visitor whose own Random/Sequence mix was already running keeps it. */ function retireDemoShowcase(): void { const s = useAvaStore.getState() @@ -430,27 +473,37 @@ export function useAudioSource() { const index = readDemoNext() try { await engine.audio.startDemo(index) + // v7.11: the welcome showcase (Bass Bloom + Zai-Magic Sequence) only + // engages for a visit that made NO choice of their own — no preset + // loaded (double-click / Load / a copy), no custom script material in + // the editor, no own mix running. Everyone else keeps their selection: + // Demo just plays the track. + const showcase = !visitorHasOwnSelection() // v7.3/v7.10: single pass — when the track runs out (or is stopped, or // replaced by another source) the engine fires onDemoEnded exactly // once, WITH the reason: sync the store to idle (the landing overlay - // returns), uncheck the randomizer the showcase had checked, and — - // when the pass truly ENDED (natural finish or the Stop button) — + // returns), uncheck the showcase mix ONLY when the showcase engaged, + // and — when the pass truly ENDED (natural finish or the Stop button) — // advance the collection place so the next Demo click plays the next // track. A source switch keeps the visitor's place. engine.audio.onDemoEnded = (reason) => { syncSource() - retireDemoShowcase() + if (showcase) retireDemoShowcase() if (reason === 'ended' || reason === 'stop') writeDemoNext(index + 1) } syncSource() - // showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the - // Zai-Magic sequence takes over — every preset in order, 20 s each, - // for this one play-through + // showcase flow (v7.2/v7.4/v7.11): nothing active and nothing of the + // visitor's own → Bass Bloom, then the Zai-Magic sequence takes over — + // every preset in order, 20 s each, for this one play-through. With + // the visitor's own selection, BOTH are skipped (ensureDefaultPreset + // holds its own guard as well). ensureDefaultPreset() - startShowcaseMix() + if (showcase) startShowcaseMix() toast({ title: 'Demo track playing', - description: `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.`, + description: showcase + ? `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · Zai-Magic preset sequence -- every preset in order, 20 s each, one play-through.` + : `${demoTrackTitle(index)} (${index + 1} of ${DEMO_TRACK_COUNT}) · your selection stays active -- the demo never touches your preset or the editor.`, }) } catch (err) { syncSource() diff --git a/src/components/ava/presets-tab.tsx b/src/components/ava/presets-tab.tsx index ed79cd2..20ddfc1 100755 --- a/src/components/ava/presets-tab.tsx +++ b/src/components/ava/presets-tab.tsx @@ -167,6 +167,9 @@ function useLoadPreset() { if (result.ok) { st.setActivePresetId(preset.id) st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) + // v7.11: an explicitly loaded preset (double-click / Load button) is + // the visitor's own choice — the Demo showcase must never override it + st.markManualChoice() // No "Preset loaded" toast (v7.3): the footer's SCRIPT: label // already echoes the running preset — the popup was intrusive and // only compile errors genuinely need the attention grab. @@ -527,6 +530,7 @@ export function PresetsTab() { applyScriptText(STARTER_TEMPLATE) st.setActivePresetId(res.preset.id) st.selectPreset(res.preset.id) + st.markManualChoice() // v7.11 — a created preset is the visitor's own choice st.setTab('script') toast({ title: 'Preset created', @@ -671,6 +675,7 @@ export function PresetsTab() { const st = useAvaStore.getState() st.toggleFolder(USER_ROOT, true) st.selectPreset(res.preset.id) + st.markManualChoice() // v7.11 — a copied preset is the visitor's own choice toast({ title: 'Copied to your presets', description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`, diff --git a/src/components/ava/script-tab.tsx b/src/components/ava/script-tab.tsx index 8a9ca58..7357c69 100755 --- a/src/components/ava/script-tab.tsx +++ b/src/components/ava/script-tab.tsx @@ -1,7 +1,9 @@ // AVA — Script tab: the custom visualization script editor. // -// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save -// target is set), Save As…, Save as Default, Save to File, Load from File, +// Toolbar: Apply, Save (to the preset picked in the Presets tab when a save +// target is set; v7.11 also with NO target — one click stores the current +// script as a new preset under "user created", auto-named from its name: +// header), Save As…, Save as Default, Save to File, Load from File, // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode // string and paste one back), New, Help. Editor: line-number gutter + // monospace textarea (synced scroll, Tab inserts two spaces), error lines @@ -48,7 +50,7 @@ import { sanitizeFileName } from '@/components/ava/shared' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths' -import { createUserPreset, presetIsLocal, updateUserPreset } from '@/lib/ava/user-presets' +import { createUserPreset, listUserPresets, presetIsLocal, renameUserPreset, updateUserPreset } from '@/lib/ava/user-presets' /** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */ function formatSavePath(folderPath: string, name: string): string { @@ -58,6 +60,16 @@ function formatSavePath(folderPath: string, name: string): string { return `${segs.slice(0, 2).join('/')}/…/${name}` } +/** First free name in a folder: "Base", "Base (1)", "Base (2)" … */ +function uniqueName(base: string, taken: Set): string { + if (!taken.has(base)) return base + for (let i = 1; i < 1000; i++) { + const candidate = `${base} (${i})` + if (!taken.has(candidate)) return candidate + } + return `${base} (${Date.now().toString(36).slice(-4)})` +} + export function ScriptTab() { const { toast } = useToast() const scriptText = useAvaStore((s) => s.scriptText) @@ -80,6 +92,17 @@ export function ScriptTab() { // v7.7 — share-string dialogs const [shareOutOpen, setShareOutOpen] = useState(false) const [shareInOpen, setShareInOpen] = useState(false) + // v7.11 — the display name a Hash⇢Script import arrived with (the script's + // name: header, else the share-string label); the one-click Save uses it + // when the script itself carries no name + const [pendingName, setPendingName] = useState(null) + // v7.11 — "Give This Preset a Name" dialog (renames the just-saved + // auto-named preset: nameless scripts / duplicate conflicts) + const [renameOpen, setRenameOpen] = useState(false) + const [renameValue, setRenameValue] = useState('') + const [renameError, setRenameError] = useState(null) + const [renameBusy, setRenameBusy] = useState(false) + const [renameTarget, setRenameTarget] = useState<{ id: string; name: string; folderPath: string } | null>(null) // debounced live validation (compile is cheap, scripts are small) useEffect(() => { @@ -159,6 +182,111 @@ export function ScriptTab() { } } + /** + * v7.11 — one-click save with NO target picked: create a new preset under + * "user created" from the current editor script. Auto-named from the + * script's name: header (e.g. a hash import of "Spectrum Bars:[▼…]" saves + * as "Spectrum Bars"); a nameless script lands as "Empty Name", a + * conflict as "Base (1)", "Base (2)"… — and whenever the name had to be + * invented, the "Give This Preset a Name" dialog opens right after so the + * just-saved preset can be renamed in one extra step. Nothing is ever + * lost to a naming problem: the preset is saved FIRST, renamed second. + */ + const saveAsNewPreset = async () => { + if (saving) return + setSaving(true) + try { + const v = validateScript(scriptText) + if (!v.ok) { + toast({ + title: 'Fix the compile errors first', + description: 'The script must compile before it can be saved as a preset.', + variant: 'destructive', + }) + return + } + // candidate name: the script's own name: header, else the share-string + // label it arrived with (Hash⇢Script import) + let base: string | null = null + const metaName = v.meta?.name?.trim() + if (metaName && metaName !== 'Untitled') base = metaName + if (!base && pendingName) base = pendingName.trim() || null + if (base) { + // preset names must stay path-safe (no slashes / control characters) + base = base.replace(/[/\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120).trim() + if (!base) base = null + } + const taken = new Set( + listUserPresets().filter((p) => p.folderPath === USER_ROOT).map((p) => p.name), + ) + const autoName = uniqueName(base ?? 'Empty Name', taken) + const invented = base === null || autoName !== base + const res = createUserPreset({ + name: autoName, + content: scriptText, + folderPath: USER_ROOT, + author: v.meta?.author || 'you', + }) + if (!res.ok) { + toast({ title: 'Save failed', description: res.error, variant: 'destructive' }) + return + } + applyUserPresets() + setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) + const st = useAvaStore.getState() + st.selectPreset(res.preset.id) + st.toggleFolder(USER_ROOT, true) + toast({ + title: `Saved as "${res.preset.name}"`, + description: invented + ? 'Stored under "user created" in this browser -- give it a better name in the dialog.' + : 'Stored under "user created" in this browser.', + }) + if (invented) { + // the name had to be invented (nameless script / duplicate conflict) + // — the preset is SAFE; offer the rename as one extra step + setRenameTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) + setRenameValue(base ?? '') + setRenameError(null) + setRenameOpen(true) + } + } finally { + setSaving(false) + } + } + + /** v7.11 — confirm the "Give This Preset a Name" dialog: rename the + * just-saved preset to the visitor's name (stays open on conflicts). */ + const applyRename = async () => { + if (!renameTarget || renameBusy) return + const trimmed = renameValue.trim() + if (!trimmed) { + setRenameError('Enter a name first.') + return + } + setRenameBusy(true) + try { + const res = renameUserPreset(renameTarget.id, trimmed) + if (!res.ok) { + setRenameError(res.error) + return + } + applyUserPresets() + const st = useAvaStore.getState() + if (st.editTarget?.id === res.preset.id) { + st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) + } + st.selectPreset(res.preset.id) + setRenameOpen(false) + toast({ + title: 'Preset renamed', + description: `Saved as "${res.preset.name}" in ${res.preset.folderPath} (this browser).`, + }) + } finally { + setRenameBusy(false) + } + } + const openSaveAs = () => { if (!editTarget) return setSaveAsName(editTarget.name) @@ -226,6 +354,9 @@ export function ScriptTab() { const text = await file.text() const result = applyScriptText(text) if (result.ok) { + // v7.11: a file from disk is the visitor's own material — the Demo + // showcase must never clobber it + useAvaStore.getState().markManualChoice() const meta = validateScript(text).meta toast({ title: 'Script loaded', description: `"${meta?.name || file.name}" applied to the engine.` }) } else if (result.error) { @@ -301,7 +432,7 @@ export function ScriptTab() { > Apply - {editTarget && ( + {editTarget ? ( + ) : ( + validation?.ok && ( + + ) )} {editTarget && ( + + + + + {/* v7.7 -- share-string dialogs */} setScriptText(text)} + onImport={(text, name) => { + setScriptText(text) + setPendingName(name) + // the editor now holds FOREIGN content — the previous save target + // no longer matches what is in the editor, so a later Save must not + // overwrite it (v7.11: Save creates a new preset instead) + setEditTarget(null) + // imported material is the visitor's own — the Demo showcase must + // never clobber it (v7.11) + useAvaStore.getState().markManualChoice() + }} /> void - /** Load the decoded script into the editor (script-tab wires this). */ - onImport: (script: string) => void + /** Load the decoded script into the editor (script-tab wires this). + * v7.11: also hands back the script's display name (its name: header, + * else the share-string label) so Save can auto-name the new preset. */ + onImport: (script: string, name: string | null) => void }) { const { toast } = useToast() const [input, setInput] = useState('') @@ -219,12 +221,12 @@ export function HashToScriptDialog({ function load() { if (!decoded) return - onImport(decoded.script) + onImport(decoded.script, decoded.name ?? null) onOpenChange(false) toast({ title: 'Script loaded from share string', description: `"${decoded.name ?? 'Untitled'}" -- ${decoded.lines} lines in the editor.${ - decoded.compiles ? ' Hit Apply to run it.' : ' Fix the errors, then Apply.' + decoded.compiles ? ' Hit Apply to run it, or Save to keep it as a preset.' : ' Fix the errors, then Apply.' }`, }) } diff --git a/src/lib/ava/wake-lock.ts b/src/lib/ava/wake-lock.ts new file mode 100644 index 0000000..7f8d802 --- /dev/null +++ b/src/lib/ava/wake-lock.ts @@ -0,0 +1,128 @@ +// OVE — Screen Wake Lock manager (v7.11). +// +// Mobile browsers dim and eventually turn the screen off after a few seconds +// of inactivity — correct for reading, fatal for a music visualizer propped +// on a table. While audio is actually playing (demo / mic / file / link — +// anything but idle) the app holds a screen wake lock so the visuals stay +// visible; the moment playback stops the lock is released and the phone's +// normal inactivity timeout takes over again. +// +// API notes (developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API): +// • navigator.wakeLock.request('screen') → Promise +// • The browser SILENTLY releases the sentinel when the page becomes +// hidden (app switch / screen lock) — so a 'visibilitychange' listener +// re-acquires it when the visitor comes back and music is still going. +// • request() rejects when unsupported (older iOS Safari, Firefox Android +// pre-126), when the document is hidden, or when a platform setting +// refuses — every rejection is swallowed: the lock is a nice-to-have, +// never a blocker. +// • Wake locks are free — no battery drain beyond the screen itself, and +// the page already renders at full tilt while playing. + +/** Minimal structural type — lib.dom versions vary in Wake Lock coverage. */ +interface WakeLockSentinelLike { + readonly released: boolean + release: () => Promise + addEventListener: (type: 'release', listener: () => void) => void + removeEventListener: (type: 'release', listener: () => void) => void +} + +interface WakeLockLike { + request: (type: 'screen') => Promise +} + +/** The held sentinel (null = not held / browser released it). */ +let sentinel: WakeLockSentinelLike | null = null +/** Whether a lock is currently WANTED (music playing). */ +let wantActive = false +/** Guards against overlapping request() calls. */ +let requesting = false +/** The sentinel's own release listener (kept so it can be detached). */ +let releaseListener: (() => void) | null = null + +function navigatorWakeLock(): WakeLockLike | null { + if (typeof navigator === 'undefined') return null + const nav = navigator as Navigator & { wakeLock?: unknown } + const wl = nav.wakeLock + if (!wl || typeof wl !== 'object') return null + const typed = wl as Partial + return typeof typed.request === 'function' ? (typed as WakeLockLike) : null +} + +/** Drop our reference when the browser auto-releases (page hidden). */ +function onSentinelRelease(): void { + sentinel = null + releaseListener = null +} + +async function acquire(): Promise { + if (sentinel !== null || requesting) return + const wl = navigatorWakeLock() + if (!wl) return + requesting = true + try { + const s = await wl.request('screen') + // state may have flipped to "stopped" while the promise was in flight + if (!wantActive) { + try { + await s.release() + } catch { + /* already gone */ + } + return + } + sentinel = s + releaseListener = onSentinelRelease + s.addEventListener('release', releaseListener) + } catch { + /* unsupported / hidden / refused — degrade silently */ + } finally { + requesting = false + } +} + +function dropSentinel(): void { + const s = sentinel + sentinel = null + if (s) { + if (releaseListener) s.removeEventListener('release', releaseListener) + releaseListener = null + s.release().catch(() => { + /* already released */ + }) + } +} + +/** + * Hold or release the screen wake lock according to playback. Called with + * `true` whenever a source starts, `false` whenever the engine goes idle. + */ +export function syncWakeLock(active: boolean): void { + wantActive = active + if (!active) { + dropSentinel() + return + } + if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + void acquire() + } + // hidden right now (rare — playback needs a gesture, but a queued playlist + // could land here): the visibilitychange listener below acquires on return. +} + +/** Explicit teardown (hook unmount / pagehide). */ +export function releaseWakeLock(): void { + syncWakeLock(false) +} + +/** + * Re-acquire the lock when the page becomes visible again while music is + * still playing (the browser always drops it on hide). Returns a cleanup. + */ +export function watchWakeLockVisibility(): () => void { + const onVisibility = () => { + if (document.visibilityState === 'visible' && wantActive) void acquire() + } + document.addEventListener('visibilitychange', onVisibility) + return () => document.removeEventListener('visibilitychange', onVisibility) +} diff --git a/src/lib/release-notes.ts b/src/lib/release-notes.ts index adaeef0..289fb5d 100755 --- a/src/lib/release-notes.ts +++ b/src/lib/release-notes.ts @@ -39,6 +39,24 @@ export interface ReleaseNote { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '7.11', + name: 'Wake lock, a respectful Demo, one-click save', + changes: [ + { + kind: 'added', + text: 'The screen stays on while music plays: on phones and tablets the site now holds a screen wake lock for as long as any audio source is live, then lets go the moment playback stops so the device\'s normal screen timeout takes over again.', + }, + { + kind: 'improved', + text: 'The Demo Track button stopped hijacking the preset selection. When a preset is already loaded (tree double-click, Load, a copied preset) or the editor holds custom script material, Demo just plays the track and leaves everything untouched -- the Zai-Magic Sequence showcase now only starts for a visit that never picked or typed anything, so a custom script can be built and debugged against the demo track without fear of it being replaced.', + }, + { + kind: 'improved', + text: 'One-click saving in the Script tab: a valid script shows the Save button even with no save target picked, and one click stores it under "user created", auto-named from the script\'s name header (a hash import of "Spectrum Bars:[▼…]" saves as "Spectrum Bars"). Nameless scripts land as "Empty Name", collisions as "(1)", "(2)"… -- and in those cases a "Give This Preset a Name" dialog opens immediately so the just-saved preset can be renamed on the spot.', + }, + ], + }, { version: '7.10.1', name: 'Spectrum edge unfrozen', diff --git a/src/lib/store.ts b/src/lib/store.ts index 0a2a7a7..3c9634d 100755 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -187,6 +187,14 @@ interface AvaState { editTarget: EditTarget | null /** Preset currently running on the engine (tree indicator). */ activePresetId: string | null + /** v7.11 — true once THIS visit made an explicit choice: loaded a preset + * (tree Load / double-click / copy-to-my-presets / created one), brought + * the visitor's own script material into the editor (typed / pasted / + * hash-imported / file-loaded), saved a default startup script, or runs + * their own mix mode. While false the Demo button may still run the + * welcome showcase; once true, Demo never touches the selection. + * Deliberately NOT persisted — every visit starts fresh. */ + manualChoice: boolean /** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence * (in-order cycling with wrap-around). The two are mutually exclusive — * ticking one unticks the other; both share one delay + folder scope and @@ -219,6 +227,8 @@ interface AvaState { selectPreset: (id: string | null) => void setEditTarget: (t: EditTarget | null) => void setActivePresetId: (id: string | null) => void + /** v7.11 — flag this visitor as having made their own choice (see field). */ + markManualChoice: () => void setRandomize: (on: boolean) => void /** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */ setSequenceMode: (on: boolean) => void @@ -371,6 +381,7 @@ export const useAvaStore = create()((set, get) => ({ expandedFolders: {}, editTarget: null, activePresetId: null, + manualChoice: false, randomize: false, sequenceMode: false, randomIntervalSec: 10, @@ -481,6 +492,8 @@ export const useAvaStore = create()((set, get) => ({ setActivePresetId: (id) => set({ activePresetId: id }), + markManualChoice: () => set({ manualChoice: true }), + setRandomize: (on) => { // v7.4 mutual exclusion: ticking Random unticks Sequence (turning one // OFF never lights the other — the visitor's explicit choice) diff --git a/src/lib/version.ts b/src/lib/version.ts index 37f979d..dd60e90 100755 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -12,6 +12,35 @@ // warns in the console when the two drift apart. // // History: +// 7.11 — wake lock, a respectful Demo button, one-click save. While any +// audio source is live the site holds a Screen Wake Lock so phone +// and tablet screens stay on (released the moment the engine goes +// idle so the normal inactivity dimming returns; browsers silently +// drop the sentinel on hide, so a visibilitychange watcher +// re-acquires it on return while music is still going; +// unsupported browsers degrade silently). The Demo Track button no +// longer force-engages the Sequence checkbox: a visitor who loaded +// a preset (tree double-click / Load / Copy to my presets / created +// one), brought their own script material into the editor (typed, +// pasted, Hash⇢Script, file), saved their own default startup +// script, or runs their own Random/Sequence mix keeps their +// selection -- Demo just plays the track (the showcase's tree +// re-scope, 20 s interval and retire-on-end all skip too; a new +// per-visit manualChoice flag + a boot-script snapshot decide, and +// ensureDefaultPreset gained the same guard so mic/file/link never +// clobber a custom draft either; the showcase now only starts for +// a visit that never picked or typed anything). Script tab: a valid +// script shows the Save button with NO save target needed -- one +// click creates a preset under "user created", auto-named from the +// script's name: header (a hash import of "Spectrum Bars:[▼…]" +// saves as "Spectrum Bars"), "Empty Name" when nameless, " (1)"/ +// " (2)"… suffixes on conflicts, and whenever the name had to be +// invented a "Give This Preset a Name" dialog opens to rename the +// just-saved preset in one step (the preset is always saved FIRST, +// renamed second, so nothing is ever lost to a naming problem). +// Hash⇢Script imports clear the stale save target (the editor now +// holds foreign content) and pass the decoded label through as the +// save-name fallback when the script has no name: header // 7.10.1 — the frozen right edge of spectrum visuals. getspec()/sv mapped // the bar field across the whole FFT (bin 2 ... Nyquist, ~12 Hz to // 24 kHz), but music carries almost nothing above ~16 kHz and every @@ -311,4 +340,4 @@ export const APP_NAME = 'Open Visualization Engine' export const APP_SHORT = 'OVE' -export const APP_VERSION = '7.10.1' +export const APP_VERSION = '7.11' diff --git a/tool-results/read_1793817131261_fb699e6a294f.txt b/tool-results/read_1793817131261_fb699e6a294f.txt new file mode 100644 index 0000000..6f34d74 --- /dev/null +++ b/tool-results/read_1793817131261_fb699e6a294f.txt @@ -0,0 +1,1254 @@ + 1→// OVE — Script ⇢ Hash share codec (v7.8 — scheme ▼, "big-base packing"). + 2→// + 3→// Turns a script into ONE short shareable Unicode string and back, exactly: + 4→// + 5→// Bass Bloom:[▼䷀…一…CJK/Hangul digits…㘎〠⼁⼃㘚⑔⑒⑑⑔⑕⑓⑑⑓⑖] + 6→// └─ label │└──────────────┘└─┬┘└ check digits + 7→// scheme ▼ 〠 = header separator + 8→// + 9→// WHY IT'S SHORT — the compression ladder (each layer feeds the next): + 10→// + 11→// 1. DICTIONARY — ~300 built-in words → 1 symbol each: every function + 12→// (sin(, cos(, if(, … WITH their open paren), every audio variable + 13→// (bass, mid, treb, vol_att, beatage, …), every header key and value, + 14→// statement openers ("x = " ⇦ "green = " ⇩), the AVS idioms + 15→// ("hue = hue + dt * ", "t = t + dt * ", " * 0.", ") * ", "\n\n[point]\n"), + 16→// ~70 number literals (6.28318, 3.14159, 0.55, 0.25, …) and the + 17→// user-named core: ♤ line return, ♡ beats, ♠ beatage, ♦ space, + 18→// ♩♪♫♬ the four sections. + 19→// + 20→// 2. BIG-BASE PACKING — the clever part. The dictionary output (a string + 21→// over ~40–120 distinct symbols) is read as ONE BIG NUMBER in base + 22→// |alphabet| and rewritten in base 32,164 — one digit per character, + 23→// the digits drawn from the CJK Unified (20,992) + Hangul (11,172) + 24→// blocks. That carries ~15 bits per character where base-62 (the + 25→// classic "1400 → Ma" trick) carries 5.95 — so "1400" costs ~0.85 of + 26→// a character, and a whole preset rides at roughly a THIRD of its + 27→// source length. The decoder gets the alphabet back from a bitmap + 28→// header (the dictionary is built-in, so "which symbols are used" is + 29→// 22 bitmap digits, not 80 literal ones). + 30→// + 31→// 3. PAIR PACKING (fallback) — scripts too short for the header to pay + 32→// off keep the v7.7 body instead: dictionary + two leftover + 33→// characters folded into one CJK code point (the classic layer — + 34→// it reads nicer for small scripts, and the encoder simply picks + 35→// whichever body is shorter). + 36→// + 37→// Guarantees (unchanged from v7.7): + 38→// • LOSSLESS — decode(encode(s)) === s for ANY text (comments, weird + 39→// whitespace, even scripts that don't compile). Anything the dictionary + 40→// doesn't cover passes through untouched, and every reserved symbol + 41→// that legitimately appears in a script is escaped ("\♠" → literal ♠). + 42→// • COPY-SAFE — no raw whitespace or control characters anywhere in the + 43→// string (folded to symbols / control pictures), so chat apps and + 44→// social sites can't collapse it. Emoji-variation selectors and + 45→// zero-width chars that platforms inject while copying are stripped on + 46→// decode (and escaped on encode, so a script that really contains them + 47→// still round-trips). + 48→// • SELF-CHECKING — the last 8 chars are check digits (①–⑯ as hex) over + 49→// an FNV-1a digest of the script, so a string that was cut off by a + 50→// character limit or altered in transit is rejected honestly instead + 51→// of decoding into garbage. + 52→// + 53→// The "Name:" prefix is cosmetic (the script's own `name:` header rides + 54→// inside the body untouched) — it just makes the string self-describing in + 55→// a social feed. + 56→// + 57→// ▲ strings from v7.7 still decode (their tables are kept below, decode + 58→// only). ▼ is everything since. + 59→ + 60→// ── scheme markers ───────────────────────────────────────────────────────── + 61→ + 62→/** v7.7 strings (dictionary + pair packing). Decode-only. */ + 63→const SCHEME_V1 = '▲' + 64→/** v7.8+ strings (dictionary + big-base or pair, whichever is shorter). */ + 65→const SCHEME_V2 = '▼' + 66→ + 67→/** Escape char — prefixes any reserved symbol that appears literally. */ + 68→const ESCAPE = '\\' + 69→ + 70→/** + 71→ * Header separator (U+3020 POSTAL MARK FACE). Splits the payload digits + 72→ * from the bitmap header. Reserved, so it only ever appears in a body as + 73→ * an escaped pair — the decoder can treat the FIRST bare 〠 as structural. + 74→ */ + 75→const SEP = '〠' + 76→ + 77→interface DictEntry { + 78→ key: string + 79→ symbol: string + 80→} + 81→ + 82→// ── the dictionary ───────────────────────────────────────────────────────── + 83→ + 84→/** + 85→ * The substitution dictionary. Longest key wins at every position, so + 86→ * "smoothstep(" resolves before "smoothstep", "beatage" before "beat" and + 87→ * "hue = hue + dt * " before "hue = ". Symbols are single BMP code points, + 88→ * none of them ASCII. + 89→ * + 90→ * COMPATIBILITY CONTRACT: the symbols of this table (plus the noise + 91→ * symbols below) form the frozen BITMAP_LIST that ▼ headers are built + 92→ * from. Adding entries to DICT changes BITMAP_LIST and silently breaks + 93→ * every ▼ string encoded by an older build — after v7.8 ships, new + 94→ * dictionary entries require a new scheme marker (▽), not an edit here. + 95→ */ + 96→const DICT: DictEntry[] = [ + 97→ // ── user-named core references ───────────────────────────────────────── + 98→ { key: '\n', symbol: '♤' }, // line return + 99→ { key: ' ', symbol: '♦' }, // space + 100→ { key: 'beats', symbol: '♡' }, + 101→ { key: 'beatage', symbol: '♠' }, + 102→ { key: '\t', symbol: '♣' }, // tab + 103→ // ── line ends (auto-ported presets are full of these) ────────────────── + 104→ { key: '\r\n', symbol: '⼀' }, + 105→ { key: ';\r\n', symbol: '⼁' }, + 106→ { key: ';\n', symbol: '⼂' }, + 107→ { key: '; ', symbol: '⼃' }, + 108→ // ── whitespace runs ──────────────────────────────────────────────────── + 109→ { key: ' ', symbol: '▣' }, // 4-space indent + 110→ { key: ' ', symbol: '·' }, // 2-space indent + 111→ { key: '\r', symbol: '⏎' }, // lone carriage return + 112→ // ── section headers (music notes) ────────────────────────────────────── + 113→ { key: '[init]', symbol: '♩' }, + 114→ { key: '[frame]', symbol: '♪' }, + 115→ { key: '[beat]', symbol: '♫' }, + 116→ { key: '[point]', symbol: '♬' }, + 117→ // ── blank line + section (the generated-preset skeleton) ─────────────── + 118→ { key: '\n\n', symbol: '⽞' }, + 119→ { key: '\n\n[init]\n', symbol: '⽟' }, + 120→ { key: '\n\n[frame]\n', symbol: '⽠' }, + 121→ { key: '\n\n[beat]\n', symbol: '⽡' }, + 122→ { key: '\n\n[point]\n', symbol: '⽢' }, + 123→ // ── comments ─────────────────────────────────────────────────────────── + 124→ { key: '# ', symbol: '♯' }, + 125→ { key: '#', symbol: '♭' }, + 126→ { key: '//', symbol: '♮' }, + 127→ // ── header keys ──────────────────────────────────────────────────────── + 128→ { key: 'description', symbol: '♖' }, + 129→ { key: 'linewidth: ', symbol: '⼅' }, + 130→ { key: 'blend: additive', symbol: '⼆' }, + 131→ { key: 'mode: lines', symbol: '⼇' }, + 132→ { key: 'mode: dots', symbol: '⼈' }, + 133→ { key: 'linewidth', symbol: '♚' }, + 134→ { key: 'persist', symbol: '♙' }, + 135→ { key: 'author', symbol: '♕' }, + 136→ { key: 'ysign', symbol: '♛' }, + 137→ { key: 'blend', symbol: '♘' }, + 138→ { key: 'name', symbol: '♔' }, + 139→ { key: 'mode', symbol: '♗' }, + 140→ { key: ': ', symbol: '⼄' }, // every other header's colon + 141→ // ── header values (zodiac) ───────────────────────────────────────────── + 142→ { key: 'additive', symbol: '♋' }, + 143→ { key: 'lines', symbol: '♈' }, + 144→ { key: 'dots', symbol: '♉' }, + 145→ { key: 'solid', symbol: '♊' }, + 146→ { key: 'alpha', symbol: '♌' }, + 147→ // ── built-in variables (the engine's full injected set) ──────────────── + 148→ { key: 'bass_att', symbol: '♎' }, + 149→ { key: 'mid_att', symbol: '♏' }, + 150→ { key: 'treb_att', symbol: '♐' }, + 151→ { key: 'vol_att', symbol: '⼉' }, + 152→ { key: 'beatphase', symbol: '⼊' }, + 153→ { key: 'beatconf', symbol: '⼋' }, + 154→ { key: 'bass', symbol: '♑' }, + 155→ { key: 'treb', symbol: '♓' }, + 156→ { key: 'frame', symbol: '⼌' }, + 157→ { key: 'time', symbol: '⼍' }, + 158→ { key: 'mid', symbol: '♒' }, + 159→ { key: 'vol', symbol: '☽' }, + 160→ { key: 'beat', symbol: '◉' }, + 161→ { key: 'red', symbol: '☾' }, + 162→ { key: 'blue', symbol: '⚑' }, + 163→ { key: 'skip', symbol: '⚐' }, + 164→ { key: 'green', symbol: '☿' }, + 165→ { key: 'aspect', symbol: '⚶' }, + 166→ { key: 'chroma', symbol: '⼏' }, + 167→ { key: 'dt', symbol: '◐' }, + 168→ { key: 'pi', symbol: '◑' }, + 169→ { key: 'tau', symbol: '◒' }, + 170→ { key: 'bpm', symbol: '⼎' }, + 171→ { key: 'hue', symbol: '⋊' }, + 172→ { key: 'rad', symbol: '⋅' }, + 173→ { key: 'flash', symbol: '∘' }, + 174→ { key: 'sv', symbol: '⼐' }, // per-point spectrum level + 175→ // ── functions WITH their open paren (a call costs one fewer symbol) ──── + 176→ { key: 'smoothstep(', symbol: '⼑' }, + 177→ { key: 'gmegabuf(', symbol: '⼒' }, + 178→ { key: 'getkbmouse(', symbol: '⼓' }, + 179→ { key: 'megabuf(', symbol: '⼔' }, + 180→ { key: 'getspec(', symbol: '⼕' }, + 181→ { key: 'getosc(', symbol: '⼖' }, + 182→ { key: 'gettime(', symbol: '⼗' }, + 183→ { key: 'invsqrt(', symbol: '⼘' }, + 184→ { key: 'sigmoid(', symbol: '⼙' }, + 185→ { key: 'atan2(', symbol: '⼚' }, + 186→ { key: 'inrange(', symbol: '⼛' }, + 187→ { key: 'log10(', symbol: '⼜' }, + 188→ { key: 'floor(', symbol: '⼝' }, + 189→ { key: 'above(', symbol: '⼞' }, + 190→ { key: 'below(', symbol: '⼟' }, + 191→ { key: 'equal(', symbol: '⼠' }, + 192→ { key: 'round(', symbol: '⼡' }, + 193→ { key: 'clamp(', symbol: '⼢' }, + 194→ { key: 'lerp(', symbol: '⼣' }, + 195→ { key: 'asin(', symbol: '⼤' }, + 196→ { key: 'acos(', symbol: '⼥' }, + 197→ { key: 'atan(', symbol: '⼦' }, + 198→ { key: 'sign(', symbol: '⼧' }, + 199→ { key: 'band(', symbol: '⼨' }, + 200→ { key: 'bnot(', symbol: '⼩' }, + 201→ { key: 'hsvr(', symbol: '⼪' }, + 202→ { key: 'hsvg(', symbol: '⼫' }, + 203→ { key: 'hsvb(', symbol: '⼬' }, + 204→ { key: 'ceil(', symbol: '⼭' }, + 205→ { key: 'frac(', symbol: '⼮' }, + 206→ { key: 'pow(', symbol: '⼯' }, + 207→ { key: 'exp(', symbol: '⼰' }, + 208→ { key: 'log(', symbol: '⼱' }, + 209→ { key: 'int(', symbol: '⼲' }, + 210→ { key: 'min(', symbol: '⼳' }, + 211→ { key: 'max(', symbol: '⼴' }, + 212→ { key: 'abs(', symbol: '⼵' }, + 213→ { key: 'sin(', symbol: '⼶' }, + 214→ { key: 'cos(', symbol: '⼷' }, + 215→ { key: 'tan(', symbol: '⼸' }, + 216→ { key: 'if(', symbol: '⼹' }, + 217→ { key: 'rnd(', symbol: '⼺' }, + 218→ { key: 'rand(', symbol: '⼻' }, + 219→ { key: 'loop(', symbol: '⼼' }, + 220→ // ── functions without paren (for the times they're not calls) ────────── + 221→ { key: 'smoothstep', symbol: '⌒' }, + 222→ { key: 'gmegabuf', symbol: '✥' }, + 223→ { key: 'getkbmouse', symbol: '◶' }, + 224→ { key: 'atan2', symbol: '⌗' }, + 225→ { key: 'megabuf', symbol: '✤' }, + 226→ { key: 'getspec', symbol: '▤' }, + 227→ { key: 'getosc', symbol: '▥' }, + 228→ { key: 'gettime', symbol: '◷' }, + 229→ { key: 'log10', symbol: '㏑' }, + 230→ { key: 'invsqrt', symbol: '√' }, + 231→ { key: 'inrange', symbol: '⋔' }, + 232→ { key: 'sigmoid', symbol: '∿' }, + 233→ { key: 'floor', symbol: '⌊' }, + 234→ { key: 'above', symbol: '≻' }, + 235→ { key: 'below', symbol: '≺' }, + 236→ { key: 'asin', symbol: '⌠' }, + 237→ { key: 'acos', symbol: '⌡' }, + 238→ { key: 'atan', symbol: '⌐' }, + 239→ { key: 'ceil', symbol: '⌈' }, + 240→ { key: 'sqr', symbol: '□' }, + 241→ { key: 'sqrt', symbol: '∛' }, + 242→ { key: 'pow', symbol: '⌃' }, + 243→ { key: 'exp', symbol: 'ℯ' }, + 244→ { key: 'log', symbol: '㏒' }, + 245→ { key: 'round', symbol: '◎' }, + 246→ { key: 'int', symbol: '⊺' }, + 247→ { key: 'frac', symbol: '∵' }, + 248→ { key: 'sign', symbol: '±' }, + 249→ { key: 'bnot', symbol: '¬' }, + 250→ { key: 'band', symbol: '∧' }, + 251→ { key: 'bor', symbol: '∨' }, + 252→ { key: 'equal', symbol: '≟' }, + 253→ { key: 'lerp', symbol: '↝' }, + 254→ { key: 'clamp', symbol: '⊜' }, + 255→ { key: 'rand', symbol: '⚄' }, + 256→ { key: 'rnd', symbol: '⚅' }, + 257→ { key: 'sin', symbol: '◠' }, + 258→ { key: 'cos', symbol: '◡' }, + 259→ { key: 'tan', symbol: '◮' }, + 260→ { key: 'abs', symbol: '¦' }, + 261→ { key: 'min', symbol: '≪' }, + 262→ { key: 'max', symbol: '≫' }, + 263→ { key: 'if', symbol: '∴' }, + 264→ { key: 'loop', symbol: '↻' }, + 265→ // ── near-universal user variables (mined from the preset library) ────── + 266→ { key: 'rings', symbol: '⼽' }, + 267→ { key: 'spokes', symbol: '⼾' }, + 268→ { key: 'travel', symbol: '⼿' }, + 269→ { key: 'bars', symbol: '⽀' }, + 270→ { key: 'segs', symbol: '⽁' }, + 271→ { key: 'ring', symbol: '⽂' }, + 272→ { key: 'spoke', symbol: '⽃' }, + 273→ { key: 'fade', symbol: '⽄' }, + 274→ { key: 'size', symbol: '⽅' }, + 275→ { key: 'rot', symbol: '⽆' }, + 276→ { key: 'seg', symbol: '⽇' }, + 277→ { key: 'col', symbol: '⽈' }, + 278→ { key: 'lum', symbol: '⽉' }, + 279→ { key: 'amp', symbol: '⽊' }, + 280→ { key: 'spin', symbol: '⽋' }, + 281→ { key: 'drift', symbol: '⽌' }, + 282→ { key: 'orbit', symbol: '⽍' }, + 283→ { key: 'pulse', symbol: '⽎' }, + 284→ { key: 'glow', symbol: '⽏' }, + 285→ { key: 'phase', symbol: '⽐' }, + 286→ { key: 'wobble', symbol: '⽑' }, + 287→ { key: 'angle', symbol: '⽒' }, + 288→ // ── structural idioms (mined — the top repeated shapes) ──────────────── + 289→ { key: ' * 0.', symbol: '⽓' }, + 290→ { key: ' * (0.', symbol: '⽔' }, + 291→ { key: '(0.', symbol: '⽕' }, + 292→ { key: ') * ', symbol: '⽖' }, + 293→ { key: 't * ', symbol: '⽗' }, + 294→ { key: '5 + ', symbol: '⽘' }, + 295→ { key: ' + dt * ', symbol: '⽙' }, + 296→ { key: '(a)', symbol: '⽚' }, + 297→ { key: 'hue = hue + dt * ', symbol: '⽛' }, + 298→ { key: 't = t + dt * ', symbol: '⽜' }, + 299→ { key: ' / aspect', symbol: '⽝' }, + 300→ // ── number literals (mined from the library) ─────────────────────────── + 301→ { key: '6.28318', symbol: '⌇' }, // 2π, written out in most scripts + 302→ { key: '3.14159', symbol: '⽣' }, + 303→ { key: '3.1415', symbol: '⽤' }, + 304→ { key: '0.55', symbol: '§' }, + 305→ { key: '0.45', symbol: '¶' }, + 306→ { key: '0.35', symbol: '※' }, + 307→ { key: '0.25', symbol: '•' }, + 308→ { key: '0.85', symbol: '⊢' }, + 309→ { key: '0.75', symbol: '⽥' }, + 310→ { key: '0.65', symbol: '⽦' }, + 311→ { key: '0.95', symbol: '⽧' }, + 312→ { key: '0.15', symbol: '⽨' }, + 313→ { key: '0.12', symbol: '⽩' }, + 314→ { key: '0.22', symbol: '⽪' }, + 315→ { key: '0.42', symbol: '⽫' }, + 316→ { key: '0.05', symbol: '⽬' }, + 317→ { key: '0.06', symbol: '⽭' }, + 318→ { key: '0.04', symbol: '⽮' }, + 319→ { key: '0.07', symbol: '⽯' }, + 320→ { key: '0.08', symbol: '⽰' }, + 321→ { key: '0.96', symbol: '⽱' }, + 322→ { key: '0.97', symbol: '⽲' }, + 323→ { key: '2.09', symbol: '⽳' }, + 324→ { key: '4.19', symbol: '⽴' }, + 325→ { key: '0.5', symbol: '†' }, + 326→ { key: '0.3', symbol: '‡' }, + 327→ { key: '0.4', symbol: '⊤' }, + 328→ { key: '0.7', symbol: '⊥' }, + 329→ { key: '0.6', symbol: '⊣' }, + 330→ { key: '0.9', symbol: '⽵' }, + 331→ { key: '0.8', symbol: '⽶' }, + 332→ { key: '0.2', symbol: '⽷' }, + 333→ { key: '0.1', symbol: '⽸' }, + 334→ { key: '1.3', symbol: '⋈' }, + 335→ { key: '1.2', symbol: '⋉' }, + 336→ { key: '1.0', symbol: '⽹' }, + 337→ { key: '100', symbol: '⽺' }, + 338→ { key: '2.5', symbol: '⽻' }, + 339→ { key: '0.11', symbol: '⽼' }, + 340→ { key: '0.18', symbol: '⽽' }, + 341→ { key: '0.92', symbol: '⽾' }, + 342→ { key: '0.99', symbol: '⽿' }, + 343→ { key: '1.5', symbol: '⾀' }, + 344→ { key: '0.98', symbol: '⾁' }, + 345→ { key: '0.13', symbol: '⾂' }, + 346→ { key: '0.14', symbol: '⾃' }, + 347→ { key: '0.16', symbol: '⾄' }, + 348→ { key: '0.17', symbol: '⾅' }, + 349→ { key: '0.19', symbol: '⾆' }, + 350→ { key: '0.21', symbol: '⾇' }, + 351→ { key: '0.28', symbol: '⾈' }, + 352→ { key: '0.31', symbol: '⾉' }, + 353→ { key: '0.32', symbol: '⾊' }, + 354→ { key: '0.33', symbol: '⾋' }, + 355→ { key: '0.38', symbol: '⾌' }, + 356→ { key: '0.44', symbol: '⾍' }, + 357→ { key: '0.48', symbol: '⾎' }, + 358→ { key: '0.52', symbol: '⾏' }, + 359→ { key: '0.58', symbol: '⾐' }, + 360→ { key: '0.62', symbol: '⾑' }, + 361→ { key: '0.72', symbol: '⾒' }, + 362→ { key: '0.78', symbol: '⾓' }, + 363→ { key: '0.82', symbol: '⾔' }, + 364→ { key: '0.88', symbol: '⾕' }, + 365→ { key: '1.1', symbol: '⾖' }, + 366→ { key: '1.4', symbol: '⾗' }, + 367→ { key: '1.6', symbol: '⾘' }, + 368→ { key: '1.7', symbol: '⾙' }, + 369→ { key: '1.8', symbol: '⾚' }, + 370→ { key: '2.0', symbol: '⾛' }, + 371→ { key: '2.2', symbol: '⾜' }, + 372→ { key: '2.4', symbol: '⾝' }, + 373→ { key: '2.6', symbol: '⾞' }, + 374→ { key: '3.0', symbol: '⾟' }, + 375→ { key: '3.5', symbol: '⾠' }, + 376→ { key: '0.87', symbol: '⾡' }, + 377→ { key: '0.24', symbol: '⾢' }, + 378→ { key: '0.26', symbol: '⾣' }, + 379→ { key: '0.27', symbol: '⾤' }, + 380→ { key: '0.36', symbol: '⾥' }, + 381→ // ── spaced operators (arrows) ────────────────────────────────────────── + 382→ { key: ' = ', symbol: '←' }, + 383→ { key: ' + ', symbol: '→' }, + 384→ { key: ' * ', symbol: '↑' }, + 385→ { key: ' - ', symbol: '↓' }, + 386→ { key: ' / ', symbol: '∙' }, + 387→ { key: ', ', symbol: '⇄' }, + 388→ // ── statement openers (double arrows) ────────────────────────────────── + 389→ { key: 'green = ', symbol: '⇩' }, + 390→ { key: 'blue = ', symbol: '⇪' }, + 391→ { key: 'x = ', symbol: '⇦' }, + 392→ { key: 'y = ', symbol: '⇨' }, + 393→ { key: 'red = ', symbol: '⇧' }, + 394→ { key: 'hue = ', symbol: '⇱' }, + 395→ { key: 't = ', symbol: '⇲' }, + 396→ { key: 'n = ', symbol: '⇴' }, + 397→] + 398→ + 399→/** Noise chars platforms inject into copies (VS16, zero-widths). The + 400→ * encoder NEVER emits these raw — a script that legitimately contains one + 401→ * gets a dedicated symbol — so the decoder can strip every raw occurrence + 402→ * as injected mangling. */ + 403→const NOISE_SYMBOLS = new Map([ + 404→ ['\uFE0E', '⌸'], + 405→ ['\uFE0F', '⌹'], + 406→ ['\u200B', '⌺'], + 407→ ['\u200C', '⌻'], + 408→ ['\u200D', '⌼'], + 409→ ['\u2060', '⌽'], + 410→ ['\uFEFF', '⌾'], + 411→]) + 412→const NOISE_CHAR_FROM_SYMBOL = new Map( + 413→ [...NOISE_SYMBOLS.entries()].map(([c, s]) => [s, c]), + 414→) + 415→ + 416→// ── derived tables (built once, self-checked by construction) ───────────── + 417→ + 418→/** Longest key first, so greedy matching always prefers the longest hit. */ + 419→const DICT_SORTED = [...DICT].sort((a, b) => b.key.length - a.key.length) + 420→ + 421→/** Entries grouped by first char, longest first — the encode lookup. */ + 422→const BY_FIRST_CHAR = new Map() + 423→for (const entry of DICT_SORTED) { + 424→ const list = BY_FIRST_CHAR.get(entry.key[0]) + 425→ if (list) list.push(entry) + 426→ else BY_FIRST_CHAR.set(entry.key[0], [entry]) + 427→} + 428→ + 429→/** Symbol → raw text (decode lookup). Throws on duplicate symbols — a + 430→ * broken table must fail loudly at import, never corrupt a share string. */ + 431→const SYMBOL_TO_TEXT = new Map() + 432→for (const { key, symbol } of DICT) { + 433→ if (SYMBOL_TO_TEXT.has(symbol)) throw new Error(`share-codec: duplicate dictionary symbol ${symbol}`) + 434→ SYMBOL_TO_TEXT.set(symbol, key) + 435→} + 436→ + 437→const ALL_SYMBOLS = new Set(DICT.map((e) => e.symbol)) + 438→for (const s of NOISE_SYMBOLS.values()) ALL_SYMBOLS.add(s) + 439→ + 440→/** + 441→ * The FROZEN bitmap list — every dictionary + noise symbol, sorted by code + 442→ * point. A ▼ header says "these of the list are in this string's alphabet" + 443→ * as a bitmap, 14 bits per digit character. This array is part of the ▼ + 444→ * wire format: changing it breaks every ▼ string ever encoded (see the + 445→ * DICT compatibility contract above). + 446→ */ + 447→const BITMAP_LIST: string[] = [...ALL_SYMBOLS].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) + 448→const BITMAP_INDEX = new Map(BITMAP_LIST.map((s, i) => [s, i])) + 449→const DICT_BITMAP_DIGITS = Math.ceil(BITMAP_LIST.length / 14) // 14 bits per digit + 450→const ASCII_BITMAP_DIGITS = Math.ceil(128 / 14) + 451→ + 452→/** + 453→ * Every character that can never appear raw in a body — emitted by the + 454→ * encoder only with a "\" escape in front (pair form) or folded into the + 455→ * bitmaps (base form). Covers the dictionary symbols, both scheme markers, + 456→ * the separator, the checksum alphabet (①–⑯), the control-picture range + 457→ * (␀–␡) and the invisible characters chat platforms inject. + 458→ */ + 459→const RESERVED = new Set([ESCAPE, SCHEME_V1, SCHEME_V2, SEP]) + 460→for (const s of ALL_SYMBOLS) RESERVED.add(s) + 461→for (let c = 0x2460; c <= 0x246f; c++) RESERVED.add(String.fromCharCode(c)) // ①–⑯ + 462→for (let c = 0x2400; c <= 0x2421; c++) RESERVED.add(String.fromCharCode(c)) // ␀–␡ + 463→for (const c of ['\uFE0E', '\uFE0F', '\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF']) { + 464→ RESERVED.add(c) + 465→} + 466→ + 467→const PLATFORM_NOISE = /[\uFE0E\uFE0F\u200B\u200C\u200D\u2060\uFEFF]/g + 468→ + 469→/** Whitespace or platform noise — safe to shave off the string's edges. */ + 470→function isEdgeTrimable(c: string): boolean { + 471→ return c === ' ' || c === '\n' || c === '\r' || c === '\t' || PLATFORM_NOISE.test(c) + 472→} + 473→ + 474→// ── pair packing (shared by the ▼ fallback form and legacy ▲ strings) ────── + 475→ + 476→/** + 477→ * Two adjacent characters from this 64-char alphabet fold into ONE code + 478→ * point at PAIR_BASE + 64·a + b (a common-CJK block — assigned since + 479→ * Unicode 1.1, text presentation, renders everywhere, copy/paste-safe). + 480→ * The block is reserved, so CJK that legitimately appears in a script is + 481→ * escaped and still round-trips. + 482→ */ + 483→const PAIR_ALPHABET = + 484→ '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.' + 485→const PAIR_BASE = 0x4e00 + 486→const PAIR_END = PAIR_BASE + PAIR_ALPHABET.length * PAIR_ALPHABET.length - 1 // 0x5dff + 487→const PAIR_INDEX = new Map() + 488→for (let i = 0; i < PAIR_ALPHABET.length; i++) PAIR_INDEX.set(PAIR_ALPHABET[i], i) + 489→for (let c = PAIR_BASE; c <= PAIR_END; c++) RESERVED.add(String.fromCharCode(c)) + 490→ + 491→// ── big-base packing ─────────────────────────────────────────────────────── + 492→ + 493→/** + 494→ * The digit alphabet for the base form: CJK Unified Ideographs (20,992) + + 495→ * Hangul syllables (11,172) = 32,164 digits — every one assigned, text + 496→ * presentation, in every CJK-capable font, and copy/paste-safe. 14.98 + 497→ * bits per character, vs 5.95 for classic base-62. + 498→ */ + 499→const DIGIT_BLOCKS: Array<[number, number]> = [ + 500→ [0x4e00, 0x9fff], // CJK Unified Ideographs + 501→ [0xac00, 0xd7a3], // Hangul syllables + 502→] + 503→const DIGIT_BLOCK0_SIZE = DIGIT_BLOCKS[0][1] - DIGIT_BLOCKS[0][0] + 1 + 504→const BIG_BASE = DIGIT_BLOCK0_SIZE + (DIGIT_BLOCKS[1][1] - DIGIT_BLOCKS[1][0] + 1) // 32164 + 505→const BIG_BASE_N = BigInt(BIG_BASE) + 506→const BITS_PER_DIGIT = 14 // floor(log2(32164)) -- bitmap digits carry 14 bits + 507→ + 508→function digitChar(d: number): string { + 509→ return d < DIGIT_BLOCK0_SIZE + 510→ ? String.fromCharCode(DIGIT_BLOCKS[0][0] + d) + 511→ : String.fromCharCode(DIGIT_BLOCKS[1][0] + (d - DIGIT_BLOCK0_SIZE)) + 512→} + 513→ + 514→/** Digit char → value, or -1 when the char isn't a digit. */ + 515→function digitValue(c: string): number { + 516→ const cc = c.charCodeAt(0) + 517→ if (cc >= DIGIT_BLOCKS[0][0] && cc <= DIGIT_BLOCKS[0][1]) return cc - DIGIT_BLOCKS[0][0] + 518→ if (cc >= DIGIT_BLOCKS[1][0] && cc <= DIGIT_BLOCKS[1][1]) return cc - DIGIT_BLOCKS[1][0] + DIGIT_BLOCK0_SIZE + 519→ return -1 + 520→} + 521→ + 522→/** Bits → exactly `digits` digit chars (left-padded with digit 0). */ + 523→function bitsToDigits(bits: boolean[], digits: number): string { + 524→ let value = 0n + 525→ for (const b of bits) value = value * 2n + (b ? 1n : 0n) + 526→ const ds: number[] = [] + 527→ let v = value + 528→ while (v > 0n) { + 529→ ds.push(Number(v % BIG_BASE_N)) + 530→ v /= BIG_BASE_N + 531→ } + 532→ while (ds.length < digits) ds.push(0) + 533→ if (ds.length > digits) throw new Error('share-codec: bitmap overflow') + 534→ let out = '' + 535→ for (let k = ds.length - 1; k >= 0; k--) out += digitChar(ds[k]) + 536→ return out + 537→} + 538→ + 539→/** The first `count` digit chars of `s` → bits (big-endian, `bits` long). */ + 540→function digitsToBits(s: string, count: number, bits: number): boolean[] { + 541→ let value = 0n + 542→ for (let i = 0; i < count; i++) { + 543→ const d = digitValue(s[i]) + 544→ if (d < 0) throw new Error('share-codec: bad bitmap digit') + 545→ value = value * BIG_BASE_N + BigInt(d) + 546→ } + 547→ const out: boolean[] = [] + 548→ for (let i = bits - 1; i >= 0; i--) out.push((value >> BigInt(i)) & 1n ? true : false) + 549→ return out + 550→} + 551→ + 552→/** + 553→ * Largest body (in symbols) the big-base packer will accept — packing is + 554→ * O(n²) in BigInt arithmetic, and 16,000 symbols packs in ~150 ms while a + 555→ * 50,000-symbol body would take seconds. Longer scripts fall back to the + 556→ * linear pair form (still ~55% shorter). + 557→ */ + 558→const PACK_SYMBOL_LIMIT = 16_000 + 559→ + 560→/** + 561→ * Pack a dictionary body into the base form: + 562→ * + 563→ * 〠 + 564→ * + 565→ * The payload is the whole body as one big number (a leading sentinel + 566→ * symbol — the alphabet's last — guarantees leading-alphabet-symbol zeros + 567→ * survive). The header rebuilds the body's alphabet for the decoder: which + 568→ * frozen dictionary symbols are used (bitmap), which ASCII chars are used + 569→ * (bitmap), and any remaining non-ASCII chars verbatim. + 570→ */ + 571→function packBase(body: string): string | null { + 572→ const n = body.length + 573→ if (n < 2 || n > PACK_SYMBOL_LIMIT) return null + 574→ + 575→ const used = new Set() + 576→ for (let i = 0; i < n; i++) used.add(body[i]) + 577→ const alphabet = [...used].sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) + 578→ const m = alphabet.length + 579→ if (m < 2) return null + 580→ + 581→ const indexOf = new Map() + 582→ alphabet.forEach((s, idx) => indexOf.set(s, idx)) + 583→ + 584→ // value = sentinel·m^n + body as a base-m number + 585→ let value = BigInt(m - 1) + 586→ for (let i = 0; i < n; i++) value = value * BigInt(m) + BigInt(indexOf.get(body[i])!) + 587→ + 588→ let payload = '' + 589→ const ds: number[] = [] + 590→ let v = value + 591→ while (v > 0n) { + 592→ ds.push(Number(v % BIG_BASE_N)) + 593→ v /= BIG_BASE_N + 594→ } + 595→ for (let k = ds.length - 1; k >= 0; k--) payload += digitChar(ds[k]) + 596→ + 597→ // header bitmaps + 598→ const dictBits: boolean[] = [] + 599→ for (const s of BITMAP_LIST) dictBits.push(used.has(s)) + 600→ const asciiBits: boolean[] = [] + 601→ for (let c = 0; c < 128; c++) asciiBits.push(used.has(String.fromCharCode(c))) + 602→ let tail = '' + 603→ for (const s of alphabet) { + 604→ const cc = s.charCodeAt(0) + 605→ if (cc >= 128 && !BITMAP_INDEX.has(s)) tail += s + 606→ } + 607→ + 608→ return ( + 609→ payload + SEP + bitsToDigits(dictBits, DICT_BITMAP_DIGITS) + bitsToDigits(asciiBits, ASCII_BITMAP_DIGITS) + tail + 610→ ) + 611→} + 612→ + 613→/** Inverse of packBase — returns null on any structural violation. */ + 614→function unpackBase(packed: string): string | null { + 615→ // first bare SEP splits payload | header (payload digits are CJK/Hangul, + 616→ // so no digit can ever be mistaken for the separator) + 617→ let sepIdx = -1 + 618→ for (let i = 0; i < packed.length; i++) { + 619→ if (packed[i] === SEP) { + 620→ sepIdx = i + 621→ break + 622→ } + 623→ } + 624→ if (sepIdx < 1 || sepIdx === packed.length - 1) return null + 625→ + 626→ let value = 0n + 627→ for (let i = 0; i < sepIdx; i++) { + 628→ const d = digitValue(packed[i]) + 629→ if (d < 0) return null + 630→ value = value * BIG_BASE_N + BigInt(d) + 631→ } + 632→ if (value <= 0n) return null + 633→ + 634→ const header = packed.slice(sepIdx + 1) + 635→ if (header.length < DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS) return null + 636→ let dictBits: boolean[] + 637→ let asciiBits: boolean[] + 638→ try { + 639→ dictBits = digitsToBits(header, DICT_BITMAP_DIGITS, BITMAP_LIST.length) + 640→ asciiBits = digitsToBits(header.slice(DICT_BITMAP_DIGITS), ASCII_BITMAP_DIGITS, 128) + 641→ } catch { + 642→ return null + 643→ } + 644→ const tail = header.slice(DICT_BITMAP_DIGITS + ASCII_BITMAP_DIGITS) + 645→ + 646→ // rebuild the alphabet: frozen symbols + ASCII + verbatim tail, sorted + 647→ const alphabet: string[] = [] + 648→ dictBits.forEach((on, i) => { + 649→ if (on) alphabet.push(BITMAP_LIST[i]) + 650→ }) + 651→ asciiBits.forEach((on, c) => { + 652→ if (on) alphabet.push(String.fromCharCode(c)) + 653→ }) + 654→ const seen = new Set(alphabet) + 655→ for (let i = 0; i < tail.length; i++) { + 656→ const c = tail[i] + 657→ if (seen.has(c)) return null // tail must be deduped + 658→ seen.add(c) + 659→ alphabet.push(c) + 660→ } + 661→ alphabet.sort((a, b) => a.charCodeAt(0) - b.charCodeAt(0)) + 662→ + 663→ const m = alphabet.length + 664→ if (m < 2) return null + 665→ const M = BigInt(m) + 666→ const idxs: number[] = [] + 667→ while (value > 0n) { + 668→ idxs.push(Number(value % M)) + 669→ value /= M + 670→ } + 671→ let s = '' + 672→ for (let k = idxs.length - 1; k >= 0; k--) { + 673→ const idx = idxs[k] + 674→ if (idx >= m) return null + 675→ s += alphabet[idx] + 676→ } + 677→ if (s.length < 1) return null + 678→ return s.slice(1) // strip the sentinel + 679→} + 680→ + 681→// ── checksum ────────────────────────────────────────────────────────────── + 682→ + 683→/** FNV-1a 32-bit over the UTF-16 code units — fast, tiny, plenty here. */ + 684→function fnv1a(s: string): number { + 685→ let h = 0x811c9dc5 + 686→ for (let i = 0; i < s.length; i++) { + 687→ h ^= s.charCodeAt(i) + 688→ h = Math.imul(h, 0x01000193) >>> 0 + 689→ } + 690→ return h >>> 0 + 691→} + 692→ + 693→/** Encode a 32-bit digest as 8 "circled digit" hex chars (① = 0 … ⑯ = f). */ + 694→function checkDigits(value: number): string { + 695→ let out = '' + 696→ for (let shift = 28; shift >= 0; shift -= 4) { + 697→ out += String.fromCharCode(0x2460 + ((value >>> shift) & 0xf)) + 698→ } + 699→ return out + 700→} + 701→ + 702→function isCheckDigit(c: string): boolean { + 703→ const cc = c.charCodeAt(0) + 704→ return cc >= 0x2460 && cc <= 0x246f + 705→} + 706→ + 707→// ── name label ──────────────────────────────────────────────────────────── + 708→ + 709→/** Pull the `name:` header out of a script (before the first [section]). */ + 710→function extractScriptName(script: string): string { + 711→ for (const line of script.split('\n')) { + 712→ const t = line.trim() + 713→ if (t.startsWith('[')) break + 714→ const m = /^name\s*:\s*(.*)$/i.exec(t) + 715→ if (m) { + 716→ let v = m[1].trim() + 717→ if ( + 718→ v.length >= 2 && + 719→ ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) + 720→ ) { + 721→ v = v.slice(1, -1).trim() + 722→ } + 723→ if (v) return v + 724→ } + 725→ } + 726→ return '' + 727→} + 728→ + 729→/** Make the label safe for the `Name:[…]` wrapper (no : [ ] or newlines). */ + 730→function sanitizeShareName(raw: string): string { + 731→ const cleaned = raw + 732→ .replace(/[\[\]:\r\n\t]+/g, ' ') + 733→ .replace(/\s+/g, ' ') + 734→ .trim() + 735→ .slice(0, 48) + 736→ .trim() + 737→ return cleaned || 'OVE' + 738→} + 739→ + 740→// ── encode ──────────────────────────────────────────────────────────────── + 741→ + 742→/** Substitute the dictionary into the script text (escaping as needed). */ + 743→function encodeDictBody(script: string): string { + 744→ let out = '' + 745→ let i = 0 + 746→ const n = script.length + 747→ while (i < n) { + 748→ const c = script[i] + 749→ // legitimate noise chars → their dedicated symbols (never raw) + 750→ const noiseSym = NOISE_SYMBOLS.get(c) + 751→ if (noiseSym !== undefined) { + 752→ out += noiseSym + 753→ i++ + 754→ continue + 755→ } + 756→ // reserved / escape char → escaped literal + 757→ if (c === ESCAPE || RESERVED.has(c)) { + 758→ out += ESCAPE + c + 759→ i++ + 760→ continue + 761→ } + 762→ // dictionary match (longest first) — also folds \n \r \t into symbols + 763→ const entries = BY_FIRST_CHAR.get(c) + 764→ if (entries) { + 765→ let matched = false + 766→ for (const e of entries) { + 767→ if (script.startsWith(e.key, i)) { + 768→ out += e.symbol + 769→ i += e.key.length + 770→ matched = true + 771→ break + 772→ } + 773→ } + 774→ if (matched) continue + 775→ } + 776→ // other control characters → Unicode control pictures (copy-safe) + 777→ const cc = c.charCodeAt(0) + 778→ if (cc < 0x20 || cc === 0x7f) { + 779→ out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc) + 780→ i++ + 781→ continue + 782→ } + 783→ out += c + 784→ i++ + 785→ } + 786→ return out + 787→} + 788→ + 789→/** Dictionary + pair packing interleaved (the fallback form, v7.7-style). */ + 790→function encodePairBody(script: string): string { + 791→ let out = '' + 792→ let i = 0 + 793→ const n = script.length + 794→ while (i < n) { + 795→ const c = script[i] + 796→ const noiseSym = NOISE_SYMBOLS.get(c) + 797→ if (noiseSym !== undefined) { + 798→ out += noiseSym + 799→ i++ + 800→ continue + 801→ } + 802→ if (c === ESCAPE || RESERVED.has(c)) { + 803→ out += ESCAPE + c + 804→ i++ + 805→ continue + 806→ } + 807→ const entries = BY_FIRST_CHAR.get(c) + 808→ if (entries) { + 809→ let matched = false + 810→ for (const e of entries) { + 811→ if (script.startsWith(e.key, i)) { + 812→ out += e.symbol + 813→ i += e.key.length + 814→ matched = true + 815→ break + 816→ } + 817→ } + 818→ if (matched) continue + 819→ } + 820→ const cc = c.charCodeAt(0) + 821→ if (cc < 0x20 || cc === 0x7f) { + 822→ out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc) + 823→ i++ + 824→ continue + 825→ } + 826→ // pair packing — two adjacent [0-9a-zA-Z_.] chars → one CJK code point + 827→ const ai = PAIR_INDEX.get(c) + 828→ if (ai !== undefined) { + 829→ const bi = PAIR_INDEX.get(script[i + 1]) + 830→ if (bi !== undefined) { + 831→ out += String.fromCharCode(PAIR_BASE + ai * PAIR_ALPHABET.length + bi) + 832→ i += 2 + 833→ continue + 834→ } + 835→ } + 836→ out += c + 837→ i++ + 838→ } + 839→ return out + 840→} + 841→ + 842→export interface ScriptHash { + 843→ /** The full share string: `Name:[▼…①①①…]`. */ + 844→ hash: string + 845→ /** The label used in the prefix (sanitized). */ + 846→ name: string + 847→ /** script.length (UTF-16 units) — for the stats line. */ + 848→ scriptLength: number + 849→ /** hash.length — for the stats line. */ + 850→ hashLength: number + 851→} + 852→ + 853→/** + 854→ * Encode a script into its share string. Never throws, never validates the + 855→ * script — sharing a script that doesn't compile is a feature (ask for help, + 856→ * get it debugged). `name` overrides the label; by default the `name:` + 857→ * header is used, falling back to "OVE". + 858→ */ + 859→export function encodeScriptHash(script: string, name?: string): ScriptHash { + 860→ const label = sanitizeShareName(name ?? extractScriptName(script)) + 861→ const dictBody = encodeDictBody(script) + 862→ const packed = packBase(dictBody) + 863→ let body: string + 864→ if (packed !== null) { + 865→ const pairBody = encodePairBody(script) + 866→ body = packed.length < pairBody.length ? packed : pairBody + 867→ } else { + 868→ body = encodePairBody(script) + 869→ } + 870→ const hash = `${label}:[${SCHEME_V2}${body}${checkDigits(fnv1a(script))}]` + 871→ return { hash, name: label, scriptLength: script.length, hashLength: hash.length } + 872→} + 873→ + 874→// ── decode ──────────────────────────────────────────────────────────────── + 875→ + 876→/** Expand a dictionary body back into the original text. */ + 877→function decodeDictBody(body: string): string { + 878→ let out = '' + 879→ for (let i = 0; i < body.length; i++) { + 880→ const c = body[i] + 881→ if (c === ESCAPE) { + 882→ const next = body[i + 1] + 883→ if (next !== undefined) { + 884→ out += next + 885→ i++ + 886→ } else { + 887→ out += c // trailing lone escape -- be lenient + 888→ } + 889→ continue + 890→ } + 891→ const cc = c.charCodeAt(0) + 892→ // control pictures → the control chars they stand for + 893→ if (cc >= 0x2400 && cc <= 0x2420) { + 894→ out += String.fromCharCode(cc - 0x2400) + 895→ continue + 896→ } + 897→ if (cc === 0x2421) { + 898→ out += '\u007f' + 899→ continue + 900→ } + 901→ // dictionary symbols → raw text + 902→ const expansion = SYMBOL_TO_TEXT.get(c) + 903→ if (expansion !== undefined) { + 904→ out += expansion + 905→ continue + 906→ } + 907→ // legitimate noise symbols → their original chars + 908→ const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c) + 909→ if (noiseChar !== undefined) { + 910→ out += noiseChar + 911→ continue + 912→ } + 913→ out += c + 914→ } + 915→ return out + 916→} + 917→ + 918→/** Expand a pair-form body (dictionary + pair slots) into the script. */ + 919→function decodePairBody(body: string): string { + 920→ let out = '' + 921→ for (let i = 0; i < body.length; i++) { + 922→ const c = body[i] + 923→ if (c === ESCAPE) { + 924→ const next = body[i + 1] + 925→ if (next !== undefined) { + 926→ out += next + 927→ i++ + 928→ } else { + 929→ out += c + 930→ } + 931→ continue + 932→ } + 933→ const cc = c.charCodeAt(0) + 934→ if (cc >= 0x2400 && cc <= 0x2420) { + 935→ out += String.fromCharCode(cc - 0x2400) + 936→ continue + 937→ } + 938→ if (cc === 0x2421) { + 939→ out += '\u007f' + 940→ continue + 941→ } + 942→ const expansion = SYMBOL_TO_TEXT.get(c) + 943→ if (expansion !== undefined) { + 944→ out += expansion + 945→ continue + 946→ } + 947→ const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c) + 948→ if (noiseChar !== undefined) { + 949→ out += noiseChar + 950→ continue + 951→ } + 952→ if (cc >= PAIR_BASE && cc <= PAIR_END) { + 953→ const k = cc - PAIR_BASE + 954→ out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length] + 955→ continue + 956→ } + 957→ out += c + 958→ } + 959→ return out + 960→} + 961→ + 962→// ── legacy ▲ support (v7.7 strings — decode only) ────────────────────────── + 963→ + 964→const LEGACY_DICT: DictEntry[] = [ + 965→ { key: '\n', symbol: '♤' }, + 966→ { key: ' ', symbol: '♦' }, + 967→ { key: 'beats', symbol: '♡' }, + 968→ { key: 'beatage', symbol: '♠' }, + 969→ { key: '\t', symbol: '♣' }, + 970→ { key: ' ', symbol: '▣' }, + 971→ { key: ' ', symbol: '·' }, + 972→ { key: '\r', symbol: '⏎' }, + 973→ { key: '[init]', symbol: '♩' }, + 974→ { key: '[frame]', symbol: '♪' }, + 975→ { key: '[beat]', symbol: '♫' }, + 976→ { key: '[point]', symbol: '♬' }, + 977→ { key: '# ', symbol: '♯' }, + 978→ { key: '#', symbol: '♭' }, + 979→ { key: '//', symbol: '♮' }, + 980→ { key: 'name', symbol: '♔' }, + 981→ { key: 'author', symbol: '♕' }, + 982→ { key: 'description', symbol: '♖' }, + 983→ { key: 'mode', symbol: '♗' }, + 984→ { key: 'blend', symbol: '♘' }, + 985→ { key: 'persist', symbol: '♙' }, + 986→ { key: 'linewidth', symbol: '♚' }, + 987→ { key: 'ysign', symbol: '♛' }, + 988→ { key: 'lines', symbol: '♈' }, + 989→ { key: 'dots', symbol: '♉' }, + 990→ { key: 'solid', symbol: '♊' }, + 991→ { key: 'additive', symbol: '♋' }, + 992→ { key: 'alpha', symbol: '♌' }, + 993→ { key: ' = ', symbol: '←' }, + 994→ { key: ' + ', symbol: '→' }, + 995→ { key: ' * ', symbol: '↑' }, + 996→ { key: ' - ', symbol: '↓' }, + 997→ { key: ', ', symbol: '⇄' }, + 998→ { key: 'x = ', symbol: '⇦' }, + 999→ { key: 'y = ', symbol: '⇨' }, + 1000→ { key: 'red = ', symbol: '⇧' }, + 1001→ { key: 'green = ', symbol: '⇩' }, + 1002→ { key: 'blue = ', symbol: '⇪' }, + 1003→ { key: 'hue = ', symbol: '⇱' }, + 1004→ { key: 't = ', symbol: '⇲' }, + 1005→ { key: 'n = ', symbol: '⇴' }, + 1006→ { key: 'bass_att', symbol: '♎' }, + 1007→ { key: 'mid_att', symbol: '♏' }, + 1008→ { key: 'treb_att', symbol: '♐' }, + 1009→ { key: 'bass', symbol: '♑' }, + 1010→ { key: 'mid', symbol: '♒' }, + 1011→ { key: 'treb', symbol: '♓' }, + 1012→ { key: 'vol', symbol: '☽' }, + 1013→ { key: 'beat', symbol: '◉' }, + 1014→ { key: 'red', symbol: '☾' }, + 1015→ { key: 'green', symbol: '☿' }, + 1016→ { key: 'blue', symbol: '⚑' }, + 1017→ { key: 'skip', symbol: '⚐' }, + 1018→ { key: 'aspect', symbol: '⚶' }, + 1019→ { key: 'dt', symbol: '◐' }, + 1020→ { key: 'pi', symbol: '◑' }, + 1021→ { key: 'tau', symbol: '◒' }, + 1022→ { key: 'smoothstep', symbol: '⌒' }, + 1023→ { key: 'gmegabuf', symbol: '✥' }, + 1024→ { key: 'getkbmouse', symbol: '◶' }, + 1025→ { key: 'megabuf', symbol: '✤' }, + 1026→ { key: 'getspec', symbol: '▤' }, + 1027→ { key: 'getosc', symbol: '▥' }, + 1028→ { key: 'gettime', symbol: '◷' }, + 1029→ { key: 'atan2', symbol: '⌗' }, + 1030→ { key: 'log10', symbol: '㏑' }, + 1031→ { key: 'invsqrt', symbol: '√' }, + 1032→ { key: 'sigmoid', symbol: '∿' }, + 1033→ { key: 'inrange', symbol: '⋔' }, + 1034→ { key: 'hsvr', symbol: '⬥' }, + 1035→ { key: 'hsvg', symbol: '⬦' }, + 1036→ { key: 'hsvb', symbol: '⬧' }, + 1037→ { key: 'above', symbol: '≻' }, + 1038→ { key: 'below', symbol: '≺' }, + 1039→ { key: 'asin', symbol: '⌠' }, + 1040→ { key: 'acos', symbol: '⌡' }, + 1041→ { key: 'atan', symbol: '⌐' }, + 1042→ { key: 'ceil', symbol: '⌈' }, + 1043→ { key: 'floor', symbol: '⌊' }, + 1044→ { key: 'sqr', symbol: '□' }, + 1045→ { key: 'sqrt', symbol: '∛' }, + 1046→ { key: 'pow', symbol: '⌃' }, + 1047→ { key: 'exp', symbol: 'ℯ' }, + 1048→ { key: 'log', symbol: '㏒' }, + 1049→ { key: 'round', symbol: '◎' }, + 1050→ { key: 'int', symbol: '⊺' }, + 1051→ { key: 'frac', symbol: '∵' }, + 1052→ { key: 'sign', symbol: '±' }, + 1053→ { key: 'bnot', symbol: '¬' }, + 1054→ { key: 'band', symbol: '∧' }, + 1055→ { key: 'bor', symbol: '∨' }, + 1056→ { key: 'equal', symbol: '≟' }, + 1057→ { key: 'lerp', symbol: '↝' }, + 1058→ { key: 'clamp', symbol: '⊜' }, + 1059→ { key: 'rand', symbol: '⚄' }, + 1060→ { key: 'rnd', symbol: '⚅' }, + 1061→ { key: 'sin', symbol: '◠' }, + 1062→ { key: 'cos', symbol: '◡' }, + 1063→ { key: 'tan', symbol: '◮' }, + 1064→ { key: 'abs', symbol: '¦' }, + 1065→ { key: 'min', symbol: '≪' }, + 1066→ { key: 'max', symbol: '≫' }, + 1067→ { key: 'if', symbol: '∴' }, + 1068→ { key: 'loop', symbol: '↻' }, + 1069→ { key: '6.28318', symbol: '⌇' }, + 1070→ { key: '0.5', symbol: '†' }, + 1071→ { key: '0.3', symbol: '‡' }, + 1072→ { key: '0.25', symbol: '•' }, + 1073→ { key: '0.55', symbol: '§' }, + 1074→ { key: '0.45', symbol: '¶' }, + 1075→ { key: '0.35', symbol: '※' }, + 1076→ { key: '0.4', symbol: '⊤' }, + 1077→ { key: '0.7', symbol: '⊥' }, + 1078→ { key: '0.85', symbol: '⊢' }, + 1079→ { key: '0.6', symbol: '⊣' }, + 1080→ { key: '1.3', symbol: '⋈' }, + 1081→ { key: '1.2', symbol: '⋉' }, + 1082→ { key: ' / ', symbol: '∙' }, + 1083→ { key: 'hue', symbol: '⋊' }, + 1084→ { key: 'rad', symbol: '⋅' }, + 1085→ { key: 'flash', symbol: '∘' }, + 1086→] + 1087→ + 1088→const LEGACY_SYMBOL_TO_TEXT = new Map() + 1089→for (const { key, symbol } of LEGACY_DICT) LEGACY_SYMBOL_TO_TEXT.set(symbol, key) + 1090→for (const [c, s] of NOISE_SYMBOLS) LEGACY_SYMBOL_TO_TEXT.set(s, c) + 1091→ + 1092→/** Expand a v7.7 ▲ body. */ + 1093→function decodeLegacyBody(body: string): string { + 1094→ let out = '' + 1095→ for (let i = 0; i < body.length; i++) { + 1096→ const c = body[i] + 1097→ if (c === ESCAPE) { + 1098→ const next = body[i + 1] + 1099→ if (next !== undefined) { + 1100→ out += next + 1101→ i++ + 1102→ } else { + 1103→ out += c + 1104→ } + 1105→ continue + 1106→ } + 1107→ const cc = c.charCodeAt(0) + 1108→ if (cc >= 0x2400 && cc <= 0x2420) { + 1109→ out += String.fromCharCode(cc - 0x2400) + 1110→ continue + 1111→ } + 1112→ if (cc === 0x2421) { + 1113→ out += '\u007f' + 1114→ continue + 1115→ } + 1116→ const expansion = LEGACY_SYMBOL_TO_TEXT.get(c) + 1117→ if (expansion !== undefined) { + 1118→ out += expansion + 1119→ continue + 1120→ } + 1121→ if (cc >= PAIR_BASE && cc <= PAIR_END) { + 1122→ const k = cc - PAIR_BASE + 1123→ out += PAIR_ALPHABET[Math.floor(k / PAIR_ALPHABET.length)] + PAIR_ALPHABET[k % PAIR_ALPHABET.length] + 1124→ continue + 1125→ } + 1126→ out += c + 1127→ } + 1128→ return out + 1129→} + 1130→ + 1131→// ── decode entry point ──────────────────────────────────────────────────── + 1132→ + 1133→export type HashDecodeResult = + 1134→ | { ok: true; script: string; name: string | null } + 1135→ | { ok: false; error: string } + 1136→ + 1137→/** + 1138→ * Decode a pasted share string back into the original script. Accepts the + 1139→ * full `Name:[…]` form or a bare `[…]` body, ▼ (current) and ▲ (v7.7) + 1140→ * schemes; strips the invisible characters platforms sprinkle into copies; + 1141→ * and verifies the check digits so a truncated or altered string is + 1142→ * rejected instead of decoding into a corrupted script. + 1143→ */ + 1144→export function decodeScriptHash(input: string): HashDecodeResult { + 1145→ if (input.length > 40_000) { + 1146→ return { ok: false, error: 'That text is far too long to be a share string.' } + 1147→ } + 1148→ + 1149→ // Trim whitespace AND stray noise from the edges (the encoder never emits + 1150→ // either raw, so edge removal is always safe). + 1151→ let text = input + 1152→ while (text.length > 0 && isEdgeTrimable(text[0])) text = text.slice(1) + 1153→ while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1) + 1154→ if ( + 1155→ text.length >= 2 && + 1156→ ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) + 1157→ ) { + 1158→ text = text.slice(1, -1) + 1159→ while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1) + 1160→ } + 1161→ // Any raw noise left is injected mangling (legit noise is symbol-coded), + 1162→ // so it can be stripped everywhere without ever eating original content. + 1163→ text = text.replace(PLATFORM_NOISE, '') + 1164→ + 1165→ // `Name:[▼…]`, `[▼…]`, `Name:[▲…]` or `[▲…]` + 1166→ let payload: string | null = null + 1167→ let name: string | null = null + 1168→ const full = /^([\s\S]*?):\[([▼▲][\s\S]*)\]$/.exec(text) + 1169→ if (full) { + 1170→ name = full[1].trim() || null + 1171→ payload = full[2] + 1172→ } else if (/^\[([▼▲][\s\S]*)\]$/.test(text)) { + 1173→ payload = text.slice(1, -1) + 1174→ } + 1175→ if (payload === null) { + 1176→ // a string that still shows the "…:[▼/▲" signature was cut off + 1177→ // somewhere after the opening — a character limit ate the end + 1178→ const looksCutOff = /:\[[▼▲]/.test(text) || /^\[[▼▲]/.test(text) + 1179→ return { + 1180→ ok: false, + 1181→ error: looksCutOff + 1182→ ? 'This share string looks cut off -- it should end with "]". Copy the whole string and try again.' + 1183→ : 'This does not look like an OVE share string -- it should read like "My Script:[▼…]".', + 1184→ } + 1185→ } + 1186→ + 1187→ // trailing 8 check digits + 1188→ if (payload.length < 9 || !isCheckDigit(payload[payload.length - 8])) { + 1189→ return { + 1190→ ok: false, + 1191→ error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.', + 1192→ } + 1193→ } + 1194→ const digits = payload.slice(-8) + 1195→ for (const d of digits) { + 1196→ if (!isCheckDigit(d)) { + 1197→ return { + 1198→ ok: false, + 1199→ error: 'This share string looks cut off -- the 8 check digits at its end are missing. Copy the whole string and try again.', + 1200→ } + 1201→ } + 1202→ } + 1203→ const marker = payload[0] + 1204→ const body = payload.slice(1, -8) + 1205→ + 1206→ let script: string + 1207→ if (marker === SCHEME_V2) { + 1208→ // base form ⇔ the body contains a bare (unescaped) 〠 — the separator + 1209→ // between payload digits and the bitmap header. The pair form escapes + 1210→ // every 〠, and base-form payload digits can never be one. + 1211→ let hasBareSep = false + 1212→ for (let i = 0; i < body.length; i++) { + 1213→ if (body[i] === ESCAPE) i++ + 1214→ else if (body[i] === SEP) { + 1215→ hasBareSep = true + 1216→ break + 1217→ } + 1218→ } + 1219→ if (hasBareSep) { + 1220→ const dictBody = unpackBase(body) + 1221→ if (dictBody === null) { + 1222→ return { + 1223→ ok: false, + 1224→ error: 'This share string is damaged -- its packing header does not parse. Copy the whole string and try again.', + 1225→ } + 1226→ } + 1227→ script = decodeDictBody(dictBody) + 1228→ } else { + 1229→ script = decodePairBody(body) + 1230→ } + 1231→ } else { + 1232→ script = decodeLegacyBody(body) + 1233→ } + 1234→ + 1235→ if (checkDigits(fnv1a(script)) !== digits) { + 1236→ return { + 1237→ ok: false, + 1238→ error: 'The check digits do not match -- the string was altered or cut off while copying. Copy the whole string and try again.', + 1239→ } + 1240→ } + 1241→ return { ok: true, script, name } + 1242→} + 1243→ + 1244→// ── legend for the UI ───────────────────────────────────────────────────── + 1245→ + 1246→/** A few representative symbol meanings, shown in the share dialogs. */ + 1247→export const SHARE_LEGEND: ReadonlyArray<{ symbol: string; meaning: string }> = [ + 1248→ { symbol: '♤', meaning: 'line return' }, + 1249→ { symbol: '♦', meaning: 'space' }, + 1250→ { symbol: '♡', meaning: 'beats' }, + 1251→ { symbol: '♠', meaning: 'beatage' }, + 1252→ { symbol: '一', meaning: 'one packed digit -- 15 bits of script' }, + 1253→] + 1254→ \ No newline at end of file diff --git a/tool-results/read_1793817160288_8d6de58d6d8d.txt b/tool-results/read_1793817160288_8d6de58d6d8d.txt new file mode 100644 index 0000000..6cf0160 --- /dev/null +++ b/tool-results/read_1793817160288_8d6de58d6d8d.txt @@ -0,0 +1,1106 @@ + 1→// OVE — Presets tab: folder/file tree manager (v6.2 security model). + 2→// + 3→// The tree merges two worlds: + 4→// • root/embedded — the shipped library, READ-ONLY for every visitor (lock + 5→// badge in the tree; the server refuses all writes). Selecting an embedded + 6→// preset offers "Copy to my presets" instead of rename/delete. + 7→// • root/user created — this visitor's OWN presets, stored in their browser + 8→// (localStorage). Create / rename / delete / import all happen locally — + 9→// nothing is sent to the server, so nobody can spam or vandalize what + 10→// other visitors see. + 11→// + 12→// Left column: the tree (chevron expands, row selects, double-click runs, + 13→// running preset flagged emerald, originals download from the row). + 14→// Right column: context-sensitive management + the club-mode randomizer. + 15→ + 16→'use client' + 17→ + 18→import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + 19→import { + 20→ ChevronRight, + 21→ CopyPlus, + 22→ Download, + 23→ FilePlus2, + 24→ FileText, + 25→ Folder, + 26→ FolderOpen, + 27→ FolderPlus, + 28→ Lock, + 29→ Pencil, + 30→ Play, + 31→ Trash2, + 32→ Upload, + 33→ User, + 34→} from 'lucide-react' + 35→import { + 36→ AlertDialog, + 37→ AlertDialogAction, + 38→ AlertDialogCancel, + 39→ AlertDialogContent, + 40→ AlertDialogDescription, + 41→ AlertDialogFooter, + 42→ AlertDialogHeader, + 43→ AlertDialogTitle, + 44→} from '@/components/ui/alert-dialog' + 45→import { Badge } from '@/components/ui/badge' + 46→import { Button } from '@/components/ui/button' + 47→import { Checkbox } from '@/components/ui/checkbox' + 48→import { + 49→ Dialog, + 50→ DialogContent, + 51→ DialogDescription, + 52→ DialogFooter, + 53→ DialogHeader, + 54→ DialogTitle, + 55→} from '@/components/ui/dialog' + 56→import { Input } from '@/components/ui/input' + 57→import { Label } from '@/components/ui/label' + 58→import { STARTER_TEMPLATE } from '@/lib/ava/presets' + 59→import { applyUserPresets, refreshPresets, useAvaStore, type PresetItem } from '@/lib/store' + 60→import { applyScriptText } from '@/components/ava/audio-source' + 61→import { thinScrollbar } from '@/components/ava/shared' + 62→import { useToast } from '@/hooks/use-toast' + 63→import { cn } from '@/lib/utils' + 64→import { ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths' + 65→import { + 66→ createUserFolder, + 67→ createUserPreset, + 68→ deleteUserFolder, + 69→ deleteUserPreset, + 70→ presetIsLocal, + 71→ renameUserFolder, + 72→ renameUserPreset, + 73→} from '@/lib/ava/user-presets' + 74→import { exportPresetsZip, importPresetsZip } from '@/lib/ava/preset-io' + 75→ + 76→/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */ + 77→function defaultOpen(path: string): boolean { + 78→ return path.split('/').length <= 2 + 79→} + 80→ + 81→function plural(n: number, word: string): string { + 82→ return `${n} ${word}${n === 1 ? '' : 's'}` + 83→} + 84→ + 85→// --------------------------------------------------------------------------- + 86→// tree construction + 87→// --------------------------------------------------------------------------- + 88→ + 89→interface FolderNode { + 90→ path: string + 91→ name: string + 92→ depth: number + 93→ children: FolderNode[] + 94→ presets: PresetItem[] + 95→ /** Presets in this folder and every folder below it. */ + 96→ subtreeCount: number + 97→} + 98→ + 99→/** Segment-wise alphabetical compare (a parent always sorts before its children). */ + 100→function comparePaths(a: string, b: string): number { + 101→ const sa = a.split('/') + 102→ const sb = b.split('/') + 103→ const shared = Math.min(sa.length, sb.length) + 104→ for (let i = 0; i < shared; i++) { + 105→ const c = sa[i].toLowerCase().localeCompare(sb[i].toLowerCase()) + 106→ if (c !== 0) return c + 107→ } + 108→ return sa.length - sb.length + 109→} + 110→ + 111→/** + 112→ * Build the tree from folder rows + presets. Defensive: a preset whose + 113→ * folderPath has no Folder row still gets a node (and its ancestors). + 114→ */ + 115→function buildTree(folders: string[], presets: PresetItem[]): FolderNode[] { + 116→ const pathSet = new Set(folders) + 117→ for (const p of presets) { + 118→ const segs = p.folderPath.split('/') + 119→ for (let i = 1; i <= segs.length; i++) pathSet.add(segs.slice(0, i).join('/')) + 120→ } + 121→ const paths = [...pathSet].sort(comparePaths) + 122→ + 123→ const nodes = new Map() + 124→ for (const path of paths) { + 125→ const segs = path.split('/') + 126→ nodes.set(path, { + 127→ path, + 128→ name: segs[segs.length - 1] || path, + 129→ depth: segs.length - 1, + 130→ children: [], + 131→ presets: [], + 132→ subtreeCount: 0, + 133→ }) + 134→ } + 135→ const roots: FolderNode[] = [] + 136→ for (const path of paths) { + 137→ const node = nodes.get(path) + 138→ if (!node) continue + 139→ const slash = path.lastIndexOf('/') + 140→ const parent = slash >= 0 ? nodes.get(path.slice(0, slash)) : undefined + 141→ if (parent) parent.children.push(node) + 142→ else roots.push(node) + 143→ } + 144→ for (const p of presets) nodes.get(p.folderPath)?.presets.push(p) + 145→ for (const node of nodes.values()) { + 146→ node.presets.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) + 147→ } + 148→ const countSubtree = (node: FolderNode): number => { + 149→ node.subtreeCount = node.presets.length + 150→ for (const child of node.children) node.subtreeCount += countSubtree(child) + 151→ return node.subtreeCount + 152→ } + 153→ roots.forEach(countSubtree) + 154→ return roots + 155→} + 156→ + 157→// --------------------------------------------------------------------------- + 158→// shared load action (Load Preset button + tree double-click) + 159→// --------------------------------------------------------------------------- + 160→ + 161→function useLoadPreset() { + 162→ const { toast } = useToast() + 163→ return useCallback( + 164→ (preset: PresetItem) => { + 165→ const result = applyScriptText(preset.content) + 166→ const st = useAvaStore.getState() + 167→ if (result.ok) { + 168→ st.setActivePresetId(preset.id) + 169→ st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) + 170→ // No "Preset loaded" toast (v7.3): the footer's SCRIPT: label + 171→ // already echoes the running preset — the popup was intrusive and + 172→ // only compile errors genuinely need the attention grab. + 173→ } else if (result.error) { + 174→ toast({ + 175→ title: 'Preset failed to compile', + 176→ description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`, + 177→ variant: 'destructive', + 178→ }) + 179→ st.setTab('script') + 180→ } + 181→ }, + 182→ [toast], + 183→ ) + 184→} + 185→ + 186→// --------------------------------------------------------------------------- + 187→// tree rows + 188→// --------------------------------------------------------------------------- + 189→ + 190→function TreeFolder({ node }: { node: FolderNode }) { + 191→ const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) + 192→ const selected = useAvaStore((s) => s.selectedFolder === node.path) + 193→ const readOnly = node.path === ROOT || isEmbeddedPath(node.path) + 194→ + 195→ return ( + 196→
+ 197→
+ 208→ + 219→ + 252→
+ 253→ {isOpen && ( + 254→
+ 255→ {node.children.map((child) => ( + 256→ + 257→ ))} + 258→ {node.presets.map((p) => ( + 259→ + 260→ ))} + 261→ {node.children.length === 0 && node.presets.length === 0 && ( + 262→

+ 269→ {node.path === USER_ROOT + 270→ ? '(Created and imported presets are stored in this browser.)' + 271→ : '(empty)'} + 272→

+ 273→ )} + 274→
+ 275→ )} + 276→
+ 277→ ) + 278→} + 279→ + 280→function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { + 281→ const loadPreset = useLoadPreset() + 282→ const selected = useAvaStore((s) => s.selectedPresetId === preset.id) + 283→ const running = useAvaStore((s) => s.activePresetId === preset.id) + 284→ const own = presetIsLocal(preset.id) + 285→ + 286→ return ( + 287→
+ 301→ + 334→ {preset.originalFile && ( + 335→ + 342→ + 343→ + 344→ )} + 345→
+ 346→ ) + 347→} + 348→ + 349→// --------------------------------------------------------------------------- + 350→// small rename/create dialog shared by the three name-requiring actions + 351→// --------------------------------------------------------------------------- + 352→ + 353→/** + 354→ * Fully controlled name prompt shared by the three name-requiring actions + 355→ * (create preset / new subfolder / rename). The parent owns the input value + 356→ * and error so each open starts clean without mount tricks. + 357→ */ + 358→function NameDialog({ + 359→ open, + 360→ onOpenChange, + 361→ title, + 362→ description, + 363→ placeholder, + 364→ value, + 365→ onChange, + 366→ error, + 367→ onError, + 368→ submitLabel, + 369→ busy, + 370→ onSubmit, + 371→}: { + 372→ open: boolean + 373→ onOpenChange: (open: boolean) => void + 374→ title: string + 375→ description: string + 376→ placeholder: string + 377→ value: string + 378→ onChange: (value: string) => void + 379→ error: string | null + 380→ onError: (error: string | null) => void + 381→ submitLabel: string + 382→ busy: boolean + 383→ /** Return true to close the dialog (errors keep it open). */ + 384→ onSubmit: (name: string) => Promise + 385→}) { + 386→ const submit = async () => { + 387→ const trimmed = value.trim() + 388→ if (!trimmed) { + 389→ onError('Enter a name first.') + 390→ return + 391→ } + 392→ const ok = await onSubmit(trimmed) + 393→ if (ok) onOpenChange(false) + 394→ } + 395→ + 396→ return ( + 397→ + 398→ + 399→ + 400→ {title} + 401→ {description} + 402→ + 403→
+ 404→ onChange(e.target.value)} + 407→ placeholder={placeholder} + 408→ autoFocus + 409→ aria-label={title} + 410→ className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs" + 411→ onFocus={(e) => e.currentTarget.select()} + 412→ onKeyDown={(e) => { + 413→ if (e.key === 'Enter') void submit() + 414→ }} + 415→ /> + 416→ {error &&

{error}

} + 417→
+ 418→ + 419→ + 422→ + 429→ + 430→
+ 431→
+ 432→ ) + 433→} + 434→ + 435→// --------------------------------------------------------------------------- + 436→// the tab + 437→// --------------------------------------------------------------------------- + 438→ + 439→type DialogKind = 'createPreset' | 'newFolder' | 'rename' | null + 440→ + 441→interface DeleteTarget { + 442→ kind: 'folder' | 'preset' + 443→ name: string + 444→ /** Folder path (folder delete) or preset id (preset delete). */ + 445→ path: string + 446→ presetCount: number + 447→ folderCount: number + 448→} + 449→ + 450→export function PresetsTab() { + 451→ const { toast } = useToast() + 452→ const loadPreset = useLoadPreset() + 453→ + 454→ const presets = useAvaStore((s) => s.presets) + 455→ const folders = useAvaStore((s) => s.folders) + 456→ const selectedFolder = useAvaStore((s) => s.selectedFolder) + 457→ const selectedPresetId = useAvaStore((s) => s.selectedPresetId) + 458→ const randomize = useAvaStore((s) => s.randomize) + 459→ const sequenceMode = useAvaStore((s) => s.sequenceMode) + 460→ const randomIntervalSec = useAvaStore((s) => s.randomIntervalSec) + 461→ const randomScope = useAvaStore((s) => s.randomScope) + 462→ + 463→ const [dialog, setDialog] = useState(null) + 464→ const [nameValue, setNameValue] = useState('') + 465→ const [nameError, setNameError] = useState(null) + 466→ const [busy, setBusy] = useState(false) + 467→ const [deleteTarget, setDeleteTarget] = useState(null) + 468→ const [delayText, setDelayText] = useState(String(randomIntervalSec)) + 469→ const [delayError, setDelayError] = useState(false) + 470→ const importInputRef = useRef(null) + 471→ + 472→ // freshness refresh whenever the tab mounts (initial merge happens on app load) + 473→ useEffect(() => { + 474→ void refreshPresets() + 475→ }, []) + 476→ + 477→ // keep the delay input in sync when the store value changes elsewhere + 478→ useEffect(() => { + 479→ setDelayText(String(randomIntervalSec)) + 480→ }, [randomIntervalSec]) + 481→ + 482→ const tree = useMemo(() => buildTree(folders, presets), [folders, presets]) + 483→ const selectedPreset = useMemo( + 484→ () => presets.find((p) => p.id === selectedPresetId) ?? null, + 485→ [presets, selectedPresetId], + 486→ ) + 487→ + 488→ // ---- read-only vs. browser-owned areas -------------------------------- + 489→ const folderReadOnly = selectedFolder === ROOT || isEmbeddedPath(selectedFolder) + 490→ const folderProtected = selectedFolder === USER_ROOT // the user area root itself + 491→ const presetOwn = selectedPreset ? presetIsLocal(selectedPreset.id) : false + 492→ + 493→ const folderName = selectedFolder.split('/').pop() ?? selectedFolder + 494→ const folderPrefix = `${selectedFolder}/` + 495→ const folderPresetCount = presets.filter( + 496→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), + 497→ ).length + 498→ const childFolderCount = folders.filter( + 499→ (f) => f.startsWith(folderPrefix) && !f.slice(folderPrefix.length).includes('/'), + 500→ ).length + 501→ const scopeCount = presets.filter( + 502→ (p) => randomScope === 'root' || p.folderPath === randomScope || p.folderPath.startsWith(`${randomScope}/`), + 503→ ).length + 504→ const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope) + 505→ + 506→ // ----------------------------------------------------------------- + 507→ // actions — all user-area CRUD happens in THIS browser (localStorage) + 508→ // ----------------------------------------------------------------- + 509→ + 510→ const submitCreatePreset = async (name: string): Promise => { + 511→ setBusy(true) + 512→ try { + 513→ const res = createUserPreset({ + 514→ name, + 515→ content: STARTER_TEMPLATE, + 516→ folderPath: selectedFolder, + 517→ author: 'you', + 518→ }) + 519→ if (!res.ok) { + 520→ toast({ title: 'Preset not created', description: res.error, variant: 'destructive' }) + 521→ return false + 522→ } + 523→ applyUserPresets() + 524→ const st = useAvaStore.getState() + 525→ st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) + 526→ st.setScriptText(STARTER_TEMPLATE) + 527→ applyScriptText(STARTER_TEMPLATE) + 528→ st.setActivePresetId(res.preset.id) + 529→ st.selectPreset(res.preset.id) + 530→ st.setTab('script') + 531→ toast({ + 532→ title: 'Preset created', + 533→ description: `"${res.preset.name}" created in this browser -- now edit and Save.`, + 534→ }) + 535→ return true + 536→ } finally { + 537→ setBusy(false) + 538→ } + 539→ } + 540→ + 541→ const submitNewFolder = async (name: string): Promise => { + 542→ const path = `${selectedFolder}/${name}` + 543→ setBusy(true) + 544→ try { + 545→ const res = createUserFolder(path) + 546→ if (!res.ok) { + 547→ toast({ title: 'Folder not created', description: res.error, variant: 'destructive' }) + 548→ return false + 549→ } + 550→ applyUserPresets() + 551→ const st = useAvaStore.getState() + 552→ st.toggleFolder(selectedFolder, true) + 553→ st.selectFolder(res.path) + 554→ toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder} (this browser).` }) + 555→ return true + 556→ } finally { + 557→ setBusy(false) + 558→ } + 559→ } + 560→ + 561→ const submitRename = async (name: string): Promise => { + 562→ setBusy(true) + 563→ try { + 564→ if (selectedPreset) { + 565→ const res = renameUserPreset(selectedPreset.id, name) + 566→ if (!res.ok) { + 567→ toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) + 568→ return false + 569→ } + 570→ applyUserPresets() + 571→ const st = useAvaStore.getState() + 572→ if (st.editTarget?.id === selectedPreset.id) { + 573→ st.setEditTarget({ + 574→ id: res.preset.id, + 575→ name: res.preset.name, + 576→ folderPath: res.preset.folderPath, + 577→ }) + 578→ } + 579→ toast({ + 580→ title: 'Preset renamed', + 581→ description: `"${selectedPreset.name}" is now "${res.preset.name}".`, + 582→ }) + 583→ return true + 584→ } + 585→ // folder rename — everything below moves with it (browser-side) + 586→ const res = renameUserFolder(selectedFolder, name) + 587→ if (!res.ok) { + 588→ toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) + 589→ return false + 590→ } + 591→ applyUserPresets() + 592→ const st = useAvaStore.getState() + 593→ st.selectFolder(res.to) + 594→ if ( + 595→ st.editTarget && + 596→ (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix)) + 597→ ) { + 598→ st.setEditTarget({ + 599→ ...st.editTarget, + 600→ folderPath: res.to + st.editTarget.folderPath.slice(selectedFolder.length), + 601→ }) + 602→ } + 603→ toast({ title: 'Folder renamed', description: `Now at ${res.to}.` }) + 604→ return true + 605→ } finally { + 606→ setBusy(false) + 607→ } + 608→ } + 609→ + 610→ const openDialog = (kind: Exclude, initialName: string) => { + 611→ setDialog(kind) + 612→ setNameValue(initialName) + 613→ setNameError(null) + 614→ } + 615→ + 616→ const openPresetDelete = () => { + 617→ if (!selectedPreset) return + 618→ setDeleteTarget({ + 619→ kind: 'preset', + 620→ name: selectedPreset.name, + 621→ path: selectedPreset.id, + 622→ presetCount: 1, + 623→ folderCount: 0, + 624→ }) + 625→ } + 626→ + 627→ const openFolderDelete = () => { + 628→ const presetCount = presets.filter( + 629→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), + 630→ ).length + 631→ const folderCount = folders.filter((f) => f.startsWith(folderPrefix)).length + 632→ setDeleteTarget({ kind: 'folder', name: folderName, path: selectedFolder, presetCount, folderCount }) + 633→ } + 634→ + 635→ const confirmDelete = async () => { + 636→ if (!deleteTarget || busy) return + 637→ setBusy(true) + 638→ try { + 639→ const isFolder = deleteTarget.kind === 'folder' + 640→ const res = isFolder ? deleteUserFolder(deleteTarget.path) : deleteUserPreset(deleteTarget.path) + 641→ if (!res.ok) { + 642→ toast({ title: 'Delete failed', description: res.error, variant: 'destructive' }) + 643→ return + 644→ } + 645→ applyUserPresets() + 646→ toast({ + 647→ title: isFolder ? 'Folder deleted' : 'Preset deleted', + 648→ description: isFolder + 649→ ? `"${deleteTarget.name}" and everything inside it removed from this browser.` + 650→ : `"${deleteTarget.name}" removed from this browser.`, + 651→ }) + 652→ } finally { + 653→ setBusy(false) + 654→ setDeleteTarget(null) + 655→ } + 656→ } + 657→ + 658→ /** Copy an embedded preset into this visitor's browser library. */ + 659→ const copyToMyPresets = (preset: PresetItem) => { + 660→ const candidates = [preset.name, `${preset.name} (copy)`, `${preset.name} (copy 2)`, `${preset.name} (copy 3)`] + 661→ for (const name of candidates) { + 662→ const res = createUserPreset({ + 663→ name, + 664→ content: preset.content, + 665→ folderPath: USER_ROOT, + 666→ author: preset.author, + 667→ description: preset.description, + 668→ }) + 669→ if (res.ok) { + 670→ applyUserPresets() + 671→ const st = useAvaStore.getState() + 672→ st.toggleFolder(USER_ROOT, true) + 673→ st.selectPreset(res.preset.id) + 674→ toast({ + 675→ title: 'Copied to your presets', + 676→ description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`, + 677→ }) + 678→ return + 679→ } + 680→ if (!res.error.includes('already exists')) { + 681→ toast({ title: 'Copy failed', description: res.error, variant: 'destructive' }) + 682→ return + 683→ } + 684→ } + 685→ toast({ + 686→ title: 'Copy failed', + 687→ description: 'A preset with that name already exists -- rename it in "user created" first.', + 688→ variant: 'destructive', + 689→ }) + 690→ } + 691→ + 692→ const exportZip = async () => { + 693→ if (busy) return + 694→ setBusy(true) + 695→ try { + 696→ const res = await exportPresetsZip(selectedFolder, presets, folders) + 697→ if (res.ok) { + 698→ toast({ title: 'Export ready', description: `${plural(res.presets, 'preset')} zipped and downloading.` }) + 699→ } else { + 700→ toast({ title: 'Export failed', description: res.error, variant: 'destructive' }) + 701→ } + 702→ } finally { + 703→ setBusy(false) + 704→ } + 705→ } + 706→ + 707→ const importZip = async (file: File) => { + 708→ setBusy(true) + 709→ try { + 710→ const res = await importPresetsZip(file, selectedFolder) + 711→ if (!res.ok) { + 712→ toast({ title: 'Import failed', description: res.error, variant: 'destructive' }) + 713→ return + 714→ } + 715→ applyUserPresets() + 716→ useAvaStore.getState().toggleFolder(USER_ROOT, true) + 717→ let summary = `Imported ${res.imported} presets into your browser library` + 718→ if ((res.skipped ?? 0) > 0) summary += `, ${res.skipped} duplicates skipped` + 719→ if ((res.foldersCreated ?? 0) > 0) summary += ` -- ${res.foldersCreated} folders created` + 720→ toast({ title: 'Import complete', description: `${summary}.` }) + 721→ } catch { + 722→ toast({ title: 'Import failed', description: 'Could not read that zip.', variant: 'destructive' }) + 723→ } finally { + 724→ setBusy(false) + 725→ } + 726→ } + 727→ + 728→ // ----------------------------------------------------------------- + 729→ // randomizer controls (timer runs in the store) + 730→ // ----------------------------------------------------------------- + 731→ + 732→ const onDelayChange = (raw: string) => { + 733→ setDelayText(raw) + 734→ const result = useAvaStore.getState().setRandomIntervalSec(Number(raw)) + 735→ setDelayError(!result.ok) + 736→ if (result.ok && result.value !== undefined) setDelayText(String(result.value)) + 737→ } + 738→ + 739→ const onDelayBlur = () => { + 740→ // invalid / partial input never persists — revert to the committed value + 741→ if (delayError) setDelayText(String(randomIntervalSec)) + 742→ } + 743→ + 744→ // ----------------------------------------------------------------- + 745→ // render + 746→ // ----------------------------------------------------------------- + 747→ + 748→ return ( + 749→
+ 750→
+ 751→

+ 752→ {presets.length} presets ·{' '} + 753→ {folders.length} folders + 754→

+ 755→

double-click a preset to run it

+ 756→
+ 757→ + 758→
+ 759→ {/* LEFT -- folder/file tree */} + 760→
+ 761→
+ 766→ {tree.map((node) => ( + 767→ + 768→ ))} + 769→ {presets.length === 0 && ( + 770→

Loading presets…

+ 771→ )} + 772→
+ 773→
+ 774→ + 775→ {/* RIGHT -- management panel */} + 776→
+ 777→
+ 778→ {selectedPreset ? ( + 779→ + 780→ ) : ( + 781→ + 782→ )} + 783→
+ 784→

+ 788→ {selectedPreset ? selectedPreset.name : folderName} + 789→

+ 790→

+ 794→ {selectedPreset ? selectedPreset.folderPath : selectedFolder} + 795→

+ 796→ {!selectedPreset && ( + 797→

+ 798→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')} + 799→

+ 800→ )} + 801→
+ 802→ {(folderReadOnly || (selectedPreset && !presetOwn)) && ( + 803→ + 807→ read-only + 808→ + 809→ )} + 810→
+ 811→ + 812→
+ 813→ {selectedPreset ? ( + 814→ <> + 815→ + 823→ {presetOwn ? ( + 824→
+ 825→ + 834→ + 843→
+ 844→ ) : ( + 845→ + 854→ )} + 855→ + 856→ ) : folderReadOnly ? ( + 857→ <> + 858→
+ 859→ + 869→ + 879→
+ 880→ + 881→ ) : ( + 882→ <> + 883→ + 892→ + 901→
+ 902→ + 912→ + 922→
+ 923→
+ 924→ + 934→ + 944→
+ 945→ + 946→ )} + 947→
+ 948→ + 949→ {/* preset mixing (club mode) -- Random or Sequence, one timer in the store */} + 950→
+ 951→
+ 952→ + 953→ useAvaStore.getState().setRandomize(v === true)} + 957→ aria-label="Random presets" + 958→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950" + 959→ /> + 960→ + 963→ + 964→ + 965→ useAvaStore.getState().setSequenceMode(v === true)} + 969→ aria-label="Sequence presets" + 970→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950" + 971→ /> + 972→ + 975→ + 976→ + 977→ onDelayChange(e.target.value)} + 980→ onBlur={onDelayBlur} + 981→ inputMode="numeric" + 982→ aria-label="Random switch delay in seconds" + 983→ className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums" + 984→ /> + 985→ sec + 986→ + 987→
+ 988→ {delayError && ( + 989→

Delay must be between 3 and 60 seconds.

+ 990→ )} + 991→

+ 992→ {sequenceMode ? 'Sequence from:' : 'Random from:'}{' '} + 993→ {scopeLabel} + 994→ {scopeCount === 0 && -- no presets in this folder yet} + 995→

+ 996→ {(randomize || sequenceMode) && ( + 997→
+ 998→ + 999→ + 1000→ {sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s + 1001→ + 1002→
+ 1003→ )} + 1004→
+ 1005→
+ 1006→
+ 1007→ + 1008→ {/* name dialogs */} + 1009→ setDialog(o ? 'createPreset' : null)} + 1012→ title="Create preset" + 1013→ description={`New preset from the starter template inside ${selectedFolder} -- saved in this browser.`} + 1014→ placeholder="Preset name" + 1015→ value={nameValue} + 1016→ onChange={setNameValue} + 1017→ error={nameError} + 1018→ onError={setNameError} + 1019→ submitLabel="Create" + 1020→ busy={busy} + 1021→ onSubmit={submitCreatePreset} + 1022→ /> + 1023→ setDialog(o ? 'newFolder' : null)} + 1026→ title="New subfolder" + 1027→ description={`Created inside ${selectedFolder} -- saved in this browser.`} + 1028→ placeholder="Folder name" + 1029→ value={nameValue} + 1030→ onChange={setNameValue} + 1031→ error={nameError} + 1032→ onError={setNameError} + 1033→ submitLabel="Create" + 1034→ busy={busy} + 1035→ onSubmit={submitNewFolder} + 1036→ /> + 1037→ setDialog(o ? 'rename' : null)} + 1040→ title={selectedPreset ? 'Rename preset' : 'Rename folder'} + 1041→ description={ + 1042→ selectedPreset + 1043→ ? `Currently "${selectedPreset.name}" in ${selectedPreset.folderPath}.` + 1044→ : `Currently "${folderName}" -- everything inside moves with it.` + 1045→ } + 1046→ placeholder="New name" + 1047→ value={nameValue} + 1048→ onChange={setNameValue} + 1049→ error={nameError} + 1050→ onError={setNameError} + 1051→ submitLabel="Rename" + 1052→ busy={busy} + 1053→ onSubmit={submitRename} + 1054→ /> + 1055→ + 1056→ {/* delete confirmation */} + 1057→ !o && setDeleteTarget(null)}> + 1058→ + 1059→ + 1060→ + 1061→ {deleteTarget?.kind === 'folder' + 1062→ ? `Delete "${deleteTarget.name}"?` + 1063→ : `Delete preset "${deleteTarget?.name ?? ''}"?`} + 1064→ + 1065→ + 1066→ {deleteTarget?.kind === 'folder' + 1067→ ? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0 + 1068→ ? `Delete "${deleteTarget.name}" and everything inside it? This removes ${plural(deleteTarget.presetCount, 'preset')}${deleteTarget.folderCount > 0 ? ` in ${plural(deleteTarget.folderCount, 'folder')}` : ''} from this browser. This cannot be undone.` + 1069→ : `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.` + 1070→ : `Delete preset "${deleteTarget?.name ?? ''}" from this browser? This cannot be undone.`} + 1071→ + 1072→ + 1073→ + 1074→ Cancel + 1075→ { + 1078→ e.preventDefault() + 1079→ void confirmDelete() + 1080→ }} + 1081→ className="h-9 bg-rose-500 text-zinc-50 hover:bg-rose-400" + 1082→ > + 1083→ {busy ? 'Deleting…' : 'Delete'} + 1084→ + 1085→ + 1086→ + 1087→ + 1088→ + 1089→ {/* hidden zip import picker */} + 1090→ { + 1098→ const file = e.target.files?.[0] + 1099→ if (file) void importZip(file) + 1100→ e.target.value = '' + 1101→ }} + 1102→ /> + 1103→
+ 1104→ ) + 1105→} + 1106→ \ No newline at end of file diff --git a/tool-results/v711-01-fresh-home.png b/tool-results/v711-01-fresh-home.png new file mode 100644 index 0000000..d635b63 Binary files /dev/null and b/tool-results/v711-01-fresh-home.png differ diff --git a/tool-results/v711-02-fresh-demo.png b/tool-results/v711-02-fresh-demo.png new file mode 100644 index 0000000..ba4f3fb Binary files /dev/null and b/tool-results/v711-02-fresh-demo.png differ diff --git a/tool-results/v711-03-preset-loaded.png b/tool-results/v711-03-preset-loaded.png new file mode 100644 index 0000000..361052c Binary files /dev/null and b/tool-results/v711-03-preset-loaded.png differ diff --git a/tool-results/v711-04-demo-with-manual.png b/tool-results/v711-04-demo-with-manual.png new file mode 100644 index 0000000..92002dd Binary files /dev/null and b/tool-results/v711-04-demo-with-manual.png differ diff --git a/tool-results/v711-05-demo-custom-material.png b/tool-results/v711-05-demo-custom-material.png new file mode 100644 index 0000000..b4f6c8e Binary files /dev/null and b/tool-results/v711-05-demo-custom-material.png differ diff --git a/tool-results/v711-06-seq-unchecked.png b/tool-results/v711-06-seq-unchecked.png new file mode 100644 index 0000000..21c4f70 Binary files /dev/null and b/tool-results/v711-06-seq-unchecked.png differ diff --git a/tool-results/v711-07-state-check.png b/tool-results/v711-07-state-check.png new file mode 100644 index 0000000..56a252f Binary files /dev/null and b/tool-results/v711-07-state-check.png differ diff --git a/tool-results/v711-08-whats-new.png b/tool-results/v711-08-whats-new.png new file mode 100644 index 0000000..a34faf2 Binary files /dev/null and b/tool-results/v711-08-whats-new.png differ diff --git a/tool-results/v711-09-decoded.png b/tool-results/v711-09-decoded.png new file mode 100644 index 0000000..e36b0da Binary files /dev/null and b/tool-results/v711-09-decoded.png differ diff --git a/tool-results/v711-10-imported.png b/tool-results/v711-10-imported.png new file mode 100644 index 0000000..d9b96e1 Binary files /dev/null and b/tool-results/v711-10-imported.png differ diff --git a/tool-results/v711-11-saved-named.png b/tool-results/v711-11-saved-named.png new file mode 100644 index 0000000..b15e337 Binary files /dev/null and b/tool-results/v711-11-saved-named.png differ diff --git a/tool-results/v711-12-empty-name-dialog.png b/tool-results/v711-12-empty-name-dialog.png new file mode 100644 index 0000000..8fda4d4 Binary files /dev/null and b/tool-results/v711-12-empty-name-dialog.png differ diff --git a/tool-results/v711-13-renamed.png b/tool-results/v711-13-renamed.png new file mode 100644 index 0000000..2167236 Binary files /dev/null and b/tool-results/v711-13-renamed.png differ diff --git a/tool-results/v711-14-rename-conflict.png b/tool-results/v711-14-rename-conflict.png new file mode 100644 index 0000000..676764d Binary files /dev/null and b/tool-results/v711-14-rename-conflict.png differ diff --git a/tool-results/v711-15-empty-name-1.png b/tool-results/v711-15-empty-name-1.png new file mode 100644 index 0000000..a7654ca Binary files /dev/null and b/tool-results/v711-15-empty-name-1.png differ diff --git a/tool-results/v711-16-suffix-conflict.png b/tool-results/v711-16-suffix-conflict.png new file mode 100644 index 0000000..43dc2f3 Binary files /dev/null and b/tool-results/v711-16-suffix-conflict.png differ diff --git a/tool-results/v711-17-named-dup.png b/tool-results/v711-17-named-dup.png new file mode 100644 index 0000000..0f4f5f0 Binary files /dev/null and b/tool-results/v711-17-named-dup.png differ diff --git a/tool-results/v711-18-default-boot.png b/tool-results/v711-18-default-boot.png new file mode 100644 index 0000000..06439a0 Binary files /dev/null and b/tool-results/v711-18-default-boot.png differ diff --git a/tool-results/v711-19-default-demo.png b/tool-results/v711-19-default-demo.png new file mode 100644 index 0000000..a2ce1ff Binary files /dev/null and b/tool-results/v711-19-default-demo.png differ diff --git a/tool-results/v711-20-final-fresh.png b/tool-results/v711-20-final-fresh.png new file mode 100644 index 0000000..a718f3a Binary files /dev/null and b/tool-results/v711-20-final-fresh.png differ diff --git a/tool-results/v711-21-mobile.png b/tool-results/v711-21-mobile.png new file mode 100644 index 0000000..bb55d47 Binary files /dev/null and b/tool-results/v711-21-mobile.png differ diff --git a/tool-results/v711-22-desktop.png b/tool-results/v711-22-desktop.png new file mode 100644 index 0000000..30689b0 Binary files /dev/null and b/tool-results/v711-22-desktop.png differ