Skip to content

Files and the web

For taking photos, recording audio and on-device transcription, see capabilities and permissions. Finished captures use the same attachment storage described below; ordinary file import needs no capability.

Save what a person would want back tomorrow. Drag positions, playback progress, timers and hover state belong in component state: writing them on every frame grows the document’s history for no benefit. Store files as attachments, never as base64 inside fields. A document’s saved data is capped at 32 MiB; a failed save keeps every edit and leaves the last saved version intact.

Your app mounts once the document is open, and again after Reload Interface. Start timers, observers and animation loops in $effect or onMount and return a cleanup. Closing the window unmounts the app, which runs every cleanup. By then the document has saved and closed, so a write made during teardown is refused; save values as they change instead.

A minimized window, or one hidden with the app, reports document.visibilityState as "hidden", and its timers all but stop. Pause work while hidden and catch up when the window shows again. Timers don’t run while the Mac sleeps either, so derive elapsed time from the clock rather than counting ticks.

<script lang="ts">
let visible = $state(document.visibilityState === "visible");
let now = $state(Date.now());
$effect(() => {
if (!visible) return;
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
function visibilityChanged() {
visible = document.visibilityState === "visible";
if (visible) now = Date.now();
}
</script>
<svelte:document onvisibilitychange={visibilityChanged} />

In a native window, an ordinary <input type="file"> opens a file picker attached to the document. Cancelling changes nothing. Background rendering and closed CLI commands never show a picker. Validate the chosen file in your app before storing it.

import { attachments } from "hitslop/svelte";
await attachments.import(file, (tx, ref) => {
tx.fields.cover.$set(ref);
});

import stores the file, then runs your collector like a change: synchronously, with its edits accepted together with the file or the promise rejects. Prepare the file asynchronously before calling import. If storing the file fails, the collector never runs; if the reference edit is refused, the file stays stored but unreferenced. A reference is { id, name, mimeType, byteLength }; model it with ordinary fields. The name is descriptive, never a path. The type comes from the file’s bytes, not its extension: images, audio, video and PDFs keep theirs, and anything else is application/octet-stream.

Show or play a file by its URL. It is same-origin and never changes, and media can seek:

<img src={attachments.url(ref.id)} alt={ref.name} />
<audio controls src={attachments.url(ref.id)}></audio>

attachments.read(id) returns the bytes as a Blob when your code needs them. A file never runs as a script, stylesheet or page.

Files are immutable and deduplicated by content: up to 10 MiB each, 100 MiB and 256 files per document. Store the reference’s id as it is, in a field or inside text: when the document closes, files whose ID appears nowhere in it are removed, and a duplicate or shared copy carries only the files it references. Close, export and duplicate wait for imports in progress. Templates never contain imported files. slop dev stores them in each preview’s own disposable document, with the same limits. From the terminal, use attachments ref and --attach.

A slop may fetch HTTPS data, play HTTPS media and embed HTTPS <iframe>s without declaring them. Images may use HTTPS, data or blob URLs. CORS still applies, and remote scripts and eval are blocked, so bundle your code. An embedded frame can’t reach the document or the host; drive it through its own postMessage API.

A slop’s page has no web origin, and YouTube’s player refuses to play without one (error 153, or 150 from slop dev). Use hitslop/embed, which plays videos through a relay page on hitslop.com. You don’t run a server, and the relay never sees which video is playing. hitslop/youtube holds the URL builders.

import { youtube } from "hitslop/embed";
import { src } from "hitslop/youtube";
const id = youtube.findId(pastedText); // null unless it holds a YouTube link
if (!id) throw new Error("Paste a YouTube link.");
const { title, author } = await youtube.lookup(id); // rejects with a LookupError if it can't be embedded
// <iframe bind:this={frame} src={src(id, { controls: false })}
// allow="autoplay; encrypted-media" title="Player"></iframe>
const player = youtube.player(frame, { onState, onTime, onEnded, onError });
player.play();
player.seek(30);
player.setVolume(0.5);
player.dispose();

hitslop/youtube exports thumbnail(id), src(id, options), defaultRelay and idShape without browser APIs; static views may import it, including Widget. These helpers only build or validate strings: native capture cannot load a remote thumbnail. Keep an image as an attachment in the editor and pass its saved ID to attachmentURL in the static view. Static views may not import hitslop/embed, which owns youtube.parseId, findId, lookup, player and LookupError. Playback needs the network; onError(-1) means the relay never answered. Pass relay to src to host the relay page yourself.