From b7037992a47bf0a65b09a06d0c614552b48b978b Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 14:47:18 -0400 Subject: [PATCH] laklok: a `realtime` tema that drifts with the calendar, and a census of who wears what MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The room's palettes were fixed objects, so a day in there looked like every other day. `realtime` is a pure function of the UTC date — hue laps every 61 days, saturation and lightness breathe on 23- and 17-day cycles — so it is a little different each day and never jumps; it refills its LAK_THEMES slot once a minute while active. Mirrored on the vector sister; parity pins the cycle constants on both. Theme choice never left the device, so nothing could say which temas were in use. `/api/laklok-theme` now keeps one row per signed-in visitor (handle, theme, last seen, since-when); both sisters report on boot and on each chip tap. GET returns the roster and per-theme counts for a window. Co-Authored-By: Claude Fable 5.1 --- system/netlify/functions/laklok-theme.mjs | 68 +++++++++++++++++ .../aesthetic.computer/disks/laklok.mjs | 76 ++++++++++++++++++- system/public/html/index.html | 72 +++++++++++++++++- toolchain/laklok-sisters/PARITY.md | 2 + toolchain/laklok-sisters/parity.mjs | 12 ++- 5 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 system/netlify/functions/laklok-theme.mjs diff --git a/system/netlify/functions/laklok-theme.mjs b/system/netlify/functions/laklok-theme.mjs new file mode 100644 index 0000000000..dd69a6b0f6 --- /dev/null +++ b/system/netlify/functions/laklok-theme.mjs @@ -0,0 +1,68 @@ +// laklok-theme, 26.09.13 +// Which `tema` each laklok (laer-klokken) visitor is looking at. The choice +// itself stays local to the device; this is the census. Both sisters report +// on boot and on every chip tap, so `at` reads as "last seen in the room". +// +// POST /api/laklok-theme { theme } → upsert { sub, handle, theme, at, since } +// headers: Authorization: Bearer +// GET /api/laklok-theme?days=30 → { days, active, themes: {name: n}, users } +// users seen within `days` (default 30), newest first — anyone can read it. + +import { authorize } from "../../backend/authorization.mjs"; +import { connect } from "../../backend/database.mjs"; +import { respond } from "../../backend/http.mjs"; + +const THEME = /^[a-z]{1,16}$/; // roster lives in the piece; the server only bounds it + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(200, {}); + if (event.httpMethod !== "GET" && event.httpMethod !== "POST") { + return respond(405, { message: "Method Not Allowed" }); + } + + const database = await connect(); + try { + const themes = database.db.collection("laklok-themes"); + + if (event.httpMethod === "GET") { + const days = Math.min(365, Math.max(1, parseInt(event.queryStringParameters?.days) || 30)); + const seenAfter = new Date(Date.now() - days * 86400000); + const users = await themes + .find({ at: { $gte: seenAfter } }, { projection: { _id: 0, handle: 1, theme: 1, at: 1, since: 1 } }) + .sort({ at: -1 }) + .toArray(); + const counts = {}; + for (const u of users) counts[u.theme] = (counts[u.theme] || 0) + 1; + return respond(200, { days, active: users.length, themes: counts, users }); + } + + const user = await authorize(event.headers); + if (!user?.sub) return respond(401, { message: "unauthorized" }); + + let body; + try { + body = JSON.parse(event.body || "{}"); + } catch { + return respond(400, { message: "Invalid JSON body" }); + } + if (!THEME.test(body.theme || "")) return respond(400, { message: "Invalid theme" }); + + const handle = (await database.db.collection("@handles").findOne({ _id: user.sub }))?.handle || null; + const now = new Date(); + const prev = await themes.findOne({ _id: user.sub }, { projection: { theme: 1, since: 1 } }); + // `since` marks when the current theme was picked; a heartbeat with the + // same theme keeps it, a switch resets it. + const since = prev?.theme === body.theme && prev.since ? prev.since : now; + await themes.updateOne( + { _id: user.sub }, + { $set: { handle, theme: body.theme, at: now, since } }, + { upsert: true }, + ); + return respond(200, { theme: body.theme, handle, at: now }); + } catch (err) { + console.error("🔴 laklok-theme error:", err); + return respond(500, { message: err?.message || "Server error" }); + } finally { + await database.disconnect(); + } +} diff --git a/system/public/aesthetic.computer/disks/laklok.mjs b/system/public/aesthetic.computer/disks/laklok.mjs index ac616811b0..24e6320cb8 100644 --- a/system/public/aesthetic.computer/disks/laklok.mjs +++ b/system/public/aesthetic.computer/disks/laklok.mjs @@ -14,6 +14,7 @@ import { Chat } from "../lib/chat.mjs"; // TODO: Eventually expand to `net.Socket` import * as chat from "./chat.mjs"; // Import chat everywhere. import { qrcode as qr, ErrorCorrectLevel } from "../dep/@akamfoad/qr/qr.mjs"; +import { hslToRgb } from "../lib/num.mjs"; let client; @@ -24,6 +25,64 @@ const LAK_TOP_MARGIN = 34; // 📱 laklok.com QR rendered in the top-right corner (see paintQR). let lakQRCells = null; +// 🌅 `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. +const LAK_REALTIME_CYCLES = [61, 23, 17]; // hue lap, saturation, lightness (days) +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; +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. @@ -148,6 +207,7 @@ const LAK_THEMES = { heart: [255, 210, 230], }, }, + realtime: realtimeTheme(), // 🌅 ambient — see realtimeTheme / realtimeTick }; // ⚙️ Settings pane state — mode (raster here / vector on laklok.com/html), @@ -178,7 +238,14 @@ function chatView() { return { ...sys, messages: sys.messages.filter((m) => hasMediaLink(m.text)) }; } -function boot({ api, wipe, debug, send, hud, store, colon, params, jump }) { +// 📊 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. +function reportTheme(net) { + net.userRequest("POST", "/api/laklok-theme", { theme: lakTheme }); +} + +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 @@ -220,6 +287,7 @@ function boot({ api, wipe, debug, send, hud, store, colon, params, jump }) { lakTheme = v; chat.refresh(client.system); } + reportTheme(net); // after the saved tema has had its say }); store.retrieve("laklok:links").then((v) => { if (!colonLinks && typeof v === "boolean") { @@ -470,7 +538,7 @@ function paint($) { } function act($) { - const { event: e, jump, store, needsPaint } = $; + const { event: e, jump, store, needsPaint, net } = $; const hit = (box) => box && e.x >= box.x && e.x < box.x + box.w && e.y >= box.y && e.y < box.y + box.h; @@ -494,7 +562,9 @@ function act($) { lakTheme = value; store["laklok:theme"] = value; store.persist("laklok:theme"); + if (value === "realtime") realtimeTick(); // catch up before first paint chat.refresh(client.system); // recolor cached message lines + reportTheme(net); } else if (type === "links") { lakLinksOnly = value; store["laklok:links"] = value; @@ -513,6 +583,8 @@ function act($) { } function sim($) { + // 🌅 The ambient tema drifts a hair each minute; recolor when it does. + if (lakTheme === "realtime" && realtimeTick()) chat.refresh(client.system); chat.sim($); } diff --git a/system/public/html/index.html b/system/public/html/index.html index 86bb1599a4..16b6368ed0 100644 --- a/system/public/html/index.html +++ b/system/public/html/index.html @@ -75,6 +75,23 @@ --send-bg: #c8bed2; --send-text: #16121a; } + :root[data-theme="realtime"] { + /* 🌅 ambient — every var below is set live by realtimeTheme(); these are + only the first-paint ground so nothing flashes white. */ + --bg: #2f3a4a; + --bg-deep: #262e3b; + --bg-soft: #3a4658; + --stripe-a: #1d242e; + --stripe-b: #262e3b; + --line: rgba(120,140,170,.26); + --text: #eef1f5; + --handle: #a6c8f0; + --time: #9aa6b8; + --input-bg: #1c232c; + --input-line:#4f5f75; + --send-bg: #8fb8f0; + --send-text: #0c1219; + } :root { /* Token + status colors shared by every theme (mirror the raster piece). */ --hover: #fff078; /* golden yellow hover */ @@ -355,6 +372,7 @@ +
filter @@ -432,7 +450,7 @@ import("/aesthetic.computer/dep/@akamfoad/qr/qr.mjs") // ── Settings (mode / tema / filter) — the raster piece's sister pane ──────── // Persisted under the same names the raster piece uses; `?theme=`/`?links=` // query params override for shareable states and the sisters suite. -const THEMES = ["ler", "nat", "skov", "lakrids"]; +const THEMES = ["ler", "nat", "skov", "lakrids", "realtime"]; const query = new URLSearchParams(location.search); let theme = query.get("theme") || localStorage.getItem("laklok:theme") || "ler"; if (!THEMES.includes(theme)) theme = "ler"; @@ -440,6 +458,43 @@ let linksOnly = query.has("links") ? query.get("links") !== "0" : localStorage.getItem("laklok:links") === "1"; +// 🌅 `realtime` — mirror of the raster piece's realtimeTheme: a palette that +// is a pure function of the date (fractional UTC days, quantized to the +// minute). Hue laps in 61 days, saturation and lightness breathe on 23 and 17, +// so everyone in the room sees the same slow drift and no midnight is a cut. +const REALTIME_CYCLES = [61, 23, 17]; // hue lap, saturation, lightness (days) +function realtimeTheme(now = Date.now()) { + const t = Math.floor(now / 60000) / 1440; + const [hueDays, satDays, lightDays] = 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, a = 1) => `hsl(${(h % 360).toFixed(1)} ${s.toFixed(1)}% ${l.toFixed(1)}% / ${a})`; + return { + "--bg": c(hue, sat, light), + "--bg-deep": c(hue, sat + 4, light * 0.8), + "--bg-soft": c(hue, sat, light + 6), + "--stripe-a": c(hue, sat + 6, light * 0.62), + "--stripe-b": c(hue, sat + 6, light * 0.8), + "--line": c(hue, sat, 62, 0.26), + "--text": c(hue, 30, 95), + "--handle": c(hue + 30, 80, 80), + "--time": c(hue, 25, 68), + "--input-bg": c(hue, sat + 4, light * 0.6), + "--input-line": c(hue, sat, light + 16), + "--send-bg": c(hue + 30, 80, 70), + "--send-text": c(hue, 40, 8), + }; +} +function paintRealtime() { + const root = document.documentElement.style; + const vars = theme === "realtime" ? realtimeTheme() : {}; + for (const k of Object.keys(realtimeTheme())) { + if (vars[k]) root.setProperty(k, vars[k]); else root.removeProperty(k); + } +} +setInterval(paintRealtime, 60000); // drifts a hair each minute while active + function applyTheme(name) { theme = name; document.documentElement.dataset.theme = name; @@ -447,6 +502,7 @@ function applyTheme(name) { chip.classList.toggle("sel", chip.dataset.tema === name); } localStorage.setItem("laklok:theme", name); + paintRealtime(); } function applyLinksFilter(on) { linksOnly = on; @@ -474,7 +530,7 @@ $settings.addEventListener("click", (e) => { const chip = e.target.closest(".chip"); if (!chip) return; if (chip.dataset.mode === "raster") location.href = "https://laklok.com"; - if (chip.dataset.tema) applyTheme(chip.dataset.tema); + if (chip.dataset.tema) { applyTheme(chip.dataset.tema); reportTheme(); } if (chip.dataset.links !== undefined) applyLinksFilter(chip.dataset.links === "1"); }); @@ -1049,6 +1105,17 @@ function setPresence(online, here) { // `@@auth0spajs@@…` keys; posting sends the same access token the piece would. const auth = { token: null, sub: null, handle: null }; +// 📊 Theme census — the same /api/laklok-theme the raster piece reports to: +// once at sign-in and on every tema chip tap. Anonymous readers aren't counted. +function reportTheme() { + if (!auth.token) return; + fetch(`${AC_API}/api/laklok-theme`, { + method: "POST", + headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ theme }), + }).catch(() => {}); +} + function decodeJwtSub(token) { try { const body = token.split(".")[1]; @@ -1096,6 +1163,7 @@ async function initAuth() { $input.disabled = false; $send.disabled = false; setGate("posting live."); + reportTheme(); // census heartbeat — the load-time applyTheme ran before sign-in try { const r = await fetch(`${HANDLE_API}?for=${encodeURIComponent(auth.sub)}`); if (r.ok) { diff --git a/toolchain/laklok-sisters/PARITY.md b/toolchain/laklok-sisters/PARITY.md index f3be46f8b4..a5cb92e338 100644 --- a/toolchain/laklok-sisters/PARITY.md +++ b/toolchain/laklok-sisters/PARITY.md @@ -29,6 +29,8 @@ same change — an unmirrored constant is future drift. | --- | --- | --- | --- | | Circus marquee "Laer Klokken" | laklok.mjs `paintLaerKlokkenSign` | `.marquee` + `CIRCUS` | colors checked | | Themes ler/nat/skov/lakrids | `LAK_THEMES` | `:root[data-theme]` vars | roster checked | +| `realtime` ambient tema (date-driven drift) | `realtimeTheme` + `realtimeTick` | `realtimeTheme` + `paintRealtime` | cycles checked; raster refills the slot per minute in `sim`, vector on a 60s interval | +| Theme census → `/api/laklok-theme` | `reportTheme` (boot + chip) | `reportTheme` (sign-in + chip) | GET the endpoint for counts | | Settings pane (mode/tema/filter) | `paintSettings` | `#settings` | chips checked | | Media-links filter | `chatView()` + `LAK_MEDIA_LINK` | `body.filter-links` + `MEDIA_LINK` | regex checked verbatim | | Corner QR → laklok.com | `paintQR` (@akamfoad/qr dep) | `#qrlink` canvas (same dep) | URL checked | diff --git a/toolchain/laklok-sisters/parity.mjs b/toolchain/laklok-sisters/parity.mjs index 7edb518552..2adc3dc464 100644 --- a/toolchain/laklok-sisters/parity.mjs +++ b/toolchain/laklok-sisters/parity.mjs @@ -50,7 +50,8 @@ function regexLiteral(src, constName) { // ── 2. Theme roster — LAK_THEMES keys ↔ data-theme blocks + tema chips ──── { const themesBlock = raster.match(/const LAK_THEMES = \{([\s\S]*?)\n\};/)?.[1] || ""; - const rasterThemes = [...themesBlock.matchAll(/^ {2}(\w+): \{/gm)].map((m) => m[1]); + // A theme is a literal block or, for `realtime`, a call that builds one. + const rasterThemes = [...themesBlock.matchAll(/^ {2}(\w+): /gm)].map((m) => m[1]); const vectorCss = [...vector.matchAll(/data-theme="(\w+)"/g)].map((m) => m[1]); const vectorChips = [...vector.matchAll(/data-tema="(\w+)"/g)].map((m) => m[1]); const same = (x, y) => x.length && x.join() === [...new Set(y)].join(); @@ -66,6 +67,15 @@ function regexLiteral(src, constName) { ); } +// ── 2b. Realtime tema — the drift cycles are the theme's identity ───────── +{ + const cycles = (src, name) => src.match(new RegExp(`const ${name} = \\[([^\\]]*)\\]`))?.[1]?.replace(/\s/g, ""); + const a = cycles(raster, "LAK_REALTIME_CYCLES"); + const b = cycles(vector, "REALTIME_CYCLES"); + check("realtime cycles (LAK_REALTIME_CYCLES ↔ REALTIME_CYCLES)", !!a && a === b, + a === b ? a : `raster: ${a} · vector: ${b}`); +} + // ── 3. Settings pane — mode + filter chips exist on both sides ──────────── { const rasterPane = -- 2.51.2