Monorepo for Aesthetic.Computer
aesthetic.computer
1// laer-klokken.mjs — Native "Learn the Clock" chat room
2// Connects to wss://chat-clock.aesthetic.computer/ (same room as web laer-klokken)
3// Warm terracotta theme. Press escape to return to prompt.
4
5const CHAT_URL = "wss://chat-clock.aesthetic.computer/";
6const SHIFT_MAP = {
7 "1":"!","2":"@","3":"#","4":"$","5":"%","6":"^","7":"&","8":"*","9":"(","0":")",
8 "-":"_","=":"+","[":"{","]":"}",";":":","'":'"',",":"<",".":">","/":"?","\\":"|","`":"~",
9};
10
11let frame = 0;
12let messages = [];
13let inputText = "";
14let cursor = 0;
15let cursorBlink = 0;
16let shiftHeld = false;
17let handle = "";
18let sub = "";
19let connected = false;
20let scrollOffset = 0;
21
22// Warm terracotta theme (matches web laer-klokken)
23const T = {
24 bg: [140, 75, 45],
25 header: [255, 200, 160],
26 status: [100, 200, 140],
27 statusWarn: [255, 220, 120],
28 statusOff: [255, 140, 120],
29 handle: [255, 180, 140],
30 inputBg: [120, 60, 35],
31 inputBorder: [180, 110, 70],
32 prompt: [255, 180, 120],
33 inputText: [255, 245, 230],
34 cursorColor: [255, 140, 80],
35 myMsg: [255, 200, 160],
36 otherHandle: [100, 220, 180],
37 msgText: [255, 245, 230],
38 scroll: [200, 140, 100],
39 empty: [180, 120, 80],
40 hint: [160, 100, 65],
41 divider: [160, 95, 60],
42};
43
44function boot({ system }) {
45 const raw = system?.readFile?.("/mnt/config.json");
46 if (raw) {
47 try {
48 const cfg = JSON.parse(raw);
49 handle = cfg.handle || "";
50 sub = cfg.sub || "";
51 } catch (_) {}
52 }
53 system?.ws?.connect(CHAT_URL);
54}
55
56function act({ event: e, system, sound }) {
57 if (e.is("keyboard:down:shift")) { shiftHeld = true; return; }
58 if (e.is("keyboard:up:shift")) { shiftHeld = false; return; }
59
60 if (e.is("keyboard:down")) {
61 const key = e.key;
62 cursorBlink = 0;
63
64 if (key === "escape") { system?.jump?.("prompt"); return; }
65
66 if (key === "enter" || key === "return") {
67 const text = inputText.trim();
68 if (text.length > 0 && system?.ws?.connected) {
69 system.ws.send(JSON.stringify({
70 type: "chat:message",
71 content: { sub: sub || "anonymous", text, handle: handle || "anon" },
72 }));
73 messages.push({ from: handle || "me", text, when: Date.now() });
74 scrollOffset = 0;
75 sound?.synth({ type: "sine", tone: 660, duration: 0.05, volume: 0.1, attack: 0.002, decay: 0.04 });
76 }
77 inputText = "";
78 cursor = 0;
79 return;
80 }
81
82 if (key === "backspace") {
83 if (cursor > 0) {
84 inputText = inputText.slice(0, cursor - 1) + inputText.slice(cursor);
85 cursor--;
86 }
87 return;
88 }
89 if (key === "arrowleft") { if (cursor > 0) cursor--; return; }
90 if (key === "arrowright") { if (cursor < inputText.length) cursor++; return; }
91 if (key === "home") { cursor = 0; return; }
92 if (key === "end") { cursor = inputText.length; return; }
93 if (key === "arrowup") { scrollOffset = Math.min(scrollOffset + 1, Math.max(0, messages.length - 3)); return; }
94 if (key === "arrowdown") { scrollOffset = Math.max(0, scrollOffset - 1); return; }
95
96 if (key === "space") {
97 inputText = inputText.slice(0, cursor) + " " + inputText.slice(cursor);
98 cursor++;
99 return;
100 }
101 if (key.length === 1) {
102 const ch = shiftHeld ? (SHIFT_MAP[key] ?? key.toUpperCase()) : key;
103 inputText = inputText.slice(0, cursor) + ch + inputText.slice(cursor);
104 cursor++;
105 }
106 }
107}
108
109function paint({ wipe, ink, box, line, write, screen, system, sound, wifi }) {
110 frame++;
111 cursorBlink++;
112 wipe(...T.bg);
113
114 const W = screen.width;
115 const H = screen.height;
116 const font = "6x10";
117 const charW = 6;
118 const charH = 10;
119 const pad = 4;
120
121 // Process incoming WebSocket messages
122 const wsMsgs = system?.ws?.messages;
123 if (wsMsgs?.length) {
124 for (const raw of wsMsgs) {
125 try {
126 const msg = JSON.parse(raw);
127 const parseContent = (c) => (typeof c === "string" ? JSON.parse(c) : c);
128 if (msg.type === "connected") {
129 connected = true;
130 const content = parseContent(msg.content);
131 for (const m of (content?.messages || [])) {
132 if (m.from && m.text) messages.push({ from: m.from, text: m.text, when: m.when || 0 });
133 }
134 } else if (msg.type === "message") {
135 const m = parseContent(msg.content);
136 if (m?.from && m?.text) {
137 messages.push({ from: m.from, text: m.text, when: Date.now() });
138 sound?.synth({ type: "triangle", tone: 520, duration: 0.06, volume: 0.08, attack: 0.002, decay: 0.05 });
139 }
140 } else if (msg.from && msg.text) {
141 messages.push({ from: msg.from, text: msg.text, when: Date.now() });
142 }
143 } catch (_) {}
144 }
145 }
146
147 // Reconnect if dropped
148 if (wifi?.connected && !system?.ws?.connected && !system?.ws?.connecting && frame % 120 === 0) {
149 system?.ws?.connect(CHAT_URL);
150 }
151
152 // Header
153 ink(...T.header);
154 write("laer-klokken", { x: pad, y: 2, size: 1, font });
155
156 const wsOk = system?.ws?.connected;
157 if (wsOk) {
158 ink(...T.status);
159 write("connected", { x: pad + 13 * charW, y: 2, size: 1, font });
160 } else if (wifi?.connected) {
161 ink(...T.statusWarn);
162 write("connecting...", { x: pad + 13 * charW, y: 2, size: 1, font });
163 } else {
164 ink(...T.statusOff);
165 write("offline", { x: pad + 13 * charW, y: 2, size: 1, font });
166 }
167
168 if (handle) {
169 ink(...T.handle);
170 const hLabel = "@" + handle;
171 write(hLabel, { x: W - pad - hLabel.length * charW, y: 2, size: 1, font });
172 }
173
174 ink(...T.divider);
175 line(0, 13, W, 13);
176
177 // Input area
178 const inputY = H - charH - 6;
179 ink(...T.inputBg);
180 box(0, inputY - 3, W, charH + 8, true);
181 ink(...T.inputBorder);
182 line(0, inputY - 3, W, inputY - 3);
183
184 ink(...T.prompt);
185 write(">", { x: pad, y: inputY, size: 1, font });
186
187 ink(...T.inputText);
188 const maxInputChars = Math.floor((W - pad * 2 - charW * 2) / charW);
189 const displayInput = inputText.length > maxInputChars
190 ? inputText.slice(inputText.length - maxInputChars)
191 : inputText;
192 const inputStartX = pad + charW + 2;
193 write(displayInput, { x: inputStartX, y: inputY, size: 1, font });
194
195 if (cursorBlink % 40 < 25) {
196 const displayCursor = inputText.length > maxInputChars ? maxInputChars : cursor;
197 ink(...T.cursorColor, 180);
198 box(inputStartX + displayCursor * charW, inputY, charW, charH, true);
199 if (cursor < inputText.length) {
200 ink(255, 255, 255);
201 write(inputText[cursor], { x: inputStartX + displayCursor * charW, y: inputY, size: 1, font });
202 }
203 }
204
205 // Messages
206 const msgTop = 15;
207 const msgBottom = inputY - 6;
208 const lineH = charH + 3;
209 const maxVisible = Math.floor((msgBottom - msgTop) / lineH);
210 const startIdx = Math.max(0, messages.length - maxVisible - scrollOffset);
211 const endIdx = Math.min(messages.length, startIdx + maxVisible);
212
213 let my = msgTop;
214 for (let i = startIdx; i < endIdx; i++) {
215 const msg = messages[i];
216 if (my + lineH > msgBottom) break;
217
218 const fromLabel = (msg.from || "?") + ": ";
219 const isMe = msg.from === handle || msg.from === "me";
220 ink(...(isMe ? T.myMsg : T.otherHandle));
221 write(fromLabel, { x: pad, y: my, size: 1, font });
222
223 const textX = pad + fromLabel.length * charW;
224 const maxChars = Math.floor((W - textX - pad) / charW);
225 ink(...T.msgText);
226 write(msg.text.slice(0, maxChars), { x: textX, y: my, size: 1, font });
227
228 my += lineH;
229 }
230
231 if (scrollOffset > 0) {
232 ink(...T.scroll);
233 write("^ scroll ^", { x: Math.floor(W / 2) - 30, y: msgTop, size: 1, font });
234 }
235
236 if (messages.length === 0 && connected) {
237 ink(...T.empty);
238 write("no messages yet", { x: pad, y: Math.floor(H / 2) - 5, size: 1, font });
239 } else if (!connected && !wifi?.connected) {
240 ink(...T.empty);
241 write("connect to wifi first", { x: pad, y: Math.floor(H / 2) - 5, size: 1, font });
242 }
243
244 ink(...T.hint);
245 write("esc:back", { x: W - 9 * charW - pad, y: inputY, size: 1, font });
246}
247
248function leave() {}
249
250export { boot, act, paint, leave };