# neko client The web client for [neko](https://github.com/m1k1o/neko): React 19 + TypeScript, built with Vite. It speaks only the v3 API (`/api/ws` + REST). ``` client/ core/ @m1k1o/neko: the framework-free client library, its own npm package (core/README.md) src/ main.tsx start-up: the strings, the instance (createNekoApp), the plugins, the connection, the render under app/ the shell: App, ErrorBoundary, the #neko grid layout/ Header, Side (the tab bar), RoomBar (members, room menu, controls, emotes) features/ one folder per feature: video, members, controls, room-menu, emotes, settings, connect, about plugins/ the registry (index.ts: the list, the slots, event dispatch), the contract (types.ts), chat/, filetransfer/ components/ shared pieces: IconButton, Avatar, Dialog, EachHook; ui/ holds the shadcn/ui kit (dialog, menus, popover, tabs, button, sonner) state/ the instance (neko.ts: createNekoApp, app.ts: the GUI store and NekoApp), the provider and hooks, actions, settings, dialogs, event wiring i18n/ i18next: initI18n(), setLang(), the loader of the locale files lib/ cn(): class names with Tailwind conflicts resolved (clsx + tailwind-merge) locales/ the strings, one folder per language: common.json, chat.json, files.json assets/ images index.css Tailwind v4: the design tokens (@theme), the global rules, the layout's breakpoint variants ``` ## Layering Dependencies point one way: `app → layout → features → components → design`, with `state`, `i18n` and `lib` below everything but `design`. In practice: - A feature is a folder with its component(s), styled with Tailwind classes in the markup. Other folders import a feature by its main file with `@/` (`import { Stage } from '@/features/video/Stage'`); only a folder's own files are imported relatively. - Shared components know nothing about features or plugins. Nothing imports `app/`. - Plugins import neither each other nor `features/`; the app shell and the layout reach plugins only through the registry `@/plugins`, and features get plugin contributions passed in (they see only the contract in `@/plugins/types`). - The core is imported only from its entry point, `@m1k1o/neko`, never from a path into `core/`. - State is zustand: the core's `client.store`, the GUI's `app` store (`state/app.ts`) and each plugin's `store.ts`, all per instance (see Composition). A component gets the instance from the context (`useClient()`, `useApp()`, `useNeko()`), selects what it renders, `useStore(useClient().store, (s) => s.video.playing)` (`useShallow` for several fields; the core's `selectControlling`, `selectIsAdmin`, `selectSession` for the computed values), and re-renders only when that value changes. Everything outside React takes the instance as its first argument, reads `store.getState()` and writes `store.setState()` with a new object, never in place. - Where two layers must talk without importing each other, there is a store or a signal in `state/`: the room's event lines ("bob took the controls") are emitted on `state/bus.ts` and shown by the chat plugin; `logout` is a bus signal every store with per-session data listens to. `npm run check` enforces this: `eslint.config.js` holds one `no-restricted-imports` rule set per layer, and `tools/cycles.mjs` fails on an import cycle (type-only imports excepted). ## Composition There is no global client. `createNekoApp()` (`src/state/neko.ts`) makes one instance of the GUI, a `NekoApp` (`src/state/app.ts`): the `NekoClient` (WebRTC transport, `autologin`, `autoconnect`, `autoplay` from the viewer settings), its input `Overlay`, the GUI store `app` and the `bus`, with the event wiring (server events to event lines and toasts, `state/events.ts`) and the settings (stored and from the URL, `state/settings.ts`) applied. `main.tsx` composes the page in the order the pieces need: ```tsx const ready = initI18n(plugins.map((p) => p.ns)) // the strings of the active language const neko = createNekoApp() // the client, its wiring, the settings initPlugins(neko) // the slots, the event dispatch, each plugin's init ready.then(() => { neko.client.setUrl(location.href) // the connection: resume a saved session and connect createRoot(root).render( , ) }) ``` `` (`src/state/provider.tsx`) puts the instance in a React context; a component reads it with `useNeko()` (the whole `NekoApp`), `useClient()`, `useApp()` (the GUI store) and `useActions()` (`state/actions.ts`, the room actions bound to the instance), and the rest, `setSetting(neko, ...)`, `ask(app, ...)`, `api(neko, ...)`, a plugin's `store(neko)`, takes it as the first argument. Two instances on one page share nothing but the page (the strings, the URL parameters, `localStorage`). To embed the GUI in another React app, compose the same pieces and render `` under a provider (`?embed=1` and `?cast=1` on the page's URL still give the video-only layouts, for an iframe or a kiosk): ```tsx import { initI18n } from '@/i18n' import { plugins, initPlugins } from '@/plugins' import { createNekoApp } from '@/state/neko' import { NekoProvider } from '@/state/provider' import { App } from '@/app/App' await initI18n(plugins.map((p) => p.ns)) const neko = createNekoApp() initPlugins(neko) neko.client.setUrl('https://neko.example.com/') export const Room = () => ( ) ``` ## Design Styling is Tailwind v4 (`tailwindcss`, `@tailwindcss/vite`): the classes are in the markup, there is no stylesheet per component. `src/index.css` is the whole of the CSS: - **Tokens**: an `@theme` block with the values of the previous SCSS variables under the same names, so `$background-primary` is `--color-background-primary` and the utility `bg-background-primary`; `$text-normal` is `text-text-normal`, `$style-primary` is `bg-style-primary` / `text-style-primary`, `$elevation-high` is `shadow-elevation-high`, `$side-width` / `$menu-height` / `$controls-height` are `w-side` / `h-menu` / `h-controls`, `$text-size` is `text-ui`. The font is Tailwind's system stack (`--font-sans`). Sizes are written in px where the legacy layout had px (`p-[5px]`, `h-7.5`). - **Global rules** the preflight does not have: the page's size and overflow, 14px text with line-height 1 on `body` (so `rem`, the base of Tailwind's spacing scale, stays 16px), no native video controls. - **Breakpoints** as variants: `tablet:` (up to 1024px: the page scrolls and the side panel goes under the video, with Tailwind's `portrait:` / `landscape:` for the split), `phone:` (up to 768px: no room bar). - The animations (`animate-shake`, `animate-loader`, `animate-badge`) and the `slider` utility (the range inputs). `cn()` (`src/lib/utils.ts`, `clsx` + `tailwind-merge`) joins class names and lets the last Tailwind class win, so a caller's `className` overrides a component's; `class-variance-authority` gives the variants. The component kit under `src/components/ui/` is shadcn/ui on Radix, restyled with the tokens: `dialog.tsx` (`@radix-ui/react-dialog`, the overlay and the content composed by the caller), `dropdown-menu.tsx` (a menu from a button: the resolution list, the emote picker), `popover.tsx` (the clipboard textarea, the emoji picker), `tabs.tsx` (the side panel), `context-menu.tsx` (the member menu: a menu opened at a point, on `@radix-ui/react-menu`, since it is opened from the member list, a chat author and Shift+F10), `button.tsx` (the text buttons: `primary`, `outline`, `confirm`, `cancel`) and `sonner.tsx` (the toasts, `toast()` in `src/state/dialogs.ts`). They bring focus trapping, roving focus, Escape, outside click and ARIA. `components.json` configures `npx shadcn add ` (the radix base); a component added that way comes with shadcn's theme classes and is rewritten with the tokens, like the ones here. Every icon control is `IconButton` (`src/components/IconButton.tsx`): a `