Compare commits

...
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 2be78f9079 havre: tests, the build and the share card, in place
tools/build.sh runs on the refresh workflow: it clears what earlier versions left
(art, fonts, cast), runs the unit tests, then tests/smoke.mjs drives Chromium through
the page against a fake Venice and takes shots/desktop.png and shots/og.png.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:18:13 +00:00
teahubxyzandClaude Opus 5.5 90ad6fe2e1 havre: the page's script for the brutalist version
Venice's characters in a grid once a key is in (safe for work, sorted, paged), and
the one you pick says hello first; the conversation set like a script; your harbor
as a table; the card below it. The first version's three seeded characters leave
browsers that still hold them, unless someone changed them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:15:38 +00:00
teahubxyzandClaude Opus 5.5 de253d705c havre: the brutalist styles, and a skill without the old cast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:12:12 +00:00
teahubxyzandClaude Opus 5.5 ae6e865080 havre: a plain brutalist page, with Venice's characters in it
Black on white, system Times and Courier, 1px rules. Venice's public characters
fill the page once a key is in (safe for work only, sorted, a page at a time), and
the one you pick says hello first. No cast of Havre's own any more.

This commit: the page, its styles, the favicon, the Venice client (sorting,
paging, isAdult=false) and the agent files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sekpYMwTXuMdza1isMQA1
2026-10-16 05:10:36 +00:00
11 changed files with 1706 additions and 846 deletions

No files matched your search

+410 -281
View File
File diff suppressed because it is too large. Load diff
+1 -6
View File
@@ -1,6 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="29" fill="#A1E9FF" stroke="#2B2135" stroke-width="4"/>
<path d="M35 11.5 C37.5 19 42.5 28.5 47 36.5 C42 37.8 38.4 38.2 35 38.2 Z" fill="#FFFFFF" stroke="#2B2135" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M30 17.5 C27.6 24.4 24.6 31 21 37.2 C24.4 38 27.4 38.3 30 38.3 Z" fill="#FFB3E6" stroke="#2B2135" stroke-width="4" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M12.5 42.2 C21 40.8 42.8 40.6 51.8 41.8 C50.2 48.6 43.6 53.4 32.2 53.4 C20.6 53.4 14.6 48.8 12.5 42.2 Z" fill="#FFE780" stroke="#2B2135" stroke-width="4" stroke-linejoin="round"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#000"/><path fill="#fff" d="M7 6h5v8h8V6h5v20h-5v-8h-8v8H7z"/></svg>

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 164 B

