Compare commits

...
Author SHA1 Message Date
teahubxyz 263e68d040 havre smoke: the model picker sorted by price, on a desktop and a phone; none for clips
ci / test (pull_request) Successful in 4s
2026-10-23 19:14:07 +00:00
teahubxyz 8f7bf1c2d4 havre tests: the model picker's order 2026-10-23 19:14:06 +00:00
teahubxyz 5fe882dc27 havre: the model picker's search and sort on one line 2026-10-23 19:12:51 +00:00
teahubxyz e74ae3d29e havre: a sort beside the model picker's search 2026-10-23 19:12:51 +00:00
teahubxyz c8cee0e72c havre: sort the model picker by name, cheapest first or priciest first (kept in this browser) 2026-10-23 19:12:42 +00:00
teahubxyz 9b835a4b6e havre: the model picker's order (by name, cheapest first, priciest first) as pure functions 2026-10-23 19:12:16 +00:00
time 3b6bf3930f ventures: daily signup counts 2026-10-23 00:28:14 +00:00
time c6f4fe489f ventures: daily signup counts 2026-10-22 00:28:21 +00:00
teahubxyz 6fff82ebb8 Havre remix: editable write-up + rewrite box (#218) 2026-10-21 21:52:33 +00:00
teahubxyz ffb16e5605 Havre: hear / whisper in the message tools line (#217) 2026-10-21 20:12:40 +00:00
teahubxyz 2e86c329d4 Havre: voice notes off / on / asmr, ASMR settings, "whisper it", and the /whisper display fix (#216) 2026-10-21 19:46:52 +00:00
teahubxyz cbb01d0a33 Havre: update pill (NEW VERSION · RELOAD) (#215) 2026-10-21 19:39:35 +00:00
teahubxyz 0c0feec53b Havre: /whisper — ASMR voice notes (#214) 2026-10-21 19:30:07 +00:00
teahubxyz 26265ee0cf mdecks README: opening a .md file 2026-10-21 17:30:23 +00:00
teahubxyz 97610b03ec mdecks: Open .md button, drop target styling, copy
Adds the Open .md button beside the editor label, a hidden file input, a page-wide drop highlight, and mentions dropping a file in the hint, How it works and the Free tier.
2026-10-21 17:30:12 +00:00
teahubxyz 23fde14eda mdecks: open a .md file by drop or button
A .md (or any text) file dropped anywhere on the page, or picked with the new Open .md button, replaces the editor text and clears the hash, so the file is the deck. Nothing is uploaded: FileReader reads it in the tab.
2026-10-21 17:29:56 +00:00
time 79635906e3 ventures: daily signup counts 2026-10-21 00:28:45 +00:00
github-actions[bot] e82c7e791f ventures: refresh roll-credits 2026-10-20 22:47:37 +00:00
teahubxyzandClaude Fable 5.1 9841186f80 ventures: roll-credits card image
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:42:28 +00:00
teahubxyzandClaude Fable 5.1 0a263c782b ventures: roll-credits gets a tools/build.sh that renders its shots/desktop.png (it launched without a card image)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:42:15 +00:00
teahubxyzandClaude Fable 5.1 f00e5156dc ventures: gandora moved to gandora/site (served at gandora.yolopreneur.lol from its own repo)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:37:40 +00:00
teahubxyzandClaude Fable 5.1 67b91770a7 gandora: 120 s tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:35:37 +00:00
teahubxyzandClaude Fable 5.1 1666f0200c ventures: gandora lives in gandora/site now; its card image comes from there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hn2DWhV7Z1qUxjhCprDb88
2026-10-20 22:34:47 +00:00
teahubxyzandClaude Fable 5.1 ffc0e85999 gandora: player order — cover, transport, trail, title, bar, lyrics/keep/share, then dj line / next / tags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:33:42 +00:00
teahubxyzandClaude Fable 5.1 3324fcc226 gandora: trail — scroll back through what this station has played, right on the player (pandora-style), tap to jump
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:15:17 +00:00
teahubxyzandClaude Fable 5.1 7769502e0b gandora: hide the shared-budget / add-key button for now (still reachable with ?key)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:09:35 +00:00
teahubxyzandClaude Fable 5.1 4ebed492bd gandora: render through the worker's one-call /v1/render (Runware when the factory has a key), real cost + seed + renderer kept on the track
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:08:16 +00:00
teahubxyzandClaude Fable 5.1 982d1af057 gandora: DJ line · NEXT · then bpm · key · TAGS ▾, with the tags opening on a line of their own
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 22:00:35 +00:00
teahubxyzandClaude Fable 5.1 bf2ecbc6b3 gandora: a tap outside any sheet (station, emoji picker, tape picker) closes it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ac/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:59:02 +00:00
teahubxyzandClaude Fable 5.1 1c57e075ff gandora: tags fold to one line (TAGS ▾ · bpm · key); palette dial per station — mono, duo, tri
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:57:02 +00:00
teahubxyzandClaude Fable 5.1 f733a66fb6 gandora: station name above the cover, centred, in the wordmark style
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:54:12 +00:00
teahubxyzandClaude Fable 5.1 c0aea23067 gandora: + tag stays visible when the tag row is folded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:53:55 +00:00
teahubxyzandClaude Fable 5.1 b976041118 gandora: new flow — cover, title, progress, DJ line, tags (folded to four, bpm and key as chips), NEXT, controls, pills
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:53:45 +00:00
teahubxyzandClaude Fable 5.1 538b93cf75 gandora: chord look fills in — slower damping, more cycles, a detuned second trace
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:34:24 +00:00
teahubxyzandClaude Fable 5.1 7b66e4dd35 gandora: looks — rings, chord (a harmonograph of the key's interval), attractor (a Clifford attractor from the song); per station, set in the station sheet
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:31:33 +00:00
teahubxyzandClaude Fable 5.1 0a22d29dd4 gandora: a station sheet — rename, reset taste, delete (kept tracks stay); from the top bar name or ⋯ on a station tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:30:04 +00:00
teahubxyzandClaude Fable 5.1 271243a334 gandora: refreshing a station link resumes the station rather than refilling the seed box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:29:28 +00:00
teahubxyzandClaude Fable 5.1 60f374527f gandora: tags sit with the reactions, above the emoji row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:26:52 +00:00
teahubxyzandClaude Fable 5.1 0c6fd2d1f1 gandora: a shared tape queues its tracks before the station opens; switching station ends a tape
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:16:42 +00:00
teahubxyzandClaude Fable 5.1 a7f342b196 gandora: mixtapes
A tape is a named, ordered list of tracks: start one from the library's TAPES strip or add any tile with its +;
the tape screen plays through, repeats all, reorders (▲▼), removes, renames, deletes; SHARE copies a link that
carries every track's sheet music, and a browser opening it gets a tape of its own, rendering each in order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:16:21 +00:00
teahubxyzandClaude Fable 5.1 abbcceba53 gandora: controls reordered (back · shuffle · replay · play · repeat · remix · next), cleaner shuffle glyph, first remix
Remix hands the current track's sheet music back to the DJ with the listener's chip edits and words, asks for a
variation with the same bones, and puts it next; a track already cooking plays after it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:14:45 +00:00
teahubxyzandClaude Fable 5.1 2631dd6f6c gandora: shuffle mixes the station's own library in, weighted by the taste net
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:09:21 +00:00
teahubxyzandClaude Fable 5.1 4add8c4484 gandora: no KEPT label inside the KEPT section
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:08:16 +00:00
teahubxyzandClaude Fable 5.1 c866d70f5c gandora: library tiles — emoji on the cover, KEPT as a label not a heart, one-line meta
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:05:19 +00:00
teahubxyzandClaude Fable 5.1 70a856db39 gandora: an emoji picker from +, and the reaction row is the listener's eight most-used emoji
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 21:03:32 +00:00
teahubxyzandClaude Fable 5.1 b3ff0f9672 gandora: tag taps train the net as word-level samples; ▲▼ counts on the taste map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:59:25 +00:00
teahubxyzandClaude Fable 5.1 5a56c56a07 gandora: tag taps can be let go (~word); tag tokens stay out of the emoji rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:59:06 +00:00
teahubxyzandClaude Fable 5.1 3a0df54646 gandora: tags are controls (more / less / let go, + your own); the DJ's line sits above them and taps into the reply box
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:58:48 +00:00
teahubxyzandClaude Fable 5.1 c435a7be1f gandora: the net hears the words — tags, title and lyrics as hashed bag-of-words inputs
5 numbers + 48 signed-hash word slots → 12 neurons → like. The DJ's taste paragraph now names the words the net
warms to and cools on; the heat map and the grid are scored with the station's own wording so the words block is
live. Checked: with identical tempo/energy/key, "wobble bass" vs "harmonica" separate cleanly on words alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:55:05 +00:00
teahubxyzandClaude Fable 5.1 ce2eead558 gandora: STORAGE.md, the contract between the page and whatever stores its tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:37:35 +00:00
teahubxyzandClaude Fable 5.1 a78d6db949 gandora: the URL follows the station; SHARE links carry a track's sheet music so it opens and renders for anyone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:34:00 +00:00
teahubxyzandClaude Fable 5.1 cd8744c64e gandora: next sits to the right of repeat
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:32:23 +00:00
teahubxyzandClaude Fable 5.1 55a580d0c6 gandora: wire in the taste brain (brain.js), the net and palette on TASTE; rows keep their play counts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:23:58 +00:00
teahubxyzandClaude Fable 5.1 9e9fdc0a70 gandora: the taste brain — a tiny net as the recommendation engine
brain.js: 5 inputs (tempo, energy, key on the circle, minor) → 8 neurons → like, retrained from scratch on
every reaction and play count (a looped track is a loud 🔥). Its picture of the listener rides in the DJ's prompt
(most and least likely regions in words); the NEXT line shows the net's guess for the track in the oven. TASTE
draws the net with neurons firing for the current track, the prediction heat map, emoji and play counts on each
dot, the cooking track as a hollow mark, and THE FIVE tracks that define the station.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:23:41 +00:00
teahubxyzandClaude Fable 5.1 6716c81106 gandora: taste map shades gold only where warm outweighs cold; heard-but-unreacted is neutral grey
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:16:19 +00:00
teahubxyzandClaude Fable 5.1 802ef8233f gandora: ❤️ is a reaction only; KEEP is the one way to pin a track
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:14:53 +00:00
teahubxyzandClaude Fable 5.1 a5720e6b3c gandora: resuming a station replays its latest track at once (free) while the next one cooks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:12:49 +00:00
teahubxyzandClaude Fable 5.1 a2a505a511 gandora: LIBRARY and TASTE reachable from the seed screen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:11:24 +00:00
teahubxyzandClaude Fable 5.1 538fa96aa6 gandora: five balanced controls (back, replay, play, forward, loop); LYRICS and KEEP pills side by side
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:09:02 +00:00
teahubxyzandClaude Fable 5.1 df2299a0f7 gandora: a TASTE map, per station and overall
Every track is a point (tempo across, energy up, colour = key, ring warm or cold by its reactions, size by how
much was said); the background is a kernel estimate of liking from the reactions, lit where warm outweighs cold.
Tabs per station with their seed marks, plus ALL. Reads the library's own rows; no new storage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:06:28 +00:00
teahubxyzandClaude Fable 5.1 a93ca1ec95 gandora: stepping back through the session doesn't re-enter history or count as a new play
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:05:36 +00:00
teahubxyzandClaude Fable 5.1 57d448443f gandora: a real previous-track button; skip walks forward through the session before cooking new tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:05:22 +00:00
teahubxyzandClaude Fable 5.1 9c9a0dd377 gandora: library grouped by station (channel), each with its seed mark and count; kept tracks on top
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:00:52 +00:00
teahubxyzandClaude Fable 5.1 f967f739e4 gandora: only wire the analyser once the audio context is running (a suspended one would mute iOS)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 20:00:20 +00:00
teahubxyzandClaude Fable 5.1 a8e8d0efbd gandora: the cover is a visualizer while music plays; reactions show on library tiles
Analyser on the player: bass breathes the rings and the seed mark, highs sharpen the lines, slow drift on the
phases; ground and grain are cached per song so the frame loop only redraws rings and core. Reactions are saved
with the track on each tap (they were only in memory before), and the tiles show them (💬 for words, ⏭ for a skip).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:59:57 +00:00
teahubxyzandClaude Fable 5.1 af7fe043bd ventures: gandora lives at gandora.lol
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:51:40 +00:00
teahubxyzandClaude Fable 5.1 c54522678b gandora: lyrics on the back of the cover (tap the cover or LYRICS to flip)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:48:31 +00:00
teahubxyzandClaude Fable 5.1 df98581056 gandora: lyrics under the title (tap LYRICS or the cover); section markers styled; stays open across tracks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:47:50 +00:00
teahubxyzandClaude Fable 5.1 8806f56907 gandora: a repeat button
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:46:56 +00:00
teahubxyzandClaude Fable 5.1 86a9ec10d3 gandora: say why a track didn't render (a budget cap reads differently from a Venice hiccup)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:43:51 +00:00
teahubxyzandClaude Fable 5.1 81805e1a25 gandora: stations and an explicit KEEP
Stations: a seed, its name, taste and recent history are saved (IndexedDB v2) and listed on the seed screen as
seed marks to resume; a new seed opens a new station, picking a station resumes it with its taste intact.
KEEP: a labelled pill under the controls pins the current track (toggle), beside the ❤️ reaction which still keeps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:38:18 +00:00
teahubxyzandClaude Fable 5.1 8d21bf11ff gandora: every played track is kept in the library (last 40), hearts pin for good; KEPT and PLAYED sections
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:27:42 +00:00
teahubxyzandClaude Fable 5.1 64fa4fc0b7 gandora: the now-playing strip follows track changes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:22:04 +00:00
teahubxyzandClaude Fable 5.1 6bdb300adb gandora: leaving the player keeps the station playing; a now-playing strip on the seed screen leads back
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:21:48 +00:00
teahubxyzandClaude Fable 5.1 050aa296a5 gandora: hedge a stalled render after 60 s with a second queue, take whichever lands first
Three timed renders through the worker took 8-24 s; the one that sat for 2+ min was Venice's queue stalling that
job. A second copy after 60 s bounds the wait at about normal + 60 s for an extra 3 cents on a bad day.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:18:48 +00:00
teahubxyzandClaude Fable 5.1 52edca2774 gandora: run on the shared inference worker by default, a personal Venice key optional
No key in the page and none needed: calls go to inference.yolopreneur.lol with an x-app header, quota'd per person
per day; a saved personal key still goes straight to api.venice.ai. DJ defaults to claude-haiku-5-5 (llama-3.3-70b
is gone from Venice's list). The ?demo key path is removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 19:09:18 +00:00
github-actions[bot] 790fa26b73 ventures: refresh havre 2026-10-20 18:52:20 +00:00
teahubxyzandClaude Opus 5.5 b3ec97387f havre: the nav is just studio, light/dark and reload (the reading is in the footer)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BQqGFv54JJTw7p3rgGjP7C
2026-10-20 18:48:53 +00:00
teahubxyzandClaude Fable 5.1 1b4641c1a4 gandora: honour a shared demo key from demo.js on ?demo links
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-20 18:48:44 +00:00
github-actions[bot] 00cbf4f4eb ventures: refresh havre from havre-live 2026-10-20 18:22:41 +00:00
teahubxyz fbf54971af ventures: gandora in the ledger
Claude-Session: gandora-poc
2026-10-20 18:16:27 +00:00
teahubxyz 3a8bdf5f56 ventures: gandora, a generative station (PoC on Venice ACE-Step)
Type a seed → a DJ (Venice chat, JSON) writes tags, lyrics, bpm, key, energy for each track → ACE-Step 1.5 on
Venice renders 60 s → Pandora-style player with render-ahead. Covers are deterministic: hue from key, rings from
bpm, grain from energy, symmetry and core mark from the seed. Emoji + text reactions edit the station's taste;
❤️ keeps a track in an IndexedDB library (free replays). Venice key stays in the browser (CSP: api.venice.ai only).

Claude-Session: gandora-poc
2026-10-20 18:16:17 +00:00
github-actions[bot] 95ea8ca9a1 ventures: refresh havre from havre-open 2026-10-20 17:39:26 +00:00
17 changed files with 669 additions and 80 deletions

No files matched your search

+35
View File
@@ -81,6 +81,41 @@ export const voicePrice = (model, chars) => (model && model.perMChars != null ?
/** a minute of your voice, heard */
export const hearingPerMinute = (model) => (model && model.perSecond != null ? model.perSecond * 60 : null);
/**
* What a model costs, as the numbers the model picker sorts by, the first counting most: a text model's price in, then
* out (a chat is mostly what's sent in: the card, the memories, the conversation so far); a photo's price, then what each
* picture past the included ones adds; a voice's price for a million characters; hearing's price for a second. null when
* Venice lists no price (a clip: it prices each one when asked).
*/
export function modelCost(type, model) {
if (!model) return null;
const n = (x) => (typeof x === "number" && Number.isFinite(x) ? x : null);
const keys = type === "text" ? [n(model.input), n(model.output)]
: type === "inpaint" || type === "image" ? [n(model.price), n(model.extra)]
: type === "tts" ? [n(model.perMChars)]
: type === "asr" ? [n(model.perSecond)]
: [];
return keys.length && keys[0] != null ? keys.map((k) => k ?? 0) : null;
}
/** the orders the model picker has: by name (the one in use first), cheapest first, priciest first */
export const MODEL_SORTS = ["name", "cheapest", "priciest"];
/**
* The model picker's list in order, as a new list. "name": the ones rank puts higher first (the one in use, then the
* character's own), then A to Z. "cheapest" and "priciest": by modelCost, A to Z between equals, the ones without a price
* last either way, and none held at the top, so the one in use shows where its price puts it.
*/
export function sortModels(list, how = "name", { type = "text", rank = () => 0 } = {}) {
const all = [...(list || [])], byName = (a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id));
if (how !== "cheapest" && how !== "priciest") return all.sort((a, b) => rank(b) - rank(a) || byName(a, b));
const dir = how === "priciest" ? -1 : 1, cost = new Map(all.map((m) => [m, modelCost(type, m)]));
return all.sort((a, b) => {
const x = cost.get(a), y = cost.get(b);
if (!x || !y) return (x ? 0 : 1) - (y ? 0 : 1) || byName(a, b);
for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return dir * (x[i] - y[i]);
return byName(a, b);
});
}
// ---------- what goes into a photo or a clip ----------
// Content filters (Venice's, and the ones of the models' makers) read the words in a prompt, not the "no" in front of
+298 -59
View File
@@ -49,7 +49,7 @@ const local = box("localStorage"), session = box("sessionStorage");
const K = { cards: "havre.cards.v1", sel: "havre.selected", key: "havre.key", model: "havre.model",
sort: "havre.vsort", seeded: "havre.seeded.v1", adult: "havre.adult", adult18: "havre.adult.18", plainModel: "havre.plain.model",
own: "havre.venice.models", ab: "havre.abilities", voiceOn: "havre.voicenotes", jobs: "havre.clips.pending", refOk: "havre.refs.ok",
spent: "havre.spent", jobsDone: "havre.clips.done",
spent: "havre.spent", jobsDone: "havre.clips.done", msort: "havre.msort",
// what each character remembers of your chats (memory.js), by card id: never in the card itself
memory: "havre.memory.v1",
// the pictures this browser made with AI (their fingerprints): an adult character's photos and clips can be adult
@@ -84,12 +84,29 @@ const S = {
// or clip being asked for, and a voice note being recorded
lists: { inpaint: null, image: null, tts: null, asr: null, video: null }, listErr: {},
ab: { photo: "", clip: {}, hear: false, hearModel: "", see: false, seeModel: "", own: true, allowance: 0.25, memory: true,
adult: true, adultPhoto: "", adultClip: "", awayMin: 360 }, voiceOn: {}, jobs: [], ask: null,
adult: true, adultPhoto: "", adultClip: "", awayMin: 360, asmr: null }, voiceOn: {}, jobs: [], ask: null,
rec: null, moving: new Set(), inflight: 0,
};
// what characters do on their own (photos, voice notes) comes out of a daily allowance, counted here by local day
const ALLOWANCES = [0.1, 0.25, 1, 5];
/** how long you've been away before a character texts first: minutes, as the setting shows them */
// voice notes in ASMR: whispered (W.WHISPER_PRESETS), at a pace, over soft rain; "every" is one reply in so many
const ASMR_DEFAULT = { voice: "luna", pace: 0.77, rain: false, level: 0.09, every: 1, v: 2 };
const ASMR_PACES = [[0.65, "slowest"], [0.77, "slow"], [0.85, "easy"], [1, "as written"]];
const ASMR_EVERY = [[1, "every reply"], [2, "every other"], [4, "now and then"]];
const ASMR_LEVELS = [[0, "no rain"], [0.05, "faint"], [0.09, "soft"], [0.16, "steady"]];
/** off, on or asmr: how a character's replies come (S.voiceOn keeps true for on, "asmr" for asmr) */
const voiceMode = (c) => (!c || !S.voiceOn[c.id] ? "off" : S.voiceOn[c.id] === "asmr" ? "asmr" : "on");
/** whether this reply comes with a voice note: always when on; in asmr, one in every so many */
const voiceDue = (c) => { const m = voiceMode(c); return m === "on" || (m === "asmr" && Math.random() < 1 / Math.max(1, S.ab.asmr.every || 1)); };
/** what a voice note of these words would cost: their own voice, or the whisper preset */
function voiceCost(c, text, asmr) {
const words = A.speakable(text);
if (!asmr) { const vm = voiceModel(c); return vm ? A.voicePrice(vm, words.length) : null; }
const p = W.WHISPER_PRESETS[S.ab.asmr.voice] || W.WHISPER_PRESETS[W.WHISPER_DEFAULT];
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
return vm ? A.voicePrice(vm, W.whisperInput(words, p).length) : null;
}
const AWAYS = [[1, "a minute"], [5, "5 minutes"], [15, "15 minutes"], [30, "half an hour"], [60, "an hour"], [180, "3 hours"], [360, "6 hours"], [1440, "a day"]];
const backAfter = () => (S.ab.awayMin || 360) * 60e3;
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
@@ -141,6 +158,10 @@ function boot() {
S.own = local.get(K.own, {}) || {};
S.ab = { ...S.ab, ...(local.get(K.ab, {}) || {}) };
S.ab.clip = S.ab.clip && typeof S.ab.clip === "object" ? S.ab.clip : {};
const savedAsmr = S.ab.asmr && typeof S.ab.asmr === "object" ? S.ab.asmr : null;
S.ab.asmr = { ...ASMR_DEFAULT, ...(savedAsmr || {}) };
if (!W.WHISPER_PRESETS[S.ab.asmr.voice]) S.ab.asmr.voice = W.WHISPER_DEFAULT;
if (savedAsmr && (savedAsmr.v || 1) < 2) S.ab.asmr = { ...S.ab.asmr, rain: false, v: 2 }; // rain went off by default: once, for settings saved before
if (!ALLOWANCES.includes(S.ab.allowance)) S.ab.allowance = 0.25;
if (!AWAYS.some(([m]) => m === S.ab.awayMin)) S.ab.awayMin = 360;
S.voiceOn = local.get(K.voiceOn, {}) || {};
@@ -657,15 +678,22 @@ function syncAdult(list) {
function renderRoster() {
const tb = $("#roster");
tb.textContent = "";
$("#harbor-empty").hidden = S.cards.length > 0;
$("#harbor .tablewrap").hidden = !S.cards.length;
$("#harbor-empty").hidden = true;
$("#harbor .tablewrap").hidden = false;
const cur = card();
// the first row is always someone new: the way back to a blank card from anyone's
const fresh = () => { S.fresh = true; syncNew(); $("#card").scrollIntoView({ behavior: motion(), block: "start" }); $("#nc-name").focus({ preventScroll: true }); };
tb.append(h("tr", { class: "opens newrow", onclick: (e) => { if (!e.target.closest("button, a")) fresh(); } },
h("th", { scope: "row" }, linkish("+ new character", fresh, { class: "linkish rowname" })),
h("td", { text: "write one here" }), h("td", { text: "" }),
h("td", { class: "files" }, linkish("import a card", () => $("#import").click()))));
queueMicrotask(syncNew);
for (const c of S.cards) {
const on = cur && c.id === cur.id;
const from = c.engine.venice_character ? "venice, as a link with credit" : c.lineage[0] ? C.creditLine(c.lineage[0]) : "written here";
const go = (tab) => () => { selectCard(c.id); openCard(tab); };
// the whole row opens their card; the links in it do what they say
tb.append(h("tr", { "aria-current": on ? "true" : null, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
tb.append(h("tr", { "data-id": c.id, class: "opens", onclick: (e) => { if (!e.target.closest("button, a")) go("edit")(); } },
h("th", { scope: "row" }, linkish(c.name, go("edit"), { class: "linkish rowname" }), c.adult ? h("span", { class: "x18", title: "for grown-ups only", text: "18+" }) : null,
on ? h("span", { class: "now", text: "talking" }) : null),
h("td", { text: from }),
@@ -1002,11 +1030,13 @@ function jumpTo(t, m) {
}
// ---------- editing and deleting messages ----------
function msgTools(el, t, m) {
function msgTools(el, t, m, voice = []) {
if (!t || !m || !m.id) return;
el.dataset.msg = m.id;
for (const old of $$(".msgtools", el)) old.remove();
const c = cardOf(t), words = !!String(m.content || "").trim() && c && !c.plain && S.ab.memory;
el.append(h("span", { class: "msgtools" }, ...slashed([
...voice,
m.role === "user" && !m.media ? linkish("edit", () => editMsg(t, m), { "aria-label": "Edit this message" }) : null,
words ? linkish("remember", () => rememberMsg(t, m), { "aria-label": "Add this to what they remember", title: "into their memory, in these words" }) : null,
words ? linkish("remind", () => remindMsg(t, m), { "aria-label": "Remind them of this at a time", title: "back to mind at a time you pick" }) : null,
@@ -1096,7 +1126,7 @@ function renderLog() {
if (m.voice) put(voiceLine(c, m));
let last = null;
for (const b of bubblesOf(c, m.content)) { last = put(line(b, "them")); if (m.id) last.dataset.msg = m.id; }
if (last) { offerVoice(last, c, m, t); msgTools(last, t, m); }
if (last) msgTools(last, t, m, offerVoice(last, c, m, t));
}
}
const ideas = hist.some((m) => m.role === "user" && !m.hidden) ? [] : C.suggestions(c);
@@ -1269,12 +1299,13 @@ async function send(text, { hidden = false, media = null, picture = "", to = nul
const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(reply).length) : null;
if (price != null && reserve(price)) mirror = price;
}
if (!demo && !c.plain && (S.voiceOn[c.id] || mirror)) {
const asmrNote = !mirror && voiceMode(c) === "asmr";
if (!demo && !c.plain && (mirror || voiceDue(c))) {
// a voice note first, then the words under it
doing(t, "is recording a voice note…");
voicing.add(msg);
try {
msg.voice = await makeVoice(c, reply, ctrl.signal);
msg.voice = await makeVoice(c, reply, ctrl.signal, { asmr: asmrNote });
if (mirror) msg.voice.own = true;
saveThread(t);
} catch (e) {
@@ -1349,7 +1380,7 @@ async function play(t, bubbles, signal, c, msg, drawn = S.gen) {
if (msg && msg.id) last.dataset.msg = msg.id;
scrollLog();
}
if (last && c && msg) offerVoice(last, c, msg, t);
if (last && c && msg) msgTools(last, t, msg, offerVoice(last, c, msg, t));
}
// ---------- photos, clips, voice notes, and yours, in the conversation ----------
@@ -1419,27 +1450,41 @@ function voiceLine(c, m) {
/** "hear it" after a reply that came as text: its voice note, made when asked for (once, however often it's clicked) */
const voicing = new WeakSet();
function offerVoice(el, c, m, t) {
if (m.voice || m.demo || c.plain || !S.key || !t) return;
const b = linkish(voicing.has(m) ? "recording…" : "hear it", async () => {
if (b.disabled || voicing.has(m) || m.voice) return;
voicing.add(m);
b.disabled = true;
b.textContent = "recording…";
try {
m.voice = await makeVoice(c, m.content);
saveThread(t);
if (b.isConnected) b.replaceWith(audioEl(m.voice.id));
else placeVoice(t, m);
balanceSoon();
} catch (e) { b.disabled = false; b.textContent = "hear it"; toast(e.message); } finally { voicing.delete(m); }
}, { class: "linkish hear", "aria-label": `Hear this as a voice note from ${c.name}`, disabled: voicing.has(m) || null });
$(".said", el).append(" ", b);
if (m.voice || m.demo || c.plain || !S.key || !t) return [];
const btns = [];
const make = (label, asmr) => {
const b = linkish(voicing.has(m) ? "recording…" : label, async () => {
if (b.disabled || voicing.has(m) || m.voice) return;
voicing.add(m);
for (const x of btns) x.disabled = true;
b.textContent = asmr ? "whispering…" : "recording…";
try {
m.voice = await makeVoice(c, m.content, undefined, { asmr });
saveThread(t);
placeVoice(t, m); // the voice note goes above the words
if (el.isConnected) msgTools(el, t, m); // and the line under them loses hear / whisper
balanceSoon();
} catch (e) { for (const x of btns) x.disabled = false; b.textContent = label; toast(e.message); } finally { voicing.delete(m); }
}, { class: "linkish hear", "aria-label": asmr ? `Hear this whispered by ${c.name}, over soft rain` : `Hear this as a voice note from ${c.name}`, disabled: voicing.has(m) || null });
btns.push(b);
return b;
};
return [make("hear", false), make("whisper", true)];
}
/** a voice note of these words, in the character's voice and mood */
async function makeVoice(c, text, signal) {
async function makeVoice(c, text, signal, { asmr = false } = {}) {
S.inflight++;
try {
await loadList("tts");
if (asmr) { // whispered, at the ASMR pace, over soft rain
const a = S.ab.asmr, p = W.WHISPER_PRESETS[a.voice] || W.WHISPER_PRESETS[W.WHISPER_DEFAULT];
const words = A.speakable(text);
if (!words) throw new Error("There are no words to whisper.");
const input = W.whisperInput(words, p), pm = (S.lists.tts || []).find((m) => m.id === p.model);
let blob = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: a.pace || p.speed, format: p.format, signal });
if (a.rain && a.level > 0) { try { blob = await overRain(blob, a.level); } catch { /* the dry take is still a whisper */ } }
return { id: await M.put(blob), model: pm ? pm.name : p.model, voice: p.voice, price: pm ? A.voicePrice(pm, input.length) : null, whisper: true };
}
const vm = voiceModel(c);
if (!vm) throw new Error(S.listErr.tts || "Venice has no voice models right now.");
const voice = voiceOf(c, vm), words = A.speakable(text);
@@ -1461,7 +1506,13 @@ function deliver(t, msg) {
saveThread(t);
if (here(t)) {
for (const el of $$("#log .suggest, #log .note")) el.remove();
put(mediaLine(c, msg));
if (msg.media) put(mediaLine(c, msg));
else { // a voice note and its words (a /whisper)
if (msg.voice) put(voiceLine(c, msg));
let last = null;
for (const b of bubblesOf(c, msg.content || "")) { last = put(line(b, "them")); last.dataset.msg = msg.id; }
if (last) msgTools(last, t, msg);
}
renderJobs();
scrollLog();
}
@@ -1500,6 +1551,11 @@ function renderCard() {
function syncNew() {
const c = card(), real = !!c && !c.plain, fresh = S.left === "harbor" && (S.fresh || !real);
$("#card-new").hidden = !fresh;
// one row marked: the one whose card is open below
for (const tr of document.querySelectorAll("#roster tr")) {
const on = tr.classList.contains("newrow") ? fresh : !fresh && real && tr.dataset.id === c.id;
if (on) tr.setAttribute("aria-current", "true"); else tr.removeAttribute("aria-current");
}
$("#card-body").hidden = fresh || !real;
$("#card-empty").hidden = fresh || real;
$("#card-h").textContent = fresh ? "NEW CHARACTER" : real ? "CARD: " + c.name.toUpperCase() : "CARD";
@@ -1610,6 +1666,12 @@ const KINDS = {
note: "turns your voice note into words for the character. " + TEXT_NOTE },
};
let pickKind = "chat";
// the order the picker lists them in, the same for every kind and kept in this browser: by name, or by price either way
let modelSort = (() => { const s = local.get(K.msort, "name"); return A.MODEL_SORTS.includes(s) ? s : "name"; })();
/** a kind's price column (its head starts with $), or -1: clips have none, venice prices each one when asked */
const costCol = (kind) => KINDS[kind].head.findIndex((t) => t.startsWith("$"));
/** how a kind is listed: by price only when it has one */
const sortOf = (kind) => (costCol(kind) < 0 ? "name" : modelSort);
/** the models a kind picks from, or null while Venice is still being asked */
function kindList(kind) {
if (kind === "chat") return S.models;
@@ -1641,13 +1703,24 @@ function openModels(kind = "chat") {
: kind === "adultPhoto" ? "for adult characters' adult photos." : kind === "adultClip" ? "for adult characters' adult clips."
: "for describing your photos to a character whose model can't see.";
$("#msearch").value = "";
const head = $("#mhead");
head.textContent = "";
head.append(h("tr", {}, k.head.map((t) => h("th", { scope: "col", text: t }))));
$("#msortpick").hidden = costCol(kind) < 0;
$("#msort").value = modelSort;
modelHead();
if (!$("#modeldlg").open) $("#modeldlg").showModal();
renderModels();
loadList(k.type);
}
/** the picker's column heads; sorted by price, the price column says which way (aria-sort, and an arrow) */
function modelHead() {
const k = KINDS[pickKind], how = sortOf(pickKind), at = how === "name" ? -1 : costCol(pickKind);
const up = how === "cheapest", arrow = up ? "↑" : "↓";
// a text model goes by its price in, so its arrow goes after IN
const label = (t) => (t.startsWith("$ IN ") ? "$ IN " + arrow + " " + t.slice(5) : t + " " + arrow);
const head = $("#mhead");
head.textContent = "";
head.append(h("tr", {}, k.head.map((t, i) => h("th", { scope: "col", text: i === at ? label(t) : t,
"aria-sort": i === at ? (up ? "ascending" : "descending") : null }))));
}
function renderModels() {
const kind = pickKind, k = KINDS[kind], c = card(), tb = $("#mlist"), q = $("#msearch").value.trim().toLowerCase();
tb.textContent = "";
@@ -1658,8 +1731,8 @@ function renderModels() {
}
const cur = (kindModel(kind) || {}).id, own = kind === "chat" ? ownModel(c) : "";
const rank = (m) => (m.id === cur ? 2 : 0) + (m.id === own ? 1 : 0);
const list = list0.filter((m) => !q || `${m.id} ${m.name} ${(m.traits || []).join(" ")} ${m.privacy} ${m.uncensored ? "uncensored" : ""}`.toLowerCase().includes(q))
.sort((a, b) => rank(b) - rank(a) || a.name.localeCompare(b.name));
const list = A.sortModels(list0.filter((m) => !q || `${m.id} ${m.name} ${(m.traits || []).join(" ")} ${m.privacy} ${m.uncensored ? "uncensored" : ""}`.toLowerCase().includes(q)),
sortOf(kind), { type: k.type, rank });
const num = (text) => h("td", { class: "num", text });
const shape = { adultPhoto: "photo", adultClip: "clip" }[kind] || kind;
for (const m of list) {
@@ -1746,9 +1819,10 @@ function renderTools() {
const pm = adultFor(c, thread()).ok ? adultPhotoModel() : photoModel();
const pp = pm ? A.photoPrice(pm, Math.min(Math.max(1, refsOf(c).length), Math.max(1, pm.maxIn || 1))) : null;
items.push(linkish(`ask for a photo${pp != null ? " · " + A.usd(pp) : ""}`, () => askFor("photo")), linkish("ask for a clip", () => askFor("clip")));
const on = !!S.voiceOn[c.id];
items.push(linkish(`voice notes: ${on ? "on" : "off"}`, () => setVoiceNotes(c, !on),
{ "aria-pressed": on ? "true" : "false", title: on ? `${c.name} replies with voice notes` : `${c.name} replies in text; "hear it" reads one out` }));
const mode = voiceMode(c), next = { off: "on", on: "asmr", asmr: "off" }[mode];
items.push(linkish(`voice notes: ${mode}`, () => setVoiceNotes(c, next),
{ "aria-pressed": mode === "off" ? "false" : "true", title: mode === "on" ? `${c.name} replies with voice notes. Click for ASMR.`
: mode === "asmr" ? `${c.name} whispers replies over soft rain (abilities → asmr). Click to turn off.` : `${c.name} replies in text; "hear it" or "whisper it" reads one out. Click to turn on.` }));
const left = Math.max(0, S.ab.allowance - spentToday());
items.push(linkish(`on their own: ${S.ab.own ? "on" : "off"}`, () => setOwn(!S.ab.own), { "aria-pressed": S.ab.own ? "true" : "false",
title: S.ab.own ? `Characters text first and send photos and voice notes when they like: ${A.usd(left)} of today's ${A.usd(S.ab.allowance)} left` : "Characters only do what you ask" }));
@@ -1806,9 +1880,17 @@ function renderAbilities() {
adultChanged();
}), ["photos: ", change("adultPhoto", apm), " · clips: ", change("adultClip", acm), ` · portraits: ${aim ? aim.name : "…"}`,
" · without safe mode, only from pictures made here with ai, with the 18+ rule first in every prompt; never anyone under 18, never a real person"]),
row("VOICE NOTES", real ? tick(`${c.name} replies with voice notes`, !!S.voiceOn[c.id], (on) => setVoiceNotes(c, on))
: "pick a character: their voice and mood are on their card.",
row("VOICE NOTES", real ? (() => {
const s = h("select", { "aria-label": `How ${c.name}'s replies come` }, [["off", "off: text"], ["on", "on: their voice"], ["asmr", "asmr: whispered"]].map(([v, t]) => h("option", { value: v, text: t })));
s.value = voiceMode(c);
s.addEventListener("change", () => setVoiceNotes(c, s.value));
return h("label", { class: "check" }, `${c.name}'s replies: `, s);
})() : "pick a character: their voice and mood are on their card.",
real ? ["voice: ", change("voice", vm), ` (${vm ? voiceOf(c, vm) : c.speech.voice || A.DEFAULT_VOICE})`, vm && vm.perMChars != null ? ` · about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""] : ""),
row("ASMR", asmrControls(), (() => {
const p = W.WHISPER_PRESETS[S.ab.asmr.voice], vm = (S.lists.tts || []).find((m) => m.id === p.model);
return `whispered by ${p.voice} on ${vm ? vm.name : p.model}${vm && vm.perMChars != null ? ` · about ${A.usd(A.voicePrice(vm, 200))} a reply` : ""} · for voice notes set to asmr, "whisper it" under a message, and /whisper`;
})()),
row("HEARING", tick("let them hear my voice notes", S.ab.hear, (on, i) => {
if (on && !(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && typeof MediaRecorder !== "undefined")) {
i.checked = false;
@@ -2279,6 +2361,7 @@ function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) {
if (id === "new") return writeFromIdea(idea, { door, name });
if (id === "photoshoot") return photoshoot(idea, { door });
if (id === "recall") return recall(idea);
if (id === "whisper") return whisper(idea);
}
/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */
function threadFor(c) {
@@ -2392,6 +2475,95 @@ async function photoshoot(arg, { door = "chat" } = {}) {
balanceSoon();
}
}
/**
* /whisper: a short ASMR voice note. One call writes the script in their voice from the chat (and what it's about,
* if given); a whisper preset speaks it (W.WHISPER_PRESETS: luna on Inworld, eve on Grok, picked from the
* asmr.gandora.lol bake-off); it's mixed over soft rain made here, and lands in their chat as a voice note.
*/
let whispering = false;
async function whisper(arg) {
const c = card();
if (!c || c.plain) { err("/whisper needs a character: pick one in the harbor first."); return; }
if (whispering) { toast("A whisper is already on its way."); return; }
const { preset: named, brief } = W.parseWhisper(arg);
const key = named || (W.WHISPER_PRESETS[S.ab.asmr.voice] ? S.ab.asmr.voice : W.WHISPER_DEFAULT), p = W.WHISPER_PRESETS[key];
await loadList("tts");
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
const guess = vm ? A.voicePrice(vm, 600) : null;
if (!confirm(`A whispered voice note from ${c.name} (${p.voice})${brief ? " about " + brief : ""}? About ${guess != null ? A.usd(guess) : "a cent"} on your Venice key, plus the words.`)) return;
const t = threadFor(c);
whispering = true;
S.inflight++;
doing(t, "is writing something to whisper…");
try {
const lines = t.msgs.filter((m) => !m.hidden && (m.role === "user" || m.role === "assistant")).slice(-10)
.map((m) => `${m.role === "user" ? "Them" : c.name}: ${String(m.content || "").replace(/\s+/g, " ").trim().slice(0, 300)}`);
const o = await askJson(await jsonModel(c), { messages: W.whisperMessages(c, { brief, lines }), schema: W.WHISPER_SCHEMA, maxTokens: 400, temperature: 0.9, what: "the whisper" });
const script = W.cleanWhisper(o);
if (!script) throw new Error("The model sent back nothing to whisper. Try again, or pick another model on the card.");
doing(t, "is whispering…");
const input = W.whisperInput(script, p);
const voice = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: S.ab.asmr.pace || p.speed, format: p.format });
let blob = voice;
if (S.ab.asmr.rain && S.ab.asmr.level > 0) { try { blob = await overRain(voice, S.ab.asmr.level); } catch { /* the dry take is still a whisper */ } }
const price = vm ? A.voicePrice(vm, input.length) : null;
done(t);
deliver(t, { role: "assistant", content: script, voice: { id: await M.put(blob), model: vm ? vm.name : p.model, voice: p.voice, price, whisper: true } });
} catch (e) {
done(t);
err(e.message || String(e));
} finally {
whispering = false;
S.inflight--;
balanceSoon();
}
}
/** the voice, close-mic'd (a little low end, gentle compression), over soft rain made here; a wav back */
async function overRain(blob, level = 0.09) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const probe = new (window.AudioContext || window.webkitAudioContext)();
const v = await probe.decodeAudioData(await blob.arrayBuffer());
probe.close && probe.close();
const rate = 44100, pad = 1.5, len = Math.ceil((v.duration + pad * 2) * rate);
const ctx = new AC(2, len, rate);
// rain: pink-ish hiss with a slow flutter, over a low brown rumble
const noise = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = noise.getChannelData(ch); let b0 = 0, b1 = 0, b2 = 0, br = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.997 * b0 + 0.029 * w; b1 = 0.985 * b1 + 0.032 * w; b2 = 0.95 * b2 + 0.048 * w;
br = (br + 0.02 * w) / 1.02;
d[i] = (b0 + b1 + b2 + w * 0.05) * 0.6 + br * 2.5;
}
}
const rain = ctx.createBufferSource(); rain.buffer = noise;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 120;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 6500;
const rg = ctx.createGain(); const end = len / rate;
rg.gain.setValueAtTime(0, 0); rg.gain.linearRampToValueAtTime(level, 1.2); rg.gain.setValueAtTime(level, end - 1.2); rg.gain.linearRampToValueAtTime(0, end);
rain.connect(hp); hp.connect(lp); lp.connect(rg); rg.connect(ctx.destination);
const src = ctx.createBufferSource(); src.buffer = v;
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 4;
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
const vg = ctx.createGain(); vg.gain.value = 1.6;
src.connect(lo); lo.connect(cmp); cmp.connect(vg); vg.connect(ctx.destination);
rain.start(0); src.start(pad);
return wavBlob(await ctx.startRendering());
}
/** an AudioBuffer as a 16-bit wav */
function wavBlob(buf) {
const n = buf.numberOfChannels, len = buf.length, out = new DataView(new ArrayBuffer(44 + len * n * 2));
const s = (o, t) => { for (let i = 0; i < t.length; i++) out.setUint8(o + i, t.charCodeAt(i)); };
s(0, "RIFF"); out.setUint32(4, 36 + len * n * 2, true); s(8, "WAVE"); s(12, "fmt "); out.setUint32(16, 16, true);
out.setUint16(20, 1, true); out.setUint16(22, n, true); out.setUint32(24, buf.sampleRate, true);
out.setUint32(28, buf.sampleRate * n * 2, true); out.setUint16(32, n * 2, true); out.setUint16(34, 16, true);
s(36, "data"); out.setUint32(40, len * n * 2, true);
const chans = []; for (let c = 0; c < n; c++) chans.push(buf.getChannelData(c));
let o = 44;
for (let i = 0; i < len; i++) for (let c = 0; c < n; c++) { const x = Math.max(-1, Math.min(1, chans[c][i])); out.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2; }
return new Blob([out], { type: "audio/wav" });
}
/**
* A photo of them from the chat, kept on their card: as their portrait (as = "portrait"; the look it was drawn from goes
* on the card too if it had none), or as one of the pictures their photos and clips are made from (as = "").
@@ -2631,12 +2803,12 @@ async function ownVoice(t) {
const c = cardOf(t), msg = [...t.msgs].reverse().find((m) => m.role === "assistant" && !m.media && !m.demo);
if (!c || !msg || msg.voice || voicing.has(msg)) return;
await loadList("tts");
const vm = voiceModel(c), price = vm ? A.voicePrice(vm, A.speakable(msg.content).length) : null;
const asmr = voiceMode(c) === "asmr", price = voiceCost(c, msg.content, asmr);
if (msg.voice || voicing.has(msg) || !reserve(price)) return;
voicing.add(msg);
doing(t, "is recording a voice note…");
doing(t, asmr ? "is whispering…" : "is recording a voice note…");
try {
const v = { ...(await makeVoice(c, msg.content)), own: true };
const v = { ...(await makeVoice(c, msg.content, undefined, { asmr })), own: true };
if (msg.voice) { refund(price); return; } // "hear it" got there first
msg.voice = v;
saveThread(t);
@@ -3060,8 +3232,23 @@ function setOwn(on) {
if ($("#abilitydlg").open) renderAbilities();
toast(on ? `Characters act on their own now, within ${A.usd(S.ab.allowance)} a day.` : "Characters only do what you ask now.");
}
/** the ASMR settings: voice, pace, rain, how often */
function asmrControls() {
const a = S.ab.asmr, set = (k, v) => { S.ab.asmr = { ...S.ab.asmr, [k]: v }; saveAb(); renderAbilities(); };
const pick = (label, opts, key, num = true) => {
const s = h("select", { "aria-label": label }, opts.map(([v, t]) => h("option", { value: String(v), text: t })));
s.value = String(key === "level" && !a.rain ? 0 : a[key]);
s.addEventListener("change", () => { const v = num ? Number(s.value) : s.value; if (key === "level") { S.ab.asmr = { ...S.ab.asmr, rain: v > 0, level: v || a.level }; saveAb(); renderAbilities(); } else set(key, v); });
return s;
};
const voices = Object.keys(W.WHISPER_PRESETS).map((k) => [k, W.WHISPER_PRESETS[k].voice]);
return h("span", {}, "voice: ", pick("Whisper voice", voices, "voice", false), " · pace: ", pick("Whisper pace", ASMR_PACES, "pace"),
" · rain: ", pick("Rain under the whisper", ASMR_LEVELS, "level"), " · how often: ", pick("How often replies are whispered in asmr", ASMR_EVERY, "every"));
}
function setVoiceNotes(c, on) {
if (on) S.voiceOn[c.id] = true; else delete S.voiceOn[c.id];
const mode = on === true ? "on" : on === false ? "off" : on;
if (mode === "on") S.voiceOn[c.id] = true; else if (mode === "asmr") S.voiceOn[c.id] = "asmr"; else delete S.voiceOn[c.id];
if (mode !== "off" && mode !== "on" && mode !== "asmr") delete S.voiceOn[c.id];
local.set(K.voiceOn, S.voiceOn);
renderTools();
if ($("#speech-on")) $("#speech-on").checked = !!S.voiceOn[(card() || {}).id];
@@ -3492,12 +3679,34 @@ async function writeFresh(avoid = []) {
rxStatus(avoid.length ? `Rewriting with ${modelName(writer)}, steering clear of what was shared…` : `Writing someone new with ${modelName(writer)}…`);
const o = await askJson(writer, { messages: C.writeMessages(S.rx.ideas, $("#rx-twist").value, avoid), schema: C.WRITE_SCHEMA,
maxTokens: 2000, temperature: 0.95, what: "a character" });
const first = C.cardFromWrite(o, { model });
const src = S.rx.source;
const check = C.similarity(C.cardText(first), src.text, { names: [src.name].filter(Boolean), newName: first.name });
// someone new from an adult character is adult too: a rewrite doesn't change who it's for
S.rx.draft = C.cardFromWrite(o, { source: src.name || src.ref ? src : null, check, model, adult: !!src.adult });
S.rx.check = check;
buildDraft(o, model);
renderDraft();
reveal($("#rx-draft"));
rxStatus("");
} catch (e) { rxStatus(e.message, true); } finally { rxBusy(false); }
}
/** the draft from the writer's words (S.rx.raw, which you can edit), checked against the source here in the browser */
function buildDraft(o, model) {
const src = S.rx.source;
S.rx.raw = { ...o };
S.rx.model = model;
const first = C.cardFromWrite(o, { model });
const check = C.similarity(C.cardText(first), src.text, { names: [src.name].filter(Boolean), newName: first.name });
// someone new from an adult character is adult too: a rewrite doesn't change who it's for
S.rx.draft = C.cardFromWrite(o, { source: src.name || src.ref ? src : null, check, model, adult: !!src.adult });
S.rx.check = check;
}
/** "rewrite": the writer revises the draft as it stands (your edits included) with your instruction */
async function reviseDraft(instruction, avoid = []) {
const c = card();
if (!c || !S.rx.raw || S.rx.busy) return;
rxBusy(true);
try {
const writer = await jsonModel(c);
rxStatus(`Rewriting with ${modelName(writer)}…`);
const o = await askJson(writer, { messages: C.reviseMessages(S.rx.ideas, S.rx.raw, instruction, avoid), schema: C.WRITE_SCHEMA,
maxTokens: 2000, temperature: 0.8, what: "a character" });
buildDraft(o, S.rx.model || c.model || S.model);
renderDraft();
reveal($("#rx-draft"));
rxStatus("");
@@ -3508,21 +3717,44 @@ function renderDraft() {
const d = S.rx.draft, chk = S.rx.check, box = $("#rx-draft");
box.textContent = "";
box.hidden = false;
const pct = Math.round(chk.share * 1000) / 10;
box.append(h("h4", { text: d.name }), h("p", { class: "tagline", text: d.tagline }),
h("p", { class: "persona", text: d.persona }),
h("span", { class: `verdict ${chk.verdict}`, text: VERDICT[chk.verdict] }),
h("p", { class: "mono", text: `${pct}% of its five-word phrases appear in the source; the longest shared run is ${chk.run.len} words.` }));
if (chk.reasons.length) box.append(h("ul", { class: "why-list" }, chk.reasons.map((r) => h("li", { text: r }))));
const raw = S.rx.raw || {};
// the write-up, editable: every change re-runs the check, here in the browser
const field = (key, label, tag, attrs = {}) => {
const el = h(tag, { "aria-label": label, class: `ed-${key}`, ...attrs });
el.value = Array.isArray(raw[key]) ? raw[key].join("\n\n") : String(raw[key] || "");
el.addEventListener("input", () => { S.rx.raw[key] = el.value; recheck(); });
if (tag === "textarea") { const fit = () => { el.style.height = "auto"; el.style.height = el.scrollHeight + 2 + "px"; }; el.addEventListener("input", fit); requestAnimationFrame(fit); }
return h("label", { class: "edfield" }, h("span", { class: "mono", text: label }), el);
};
const verdict = h("span"), stat = h("p", { class: "mono" }), why = h("ul", { class: "why-list" });
const keep = h("button", { type: "button", class: "btn solid small", text: "KEEP IN HARBOR", onclick: () => {
buildDraft(S.rx.raw, S.rx.model); // the last keystroke counts, even inside the recheck's pause
if (S.rx.check.verdict === "too-close") { paint(); return; }
keepDraft();
} });
const avoidBtn = h("button", { type: "button", class: "btn small", text: "REWRITE, AVOIDING THESE",
onclick: () => reviseDraft("change whatever is listed under avoid, in your own words", C.avoidList(S.rx.check, [S.rx.source.name])) });
const paint = () => {
const k = S.rx.check, pct = Math.round(k.share * 1000) / 10;
verdict.className = `verdict ${k.verdict}`; verdict.textContent = VERDICT[k.verdict];
stat.textContent = `${pct}% of its five-word phrases appear in the source; the longest shared run is ${k.run.len} words.`;
why.textContent = ""; for (const r of k.reasons) why.append(h("li", { text: r })); why.hidden = !k.reasons.length;
keep.disabled = k.verdict === "too-close"; avoidBtn.hidden = k.verdict === "fresh";
};
let timer = 0;
const recheck = () => { clearTimeout(timer); timer = setTimeout(() => { buildDraft(S.rx.raw, S.rx.model); paint(); }, 250); };
box.append(field("name", "NAME", "input", { maxlength: 80, class: "ed-name" }), field("tagline", "TAGLINE", "input", { maxlength: 120 }),
field("greeting", "GREETING", "textarea", { rows: 3 }), field("persona", "PERSONA", "textarea", { rows: 8 }),
verdict, stat, why);
paint();
if (d.lineage[0]) box.append(h("p", { class: "mono", text: "lineage: " + C.creditLine(d.lineage[0]) + "." }));
if (d.adult) box.append(h("p", { class: "mono", text: "adult (18+), like the character they came from." }));
const ask = h("input", { "aria-label": "How to rewrite it", placeholder: "rewrite: e.g. older, drier humour, a lighthouse keeper", maxlength: 400 });
const go = () => { const v = ask.value.trim(); if (v) reviseDraft(v); };
ask.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); go(); } });
box.append(h("div", { class: "rewrite" }, ask, h("button", { type: "button", class: "btn small", text: "REWRITE", onclick: go })));
const row = h("div", { class: "acts" });
const keep = h("button", { type: "button", class: "btn solid small", text: "KEEP IN HARBOR", onclick: keepDraft });
if (chk.verdict === "too-close") keep.disabled = true;
row.append(keep);
if (chk.verdict !== "fresh") row.append(h("button", { type: "button", class: "btn small", text: "REWRITE, AVOIDING THESE",
onclick: () => writeFresh(C.avoidList(chk, [S.rx.source.name])) }));
row.append(h("button", { type: "button", class: "btn small", text: "WRITE ANOTHER", onclick: () => writeFresh() }));
row.append(keep, avoidBtn, h("button", { type: "button", class: "btn small", text: "WRITE ANOTHER", onclick: () => writeFresh() }));
box.append(row);
}
/** a remix kept: they join the harbor, and their chat opens, with their greeting, for you to say hello */
@@ -3758,6 +3990,13 @@ function bind() {
});
$("#plain").addEventListener("click", () => selectCard(PLAIN, { talk: true }));
$("#msearch").addEventListener("input", renderModels);
$("#msort").addEventListener("change", () => {
const how = $("#msort").value;
modelSort = A.MODEL_SORTS.includes(how) ? how : "name";
local.set(K.msort, modelSort);
modelHead();
renderModels();
});
$("#modelform").addEventListener("submit", (e) => e.preventDefault()); // Enter in the search box keeps it open
$("#mclose").addEventListener("click", () => $("#modeldlg").close());
$("#vprev").addEventListener("click", () => loadVenice(Math.max(0, S.v.offset - PAGE), { paging: true }));
+9
View File
@@ -665,6 +665,15 @@ export function writeMessages(distillate, twist = "", avoid = []) {
{ role: "user", content: "Write a new character from these ideas. Do not use anything listed under avoid.\n\n" + JSON.stringify(ask, null, 1) }];
}
/** a rewrite of a draft: the reviser sees the ideas, the draft and what to change, never the source */
export function reviseMessages(distillate, draft, instruction = "", avoid = []) {
const ask = { ideas: distillate, draft: { name: draft.name, tagline: draft.tagline, greeting: draft.greeting, persona: draft.persona,
examples: draft.examples, tags: draft.tags }, change: str(instruction, 1000).trim() || "tighten it; keep who they are", avoid: avoid.slice(0, 20) };
return [{ role: "system", content: WRITE_SYSTEM + " Here you are revising a draft you wrote: make the change asked for, keep " +
"everything else as it is unless the change needs it to move, and return the whole character again." },
{ role: "user", content: "Revise this character. Do not use anything listed under avoid.\n\n" + JSON.stringify(ask, null, 1) }];
}
export function normalizeDistillate(o = {}) {
const out = {};
for (const k of DISTILL_KEYS) {
+12 -4
View File
@@ -23,6 +23,7 @@
<link rel="alternate" type="text/markdown" href="skill.md" title="Skill">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
<script src="update.js" defer></script>
</head>
<body>
<a class="skip" href="#chat">skip to the chat</a>
@@ -30,7 +31,7 @@
<header class="mast" id="top">
<h1>HAVRE</h1>
<p class="motto">a harbor for characters. <br>talk to venice characters on your own key. <br>no account. no server. nothing kept but what you keep.</p>
<nav class="nav" aria-label="On this page"><a href="#how">how it works</a> / <a href="#agents">for agents</a> / <a href="#costs">costs</a> / <a href="#plans">early access</a> / <a href="open/">open studio (beta)</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
<nav class="nav" aria-label="Havre"><a href="open/">studio</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
</header>
<form class="keybar" id="keyform" autocomplete="off" aria-label="Your Venice API key">
@@ -300,7 +301,7 @@
<tr><th scope="row">a remix</th><td>two short calls; five more for a venice character</td></tr>
</tbody>
</table>
<p class="mono">the model pickers show venice's prices, what you ask for shows its price before you pay, and your balance shows next to your key.</p>
<p class="mono">the model pickers show venice's prices and can sort by them, what you ask for shows its price before you pay, and your balance shows next to your key.</p>
</div>
</section>
@@ -370,8 +371,15 @@
<h2 id="model-h">MODEL</h2>
<div class="dlgbody">
<p class="mono" id="model-for"></p>
<label class="sr" for="msearch">Search models</label>
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
<div class="mfind">
<label class="sr" for="msearch">Search models</label>
<input id="msearch" type="search" placeholder="search models: a name, private, uncensored…" autocomplete="off">
<label class="pick" id="msortpick">sort <select id="msort">
<option value="name" selected>name</option>
<option value="cheapest">cheapest first</option>
<option value="priciest">priciest first</option>
</select></label>
</div>
<div class="mtable">
<table class="table models">
<thead id="mhead"><tr><th scope="col">MODEL</th><th scope="col">PRIVACY</th><th scope="col">CAN</th><th scope="col">$ IN / OUT</th><th scope="col">CONTEXT</th></tr></thead>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 78 KiB

+20
View File
@@ -319,6 +319,9 @@ pre.saw {
.newcard input, .newcard textarea { display: block; width: 100%; margin-top: 4px; padding: 6px 8px; font: 16px var(--serif);
border: 1px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink); }
tr.opens { cursor: pointer; }
#roster tr.newrow .rowname { font-weight: 700; }
#roster tr.newrow[aria-current="true"] { background: var(--ink); color: var(--paper); }
#roster tr.newrow[aria-current="true"] .linkish { color: var(--paper); }
tr.opens:hover { background: var(--hi); }
.rowname { font: inherit; color: inherit; text-decoration: none; }
.rewrite { display: flex; gap: 8px; margin: 6px 0; }
@@ -425,6 +428,13 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.verdict.too-close { background: var(--bad); border-color: var(--bad); color: var(--paper); }
.why-list { margin: 0; padding-left: 20px; font: 13px var(--mono); }
.draft .acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.draft .edfield { display: flex; flex-direction: column; gap: 3px; }
.draft .edfield input, .draft .edfield textarea { width: 100%; border: 1px solid var(--ink); background: transparent; color: inherit; padding: 6px 8px; font: inherit; border-radius: 0; }
.draft .edfield textarea { resize: vertical; font-size: 17px; line-height: 1.4; overflow: hidden; }
.draft .edfield .ed-name { font-size: 26px; font-weight: 700; }
.draft .edfield .ed-tagline { font-style: italic; }
.draft .rewrite { display: flex; gap: 8px; }
.draft .rewrite input { flex: 1; min-width: 0; border: 1px solid var(--ink); background: transparent; color: inherit; padding: 6px 8px; font: inherit; border-radius: 0; }
.exports { margin: 0 0 12px; padding: 0; list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 0; }
.exports li { margin: 0 -1px -1px 0; }
@@ -460,6 +470,8 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.dlg::backdrop { background: var(--veil); }
.dlg.wide { width: min(1040px, calc(100vw - 32px)); }
.mtable { max-height: min(58vh, 560px); overflow: auto; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
.mfind { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.mfind input[type=search] { flex: 1 1 220px; width: auto; }
.models { font-size: 16px; }
.models thead th { position: sticky; top: 0; background: var(--paper); }
.models tbody th .mid { display: block; font: 12px/1.4 var(--mono); font-weight: 400; overflow-wrap: anywhere; }
@@ -476,6 +488,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.dlgbody { display: flex; flex-direction: column; gap: 14px; padding: 16px; }
.dlgbody label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); }
.dlgbody label.check { flex-direction: row; align-items: center; gap: 8px; }
.dlgbody label.pick { flex-direction: row; align-items: center; gap: 6px; }
.dlgbody textarea { font: 17px var(--serif); }
.drop {
display: flex; flex-direction: column; gap: 4px; align-items: flex-start; padding: 18px;
@@ -548,6 +561,13 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.lefthead [role=tab] { font-size: 19px; }
.lefthead .sep::before { font-size: 19px; }
}
/* a new version is out: a bar at the top to reload (update.js) */
#upd {
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 30;
padding: 9px 16px; border: 1px solid var(--paper); border-radius: 0; background: var(--ink); color: var(--paper);
font: 700 13px/1 var(--mono); letter-spacing: .12em; white-space: nowrap; cursor: pointer;
}
#upd:hover, #upd:focus-visible { background: var(--hi); color: var(--ink); border-color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
}
+28 -2
View File
@@ -72,6 +72,32 @@ test("prices: a photo with extra pictures, a voice note, a minute of hearing, wr
["$0.03", "$1.40", "$0.0007", "$0.00072", "$188", "$0", "?", "under $0.0001"]);
});
test("the model picker's order: by name with the one in use first, or by price either way, the unpriced last", () => {
const ids = (l) => l.map((m) => m.id);
const text = [{ id: "glm", name: "GLM 5.2", input: 1.4, output: 4.4 }, { id: "tiny", name: "Tiny", input: 0.1, output: 0.2 },
{ id: "dear", name: "Dear", input: 6, output: 30 }, { id: "even", name: "Even", input: 0.1, output: 0.9 },
{ id: "free", name: "Free", input: 0, output: 0 }, { id: "odd", name: "Odd" }];
const rank = (m) => (m.id === "tiny" ? 2 : m.id === "glm" ? 1 : 0);
assert.deepEqual(ids(A.sortModels(text, "name", { rank })), ["tiny", "glm", "dear", "even", "free", "odd"], "the one in use, their own, then A to Z");
assert.deepEqual(ids(A.sortModels(text, "cheapest", { rank })), ["free", "tiny", "even", "glm", "dear", "odd"],
"by the price in, then out; none held at the top; no price last");
assert.deepEqual(ids(A.sortModels(text, "priciest", { rank })), ["dear", "glm", "even", "tiny", "free", "odd"], "the other way, no price still last");
assert.deepEqual(ids(A.sortModels(text, "sideways", { rank })), ids(A.sortModels(text, "name", { rank })), "an order it doesn't know is by name");
assert.equal(text[0].id, "glm", "the list it was given stays as it was");
assert.deepEqual(A.MODEL_SORTS, ["name", "cheapest", "priciest"]);
const pics = [{ id: "a", name: "A", price: 0.1, extra: 0.03 }, { id: "b", name: "B", price: 0.04 }, { id: "c", name: "C", price: 0.1 },
{ id: "d", name: "D", price: null }];
assert.deepEqual(ids(A.sortModels(pics, "cheapest", { type: "inpaint" })), ["b", "c", "a", "d"], "a photo by its price, then what more pictures add");
assert.deepEqual(ids(A.sortModels([{ id: "q", name: "Qwen 3 TTS", perMChars: 112.5 }, { id: "k", name: "Kokoro", perMChars: 3.5 }], "cheapest", { type: "tts" })),
["k", "q"]);
assert.deepEqual(ids(A.sortModels([{ id: "p", name: "Parakeet", perSecond: 0.0001 }, { id: "w", name: "Whisper", perSecond: 0.0002 }], "priciest", { type: "asr" })),
["w", "p"]);
assert.equal(A.modelCost("video", { id: "x", durations: ["5s"] }), null, "a clip has no price to sort by: venice prices each one");
assert.deepEqual(ids(A.sortModels([{ id: "y", name: "Y" }, { id: "x", name: "X" }], "cheapest", { type: "video" })), ["x", "y"], "so clips stay A to Z");
assert.deepEqual(A.modelCost("text", { input: 1.4, output: null }), [1.4, 0]);
assert.equal(A.modelCost("text", null), null);
});
// ---------- the shot ----------
test("the shot is written from the conversation, with the rules, and the photo keeps the character", () => {
const hist = [{ role: "user", content: "[Start the conversation…]", hidden: true }, { role: "assistant", content: "Ticket? Kidding." },
@@ -287,8 +313,8 @@ test("the conversation as the model reads it: a photo it sent is mentioned to it
assert.deepEqual(api.map((m) => m.role), ["user", "assistant", "user", "user", "assistant", "user"]);
assert.equal(api[1].content, "*sends a photo: On the deck*\n\n*sends a short video: Waving*");
assert.equal(api[2].content, "lovely");
assert.equal(api[2].content, "[a photo: A gull on a post.] what's this?");
assert.equal(api[4].content, "hello there");
assert.equal(api[3].content, "[a photo: A gull on a post.] what's this?");
assert.equal(api[5].content, "hello there");
assert.ok(!api.some((m) => /\[photo\]/.test(m.content)), "the model never sees a photo placeholder in its own voice");
const seen = A.withPicture([{ role: "system", content: "s" }, { role: "user", content: "[a photo] look" }], "data:image/jpeg;base64,AAAA");
assert.deepEqual(seen[1].content[1], { type: "image_url", image_url: { url: "data:image/jpeg;base64,AAAA" } });
+33 -7
View File
@@ -325,7 +325,7 @@ async function main() {
assert.match(await textOf(page, "#who-name"), /^chat$/i);
await has(page, "#log .line.note .said", /paste your venice key/);
assert.ok(await page.locator("#cast-box").isHidden(), "the harbor's own aren't listed");
assert.equal(await page.locator("#harbor-empty").evaluate((e) => e.hidden), false, "the empty harbor says so");
assert.equal(await page.locator("#roster tr.newrow").count(), 1, "the harbor always has a row for someone new");
assert.equal(await page.locator("#card-empty").evaluate((e) => e.hidden), false, "so does the empty card");
assert.ok(await page.locator("#card").isHidden(), "the card lives in the harbor tab, not under the characters");
assert.ok(await page.locator("#say").isDisabled(), "nobody to talk to yet");
@@ -334,13 +334,14 @@ async function main() {
await page.click("#lt-harbor");
assert.ok(await page.locator("#card-new").isVisible(), "an empty harbor opens on a new character to write");
assert.match(await textOf(page, "#card-h"), /^NEW CHARACTER$/);
assert.match(await textOf(page, "#roster tr.newrow"), /\+ new character/);
await page.click("#lt-chars");
await shot(page, "desktop.png");
// a card comes in through the import dialog, and talks from its own examples without a key
await page.click("#import");
await page.setInputFiles("#file", { name: "ro.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(v2)) });
await count(page, "#roster tr", 1);
await count(page, "#roster tr:not(.newrow)", 1);
await has(page, "#who-name", /^Captain Ro$/);
await has(page, "#log .line.them .said", /We're closed\. Mostly\./);
await has(page, "#roster td", /Ported from Captain Ro by someone/);
@@ -420,7 +421,7 @@ async function main() {
assert.equal(hello.messages[0].role, "system");
assert.match(hello.messages[0].content, /you can send photos of yourself/);
assert.equal(hello.messages[1].role, "user");
await count(page, "#roster tr", 2);
await count(page, "#roster tr:not(.newrow)", 2);
assert.match(await page.locator('#vgrid .tile[aria-pressed="true"]').evaluate((el) => el.textContent), /Sable Penrose.*talking now/);
// the model: their own on Venice to start with, and any other Venice model from the picker
await has(page, "#model-btn", /^GLM 5\.2$/);
@@ -429,6 +430,18 @@ async function main() {
assert.match(await textOf(page, "#mlist tr"), /GLM 5\.2.*their own/);
assert.match(await page.locator("#mlist tr", { hasText: "Tiny" }).evaluate((el) => el.textContent), /anonymized.*sees.*uncensored.*\$0\.10 \/ \$0\.20.*32k/);
await shot(page, "desktop-models.png");
// by price instead: cheapest first, then priciest first (the price column says which way), kept for next time
await page.selectOption("#msort", "cheapest");
await has(page, "#mlist tr:first-child", /^Tiny/);
assert.match(await textOf(page, "#mhead"), /\$ IN ↑ \/ OUT/);
assert.equal(await page.locator('#mhead th[aria-sort="ascending"]').count(), 1);
await page.selectOption("#msort", "priciest");
await has(page, "#mhead", /\$ IN ↓ \/ OUT/);
await has(page, "#mlist tr:first-child", /^GLM 5\.2/);
assert.equal(await page.evaluate(() => localStorage.getItem("havre.msort")), JSON.stringify("priciest"));
await page.selectOption("#msort", "name");
await has(page, "#mlist tr:first-child", /^GLM 5\.2.*their own/);
assert.equal(await page.locator("#mhead th[aria-sort]").count(), 0);
await page.getByRole("button", { name: "Use Tiny" }).click();
await has(page, "#model-btn", /^Tiny$/);
await page.fill("#say", "Do you have a boat?");
@@ -768,6 +781,10 @@ async function main() {
await page.getByRole("button", { name: "abilities" }).click();
await page.getByRole("button", { name: "Change the photo model", exact: true }).click();
await page.getByRole("button", { name: "Use Grok Imagine", exact: true }).click();
// a clip has no price to sort by (venice prices each one when asked): its picker has no sort
await page.getByRole("button", { name: "Change the clip model", exact: true }).click();
assert.ok(await page.locator("#msortpick").isHidden(), "no sort for clips");
await page.click("#mclose");
await page.click("#abclose");
// photos and voice notes stay through a reload, from this browser's own store
await page.reload();
@@ -794,7 +811,7 @@ async function main() {
await shot(page, "desktop-remix.png");
await page.locator("#rx-draft button", { hasText: "KEEP IN HARBOR" }).click();
await has(page, "#who-name", /^Ines Corbel$/);
await count(page, "#roster tr", 4);
await count(page, "#roster tr:not(.newrow)", 4);
await has(page, "#log .line.them .said", /Where are you headed\?/);
assert.equal(await page.locator("#studio.pane-chat").count(), 1, "a kept remix opens their chat, to say hello");
assert.ok(await page.locator("#chat-back").isHidden(), "a wide screen shows the list and the chat side by side, so no way back is needed");
@@ -1060,7 +1077,7 @@ async function main() {
await page.click("#lt-chars");
await page.click("#import");
await page.setInputFiles("#file", png);
await count(page, "#roster tr", 5);
await count(page, "#roster tr:not(.newrow)", 5);
// the security policy: nothing but api.venice.ai (and its private-share links for clips)
assert.equal(await page.evaluate(() => fetch("https://example.com/").then(() => "reached", () => "blocked")), "blocked");
@@ -1131,7 +1148,7 @@ async function main() {
taskmaster: [{ role: "user", content: "Plan my week" }, { role: "assistant", content: "Monday first." }] }));
});
await p2.reload();
await count(p2, "#roster tr", 1);
await count(p2, "#roster tr:not(.newrow)", 1);
const left = await p2.evaluate(() => {
const threads = JSON.parse(localStorage.getItem("havre.threads.v1") || "{}"), t = Object.values(threads)[0];
return { cards: JSON.parse(localStorage.getItem("havre.cards.v1")), seeded: localStorage.getItem("havre.seeded.v1"),
@@ -1179,7 +1196,7 @@ async function main() {
await has(p5, "#cast .tile[data-cast=maren] .go", /^talk again$/);
await p5.goto(base + "?meet=maren");
await has(p5, "#who-name", /^Maren$/);
await count(p5, "#roster tr", 2);
await count(p5, "#roster tr:not(.newrow)", 2);
assert.ok(await p5.locator("#log .line.me .said", { hasText: "What is this place?" }).isVisible(), "the same chat, not a new copy of her");
await own.close();
@@ -1269,6 +1286,15 @@ async function main() {
await p3.waitForTimeout(500);
await shot(p3, "phone-chat.png");
await shot(p3, "phone-full.png", { fullPage: true });
// the model picker on a phone: the table's heads are gone, the sort isn't, and nothing in it to zoom in on
await p3.click("#model-btn");
await count(p3, "#mlist tr", 2);
assert.ok(await p3.locator("#msort").isVisible(), "the sort, without the table's heads");
await p3.selectOption("#msort", "cheapest");
await has(p3, "#mlist tr:first-child", /^Tiny/);
assert.ok(await wide() <= W, `no sideways scroll in the picker (scrollWidth ${await wide()})`);
assert.deepEqual(await small(), [], "nothing in the picker to zoom in on");
await p3.click("#mclose");
// from a chat, the tabs above it go back to the list; a chat from your chats opens in its place
assert.ok(await p3.locator("#chat-back").isHidden(), "no back link: the tabs are the way back");
await p3.click("#lt-chars");
+37
View File
@@ -0,0 +1,37 @@
// update.js: an installed Havre has no address bar to refresh from, so the page notices when a new version has been
// published and offers a reload, like gandora's pill. It fingerprints the files the app is made of when it opens,
// checks again whenever it comes back to the foreground (and every ten minutes), and shows the pill when they differ.
// Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files.
(() => {
const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js",
"threads.js", "media.js", "backup.js", "theme.js"];
let base = null, shown = false, busy = false;
async function print() {
const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));
if (parts.some((p) => p === null)) return null; // offline: no verdict
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\u0000")));
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
}
function show() {
if (shown) return;
shown = true;
const b = document.createElement("button");
b.id = "upd"; b.type = "button"; b.textContent = "NEW VERSION · RELOAD";
b.addEventListener("click", () => location.reload());
document.body.append(b);
}
async function check() {
if (busy || shown || !crypto.subtle) return;
busy = true;
try {
const now = await print();
if (!now) return;
if (!base) base = now;
else if (now !== base) show();
} finally { busy = false; }
}
if (location.protocol !== "https:") return;
setTimeout(check, 3000);
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
})();
+2 -1
View File
@@ -141,8 +141,9 @@ export class Venice {
}
/** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */
async speak({ model, voice, input, mood = "", speed = 1, signal } = {}) {
async speak({ model, voice, input, mood = "", speed = 1, format = "", signal } = {}) {
const body = { input: String(input || "").slice(0, 4096), model, speed };
if (format) body.response_format = format;
if (voice) body.voice = voice;
if (mood) body.prompt = String(mood).slice(0, 500);
const res = await this.raw("/audio/speech", { body, signal });
+50
View File
@@ -33,6 +33,11 @@ export const WORKFLOWS = [
about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)",
usage: "/photoshoot [how many] [what the shoot is about]",
},
{
id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"],
about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)",
usage: "/whisper [luna|eve] [what it's about]",
},
{
id: "recall", command: "recall", title: "recall", takes: "words", gives: "memory", doors: ["chat"],
about: "what they remember about something (their notes, then the chats themselves), brought to mind for a while",
@@ -128,6 +133,51 @@ export function cardFromIdea(o, { model } = {}) {
return normalizeCard({ ...base, look: { ...base.look, description: look } });
}
// ---------- whisper: a short ASMR voice note, over soft rain ----------
// The voices that whisper well and cheaply (picked in the asmr.gandora.lol bake-off): Venice model, voice, how the
// words are wrapped so the model whispers, and the pace (0.9 of their usual reading speed sounded best).
export const WHISPER_PRESETS = {
luna: { model: "tts-inworld-1-5-max", voice: "Luna", lead: "[whispering] ", tail: "", speed: 0.77, format: "wav" },
eve: { model: "tts-xai-v1", voice: "eve", lead: "<whisper>", tail: "</whisper>", speed: 0.77, format: "mp3" },
};
export const WHISPER_DEFAULT = "luna";
/** the words after /whisper: a preset name first if there is one, the rest what it's about */
export function parseWhisper(arg = "") {
const m = /^\s*(\w+)(?:\s+|$)([\s\S]*)$/.exec(str(arg, 2000));
const first = m ? m[1].toLowerCase() : "";
if (WHISPER_PRESETS[first]) return { preset: first, brief: m[2].replace(/\s+/g, " ").trim() };
return { preset: "", brief: str(arg, 2000).replace(/\s+/g, " ").trim() };
}
export const WHISPER_SCHEMA = {
type: "object", additionalProperties: false, required: ["script"],
properties: { script: { type: "string" } },
};
export function whisperMessages(card, { brief = "", lines = [] } = {}) {
const system = `You are ${card.name}. Write a short ASMR voice note you whisper to the person you're talking with: ` +
"60 to 110 words, in your own voice and manner, intimate and calming, present tense, short phrases with pauses " +
"written as \"...\". Gentle sensory detail (breath, rain, a soft sound nearby) is welcome. No stage directions, no " +
"emoji, no markup, nothing in brackets: only the words to be whispered. Return one JSON object: script.";
const persona = card.persona ? clip(card.persona.replace(/\s+/g, " "), 900) : "";
const user = [
persona ? `Who you are: ${persona}` : "",
lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "You haven't talked yet.",
"What it's about: " + (str(brief, 500).trim() || "winding down together; help them relax."),
].filter(Boolean).join("\n\n");
return [{ role: "system", content: system }, { role: "user", content: user }];
}
/** the script, cleaned of anything the model shouldn't have added (brackets, asterisks, emoji-ish markup) */
export function cleanWhisper(o) {
return str(o && o.script, 1500).replace(/\[[^\]]*\]|\([^)]*\)|<[^>]*>|\*[^*]*\*/g, "").replace(/\s+/g, " ").trim();
}
/** what the voice model is sent: the script inside the preset's whisper markup */
export const whisperInput = (script, preset) => (preset.lead || "") + script + (preset.tail || "");
// ---------- photoshoot: a few photos of them, from the pictures on their card ----------
export const SHOOT_MIN = 1, SHOOT_MAX = 6, SHOOT_DEFAULT = 3;
+1 -1
View File
@@ -10,7 +10,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat
## How to use it
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately.
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately. Already have a `.md` file? Drop it anywhere on the page, or press **Open .md** above the editor; the file is read in the tab and replaces the text.
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
+58
View File
@@ -576,6 +576,64 @@
if (copied) done(); else failed();
}
// Files: a .md (or any text file) dropped anywhere on the page, or picked with Open .md, becomes the
// deck. It is read in the tab with FileReader; nothing is uploaded.
var fileInput = document.getElementById("file");
var openBtn = document.getElementById("open");
var dragDepth = 0;
function hasFile(e) {
var t = e.dataTransfer && e.dataTransfer.types;
return !!t && Array.prototype.indexOf.call(t, "Files") >= 0;
}
function openFile(file) {
if (!file) return;
if (file.size > 2000000) {
say(file.name + " is over 2 MB. A deck is plain text, so something else is in that file.", true);
return;
}
var reader = new FileReader();
reader.onload = function () {
load(String(reader.result).replace(/^/, ""));
current = 0;
history.replaceState(null, "", location.href.split("#")[0]);
parse();
say("Opened " + file.name + ". Edits stay in this tab; copy a link or the HTML to keep them.");
};
reader.onerror = function () { say("Couldn't read " + file.name + ".", true); };
reader.readAsText(file);
}
openBtn.addEventListener("click", function () { fileInput.click(); });
fileInput.addEventListener("change", function () {
openFile(fileInput.files[0]);
fileInput.value = "";
});
document.addEventListener("dragenter", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add("dropping");
});
document.addEventListener("dragover", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("dragleave", function (e) {
if (!hasFile(e)) return;
if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); }
});
document.addEventListener("drop", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove("dropping");
openFile(e.dataTransfer.files[0]);
});
// A deck in the address opens in place of the sample, and so does one pasted into it later.
var linked = /(^#|&)(d|md)=/.test(location.hash);
load(linked ? "" : SAMPLE);
+31 -5
View File
@@ -87,12 +87,34 @@
flex-direction: column;
min-height: 0;
}
.editor-pane .label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.editor-pane label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
margin-bottom: 6px;
}
.editor-pane .open { padding: 4px 10px; font-size: 0.78rem; }
#file { display: none; }
body.dropping textarea { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(110,168,254,0.25); }
body.dropping::after {
content: "Drop your .md file to open it";
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15,17,21,0.75);
color: var(--ink);
font-size: 1.3rem;
pointer-events: none;
z-index: 10;
}
textarea {
flex: 1;
@@ -402,12 +424,16 @@
</header>
<main id="try">
<section class="editor-pane">
<label for="src">Slides</label>
<div class="label-row">
<label for="src">Slides</label>
<button id="open" class="open" type="button">Open .md</button>
<input id="file" type="file" accept=".md,.markdown,.txt,text/markdown,text/plain">
</div>
<textarea id="src" spellcheck="false"></textarea>
<p class="hint">
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
Plain lines become paragraphs.
Plain lines become paragraphs. Or drop a <code>.md</code> file anywhere on the page.
</p>
</section>
<section class="stage">
@@ -429,7 +455,7 @@
<div class="steps">
<div class="step">
<h3>Type your slides</h3>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type.</p>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type. Already have a <code>.md</code> file? Drop it on the page.</p>
</div>
<div class="step">
<h3>Watch it set live</h3>
@@ -475,7 +501,7 @@
<div class="price">$0</div>
<p>For anyone who needs a quick deck right now.</p>
<ul>
<li>Full plain-text editor</li>
<li>Full plain-text editor; drop in a <code>.md</code> file to start from one</li>
<li>Live navigable preview with arrow keys and progress bar</li>
<li>One-click copy of the deck as self-contained HTML</li>
<li>Links with the deck inside them, nothing stored</li>
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# tools/build.sh: shots/desktop.png, the first screen at 1440x900, rendered from this folder by headless Chrome.
# Time's refresh workflow runs this (Actions -> refresh, slug roll-credits). Needs python3 (for a local server) and
# Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and CHROME_FLAGS=--no-sandbox when running as root.
set -eu
cd "$(dirname "$0")/.."
CHROME=${CHROME:-}
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
done
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
PORT=${PORT:-8795}
python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
mkdir -p shots
out=$PWD/shots/desktop.png
prof=$(mktemp -d)
rm -f "$out"
# shellcheck disable=SC2086
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --window-size=1440,900 \
--screenshot="$out" ${CHROME_FLAGS:-} "http://127.0.0.1:$PORT/" >"$prof.log" 2>&1 &
pid=$!
n=0
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5; done
sleep 1
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
[ -s "$out" ] || { echo "Chrome made no screenshot:" >&2; tail -5 "$prof.log" >&2; exit 1; }
rm -rf "$prof" "$prof.log"
echo "built: shots/desktop.png"
+16 -1
View File
@@ -1,5 +1,19 @@
{
"ventures": [
{
"slug": "gandora",
"name": "gandora",
"one_liner": "Type a seed, get a station: every song is written and rendered for you as it plays, every cover is drawn from the song's own settings, and your reactions steer what comes next.",
"url": "https://gandora.lol/",
"pages": "https://gandora.yolopreneur.lol/",
"launched": "2026-10-20",
"status": "live",
"signups": 0,
"source": "yolopreneur",
"repo": "https://teahub.xyz/gandora/site",
"hosting": "pages",
"shot": "https://gandora.yolopreneur.lol/shots/desktop.png"
},
{
"slug": "slyng",
"name": "SLYNG",
@@ -487,6 +501,7 @@
},
{
"slug": "roll-credits",
"shot": "roll-credits/shots/desktop.png",
"name": "Roll Credits",
"one_liner": "Type the names of the people who made something, pick a credits style, and download a scrolling end-credits video, entirely in your browser.",
"for_whom": "A team lead or friend-group organizer who wants to thank the people behind a launch, a trip, a bake-off, or a last day at a job, and wants it to feel like a film ending.",
@@ -505,5 +520,5 @@
"hosting": "pages"
}
],
"refreshed": "2026-10-20"
"refreshed": "2026-10-23"
}