From a500d4e572cabc24d76967e819ca0694a807025b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 15:46:51 -0400 Subject: [PATCH] amail: a notice for the signed-out, a tongue for the pane, and a lighter hand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed out, amail used to say "log in for mail" and nothing else. Now it says what it is — letters between @handles, a post that never leaves the computer — and offers the two doors, sign up first. The address that came in the URL waits for the box to load, and lands after the focus has moved off the `to` row (TextFields.focus syncs the old row first, which was wiping it). Both rooms' panes gain a sprog row, da / en, saved under one key like the tema. laklok keeps Danish as its mother tongue, amail speaks English until told otherwise; every word on amail's chrome comes from the shared table. The pane wraps a row that is wider than a phone instead of running off it. Amail's header is one line now: the tabs are words with the open one underlined in its hue, and mark-read / write are chips at the right end. The Button objects are gone; controls register hit boxes as they paint, the way the pane's chips already did. Co-Authored-By: Claude Fable 5.1 --- .../public/aesthetic.computer/disks/amail.mjs | 421 +++++++++--------- .../public/aesthetic.computer/disks/chat.mjs | 2 +- .../disks/common/laklok-tema.mjs | 227 ++++++++-- .../aesthetic.computer/disks/laklok.mjs | 43 +- toolchain/laklok-sisters/parity.mjs | 6 +- 5 files changed, 436 insertions(+), 263 deletions(-) diff --git a/system/public/aesthetic.computer/disks/amail.mjs b/system/public/aesthetic.computer/disks/amail.mjs index 03cc80d9a2..df5dcaf07f 100644 --- a/system/public/aesthetic.computer/disks/amail.mjs +++ b/system/public/aesthetic.computer/disks/amail.mjs @@ -3,9 +3,11 @@ // prompt; `amail` lands here; `amail~@handle` lands in compose, addressed. // Tier 1: nothing leaves aesthetic.computer. See `system/backend/mail.mjs`. // -// Amail wears the same tema as `laklok` — one saved choice, one census — so -// the two rooms read as one house. Its QR sits on pale blue paper where -// laklok's sits on white, so the corners tell the rooms apart. +// Amail wears the same tema and speaks the same language as `laklok` — one +// saved choice each, one census — so the two rooms read as one house. Its QR +// sits on pale blue paper where laklok's sits on white, so the corners tell +// the rooms apart. Signed out, the room is a short notice of what amail is, +// with the door to signing up. import { LAK_THEMES, @@ -14,6 +16,10 @@ import { restoreTema, saveTema, reportTema, + strings, + pickLang, + restoreLang, + saveLang, makeQR, paintQR, paintGear, @@ -22,8 +28,13 @@ import { ENVELOPE, paintBadge, badgeWidth, + paintChip, + chipWidth, + CHIP_H, + CHIP_FONT, paintTemaPane, temaRow, + langRow, } from "./common/laklok-tema.mjs"; let view = "inbox"; // inbox · sent · prefs · compose @@ -31,17 +42,20 @@ let status = "loading"; // loading, loaded, error, noauth let mail = null; let prefs = null; // blast subscription + history, fetched when prefs opens let errorMsg = null; -let inboxBtn, sentBtn, prefsBtn, readBtn, writeBtn, subBtn, unsubBtn; let rows = []; // [{ y0, y1, who }] — paint measures them, act replies to them let ellipsisTicker; let busy = false; let fields; // to · subject · body, sharing one keyboard (see lib/type.mjs) -let sendBtn; let composeNote = null; // what went wrong with the last send, if anything let pendingTo = null; // an address that arrived in the URL, waiting for the box -// 👗 The tema, shared with laklok via the same store key. +// 🎛️ The compact controls — tabs and chips — register hit boxes each paint, +// the way the pane's chips do, instead of carrying Button objects around. +let hits = []; // [{ x, y, w, h, action }] + +// 👗 The tema and 🗣️ language, shared with laklok via the same store keys. let tema = "ler"; +let lang = "en"; // amail is for every handle, so English until told otherwise let settingsOpen = false; let gearBox = null; // {x, y, w, h} hit area for the ⚙ toggle let settingsHits = []; // [{x, y, w, h, action}] chips, rebuilt each paint @@ -55,9 +69,18 @@ function meta() { }; } -// The current palette, read fresh each frame so the `realtime` tema's minute -// drift shows up without anyone asking. +// The current palette and words, read fresh each frame so the `realtime` +// tema's minute drift and a language switch show up without anyone asking. const T = () => LAK_THEMES[tema]; +const S = () => strings(lang); + +// Each tab keeps its own hue so you can tell them apart at a glance. +const TABS = { + inbox: [120, 190, 255], + sent: [255, 190, 90], + prefs: [200, 150, 255], +}; +const WRITE = [110, 230, 165]; // the write / send accent // The compose fields dress in the tema too. function fieldScheme() { @@ -76,17 +99,40 @@ function wearTema(name) { if (fields?.input) fields.input.scheme = fieldScheme(); } +// The field labels are words, so a language switch rebuilds the fields. +function makeFields(api) { + const s = S(); + fields = new api.ui.TextFields( + api, + [ + { name: "to", label: s.to, placeholder: "@handle or ac25namuc" }, + { name: "subject", label: s.re, placeholder: s.optional }, + { name: "body", label: s.say, lines: 4, placeholder: "…" }, + ], + (letter) => send(api, letter), + { scheme: fieldScheme() }, + ); +} + +function speak(api, name) { + lang = name; + if (view === "compose") leaveCompose(api, "inbox"); + makeFields(api); +} + // 🥾 Boot async function boot(api) { - const { user, gizmo, hud, net, ui, screen, store, colon, params } = api; + const { user, gizmo, hud, net, store, colon, params } = api; hud.label("amail"); ellipsisTicker = new gizmo.EllipsisTicker(); settingsOpen = false; view = "inbox"; composeNote = null; + hits = []; - // 👗 A colon/`~` token pins a tema (`amail~skov`); otherwise the saved one. + // 👗 A colon/`~` token pins a tema or language (`amail~skov`, `amail~da`); + // otherwise the saved ones. const tokens = [...(colon || []), ...(params || [])]; const picked = pickTema(tokens, store); wearTema(picked.name); @@ -94,30 +140,14 @@ async function boot(api) { if (saved) wearTema(saved); if (user) reportTema(net, tema); // after the saved tema has had its say }); + const spoken = pickLang(tokens, store, "en"); + lang = spoken.name; + restoreLang(store, spoken.pinned, (saved) => { + if (saved) speak(api, saved); + }); qrCells = makeQR("https://aesthetic.computer/amail"); - - inboxBtn = new ui.TextButton("inbox", { screen }); - sentBtn = new ui.TextButton("sent", { screen }); - prefsBtn = new ui.TextButton("prefs", { screen }); - readBtn = new ui.TextButton("mark read", { screen }); - writeBtn = new ui.TextButton("write", { screen }); - sendBtn = new ui.TextButton("send", { screen }); - subBtn = new ui.TextButton("subscribe", { screen }); - unsubBtn = new ui.TextButton("unsubscribe", { screen }); - - // The compose field takes exactly what the prompt command takes, so there's - // one grammar to learn: `@handle your message`. - fields = new ui.TextFields( - api, - [ - { name: "to", label: "to", placeholder: "@handle or ac25namuc" }, - { name: "subject", label: "re", placeholder: "(optional)" }, - { name: "body", label: "say", lines: 4, placeholder: "…" }, - ], - (letter) => send(api, letter), - { scheme: fieldScheme() }, - ); + makeFields(api); // `amail~@handle` — arrived with an address (a tapped handle in laklok); // open straight onto a letter to them once the box has loaded. @@ -150,13 +180,14 @@ async function refresh({ net }) { // Post the letter, then show it in `sent`. async function send(api, { to, subject, body }) { + const s = S(); const text = (body || "").trim(); if (!to?.trim()) { - composeNote = "who is it to?"; + composeNote = s.whoTo; return; } if (!text) { - composeNote = "nothing to say yet"; + composeNote = s.nothingToSay; return; } @@ -172,9 +203,9 @@ async function send(api, { to, subject, body }) { leaveCompose(api, "sent"); await refresh(api); } else if (res.status === 404) { - composeNote = `no one answers to ${to.trim()}`; + composeNote = `${s.noOne} ${to.trim()}`; } else { - composeNote = "couldn't send that"; + composeNote = s.couldntSend; } } @@ -192,8 +223,10 @@ function compose(api, to) { view = "compose"; composeNote = null; fields.input.mute = false; - if (to) fields.values.to = to; + // `focus` syncs the live buffer back into the field it is leaving first, so + // the address has to land after the focus has moved off the `to` row. fields.focus(to ? 1 : 0, api); + if (to) fields.values.to = to; } // 🧮 Sim @@ -211,24 +244,6 @@ function sim(api) { if (tema === "realtime" && realtimeTick()) wearTema(tema); } -const DIM = [[40, 40, 46], [80, 80, 90], [120, 120, 130]]; - -// Each tab keeps its own hue so you can tell them apart at a glance, and the -// open one is the only one that fills. TextButton takes colors, not ink. -const TABS = { - inbox: [60, 130, 210], - sent: [210, 140, 50], - prefs: [150, 100, 200], -}; - -function tab(name) { - const [r, g, b] = TABS[name]; - const ground = T().stripeA; - return view === name - ? [[r >> 1, g >> 1, b >> 1], [r, g, b], [255, 255, 255]] - : [ground, [r >> 2, g >> 2, b >> 2], [r * 0.55, g * 0.55, b * 0.55]]; -} - const PERMA = /^ac\d\d[a-z]{5}@/; // How long ago, short enough to sit next to a handle. @@ -269,31 +284,68 @@ function paintCorner(api) { if (label) paintBadge(api, ex + ENVELOPE.w - 3, ey - 5, label); } -// ⚙️ The indstillinger pane — just the tema row here; mode and filter are -// the chat's business. +// ⚙️ The indstillinger pane — tema and sprog; mode and filter are the chat's +// business. function paintSettings(api) { settingsHits = []; if (!settingsOpen) return; const { screen } = api; + const s = S(); const qrBoxSize = (qrCells?.length || 0) + 2; settingsHits = paintTemaPane(api, { theme: T(), - rows: [temaRow(tema)], + rows: [temaRow(tema, s), langRow(lang, s)], right: screen.width - 3, top: 3 + qrBoxSize + 4, + title: s.settings, }); } +// A chip that does something — painted, then remembered for act(). +function control(api, x, y, text, action, opts) { + const box = paintChip(api, x, y, text, opts); + hits.push({ ...box, action }); + return box; +} + +// 📣 Signed out, the room explains itself and offers the door. Everything the +// letters would use — tema, corner, pane — is already here, so a visitor sees +// the room they'd get. +function paintNotice(api, x, wide) { + const { ink, screen } = api; + const s = S(); + const c = T().chat; + let y = 30; + + ink(c.handle).write(s.adTitle, { x, y }); + y += 14; + // Measure the wrapped body first so the lines below sit under it. + const bodyH = api.text.box(s.adBody, { x, y }, wide).box.height; + ink(c.messageText).write(s.adBody, { x, y }, undefined, wide, true); + y += bodyH + 8; + ink(c.timestamp).write(s.adPrompt, { x, y }, undefined, wide, true, CHIP_FONT); + y += 16; + + const signup = control(api, x, y, s.signup, { type: "signup" }, { selected: true }); + control(api, signup.x + signup.w + 6, y, s.login, { type: "login" }); + void screen; +} + // 🎨 Paint function paint(api) { const { wipe, ink, screen, text, help } = api; const t = T(); const c = t.chat; + const s = S(); + hits = []; wipe(...t.bg); + const x = 6; + const wide = screen.width - x * 2; + if (status === "loading") { ink(c.timestamp).write( - "loading" + ellipsisTicker.text(help.repeat, { pad: false }), + s.loading + ellipsisTicker.text(help.repeat, { pad: false }), { center: "xy", size: 1 }, ); paintCorner(api); @@ -302,69 +354,59 @@ function paint(api) { } if (status === "noauth") { - ink(c.messageText).write("log in for amail", { center: "xy" }); + paintNotice(api, x, wide); paintCorner(api); paintSettings(api); return; } if (status === "error") { - ink(200, 100, 100).write("error: " + (errorMsg || "unknown"), { - center: "xy", - }); + ink(200, 100, 100).write(`${s.error}: ${errorMsg || "?"}`, { center: "xy" }); paintCorner(api); paintSettings(api); return; } - const x = 6; - const wide = screen.width - x * 2; let y = 6; // the hud label is already the title — don't write a second one y += 16; // the corner cluster owns the first row - // The two spellings of this mailbox. The permahandle never moves and reads - // like a serial number, so it wears MatrixChunky8; the @handle is the human - // alias and stays in the normal face. + // The two spellings of this mailbox on one tight stack. The permahandle + // never moves and reads like a serial number, so it wears MatrixChunky8; + // the @handle is the human alias and stays in the normal face. for (const address of mail.addresses) { if (PERMA.test(address)) { - ink(c.log).write( - address, - { x, y: y + 1 }, - undefined, - undefined, - false, - "MatrixChunky8", - ); + ink(c.log).write(address, { x, y: y + 1 }, undefined, undefined, false, CHIP_FONT); y += 9; } else { ink(c.handle).write(address, { x, y }); y += 11; } } - y += 5; - - inboxBtn.reposition({ x, y, screen }); - inboxBtn.paint(api, tab("inbox")); - sentBtn.reposition({ x: x + 48, y, screen }); - sentBtn.paint(api, tab("sent")); - prefsBtn.reposition({ x: x + 88, y, screen }); - prefsBtn.paint(api, tab("prefs")); - y += 18; - - writeBtn.reposition({ x, y, screen }); - writeBtn.paint(api, [[28, 54, 42], [110, 210, 155], [205, 255, 225]]); - ink(c.timestamp).write( - "or from the prompt: amail @handle your message", - { x: x + 44, y: y + 6 }, - undefined, - undefined, - false, - "MatrixChunky8", - ); - y += 18; + y += 4; + + // 🗂️ Tabs — words on a line, the open one underlined in its hue. Actions + // sit at the right end of the same line as chips, so the header is one row. + let tx = x; + for (const name of ["inbox", "sent", "prefs"]) { + const label = s[name]; + const w = label.length * 6; + const open = view === name || (view === "compose" && name === "inbox"); + ink(open ? c.messageText : c.timestamp).write(label, { x: tx, y }); + if (open) ink(...TABS[name]).box(tx, y + 10, w, 1); + hits.push({ x: tx - 2, y: y - 3, w: w + 6, h: 15, action: { type: "view", value: name } }); + tx += w + 12; + } + // Right end: [mark read] [write] — write lights up while composing. + let cx = screen.width - x - chipWidth(s.write); + control(api, cx, y - 1, s.write, { type: "write" }, { selected: view === "compose", tint: WRITE }); + if (view === "inbox" && mail.unread > 0) { + cx -= chipWidth(s.markRead) + 6; + control(api, cx, y - 1, s.markRead, { type: "read" }, { tint: c.log, dim: busy }); + } + y += 15; ink(c.lines).box(x, y, wide, 1); - y += 8; + y += 6; // Compose sits in the room instead of replacing it — the addresses and tabs // stay put and the field takes the space the letters were using. @@ -373,20 +415,19 @@ function paint(api) { x, y, width: wide, - height: Math.min(110, Math.max(64, screen.height - y - 34)), + height: Math.min(110, Math.max(64, screen.height - y - 30)), }; fields.paint(api, frame); const footer = frame.y + frame.height + 4; - sendBtn.reposition({ x, y: footer, screen }); - sendBtn.paint(api, [[28, 54, 42], [110, 210, 155], [205, 255, 225]]); + const sendBox = control(api, x, footer, s.send, { type: "send" }, { selected: true }); ink(composeNote ? [255, 130, 130] : c.timestamp).write( - composeNote || "enter moves down · tap a row to jump", - { x: x + 40, y: footer + 6 }, + composeNote || s.composeHint, + { x: sendBox.x + sendBox.w + 6, y: footer + 2 }, undefined, - wide - 40, + wide - sendBox.w - 6, false, - "MatrixChunky8", + CHIP_FONT, ); paintCorner(api); paintSettings(api); @@ -403,24 +444,15 @@ function paint(api) { const letters = view === "inbox" ? mail.inbox : mail.sent; if (letters.length === 0) { - ink(c.timestamp).write( - view === "inbox" ? "no amail yet" : "nothing sent yet", - { x, y }, - ); + ink(c.timestamp).write(view === "inbox" ? s.noMail : s.nothingSent, { x, y }); if (view === "inbox") { - ink([...c.timestamp, 160]).write("try: amail @jeffrey hello", { x, y: y + 12 }); + ink([...c.timestamp, 160]).write(s.tryHint, { x, y: y + 12 }); } paintCorner(api); paintSettings(api); return; } - if (view === "inbox" && mail.unread > 0) { - readBtn.reposition({ x, y, screen }); - readBtn.paint(api, busy ? DIM : [[30, 60, 60], [100, 200, 200], [190, 255, 255]]); - y += 18; - } - const bounds = wide - 10; rows = []; @@ -442,10 +474,7 @@ function paint(api) { rows.push({ y0: y - 3, y1: y + body + 14, who }); if (unread) ink(c.log).box(x + 2, y + 2, 3, 3); - ink(unread ? c.handle : c.timestamp).write(who, { - x: x + 8, - y, - }); + ink(unread ? c.handle : c.timestamp).write(who, { x: x + 8, y }); if (letter.subject) { ink(unread ? c.painting : [...c.painting, 150]).write( letter.subject, @@ -468,9 +497,10 @@ function paint(api) { function paintPrefs(api, x, y, wide) { const { ink, screen } = api; const c = T().chat; + const s = S(); if (!prefs) { - ink(c.timestamp).write("loading prefs…", { x, y }); + ink(c.timestamp).write(s.loadingPrefs, { x, y }); return; } @@ -479,27 +509,25 @@ function paintPrefs(api, x, y, wide) { y += 12; if (prefs.subscribed) { - ink(120, 200, 120).write("subscribed", { x, y }); - unsubBtn.reposition({ x: x + 80, y: y - 2, screen }); - unsubBtn.paint(api, [[60, 26, 26], [200, 110, 110], [255, 190, 190]]); + ink(120, 200, 120).write(s.subscribed, { x, y }); + control(api, x + s.subscribed.length * 6 + 8, y - 1, s.unsubscribe, { type: "unsubscribe" }, { tint: [230, 120, 120], dim: busy }); } else { - ink(200, 120, 80).write("unsubscribed", { x, y }); - subBtn.reposition({ x: x + 96, y: y - 2, screen }); - subBtn.paint(api, [[26, 60, 30], [110, 200, 120], [190, 255, 200]]); + ink(200, 120, 80).write(s.unsubscribed, { x, y }); + control(api, x + s.unsubscribed.length * 6 + 8, y - 1, s.subscribe, { type: "subscribe" }, { tint: [120, 220, 130], dim: busy }); } y += 16; } if (busy) { - ink(c.timestamp).write("updating…", { x, y }); + ink(c.timestamp).write(s.updating, { x, y }); y += 14; } - ink(c.kidlisp).write("blast history", { x, y }); + ink(c.kidlisp).write(s.blastHistory, { x, y }); y += 14; if (!prefs.blasts || prefs.blasts.length === 0) { - ink(c.timestamp).write("no blasts sent yet", { x, y }); + ink(c.timestamp).write(s.noBlasts, { x, y }); return; } @@ -550,6 +578,9 @@ function act(api) { saveTema(store, value); if (value === "realtime") realtimeTick(); // catch up before first paint if (status === "loaded") reportTema(net, value); + } else if (type === "lang") { + speak(api, value); + saveLang(store, value); } } else if (!hit(settingsHits.pane)) { settingsOpen = false; // Tap outside closes. @@ -559,41 +590,57 @@ function act(api) { return; } + // The compact controls, wherever they were painted this frame. + const control = e.is("touch") ? hits.find((h) => hit(h)) : null; + if (control) { + const { type, value } = control.action; + if (type === "signup") net.signup(); + else if (type === "login") net.login(); + else if (type === "view") { + if (view === "compose") leaveCompose(api, value); + else view = value; + if (value === "prefs" && !prefs) { + net.userRequest("GET", "/api/mail-status").then((res) => { + if (res.status === 200) prefs = res; + needsPaint(); + }); + } + } else if (type === "write") { + if (view === "compose") leaveCompose(api, "inbox"); + else compose(api); + } else if (type === "send") { + fields.sync(); + send(api, { ...fields.values }); + } else if (type === "read" && !busy) { + busy = true; + net.userRequest("POST", "/api/mail", { action: "read" }).then((res) => { + if (res.status === 200) { + mail.unread = 0; + mail.inbox.forEach((letter) => (letter.read = true)); + } + busy = false; + needsPaint(); + }); + } else if ((type === "subscribe" || type === "unsubscribe") && !busy) { + busy = true; + net.userRequest("POST", "/api/mail-status", { action: type }).then((res) => { + if (res.status === 200) prefs.subscribed = type === "subscribe"; + busy = false; + needsPaint(); + }); + } + needsPaint(); + return; + } + if (status !== "loaded") return; if (view === "compose") { - // The tabs stay live while composing — they're painted, so they have to - // work. Otherwise the only way out is a key, and a phone has no escape. - let left = false; - const leave = (to) => { - leaveCompose(api, to); - left = true; - }; - inboxBtn?.act(e, () => leave("inbox")); - sentBtn?.act(e, () => leave("sent")); - prefsBtn?.act(e, () => leave("prefs")); - if (left) { - needsPaint(); - return; - } - if (e.is("keyboard:down:escape")) { leaveCompose(api, "inbox"); needsPaint(); return; } - - let sending = false; - sendBtn?.act(e, () => { - sending = true; - fields.sync(); - send(api, { ...fields.values }); - }); - if (sending) { - needsPaint(); - return; - } - if (fields.act(api)) needsPaint(); return; } @@ -613,74 +660,14 @@ function act(api) { return; } - inboxBtn?.act(e, () => { - view = "inbox"; - needsPaint(); - }); - - sentBtn?.act(e, () => { - view = "sent"; - needsPaint(); - }); - - writeBtn?.act(e, () => { - compose(api); - needsPaint(); - }); - - prefsBtn?.act(e, async () => { - view = "prefs"; - needsPaint(); - if (prefs) return; - const res = await net.userRequest("GET", "/api/mail-status"); - if (res.status === 200) prefs = res; - needsPaint(); - }); - // Tap a letter to answer it — the field opens already addressed. if (e.is("touch") && (view === "inbox" || view === "sent")) { const row = rows.find((r) => e.y >= r.y0 && e.y < r.y1); if (row?.who?.startsWith("@")) { compose(api, row.who); needsPaint(); - return; } } - - if (busy) return; - - // Only a button that's actually on screen may act — otherwise it keeps the - // box it had in another view and catches clicks meant for something else. - if (view === "inbox" && mail.unread > 0) { - readBtn?.act(e, async () => { - busy = true; - needsPaint(); - const res = await net.userRequest("POST", "/api/mail", { action: "read" }); - if (res.status === 200) { - mail.unread = 0; - mail.inbox.forEach((letter) => (letter.read = true)); - } - busy = false; - needsPaint(); - }); - } - - if (view !== "prefs" || !prefs) return; - - const toggle = async (action, subscribed) => { - busy = true; - needsPaint(); - const res = await net.userRequest("POST", "/api/mail-status", { action }); - if (res.status === 200) prefs.subscribed = subscribed; - busy = false; - needsPaint(); - }; - - if (prefs.subscribed) { - unsubBtn?.act(e, () => toggle("unsubscribe", false)); - } else { - subBtn?.act(e, () => toggle("subscribe", true)); - } } export { meta, boot, sim, paint, act }; diff --git a/system/public/aesthetic.computer/disks/chat.mjs b/system/public/aesthetic.computer/disks/chat.mjs index 1d9657a65d..ffe0285dab 100644 --- a/system/public/aesthetic.computer/disks/chat.mjs +++ b/system/public/aesthetic.computer/disks/chat.mjs @@ -3812,7 +3812,7 @@ function act( type: "handle", text: element.text, displayText: element.text, - description: "profile · or write them an amail", + description: "their profile, or an amail", yesLabel: "profile", alt: { label: "amail", action: () => jump(`amail~${element.text}`) }, action: () => jump(element.text) diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs index 35bd536186..82645c2640 100644 --- a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -1,7 +1,8 @@ // laklok-tema — the shared dress of the Laer Klokken rooms: `laklok` (the // chat) and `amail` (the post). One roster of temas, one saved choice -// (`laklok:theme`), one census, and the corner chrome both rooms wear — the -// QR, the ⚙ gear, the envelope with its count, and the indstillinger pane. +// (`laklok:theme`), one interface language (`laklok:lang`), one census, and +// the corner chrome both rooms wear — the QR, the ⚙ gear, the envelope with +// its count, and the indstillinger pane. // // The vector sister (system/public/html/index.html) mirrors LAK_THEMES and // LAK_REALTIME_CYCLES by name; toolchain/laklok-sisters/parity.mjs reads this @@ -11,6 +12,7 @@ import { hslToRgb } from "../../lib/num.mjs"; import { qrcode as qr, ErrorCorrectLevel } from "../../dep/@akamfoad/qr/qr.mjs"; export const TEMA_KEY = "laklok:theme"; +export const LANG_KEY = "laklok:lang"; // 🌅 `realtime` — the ambient tema: a palette that is a pure function of the // date, so the room is a little different every day and the same for @@ -230,6 +232,126 @@ export function reportTema(net, name) { net.userRequest("POST", "/api/laklok-theme", { theme: name }); } +// 🗣️ Interface language — the words on the chrome, not the letters or the +// chat. `da` is the rooms' mother tongue; `en` for everyone else. One saved +// choice for both rooms, like the tema. Each room names its own fallback. +export const LANGS = ["da", "en"]; +const STRINGS = { + da: { + settings: "indstillinger", + mode: "tilstand", + tema: "tema", + filter: "filter", + all: "alle", + links: "links", + language: "sprog", + // amail + inbox: "indbakke", + sent: "sendt", + prefs: "valg", + write: "skriv", + send: "send", + markRead: "markér læst", + noMail: "ingen amail endnu", + nothingSent: "intet sendt endnu", + tryHint: "prøv: amail @jeffrey hej", + composeHint: "enter går ned · tryk på en række", + whoTo: "til hvem?", + nothingToSay: "intet at sige endnu", + noOne: "ingen svarer på", + couldntSend: "kunne ikke sendes", + to: "til", + re: "re", + say: "sig", + optional: "(valgfri)", + loading: "henter", + loadingPrefs: "henter valg…", + updating: "opdaterer…", + subscribed: "tilmeldt", + unsubscribed: "afmeldt", + subscribe: "tilmeld", + unsubscribe: "afmeld", + blastHistory: "udsendelser", + noBlasts: "ingen udsendelser endnu", + error: "fejl", + adTitle: "breve mellem @handles", + adBody: + "amail er posten på aesthetic.computer — intet forlader computeren. log ind, tag et @handle, og din boks er klar.", + adPrompt: "fra enhver prompt: amail @handle dine ord", + signup: "opret dig", + login: "log ind", + }, + en: { + settings: "settings", + mode: "mode", + tema: "theme", + filter: "filter", + all: "all", + links: "links", + language: "language", + // amail + inbox: "inbox", + sent: "sent", + prefs: "prefs", + write: "write", + send: "send", + markRead: "mark read", + noMail: "no amail yet", + nothingSent: "nothing sent yet", + tryHint: "try: amail @jeffrey hello", + composeHint: "enter moves down · tap a row to jump", + whoTo: "who is it to?", + nothingToSay: "nothing to say yet", + noOne: "no one answers to", + couldntSend: "couldn't send that", + to: "to", + re: "re", + say: "say", + optional: "(optional)", + loading: "loading", + loadingPrefs: "loading prefs…", + updating: "updating…", + subscribed: "subscribed", + unsubscribed: "unsubscribed", + subscribe: "subscribe", + unsubscribe: "unsubscribe", + blastHistory: "blast history", + noBlasts: "no blasts sent yet", + error: "error", + adTitle: "letters between @handles", + adBody: + "amail is the post of aesthetic.computer — nothing leaves the computer. log in, take a @handle, and your box is ready.", + adPrompt: "from any prompt: amail @handle your words", + signup: "sign up", + login: "log in", + }, +}; + +export function isLang(name) { + return LANGS.includes(name); +} + +export function strings(lang) { + return STRINGS[isLang(lang) ? lang : "en"]; +} + +// A `~da` / `:en` token pins the language; otherwise the saved one; otherwise +// the room's own fallback. +export function pickLang(tokens, store, fallback = "en") { + const pinned = (tokens || []).find((t) => isLang(t)); + if (pinned) return { name: pinned, pinned: true }; + return { name: isLang(store[LANG_KEY]) ? store[LANG_KEY] : fallback, pinned: false }; +} + +export function restoreLang(store, pinned, onLang) { + store.retrieve(LANG_KEY).then((v) => onLang(!pinned && isLang(v) ? v : null)); +} + +export function saveLang(store, name) { + store[LANG_KEY] = name; + store.persist(LANG_KEY); +} + // 📱 A QR's cells for a url, or null when the encoder balks. export function makeQR(url) { try { @@ -305,63 +427,96 @@ export function paintBadge($, x, y, label, hot = false) { return w; } +// 🏷️ A chip — the unit of the pane and of amail's compact controls. Filled +// gold when selected, outlined otherwise; `tint` colours an outlined chip so +// an action can carry its own hue. Returns its hit box. +export const CHIP_H = 11; +export const CHIP_FONT = "MatrixChunky8"; +export function chipWidth(text) { + return text.length * 5 + 8; +} +export function paintChip($, x, y, text, { selected = false, tint = null, dim = false } = {}) { + const { ink } = $; + const w = chipWidth(text); + if (selected) { + ink(255, 240, 120).box(x, y, w, CHIP_H); + ink(20, 10, 6).write(text, { x: x + 4, y: y + 2 }, undefined, undefined, false, CHIP_FONT); + } else { + const edge = tint ? [...tint, dim ? 110 : 200] : [255, 255, 255, dim ? 70 : 120]; + const face = tint ? [...tint, dim ? 170 : 255] : [255, 255, 255, dim ? 140 : 200]; + ink(edge).box(x, y, w, CHIP_H, "outline"); + ink(face).write(text, { x: x + 4, y: y + 2 }, undefined, undefined, false, CHIP_FONT); + } + return { x, y, w, h: CHIP_H }; +} + // ⚙️ The indstillinger pane — rows of chips hung from the top-right under the // corner chrome. `rows` is [{ label, chips: [{ text, selected, action }] }]. // Returns the chip hit boxes, with the pane's own bounds on `.pane` so act() // can tell a tap inside from a tap that should close it. -export function paintTemaPane($, { theme, rows, right, top }) { +export function paintTemaPane($, { theme, rows, right, top, title = "indstillinger" }) { const { ink } = $; const hits = []; - const chipH = 11; - const rowH = 16; + const lineH = 16; const padX = 6; - const labelW = 34; - const chipFont = "MatrixChunky8"; - const chipW = (t) => t.length * 5 + 8; + const labelW = 42; + // The pane is as wide as its widest row wants, but never wider than the + // screen allows; a row that doesn't fit flows its chips onto more lines + // (phones are ~256px, and the tema row alone is wider than that). let paneW = 0; for (const row of rows) { let w = labelW; - for (const chip of row.chips) w += chipW(chip.text) + 4; + for (const chip of row.chips) w += chipWidth(chip.text) + 4; paneW = Math.max(paneW, w); } - paneW += padX * 2; - const paneH = rows.length * rowH + 14 + 8; + paneW = Math.min(paneW + padX * 2, right - 2); + const chipsLeft = padX + labelW; + const chipsRight = paneW - padX; + + // Lay the chips out once (relative to the pane) so the height is known + // before anything is drawn. + const placed = []; // [{ chip, x, y }] + let lineY = 14 + 4; + for (const row of rows) { + let chipX = chipsLeft; + let y = lineY; + for (const chip of row.chips) { + const w = chipWidth(chip.text); + if (chipX + w > chipsRight && chipX > chipsLeft) { + chipX = chipsLeft; + y += CHIP_H + 3; + } + placed.push({ chip, x: chipX, y, label: chipX === chipsLeft && y === lineY ? row.label : null }); + chipX += w + 4; + } + lineY = y + lineH; + } + const paneH = lineY + 4; const paneX = Math.max(2, right - paneW); const paneY = top; ink(theme.stripeA[0], theme.stripeA[1], theme.stripeA[2], 245).box(paneX, paneY, paneW, paneH); ink(255, 240, 120).box(paneX, paneY, paneW, paneH, "outline"); - ink(255, 240, 120).write("indstillinger", { x: paneX + padX, y: paneY + 4 }, undefined, undefined, false, chipFont); + ink(255, 240, 120).write(title, { x: paneX + padX, y: paneY + 4 }, undefined, undefined, false, CHIP_FONT); - let rowY = paneY + 14 + 4; - for (const row of rows) { - ink(255, 255, 255, 180).write(row.label, { x: paneX + padX, y: rowY + 2 }, undefined, undefined, false, chipFont); - let chipX = paneX + padX + labelW; - for (const chip of row.chips) { - const w = chipW(chip.text); - if (chip.selected) { - ink(255, 240, 120).box(chipX, rowY, w, chipH); - ink(20, 10, 6).write(chip.text, { x: chipX + 4, y: rowY + 2 }, undefined, undefined, false, chipFont); - } else { - ink(255, 255, 255, 120).box(chipX, rowY, w, chipH, "outline"); - ink(255, 255, 255, 200).write(chip.text, { x: chipX + 4, y: rowY + 2 }, undefined, undefined, false, chipFont); - } - hits.push({ x: chipX, y: rowY, w, h: chipH, action: chip.action }); - chipX += w + 4; + for (const { chip, x, y, label } of placed) { + if (label) { + ink(255, 255, 255, 180).write(label, { x: paneX + padX, y: paneY + y + 2 }, undefined, undefined, false, CHIP_FONT); } - rowY += rowH; + const box = paintChip($, paneX + x, paneY + y, chip.text, { selected: chip.selected }); + hits.push({ ...box, action: chip.action }); } hits.pane = { x: paneX, y: paneY, w: paneW, h: paneH }; return hits; } -// The tema row every room's pane starts with. -export function temaRow(current) { +// The two rows every room's pane carries: tema and sprog. +export function temaRow(current, s) { return { - label: "tema", + label: s.tema, chips: Object.keys(LAK_THEMES).map((name) => ({ text: name, selected: current === name, @@ -369,3 +524,13 @@ export function temaRow(current) { })), }; } +export function langRow(current, s) { + return { + label: s.language, + chips: LANGS.map((name) => ({ + text: name, + selected: current === name, + action: { type: "lang", value: name }, + })), + }; +} diff --git a/system/public/aesthetic.computer/disks/laklok.mjs b/system/public/aesthetic.computer/disks/laklok.mjs index e0d6327a19..7c3bc29d09 100644 --- a/system/public/aesthetic.computer/disks/laklok.mjs +++ b/system/public/aesthetic.computer/disks/laklok.mjs @@ -10,9 +10,10 @@ `parity.mjs` diffs the mirrored constants (run it after touching either side) and `sisters.mjs` renders them side by side for the visual half. - The temas themselves, the saved choice, the census and the corner chrome - (QR, gear, envelope, pane) live in `common/laklok-tema.mjs`, shared with - `amail` so the two rooms always wear the same dress. + The temas themselves, the saved choice, the interface language, the census + and the corner chrome (QR, gear, envelope, pane) live in + `common/laklok-tema.mjs`, shared with `amail` so the two rooms always wear + the same dress. */ import { Chat } from "../lib/chat.mjs"; // TODO: Eventually expand to `net.Socket` @@ -24,6 +25,10 @@ import { restoreTema, saveTema, reportTema, + strings, + pickLang, + restoreLang, + saveLang, makeQR, paintQR, paintGear, @@ -34,6 +39,7 @@ import { badgeWidth, paintTemaPane, temaRow, + langRow, } from "./common/laklok-tema.mjs"; let client; @@ -46,8 +52,10 @@ const LAK_TOP_MARGIN = 34; let lakQRCells = null; // ⚙️ Settings pane state — mode (raster here / vector on laklok.com/html), -// tema, and the media-links filter. The same pane exists on the vector side. +// tema, the media-links filter, and the interface language. The same pane +// exists on the vector side. let lakTheme = "ler"; +let lakLang = "da"; // the room's mother tongue, until the visitor says otherwise let lakLinksOnly = false; let settingsOpen = false; let gearBox = null; // {x, y, w, h} hit area for the ⚙ toggle @@ -79,7 +87,7 @@ function chatView() { return { ...sys, messages: sys.messages.filter((m) => hasMediaLink(m.text)) }; } -function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net, user }) { +function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net }) { client = new Chat(debug, send); client.connect("clock"); // Connect to 'clock' chat. (DB stays `chat-clock`.) chat.boot(api, client.system); // Use default font @@ -117,6 +125,11 @@ function boot({ api, wipe, debug, send, hud, store, colon, params, jump, net, us } reportTema(net, lakTheme); // after the saved tema has had its say }); + const lang = pickLang(tokens, store, "da"); + lakLang = lang.name; + restoreLang(store, lang.pinned, (saved) => { + if (saved) lakLang = saved; + }); if (!colonLinks && typeof store["laklok:links"] === "boolean") { lakLinksOnly = store["laklok:links"]; } @@ -259,29 +272,31 @@ function paintCorner($) { if (label) paintBadge($, ex + ENVELOPE.w - 3, ey - 5, label, hot); } -// ⚙️ The settings pane — mode / tema / filter, drawn under the header by the -// QR. Chips register their hit boxes into `settingsHits` for act(). +// ⚙️ The settings pane — mode / tema / filter / sprog, drawn under the header +// by the QR. Chips register their hit boxes into `settingsHits` for act(). function paintSettings($) { settingsHits = []; if (!settingsOpen) return; const { screen } = $; + const s = strings(lakLang); const rows = [ { - label: "mode", + label: s.mode, chips: [ { text: "raster", selected: true, action: { type: "mode", value: "raster" } }, { text: "vector", selected: false, action: { type: "mode", value: "vector" } }, ], }, - temaRow(lakTheme), + temaRow(lakTheme, s), { - label: "filter", + label: s.filter, chips: [ - { text: "alle", selected: !lakLinksOnly, action: { type: "links", value: false } }, - { text: "links", selected: lakLinksOnly, action: { type: "links", value: true } }, + { text: s.all, selected: !lakLinksOnly, action: { type: "links", value: false } }, + { text: s.links, selected: lakLinksOnly, action: { type: "links", value: true } }, ], }, + langRow(lakLang, s), ]; settingsHits = paintTemaPane($, { @@ -289,6 +304,7 @@ function paintSettings($) { rows, right: screen.width - 3, top: LAK_TOP_MARGIN + 2, + title: s.settings, }); } @@ -356,6 +372,9 @@ function act($) { store["laklok:links"] = value; store.persist("laklok:links"); chat.refresh(client.system); // relayout the filtered feed + } else if (type === "lang") { + lakLang = value; + saveLang(store, value); } } else if (!hit(settingsHits.pane)) { settingsOpen = false; // Tap outside closes. diff --git a/toolchain/laklok-sisters/parity.mjs b/toolchain/laklok-sisters/parity.mjs index 12bd4d7092..c63e1ca1fa 100644 --- a/toolchain/laklok-sisters/parity.mjs +++ b/toolchain/laklok-sisters/parity.mjs @@ -83,8 +83,10 @@ function regexLiteral(src, constName) { const rasterPane = /text: "raster"/.test(raster) && /text: "vector"/.test(raster) && - /text: "alle"/.test(raster) && - /text: "links"/.test(raster); + // The filter chips are words now (da/en), so their Danish spellings live + // in the shared strings table rather than as literals in the piece. + /all: "alle"/.test(tema) && + /links: "links"/.test(tema); const vectorPane = /data-mode="raster"/.test(vector) && /data-mode="vector"/.test(vector) && -- 2.51.2