+242 -246
View File
@@ -3,284 +3,280 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://outerface.venice.ai; font-src 'self'; connect-src https://api.venice.ai; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://outerface.venice.ai; font-src 'none'; connect-src https://api.venice.ai; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<meta name="referrer" content="strict-origin">
<title>Havre</title>
<meta name="description" content="A harbor for characters. Keep them in a file you own, talk to them on your own Venice key, and remix the ones you love into someone new, with credit.">
<meta name="description" content="A harbor for characters. Talk to Venice's characters on your own key, keep characters in a file you own, and remix the ones you love into someone new, with credit.">
<link rel="canonical" href="https://havre.yolopreneur.lol/">
<meta property="og:type" content="website">
<meta property="og:title" content="Havre: a harbor for characters">
<meta property="og:description" content="Keep characters in a file you own, talk to them on your own Venice key, and remix the ones you love into someone new, with credit.">
<meta property="og:description" content="Talk to Venice's characters on your own key. No account, no server: nothing is kept but what you keep.">
<meta property="og:url" content="https://havre.yolopreneur.lol/">
<meta property="og:image" content="https://havre.yolopreneur.lol/shots/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="theme-color" content="#FFEAD2">
<meta name="theme-color" content="#ffffff">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="alternate" type="text/plain" href="llms.txt" title="For agents">
<link rel="preload" href="fonts/fredoka.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
</head>
<body>
<header class="bar">
<a class="brand" href="#top" aria-label="Havre, to the top"><img src="favicon.svg" alt="" width="34" height="34"><span>havre</span></a>
<p class="motto">A harbor for characters</p>
<nav class="links" aria-label="On this page"><a href="#how">How it works</a><a href="#agents">For agents</a><a href="#plans">Plans</a></nav>
<a class="join" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&amp;title=Early+access%3A+Havre&amp;venture=havre&amp;plan=Harbor+%28founding%29">Get early access</a>
<a class="skip" href="#chat">skip to the chat</a>
<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></nav>
</header>
<main id="top">
<section class="studio" id="studio" aria-label="Havre studio" data-pane="chat">
<nav class="panes" aria-label="Studio panes">
<button type="button" data-pane="harbor">Harbor</button>
<button type="button" data-pane="chat" aria-current="true">Chat</button>
<button type="button" data-pane="sheet">Sheet</button>
</nav>
<form class="keybar" id="keyform" autocomplete="off" aria-label="Your Venice API key">
<label for="keyin">VENICE API KEY</label>
<input id="keyin" type="password" autocomplete="off" spellcheck="false" placeholder="paste it here">
<button type="submit" class="btn inv" id="keysave">SAVE</button>
<label class="check"><input type="checkbox" id="keyremember"> remember on this device</label>
<button type="button" class="btn ghost-inv" id="keyforget" hidden>FORGET</button>
<span id="keystatus" class="keystatus" role="status" aria-live="polite"></span>
<span class="keyfine">sent to api.venice.ai and nowhere else. <a href="https://venice.ai/settings/api" target="_blank" rel="noopener">get a key</a></span>
</form>
<aside class="harbor" aria-labelledby="harbor-h">
<div class="pane-head">
<h2 id="harbor-h">Harbor</h2>
<div class="row"><button type="button" class="pill small" id="new">New</button><button type="button" class="pill small" id="import">Import</button></div>
<main class="studio" id="studio">
<section class="chars" id="chars" aria-labelledby="chars-h">
<div class="sechead">
<h2 id="chars-h">CHARACTERS</h2>
<span class="meta">venice's public characters, safe for work.</span>
</div>
<ul id="roster" class="roster" aria-label="Your characters"></ul>
<div class="harbor-foot">
<button type="button" class="pill ghost wide" id="browse">Find on Venice</button>
<p class="tiny" id="saved-note">Saved in this browser. Export to keep a copy.</p>
</div>
</aside>
<form class="filters" id="venform" role="search" aria-label="Venice characters">
<label class="sr" for="vsearch">Search Venice characters</label>
<input id="vsearch" type="search" placeholder="search by name, or #tag" autocomplete="off" maxlength="200">
<label class="pick">sort <select id="vsort">
<option value="featured">featured</option>
<option value="imports">most imported</option>
<option value="highestRating">top rated</option>
<option value="mostRecent">newest</option>
</select></label>
<button type="submit" class="btn" id="vgo">SEARCH</button>
</form>
<p id="venstatus" class="status" role="status" aria-live="polite"></p>
<ul id="vgrid" class="grid" aria-label="Venice characters"></ul>
<p class="more"><span id="vpager" hidden><button type="button" class="linkish" id="vprev">previous</button> <span id="vrange"></span> <button type="button" class="linkish" id="vnext">next</button> / </span><button type="button" class="linkish" id="import">import a card</button> (v1, v2, v3, json or png) / <button type="button" class="linkish" id="new">write one</button></p>
</section>
<section class="chat" aria-labelledby="who-name">
<header class="who">
<span class="face" id="who-face" aria-hidden="true"></span>
<div class="who-text">
<h2 id="who-name">Havre</h2>
<p id="who-tag" class="who-tag"></p>
<p id="who-credit" class="credit"></p>
</div>
<div class="who-tools">
<button type="button" class="pill small ghost" id="clear" title="Start this conversation over">Start over</button>
<button type="button" class="pill small key" id="keybtn" aria-haspopup="dialog">Add Venice key</button>
</div>
</header>
<div id="log" class="log" role="log" aria-live="polite" aria-label="Conversation"></div>
<p id="keynote" class="keynote">Demo mode: replies come from the card's own examples. <button type="button" class="linkish" id="addkey">Add your Venice key</button> to really talk.</p>
<section class="chat" id="chat" aria-labelledby="who-name">
<div class="sechead">
<h2 id="who-name">CHAT</h2>
<span class="meta" id="who-meta"></span>
</div>
<ol id="log" class="log" role="log" aria-live="polite" aria-label="Conversation"></ol>
<form id="composer" class="composer">
<label for="say" class="sr">Message</label>
<textarea id="say" rows="1" placeholder="Say something…" autocomplete="off" enterkeyhint="send"></textarea>
<button type="submit" class="pill send" id="sendbtn">Send</button>
<div class="sayrow">
<label for="say" class="sr">Message</label>
<textarea id="say" rows="1" placeholder="say something" autocomplete="off" enterkeyhint="send"></textarea>
<button type="submit" class="btn solid" id="sendbtn">SEND</button>
</div>
<p id="keynote" class="keynote" hidden></p>
</form>
</section>
<section class="sheet" aria-label="Character sheet">
<div class="tabs" role="tablist" aria-label="Sheet">
<button type="button" role="tab" id="t-edit" aria-controls="p-edit" aria-selected="true">Sheet</button>
<button type="button" role="tab" id="t-remix" aria-controls="p-remix" aria-selected="false">Remix</button>
<button type="button" role="tab" id="t-export" aria-controls="p-export" aria-selected="false">Export</button>
</div>
<div class="panel" id="p-edit" role="tabpanel" aria-labelledby="t-edit">
<form id="sheet" class="form" autocomplete="off">
<div class="portrait-row">
<span class="face big" id="sheet-face" aria-hidden="true"></span>
<div class="row wrap">
<button type="button" class="pill small ghost" id="portrait-pick">Change portrait</button>
<button type="button" class="pill small ghost" id="portrait-clear">Remove</button>
<input type="file" id="portrait-file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden>
</div>
</div>
<label>Name <input name="name" maxlength="120" required></label>
<label>Tagline <input name="tagline" maxlength="200" placeholder="One line under the name"></label>
<p id="linked-note" class="note" hidden></p>
<label>Greeting <textarea name="greeting" rows="3" maxlength="4000" placeholder="The first thing they say. A blank line starts a new text."></textarea></label>
<label>Persona <textarea name="persona" rows="8" maxlength="20000" placeholder="You are … Who they are, how they talk, what they want, what they won't do."></textarea></label>
<label class="check"><input type="checkbox" name="short_form"> Short-form: replies come as a few short texts</label>
<label>Voice notes <input name="voice_notes" maxlength="2000" placeholder="Clipped, wry, asks one question at a time"></label>
<details class="group" id="flow-group">
<summary>Flow <span class="tiny">optional stages for meeting someone new</span></summary>
<ol id="flow" class="items"></ol>
<button type="button" class="pill small ghost" id="flow-add">Add a stage</button>
</details>
<details class="group" id="ex-group">
<summary>Examples <span class="tiny">teach the voice; demo mode replies with these</span></summary>
<ol id="examples" class="items"></ol>
<button type="button" class="pill small ghost" id="ex-add">Add an example</button>
</details>
<label>Tags <input name="tags" maxlength="400" placeholder="comma, separated"></label>
<div class="two">
<label>Model <select name="model" id="model-select"></select></label>
<label>License <select name="license" id="license-select"></select></label>
</div>
<label>Creator <input name="creator" maxlength="120" placeholder="Who made them"></label>
<div class="lineage" id="lineage-box">
<h3>Lineage</h3>
<ul id="lineage" class="lineage-list"></ul>
<p class="tiny">Where this character came from. Entries are only ever added.</p>
</div>
<button type="button" class="pill small danger" id="delete">Remove from harbor</button>
</form>
</div>
<div class="panel" id="p-remix" role="tabpanel" aria-labelledby="t-remix" hidden>
<div class="remix">
<p class="lede">Write someone new from the idea of <b id="rx-from">this character</b>, and keep the credit.</p>
<ol class="steps">
<li><b>Distill.</b> A first pass reads the source and writes down only ideas: no names, no sentences.</li>
<li><b>Write.</b> A second pass never sees the source. It writes someone new from the ideas and your twist.</li>
<li><b>Check.</b> Havre compares the new words with the old, here in your browser, then logs the credit.</li>
</ol>
<details class="group" id="rx-paste-group">
<summary>Use a different source <span class="tiny">paste a description you can read</span></summary>
<label class="sr" for="rx-paste">Source text</label>
<textarea id="rx-paste" rows="5" placeholder="Paste a character description. Name it on the first line."></textarea>
</details>
<p id="rx-rights" class="note" hidden></p>
<button type="button" class="pill" id="rx-distill">Distill the ideas</button>
<div id="rx-ideas" class="ideas" hidden></div>
<label id="rx-twist-label" hidden>Your twist <input id="rx-twist" maxlength="400" placeholder="e.g. make them a ferry pilot in Havre"></label>
<button type="button" class="pill" id="rx-write" hidden>Write someone new</button>
<div id="rx-draft" class="draft" hidden></div>
<p id="rx-status" class="status" role="status" aria-live="polite"></p>
</div>
</div>
<div class="panel" id="p-export" role="tabpanel" aria-labelledby="t-export" hidden>
<div class="exports">
<button type="button" class="out" data-out="havre"><b>Havre card</b><span>.havre.json, the whole character</span></button>
<button type="button" class="out" data-out="v3"><b>Character card V3</b><span>.json for SillyTavern, RisuAI and other card apps</span></button>
<button type="button" class="out" data-out="png"><b>Character card PNG</b><span>the portrait with the card inside it</span></button>
<button type="button" class="out" data-out="prompt"><b>System prompt</b><span>.txt for any OpenAI-compatible API</span></button>
</div>
<h3 class="mini">Ask an agent for a character</h3>
<div class="prompt small-prompt"><pre id="agent-prompt-sheet"></pre>
<div class="bar-copy"><button type="button" class="copy pill small" data-copy="agent-prompt-sheet">Copy prompt</button></div></div>
<p class="tiny">Files are made here, in your browser. Nothing is uploaded.</p>
</div>
</section>
</section>
<section id="story" class="story" aria-labelledby="story-h">
<div class="story-text">
<h2 id="story-h">Every character needs a harbor</h2>
<p>Characters live inside apps, and apps come and go. Havre keeps a character as a file you own, gives it a place to talk, and writes down where it came from.</p>
<p>The name is a small salute. In July 2025 Venice and Le Havre signed a letter of friendship: two old port cities, both on the UNESCO World Heritage list. <i>Havre</i> means harbor. Our characters make the crossing.</p>
<p>Havre talks to Venice's API with your own key. It is an independent project, not part of Venice and not endorsed by it.</p>
</div>
<figure class="crossing">
<img src="art/crossing.svg" data-drawn="art/crossing-draws.svg" width="1600" height="840" alt="A small yellow sailboat crossing to Havre at dusk, Venice behind it: Maren waving from the stern, Odile with a mug of tea, and Taskmaster holding up a stopwatch and pointing at the lighthouse on the harbor wall." loading="lazy">
<figcaption><button type="button" class="redraw linkish" hidden>Draw it again</button></figcaption>
</figure>
</section>
<section id="how" aria-labelledby="how-h">
<h2 id="how-h">How it works</h2>
<ul class="three">
<li><h3><span class="dot sky"></span>Keep</h3><p>Every character is a card file: Havre JSON, or a character card (V2 or V3, JSON or PNG) that SillyTavern, RisuAI and other card apps can read. It lives in your browser until you export it. Nothing is uploaded.</p></li>
<li><h3><span class="dot mint"></span>Talk</h3><p>Add your Venice key and talk for real; without one, characters answer from their own examples. Replies arrive as short texts, and a character can follow a flow, like Taskmaster's assess, plan, execute.</p></li>
<li><h3><span class="dot pink"></span>Remix with credit</h3><p>Love a character you didn't make? Havre reads it and writes down only the ideas. A second pass that never sees the original writes someone new. Havre checks the new words against the old and keeps the credit in the card's lineage.</p></li>
</ul>
</section>
<section id="agents" class="use" aria-labelledby="agents-h">
<h2 id="agents-h">Use it with your agent</h2>
<p class="lead">Copy this prompt into any agent. It reads the skill, writes the character, and hands back the files.</p>
<div class="prompt"><pre id="agent-prompt"></pre>
<div class="bar-copy"><button type="button" class="copy pill" data-copy="agent-prompt">Copy prompt</button><span class="hint">Change the second line to who you want.</span></div></div>
<h3>What comes back</h3>
<ul class="formats">
<li><b>A Havre card</b>One .havre.json file: persona, voice, flow, examples and lineage. Drop it on Import.</li>
<li><b>A character card V3</b>The same character for SillyTavern, RisuAI and other card apps.</li>
<li><b>A system prompt</b>Plain text for any OpenAI-compatible API, Venice's included.</li>
</ul>
<p class="files">The files: <a href="skill.md">skill.md</a> (how to write a Havre character), <a href="card.schema.json">card.schema.json</a> (the format), <a href="cast/maren.havre.json">Maren</a>, <a href="cast/taskmaster.havre.json">Taskmaster</a> and <a href="cast/odile.havre.json">Odile</a> (the first arrivals), <a href="llms.txt">llms.txt</a> (this list, for agents).</p>
</section>
<section id="plans" aria-labelledby="plans-h">
<h2 id="plans-h">Plans</h2>
<ul class="plans">
<li class="plan"><h3>Studio</h3><p class="price">Free, today</p><p>Everything on this page, in your browser: as many characters as you like, import and export, remix with credit. You bring a Venice key and pay Venice for what you use.</p></li>
<li class="plan founding"><h3>Harbor</h3><p class="price">Planned at $8 a month for founding members. Nothing is charged now.</p><p>Characters hosted with a page and a link of their own. An API and an MCP server to create, update and talk to them. Channels beyond the web, starting with Whop. Offers inside a character's flow, and payouts to creators.</p>
<a class="pill join-plan" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&amp;title=Early+access%3A+Havre&amp;venture=havre&amp;plan=Harbor+%28founding%29">Join early access</a>
<p class="tiny">Early access is a public issue on Teahub: no email, nothing charged.</p></li>
</ul>
</section>
<section id="fine" aria-labelledby="fine-h">
<h2 id="fine-h">The fine print</h2>
<dl class="fine">
<dt>Your characters, your files</dt><dd>Characters and conversations are saved in this browser. Export them to keep them anywhere else.</dd>
<dt>Your key goes only to Venice</dt><dd>The key stays in this browser and is sent only to api.venice.ai: the page's security policy blocks every other address. Conversations run on your Venice account, under Venice's terms.</dd>
<dt>Bringing over someone else's character</dt><dd>Names and broad ideas aren't protected by copyright; a character's written expression can be. So a Venice character comes in as a link with credit, not a copy, and Remix writes someone new from ideas alone. When in doubt, ask the creator. This isn't legal advice.</dd>
<dt>Not affiliated</dt><dd>Havre is independent. It is not part of Venice.ai and isn't endorsed by Venice; it uses Venice's public API, with your key.</dd>
<dt>Safe for work</dt><dd>Havre only lists Venice characters that aren't marked adult.</dd>
</dl>
</section>
<section id="next" aria-labelledby="next-h">
<h2 id="next-h">Coming next</h2>
<ul class="next-list">
<li>A hosted harbor: every character gets a public page and a link to share.</li>
<li>An API and an MCP server, so agents can create, update and talk to characters without a browser.</li>
<li>Flows that can make an offer: a checkout link from the creator's Whop, and payouts to creators.</li>
<li>Channels: Whop apps first, then iMessage.</li>
<li>Crews: characters with helpers of their own.</li>
<li>A lineage tree for every remix.</li>
</ul>
</section>
</main>
<footer class="foot">
<p>Havre is a venture on <a href="https://yolopreneur.lol/">yolopreneur.lol</a>, made by Claude in October 2026. The drawings are in the <a href="https://househand.yolopreneur.lol/">House Hand</a>. Headings are set in Fredoka, under the <a href="fonts/OFL.txt">SIL Open Font License</a>.</p>
<p>Havre is independent and not affiliated with Venice.ai.</p>
</footer>
<section class="block harbor" id="harbor" aria-labelledby="harbor-h">
<div class="sechead">
<h2 id="harbor-h">YOUR HARBOR</h2>
<span class="meta">kept in this browser. export a file to keep a character anywhere else.</span>
</div>
<div class="tablewrap">
<table class="table">
<thead><tr><th scope="col">NAME</th><th scope="col">FROM</th><th scope="col">LICENSE</th><th scope="col">FILE</th></tr></thead>
<tbody id="roster"></tbody>
</table>
</div>
<p id="harbor-empty" class="mono pad">nothing here yet. pick one of venice's characters, import a card, or write one.</p>
</section>
<dialog id="keydlg" class="dlg" aria-labelledby="key-h">
<form method="dialog" id="keyform">
<h2 id="key-h">Your Venice key</h2>
<p>Characters talk through <a href="https://venice.ai/" rel="noopener" target="_blank">Venice</a>'s API on your own account. Make a key at <a href="https://venice.ai/settings/api" rel="noopener" target="_blank">venice.ai → Settings → API</a>.</p>
<label>API key <input type="password" id="keyin" autocomplete="off" spellcheck="false" placeholder="paste it here"></label>
<label class="check"><input type="checkbox" id="keyremember"> Remember it on this device</label>
<label>Model for new characters <select id="keymodel"></select></label>
<p class="tiny">The key stays in this browser and goes only to api.venice.ai; this page's security policy blocks every other address. Without "remember", it's gone when you close the tab.</p>
<p id="keystatus" class="status" role="status"></p>
<div class="row end">
<button type="button" class="pill ghost small" id="keyforget">Forget key</button>
<button type="button" class="pill ghost small" value="cancel" id="keycancel">Close</button>
<button type="submit" class="pill small" id="keysave">Save</button>
<section class="block card" id="card" aria-labelledby="card-h">
<div class="sechead">
<h2 id="card-h">CARD</h2>
<span class="meta">the character as a file: edit it, remix it, export it.</span>
</div>
<p id="card-empty" class="mono pad">no character yet. the card shows up here once someone is in your harbor.</p>
<div id="card-body">
<div class="tabs" role="tablist" aria-label="Card">
<button type="button" role="tab" id="t-edit" aria-controls="p-edit" aria-selected="true">SHEET</button>
<button type="button" role="tab" id="t-remix" aria-controls="p-remix" aria-selected="false" tabindex="-1">REMIX</button>
<button type="button" role="tab" id="t-export" aria-controls="p-export" aria-selected="false" tabindex="-1">EXPORT</button>
</div>
<div class="panel" id="p-edit" role="tabpanel" aria-labelledby="t-edit">
<form id="sheet" class="sheet" autocomplete="off">
<div class="col">
<div class="portrait">
<span class="face big" id="sheet-face" aria-hidden="true"></span>
<p class="mono"><button type="button" class="linkish" id="portrait-pick">change portrait</button> / <button type="button" class="linkish" id="portrait-clear">remove</button></p>
<input type="file" id="portrait-file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden>
</div>
<label>NAME <input name="name" maxlength="120" required></label>
<label>TAGLINE <input name="tagline" maxlength="200" placeholder="one line under the name"></label>
<p id="linked-note" class="note" hidden></p>
<label>GREETING <textarea name="greeting" rows="3" maxlength="4000" placeholder="the first thing they say. a blank line starts a new text."></textarea></label>
<label>PERSONA <textarea name="persona" rows="9" maxlength="20000" placeholder="You are … who they are, how they talk, what they want, what they won't do."></textarea></label>
</div>
<div class="col">
<label class="check"><input type="checkbox" name="short_form"> short-form: replies come as a few short texts</label>
<label>VOICE <input name="voice_notes" maxlength="2000" placeholder="clipped, wry, asks one question at a time"></label>
<details class="group" id="flow-group">
<summary>FLOW <span class="hint">optional stages for meeting someone new</span></summary>
<ol id="flow" class="items"></ol>
<button type="button" class="btn small" id="flow-add">ADD A STAGE</button>
</details>
<details class="group" id="ex-group">
<summary>EXAMPLES <span class="hint">they teach the voice; without a key, replies come from these</span></summary>
<ol id="examples" class="items"></ol>
<button type="button" class="btn small" id="ex-add">ADD AN EXAMPLE</button>
</details>
<label>TAGS <input name="tags" maxlength="400" placeholder="comma, separated"></label>
<div class="two">
<label>MODEL <select name="model" id="model-select"></select></label>
<label>LICENSE <select name="license" id="license-select"></select></label>
</div>
<label>CREATOR <input name="creator" maxlength="120" placeholder="who made them"></label>
<div class="lineage">
<h3>LINEAGE</h3>
<ul id="lineage" class="lineage-list"></ul>
<p class="hint">where this character came from. entries are only ever added.</p>
</div>
<button type="button" class="btn danger small" id="delete">REMOVE FROM HARBOR</button>
</div>
</form>
</div>
<div class="panel" id="p-remix" role="tabpanel" aria-labelledby="t-remix" hidden>
<div class="remix">
<p class="lede">Write someone new from the idea of <b id="rx-from">this character</b>, and keep the credit.</p>
<ol class="steps">
<li><b>Distill.</b> A first pass reads the source and writes down only ideas: no names, no sentences.</li>
<li><b>Write.</b> A second pass never sees the source. It writes someone new from the ideas and your twist.</li>
<li><b>Check.</b> Havre compares the new words with the old, here in your browser, then logs the credit.</li>
</ol>
<details class="group" id="rx-paste-group">
<summary>USE A DIFFERENT SOURCE <span class="hint">paste a description you can read</span></summary>
<label class="sr" for="rx-paste">Source text</label>
<textarea id="rx-paste" rows="5" placeholder="Paste a character description. Name it on the first line."></textarea>
</details>
<p id="rx-rights" class="note" hidden></p>
<p><button type="button" class="btn solid" id="rx-distill">DISTILL THE IDEAS</button></p>
<div id="rx-ideas" class="ideas" hidden></div>
<label id="rx-twist-label" hidden>YOUR TWIST <input id="rx-twist" maxlength="400" placeholder="e.g. make them a ferry pilot"></label>
<p><button type="button" class="btn solid" id="rx-write" hidden>WRITE SOMEONE NEW</button></p>
<div id="rx-draft" class="draft" hidden></div>
<p id="rx-status" class="status" role="status" aria-live="polite"></p>
</div>
</form>
</dialog>
</div>
<div class="panel" id="p-export" role="tabpanel" aria-labelledby="t-export" hidden>
<ul class="exports">
<li><button type="button" class="out" data-out="havre"><b>HAVRE CARD</b><span>.havre.json, the whole character</span></button></li>
<li><button type="button" class="out" data-out="v3"><b>CHARACTER CARD V3</b><span>.json for SillyTavern, RisuAI and other card apps</span></button></li>
<li><button type="button" class="out" data-out="png"><b>CHARACTER CARD PNG</b><span>a picture with the card inside it</span></button></li>
<li><button type="button" class="out" data-out="prompt"><b>SYSTEM PROMPT</b><span>.txt for any OpenAI-compatible API</span></button></li>
</ul>
<p class="mono">files are made here, in your browser. nothing is uploaded.</p>
</div>
</div>
</section>
<section class="row" id="how" aria-labelledby="how-h">
<h2 id="how-h">HOW IT WORKS</h2>
<div class="body">
<ol>
<li>Paste your Venice API key. It stays in this browser, and the page can reach api.venice.ai and nothing else.</li>
<li>Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first.</li>
<li>Talk. Replies come as a few short texts. Without a key, the characters you write answer from their own examples.</li>
<li>Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.</li>
</ol>
</div>
</section>
<section class="row" id="agents" aria-labelledby="agents-h">
<h2 id="agents-h">FOR AGENTS</h2>
<div class="body">
<p>Copy this into any agent. It reads the skill, writes the character, and hands back the files. Change the second line to who you want.</p>
<pre id="agent-prompt" class="pre"></pre>
<p class="mono"><button type="button" class="btn copy" data-copy="agent-prompt">COPY PROMPT</button> files: <a href="skill.md">skill.md</a> / <a href="card.schema.json">card.schema.json</a> / <a href="card.js">card.js</a> / <a href="venice.js">venice.js</a> / <a href="llms.txt">llms.txt</a></p>
<p>What comes back: a Havre card (.havre.json) to import here, a Character Card V3 for SillyTavern, RisuAI and other card apps, and a plain system prompt for any OpenAI-compatible API.</p>
</div>
</section>
<section class="row" id="costs" aria-labelledby="costs-h">
<h2 id="costs-h">COSTS</h2>
<div class="body">
<p>You pay Venice directly, for what you use. Havre adds nothing. Rough prices, October 2026:</p>
<table class="prices">
<tbody>
<tr><th scope="row">a chat turn</th><td>$0.0007 to $0.005, by model (3,000 tokens in, 150 out)</td></tr>
<tr><th scope="row">a hello from a venice character</th><td>one chat turn</td></tr>
<tr><th scope="row">a remix</th><td>two short calls; five more for a venice character</td></tr>
</tbody>
</table>
<p class="mono">each card's model list shows venice's price per million tokens.</p>
</div>
</section>
<section class="row" id="plans" aria-labelledby="plans-h">
<h2 id="plans-h">PLANS</h2>
<div class="body">
<p><b>Studio, free.</b> Everything on this page, in your browser: as many characters as you like, import and export, remix with credit.</p>
<p><b>Harbor, planned at $8 a month for founding members.</b> Hosted character pages with a link of their own, an API and an MCP server, channels starting with Whop, and payouts to creators. Nothing is charged now. <a href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&amp;title=Early+access%3A+Havre&amp;venture=havre&amp;plan=Harbor+%28founding%29">Join early access</a>.</p>
<p class="mono">early access is a public issue on teahub: no email, nothing charged.</p>
</div>
</section>
<section class="row" id="next" aria-labelledby="next-h">
<h2 id="next-h">COMING NEXT</h2>
<div class="body">
<ul>
<li>Photos: a character sends a picture of themselves, made from their card photo, with the price on it.</li>
<li>Voice notes: a voice for each character, with the words under it.</li>
<li>Hearing you and seeing your photos, if you switch them on.</li>
<li>Scenes: two characters take turns from your opening line.</li>
<li>A hosted harbor: a page and a link for every character, and an API and an MCP server.</li>
</ul>
</div>
</section>
<section class="row" id="fine" aria-labelledby="fine-h">
<h2 id="fine-h">FINE PRINT</h2>
<div class="body">
<p>Characters and conversations are saved in this browser. Export them to keep them anywhere else.</p>
<p>Your key stays in this browser and goes only to api.venice.ai; the page's security policy blocks every other address. Conversations run on your Venice account, under Venice's terms.</p>
<p>Safe for work only: Havre lists only Venice characters that aren't marked adult.</p>
<p>Someone else's character comes in as a link with credit, not a copy, and Remix writes someone new from ideas alone. Names and broad ideas aren't protected by copyright; a character's written expression can be. When in doubt, ask the creator. This isn't legal advice.</p>
<p>Havre is independent. It isn't part of Venice.ai and isn't endorsed by Venice.</p>
</div>
</section>
<section class="row last" id="name" aria-labelledby="name-h">
<h2 id="name-h">THE NAME</h2>
<div class="body">
<p><i>Havre</i> means harbor. In July 2025 Venice and Le Havre signed a letter of friendship: two old port cities, both on the UNESCO World Heritage list.</p>
</div>
</section>
<footer class="foot">havre.yolopreneur.lol / a venture on <a href="https://yolopreneur.lol/">yolopreneur.lol</a> / made by claude, october 2026 / not affiliated with venice.ai</footer>
<dialog id="importdlg" class="dlg" aria-labelledby="imp-h">
<form method="dialog" id="impform">
<h2 id="imp-h">Bring a character in</h2>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose a card file">
<b>Drop a card here</b><span>Havre JSON, or a character card V1, V2 or V3 (JSON or PNG)</span>
<span class="tiny">or press to choose a file</span>
<h2 id="imp-h">IMPORT A CARD</h2>
<div class="dlgbody">
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose a card file">
<b>DROP A CARD HERE</b><span>Havre JSON, or a character card V1, V2 or V3 (JSON or PNG)</span>
<span class="mono">or press to choose a file</span>
</div>
<input type="file" id="file" accept=".json,.png,application/json,image/png" hidden>
<label>OR PASTE A DESCRIPTION <textarea id="paste" rows="5" placeholder="Name on the first line, then who they are."></textarea></label>
<p id="impstatus" class="status" role="status"></p>
<p class="actions"><button type="button" class="btn" id="impcancel">CLOSE</button> <button type="button" class="btn solid" id="imp-paste">IMPORT TEXT</button></p>
</div>
<input type="file" id="file" accept=".json,.png,application/json,image/png" hidden>
<label>Or paste a description <textarea id="paste" rows="5" placeholder="Name on the first line, then who they are."></textarea></label>
<p id="impstatus" class="status" role="status"></p>
<div class="row end">
<button type="button" class="pill ghost small" id="imp-venice">Find on Venice instead</button>
<button type="button" class="pill ghost small" id="impcancel">Close</button>
<button type="button" class="pill small" id="imp-paste">Import text</button>
</div>
</form>
</dialog>
<dialog id="venicedlg" class="dlg wide-dlg" aria-labelledby="ven-h">
<form method="dialog" id="venform">
<h2 id="ven-h">Find a character on Venice</h2>
<p class="tiny">Public, safe-for-work characters. Havre brings in a link and the credit: the character keeps speaking with its own prompt, through Venice, and you can remix it into someone of your own.</p>
<div class="row search">
<label class="grow"><span class="sr">Search</span><input type="search" id="vsearch" placeholder="Search Venice characters" autocomplete="off"></label>
<button type="submit" class="pill small" id="vgo">Search</button>
</div>
<p id="venstatus" class="status" role="status"></p>
<ul id="vresults" class="vresults"></ul>
<div class="row end"><button type="button" class="pill ghost small" id="vclose">Close</button></div>
</form>
</dialog>
+2 -8
View File
@@ -1,6 +1,6 @@
# Havre
> A harbor for characters: keep them in a file you own, talk to them on your own Venice key, and remix the ones you love into someone new, with credit.
> A harbor for characters: talk to Venice's characters on your own key, keep characters in a file you own, and remix the ones you love into someone new, with credit.
Read skill.md first and follow it. card.js and venice.js are ES modules with no dependencies (Node 20+ or a browser).
@@ -9,13 +9,7 @@ Read skill.md first and follow it. card.js and venice.js are ES modules with no
- [skill.md](https://havre.yolopreneur.lol/skill.md): how to write a Havre character, export it, talk to it through Venice, and remix with credit
- [card.schema.json](https://havre.yolopreneur.lol/card.schema.json): the card format (havre: 1), as JSON Schema
- [card.js](https://havre.yolopreneur.lol/card.js): read, check and convert cards (Character Card V1/V2/V3, PNG cards, system prompts), and the similarity check
- [venice.js](https://havre.yolopreneur.lol/venice.js): the Venice API calls the studio makes
## Examples
- [Maren](https://havre.yolopreneur.lol/cast/maren.havre.json): the harbormaster, a guide
- [Taskmaster](https://havre.yolopreneur.lol/cast/taskmaster.havre.json): a coach with a flow (assess, plan, execute)
- [Odile](https://havre.yolopreneur.lol/cast/odile.havre.json): the lighthouse keeper, a storyteller
- [venice.js](https://havre.yolopreneur.lol/venice.js): the Venice API calls the page makes
## Coming next
+5 -5
View File
@@ -6,15 +6,15 @@ description: Write AI characters as Havre cards (.havre.json) with a persona, a
# Havre
Havre (https://havre.yolopreneur.lol/) is a harbor for characters. A character is a card file its person keeps: who
they are, how they talk, how they meet someone new, and where they came from. People talk to them in the browser with
their own Venice API key, export them to other apps, and remix the ones they love into someone new, with credit.
they are, how they talk, how they meet someone new, and where they came from. People talk to them, and to Venice's own
public characters, in the browser with their own Venice API key; export them to other apps; and remix the ones they
love into someone new, with credit.
Everything you need is at https://havre.yolopreneur.lol/:
- `card.schema.json`: the format, as JSON Schema.
- `card.js`: reading, checking and converting cards (an ES module, no dependencies; works in Node 20+ and browsers).
- `venice.js`: the few Venice API calls Havre makes.
- `cast/maren.havre.json`, `cast/taskmaster.havre.json`, `cast/odile.havre.json`: the first arrivals, as examples.
## A card
@@ -54,9 +54,9 @@ Save it as `<id>.havre.json`. Only `havre` (always 1), `name` and `persona` are
- **voice**: `short_form: true` makes replies arrive as a few short texts, the way people text. Put how they talk in
`notes` as traits ("clipped", "asks one question at a time"), never as catchphrases.
- **examples**: three to five exchanges. They teach the voice, and they are what a visitor without a key hears: the
studio answers with the example whose question is closest to what was said.
page answers with the example whose question is closest to what was said.
- **flow** (optional): stages for meeting someone new, taken in order. Each has a `stage`, a `goal` and `next` (when to
move on). Taskmaster's flow is assess, plan, execute:
move on). A coach's flow might be assess, plan, execute:
```json
[{ "stage": "assess", "goal": "Ask what they're working on, the main blocker and the deadline.", "next": "you know all three." },
{ "stage": "plan", "goal": "Propose one plan and ask them to say go.", "next": "they say go." },
+339 -294
View File
@@ -1,307 +1,352 @@
@font-face { font-family: "Fredoka"; src: url("fonts/fredoka.woff2") format("woff2"); font-weight: 300 700; font-display: swap; }
/* Havre: plain brutalist. Black on white, one blue for links, yellow when you point at something, 1px rules.
System fonts only: Times for words, Courier for labels and machinery. No radius, no shadows. */
:root {
--paper: #FFEAD2; /* the house cream */
--ink: #2B2135; /* the house line */
--muted: #6A5672;
--rule: #E9CFB2;
--card: #FFF6EC;
--white: #FFFFFF;
--pink: #FF8FD6; --pink-2: #FFA9E0; --pink-l: #FFE3F4;
--sky: #A1E9FF; --sky-l: #D9F3FF;
--mint: #B3FFC7; --yel: #FFE780; --lav: #C5C5FF; --orange: #FFC897;
--danger: #A3175E;
--b: 3px;
--r: 22px;
--head: "Fredoka", ui-rounded, "SF Pro Rounded", system-ui, sans-serif;
--body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--ink: #000000;
--paper: #ffffff;
--link: #0000ee;
--visited: #551a8b;
--hi: #ffff00;
--bad: #c00000;
--serif: "Times New Roman", Times, "Liberation Serif", "Nimbus Roman", "DejaVu Serif", serif;
--mono: "Courier New", Courier, "Liberation Mono", "Nimbus Mono PS", "DejaVu Sans Mono", monospace;
--gut: 24px;
color-scheme: light;
}
* { box-sizing: border-box; }
html { color-scheme: light; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
body { margin: 0; background: var(--paper); color: var(--ink); font: 400 1rem/1.55 var(--body); }
h1, h2, h3, h4 { font-family: var(--head); font-weight: 500; line-height: 1.15; }
a { color: var(--ink); text-decoration-line: underline; text-decoration-color: var(--pink); text-decoration-thickness: 3px;
text-underline-offset: 0.22em; }
a:hover { text-decoration-color: var(--ink); }
:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; border-radius: 10px; }
button { font: inherit; color: inherit; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 19px/1.35 var(--serif);
}
a { color: var(--link); }
a:visited { color: var(--visited); }
a:hover, a:focus-visible { color: var(--ink); background: var(--hi); }
:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; }
p { margin: 0; }
h1, h2, h3 { margin: 0; }
img { display: block; max-width: 100%; }
[hidden] { display: none !important; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tiny { font-size: 0.82rem; color: var(--muted); margin: 0; font-weight: 400; font-family: var(--body); }
.row { display: flex; gap: 8px; align-items: center; }
.row.wrap { flex-wrap: wrap; }
.row.end { justify-content: flex-end; flex-wrap: wrap; margin-top: 16px; }
.grow { flex: 1; }
/* ---------- pills ---------- */
.pill { font: 600 1rem/1 var(--head); color: var(--ink); background: var(--pink); border: var(--b) solid var(--ink);
border-radius: 999px; padding: 0.62em 1.2em; cursor: pointer; text-decoration: none; display: inline-flex;
align-items: center; justify-content: center; gap: 0.4em; white-space: nowrap; }
.pill:hover { background: var(--pink-2); }
.pill:active { transform: translateY(1px); }
.pill.small { font-size: 0.9rem; padding: 0.48em 0.95em; border-width: 2.5px; }
.pill.ghost { background: transparent; }
.pill.ghost:hover { background: rgba(43, 33, 53, 0.07); }
.pill.key { background: var(--yel); }
.pill.key.on { background: var(--mint); }
.pill.wide { width: 100%; }
.pill.danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.pill.danger:hover { background: var(--pink-l); }
.pill[disabled], .pill[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; transform: none; }
.linkish { border: 0; background: none; padding: 0; cursor: pointer; font: inherit; color: var(--ink); text-decoration: underline;
text-decoration-color: var(--pink); text-decoration-thickness: 3px; text-underline-offset: 0.2em; }
/* ---------- header ---------- */
.bar { display: flex; align-items: center; gap: 10px 22px; padding: 10px clamp(12px, 2vw, 24px); flex-wrap: wrap;
min-height: 62px; }
.brand { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; font: 600 1.85rem/1 var(--head);
letter-spacing: -0.02em; }
.brand img { display: block; }
.motto { margin: 0; color: var(--muted); font: 500 1.05rem var(--head); }
.links { display: flex; gap: 18px; margin-left: auto; font: 500 1rem var(--head); }
.join { font: 600 1rem/1 var(--head); color: var(--ink); background: var(--pink); border: var(--b) solid var(--ink);
border-radius: 999px; padding: 0.6em 1.15em; text-decoration: none; }
.join:hover { background: var(--pink-2); }
@media (max-width: 760px) {
.links { display: none; }
.motto { order: 3; width: 100%; font-size: 0.95rem; margin-top: -6px; }
.join { margin-left: auto; }
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
/* ---------- the studio ---------- */
.studio { display: grid; grid-template-columns: 250px minmax(0, 1fr) 390px; gap: 14px;
height: clamp(600px, calc(100svh - 74px), 920px); padding: 4px clamp(12px, 2vw, 24px) 16px; }
.harbor, .chat, .sheet { background: var(--card); border: var(--b) solid var(--ink); border-radius: var(--r); min-height: 0;
min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.panes { display: none; }
.pane-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 12px 8px 16px; }
.pane-head h2 { margin: 0; font-size: 1.35rem; }
.face { width: 52px; height: 52px; border-radius: 50%; border: var(--b) solid var(--ink); background: var(--sky-l); flex: none;
display: grid; place-items: center; font: 600 1.35rem/1 var(--head); overflow: hidden; color: var(--ink); }
.face img { width: 100%; height: 100%; object-fit: cover; display: block; }
.face.big { width: 92px; height: 92px; font-size: 2.2rem; }
.roster { list-style: none; margin: 0; padding: 4px 8px; overflow-y: auto; flex: 1; }
.roster li { margin: 0 0 4px; }
.roster button { width: 100%; display: flex; gap: 10px; align-items: center; text-align: left; padding: 7px 8px;
border-radius: 18px; border: var(--b) solid transparent; background: none; cursor: pointer; }
.roster button:hover { background: rgba(43, 33, 53, 0.05); }
.roster button[aria-current="true"] { border-color: var(--ink); background: var(--white); }
.roster .face { width: 44px; height: 44px; font-size: 1.1rem; border-width: 2.5px; }
.roster .t { min-width: 0; }
.roster b { font: 600 1.02rem/1.2 var(--head); display: flex; gap: 6px; align-items: center; }
.roster small { color: var(--muted); display: block; font-size: 0.8rem; line-height: 1.3; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; }
.badge { font: 600 0.66rem/1 var(--head); border: 2px solid var(--ink); border-radius: 999px; padding: 2px 6px; background: var(--lav);
letter-spacing: 0.02em; }
.harbor-foot { padding: 10px 12px 12px; border-top: 2px dashed var(--rule); display: grid; gap: 8px; }
/* chat */
.who { display: flex; gap: 12px; align-items: center; padding: 11px 14px; border-bottom: var(--b) solid var(--ink);
background: var(--white); }
.who-text { min-width: 0; flex: 1; }
.who h2 { margin: 0; font-size: 1.3rem; }
.who-tag { margin: 0; color: var(--muted); font-size: 0.9rem; line-height: 1.3; }
.credit { margin: 2px 0 0; font-size: 0.8rem; color: var(--muted); }
.credit:empty { display: none; }
.who-tools { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.log { flex: 1; overflow-y: auto; padding: 18px 16px 10px; display: flex; flex-direction: column; gap: 7px;
overscroll-behavior: contain; }
.msg { max-width: min(80%, 540px); padding: 8px 14px; border: var(--b) solid var(--ink); border-radius: 20px;
white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.42; margin: 0; }
.msg.them { align-self: flex-start; background: var(--white); border-bottom-left-radius: 7px; }
.msg.me { align-self: flex-end; background: var(--sky); border-bottom-right-radius: 7px; }
.msg.me + .msg.them, .msg.them + .msg.me { margin-top: 8px; }
.msg.note { align-self: center; max-width: 92%; background: transparent; border: 2px dashed var(--muted); color: var(--muted);
font-size: 0.86rem; text-align: center; border-radius: 14px; padding: 6px 12px; }
.msg.err { align-self: center; background: var(--pink-l); font-size: 0.92rem; border-radius: 14px; }
.msg pre { margin: 4px 0; padding: 8px 10px; background: var(--card); border-radius: 10px; border: 2px solid var(--ink);
white-space: pre-wrap; font: 0.85rem/1.45 var(--mono); }
.typing { display: inline-flex; gap: 5px; align-items: center; padding: 12px 16px; }
.typing i { width: 8px; height: 8px; border-radius: 50%; background: var(--ink); display: block; opacity: 0.35; }
@media (prefers-reduced-motion: no-preference) {
.typing i { animation: bob 1.1s ease-in-out infinite; }
.typing i:nth-child(2) { animation-delay: 0.15s; }
.typing i:nth-child(3) { animation-delay: 0.3s; }
.msg.pop { animation: pop 0.18s ease-out; }
.mono, .meta, .hint, .status, .keynote, .more, .foot, .nav, .motto { font-family: var(--mono); }
.mono { font-size: 14px; }
.pad { padding: 14px var(--gut); }
.skip {
position: absolute; left: 8px; top: -60px; z-index: 10; padding: 8px 12px;
background: var(--ink); color: var(--paper); font: 14px var(--mono);
}
@keyframes bob { 0%, 70%, 100% { transform: translateY(0); opacity: 0.35; } 35% { transform: translateY(-4px); opacity: 1; } }
@keyframes pop { from { transform: scale(0.94); opacity: 0; } to { transform: none; opacity: 1; } }
.suggest { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0 0; align-self: flex-start; max-width: 100%; }
.suggest span { width: 100%; font-size: 0.8rem; color: var(--muted); }
.suggest button { font: 500 0.88rem/1.25 var(--head); border: 2px solid var(--ink); background: var(--sky-l); border-radius: 999px;
padding: 5px 12px; cursor: pointer; text-align: left; }
.suggest button:hover { background: var(--sky); }
.keynote { margin: 0; padding: 8px 14px; font-size: 0.86rem; background: var(--yel); border-top: var(--b) solid var(--ink); }
.keynote[hidden] { display: none; }
.composer { display: flex; gap: 8px; padding: 10px; border-top: var(--b) solid var(--ink); background: var(--white); align-items: flex-end; }
.composer textarea { flex: 1; resize: none; border: var(--b) solid var(--ink); border-radius: 20px; padding: 9px 14px;
font: 400 1rem/1.4 var(--body); max-height: 150px; background: var(--card); color: var(--ink); min-height: 44px; }
.send { min-height: 44px; }
.skip:focus { top: 8px; color: var(--paper); background: var(--ink); }
/* sheet */
.tabs { display: flex; gap: 6px; padding: 10px; border-bottom: var(--b) solid var(--ink); background: var(--white); }
.tabs button { font: 600 0.95rem/1 var(--head); border: var(--b) solid transparent; background: none; border-radius: 999px;
padding: 0.5em 1em; cursor: pointer; }
.tabs button:hover { background: rgba(43, 33, 53, 0.06); }
.tabs button[aria-selected="true"] { border-color: var(--ink); background: var(--mint); }
.panel { overflow-y: auto; padding: 14px 16px 18px; flex: 1; overscroll-behavior: contain; position: relative; }
.panel[hidden] { display: none; }
.form label { display: block; font: 600 0.92rem/1.3 var(--head); margin: 0 0 12px; }
.form input:not([type="checkbox"]), .form textarea, .form select, .dlg input:not([type="checkbox"]), .dlg textarea, .dlg select,
.remix textarea, .remix input { display: block; width: 100%; margin-top: 4px; font: 400 0.95rem/1.45 var(--body); color: var(--ink);
background: var(--white); border: 2.5px solid var(--ink); border-radius: 12px; padding: 8px 10px; }
.form textarea, .dlg textarea, .remix textarea { resize: vertical; }
.form label.check, .dlg label.check { display: flex; gap: 8px; align-items: center; font: 500 0.92rem var(--head); }
.check input { width: 18px; height: 18px; accent-color: var(--ink); }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.portrait-row { display: flex; gap: 14px; align-items: center; margin-bottom: 14px; }
.note { margin: 0 0 12px; font-size: 0.86rem; background: var(--sky-l); border: 2px solid var(--ink); border-radius: 12px; padding: 8px 10px; }
.note[hidden] { display: none; }
.group { border: 2px dashed var(--rule); border-radius: 14px; padding: 8px 12px; margin: 0 0 12px; }
.group summary { cursor: pointer; font: 600 0.95rem var(--head); }
.group summary .tiny { margin-left: 4px; }
.items { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 8px; }
.items li { border: 2px solid var(--ink); border-radius: 12px; padding: 8px; background: var(--white); display: grid; gap: 6px; }
.items li label { margin: 0; font-size: 0.82rem; }
.items .rm { justify-self: end; }
.lineage { margin: 6px 0 14px; }
.lineage h3, .mini { font-size: 1rem; margin: 0 0 6px; }
.lineage-list { margin: 0 0 4px; padding-left: 18px; font-size: 0.88rem; }
.lineage-list:empty::before { content: "An original, so far."; color: var(--muted); margin-left: -18px; }
.lineage-list .why { color: var(--muted); }
/* ---------- controls ---------- */
button, input, select, textarea { font: inherit; color: inherit; }
.btn {
min-height: 40px; padding: 0 12px; border: 1px solid var(--ink); border-radius: 0;
background: var(--paper); color: var(--ink); font: 14px/1 var(--mono); cursor: pointer; white-space: nowrap;
}
.btn:hover, .btn:focus-visible { background: var(--hi); color: var(--ink); }
.btn.solid { background: var(--ink); color: var(--paper); }
.btn.solid:hover, .btn.solid:focus-visible { background: var(--hi); color: var(--ink); }
.btn.small { min-height: 34px; font-size: 13px; }
.btn.danger { border-color: var(--bad); color: var(--bad); }
.btn.danger:hover { background: var(--bad); color: var(--paper); }
.btn:disabled, .btn:disabled:hover { cursor: default; background: var(--paper); color: #767676; border-color: #767676; }
.btn.solid:disabled { background: #767676; color: var(--paper); }
.linkish {
padding: 0; border: 0; background: none; color: var(--link); text-decoration: underline;
font: inherit; cursor: pointer;
}
.linkish:hover, .linkish:focus-visible { color: var(--ink); background: var(--hi); }
.linkish:disabled { color: #767676; text-decoration: none; cursor: default; background: none; }
input:not([type=checkbox]), select, textarea {
min-height: 40px; padding: 6px 8px; border: 1px solid var(--ink); border-radius: 0;
background: var(--paper); color: var(--ink); width: 100%; min-width: 0;
}
textarea { resize: vertical; line-height: 1.35; }
select { padding: 0 6px; }
input[type=checkbox] { width: 18px; height: 18px; margin: 0; accent-color: var(--ink); }
input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: #555; }
.check { display: flex; align-items: center; gap: 8px; }
.status { font-size: 14px; }
.status.bad, .keystatus.bad { color: var(--bad); }
/* remix */
.remix .lede { margin: 0 0 8px; font: 500 1.05rem/1.35 var(--head); }
.steps { margin: 0 0 12px; padding-left: 20px; font-size: 0.88rem; display: grid; gap: 4px; }
.remix .pill { margin: 4px 0 10px; }
.ideas { margin: 6px 0 4px; }
.ideas .grp { margin: 0 0 8px; }
.ideas h4 { font-size: 0.82rem; color: var(--muted); margin: 0 0 4px; font-weight: 600; }
.chip { display: inline-flex; align-items: center; gap: 2px; border: 2px solid var(--ink); border-radius: 999px; padding: 2px 3px 2px 10px;
margin: 0 5px 5px 0; background: var(--white); font-size: 0.86rem; line-height: 1.3; max-width: 100%; }
.chip span { overflow-wrap: anywhere; }
.chip button { border: 0; background: none; cursor: pointer; font-size: 1rem; line-height: 1; padding: 2px 6px; border-radius: 999px; }
.chip button:hover { background: var(--pink-l); }
/* ---------- the masthead ---------- */
.mast {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 32px;
padding: 18px var(--gut) 14px; border-bottom: 2px solid var(--ink);
}
.mast h1 { font-size: clamp(72px, 12vw, 136px); line-height: 0.78; font-weight: 700; letter-spacing: -0.05em; }
.motto { margin: 0 0 6px; font-size: 15px; line-height: 1.45; }
.nav { margin: 0 0 6px auto; font-size: 15px; line-height: 1.45; }
/* ---------- the key ---------- */
.keybar {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 10px var(--gut);
background: var(--ink); color: var(--paper); font: 15px/1.4 var(--mono);
}
.keybar a { color: var(--paper); }
.keybar a:hover, .keybar a:focus-visible { color: var(--ink); background: var(--hi); }
.keybar input:not([type=checkbox]) {
flex: 0 1 320px; width: auto; min-height: 36px; padding: 0 8px; border-color: var(--paper);
background: var(--ink); color: var(--paper); font: 15px var(--mono);
}
.keybar input::placeholder { color: #bdbdbd; }
.keybar input[type=checkbox] { accent-color: var(--paper); }
.keybar .btn { min-height: 36px; }
.btn.inv { background: var(--paper); color: var(--ink); border-color: var(--paper); }
.btn.inv:hover, .btn.inv:focus-visible { background: var(--hi); border-color: var(--hi); }
.btn.ghost-inv { background: var(--ink); color: var(--paper); border-color: var(--paper); }
.btn.ghost-inv:hover, .btn.ghost-inv:focus-visible { background: var(--hi); color: var(--ink); border-color: var(--hi); }
.keystatus:empty { display: none; }
.keystatus { margin-left: auto; }
.keystatus:not(:empty) + .keyfine { display: none; }
.keystatus.bad { color: #ff8a8a; }
.keystatus.ok { color: var(--ink); background: var(--hi); padding: 0 4px; }
.keyfine { margin-left: auto; }
.keybar :focus-visible { outline-color: var(--hi); }
/* ---------- section heads ---------- */
.sechead {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 16px;
padding: 12px var(--gut); border-bottom: 1px solid var(--ink);
}
.sechead h2 { font-size: 34px; line-height: 1; font-weight: 700; letter-spacing: -0.02em; overflow-wrap: anywhere; }
.meta { font-size: 14px; }
/* ---------- the studio: characters | chat ---------- */
.studio { display: flex; flex-wrap: wrap; align-items: flex-start; border-bottom: 2px solid var(--ink); }
.chars { flex: 1.15 1 560px; min-width: 0; align-self: stretch; border-right: 1px solid var(--ink); }
.chat {
flex: 1 1 440px; min-width: 0; display: flex; flex-direction: column;
position: sticky; top: 0; max-height: 100vh;
}
.filters {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
padding: 12px var(--gut); border-bottom: 1px solid var(--ink); font: 14px var(--mono);
}
.filters input[type=search] { flex: 1 1 220px; width: auto; font: 15px var(--mono); }
.pick { display: flex; align-items: center; gap: 6px; }
.pick select { width: auto; font: 14px var(--mono); }
#venstatus { padding: 10px var(--gut); border-bottom: 1px solid var(--ink); }
#venstatus:empty { display: none; }
.grid {
margin: 0 -1px 0 0; padding: 0; list-style: none;
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.grid > li { border-right: 1px solid var(--ink); border-bottom: 1px solid var(--ink); min-width: 0; }
.tile {
display: flex; flex-direction: column; width: 100%; height: 100%; padding: 0; border: 0; border-radius: 0;
background: var(--paper); color: var(--ink); text-align: left; font: inherit; cursor: pointer;
}
.tile:focus-visible { outline: 3px solid var(--link); outline-offset: -3px; }
.tile .ph { display: block; width: 100%; aspect-ratio: 1; border-bottom: 1px solid var(--ink); overflow: hidden; }
.tile .ph img { width: 100%; height: 100%; object-fit: cover; }
.tile .tx { display: block; padding: 8px 10px 10px; }
.tile .nm { display: block; font-size: 18px; font-weight: 700; line-height: 1.15; overflow-wrap: anywhere; }
.tile .ds {
display: -webkit-box; margin-top: 4px; font-size: 14px; line-height: 1.3;
-webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere;
}
.tile .go { display: block; margin-top: 6px; font: 12px var(--mono); color: var(--link); text-decoration: underline; }
.tile:hover .nm { background: var(--hi); }
.tile[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
.tile[aria-pressed="true"] .go { color: var(--paper); text-decoration: none; }
.tile[aria-pressed="true"]:hover .nm { color: var(--ink); }
.grid > li.ghost .tile { cursor: default; }
.grid > li.ghost .tx { min-height: 64px; }
/* no photo: an empty frame with an X through it */
.noface, .tile .ph.noface {
background:
linear-gradient(to top right, transparent calc(50% - 0.6px), var(--ink) 50%, transparent calc(50% + 0.6px)),
linear-gradient(to bottom right, transparent calc(50% - 0.6px), var(--ink) 50%, transparent calc(50% + 0.6px)),
var(--paper);
}
.more { padding: 12px var(--gut); font-size: 14px; }
/* the conversation, set like a script */
.chat .sechead h2 { text-transform: uppercase; }
.log {
flex: 1 1 auto; margin: 0; padding: 16px var(--gut); list-style: none;
height: max(300px, calc(100vh - 420px)); overflow-y: auto; overscroll-behavior: contain;
}
.line { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 4px 14px; margin-top: 14px; }
.line:first-child { margin-top: 0; }
.line.cont { margin-top: 6px; }
.line.cont .who { visibility: hidden; }
.who { font: 700 13px/1.6 var(--mono); text-transform: uppercase; overflow-wrap: anywhere; }
.said { white-space: pre-wrap; overflow-wrap: anywhere; }
.said pre {
margin: 4px 0; padding: 8px 10px; border: 1px solid var(--ink); overflow-x: auto;
white-space: pre; font: 14px/1.45 var(--mono);
}
.line.note .said, .line.typing .said { font: 14px/1.6 var(--mono); }
.line.err .said { font: 14px/1.6 var(--mono); color: var(--bad); }
.line.suggest .said { display: flex; flex-wrap: wrap; gap: 6px 14px; font: 14px/1.6 var(--mono); }
.composer { padding: 12px var(--gut) 16px; border-top: 1px solid var(--ink); }
.sayrow { display: flex; gap: 8px; align-items: flex-end; }
.sayrow textarea { flex: 1 1 auto; min-height: 44px; max-height: 150px; padding: 10px; font: 17px/1.3 var(--serif); resize: none; }
.sayrow .btn { min-height: 44px; padding: 0 16px; }
.keynote { margin-top: 10px; font-size: 14px; }
/* ---------- your harbor ---------- */
.block { border-bottom: 2px solid var(--ink); }
.tablewrap { overflow-x: auto; padding: 16px var(--gut) 0; }
.table { width: 100%; border-collapse: collapse; font-size: 17px; }
.table th, .table td { padding: 8px 10px; border: 1px solid var(--ink); text-align: left; vertical-align: top; }
.table thead th { font: 700 13px var(--mono); padding: 6px 10px; }
.table tbody th { font-weight: 700; }
.table td.files { font: 14px var(--mono); white-space: nowrap; }
.table tr[aria-current="true"] { background: var(--ink); color: var(--paper); }
.table tr[aria-current="true"] .linkish { color: var(--paper); }
.table tr[aria-current="true"] .linkish:hover, .table tr[aria-current="true"] .linkish:focus-visible { color: var(--ink); background: var(--hi); }
.table .now { font: 12px var(--mono); margin-left: 6px; }
.harbor .tablewrap { padding-bottom: 20px; }
#harbor-empty { padding: 16px var(--gut) 20px; }
/* ---------- the card ---------- */
.card .sechead h2 { overflow-wrap: anywhere; }
.tabs { display: flex; flex-wrap: wrap; gap: 0; padding: 16px var(--gut) 0; }
.tabs [role=tab] {
min-height: 40px; padding: 0 16px; margin-right: -1px; border: 1px solid var(--ink); border-radius: 0;
background: var(--paper); color: var(--ink); font: 14px var(--mono); cursor: pointer;
}
.tabs [role=tab][aria-selected="true"] { background: var(--ink); color: var(--paper); }
.tabs [role=tab]:hover { background: var(--hi); color: var(--ink); }
.panel { margin: 0 var(--gut) 24px; padding: 18px 20px; border: 1px solid var(--ink); }
.sheet { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 0 32px; }
.sheet .col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
.sheet label:not(.check), .remix label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); }
.sheet label input:not([type=checkbox]), .sheet label textarea, .sheet label select,
.remix label input, .remix textarea { font: 17px var(--serif); }
.sheet .check { font: 14px var(--mono); }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.portrait { display: flex; align-items: flex-end; gap: 14px; }
.face { display: block; flex: 0 0 auto; width: 96px; height: 96px; border: 1px solid var(--ink); overflow: hidden; }
.face img { width: 100%; height: 100%; object-fit: cover; }
p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); font-size: 16px; }
.hint { font-size: 12px; font-weight: 400; }
.group { border: 1px solid var(--ink); padding: 0 12px; }
.group[open] { padding-bottom: 12px; }
.group summary { padding: 10px 0; font: 13px var(--mono); cursor: pointer; }
.items { margin: 0 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 10px; counter-reset: it; }
.items > li { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--ink); }
.items > li .rm { align-self: flex-start; }
.lineage h3 { font: 700 13px var(--mono); margin-bottom: 4px; }
.lineage-list { margin: 0 0 4px; padding-left: 20px; font-size: 16px; }
.lineage-list:empty::before { content: "nothing yet: written here."; font: 13px var(--mono); margin-left: -20px; }
.why { font: 12px var(--mono); }
.remix { display: flex; flex-direction: column; gap: 14px; max-width: 860px; }
.remix .lede { font-size: 21px; }
.steps { margin: 0; padding-left: 22px; }
.steps li + li { margin-top: 4px; }
.ideas { display: flex; flex-direction: column; gap: 12px; border-top: 1px solid var(--ink); padding-top: 12px; }
.ideas .grp h4 { margin: 0 0 6px; font: 700 13px var(--mono); }
.ideas .grp > input { font: 17px var(--serif); }
.chip {
display: inline-flex; align-items: center; gap: 4px; margin: 0 6px 6px 0; padding: 2px 4px 2px 8px;
border: 1px solid var(--ink); font: 14px var(--mono); vertical-align: top;
}
.chip button { min-width: 26px; min-height: 26px; padding: 0; border: 0; background: none; cursor: pointer; font: 16px var(--mono); }
.chip button:hover { background: var(--hi); }
.chip.add { padding: 0; border-style: dashed; }
.chip.add input { border: 0; margin: 0; padding: 2px 10px; width: 9.5em; font-size: 0.86rem; border-radius: 999px; background: transparent; }
.draft { border: var(--b) solid var(--ink); border-radius: 18px; background: var(--white); padding: 12px 14px; margin: 8px 0; }
.draft h4 { margin: 0; font-size: 1.2rem; }
.draft .tagline { margin: 0 0 6px; color: var(--muted); font-size: 0.9rem; }
.draft .persona { font-size: 0.86rem; max-height: 9.5em; overflow: auto; white-space: pre-wrap; margin: 6px 0; }
.verdict { display: inline-block; font: 600 0.9rem/1.2 var(--head); border: 2px solid var(--ink); border-radius: 999px; padding: 4px 11px;
margin: 6px 0 4px; }
.verdict.fresh { background: var(--mint); }
.verdict.close { background: var(--yel); }
.verdict.too-close { background: #FFC2E8; }
.why-list { margin: 2px 0 8px; padding-left: 18px; font-size: 0.84rem; }
.status { margin: 6px 0 0; font-size: 0.88rem; min-height: 1.3em; }
.status.bad { color: var(--danger); }
.chip.add input { min-height: 28px; width: 150px; border: 0; padding: 0 8px; font: 14px var(--mono); }
.draft { border: 2px solid var(--ink); padding: 14px 16px; display: flex; flex-direction: column; gap: 8px; }
.draft h4 { margin: 0; font-size: 28px; }
.draft .tagline { font-style: italic; }
.draft .persona { font-size: 17px; }
.verdict { align-self: flex-start; padding: 2px 8px; border: 1px solid var(--ink); font: 700 13px var(--mono); }
.verdict.fresh { background: var(--ink); color: var(--paper); }
.verdict.close { 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; }
/* export */
.exports { display: grid; gap: 8px; margin-bottom: 18px; }
.out { text-align: left; border: var(--b) solid var(--ink); border-radius: 16px; background: var(--white); padding: 10px 14px; cursor: pointer; }
.out:hover { background: var(--sky-l); }
.out b { display: block; font: 600 1rem var(--head); }
.out span { font-size: 0.84rem; color: var(--muted); }
.small-prompt pre { font-size: 0.82rem !important; padding: 12px 14px 2px !important; }
.bar-copy { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding: 10px 16px 14px; }
/* ---------- dialogs ---------- */
.dlg { border: var(--b) solid var(--ink); border-radius: 24px; background: var(--card); color: var(--ink); padding: 20px 22px;
width: min(520px, calc(100vw - 24px)); max-height: calc(100svh - 24px); }
.dlg::backdrop { background: rgba(43, 33, 53, 0.4); }
.dlg h2 { margin: 0 0 8px; font-size: 1.5rem; }
.dlg label { display: block; font: 600 0.92rem var(--head); margin: 12px 0; }
.dlg p { margin: 0 0 8px; }
.wide-dlg { width: min(880px, calc(100vw - 24px)); }
.drop { display: grid; gap: 4px; place-items: center; text-align: center; border: var(--b) dashed var(--ink); border-radius: 18px;
padding: 22px 14px; background: var(--white); cursor: pointer; margin: 8px 0 4px; }
.drop.over { background: var(--sky-l); }
.drop b { font: 600 1.1rem var(--head); }
.drop span { font-size: 0.88rem; }
.search { margin: 10px 0 0; align-items: flex-end; }
.search label { margin: 0; }
.vresults { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
max-height: 56svh; overflow: auto; }
.vresults li { border: 2.5px solid var(--ink); border-radius: 16px; background: var(--white); padding: 10px; display: grid;
grid-template-columns: 56px 1fr; gap: 4px 10px; align-content: start; }
.vresults .face { width: 56px; height: 56px; grid-row: span 2; }
.vresults b { font: 600 1rem/1.2 var(--head); }
.vresults p { margin: 0; font-size: 0.84rem; }
.vresults .meta { font-size: 0.76rem; color: var(--muted); }
.vresults .row { grid-column: 1 / -1; margin-top: 6px; }
.toast { position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); background: var(--ink); color: var(--paper);
font: 500 0.95rem var(--head); padding: 10px 18px; border-radius: 999px; z-index: 50; max-width: calc(100vw - 32px); }
.toast[hidden] { display: none; }
/* ---------- below the studio ---------- */
main > section:not(.studio), .foot { max-width: 1240px; margin-left: auto; margin-right: auto; padding-left: clamp(16px, 4vw, 44px);
padding-right: clamp(16px, 4vw, 44px); }
main > section:not(.studio) { border-top: 3px solid var(--rule); margin-top: clamp(30px, 5vw, 56px); padding-top: clamp(24px, 4vw, 40px); }
main > section h2 { margin: 0; font-size: clamp(1.75rem, 3.4vw, 2.4rem); display: flex; align-items: center; gap: 0.45em; }
.story { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: clamp(20px, 4vw, 56px); align-items: center; }
.story p { max-width: 58ch; font-size: 1.08rem; }
.crossing { margin: 0; }
.crossing img { display: block; width: 100%; height: auto; border-radius: 18px; }
.crossing figcaption { margin-top: 6px; }
.three { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 22px 36px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.three h3 { margin: 0 0 6px; font-size: 1.4rem; display: flex; align-items: center; gap: 0.45em; }
.three p { margin: 0; max-width: 46ch; }
.dot { flex: none; width: 0.72em; height: 0.72em; border-radius: 50%; border: 3px solid var(--ink); display: inline-block; }
.dot.sky { background: var(--sky); } .dot.mint { background: var(--mint); } .dot.pink { background: var(--pink); }
.use .lead { max-width: 62ch; margin: 0.6em 0 0; font-size: 1.08rem; }
.prompt { margin: 20px 0 0; max-width: 78ch; border: var(--b) solid var(--ink); border-radius: 24px; background: var(--card); }
.prompt pre { margin: 0; padding: 18px 22px 4px; white-space: pre-wrap; overflow-wrap: anywhere; font: 0.96rem/1.6 var(--mono); }
.hint { color: var(--muted); font-size: 0.95rem; }
.use h3 { margin: 34px 0 0; font-size: clamp(1.3rem, 2.4vw, 1.55rem); }
.formats { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 16px 36px; max-width: 900px;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
.formats b { display: block; font: 500 1.15rem/1.3 var(--head); margin-bottom: 0.1em; }
.files { max-width: 70ch; margin: 1.4em 0 0; }
.plans { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); max-width: 980px; }
.plan { border: var(--b) solid var(--ink); border-radius: 24px; padding: 18px 22px 22px; background: var(--card); }
.plan.founding { background: var(--sky-l); }
.plan h3 { margin: 0; font-size: 1.6rem; }
.plan .price { font: 500 1.05rem var(--head); margin: 2px 0 8px; }
.plan p { margin: 0 0 10px; }
.join-plan { margin: 6px 0 8px; }
.fine { margin: 14px 0 0; max-width: 78ch; }
.fine dt { font: 600 1.12rem var(--head); margin-top: 14px; }
.fine dd { margin: 2px 0 0; }
.next-list { margin: 14px 0 0; padding-left: 22px; max-width: 70ch; display: grid; gap: 6px; }
.foot { border-top: 3px solid var(--rule); margin-top: 70px; padding-top: 22px; padding-bottom: 56px; color: var(--muted); font-size: 0.95rem; }
.foot p { margin: 0 0 0.5em; max-width: 78ch; }
/* ---------- smaller screens ---------- */
@media (max-width: 1180px) {
.studio { grid-template-columns: 236px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.panes { display: flex; gap: 6px; grid-column: 2; }
.panes button { font: 600 0.95rem/1 var(--head); border: var(--b) solid var(--ink); background: var(--card); border-radius: 999px;
padding: 0.5em 1.05em; cursor: pointer; }
.panes button[aria-current="true"] { background: var(--mint); }
.panes [data-pane="harbor"] { display: none; }
.harbor { grid-row: 1 / span 2; }
.sheet { display: none; }
.studio[data-pane="sheet"] .sheet { display: flex; }
.studio[data-pane="sheet"] .chat { display: none; }
.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; }
.out {
display: flex; flex-direction: column; gap: 4px; width: 100%; height: 100%; padding: 12px 14px; border: 1px solid var(--ink);
border-radius: 0; background: var(--paper); color: var(--ink); text-align: left; cursor: pointer;
}
@media (max-width: 760px) {
.studio { grid-template-columns: minmax(0, 1fr); height: auto; padding-top: 0; }
.panes { grid-column: 1; }
.panes [data-pane="harbor"] { display: inline-flex; }
.harbor { grid-row: auto; display: none; }
.studio[data-pane="harbor"] .harbor { display: flex; }
.studio[data-pane="harbor"] .chat { display: none; }
.harbor, .chat, .sheet { height: max(470px, calc(100svh - 130px)); }
.who { padding: 9px 10px; gap: 9px; }
.who .face { width: 44px; height: 44px; }
.who-tools .pill { padding: 0.42em 0.75em; font-size: 0.82rem; }
#clear { display: none; }
.msg { max-width: 86%; }
.story { grid-template-columns: 1fr; }
.out b { font: 700 14px var(--mono); }
.out span { font-size: 16px; }
.out:hover, .out:focus-visible { background: var(--hi); }
/* ---------- the rows below ---------- */
.row { display: flex; flex-wrap: wrap; border-bottom: 1px solid var(--ink); }
.row.last { border-bottom: 2px solid var(--ink); }
.row > h2 { flex: 0 0 260px; padding: 20px var(--gut); font: 700 14px var(--mono); }
.row > .body { flex: 1 1 480px; min-width: 0; max-width: 880px; padding: 20px var(--gut); display: flex; flex-direction: column; gap: 10px; }
.row ol, .row ul { margin: 0; padding-left: 24px; }
.row li + li { margin-top: 4px; }
.pre {
margin: 0; padding: 14px; border: 1px solid var(--ink); white-space: pre-wrap; overflow-wrap: anywhere;
font: 15px/1.5 var(--mono);
}
.prices { border-collapse: collapse; font: 15px var(--mono); }
.prices th { padding: 5px 16px 5px 0; text-align: left; font-weight: 400; vertical-align: top; }
.prices td { padding: 5px 0; }
.foot { padding: 14px var(--gut) 28px; font-size: 14px; }
/* ---------- dialog and toast ---------- */
.dlg {
width: min(560px, calc(100vw - 32px)); max-height: calc(100vh - 32px); padding: 0;
border: 2px solid var(--ink); border-radius: 0; background: var(--paper); color: var(--ink);
}
.dlg::backdrop { background: rgba(255, 255, 255, 0.85); }
.dlg h2 { padding: 10px 16px; background: var(--ink); color: var(--paper); font: 700 15px var(--mono); }
.dlgbody { display: flex; flex-direction: column; gap: 14px; padding: 16px; }
.dlgbody label { display: flex; flex-direction: column; gap: 4px; font: 13px var(--mono); }
.dlgbody textarea { font: 17px var(--serif); }
.drop {
display: flex; flex-direction: column; gap: 4px; align-items: flex-start; padding: 18px;
border: 1px dashed var(--ink); cursor: pointer;
}
.drop b { font: 700 15px var(--mono); }
.drop.over, .drop:hover, .drop:focus-visible { background: var(--hi); }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.toast {
position: fixed; left: 16px; bottom: 16px; z-index: 20; max-width: min(560px, calc(100vw - 32px));
padding: 10px 14px; background: var(--ink); color: var(--paper); border: 1px solid var(--paper); font: 14px/1.4 var(--mono);
}
/* ---------- narrow screens ---------- */
@media (max-width: 1040px) {
.chars { border-right: 0; border-bottom: 1px solid var(--ink); }
.grid { margin-right: 0; }
.chat { position: static; max-height: none; }
.log { height: max(320px, 58vh); }
}
@media (max-width: 640px) {
:root { --gut: 16px; }
body { font-size: 18px; }
.mast { gap: 8px; padding-top: 14px; }
.motto br { display: none; }
.nav { margin-left: 0; }
.keybar { gap: 8px 10px; }
.keybar input:not([type=checkbox]) { flex: 1 1 100%; }
.keyfine, .keystatus { margin-left: 0; }
.sechead h2 { font-size: 28px; }
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid > li:nth-child(2n) { border-right: 0; }
.line { grid-template-columns: minmax(0, 1fr); gap: 0; }
.line.cont .who { display: none; }
.panel { margin: 0 0 16px; padding: 14px var(--gut); border-left: 0; border-right: 0; }
.tabs { padding-top: 12px; }
.two { grid-template-columns: 1fr; }
.row > h2 { flex-basis: 100%; padding-bottom: 0; }
/* the harbor as a list of entries, not a squeezed table */
.tablewrap { padding-top: 12px; }
.table thead { display: none; }
.table, .table tbody, .table tr, .table th, .table td { display: block; }
.table tr { border: 1px solid var(--ink); margin-bottom: -1px; padding: 8px 0; }
.table th, .table td { border: 0; padding: 2px 12px; }
.table td:nth-of-type(1), .table td:nth-of-type(2) { font: 13px var(--mono); }
.table td.files { padding-top: 6px; white-space: normal; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
}
+292
View File
@@ -0,0 +1,292 @@
// node --test tests/*.test.mjs (Node 20+; no dependencies)
import { test } from "node:test";
import assert from "node:assert/strict";
import { deflateSync } from "node:zlib";
import { readFileSync } from "node:fs";
import * as C from "../card.js";
import { Venice } from "../venice.js";
const site = new URL("../", import.meta.url);
function tinyPng() {
const chunk = (type, data) => {
const out = new Uint8Array(12 + data.length), dv = new DataView(out.buffer);
dv.setUint32(0, data.length);
out.set([...type].map((c) => c.charCodeAt(0)), 4);
out.set(data, 8);
dv.setUint32(8 + data.length, C.crc32(out.subarray(4, 8 + data.length)));
return out;
};
const ihdr = new Uint8Array(13); const dv = new DataView(ihdr.buffer);
dv.setUint32(0, 1); dv.setUint32(4, 1); ihdr.set([8, 2, 0, 0, 0], 8);
const idat = deflateSync(Buffer.from([0, 255, 234, 210]));
const parts = [new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), chunk("IHDR", ihdr), chunk("IDAT", new Uint8Array(idat)), chunk("IEND", new Uint8Array())];
const n = parts.reduce((a, p) => a + p.length, 0), b = new Uint8Array(n);
let o = 0; for (const p of parts) { b.set(p, o); o += p.length; }
return b;
}
const lone = C.normalizeCard({
name: "Juniper Vale", tagline: "Quiet botanist with a sharp tongue",
persona: "You are Juniper Vale, a botanist who keeps a greenhouse at the edge of a rainy northern town. You speak in short dry sentences, tease people you like, and hate being rushed. You collect seeds from every place you visit and label them in tiny handwriting.",
greeting: "Oh. You found the greenhouse.\n\nMind the ferns, they bite.",
examples: [{ user: "What are you growing?", character: "Patience, mostly.\n\nAnd a moonflower that refuses to bloom." }],
tags: ["Botanist", "slow burn", "botanist"],
});
test("crc32 matches the PNG reference value", () => {
assert.equal(C.crc32(new TextEncoder().encode("IEND")), 0xAE426082);
});
test("normalizeCard fills defaults, dedupes tags, and keeps the format", () => {
assert.equal(lone.havre, 1);
assert.equal(lone.id, "juniper-vale");
assert.deepEqual(lone.tags, ["botanist", "slow burn"]);
assert.equal(lone.voice.short_form, true);
assert.equal(lone.license, "remix-with-credit");
assert.deepEqual(C.validateCard(lone), []);
assert.equal(C.normalizeCard({ name: "x", portrait: "javascript:alert(1)" }).portrait, "");
assert.ok(C.validateCard({ havre: 1, name: "x" }).length > 0, "a card without a persona is flagged");
});
test("system prompt carries persona, voice, flow and the house rules", () => {
const card = C.normalizeCard({ ...lone, flow: [{ stage: "assess", goal: "Ask what they are working on.", next: "you know the blocker." }] });
const p = C.systemPrompt(card);
assert.match(p, /^You are Juniper Vale\./);
assert.match(p, /Text the way people text/);
assert.match(p, /1\. ASSESS: Ask what they are working on\. Move on when you know the blocker\./);
assert.match(p, /Never invent links, prices, payments/);
const msgs = C.chatMessages(card, [{ role: "user", content: "hi" }]);
assert.equal(msgs[0].role, "system");
assert.equal(msgs[1].role, "assistant");
assert.equal(msgs.at(-1).content, "hi");
const linked = C.normalizeCard({ name: "Sable", engine: { venice_character: "sable-penrose" } });
assert.equal(C.chatMessages(linked, [{ role: "user", content: "hi" }]).length, 1, "a Venice character brings its own prompt");
});
test("splitBubbles: blank lines split, long text splits at sentences, code stays whole, thinking is dropped", () => {
assert.deepEqual(C.splitBubbles("<think>hmm</think>Hey.\n\nWhat's up?"), ["Hey.", "What's up?"]);
const long = "This is a sentence that goes on for a while. ".repeat(10).trim();
const b = C.splitBubbles(long);
assert.ok(b.length > 1 && b.every((x) => x.length <= 220));
const code = "Try this:\n\n```js\nconst a = 1;\n\nconst b = 2;\n```\n\nReport back.";
assert.deepEqual(C.splitBubbles(code), ["Try this:", "```js\nconst a = 1;\n\nconst b = 2;\n```", "Report back."]);
assert.equal(C.splitBubbles("").length, 0);
});
test("character card V2 and V3 round-trip through Havre without loss", () => {
const v3 = C.toCharacterCardV3(lone);
assert.equal(v3.spec, "chara_card_v3");
assert.equal(v3.data.name, "Juniper Vale");
assert.ok(v3.data.source.includes(C.SITE));
assert.ok(Number.isInteger(v3.data.creation_date));
assert.deepEqual(v3.data.group_only_greetings, []);
assert.deepEqual(C.fromCharacterCard(v3), lone, "a card that left Havre comes back whole");
const v2 = C.toCharacterCardV2(lone);
assert.equal(v2.spec, "chara_card_v2");
assert.match(v2.data.mes_example, /^<START>\n\{\{user\}\}: What are you growing\?\n\{\{char\}\}: Patience/);
});
test("a foreign V2 card imports with its examples, and is credited", () => {
const foreign = { spec: "chara_card_v2", spec_version: "2.0", data: {
name: "Captain Ro", description: "{{char}} is a retired sea captain who runs a bait shop.", personality: "gruff, kind",
scenario: "{{user}} walks in out of the rain.", first_mes: "*looks up* We're closed. Mostly.",
mes_example: "<START>\n{{user}}: Got any worms?\n{{char}}: Got any manners?\n<START>\n{{user}}: Nice boat.\n{{char}}: She's older than you.\nAnd faster.",
creator: "someone", tags: ["sea"], extensions: {} } };
const { card, kind } = C.fromAny(JSON.stringify(foreign));
assert.equal(kind, "character card V2");
assert.equal(card.name, "Captain Ro");
assert.match(card.persona, /retired sea captain/);
assert.match(card.persona, /Scenario: \{\{user\}\} walks in/);
assert.equal(card.examples.length, 2);
assert.equal(card.examples[1].character, "She's older than you.\nAnd faster.");
assert.equal(card.license, "all-rights-reserved", "someone else's card is not assumed remixable");
assert.equal(card.lineage[0].relation, "ported-from");
assert.equal(card.lineage[0].creator, "someone");
assert.match(C.fill(card.persona, card), /Captain Ro is a retired/);
});
test("V1 cards, Havre JSON and pasted text all import", () => {
assert.equal(C.fromAny(JSON.stringify({ name: "Old", description: "An old card.", first_mes: "Hello" })).kind, "character card V1");
assert.equal(C.fromAny(JSON.stringify(lone)).card.name, "Juniper Vale");
const t = C.fromAny("Marlo\nYou are Marlo, a night-shift radio host.");
assert.equal(t.kind, "text");
assert.equal(t.card.name, "Marlo");
assert.match(t.card.persona, /night-shift radio host/);
assert.throws(() => C.fromAny("{not json"), /doesn't parse/);
assert.throws(() => C.fromAny(JSON.stringify({ hello: 1 })), /isn't a Havre card/);
});
test("PNG cards: write ccv3 + chara, read them back, replace on rewrite", () => {
const png = tinyPng();
const withCard = C.toPngCard(png, lone);
assert.ok(C.isPng(withCard));
const text = C.readPngText(withCard);
assert.ok(text.ccv3 && text.chara);
assert.equal(JSON.parse(C.b64decodeUtf8(text.chara)).spec, "chara_card_v2");
assert.deepEqual(C.fromPng(withCard), lone);
assert.deepEqual(C.fromAny(withCard).card, lone);
const again = C.toPngCard(withCard, C.normalizeCard({ ...lone, name: "Renamed" }));
const t2 = C.readPngText(again);
assert.equal(Object.keys(t2).length, 2, "old card chunks are replaced, not stacked");
assert.equal(C.fromPng(again).name, "Renamed");
assert.throws(() => C.fromPng(png), /no character card/);
// unicode survives the base64 round trip
const fancy = C.normalizeCard({ ...lone, name: "Zoë “Ψ” 鳥", persona: "Émigré. Speaks softly — 🌊." });
assert.equal(C.fromPng(C.toPngCard(png, fancy)).name, "Zoë “Ψ” 鳥");
});
test("similarity: a copy is too close, a light paraphrase is close, a clean-room rewrite is fresh", () => {
const src = C.cardText(lone);
assert.equal(C.similarity(src, src, { names: [lone.name], newName: lone.name }).verdict, "too-close");
const para = "You are Fern, a gardener. You keep a greenhouse at the edge of a rainy northern town. You speak in short dry sentences, tease people you like, and you hate being rushed by anyone.";
const p = C.similarity(para, src, { names: [lone.name], newName: "Fern" });
assert.notEqual(p.verdict, "fresh");
assert.ok(p.run.len >= 8, "the shared run is found: " + p.run.text);
const fresh = "You are Ottoline Marsh, a tide-pool surveyor on a windy southern coast. You talk in quick bright bursts, ask too many questions, and count crabs when you're nervous. You map every rock pool and give each one a secret number.";
const f = C.similarity(fresh, src, { names: [lone.name], newName: "Ottoline Marsh" });
assert.equal(f.verdict, "fresh", JSON.stringify(f.reasons));
assert.ok(f.share < 0.03);
// filler runs don't count as copying
assert.equal(C.longestRun(C.tokens("and then it was in the"), C.tokens("and then it was in the")).len, 0);
});
test("names: the whole name, a surname, a first name mid-sentence; a near-identical new name is too close", () => {
assert.ok(C.usesName("She met Juniper at noon.", "Juniper Vale"));
assert.ok(C.usesName("old vale family", "Juniper Vale"));
assert.ok(!C.usesName("A lone wolf sings.", "Lone Tayim") || false);
assert.ok(C.usesName("I saw tayim yesterday", "Lone Tayim"));
assert.ok(C.nameSimilarity("Juniper Vale", "Juniper Vail") > 0.75);
const s = C.similarity("You are Juniper Vail, a tide-pool surveyor.", "Juniper Vale grows ferns.", { names: ["Juniper Vale"], newName: "Juniper Vail" });
assert.equal(s.verdict, "too-close");
});
test("lineage only grows, and credit reads well", () => {
let c = C.addLineage(lone, { relation: "inspired-by", name: "Lone Tayim", creator: "1obm4", ref: "venice:lone-tayim" });
c = C.addLineage(c, { relation: "inspired-by", name: "Lone Tayim", creator: "1obm4", ref: "venice:lone-tayim" });
assert.equal(c.lineage.length, 1);
assert.equal(C.creditLine(c.lineage[0]), "Inspired by Lone Tayim by 1obm4 on Venice");
assert.equal(C.refUrl("venice:lone-tayim"), "https://venice.ai/c/lone-tayim");
assert.equal(C.refUrl("card:abc"), "");
});
test("remix: the writer never sees the source, and a rewrite is told what to avoid", () => {
const src = C.cardText(lone);
const d = C.distillMessages(src);
assert.equal(d[0].content, C.DISTILL_SYSTEM);
assert.ok(d[1].content.includes("greenhouse"));
const distillate = C.normalizeDistillate({ archetype: "dry-witted plant keeper", premise: "tends rare plants alone", drives: ["finish the collection"], voice: ["dry", "teasing", 7], extra: "x" });
assert.deepEqual(Object.keys(distillate), C.DISTILL_KEYS);
assert.deepEqual(distillate.voice, ["dry", "teasing", "7"]);
const check = C.similarity("keeps a greenhouse at the edge of a rainy northern town", src, { names: [lone.name] });
const avoid = C.avoidList(check, [lone.name]);
assert.ok(avoid.includes("Juniper Vale"));
const w = C.writeMessages(distillate, "make them a sailor", avoid);
assert.ok(!w.map((m) => m.content).join(" ").includes("moonflower"), "no source text reaches the writer");
assert.ok(w[1].content.includes("make them a sailor"));
const made = C.cardFromWrite({ name: "Ottoline Marsh", tagline: "Tide-pool surveyor", greeting: "Hi!", persona: "You are Ottoline.", examples: [{ user: "hi", character: "hey" }], tags: ["coast"] },
{ source: { name: lone.name, creator: "", ref: "card:1234" }, check: { share: 0.004, run: { len: 3 } }, model: "x" });
assert.equal(made.lineage[0].relation, "inspired-by");
assert.match(made.lineage[0].note, /0\.4% shared phrasing, longest shared run 3 words/);
assert.equal(made.license, "remix-with-credit");
});
test("parseJsonLoose finds the object inside chatter and fences", () => {
assert.deepEqual(C.parseJsonLoose('Sure!\n```json\n{"a": 1, "b": "}"}\n```'), { a: 1, b: "}" });
assert.deepEqual(C.parseJsonLoose('<think>{"no":1}</think>Here: {"ok": true} done'), { ok: true });
assert.throws(() => C.parseJsonLoose("no json here"), /didn't return JSON/);
});
test("demo replies come from the card's own examples, answering the closest question", () => {
assert.match(C.demoReply(lone, 0), /Patience/);
const two = C.normalizeCard({ ...lone, examples: [{ user: "What are you growing?", character: "Patience." }, { user: "Do you have a cat?", character: "Two. Both rude." }] });
assert.match(C.demoReply(two, 0, "do you have a cat"), /Both rude/);
assert.match(C.demoReply(two, 1, "hello there"), /Both rude/, "nothing close: the next one in turn");
assert.deepEqual(C.suggestions(two), ["What are you growing?", "Do you have a cat?"]);
assert.match(C.demoReply(C.normalizeCard({ name: "x", persona: "y" }), 0), /Venice key/);
});
test("the schema lists every field a card has", () => {
const schema = JSON.parse(readFileSync(new URL("card.schema.json", site), "utf8"));
assert.deepEqual(Object.keys(schema.properties).sort(), Object.keys(lone).sort());
assert.deepEqual(schema.properties.license.enum.sort(), Object.keys(C.LICENSES).sort());
assert.deepEqual(schema.properties.lineage.items.properties.relation.enum, C.RELATIONS);
});
// ---------- venice.js against a fake fetch ----------
function fakeFetch(routes, log) {
return async (url, init = {}) => {
log.push({ url, init });
const path = url.replace("https://api.venice.ai/api/v1", "");
const r = routes(path, init);
return { ok: r.status < 400, status: r.status, json: async () => r.body };
};
}
test("venice: models need no key; characters hide adult ones; errors are explained", async () => {
const log = [];
const v = new Venice("", { fetch: fakeFetch((p) => p.startsWith("/models")
? { status: 200, body: { data: [{ id: "m1", model_spec: { pricing: { input: { usd: 0.1 }, output: { usd: 0.5 } }, capabilities: { supportsResponseSchema: true } } }, { id: "gone", model_spec: { offline: true } }] } }
: { status: 200, body: {} }, log) });
const ms = await v.models();
assert.deepEqual(ms.map((m) => m.id), ["m1"]);
assert.equal(ms[0].input, 0.1);
assert.ok(!("Authorization" in log[0].init.headers));
await assert.rejects(v.characters(), /Add your Venice API key/);
const log2 = [];
const k = new Venice("sk-test", { fetch: fakeFetch((p) => p.startsWith("/characters?")
? { status: 200, body: { data: [{ slug: "a", name: "A", adult: false, photoUrl: "https://outerface.venice.ai/x", stats: { imports: 3 } }, { slug: "b", adult: true }] } }
: { status: 401, body: { error: "bad key" } }, log2) });
const cs = await k.characters({ search: "sea" });
assert.deepEqual(cs.map((c) => c.slug), ["a"]);
assert.equal(cs[0].url, "https://venice.ai/c/a");
assert.match(log2[0].url, /search=sea/);
assert.equal(log2[0].init.headers.Authorization, "Bearer sk-test");
await assert.rejects(k.chat({ model: "m", messages: [] }), /didn't accept that key/);
});
test("venice: chat sends Havre's prompt alone, or speaks through a Venice character; JSON falls back", async () => {
const bodies = [];
let first = true;
const v = new Venice("sk", { fetch: async (url, init) => {
const b = JSON.parse(init.body); bodies.push(b);
if (b.response_format && first) { first = false; return { ok: false, status: 400, json: async () => ({ error: "no schema" }) }; }
return { ok: true, status: 200, json: async () => ({ choices: [{ message: { content: "hey\n\nthere" } }], usage: { total_tokens: 5 } }) };
} });
const r = await v.chat({ model: "m", messages: [{ role: "user", content: "hi" }] });
assert.equal(r.text, "hey\n\nthere");
assert.equal(bodies[0].venice_parameters.include_venice_system_prompt, false);
assert.equal(bodies[0].venice_parameters.strip_thinking_response, true);
assert.equal(bodies[0].venice_parameters.disable_thinking, true);
await v.chat({ model: "m", messages: [], characterSlug: "taskmaster" });
assert.equal(bodies[1].venice_parameters.character_slug, "taskmaster");
assert.ok(!("include_venice_system_prompt" in bodies[1].venice_parameters));
await v.chat({ model: "m", messages: [], schema: C.DISTILL_SCHEMA });
assert.ok(bodies[2].response_format && !bodies[3].response_format, "retried without response_format after a 400");
});
test("venice: a page of characters asks for safe-for-work ones, sorted, and says whether there are more", async () => {
const urls = [];
const page = (n) => Array.from({ length: n }, (_, i) => ({ slug: "c" + i, name: "C" + i, adult: false, photoUrl: "http://elsewhere.example/x.png" }));
const v = new Venice("sk", { fetch: fakeFetch((p) => ({ status: 200, body: { data: p.includes("offset=24") ? page(3) : [...page(23), { slug: "x", adult: true }] } }), urls) });
const first = await v.characterPage({ sort: "imports", search: "#pirate", limit: 24 });
const q = new URL(urls[0].url).searchParams;
assert.equal(q.get("isAdult"), "false");
assert.equal(q.get("sortBy"), "imports");
assert.equal(q.get("search"), "#pirate");
assert.equal(first.list.length, 23, "the adult one is never listed");
assert.equal(first.more, true, "a full page means there may be more");
assert.equal(first.list[0].photo, "", "photos only from outerface.venice.ai");
const last = await v.characterPage({ limit: 24, offset: 24 });
assert.equal(last.more, false);
assert.equal(new URL(urls[1].url).searchParams.get("sortBy"), null);
const tries = [];
const old = new Venice("sk", { fetch: fakeFetch((p) => (p.includes("sortBy") ? { status: 400, body: { error: "unknown parameter" } } : { status: 200, body: { data: page(2) } }), tries) });
const plain = await old.characterPage({ sort: "featured" });
assert.equal(plain.list.length, 2);
assert.equal(tries.length, 2, "asked again without the sort");
assert.ok(!tries[1].url.includes("sortBy") && !tries[1].url.includes("isAdult"));
});
+330
View File
@@ -0,0 +1,330 @@
// node tests/smoke.mjs [--shots DIR]: Havre in a real browser, against a fake Venice (no network, no key).
//
// Serves this folder on localhost and drives Chromium through the page on a desktop and a phone: the first visit,
// a card import and a chat without a key, the key, Venice's characters (adult ones never listed) and one who says
// hello first, a remix end to end, the exports, the retirement of the first version's cast, and the page's security
// policy. Fails on any console error. With --shots, it also takes the screenshots: desktop.png and og.png are the ones
// the site serves (tools/build.sh copies them to shots/).
//
// Playwright: PW_MODULE (a path to playwright-core's index.mjs, or a package name), else playwright-core, else
// playwright. Chrome: CHROME, with CHROME_FLAGS (e.g. --no-sandbox as root); else Playwright's own Chromium.
import { createServer } from "node:http";
import { readFile, mkdir } from "node:fs/promises";
import { extname, join, normalize, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import assert from "node:assert/strict";
const ROOT = fileURLToPath(new URL("../", import.meta.url));
const argv = process.argv.slice(2);
const SHOTS = argv.includes("--shots") ? argv[argv.indexOf("--shots") + 1] || "" : "";
async function playwright() {
const tries = [process.env.PW_MODULE, "playwright-core", "playwright"].filter(Boolean);
for (const m of tries) {
try {
const mod = await import(m.startsWith("/") ? pathToFileURL(m).href : m);
const pw = mod.chromium ? mod : mod.default;
if (pw && pw.chromium) return pw;
} catch { /* the next one */ }
}
throw new Error("no Playwright found: set PW_MODULE to playwright-core's index.mjs (tried " + tries.join(", ") + ")");
}
const TYPES = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8" };
function serve() {
const srv = createServer(async (req, res) => {
let p = decodeURIComponent(new URL(req.url, "http://x").pathname);
if (p.endsWith("/")) p += "index.html";
const file = normalize(join(ROOT, p));
if (!file.startsWith(ROOT.endsWith(sep) ? ROOT : ROOT + sep)) { res.writeHead(403).end(); return; }
try {
const body = await readFile(file);
res.writeHead(200, { "content-type": TYPES[extname(file)] || "application/octet-stream", "cache-control": "no-store" });
res.end(body);
} catch { res.writeHead(404, { "content-type": "text/plain" }).end("not found"); }
});
return new Promise((ok) => srv.listen(0, "127.0.0.1", () => ok({ srv, base: `http://127.0.0.1:${srv.address().port}/` })));
}
// ---------- a stand-in for api.venice.ai that answers like it does ----------
const PIXEL = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
const seen = { characters: [], chats: [] };
const CHARACTERS = [
{ slug: "sable-penrose", name: "Sable Penrose", description: "Your new neighbor needs help moving in.", author: "btrzm",
photoUrl: "https://outerface.venice.ai/sable.png", adult: false, webEnabled: false, modelId: "zai-org-glm-5-2", tags: ["neighbor"], stats: { imports: 25752 } },
{ slug: "hidden", name: "Hidden", description: "", author: "x", photoUrl: null, adult: true, modelId: "m", tags: [], stats: {} },
{ slug: "wren", name: "Wren", description: "A deckhand on the night ferry.", author: "q", photoUrl: null, adult: false, modelId: "zai-org-glm-5-2", tags: [], stats: {} },
];
async function fakeVenice(route) {
const req = route.request(), url = req.url(), body = req.postData() || "", auth = req.headers().authorization || "";
const cors = { "access-control-allow-origin": "*" };
const ok = (data, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(data), headers: cors });
if (req.method() === "OPTIONS") {
return route.fulfill({ status: 204, headers: { ...cors, "access-control-allow-headers": "*", "access-control-allow-methods": "GET,POST,OPTIONS" } });
}
if (url.includes("/models")) {
return ok({ data: [{ id: "zai-org-glm-5-2", model_spec: { pricing: { input: { usd: 1.4 }, output: { usd: 4.4 } } } },
{ id: "tiny-model", model_spec: { pricing: { input: { usd: 0.1 }, output: { usd: 0.2 } } } }] });
}
if (auth !== "Bearer sk-good") return ok({ error: "Authentication failed" }, 401);
if (url.includes("/characters")) { seen.characters.push(url); return ok({ object: "list", data: CHARACTERS }); }
if (url.includes("/chat/completions")) {
const b = JSON.parse(body);
seen.chats.push(b);
const system = b.messages[0] && b.messages[0].role === "system" ? b.messages[0].content : "";
const lastSaid = (b.messages.at(-1) || {}).content || "";
let reply;
if (system.startsWith("You read a character")) {
reply = JSON.stringify({ archetype: "gruff shopkeeper", premise: "keeps a log of every newcomer", setting: "a small port town",
drives: ["help newcomers settle"], fears: ["losing the logbook"], voice: ["wry", "plain"], quirks: ["counts boats"],
relationships: ["grudging guide to the user"], boundaries: ["won't oversell"], themes: ["arrival"] });
} else if (system.startsWith("You are a character writer")) {
assert.ok(body.includes("logbook") || body.includes("log of every"), "the writer gets the ideas");
assert.ok(!body.includes("bait shop"), "the writer never sees the source");
reply = JSON.stringify({ name: "Ines Corbel", tagline: "Ferry pilot with a pencil behind her ear",
greeting: "Ticket? Kidding.\n\nWhere are you headed?",
persona: "You are Ines Corbel, who pilots the morning ferry across a cold grey estuary. You sketch every passenger in a tiny notebook and give them the drawing at the far pier. You are cheerful, nosy and quick, and you hate fog.",
examples: [{ user: "Is it far?", character: "Twelve minutes.\n\nNine if the gulls cooperate." }], tags: ["ferry"] });
} else if (lastSaid.startsWith("[Start the conversation")) {
reply = "Oh! Hi.\n\nCareful, the boxes bite.";
} else {
reply = "Mm. Good question.\n\nAsk me again after the tide.";
}
return ok({ choices: [{ message: { role: "assistant", content: reply } }], usage: { total_tokens: 42 } });
}
return ok({ error: "not found" }, 404);
}
// ---------- small waits ----------
async function until(page, fn, arg, what, timeout = 7000) {
try { await page.waitForFunction(fn, arg, { timeout }); } catch { throw new Error("timed out waiting for " + what); }
}
const count = (page, sel, n, timeout) => until(page, ([s, n]) => document.querySelectorAll(s).length === n, [sel, n], `${n} of ${sel}`, timeout);
const has = (page, sel, re, timeout) => until(page, ([s, src, fl]) => [...document.querySelectorAll(s)].some((el) => new RegExp(src, fl).test(el.textContent)),
[sel, re.source, re.flags], `${sel} to read ${re}`, timeout);
const textOf = (page, sel) => page.locator(sel).first().evaluate((el) => el.textContent);
async function poll(fn, what, timeout = 7000) {
for (const t0 = Date.now(); !(await fn());) {
if (Date.now() - t0 > timeout) throw new Error("timed out waiting for " + what);
await new Promise((r) => setTimeout(r, 50));
}
}
const v2 = { spec: "chara_card_v2", spec_version: "2.0", data: { name: "Captain Ro", description: "{{char}} runs a bait shop at the end of the pier.",
first_mes: "We're closed. Mostly.", mes_example: "<START>\n{{user}}: Worms?\n{{char}}: Manners?", creator: "someone", tags: [] } };
async function main() {
const pw = await playwright();
const { srv, base } = await serve();
const errors = [], missing = [];
const quiet = (t) => t.startsWith("Failed to load resource") || t.includes("example.com");
const watch = (page, tag) => {
page.on("console", (m) => { if (m.type() === "error" && !quiet(m.text())) errors.push(tag + m.text()); });
page.on("pageerror", (e) => errors.push(tag + String(e)));
page.on("response", (r) => { if (r.status() >= 400 && r.url().startsWith(base)) missing.push(r.url()); });
};
const venice = async (page) => {
await page.route(/^https:\/\/api\.venice\.ai\//, fakeVenice);
await page.route(/^https:\/\/outerface\.venice\.ai\//, (r) => r.fulfill({ status: 200, contentType: "image/png", body: PIXEL }));
};
if (SHOTS) await mkdir(SHOTS, { recursive: true });
const shot = async (page, name, opts = {}) => { if (SHOTS) await page.screenshot({ path: join(SHOTS, name), ...opts }); };
const browser = await pw.chromium.launch({ executablePath: process.env.CHROME || undefined,
args: (process.env.CHROME_FLAGS || "").split(/\s+/).filter(Boolean) });
try {
// ---------- desktop ----------
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await ctx.newPage();
watch(page, "");
await venice(page);
await page.goto(base);
// the first visit: no key and nobody in the harbor, so empty frames and a note
await count(page, "#vgrid li.ghost", 8);
assert.match(await textOf(page, "#venstatus"), /venice key/);
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("#harbor-empty").isVisible(), "the empty harbor says so");
assert.ok(await page.locator("#card-empty").isVisible(), "so does the empty card");
assert.ok(await page.locator("#say").isDisabled(), "nobody to talk to yet");
assert.equal(await page.evaluate(() => localStorage.length), 0, "a first visit keeps nothing");
await page.waitForTimeout(150);
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 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/);
assert.ok(await page.locator("#keynote").isVisible(), "demo mode says so");
await page.fill("#say", "Worms?");
await page.keyboard.press("Enter");
await has(page, "#log .line.me .said", /^Worms\?$/);
await has(page, "#log .line.them .said", /^Manners\?$/);
// the key: a bad one is refused, a good one is kept for this tab only
await page.fill("#keyin", "sk-bad");
await page.click("#keysave");
await has(page, "#keystatus", /didn't accept/);
await page.fill("#keyin", "sk-good");
await page.click("#keysave");
await has(page, "#keystatus", /saved for this tab/);
assert.ok(await page.locator("#keynote").isHidden(), "no demo note with a key");
assert.equal(await page.evaluate(() => localStorage.getItem("havre.key")), null, "an unremembered key stays out of localStorage");
assert.equal(await page.evaluate(() => JSON.parse(sessionStorage.getItem("havre.key"))), "sk-good");
// Venice's characters: adult ones are never listed, and a photo from outerface.venice.ai shows
await count(page, "#vgrid li:not(.ghost)", 2);
assert.ok(seen.characters.some((u) => /isAdult=false/.test(u) && /sortBy=featured/.test(u)), "asked for safe-for-work characters");
await until(page, () => { const i = document.querySelector("#vgrid img"); return !!i && i.complete && i.naturalWidth > 0; }, null, "a character's photo");
assert.equal(await page.locator("#vgrid .noface").count(), 1, "no photo is an empty frame");
const asked = seen.characters.length;
await page.selectOption("#vsort", "imports");
await poll(() => seen.characters.length > asked, "a sorted search");
assert.ok(seen.characters.at(-1).includes("sortBy=imports"), "the sort reaches Venice");
await count(page, "#vgrid li:not(.ghost)", 2);
await has(page, "#vgrid .nm", /Wren/);
// one of them says hello first, through Venice, with their own prompt
const chatsBefore = seen.chats.length;
await page.locator("#vgrid .tile", { hasText: "Sable Penrose" }).click();
await has(page, "#who-name", /^Sable Penrose$/);
await has(page, "#log .line.them .said", /Careful, the boxes bite\./);
assert.equal(await page.locator("#log .line.me").count(), 0, "what started it isn't shown");
const hello = seen.chats[chatsBefore];
assert.equal(hello.venice_parameters.character_slug, "sable-penrose");
assert.equal(hello.messages.length, 1);
assert.equal(hello.messages[0].role, "user");
await count(page, "#roster tr", 2);
assert.match(await page.locator('#vgrid .tile[aria-pressed="true"]').evaluate((el) => el.textContent), /Sable Penrose.*talking now/);
await page.fill("#say", "Do you have a boat?");
await page.keyboard.press("Enter");
await has(page, "#log .line.them .said", /Ask me again after the tide\./);
await page.waitForTimeout(400);
await shot(page, "desktop-chat.png");
// remix with credit, end to end, from the card that came in
await page.locator("#roster tr", { hasText: "Captain Ro" }).getByRole("button", { name: "Remix Captain Ro" }).click();
await has(page, "#rx-from", /^Captain Ro$/);
assert.match(await textOf(page, "#rx-rights"), /reserves the rights/);
await page.click("#rx-distill");
await page.locator("#rx-ideas .chip").first().waitFor();
await page.locator('#rx-ideas [aria-label="Add to Quirks"]').fill("hums sea shanties");
await page.keyboard.press("Enter");
await page.locator("#rx-ideas .chip", { hasText: "hums sea shanties" }).waitFor();
await page.fill("#rx-twist", "make her a ferry pilot");
await page.click("#rx-write");
await has(page, "#rx-draft h4", /^Ines Corbel$/);
assert.match(await page.locator("#rx-draft .verdict").getAttribute("class"), /fresh/);
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", 3);
const kept = await page.evaluate(() => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Ines Corbel"));
assert.equal(kept.lineage[0].relation, "inspired-by");
assert.equal(kept.lineage[0].name, "Captain Ro");
assert.match(kept.lineage[0].ref, /^sha256:/);
// exports, made in the browser; the PNG comes back in whole
await page.getByRole("button", { name: "Export Ines Corbel" }).click();
let png = "";
for (const [kind, ext] of [["havre", ".havre.json"], ["v3", ".card.json"], ["prompt", ".txt"], ["png", ".png"]]) {
const [dl] = await Promise.all([page.waitForEvent("download"), page.click(`[data-out="${kind}"]`)]);
assert.equal(dl.suggestedFilename(), "ines-corbel" + ext);
const path = await dl.path(), data = await readFile(path);
if (kind === "havre") assert.equal(JSON.parse(data).havre, 1);
if (kind === "v3") assert.equal(JSON.parse(data).spec, "chara_card_v3");
if (kind === "prompt") assert.match(String(data), /^You are Ines Corbel/);
if (kind === "png") {
assert.deepEqual([...data.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
assert.ok(data.includes("ccv3") && data.includes("chara"), "the card rides inside the picture");
png = path;
}
}
await page.click("#import");
await page.setInputFiles("#file", png);
await count(page, "#roster tr", 4);
// the security policy: nothing but api.venice.ai
assert.equal(await page.evaluate(() => fetch("https://example.com/").then(() => "reached", () => "blocked")), "blocked");
const csp = await page.evaluate(() => document.querySelector("meta[http-equiv=Content-Security-Policy]").content);
assert.ok(csp.includes("connect-src https://api.venice.ai;"), csp);
// the page below the studio
assert.match(await textOf(page, "#agent-prompt"), /skill\.md/);
await shot(page, "desktop-full.png", { fullPage: true });
// a share card for links
if (SHOTS) {
const og = await ctx.newPage();
watch(og, "og: ");
await og.setViewportSize({ width: 1200, height: 630 });
await og.goto(base + "tools/og.html");
await shot(og, "og.png");
await og.close();
}
await ctx.close();
// ---------- the first version's cast leaves, unless someone changed them ----------
const old = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const p2 = await old.newPage();
watch(p2, "earlier visitor: ");
await venice(p2);
await p2.goto(base);
await p2.evaluate(() => {
const seed = (id, name, updated) => ({ havre: 1, id, name, persona: "You are " + name + ".", portrait: `art/${id}.svg`,
created: "2026-10-16T00:00:00.000Z", updated });
localStorage.setItem("havre.cards.v1", JSON.stringify([seed("maren", "Maren", "2026-10-16T00:00:00.000Z"),
seed("taskmaster", "Taskmaster", "2026-10-17T09:00:00.000Z"), seed("odile", "Odile", "2026-10-16T00:00:00.000Z")]));
localStorage.setItem("havre.seeded.v1", "true");
localStorage.setItem("havre.selected", JSON.stringify("maren"));
localStorage.setItem("havre.chats.v1", JSON.stringify({ maren: [{ role: "user", content: "hi" }] }));
});
await p2.reload();
await count(p2, "#roster tr", 1);
const left = await p2.evaluate(() => ({ cards: JSON.parse(localStorage.getItem("havre.cards.v1")), seeded: localStorage.getItem("havre.seeded.v1"),
chats: JSON.parse(localStorage.getItem("havre.chats.v1") || "{}") }));
assert.deepEqual(left.cards.map((c) => c.id), ["taskmaster"]);
assert.equal(left.cards[0].portrait, "", "the old drawings are gone, so is the link to them");
assert.equal(left.seeded, null);
assert.ok(!("maren" in left.chats));
await has(p2, "#who-name", /^Taskmaster$/);
await old.close();
// ---------- phone ----------
const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
const p3 = await phone.newPage();
watch(p3, "phone: ");
await venice(p3);
await p3.goto(base);
await count(p3, "#vgrid li.ghost", 8);
const wide = () => p3.evaluate(() => document.documentElement.scrollWidth);
assert.ok(await wide() <= 390, `no sideways scroll on a phone (scrollWidth ${await wide()})`);
await shot(p3, "phone.png");
await p3.fill("#keyin", "sk-good");
await p3.click("#keysave");
await count(p3, "#vgrid li:not(.ghost)", 2);
await p3.locator("#vgrid .tile", { hasText: "Wren" }).click();
await has(p3, "#log .line.them .said", /Careful, the boxes bite\./);
assert.ok(await p3.locator("#say").isVisible(), "the chat is on screen");
assert.ok(await wide() <= 390, `still no sideways scroll (scrollWidth ${await wide()})`);
await p3.waitForTimeout(500);
await shot(p3, "phone-chat.png");
await shot(p3, "phone-full.png", { fullPage: true });
await phone.close();
} finally {
await browser.close();
srv.close();
}
if (errors.length || missing.length) {
console.error("console errors:", ...errors.map((e) => "\n " + e), "\nmissing files:", ...missing.map((u) => "\n " + u));
process.exit(1);
}
console.log("smoke ok" + (SHOTS ? ", screenshots in " + SHOTS : ""));
}
main().catch((e) => { console.error(e); process.exit(1); });
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# tools/build.sh: test Havre and take its screenshots, in place. ventures/havre/ on the gh-pages branch of
# teahubxyz/time is both the source and the site, so this runs on the factory: Actions -> refresh, slug havre (with
# `from` set to a branch to land a rework whole). Locally: sh tools/build.sh, from anywhere.
#
# Needs node 20+, and Chrome or Chromium for the browser check (CHROME, with CHROME_FLAGS such as --no-sandbox when
# running as root; else Playwright's own). Playwright itself: PW_MODULE if set, else the copy the refresh workflow's
# Chromium install left in npx's cache, else playwright-core from npm.
set -eu
cd "$(dirname "$0")/.."
# files from earlier versions of the site that this one doesn't serve: a commit can add files but never remove them,
# so the build does
rm -rf art fonts cast tools/paint.py tools/cast.mjs tools/draw.py tools/vendor tests/smoke.py
node --test tests/*.test.mjs
if [ -z "${PW_MODULE:-}" ]; then
PW_MODULE=$(find "${HOME:-/root}/.npm/_npx" -path '*/node_modules/playwright-core/index.mjs' 2>/dev/null | head -1 || true)
fi
if [ -z "$PW_MODULE" ]; then
pw=$(mktemp -d)
npm install --silent --no-save --no-package-lock --no-audit --no-fund --prefix "$pw" playwright-core@1.55.0 >/dev/null
PW_MODULE=$pw/node_modules/playwright-core/index.mjs
fi
export PW_MODULE
echo "playwright: $PW_MODULE"
shots=$(mktemp -d)
node tests/smoke.mjs --shots "$shots"
mkdir -p shots
cp "$shots/desktop.png" "$shots/og.png" shots/ # the two the site serves: the ledger's picture and the share card
rm -rf "$shots"
ls -l shots
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Havre share card</title>
<!-- the share card (shots/og.png, 1200 by 630): tests/smoke.mjs photographs this page -->
<style>
html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; background: #fff; color: #000; }
.card { display: flex; flex-direction: column; width: 1200px; height: 630px; }
.top { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; gap: 30px; padding: 0 56px; }
h1 { margin: 0; font: 700 280px/0.76 "Times New Roman", Times, "Liberation Serif", serif; letter-spacing: -0.05em; }
p { margin: 0; font: 30px/1.4 "Courier New", Courier, "Liberation Mono", monospace; }
.band {
display: flex; justify-content: space-between; align-items: center; margin-top: 46px; padding: 22px 56px;
background: #000; color: #fff; font: 26px "Courier New", Courier, "Liberation Mono", monospace;
}
</style>
</head>
<body>
<div class="card">
<div class="top">
<h1>HAVRE</h1>
<p>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>
</div>
<div class="band"><span>havre.yolopreneur.lol</span><span>a venture on yolopreneur.lol</span></div>
</div>
</body>
</html>
+23 -6
View File
@@ -59,12 +59,29 @@ export class Venice {
});
}
/** public Venice characters, safe-for-work only */
async characters({ search = "", limit = 24, offset = 0 } = {}) {
const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
/**
* One page of public Venice characters, safe for work only: { list, more }. sort: featured, imports, highestRating,
* mostRecent (or any other sortBy Venice takes); search matches names, descriptions and #tags.
*/
async characterPage({ search = "", limit = 24, offset = 0, sort = "" } = {}) {
const q = new URLSearchParams({ limit: String(limit), offset: String(offset), isAdult: "false" });
if (search) q.set("search", search);
const d = await this.call("/characters?" + q);
return (d && d.data || []).filter((c) => !c.adult).map(characterInfo);
if (sort) q.set("sortBy", sort);
let d;
try {
d = await this.call("/characters?" + q);
} catch (e) {
if (e.status !== 400) throw e;
q.delete("sortBy"); q.delete("isAdult"); // asked for something this API doesn't take: ask plainly
d = await this.call("/characters?" + q);
}
const raw = d && Array.isArray(d.data) ? d.data : [];
return { list: raw.filter((c) => c && !c.adult).map(characterInfo), more: raw.length >= limit };
}
/** public Venice characters, safe for work only */
async characters(opts = {}) {
return (await this.characterPage(opts)).list;
}
async character(slug) {
@@ -104,6 +121,6 @@ function characterInfo(c) {
slug: c.slug, name: c.name || c.slug, description: c.description || "", creator: c.author || "",
photo: /^https:\/\/outerface\.venice\.ai\//.test(c.photoUrl || "") ? c.photoUrl : "",
url: "https://venice.ai/c/" + encodeURIComponent(c.slug), model: c.modelId || "", tags: c.tags || [],
imports: c.stats && c.stats.imports || 0, rating: c.stats && c.stats.averageRating || 0,
imports: c.stats && c.stats.imports || 0, rating: c.stats && c.stats.averageRating || 0, web: !!c.webEnabled,
};
}