133604ae-01f0-4bd7-96a5-69156cef7530

This commit is contained in:
Z User 2026-10-29 10:04:00 +00:00
parent 78bf025686
commit 80782609b2
9 changed files with 1144 additions and 100 deletions

View file

@ -2,14 +2,28 @@
// //
// Toolbar: Apply, Save (to the preset picked in the Presets tab, when a save // 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, // target is set), Save As…, Save as Default, Save to File, Load from File,
// New, Help. Editor: line-number gutter + monospace textarea (synced scroll, // Script⇢Hash / Hash⇢Script (v7.7 — share the script as one short Unicode
// Tab inserts two spaces), error lines flagged in the gutter. Status line // string and paste one back), New, Help. Editor: line-number gutter +
// under the editor shows compile status or runtime errors. // monospace textarea (synced scroll, Tab inserts two spaces), error lines
// flagged in the gutter. Status line under the editor shows compile status
// or runtime errors.
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, CircleHelp, Copy, Download, FilePlus2, FolderOpen, Play, Save, X } from 'lucide-react' import {
Check,
CircleHelp,
ClipboardPaste,
Copy,
Download,
FilePlus2,
FolderOpen,
Play,
Save,
Share2,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@ -29,6 +43,7 @@ import {
} from '@/lib/store' } from '@/lib/store'
import { applyScriptText } from '@/components/ava/audio-source' import { applyScriptText } from '@/components/ava/audio-source'
import { HelpPanel } from '@/components/ava/help-panel' import { HelpPanel } from '@/components/ava/help-panel'
import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs'
import { sanitizeFileName } from '@/components/ava/shared' import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -62,6 +77,9 @@ export function ScriptTab() {
const [saveAsName, setSaveAsName] = useState('') const [saveAsName, setSaveAsName] = useState('')
const [saveAsBusy, setSaveAsBusy] = useState(false) const [saveAsBusy, setSaveAsBusy] = useState(false)
const [saveAsError, setSaveAsError] = useState<string | null>(null) const [saveAsError, setSaveAsError] = useState<string | null>(null)
// v7.7 — share-string dialogs
const [shareOutOpen, setShareOutOpen] = useState(false)
const [shareInOpen, setShareInOpen] = useState(false)
// debounced live validation (compile is cheap, scripts are small) // debounced live validation (compile is cheap, scripts are small)
useEffect(() => { useEffect(() => {
@ -332,6 +350,24 @@ export function ScriptTab() {
> >
<FolderOpen className="size-3.5" /> <span className="hidden sm:inline">Load from</span> File <FolderOpen className="size-3.5" /> <span className="hidden sm:inline">Load from</span> File
</Button> </Button>
<Button
size="sm"
variant="outline"
onClick={() => setShareOutOpen(true)}
className="h-9 gap-1.5 border-zinc-700"
title="Turn this script into one short shareable string — post it anywhere"
>
<Share2 className="size-3.5 text-emerald-400/90" /> Script⇢Hash
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setShareInOpen(true)}
className="h-9 gap-1.5 border-zinc-700"
title="Paste a share string to restore its script into the editor"
>
<ClipboardPaste className="size-3.5 text-emerald-400/90" /> Hash⇢Script
</Button>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -450,6 +486,14 @@ export function ScriptTab() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* v7.7 — share-string dialogs */}
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
<HashToScriptDialog
open={shareInOpen}
onOpenChange={setShareInOpen}
onImport={(text) => setScriptText(text)}
/>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -3,6 +3,13 @@
// the server) and applies instantly. The Native tab that previously lived // the server) and applies instantly. The Native tab that previously lived
// in this slot is parked (see control-panel.tsx) and returns unchanged // in this slot is parked (see control-panel.tsx) and returns unchanged
// when the project nears completion. // when the project nears completion.
//
// v7.7: the Music Services log in / "log out of all" section is PARKED
// (MUSIC_SERVICES_UI below). Stream-a-Link already plays for everyone
// through the operator's built-in house account, so the section was noise —
// it returns (reworked) in a later update. Nothing server-side is affected
// by this flag: saved visitor logins keep riding along with resolves, and
// the house account keeps working exactly as before.
'use client' 'use client'
@ -31,6 +38,14 @@ import {
import { useToast } from '@/hooks/use-toast' import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/**
* v7.7 — Music Services login UI parked. The built-in house account already
* powers Stream-a-Link for every visitor, so the log in / "log out of all"
* section is hidden until it is reworked (the underlying session storage,
* resolve auth attach and house-account behavior all keep working).
*/
const MUSIC_SERVICES_UI = false
const emptySubscribe = () => () => {} const emptySubscribe = () => () => {}
function useMounted() { function useMounted() {
return useSyncExternalStore(emptySubscribe, () => true, () => false) return useSyncExternalStore(emptySubscribe, () => true, () => false)
@ -84,7 +99,7 @@ export function SettingsTab() {
applyAccent(DEFAULT_ACCENT) applyAccent(DEFAULT_ACCENT)
} }
// ---- music service logins (v6.6) ---- // ---- music service logins (v6.6; UI parked in v7.7) ----
const [sessions, setSessions] = useState<SessionSummary | null>(null) const [sessions, setSessions] = useState<SessionSummary | null>(null)
const [loginOpen, setLoginOpen] = useState(false) const [loginOpen, setLoginOpen] = useState(false)
const [chipService, setChipService] = useState<ServiceId | null>(null) const [chipService, setChipService] = useState<ServiceId | null>(null)
@ -94,6 +109,7 @@ export function SettingsTab() {
const [house, setHouse] = useState<Partial<Record<ServiceId, boolean>> | null>(null) const [house, setHouse] = useState<Partial<Record<ServiceId, boolean>> | null>(null)
useEffect(() => { useEffect(() => {
if (!MUSIC_SERVICES_UI) return
let cancelled = false let cancelled = false
void listMusicSessions().then((s) => { void listMusicSessions().then((s) => {
if (!cancelled) setSessions(s) if (!cancelled) setSessions(s)
@ -244,99 +260,103 @@ export function SettingsTab() {
</div> </div>
</section> </section>
<Separator /> {MUSIC_SERVICES_UI && (
<>
<Separator />
<section aria-label="Music services"> <section aria-label="Music services">
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500"> <h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
<Music4 className="size-3.5" aria-hidden /> <Music4 className="size-3.5" aria-hidden />
Music services Music services
</h4> </h4>
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500"> <p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
Log in to your own accounts — the Link button then opens your playlists and liked music Log in to your own accounts — the Link button then opens your playlists and liked music
with your account, so you never get stuck behind bot-detection walls. Login cookies are with your account, so you never get stuck behind bot-detection walls. Login cookies are
encrypted and stored on this computer only. encrypted and stored on this computer only.
</p> </p>
{houseNames.length > 0 && ( {houseNames.length > 0 && (
<p className="mt-2 flex items-start gap-1.5 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-emerald-300"> <p className="mt-2 flex items-start gap-1.5 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-emerald-300">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden /> <Check className="mt-0.5 size-3 shrink-0" aria-hidden />
Built-in account active — {houseNames.join(', ')} links play for everyone, no login Built-in account active — {houseNames.join(', ')} links play for everyone, no login
needed. Log in only to reach your own playlists, liked music and uploads. needed. Log in only to reach your own playlists, liked music and uploads.
</p> </p>
)} )}
<div className="mt-3 flex flex-wrap gap-1.5"> <div className="mt-3 flex flex-wrap gap-1.5">
{SERVICES.map((s) => { {SERVICES.map((s) => {
const active = Boolean(sessions?.loggedIn[s.id]) const active = Boolean(sessions?.loggedIn[s.id])
return ( return (
<button <button
key={s.id} key={s.id}
type="button" type="button"
onClick={() => {
setChipService(s.id)
setLoginOpen(true)
}}
title={active ? `${s.name} — logged in. Click to manage.` : `${s.name} — click to log in`}
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors',
active
? 'border-emerald-400/50 bg-emerald-400/10 text-emerald-200'
: 'border-zinc-800 bg-zinc-900/40 text-zinc-400 hover:border-zinc-700 hover:text-zinc-200',
)}
>
<span
className="size-2 shrink-0 rounded-full"
style={{ background: s.brandColor }}
aria-hidden
/>
{s.name}
{s.streamStatus === 'coming-soon' && (
<span className="rounded bg-amber-400/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-amber-300">
soon
</span>
)}
{active ? (
<Check className="size-3 text-emerald-300" aria-hidden />
) : (
<span className="text-[9px] text-zinc-600" aria-hidden>
—
</span>
)}
</button>
)
})}
</div>
{sessions?.unavailable && (
<p className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-amber-300">
This browser context is not secure (https), so login cookies cannot be stored here.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
onClick={() => { onClick={() => {
setChipService(s.id) setChipService(null)
setLoginOpen(true) setLoginOpen(true)
}} }}
title={active ? `${s.name} — logged in. Click to manage.` : `${s.name} — click to log in`} className="h-9 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors',
active
? 'border-emerald-400/50 bg-emerald-400/10 text-emerald-200'
: 'border-zinc-800 bg-zinc-900/40 text-zinc-400 hover:border-zinc-700 hover:text-zinc-200',
)}
> >
<span <LogIn className="size-3.5" aria-hidden />
className="size-2 shrink-0 rounded-full" Log in
style={{ background: s.brandColor }} </Button>
aria-hidden {anyLoggedIn && (
/> <Button
{s.name} variant="outline"
{s.streamStatus === 'coming-soon' && ( onClick={() => void logoutAll()}
<span className="rounded bg-amber-400/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-amber-300"> className="h-9 gap-1.5 border-zinc-700 text-xs"
soon title="Remove every saved music login cookie from this browser"
</span> >
)} <LogOut className="size-3.5" aria-hidden />
{active ? ( Log out of all
<Check className="size-3 text-emerald-300" aria-hidden /> </Button>
) : ( )}
<span className="text-[9px] text-zinc-600" aria-hidden> </div>
— </section>
</span> </>
)} )}
</button>
)
})}
</div>
{sessions?.unavailable && (
<p className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-amber-300">
This browser context is not secure (https), so login cookies cannot be stored here.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setChipService(null)
setLoginOpen(true)
}}
className="h-9 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
<LogIn className="size-3.5" aria-hidden />
Log in
</Button>
{anyLoggedIn && (
<Button
variant="outline"
onClick={() => void logoutAll()}
className="h-9 gap-1.5 border-zinc-700 text-xs"
title="Remove every saved music login cookie from this browser"
>
<LogOut className="size-3.5" aria-hidden />
Log out of all
</Button>
)}
</div>
</section>
<Separator /> <Separator />
@ -355,14 +375,16 @@ export function SettingsTab() {
</Button> </Button>
</div> </div>
<ServiceLoginDialog {MUSIC_SERVICES_UI && (
open={loginOpen} <ServiceLoginDialog
onOpenChange={setLoginOpen} open={loginOpen}
initialService={chipService} onOpenChange={setLoginOpen}
sessions={sessions} initialService={chipService}
houseServices={house} sessions={sessions}
onChanged={refreshSessions} houseServices={house}
/> onChanged={refreshSessions}
/>
)}
</div> </div>
) )
} }

View file

@ -0,0 +1,317 @@
// AVA — Script ⇢ Hash share dialogs (v7.7).
//
// Two dialogs used by the Script tab toolbar:
// • ScriptToHashDialog — encodes the current editor script into ONE
// shareable Unicode string (see lib/ava/share-codec.ts for the format),
// shows it with a Copy button, live stats and the symbol legend.
// Compiling is NOT required — sharing a broken script to get it
// debugged is a feature.
// • HashToScriptDialog — pastes a share string back; on success it shows
// what came out (name, size, compile status) and loads the script into
// the editor exactly as it was, ready to Apply / Save As….
'use client'
import { useMemo, useState } from 'react'
import { ArrowRightLeft, Check, ClipboardPaste, CircleHelp, FileCode2, Share2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { decodeScriptHash, encodeScriptHash, SHARE_LEGEND } from '@/lib/ava/share-codec'
import { validateScript } from '@/lib/ava/script'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
/** Copy text to the clipboard with a legacy fallback. */
async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
ta.remove()
return ok
} catch {
return false
}
}
}
// ── Script ⇢ Hash ─────────────────────────────────────────────────────────
export function ScriptToHashDialog({
open,
onOpenChange,
scriptText,
}: {
open: boolean
onOpenChange: (open: boolean) => void
scriptText: string
}) {
const { toast } = useToast()
const [copied, setCopied] = useState(false)
const encoded = useMemo(() => encodeScriptHash(scriptText), [scriptText])
const savedPct = Math.round((1 - encoded.hashLength / Math.max(1, encoded.scriptLength)) * 100)
const empty = scriptText.trim() === ''
async function copy() {
const ok = await copyText(encoded.hash)
if (ok) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} else {
toast({
title: 'Copy failed',
description: 'Select the string in the box and copy it manually (⌘/Ctrl+C).',
variant: 'destructive',
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Share2 className="size-4 text-emerald-400" aria-hidden />
Script ⇢ Hash
</DialogTitle>
<DialogDescription>
Your script as ONE shareable string — post it anywhere (chat, social, forums). Anyone
pastes it back with Hash⇢Script and gets your script exactly as it is now.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
readOnly
value={encoded.hash}
aria-label="The share string for this script"
onFocus={(e) => e.currentTarget.select()}
rows={5}
spellCheck={false}
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-emerald-200/90"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-mono text-[11px] text-zinc-500" role="status">
{encoded.hashLength} characters
{savedPct > 0 ? (
<span className="text-emerald-400"> · {savedPct}% shorter than the script</span>
) : null}
{empty ? <span className="text-amber-400"> · the editor is empty</span> : null}
</p>
<Button
onClick={() => void copy()}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
{copied ? <Check className="size-3.5" /> : <ClipboardPaste className="size-3.5" />}
{copied ? 'Copied!' : 'Copy string'}
</Button>
</div>
<p className="rounded-md border border-zinc-800 bg-zinc-900/40 px-2.5 py-2 text-[11px] leading-relaxed text-zinc-400">
Built-in references keep it short:{' '}
<span className="font-mono text-zinc-300">
{SHARE_LEGEND.map((l) => `${l.symbol} = ${l.meaning}`).join(' · ')}
</span>
. The string works even if the script doesn&apos;t compile — perfect for asking for
help or sharing work in progress.
</p>
</div>
<DialogFooter>
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Done
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── Hash ⇢ Script ─────────────────────────────────────────────────────────
interface Decoded {
script: string
name: string | null
lines: number
compiles: boolean
compileNote: string
}
export function HashToScriptDialog({
open,
onOpenChange,
onImport,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** Load the decoded script into the editor (script-tab wires this). */
onImport: (script: string) => void
}) {
const { toast } = useToast()
const [input, setInput] = useState('')
const [error, setError] = useState<string | null>(null)
const [decoded, setDecoded] = useState<Decoded | null>(null)
// Reset whenever the dialog is (re)opened. The toolbar button sets the
// parent's open state directly, so Radix's onOpenChange never fires for
// it — resetting during render on the closed→open transition is the
// React-blessed pattern (re-renders immediately, no effect needed).
const [wasOpen, setWasOpen] = useState(false)
if (open && !wasOpen) {
setWasOpen(true)
setInput('')
setError(null)
setDecoded(null)
} else if (!open && wasOpen) {
setWasOpen(false)
}
function tryDecode() {
const text = input.trim()
if (!text) {
setError('Paste a share string first.')
setDecoded(null)
return
}
const result = decodeScriptHash(text)
if (!result.ok) {
setError(result.error)
setDecoded(null)
return
}
setError(null)
const v = validateScript(result.script)
const meta = v.meta
const lines = result.script.split('\n').length
const compiles = v.ok
setDecoded({
script: result.script,
name: meta && meta.name !== 'Untitled' ? meta.name : result.name,
lines,
compiles,
compileNote: v.ok
? `Compiles OK — mode: ${meta?.mode ?? '?'}`
: v.error
? `Doesn't compile (yet) — [${v.error.section}] line ${v.error.line}: ${v.error.message}`
: "Doesn't compile (yet)",
})
}
function load() {
if (!decoded) return
onImport(decoded.script)
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.'
}`,
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="size-4 text-emerald-400" aria-hidden />
Hash ⇢ Script
</DialogTitle>
<DialogDescription>
Paste a share string someone posted — the script it carries reappears in the editor,
exactly as they wrote it, ready to test or save.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Textarea
value={input}
onChange={(e) => {
setInput(e.target.value)
setError(null)
}}
placeholder={'My Script:[▲…]'}
aria-label="Paste a share string here"
rows={5}
spellCheck={false}
autoFocus
className="max-h-[240px] min-h-[110px] resize-none border-zinc-700 bg-zinc-950 font-mono text-xs leading-relaxed text-zinc-200"
/>
{error && (
<p className="flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed text-rose-400" role="alert">
<X className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{error}
</p>
)}
{decoded && (
<div className="space-y-2 rounded-lg border border-emerald-400/25 bg-emerald-400/5 p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-emerald-200">
<FileCode2 className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{decoded.name ?? 'Untitled'}</span>
</span>
<span className="font-mono text-[11px] text-zinc-400">
{decoded.lines} lines · {decoded.script.length} characters
</span>
</div>
<p
className={cn(
'flex items-start gap-1.5 break-words font-mono text-[11px] leading-relaxed',
decoded.compiles ? 'text-emerald-400' : 'text-amber-400',
)}
>
{decoded.compiles ? (
<Check className="mt-0.5 size-3.5 shrink-0" aria-hidden />
) : (
<CircleHelp className="mt-0.5 size-3.5 shrink-0" aria-hidden />
)}
{decoded.compileNote}
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" className="h-9 border-zinc-700 text-xs" onClick={() => onOpenChange(false)}>
Cancel
</Button>
{!decoded ? (
<Button
onClick={tryDecode}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<ArrowRightLeft className="size-3.5" /> Decode
</Button>
) : (
<Button
onClick={load}
className="h-9 gap-1.5 bg-emerald-400 px-3 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<FileCode2 className="size-3.5" /> Load into Editor
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -55,6 +55,8 @@ function AlertDialogContent({
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
// v7.5.1 width cap + v7.7 height cap (see dialog.tsx)
"grid-cols-[minmax(0,1fr)] max-h-[calc(100dvh-2rem)] overflow-y-auto",
// single column capped at the available width (same v7.5.1 fix as dialog.tsx) // single column capped at the available width (same v7.5.1 fix as dialog.tsx)
"grid-cols-[minmax(0,1fr)]", "grid-cols-[minmax(0,1fr)]",
className className

View file

@ -65,6 +65,11 @@ function DialogContent({
// nowrap content (truncated list rows, long URLs) can no longer // nowrap content (truncated list rows, long URLs) can no longer
// blow the implicit track out past the dialog border (v7.5.1) // blow the implicit track out past the dialog border (v7.5.1)
"grid-cols-[minmax(0,1fr)]", "grid-cols-[minmax(0,1fr)]",
// …and the HEIGHT gets the same protection (v7.7): content taller
// than the viewport (auto-growing textareas, long lists on short
// screens) scrolls inside the dialog instead of poking off-screen
// past the top/bottom edges
"max-h-[calc(100dvh-2rem)] overflow-y-auto",
className className
)} )}
{...props} {...props}

561
src/lib/ava/share-codec.ts Normal file
View file

@ -0,0 +1,561 @@
// AVA — Script ⇢ Hash share codec (v7.7)
//
// Turns a script into ONE short shareable Unicode string and back, exactly:
//
// Bass Bloom:[▲…symbols…①②③④⑤⑥⑦⑧]
// └─ label │└────────────┘└ check digits
// └ scheme marker (▲ = v1)
//
// The body is the script itself with every built-in word replaced by a
// single symbol reference — the user-facing legend (a few of these are
// named by design):
//
// ♤ = line return ♡ = beats ♠ = beatage
// ♦ = space ♣ = tab · = 2-space indent
// ♩ ♪ ♫ ♬ = [init] [frame] [beat] [point] ♯ = "# " comment
// ← = " = " → = " + " ↑ = " * " ↓ = " - " ⇦ ⇨ = "x = " / "y = "
// …plus every function (sin cos abs …) and audio variable (bass mid
// treb vol …) on its own symbol — see DICT below for the full table.
//
// Guarantees:
// • LOSSLESS — decode(encode(s)) === s for ANY text (comments, weird
// whitespace, even scripts that don't compile). Anything the dictionary
// doesn't cover passes through untouched, and every reserved symbol
// that legitimately appears in a script is escaped ("\♠" → literal ♠).
// • COPY-SAFE — the string contains no raw whitespace or control
// characters (all folded to symbols/control pictures), so chat apps and
// social sites can't collapse it. Emoji-variation selectors and
// zero-width chars that platforms inject while copying are stripped on
// decode (and escaped on encode, so a script that really contains them
// still round-trips).
// • SELF-CHECKING — the last 8 chars are check digits (①–⑯ as hex) over
// an FNV-1a digest of the script, so a string that was cut off by a
// character limit or altered in transit is rejected honestly instead
// of decoding into garbage.
//
// Compression layers (applied in order; decode applies them in reverse):
// 1. DICTIONARY — the built-in words above (biggest win per hit).
// 2. PAIR PACKING — any two ADJACENT characters from [0-9a-zA-Z_.] that
// the dictionary didn't consume fold into one common CJK code point
// (U+4E00 + 64·a + b). Identifiers, numbers and prose shrink to half
// their length while the semantic symbols stay visible around them.
// 3. everything else passes through literally (operators, punctuation).
//
// The "Name:" prefix is cosmetic (the script's own `name:` header rides
// inside the body untouched) — it just makes the string self-describing in
// a social feed.
/** Scheme marker for v1 strings (first char inside the brackets). */
const SCHEME_SYMBOL = '▲'
/** Escape char — prefixes any reserved symbol that appears literally. */
const ESCAPE = '\\'
/** One dictionary entry: raw script text → single symbol. */
interface DictEntry {
key: string
symbol: string
}
/**
* The substitution dictionary. Longest key wins at every position, so e.g.
* "beatage" → ♠ resolves before "beat" → ◉, and "red = " before "red".
* Symbols are single BMP code points, none of them ASCII.
*/
const DICT: DictEntry[] = [
// ── user-named core references ─────────────────────────────────────────
{ key: '\n', symbol: '♤' }, // line return
{ key: ' ', symbol: '♦' }, // space
{ key: 'beats', symbol: '♡' },
{ key: 'beatage', symbol: '♠' },
{ key: '\t', symbol: '♣' }, // tab
// ── whitespace runs ────────────────────────────────────────────────────
{ key: ' ', symbol: '▣' }, // 4-space indent
{ key: ' ', symbol: '·' }, // 2-space indent
{ key: '\r', symbol: '⏎' }, // carriage return (kept distinct from ♤)
// ── section headers (music notes) ─────────────────────────────────────
{ key: '[init]', symbol: '♩' },
{ key: '[frame]', symbol: '♪' },
{ key: '[beat]', symbol: '♫' },
{ key: '[point]', symbol: '♬' },
// ── comments ───────────────────────────────────────────────────────────
{ key: '# ', symbol: '♯' },
{ key: '#', symbol: '♭' },
{ key: '//', symbol: '♮' },
// ── header keys (chess) ────────────────────────────────────────────────
{ key: 'name', symbol: '♔' },
{ key: 'author', symbol: '♕' },
{ key: 'description', symbol: '♖' },
{ key: 'mode', symbol: '♗' },
{ key: 'blend', symbol: '♘' },
{ key: 'persist', symbol: '♙' },
{ key: 'linewidth', symbol: '♚' },
{ key: 'ysign', symbol: '♛' },
// ── header values (zodiac) ─────────────────────────────────────────────
{ key: 'lines', symbol: '♈' },
{ key: 'dots', symbol: '♉' },
{ key: 'solid', symbol: '♊' },
{ key: 'additive', symbol: '♋' },
{ key: 'alpha', symbol: '♌' },
// ── spaced operators (arrows) ──────────────────────────────────────────
{ key: ' = ', symbol: '←' },
{ key: ' + ', symbol: '→' },
{ key: ' * ', symbol: '↑' },
{ key: ' - ', symbol: '↓' },
{ key: ', ', symbol: '⇄' },
// ── statement openers (double arrows) ──────────────────────────────────
{ key: 'x = ', symbol: '⇦' },
{ key: 'y = ', symbol: '⇨' },
{ key: 'red = ', symbol: '⇧' },
{ key: 'green = ', symbol: '⇩' },
{ key: 'blue = ', symbol: '⇪' },
{ key: 'hue = ', symbol: '⇱' },
{ key: 't = ', symbol: '⇲' },
{ key: 'n = ', symbol: '⇴' },
// ── built-in variables ─────────────────────────────────────────────────
{ key: 'bass_att', symbol: '♎' },
{ key: 'mid_att', symbol: '♏' },
{ key: 'treb_att', symbol: '♐' },
{ key: 'bass', symbol: '♑' },
{ key: 'mid', symbol: '♒' },
{ key: 'treb', symbol: '♓' },
{ key: 'vol', symbol: '☽' },
{ key: 'beat', symbol: '◉' },
{ key: 'red', symbol: '☾' },
{ key: 'green', symbol: '☿' },
{ key: 'blue', symbol: '⚑' },
{ key: 'skip', symbol: '⚐' },
{ key: 'aspect', symbol: '⚶' },
{ key: 'dt', symbol: '◐' },
{ key: 'pi', symbol: '◑' },
{ key: 'tau', symbol: '◒' },
// ── functions ──────────────────────────────────────────────────────────
{ key: 'smoothstep', symbol: '⌒' },
{ key: 'gmegabuf', symbol: '✥' },
{ key: 'getkbmouse', symbol: '◶' },
{ key: 'megabuf', symbol: '✤' },
{ key: 'getspec', symbol: '▤' },
{ key: 'getosc', symbol: '▥' },
{ key: 'gettime', symbol: '◷' },
{ key: 'atan2', symbol: '⌗' },
{ key: 'log10', symbol: '㏑' },
{ key: 'invsqrt', symbol: '√' },
{ key: 'sigmoid', symbol: '∿' },
{ key: 'inrange', symbol: '⋔' },
{ key: 'hsvr', symbol: '⬥' },
{ key: 'hsvg', symbol: '⬦' },
{ key: 'hsvb', symbol: '⬧' },
{ key: 'above', symbol: '≻' },
{ key: 'below', symbol: '≺' },
{ key: 'asin', symbol: '⌠' },
{ key: 'acos', symbol: '⌡' },
{ key: 'atan', symbol: '⌐' },
{ key: 'ceil', symbol: '⌈' },
{ key: 'floor', symbol: '⌊' },
{ key: 'sqr', symbol: '□' },
{ key: 'sqrt', symbol: '∛' },
{ key: 'pow', symbol: '⌃' },
{ key: 'exp', symbol: 'ℯ' },
{ key: 'log', symbol: '㏒' },
{ key: 'round', symbol: '◎' },
{ key: 'int', symbol: '⊺' },
{ key: 'frac', symbol: '∵' },
{ key: 'sign', symbol: '±' },
{ key: 'bnot', symbol: '¬' },
{ key: 'band', symbol: '∧' },
{ key: 'bor', symbol: '∨' },
{ key: 'equal', symbol: '≟' },
{ key: 'lerp', symbol: '↝' },
{ key: 'clamp', symbol: '⊜' },
{ key: 'rand', symbol: '⚄' },
{ key: 'rnd', symbol: '⚅' },
{ key: 'sin', symbol: '◠' },
{ key: 'cos', symbol: '◡' },
{ key: 'tan', symbol: '◮' },
{ key: 'abs', symbol: '¦' },
{ key: 'min', symbol: '≪' },
{ key: 'max', symbol: '≫' },
{ key: 'if', symbol: '∴' },
{ key: 'loop', symbol: '↻' },
// ── very common literals ────────────────────────────────────────────────
{ key: '6.28318', symbol: '⌇' }, // 2π, written out in most scripts
{ key: '0.5', symbol: '†' },
{ key: '0.3', symbol: '‡' },
{ key: '0.25', symbol: '•' },
{ key: '0.55', symbol: '§' },
{ key: '0.45', symbol: '¶' },
{ key: '0.35', symbol: '※' },
{ key: '0.4', symbol: '⊤' },
{ key: '0.7', symbol: '⊥' },
{ key: '0.85', symbol: '⊢' },
{ key: '0.6', symbol: '⊣' },
{ key: '1.3', symbol: '⋈' },
{ key: '1.2', symbol: '⋉' },
{ key: ' / ', symbol: '∙' },
// ── near-universal user variables ──────────────────────────────────
{ key: 'hue', symbol: '⋊' },
{ key: 'rad', symbol: '⋅' },
{ key: 'flash', symbol: '∘' },
]
/**
* Pair-packing layer: two adjacent characters from this 64-char alphabet
* fold into ONE code point at PAIR_BASE + 64·a + b (a common-CJK block —
* assigned since Unicode 1.1, text presentation, renders everywhere, and
* copy/paste-safe). The block is reserved, so CJK that legitimately appears
* in a script is escaped and still round-trips.
*/
const PAIR_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_.'
const PAIR_BASE = 0x4e00
const PAIR_END = PAIR_BASE + PAIR_ALPHABET.length * PAIR_ALPHABET.length - 1 // 0x5dff
const PAIR_INDEX = new Map<string, number>()
for (let i = 0; i < PAIR_ALPHABET.length; i++) PAIR_INDEX.set(PAIR_ALPHABET[i], i)
// ── derived tables (built once, correctness-checked by construction) ──────
/** Longest key first, so greedy matching always prefers the longest hit. */
const DICT_SORTED = [...DICT].sort((a, b) => b.key.length - a.key.length)
/** Entries grouped by first char, longest first — the encode lookup. */
const BY_FIRST_CHAR = new Map<string, DictEntry[]>()
for (const entry of DICT_SORTED) {
const list = BY_FIRST_CHAR.get(entry.key[0])
if (list) list.push(entry)
else BY_FIRST_CHAR.set(entry.key[0], [entry])
}
/** Symbol → raw text (decode lookup). */
const SYMBOL_TO_TEXT = new Map<string, string>()
for (const { key, symbol } of DICT) SYMBOL_TO_TEXT.set(symbol, key)
/**
* Every character that can never appear raw in an encoded body — emitted
* by the encoder only with a "\" escape in front. Aside from the dictionary
* symbols and the scheme marker this covers the checksum alphabet (①–⑯),
* the control-picture range (␀–␡), and the invisible characters chat
* platforms inject (variation selectors, zero-widths) which decode strips.
*/
const RESERVED = new Set<string>([ESCAPE, SCHEME_SYMBOL])
for (const { symbol } of DICT) RESERVED.add(symbol)
for (let c = 0x2460; c <= 0x246f; c++) RESERVED.add(String.fromCharCode(c)) // ①–⑯
for (let c = 0x2400; c <= 0x2421; c++) RESERVED.add(String.fromCharCode(c)) // ␀–␡
for (const c of ['\uFE0E', '\uFE0F', '\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF']) {
RESERVED.add(c)
}
for (let c = PAIR_BASE; c <= PAIR_END; c++) RESERVED.add(String.fromCharCode(c))
/** Noise chars platforms inject into copies (VS16 after emoji-capable
* symbols, zero-widths). The encoder NEVER emits these raw — a script that
* legitimately contains one gets a dedicated symbol from NOISE_SYMBOLS —
* so the decoder can strip every raw occurrence as injected mangling. */
const PLATFORM_NOISE = /[\uFE0E\uFE0F\u200B\u200C\u200D\u2060\uFEFF]/g
/** Symbol per legitimate noise char (round-trips the original text). */
const NOISE_SYMBOLS = new Map<string, string>([
['\uFE0E', '⌸'],
['\uFE0F', '⌹'],
['\u200B', '⌺'],
['\u200C', '⌻'],
['\u200D', '⌼'],
['\u2060', '⌽'],
['\uFEFF', '⌾'],
])
const NOISE_CHAR_FROM_SYMBOL = new Map<string, string>(
[...NOISE_SYMBOLS.entries()].map(([c, s]) => [s, c]),
)
for (const s of NOISE_SYMBOLS.values()) RESERVED.add(s)
/** Whitespace or platform noise — safe to shave off the string's edges. */
function isEdgeTrimable(c: string): boolean {
return c === ' ' || c === '\n' || c === '\r' || c === '\t' || PLATFORM_NOISE.test(c)
}
// ── checksum ──────────────────────────────────────────────────────────────
/** FNV-1a 32-bit over the UTF-16 code units — fast, tiny, plenty here. */
function fnv1a(s: string): number {
let h = 0x811c9dc5
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 0x01000193) >>> 0
}
return h >>> 0
}
/** Encode a 32-bit digest as 8 "circled digit" hex chars (① = 0 … ⑯ = f). */
function checkDigits(value: number): string {
let out = ''
for (let shift = 28; shift >= 0; shift -= 4) {
out += String.fromCharCode(0x2460 + ((value >>> shift) & 0xf))
}
return out
}
function isCheckDigit(c: string): boolean {
const cc = c.charCodeAt(0)
return cc >= 0x2460 && cc <= 0x246f
}
// ── name label ────────────────────────────────────────────────────────────
/** Pull the `name:` header out of a script (before the first [section]). */
function extractScriptName(script: string): string {
for (const line of script.split('\n')) {
const t = line.trim()
if (t.startsWith('[')) break
const m = /^name\s*:\s*(.*)$/i.exec(t)
if (m) {
let v = m[1].trim()
if (
v.length >= 2 &&
((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
) {
v = v.slice(1, -1).trim()
}
if (v) return v
}
}
return ''
}
/** Make the label safe for the `Name:[…]` wrapper (no : [ ] or newlines). */
function sanitizeShareName(raw: string): string {
const cleaned = raw
.replace(/[\[\]:\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 48)
.trim()
return cleaned || 'AVA'
}
// ── encode ────────────────────────────────────────────────────────────────
/** Substitute the dictionary into the script text (escaping as needed). */
function encodeBody(script: string): string {
let out = ''
let i = 0
const n = script.length
while (i < n) {
const c = script[i]
// legitimate noise chars → their dedicated symbols (never raw)
const noiseSym = NOISE_SYMBOLS.get(c)
if (noiseSym !== undefined) {
out += noiseSym
i++
continue
}
// reserved / escape char → escaped literal
if (c === ESCAPE || RESERVED.has(c)) {
out += ESCAPE + c
i++
continue
}
// dictionary match (longest first) — also folds \n \r \t into symbols
const entries = BY_FIRST_CHAR.get(c)
if (entries) {
let matched = false
for (const e of entries) {
if (script.startsWith(e.key, i)) {
out += e.symbol
i += e.key.length
matched = true
break
}
}
if (matched) continue
}
// other control characters → Unicode control pictures (copy-safe)
const cc = c.charCodeAt(0)
if (cc < 0x20 || cc === 0x7f) {
out += cc === 0x7f ? '\u2421' : String.fromCharCode(0x2400 + cc)
i++
continue
}
// pair packing — two adjacent [0-9a-zA-Z_.] chars → one CJK code point
const ai = PAIR_INDEX.get(c)
if (ai !== undefined) {
const bi = PAIR_INDEX.get(script[i + 1])
if (bi !== undefined) {
out += String.fromCharCode(PAIR_BASE + ai * 64 + bi)
i += 2
continue
}
}
out += c
i++
}
return out
}
export interface ScriptHash {
/** The full share string: `Name:[▲body①①①…]`. */
hash: string
/** The label used in the prefix (sanitized). */
name: string
/** script.length (UTF-16 units) — for the stats line. */
scriptLength: number
/** hash.length — for the stats line. */
hashLength: number
}
/**
* Encode a script into its share string. Never throws, never validates the
* script — sharing a script that doesn't compile is a feature (ask for help,
* get it debugged). `name` overrides the label; by default the `name:`
* header is used, falling back to "AVA".
*/
export function encodeScriptHash(script: string, name?: string): ScriptHash {
const label = sanitizeShareName(name ?? extractScriptName(script))
const body = encodeBody(script)
const hash = `${label}:[${SCHEME_SYMBOL}${body}${checkDigits(fnv1a(script))}]`
return { hash, name: label, scriptLength: script.length, hashLength: hash.length }
}
// ── decode ────────────────────────────────────────────────────────────────
/** Expand an encoded body back into the original text. */
function decodeBody(body: string): string {
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i]
if (c === ESCAPE) {
const next = body[i + 1]
if (next !== undefined) {
out += next
i++
} else {
out += c // trailing lone escape — be lenient
}
continue
}
const cc = c.charCodeAt(0)
// control pictures → the control chars they stand for
if (cc >= 0x2400 && cc <= 0x2420) {
out += String.fromCharCode(cc - 0x2400)
continue
}
if (cc === 0x2421) {
out += '\u007f'
continue
}
// dictionary symbols → raw text
const expansion = SYMBOL_TO_TEXT.get(c)
if (expansion !== undefined) {
out += expansion
continue
}
// legitimate noise symbols → their original chars
const noiseChar = NOISE_CHAR_FROM_SYMBOL.get(c)
if (noiseChar !== undefined) {
out += noiseChar
continue
}
// packed pairs → the two original characters
const pc = c.charCodeAt(0)
if (pc >= PAIR_BASE && pc <= PAIR_END) {
const k = pc - PAIR_BASE
out += PAIR_ALPHABET[Math.floor(k / 64)] + PAIR_ALPHABET[k % 64]
continue
}
out += c
}
return out
}
export type HashDecodeResult =
| { ok: true; script: string; name: string | null }
| { ok: false; error: string }
/**
* Decode a pasted share string back into the original script. Accepts the
* full `Name:[…]` form or a bare `[…]` body; strips the invisible
* characters platforms sprinkle into copies; and verifies the check digits
* so a truncated or altered string is rejected instead of decoding into a
* corrupted script.
*/
export function decodeScriptHash(input: string): HashDecodeResult {
if (input.length > 300_000) {
return { ok: false, error: 'That text is far too long to be a share string.' }
}
// Trim whitespace AND stray noise from the edges (the encoder never emits
// either raw, so edge removal is always safe). Interior noise is handled
// escape-aware in decodeBody / name cleanup below.
let text = input
while (text.length > 0 && (isEdgeTrimable(text[0]))) text = text.slice(1)
while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1)
if (
text.length >= 2 &&
((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))
) {
text = text.slice(1, -1)
while (text.length > 0 && isEdgeTrimable(text[text.length - 1])) text = text.slice(0, -1)
}
// Any raw noise left is injected mangling (legit noise is symbol-coded),
// so it can be stripped everywhere — including between the escape char
// and its target — without ever eating original content.
text = text.replace(PLATFORM_NOISE, '')
// `Name:[▲…]` or bare `[▲…]`
let payload: string | null = null
let name: string | null = null
const full = /^([\s\S]*?):\[(▲[\s\S]*)\]$/.exec(text)
if (full) {
name = full[1].trim() || null
payload = full[2]
} else if (/^\[(▲[\s\S]*)\]$/.test(text)) {
payload = text.slice(1, -1)
}
if (payload === null) {
// a string that still shows the "…:[▲" signature was cut off somewhere
// after the opening — a character limit ate the end (and its "]")
const looksCutOff = /:\[▲/.test(text) || /^\[▲/.test(text)
return {
ok: false,
error: looksCutOff
? 'This share string looks cut off — it should end with "]". Copy the whole string and try again.'
: 'This does not look like an OVE share string — it should read like "My Script:[▲…]".',
}
}
// trailing 8 check digits
if (payload.length < 9 || !isCheckDigit(payload[payload.length - 8])) {
return {
ok: false,
error: 'This share string looks cut off — the 8 check digits at its end are missing. Copy the whole string and try again.',
}
}
const digits = payload.slice(-8)
for (const d of digits) {
if (!isCheckDigit(d)) {
return {
ok: false,
error: 'This share string looks cut off — the 8 check digits at its end are missing. Copy the whole string and try again.',
}
}
}
const body = payload.slice(1, -8)
const script = decodeBody(body)
if (checkDigits(fnv1a(script)) !== digits) {
return {
ok: false,
error: 'The check digits do not match — the string was altered or cut off while copying. Copy the whole string and try again.',
}
}
return { ok: true, script, name }
}
// ── legend for the UI ─────────────────────────────────────────────────────
/** A few representative symbol meanings, shown in the share dialogs. */
export const SHARE_LEGEND: ReadonlyArray<{ symbol: string; meaning: string }> = [
{ symbol: '♤', meaning: 'line return' },
{ symbol: '♦', meaning: 'space' },
{ symbol: '♡', meaning: 'beats' },
{ symbol: '♠', meaning: 'beatage' },
{ symbol: '♩ ♪ ♫ ♬', meaning: '[init] [frame] [beat] [point]' },
{ symbol: '◠ ◡', meaning: 'sin cos' },
]

View file

@ -33,6 +33,28 @@ export interface ReleaseNote {
} }
export const RELEASE_NOTES: ReleaseNote[] = [ export const RELEASE_NOTES: ReleaseNote[] = [
{
version: '7.7',
name: 'Share scripts as one string',
changes: [
{
kind: 'added',
text: 'Script⇢Hash / Hash⇢Script — turn your script into ONE short Unicode string (♠ ♡ ♦ built-in references, ~half the length) and post it anywhere; anyone pastes it back and your script returns exactly as written.',
},
{
kind: 'added',
text: 'Share strings work even when the script doesn\u2019t compile — share work in progress to get help, advice or debugging.',
},
{
kind: 'added',
text: 'Every string ends in check digits, so one that was cut off by a character limit is refused honestly instead of decoding into garbage.',
},
{
kind: 'removed',
text: 'The Music Services log in / “log out of all” section in Settings — the built-in account already plays links for everyone (it returns, reworked, in a later update).',
},
],
},
{ {
version: '7.6.1', version: '7.6.1',
changes: [ changes: [

View file

@ -12,6 +12,25 @@
// warns in the console when the two drift apart. // warns in the console when the two drift apart.
// //
// History: // History:
// 7.7 — script sharing + settings declutter. Script⇢Hash / Hash⇢Script:
// two buttons in the Script tab turn the editor's script into ONE
// short shareable Unicode string ("Bass Bloom:[▲…①①①①①①①①]") and
// paste one back — the script returns exactly as written (lossless
// for ANY text, compiling NOT required, so WIP scripts can be
// shared for debugging). The string is built from built-in symbol
// references (♤ = line return, ♡ = beats, ♠ = beatage, ♦ = space,
// ♩♪♫♬ = the four sections, one symbol per function/variable, plus
// a pair-packing layer that folds leftover identifier/number text
// two chars per CJK code point) — ~50% shorter than the script on
// real presets. Copy-safe by construction (no raw whitespace or
// control chars; platform-injected variation selectors / zero-
// widths stripped on paste) and self-checking: 8 check digits (an
// FNV-1a digest rendered as ①–⑯) reject strings that were cut off
// by a character limit or altered in transit, instead of decoding
// into garbage. Also: the Music Services log in / "log out of all"
// section in Settings is parked (MUSIC_SERVICES_UI flag) — the
// built-in house account already powers Stream-a-Link for
// everyone; the section returns reworked in a later update
// 7.6.1 — header declutter: the Set Beat button is gone from the header // 7.6.1 — header declutter: the Set Beat button is gone from the header
// (tap-tempo lives in the Beat tab, and the Space shortcut works // (tap-tempo lives in the Beat tab, and the Space shortcut works
// anywhere — both unchanged). The beat indicator dot stays. // anywhere — both unchanged). The beat indicator dot stays.
@ -223,4 +242,4 @@
export const APP_NAME = 'Open Visualization Engine' export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE' export const APP_SHORT = 'OVE'
export const APP_VERSION = '7.6.1' export const APP_VERSION = '7.7'

View file

@ -2161,3 +2161,55 @@ E2E (agent-browser + VLM, ALL PASS):
Stage Summary: Stage Summary:
- v7.6 shipped: the header now carries a "What's New!" button (left of the theme switch) opening the full release-notes dialog — 26 releases from v4.0 to today, tagged Added/Improved/Fixed/Removed, newest first, current version badged, with an unseen-updates dot. The release-notes module is now part of the version-bump workflow (standing rule + dev-mode drift guard). - v7.6 shipped: the header now carries a "What's New!" button (left of the theme switch) opening the full release-notes dialog — 26 releases from v4.0 to today, tagged Added/Improved/Fixed/Removed, newest first, current version badged, with an unseen-updates dot. The release-notes module is now part of the version-bump workflow (standing rule + dev-mode drift guard).
---
Task ID: 35
Agent: main (Z.ai Code)
Task: v7.6.1 header declutter (user request): remove the "Set Beat" button from the header — tap-tempo lives in the Beat tab, and Space works anywhere; the beat indicator stays. (Worklog entry restored retroactively — the code shipped on schedule but this entry was lost in a session cutoff.)
Work Log:
- header.tsx: Set Beat button removed; comment updated (v7.6.1 note). BeatDot + BPM readout unchanged; Beat tab's tap-tempo and the global Space shortcut untouched.
- version.ts 7.6.1 + release-notes.ts 7.6.1 entry (removed). lint clean; tsc clean (pre-existing examples/skills only).
E2E (agent-browser, ALL PASS):
- Header no longer renders the button; Beat tab still offers Set Beat tap-tempo.
- Follow-up measurement at 320 px (this session): scrollWidth 320 = clientWidth — the pre-existing ≤360 px header overflow is GONE (the BPM/BeatDot/Set-Beat row was the last overwide group).
Stage Summary:
- v7.6.1 shipped: header decluttered; the beat indicator and BPM remain, tap-tempo is one tab away.
---
Task ID: 36
Agent: main (Z.ai Code)
Task: v7.7 (user request, two parts): (1) disable the "Music Services — log in / log out of all" section in Settings (the built-in burner/house account already powers Stream-a-Link; section returns reworked later); (2) script sharing — Script⇢Hash / Hash⇢Script buttons in the Script tab that turn any script into ONE short shareable Unicode string (letters, numbers, symbols like ♠♡♦♤♣) and paste it back losslessly, so users can share custom scripts on any social site instead of passing files around. Non-compiling scripts must be shareable too (debugging/advice use case).
Work Log:
- settings-tab.tsx: MUSIC_SERVICES_UI = false feature flag gates the whole Music services section + ServiceLoginDialog + the sessions/house fetch effect (code intact, one line to re-enable). Nothing server-side touched: saved visitor logins keep riding with resolves, the house account keeps powering Stream-a-Link. Appearance sections + separator layout verified without the section.
- src/lib/ava/share-codec.ts (NEW, pure client-side codec):
* Format: `Name:[▲body①①①①①①①①]` — cosmetic label (sanitized script name, [ ] : stripped, ≤48 chars, fallback AVA), ▲ = scheme v1 marker, 8 trailing check digits (①–⑯ as hex of an FNV-1a 32-bit digest of the script).
* Layer 1 DICTIONARY (104 entries, longest-key-first): user-named symbols honored — ♤ = \n, ♡ = beats, ♠ = beatage, ♦ = space, ♣ = tab — plus · = 2-space indent, ▣ = 4-space, ⏎ = \r; sections as music notes ♩[init] ♪[frame] ♫[beat] ♬[point]; comments ♯♭♮; header keys as chess, values as zodiac; spaced ops ← = " = ", → +, ↑ *, ↓ -, ⇄ ", "; statement openers ⇦"x = " ⇨"y = " ⇧"red = " ⇩"green = " ⇪"blue = " ⇱"hue = " ⇲"t = " ⇴"n = "; all 47 functions + audio vars (bass/mid/treb/att variants/vol/beat/red/green/blue/skip/aspect/dt/pi/tau) on individual symbols; frequency-tuned literals from a 207-script corpus analysis (0.5/0.3/0.25/0.55/… , 6.28318 = 2π, hue, rad, flash, " / ").
* Layer 2 PAIR PACKING: any two adjacent [0-9a-zA-Z_.] chars the dictionary didn't consume fold into one common-CJK code point (U+4E00 + 64a + b, block U+4E00–U+5DFF) — identifiers, numbers and prose shrink to half length while semantic symbols stay visible.
* Losslessness machinery: every reserved symbol appearing literally in a script is escaped ("\♠"); control chars → Unicode control pictures ␀–␡; legitimate variation-selectors/zero-widths get dedicated symbols ⌸⌹⌺⌻⌼⌽⌾ so decode can strip every RAW occurrence as platform-injected mangling (VS16 after emoji-capable suits, ZWSP) — including between the escape char and its target; lone surrogates pass through; \r\n round-trips exactly.
* Decode: trims edges + wrapping quotes, strips raw noise, accepts full `Name:[…]` or bare `[…]`, verifies the 8 check digits (truncation → "looks cut off — should end with ]" via the `:[▲` signature, alteration → "check digits do not match"), 300k length guard.
- share-dialogs.tsx (NEW): ScriptToHashDialog (read-only auto-selected textarea, max-h-240px, Copy with clipboard+execCommand fallback and 2s "Copied!" swap, "N characters · X% shorter" stats, built-in-references legend, empty-editor hint) and HashToScriptDialog (paste box, Decode → emerald summary panel: name · lines · chars · compile status via validateScript — compiles OK or "Doesn't compile (yet)" with the error, per the debugging-share use case — then Load into Editor). Re-open state reset uses the render-phase closed→open transition pattern (Radix never fires onOpenChange for programmatic toolbar opens — first E2E pass caught the stale-state bug).
- script-tab.tsx: two outline buttons (emerald-tinted Share2/ClipboardPaste icons) between Load from File and New; ScriptToHash encodes the live editor text (no compile gate), HashToScript loads via setScriptText + toast (Apply/Save stay manual, per "reappear in the script box, then tested/saved").
- Shared dialog HEIGHT cap (v7.5.1 sibling): ui/dialog.tsx + ui/alert-dialog.tsx DialogContent gain max-h-[calc(100dvh-2rem)] overflow-y-auto — the shadcn Textarea's field-sizing-content auto-grew the 679-char hash box past the viewport on mobile (dialog top measured at -93px in an 812px viewport); share textareas additionally capped at max-h-[240px].
- version.ts 7.7 + history; release-notes.ts 7.7 entry (3× added incl. the WIP-sharing and check-digits notes, 1× removed for Music Services). lint clean; tsc clean (pre-existing examples/skills only).
HEADLESS VALIDATION (/home/z/tmp-validate/share-codec, ALL PASS):
- Round-trip EXACT on 207 scripts (90 tools/converted .ava + 6 rust-agent presets + presets.ts inline + 105 DB built-ins): 136,046 → 69,523 chars = 49% shorter overall (best 56%, worst 28%); determinism verified per script.
- Adversarial "soup" (every reserved symbol, all card suits, chess, zodiac, arrows, circled digits, control pictures, backslashes, CRLF, raw control chars, VS16/ZWJ sequences, emoji incl. ZWJ families, combining marks, surrogate edges): exact round-trip.
- 3,000-string fuzz over a hostile alphabet: 3,000/3,000 exact. CJK-comment scripts (colliding with the pair-packing block) and literal pair-block content round-trip via the escape path.
- Decode rejects: truncation (−1/−5/−20 chars), flipped body char (checksum), garbage/empty/malformed inputs; accepts: padded+quoted, bare body, VS16+ZWSP mangled (injected between EVERY char), name with colons/brackets (sanitized), >48-char names (capped).
BROWSER E2E (agent-browser + VLM, ALL PASS):
- Script tab shows both buttons; Script⇢Hash opens with `Bass Bloom:[▲…③⑬⑧⑭①⑧⑩⑪]` — 679 chars, "49% shorter than the script", semantic symbols + CJK pairs visible; Copy click → "Copied!" (clipboard API present).
- Full UI round-trip: hash captured → Hash⇢Script pasted → Decode → panel "Bass Bloom · 56 lines · 1341 characters · Compiles OK — mode: dots" → Load into Editor → editor content BYTE-IDENTICAL to the original (1341/1341 chars, string equality true).
- Errors: garbage → "does not look like an OVE share string…"; truncated (−15, loses "]") → "looks cut off — it should end with "]" (signature-based message); altered → checksum message.
- Re-open resets paste state (bug fix verified); Settings tab: no Music services section/heading/log-in buttons, Appearance intact (visible panel text verified).
- Mobile 375 px: toolbar wraps with both buttons on-screen, no horizontal overflow; both dialogs fit (16–359 px wide, Script⇢Hash 69–743 px in 812 px viewport after the height-cap fix; textarea ≤ 240 px, scrolls internally). 320 px header: scrollWidth 320 = clientWidth (v7.6.1's removal fixed the old ≤360 px overflow — re-measured as promised).
- VLM (dark, Script⇢Hash): nothing crosses the border, string/symbols/ Copy/stats all present, readable, no defects. VLM (light, Hash⇢Script): title, placeholder "My Script:[▲…]", Cancel/Decode well-formed, contrast good on white. VLM (mobile): fully visible, nothing cut off.
- Fresh reload: console 0 errors/0 warnings (release-notes drift guard silent — v7.7 entries match); dev.log clean (compile + 200s only). Test-browser localStorage cleared afterwards.
Stage Summary:
- v7.7 shipped: scripts are now shareable as single Unicode strings — ~half the length of the script, self-checking, copy/paste-survivable, lossless for ANY text (compiling not required) — and the Settings tab is decluttered while the house account keeps Stream-a-Link working for everyone. The shared dialog height cap protects every dialog app-wide from tall content on short screens.