From c673958babcb5c7392c814eaf9c7f5ee67cd41a1 Mon Sep 17 00:00:00 2001 From: Jim Huang Date: Fri, 27 Mar 2026 09:39:34 +0800 Subject: [PATCH] Add Kernel House educational visualization Pixel-art cross-section of Linux subsystems driven by syscall telemetry from kbox's seccomp-unotify intercept path. Scene layout: - 8 rooms (User Space, Syscall Gate, VFS, Process, Memory, Network, Block I/O, FD Table) rendered on an offscreen-cached static canvas - Warm tinyoffice-inspired palette with floor tile grid - Theme-aware (dark/light) for all visual elements Character system: - 16x28px Tux sprite sheets (7 frames x 3 directions) at 2.5x scale - Waddle animation: sinusoidal side-to-side sway + vertical bob + body tilt - 6 resident penguins with role accessories (hat, folder, stopwatch, etc.) - 8-penguin guest pool representing active syscall flows Telemetry binding: - SSE sampled events drive guest penguin lifecycle (attic -> gate -> room) - /api/snapshot deltas drive room glow intensity - Disposition color coding (green=CONTINUE, blue=LKL, orange=ENOSYS) - PID tracking with syscall-pattern activity inference for User Space Interactive features: - Hover tooltip showing PID, command, syscall, dispatch method - Clickable rooms with kernel subsystem descriptions from strings.json - Narrator mode for educational commentary on interesting transitions - Flow arrows showing active gate-to-room dispatch paths - FD Table gauge (fd.used/fd.max with color-coded bar) - Demo mode with scripted "cat /etc/hostname" narrative - Screenshot export via canvas.toDataURL Robustness: - Offline detection (2 consecutive poll failures -> overlay + animation stop) - Pause freezes animation without accumulating tick backlog - Demo runs independently of pause state, cancellable via Stop Demo - Intent queue with room-fair FIFO scheduling and walk concurrency cap - demoVersion token invalidates in-flight walk callbacks on cancel Change-Id: I25f0eaea1d3622b4a838144904c9e543b343e8a2 --- scripts/gen-penguin-sprites.py | 270 +++++++++++++ scripts/gen-web-assets.sh | 2 +- src/web-server.c | 4 + web/art/README.md | 26 ++ web/art/acc-envelope.png | Bin 0 -> 135 bytes web/art/acc-folder.png | Bin 0 -> 130 bytes web/art/acc-hat.png | Bin 0 -> 138 bytes web/art/acc-memblock.png | Bin 0 -> 139 bytes web/art/acc-stopwatch.png | Bin 0 -> 135 bytes web/art/penguin-base.png | Bin 0 -> 728 bytes web/art/penguin-guest.png | Bin 0 -> 733 bytes web/index.html | 37 +- web/js/bubble.js | 139 +++++++ web/js/controls.js | 65 +++- web/js/education.js | 265 +++++++++++++ web/js/house.js | 671 +++++++++++++++++++++++++++++++++ web/js/intent.js | 298 +++++++++++++++ web/js/main.js | 2 + web/js/penguin.js | 311 +++++++++++++++ web/js/polling.js | 36 +- web/js/scene.js | 468 +++++++++++++++++++++++ web/js/strings.json | 87 +++++ web/js/telemetry.js | 217 +++++++++++ web/style.css | 149 +++++++- 24 files changed, 3029 insertions(+), 18 deletions(-) create mode 100644 scripts/gen-penguin-sprites.py create mode 100644 web/art/README.md create mode 100644 web/art/acc-envelope.png create mode 100644 web/art/acc-folder.png create mode 100644 web/art/acc-hat.png create mode 100644 web/art/acc-memblock.png create mode 100644 web/art/acc-stopwatch.png create mode 100644 web/art/penguin-base.png create mode 100644 web/art/penguin-guest.png create mode 100644 web/js/bubble.js create mode 100644 web/js/education.js create mode 100644 web/js/house.js create mode 100644 web/js/intent.js create mode 100644 web/js/penguin.js create mode 100644 web/js/scene.js create mode 100644 web/js/strings.json create mode 100644 web/js/telemetry.js diff --git a/scripts/gen-penguin-sprites.py b/scripts/gen-penguin-sprites.py new file mode 100644 index 0000000..a6f27a2 --- /dev/null +++ b/scripts/gen-penguin-sprites.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Generate pixel-art penguin sprite sheets for kbox kernel house visualization. + +Style: tinyoffice-inspired tall characters with high detail and clear +silhouette. Each penguin is drawn in a 16x28 frame with a rounded body, +large expressive eyes, visible beak and feet, and wing animations. + +Outputs: + web/art/penguin-base.png -- 7x3 sprite sheet (112x84), resident Tux + web/art/penguin-guest.png -- 7x3 sprite sheet, guest variant (teal) + web/art/acc-hat.png -- 7x1 accessory strip (112x28) + web/art/acc-folder.png -- 7x1 accessory strip + web/art/acc-stopwatch.png -- 7x1 accessory strip + web/art/acc-memblock.png -- 7x1 accessory strip + web/art/acc-envelope.png -- 7x1 accessory strip + +Frame layout (7 cols): idle, walk1, walk2, walk3, type1, type2, error +Row layout (3 rows): down (front), up (back), left/right (side) +Each frame: 16x28 pixels. +""" + +import struct, zlib, os + +FRAME_W, FRAME_H = 16, 28 +COLS, ROWS = 7, 3 +SHEET_W, SHEET_H = FRAME_W * COLS, FRAME_H * ROWS +CLEAR = (0, 0, 0, 0) + +# Tux palette (warm, high contrast) +BODY = (25, 25, 40, 255) # deep blue-black +BELLY = (235, 235, 245, 255) # bright white +BEAK = (255, 185, 50, 255) # warm gold-orange +FEET = (255, 160, 30, 255) # orange +EYE_W = (255, 255, 255, 255) # eye white +PUPIL = (12, 12, 25, 255) # near-black pupil +CHEEK = (255, 140, 140, 255) # rosy cheek +OUTLINE = (15, 15, 28, 255) # dark outline for definition + +# Guest variant +G_BODY = (35, 75, 105, 255) +G_BELLY = (185, 220, 240, 255) + +# Accessories +HAT_RED = (220, 55, 80, 255) +HAT_GOLD = (255, 215, 0, 255) +FOLDER_Y = (255, 200, 60, 255) +FOLDER_T = (220, 170, 40, 255) +WATCH_S = (180, 185, 200, 255) +WATCH_F = (240, 240, 255, 255) +MEM_G = (80, 200, 120, 255) +MEM_D = (50, 160, 90, 255) +ENV_C = (255, 240, 210, 255) +ENV_S = (220, 55, 80, 255) + + +def make_png(width, height, pixels): + def chunk(ct, d): + c = ct + d + return struct.pack('>I', len(d)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF) + raw = b'' + for y in range(height): + raw += b'\x00' + for x in range(width): + raw += struct.pack('BBBB', *pixels[y * width + x]) + sig = b'\x89PNG\r\n\x1a\n' + ihdr = struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0) + return sig + chunk(b'IHDR', ihdr) + chunk(b'IDAT', zlib.compress(raw, 9)) + chunk(b'IEND', b'') + + +def px(buf, w, x, y, c): + h = len(buf) // w + if 0 <= x < w and 0 <= y < h: + buf[y * w + x] = c + +def rect(buf, w, x0, y0, rw, rh, c): + for dy in range(rh): + for dx in range(rw): + px(buf, w, x0 + dx, y0 + dy, c) + + +def tux(buf, w, fx, fy, facing, anim, body, belly): + """Draw one penguin frame. 16x28, centered body ~12px wide, ~20px tall.""" + ox = fx * FRAME_W + oy = fy * FRAME_H + + bob = {1: -1, 2: -2, 3: -1}.get(anim, 0) + jit = 1 if anim == 6 else 0 + cx = ox + 8 + jit + base_y = oy + 24 + bob # feet baseline + + if facing == 0: # FRONT + # Head (round: 10w x 7h with clipped corners) + hy = base_y - 20 + rect(buf, w, cx-5, hy, 10, 7, body) + for c in [(cx-5, hy), (cx+4, hy), (cx-5, hy+1), (cx+4, hy+1)]: + px(buf, w, c[0], c[1], CLEAR) # round top corners + # Outline top of head + for dx in range(-4, 5): + px(buf, w, cx+dx, hy + (1 if abs(dx) >= 4 else 0), OUTLINE) + + # Body (12w x 11h) + by = base_y - 13 + rect(buf, w, cx-6, by, 12, 11, body) + px(buf, w, cx-6, by, CLEAR) + px(buf, w, cx+5, by, CLEAR) + + # Belly (8w x 8h, centered) + rect(buf, w, cx-4, by+2, 8, 7, belly) + + # Eyes (2x2 white + 1x1 pupil each, big and expressive) + ey = hy + 2 + rect(buf, w, cx-4, ey, 2, 2, EYE_W) + rect(buf, w, cx+2, ey, 2, 2, EYE_W) + px(buf, w, cx-3, ey+1, PUPIL) + px(buf, w, cx+3, ey+1, PUPIL) + # Blink on type2 + if anim == 5: + rect(buf, w, cx-4, ey, 2, 2, body) + rect(buf, w, cx+2, ey, 2, 2, body) + px(buf, w, cx-4, ey+1, EYE_W) + px(buf, w, cx-3, ey+1, EYE_W) + px(buf, w, cx+2, ey+1, EYE_W) + px(buf, w, cx+3, ey+1, EYE_W) + + # Beak (3px wide, centered) + rect(buf, w, cx-1, hy+5, 3, 1, BEAK) + px(buf, w, cx, hy+6, BEAK) + + # Cheeks + px(buf, w, cx-5, hy+4, CHEEK) + px(buf, w, cx+4, hy+4, CHEEK) + + # Wings + rect(buf, w, cx-7, by+2, 1, 7, body) + rect(buf, w, cx+6, by+2, 1, 7, body) + if anim in (1, 3): # flap + px(buf, w, cx-8, by+3, body) + px(buf, w, cx+7, by+3, body) + if anim in (4, 5): # type: extend right wing + rect(buf, w, cx+6, by+4, 2, 3, body) + + # Feet + fy2 = base_y - 1 + if anim == 1: + rect(buf, w, cx-4, fy2, 3, 2, FEET) + rect(buf, w, cx+1, fy2+1, 3, 2, FEET) + elif anim == 3: + rect(buf, w, cx-4, fy2+1, 3, 2, FEET) + rect(buf, w, cx+1, fy2, 3, 2, FEET) + else: + rect(buf, w, cx-4, fy2, 3, 2, FEET) + rect(buf, w, cx+1, fy2, 3, 2, FEET) + + elif facing == 1: # BACK + hy = base_y - 20 + rect(buf, w, cx-5, hy, 10, 7, body) + px(buf, w, cx-5, hy, CLEAR); px(buf, w, cx+4, hy, CLEAR) + by = base_y - 13 + rect(buf, w, cx-6, by, 12, 11, body) + # Tail nub + px(buf, w, cx, base_y-3, body) + px(buf, w, cx-1, base_y-3, body) + # Wings + rect(buf, w, cx-7, by+2, 1, 7, body) + rect(buf, w, cx+6, by+2, 1, 7, body) + if anim in (1, 3): + px(buf, w, cx-8, by+3, body) + px(buf, w, cx+7, by+3, body) + # Feet + rect(buf, w, cx-4, base_y-1, 3, 2, FEET) + rect(buf, w, cx+1, base_y-1, 3, 2, FEET) + + elif facing == 2: # SIDE (facing left) + hy = base_y - 20 + rect(buf, w, cx-3, hy, 8, 7, body) + px(buf, w, cx-3, hy, CLEAR); px(buf, w, cx+4, hy, CLEAR) + by = base_y - 13 + rect(buf, w, cx-4, by, 10, 11, body) + # Belly + rect(buf, w, cx-4, by+2, 5, 7, belly) + # Eye + rect(buf, w, cx-3, hy+2, 2, 2, EYE_W) + px(buf, w, cx-3, hy+3, PUPIL) + if anim == 5: + rect(buf, w, cx-3, hy+2, 2, 2, body) + px(buf, w, cx-3, hy+3, EYE_W) + px(buf, w, cx-2, hy+3, EYE_W) + # Beak + rect(buf, w, cx-5, hy+4, 2, 1, BEAK) + px(buf, w, cx-5, hy+5, BEAK) + # Cheek + px(buf, w, cx-2, hy+4, CHEEK) + # Far wing + rect(buf, w, cx+5, by+2, 1, 7, body) + if anim in (4, 5): + rect(buf, w, cx-6, by+5, 2, 3, body) + # Feet + if anim == 1: + rect(buf, w, cx-3, base_y-1, 3, 2, FEET) + rect(buf, w, cx+1, base_y, 3, 2, FEET) + elif anim == 3: + rect(buf, w, cx-3, base_y, 3, 2, FEET) + rect(buf, w, cx+1, base_y-1, 3, 2, FEET) + else: + rect(buf, w, cx-2, base_y-1, 3, 2, FEET) + rect(buf, w, cx+1, base_y-1, 3, 2, FEET) + + +# Accessories (positioned relative to frame center, top area) +def acc_hat(buf, w, fx, fy): + cx, top = fx*FRAME_W+8, fy*FRAME_H+2 + rect(buf, w, cx-4, top+3, 8, 2, HAT_RED) + rect(buf, w, cx-3, top+2, 6, 1, HAT_RED) + rect(buf, w, cx-2, top+1, 4, 1, HAT_RED) + rect(buf, w, cx-1, top, 2, 1, HAT_RED) + rect(buf, w, cx-5, top+4, 10, 1, HAT_GOLD) + +def acc_folder(buf, w, fx, fy): + ox, oy = fx*FRAME_W+11, fy*FRAME_H+12 + rect(buf, w, ox, oy, 5, 6, FOLDER_Y) + rect(buf, w, ox, oy, 3, 1, FOLDER_T) + +def acc_stopwatch(buf, w, fx, fy): + ox, oy = fx*FRAME_W+11, fy*FRAME_H+11 + rect(buf, w, ox, oy, 4, 4, WATCH_S) + rect(buf, w, ox+1, oy+1, 2, 2, WATCH_F) + px(buf, w, ox+1, oy-1, WATCH_S) + +def acc_memblock(buf, w, fx, fy): + ox, oy = fx*FRAME_W+11, fy*FRAME_H+11 + rect(buf, w, ox, oy, 5, 5, MEM_G) + rect(buf, w, ox, oy, 5, 1, MEM_D) + rect(buf, w, ox+1, oy+3, 3, 1, MEM_D) + +def acc_envelope(buf, w, fx, fy): + ox, oy = fx*FRAME_W+11, fy*FRAME_H+13 + rect(buf, w, ox, oy, 5, 4, ENV_C) + px(buf, w, ox+2, oy+1, ENV_S) # seal + rect(buf, w, ox, oy, 5, 1, ENV_S) # top edge + + +def gen_sheet(body, belly): + buf = [CLEAR] * (SHEET_W * SHEET_H) + for r in range(ROWS): + for c in range(COLS): + tux(buf, SHEET_W, c, r, r, c, body, belly) + return buf + +def gen_acc(fn): + w, h = FRAME_W * COLS, FRAME_H + buf = [CLEAR] * (w * h) + for c in range(COLS): + fn(buf, w, c, 0) + return buf, w, h + +def main(): + d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'web', 'art') + os.makedirs(d, exist_ok=True) + for name, data in [('penguin-base.png', gen_sheet(BODY, BELLY)), + ('penguin-guest.png', gen_sheet(G_BODY, G_BELLY))]: + open(os.path.join(d, name), 'wb').write(make_png(SHEET_W, SHEET_H, data)) + for name, fn in [('acc-hat.png', acc_hat), ('acc-folder.png', acc_folder), + ('acc-stopwatch.png', acc_stopwatch), ('acc-memblock.png', acc_memblock), + ('acc-envelope.png', acc_envelope)]: + buf, w, h = gen_acc(fn) + open(os.path.join(d, name), 'wb').write(make_png(w, h, buf)) + print('Generated sprites in', d) + +if __name__ == '__main__': + main() diff --git a/scripts/gen-web-assets.sh b/scripts/gen-web-assets.sh index e873379..ef205a1 100755 --- a/scripts/gen-web-assets.sh +++ b/scripts/gen-web-assets.sh @@ -19,7 +19,7 @@ fi FILES=() while IFS= read -r -d '' f; do FILES+=("$f") -done < <(find "$WEB_DIR" -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.svg' \) -print0 | sort -z) +done < <(find "$WEB_DIR" -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.svg' -o -name '*.png' -o -name '*.json' \) -print0 | sort -z) if [ ${#FILES[@]} -eq 0 ]; then echo "error: no web assets found in web/" >&2 diff --git a/src/web-server.c b/src/web-server.c index 148e1da..4c8f4bf 100644 --- a/src/web-server.c +++ b/src/web-server.c @@ -314,6 +314,10 @@ static const char *content_type_for(const char *path) return "application/javascript"; if (strcmp(dot, ".svg") == 0) return "image/svg+xml"; + if (strcmp(dot, ".png") == 0) + return "image/png"; + if (strcmp(dot, ".json") == 0) + return "application/json"; return "application/octet-stream"; } diff --git a/web/art/README.md b/web/art/README.md new file mode 100644 index 0000000..32027ca --- /dev/null +++ b/web/art/README.md @@ -0,0 +1,26 @@ +# Art Asset Credits + +## Penguin Sprites +- `penguin-base.png`, `penguin-guest.png` -- Original pixel art generated + for kbox. Frame layout (7x3) inspired by tinyclaw/TinyOffice character + sprite system. MIT licensed. + +## Accessory Overlays +- `acc-hat.png`, `acc-folder.png`, `acc-stopwatch.png`, `acc-memblock.png`, + `acc-envelope.png` -- Original pixel art generated for kbox. MIT licensed. + +## Inspiration +- Character animation patterns adapted from: + - [tinyclaw/TinyOffice](https://github.com/tinyagi/tinyagi) (sprite sheet + frame layout, animation state machine) + - [star-office-ui-v2](https://github.com/acsone/star-office-ui-v2) (pixel + art aesthetic, state-driven character behavior) +- Visual narrative style inspired by [inside the linux kernel](https://turnoff.us/geek/inside-the-linux-kernel/) + by Daniel Stori + +## Generator +Sprites produced by `scripts/gen-penguin-sprites.py` (pure Python, no +external dependencies). Regenerate with: +``` +python3 scripts/gen-penguin-sprites.py +``` diff --git a/web/art/acc-envelope.png b/web/art/acc-envelope.png new file mode 100644 index 0000000000000000000000000000000000000000..0c7ca8fa9a94ced1cf8211e274c91af8843ebb5b GIT binary patch literal 135 zcmeAS@N?(olHy`uVBq!ia0vp^1wbsr!3HFEUbD^uQhuH;jv*Dd-d^0udq9E5)iM95 z)XcfF6gM>rvTB@S^Lw{M|Dp|72T&mcLxaBL+f(1al>R!g`}r-d?~(D#?Zs`DufJS< g;FUZFNWp(oMn~q$OQy{;D+F;pUHx3vIVCg!0LLLM%K!iX literal 0 HcmV?d00001 diff --git a/web/art/acc-folder.png b/web/art/acc-folder.png new file mode 100644 index 0000000000000000000000000000000000000000..2002657dc2cd50caa0bf3afbda221e03842ad0f5 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^1wbsr!3HFEUbD^uQl6eJjv*Dd-d^0u$)LdFda&oQ zfZrPxzK42TO$)Y8tzg%TpDfP^RL5{&;nXYFzs`F8^8U+Y*WWjv*Dd-kve!YA_IRxwtoU z-Nf6sE2i^`nKezgk<;=$;gr4Lfo12eeV+BN{jY4j?M3l#=PSOt#NXI|bhG-eLw5Zq iX1@IXh7o8K7@XoY2@w7o*XQyB#PxLbb6Mw<&;$VOFEtGS literal 0 HcmV?d00001 diff --git a/web/art/acc-memblock.png b/web/art/acc-memblock.png new file mode 100644 index 0000000000000000000000000000000000000000..d62c73babd333cd8265668b88cded433216ef801 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^1wbsr!3HFEUbD^uQbC?Bjv*Dd-d;Y)*W=+>k^J>_!?j z_jbQrUS40tmSCl0G#qcKcc^TXIA)K*>W)Q4UW4-tig+ zur$!W9Fhi@CfyVNEJwEM*D%oT9NBx&(MA9O8pTU{u-ZKU5P$$|86Z!=&Hy7khG#Z_ zN{$HgAnAL|tOioa;SJ#N&)kpK`%spS9nAn?7)Tqt20#k~gpsrXy?RiT`fJ?3C`a}l z;5`Dk14IZRgb+d%Ce*Z0U;q|tN&rw1K!!y{79&V;j_d$hFi^E+g~S68fB*!b+YKCH zWp!$RExi#B2{6i<6ssJW4Wz!LLJGh;K0QE51C+*UpK{elFGLdI*M7w(YyyMdY z%nl%Xyf_B{07yIl?cCC$HA@%afCwRk*7OZvz`jfkD%Z*Y0000< KMNUMnLSTZ~fHzVA literal 0 HcmV?d00001 diff --git a/web/art/penguin-guest.png b/web/art/penguin-guest.png new file mode 100644 index 0000000000000000000000000000000000000000..50a7cf700012eed17d797a063015b6c4c79c14ec GIT binary patch literal 733 zcmV<30wVp1P)h! z!}XDj}j=jWWL23V?lUzU1baUCK6_;c>%?OxV2Kz0L&>);LG;q^wb zTjH$^AdMI2hz4Nm5aewDX}maxCxErF8aXTtxFuek!wbOh_~q++wyen!$p9q}F-18% z0eHu27{Jm%|8htgV4QSI{G%M%u3y7In{#CEL3+b z@ED%i04g~m%!8!wF|!&-C5JbF`#*C(U#@+bJGM6igkd0U>>2kEcKYHa-ta{~H)#9#rE!CKGw%H`~L7 ztd@&f(*x9Q{0IP>T-7XT!14Lk4XAN_sev>MD9Vw!2SgYF+yNql5L(g=J - + + + + +
+
+ +
+
+ + +
+
+
+ + +
@@ -79,7 +99,12 @@

Block I/O

Data source pending (Phase 2+)
- +
+
+ + +
+

Event Feed @@ -91,6 +116,7 @@

Event Feed

+
@@ -99,6 +125,13 @@

Event Feed + + + + + + + diff --git a/web/js/bubble.js b/web/js/bubble.js new file mode 100644 index 0000000..4589aeb --- /dev/null +++ b/web/js/bubble.js @@ -0,0 +1,139 @@ +/* Speech bubble system (DOM overlay). + * + * Bubbles are positioned absolutely over the canvas, anchored to a + * penguin's canvas position. They auto-expire after a timeout and + * animate in/out via CSS. + */ +'use strict'; + +var KBubble = { + MAX_BUBBLES: 12, + DEFAULT_TTL: 3000, /* ms */ + bubbles: [], /* { el, penguinId, expires } */ + + /* Show a bubble above a penguin. Text is plain string. */ + show: function(penguinId, text, ttl) { + if (!KScene.overlay) return; + ttl = ttl || this.DEFAULT_TTL; + + /* Reuse existing bubble for same penguin if still visible */ + for (var i = 0; i < this.bubbles.length; i++) { + if (this.bubbles[i].penguinId === penguinId) { + this.bubbles[i].el.textContent = text; + this.bubbles[i].expires = Date.now() + ttl; + return; + } + } + + /* Evict oldest if at capacity */ + if (this.bubbles.length >= this.MAX_BUBBLES) { + this.remove(0); + } + + var el = document.createElement('div'); + el.className = 'bubble'; + el.textContent = text; + el.style.transform = 'translateX(-50%)'; + KScene.overlay.appendChild(el); + + this.bubbles.push({ + el: el, + penguinId: penguinId, + expires: Date.now() + ttl + }); + }, + + /* Cached canvas layout (updated by updateLayout, not every frame) */ + canvasRect: null, + scaleX: 1, + scaleY: 1, + offsetX: 0, + offsetY: 0, + + /* Call after canvas resize or tab switch */ + updateLayout: function() { + if (!KScene.canvas || !KScene.overlay) return; + var canvasRect = KScene.canvas.getBoundingClientRect(); + var overlayRect = KScene.overlay.getBoundingClientRect(); + this.scaleX = canvasRect.width / (KScene.width || 1); + this.scaleY = canvasRect.height / (KScene.height || 1); + this.offsetX = canvasRect.left - overlayRect.left; + this.offsetY = canvasRect.top - overlayRect.top; + this.canvasRect = canvasRect; + }, + + /* Penguin id -> penguin map for O(1) lookup */ + penguinMap: null, + + buildPenguinMap: function(penguins) { + this.penguinMap = {}; + for (var i = 0; i < penguins.length; i++) { + this.penguinMap[penguins[i].id] = penguins[i]; + } + }, + + /* Reposition all bubbles to track their penguin's canvas position */ + reposition: function(penguins) { + if (!this.canvasRect) this.updateLayout(); + if (!this.penguinMap) this.buildPenguinMap(penguins); + + for (var i = 0; i < this.bubbles.length; i++) { + var b = this.bubbles[i]; + var p = this.penguinMap[b.penguinId]; + if (!p) continue; + + var bx = this.offsetX + p.x * this.scaleX; + var by = this.offsetY + (p.y - KPenguin.FRAME_H * KPenguin.SCALE - 8) * this.scaleY; + + /* Skip rewrite if position unchanged (within 1px) */ + var prevLeft = parseFloat(b.el.style.left) || 0; + var prevTop = parseFloat(b.el.style.top) || 0; + if (Math.abs(bx - prevLeft) < 1 && Math.abs(by - prevTop) < 1) continue; + + b.el.style.left = bx + 'px'; + b.el.style.top = by + 'px'; + } + }, + + /* Expire old bubbles */ + cleanup: function() { + var now = Date.now(); + var self = this; + for (var i = this.bubbles.length - 1; i >= 0; i--) { + if (now >= this.bubbles[i].expires) { + this.bubbles[i].el.classList.add('out'); + /* IIFE captures `el` per-iteration (var is function-scoped in ES5) */ + (function(el) { + setTimeout(function() { self.removeByEl(el); }, 300); + })(this.bubbles[i].el); + this.bubbles[i].expires = Infinity; + } + } + }, + + /* Remove by DOM element reference (stable across splice shifts) */ + removeByEl: function(el) { + for (var i = 0; i < this.bubbles.length; i++) { + if (this.bubbles[i].el === el) { + if (el.parentNode) el.parentNode.removeChild(el); + this.bubbles.splice(i, 1); + return; + } + } + /* Element already removed (e.g. clear() called during fade-out) */ + if (el.parentNode) el.parentNode.removeChild(el); + }, + + remove: function(idx) { + if (idx < 0 || idx >= this.bubbles.length) return; + var b = this.bubbles[idx]; + if (b.el.parentNode) b.el.parentNode.removeChild(b.el); + this.bubbles.splice(idx, 1); + }, + + clear: function() { + for (var i = this.bubbles.length - 1; i >= 0; i--) { + this.remove(i); + } + } +}; diff --git a/web/js/controls.js b/web/js/controls.js index f082873..23e154a 100644 --- a/web/js/controls.js +++ b/web/js/controls.js @@ -3,12 +3,42 @@ var KControls = { init: function() { + /* Tab switching */ + var tabs = document.querySelectorAll('.tab-bar .tab'); + var contents = document.querySelectorAll('.tab-content'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].addEventListener('click', function() { + var target = this.getAttribute('data-tab'); + /* Deactivate all tabs and contents */ + for (var j = 0; j < tabs.length; j++) + tabs[j].classList.remove('active'); + for (var k = 0; k < contents.length; k++) + contents[k].classList.remove('visible'); + /* Activate selected */ + this.classList.add('active'); + var el = document.getElementById('tab-' + target); + if (el) el.classList.add('visible'); + /* Resize scene canvas when switching to kernel house */ + if (target === 'kernel-house' && KScene.canvas) { + KScene.resize(); + KBubble.updateLayout(); + KBubble.penguinMap = null; + KHouse.ensurePenguins(); + KHouse.repositionAll(); + } + }); + } + /* Show default tab */ + var defaultTab = document.querySelector('.tab-bar .tab.active'); + if (defaultTab) defaultTab.click(); + /* Theme toggle */ var btn = document.getElementById('btn-theme'); if (btn) btn.addEventListener('click', function() { document.body.classList.toggle('light'); localStorage.setItem('kbox-theme', document.body.classList.contains('light') ? 'light' : 'dark'); + KScene.readTheme(); }); /* Restore theme */ @@ -19,15 +49,19 @@ var KControls = { var pauseBtn = document.getElementById('btn-pause'); if (pauseBtn) pauseBtn.addEventListener('click', function() { var want = !KState.paused; + /* Apply optimistically so animation freezes/resumes immediately */ + KState.paused = want; + pauseBtn.textContent = want ? 'Resume' : 'Pause'; pauseBtn.disabled = true; fetch('/api/control', { method: 'POST', body: JSON.stringify({ action: want ? 'pause' : 'resume' }) }).then(function(res) { if (!res.ok) throw new Error('server error'); - KState.paused = want; - pauseBtn.textContent = want ? 'Resume' : 'Pause'; - }).catch(function(){}).finally(function() { + }).catch(function() { + /* Server may be dead (offline); keep the client-side state as-is + * so the user can still pause/resume the animation locally. */ + }).then(function() { pauseBtn.disabled = false; }); }); @@ -46,23 +80,32 @@ var KControls = { KEvents.filters.errorsOnly = fErr.checked; }); + /* Screenshot (kernel house canvas) */ + var ssBtn = document.getElementById('btn-screenshot'); + if (ssBtn) ssBtn.addEventListener('click', function() { + if (!KScene.canvas) return; + var a = document.createElement('a'); + a.href = KScene.canvas.toDataURL('image/png'); + a.download = 'kbox-kernel-house.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }); + /* Export CSV (chart data from snapshot history) */ var csvBtn = document.getElementById('btn-export-csv'); - if (csvBtn) csvBtn.addEventListener('click', function() { - KControls.exportCSV(); - }); + if (csvBtn) csvBtn.addEventListener('click', KControls.exportCSV.bind(KControls)); /* Export JSON (event feed) */ var jsonBtn = document.getElementById('btn-export-json'); - if (jsonBtn) jsonBtn.addEventListener('click', function() { - KControls.exportJSON(); - }); + if (jsonBtn) jsonBtn.addEventListener('click', KControls.exportJSON.bind(KControls)); }, exportCSV: function() { var rows = ['timestamp_ns,uptime_s,syscalls,continue,return,enosys,' + 'ctx_switches,mem_free_kb,mem_cached_kb,pgfault,loadavg_1']; - KState.snapHistory.forEach(function(s) { + for (var i = 0; i < KState.snapHistory.length; i++) { + var s = KState.snapHistory[i]; var d = s.dispatch || {}; rows.push([ s.timestamp_ns, @@ -74,7 +117,7 @@ var KControls = { s.pgfault || 0, s.loadavg ? s.loadavg[0] : 0 ].join(',')); - }); + } KControls._download('kbox-telemetry.csv', rows.join('\n'), 'text/csv'); }, diff --git a/web/js/education.js b/web/js/education.js new file mode 100644 index 0000000..88b7742 --- /dev/null +++ b/web/js/education.js @@ -0,0 +1,265 @@ +/* Educational overlays for the Kernel House. + * + * - Clickable rooms: info panels from strings.json + * - Syscall flow arrows: animated dotted lines on canvas + * - Disposition legend: color-coded key + * - Narrator mode: opt-in text overlay for interesting transitions + */ +'use strict'; + +var KEducation = { + activePanel: null, /* currently open room panel element */ + narratorEnabled: false, + narratorCooldown: 0, /* timestamp of next allowed narrator message */ + narratorFadeTimer: 0, /* pending fade setTimeout id */ + narratorHideTimer: 0, /* pending hide setTimeout id */ + NARRATOR_COOLDOWN_MS: 5000, + seenFamilies: {}, /* track first-seen syscall families */ + lastErrorCount: 0, /* for error spike detection */ + + init: function() { + this.bindRoomClicks(); + this.bindNarrator(); + }, + + /* --- Room info panels --- */ + + bindRoomClicks: function() { + if (!KScene.canvas) return; + var self = this; + KScene.canvas.addEventListener('click', function(e) { + var rect = KScene.canvas.getBoundingClientRect(); + var scaleX = KScene.width / rect.width; + var scaleY = KScene.height / rect.height; + var cx = (e.clientX - rect.left) * scaleX; + var cy = (e.clientY - rect.top) * scaleY; + var roomId = KScene.hitTest(cx, cy); + if (roomId) { + self.showRoomPanel(roomId, e.clientX, e.clientY); + } else { + self.closePanel(); + } + }); + }, + + showRoomPanel: function(roomId, screenX, screenY) { + this.closePanel(); + if (!KScene.overlay) return; + + var name = KScene.str('rooms.' + roomId + '.name') || roomId; + var desc = KScene.str('rooms.' + roomId + '.desc') || ''; + var src = KScene.str('rooms.' + roomId + '.source') || ''; + + var panel = document.createElement('div'); + panel.className = 'room-panel'; + + var h3 = document.createElement('h3'); + h3.textContent = name; + panel.appendChild(h3); + + if (desc) { + var p = document.createElement('p'); + p.textContent = desc; + panel.appendChild(p); + } + + if (src) { + var srcEl = document.createElement('div'); + srcEl.className = 'src'; + srcEl.textContent = src; + panel.appendChild(srcEl); + } + + var closeBtn = document.createElement('button'); + closeBtn.className = 'close-btn'; + closeBtn.textContent = '\u00d7'; + var self = this; + closeBtn.addEventListener('click', function(e) { + e.stopPropagation(); + self.closePanel(); + }); + panel.appendChild(closeBtn); + + /* Position near click, clamped to overlay bounds */ + var overlayRect = KScene.overlay.getBoundingClientRect(); + var left = screenX - overlayRect.left + 10; + var top = screenY - overlayRect.top - 20; + left = Math.max(0, Math.min(left, overlayRect.width - 310)); + top = Math.max(0, Math.min(top, overlayRect.height - 200)); + panel.style.left = left + 'px'; + panel.style.top = top + 'px'; + panel.style.pointerEvents = 'auto'; + + KScene.overlay.appendChild(panel); + this.activePanel = panel; + }, + + closePanel: function() { + if (this.activePanel && this.activePanel.parentNode) { + this.activePanel.parentNode.removeChild(this.activePanel); + } + this.activePanel = null; + }, + + /* --- Disposition legend (drawn on canvas) --- */ + + drawLegend: function(ctx, x, y) { + var items = [ + { color: KScene.theme.ok, label: KScene.str('legend.continue') || 'CONTINUE (host)' }, + { color: KScene.theme.accent, label: KScene.str('legend.return') || 'LKL emulated' }, + { color: KScene.theme.warn, label: KScene.str('legend.enosys') || 'ENOSYS (rejected)' }, + { color: KScene.theme.err, label: KScene.str('legend.error') || 'Error' } + ]; + var textColor = KScene.theme.fg2 || '#8b949e'; + ctx.save(); + ctx.font = '10px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + for (var i = 0; i < items.length; i++) { + var iy = y + i * 14; + ctx.globalAlpha = 0.8; + ctx.fillStyle = items[i].color; + ctx.fillRect(x, iy - 4, 8, 8); + ctx.globalAlpha = 0.6; + ctx.fillStyle = textColor; + ctx.fillText(items[i].label, x + 12, iy); + } + ctx.restore(); + }, + + /* --- Syscall flow arrows (canvas) --- */ + + drawFlowArrow: function(ctx, fromRoom, toRoom, color) { + var from = KScene.roomRect(fromRoom); + var to = KScene.roomRect(toRoom); + if (!from || !to) return; + + /* Vertical drop from gate into the target room (inset both ends + * to avoid zero-length lines when gate.bottom == room.top) */ + var tx = to.x + to.w / 2; + var fy = from.y + from.h - 4; + var ty = to.y + 10; + + ctx.save(); + ctx.strokeStyle = color || '#58a6ff'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 4]); + ctx.lineDashOffset = -((KPenguin.clock * 2) % 8); + + ctx.beginPath(); + ctx.moveTo(tx, fy); + ctx.lineTo(tx, ty); + ctx.stroke(); + + /* Small arrowhead */ + ctx.setLineDash([]); + ctx.fillStyle = color || '#58a6ff'; + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(tx - 3, ty - 5); + ctx.lineTo(tx + 3, ty - 5); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + }, + + /* Draw active flow arrows based on recent intent activity */ + drawActiveFlows: function(ctx) { + /* Show flow from gate to rooms that have recent glow */ + /* Only draw arrows to rooms directly below gate (y=24%). + * fdvault is at y=64%, below the subsystem band -- skip it + * to avoid a tall vertical line cutting through other rooms. */ + var rooms = ['vfs', 'process', 'memory', 'network']; + var t = KScene.theme; + var colors = { + vfs: t.ok, process: t.accent, memory: t.accent, + network: t.warn + }; + for (var i = 0; i < rooms.length; i++) { + var glow = KScene.glow[rooms[i]] || 0; + if (glow > 0.1) { + ctx.globalAlpha = Math.min(1, glow * 1.5); + this.drawFlowArrow(ctx, 'gate', rooms[i], colors[rooms[i]]); + ctx.globalAlpha = 1; + } + } + }, + + /* --- Narrator mode --- */ + + bindNarrator: function() { + /* Narrator toggle button in kh-controls */ + var controls = document.querySelector('.kh-controls'); + if (!controls) return; + var btn = document.createElement('button'); + btn.id = 'btn-narrator'; + btn.textContent = 'Narrator'; + btn.title = 'Toggle narrator mode'; + var self = this; + btn.addEventListener('click', function() { + self.narratorEnabled = !self.narratorEnabled; + btn.classList.toggle('active', self.narratorEnabled); + if (self.narratorEnabled) { + self.showNarrator(KScene.str('narrator.welcome') || 'Welcome to the Kernel House.'); + } + }); + controls.insertBefore(btn, controls.firstChild); + }, + + showNarrator: function(text) { + if (!this.narratorEnabled) return; + var now = Date.now(); + if (now < this.narratorCooldown) return; + this.narratorCooldown = now + this.NARRATOR_COOLDOWN_MS; + + /* Cancel any pending fade/hide from a previous message */ + clearTimeout(this.narratorFadeTimer); + clearTimeout(this.narratorHideTimer); + + /* Reuse or create narrator overlay */ + var el = document.getElementById('kh-narrator'); + if (!el) { + el = document.createElement('div'); + el.id = 'kh-narrator'; + el.className = 'narrator-bar'; + if (KScene.overlay) KScene.overlay.appendChild(el); + } + el.textContent = text; + el.style.display = 'block'; + el.style.opacity = '1'; + + /* Auto-fade after 4s */ + var self = this; + this.narratorFadeTimer = setTimeout(function() { + el.style.opacity = '0'; + self.narratorHideTimer = setTimeout(function() { + el.style.display = 'none'; + }, 500); + }, 4000); + }, + + /* Called each tick to detect interesting transitions */ + checkNarration: function(evt) { + if (!this.narratorEnabled) return; + + /* First syscall of a new family */ + var room = KTelemetry.classifyRoom(evt); + if (!this.seenFamilies[room]) { + this.seenFamilies[room] = true; + var roomName = KScene.str('rooms.' + room + '.name') || room; + var tmpl = KScene.str('narrator.firstFamily') || 'First {family} syscall observed this session'; + this.showNarrator(tmpl.replace('{family}', evt.name || 'syscall') + ' \u2192 ' + roomName); + } + }, + + /* Check for error spikes from snapshot data */ + checkErrorSpike: function(snap) { + if (!this.narratorEnabled || !snap || !snap.dispatch) return; + var enosys = snap.dispatch.enosys || 0; + if (enosys > this.lastErrorCount + 5) { + var tmpl = KScene.str('narrator.errorSpike') || 'Error spike detected in {room}'; + this.showNarrator(tmpl.replace('{room}', (enosys - this.lastErrorCount) + ' ENOSYS')); + } + this.lastErrorCount = enosys; + } +}; diff --git a/web/js/house.js b/web/js/house.js new file mode 100644 index 0000000..12301ce --- /dev/null +++ b/web/js/house.js @@ -0,0 +1,671 @@ +/* Kernel House orchestrator. + * + * Manages the penguin roster (resident Tux characters + guest pool), + * drives the animation loop via requestAnimationFrame, and provides + * the demo sequence for Milestone 1. + */ +'use strict'; + +var KHouse = { + penguins: [], /* all penguin instances */ + guests: [], /* guest penguin pool (subset of penguins) */ + residents: {}, /* room id -> penguin instance */ + crowdCount: 0, /* overflow guest count for badge */ + running: false, + lastTick: 0, + GUEST_POOL_SIZE: 8, + + /* Room -> resident penguin config */ + RESIDENT_CONFIG: { + gate: { acc: 'hat', facing: 0 }, + vfs: { acc: 'folder', facing: 0 }, + process: { acc: 'stopwatch', facing: 0 }, + memory: { acc: 'memblock', facing: 0 }, + network: { acc: 'envelope', facing: 0 }, + fdvault: { acc: null, facing: 0 } + }, + + demoRunning: false, + demoVersion: 0, + demoTimers: [], + boundLoop: null, + + init: function() { + KPenguin.init(); + this.boundLoop = this.loop.bind(this); + this.bindDemo(); + /* Defer penguin creation until canvas has real dimensions. + * Tab is hidden at boot, so clientWidth is 0. We create + * penguins on first tab show (resize triggers reposition). */ + this.penguinsCreated = false; + }, + + ensurePenguins: function() { + if (this.penguinsCreated || KScene.width === 0) return; + this.createResidents(); + this.createGuestPool(); + this.penguinsCreated = true; + KEducation.init(); + this.bindHover(); + this.start(); + }, + + createResidents: function() { + var ids = Object.keys(this.RESIDENT_CONFIG); + for (var i = 0; i < ids.length; i++) { + var roomId = ids[i]; + var cfg = this.RESIDENT_CONFIG[roomId]; + var center = this.roomCenter(roomId); + var p = KPenguin.create({ + id: 'res-' + roomId, + x: center.x, + y: center.y, + facing: cfg.facing, + acc: cfg.acc, + room: roomId, + speed: 1.5 + }); + this.penguins.push(p); + this.residents[roomId] = p; + } + }, + + createGuestPool: function() { + for (var i = 0; i < this.GUEST_POOL_SIZE; i++) { + var center = this.roomCenter('attic'); + var p = KPenguin.create({ + id: 'guest-' + i, + x: center.x + (i - this.GUEST_POOL_SIZE / 2) * 20, + y: center.y, + isGuest: true, + visible: false, + room: 'attic', + speed: 2.5 + }); + this.penguins.push(p); + this.guests.push(p); + } + }, + + /* Room center with optional index offset to spread multiple penguins. + * idx/total spreads penguins horizontally within the room. */ + roomCenter: function(roomId, idx, total) { + var r = KScene.rooms[roomId]; + if (!r) return { x: KScene.width / 2, y: KScene.height / 2 }; + var cx = (r.x + r.w / 2) / 100 * KScene.width; + var cy = (r.y + r.h * 0.75) / 100 * KScene.height; + /* Gate room: offset the resident penguin to the right so it + * doesn't cover the centered "Syscall Gate" label text. */ + if (roomId === 'gate') { + cx = (r.x + r.w * 0.35) / 100 * KScene.width; + } + /* Horizontal spread when multiple penguins in same room */ + if (total && total > 1) { + var roomW = r.w / 100 * KScene.width; + var spacing = Math.min(50, roomW * 0.6 / total); + cx += (idx - (total - 1) / 2) * spacing; + } + return { x: cx, y: cy }; + }, + + /* Reposition all idle residents to their room centers (after resize) */ + repositionAll: function() { + var ids = Object.keys(this.residents); + for (var i = 0; i < ids.length; i++) { + var p = this.residents[ids[i]]; + if (p.state === 'idle') { + var c = this.roomCenter(ids[i]); + p.x = c.x; + p.y = c.y; + } + } + }, + + /* Get an available guest penguin from the pool, or null */ + acquireGuest: function() { + for (var i = 0; i < this.guests.length; i++) { + if (!this.guests[i].visible) { + this.guests[i].visible = true; + this.guests[i].gen++; + return this.guests[i]; + } + } + this.crowdCount++; + return null; + }, + + /* Reset a guest penguin's narrative state (tint, trail, labels). + * Called before releasing or when cleaning up after demo/offline. */ + resetGuest: function(p) { + p.tint = null; + p.dispName = ''; + p.trail = []; + p.label = ''; + p.pid = 0; + p.cmd = ''; + p.onArrive = null; + }, + + releaseGuest: function(p) { + p.visible = false; + p.room = 'attic'; + KPenguin.setState(p, 'idle'); + if (this.crowdCount > 0) this.crowdCount--; + }, + + start: function() { + if (this.running) return; + this.running = true; + this.lastTick = performance.now(); + requestAnimationFrame(this.boundLoop); + }, + + stop: function() { + this.running = false; + }, + + loop: function(now) { + if (!this.running) return; + requestAnimationFrame(this.boundLoop); + + /* When paused AND not running a demo, freeze animation. + * Demo always runs regardless of pause state. */ + if (!KState.paused || this.demoRunning) { + while (now - this.lastTick >= KPenguin.TICK_MS) { + this.lastTick += KPenguin.TICK_MS; + KPenguin.tick(); + KIntent.drain(KPenguin.clock); + for (var i = 0; i < this.penguins.length; i++) { + KPenguin.update(this.penguins[i]); + } + KBubble.cleanup(); + } + } else { + this.lastTick = now; + } + + this.render(); + }, + + render: function() { + if (!KScene.ctx) return; + KScene.drawHouse(); + + /* Sort by Y for depth ordering */ + var visible = []; + for (var i = 0; i < this.penguins.length; i++) { + if (this.penguins[i].visible) visible.push(this.penguins[i]); + } + visible.sort(function(a, b) { return a.y - b.y; }); + + for (var j = 0; j < visible.length; j++) { + KPenguin.draw(KScene.ctx, visible[j]); + } + + /* Educational overlays: flow arrows (only when rooms are active) */ + KEducation.drawActiveFlows(KScene.ctx); + + /* Crowd badge */ + if (this.crowdCount > 0) { + this.drawCrowdBadge(); + } + + /* Reposition bubbles */ + KBubble.reposition(this.penguins); + }, + + drawCrowdBadge: function() { + var center = this.roomCenter('attic'); + var ctx = KScene.ctx; + var text = '+' + this.crowdCount; + ctx.save(); + ctx.font = 'bold 12px monospace'; + ctx.fillStyle = KScene.theme.accent; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(text, center.x + 60, center.y - 20); + ctx.restore(); + }, + + /* --- Offline detection --- */ + + offlineEl: null, + + showOffline: function() { + /* Hide all penguins and null their walk callbacks to prevent + * stranded onArrive closures (update skips invisible penguins). */ + for (var i = 0; i < this.penguins.length; i++) { + var p = this.penguins[i]; + p.visible = false; + p.busyLevel = 0; + this.resetGuest(p); + KPenguin.setState(p, 'idle'); + } + /* Clear bubbles, intent queue, and crowd counter */ + KBubble.clear(); + KIntent.queue = []; + KIntent.activeWalks = 0; + this.crowdCount = 0; + /* Cancel any demo */ + if (this.demoRunning) { + this.cancelDemo(); + this.updateDemoUI(false); + } + + /* Draw one last frame showing empty rooms */ + this.render(); + /* Stop the animation loop */ + this.stop(); + + /* Show offline overlay */ + if (!KScene.overlay) return; + if (!this.offlineEl) { + this.offlineEl = document.createElement('div'); + this.offlineEl.className = 'kh-offline'; + KScene.overlay.appendChild(this.offlineEl); + } + this.offlineEl.innerHTML = + 'kbox offline
' + + 'The guest process has exited.
' + + 'Waiting for reconnection\u2026'; + this.offlineEl.style.display = 'block'; + }, + + hideOffline: function() { + if (this.offlineEl) this.offlineEl.style.display = 'none'; + if (!this.running && this.penguinsCreated) { + /* Re-show resident penguins at their home positions */ + var ids = Object.keys(this.residents); + for (var i = 0; i < ids.length; i++) { + var p = this.residents[ids[i]]; + var c = this.roomCenter(ids[i]); + p.x = c.x; + p.y = c.y; + p.visible = true; + p.busyLevel = 0; + p.label = ''; + KPenguin.setState(p, 'idle'); + } + /* Guests stay hidden until new events arrive */ + this.start(); + } + }, + + /* --- Hover tooltip --- */ + + tooltipEl: null, + + bindHover: function() { + if (!KScene.canvas) return; + var self = this; + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'kh-tooltip'; + this.tooltipEl.style.display = 'none'; + if (KScene.overlay) KScene.overlay.appendChild(this.tooltipEl); + + KScene.canvas.addEventListener('mousemove', function(e) { + var rect = KScene.canvas.getBoundingClientRect(); + var sx = KScene.width / rect.width; + var sy = KScene.height / rect.height; + var cx = (e.clientX - rect.left) * sx; + var cy = (e.clientY - rect.top) * sy; + + var found = null; + for (var i = 0; i < self.penguins.length; i++) { + if (KPenguin.hitTest(self.penguins[i], cx, cy)) { + found = self.penguins[i]; + break; + } + } + + if (found) { + self.showTooltip(found, e.clientX - rect.left, e.clientY - rect.top); + } else { + self.hideTooltip(); + } + }); + + KScene.canvas.addEventListener('mouseleave', function() { + self.hideTooltip(); + }); + }, + + showTooltip: function(p, px, py) { + if (!this.tooltipEl) return; + var lines = []; + + if (p.isGuest) { + lines.push('Guest Process'); + if (p.pid) lines.push('PID: ' + p.pid); + if (p.cmd) lines.push('Command: ' + p.cmd); + if (p.label) lines.push('Syscall: ' + p.label); + if (p.dispName) { + lines.push('Dispatch: ' + p.dispName); + } + lines.push('Room: ' + (KScene.str('rooms.' + p.room + '.name') || p.room)); + } else { + /* Map room id to strings.json character key */ + var charKeys = { + gate: 'dispatcher', vfs: 'file', process: 'sched', + memory: 'memory', network: 'net', fdvault: 'storage' + }; + var charKey = charKeys[p.room] || p.room; + var role = KScene.str('characters.' + charKey) || p.id; + lines.push(role); + lines.push('Room: ' + (KScene.str('rooms.' + p.room + '.name') || p.room)); + if (p.label) lines.push('Last: ' + p.label); + if (p.busyLevel > 0.05) lines.push('Load: ' + Math.round(p.busyLevel * 100) + '%'); + lines.push('State: ' + p.state); + } + + this.tooltipEl.innerHTML = lines.join('
'); + this.tooltipEl.style.display = 'block'; + this.tooltipEl.style.left = (px + 12) + 'px'; + this.tooltipEl.style.top = (py - 10) + 'px'; + }, + + hideTooltip: function() { + if (this.tooltipEl) this.tooltipEl.style.display = 'none'; + }, + + /* --- Demo sequence --- */ + + demoBtn: null, + demoBannerEl: null, + + bindDemo: function() { + this.demoBtn = document.getElementById('btn-demo'); + if (this.demoBtn) { + var self = this; + this.demoBtn.addEventListener('click', function() { + if (self.demoRunning) { + self.stopDemo(); + } else { + self.runDemo(); + } + }); + } + }, + + updateDemoUI: function(running) { + /* Button text */ + if (this.demoBtn) { + this.demoBtn.textContent = running ? 'Stop Demo' : 'Demo'; + this.demoBtn.classList.toggle('active', running); + } + /* Canvas banner */ + if (running) { + if (!this.demoBannerEl && KScene.overlay) { + this.demoBannerEl = document.createElement('div'); + this.demoBannerEl.className = 'kh-demo-banner'; + KScene.overlay.appendChild(this.demoBannerEl); + } + if (this.demoBannerEl) { + this.demoBannerEl.textContent = 'DEMO — cat /etc/hostname'; + this.demoBannerEl.style.display = 'block'; + } + } else { + if (this.demoBannerEl) this.demoBannerEl.style.display = 'none'; + } + }, + + /* Cancel a running demo, clean up guests, restore live state */ + cancelDemo: function() { + for (var i = 0; i < this.demoTimers.length; i++) { + clearTimeout(this.demoTimers[i]); + } + this.demoTimers = []; + this.demoRunning = false; + this.demoVersion++; + }, + + stopDemo: function() { + this.cancelDemo(); + /* Release all demo guests */ + for (var i = 0; i < this.guests.length; i++) { + if (this.guests[i].visible) { + this.resetGuest(this.guests[i]); + this.releaseGuest(this.guests[i]); + } + } + /* Reset residents */ + var ids = Object.keys(this.residents); + for (var j = 0; j < ids.length; j++) { + this.residents[ids[j]].label = ''; + this.residents[ids[j]].busyLevel = 0; + KPenguin.setState(this.residents[ids[j]], 'idle'); + } + KBubble.clear(); + this.updateDemoUI(false); + }, + + demoDelay: function(fn, ms) { + var ver = this.demoVersion; + var self = this; + this.demoTimers.push(setTimeout(function() { + if (self.demoVersion === ver) fn(); + }, ms)); + }, + + runDemo: function() { + var self = this; + + /* Guard re-entry: cancel previous demo */ + if (this.demoRunning) this.cancelDemo(); + this.demoRunning = true; + var ver = this.demoVersion; + this.updateDemoUI(true); + this.start(); + + /* Reset: release all visible guests, clear bubbles and crowd counter */ + for (var g = 0; g < this.guests.length; g++) { + if (this.guests[g].visible) { + this.resetGuest(this.guests[g]); + this.releaseGuest(this.guests[g]); + } + } + this.crowdCount = 0; + KBubble.clear(); + KIntent.queue = []; + KIntent.activeWalks = 0; + + /* Reset resident positions and busyness */ + var ids = Object.keys(this.residents); + for (var i = 0; i < ids.length; i++) { + var c = this.roomCenter(ids[i]); + var p = this.residents[ids[i]]; + p.x = c.x; + p.y = c.y; + p.busyLevel = 0; + p.label = ''; + KPenguin.setState(p, 'idle'); + } + + /* --- Demo narrative: "A shell runs `cat /etc/hostname`" --- + * Shows the full lifecycle: openat (LKL) -> read (LKL) -> write (CONTINUE) + * plus a concurrent getpid (CONTINUE) and a rejected accept4 (ENOSYS). */ + + var attic = this.roomCenter('attic'); + var gate = this.roomCenter('gate'); + + /* Scene 1: openat -> VFS (LKL emulated, blue trail) */ + var g1 = this.acquireGuest(); + if (!g1) { this.demoRunning = false; this.updateDemoUI(false); return; } + g1.x = attic.x - 30; + g1.y = attic.y; + g1.label = 'openat'; + g1.tint = null; + g1.trail = []; + KBubble.show(g1.id, 'openat', 2500); + + this.demoDelay(function() { + /* Walk to gate */ + KPenguin.walkTo(g1, gate.x, gate.y, function() { + if (self.demoVersion !== ver) return; + g1.room = 'gate'; + /* Dispatcher routes to VFS */ + var disp = self.residents.gate; + if (disp) { + disp.facing = 2; disp.flipX = false; + KPenguin.setState(disp, 'type'); + disp.busyLevel = 0.6; + KBubble.show(disp.id, '\u2192 VFS', 2000); + } + g1.tint = KIntent.DISP_TINT['return']; /* blue: LKL */ + g1.trail = []; + /* Walk to VFS */ + var vfs = self.roomCenter('vfs'); + KPenguin.walkTo(g1, vfs.x, vfs.y, function() { + if (self.demoVersion !== ver) return; + g1.room = 'vfs'; + g1.label = 'LKL'; + var res = self.residents.vfs; + if (res) { + KPenguin.setState(res, 'type'); + res.busyLevel = 0.7; + res.label = 'openat'; + KBubble.show(res.id, 'openat 23.5\u00b5s', 2500); + } + /* Fade out after pause */ + self.demoDelay(function() { + self.resetGuest(g1); + self.releaseGuest(g1); + }, 1800); + }); + }); + }, 400); + + /* Scene 2 (t=1.5s): getpid -> Process (CONTINUE, green trail) */ + this.demoDelay(function() { + var g2 = self.acquireGuest(); + if (!g2) return; + g2.x = attic.x + 30; + g2.y = attic.y; + g2.label = 'getpid'; + g2.trail = []; + KBubble.show(g2.id, 'getpid', 2000); + self.demoDelay(function() { + KPenguin.walkTo(g2, gate.x + 20, gate.y, function() { + if (self.demoVersion !== ver) return; + g2.room = 'gate'; + g2.tint = KIntent.DISP_TINT['continue']; /* green: CONTINUE */ + g2.trail = []; + var proc = self.roomCenter('process'); + KPenguin.walkTo(g2, proc.x, proc.y, function() { + if (self.demoVersion !== ver) return; + g2.room = 'process'; + g2.label = 'HOST'; + var res = self.residents.process; + if (res) { + KPenguin.setState(res, 'type'); + res.busyLevel = 0.4; + KBubble.show(res.id, 'getpid 0.3\u00b5s', 2000); + } + self.demoDelay(function() { + self.resetGuest(g2); + self.releaseGuest(g2); + }, 1200); + }); + }); + }, 300); + }, 1500); + + /* Scene 3 (t=3s): accept4 -> Network (ENOSYS, orange trail) */ + this.demoDelay(function() { + var g3 = self.acquireGuest(); + if (!g3) return; + g3.x = attic.x; + g3.y = attic.y; + g3.label = 'accept4'; + g3.trail = []; + KBubble.show(g3.id, 'accept4', 2000); + self.demoDelay(function() { + KPenguin.walkTo(g3, gate.x - 10, gate.y, function() { + if (self.demoVersion !== ver) return; + g3.room = 'gate'; + var disp = self.residents.gate; + if (disp) { + disp.facing = 2; disp.flipX = true; + KPenguin.setState(disp, 'type'); + KBubble.show(disp.id, '\u2192 Network', 2000); + } + g3.tint = KIntent.DISP_TINT['enosys']; /* orange: ENOSYS */ + g3.trail = []; + var net = self.roomCenter('network'); + KPenguin.walkTo(g3, net.x, net.y, function() { + if (self.demoVersion !== ver) return; + g3.room = 'network'; + g3.label = 'ENOSYS'; + var res = self.residents.network; + if (res) { + KPenguin.setState(res, 'error'); + res.busyLevel = 0.5; + KBubble.show(res.id, 'ENOSYS: accept4', 2500); + } + self.demoDelay(function() { + self.resetGuest(g3); + self.releaseGuest(g3); + }, 1500); + }); + }); + }, 300); + }, 3000); + + /* Scene 4 (t=4s): mmap -> Memory (LKL, blue trail) */ + this.demoDelay(function() { + var g4 = self.acquireGuest(); + if (!g4) return; + g4.x = attic.x + 15; + g4.y = attic.y; + g4.label = 'mmap'; + g4.trail = []; + KPenguin.walkTo(g4, gate.x, gate.y, function() { + if (self.demoVersion !== ver) return; + g4.room = 'gate'; + g4.tint = KIntent.DISP_TINT['return']; + g4.trail = []; + var mem = self.roomCenter('memory'); + KPenguin.walkTo(g4, mem.x, mem.y, function() { + if (self.demoVersion !== ver) return; + g4.room = 'memory'; + g4.label = 'LKL'; + var res = self.residents.memory; + if (res) { + KPenguin.setState(res, 'type'); + res.busyLevel = 0.6; + KBubble.show(res.id, 'mmap 18.7\u00b5s', 2000); + } + self.demoDelay(function() { + if (res) KPenguin.setState(res, 'celebrate'); + self.demoDelay(function() { + self.resetGuest(g4); + self.releaseGuest(g4); + }, 1000); + }, 1200); + }); + }); + }, 4000); + + /* Auto-clear demo: release remaining guests, reset state, resume live */ + this.demoDelay(function() { + /* Release any demo guests still visible */ + for (var gi = 0; gi < self.guests.length; gi++) { + if (self.guests[gi].visible) { + self.resetGuest(self.guests[gi]); + self.releaseGuest(self.guests[gi]); + } + } + /* Reset resident labels and state */ + var rids = Object.keys(self.residents); + for (var j = 0; j < rids.length; j++) { + self.residents[rids[j]].label = ''; + self.residents[rids[j]].busyLevel = 0; + KPenguin.setState(self.residents[rids[j]], 'idle'); + } + KBubble.clear(); + self.demoRunning = false; + self.demoTimers = []; + self.updateDemoUI(false); + }, 8000); + } +}; diff --git a/web/js/intent.js b/web/js/intent.js new file mode 100644 index 0000000..18e5257 --- /dev/null +++ b/web/js/intent.js @@ -0,0 +1,298 @@ +/* Animation intent queue. + * + * Decouples telemetry event arrival (SSE callbacks, snapshot deltas) + * from the render loop. Producers push "intents" into a FIFO queue; + * the fixed-timestep consumer drains them at a capped rate inside the + * requestAnimationFrame loop. + * + * Intent types: + * { type: 'move', penguin: id, room: roomId, onArrive: fn } + * { type: 'action', penguin: id, state: 'type'|'celebrate'|'error' } + * { type: 'bubble', penguin: id, text: string, ttl: ms } + * { type: 'glow', room: roomId, level: 0..1 } + * { type: 'guest', room: roomId, syscall: string, disp: string, latNs: number } + * + * Subsystem-weighted selection: when the queue has more entries than + * can be processed per tick, prefer intents targeting rooms not recently + * animated (round-robin fairness). + */ +'use strict'; + +var KIntent = { + queue: [], + MAX_QUEUE: 128, + MAX_PER_TICK: 3, /* max intents consumed per animation tick */ + MAX_CONCURRENT_WALKS: 4, /* max simultaneous penguin walks */ + activeWalks: 0, + + /* Track last-animated room for weighted selection */ + lastAnimatedRoom: {}, /* roomId -> tick number */ + + push: function(intent) { + if (this.queue.length >= this.MAX_QUEUE) { + /* Drop oldest non-glow intent; if all are glow, drop oldest glow */ + var victim = -1; + for (var i = 0; i < this.queue.length; i++) { + if (this.queue[i].type !== 'glow') { victim = i; break; } + } + if (victim === -1) victim = 0; + this.queue.splice(victim, 1); + } + this.queue.push(intent); + }, + + /* Consume up to MAX_PER_TICK intents. Called once per animation tick. */ + drain: function(tick) { + if (this.queue.length === 0) return; + + /* Partition: glow intents are instant (always process all) */ + var glows = []; + var pending = []; + for (var i = 0; i < this.queue.length; i++) { + if (this.queue[i].type === 'glow') { + glows.push(this.queue[i]); + } else { + pending.push(this.queue[i]); + } + } + + /* Apply all glow intents immediately */ + for (var g = 0; g < glows.length; g++) { + this.execGlow(glows[g]); + } + + /* Room-fair FIFO: pick the next intent from the room least recently + * animated, but preserve FIFO order within each room. This prevents + * a flood of VFS events from starving other rooms while keeping + * causal ordering (move before action/bubble) intact per room. */ + var consumed = 0; + var remaining = []; + var skippedRooms = {}; /* rooms that hit walk cap this tick */ + + while (consumed < this.MAX_PER_TICK && pending.length > 0) { + /* Find the pending intent whose room was least recently animated */ + var bestIdx = -1; + var bestTick = Infinity; + for (var j = 0; j < pending.length; j++) { + var room = pending[j].room || ''; + if (skippedRooms[room]) continue; + var lastTick = this.lastAnimatedRoom[room] || 0; + if (lastTick < bestTick) { + bestTick = lastTick; + bestIdx = j; + } + } + if (bestIdx === -1) break; /* all remaining rooms are blocked */ + + var intent = pending[bestIdx]; + + /* Check walk concurrency cap */ + if ((intent.type === 'move' || intent.type === 'guest') && + this.activeWalks >= this.MAX_CONCURRENT_WALKS) { + skippedRooms[intent.room || ''] = true; + continue; /* retry with next best room */ + } + + pending.splice(bestIdx, 1); + this.exec(intent, tick); + consumed++; + } + + this.queue = pending; + }, + + exec: function(intent, tick) { + var room = intent.room || ''; + if (room) this.lastAnimatedRoom[room] = tick; + + switch (intent.type) { + case 'move': + this.execMove(intent); + break; + case 'action': + this.execAction(intent); + break; + case 'bubble': + this.execBubble(intent); + break; + case 'guest': + this.execGuest(intent, tick); + break; + } + }, + + execGlow: function(intent) { + if (KScene.glow.hasOwnProperty(intent.room)) { + KScene.glow[intent.room] = intent.level; + } + }, + + /* Deduplicate glow: update existing glow intent for same room instead of pushing */ + pushGlow: function(room, level) { + for (var i = 0; i < this.queue.length; i++) { + if (this.queue[i].type === 'glow' && this.queue[i].room === room) { + this.queue[i].level = level; + return; + } + } + this.push({ type: 'glow', room: room, level: level }); + }, + + decrementWalks: function() { + this.activeWalks = Math.max(0, this.activeWalks - 1); + }, + + execMove: function(intent) { + var p = this.findPenguin(intent.penguin); + if (!p) return; + var center = KHouse.roomCenter(intent.room); + /* Slight random offset to avoid stacking */ + var ox = (Math.random() - 0.5) * 20; + this.activeWalks++; + var self = this; + KPenguin.walkTo(p, center.x + ox, center.y, function() { + self.decrementWalks(); + p.room = intent.room; + if (intent.onArrive) intent.onArrive(p); + }); + }, + + execAction: function(intent) { + var p = this.findPenguin(intent.penguin); + if (!p) return; + KPenguin.setState(p, intent.state); + }, + + execBubble: function(intent) { + KBubble.show(intent.penguin, intent.text, intent.ttl || 3000); + }, + + /* Disposition -> tint color */ + DISP_TINT: { + 'continue': '#3fb950', /* green: host kernel handles */ + 'return': '#58a6ff', /* blue: LKL emulated */ + 'enosys': '#d29922' /* orange: rejected */ + }, + + fmtLat: function(ns) { + if (ns <= 0) return ''; + if (ns < 1000) return ns + 'ns'; + if (ns < 1000000) return (ns / 1000).toFixed(1) + '\u00b5s'; + return (ns / 1000000).toFixed(1) + 'ms'; + }, + + /* Guest syscall narrative lifecycle: + * 1. Appear in attic with syscall label + * 2. Walk to gate (dispatcher routing stop) + * 3. Dispatcher faces target room, shows routing info + * 4. Guest gets disposition tint, walks gate -> target room (trail visible) + * 5. Resident shows sustained busyness + * 6. Guest fades out in-place (no noisy return walk) */ + execGuest: function(intent, tick) { + var guest = KHouse.acquireGuest(); + if (!guest) return; + + var attic = KHouse.roomCenter('attic'); + var gate = KHouse.roomCenter('gate'); + var targetRoom = intent.room || 'gate'; + var syscall = intent.syscall || '?'; + var disp = intent.disp || 'return'; + var latNs = intent.latNs || 0; + var tint = this.DISP_TINT[disp] || '#58a6ff'; + + var pid = intent.pid || 0; + + /* Phase 1: appear in attic, spread across the room width */ + var atticRect = KScene.roomRect('attic'); + var spreadW = atticRect ? atticRect.w * 0.6 : 60; + guest.x = attic.x + (Math.random() - 0.5) * spreadW; + guest.y = attic.y; + guest.room = 'attic'; + guest.tint = null; + guest.trail = []; + guest.pid = pid; + var pidInfo = KScene.userSpace.pidCmds[pid]; + guest.cmd = (pidInfo && pidInfo.label) ? pidInfo.label : ''; + guest.label = syscall; + KBubble.show(guest.id, (pid ? 'PID ' + pid + ': ' : '') + syscall, 2500); + + /* Phase 2: walk to gate */ + this.activeWalks++; + var self = this; + var gateRect = KScene.roomRect('gate'); + var gateSpread = gateRect ? gateRect.w * 0.4 : 40; + KPenguin.walkTo(guest, gate.x + (Math.random() - 0.5) * gateSpread, gate.y, function() { + self.decrementWalks(); + guest.room = 'gate'; + + /* Phase 3: dispatcher routing */ + var dispatcher = KHouse.residents.gate; + if (dispatcher) { + var targetCenter = KHouse.roomCenter(targetRoom); + var ddx = targetCenter.x - dispatcher.x; + if (Math.abs(ddx) > 10) { + dispatcher.facing = 2; + dispatcher.flipX = ddx > 0; + } else { + dispatcher.facing = 0; + } + KPenguin.setState(dispatcher, 'type'); + dispatcher.busyLevel = Math.min(1, dispatcher.busyLevel + 0.3); + var roomName = KScene.str('rooms.' + targetRoom + '.name') || targetRoom; + KBubble.show(dispatcher.id, '\u2192 ' + roomName, 2000); + } + + /* Phase 4: apply tint + disposition name, walk to target */ + guest.tint = tint; + guest.dispName = disp === 'continue' ? 'CONTINUE (host)' : + disp === 'enosys' ? 'ENOSYS (rejected)' : 'LKL emulated'; + guest.trail = []; + + if (targetRoom === 'gate') { + self.finishGuest(guest, targetRoom, syscall, disp, latNs); + return; + } + + self.activeWalks++; + var dest = KHouse.roomCenter(targetRoom); + var destRect = KScene.roomRect(targetRoom); + var destSpread = destRect ? destRect.w * 0.4 : 30; + KPenguin.walkTo(guest, dest.x + (Math.random() - 0.5) * destSpread, dest.y, function() { + self.decrementWalks(); + guest.room = targetRoom; + self.finishGuest(guest, targetRoom, syscall, disp, latNs); + }); + }); + }, + + /* Phase 5-6: resident reacts, guest fades */ + finishGuest: function(guest, room, syscall, disp, latNs) { + var resident = KHouse.residents[room]; + if (resident) { + KPenguin.setState(resident, disp === 'enosys' ? 'error' : 'type'); + resident.busyLevel = Math.min(1, resident.busyLevel + 0.25); + resident.label = syscall; + var latStr = this.fmtLat(latNs); + KBubble.show(resident.id, latStr ? syscall + ' ' + latStr : syscall, 2500); + } + + /* Guest shows disposition result label */ + guest.label = disp === 'continue' ? 'HOST' : disp === 'enosys' ? 'ENOSYS' : 'LKL'; + + /* Fade out in-place. Use generation counter to avoid releasing a + * guest that was already recycled into a newer flow. */ + var gen = guest.gen; + setTimeout(function() { + if (!guest.visible || guest.gen !== gen) return; + KHouse.resetGuest(guest); + KHouse.releaseGuest(guest); + }, 1500); + }, + + findPenguin: function(id) { + for (var i = 0; i < KHouse.penguins.length; i++) { + if (KHouse.penguins[i].id === id) return KHouse.penguins[i]; + } + return null; + } +}; diff --git a/web/js/main.js b/web/js/main.js index 6e9f9c7..d343b43 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -5,5 +5,7 @@ document.addEventListener('DOMContentLoaded', function() { KEvents.init(); KCharts.init(); KControls.init(); + KScene.init('kh-canvas', 'kh-overlay'); + KHouse.init(); KPolling.start(); }); diff --git a/web/js/penguin.js b/web/js/penguin.js new file mode 100644 index 0000000..d80cd23 --- /dev/null +++ b/web/js/penguin.js @@ -0,0 +1,311 @@ +/* Penguin sprite animation engine. + * + * Renders pixel-art penguins on KScene's canvas. Each penguin has a + * state machine (idle/walk/type/celebrate/error), position, facing + * direction, and optional accessory overlay. + * + * Style: star-office-ui-v2 inspired -- smooth sinusoidal wobble, + * quadratic ease-out movement, bouncy celebrate, elastic error shake. + * + * Sprite sheet layout: 7 columns x 3 rows, each frame 20x20 px. + * Columns: 0=idle, 1-3=walk cycle, 4=type1, 5=type2, 6=error + * Rows: 0=down(front), 1=up(back), 2=side(left) + * + * Side-facing sprites face left; mirror via scaleX(-1) for right. + */ +'use strict'; + +var KPenguin = { + FRAME_W: 16, + FRAME_H: 28, + COLS: 7, + SCALE: 2.5, /* render scale (16*2.5=40w, 28*2.5=70h, matches tinyoffice's 35x70) */ + TICK_MS: 100, /* faster tick for smoother animation */ + + /* Sprite images (loaded in init) */ + imgBase: null, + imgGuest: null, + accImages: {}, + + /* Animation sequences: state -> frame index array */ + ANIMS: { + idle: [0], + walk: [1, 2, 3, 2], + type: [4, 5], + celebrate: [4, 5], + error: [6, 4] + }, + + DURATIONS: { + idle: 0, + walk: 0, + type: 10, /* ~1s */ + celebrate: 8, + error: 10 + }, + + loaded: false, + clock: 0, + timeMs: 0, /* continuous time for smooth sin/cos */ + isLight: false, /* cached theme state, updated by KScene.readTheme() */ + + init: function() { + var self = this; + var pending = 7; + var done = function() { if (--pending === 0) self.loaded = true; }; + + this.imgBase = new Image(); + this.imgBase.onload = done; + this.imgBase.src = '/art/penguin-base.png'; + + this.imgGuest = new Image(); + this.imgGuest.onload = done; + this.imgGuest.src = '/art/penguin-guest.png'; + + var accNames = ['hat', 'folder', 'stopwatch', 'memblock', 'envelope']; + for (var i = 0; i < accNames.length; i++) { + var img = new Image(); + img.onload = done; + img.src = '/art/acc-' + accNames[i] + '.png'; + this.accImages[accNames[i]] = img; + } + }, + + tick: function() { + this.clock++; + this.timeMs += this.TICK_MS; + }, + + create: function(opts) { + return { + id: opts.id || '', + x: opts.x || 0, + y: opts.y || 0, + targetX: opts.x || 0, + targetY: opts.y || 0, + state: 'idle', + facing: opts.facing || 0, + flipX: false, + frameIdx: 0, + stateTimer: 0, + acc: opts.acc || null, + isGuest: opts.isGuest || false, + visible: opts.visible !== undefined ? opts.visible : true, + room: opts.room || 'gate', + speed: opts.speed || 2, + walkProgress: 0, /* 0..1 for eased movement */ + startX: 0, startY: 0, /* walk origin for easing */ + onArrive: null, + /* Narrative state */ + tint: null, /* disposition color */ + dispName: '', /* disposition label for tooltip */ + trail: [], /* recent positions for motion trail */ + label: '', /* short label shown below */ + busyLevel: 0, /* 0..1: sustained busyness for residents */ + pid: 0, /* tracee PID (from SSE event) */ + cmd: '', /* command name (e.g. 'ash', 'cat') */ + gen: 0 /* generation counter for timer guards */ + }; + }, + + setState: function(p, state) { + if (p.state === state) return; + p.state = state; + p.frameIdx = 0; + p.stateTimer = this.DURATIONS[state] || 0; + /* Clear trail when leaving walk state to prevent stale ghost dots */ + if (state !== 'walk') p.trail = []; + }, + + /* Quadratic ease-out: fast start, smooth deceleration */ + easeOut: function(t) { + return t * (2 - t); + }, + + walkTo: function(p, tx, ty, onArrive) { + p.startX = p.x; + p.startY = p.y; + p.targetX = tx; + p.targetY = ty; + p.walkProgress = 0; + p.onArrive = onArrive || null; + this.setState(p, 'walk'); + + var dx = tx - p.x; + var dy = ty - p.y; + if (Math.abs(dx) > Math.abs(dy)) { + p.facing = 2; + p.flipX = dx > 0; + } else if (dy < 0) { + p.facing = 1; + p.flipX = false; + } else { + p.facing = 0; + p.flipX = false; + } + }, + + update: function(p) { + if (!p.visible) return; + + /* Record trail for walking tinted guests (every 4th tick, max 3 dots) */ + if (p.state === 'walk' && p.tint && this.clock % 4 === 0) { + p.trail.push({ x: p.x, y: p.y }); + if (p.trail.length > 3) p.trail.shift(); + } + + /* Decay resident busyness toward 0 */ + if (p.busyLevel > 0) { + p.busyLevel = Math.max(0, p.busyLevel - 0.008); + } + + /* Walk: eased interpolation instead of fixed-speed linear */ + if (p.state === 'walk') { + var totalDist = Math.sqrt( + Math.pow(p.targetX - p.startX, 2) + + Math.pow(p.targetY - p.startY, 2)); + /* Speed as progress per tick (faster for shorter distances) */ + var step = totalDist > 0 ? (p.speed / totalDist) : 1; + p.walkProgress = Math.min(1, p.walkProgress + step); + var t = this.easeOut(p.walkProgress); + + p.x = p.startX + (p.targetX - p.startX) * t; + p.y = p.startY + (p.targetY - p.startY) * t; + + if (p.walkProgress >= 1) { + p.x = p.targetX; + p.y = p.targetY; + this.setState(p, 'idle'); + if (p.onArrive) { + var cb = p.onArrive; + p.onArrive = null; + cb(p); + } + return; + } + } + + if (p.stateTimer > 0) { + p.stateTimer--; + if (p.stateTimer === 0) { + this.setState(p, 'idle'); + } + } + + var anim = this.ANIMS[p.state] || this.ANIMS.idle; + p.frameIdx = (p.frameIdx + 1) % anim.length; + }, + + draw: function(ctx, p) { + if (!p.visible || !this.loaded) return; + + var anim = this.ANIMS[p.state] || this.ANIMS.idle; + var col = anim[p.frameIdx % anim.length]; + var row = p.facing; + var img = p.isGuest ? this.imgGuest : this.imgBase; + + var sw = this.FRAME_W; + var sh = this.FRAME_H; + var sx = col * sw; + var sy = row * sh; + var dw = sw * this.SCALE; + var dh = sh * this.SCALE; + + /* Animation offsets. + * Walk: penguin waddle -- side-to-side sway + vertical bob, like a + * real penguin rocking from foot to foot. The sway is perpendicular + * to the travel direction (horizontal if walking vertically, etc). */ + var bob = 0; /* vertical offset */ + var sway = 0; /* horizontal offset (waddle) */ + var tilt = 0; /* rotation in radians (body lean) */ + + if (p.state === 'walk') { + var phase = this.timeMs / 180; /* waddle cycle */ + bob = Math.abs(Math.sin(phase)) * -4; /* up on each step (pronounced) */ + sway = Math.sin(phase) * 2.5; /* side-to-side rock */ + tilt = Math.sin(phase) * 0.06; /* slight body lean toward foot */ + } else if (p.state === 'idle') { + bob = Math.sin(this.timeMs / 800) * 0.5; /* gentle breathing */ + } else if (p.state === 'celebrate') { + bob = -Math.abs(Math.sin(this.timeMs / 150)) * 5; + } else if (p.state === 'type') { + bob = Math.sin(this.timeMs / 300) * 0.8; + } + bob = Math.round(bob); + sway = Math.round(sway); + + var shake = 0; + if (p.state === 'error') { + shake = Math.sin(this.timeMs / 50) * 3 * Math.exp(-((this.clock % 20) / 15)); + } + shake = Math.round(shake); + + var dx = Math.round(p.x - dw / 2) + shake + sway; + var dy = Math.round(p.y - dh) + bob; + + /* Motion trail (3 dots max, only for walking tinted guests) */ + if (p.trail.length > 0 && p.tint) { + ctx.save(); + ctx.fillStyle = p.tint; + for (var ti = 0; ti < p.trail.length; ti++) { + ctx.globalAlpha = (ti + 1) / p.trail.length * 0.18; + ctx.beginPath(); + ctx.arc(p.trail[ti].x, p.trail[ti].y, 1.5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + /* Ground shadow -- single clean ellipse, no layering. + * Use slightly lighter color in dark mode for visibility. */ + var shadowW = dw * 0.35; + var shadowH = Math.max(3, dh * 0.06); + var shadowX = Math.round(p.x) + sway; + var shadowY = Math.round(p.y); + ctx.save(); + ctx.globalAlpha = this.isLight ? 0.3 : 0.4; + ctx.fillStyle = this.isLight ? '#000' : '#1a1510'; + ctx.beginPath(); + ctx.ellipse(shadowX, shadowY + 2, shadowW, shadowH, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + /* Sprite + accessory -- apply waddle tilt during walk */ + ctx.save(); + if (tilt !== 0) { + /* Rotate around penguin's feet, tracking bob for accurate pivot */ + var pivotX = Math.round(p.x) + sway; + var pivotY = Math.round(p.y) + bob; + ctx.translate(pivotX, pivotY); + ctx.rotate(tilt); + ctx.translate(-pivotX, -pivotY); + } + if (p.flipX) { + ctx.translate(Math.round(p.x) + shake + sway, dy); + ctx.scale(-1, 1); + ctx.drawImage(img, sx, sy, sw, sh, -dw / 2, 0, dw, dh); + if (p.acc && this.accImages[p.acc]) { + ctx.drawImage(this.accImages[p.acc], sx, 0, sw, sh, -dw / 2, 0, dw, dh); + } + } else { + ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh); + if (p.acc && this.accImages[p.acc]) { + ctx.drawImage(this.accImages[p.acc], sx, 0, sw, sh, dx, dy, dw, dh); + } + } + ctx.restore(); + + /* Labels removed -- info shown via hover tooltip to avoid clutter + * when multiple penguins are close together. */ + }, + + /* Hit-test: is canvas point (cx, cy) over this penguin? */ + hitTest: function(p, cx, cy) { + if (!p.visible) return false; + var dw = this.FRAME_W * this.SCALE; + var dh = this.FRAME_H * this.SCALE; + var left = p.x - dw / 2; + var top = p.y - dh; + return cx >= left && cx < left + dw && cy >= top && cy < top + dh; + } +}; diff --git a/web/js/polling.js b/web/js/polling.js index e76eea1..87e6561 100644 --- a/web/js/polling.js +++ b/web/js/polling.js @@ -9,10 +9,11 @@ var KPolling = { start: function() { var self = this; /* Load history first, then start polling to avoid prevSnap races */ - this.loadHistory().finally(function() { + var startPolling = function() { self.poll(); self.timer = setInterval(self.poll.bind(self), KState.pollInterval); - }); + }; + this.loadHistory().then(startPolling, startPolling); this.connectSSE(); }, @@ -33,6 +34,9 @@ var KPolling = { if (i > 0) KCharts.update(snaps[i], snaps[i - 1]); } KState.prevSnap = snaps[snaps.length - 1]; + /* Seed glow levels from last two history snapshots */ + if (snaps.length >= 2) + KTelemetry.onSnapshot(snaps[snaps.length - 1], snaps[snaps.length - 2]); KPolling.historyLoaded = true; }) .catch(function() {}); @@ -49,9 +53,15 @@ var KPolling = { KGauges.update(snap, prev); KCharts.update(snap, prev); + KTelemetry.onSnapshot(snap, prev); + KState.connected = true; + KPolling.failCount = 0; + KPolling.setOffline(false); }) .catch(function() { KState.connected = false; + KPolling.failCount = (KPolling.failCount || 0) + 1; + if (KPolling.failCount >= 2) KPolling.setOffline(true); }); /* Guest name changes rarely; fetch in parallel, not chained */ @@ -71,6 +81,7 @@ var KPolling = { try { var d = JSON.parse(e.data); KEvents.addEvent('syscall', d); + KTelemetry.onSyscallEvent(d); } catch(err) {} }); this.evtSource.addEventListener('process', function(e) { @@ -79,7 +90,26 @@ var KPolling = { KEvents.addEvent('process', d); } catch(err) {} }); - this.evtSource.onopen = function() { KState.connected = true; }; + this.evtSource.onopen = function() { + KState.connected = true; + KPolling.setOffline(false); + }; this.evtSource.onerror = function() { KState.connected = false; }; + }, + + failCount: 0, + offline: false, + + setOffline: function(val) { + if (this.offline === val) return; + this.offline = val; + /* Notify the Kernel House to pause/show offline state */ + if (typeof KHouse !== 'undefined') { + if (val) { + KHouse.showOffline(); + } else { + KHouse.hideOffline(); + } + } } }; diff --git a/web/js/scene.js b/web/js/scene.js new file mode 100644 index 0000000..1b3f319 --- /dev/null +++ b/web/js/scene.js @@ -0,0 +1,468 @@ +/* Kernel House scene layout engine. + * + * Draws the "kernel house" cross-section on a . Each room is a + * rectangular region with a flat-color background. Room coordinates are + * stored as percentages of the canvas so the layout scales with viewport. + */ +'use strict'; + +var KScene = { + canvas: null, + ctx: null, + overlay: null, /* DOM overlay for speech bubbles / panels */ + strings: null, /* loaded from strings.json */ + width: 0, + height: 0, + + /* Room layout: dynamic -- subsystem rooms sized to fit penguins. + * Heights adapt based on canvas size via adjustRoomLayout(). */ + rooms: { + attic: { x: 0, y: 0, w: 100, h: 12, color: '#2a2520', label: 'User Space', active: true }, + gate: { x: 0, y: 12, w: 100, h: 10, color: '#332e28', label: 'Syscall Gate', active: true }, + vfs: { x: 0, y: 22, w: 25, h: 50, color: '#2d2822', label: 'VFS', active: true }, + process: { x: 25, y: 22, w: 25, h: 50, color: '#2a2428', label: 'Process Mgmt', active: true }, + memory: { x: 50, y: 22, w: 25, h: 50, color: '#28282d', label: 'Memory Mgmt', active: true }, + network: { x: 75, y: 22, w: 25, h: 50, color: '#2a2525', label: 'Network', active: true }, + basement:{ x: 0, y: 72, w: 70, h: 28, color: '#1e1a18', label: 'Block I/O', active: false }, + fdvault: { x: 70, y: 72, w: 30, h: 28, color: '#252020', label: 'FD Table', active: true } + }, + + /* Room glow intensities 0..1, driven by telemetry */ + glow: {}, + + /* User space stats */ + userSpace: { + syscallRate: 0, + recentSyscalls: [], + /* PID -> command name mapping (learned from execve events) */ + pidCmds: {}, /* pid -> 'ash' | 'cat' | 'ps' | ... */ + activePids: {}, /* pid -> last-seen timestamp */ + processNames: [] /* visible process names for display */ + }, + + /* Track a PID's activity. Infer a human-readable label from syscall + * patterns since the SSE payload doesn't include the binary name. + * - execve: mark PID as "launching" (next syscalls reveal behavior) + * - file I/O heavy: "reader" / "writer" + * - scheduling: "worker" + * - Default: classify by most-frequent syscall family */ + /* Activity labels: map syscall names to short human-readable actions. + * We show WHAT each PID is doing, not a guessed command name -- + * guessing is unreliable (find and ls both do getdents64). */ + ACT_LABELS: { + 'wait4': 'shell', 'waitid': 'shell', + 'read': 'reading', 'pread64': 'reading', 'readv': 'reading', + 'write': 'writing', 'writev': 'writing', 'pwrite64': 'writing', + 'getdents64': 'scanning', 'getdents': 'scanning', + 'clone': 'forking', 'clone3': 'forking', 'fork': 'forking', + 'execve': 'exec', 'execveat': 'exec', + 'nanosleep': 'sleeping', 'clock_nanosleep': 'sleeping', + 'stat': 'stat', 'newfstatat': 'stat', 'fstat': 'stat', 'lstat': 'stat', + 'openat': 'opening', 'open': 'opening', 'close': 'closing', + 'poll': 'polling', 'epoll_wait': 'polling', 'ppoll': 'polling', + 'select': 'polling', 'pselect6': 'polling', + 'sendto': 'sending', 'recvfrom': 'receiving', 'connect': 'connecting', + 'socket': 'socket', 'mmap': 'mapping', 'brk': 'alloc', + 'ioctl': 'ioctl', 'fcntl': 'fcntl', + 'rt_sigaction': 'signal', 'rt_sigprocmask': 'signal' + }, + + trackPid: function(pid, syscallName) { + if (!pid) return; + var us = this.userSpace; + us.activePids[pid] = Date.now(); + if (!us.pidCmds[pid]) us.pidCmds[pid] = { calls: {}, label: '', total: 0 }; + var info = us.pidCmds[pid]; + info.calls[syscallName] = (info.calls[syscallName] || 0) + 1; + info.total++; + + /* Reset on execve */ + if (syscallName === 'execve' || syscallName === 'execveat') { + info.label = 'exec'; + info.calls = {}; + info.total = 0; + return; + } + + /* Re-evaluate on every event (short-lived processes may only + * generate 1-2 events at 1% sampling -- can't wait for 5) */ + var best = '', bestN = 0; + for (var k in info.calls) { + if (info.calls[k] > bestN) { bestN = info.calls[k]; best = k; } + } + info.label = this.ACT_LABELS[best] || best || ''; + }, + + /* Update visible process list (expire old PIDs) */ + refreshProcessNames: function() { + var cutoff = Date.now() - 5000; /* 5s window */ + var pids = this.userSpace.activePids; + var cmds = this.userSpace.pidCmds; + /* Aggregate: count PIDs per activity label */ + var counts = {}; + for (var pid in pids) { + if (pids[pid] < cutoff) { delete pids[pid]; continue; } + var info = cmds[pid]; + var label = (info && info.label) ? info.label : ''; + if (!label || label === 'signal' || label === 'alloc') continue; /* noise */ + counts[label] = (counts[label] || 0) + 1; + } + /* Format: "scanning x3", "reading x2", "shell" */ + var entries = []; + for (var act in counts) { + entries.push({ label: act, n: counts[act] }); + } + entries.sort(function(a, b) { return b.n - a.n; }); + var names = []; + for (var i = 0; i < Math.min(entries.length, 8); i++) { + var e = entries[i]; + names.push(e.n > 1 ? e.label + ' x' + e.n : e.label); + } + this.userSpace.processNames = names; + }, + + /* Theme-aware colors (read from CSS custom properties) */ + theme: { + bg: '#0d1117', fg: '#c9d1d9', fg2: '#8b949e', + accent: '#58a6ff', border: '#30363d', bg2: '#161b22', + ok: '#3fb950', warn: '#d29922', err: '#f85149' + }, + + readTheme: function() { + var style = getComputedStyle(document.body); + this.theme.bg = style.getPropertyValue('--bg').trim() || '#0d1117'; + this.theme.fg = style.getPropertyValue('--fg').trim() || '#c9d1d9'; + this.theme.fg2 = style.getPropertyValue('--fg2').trim() || '#8b949e'; + this.theme.accent = style.getPropertyValue('--accent').trim() || '#58a6ff'; + this.theme.border = style.getPropertyValue('--border').trim() || '#30363d'; + this.theme.bg2 = style.getPropertyValue('--bg2').trim() || '#161b22'; + this.theme.ok = style.getPropertyValue('--ok').trim() || '#3fb950'; + this.theme.warn = style.getPropertyValue('--warn').trim() || '#d29922'; + this.theme.err = style.getPropertyValue('--err').trim() || '#f85149'; + + /* Lighten room colors for light theme */ + var isLight = document.body.classList.contains('light'); + KPenguin.isLight = isLight; + var roomIds = Object.keys(this.rooms); + for (var i = 0; i < roomIds.length; i++) { + var r = this.rooms[roomIds[i]]; + /* Save the original dark-mode color on first access (before any lightening) */ + if (!r._darkColor) r._darkColor = r.color; + r.color = isLight ? this.lightenColor(r._darkColor) : r._darkColor; + } + this.staticDirty = true; + }, + + lightenColor: function(hex) { + /* Shift warm dark browns to light beige equivalents */ + var r = parseInt(hex.slice(1, 3), 16); + var g = parseInt(hex.slice(3, 5), 16); + var b = parseInt(hex.slice(5, 7), 16); + r = Math.min(255, r + 170); + g = Math.min(255, g + 160); + b = Math.min(255, b + 150); + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); + }, + + init: function(canvasId, overlayId) { + this.canvas = document.getElementById(canvasId); + if (!this.canvas) return; + this.ctx = this.canvas.getContext('2d'); + this.overlay = document.getElementById(overlayId); + + /* Initialize glow map */ + var ids = Object.keys(this.rooms); + for (var i = 0; i < ids.length; i++) { + this.glow[ids[i]] = 0; + } + + var self = this; + this.readTheme(); + this.resize(); + window.addEventListener('resize', function() { + self.resize(); + KBubble.updateLayout(); + KBubble.penguinMap = null; /* rebuild on next reposition */ + }); + + /* Load strings */ + this.loadStrings(); + }, + + loadStrings: function() { + var self = this; + fetch('/js/strings.json') + .then(function(r) { return r.json(); }) + .then(function(data) { self.strings = data; self.staticDirty = true; }) + .catch(function() { self.strings = {}; }); + }, + + str: function(path) { + if (!this.strings) return null; + var parts = path.split('.'); + var obj = this.strings; + for (var i = 0; i < parts.length; i++) { + obj = obj && obj[parts[i]]; + } + return obj || null; + }, + + /* Cached room pixel rects (rebuilt on resize) */ + cachedRects: {}, + staticCanvas: null, /* offscreen canvas for static room layer */ + staticDirty: true, /* true = must redraw static layer */ + + resize: function() { + if (!this.canvas) return; + var container = this.canvas.parentElement; + var w = container.clientWidth; + /* Rooms fill 100% of canvas. Subsystem rooms at 50% need ~130px. + * 130/0.50 = 260. Use 0.4 aspect for compact layout. */ + var h = Math.max(260, Math.round(w * 0.4)); + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + this.ctx.imageSmoothingEnabled = false; + /* Rebuild cached rects */ + var ids = Object.keys(this.rooms); + for (var i = 0; i < ids.length; i++) { + var r = this.rooms[ids[i]]; + this.cachedRects[ids[i]] = { + x: Math.round(r.x / 100 * w), + y: Math.round(r.y / 100 * h), + w: Math.round(r.w / 100 * w), + h: Math.round(r.h / 100 * h) + }; + } + /* Rebuild static offscreen canvas */ + this.staticDirty = true; + if (!this.staticCanvas) { + this.staticCanvas = document.createElement('canvas'); + } + this.staticCanvas.width = w; + this.staticCanvas.height = h; + }, + + roomRect: function(id) { + return this.cachedRects[id] || null; + }, + + invalidateStatic: function() { + this.staticDirty = true; + }, + + /* Tinyoffice-style floor tile size (in canvas pixels) */ + TILE_SIZE: 20, + + renderStatic: function() { + var sctx = this.staticCanvas.getContext('2d'); + sctx.imageSmoothingEnabled = false; + var w = this.width, h = this.height; + + /* Background adapts to theme */ + sctx.fillStyle = this.theme.bg; + sctx.fillRect(0, 0, w, h); + + var ids = Object.keys(this.rooms); + var fontSize = Math.max(10, Math.round(h * 0.022)); + sctx.font = fontSize + 'px monospace'; + var isLightTheme = KPenguin.isLight; /* use cached theme state */ + + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + var room = this.rooms[id]; + var rect = this.cachedRects[id]; + + /* Room fill */ + sctx.globalAlpha = room.active ? 1 : 0.4; + sctx.fillStyle = room.color; + sctx.fillRect(rect.x, rect.y, rect.w, rect.h); + + /* Floor tile grid */ + if (room.active) { + sctx.strokeStyle = isLightTheme ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.04)'; + sctx.lineWidth = 0.5; + var tile = this.TILE_SIZE; + for (var tx = rect.x + tile; tx < rect.x + rect.w; tx += tile) { + sctx.beginPath(); + sctx.moveTo(tx, rect.y); + sctx.lineTo(tx, rect.y + rect.h); + sctx.stroke(); + } + for (var ty = rect.y + tile; ty < rect.y + rect.h; ty += tile) { + sctx.beginPath(); + sctx.moveTo(rect.x, ty); + sctx.lineTo(rect.x + rect.w, ty); + sctx.stroke(); + } + } + sctx.globalAlpha = 1; + + /* Wall-band top border */ + sctx.fillStyle = isLightTheme ? 'rgba(0,0,0,0.08)' : 'rgba(180,150,120,0.15)'; + sctx.fillRect(rect.x, rect.y, rect.w, 2); + + /* Room border */ + sctx.strokeStyle = isLightTheme ? 'rgba(0,0,0,0.12)' : 'rgba(120,100,80,0.3)'; + sctx.lineWidth = 1; + sctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1); + + /* Label with text shadow */ + var label = this.str('rooms.' + id + '.name') || room.label; + sctx.textAlign = 'center'; + sctx.textBaseline = 'top'; + /* Shadow text */ + sctx.fillStyle = isLightTheme ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.5)'; + sctx.fillText(label, rect.x + rect.w / 2 + 1, rect.y + 5); + /* Foreground label */ + sctx.fillStyle = room.active ? + (isLightTheme ? '#5a4a30' : '#d4a76a') : + (isLightTheme ? 'rgba(80,70,60,0.5)' : 'rgba(120,100,80,0.5)'); + sctx.fillText(label, rect.x + rect.w / 2, rect.y + 4); + + if (!room.active) { + sctx.font = (fontSize - 2) + 'px monospace'; + sctx.fillStyle = isLightTheme ? 'rgba(80,70,60,0.5)' : 'rgba(120,100,80,0.5)'; + sctx.fillText('(no data)', rect.x + rect.w / 2, rect.y + 4 + fontSize + 4); + sctx.font = fontSize + 'px monospace'; + } + } + + /* Disposition legend -- positioned inside the basement room */ + var basementRect = this.cachedRects.basement; + if (basementRect) { + KEducation.drawLegend(sctx, basementRect.x + 8, basementRect.y + 18); + } + + this.staticDirty = false; + }, + + /* Draw the house scene. Static layer is cached; only glow + user space are live. */ + drawHouse: function() { + var ctx = this.ctx; + if (!ctx) return; + + /* Rebuild static layer if needed */ + if (this.staticDirty) this.renderStatic(); + + /* Blit static layer */ + ctx.drawImage(this.staticCanvas, 0, 0); + + /* Live overlays: room glow (only for active, glowing rooms) */ + var ids = Object.keys(this.rooms); + for (var i = 0; i < ids.length; i++) { + var id = ids[i]; + var glowVal = this.glow[id] || 0; + if (glowVal > 0.02 && this.rooms[id].active) { + var rect = this.cachedRects[id]; + var glowColor = KPenguin.isLight ? + 'rgba(180, 140, 80, ' + (glowVal * 0.1) + ')' : + 'rgba(212, 167, 106, ' + (glowVal * 0.08) + ')'; + ctx.fillStyle = glowColor; + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); + } + } + + /* FD Table detail gauge */ + this.drawFdDetail(ctx); + + /* User space stats in attic */ + this.drawUserSpace(ctx); + }, + + /* FD Table: show fd.used / fd.max as a mini bar gauge */ + fdStats: { used: 0, max: 1 }, + + drawFdDetail: function(ctx) { + var rect = this.roomRect('fdvault'); + if (!rect || this.fdStats.max <= 0) return; + + var used = this.fdStats.used; + var max = this.fdStats.max; + var pct = Math.max(0, Math.min(1, used / max)); + + ctx.save(); + var fontSize = Math.max(9, Math.round(this.height * 0.016)); + ctx.font = fontSize + 'px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + /* Usage text */ + ctx.fillStyle = this.theme.fg2; + ctx.globalAlpha = 0.7; + ctx.fillText(used + ' / ' + max, rect.x + rect.w / 2, rect.y + 20); + + /* Mini bar gauge */ + var barW = rect.w * 0.6; + var barH = 6; + var barX = rect.x + (rect.w - barW) / 2; + var barY = rect.y + 20 + fontSize + 4; + + /* Background */ + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + ctx.fillRect(barX, barY, barW, barH); + + /* Fill (color changes with usage) */ + var fillColor = pct < 0.5 ? this.theme.ok : + pct < 0.8 ? this.theme.warn : this.theme.err; + ctx.globalAlpha = 0.8; + ctx.fillStyle = fillColor; + ctx.fillRect(barX, barY, barW * pct, barH); + + ctx.restore(); + }, + + /* Draw user space info: process names + syscall rate */ + drawUserSpace: function(ctx) { + var rect = this.roomRect('attic'); + if (!rect) return; + var us = this.userSpace; + this.refreshProcessNames(); + + ctx.save(); + var fontSize = Math.max(9, Math.round(this.height * 0.018)); + + /* Syscall rate (bottom-left of attic) */ + if (us.syscallRate > 0) { + ctx.font = fontSize + 'px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = this.theme.fg2; + ctx.globalAlpha = 0.5; + var rateStr = us.syscallRate < 1000 ? + Math.round(us.syscallRate) + '/s' : + (us.syscallRate / 1000).toFixed(1) + 'k/s'; + ctx.fillText('syscalls: ' + rateStr, rect.x + 8, rect.y + rect.h - 4); + } + + /* Active process names (right side of attic, like a task bar) */ + if (us.processNames.length > 0) { + ctx.font = (fontSize - 1) + 'px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'bottom'; + var px = rect.x + rect.w - 8; + var py = rect.y + rect.h - 4; + for (var i = 0; i < us.processNames.length; i++) { + ctx.globalAlpha = 0.6; + ctx.fillStyle = this.theme.fg2; + ctx.fillText(us.processNames[i], px, py - i * (fontSize + 1)); + } + } + + ctx.restore(); + }, + + /* Hit-test: which room was clicked? Returns room id or null */ + hitTest: function(canvasX, canvasY) { + var ids = Object.keys(this.rooms); + for (var i = 0; i < ids.length; i++) { + var rect = this.roomRect(ids[i]); + if (canvasX >= rect.x && canvasX < rect.x + rect.w && + canvasY >= rect.y && canvasY < rect.y + rect.h) { + return ids[i]; + } + } + return null; + } +}; diff --git a/web/js/strings.json b/web/js/strings.json new file mode 100644 index 0000000..338360a --- /dev/null +++ b/web/js/strings.json @@ -0,0 +1,87 @@ +{ + "tabs": { + "observatory": "Observatory", + "kernelHouse": "Kernel House", + "events": "Events" + }, + "rooms": { + "attic": { + "name": "User Space", + "desc": "Guest processes live here. Every syscall begins as a request from user space, intercepted by kbox's seccomp-unotify filter before reaching the kernel.", + "source": "src/seccomp-bpf.c" + }, + "gate": { + "name": "Syscall Gate", + "desc": "The seccomp dispatch loop. Each intercepted syscall arrives here as a notification. The dispatcher decides: forward to LKL, let the host kernel handle it (CONTINUE), or reject it (ENOSYS).", + "source": "src/seccomp-dispatch.c" + }, + "vfs": { + "name": "Virtual Filesystem", + "desc": "File operations routed through LKL's VFS layer. open, read, write, stat, and directory operations are emulated by the in-process Linux kernel against the guest ext4 image.", + "source": "src/seccomp-dispatch.c (forward_openat, forward_read, forward_write)" + }, + "process": { + "name": "Process Management", + "desc": "Scheduler and process lifecycle. Context switches, clone, execve, and exit are tracked here. Most scheduling syscalls (sched_yield, getpid) use host CONTINUE; clone and execve are emulated by the supervisor.", + "source": "src/seccomp-dispatch.c (forward_clone3, forward_execve)" + }, + "memory": { + "name": "Memory Management", + "desc": "Page allocation, mmap, and brk. LKL manages its own page cache and memory accounting. Shadow FDs enable mmap of guest files via host memfd.", + "source": "src/shadow-fd.c, src/seccomp-dispatch.c (forward_mmap)" + }, + "network": { + "name": "Network Stack", + "desc": "Socket operations routed through LKL's TCP/IP stack. SLIRP provides user-mode networking without root privileges.", + "source": "src/net-slirp.c" + }, + "basement": { + "name": "Block I/O", + "desc": "Storage subsystem. LKL's block layer manages the guest ext4 filesystem image. Block counters not yet wired to telemetry.", + "source": "LKL internal (not yet instrumented)" + }, + "fdvault": { + "name": "FD Table", + "desc": "Virtual file descriptor management. kbox maintains a split FD table: high range (>=32768) for normal allocation, low range (0-1023) for dup2/dup3. Tracks LKL FD to host FD mappings.", + "source": "src/fd-table.c" + } + }, + "characters": { + "dispatcher": "Dispatcher Tux", + "file": "File Tux", + "sched": "Sched Tux", + "memory": "Memory Tux", + "net": "Net Tux", + "storage": "Storage Tux", + "guest": "Guest Process" + }, + "disposition": { + "continue": "CONTINUE (host kernel)", + "return": "LKL emulated", + "enosys": "ENOSYS (rejected)" + }, + "legend": { + "continue": "Host kernel handles directly", + "return": "Emulated via LKL", + "enosys": "Rejected (unimplemented)", + "error": "Error" + }, + "narrator": { + "welcome": "Welcome to the kbox Kernel House. Each room represents a Linux kernel subsystem. Watch the penguins as they process real syscalls from the guest.", + "syscallFlow": "{name} dispatched to {room}", + "errorSpike": "Error spike detected in {room}", + "firstFamily": "First {family} syscall observed this session" + }, + "demo": { + "button": "Demo", + "tooltip": "Run a canned animation sequence (no live data needed)" + }, + "ui": { + "crowdBadge": "+{count}", + "narrator": "Narrator", + "close": "Close", + "screenshot": "Screenshot", + "toggleNarrator": "Toggle narrator mode", + "latency": "{value}" + } +} diff --git a/web/js/telemetry.js b/web/js/telemetry.js new file mode 100644 index 0000000..e0d6c2a --- /dev/null +++ b/web/js/telemetry.js @@ -0,0 +1,217 @@ +/* Telemetry bridge: maps kbox SSE events and /api/snapshot data + * to animation intents for the Kernel House. + * + * Two data channels: + * 1. SSE sampled syscall events -> individual character animations + * 2. /api/snapshot deltas -> ambient room state (glow, indicators) + * + * The SSE stream is sampled at 1% server-side. This module does NOT + * attempt to reconstruct full syscall traces; it creates an + * impressionistic view of subsystem activity. + */ +'use strict'; + +var KTelemetry = { + /* Syscall number -> room mapping for network detection (fallback only). + * The primary classifier is SYSCALL_ROOM by name; this table is a + * safety net for the rare case where evt.name is missing. + * x86_64-only; aarch64 has different NRs but the name path handles it. */ + NETWORK_NRS: { + /* x86_64 syscall numbers */ + 41: true, /* socket */ + 42: true, /* connect */ + 43: true, /* accept */ + 44: true, /* sendto */ + 45: true, /* recvfrom */ + 46: true, /* sendmsg */ + 47: true, /* recvmsg */ + 48: true, /* shutdown */ + 49: true, /* bind */ + 50: true, /* listen */ + 51: true, /* getsockname */ + 52: true, /* getpeername */ + 53: true, /* socketpair */ + 54: true, /* setsockopt */ + 55: true, /* getsockopt */ + 288: true, /* accept4 */ + 299: true, /* recvmmsg */ + 307: true /* sendmmsg */ + }, + + /* Syscall family -> room mapping. + * Family names come from the SSE event or can be derived from the + * snapshot counters. We classify by syscall name patterns. */ + SYSCALL_ROOM: { + /* File I/O */ + 'openat': 'vfs', 'open': 'vfs', 'read': 'vfs', 'write': 'vfs', + 'pread64': 'vfs', 'pwrite64': 'vfs', 'readv': 'vfs', 'writev': 'vfs', + 'preadv': 'vfs', 'preadv2': 'vfs', 'pwritev': 'vfs', 'pwritev2': 'vfs', + 'lseek': 'vfs', 'sendfile': 'vfs', 'ftruncate': 'vfs', + 'fallocate': 'vfs', 'readlinkat': 'vfs', 'readlink': 'vfs', + 'access': 'vfs', 'faccessat': 'vfs', 'faccessat2': 'vfs', + 'getcwd': 'vfs', 'chdir': 'vfs', 'fchdir': 'vfs', + 'chmod': 'vfs', 'fchmod': 'vfs', 'fchmodat': 'vfs', + 'chown': 'vfs', 'fchown': 'vfs', 'fchownat': 'vfs', + 'utimensat': 'vfs', 'copy_file_range': 'vfs', + 'mount': 'vfs', 'umount2': 'vfs', + /* Stat */ + 'stat': 'vfs', 'fstat': 'vfs', 'lstat': 'vfs', 'newfstatat': 'vfs', + 'statx': 'vfs', 'statfs': 'vfs', 'fstatfs': 'vfs', + /* Directory */ + 'getdents': 'vfs', 'getdents64': 'vfs', 'mkdir': 'vfs', + 'mkdirat': 'vfs', 'rmdir': 'vfs', 'unlink': 'vfs', + 'unlinkat': 'vfs', 'rename': 'vfs', 'renameat': 'vfs', + 'renameat2': 'vfs', 'symlink': 'vfs', 'symlinkat': 'vfs', + 'link': 'vfs', 'linkat': 'vfs', + /* FD ops */ + 'close': 'fdvault', 'dup': 'fdvault', 'dup2': 'fdvault', + 'dup3': 'fdvault', 'fcntl': 'fdvault', 'pipe': 'fdvault', + 'pipe2': 'fdvault', 'ioctl': 'fdvault', + 'epoll_create': 'fdvault', 'epoll_create1': 'fdvault', + 'epoll_ctl': 'fdvault', 'epoll_wait': 'fdvault', + 'epoll_pwait': 'fdvault', 'epoll_pwait2': 'fdvault', + 'poll': 'fdvault', 'ppoll': 'fdvault', + 'select': 'fdvault', 'pselect6': 'fdvault', + 'eventfd': 'fdvault', 'eventfd2': 'fdvault', + 'timerfd_create': 'fdvault', 'timerfd_settime': 'fdvault', + 'timerfd_gettime': 'fdvault', 'signalfd': 'fdvault', + 'signalfd4': 'fdvault', + /* Process / scheduler */ + 'clone': 'process', 'clone3': 'process', 'fork': 'process', + 'vfork': 'process', 'execve': 'process', 'execveat': 'process', + 'exit': 'process', 'exit_group': 'process', 'wait4': 'process', + 'waitid': 'process', 'sched_yield': 'process', + 'sched_getscheduler': 'process', 'sched_setscheduler': 'process', + 'sched_getparam': 'process', 'sched_setparam': 'process', + 'sched_get_priority_max': 'process', 'sched_get_priority_min': 'process', + 'sched_getaffinity': 'process', 'sched_setaffinity': 'process', + 'getpid': 'process', 'getppid': 'process', 'gettid': 'process', + 'getuid': 'process', 'geteuid': 'process', + 'getgid': 'process', 'getegid': 'process', + 'setuid': 'process', 'setgid': 'process', + 'getresuid': 'process', 'getresgid': 'process', + 'setresuid': 'process', 'setresgid': 'process', + 'set_tid_address': 'process', 'set_robust_list': 'process', + 'prlimit64': 'process', 'getrlimit': 'process', 'setrlimit': 'process', + 'prctl': 'process', 'arch_prctl': 'process', + 'nanosleep': 'process', 'clock_nanosleep': 'process', + 'clock_gettime': 'process', 'clock_getres': 'process', + 'gettimeofday': 'process', + 'rt_sigaction': 'process', 'rt_sigprocmask': 'process', + 'rt_sigreturn': 'process', 'sigaltstack': 'process', + 'kill': 'process', 'tgkill': 'process', 'tkill': 'process', + 'getrusage': 'process', 'times': 'process', 'uname': 'process', + 'getrandom': 'process', + /* Memory */ + 'mmap': 'memory', 'mprotect': 'memory', 'munmap': 'memory', + 'brk': 'memory', 'mremap': 'memory', 'madvise': 'memory', + 'msync': 'memory', 'mlock': 'memory', 'munlock': 'memory', + 'mlock2': 'memory', 'mlockall': 'memory', 'munlockall': 'memory', + 'futex': 'memory', 'get_robust_list': 'memory', + /* Network */ + 'socket': 'network', 'connect': 'network', 'accept': 'network', + 'accept4': 'network', 'bind': 'network', 'listen': 'network', + 'sendto': 'network', 'recvfrom': 'network', 'sendmsg': 'network', + 'recvmsg': 'network', 'setsockopt': 'network', 'getsockopt': 'network', + 'socketpair': 'network', 'shutdown': 'network', + 'getsockname': 'network', 'getpeername': 'network', + 'sendmmsg': 'network', 'recvmmsg': 'network' + }, + + /* Classify a syscall event to a target room */ + classifyRoom: function(evt) { + /* Try by name first (most reliable) */ + if (evt.name && this.SYSCALL_ROOM[evt.name]) { + return this.SYSCALL_ROOM[evt.name]; + } + /* Fallback: check if it's a network syscall by nr */ + if (evt.nr !== undefined && this.NETWORK_NRS[evt.nr]) { + return 'network'; + } + /* Default to gate (unclassified) */ + return 'gate'; + }, + + /* Called when a SSE syscall event arrives. + * Pushes animation intents into the queue. */ + onSyscallEvent: function(evt) { + /* Don't queue events during demo or pause */ + if (KHouse.demoRunning || KState.paused) return; + + var room = this.classifyRoom(evt); + var name = evt.name || 'syscall#' + evt.nr; + + /* Track PID activity and command names */ + var pid = evt.pid || 0; + if (pid) { + KScene.trackPid(pid, name); + } + + KIntent.push({ + type: 'guest', + room: room, + syscall: name, + disp: evt.disp || 'return', + latNs: evt.lat_ns || 0, + pid: evt.pid || 0 + }); + + /* Narrator check */ + KEducation.checkNarration(evt); + }, + + /* Called when a new /api/snapshot arrives. + * Computes deltas and pushes ambient glow intents. */ + onSnapshot: function(snap, prev) { + if (!prev || !snap) return; + + /* Compute per-family rates for glow */ + var fileRate = KState.rate(snap, prev, 'family.file_io') + + KState.rate(snap, prev, 'family.dir'); + var otherRate = KState.rate(snap, prev, 'family.other'); + var scRate = KState.rate(snap, prev, 'dispatch.total'); + + /* User space stats */ + KScene.userSpace.syscallRate = scRate; + + /* Glow intents from rates computed above */ + KIntent.pushGlow('gate', this.rateToGlow(scRate, 5000)); + KIntent.pushGlow('vfs', this.rateToGlow(fileRate, 2000)); + + var csRate = KState.rate(snap, prev, 'context_switches'); + KIntent.pushGlow('process', this.rateToGlow(csRate, 5000)); + + /* Memory pressure -> Memory glow */ + var memPct = 0; + if (snap.mem && snap.mem.total > 0) + memPct = 1 - (snap.mem.free / snap.mem.total); + KIntent.pushGlow('memory', memPct); + + /* FD usage -> FD Vault glow + detail stats */ + if (snap.fd) { + KScene.fdStats.used = snap.fd.used || 0; + KScene.fdStats.max = snap.fd.max || 1; + } + var fdPct = 0; + if (snap.fd && snap.fd.max > 0) + fdPct = snap.fd.used / snap.fd.max; + KIntent.pushGlow('fdvault', fdPct); + + /* Network (family.other rate, already computed) */ + KIntent.pushGlow('network', this.rateToGlow(otherRate, 1000)); + + /* Attic: overall activity */ + KIntent.pushGlow('attic', this.rateToGlow(scRate, 3000)); + + /* Basement stays at 0 (no data source) */ + + /* Narrator: error spike detection */ + KEducation.checkErrorSpike(snap); + }, + + /* Map a rate (events/sec) to a glow level 0..1 */ + rateToGlow: function(rate, maxRate) { + if (rate <= 0) return 0; + return Math.min(1, rate / maxRate); + } +}; diff --git a/web/style.css b/web/style.css index 39fc8d4..02c2ee0 100644 --- a/web/style.css +++ b/web/style.css @@ -79,11 +79,158 @@ header { .event-controls { font-size: 0.8em; font-weight: 400; margin-left: auto; } .event-controls label { margin-left: 0.5em; cursor: pointer; } +/* Tab bar */ +.tab-bar { + display: flex; gap: 0; background: var(--bg2); + border-bottom: 1px solid var(--border); padding: 0 1em; +} +.tab { + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--fg2); padding: 0.5em 1em; cursor: pointer; + font-size: 0.85em; font-weight: 600; +} +.tab:hover { color: var(--fg); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-content { display: none; } +.tab-content.visible { display: block; } + +/* Kernel House scene */ +.kh-container { + position: relative; width: 100%; min-height: 400px; + background: var(--bg); padding: 0.5em; +} +.kh-container canvas { + width: 100%; display: block; + border: 2px solid rgba(180, 150, 120, 0.3); + border-radius: 4px; + box-shadow: 0 0 8px rgba(120, 100, 80, 0.2); + image-rendering: pixelated; + image-rendering: crisp-edges; +} +.kh-overlay { + position: absolute; top: 0; left: 0; width: 100%; height: 100%; + pointer-events: none; overflow: hidden; +} +/* Bubbles: star-office style -- white bg, crisp border, monospace */ +.kh-overlay .bubble { + position: absolute; pointer-events: auto; + background: rgba(255, 255, 255, 0.95); + border: 2px solid #000; border-radius: 4px; + padding: 3px 8px; + font-size: 11px; font-family: 'Courier New', Consolas, monospace; + color: #000; white-space: nowrap; + opacity: 0; box-shadow: 2px 2px 0 rgba(0,0,0,0.15); + animation: bubble-in 0.3s ease-out forwards; +} +body.light .kh-overlay .bubble { + background: rgba(255, 255, 255, 0.98); + box-shadow: 2px 2px 0 rgba(0,0,0,0.1); +} +.kh-overlay .bubble.out { animation: bubble-out 0.3s ease-in forwards; } +@keyframes bubble-in { + from { opacity: 0; transform: translateY(6px) scale(0.9); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes bubble-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: translateY(-4px) scale(0.9); } +} + +.kh-overlay .room-panel { + position: absolute; pointer-events: auto; + background: var(--bg2); border: 1px solid var(--accent); + border-radius: var(--radius); padding: 0.75em; + font-size: 0.8em; color: var(--fg); max-width: 300px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} +.kh-overlay .room-panel h3 { + margin: 0 0 0.4em 0; font-size: 1em; color: var(--accent); +} +.kh-overlay .room-panel p { margin: 0 0 0.3em 0; color: var(--fg2); line-height: 1.4; } +.kh-overlay .room-panel .src { font-family: monospace; font-size: 0.85em; color: var(--fg2); } +.kh-overlay .room-panel .close-btn { + position: absolute; top: 4px; right: 6px; cursor: pointer; + background: none; border: none; color: var(--fg2); font-size: 1.1em; +} + +.kh-controls { + position: absolute; bottom: 8px; right: 8px; +} +.kh-controls button { + background: var(--bg3); border: 1px solid var(--border); + color: var(--fg); padding: 4px 12px; border-radius: var(--radius); + cursor: pointer; font-size: 0.8em; +} +.kh-controls button:hover { background: var(--accent); color: #fff; } + +/* Narrator bar: star-office status-bar style */ +.narrator-bar { + position: absolute; bottom: 40px; left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.7); + border: 2px solid rgba(212, 167, 106, 0.4); + border-radius: 8px; padding: 10px 20px; + font-size: 13px; font-family: 'Courier New', Consolas, monospace; + color: #eee; max-width: 500px; + text-align: center; pointer-events: none; + transition: opacity 0.5s ease; + backdrop-filter: blur(8px); + box-shadow: 0 2px 10px rgba(0,0,0,0.3); +} + +/* Active narrator button */ +.kh-controls button.active { + background: var(--accent); color: #fff; +} + +/* Demo mode banner */ +.kh-demo-banner { + position: absolute; top: 6px; right: 8px; + background: rgba(212, 167, 106, 0.85); + color: #1a1714; font: bold 11px monospace; + padding: 3px 10px; border-radius: 4px; + pointer-events: none; z-index: 50; + letter-spacing: 0.5px; +} +body.light .kh-demo-banner { + background: rgba(90, 74, 48, 0.85); + color: #fff; +} + +/* Offline overlay */ +.kh-offline { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + background: rgba(0,0,0,0.8); border: 2px solid rgba(180,150,120,0.4); + border-radius: 8px; padding: 20px 32px; text-align: center; + font: 14px 'Courier New', Consolas, monospace; color: #ccc; + pointer-events: auto; z-index: 200; +} +.kh-offline b { color: #f85149; } + +/* Hover tooltip (tinyclaw-style info card) */ +.kh-tooltip { + position: absolute; pointer-events: none; z-index: 100; + background: rgba(17, 24, 39, 0.94); + border: 1px solid rgba(212, 167, 106, 0.5); + border-radius: 6px; padding: 6px 10px; + font: 11px 'Courier New', Consolas, monospace; + color: #eee; white-space: nowrap; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + line-height: 1.5; +} +body.light .kh-tooltip { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(180, 150, 120, 0.4); + color: #24292f; +} + /* Charts */ -canvas { width: 100% !important; } +.grid canvas { width: 100% !important; } /* Responsive */ @media (max-width: 800px) { .grid { grid-template-columns: 1fr; } .gauges { flex-wrap: wrap; } + .kh-container { min-height: 300px; } }