133604ae-01f0-4bd7-96a5-69156cef7530
This commit is contained in:
parent
78bf025686
commit
80782609b2
9 changed files with 1144 additions and 100 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,6 +260,8 @@ export function SettingsTab() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{MUSIC_SERVICES_UI && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<section aria-label="Music services">
|
||||
|
|
@ -337,6 +355,8 @@ export function SettingsTab() {
|
|||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
|
|
@ -355,6 +375,7 @@ export function SettingsTab() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{MUSIC_SERVICES_UI && (
|
||||
<ServiceLoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
|
|
@ -363,6 +384,7 @@ export function SettingsTab() {
|
|||
houseServices={house}
|
||||
onChanged={refreshSessions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
317
src/components/ava/share-dialogs.tsx
Normal file
317
src/components/ava/share-dialogs.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
561
src/lib/ava/share-codec.ts
Normal 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' },
|
||||
]
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
52
worklog.md
52
worklog.md
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue