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 ? (
void saveToTarget()}
@@ -311,6 +442,18 @@ export function ScriptTab() {
>
Save
+ ) : (
+ validation?.ok && (
+ void saveAsNewPreset()}
+ disabled={saving}
+ className="h-9 gap-1.5 bg-emerald-400 px-3 text-emerald-950 hover:bg-emerald-300"
+ title='Save the current script as a NEW preset under "user created" -- auto-named from the script name: header'
+ >
+ Save
+
+ )
)}
{editTarget && (
{
setScriptText(STARTER_TEMPLATE)
setEditTarget(null)
+ setPendingName(null)
}}
className="h-9 gap-1.5 border-zinc-700"
title="Start over from the template (clears the save target)"
@@ -486,12 +630,73 @@ export function ScriptTab() {
+ {/* v7.11 -- rename the just-auto-named preset (nameless script /
+ duplicate conflict): the preset is already saved under "user
+ created"; this dialog only gives it the visitor's own name */}
+ {
+ setRenameOpen(o)
+ if (o) setRenameError(null)
+ }}
+ >
+
+
+ Give This Preset a Name
+
+ The script is already saved as “{renameTarget?.name ?? 'Untitled'}” under “user created” in this
+ browser. Name it anything you like -- the rename applies instantly.
+
+
+
+
{
+ setRenameValue(e.target.value)
+ setRenameError(null)
+ }}
+ placeholder="Preset name"
+ autoFocus
+ aria-label="New preset name"
+ className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
+ onFocus={(e) => e.currentTarget.select()}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void applyRename()
+ }}
+ />
+ {renameError &&
{renameError}
}
+
+
+ setRenameOpen(false)}>
+ Keep as-is
+
+ void applyRename()}
+ >
+ {renameBusy ? 'Renaming…' : 'Save Name'}
+
+
+
+
+
{/* 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→ useAvaStore.getState().toggleFolder(node.path)}
+ 211→ aria-label={isOpen ? `Collapse folder ${node.name}` : `Expand folder ${node.name}`}
+ 212→ className="flex size-9 shrink-0 items-center justify-center rounded-sm text-zinc-500 hover:text-zinc-200"
+ 213→ >
+ 214→
+ 218→
+ 219→ useAvaStore.getState().selectFolder(node.path)}
+ 222→ title={readOnly ? `${node.path} -- read-only (shipped with OVE)` : node.path}
+ 223→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
+ 224→ >
+ 225→ {isOpen ? (
+ 226→
+ 227→ ) : (
+ 228→
+ 229→ )}
+ 230→
+ 233→ {node.name}
+ 234→
+ 235→ {readOnly && (
+ 236→
+ 241→
+ 242→
+ 243→ )}
+ 244→
+ 249→ {node.subtreeCount}
+ 250→
+ 251→
+ 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→
useAvaStore.getState().selectPreset(preset.id)}
+ 304→ onDoubleClick={() => loadPreset(preset)}
+ 305→ title={`${preset.name} (double-click to run)${own ? ' -- yours (this browser)' : ''}`}
+ 306→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
+ 307→ >
+ 308→ {running ? (
+ 309→
+ 310→ ) : (
+ 311→
+ 312→ )}
+ 313→
+ 314→ {preset.name}
+ 315→
+ 316→ {preset.approx && (
+ 317→
+ 321→ ≈
+ 322→
+ 323→ )}
+ 324→ {own && (
+ 325→
+ 330→
+ 331→
+ 332→ )}
+ 333→
+ 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→ onOpenChange(false)}>
+ 420→ Cancel
+ 421→
+ 422→ void submit()}
+ 426→ >
+ 427→ {busy ? 'Working…' : submitLabel}
+ 428→
+ 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→
loadPreset(selectedPreset)}
+ 818→ className="h-9 w-full gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
+ 819→ title="Run this preset on the engine and open it in the editor"
+ 820→ >
+ 821→ Load Preset
+ 822→
+ 823→ {presetOwn ? (
+ 824→
+ 825→
openDialog('rename', selectedPreset.name)}
+ 829→ title="Rename this preset (within its folder)"
+ 830→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 831→ >
+ 832→ Rename
+ 833→
+ 834→
+ 841→ Delete
+ 842→
+ 843→
+ 844→ ) : (
+ 845→
copyToMyPresets(selectedPreset)}
+ 849→ title="Save an editable copy of this preset into your browser's 'user created' folder"
+ 850→ className="h-9 w-full gap-1.5 border-zinc-700"
+ 851→ >
+ 852→ Copy to my presets
+ 853→
+ 854→ )}
+ 855→ >
+ 856→ ) : folderReadOnly ? (
+ 857→ <>
+ 858→
+ 859→ void exportZip()}
+ 864→ title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
+ 865→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 866→ >
+ 867→ Export .zip
+ 868→
+ 869→ importInputRef.current?.click()}
+ 874→ title="Import a .zip of .ava presets -- they land in your browser's 'user created' folder"
+ 875→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 876→ >
+ 877→ Import .zip
+ 878→
+ 879→
+ 880→ >
+ 881→ ) : (
+ 882→ <>
+ 883→
openDialog('createPreset', '')}
+ 887→ title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`}
+ 888→ className="h-9 w-full gap-1.5 border-zinc-700"
+ 889→ >
+ 890→ Create Preset
+ 891→
+ 892→
openDialog('newFolder', '')}
+ 896→ title={`Create a subfolder inside ${selectedFolder} (this browser)`}
+ 897→ className="h-9 w-full gap-1.5 border-zinc-700"
+ 898→ >
+ 899→ New Subfolder
+ 900→
+ 901→
+ 902→
openDialog('rename', folderName)}
+ 907→ title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} -- everything inside moves with it`}
+ 908→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 909→ >
+ 910→ Rename
+ 911→
+ 912→
+ 920→ Delete
+ 921→
+ 922→
+ 923→
+ 924→ void exportZip()}
+ 929→ title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
+ 930→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 931→ >
+ 932→ Export .zip
+ 933→
+ 934→ importInputRef.current?.click()}
+ 939→ title={`Import a .zip of .ava presets into ${selectedFolder} -- the tree inside the zip is preserved`}
+ 940→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
+ 941→ >
+ 942→ Import .zip
+ 943→
+ 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→
+ 961→ Random
+ 962→
+ 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→
+ 973→ Sequence
+ 974→
+ 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