From 47302044af3e9bc43f5080fdbb56961abfc554cc Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 15:21:41 -0400 Subject: [PATCH] amail: the post gets its noun, wears laklok's tema, and a handle opens two doors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mail` becomes `amail` — a proper noun inside aesthetic.computer, next to real email rather than confusable with it. The old path aliases through, the prompt command answers to both spellings, and the curtain envelope jumps to the new name. (The rename itself rode along in ab16951afb; this is the rest of it.) The temas, the saved choice, the census and the corner chrome move out of laklok into common/laklok-tema.mjs so amail can wear the same dress: one `laklok:theme` key, so switching in either room switches both. Amail's QR sits on pale blue paper where laklok's sits on white — same corner, told apart at a glance. Amail gets the ⚙ pane with the tema row. Laer Klokken gets an envelope beside its gear for a signed-in visitor — lit with a red count when letters wait — that opens amail. Tapping a handle in any chat now offers profile *or* amail; the amail door lands in compose with the address filled in (`amail~@handle`). Both rooms take the signed-in user from sim, since auth can settle a beat after boot. Co-Authored-By: Claude Fable 5.1 --- .../disks/common/laklok-tema.mjs | 371 ++++++++++++++++++ .../public/aesthetic.computer/disks/mail.mjs | 10 + 2 files changed, 381 insertions(+) create mode 100644 system/public/aesthetic.computer/disks/common/laklok-tema.mjs create mode 100644 system/public/aesthetic.computer/disks/mail.mjs diff --git a/system/public/aesthetic.computer/disks/common/laklok-tema.mjs b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs new file mode 100644 index 0000000000..35bd536186 --- /dev/null +++ b/system/public/aesthetic.computer/disks/common/laklok-tema.mjs @@ -0,0 +1,371 @@ +// 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. +// +// The vector sister (system/public/html/index.html) mirrors LAK_THEMES and +// LAK_REALTIME_CYCLES by name; toolchain/laklok-sisters/parity.mjs reads this +// file to check them, so add or rename a tema in both places. + +import { hslToRgb } from "../../lib/num.mjs"; +import { qrcode as qr, ErrorCorrectLevel } from "../../dep/@akamfoad/qr/qr.mjs"; + +export const TEMA_KEY = "laklok:theme"; + +// 🌅 `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 +// everyone in it. `t` is fractional UTC days; the hue walks ~6° a day around +// a 61-day lap while saturation and lightness breathe on their own cycles, +// so no midnight is a cut. Text sits at a fixed lightness above the ground. +// Mirrored in the vector client as `realtimeTheme` — same cycles, same slots. +export const LAK_REALTIME_CYCLES = [61, 23, 17]; // hue lap, saturation, lightness (days) +export function realtimeTheme(now = Date.now()) { + const t = Math.floor(now / 60000) / 1440; // quantized to the minute + const [hueDays, satDays, lightDays] = LAK_REALTIME_CYCLES; + const hue = (t * 360) / hueDays; + const sat = 34 + 12 * Math.sin((t * 2 * Math.PI) / satDays); + const light = 22 + 5 * Math.sin((t * 2 * Math.PI) / lightDays + 2); + const c = (h, s, l) => hslToRgb(((h % 360) + 360) % 360, s, l); + const bg = c(hue, sat, light); + return { + bg, + stripeA: c(hue, sat + 6, light * 0.62), + stripeB: c(hue, sat + 6, light * 0.8), + chat: { + background: bg, + chromeBg: bg, + lines: [...c(hue, sat, 62), 64], + scrollbar: c(hue, 60, 78), + messageText: c(hue, 30, 95), + messageBox: c(hue, 45, 85), + log: [100, 255, 220], + logHover: [255, 240, 120], + handle: c(hue + 30, 80, 80), + handleHover: [255, 240, 120], + url: c(hue + 180, 85, 80), + urlHover: [255, 240, 120], + prompt: c(hue + 120, 80, 80), + promptContent: c(hue + 180, 85, 80), + promptHover: [255, 240, 120], + promptContentHover: [255, 240, 120], + painting: c(hue + 60, 85, 80), + paintingHover: [255, 240, 120], + kidlisp: c(hue + 300, 85, 80), + kidlispHover: [255, 240, 120], + timestamp: c(hue, 25, 68), + timestampHover: [255, 240, 120], + heart: [255, 220, 240], + }, + }; +} + +// The `realtime` slot in LAK_THEMES is refilled once a minute while it is the +// active tema (sim). Returns true when the palette actually moved. +let realtimeMinute = 0; +export function realtimeTick() { + const minute = Math.floor(Date.now() / 60000); + if (minute === realtimeMinute) return false; + realtimeMinute = minute; + Object.assign(LAK_THEMES.realtime, realtimeTheme()); + return true; +} + +// 🎨 Themes — each recolors the whole room. `ler` (clay) is the historical +// terracotta; the others keep the same relationships in new light. The vector +// client mirrors these by name, so add/rename in both places. +export const LAK_THEMES = { + ler: { + bg: [180, 100, 60], + stripeA: [122, 60, 26], + stripeB: [150, 78, 34], + chat: { + background: [180, 100, 60], + chromeBg: [180, 100, 60], + lines: [220, 150, 100, 64], + scrollbar: [255, 180, 100], + messageText: [255, 255, 240], + messageBox: [255, 220, 180], + log: [100, 255, 220], + logHover: [255, 240, 120], + handle: [255, 160, 120], + handleHover: [255, 240, 120], + url: [120, 220, 255], + urlHover: [255, 240, 120], + prompt: [200, 255, 180], + promptContent: [120, 220, 255], + promptHover: [255, 240, 120], + promptContentHover: [255, 240, 120], + painting: [255, 200, 140], + paintingHover: [255, 240, 120], + kidlisp: [255, 140, 200], + kidlispHover: [255, 240, 120], + timestamp: [220, 180, 150], + timestampHover: [255, 240, 120], + heart: [255, 220, 240], + }, + }, + nat: { + bg: [26, 30, 62], + stripeA: [20, 24, 50], + stripeB: [32, 38, 76], + chat: { + background: [26, 30, 62], + chromeBg: [26, 30, 62], + lines: [90, 110, 190, 64], + scrollbar: [120, 150, 255], + messageText: [235, 240, 255], + messageBox: [180, 200, 255], + log: [100, 255, 220], + logHover: [255, 240, 120], + handle: [150, 180, 255], + handleHover: [255, 240, 120], + url: [120, 220, 255], + urlHover: [255, 240, 120], + prompt: [200, 255, 180], + promptContent: [120, 220, 255], + promptHover: [255, 240, 120], + promptContentHover: [255, 240, 120], + painting: [255, 200, 140], + paintingHover: [255, 240, 120], + kidlisp: [255, 140, 200], + kidlispHover: [255, 240, 120], + timestamp: [150, 160, 210], + timestampHover: [255, 240, 120], + heart: [255, 220, 240], + }, + }, + skov: { + bg: [24, 56, 36], + stripeA: [18, 44, 28], + stripeB: [30, 66, 42], + chat: { + background: [24, 56, 36], + chromeBg: [24, 56, 36], + lines: [90, 150, 110, 64], + scrollbar: [130, 220, 150], + messageText: [235, 255, 240], + messageBox: [190, 230, 200], + log: [100, 255, 220], + logHover: [255, 240, 120], + handle: [170, 230, 150], + handleHover: [255, 240, 120], + url: [120, 220, 255], + urlHover: [255, 240, 120], + prompt: [220, 255, 170], + promptContent: [120, 220, 255], + promptHover: [255, 240, 120], + promptContentHover: [255, 240, 120], + painting: [255, 210, 140], + paintingHover: [255, 240, 120], + kidlisp: [255, 150, 190], + kidlispHover: [255, 240, 120], + timestamp: [150, 190, 160], + timestampHover: [255, 240, 120], + heart: [255, 215, 235], + }, + }, + lakrids: { + bg: [22, 20, 24], + stripeA: [14, 12, 16], + stripeB: [30, 27, 34], + chat: { + background: [22, 20, 24], + chromeBg: [22, 20, 24], + lines: [90, 80, 95, 64], + scrollbar: [200, 190, 210], + messageText: [240, 238, 244], + messageBox: [210, 205, 215], + log: [100, 255, 220], + logHover: [255, 240, 120], + handle: [240, 170, 190], + handleHover: [255, 240, 120], + url: [130, 210, 255], + urlHover: [255, 240, 120], + prompt: [190, 240, 170], + promptContent: [130, 210, 255], + promptHover: [255, 240, 120], + promptContentHover: [255, 240, 120], + painting: [250, 200, 150], + paintingHover: [255, 240, 120], + kidlisp: [255, 150, 210], + kidlispHover: [255, 240, 120], + timestamp: [150, 145, 160], + timestampHover: [255, 240, 120], + heart: [255, 210, 230], + }, + }, + realtime: realtimeTheme(), // 🌅 ambient — see realtimeTheme / realtimeTick +}; + +export function isTema(name) { + return typeof name === "string" && Object.hasOwn(LAK_THEMES, name); +} + +// 👗 Which tema to wear. A colon or `~` token pins one (`laklok:nat`, +// `amail~skov` — shareable themed URLs); otherwise whatever the store already +// has synchronously, falling back to `ler`. The retrieved store settles later +// — see restoreTema. +export function pickTema(tokens, store) { + const pinned = (tokens || []).find((t) => isTema(t)); + if (pinned) return { name: pinned, pinned: true }; + return { name: isTema(store[TEMA_KEY]) ? store[TEMA_KEY] : "ler", pinned: false }; +} + +// `store.retrieve` resolves via .then — awaiting it in boot stalls the piece +// (see cal.mjs for the same pattern). Hands the saved tema over when it lands, +// or null if a token already pinned one or nothing was saved. +export function restoreTema(store, pinned, onTema) { + store.retrieve(TEMA_KEY).then((v) => onTema(!pinned && isTema(v) ? v : null)); +} + +export function saveTema(store, name) { + store[TEMA_KEY] = name; + store.persist(TEMA_KEY); +} + +// 📊 Theme census — tell /api/laklok-theme which tema this visitor is on (boot +// = heartbeat, chip tap = switch). Needs a login; anonymous visitors are not +// counted. Fire-and-forget: the room never waits on it. +export function reportTema(net, name) { + net.userRequest("POST", "/api/laklok-theme", { theme: name }); +} + +// 📱 A QR's cells for a url, or null when the encoder balks. +export function makeQR(url) { + try { + return qr(url, { errorCorrectLevel: ErrorCorrectLevel.L }).modules; + } catch (e) { + console.error("QR generation failed:", url, e); + return null; + } +} + +// 1px per cell on a `paper` ground with a 1px border of the same. The two +// rooms use different paper so their corners read apart at a glance. +export function paintQR($, cells, x, y, paper = [255, 255, 255]) { + const { ink } = $; + const size = cells.length; + ink(...paper).box(x, y, size + 2, size + 2); + for (let cy = 0; cy < size; cy++) { + for (let cx = 0; cx < size; cx++) { + if (cells[cy][cx]) ink(0, 0, 0).box(x + 1 + cx, y + 1 + cy, 1, 1); + } + } + return { x, y, w: size + 2, h: size + 2 }; +} + +// ⚙️ The settings toggle — a little hamburger. Returns its padded hit box. +export const GEAR = 13; +export function paintGear($, x, y, open) { + const { ink } = $; + ink(open ? [255, 240, 120] : [255, 255, 255, 200]).box(x, y, GEAR, GEAR, "outline"); + const lineCol = open ? [255, 240, 120] : [255, 255, 255, 220]; + for (let li = 0; li < 3; li++) { + ink(...lineCol).box(x + 3, y + 3 + li * 3, GEAR - 6, 1); + } + return { x: x - 2, y: y - 2, w: GEAR + 4, h: GEAR + 4 }; +} + +// 📬 The envelope — shut and grey when the box is empty, lit teal with its +// flap open when something is waiting. Same palette as the prompt curtain. +export const ENVELOPE = { w: 15, h: 10 }; +export function paintEnvelope($, x, y, { lit = false, hot = false, down = false } = {}) { + const { ink, line } = $; + const { w, h } = ENVELOPE; + const fill = down + ? [0, 80, 100, 235] + : lit + ? hot ? [0, 150, 175, 230] : [0, 120, 140, 210] + : hot ? [40, 46, 58, 205] : [26, 30, 38, 170]; + const edge = down + ? [210, 255, 255] + : lit + ? hot ? [200, 255, 255] : [120, 255, 255] + : hot ? [150, 162, 186] : [80, 88, 104]; + ink(fill).box(x, y, w, h); + ink(edge).box(x, y, w, h, "outline"); + ink(lit || hot ? [190, 255, 255] : [60, 66, 80]); + line(x, y, x + (w >> 1), y + (h >> 1)); + line(x + w - 1, y, x + (w >> 1), y + (h >> 1)); +} + +// 🔴 A red count badge. MatrixChunky8 digits are 3×7 at rows 0–6 of their +// cell; 11 tall with the glyph at +2 gives them two clear pixels above and +// below. Returns its width so a caller can centre the cluster it sits on. +export const BADGE_H = 11; +export function badgeWidth(label) { + return label.length * 4 + 5; +} +export function paintBadge($, x, y, label, hot = false) { + const { ink } = $; + const w = badgeWidth(label); + ink(hot ? [255, 60, 70] : [220, 30, 40]).box(x, y, w, BADGE_H); + ink(hot ? [255, 180, 185] : [255, 120, 130]).box(x, y, w, BADGE_H, "outline"); + ink(255, 240, 240).write(label, { x: x + 3, y: y + 2 }, undefined, undefined, false, "MatrixChunky8"); + return w; +} + +// ⚙️ 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 }) { + const { ink } = $; + const hits = []; + + const chipH = 11; + const rowH = 16; + const padX = 6; + const labelW = 34; + const chipFont = "MatrixChunky8"; + const chipW = (t) => t.length * 5 + 8; + + let paneW = 0; + for (const row of rows) { + let w = labelW; + for (const chip of row.chips) w += chipW(chip.text) + 4; + paneW = Math.max(paneW, w); + } + paneW += padX * 2; + const paneH = rows.length * rowH + 14 + 8; + 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); + + 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; + } + rowY += rowH; + } + + 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) { + return { + label: "tema", + chips: Object.keys(LAK_THEMES).map((name) => ({ + text: name, + selected: current === name, + action: { type: "theme", value: name }, + })), + }; +} diff --git a/system/public/aesthetic.computer/disks/mail.mjs b/system/public/aesthetic.computer/disks/mail.mjs new file mode 100644 index 0000000000..d74ebbad7f --- /dev/null +++ b/system/public/aesthetic.computer/disks/mail.mjs @@ -0,0 +1,10 @@ +// mail — alias for `amail`, the post of aesthetic.computer. +// The piece now lives in `amail.mjs` so it has a proper noun of its own; this +// keeps the original /mail path working, mirroring how `laer-klokken` +// aliases to `laklok`. + +// 🥾 Boot (Runs once before first paint and sim) +export function boot({ colon, params, alias }) { + // Pass all parameters through to `amail`. + alias(`amail`, colon, params); +} -- 2.51.2