diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 1fded69..80b6043 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -129,7 +129,7 @@ function boot() { const open = S.threads[local.get(K.open, "")], c0 = card(); S.open = open && c0 && open.card === c0.id ? open.id : c0 ? (latestOf(c0.id) || {}).id || null : null; const left = local.get(K.left, ""); - S.left = left === "chats" || left === "chars" ? left : Object.keys(S.threads).length ? "chats" : "chars"; + S.left = ["chats", "chars", "harbor"].includes(left) ? left : Object.keys(S.threads).length ? "chats" : "chars"; const sort = local.get(K.sort, "imports"); S.v.sort = SORTS.includes(sort) ? sort : "imports"; S.v.adult = local.get(K.adult, false) === true && local.get(K.adult18, false) === true; @@ -365,7 +365,7 @@ function setLeft(which, { focus = false } = {}) { local.set(K.left, which); setPane("list"); renderLeft(); - if (focus) $(which === "chats" ? "#lt-chats" : "#lt-chars").focus(); + if (focus) $("#lt-" + which).focus(); } /** * On a narrow screen the studio shows one thing at a time, the way a phone's messages do: the list (your chats, the @@ -379,13 +379,13 @@ function setPane(which) { if (S.pane === "chat") scrollLog(); // drawn while hidden, it couldn't scroll: now it can } function renderLeft() { - for (const [w, tab, panel] of [["chats", "#lt-chats", "#lp-chats"], ["chars", "#lt-chars", "#lp-chars"]]) { + for (const [w, tab, panel] of [["chats", "#lt-chats", "#lp-chats"], ["chars", "#lt-chars", "#lp-chars"], ["harbor", "#lt-harbor", "#lp-harbor"]]) { $(tab).setAttribute("aria-selected", S.left === w ? "true" : "false"); $(tab).tabIndex = S.left === w ? 0 : -1; $(panel).hidden = S.left !== w; } // a narrow screen's way back from a chat: to whichever list you came from - $("#chat-back").textContent = S.left === "chats" ? "← your chats" : "← characters"; + $("#chat-back").textContent = S.left === "chats" ? "← your chats" : S.left === "harbor" ? "← your harbor" : "← characters"; } const faces = new Map(); // each chat's face in the list, kept between drawings so the photos don't flicker function threadFace(t, src) { @@ -998,8 +998,8 @@ function renderLog() { const log = $("#log"), c = card(), t = thread(); log.textContent = ""; if (!c) { - put(line(S.key ? "pick one of the harbor's own or one of venice's characters, or write your own. they talk here." - : "meet one of the harbor's own, write a character of your own, or import a card: those work without a key. paste your venice key above, and venice's characters come too.", "havre", "note")); + put(line(S.key ? "pick one of venice's characters, or write your own. they talk here." + : "paste your venice key above, then pick one of venice's characters. or write a character of your own, or import a card: those work without a key.", "havre", "note")); return; } const hist = t ? t.msgs : []; @@ -3266,6 +3266,13 @@ function toast(text) { // ---------- wiring ---------- function bind() { + // the reading lives folded in the footer: a link to a part of it (#costs, #fine…) opens it there + const openAbout = () => { + const el = location.hash && document.getElementById(location.hash.slice(1)), about = $("#about"); + if (el && about && about.contains(el)) { about.open = true; el.scrollIntoView({ block: "start" }); } + }; + window.addEventListener("hashchange", openAbout); + openAbout(); $("#keychange").addEventListener("click", () => { S.keyEditing = true; updateKeyUI(); $("#keyin").focus(); }); $("#keyform").addEventListener("submit", () => { S.keyEditing = false; }); $("#keyin").addEventListener("blur", () => { setTimeout(() => { @@ -3446,13 +3453,15 @@ function bind() { // your chats, and the left column's two tabs (on a narrow screen, the chat has its way back to them) $("#chat-back").addEventListener("click", backToList); - const left = [$("#lt-chats"), $("#lt-chars")]; - for (const t of left) { - t.addEventListener("click", () => setLeft(t.id === "lt-chats" ? "chats" : "chars")); + const leftTabs = ["chats", "chars", "harbor"]; + for (const w of leftTabs) { + const t = $("#lt-" + w); + t.addEventListener("click", () => setLeft(w)); t.addEventListener("keydown", (e) => { if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; e.preventDefault(); - setLeft(t.id === "lt-chats" ? "chars" : "chats", { focus: true }); + const i = leftTabs.indexOf(w) + (e.key === "ArrowRight" ? 1 : -1); + setLeft(leftTabs[(i + leftTabs.length) % leftTabs.length], { focus: true }); }); } // coming back to the tab: what's on screen has been seen, and after a while away, characters text first diff --git a/ventures/havre/index.html b/ventures/havre/index.html index 1c7c8a5..2e8898c 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -52,6 +52,8 @@ + +
-

the harbor's own. they answer without a key, from their own examples; with one, for real.

- +

venice's public characters. pick one to start a chat.

+
@@ -103,20 +119,6 @@
-
-
-

YOUR HARBOR

- kept in this browser. export a file to keep a character anywhere else. -
-
- - - -
NAMEFROMLICENSEFILE
-
-

nothing here yet. pick one of venice's characters, import a card, or write one.

-
-

CARD

@@ -243,13 +245,15 @@
+
+ABOUT HAVRE how it works / for agents / costs / plans / fine print

HOW IT WORKS

  1. Paste your Venice API key. It stays in this browser and goes only to api.venice.ai; the page can reach nothing but Venice.
  2. -
  3. Pick a character: one of the harbor's own, one of Venice's, a card file you import, or one you write. Venice's characters say hello first. Or chat with a model and no character at all.
  4. -
  5. Talk. Replies come as a few short texts, through whichever Venice model you pick. Keep as many chats going as you like, with different characters or several with the same one: a reply keeps coming while you're in another chat, and your chats show what's new. Without a key, the harbor's own and the characters you write answer from their own examples.
  6. +
  7. Pick a character: one of Venice's, a card file you import, or one you write. Venice's characters say hello first. Or chat with a model and no character at all.
  8. +
  9. Talk. Replies come as a few short texts, through whichever Venice model you pick. Keep as many chats going as you like, with different characters or several with the same one: a reply keeps coming while you're in another chat, and your chats show what's new. Without a key, the characters you write answer from their own examples.
  10. They remember. Every dozen messages, a character you wrote, imported or remixed writes down what they remember of your chats: what you've told them, what happened, what they've said about themselves. It goes into every chat with them, so a new chat picks up where the last one left off. Read it, add to it, or make them forget, on their card.
  11. More than texts. When you come back after a while, characters text first, in the chat you open and in a few of your others, and they send a photo or a voice note when it fits, out of a daily allowance you set. Ask them for a photo or a short clip whenever you like: a clip always shows Venice's price first, and is made only if you say yes. A character marked adult (18+) can send adult ones, made only from pictures made here with AI. Switch them on, and they hear your voice notes and see your photos.
  12. Keep them. Export a character as a file, or remix them into someone new; the credit stays in the card.
  13. @@ -331,6 +335,8 @@
+
+ diff --git a/ventures/havre/shots/desktop.png b/ventures/havre/shots/desktop.png index 70c1aab..364c01e 100644 Binary files a/ventures/havre/shots/desktop.png and b/ventures/havre/shots/desktop.png differ diff --git a/ventures/havre/style.css b/ventures/havre/style.css index 55f10fd..8a91719 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -87,6 +87,11 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: background: var(--ink); color: var(--paper); font: 15px/1.4 var(--mono); } .keybar a { color: var(--paper); } +/* the reading, folded into one footer */ +.about { border-top: 2px solid var(--ink); } +.about > summary { padding: 14px var(--gut); font: 700 15px/1.4 var(--mono); cursor: pointer; } +.about > summary .hint { font-weight: 400; } +.about > summary:hover, .about > summary:focus-visible { background: var(--hi); } /* with a key saved: one line (where it's kept, the balance, change); change brings the form back */ .keybar:not(.set) .keyset { display: none; } .keybar.set > :not(.keyset):not(#balance):not(#keystatus) { display: none !important; } diff --git a/ventures/havre/tests/smoke.mjs b/ventures/havre/tests/smoke.mjs index 07ffe63..a00613c 100644 --- a/ventures/havre/tests/smoke.mjs +++ b/ventures/havre/tests/smoke.mjs @@ -324,7 +324,8 @@ async function main() { 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("#cast-box").isHidden(), "the harbor's own aren't listed"); + assert.equal(await page.locator("#harbor-empty").evaluate((e) => e.hidden), false, "the empty harbor says so"); assert.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"); @@ -765,10 +766,12 @@ async function main() { // remix with credit, end to end, from the card that came in. His model can't keep to JSON (it writes it by hand), so // the two passes run on one that can, and the new character talks through his + await page.click("#lt-harbor"); await page.locator("#roster tr", { hasText: "Captain Ro" }).getByRole("button", { name: "Captain Ro's card" }).click(); await page.selectOption("#model-select", "tiny-model"); await until(page, () => JSON.parse(localStorage.getItem("havre.cards.v1")).find((c) => c.name === "Captain Ro").model === "tiny-model", null, "his model saved"); await page.locator("#roster tr", { hasText: "Captain Ro" }).getByRole("button", { name: "Remix Captain Ro" }).click(); + await page.click("#lt-chars"); await has(page, "#rx-from", /^Captain Ro$/); assert.match(await textOf(page, "#rx-rights"), /reserves the rights/); await page.click("#rx-distill"); @@ -1029,7 +1032,9 @@ async function main() { await has(page, "#adult-note", /^their photos and clips can be adult/); // exports, made in the browser; the PNG comes back in whole + await page.click("#lt-harbor"); await page.getByRole("button", { name: "Export Ines Corbel" }).click(); + await page.click("#lt-chars"); 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}"]`)]); @@ -1147,7 +1152,7 @@ async function main() { await count(p5, "#cast li", 3); assert.deepEqual(await p5.evaluate(() => [...document.querySelectorAll("#cast .nm")].map((e) => e.textContent)), ["Maren", "Odile", "Taskmaster"]); assert.equal(await p5.evaluate(() => localStorage.length), 0, "looking at them keeps nothing"); - await p5.click("#cast .tile[data-cast=maren]"); + await p5.goto(base + "?meet=maren"); await has(p5, "#who-name", /^Maren$/); await has(p5, "#log .line.them .said", /I'm Maren, I run the harbor\./); await has(p5, "#cast .tile[data-cast=maren] .go", /^talking now$/); @@ -1160,10 +1165,10 @@ async function main() { assert.equal(mine.length, 1); assert.deepEqual([mine[0].id, mine[0].lineage.at(-1).ref, mine[0].lineage.at(-1).relation], ["maren", "havre:maren", "ported-from"], "a copy with the credit"); assert.notEqual(mine[0].created, "2026-10-16T00:00:00.000Z", "the copy is dated when it was met, not when the card was written"); - await p5.click("#cast .tile[data-cast=odile]"); + await p5.goto(base + "?meet=odile"); await has(p5, "#who-name", /^Odile$/); await has(p5, "#cast .tile[data-cast=maren] .go", /^talk again$/); - await p5.click("#cast .tile[data-cast=maren]"); + await p5.goto(base + "?meet=maren"); await has(p5, "#who-name", /^Maren$/); await count(p5, "#roster tr", 2); assert.ok(await p5.locator("#log .line.me .said", { hasText: "What is this place?" }).isVisible(), "the same chat, not a new copy of her");