From d76b25f4f69ddcb12a46feb329839387050573ab Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 7 Oct 2026 18:17:10 -0700 Subject: [PATCH] Preview animated SVGs on the Aesel card Aesel now treats .svg as a picture, and the Slab card stages it as artifact.svg so WebKit draws it (it never sniffs SVG), sizes it from width/height or viewBox, and checks it as text rather than through NSImage. Loaded as an img, its CSS and SMIL animation play while no script runs. Bumps Aesel to 0.8.21; the card probe now includes SoundSketch so it compiles again. Co-Authored-By: Claude Opus 5.5 --- aesel/package.json | 2 +- aesel/src/media.mjs | 1 + .../SlabMenubar/LocalArtifactPreview.swift | 55 ++++++++++++++++++- .../Sources/SlabMenubar/PromptPreview.swift | 4 +- .../tests/local-artifact-preview.sh | 13 ++++- 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/aesel/package.json b/aesel/package.json index 9f9d4c7a2e..542f3bcb6e 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.20", + "version": "0.8.21", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/media.mjs b/aesel/src/media.mjs index 0ffcbb7d9f..5d1760bcaf 100644 --- a/aesel/src/media.mjs +++ b/aesel/src/media.mjs @@ -15,6 +15,7 @@ export const MEDIA_TYPES = new Map([ ["jpg", { kind: "picture", mime: "image/jpeg", glyph: "🖼" }], ["jpeg", { kind: "picture", mime: "image/jpeg", glyph: "🖼" }], ["webp", { kind: "picture", mime: "image/webp", glyph: "🖼" }], + ["svg", { kind: "picture", mime: "image/svg+xml", glyph: "🖼" }], ["wav", { kind: "sound", mime: "audio/wav", glyph: "🔊" }], ["mp3", { kind: "sound", mime: "audio/mpeg", glyph: "🔊" }], ["pdf", { kind: "paper", mime: "application/pdf", glyph: "📄" }], diff --git a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift index 7885765e5c..343181794c 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/LocalArtifactPreview.swift @@ -20,7 +20,7 @@ struct LocalArtifactPreview: Equatable { let version = marker["version"] as? Int, version > 0, let artifactID = marker["artifactId"] as? String, !artifactID.isEmpty else { return nil } let allowed: [String: [String]] = [ - "picture": ["image/png", "image/jpeg", "image/webp"], + "picture": ["image/png", "image/jpeg", "image/webp", "image/svg+xml"], "sound": ["audio/wav", "audio/x-wav", "audio/mpeg"], "paper": ["application/pdf", "text/plain", "text/markdown", "text/x-tex", "application/x-tex"], "video": ["video/mp4", "video/quicktime", "video/webm"], @@ -35,6 +35,12 @@ struct LocalArtifactPreview: Equatable { /// The file's own name, drawn under it on the card. var name: String { (path as NSString).lastPathComponent } + var isSVG: Bool { mime == "image/svg+xml" } + + /// The name the file takes in the staging directory. WebKit never sniffs + /// SVG, so a vector picture keeps its extension or the img stays blank. + var stagedName: String { isSVG ? "artifact.svg" : "artifact" } + /// Pictures, sounds and papers are read whole and re-written into the /// staging directory. A video is linked there instead of copied, so it may /// be as large as a render actually is. @@ -64,7 +70,7 @@ struct LocalArtifactPreview: Equatable { /// alone. func stageFile(into directory: URL) throws -> URL { let source = try validatedFile() - let target = directory.appendingPathComponent("artifact") + let target = directory.appendingPathComponent(stagedName) do { try FileManager.default.linkItem(at: source, to: target) } catch { try FileManager.default.copyItem(at: source, to: target) } return target @@ -85,6 +91,7 @@ struct LocalArtifactPreview: Equatable { /// Pixel dimensions for pictures; PDF points for the first page. UI resizing /// scales this surface and never changes the artifact's logical viewport. func dimensions(_ data: Data) -> CGSize { + if isSVG, let size = Self.svgDimensions(data) { return size } if kind == "picture", let rep = NSBitmapImageRep(data: data), rep.pixelsWide > 0, rep.pixelsHigh > 0 { return CGSize(width: rep.pixelsWide, height: rep.pixelsHigh) } @@ -95,6 +102,46 @@ struct LocalArtifactPreview: Equatable { return CGSize(width: 768, height: 512) } + /// Whether the bytes are a picture this card can draw. NSImage reads the + /// rasters; an SVG only has to be UTF-8 with an svg root, and WebKit + /// draws it, animations and all. + func readable(_ data: Data) -> Bool { + if isSVG { return Self.svgRoot(data) != nil } + return NSImage(data: data) != nil + } + + /// The opening `` tag, or nil when the text has none. + static func svgRoot(_ data: Data) -> String? { + guard let text = String(data: data, encoding: .utf8), + let start = text.range(of: "", range: start.upperBound.. CGSize? { + guard let root = svgRoot(data) else { return nil } + func attribute(_ name: String) -> String? { + guard let match = root.range(of: "\\s\(name)\\s*=\\s*[\"']([^\"']*)[\"']", options: .regularExpression) else { return nil } + let pair = root[match] + guard let quote = pair.firstIndex(where: { $0 == "\"" || $0 == "'" }) else { return nil } + return String(pair[pair.index(after: quote).. Double? { + guard let value = attribute(name)?.trimmingCharacters(in: .whitespaces), + value.range(of: "^[0-9.]+(px)?$", options: .regularExpression) != nil else { return nil } + return Double(value.replacingOccurrences(of: "px", with: "")) + } + if let width = length("width"), let height = length("height"), width > 0, height > 0 { + return CGSize(width: width, height: height) + } + let box = (attribute("viewBox") ?? "").split(whereSeparator: { $0 == " " || $0 == "," }).compactMap { Double($0) } + if box.count == 4, box[2] > 0, box[3] > 0 { return CGSize(width: box[2], height: box[3]) } + return nil + } + static func escaped(_ text: String) -> String { text.replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "<", with: "<") @@ -111,7 +158,9 @@ struct LocalArtifactPreview: Equatable { content = Self.soundContent ready = Self.soundScript(sketch) } else if kind == "picture" { - content = "Picture preview" + // An SVG loaded as an img plays its own CSS and SMIL animation + // but runs no script and fetches nothing, like any other picture. + content = "Picture preview" ready = "const a=document.getElementById('artifact'); a.onload=ready; a.onerror=failed; if(a.complete&&a.naturalWidth)ready();" } else if kind == "sound" { content = (waveform ?? "") + "" diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift index 4d823d76cd..38ee861585 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -464,7 +464,7 @@ final class PromptPreview { // staging directory and WebKit streams it from there. let bytes = artifact.kind == "video" ? Data() : try artifact.readValidatedFile() let dimensions = artifact.kind == "video" ? artifact.videoDimensions() : artifact.dimensions(bytes) - if artifact.kind == "picture", NSImage(data: bytes) == nil { throw CocoaError(.fileReadCorruptFile) } + if artifact.kind == "picture", !artifact.readable(bytes) { throw CocoaError(.fileReadCorruptFile) } if artifact.mime == "application/pdf", (PDFDocument(data: bytes)?.pageCount ?? 0) == 0 { throw CocoaError(.fileReadCorruptFile) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent("slab-artifact-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, @@ -484,7 +484,7 @@ final class PromptPreview { _ = try artifact.stageFile(into: directory) } else { text = nil - try bytes.write(to: directory.appendingPathComponent("artifact"), options: .atomic) + try bytes.write(to: directory.appendingPathComponent(artifact.stagedName), options: .atomic) } target = directory.appendingPathComponent("index.html") let sketch = artifact.kind == "sound" ? (sketches[artifact.key] ?? nil) : nil diff --git a/slab/menubar-swift/tests/local-artifact-preview.sh b/slab/menubar-swift/tests/local-artifact-preview.sh index 09a9bd7b13..910512b3c7 100755 --- a/slab/menubar-swift/tests/local-artifact-preview.sh +++ b/slab/menubar-swift/tests/local-artifact-preview.sh @@ -5,7 +5,7 @@ root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) probeDir=$(mktemp -d /tmp/slab-artifact-check.XXXXXX) probe="$probeDir/check.swift" trap 'rm -rf "$probeDir"' EXIT -cat "$root/Sources/SlabMenubar/LocalArtifactPreview.swift" > "$probe" +cat "$root/Sources/SlabMenubar/LocalArtifactPreview.swift" "$root/Sources/SlabMenubar/SoundSketch.swift" > "$probe" cat >> "$probe" <<'SWIFT' let marker: [String: Any] = ["path": "/tmp/picture.png", "mime": "image/png", "version": 2, "artifactId": "picture-one"] let picture = LocalArtifactPreview(marker: marker, kind: "picture")! @@ -17,6 +17,17 @@ invalid = marker; invalid["mime"] = "text/html" assert(LocalArtifactPreview(marker: invalid, kind: "picture") == nil) invalid = marker; invalid["version"] = 0 assert(LocalArtifactPreview(marker: invalid, kind: "picture") == nil) +let vector = LocalArtifactPreview(marker: ["path":"/tmp/turtle.svg", "mime":"image/svg+xml", "version":1,"artifactId":"svg-one"], kind:"picture")! +assert(vector.stagedName == "artifact.svg") +assert(picture.stagedName == "artifact") +assert(vector.html(nonce:"test").contains("".utf8) +assert(vector.readable(walking)) +assert(vector.dimensions(walking) == CGSize(width: 1024, height: 768)) +let boxed = Data("".utf8) +assert(vector.dimensions(boxed) == CGSize(width: 300, height: 150)) +assert(!vector.readable(Data("not a picture".utf8))) +assert(vector.dimensions(Data("nope".utf8)) == CGSize(width: 768, height: 512)) let sound = LocalArtifactPreview(marker: ["path":"/tmp/sound.wav", "mime":"audio/wav", "version":1,"artifactId":"sound-one"], kind:"sound")! let audioHTML = sound.html(nonce:"test") assert(audioHTML.contains("controls preload='metadata'")) -- 2.51.2