From 6ad3ad046b234c8dd3d1bbec907ace6b6479171b Mon Sep 17 00:00:00 2001 From: SamAcctX <87765660+SamAcctX@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:45:48 -0500 Subject: [PATCH] client: React client on the v3 API, replacing the Vue 2 client (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * client-react: framework-free client core for the v3 API A new client is built next to the Vue 2 one (client/ keeps shipping until it is replaced at the end of this series). This first part is the core library in client-react/src/core: no UI framework, no runtime dependencies, written against master's protocol (server/pkg/types, webrtc payloads, openapi.yaml). - client.ts: NekoClient: login/logout/whoami (token or cookie, ?token=), websocket with backoff reconnect and stale-socket detection (the server heartbeats every ~10s), WebRTC as answerer (server candidates that arrive before signal/provide are buffered), the binary big-endian input data channel, control/screen/clipboard/broadcast/unicast messages, letterboxed video mount, microphone tracks via renegotiation. - overlay.ts: input layer over the video: mouse (throttled, last position always sent), wheel steps with sensitivity, keyboard through the vendored Apache Guacamole keyboard (also used by the Vue 2 client) with the macOS key remapping, native touch or touch-as-mouse, file drop, and the host's cursor drawn for everyone not in control. - store.ts: tiny proxy store (subscribe/watch/version) + typed emitter. - api.ts + api.gen.ts: fetch wrapper; types generated from server/openapi.yaml (npm run gen:api), no generated runtime code. - types.ts: client state and wire types mirroring server/pkg/types. The root .gitignore ignores "core" (core dumps); client-react/.gitignore re-includes src/core. Check: npm run check (types, prettier, store self-check). Co-Authored-By: Claude Opus 5.5 * client-react: React GUI with the look and features of the Vue 2 client React 19 + Vite GUI on top of the core (client-react/src/app). The Vue 2 client's SCSS is reused with the same class names, so it looks the same. Features: login (display name + password, ?usr=/?pwd= invite links, OAuth button), video with fullscreen + keyboard lock, picture-in-picture, resolution menu, clipboard sync and fallback, play/unmute overlays, members with admin menu, control request/release/give/take, local controls lock, microphone, volume, emotes, chat, file transfer (streamed downloads, uploads with progress, multi-select delete), settings (scroll, autoplay, emotes, chat sound, links in app, keyboard layout, broadcast), locks for login/controls/file transfer, toasts and SweetAlert-style dialogs (native ), all 15 translations, URL params (embed, cast, volume, scroll, lang, show_side, mute_chat). Behaviour notes: - Kick/ban follow common chat-app semantics, documented at actions.kick in neko.ts: kick removes the session (may log in again); ban sets can_login:false and is only offered when the auth provider stores accounts (GET /api/members), with an unban list in Settings. Stale "IP ban / restart server" translations are dropped (English fallback). - Settings keep the Vue 2 localStorage keys and format, so preferences carry over; scroll sensitivity uses the v3 steps (new key), ?scroll= is mapped onto them. - Chat markdown (markdown.ts) parses to React elements: no HTML strings, links limited to http(s)/mailto. Emoji use the same emoji.json and twitter sprite sheet as before. - Icon controls are labelled buttons with keyboard activation. Check: npm run check (adds markdown self-check), npm run build. Co-Authored-By: Claude Opus 5.5 * client-react: two-user end-to-end test e2e.mjs drives headless Chromium users (Playwright) through a running server with the legacy API off and the multiuser provider: login + video, members, chat (incl. markdown rendered safely and the emoji picker), control hand-off and admin force-release, emotes, file upload/download/ delete, language switch, locks, mute, ban hidden without stored accounts, keyboard activation, resolution change, keyboard layouts, about, implicit hosting, clipboard sync, microphone (fake device), reconnect after a network drop, typing into the desktop, kick, and URL params (usr/pwd/lang/show_side, embed). With NEKO_FILE_URL pointing at a file-provider server it also covers ban/unban. Failing steps save screenshots of both users and log their member lists. NEKO_URL=http://host:port/ npm run test:e2e Co-Authored-By: Claude Opus 5.5 * client-react: Dockerfile, dev scripts, emoji tool, README - Dockerfile (node 24 LTS, npm ci) with the same contract as the Vue 2 client stage: npm run build -> /src/dist, used by Dockerfile.tmpl. - dev/ docker wrappers like the Vue 2 client: serve runs vite on :3001 with /api (incl. websocket) proxied to the backend on :3000 (override with NEKO_URL), plus npm/build/exec. - tools/emoji.ts: the Vue 2 emoji generator ported to ESM (runs on node directly); regenerates _emoji.scss byte-identically (npm run build:emoji). - README: core API, development, checks, e2e, behaviour notes. Check: npm run check; docker build of this directory. Co-Authored-By: Claude Opus 5.5 * client: replace the Vue 2 client with the React client client-react/ becomes client/. The client uses only the v3 API (it makes no requests to the legacy compatibility layer, which stays in the server for other V2 clients), so images built from this tree also work with NEKO_LEGACY=false. The image build is unchanged: Dockerfile.tmpl still builds ./client and copies /src/dist to /var/www. - CI: node 24 LTS (React 19 / Vite 7 need >= 20.19), `npm run check` before the build, no browser download for the e2e tool. - Docs: developer guide (build, development, repository structure) and release notes. Tested on an image built with ./build (xfce, default config, legacy on): client e2e (28 steps), ban/unban on a file-provider server, no requests to /ws, /stats, /file or /screenshot.jpg and no V2 data channel. Co-Authored-By: Claude Opus 5.5 * client: back off and give up when WebRTC keeps failing When the peer connection failed (no media route: firewall, wrong NAT1TO1, missing TURN, or a browser that cannot reach the server), the client immediately asked for a new peer, which failed again: ~9 peer setups per second on the server (551 in 60s in a test with Firefox). Retries now back off (1.5s up to 6s) and stop after 10 consecutive failures with "video connection failed (WebRTC)"; a successful connection resets the count. Tested: Firefox (ICE fails in this environment, for the Vue 2 client too) now makes 11 attempts in 75s and shows the Disconnected dialog; Chromium e2e 28/28. Co-Authored-By: Claude Opus 5.5 * client: tell the user when the browser cannot play the stream's codec If the browser has no video codec in common with the server (e.g. an H264 stream and a Firefox without the OpenH264 plugin), it rejects the video section of the offer (m=video 0). The server then fails with "unable to start track, codec is not supported by remote" and never answers ICE, so the client spun and retried until it gave up with a generic WebRTC error. It now stops right away with "this browser cannot play H264 video". Tested with stock Firefox 157 (no OpenH264): H264 server -> message after 0.3s; VP8 server -> video plays. Co-Authored-By: Claude Opus 5.5 * client: build on Debian trixie like the rest of the repo Upstream moved its images to Debian 13 (trixie) in #581; the client Dockerfile and dev scripts still used node:24-bookworm-slim. Tested: client/Dockerfile builds on node:24-trixie-slim (Node 24.21) and produces dist/; dev/build builds dist/. Co-Authored-By: Claude Opus 5.5 * client: ask for a new peer when the server closes it; mic survives peer changes When the media path fails, the server notices within about 5 s, destroys its peer and sends signal/close. The client only closed its own peer: a locally closed RTCPeerConnection never reports "failed", so the backoff from 5c9418fb never ran, and the websocket's heartbeats kept the stale check quiet. Result: a frozen frame with status "connected" until the page was reloaded. Reproduced by dropping the media port for 12 s with the websocket up: no signal/request followed, and video.currentTime stayed frozen for 60 s after the port was open again. signal/close now takes the same path as a failure the browser noticed: close, back off, request again. With this the client re-requested 1.5 s after signal/close and video resumed about 5 s after the port opened. Recovering exposed the microphone: its RTCRtpSender belonged to the old peer connection, so removeTrack() on the new one threw InvalidAccessError before the track was stopped (mic stuck on; releasing control then threw inside an effect and the error boundary replaced the page). removeTrack() now ignores senders of a previous connection, and the mic is switched off whenever the connection is re-established, as the Vue client did on every disconnect. The core now runs under plain Node (imports with extensions, no parameter properties, erasableSyntaxOnly), which makes src/core/client.check.ts possible: a state-machine check with stub WebSocket/RTCPeerConnection that fails on the old signal/close handling. It is part of `npm run check`. Co-Authored-By: Claude Fable 5.1 * client: encode session ids in REST paths Session ids on the multiuser and noauth providers start with the name the user typed at login, and the client interpolated them into URLs as they were. The browser resolves "..", "?" and "#" before a request leaves, so a member could choose what an admin's action against them does: display name admin clicks request actually sent x/../../logout? Mute POST /api/logout (admin logged out) # Kick DELETE /api/sessions/ Both were reproduced against a test server. Every id that is part of a path now goes through encodeURIComponent; with that the first name no longer leaves /api/members/ and the second gets the right session kicked. The e2e test logs in a member named "evil#?x" and checks that the admin's mute lands on that member and the admin keeps the session. Names with "/" need the server to build ids from a sanitised name; that is a separate server change. Co-Authored-By: Claude Fable 5.1 * client: chat stays fast past the line limit Once the chat held CHAT_LIMIT lines, every new line replaced the list with `chat.slice(-CHAT_LIMIT)`. The elements of that slice are the proxies the store had handed out, the setter unwraps only the array itself, and the next read wrapped every element again: one more proxy layer on each of the 1,000 kept lines per incoming message. Measured in the UI, the main thread blocked 78 ms per line at the limit and 751 ms eighty lines later; a read overflowed the stack about 3,000 lines in and the error boundary replaced the page. Event lines count toward the limit, so a busy room reached it in an evening. Store.wrap() now returns a value that is already one of its proxies, and the chat is trimmed in place. Lines carry an increasing `seq` used as the React key and by the scroll-to-newest effect, so a trim no longer shifts spoiler state between messages, re-parses every message, or stops the scrolling (the length no longer changed once at the limit). store.check.ts asserts that an element put back into the store keeps its identity, and that 3,000 such round trips still read in constant depth; the old store fails both. Co-Authored-By: Claude Fable 5.1 * client: keep one identity per login, keep the session through an outage Three session-flow defects, all reproduced against a test server: - An invite link (?usr=&pwd=) opened in a browser that still had a valid session raced the autologin: the resumed session opened the websocket, the invite's login replaced the token, and connect() was a no-op because a connection was already wanted. The websocket then ran as the old session and every REST call as the new one (3 of 3 runs). login() now closes whatever was resumed before storing the new token. - After the core gave up reconnecting (about 80 s offline) the app asked /whoami and treated any failure as "session gone", including a network error, so logout() removed the saved token and the user landed on the login form although the session was still valid. The screen written for this case (logged in but disconnected, with a Connect button) was unreachable. Only a 401 logs out now. - A wrong ?pwd= was a dead end: the invite was cleared only on success, the password field stayed hidden and the URL was already cleaned. The invite is now used for the first attempt only, as in the Vue client. The disconnect dialog also names the common case where a second tab or window took over the session, instead of the raw "connection replaced". e2e: a second invite link in a profile with a live session must leave exactly the new session connected; a long outage must end at the Connect button, not the login form. The websocket is closed with status 1000 when the client closes it on purpose (login, logout, giving up). Without a status the server assumed a reconnect was coming and kept the session connected, and host, for 5 s; with it the old session drops at once, which the invite test relies on. Co-Authored-By: Claude Fable 5.1 * client: chat parser cannot be stalled by a message The chat markdown parser runs during render for every message from every member. Three inputs made that expensive or fatal: - A code fence with no closing fence followed by newlines: the fence regex backtracked for 4.3 s on a 512-character message that fits the normal chat box (the same expression the Vue client used). - Long runs of backticks: the inline-code regex with a backreference was quadratic per position. - Deep nesting or very long messages: 24,000 asterisks overflowed the stack inside render, which ends at the error boundary for everyone. Fences and inline code are now found with indexOf, the line-start and previous-word facts a rule needs are computed once per position instead of by regex over the whole prefix, and the UI parses through parseSafe(): messages above 10,000 characters are shown as plain text, and any parser failure falls back to plain text as well. The self-check covers the unclosed fence (must parse in milliseconds), 40,000 asterisks (must not throw) and the length cap, and keeps the earlier formatting cases. Co-Authored-By: Claude Fable 5.1 * client: muting the sound keeps the desktop usable The "unmute" overlay was shown whenever the video played muted, so a user who muted on purpose got a click-catching layer over the desktop: the first click unmuted instead of reaching the remote, and the only way to work with the sound off was the volume slider at zero. The Vue client showed that overlay only until the first unmute. The core now remembers when it was autoplay that muted the video, and the overlay appears only in that case. e2e: muting from the controls must leave no overlay over the player. Co-Authored-By: Claude Fable 5.1 * client: member menu works on narrow screens The member menu was rendered inside the members bar, which the layout hides at 768 px and below. Right-clicking a chat author there set the menu state but nothing appeared, so ignore, mute, kick and give-control were unreachable on phones. The Vue chat had its own menu. The menu is position: fixed anyway; it is now rendered by App, outside the hidden container. e2e: at 700 px wide, the menu opens from a chat author. Co-Authored-By: Claude Fable 5.1 * client: the video takes the keyboard only while input can flow Mouse-enter focused the hidden input layer for everyone, so a viewer typing in the chat lost the focus by brushing the video and the rest of the sentence went nowhere. The Vue client focused the overlay only while hosting and unlocked. The overlay now focuses on mouse-enter only when input is active, and also when control arrives while the pointer is already over the video. e2e: a viewer types half a sentence, crosses the video, and the chat box has the whole sentence. Co-Authored-By: Claude Fable 5.1 * client: emote repeat stops with the bar Holding an emote sends it every 350 ms; the interval was cleared only by mouse-up or mouse-leave on the bar. When an admin muted the member the bar was removed first, so neither event could arrive and the member kept sending that emote until reload (11 more in the 4 s after releasing the mouse, then on). The interval now dies with the component, and a muted member's sendEmote is a no-op, as the Vue client's action was. Co-Authored-By: Claude Fable 5.1 * client: master's keyboard file; typed text as keys; Ctrl+V keeps both clipboards right The vendored Guacamole keyboard was the 2022 copy from the reference branch, not the one upstream master ships. It lacked two fixes from September 2025: typing of indirectly entered text (IME, on-screen keyboards) as key events (#577) and the GUACAMOLE-1823 Caps Lock fix for macOS Chrome; it also sent right Alt as AltGr on every platform and had wrong or missing Japanese keys. The file is now master's, byte for byte. With it, IME and phone text is typed as key events instead of going through control/paste, which overwrote the remote clipboard (and over https the local one) on every keystroke and did nothing in programs where Ctrl+V is not paste. Ctrl+V keeps using the browser's paste event, the reason the reference client introduced the textarea in 2022: only Chromium over https can read the clipboard programmatically, a paste event hands the text over in every browser. Two defects in that path are fixed: - Ctrl was released to let the paste through and the server's paste reset all keys, so with Ctrl still held the next key arrived as a plain letter ("xyzxyza"). Ctrl is no longer touched; the remote gets its paste keystroke from the overlay, which completes the chord with its own Ctrl if the user has let go by then. - The local text always won, so after copying inside the remote a paste on a plain-http site (no clipboard sync) pasted stale local text. Now the side that copied most recently wins: the server reports every remote change, and if the remote changed since the local clipboard was last seen, only the keystroke goes out; otherwise the local text is set first and the keystroke follows once the server confirms it. Verified in a browser on https and on plain http: a fresh session pastes local text; copy-then-paste inside the remote yields the remote text on both origins; Ctrl held across V and A selects all instead of typing a. client.check.ts covers the decision; e2e now asserts what the typing step delivered and that Ctrl+V inside the remote doubles it. Co-Authored-By: Claude Fable 5.1 * client: e2e asserts what its step names promise - Page errors and console errors now fail the run; they were only printed, so a crash to the error boundary passed. - The typing step checks what arrived in the remote (added with the keyboard change) and the resolution step waits for the revert to take effect instead of leaving later steps to run mid-resize. - "cannot log in" for a banned account requires a login error with the form still shown, not any open dialog. - The upload step is named for what it checks (the uploader's own list; the documented setup gives plain users no Files tab). Co-Authored-By: Claude Fable 5.1 * client: core fixes from the review's minor list - A member who may not watch never receives an offer and was left on a full-screen spinner for good, chat included. Eight seconds without an offer now shows the room without video. e2e: can_watch off, reload, the chat is reachable. - The reconnect lookup (/whoami) is bounded to 5 s; on a black-holed network a fetch can otherwise hang for minutes. A close()+connect() while that lookup was pending could open a second socket; the decision now belongs to one connection generation. - A closed peer took no retry timer with it, so a websocket reconnect could trigger a second signal/request. - play() treated any rejection as a refused autoplay and muted the video; only NotAllowedError does now. - Upload errors showed xhr.statusText, which is empty under HTTP/2; the server's JSON message is used. - keyboard/map was re-sent, and setxkbmap run on the server, on every viewer setting change while hosting; the watcher now follows the layout, not the object. - A click waiting for control under implicit hosting was replayed whenever control arrived, even minutes later by an admin giving it; only a press from the last two seconds is replayed. Co-Authored-By: Claude Fable 5.1 * client: small behaviours the Vue client had - The side panel, the selected tab and the volume are remembered again, under the Vue client's keys (side, tab, volume 0..100). ?show_side= and ?volume= still win for the visit they are in; ?volume= is applied once, not on every reconnect. - The autoplay setting is read when a track arrives, so changing it no longer needs a reload. - Control events say who took the controls from whom and who released whose, with the strings every locale already had. - Logging out clears the chat, the ignore list, uploads and the broadcast URL for whoever uses the browser next, as the Vue client's reset did. The login name and password the Vue client stored in localStorage are removed on load; nothing reads them. - gets zh-CN and zh-TW for the two Chinese locales instead of the invalid cn and tw. - ?token= is removed from the address bar after login, like ?pwd=. e2e opens the side panel instead of toggling it blindly, since the state now survives a reload. Co-Authored-By: Claude Fable 5.1 * client: small UI defects from the review - The resolution menu was pinned to the window and sat on the side panel when that was open; it is now inside the player. It also no longer shares the member menu's class, so the member menu's row styles stop leaking into it. - The file icon lost its class when the list was ported, so the gap before the name was gone. - Choosing the same file twice in a row uploaded nothing: the file input is reset after each pick. - The banned list reloads after a ban or unban from anywhere. - A slow "can this member be banned" answer could land on another member's menu. - The clipboard box follows what is copied on the remote while it is open. Fullscreen and picture-in-picture rejections are no longer unhandled. The video's control button toggles through the same action as the one in the room bar, with its toast. - Emote names from other clients are checked against the known list before they become class names; link text in chat is not linked again, so [https://a](https://b) is one anchor. Co-Authored-By: Claude Fable 5.1 * client: keyboard access to menus and pickers - Escape closes the member menu, the emote and emoji pickers, the resolution and clipboard menus and the About panel; before, only a click anywhere did. - The member menu moves focus to its first item when it opens, and the emote picker opened from the keyboard appears at the icon instead of the top-left corner. - The 1,800 emoji were individual tab stops; they are reachable through the search box now, where Enter takes the first match. - The broadcast buttons, the setting switches and the control lock have accessible names. Co-Authored-By: Claude Fable 5.1 * client: remove what nothing uses - The generated OpenAPI types (2,492 lines, a devDependency and a script) served two type names in three places; those two shapes are now declared in types.ts next to the other hand-written wire types. The generated schemas were all-optional and already lacked two settings fields, so nothing is lost. - 47 emote PNGs that no stylesheet referenced (the SVGs are used). - Styles ported from Vue components that have no element any more: the About page's markdown loader and bounce animation (connect.scss has the same keyframes), `body > p`, the event line's ``. - A re-export and a helper that nothing imported, and three locale keys nothing reads (one of them added by this branch). Co-Authored-By: Claude Fable 5.1 * docs and build: release notes, Node version, dead build settings - Release notes cover the behaviour changes of the fix series: media recovery, paste semantics and IME typing, the microphone on reconnect, the second tab, sanitised session ids. - The build page said Node 20.19 works; `npm run check` runs TypeScript files with Node itself, which needs 22.18 or newer. - PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD did nothing (Playwright 1.63 has no install script); removed from the Dockerfile, the workflow and the dev script. - Three comments used a private convention; reworded as plain notes. Co-Authored-By: Claude Fable 5.1 * client: lint the two React hook rules ESLint with only rules-of-hooks and exhaustive-deps (typescript-eslint for the parser), run by `npm run check` and therefore in CI. The five findings were effects that meant to run once but read a hook value: they now read the stable store instead, or list the dependency. Co-Authored-By: Claude Fable 5.1 * client: e2e can drive a real Chromium-based browser NEKO_CDP=http://host:port connects the suite to a browser started with --remote-debugging-port (Brave, Chrome, Edge) instead of launching Playwright's Chromium, so the same steps run against a browser people actually use. README: how to start it, the check script also lints, the typing step works with Xfce's launcher too. Co-Authored-By: Claude Fable 5.1 * client: the on-screen keyboard toggle closes the keyboard Tapping the keyboard button while the keyboard was open closed and reopened it: the button is focusable, so the tap took the focus from the hidden input first (its blur handler cleared the flag), and the toggle then saw "closed" and focused again. Checked with a Pixel 7 emulation: focus went overlay, overlay, overlay. The button no longer takes the focus on press, and the toggle decides from its own flag before the blur handler changes it. Now: overlay, none, overlay. Co-Authored-By: Claude Fable 5.1 * server: default video pipeline without the mouse pointer The client draws the pointer itself from the data channel (cursor image + position, sent to every peer when its channel opens and on every host move): CSS cursor for the host, canvas overlay for everyone else. With the pointer also drawn into the stream, a second pointer trailed the real one by the stream latency, for the host and for watchers. Before #691 the default main pipeline had no pointer (the "legacy" stream carried one for V2 clients, and the V2-config path still forces show-pointer=false on main). #691 made the default visible; this goes back to the original default for the main stream. Broadcast and screencast keep following capture.video.show_pointer (default true), the legacy stream keeps its pointer, and capture.video.show_pointer=true puts it back on all video pipelines. Docs: custom pipeline examples use show-pointer=false, with a note why. Tested (xfce image built with ./build, default config, compat layer on): - stream: pointer moved into an empty region changes received frames 6/6 before (diff ~41k), 0/6 after (diff 0) - watcher (not in control) sees the host's pointer drawn at the right spot 5/5, and still sees it after the host releases control - client e2e passes Known gap (from the code): pointer moves that do not come from neko input (apps warping the pointer, touch-only hosts) are not broadcast, so watchers see the last position until the next mouse move. Co-Authored-By: Claude Opus 5.5 * client: an invite link logs in even when a saved session resumes first Opening a `?usr=&pwd=` link in a browser that still holds a session did the invite login only if the saved session had not finished resuming by the time the connect screen mounted. That is a race between the whoami request and React's first effect; WebKit loses it every time (the page stayed logged in as the old user, the server never saw the new one) and Chromium happens to win it. The link now always logs in; login() already drops a websocket the resumed session may have opened meanwhile. Found by running e2e.mjs in WebKit (url params step). Co-Authored-By: Claude Fable 5.1 * client: no "connected" without video while the server is silent When a request for media gets no offer within 8 s, the room is shown without video (the server declines, e.g. can_watch is off). After a lost network the same timer ran for the wrong reason: the media path fails within seconds, the client asks for a new peer into a socket that is dead but not yet known to be (the stale check needs 25 s), and 8 s later the connect screen gave way to a frozen picture labelled connected, until the stale check reconnected. The fallback now applies only while the server has spoken within the last 16 s (it heartbeats every 10 s); otherwise it re-arms and leaves the outage to the stale check. client.check.ts drives the timers by hand and fails on the old code. Observed in WebKit with the server container paused for 40 s: before, the connect screen disappeared at +25 s while the server was still frozen; after, it stays until the real reconnect. Co-Authored-By: Claude Fable 5.1 * client: e2e steps hold in WebKit and Firefox too The second-invite step gave the server 1.5 s to drop the first session. Chromium closes the old page's socket with a close code; Firefox closes it abruptly (1006), and the server then keeps the session for its 5 s reconnect grace. The step now polls for up to 8 s. Playwright's offline emulation freezes an open websocket only in Chromium; WebKit and Firefox keep delivering heartbeats to it (measured: one frame in 15 s offline), so the two outage steps would wait for nothing there. They say so and skip outside Chromium; the recovery path itself was verified in WebKit by pausing the server container. Co-Authored-By: Claude Fable 5.1 * client: @/ path alias in tsconfig; design layer for tokens, reset and fonts First step of the layout requested in the #713 review: `src/design/` holds the SCSS tokens (`_variables`), the reset, the Whitney font faces and the global page styles that `assets/styles/main.scss` used to collect. The `@/` alias that only Vite knew is now also in `tsconfig.json` (`paths`), so cross-layer imports can use it everywhere. The emote and emoji sprite sheets are still under `assets/styles/vendor/` and are imported from `main.tsx` for now; they move next to their owners (emotes feature, chat plugin) in the following commits. No CSS rule changes (built CSS compared rule by rule). Co-Authored-By: Claude Fable 5.1 * client: split neko.ts into state, i18n and a11y modules `src/app/neko.ts` held the stores, settings, actions, dialogs, chat log, event wiring and i18n in one module. It is now split by concern: state/storage.ts localStorage helpers (same keys and '1'/'0' booleans as before) state/stores.ts createStore(): every GUI store registers here so one hook can subscribe to all of them (plugins will add their own) state/app.ts the app store and the viewer-settings defaults state/client.ts the NekoClient instance; name(), isMuted(), hostId(), isLocked() state/settings.ts setSetting(), applySettings(), initSettings() (URL parameters) state/actions.ts room actions (login, control, locks, kick/ban, broadcast) state/dialogs.ts ask(), tell(), toast() state/api.ts REST call with a toast on failure state/chat.ts chat log, sendChat(), mute(), openInApp() state/files.ts file transfer actions and types state/emoji.ts emoji data for the picker state/emotes.ts EMOTES, showEmote(), sendEmote() state/events.ts server events -> chat lines and toasts state/hooks.ts useNeko() i18n/ t(), setLang(), the locale table (moved from app/locale) components/a11y.ts a11y(), closeOn() and the Enter/Space activation listener app/boot.ts start-up order: event wiring, settings, then the connection Components still live in `app/` and now import from these modules with `@/`. No behaviour change; built CSS identical. Co-Authored-By: Claude Fable 5.1 * client: GUI split into app, layout, features and components; styles beside their components The four GUI files (`App.tsx`, `Room.tsx`, `Side.tsx`, `Video.tsx`) and the `app/styles/` folder become one folder per unit, each with its component and its `.scss` next to it, imported by the component: app/ App (shell), ErrorBoundary, boot, app.scss (the #neko grid) layout/ Header, Side, RoomBar (the bar under the video) features/ video (+ Resolution, Clipboard), members (+ MemberMenu), controls, room-menu, emotes (+ Emote animation, sprite sheet), settings (+ Banned), connect, about components/ Avatar, Dialog, Toasts, Logo, ContextMenu (the `.context` menu used by the member menu and the emote picker), a11y plugins/ chat (Chat, Markdown, EmojiPicker, parser, emoji sprite sheet) and filetransfer (Files): components and styles only for now, still wired statically; the manifest and registry follow in the next commit Units are imported through their `index.ts` with `@/`. `assets/` keeps only images. `openMenu()` moved to `state/actions.ts` so the member list and the chat open the same menu without importing each other. `tools/emoji.ts` and `build:emoji` write the sprite sheet to its new place. No markup, class-name or behaviour change; built CSS identical rule for rule. Co-Authored-By: Claude Fable 5.1 * client: plugin manifest and registry; chat and file transfer become plugins `src/plugins/types.ts` describes what a plugin contributes (sidebar tab, top-bar items, member-menu items, locale strings, server event handler, init), and `src/plugins/index.ts` lists the plugins of the build in order: `[chat, filetransfer]`. The side panel, the header and the member menu render what the registry returns, and `message` events starting with `/` go to that plugin's `onEvent`. Adding a plugin is its folder plus one entry in the registry; removing the entry removes its tab, header item and event handling, and the app still compiles (checked with each plugin and with both removed). Each plugin owns its store (`createStore` registers it so useNeko() re-renders on its changes), its actions, its locale strings (`locale.ts`, merged into the i18n table at start-up; the merged table is identical to before) and its components: plugins/chat Chat, Markdown, EmojiPicker, emoji data, the mute member-menu item, `chat/init` and `chat/message`, the muted/unmuted lines plugins/filetransfer Files, the header lock (via the shared LockButton), the `filetransfer/update` event, the lock event line, uploads `chat`, `texts`, `files` and `uploads` left the app store. Room events that the chat shows as lines ("bob took the controls") go through a small bus in `state/bus.ts`, so features and plugins never import each other; `logout` is a bus signal too. `openInApp`, `ignored` and `chat_sound` stay in the app store and settings: they are read by features (Settings, the member menu, emotes), not only by the chat. The file drop on the video is unchanged: it is handled in the core (room/upload/drop, the server's drop into the remote desktop) and the video feature never imported file transfer. No markup, class-name or behaviour change; built CSS identical rule for rule. Co-Authored-By: Claude Fable 5.1 * client: publish the core as the @m1k1o/neko package `client/src/core` becomes `client/core`, its own npm package: core/package.json @m1k1o/neko 3.0.0, ESM (`type: module`), `exports` with types, `files: dist + README`, no runtime dependencies, `typescript` as the only dev dependency (own lockfile, so `npm ci && npm run build` works on its own) core/tsconfig.json declaration build into dist/ with `rewriteRelativeImportExtensions` (the sources import `./x.ts` so the self-checks run under plain Node) core/build.mjs after tsc: `.ts` -> `.js` in the .d.ts imports (tsc only rewrites the JavaScript), copies the vendored Guacamole keyboard core/src/index.ts the public entry point: NekoClient, Overlay, Store, Emitter, NekoApi, ApiError and the wire types core/README.md the API reference with usage examples The GUI imports only `@m1k1o/neko`: for development and the Vite build the name is aliased to `core/src/index.ts` in tsconfig.json (`paths`) and vite.config.ts, so there is no workspace and `client/Dockerfile` is unchanged. `npm run build:core` builds the package; `npm run check` keeps running the core's `*.check.ts` and lints both trees. `client/.gitignore` re-includes `core/` (the root .gitignore ignores core dumps). Co-Authored-By: Claude Fable 5.1 * client: lint the layering rules and fail on import cycles `eslint.config.js` gets one `no-restricted-imports` rule set per layer: between folders only `@/` imports (a folder's own files relatively), features through their `index.ts`, plugins only through the registry `@/plugins` (and the contract `@/plugins/types`), the core only from `@m1k1o/neko`, and no layer imports one above it (app -> layout -> features -> components -> design; state and i18n below all but design). `tools/cycles.mjs` walks the import graph of `src/` (type-only imports excepted) and fails on a cycle. Both run in `npm run check`. Two imports did not fit the rules: the plugin entries used `../types` (now `@/plugins/types`), and the About dialog read the version from `../../../package.json`; Vite now defines `__APP_VERSION__` from it, same text in the dialog. Co-Authored-By: Claude Fable 5.1 * ci: build the @m1k1o/neko package in the client workflow Co-Authored-By: Claude Fable 5.1 * docs: client layering, plugin contract and the core package `client/README.md` documents the layout and its dependency rules, the plugin contract with the steps to add one, the core package and the scripts. `client/core/README.md` is the API reference of `@m1k1o/neko` (added in the package commit). The repository structure page and the release notes name `client/core` and the package. Co-Authored-By: Claude Fable 5.1 * core: unit tests for the connection state machine node:test suites for @m1k1o/neko, run with `npm test` (from client/ or core/) and as part of `npm run check`. client.test.ts drives NekoClient headless against fake sockets, peers, fetch and a virtual clock: the handshake, signal/close and peer failures with backoff and the give-up after RECONNECT_MAX, the offer timeout, the stale socket check, socket loss and reconnect, disconnect(), heartbeats, the codec mismatch, system/disconnect, session/control/screen/clipboard/settings events, preparePaste precedence, login/authenticate/logout, send() and sendData(). store.test.ts ports store.check.ts. The *.check.ts self-checks stay; *.test.ts is excluded from the tsc builds like *.check.ts, so dist/ and the published package are unchanged. Co-Authored-By: Claude Fable 5.1 * client: vitest replaces node:test and the check scripts One test runner: `npm test` is `vitest run` (core/package.json delegates to it for core/src). client.test.ts and store.test.ts import the runner from vitest and keep node:assert; the custom clock in client.test.ts is now vi.useFakeTimers (setImmediate stays real to settle promises between timers). The three *.check.ts scripts are folded in: store.check.ts was already covered by store.test.ts, client.check.ts's stale-sender removeTrack case moved into client.test.ts, markdown.check.ts became markdown.test.ts. The tsconfigs, tools/cycles.mjs, dist.check.mjs and the READMEs no longer mention them. Co-Authored-By: Claude Fable 5.1 * client: Video.tsx -> Stage, Player and three hooks Pure split of the 156-line Video component, no markup or behaviour change (the view harness shows identical #root HTML for every view): Stage.tsx keeps the layout, the two popover menus and the play/unmute overlays; Player.tsx is the mount point the client puts the stream and the input overlay into; useFullscreen (with the keyboard lock), useClipboardSync (focus listener + the hover sync, and the canReadClipboard capability) and usePip (the capability and the request) carry the browser-API bits out of the component. The feature index exports Stage; App.tsx renders it in place of Video. Co-Authored-By: Claude Fable 5.1 * core: StreamTransport seam, WebRTCTransport, input channels NekoClient no longer owns the RTCPeerConnection, the data channel or the