diff --git a/ventures/havre/abilities.js b/ventures/havre/abilities.js index 0af9113..672ec6b 100644 --- a/ventures/havre/abilities.js +++ b/ventures/havre/abilities.js @@ -133,14 +133,20 @@ function transcript(card, history, n = 10) { * The messages for a text model to write the shot: the character, the conversation, what was asked for. adult: for an * adult character whose pictures can be adult (adultCheck). */ -export function shotMessages(card, history, ask = "", kind = "photo", { adult = false } = {}) { +export function shotMessages(card, history, ask = "", kind = "photo", { adult = false, current = "", change = "" } = {}) { const lines = transcript(card, history); const look = card.look && card.look.description; + const now = str(current, 900).trim(), how = str(change, 300).trim(); + // a shot that's there already: changed the way they say, or, written again with nothing said, a different one + const again = !now ? "" : how + ? `The shot as it stands:\n${now}\n\nChange it: ${how}. Write the whole shot again with that change, keeping what the change doesn't touch.` + : `The last shot was:\n${now}\n\nWrite a clearly different one: another moment, angle, setting or mood.`; const user = [ `The character: ${card.name}${card.tagline ? ". " + card.tagline : ""}`, look ? `How they look: ${look}` : "", lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "The conversation hasn't started yet.", "What was asked for: " + (str(ask, 500).trim() || "nothing in particular. Pick a moment that fits the conversation."), + again, `Write the shot for the ${kind === "clip" ? "clip" : "photo"} ${card.name} sends now.`, ].filter(Boolean).join("\n\n"); const system = kind === "clip" ? (adult ? CLIP_SYSTEM_ADULT : CLIP_SYSTEM) : adult ? PHOTO_SYSTEM_ADULT : PHOTO_SYSTEM; @@ -357,7 +363,9 @@ export function moveLikely(history, { every = 6 } = {}) { const real = (history || []).filter((m) => m && !m.demo && !m.hidden); const last = real.at(-1), said = [...real].reverse().find((m) => m.role === "user"); if (!last || last.role !== "assistant" || last.media) return false; - if ((said && ASKS.test(str(said.content))) || SHOWS.test(str(last.content))) return true; + if (said && ASKS.test(str(said.content))) return true; + if (!every) return false; // never on their own: only when asked + if (SHOWS.test(str(last.content))) return true; return real.filter((m) => m.role === "assistant" && !m.media).length % every === 0; } /** did they send a photo or clip of their own in the last few messages (so not another one yet) */ diff --git a/ventures/havre/app.js b/ventures/havre/app.js index 80b6043..bbaed73 100644 --- a/ventures/havre/app.js +++ b/ventures/havre/app.js @@ -837,11 +837,18 @@ function renderChatMeta() { h("span", {}, "model: ", linkish(modelName(c.model), () => openModels("chat"), { id: "model-btn", "aria-label": `Model: ${modelName(c.model)}. Change it` })), c.plain ? null : linkish("card", () => openCard("edit")), linkish("gallery", () => openGallery(t ? "chat" : "all"), { title: "Every photo and clip" }), + t && chatCost(t) > 0 ? h("span", { id: "chat-cost", title: "What this chat has cost on your Venice key: replies (since costs were kept), photos, clips and voice notes", text: `spent: ${A.usd(chatCost(t))}` }) : null, t ? linkish("new chat", newChat, { id: "new-chat", title: `A new chat${c.plain ? "" : " with " + c.name}; this one stays in your chats` }) : null, t ? linkish("rename", renameThread, { title: "Name this chat" }) : null, t ? linkish("delete", deleteThread, { title: "Delete this chat" }) : null, ])); } +/** what a chat has cost on your key: its replies, photos, clips and voice notes */ +function chatCost(t) { + let usd = 0; + for (const m of (t && t.msgs) || []) usd += (m.cost || 0) + ((m.media && m.media.price) || 0) + ((m.voice && m.voice.price) || 0); + return usd; +} /** a narrow screen goes back from the chat to the list it came from */ function backToList() { setLeft(S.left); @@ -1166,17 +1173,18 @@ async function send(text, { hidden = false, media = null, picture = "", to = nul // a model that thinks before it answers can spend all its room thinking, and send back nothing: once more, with room if (!reply && !ctrl.signal.aborted) { r = await ask(room * 3); cost += A.callCost(r.usage, model); reply = C.unloop(C.stripThinking(r.text)); } if (own) { settle(held, cost); held = 0; } + ctrl.cost = cost; if (!reply) throw new Error(`${modelName(c.model || S.model)} sent back nothing, twice. Try again, or pick another model (model:, at the top of the chat).`); } if (ctrl.signal.aborted || !S.threads[t.id]) return; - const msg = { id: mid(), role: "assistant", content: reply, at: Date.now(), ...(demo ? { demo: true } : {}) }; + const msg = { id: mid(), role: "assistant", content: reply, at: Date.now(), ...(demo ? { demo: true } : {}), ...(ctrl.cost ? { cost: ctrl.cost } : {}) }; hist.push(msg); const drawn = S.gen; // drawn again from here on, the chat has it all already t.at = msg.at; if (here(t)) markRead(t); saveThread(t); done(t); - if (!demo) { balanceSoon(); remember(t); } + if (!demo) { balanceSoon(); remember(t); if (here(t)) renderChatMeta(); } // a voice note with the reply: every reply when they're set to, and, on their own, one back to yours (priced // first, and set aside from today's allowance) let mirror = 0; @@ -1639,13 +1647,20 @@ function renderTools() { } const items = []; if (!c.plain) { - items.push(linkish("ask for a photo", () => askFor("photo")), linkish("ask for a clip", () => askFor("clip"))); + const pm = adultFor(c, thread()).ok ? adultPhotoModel() : photoModel(); + const pp = pm ? A.photoPrice(pm, Math.min(Math.max(1, refsOf(c).length), Math.max(1, pm.maxIn || 1))) : null; + items.push(linkish(`ask for a photo${pp != null ? " · " + A.usd(pp) : ""}`, () => askFor("photo")), linkish("ask for a clip", () => askFor("clip"))); const on = !!S.voiceOn[c.id]; items.push(linkish(`voice notes: ${on ? "on" : "off"}`, () => setVoiceNotes(c, !on), { "aria-pressed": on ? "true" : "false", title: on ? `${c.name} replies with voice notes` : `${c.name} replies in text; "hear it" reads one out` })); const left = Math.max(0, S.ab.allowance - spentToday()); items.push(linkish(`on their own: ${S.ab.own ? "on" : "off"}`, () => setOwn(!S.ab.own), { "aria-pressed": S.ab.own ? "true" : "false", title: S.ab.own ? `Characters text first and send photos and voice notes when they like: ${A.usd(left)} of today's ${A.usd(S.ab.allowance)} left` : "Characters only do what you ask" })); + if (S.ab.own) { + const i = Math.max(0, TEMPOS.findIndex(([v]) => v === (S.ab.tempo || "sometimes"))); + items.push(linkish(`how often: ${TEMPOS[i][1]}`, () => setTempo(TEMPOS[(i + 1) % TEMPOS.length][0]), + { title: "How often they send photos and voice notes without being asked. Click to change." })); + } } if (S.ab.hear) items.push(linkish("record a voice note", startRecording)); if (S.ab.see) items.push(linkish("send a photo", () => $("#photo-in").click())); @@ -1678,7 +1693,7 @@ function renderAbilities() { allowance.addEventListener("change", () => { S.ab.allowance = Number(allowance.value); saveAb(); renderAbilities(); renderTools(); }); tb.append( row("ON THEIR OWN", tick("characters text first when you come back, and send photos and voice notes when they like", S.ab.own, (on) => setOwn(on)), - ["up to ", allowance, ` a day · ${A.usd(spentToday())} spent today · clips are offered with venice's price, and made only if you say yes`]), + ["up to ", allowance, " a day · how often: ", tempoSelect(), ` · ${A.usd(spentToday())} spent today · clips are offered with venice's price, and made only if you say yes`]), row("MEMORY", tick("characters remember your chats with them", S.ab.memory, (on) => { S.ab.memory = on; saveAb(); @@ -1769,7 +1784,16 @@ function renderAsk() { else if (a.kind === "clip") { a.opts = null; requote(); } else renderAsk(); }); box.append(h("label", {}, h("span", {}, "THE SHOT ", h("span", { class: "hint", text: "written from your conversation; change anything" })), ta)); - box.append(h("p", { class: "mono" }, linkish("write it again", writeShot, { disabled: a.busy || null }), a.hint ? ` (you asked for: ${a.hint})` : "")); + // written again: to what they say ("make it funnier"), or, with nothing said, a different one + const chg = h("input", { type: "text", id: "ask-change", maxlength: 300, placeholder: "change it: make it funnier, at night…", + "aria-label": "How to change the shot", disabled: a.busy || null }); + chg.value = a.change || ""; + const relabel = () => { const b = $("#ask-rewrite"); if (b) b.textContent = chg.value.trim() ? "REWRITE" : "WRITE ANOTHER"; }; + chg.addEventListener("input", () => { a.change = chg.value; relabel(); }); + chg.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); writeShot(); } }); + box.append(h("div", { class: "rewrite" }, chg, h("button", { type: "button", class: "btn small", id: "ask-rewrite", onclick: writeShot, + disabled: a.busy || null, text: (a.change || "").trim() ? "REWRITE" : "WRITE ANOTHER" }))); + if (a.hint) box.append(h("p", { class: "mono", text: `you asked for: ${a.hint}` })); if (!clipping) { const pm = askPhotoModel(a), n = pm ? Math.min(refs.length, pm.maxIn) : refs.length, price = A.photoPrice(pm, n), alt = askAlt(a, c); box.append(h("p", { class: "mono" }, `from ${n} picture${n === 1 ? "" : "s"} of ${c.name} · model: `, @@ -1879,7 +1903,7 @@ async function writeShot() { renderAsk(); try { const t = askChat(a), adult = adultFor(c, t, a.hint).ok; - const r = await new Venice(S.key).chat({ model: c.model || S.model, messages: A.shotMessages(c, A.apiHistory(t ? t.msgs : []), a.hint, a.kind, { adult }), + const r = await new Venice(S.key).chat({ model: c.model || S.model, messages: A.shotMessages(c, A.apiHistory(t ? t.msgs : []), a.hint, a.kind, { adult, current: a.shot, change: a.change }), maxTokens: 260, temperature: 0.8 }); a.shot = A.cleanText(r.text); Object.assign(a, a.shot ? { status: "", bad: false } : { status: "The model sent back no shot. Write one yourself, or try again.", bad: true }); @@ -2056,6 +2080,22 @@ function adultChanged() { } // ---------- pictures for a character's card, made with AI ---------- +/** a picture in the chat, opened big over the page; a click or Escape closes it */ +function lightbox(src, alt) { + let d = $("#lightbox"); + if (!d) { + d = h("dialog", { id: "lightbox", class: "lightbox", "aria-label": "The picture, big" }); + d.addEventListener("click", () => d.close()); + document.body.append(d); + } + d.textContent = ""; + d.append(h("img", { src, alt: alt || "" })); + d.showModal(); +} +document.addEventListener("click", (e) => { + const img = e.target && e.target.closest && e.target.closest(".log .pic img"); + if (img && img.src) { e.preventDefault(); lightbox(img.src, img.alt); } +}); const BLURRED = "Venice's safe mode blurred that picture, so it wasn't kept. Change how they look on their card (look), then try again."; /** a picture for their card that safe mode blurred: for a character marked adult, why safe mode was on */ const blurredWhy = (ad) => (ad && ad.why ? `Venice's safe mode blurred that picture, so it wasn't kept. It was on because ${ad.why}` : BLURRED); @@ -2221,7 +2261,7 @@ async function startClip(c, cm, shot, opts, price, where, { adult = false } = {} */ async function onTheirOwn(t) { const c = cardOf(t); - if (!c || c.plain || !S.key || !S.ab.own || S.moving.has(t.id) || !A.moveLikely(t.msgs)) return; + if (!c || c.plain || !S.key || !S.ab.own || S.moving.has(t.id) || !A.moveLikely(t.msgs, { every: tempoEvery() })) return; // the call that decides is paid for too: set aside its most, from what's left today, and settle after const model = (S.models || []).find((m) => m.id === (c.model || S.model)) || null; const messages = A.moveMessages(c, A.apiHistory(t.msgs), { adult: adultFor(c, t).ok }), most = A.callEstimate(messages, 300, model); @@ -2234,7 +2274,7 @@ async function onTheirOwn(t) { held = 0; if (!S.threads[t.id] || !cardOf(t)) return; // the chat, or they, went meanwhile const { move, shot } = A.normalizeMove(C.parseJsonLoose(r.text)); - const lately = A.sentLately(t.msgs); // never two photos in a row + const lately = A.sentLately(t.msgs, tempoEvery() === 1 ? 1 : 4); // never two photos in a row if (move === "photo" && !lately) await ownPhoto(t, shot); else if (move === "voice") await ownVoice(t); else if (move === "clip" && !lately) await offerClip(t, shot); @@ -2683,6 +2723,23 @@ function renderSpeech() { sel.value = cur || sel.options[0].value; $("#speech-on").checked = !!S.voiceOn[c.id]; } +// how often a character sends a photo, voice note or clip without being asked: one chance in every so many replies +const TEMPOS = [["never", "never"], ["rarely", "rarely"], ["sometimes", "sometimes"], ["often", "often"], ["every", "every round"]]; +const TEMPO = { never: 0, rarely: 12, sometimes: 6, often: 3, every: 1 }; +const tempoEvery = () => TEMPO[S.ab.tempo] ?? 6; +function setTempo(v) { + S.ab.tempo = v; + saveAb(); + renderTools(); + if ($("#abilitydlg").open) renderAbilities(); + toast(`Photos and voice notes on their own: ${TEMPOS.find(([k]) => k === v)[1]}.`); +} +function tempoSelect() { + const s = h("select", { "aria-label": "How often they send things on their own" }, TEMPOS.map(([v, t]) => h("option", { value: v, text: t }))); + s.value = S.ab.tempo || "sometimes"; + s.addEventListener("change", () => setTempo(s.value)); + return s; +} function setOwn(on) { S.ab.own = !!on; saveAb(); diff --git a/ventures/havre/index.html b/ventures/havre/index.html index 2e8898c..ab9cb84 100644 --- a/ventures/havre/index.html +++ b/ventures/havre/index.html @@ -6,6 +6,7 @@ Havre + @@ -29,7 +30,7 @@

HAVRE

a harbor for characters.
talk to venice characters on your own key.
no account. no server. nothing kept but what you keep.

- +
diff --git a/ventures/havre/shots/desktop.png b/ventures/havre/shots/desktop.png index 364c01e..879e0dc 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 8a91719..c3b224a 100644 --- a/ventures/havre/style.css +++ b/ventures/havre/style.css @@ -10,8 +10,22 @@ --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; + --dim: #767676; + --well: #f2f2f2; + --veil: rgba(255, 255, 255, 0.85); color-scheme: light; } +/* dark: the same brutalism, inverted. Chosen in the nav (theme.js), or the system's when nothing's chosen */ +:root[data-theme="dark"] { + --ink: #f2f2f2; --paper: #0d0d0d; --link: #8cb4ff; --visited: #c9a2ff; --hi: #4d4a00; --bad: #ff7a7a; + --dim: #8a8a8a; --well: #1c1c1c; --veil: rgba(0, 0, 0, 0.8); color-scheme: dark; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --ink: #f2f2f2; --paper: #0d0d0d; --link: #8cb4ff; --visited: #c9a2ff; --hi: #4d4a00; --bad: #ff7a7a; + --dim: #8a8a8a; --well: #1c1c1c; --veil: rgba(0, 0, 0, 0.8); color-scheme: dark; + } +} * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } body { @@ -52,14 +66,14 @@ button, input, select, textarea { font: inherit; color: inherit; } .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); } +.btn:disabled, .btn:disabled:hover { cursor: default; background: var(--paper); color: var(--dim); border-color: var(--dim); } +.btn.solid:disabled { background: var(--dim); color: var(--paper); } .linkish { padding: 0; border: 0; background: none; color: var(--link); text-decoration: underline; font: inherit; text-align: left; cursor: pointer; } .linkish:hover, .linkish:focus-visible { color: var(--ink); background: var(--hi); } -.linkish:disabled { color: #767676; text-decoration: none; cursor: default; background: none; } +.linkish:disabled { color: var(--dim); 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; @@ -67,7 +81,7 @@ input:not([type=checkbox]), select, textarea { 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; } +input:disabled, textarea:disabled, select:disabled { background: var(--well); color: var(--dim); } .check { display: flex; align-items: center; gap: 8px; } .status { font-size: 14px; } .status.bad, .keystatus.bad { color: var(--bad); } @@ -157,7 +171,7 @@ input:disabled, textarea:disabled, select:disabled { background: #f2f2f2; color: } .lefthead [role=tab][aria-selected="true"] { color: var(--ink); } .lefthead [role=tab]:hover, .lefthead [role=tab]:focus-visible { color: var(--ink); background: var(--hi); } -.lefthead .sep { font: 700 34px/1 var(--serif); color: #767676; } +.lefthead .sep { font: 700 34px/1 var(--serif); color: var(--dim); } .lefthead .new { display: inline-block; margin-left: 8px; padding: 2px 6px; vertical-align: 6px; background: var(--ink); color: var(--paper); font: 700 14px/1.2 var(--mono); letter-spacing: 0; @@ -296,11 +310,16 @@ pre.saw { .line.media .said { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; white-space: normal; } .line.media .words { white-space: pre-wrap; } .pic { margin: 0; border: 1px solid var(--ink); width: min(100%, 340px); } -.pic img { width: 100%; height: auto; max-height: 440px; object-fit: contain; background: #f2f2f2; } +.pic img { width: 100%; height: auto; max-height: 440px; object-fit: contain; background: var(--well); cursor: zoom-in; } +.lightbox { padding: 0; border: 1px solid var(--ink); background: var(--paper); max-width: 96vw; max-height: 96vh; } +.lightbox::backdrop { background: rgba(0, 0, 0, 0.88); } +.lightbox img { max-width: 96vw; max-height: 94vh; object-fit: contain; cursor: zoom-out; } +.rewrite { display: flex; gap: 8px; margin: 6px 0; } +.rewrite input { flex: 1 1 auto; min-width: 0; font: 14px var(--mono); padding: 6px 8px; border: 1px solid var(--ink); background: var(--paper); color: var(--ink); } .line video { display: block; max-width: min(100%, 360px); max-height: 460px; border: 1px solid var(--ink); background: var(--ink); } .line audio, .status audio { display: block; width: min(100%, 320px); height: 36px; } .cap { font: 12px/1.5 var(--mono); } -.gone { font: 13px var(--mono); color: #555; } +.gone { font: 13px var(--mono); color: var(--dim); } .hear { margin-left: 4px; font: 12px var(--mono); white-space: nowrap; } /* a clip they'd like to send, with Venice's price */ .line.offer .said { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; padding: 8px 10px; border: 1px dashed var(--ink); } @@ -430,7 +449,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); 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::backdrop { background: var(--veil); } .dlg.wide { width: min(1040px, calc(100vw - 32px)); } .mtable { max-height: min(58vh, 560px); overflow: auto; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); } .models { font-size: 16px; } @@ -504,7 +523,7 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi); /* phones and other touch screens: a field under 16px makes iOS Safari zoom in when it's tapped, and the page stays zoomed in, cut off at the sides */ @media (max-width: 640px), (pointer: coarse) { - .keybar input:not([type=checkbox]), .filters input[type=search], .pick select, .ask .opts select, .abilities select, + .keybar input:not([type=checkbox]), .filters input[type=search], .pick select, .ask .opts select, .abilities select, .rewrite input, .chip.add input { font-size: 16px; } } /* a small phone (an iPhone SE is 375px wide): the left column's tabs a little smaller, so a count of what's new fits */ diff --git a/ventures/havre/tests/shot.test.mjs b/ventures/havre/tests/shot.test.mjs new file mode 100644 index 0000000..a65c388 --- /dev/null +++ b/ventures/havre/tests/shot.test.mjs @@ -0,0 +1,23 @@ +// node --test tests/*.test.mjs: a shot written again (to an instruction, or different), and how often they move +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as A from "../abilities.js"; + +const card = { name: "Odile", tagline: "Keeps the light.", look: { description: "white hair" } }; +const said = (msgs) => msgs.at(-1).content; + +test("a shot written again keeps the instruction, or asks for a different one", () => { + assert.doesNotMatch(said(A.shotMessages(card, [], "")), /as it stands|last shot/); + const changed = said(A.shotMessages(card, [], "", "photo", { current: "Odile on the wall at dawn.", change: "make it funnier" })); + assert.match(changed, /The shot as it stands:\nOdile on the wall at dawn\./); + assert.match(changed, /Change it: make it funnier\./); + const other = said(A.shotMessages(card, [], "", "photo", { current: "Odile on the wall at dawn." })); + assert.match(other, /clearly different/); +}); + +test("tempo: never moves unasked, every round always may", () => { + const h = [{ role: "user", content: "hi" }, { role: "assistant", content: "Hello there." }]; + assert.equal(A.moveLikely(h, { every: 0 }), false); + assert.equal(A.moveLikely([{ role: "user", content: "send me a photo" }, { role: "assistant", content: "ok" }], { every: 0 }), true); + assert.equal(A.moveLikely(h, { every: 1 }), true); +}); diff --git a/ventures/havre/theme.js b/ventures/havre/theme.js new file mode 100644 index 0000000..b87bcc2 --- /dev/null +++ b/ventures/havre/theme.js @@ -0,0 +1,19 @@ +// theme.js: light or dark, set before the page draws (so it doesn't flash), and the switch in the nav. +// "havre.theme" is "light", "dark", or absent (follow the system). /open reads the same key. +(function () { + var root = document.documentElement, K = "havre.theme"; + function get() { try { return localStorage.getItem(K) || ""; } catch (e) { return ""; } } + function dark() { var t = get(); return t ? t === "dark" : !!(window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches); } + function apply() { + var t = get(); + if (t) root.setAttribute("data-theme", t); else root.removeAttribute("data-theme"); + var b = document.getElementById("theme"); + if (b) { b.textContent = dark() ? "light mode" : "dark mode"; b.setAttribute("aria-pressed", dark() ? "true" : "false"); } + } + apply(); + document.addEventListener("DOMContentLoaded", function () { + apply(); + var b = document.getElementById("theme"); + if (b) b.addEventListener("click", function () { try { localStorage.setItem(K, dark() ? "light" : "dark"); } catch (e) { /* this time only */ } apply(); }); + }); +})();