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
// target is set), Save As…, Save as Default, Save to File, Load from File,
// New, Help. Editor: line-number gutter + 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.
// 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
// flagged in the gutter. Status line under the editor shows compile status
// or runtime errors.
'use client'
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 {
Dialog,
@ -29,6 +43,7 @@ import {
} from '@/lib/store'
import { applyScriptText } from '@/components/ava/audio-source'
import { HelpPanel } from '@/components/ava/help-panel'
import { HashToScriptDialog, ScriptToHashDialog } from '@/components/ava/share-dialogs'
import { sanitizeFileName } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
@ -62,6 +77,9 @@ export function ScriptTab() {
const [saveAsName, setSaveAsName] = useState('')
const [saveAsBusy, setSaveAsBusy] = useState(false)
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)
useEffect(() => {
@ -332,6 +350,24 @@ export function ScriptTab() {
>
<FolderOpen className="size-3.5" /> <span className="hidden sm:inline">Load from</span> File
</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
size="sm"
variant="outline"
@ -450,6 +486,14 @@ export function ScriptTab() {
</DialogContent>
</Dialog>
{/* v7.7 — share-string dialogs */}
<ScriptToHashDialog open={shareOutOpen} onOpenChange={setShareOutOpen} scriptText={scriptText} />
<HashToScriptDialog
open={shareInOpen}
onOpenChange={setShareInOpen}
onImport={(text) => setScriptText(text)}
/>
<input
ref={fileInputRef}
type="file"

View file

@ -3,6 +3,13 @@
// the server) and applies instantly. The Native tab that previously lived
// in this slot is parked (see control-panel.tsx) and returns unchanged
// 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'
@ -31,6 +38,14 @@ import {
import { useToast } from '@/hooks/use-toast'
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 = () => () => {}
function useMounted() {
return useSyncExternalStore(emptySubscribe, () => true, () => false)
@ -84,7 +99,7 @@ export function SettingsTab() {
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 [loginOpen, setLoginOpen] = useState(false)
const [chipService, setChipService] = useState<ServiceId | null>(null)
@ -94,6 +109,7 @@ export function SettingsTab() {
const [house, setHouse] = useState<Partial<Record<ServiceId, boolean>> | null>(null)
useEffect(() => {
if (!MUSIC_SERVICES_UI) return
let cancelled = false
void listMusicSessions().then((s) => {
if (!cancelled) setSessions(s)
@ -244,99 +260,103 @@ export function SettingsTab() {
</div>
</section>
<Separator />
{MUSIC_SERVICES_UI && (
<>
<Separator />
<section aria-label="Music services">
<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 />
Music services
</h4>
<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
with your account, so you never get stuck behind bot-detection walls. Login cookies are
encrypted and stored on this computer only.
</p>
<section aria-label="Music services">
<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 />
Music services
</h4>
<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
with your account, so you never get stuck behind bot-detection walls. Login cookies are
encrypted and stored on this computer only.
</p>
{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">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
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.
</p>
)}
{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">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
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.
</p>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
{SERVICES.map((s) => {
const active = Boolean(sessions?.loggedIn[s.id])
return (
<button
key={s.id}
type="button"
<div className="mt-3 flex flex-wrap gap-1.5">
{SERVICES.map((s) => {
const active = Boolean(sessions?.loggedIn[s.id])
return (
<button
key={s.id}
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={() => {
setChipService(s.id)
setChipService(null)
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',
)}
className="h-9 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
<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={() => {
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>
<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 />
@ -355,14 +375,16 @@ export function SettingsTab() {
</Button>
</div>
<ServiceLoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
initialService={chipService}
sessions={sessions}
houseServices={house}
onChanged={refreshSessions}
/>
{MUSIC_SERVICES_UI && (
<ServiceLoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
initialService={chipService}
sessions={sessions}
houseServices={house}
onChanged={refreshSessions}
/>
)}
</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"
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",
// 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)
"grid-cols-[minmax(0,1fr)]",
className

View file

@ -65,6 +65,11 @@ function DialogContent({
// nowrap content (truncated list rows, long URLs) can no longer
// blow the implicit track out past the dialog border (v7.5.1)
"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
)}
{...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[] = [
{
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',
changes: [

View file

@ -12,6 +12,25 @@
// warns in the console when the two drift apart.
//
// 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
// (tap-tempo lives in the Beat tab, and the Space shortcut works
// anywhere — both unchanged). The beat indicator dot stays.
@ -223,4 +242,4 @@
export const APP_NAME = 'Open Visualization Engine'
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:
- 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.