Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
HTML
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357<!doctype html><html lang="da"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /><title>laklok · Laer Klokken</title><meta name="description" content="Laer Klokken — a fast HTML chat client for the laklok.com community." /><meta name="theme-color" content="#b4643c" /><style> /* 🎪 laklok HTML chat client — the "vector" sister of disks/laklok.mjs (the "raster" piece). Same terracotta theme + token colors, plain DOM so it loads instantly instead of waiting on the AC runtime + worker boot. The settings pane (mode / tema / filter) and the theme palettes mirror the raster piece by name — keep them in lockstep. The update path lives in toolchain/laklok-sisters/PARITY.md: parity.mjs diffs the mirrored constants (run it after touching either sister) and sisters.mjs renders both side by side for the visual half. */ :root, :root[data-theme="ler"] { --bg: #b4643c; /* warm terracotta/rust (laklok background) */ --bg-deep: #8f4e2e; /* deeper rust for chrome wells */ --bg-soft: #c2734a; /* lifted panel */ --stripe-a: #7a3c1a; /* marquee stripes (mirror LAK_THEMES.stripeA/B) */ --stripe-b: #964e22; --line: rgba(220,150,100,.26); --text: #fffff0; /* cream message text */ --handle: #ffa078; /* coral handles */ --time: #dcb496; /* light brown timestamps */ --input-bg: #6f3c23; --input-line:#b46e46; --send-bg: #ff8c50; --send-text: #2a1308; } :root[data-theme="nat"] { --bg: #1a1e3e; --bg-deep: #12152e; --bg-soft: #232a55; --stripe-a: #141832; --stripe-b: #20264c; --line: rgba(90,110,190,.26); --text: #ebf0ff; --handle: #96b4ff; --time: #96a0d2; --input-bg: #101430; --input-line:#4656a0; --send-bg: #7890ff; --send-text: #0a0d20; } :root[data-theme="skov"] { --bg: #183824; --bg-deep: #10281a; --bg-soft: #1e462c; --stripe-a: #122c1c; --stripe-b: #1e422a; --line: rgba(90,150,110,.26); --text: #ebfff0; --handle: #aae696; --time: #96bea0; --input-bg: #0e2416; --input-line:#3c7850; --send-bg: #82dc96; --send-text: #08180c; } :root[data-theme="lakrids"] { --bg: #161418; --bg-deep: #0e0c10; --bg-soft: #221f26; --stripe-a: #0e0c10; --stripe-b: #1e1b22; --line: rgba(90,80,95,.26); --text: #f0eef4; --handle: #f0aabe; --time: #96919f; --input-bg: #0c0a0e; --input-line:#4a4452; --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 */ --log: #64ffdc; /* cyan system log */ --url: #78dcff; /* light blue links */ --prompt: #c8ffb4; /* soft green prompts */ --painting: #ffc88c; /* peachy paintings */ --kidlisp: #ff8cc8; /* pink kidlisp */ --clock: #ffd24b; /* gold clock refs */ --yt: #ff5050; /* red youtube links (theme.youtube 255,80,80) */ --heart: #ffdcf0; /* light pink hearts */ --ok: #64dc8c; /* connected */ --warn: #ffd278; /* connecting */ --bad: #ff8c78; /* offline */ --font: "Berkeley Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; } * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 15px; line-height: 1.45; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: none; }
/* ── Header ─────────────────────────────────────────────────────────── */ header { flex: 0 0 auto; position: relative; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, var(--bg-deep), var(--bg)); overflow: visible; user-select: none; z-index: 3; } /* Barber-pole circus stripes behind the marquee. */ .stripes { position: absolute; inset: 0; background: repeating-linear-gradient(90deg, var(--stripe-a) 0 14px, var(--stripe-b) 14px 28px); opacity: .5; animation: slide 6s linear infinite; } @keyframes slide { from { background-position: 0 0; } to { background-position: 56px 0; } } .header-row { position: relative; display: flex; align-items: center; gap: 12px; padding: 9px 66px 9px 14px; /* right gutter clears the corner QR */ } .wordmark { font-weight: 800; font-size: 22px; letter-spacing: -.5px; color: #5a2f1c; text-shadow: 0 1px 0 rgba(255,230,200,.5); } :root[data-theme="nat"] .wordmark, :root[data-theme="skov"] .wordmark, :root[data-theme="lakrids"] .wordmark { color: rgba(255,255,255,.55); text-shadow: 0 1px 0 rgba(0,0,0,.5); } .wordmark sup { font-size: 11px; color: #c06030; font-weight: 700; top: -.7em; } .marquee { flex: 1 1 auto; text-align: center; font-weight: 700; font-size: 18px; letter-spacing: 1px; white-space: nowrap; overflow: hidden; } .marquee b { display: inline-block; text-shadow: 1px 1px 0 #140a06; animation: bounce 1.6s ease-in-out infinite; } @keyframes bounce { 0%,100% { transform: translateY(2px); } 50% { transform: translateY(-2px); } } .presence { position: relative; display: flex; align-items: center; gap: 8px; padding: 0 66px 7px 14px; font-size: 12px; color: var(--text); } .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 6px currentColor; } .dot.ok { background: var(--ok); color: var(--ok); } .dot.bad { background: var(--bad); color: var(--bad); } .presence .count b { color: #fff; } .presence .status { margin-left: auto; opacity: .8; }
/* 📱 laklok.com QR — pinned top-right with a white quiet zone, spanning both header rows, same as the raster piece's paintQR. The gear sits to its left. */ #qrlink { position: absolute; top: 6px; right: 6px; z-index: 4; display: none; background: #fff; padding: 3px; line-height: 0; } #qr { display: block; image-rendering: pixelated; width: 50px; height: 50px; }
/* ⚙️ Settings toggle — a little hamburger by the wordmark's QR-corner. */ #gear { flex: 0 0 auto; width: 26px; height: 22px; border: 1px solid rgba(255,255,255,.6); border-radius: 4px; background: transparent; color: var(--text); font-family: var(--font); font-size: 13px; line-height: 1; cursor: pointer; padding: 0; } #gear:hover, #gear.open { border-color: var(--hover); color: var(--hover); }
/* ── Settings pane (mode / tema / filter — mirrors the raster pane) ─── */ #settings { position: absolute; top: 100%; right: 8px; background: var(--stripe-a); border: 1px solid var(--hover); padding: 8px 10px; font-size: 12px; display: none; min-width: 210px; z-index: 9; } #settings.open { display: block; } #settings h2 { margin: 0 0 6px; font-size: 12px; color: var(--hover); font-weight: 700; } #settings .row { display: flex; align-items: center; gap: 6px; margin: 5px 0; } #settings .row .lbl { width: 40px; opacity: .75; } .chip { border: 1px solid rgba(255,255,255,.5); border-radius: 3px; background: transparent; color: var(--text); font-family: var(--font); font-size: 11px; padding: 2px 7px; cursor: pointer; } .chip.sel { background: var(--hover); border-color: var(--hover); color: #14140a; font-weight: 700; } .chip:hover { border-color: var(--hover); }
/* ── Message list ───────────────────────────────────────────────────── */ #log { flex: 1 1 auto; overflow-y: auto; overflow-x: hidden; padding: 10px 14px 4px; scrollbar-color: var(--handle) transparent; } .loadmore { text-align: center; color: var(--time); font-size: 12px; padding: 6px 0 10px; opacity: .7; } .msg { padding: 2px 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; border-radius: 5px; position: relative; } .msg:hover { background: rgba(255,255,255,.06); } .from { color: var(--handle); font-weight: 600; } .from.me { color: #fff; } /* 📺 Televised guests — relayed in from YouTube live chat (via: "youtube"). Red banner mirrors the raster sister (disks/chat.mjs paint loop); the little `yt` chip walks back to the broadcast's popout live chat. */ .msg.tv { background: rgba(205, 30, 30, 0.18); border-left: 2px solid rgb(205, 30, 30); } .ytchip { background: #c00; color: #fff; font: 600 10px var(--sans); padding: 1px 5px; border-radius: 3px; margin-left: 6px; text-decoration: none; vertical-align: middle; } .ytchip:hover { background: #f22; } /* 🗣️ Pruttivox — @prutti's messages carry a `vox` chip that speaks them in his consented voice clone; the word being spoken lights up lime. Colors mirror the raster sister (disks/chat.mjs voxlink/voxword). */ .voxchip { background: none; border: 0; padding: 0 0 0 1px; margin-left: 6px; cursor: pointer; color: #ffaa3c; line-height: 0; vertical-align: middle; } .voxchip:hover { color: #ffdc78; } .voxchip.loading { color: #ffebb4; } .voxchip.playing { color: #beff50; } /* The glyph is drawn on the raster sister's pixel grid, so it must not be smoothed on the way up to DOM size. */ .voxchip svg { display: block; fill: currentColor; shape-rendering: crispEdges; } .body .lit, .body a.lit { color: #beff50 !important; } .body { word-break: break-word; } .time { color: var(--time); font-size: 11px; opacity: .8; margin-left: 2px; } .heart { color: var(--heart); font-size: 12px; } .sys .body { color: var(--log); } .deleted .body { color: #b48c8c; font-style: italic; }
/* 🔗 media-links filter: when on, only rows carrying a media link show. */ body.filter-links .msg:not(.has-media) { display: none; }
/* ✏️ own-row edit affordance (appears once signed in) */ .editbtn { display: none; border: 0; background: transparent; color: var(--time); font-family: var(--font); font-size: 11px; cursor: pointer; padding: 0 2px; } .msg.own:hover .editbtn { display: inline; } .editbtn:hover { color: var(--hover); } .editbtn.slet:hover, .editbtn.armed { color: var(--bad); display: inline; }
/* token colors */ .t-handle { color: var(--handle); cursor: pointer; } .t-url { color: var(--url); text-decoration: none; } .t-url.sensitive { color: #8c8c8c; } .t-prompt { color: var(--prompt); } .t-painting{ color: var(--painting); } .t-kidlisp { color: var(--kidlisp); } .t-clock { color: var(--clock); } .t-url.yt { color: var(--yt); } /* YouTube links read red, like theme.youtube */ .t-handle:hover, .t-url:hover { color: var(--hover); }
/* ── Media embeds (mirror chat.mjs previews) ──────────────────────────── #painting → 64px pixel preview · YouTube → 16:9 thumb linking out · direct image/video/audio files → native tags · other links → OG cards. */ .embeds { flex: 1 0 100%; display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 4px; } .embed { display: block; } .embed.painting { width: 64px; height: 64px; object-fit: cover; image-rendering: pixelated; border: 1px solid var(--painting); cursor: zoom-in; } /* Fixed frames (like raster's preview boxes) so late image loads don't reflow the scroll position. */ .embed.img { width: 220px; height: 150px; object-fit: cover; border-radius: 4px; cursor: zoom-in; background: rgba(0,0,0,.2); } .embed.yt { position: relative; width: 180px; height: 101px; } /* LINK_PREVIEW 16:9 */ .embed.yt img { width: 100%; height: 100%; object-fit: cover; border-radius: 4px; } .embed.yt .play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 26px; text-shadow: 0 1px 4px rgba(0,0,0,.8); pointer-events: none; } .embed.vid { width: 260px; height: 150px; object-fit: contain; border-radius: 4px; background: #000; } .embed.aud { width: 240px; height: 32px; } .embed.og { display: none; gap: 8px; align-items: center; text-decoration: none; border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; background: rgba(0,0,0,.15); color: var(--text); max-width: 320px; } .embed.og.ready { display: flex; } .embed.og .thumb { width: 90px; height: 51px; object-fit: cover; border-radius: 3px; flex: 0 0 auto; } .embed.og .fav { width: 16px; height: 16px; flex: 0 0 auto; } .embed.og .t { font-size: 12px; line-height: 1.3; overflow: hidden; } .embed.og .t .site { color: var(--time); font-size: 10px; display: block; } .embed.og:hover { border-color: var(--hover); } /* SoundCloud cards carry a ▶ that swaps the card for the widget in place. */ .embed.og .listen { flex: 0 0 auto; align-self: center; margin-left: auto; padding: 2px 8px; border: 1px solid var(--time); border-radius: 3px; color: inherit; background: none; font: inherit; font-size: 12px; cursor: pointer; } .embed.og .listen:hover { border-color: var(--hover); } .embed.player { width: 300px; max-width: 100%; height: 166px; border: 0; border-radius: 4px; }
/* 🔍 Lightbox — the vector's take on chat.mjs's fullscreen painting modal. */ #lightbox { position: fixed; inset: 0; z-index: 99; display: none; background: rgba(10,6,4,.88); cursor: zoom-out; align-items: center; justify-content: center; padding: 20px; } #lightbox.open { display: flex; } #lightbox img { max-width: 92vw; max-height: 88vh; image-rendering: pixelated; }
/* ── Composer ───────────────────────────────────────────────────────── */ footer { flex: 0 0 auto; border-top: 1px solid var(--line); background: var(--bg-deep); padding: 9px 12px calc(9px + env(safe-area-inset-bottom)); display: flex; gap: 9px; align-items: center; } #input { flex: 1 1 auto; min-width: 0; background: var(--input-bg); color: var(--text); border: 1px solid var(--input-line); border-radius: 8px; padding: 9px 11px; font-family: var(--font); font-size: 15px; outline: none; } #input::placeholder { color: var(--time); } #input:focus { border-color: var(--clock); } body.editing #input { border-color: var(--warn); } #send { flex: 0 0 auto; background: var(--send-bg); color: var(--send-text); border: 0; border-radius: 8px; padding: 9px 16px; font-family: var(--font); font-weight: 700; font-size: 15px; cursor: pointer; } #send:disabled { background: var(--bg-soft); color: var(--time); cursor: not-allowed; } .gate { font-size: 11px; color: var(--text); opacity: .85; padding: 0 14px 8px; text-align: center; } .gate a { color: var(--clock); } .gate .cancel { color: var(--bad); cursor: pointer; margin-left: 6px; } a { color: var(--url); }</style><script type="module" src="https://aesthetic.computer/aesthetic.computer/lib/visit-tracker.mjs" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head><body> <header> <div class="stripes"></div> <div class="header-row"> <div class="wordmark">laklok<sup>.com</sup></div> <div class="marquee" id="marquee"></div> <button id="gear" title="indstillinger" aria-label="indstillinger">≡</button> </div> <a id="qrlink" href="https://laklok.com" title="laklok.com — scan, or click for the raster piece" aria-label="laklok.com QR code"><canvas id="qr"></canvas></a> <div class="presence"> <span class="dot" id="dot"></span> <span class="count"><b id="online">0</b> online · <b id="here">0</b> here</span> <span class="status" id="status">connecting…</span> </div> <div id="settings"> <h2>indstillinger</h2> <div class="row" data-setting="mode"> <span class="lbl">mode</span> <button class="chip" data-mode="raster">raster</button> <button class="chip sel" data-mode="vector">vector</button> </div> <div class="row" data-setting="tema"> <span class="lbl">tema</span> <button class="chip" data-tema="ler">ler</button> <button class="chip" data-tema="nat">nat</button> <button class="chip" data-tema="skov">skov</button> <button class="chip" data-tema="lakrids">lakrids</button> <button class="chip" data-tema="realtime">realtime</button> </div> <div class="row" data-setting="filter"> <span class="lbl">filter</span> <button class="chip" data-links="0">alle</button> <button class="chip" data-links="1">links</button> </div> </div> </header>
<main id="log"> <div class="loadmore" id="loadmore">— laer klokken —</div> </main>
<div class="gate" id="gate"> reading live. posting opens when you’re signed in at <a href="https://laklok.com" target="_blank" rel="noopener">laklok.com</a>. </div>
<footer> <input id="input" placeholder="say something to the clock…" autocomplete="off" maxlength="128" disabled /> <button id="send" disabled>send</button> </footer>
<div id="lightbox"><img alt="" /></div>
<script type="module">import { startAccountActivity } from "/aesthetic.computer/lib/account-activity.mjs";function laklokEvent(name) { window.acAccountActivity?.action("laklok_" + name); }// ── laklok HTML chat client ────────────────────────────────────────────────// Connects to the live `chat-clock` instance (Laer Klokken). History via REST,// realtime via WebSocket — same wire protocol as the AC piece, no AC runtime.// Posting reuses the Auth0 session the AC piece leaves in localStorage when// you sign in at laklok.com — same origin, same token the raster client sends.
const REST = "https://aesthetic.computer/api/chat-messages?instance=clock";const HANDLE_API = "https://aesthetic.computer/handle";const WS_HOST = "chat-clock.aesthetic.computer";
const $log = document.getElementById("log");const $loadmore = document.getElementById("loadmore");const $online = document.getElementById("online");const $here = document.getElementById("here");const $status = document.getElementById("status");const $dot = document.getElementById("dot");const $input = document.getElementById("input");const $send = document.getElementById("send");const $gate = document.getElementById("gate");const $gear = document.getElementById("gear");const $settings = document.getElementById("settings");$log.addEventListener("click", e => { if (e.isTrusted && e.target.closest("a.embed")) laklokEvent("media_open_requested");});$log.addEventListener("play", e => { if (e.isTrusted && e.target.matches("video.embed,audio.embed")) laklokEvent("media_open_requested");}, true);
// 🎪 Circus marquee — each letter its own color + bounce phase.const CIRCUS = ["#ff5050","#ffd246","#78d2ff","#8cf096","#ff96dc","#b496ff"];document.getElementById("marquee").innerHTML = [..."Laer Klokken"] .map((c, i) => c === " " ? " " : `<b style="color:${CIRCUS[i % CIRCUS.length]};animation-delay:${(-i * .13).toFixed(2)}s">${c}</b>`) .join("");
// 📱 laklok.com QR — same generator dep the raster piece uses (paintQR), served// from this origin, so both sisters encode the identical code. Fails quiet.import("/aesthetic.computer/dep/@akamfoad/qr/qr.mjs") .then(({ qrcode, ErrorCorrectLevel }) => { const cells = qrcode("https://laklok.com", { errorCorrectLevel: ErrorCorrectLevel.L, }).modules; const size = cells.length; const canvas = document.getElementById("qr"); canvas.width = size; canvas.height = size; // 1 canvas px per cell, CSS scales up const ctx = canvas.getContext("2d"); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, size, size); ctx.fillStyle = "#000"; for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (cells[y][x]) ctx.fillRect(x, y, 1, 1); document.getElementById("qrlink").style.display = "block"; }) .catch((e) => console.warn("laklok QR generation failed:", e));
// ── 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", "realtime"];const query = new URLSearchParams(location.search);let theme = query.get("theme") || localStorage.getItem("laklok:theme") || "ler";if (!THEMES.includes(theme)) theme = "ler";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; for (const chip of $settings.querySelectorAll("[data-tema]")) { chip.classList.toggle("sel", chip.dataset.tema === name); } localStorage.setItem("laklok:theme", name); paintRealtime();}function applyLinksFilter(on) { linksOnly = on; document.body.classList.toggle("filter-links", on); for (const chip of $settings.querySelectorAll("[data-links]")) { chip.classList.toggle("sel", (chip.dataset.links === "1") === on); } localStorage.setItem("laklok:links", on ? "1" : "0");}applyTheme(theme);applyLinksFilter(linksOnly);
$gear.onclick = (e) => { e.stopPropagation(); const open = $settings.classList.toggle("open"); if (open) laklokEvent("settings_opened"); $gear.classList.toggle("open", open);};document.addEventListener("click", (e) => { if (!$settings.contains(e.target) && e.target !== $gear) { $settings.classList.remove("open"); $gear.classList.remove("open"); }});$settings.addEventListener("click", (e) => { const chip = e.target.closest(".chip"); if (!chip) return; if (chip.dataset.mode === "raster") { laklokEvent("mode_switch_requested"); location.href = "https://laklok.com"; } if (chip.dataset.tema) { if (theme !== chip.dataset.tema) laklokEvent("theme_changed"); applyTheme(chip.dataset.tema); reportTheme(); } if (chip.dataset.links !== undefined) { if (linksOnly !== (chip.dataset.links === "1")) laklokEvent("filter_changed"); applyLinksFilter(chip.dataset.links === "1"); }});
// ── Handle tinting ──────────────────────────────────────────────────────────// v1: deterministic hue per handle (real per-character /api/handle-colors is a// follow-up). Keeps the social-graph color cue without an extra round-trip.function handleColor(name) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return `hsl(${h % 360} 75% 72%)`;}
// ── Sensitive URLs (mirror lib/chat-highlighting.mjs) ───────────────────────const SENSITIVE = [ /shit/i, /fuck/i, /cock/i, /dick/i, /pussy/i, /porn/i, /xxx/i, /penis/i, /vagina/i, /anal/i, /sex(?!ton|y)/i, /cum/i, /nude/i, /nigger/i, /nigga/i, /fag/i, /cunt/i, /whore/i, /slut/i, /gore/i, /kill/i, /murder/i, /rape/i, /nazi/i, /hitler/i,];const isSensitiveUrl = (url) => SENSITIVE.some((p) => p.test(url));
// ── Media links (mirror LAK_MEDIA_LINK in disks/laklok.mjs verbatim) ───────const MEDIA_LINK = /(youtube\.com\/(watch|shorts|embed)|youtu\.be\/|vimeo\.com\/|tiktok\.com\/|soundcloud\.com\/|bandcamp\.com|\.(png|jpe?g|gif|webp|mp4|mov|webm|mp3|wav|ogg|m4a)\b)/i;const URLISH = /https?:\/\/[^\s]+|www\.[^\s]+/gi;function hasMediaLink(text) { if (!text) return false; const urls = text.match(URLISH); return !!urls && urls.some((u) => MEDIA_LINK.test(u));}
// ── Media embeds — the vector's take on chat.mjs's inline previews ─────────// #painting → 64px preview (confirmed via /api/painting-code, image from// /media/paintings/<code>.png); YouTube → mqdefault thumb linking out (the// raster jumps out to youtube.com too); direct image/video/audio files →// native tags; every other non-sensitive URL → an OG card via// /api/og-preview + the /api/og-image proxy. All three endpoints send// Access-Control-Allow-Origin: *, so cross-origin from laklok.com is fine.const AC_API = "https://aesthetic.computer";const YT_LINK = /(?:youtube\.com\/(?:watch\?v=|shorts\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; // mirror lib/chat-highlighting.mjsconst FILE_IMG = /\.(png|jpe?g|gif|webp)([?#]|$)/i;const FILE_VID = /\.(mp4|mov|webm)([?#]|$)/i;const FILE_AUD = /\.(mp3|wav|ogg|m4a)([?#]|$)/i;const PAINTING_TOKEN = /(?<![\w&])#([a-z0-9]{3,})\b/gi; // mirror TOKEN's #painting arm
const ogCache = new Map(); // url → metadata | null (failed — never retried, like ogPreviewCache)const paintingCache = new Map(); // code → true | false (confirmed / not-a-painting)
// Rows hydrate their OG cards + painting confirms only once scrolled into// view, so a 40-message history seed doesn't fire 40 API calls at boot.const lazyRows = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; lazyRows.unobserve(entry.target); entry.target._hydrateEmbeds?.(); } }, { root: $log, rootMargin: "200px" },);
const normalizeUrl = (tok) => (/^www\./i.test(tok) ? "https://" + tok : tok).replace(/[),.;!?]+$/, "");
function ytEmbed(id) { const a = document.createElement("a"); a.className = "embed yt"; a.href = "https://www.youtube.com/watch?v=" + id; // same jump-out as openYoutubeModal a.target = "_blank"; a.rel = "noopener nofollow"; const img = document.createElement("img"); img.loading = "lazy"; img.alt = "YouTube"; img.src = `https://i.ytimg.com/vi/${id}/mqdefault.jpg`; // same thumb chat.mjs loads img.onerror = () => a.remove(); a.append(img, span("play", "▶")); return a;}
function fileEmbed(url, kind) { let el; if (kind === "img") { el = document.createElement("img"); el.loading = "lazy"; el.alt = url; el.onclick = () => lightbox(url); } else if (kind === "vid") { el = document.createElement("video"); el.controls = true; el.playsInline = true; el.preload = "metadata"; } else { el = document.createElement("audio"); el.controls = true; el.preload = "none"; } el.className = "embed " + kind; el.src = url; el.onerror = () => el.remove(); return el;}
function ogEmbed(url) { const a = document.createElement("a"); a.className = "embed og"; a.href = url; a.target = "_blank"; a.rel = "noopener nofollow"; a._hydrate = async () => { let meta = ogCache.get(url); if (meta === undefined) { try { const r = await fetch(`${AC_API}/api/og-preview?url=${encodeURIComponent(url)}`); meta = r.ok ? await r.json() : null; } catch { meta = null; } ogCache.set(url, meta); } if (!meta || (!meta.title && !meta.image)) return; // no card — the plain link already shows if (meta.image) { const img = document.createElement("img"); img.className = "thumb"; img.loading = "lazy"; img.alt = ""; img.src = `${AC_API}/api/og-image?url=${encodeURIComponent(meta.image)}`; img.onerror = () => img.remove(); a.append(img); } else if (meta.favicon) { const fav = document.createElement("img"); fav.className = "fav"; fav.loading = "lazy"; fav.alt = ""; fav.src = `${AC_API}/api/og-image?url=${encodeURIComponent(meta.favicon)}`; fav.onerror = () => fav.remove(); a.append(fav); } const t = span("t", ""); let host = url; try { host = new URL(url).hostname; } catch {} t.append(span("site", meta.siteName || host), document.createTextNode(meta.title || url)); a.append(t); if (meta.player?.kind === "soundcloud" && /^https:\/\/w\.soundcloud\.com\/player\//.test(meta.player.src)) { const listen = document.createElement("button"); listen.className = "listen"; listen.textContent = "▶"; listen.title = "lyt her"; listen.onclick = (e) => { e.preventDefault(); e.stopPropagation(); // play in place, don't open the link laklokEvent("media_open_requested"); const frame = document.createElement("iframe"); frame.className = "embed player"; frame.allow = "autoplay"; frame.src = meta.player.src; frame.title = meta.title || "SoundCloud"; a.replaceWith(frame); }; a.append(listen); } a.classList.add("ready"); }; return a;}
function paintingEmbed(code) { const img = document.createElement("img"); img.className = "embed painting"; img.alt = "#" + code; img.title = "painting #" + code; img.style.display = "none"; img.onclick = () => lightbox(img.src); img._hydrate = async () => { let ok = paintingCache.get(code); if (ok === undefined) { try { const r = await fetch(`${AC_API}/api/painting-code?code=${code}`); ok = r.ok; // 404 → ordinary hashtag, not a painting (nonPaintingCodeCache) } catch { ok = false; } paintingCache.set(code, ok); } if (!ok) return; img.src = `${AC_API}/media/paintings/${code}.png`; // short-code path chat.mjs resolves img.onerror = () => img.remove(); // broken load sticks, no per-frame retry img.style.display = "block"; }; return img;}
function buildEmbeds(text) { const wrap = document.createElement("div"); wrap.className = "embeds"; const seenUrl = new Set(); for (const tok of text.match(URLISH) || []) { const url = normalizeUrl(tok); if (seenUrl.has(url) || isSensitiveUrl(url)) continue; seenUrl.add(url); const yt = url.match(YT_LINK); if (yt) wrap.append(ytEmbed(yt[1])); else if (FILE_IMG.test(url)) wrap.append(fileEmbed(url, "img")); else if (FILE_VID.test(url)) wrap.append(fileEmbed(url, "vid")); else if (FILE_AUD.test(url)) wrap.append(fileEmbed(url, "aud")); else wrap.append(ogEmbed(url)); } PAINTING_TOKEN.lastIndex = 0; const seenCode = new Set(); let m; while ((m = PAINTING_TOKEN.exec(text))) { const code = m[1].toLowerCase(); if (!seenCode.has(code)) { seenCode.add(code); wrap.append(paintingEmbed(code)); } } return wrap.childElementCount ? wrap : null;}
function attachEmbeds(row, text) { row.querySelector(".embeds")?.remove(); const wrap = buildEmbeds(text || ""); if (!wrap) return; row.append(wrap); row._hydrateEmbeds = () => { for (const el of wrap.children) el._hydrate?.(); }; lazyRows.observe(row);}
// 🔍 Lightbox for paintings + direct images.const $lightbox = document.getElementById("lightbox");const $lightboxImg = $lightbox.querySelector("img");function lightbox(src) { laklokEvent("media_open_requested"); if (!src) return; $lightboxImg.src = src; $lightbox.classList.add("open");}$lightbox.onclick = () => { $lightbox.classList.remove("open"); $lightboxImg.src = ""; };document.addEventListener("keydown", (e) => { if (e.key === "Escape") $lightbox.classList.remove("open");});
// ── Inline color codes (mirror lib/color-codes.mjs + chat's validity) ──────// A chatter can dye their own words with `\red\…\reset\`; anything that isn't// a real color stays literal typing. Same rules as the raster chat.const COLOR_KEYWORDS = ["reset", "default", "base", "transparent", "clear"];function isChatColorCode(code) { const c = code.trim().toLowerCase(); if (!c) return false; if (COLOR_KEYWORDS.includes(c)) return true; if (/^\d{1,3}(,\d{1,3}){2,3}$/.test(c)) return true; // r,g,b(,a) return /^[a-z]+$/.test(c) && CSS.supports("color", c);}// Split into [text, code, text, code, … text]; invalid codes stay in the text.function splitChatColors(str) { const parts = [""]; let i = 0; while (i < str.length) { if (str[i] !== "\\") { parts[parts.length - 1] += str[i]; i += 1; continue; } const close = str[i + 1] === "\\" ? -1 : str.indexOf("\\", i + 1); const code = close > i ? str.slice(i + 1, close) : null; if (code !== null && isChatColorCode(code)) { parts.push(code, ""); i = close + 1; } else { parts[parts.length - 1] += "\\"; i += 1; } } return parts;}function cssForCode(code) { const c = code.trim().toLowerCase(); if (COLOR_KEYWORDS.includes(c)) return null; // back to theme text color if (c.includes(",")) { const p = c.split(",").map((n) => parseInt(n.trim(), 10) || 0); return p.length >= 4 ? `rgba(${p[0]},${p[1]},${p[2]},${p[3] / 255})` : `rgb(${p[0]},${p[1]},${p[2]})`; } return c;}
// ── Token parsing ───────────────────────────────────────────────────────────// Mirrors lib/chat-highlighting.mjs: @handles, urls, 'prompts', #paintings,// $kidlisp, *clock. Prompts require a word boundary before the opening quote// so contractions ("it's… i'll…") never read as one long green prompt, and// handles can't start mid-word (so emails don't half-match).const TOKEN = /((?<=^|[\s([{])'[^']*'|https?:\/\/[^\s]+|www\.[^\s]+|(?<![\w.@])@[a-z0-9][a-z0-9._]*|(?<![\w&])#[a-z0-9]{3,}|(?<!\w)\$[a-z0-9]+|(?<!\w)\*[a-z0-9]+)/gi;
function renderTokens(frag, text, inheritColor) { let last = 0, m; TOKEN.lastIndex = 0; const textNode = (t) => { if (!t) return; if (inheritColor) { const s = document.createElement("span"); s.style.color = inheritColor; s.textContent = t; frag.append(s); } else { frag.append(document.createTextNode(t)); } }; while ((m = TOKEN.exec(text))) { textNode(text.slice(last, m.index)); const tok = m[0]; let el; if (tok[0] === "@") { el = span("t-handle", tok); el.style.color = handleColor(tok.slice(1).toLowerCase()); el.title = "open " + tok; el.onclick = () => open("https://aesthetic.computer/" + tok); } else if (/^(https?:|www\.)/i.test(tok)) { el = document.createElement("a"); el.className = "t-url"; if (YT_LINK.test(tok)) el.classList.add("yt"); // red youtube links, like raster const href = /^www\./i.test(tok) ? "https://" + tok : tok; if (isSensitiveUrl(tok)) { el.classList.add("sensitive"); el.textContent = "[click to reveal link]"; el.title = "sensitive link"; el.onclick = (ev) => { if (el.textContent !== tok) { ev.preventDefault(); el.textContent = tok; } }; } else { el.textContent = tok; } el.href = href; el.target = "_blank"; el.rel = "noopener nofollow"; } else if (tok[0] === "'") { el = span("t-prompt", tok); } else if (tok[0] === "#") { el = span("t-painting", tok); el.title = "painting " + tok; } else if (tok[0] === "$") { el = span("t-kidlisp", tok); el.title = "kidlisp " + tok; el.style.cursor = "pointer"; el.onclick = () => open("https://aesthetic.computer/" + tok); } else if (tok[0] === "*") { el = span("t-clock", tok); } frag.append(el); last = m.index + tok.length; } textNode(text.slice(last));}
function renderBody(text) { const frag = document.createDocumentFragment(); const parts = splitChatColors(text || ""); let color = null; for (let i = 0; i < parts.length; i++) { if (i % 2 === 0) renderTokens(frag, parts[i], color); else color = cssForCode(parts[i]); } return frag;}function span(cls, txt) { const s = document.createElement("span"); s.className = cls; s.textContent = txt; return s; }
// ── Time ──────────────────────────────────────────────────────────────────────function ago(when) { const t = new Date(when).getTime(); const s = Math.max(0, (Date.now() - t) / 1000); if (s < 45) return "now"; if (s < 3600) return Math.round(s / 60) + "m"; if (s < 86400) return Math.round(s / 3600) + "h"; return Math.round(s / 86400) + "d";}
// ── 🗣️ Pruttivox ──────────────────────────────────────────────────────────────// A `vox` chip on @prutti's messages fetches /api/pruttivox (server verifies// authorship, caches mp3 + word timings on the CDN) and speaks the message.// Word i in the timing table is displayed token i — whitespace split, with a// URL counting as one word (the voice says "link"), matching the raster// sister and the server tokenizer. Highlight clocks on audio.currentTime.const VOX_HANDLE = "prutti";// The speaker glyph, mirrored pixel for pixel from the raster sister// (VOX_CONE_ROWS in disks/chat.mjs): a cone in cols 0–3, then two waves at// cols 5 and 7. Drawn at 2× so it carries at DOM sizes; parity.mjs diffs the// row table against chat.mjs, so edit both or neither.const VOX_CONE_ROWS = [1, 2, 3, 4, 3, 2, 1]; // Row widths, all left-aligned.const VOX_ICON_W = 8;const VOX_ICON_H = 7;const SVG_NS = "http://www.w3.org/2000/svg";
function voxGlyph() { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("viewBox", `0 0 ${VOX_ICON_W} ${VOX_ICON_H}`); svg.setAttribute("width", VOX_ICON_W * 2); svg.setAttribute("height", VOX_ICON_H * 2); svg.setAttribute("aria-hidden", "true"); const rect = (x, y, w, h) => { const r = document.createElementNS(SVG_NS, "rect"); r.setAttribute("x", x); r.setAttribute("y", y); r.setAttribute("width", w); r.setAttribute("height", h); svg.append(r); }; VOX_CONE_ROWS.forEach((w, row) => rect(0, row, w, 1)); rect(5, 2, 1, 3); // Near wave. rect(7, 1, 1, 5); // Far wave. return svg;}
let vox = null; // { id, audio, chip, spans, words, raf }
function voxStop() { if (!vox) return; vox.audio.pause(); cancelAnimationFrame(vox.raf); vox.chip.classList.remove("playing", "loading"); for (const s of vox.spans) s.classList.remove("lit"); vox = null;}
// Wrap the body's text into one span per whitespace token, once per row.// Token elements (.t-*) are atomic: a URL is one spoken word; a quoted// prompt with spaces occupies one slot per word so the whole run lights up.function voxWords(body) { if (body._voxSpans) return body._voxSpans; const spans = []; const walk = (node) => { for (const child of [...node.childNodes]) { if (child.nodeType === Node.TEXT_NODE) { const frag = document.createDocumentFragment(); for (const bit of child.textContent.split(/(\s+)/)) { if (!bit) continue; if (/^\s+$/.test(bit)) frag.append(bit); else { const s = span("", bit); spans.push(s); frag.append(s); } } child.replaceWith(frag); } else if (child.nodeType === Node.ELEMENT_NODE) { if (child.classList.contains("ytchip") || child.classList.contains("voxchip")) continue; const isToken = [...child.classList].some((c) => c.startsWith("t-")); if (!isToken) { walk(child); continue; } // color wrapper — split inside if (child.classList.contains("t-url")) spans.push(child); else { const count = child.textContent.split(/\s+/).filter(Boolean).length || 1; for (let i = 0; i < count; i += 1) spans.push(child); } } } }; walk(body); body._voxSpans = spans; return spans;}
async function voxToggle(row, chip) { if (vox && vox.id === row.dataset.id) return voxStop(); voxStop(); chip.classList.add("loading"); const my = (vox = { id: row.dataset.id, chip, spans: [], words: [], raf: 0, audio: new Audio() }); try { const r = await fetch(`${AC_API}/api/pruttivox?id=${encodeURIComponent(my.id)}`); if (!r.ok) throw new Error(`pruttivox ${r.status}`); const data = await r.json(); if (vox !== my) return; my.words = data.words || []; my.spans = voxWords(row.querySelector(".body")); my.audio.src = data.audio; my.audio.onended = () => { if (vox === my) voxStop(); }; await my.audio.play(); if (vox !== my) { my.audio.pause(); return; } chip.classList.remove("loading"); chip.classList.add("playing"); const tick = () => { if (vox !== my) return; const t = my.audio.currentTime; let index = -1; for (let i = 0; i < my.words.length; i += 1) { if (t >= my.words[i].s) index = i; else break; } // A multi-word token occupies several slots of the same element, so // resolve to the element first — a plain index toggle would un-light it. const active = index >= 0 ? my.spans[index] : null; for (const s of my.spans) s.classList.toggle("lit", s === active); my.raf = requestAnimationFrame(tick); }; tick(); } catch (err) { console.warn("🗣️ vox failed:", err); if (vox === my) { chip.classList.remove("loading"); vox = null; } }}
// ── Rendering ──────────────────────────────────────────────────────────────────const seen = new Set();function nearBottom() { return $log.scrollHeight - $log.scrollTop - $log.clientHeight < 80; }
// A starred timestamp marks a re-edited message — same as the raster piece.function timeLabel(row) { return ago(row.dataset.when) + (row.dataset.edited ? "*" : "");}
function decorateRow(row, msg) { row.dataset.when = new Date(msg.when).toISOString(); if (msg.edited) row.dataset.edited = "1"; row.classList.toggle("has-media", !msg.deleted && hasMediaLink(msg.text || "")); row.classList.toggle("tv", msg.via === "youtube"); // 📺 televised guests const fromHandle = (msg.from || "").replace(/^@/, "").toLowerCase(); row.classList.toggle("own", !!auth.handle && fromHandle === auth.handle);}
function rowFor(msg) { const row = document.createElement("div"); row.className = "msg" + (msg.deleted ? " deleted" : ""); row.dataset.id = msg.id || ""; const from = document.createElement("span"); from.className = "from"; from.textContent = msg.from || "anon"; if (msg.from && msg.from !== "anon") from.style.color = handleColor(msg.from.replace(/^@/, "").toLowerCase()); const body = document.createElement("span"); body.className = "body"; if (msg.deleted) body.textContent = "[deleted]"; else body.append(renderBody(msg.text || "")); const time = document.createElement("span"); time.className = "time"; time.title = new Date(msg.when).toLocaleString(); row.append(from, body, time); // 📺 Televised guests carry a `yt` chip back to the popout live chat. if (msg.via === "youtube" && msg.link) { const chip = document.createElement("a"); chip.className = "ytchip"; chip.textContent = "yt"; chip.href = msg.link; chip.target = "_blank"; chip.rel = "noopener"; body.append(chip); } // 🗣️ @prutti's messages carry a `vox` chip that speaks them aloud. if (msg.id && !msg.deleted && (msg.from || "").replace(/^@/, "").toLowerCase() === VOX_HANDLE) { const chip = document.createElement("button"); chip.className = "voxchip"; chip.append(voxGlyph()); chip.title = "hør beskeden (syntetisk stemme)"; chip.setAttribute("aria-label", "hør beskeden (syntetisk stemme)"); chip.onclick = () => voxToggle(row, chip); body.append(chip); } if (msg.hearts > 0) { const h = span("heart", "♥" + msg.hearts); row.append(h); } // ✏️ own messages get edit + delete affordances once signed in (CSS shows // them). Delete arms on first click ("sikker?") — the raster confirms via // its copy modal; both end in the same chat:delete wire message. if (msg.id && !msg.deleted) { const edit = document.createElement("button"); edit.className = "editbtn"; edit.textContent = "✎ ret"; edit.title = "ret beskeden"; edit.onclick = () => beginEdit(row); const del = document.createElement("button"); del.className = "editbtn slet"; del.textContent = "✕ slet"; del.title = "slet beskeden"; del.onclick = () => { if (del.classList.contains("armed")) { postDelete(row.dataset.id); } else { del.classList.add("armed"); del.textContent = "sikker?"; setTimeout(() => { del.classList.remove("armed"); del.textContent = "✕ slet"; }, 2500); } }; row.append(edit, del); } decorateRow(row, msg); time.textContent = timeLabel(row); row._text = msg.text || ""; if (!msg.deleted) attachEmbeds(row, msg.text); return row;}
function appendMessage(msg, { prepend = false } = {}) { if (msg.id && seen.has(msg.id)) return; if (msg.id) seen.add(msg.id); const stick = !prepend && nearBottom(); const row = rowFor(msg); if (prepend) $loadmore.after(row); else $log.append(row); if (stick) $log.scrollTop = $log.scrollHeight;}
// ── History (REST) ──────────────────────────────────────────────────────────────let oldestCursor = null, loadingMore = false, exhausted = false;async function loadHistory(before = null) { if (loadingMore || exhausted) return; loadingMore = true; $loadmore.textContent = "loading…"; try { const url = REST + "&limit=40" + (before ? "&before=" + encodeURIComponent(before) : ""); const r = await fetch(url); const data = await r.json(); const msgs = data.messages || []; if (before) { const prevH = $log.scrollHeight, prevT = $log.scrollTop; for (let i = msgs.length - 1; i >= 0; i--) appendMessage(msgs[i], { prepend: true }); $log.scrollTop = prevT + ($log.scrollHeight - prevH); // preserve scroll anchor } else { msgs.forEach((m) => appendMessage(m)); $log.scrollTop = $log.scrollHeight; } oldestCursor = data.nextBefore || oldestCursor; if (!msgs.length || !data.nextBefore) { exhausted = true; $loadmore.textContent = "— laer klokken —"; } else $loadmore.textContent = "↑ older"; } catch (e) { $loadmore.textContent = "↻ retry (offline)"; console.warn("history failed", e); } loadingMore = false;}$loadmore.onclick = () => { if (!exhausted) { laklokEvent("history_older_requested"); loadHistory(oldestCursor); } };$log.addEventListener("scroll", () => { if ($log.scrollTop < 60 && !exhausted) loadHistory(oldestCursor); });
// ── Presence ──────────────────────────────────────────────────────────────────function setStatus(state, text) { $dot.className = "dot" + (state === "ok" ? " ok" : state === "bad" ? " bad" : ""); $status.textContent = text;}function setPresence(online, here) { if (Array.isArray(online)) $online.textContent = online.length; else if (typeof online === "number") $online.textContent = online; if (Array.isArray(here)) $here.textContent = here.length;}
// ── Auth — reuse the Auth0 session the AC runtime cached on this origin ────// Signing in at laklok.com (the raster piece) stores the SPA SDK cache under// `@@auth0spajs@@…` keys; posting sends the same access token the piece would.const auth = { token: null, sub: null, handle: null };const accountActivity = startAccountActivity(window, document, { getUser: () => auth.sub ? { sub: auth.sub } : null, getToken: () => { const session = findAcSession(); return session?.sub === auth.sub ? session.token : null; },});accountActivity.load("aesthetic.computer/disks/laklok-vector");accountActivity.ready();
// 📊 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]; return JSON.parse(atob(body.replace(/-/g, "+").replace(/_/g, "/"))).sub || null; } catch { return null; }}
function findAcSession() { const now = Date.now() / 1000; let best = null, expired = false; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key || !key.startsWith("@@auth0spajs@@")) continue; let entry; try { entry = JSON.parse(localStorage.getItem(key)); } catch { continue; } const body = entry?.body; const token = body?.access_token; if (!token) continue; if ((entry.expiresAt || 0) <= now + 30) { expired = true; continue; } const sub = body?.decodedToken?.claims?.sub || body?.decodedToken?.user?.sub || decodeJwtSub(token); if (sub) best = { token, sub }; } return best || (expired ? { expired: true } : null);}
function setGate(html) { $gate.innerHTML = html; }
async function initAuth() { const session = findAcSession(); if (!session || session.expired) { $input.disabled = true; $send.disabled = true; setGate( session?.expired ? 'session expired — sign in again at <a href="https://laklok.com" target="_blank" rel="noopener">laklok.com</a>, then reload.' : 'reading live. posting opens when you’re signed in at <a href="https://laklok.com" target="_blank" rel="noopener">laklok.com</a>.', ); return; } auth.token = session.token; auth.sub = session.sub; $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) { const data = await r.json(); if (data.handle) { auth.handle = String(data.handle).replace(/^@/, "").toLowerCase(); setGate(`posting as <b>@${auth.handle}</b>.`); // Light up the ✏️ affordance on rows that are yours. for (const row of $log.querySelectorAll(".msg")) { const from = row.querySelector(".from")?.textContent?.replace(/^@/, "").toLowerCase(); row.classList.toggle("own", from === auth.handle); } } } } catch { /* handle stays unknown; posting still works */ }}
// ── Live WebSocket ──────────────────────────────────────────────────────────────// Defensive: `content` arrives as a JSON string for most types, already-object// for a few (e.g. presence). parse() copes with both.function parse(c) { if (typeof c !== "string") return c; try { return JSON.parse(c); } catch { return c; } }
let ws, reconnect = 800;function connect() { setStatus("warn", "connecting…"); try { ws = new WebSocket("wss://" + WS_HOST); } catch { return scheduleReconnect(); }
ws.onopen = () => { reconnect = 800; setStatus("ok", "live"); }; ws.onclose = () => { setStatus("bad", "offline"); scheduleReconnect(); }; ws.onerror = () => { try { ws.close(); } catch {} }; ws.onmessage = (e) => { let env; try { env = JSON.parse(e.data); } catch { return; } const { type } = env; const content = parse(env.content); if (type === "connected") { setStatus("ok", "live"); setPresence(content.chatters ?? content.handles, content.here); // The WS `connected` payload already carries recent history; REST seed // covers the first paint, so only merge anything newer here. (content.messages || []).forEach((m) => appendMessage(m)); $log.scrollTop = $log.scrollHeight; } else if (type === "message") { appendMessage(content); } else if (type === "message:edit") { const row = $log.querySelector(`.msg[data-id="${content.id}"]`); if (row) { const body = row.querySelector(".body"); body.textContent = ""; body.append(renderBody(content.text || "")); row._text = content.text || ""; row.dataset.edited = "1"; row.classList.toggle("has-media", hasMediaLink(content.text || "")); attachEmbeds(row, content.text); const t = row.querySelector(".time"); if (t) t.textContent = timeLabel(row); } } else if (type === "message:delete") { const row = $log.querySelector(`.msg[data-id="${content.id}"]`); if (row) { row.classList.add("deleted"); row.classList.remove("has-media"); row.querySelector(".body").textContent = "[deleted]"; row.querySelectorAll(".editbtn").forEach((b) => b.remove()); row.querySelector(".embeds")?.remove(); } } else if (type === "message:hearts") { const row = $log.querySelector(`.msg[data-id="${content.for}"]`); if (row) { let h = row.querySelector(".heart"); if (!h) { h = span("heart", ""); row.append(h); } h.textContent = "♥" + content.count; } } else if (type === "presence" || type === "online-handles") { setPresence(content.online || content.handles, content.here); } else if (type === "joined" || type === "left") { setPresence(content.chatters ?? content.handles, content.here); } else if (type === "unauthorized") { setGate('not authorized — sign in again at <a href="https://laklok.com" target="_blank" rel="noopener">laklok.com</a>, then reload.'); } else if (type === "too-long") { setGate(`too long — max ${content.maxChars ?? 128} characters.`); } else if (type === "muted") { setGate("your user has been muted."); } };}function scheduleReconnect() { setTimeout(connect, reconnect); reconnect = Math.min(reconnect * 2, 16000); }
// ── Composer: posting + re-editing ─────────────────────────────────────────let editingId = null;
function beginEdit(row) { if (!auth.token) return; editingId = row.dataset.id; document.body.classList.add("editing"); $input.value = row._text || ""; $send.textContent = "gem"; setGate('retter besked… <span class="cancel" id="canceledit">annuller</span>'); document.getElementById("canceledit").onclick = cancelEdit; $input.focus();}function cancelEdit() { editingId = null; document.body.classList.remove("editing"); $input.value = ""; $send.textContent = "send"; setGate(auth.handle ? `posting as <b>@${auth.handle}</b>.` : "posting live.");}
function post(text) { if (!auth.token || !ws || ws.readyState !== 1) return; if (editingId) { ws.send(JSON.stringify({ type: "chat:edit", content: { id: editingId, text, token: auth.token, sub: auth.sub }, })); laklokEvent("message_edit_requested"); cancelEdit(); } else { ws.send(JSON.stringify({ type: "chat:message", content: { text, token: auth.token, sub: auth.sub, font: "font_1" }, })); laklokEvent("message_send_requested"); }}// 🗑 Same payload the raster's copy-modal delete sends; the server answers// with a message:delete broadcast that redraws the row.function postDelete(id) { if (!auth.token || !ws || ws.readyState !== 1 || !id) return; ws.send(JSON.stringify({ type: "chat:delete", content: { id, token: auth.token, sub: auth.sub }, }));}
$send.onclick = () => { const t = $input.value.trim(); if (t) { post(t); $input.value = ""; } };$input.addEventListener("keydown", (e) => { if (e.key === "Enter") $send.click(); if (e.key === "Escape" && editingId) cancelEdit();});
// ── Boot ──────────────────────────────────────────────────────────────────────loadHistory().then(connect);initAuth();// keep relative timestamps freshsetInterval(() => { document.querySelectorAll(".msg").forEach((r) => { const t = r.querySelector(".time"); if (t && r.dataset.when) t.textContent = timeLabel(r); }); }, 30000);</script></body></html>