From 30a75a0629e2267dd69971d70ee93faa19e3808e Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 2 Oct 2026 20:16:03 +0200 Subject: [PATCH 1/4] Text channels between mumh5 users; drag channels in front of or behind another Text channels are real Mumble channels with a marker in the description. Their messages travel as plugin data to everyone on the server, are kept by the clients and handed to people who connect later. Channels can be dropped in front of or behind another channel, with a line where they land. A description preview no longer sticks after a drop. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + CLAUDE.md | 5 +- README.md | 4 +- src/core/channel-order.ts | 25 ++++ src/core/share-signal.ts | 26 +++- src/core/text-signal.ts | 102 +++++++++++++ src/lib/actions.ts | 12 +- src/lib/blobstore.ts | 5 +- src/lib/session.svelte.ts | 65 ++++++++- src/lib/text-channels.ts | 275 ++++++++++++++++++++++++++++++++++++ src/ui/ChannelDialog.svelte | 23 ++- src/ui/ChannelNode.svelte | 145 +++++++++++++++---- src/ui/Chat.svelte | 3 +- src/ui/ChatLog.svelte | 2 +- src/ui/RightPanel.svelte | 4 +- test/channel-order.test.ts | 47 ++++++ test/e2e/app.e2e.ts | 116 +++++++++++++++ test/text-signal.test.ts | 84 +++++++++++ 18 files changed, 892 insertions(+), 54 deletions(-) create mode 100644 src/core/channel-order.ts create mode 100644 src/core/text-signal.ts create mode 100644 src/lib/text-channels.ts create mode 100644 test/channel-order.test.ts create mode 100644 test/text-signal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cae517..76636f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them. +- Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it. - Voice in browsers without an audio codec of their own (Firefox on Android): a built-in Opus codec takes over there. - Cameras of people in your channel show by themselves, like in a video call; each one can be hidden from its right-click menu, and all of them switched off with one button. - Camera sharing, as a second source in the screen sharing dialog, and camera rounds: several people's cameras at once in the voice tiles. @@ -28,6 +30,7 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Fixed +- A description preview could open after dropping a channel and stay on screen. - Messages sent in quick, irregular bursts could still be dropped by the server's rate limit. The client now mirrors Murmur's bucket exactly (it only leaks whole seconds and restarts its timer on every message) and queues instead of losing messages. ## 0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 08beab0..b12e600 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,8 @@ A reused test server keeps registrations and channels from earlier runs; tests m - Screen sharing: `src/core/share-signal.ts` (signals as Mumble plugin data, id `mumh5.share`, compressed and chunked) and `src/lib/share.svelte.ts` (one WebRTC connection per viewer and stream, candidates inside the description, no trickle; a viewer holds one screen and any number of cameras in `share.watches`). The start dialog is `ShareDialog.svelte`, the tiles and the large view are `VoiceStage.svelte`. Desktop source listing is `share:sources` / `share:pick` in `electron/main.ts`; Linux sound is `electron/pipewire.ts` (a virtual microphone fed with pw-link, never with this app's own playback). - `src/ui/`: Svelte components. `App.svelte` owns layout and global dialogs (`ui.svelte.ts` store). +- Text channels: `src/core/text-signal.ts` (a marker in the channel description, messages as plugin data, id `mumh5.text`, sent to everyone on the server) and `src/lib/text-channels.ts` (pacing under Murmur's plugin limit, messages kept in IndexedDB per server, catch-up from one other client after connecting). They share the `ch:` chat view; `session.isText(id)` tells them apart. Channel order for drag and drop is `src/core/channel-order.ts`. + Voice runs on one server at a time (where you last joined a channel); background servers are auto self-deafened and restored when voice returns. ## Conventions @@ -79,7 +81,8 @@ Voice runs on one server at a time (where you last joined a channel); background - Murmur silently drops own UserState, TextMessage, ChannelState, ACL and Version past a leaky bucket (burst 5, 1/s). The client paces these; don't bypass `send()`. - 1.5 servers use the protobuf UDP voice format (type byte 0 + MumbleUDP.Audio) with clients announcing 1.5; older ones the legacy format. The UDPTunnel TCP body is the raw voice packet, not a protobuf message. Sequence numbers count 10 ms frames. - Long comments and descriptions arrive as a hash only; a new hash invalidates the old text; fetch with RequestBlob. -- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it; keep packets at 900 bytes and few. +- Murmur drops PluginDataTransmission over 1000 bytes and rate limits it (measured: burst 15, then 4 per second, per sender, dropped silently); keep packets at 900 bytes and few. +- Murmur gives the id of a removed channel to the next one created; anything kept per channel id must be dropped on ChannelRemove. - Renaming while connected is not possible; mumh5 reconnects with the new name. - Murmur never sends SuperUser (user id 0) PermissionQuery answers; treat SuperUser as allowed everything. - In ACLs, Write overrides denies. Grant test rights to one user (`$` group), not `@all`. diff --git a/README.md b/README.md index bba8ffa..f603097 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ mumh5 keeps the foundation and replaces the experience. | Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 | | Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor | -**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. These are on the roadmap below. +**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. Push-to-talk and whisper are on the roadmap below. --- @@ -281,6 +281,8 @@ Without an upload host, mumh5 still sends images, scaled to fit the server's lim - System-wide push to talk - Whisper and shout +- Text channels: private ones, and history while nobody is online (needs a helper on the server) +- A second, mobile-first interface with the same features in a different layout - Rich chat between mumh5 users: replies, reactions, edits, typing indicators - Screen sharing: a media server for large rounds (today every sender uploads once per viewer, up to 8) - Release builds for all platforms diff --git a/src/core/channel-order.ts b/src/core/channel-order.ts new file mode 100644 index 0000000..ccb1727 --- /dev/null +++ b/src/core/channel-order.ts @@ -0,0 +1,25 @@ +// Where a channel sits among its siblings. Mumble sorts by position, then by name, so a +// channel dropped between two others needs a position that puts it exactly there. +export interface Ordered { id: number; position: number; name: string } + +export const byOrder = (a: Ordered, b: Ordered) => a.position - b.position || a.name.localeCompare(b.name); + +// Position changes that put `moved` at `index` among `siblings` (which need not contain it). +// One change when there is room between the neighbours; otherwise the row is numbered again. +export function planMove(siblings: Ordered[], moved: Ordered, index: number): { id: number; position: number }[] { + const rest = siblings.filter(c => c.id !== moved.id).sort(byOrder); + const at = Math.max(0, Math.min(index, rest.length)); + const prev = rest[at - 1]; + const next = rest[at]; + const fits = (position: number) => { + const m = { ...moved, position }; + return (!prev || byOrder(prev, m) < 0) && (!next || byOrder(m, next) < 0); + }; + const tries = [moved.position, prev?.position, next?.position, prev && prev.position + 1, next && next.position - 1]; + for (const p of tries) { + if (p == null || !fits(p)) continue; + return p === moved.position ? [] : [{ id: moved.id, position: p }]; + } + const order = [...rest.slice(0, at), moved, ...rest.slice(at)]; + return order.map((c, i) => ({ id: c.id, position: i })).filter((u, i) => order[i].position !== u.position); +} diff --git a/src/core/share-signal.ts b/src/core/share-signal.ts index 41b4d39..392998a 100644 --- a/src/core/share-signal.ts +++ b/src/core/share-signal.ts @@ -47,7 +47,7 @@ async function inflate(bytes: Uint8Array): Promise { } // One packet for a bare signal, several for a signal with text: [type, id, index, total, ...data] -export async function encodeShare(type: ShareType, text?: string): Promise { +export async function encodeSignal(type: number, text?: string): Promise { if (text == null) return [new Uint8Array([type])]; const data = await deflate(new TextEncoder().encode(text)); const total = Math.max(1, Math.ceil(data.length / CHUNK)); @@ -62,15 +62,23 @@ export async function encodeShare(type: ShareType, text?: string): Promise encodeSignal(type, text); + +export interface Signal { type: T; text?: string } +export type ShareSignal = Signal; // Puts chunked signals back together, per sender -export class ShareAssembler { +export class SignalAssembler { private pending = new Map(); + private types: readonly T[]; - async push(sender: number, packet: Uint8Array, now = Date.now()): Promise { - const type = packet[0] as ShareType; - if (!Object.values(SHARE).includes(type)) return null; + constructor(types: readonly T[]) { + this.types = types; + } + + async push(sender: number, packet: Uint8Array, now = Date.now()): Promise | null> { + const type = packet[0] as T; + if (!this.types.includes(type)) return null; if (packet.length === 1) return { type }; if (packet.length < 4) return null; const [, id, index, total] = packet; @@ -95,3 +103,9 @@ export class ShareAssembler { return plain ? { type, text: new TextDecoder().decode(plain) } : null; } } + +export class ShareAssembler extends SignalAssembler { + constructor() { + super(Object.values(SHARE)); + } +} diff --git a/src/core/text-signal.ts b/src/core/text-signal.ts new file mode 100644 index 0000000..95357e8 --- /dev/null +++ b/src/core/text-signal.ts @@ -0,0 +1,102 @@ +// Text channels between mumh5 clients. Mumble only delivers a channel message to the people +// sitting in that channel, so a channel you read while you are in voice elsewhere cannot be +// a plain Mumble chat. A text channel is a normal Mumble channel with a marker in its +// description; its messages travel as plugin data to everyone on the server, which other +// clients ignore. The server stores nothing: clients keep the messages and hand the recent +// ones to whoever connects later. +import { encodeSignal, SignalAssembler } from './share-signal.ts'; + +export const TEXT_DATA_ID = 'mumh5.text'; + +export const TEXT = { + hello: 1, // I just connected: who else runs mumh5? + here: 2, // answer to hello + msg: 3, // a message: { i, c, h } + want: 4, // send me the recent messages + history: 5 // answer to want, one per channel: { c, m: [...] } +} as const; +export type TextType = typeof TEXT[keyof typeof TEXT]; + +// An HTML comment: invisible in every client, and Murmur keeps it +export const TEXT_MARK = ''; +export const isTextChannel = (description: string) => description.includes(TEXT_MARK); +export const stripMark = (description: string) => description.split(TEXT_MARK).join(''); +export const markText = (description: string, on: boolean) => (on ? TEXT_MARK : '') + stripMark(description); + +// Kept per channel, and handed to a newcomer +export const KEEP = 200; +export const HAND_OVER = 50; +// Compressed, this stays well inside the 32 packets a signal may take +export const MAX_HTML = 24000; + +// i: id, c: channel, t: time, n: author name, k: author certificate hash, h: html +export interface TextMsg { i: string; c: number; t: number; n: string; k: string; h: string } + +export const encodeText = (type: TextType, body?: unknown) => encodeSignal(type, body === undefined ? undefined : JSON.stringify(body)); + +export class TextAssembler extends SignalAssembler { + constructor() { + super(Object.values(TEXT)); + } +} + +export function newId(): string { + return Date.now().toString(36) + Array.from(crypto.getRandomValues(new Uint8Array(6)), b => b.toString(36)).join(''); +} + +function parse(text: string | undefined): Record | null { + try { + const v = JSON.parse(text ?? ''); + return v && typeof v === 'object' && !Array.isArray(v) ? v : null; + } catch { + return null; + } +} + +const okId = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 40; +const okChannel = (v: unknown): v is number => Number.isInteger(v) && (v as number) >= 0; +const okHtml = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= MAX_HTML; + +// A live message; who sent it and when is filled in by the receiver +export function parseMsg(text: string | undefined): { i: string; c: number; h: string } | null { + const v = parse(text); + return v && okId(v.i) && okChannel(v.c) && okHtml(v.h) ? { i: v.i, c: v.c, h: v.h } : null; +} + +// Messages another client kept. Times from the future are pulled back to now. +export function parseHistory(text: string | undefined, now = Date.now()): TextMsg[] { + const v = parse(text); + if (!v || !okChannel(v.c) || !Array.isArray(v.m)) return []; + const c = v.c; + const out: TextMsg[] = []; + for (const m of v.m.slice(-HAND_OVER) as Record[]) { + if (!m || !okId(m.i) || !okHtml(m.h) || typeof m.t !== 'number' || !Number.isFinite(m.t)) continue; + if (typeof m.n !== 'string' || m.n.length > 200 || typeof m.k !== 'string' || m.k.length > 200) continue; + out.push({ i: m.i, c, t: Math.min(m.t, now), n: m.n, k: m.k, h: m.h }); + } + return out; +} + +// Adds what is not there yet, in order of time. Returns the new list and what was added. +export function mergeMsgs(have: TextMsg[], incoming: TextMsg[], keep = KEEP): { all: TextMsg[]; added: TextMsg[] } { + const ids = new Set(have.map(m => m.i)); + const added: TextMsg[] = []; + for (const m of incoming) { + if (ids.has(m.i)) continue; + ids.add(m.i); + added.push(m); + } + if (!added.length) return { all: have, added }; + const all = [...have, ...added].sort((a, b) => a.t - b.t).slice(-keep); + return { all, added: added.filter(m => all.includes(m)) }; +} + +// The newest messages of a channel that still fit one signal +export async function encodeHistory(c: number, msgs: TextMsg[]): Promise { + for (let n = Math.min(HAND_OVER, msgs.length); n > 0; n = Math.floor(n / 2)) { + try { + return await encodeText(TEXT.history, { c, m: msgs.slice(-n).map(({ i, t, n: name, k, h }) => ({ i, t, n: name, k, h })) }); + } catch { /* too large: hand over fewer */ } + } + return []; +} diff --git a/src/lib/actions.ts b/src/lib/actions.ts index e50a02d..6afd86b 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -36,11 +36,17 @@ export function channelMenu(c: Channel): MenuItem[] { }) }); } + // A text channel is opened, not joined + const open: MenuItem[] = session.isText(c.id) + ? [{ label: 'Open text channel', action: () => session.selectView(`ch:${c.id}`) }] + : [ + { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, + // Opens the channel's chat in the side panel, ready to type, without joining it + { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } } + ]; return [ { header: true, label: c.name || 'Root' }, - { label: here ? 'You are here' : 'Join channel', action: () => session.joinChannel(c.id), disabled: here || !c.canEnter }, - // Opens the channel's chat in the side panel, ready to type, without joining it - { label: 'Send message to channel', action: () => { session.showChannel(c.id, 'chat'); ui.panelOpen = true; } }, + ...open, { label: 'Show description', action: () => { session.showChannel(c.id, 'info'); ui.panelOpen = true; } }, { separator: true, label: '' }, { label: session.isExpanded(c.id) ? 'Collapse' : 'Expand', action: () => session.toggleExpanded(c.id), disabled: c.id === 0 }, diff --git a/src/lib/blobstore.ts b/src/lib/blobstore.ts index 876ec46..dbeac00 100644 --- a/src/lib/blobstore.ts +++ b/src/lib/blobstore.ts @@ -21,4 +21,7 @@ function run(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest< export const putBlob = (key: string, blob: Blob) => run('readwrite', s => s.put(blob, key)); export const getBlob = (key: string) => run('readonly', s => s.get(key)); -export const deleteBlob = (key: string) => run('readwrite', s => s.delete(key)); +// Plain data that outgrows localStorage (text channel messages) +export const putData = (key: string, value: unknown) => run('readwrite', s => s.put(value, key)); +export const getData = (key: string) => run('readonly', s => s.get(key)); +export const deleteBlob =(key: string) => run('readwrite', s => s.delete(key)); diff --git a/src/lib/session.svelte.ts b/src/lib/session.svelte.ts index 2f0bba0..1202b83 100644 --- a/src/lib/session.svelte.ts +++ b/src/lib/session.svelte.ts @@ -2,6 +2,8 @@ import { createCodec } from '../core/proto.ts'; import { MumbleClient, describeDenial, type Channel, type User, type TextMessage } from '../core/client.ts'; import { native, openTransport } from './native.ts'; import { share, type ShareKind } from './share.svelte.ts'; +import { textChannels } from './text-channels.ts'; +import { MAX_HTML, type TextMsg } from '../core/text-signal.ts'; import type { ServerCertInfo } from '../core/transport.ts'; import { store, type SavedServer } from './settings.svelte.ts'; import { renderIncoming, textToHtml, escapeHtml, formatBytes, type Embed } from './html.ts'; @@ -188,6 +190,12 @@ export class Session { } // Whether a channel lies somewhere below another (a channel cannot move into its own subtree) + // A channel that is read and written without joining it (mumh5 clients only) + isText(channelId: number): boolean { + void this.tick; + return textChannels.isText(this, channelId); + } + isInside(channelId: number, ancestor: number): boolean { let c = this.client?.channels.get(channelId); while (c && c.parent != null) { @@ -292,6 +300,7 @@ export class Session { this.client = null; client?.disconnect(); share.closed(this); + textChannels.closed(this); this.sharing = {}; sessions.onClosed(this); this.status = 'idle'; @@ -318,11 +327,17 @@ export class Session { for (const id of client.channels.keys()) client.requestPermissions(id); if (client.welcomeText) this.system(this.view, client.welcomeText, true); share.entered(this); + textChannels.entered(this); bump(); }); - client.on('pluginData', (sender, dataId, data) => { if (live()) share.receive(this, sender, dataId, data); }); + client.on('pluginData', (sender, dataId, data) => { + if (!live()) return; + share.receive(this, sender, dataId, data); + textChannels.receive(this, sender, dataId, data); + }); client.on('channel', ch => { if (live() && client.synced && !client.permissions.has(ch.id)) client.requestPermissions(ch.id); + if (live() && client.synced) textChannels.channelChanged(this, ch.id); bump(); }); client.on('permissions', () => { @@ -334,6 +349,11 @@ export class Session { client.on('channelRemove', id => { if (!live()) return; if (this.panel.kind === 'channel' && this.panel.id === id) this.panel = { kind: 'members' }; + textChannels.channelRemoved(this, id); + // The chat on screen is gone: back to the channel we are in + if (this.view === `ch:${id}`) this.view = `ch:${client.self?.channelId ?? 0}`; + delete this.logs[`ch:${id}`]; + delete this.unread[`ch:${id}`]; bump(); }); client.on('ping', rtt => { if (live()) this.rtt = rtt; }); @@ -378,6 +398,7 @@ export class Session { } const self = client.self; share.userMoved(this, user, true); + textChannels.userLeft(this, user.session); if (self && user.session !== self.session && this.isVoice() && user.channelId === self.channelId) { const byOther = actor != null && actor !== user.session; sounds.play(ban ? 'userBanned' : byOther ? 'userKicked' : 'userDisconnected'); @@ -427,6 +448,7 @@ export class Session { const kind = this.disconnectInfo?.kind; sounds.play(kind === 'kicked' ? 'kicked' : kind === 'banned' ? 'banned' : kind === 'lost' ? 'connectionLost' : kind === 'rejected' ? 'error' : 'disconnected'); this.client = null; + textChannels.closed(this); sessions.onClosed(this); this.status = 'idle'; this.error = reason === 'Disconnected' ? '' : reason; @@ -519,11 +541,27 @@ export class Session { // ─── Chat ─────────────────────────────────────────────────────────────────── - private push(key: string, entry: Omit): void { + // time: for messages from earlier, which go where they belong in the log. quiet: not counted as unread. + private push(key: string, entry: Omit, opts: { time?: number; quiet?: boolean } = {}): void { // Assign first, then push through the stored proxy; pushing onto the raw array would not be reactive if (!this.logs[key]) this.logs[key] = []; - this.logs[key].push({ ...entry, id: nextId++, time: Date.now() }); - if (sessions.active !== this || !this.visibleKeys().includes(key)) this.unread[key] = (this.unread[key] ?? 0) + 1; + const log = this.logs[key]; + const time = opts.time ?? Date.now(); + let at = log.length; + while (at > 0 && log[at - 1].time > time) at--; + log.splice(at, 0, { ...entry, id: nextId++, time }); + if (!opts.quiet && (sessions.active !== this || !this.visibleKeys().includes(key))) this.unread[key] = (this.unread[key] ?? 0) + 1; + } + + // A text channel message: live, kept from earlier, or handed over by another client + addText(m: TextMsg, self: boolean, quiet: boolean, sender: number | null = null): void { + const client = this.client; + if (!client) return; + const online = sender != null ? client.users.get(sender) : self ? client.self : m.k ? [...client.users.values()].find(u => u.hash === m.k) : undefined; + this.push(`ch:${m.c}`, { + kind: 'text', author: online ? store.displayName(online) : m.n, authorSession: online?.session ?? null, self, + ...renderIncoming(m.h, store.trustedHosts) + }, { time: m.t, quiet }); } private system(key: string, html: string, trusted = false): void { @@ -592,6 +630,16 @@ export class Session { sendHtml(html: string, key: string = this.view): boolean { const client = this.client; if (!client || this.status !== 'connected') return false; + if (key.startsWith('ch:') && this.isText(Number(key.slice(3)))) { + if (html.length > MAX_HTML) { + this.system(key, `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`); + return false; + } + // Shown in the log by the text channel code once it is on its way + textChannels.post(this, Number(key.slice(3)), html).then(problem => { if (problem) this.system(key, escapeHtml(problem)); }); + sounds.play('messageSent'); + return true; + } const limit = / limit) { this.system(key, `Message too long for this server (${html.length} / ${limit} characters)`); @@ -623,6 +671,7 @@ export class Session { const problem = checkFile(info, f); if (problem) this.system(key, escapeHtml(problem)); } + const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3))); for (const file of files.filter(f => !checkFile(info, f))) { const job = { id: nextId++, key, name: file.name, progress: 0, error: '' }; this.uploads.push(job); @@ -633,7 +682,8 @@ export class Session { const link = `${escapeHtml(res.name)} (${formatBytes(res.size)})`; let html = link; const cfg = this.client?.config; - if (sendThumbnails && cfg?.allowHtml !== false) { + // Text channels only reach mumh5, which shows the file itself instead of a thumbnail + if (sendThumbnails && cfg?.allowHtml !== false && !textKey) { const budget = Math.min(cfg?.imageMessageLength || 131072, 100000) - link.length - 64; const thumb = await makeThumbnail(file, budget); if (thumb) html += `
`; @@ -654,17 +704,18 @@ export class Session { // scaled to the server's image limit. Other files need an upload host. private async sendInlineImages(files: File[], key: string): Promise { const cfg = this.client?.config; + const textKey = key.startsWith('ch:') && this.isText(Number(key.slice(3))); for (const file of files) { if (!file.type.startsWith('image/')) { this.system(key, `${escapeHtml(file.name)}: sending files needs file hosting. Set up an upload host in Settings; without one only images can be sent.`); continue; } - if (cfg?.allowHtml === false) { + if (cfg?.allowHtml === false && !textKey) { this.system(key, 'This server does not allow images in messages.'); return; } // 0 means no limit; stay reasonable anyway - const limit = cfg?.imageMessageLength || 1024 * 1024; + const limit = textKey ? MAX_HTML : cfg?.imageMessageLength || 1024 * 1024; const uri = await makeInlineImage(file, limit - 64); if (!uri) { this.system(key, `${escapeHtml(file.name)}: could not make this image small enough for the server (limit ${formatBytes(limit)})`); diff --git a/src/lib/text-channels.ts b/src/lib/text-channels.ts new file mode 100644 index 0000000..ab41b9a --- /dev/null +++ b/src/lib/text-channels.ts @@ -0,0 +1,275 @@ +// Text channels: channels you read and write without sitting in them (see core/text-signal.ts). +// Messages go to everyone on the server as plugin data; the server has checked the sender's +// certificate, so a live message is known to come from that user. Messages handed over by +// another client after connecting are that client's word for what was said. +import { TEXT, TEXT_DATA_ID, KEEP, MAX_HTML, TextAssembler, encodeText, encodeHistory, parseMsg, parseHistory, mergeMsgs, newId, isTextChannel, type TextMsg, type TextType } from '../core/text-signal.ts'; +import type { Session } from './session.svelte.ts'; +import { getData, putData } from './blobstore.ts'; +import { sounds } from './audio/sounds.svelte.ts'; + +// Murmur allows a burst of 15 plugin messages per user and 4 a second after that, and drops +// the rest silently. Screen sharing signals count against the same limit, so stay well under it. +const BURST = 6; +const PER_SECOND = 2.5; +// How long a client may take to hand over its messages before the next one is asked +const ANSWER_MS = 6000; +const MAX_ASKED = 3; + +interface State { + assembler: TextAssembler; + msgs: Map; + // Channels whose kept messages are in the chat log already + shown: Set; + // What this computer kept has been read; nothing is shown before, or it would come out of order + loaded: boolean; + queue: { to: number[]; packet: Uint8Array }[]; + level: number; + levelAt: number; + timer: ReturnType | undefined; + // Catching up: who answered our hello, who we asked, and whether that is settled + peers: number[]; + asked: number[]; + caughtUp: boolean; + askTimer: ReturnType | undefined; + saveTimer: ReturnType | undefined; + storeKey: string; +} + +class TextChannels { + private states = new WeakMap(); + + // Plugin data needs a 1.4 server; older ones would not pass the messages on + supported(s: Session): boolean { + return !!s.client && s.client.serverVersionNum >= 0x010400; + } + + isText(s: Session, channelId: number): boolean { + return this.supported(s) && isTextChannel(s.client!.channels.get(channelId)?.description ?? ''); + } + + // After connecting: bring back what this computer kept, then ask the others for what we missed + async entered(s: Session): Promise { + const client = s.client; + if (!client || !this.supported(s) || !s.server) return; + const st: State = { + assembler: new TextAssembler(), msgs: new Map(), shown: new Set(), loaded: false, queue: [], level: 0, levelAt: Date.now(), timer: undefined, + peers: [], asked: [], caughtUp: false, askTimer: undefined, saveTimer: undefined, + storeKey: `text:${s.server.host.toLowerCase()}:${s.server.port}` + }; + this.states.set(s, st); + // The marker is in the description, and long descriptions are only announced by hash + for (const c of client.channels.values()) s.loadDescription(c.id); + try { + const kept = await getData>(st.storeKey); + if (s.client !== client) return; + for (const [id, list] of Object.entries(kept ?? {})) { + if (Array.isArray(list)) st.msgs.set(Number(id), mergeMsgs(st.msgs.get(Number(id)) ?? [], list).all); + } + } catch { /* no storage: start empty */ } + if (s.client !== client) return; + st.loaded = true; + this.reveal(s); + this.send(s, this.everyone(s), TEXT.hello); + } + + closed(s: Session): void { + const st = this.states.get(s); + if (!st) return; + clearTimeout(st.timer); + clearTimeout(st.askTimer); + if (st.saveTimer) { clearTimeout(st.saveTimer); this.save(st); } + this.states.delete(s); + } + + // A channel appeared or its description changed + channelChanged(s: Session, channelId: number): void { + if (!this.states.has(s)) return; + s.loadDescription(channelId); + this.reveal(s); + } + + // Murmur gives the id of a removed channel to a later one + channelRemoved(s: Session, channelId: number): void { + const st = this.states.get(s); + if (!st) return; + st.shown.delete(channelId); + st.msgs.delete(channelId); + this.forget(st, channelId); + } + + userLeft(s: Session, session: number): void { + const st = this.states.get(s); + if (!st) return; + st.peers = st.peers.filter(p => p !== session); + // The one we were waiting for is gone: ask the next + if (!st.caughtUp && st.asked[st.asked.length - 1] === session) this.askNext(s, st); + } + + // Sends a message to a text channel. The caller shows it in the log. + async post(s: Session, channelId: number, html: string): Promise { + const st = this.states.get(s); + const self = s.client?.self; + if (!st || !self) return 'Not connected.'; + if (html.length > MAX_HTML) return `Message too long for a text channel (${html.length} / ${MAX_HTML} characters)`; + const m: TextMsg = { i: newId(), c: channelId, t: Date.now(), n: self.name, k: self.hash, h: html }; + let packets: Uint8Array[]; + try { + packets = await encodeText(TEXT.msg, { i: m.i, c: m.c, h: m.h }); + } catch { + return 'Message too long for a text channel.'; + } + const to = this.everyone(s); + for (const packet of packets) st.queue.push({ to, packet }); + this.flush(s, st); + this.keep(st, [m]); + st.shown.add(channelId); + s.addText(m, true, false); + return null; + } + + async receive(s: Session, sender: number, dataId: string, data: Uint8Array): Promise { + const st = this.states.get(s); + if (dataId !== TEXT_DATA_ID || !st) return; + const signal = await st.assembler.push(sender, data); + const client = s.client; + if (!signal || !client || this.states.get(s) !== st) return; + switch (signal.type) { + case TEXT.hello: + this.send(s, [sender], TEXT.here); + break; + case TEXT.here: + if (!st.peers.includes(sender)) st.peers.push(sender); + if (!st.caughtUp && !st.asked.length) this.askNext(s, st); + break; + case TEXT.msg: { + const m = parseMsg(signal.text); + const from = client.users.get(sender); + const ch = m && client.channels.get(m.c); + // Only into text channels, or channels whose description has not arrived yet + if (!m || !from || !ch || !(isTextChannel(ch.description) || (ch.descriptionHash && !ch.description))) return; + const msg: TextMsg = { ...m, t: Date.now(), n: from.name, k: from.hash }; + if (!this.keep(st, [msg]).length) return; + if (st.shown.has(m.c)) { + s.addText(msg, false, false, sender); + sounds.play('message'); + } + break; + } + case TEXT.want: { + for (const [c, list] of st.msgs) { + if (!this.isText(s, c) || !list.length) continue; + for (const packet of await encodeHistory(c, list)) st.queue.push({ to: [sender], packet }); + } + // Also when there is nothing, so the asker stops waiting + for (const packet of await encodeText(TEXT.history, { c: 0, m: [] })) st.queue.push({ to: [sender], packet }); + this.flush(s, st); + break; + } + case TEXT.history: { + // Only from the client we asked + if (st.asked[st.asked.length - 1] !== sender) return; + st.caughtUp = true; + clearTimeout(st.askTimer); + const list = parseHistory(signal.text); + if (!list.length || !client.channels.has(list[0].c)) return; + const added = this.keep(st, list); + if (st.shown.has(list[0].c)) for (const m of added) s.addText(m, this.mine(s, m), true); + break; + } + } + } + + private mine(s: Session, m: TextMsg): boolean { + const self = s.client?.self; + return !!self && (m.k ? m.k === self.hash : m.n === self.name); + } + + private everyone(s: Session): number[] { + const client = s.client; + return client ? [...client.users.keys()].filter(id => id !== client.session) : []; + } + + private askNext(s: Session, st: State): void { + clearTimeout(st.askTimer); + const next = st.peers.find(p => !st.asked.includes(p)); + if (next == null || st.asked.length >= MAX_ASKED) return; + st.asked.push(next); + this.send(s, [next], TEXT.want); + st.askTimer = setTimeout(() => { if (!st.caughtUp && this.states.get(s) === st) this.askNext(s, st); }, ANSWER_MS); + } + + // Puts the kept messages of text channels into their chat logs, once per channel + private reveal(s: Session): void { + const st = this.states.get(s); + if (!st?.loaded) return; + for (const [c, list] of st.msgs) { + if (st.shown.has(c) || !this.isText(s, c)) continue; + st.shown.add(c); + for (const m of list) s.addText(m, this.mine(s, m), true); + } + for (const c of s.client?.channels.keys() ?? []) if (this.isText(s, c)) st.shown.add(c); + } + + private keep(st: State, list: TextMsg[]): TextMsg[] { + if (!list.length) return []; + const c = list[0].c; + const { all, added } = mergeMsgs(st.msgs.get(c) ?? [], list, KEEP); + if (added.length) { + st.msgs.set(c, all); + this.saveSoon(st); + } + return added; + } + + private saveSoon(st: State): void { + clearTimeout(st.saveTimer); + st.saveTimer = setTimeout(() => this.save(st), 1000); + } + + // Merged into what is stored: a second connection to the same server writes there too + private async save(st: State): Promise { + st.saveTimer = undefined; + try { + const kept = await getData>(st.storeKey) ?? {}; + for (const [c, list] of st.msgs) kept[c] = mergeMsgs(Array.isArray(kept[c]) ? kept[c] : [], list).all; + await putData(st.storeKey, kept); + } catch { /* storage unavailable */ } + } + + private async forget(st: State, channelId: number): Promise { + try { + const kept = await getData>(st.storeKey); + if (!kept || !(channelId in kept)) return; + delete kept[channelId]; + await putData(st.storeKey, kept); + } catch { /* storage unavailable */ } + } + + private async send(s: Session, to: number[], type: TextType, body?: unknown): Promise { + const st = this.states.get(s); + if (!st || !to.length) return; + for (const packet of await encodeText(type, body)) st.queue.push({ to, packet }); + this.flush(s, st); + } + + private flush(s: Session, st: State): void { + if (st.timer) return; + const client = s.client; + while (st.queue.length && client && this.states.get(s) === st) { + const now = Date.now(); + st.level = Math.max(0, st.level - (now - st.levelAt) / 1000 * PER_SECOND); + st.levelAt = now; + if (st.level > BURST - 1) { + st.timer = setTimeout(() => { st.timer = undefined; this.flush(s, st); }, 1000 / PER_SECOND); + return; + } + st.level++; + const { to, packet } = st.queue.shift()!; + // Queued packets can outlive the people they were meant for + const there = to.filter(id => client.users.has(id)); + if (there.length) client.sendPluginData(there, TEXT_DATA_ID, packet); + } + } +} + +export const textChannels = new TextChannels(); diff --git a/src/ui/ChannelDialog.svelte b/src/ui/ChannelDialog.svelte index a8b8ceb..a3623a5 100644 --- a/src/ui/ChannelDialog.svelte +++ b/src/ui/ChannelDialog.svelte @@ -4,6 +4,9 @@ import { session } from '../lib/session.svelte.ts'; import { PERM, type ChannelAcl, type AclEntry, type AclGroup } from '../core/client.ts'; import { sounds } from '../lib/audio/sounds.svelte.ts'; + import { isTextChannel, markText, stripMark } from '../core/text-signal.ts'; + import { textChannels } from '../lib/text-channels.ts'; + import { sessions } from '../lib/session.svelte.ts'; type Props = | { mode: 'create'; parent: number; onclose: () => void; tab?: undefined; id?: undefined } @@ -30,7 +33,11 @@ // svelte-ignore state_referenced_locally const initial = channelId != null ? session.channel(channelId) : undefined; let name = $state(initial?.name ?? ''); - let description = $state(initial?.description ?? ''); + // The editor never sees the text channel marker; it is put back when saving + let description = $state(stripMark(initial?.description ?? '')); + let textChannel = $state(isTextChannel(initial?.description ?? '')); + // The marker is an HTML comment and the messages are plugin data + const textPossible = textChannels.supported(sessions.active) && session.serverInfo?.config.allowHtml !== false; let position = $state(initial?.position ?? 0); let maxUsers = $state(initial?.maxUsers ?? 0); let temporary = $state(false); @@ -43,7 +50,7 @@ let descLoaded = $state(!(initial?.descriptionHash && !initial?.description)); $effect(() => { if (channelId != null && descPending) session.loadDescription(channelId); - if (!descLoaded && channel?.description) { description = channel.description; descLoaded = true; } + if (!descLoaded && channel?.description) { description = stripMark(channel.description); textChannel = isTextChannel(channel.description); descLoaded = true; } }); // ─── ACL ──────────────────────────────────────────────────────────────────── @@ -217,7 +224,7 @@ error = ''; if (mode === 'create') { if (!name.trim()) { error = 'The channel needs a name.'; return; } - client.createChannel(parentId!, name.trim(), { temporary, description, position, maxUsers }); + client.createChannel(parentId!, name.trim(), { temporary, description: markText(description, textChannel), position, maxUsers }); sounds.play('channelCreated'); return onclose(); } @@ -225,7 +232,9 @@ if (!ch || channelId == null) return onclose(); const fields: { name?: string; description?: string; position?: number; maxUsers?: number } = {}; if (!isRoot && name.trim() && name.trim() !== ch.name) fields.name = name.trim(); - if (descLoaded && description !== ch.description) fields.description = description; + // An untouched description may not start with the marker; that alone is no change + const changed = description !== stripMark(ch.description) || textChannel !== isTextChannel(ch.description); + if (descLoaded && changed) fields.description = markText(description, textChannel); if (position !== ch.position) fields.position = Number(position) || 0; if (maxUsers !== ch.maxUsers) fields.maxUsers = Number(maxUsers) || 0; if (Object.keys(fields).length) client.updateChannel(channelId, fields); @@ -268,6 +277,12 @@ {/if} + {#if !isRoot} + + {#if !textPossible}

Text channels need a server from version 1.4 that allows HTML.

{/if} + {/if}

Lower positions come first. A user limit of 0 uses the server default.

{:else if !acl}

Loading permissions...

diff --git a/src/ui/ChannelNode.svelte b/src/ui/ChannelNode.svelte index 5912643..39a8c3a 100644 --- a/src/ui/ChannelNode.svelte +++ b/src/ui/ChannelNode.svelte @@ -14,6 +14,8 @@ import { share } from '../lib/share.svelte.ts'; import { sessions } from '../lib/session.svelte.ts'; import type { Channel } from '../core/client.ts'; + import { stripMark } from '../core/text-signal.ts'; + import { planMove } from '../core/channel-order.ts'; let { channel, depth = 0, onnavigate }: { channel: Channel; depth?: number; onnavigate: () => void } = $props(); @@ -21,12 +23,14 @@ const children = $derived(session.childrenOf(channel.id)); const expanded = $derived(session.isExpanded(channel.id)); const here = $derived(session.self?.channelId === channel.id); - const selected = $derived(session.panel.kind === 'channel' && session.panel.id === channel.id); + // Read and written without joining: a click opens its chat + const isText = $derived(session.isText(channel.id)); + const selected = $derived((session.panel.kind === 'channel' && session.panel.id === channel.id) || (isText && session.view === `ch:${channel.id}`)); const unread = $derived(session.unread[`ch:${channel.id}`] ?? 0); const total = $derived(session.subtreeUserCount(channel.id)); // Resting on a channel that has a description for 1 second opens a quick preview next to its row - const hasDescription = $derived(!!(channel.description || channel.descriptionHash)); + const hasDescription = $derived(!!(stripMark(channel.description) || (!channel.description && channel.descriptionHash))); const descriptionHtml = $derived(channel.description ? renderIncoming(channel.description, store.trustedHosts).html : ''); let preview = $state<{ x: number; y: number } | null>(null); let hoverTimer: ReturnType | undefined; @@ -40,6 +44,8 @@ trackPointer(e); clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { + // Not while something is dragged, and not when the row moved away in the meantime + if (drag.item || !rowEl?.matches(':hover')) return; // Long descriptions are only announced by hash; the text is fetched now session.loadDescription(channel.id); // Just below and right of the pointer, kept inside the window @@ -52,6 +58,20 @@ // An image opened from the preview covers it; the preview stays underneath hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250); } + // A row that moves away under the pointer (after a drop) never reports the pointer leaving, + // so an open preview also watches where the pointer is + let rowEl = $state(); + $effect(() => { + if (!preview) return; + let away: ReturnType | undefined; + const inside = () => !!rowEl?.matches(':hover') || !!popEl?.matches(':hover') || !!ui.image; + const check = () => { + if (inside()) { clearTimeout(away); away = undefined; } + else away ??= setTimeout(() => { away = undefined; if (!inside()) preview = null; }, 300); + }; + window.addEventListener('mousemove', check); + return () => { window.removeEventListener('mousemove', check); clearTimeout(away); }; + }); // Back from the image: the preview stays while the pointer is on it, and goes when it is not let popEl = $state(); let hadImage = false; @@ -80,44 +100,99 @@ }; } - // Drop a person to move them here, or a channel to make it a subchannel of this one - let over = $state(false); - const canDrop = $derived.by(() => { + // Drop a person to move them here. A channel dropped on the middle of a row becomes a + // subchannel; dropped on the upper or lower third it goes in front of or behind this one. + type Zone = 'before' | 'after' | 'into'; + let zone = $state(null); + const dragging = $derived(drag.item?.kind === 'channel' && drag.item.id === channel.id); + const open = $derived(expanded && (children.length > 0 || users.length > 0)); + + function intoAllowed(): boolean { const d = drag.item; if (!d) return false; if (d.kind === 'user') return session.user(d.id)?.channelId !== channel.id; return d.id !== channel.id && channel.id !== session.channel(d.id)?.parent && !session.isInside(channel.id, d.id); - }); + } - function onDrop(e: DragEvent) { - e.preventDefault(); - over = false; - // Check before clearing: canDrop is derived from the dragged item + function zoneAt(e: DragEvent, row: HTMLElement): Zone | null { const d = drag.item; - const ok = canDrop; + if (!d) return null; + const into = intoAllowed(); + if (d.kind === 'user' || channel.id === 0) return into ? 'into' : null; + if (d.id === channel.id || session.isInside(channel.id, d.id)) return null; + const box = row.getBoundingClientRect(); + const y = (e.clientY - box.top) / box.height; + if (y < 0.34) return 'before'; + if (y > 0.66) return 'after'; + if (into) return 'into'; + return y < 0.5 ? 'before' : 'after'; + } + + // Resting on a closed channel opens it, to reach the channels inside + let openTimer: ReturnType | undefined; + function setZone(z: Zone | null) { + if (z === zone) return; + zone = z; + clearTimeout(openTimer); + if (z === 'into' && !expanded && children.length) openTimer = setTimeout(() => { if (zone === 'into' && !expanded) session.toggleExpanded(channel.id); }, 700); + } + + function onDragOver(e: DragEvent, fixed?: Zone) { + const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement); + if (z) e.preventDefault(); + setZone(z); + } + + // Leaving for an element inside the same target is not leaving + function onDragLeave(e: DragEvent) { + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) setZone(null); + } + + function onDrop(e: DragEvent, fixed?: Zone) { + e.preventDefault(); + // Work out the target before clearing: it depends on the dragged item + const d = drag.item; + const z = fixed ? (intoAllowed() ? fixed : null) : zoneAt(e, e.currentTarget as HTMLElement); + setZone(null); drag.item = null; - if (!d || !ok) return; + const client = session.client; + if (!d || !z || !client) return; if (d.kind === 'user') { if (d.id === session.self?.session) session.joinChannel(channel.id); else session.moveUser(d.id, channel.id); - } else { - session.client?.updateChannel(d.id, { parent: channel.id }); - sounds.play('channelMoved'); + return; } + if (z === 'into') { + client.updateChannel(d.id, { parent: channel.id }); + } else { + const moved = session.channel(d.id); + if (!moved || channel.parent == null) return; + const rest = session.childrenOf(channel.parent).filter(c => c.id !== d.id); + const plan = planMove(rest, moved, rest.findIndex(c => c.id === channel.id) + (z === 'after' ? 1 : 0)); + const own = plan.find(u => u.id === d.id); + const reparent = moved.parent !== channel.parent; + if (!reparent && !plan.length) return; + if (reparent || own) client.updateChannel(d.id, { ...(reparent ? { parent: channel.parent } : {}), ...(own ? { position: own.position } : {}) }); + for (const u of plan) if (u.id !== d.id) client.updateChannel(u.id, { position: u.position }); + } + sounds.play('channelMoved'); } - const channelClicks = clickOrDouble( + const openText = () => { session.selectView(`ch:${channel.id}`); onnavigate(); }; + const channelClicks = $derived(isText ? { onclick: openText } : clickOrDouble( () => { session.showChannel(channel.id); ui.panelOpen = true; }, () => { session.joinChannel(channel.id); onnavigate(); } - ); + )); -
  • -
    +
  • +
    menu.show(e, channelMenu(channel))} role="presentation" - ondragover={e => { if (canDrop) { e.preventDefault(); over = true; } }} - ondragleave={() => (over = false)} - ondrop={onDrop} + ondragover={onDragOver} + ondragleave={onDragLeave} + ondrop={onDrop} bind:this={rowEl} onmouseenter={hoverRow} onmousemove={trackPointer} onmouseleave={leaveRow}> {#if children.length || users.length} + {:else if session.self?.channelId === channel.id} You are in this channel {:else}
    +

    Interface

    +
    + {#each INTERFACES as f (f.id)} + {@const on = (store.settings.interface ?? 'standard') === f.id} + + {/each} +
    + +{#if (store.settings.interface ?? 'standard') === 'standard'}

    Layout

    {#each ARRANGEMENTS as a (a.id)} @@ -64,6 +97,7 @@ {/each}

    Narrow windows use the phone layout, except Stacked, which stays as it is.

    +{/if} diff --git a/src/ui/VoiceStage.svelte b/src/ui/VoiceStage.svelte index 68ccdac..b17fdda 100644 --- a/src/ui/VoiceStage.svelte +++ b/src/ui/VoiceStage.svelte @@ -18,7 +18,8 @@ // as are on, and a click enlarges one. // videoOnly: just the picture, as wide as the chat (stacked layout, where the channel list // above already shows who is here) - let { always = false, videoOnly = false }: { always?: boolean; videoOnly?: boolean } = $props(); + // fill: take all the room the parent gives, without a height of its own (the voice screen) + let { always = false, videoOnly = false, fill = false }: { always?: boolean; videoOnly?: boolean; fill?: boolean } = $props(); const self = $derived(session.self); const channel = $derived(self ? session.channel(self.channelId) : undefined); @@ -119,13 +120,15 @@ return Math.max(24, Math.min(128, Math.round(Math.min(cellH * 0.5, cellW * 0.34)))); }); - let collapsed = $state(load()); + let folded = $state(load()); + const collapsed = $derived(folded && !fill); function load(): boolean { try { return localStorage.getItem('mumh5.stageCollapsed') === '1'; } catch { return false; } } function toggle() { - collapsed = !collapsed; - try { localStorage.setItem('mumh5.stageCollapsed', collapsed ? '1' : '0'); } catch { /* storage unavailable */ } + if (fill) return; + folded = !folded; + try { localStorage.setItem('mumh5.stageCollapsed', folded ? '1' : '0'); } catch { /* storage unavailable */ } } const talking = (s: number) => !!voice.talking[s] || (s === self?.session && voice.transmitting); @@ -349,9 +352,9 @@ {/if} {:else if visible && self} -
    - {#if !collapsed} +
    + {#if !collapsed && !fill} {#if focused} {:else} @@ -404,6 +407,7 @@ {/if} diff --git a/src/ui2/Dock.svelte b/src/ui2/Dock.svelte new file mode 100644 index 0000000..940b8dc --- /dev/null +++ b/src/ui2/Dock.svelte @@ -0,0 +1,50 @@ + + +
    + + {#if elsewhere} + + {:else} + + {/if} +
    + + diff --git a/src/ui2/People.svelte b/src/ui2/People.svelte new file mode 100644 index 0000000..36d4131 --- /dev/null +++ b/src/ui2/People.svelte @@ -0,0 +1,91 @@ + + +
    +
    +

    People

    + {session.status === 'connected' ? `${total} online` : 'Not connected'} +
    + {#if session.status === 'connected'} + +
    + {#each groups as g (g.id)} +

    {g.name}{#if g.id === self?.channelId} your room{/if} {g.users.length}

    +
      + {#each g.users as u (u.session)} + {@const me = u.session === self?.session} + + {/each} +
    + {:else} +

    Nobody matches "{query}".

    + {/each} +
    + {/if} +
    + + diff --git a/src/ui2/Rooms.svelte b/src/ui2/Rooms.svelte new file mode 100644 index 0000000..02e2883 --- /dev/null +++ b/src/ui2/Rooms.svelte @@ -0,0 +1,368 @@ + + +
    + + +
    + + {#if connected}{/if} + {#if session.status !== 'idle'} + + {/if} +
    + + {#if connected} + + {/if} + + +
    + + diff --git a/src/ui2/Shell.svelte b/src/ui2/Shell.svelte new file mode 100644 index 0000000..af9b2e2 --- /dev/null +++ b/src/ui2/Shell.svelte @@ -0,0 +1,125 @@ + + + { if (e.key === 'Escape' && sheet) closeSheet(); }} /> + +
    + + + {#if wide} +
    + (chosen = 'chat')} /> + (chosen = 'voice')} /> +
    + {/if} + +
    +
    + {#if screen === 'rooms'} (chosen = 'chat')} /> + {:else if screen === 'chat'} (chosen = 'rooms')} /> + {:else if screen === 'voice'} (chosen = 'rooms')} onchat={() => (chosen = 'chat')} /> + {:else if screen === 'people'} (chosen = 'chat')} /> + {:else}{/if} +
    + {#if !wide && screen !== 'voice'} (chosen = 'voice')} />{/if} +
    + + {#if sheet} + +
    { closeSheet(); chosen = 'chat'; }} onclose={closeSheet} />
    + {/if} +
    + + diff --git a/src/ui2/VoiceControls.svelte b/src/ui2/VoiceControls.svelte new file mode 100644 index 0000000..eb4be35 --- /dev/null +++ b/src/ui2/VoiceControls.svelte @@ -0,0 +1,53 @@ + + +
    + + + {#if large} + + {/if} +
    + + diff --git a/src/ui2/VoiceScreen.svelte b/src/ui2/VoiceScreen.svelte new file mode 100644 index 0000000..1d59be8 --- /dev/null +++ b/src/ui2/VoiceScreen.svelte @@ -0,0 +1,54 @@ + + +
    + {#if self} +
    +
    +

    {room?.name || 'Root'}

    + {session.usersIn(self.channelId).length} here, voice over {transport.udp ? 'UDP' : 'TCP'}{#if voice.error}, voice unavailable{/if} +
    + +
    + {#if voice.error}

    {voice.error}

    {/if} + {#if elsewhere} +

    Voice is on {sessions.voiceSession?.server?.label}; you are deafened here. +

    + {/if} +
    +
    + {:else} +
    +

    Voice

    +

    Connect to a server and join a room to talk.

    + +
    + {/if} +
    + + diff --git a/src/ui2/You.svelte b/src/ui2/You.svelte new file mode 100644 index 0000000..1d62097 --- /dev/null +++ b/src/ui2/You.svelte @@ -0,0 +1,117 @@ + + +
    +
    +
    + {#if self} + +
    +

    {self.name}

    + {self.userId != null ? 'Registered' : 'Guest'} on {session.server?.label}, in {session.channel(self.channelId)?.name || 'Root'} +
    + {:else} +
    +

    You

    + {session.status === 'connecting' ? 'Connecting...' : 'Not connected'} +
    + {/if} +
    + {#if self} +
    + + + + {#if self.userId == null}{/if} + +
    + {/if} + +

    Servers

    +
      + {#each store.servers as s (s.id)} + {@const state = stateOf(s.id)} +
    • + + {#if state !== 'idle'}{/if} + +
    • + {/each} +
    +
    + + {#if desktop}{/if} + {#if session.status === 'connected'}{/if} +
    + +

    Settings

    +
      + {#each SETTINGS as item (item.tab)} +
    • + +
    • + {/each} +
    +
    +
    + + diff --git a/test/e2e/app.e2e.ts b/test/e2e/app.e2e.ts index 34a93fa..db4f84c 100644 --- a/test/e2e/app.e2e.ts +++ b/test/e2e/app.e2e.ts @@ -1109,6 +1109,90 @@ try { await page.keyboard.press('Escape'); console.log('ok: registered on the server'); + // ─── The Rooms interface: the same app arranged for phones ───────────────── + { + const before = page.viewportSize()!; + await page.getByTitle('Settings').click(); + await page.getByRole('tab', { name: 'Appearance' }).click(); + await page.getByRole('radio', { name: 'Rooms' }).click(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.setViewportSize({ width: 390, height: 800 }); + const nav = page.getByRole('navigation', { name: 'Screens' }); + const rooms = page.getByRole('region', { name: 'Rooms' }); + await rooms.getByRole('heading', { name: 'Test Server' }).waitFor(); + assert.equal(await page.locator('.sidebar').count(), 0, 'the standard interface is gone'); + // People show in their room without opening it; the dock names the room you are in + await rooms.locator('.room.here .person', { hasText: 'bob' }).waitFor(); + await page.locator('.dock', { hasText: 'Root' }).waitFor(); + if (admin) { + await rooms.getByRole('button', { name: 'Join Games' }).click(); + await until2('alice in Games', () => bob.channels.get(userState(newName)!.channelId)?.name === 'Games'); + await page.locator('.dock', { hasText: 'Games' }).waitFor(); + await rooms.getByRole('button', { name: 'Join Root' }).click(); + await until2('alice back in Root', () => userState(newName)!.channelId === 0); + await page.locator('.dock', { hasText: 'Root' }).waitFor(); + } + console.log('ok: rooms interface lists rooms with their people and joins from the list'); + + // Mute from the dock + const wasMuted = !!userState(newName)!.selfMute; + await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Unmute' : 'Mute', exact: true }).click(); + await until2('mute toggled from the dock', () => !!userState(newName)!.selfMute === !wasMuted); + await page.locator('.dock').getByRole('button', { name: wasMuted ? 'Mute' : 'Unmute', exact: true }).click(); + await until2('mute restored', () => !!userState(newName)!.selfMute === wasMuted); + + // Chat is a screen of its own + await nav.getByRole('button', { name: 'Chat' }).click(); + const roomsMsg = new Promise(res => bob.on('text', m => { if (m.html === 'hello from rooms') res(); })); + await page.locator('.chat2 .composer [contenteditable]').fill('hello from rooms'); + await page.locator('.chat2 .composer [contenteditable]').press('Enter'); + await within(roomsMsg, 'message from the rooms interface'); + await page.locator('.chat2 .msg', { hasText: 'hello from rooms' }).waitFor(); + await page.getByRole('button', { name: 'Back to rooms' }).click(); + await rooms.waitFor(); + + // The voice screen shows the room as tiles with the large controls + await nav.getByRole('button', { name: 'Voice' }).click(); + const voiceScreen = page.getByRole('region', { name: 'Voice', exact: true }); + await voiceScreen.locator('.tile', { hasText: 'bob' }).waitFor(); + await voiceScreen.getByRole('button', { name: /^Share your screen or camera$/ }).waitFor(); + assert.equal(await page.locator('.dock').count(), 0, 'no dock on the voice screen'); + + // People: a profile opens over the screen, a message opens the chat + await nav.getByRole('button', { name: 'People' }).click(); + const people = page.getByRole('region', { name: 'People' }); + await people.locator('.entry', { hasText: 'bob' }).locator('.main').click(); + await page.locator('.sheet h3', { hasText: 'bob' }).waitFor(); + await page.keyboard.press('Escape'); + await page.locator('.sheet').waitFor({ state: 'detached' }); + await people.getByRole('button', { name: 'Message bob' }).click(); + await page.locator('.chat2 h1', { hasText: 'bob' }).waitFor(); + if (shots) await page.screenshot({ path: path.join(shots, 'rooms-phone.png') }); + + // Wide windows keep the rooms next to the screen + await page.setViewportSize({ width: 1280, height: 800 }); + await rooms.waitFor(); + await page.locator('.chat2').waitFor(); + assert.equal(await nav.getByRole('button', { name: 'Rooms' }).count(), 0); + if (notesId != null) { + await rooms.locator('.entry', { hasText: 'notes' }).locator('.main').click(); + await page.locator('.chat2 .msg', { hasText: 'text hello' }).waitFor(); + } + if (shots) await page.screenshot({ path: path.join(shots, 'rooms-wide.png') }); + console.log('ok: rooms interface: dock, chat, voice screen, people, profile sheet, wide layout'); + + // Back to the standard interface from the You screen + await nav.getByRole('button', { name: 'You' }).click(); + await page.getByRole('region', { name: 'You' }).getByRole('button', { name: /^Appearance/ }).click(); + await page.getByRole('radio', { name: 'Standard' }).click(); + await page.getByRole('button', { name: 'Done', exact: true }).click(); + await page.locator('.sidebar').waitFor(); + await page.setViewportSize(before); + await page.locator('.channel .name', { hasText: 'Root' }).dblclick(); + await page.locator('.chat h1', { hasText: 'Root' }).waitFor(); + console.log('ok: back to the standard interface from the You screen'); + } + // Kicked: the reason is shown with a Reconnect button if (admin) { const me = [...admin.users.values()].find(u => u.name === newName)!; -- 2.55.0 From 165e8ef54fedc15c138608bee60de7406e4fdc73 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 2 Oct 2026 20:57:47 +0200 Subject: [PATCH 3/4] Give the microphone back while muted, for Bluetooth headsets An open microphone puts a Bluetooth headset into its telephone mode. With this setting (on by default on phones) the microphone is released while muted and taken again on unmute. Co-Authored-By: Claude Opus 5.5 --- src/lib/audio/voice.svelte.ts | 50 ++++++++++++++++++++++++++++++++--- src/ui/VoiceSettings.svelte | 2 ++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/lib/audio/voice.svelte.ts b/src/lib/audio/voice.svelte.ts index a010e94..77016a1 100644 --- a/src/lib/audio/voice.svelte.ts +++ b/src/lib/audio/voice.svelte.ts @@ -24,11 +24,17 @@ export interface VoiceSettings { frameMs: 10 | 20 | 40 | 60; jitterMs: number; forceTcp: boolean; // send voice through the TCP connection even when UDP works + // Give the microphone back while muted. An open microphone puts a Bluetooth headset into its + // telephone mode, which also makes what you hear sound like a phone call. + releaseMicMuted: boolean; // Per-user local volume and mute, keyed by certificate hash (or name without one) userVolumes: Record; localMutes: Record; } +// Phones and tablets, where Bluetooth headsets are the usual case +const handheld = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/.test(navigator.userAgent); + const defaults: VoiceSettings = { inputDevice: 'default', outputDevice: 'default', @@ -45,6 +51,7 @@ const defaults: VoiceSettings = { frameMs: 20, jitterMs: 60, forceTcp: false, + releaseMicMuted: handheld, userVolumes: {}, localMutes: {} }; @@ -63,6 +70,7 @@ class VoiceEngine { inputDevices = $state([]); outputDevices = $state([]); running = $state(false); + micOpen = $state(false); // the microphone is held right now error = $state(''); level = $state(-100); // current input level, dBFS transmitting = $state(false); @@ -122,7 +130,8 @@ class VoiceEngine { if ('jitterMs' in patch) this.playback?.port.postMessage({ jitterMs: this.settings.jitterMs }); if ('outputDevice' in patch) this.applyOutputDevice(); if ('bitrate' in patch || 'frameMs' in patch) this.configureEncoder(); - if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.restartInput(); + if (['inputDevice', 'echoCancellation', 'noiseSuppression', 'autoGainControl'].some(k => k in patch)) this.syncInput(true); + else if ('releaseMicMuted' in patch) this.syncInput(); } // ─── Devices ──────────────────────────────────────────────────────────────── @@ -174,7 +183,7 @@ class VoiceEngine { this.inputGainNode.connect(this.capture); await this.applyOutputDevice(); - await this.restartInput(); + await this.syncInput(); this.configureEncoder(); await ctx.resume(); this.levelTimer = setInterval(() => { this.level = this.levelRaw; }, 50); @@ -192,6 +201,7 @@ class VoiceEngine { this.levelTimer = null; this.stream?.getTracks().forEach(t => t.stop()); this.stream = null; + this.micOpen = false; for (const d of this.decoders.values()) { try { d.decoder.close(); } catch { /* closed */ } } this.decoders.clear(); try { this.encoder?.close(); } catch { /* closed */ } @@ -205,13 +215,39 @@ class VoiceEngine { this.level = -100; } + // The microphone is not needed while we cannot talk anyway (muted or deafened, by us or the server) + private micWanted(): boolean { + if (!this.settings.releaseMicMuted || this.testing) return true; + const self = this.client?.self; + return !self || !(self.selfMute || self.mute || self.suppress || self.selfDeaf || self.deaf); + } + + // Opens or gives back the microphone as the mute state asks. restart: open it anew (settings changed). + private async syncInput(restart = false): Promise { + if (!this.ctx) return; + const wanted = this.micWanted(); + if (wanted && (restart || !this.stream)) return this.restartInput(); + if (!wanted && this.stream) { + this.endTransmission(); + this.source?.disconnect(); + this.source = null; + this.stream.getTracks().forEach(t => t.stop()); + this.stream = null; + this.micOpen = false; + this.levelRaw = -100; + } + } + private async restartInput(): Promise { if (!this.ctx || !this.inputGainNode) return; this.source?.disconnect(); this.stream?.getTracks().forEach(t => t.stop()); + this.stream = null; + this.micOpen = false; const s = this.settings; + let stream: MediaStream; try { - this.stream = await navigator.mediaDevices.getUserMedia({ + stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: s.inputDevice && s.inputDevice !== 'default' ? { exact: s.inputDevice } : undefined, echoCancellation: s.echoCancellation, @@ -225,6 +261,10 @@ class VoiceEngine { this.error = `Microphone unavailable: ${(e as Error).message}`; return; } + // Muted again, or opened a second time, while the browser was getting the microphone + if (!this.ctx || this.stream || !this.micWanted()) return stream.getTracks().forEach(t => t.stop()); + this.stream = stream; + this.micOpen = true; this.error = ''; this.source = this.ctx.createMediaStreamSource(this.stream); this.source.connect(this.inputGainNode); @@ -249,7 +289,8 @@ class VoiceEngine { this.stats = { sent: 0, received: 0, decoded: 0, lost: 0 }; this.unsubscribe = [ client.on('voice', raw => { if (this.client === client) this.receive(raw); }), - client.on('userRemove', u => this.dropSession(u.session)) + client.on('userRemove', u => this.dropSession(u.session)), + client.on('user', u => { if (this.client === client && u.session === client.session) this.syncInput(); }) ]; this.limitBitrate(); this.start(); @@ -490,6 +531,7 @@ class VoiceEngine { setTesting(on: boolean): void { this.testing = on; + this.syncInput(); if (on) this.start(); else { this.playback?.port.postMessage({ drop: -1 }); diff --git a/src/ui/VoiceSettings.svelte b/src/ui/VoiceSettings.svelte index aae0f77..e9b2a29 100644 --- a/src/ui/VoiceSettings.svelte +++ b/src/ui/VoiceSettings.svelte @@ -114,6 +114,8 @@

    Processing

    + +

    For Bluetooth headsets: with the microphone open, a headset switches to its telephone mode and everything sounds like a phone call. Muted, you then hear in full quality. To talk in full quality too, choose the phone's own microphone above instead of the headset's. No reminder when you speak while muted.{#if s.releaseMicMuted && voice.running} {voice.micOpen ? 'The microphone is open now.' : 'The microphone is given back now.'}{/if}

    -- 2.55.0 From 2883c6196527eb1c7fd20cb56b4a99339370bbaa Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 2 Oct 2026 20:57:47 +0200 Subject: [PATCH 4/4] 100 color schemes, a plainer README Six more schemes by hand and 89 generated from a hue and an accent (npm run themes), with a readability test over all of them and a search box in the picker. The README describes the software plainly and covers text channels, the Rooms interface and the new settings. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + CLAUDE.md | 1 + README.md | 140 +-- package.json | 1 + scripts/gen-themes.mjs | 170 +++ src/lib/settings.svelte.ts | 23 +- src/lib/themes-generated.ts | 92 ++ src/main.ts | 1 + src/themes-generated.css | 1959 ++++++++++++++++++++++++++++++ src/themes.css | 132 ++ src/ui/AppearanceSettings.svelte | 23 +- test/e2e/app.e2e.ts | 53 +- test/themes.test.ts | 44 + 13 files changed, 2556 insertions(+), 85 deletions(-) create mode 100644 scripts/gen-themes.mjs create mode 100644 src/lib/themes-generated.ts create mode 100644 src/themes-generated.css create mode 100644 test/themes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 039cc8f..aad7a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to mumh5. Versions follow the `version` in `package.json`. ### Added +- 95 more color schemes, 100 in all: six made by hand (Forest, Ember, Ocean, Plum, Paper, High contrast) and 89 generated from a hue and an accent (`npm run themes`), 64 dark and 25 light. The picker can be narrowed by name and by dark or light. +- Voice setting "Give the microphone back while muted", on by default on phones and tablets: a Bluetooth headset then stays out of its telephone mode while you are muted, so what you hear keeps its full quality. - A second interface, "Rooms" (Settings, Appearance, Interface), made for phones first: one screen at a time with a bar for Rooms, Chat, Voice, People and You. Rooms are a list with the people inside shown inline and a Join button; your room and a mute and deafen dock stay in reach; the voice screen shows the room as tiles with large controls. Wide windows keep the rooms next to the chosen screen. It uses the same dialogs and menus as the standard interface. Not in it yet: moving channels and people by dragging. - Text channels (mumh5 only): a channel marked as a text channel in its dialog is read and written without joining it, from any voice channel. Messages travel between mumh5 clients; other Mumble clients see an ordinary channel and none of the messages. The server keeps nothing: each client keeps the last 200 messages per channel and hands the last 50 to people who connect later. Everyone on the server who uses mumh5 can read them. - Channels can be dragged in front of or behind another channel, not only into one. A line shows where the channel will land, the dragged channel is dimmed, the people under a channel count as part of its drop target, and resting on a closed channel opens it. diff --git a/CLAUDE.md b/CLAUDE.md index ef120b4..2922ac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,7 @@ npm run dev:web # browser build with hot reload: starts the proxy (any se npm run proxy # run the proxy from source (MUMH5_SERVERS=host:port required) npm run test:e2e:web # drives the browser build through the proxy (needs npm run build:web first) npm run test:e2e:share # screen sharing between two instances of the built app +npm run themes # regenerate src/themes-generated.css and src/lib/themes-generated.ts (scripts/gen-themes.mjs) npm run proto # regenerate src/core/mumble-pb.js and mumble-udp-pb.js from proto/ ``` diff --git a/README.md b/README.md index 6a9fe80..d9f0987 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,92 @@ # mumh5 -**Mumble, the way it should feel in 2026.** +mumh5 is a client for [Mumble](https://www.mumble.info/), the open source voice chat. It runs on Windows, macOS and Linux, and in a browser through a small proxy you host yourself. -mumh5 is a modern client for [Mumble](https://www.mumble.info/), the free, open source, low-latency voice chat. It gives Mumble the kind of interface people know from Discord: servers on the left, channels and people in the middle, chat that can do more than text. Underneath, it's plain Mumble, so it connects to **any existing Mumble server** without changing anything on the server side. - -Your community keeps its own server, its own rules and its own data. You get an app that's nice to use. +It connects to any Mumble server as it is. Nothing has to be installed or changed on the server, and people on the regular Mumble client see and hear you like anyone else. ![mumh5 main window: channel tree, chat with a YouTube card, member list](docs/screenshots/main.png) --- -## Why mumh5 +## What it does -Mumble has the best foundation of any voice chat: tiny latency, strong encryption, self-hosting, no accounts, no ads, no tracking. What held it back was the desktop client, which still looks and behaves like 2010. +- **Voice**: Opus over encrypted UDP, with voice activity, push to talk or always on. +- **Chat**: a separate chat per channel and person, with files, pictures, formatting and link previews. +- **Several servers at once**: text on all of them, voice on the one where you joined a channel. +- **Screen and camera sharing** between mumh5 users in a channel. +- **Text channels** between mumh5 users: read and written without joining them. +- **Channel management**: create, edit, move and link channels, and edit permissions and groups. +- **Two interfaces**: Standard (channel tree, chat and members side by side) and Rooms (one screen at a time, made for phones). +- **100 color schemes**, from Dark and Light to Windows 95. -mumh5 keeps the foundation and replaces the experience. +Some features only work between mumh5 users, because Mumble itself has no such thing: screen and camera sharing, text channels, and inline players for shared files. People on other Mumble clients are not disturbed by them; they see a normal channel, a normal link, or nothing. -- **Works with the servers you already use.** No plugins, no server changes, no migration. Friends on the regular Mumble client see you and hear you like anyone else. -- **Feels familiar.** A server rail, a channel tree, a real chat with message grouping, profiles, right-click menus everywhere. Anyone who has used Discord knows where things are. -- **Chat that is actually useful.** Drop in files, paste screenshots, share videos and YouTube links. Formatting works, links work, and everything stays compatible with the desktop client. -- **Several servers at once.** Stay in the text chat of every server you like, with unread badges, while voice runs where you actually are. -- **Private by default.** Opening a chat never contacts servers you didn't choose. No remote images or trackers, and YouTube links stay plain links unless you turn on the click-to-play player. -- **Runs everywhere.** Windows, macOS and Linux, with a layout that also works on narrow windows and small screens. +### Not in mumh5 yet -### mumh5 compared to the Mumble desktop client - -| | Mumble desktop | mumh5 | -|---|---|---| -| Interface | Qt widgets, dense dialogs | Modern layout (server rail, channel tree, chat, side panels), or the classic and stacked layouts of the desktop client | -| Several servers at the same time | One connection | Many; voice on one, text on all, unread badges | -| Sharing files | Images only, pasted inline and heavily limited | Any allowed file through your own [f0ckm](#file-sharing-with-f0ckm) host, inline players for images, video and audio. Images still work without one | -| YouTube links | Plain links | Plain links by default; optional click-to-play player (privacy mode) and thumbnails | -| Link previews | None | Title, description and image, fetched by your upload host so linked sites never see your IP | -| Chat per channel | One shared log for everything | Separate chats per channel and person, plus a side chat for any channel next to your own | -| Descriptions | Rich text editor | Rich text editor with pictures, and a live size counter against the server limit | -| Identities | One certificate per installation | Several identities, a different one per server if you like; imports your desktop certificate | -| Server certificate trust | Remembered after a warning dialog | Pinned on first use, green header for CA-signed servers, full chain viewer | -| Fast actions in a row | The server silently drops them past its rate limit | Paced to the server's limit, nothing gets lost | -| Right-click on mute or deafen | Nothing | Quick device and volume options | -| Look | One Qt style (plus skins) | Five built-in color schemes, including Windows 95 | -| Channel and permission editing | Dialogs, drag and drop | Same power: create, edit, link, drag and drop, rule and group editor | - -**Where the desktop client is still ahead, today:** system-wide push-to-talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. Push-to-talk and whisper are on the roadmap below. +System-wide push to talk, whisper and shout, positional audio, the in-game overlay, recording, and the ban list and registered-user editors. The Mumble desktop client has these. --- ## Features ### Voice -- Opus voice with voice activity detection, push to talk or always-on -- Live level meter with an adjustable threshold, so you set it once and forget it +- Voice activity detection with a live level meter and an adjustable threshold, push to talk, or always on - Noise suppression, echo cancellation and automatic gain control -- Microphone and output device selection, input and output volume +- Microphone and output selection, input and output volume - Per-person volume (0 to 300%) and "mute for me" -- Speaking indicators in the channel tree, member list and your own panel -- On small windows, your voice channel appears as tiles above the chat, lighting up as people talk, with mute and deafen at hand -- Low-latency voice over encrypted UDP (Mumble's OCB2-AES128), with automatic fallback to the TCP connection and a TCP-only switch -- Bitrate automatically limited to what the server allows -- Right-click the mute or deafen button for quick device and volume options -- Hear-yourself microphone test +- Speaking indicators in the channel tree, the member list and your own panel +- Encrypted UDP (Mumble's OCB2-AES128), with fallback to the TCP connection and a TCP-only switch +- Bitrate limited to what the server allows +- Right-click mute or deafen for quick device and volume options +- Microphone test that plays your voice back to you +- Optional: give the microphone back while muted, so a Bluetooth headset stays out of its telephone mode. On by default on phones; not yet tried on a real phone ![Voice settings](docs/screenshots/voice-settings.png) ### Channels and people - Channel tree that starts collapsed; channels with people in them open up -- Single click previews a channel's description, double click joins +- Single click shows a channel's description, double click joins - Side chat: write in any channel without leaving yours -- Profiles with rich descriptions (formatting, lists, links, pictures), local nicknames, registration status -- Change your name (mumh5 reconnects for you) and register on a server with one click -- Connection information: ping, packet loss, client version and, for admins, the user's certificate chain +- Profiles with formatted descriptions, local nicknames and registration status +- Change your name (mumh5 reconnects for you) and register on a server +- Connection information: ping, packet loss, client version and, for admins, the certificate chain - Moderation from the right-click menu: move, server mute and deafen, priority speaker, kick, ban, register -- Channel management: create, edit, delete, link, and drag and drop channels and people -- Full permission editor: rules per channel with inheritance, groups and members, like the desktop ACL editor -- If you get kicked, you see who kicked you and why, with a Reconnect button +- Create, edit, delete and link channels. Drag a channel into another, or in front of or behind it; drag people between channels +- Permission editor: rules per channel with inheritance, groups and members +- If you are kicked, you see who did it and why, with a Reconnect button ![Profile with a formatted description](docs/screenshots/profile.png) -### Screen sharing - -- Camera rounds: everyone who turns their camera on appears in the tiles at once, like a video meeting; one click joins or leaves the round, and a click on a tile enlarges that camera -- Share a screen, a window or a camera with the mumh5 users in your channel. A dialog lets you choose what to show, with a live preview, which sound goes with it and the quality, before anything is sent -- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers -- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. Viewers set the stream's volume -- No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams -- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy, and the proxy's relay when no direct connection is possible (mobile networks, Vanadium); in the desktop app you choose a STUN server in Settings, Voice (Google, Cloudflare, or any address), and can enter the address of a mumh5 web version as a relay, which then takes over by itself when no direct connection comes up. Nothing is contacted unless you set it -- Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested - ### Chat - Messages grouped by author, direct messages, unread badges -- File sharing: drag and drop, paste, or the attach button, with upload progress -- Inline images, video and audio players for shared media +- Files by drag and drop, paste or the attach button, with upload progress (needs an [upload host](#file-sharing-with-f0ckm); pictures work without one) +- Inline pictures, video and audio for shared files - YouTube links as click-to-play players (off by default) -- Link previews with title, description and image; fetched by your upload host by default, so posting a link cannot reveal who reads it -- Incoming HTML is sanitized; remote images from unknown hosts are never loaded -- Messages stay compatible with the desktop client, including picture previews +- Link previews with title, description and picture +- Incoming HTML is sanitized, and pictures from unknown hosts are never loaded +- Messages stay readable in the Mumble desktop client -### Color schemes -Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme with beveled buttons, navy title bars and the teal desktop. +### Text channels +- A channel marked as a text channel is read and written without joining it, from any voice channel +- It is a normal Mumble channel on the server, created and removed with the usual channel permissions +- The server keeps no messages. Each client keeps the last 200 per channel and passes the last 50 to people who connect later +- Everyone on the server who uses mumh5 can read them; there are no private text channels yet +- Needs a server from version 1.4 that allows HTML + +### Screen and camera sharing +- Share a screen, a window or a camera with the mumh5 users in your channel. A dialog shows a preview and lets you choose the sound and the quality first +- Cameras appear in the voice tiles, several at once; a click enlarges one +- A screen opens large, with the people as a strip of tiles below, and can move to its own window +- Sound: on Linux one program or everything except mumh5 (through PipeWire); on Windows the whole system; in a browser what the browser offers +- The stream goes directly between the two clients (WebRTC), up to 8 viewers per stream. Only the setup messages pass through the Mumble server +- Sharer and viewer see each other's IP address; mumh5 says so before the first use +- Across the internet both sides need a STUN server. The browser version uses its proxy's, with a relay when no direct connection works. In the desktop app you set one in Settings, Voice. Nothing is contacted unless you set it +- Tested between two instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested + +### Interfaces and look +- Standard interface in three layouts: channels left and members right, chat left and channels right, or channels on top +- Rooms interface: rooms as a list with the people inside, a voice screen, a people screen, and mute and deafen always in reach. New and not complete: channels and people cannot be moved by dragging there yet +- 100 color schemes with a search box. Eleven are made by hand, the rest are generated from a hue and an accent | Windows 95 | Light | Frost | Midnight | |---|---|---|---| @@ -104,15 +94,15 @@ Dark, Light, Midnight (true black), Frost, and a faithful **Windows 95** theme w ### Sounds - Notification sounds for joins, leaves, messages, mute, push to talk, kicks and more -- Replace any sound with your own file, or load a whole sound pack at once (matched by file name) +- Replace any sound with your own file, or load a sound pack at once (matched by file name) ### Servers and identity -- Server rail with custom icons, drag-and-drop ordering, connection status and unread counts -- Public server browser with live user counts and ping, like the desktop client -- Several servers connected at once; voice follows the channel you join -- Identity setup wizard: create a new certificate, import a `.p12`, or reuse your desktop Mumble identity -- Password-protected backups of your identity -- Trust on first use for server certificates, a green header for CA-signed ones, and a certificate viewer +- Server list with custom icons, your own order, connection status and unread counts +- Public server browser with user counts and ping (desktop app) +- Identity setup: create a certificate, import a `.p12`, or reuse the one from the Mumble desktop client +- Several identities, and a different one per server if you like +- Password-protected backups of an identity +- Server certificates are pinned on first use; a certificate viewer shows the chain - Tray icon that shows whether you are talking, muted or deafened

    mumh5 in a narrow window

    @@ -323,11 +313,9 @@ test/ unit, server and end-to-end tests ## AI disclosure and credits -mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code. +mumh5 is built by **Kibi** together with **Claude**, Anthropic's AI model, working in Claude Code. Kibi decides what mumh5 should be and whether a result is good enough. Claude writes most of the code, the tests and this documentation. Commits written with Claude carry a `Co-Authored-By: Claude` line. -Kibi sets the direction: what mumh5 should be, how it should look and feel, which trade-offs to make, and whether a result is good enough. Claude writes most of the code, the tests and this documentation, and checks each feature against a real Mumble server before calling it done. Commits written with Claude carry a `Co-Authored-By: Claude` line. - -We say this openly because you should know how the software you run was made. Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue. +Features are covered by automated tests, including end-to-end tests against a real Mumble server. If something is wrong, please open an issue. ## License diff --git a/package.json b/package.json index 67a085c..2079515 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:e2e": "node test/e2e/app.e2e.ts", "test:e2e:web": "node test/e2e/web.e2e.ts", "test:e2e:share": "node test/e2e/share.e2e.ts", + "themes": "node scripts/gen-themes.mjs", "proto": "pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/Mumble.proto -o src/core/mumble-pb.js && pbjs -t static-module -w es6 --keep-case --no-delimited --no-service --no-comments --force-number proto/MumbleUDP.proto -o src/core/mumble-udp-pb.js", "screenshots": "node test/e2e/screenshots.ts" }, diff --git a/scripts/gen-themes.mjs b/scripts/gen-themes.mjs new file mode 100644 index 0000000..f310bf5 --- /dev/null +++ b/scripts/gen-themes.mjs @@ -0,0 +1,170 @@ +// Writes the generated color schemes: src/themes-generated.css and src/lib/themes-generated.ts. +// Each scheme is a few numbers (surface hue and saturation, accent hue and saturation); the +// shades are derived from them and nudged until text, links and accent labels are readable. +// npm run themes +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); + +function hex(h, s, l) { + h = ((h % 360) + 360) % 360; s = Math.max(0, Math.min(100, s)) / 100; l = Math.max(0, Math.min(100, l)) / 100; + const k = n => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)))); + return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join(''); +} +function luminance(c) { + const n = parseInt(c.slice(1), 16); + const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map(v => { const x = v / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); }; +// Moves the lightness away from the backgrounds until the color is readable on all of them +function readable(h, s, l, backgrounds, min, dir) { + let c = hex(h, s, l); + while (backgrounds.some(bg => contrast(c, bg) < min) && l > 0 && l < 100) { l += dir; c = hex(h, s, l); } + return c; +} + +// [id, name, note, surface hue, surface saturation, accent hue, accent saturation, options] +const DARK = [ + ['slate', 'Slate', 'Blue-grey, calm', 215, 14, 215, 70], + ['graphite', 'Graphite', 'Neutral grey, no color', 0, 0, 0, 0], + ['charcoal', 'Charcoal', 'Warm grey, amber accents', 30, 5, 36, 85], + ['obsidian', 'Obsidian', 'Near black, violet accents', 260, 10, 265, 85, { base: 3 }], + ['navy', 'Navy', 'Dark blue', 222, 45, 215, 90], + ['cobalt', 'Cobalt', 'Blue, yellow accents', 218, 60, 46, 95, { base: 8, link: 190 }], + ['indigo', 'Indigo', 'Blue-violet', 240, 35, 245, 90], + ['violet', 'Violet', 'Purple', 270, 35, 275, 85], + ['orchid', 'Orchid', 'Soft purple-pink', 295, 28, 300, 70], + ['magenta', 'Magenta', 'Dark pink', 320, 30, 325, 85], + ['crimson', 'Crimson', 'Dark red', 350, 30, 352, 80], + ['ruby', 'Ruby', 'Deep red, orange accents', 340, 40, 12, 85, { base: 5 }], + ['rust', 'Rust', 'Red-brown', 15, 35, 18, 80], + ['copper', 'Copper', 'Brown, muted orange', 25, 30, 28, 65], + ['amber', 'Amber', 'Dark yellow-brown', 38, 30, 42, 95], + ['olive', 'Olive', 'Yellow-green', 70, 22, 75, 60], + ['moss', 'Moss', 'Muted green', 95, 22, 100, 50], + ['emerald', 'Emerald', 'Rich green', 150, 35, 152, 70], + ['jade', 'Jade', 'Blue-green', 165, 30, 168, 65], + ['teal', 'Teal', 'Green-blue', 180, 35, 180, 70], + ['lagoon', 'Lagoon', 'Bright turquoise accents', 192, 45, 190, 85], + ['arctic', 'Arctic', 'Lighter cool grey-blue', 200, 25, 195, 75, { base: 9 }], + ['storm', 'Storm', 'Lighter blue-grey', 225, 18, 200, 60, { base: 10 }], + ['dusk', 'Dusk', 'Purple evening, orange accents', 255, 22, 20, 85, { base: 9 }], + ['wine', 'Wine', 'Dark red-purple', 335, 35, 340, 60, { base: 5 }], + ['cocoa', 'Cocoa', 'Brown', 22, 25, 30, 55, { base: 7 }], + ['espresso', 'Espresso', 'Very dark brown', 28, 30, 35, 70, { base: 4 }], + ['terminal', 'Terminal', 'Green on black', 120, 30, 120, 90, { base: 2, textHue: 120, textSat: 85, link: 150 }], + ['phosphor', 'Phosphor', 'Amber on black', 40, 30, 40, 95, { base: 2, textHue: 40, textSat: 90, link: 25 }], + ['neon', 'Neon', 'Black, pink and cyan', 280, 20, 320, 100, { base: 3, link: 185 }], + ['synthwave', 'Synthwave', 'Purple, pink and cyan', 265, 45, 320, 90, { base: 8, link: 185 }], + ['aurora', 'Aurora', 'Dark blue, green accents', 210, 30, 160, 75], + ['abyss', 'Abyss', 'Blue-black, turquoise accents', 230, 50, 195, 90, { base: 3 }], + ['carbon', 'Carbon', 'Black, red accents', 0, 0, 0, 85, { base: 3 }], + ['ash', 'Ash', 'Lighter grey, blue accents', 0, 0, 210, 60, { base: 12 }], + ['steel', 'Steel', 'Grey-blue, orange accents', 210, 10, 30, 80, { base: 11 }], + ['ink', 'Ink', 'Blue-black, yellow accents', 235, 30, 50, 90, { base: 3 }], + ['sapphire', 'Sapphire', 'Strong blue', 228, 55, 228, 95], + ['azure', 'Azure', 'Bright blue accents', 208, 50, 205, 95], + ['peacock', 'Peacock', 'Turquoise, purple accents', 188, 50, 300, 70], + ['pine', 'Pine', 'Dark green, orange accents', 160, 40, 35, 85, { base: 4 }], + ['fern', 'Fern', 'Soft green', 125, 25, 130, 55], + ['lime', 'Lime', 'Yellow-green accents', 85, 30, 82, 85], + ['citrus', 'Citrus', 'Dark yellow, orange accents', 60, 25, 28, 95], + ['sunset', 'Sunset', 'Purple, orange accents', 280, 30, 25, 95], + ['flamingo', 'Flamingo', 'Dark rose, bright pink', 330, 25, 345, 90], + ['cherry', 'Cherry', 'Deep red', 355, 40, 355, 90, { base: 5 }], + ['brick', 'Brick', 'Red-brown, gold accents', 8, 35, 40, 80], + ['terracotta', 'Terracotta', 'Clay, teal accents', 18, 30, 175, 55, { base: 8 }], + ['sandstone', 'Sandstone', 'Lighter warm grey', 35, 18, 20, 70, { base: 10 }], + ['mocha', 'Mocha', 'Brown, lilac accents', 20, 18, 280, 50, { base: 9 }], + ['walnut', 'Walnut', 'Dark brown, gold accents', 25, 35, 45, 80, { base: 5 }], + ['bronze', 'Bronze', 'Yellow-brown', 40, 35, 38, 70], + ['brass', 'Brass', 'Dark gold', 48, 30, 50, 85], + ['mulberry', 'Mulberry', 'Dark purple-pink', 310, 35, 310, 65, { base: 5 }], + ['grape', 'Grape', 'Strong purple', 280, 40, 280, 90], + ['iris', 'Iris', 'Lighter blue-violet', 250, 30, 255, 90, { base: 9 }], + ['twilight', 'Twilight', 'Night blue, pink accents', 245, 35, 330, 75, { base: 5 }], + ['nebula', 'Nebula', 'Purple, turquoise accents', 270, 45, 190, 85, { base: 4 }], + ['cathode', 'Cathode', 'Cyan on black', 185, 30, 185, 90, { base: 2, textHue: 185, textSat: 80, link: 160 }], + ['bubblegum', 'Bubblegum', 'Purple-pink, cyan links', 300, 25, 330, 95, { base: 8, link: 185 }], + ['volcano', 'Volcano', 'Black, lava orange', 10, 30, 20, 100, { base: 3 }], + ['glacier', 'Glacier', 'The lightest dark, cool', 205, 30, 190, 70, { base: 12 }], + ['seaweed', 'Seaweed', 'Dark teal, green accents', 170, 35, 90, 60, { base: 4 }] +]; +const LIGHT = [ + ['snow', 'Snow', 'Cool white', 220, 10, 225, 75], + ['linen', 'Linen', 'Warm white', 40, 30, 25, 60], + ['sky', 'Sky', 'Light blue', 205, 60, 210, 85], + ['mint', 'Mint', 'Light green', 150, 40, 160, 70], + ['lavender', 'Lavender', 'Light purple', 265, 45, 265, 60], + ['blush', 'Blush', 'Light pink', 345, 50, 345, 65], + ['peach', 'Peach', 'Light orange', 25, 65, 18, 75], + ['lemon', 'Lemon', 'Light yellow', 55, 60, 45, 90], + ['sage', 'Sage', 'Grey-green', 100, 20, 110, 35], + ['latte', 'Latte', 'Milky brown', 30, 35, 25, 45], + ['ice', 'Ice', 'Pale turquoise', 190, 45, 195, 80], + ['cloud', 'Cloud', 'Soft blue-grey', 210, 25, 210, 60], + ['pearl', 'Pearl', 'Near white, rose accents', 300, 8, 320, 45], + ['cream', 'Cream', 'Soft yellow', 48, 55, 30, 70], + ['sand', 'Sand', 'Beige', 38, 40, 20, 55], + ['rose', 'Rose', 'Pink-red', 355, 55, 350, 70], + ['coral', 'Coral', 'Light red-orange', 12, 65, 8, 75], + ['apricot', 'Apricot', 'Warm orange', 32, 70, 28, 85], + ['pistachio', 'Pistachio', 'Yellow-green', 95, 35, 110, 50], + ['seafoam', 'Seafoam', 'Green-turquoise', 165, 40, 172, 75], + ['aqua', 'Aqua', 'Turquoise', 182, 50, 186, 85], + ['periwinkle', 'Periwinkle', 'Blue-violet', 232, 55, 235, 70], + ['lilac', 'Lilac', 'Soft purple', 285, 40, 285, 55], + ['petal', 'Petal', 'Purple-pink', 320, 45, 320, 60], + ['fog', 'Fog', 'Grey with a little blue', 200, 8, 200, 25] +]; + +function dark([id, name, note, h, s, a, as, o = {}]) { + const b = o.base ?? 6; + const bg = [b, b + 3, b + 5.5, b + 10].map(l => hex(h, s, l)); + const th = o.textHue ?? h, ts = o.textSat ?? Math.min(s, 20); + const accent = hex(a, as, as ? 62 : 80); + const v = { + '--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': bg[3], + '--bg-hover': hex(h, s, b + 13), '--bg-active': hex(h, s, b + 18), '--line': hex(h, s, b + 11), + '--text': readable(th, ts, o.textSat ? 70 : 91, bg, 7.5, 1), + '--text-dim': readable(th, o.textSat ? ts * 0.7 : Math.min(s, 15), o.textSat ? 50 : 66, bg, 4.6, 1), + '--text-faint': hex(th, o.textSat ? ts * 0.5 : Math.min(s, 12), 45), + '--accent': accent, '--accent-hover': hex(a, as, as ? 69 : 88), + '--accent-text': contrast('#ffffff', accent) >= contrast('#0b0b0b', accent) ? '#ffffff' : '#0b0b0b', + '--speaking': a > 90 && a < 170 ? '#f2c14e' : '#3bd16f', + '--danger': readable(4, 85, 62, bg, 4.6, 1), '--warn': '#e8b23c', + '--link': readable(o.link ?? (a > 190 && a < 250 ? 185 : 210), 85, 70, bg, 4.6, 1) + }; + return { id, name, note, kind: 'dark', v }; +} + +function light([id, name, note, h, s, a, as]) { + const bg = [hex(h, s, 89), hex(h, s, 94.5), hex(h, s, 98)]; + const accent = readable(a, as, 42, ['#ffffff'], 4.6, -1); + const v = { + '--bg-0': bg[0], '--bg-1': bg[1], '--bg-2': bg[2], '--bg-3': hex(h, s, 91), + '--bg-hover': hex(h, s, 87), '--bg-active': hex(h, s, 82), '--line': hex(h, Math.min(s, 30), 82), + '--text': readable(h, 20, 13, bg, 7.5, -1), + '--text-dim': readable(h, 12, 34, bg, 4.6, -1), + '--text-faint': hex(h, 10, 52), + '--accent': accent, '--accent-hover': readable(a, as, 34, ['#ffffff'], 6, -1), '--accent-text': '#ffffff', + '--speaking': readable(140, 65, 34, bg, 3.2, -1), '--danger': readable(4, 70, 44, bg, 4.6, -1), '--warn': readable(38, 85, 36, bg, 3.2, -1), + '--link': readable(215, 75, 40, bg, 4.6, -1) + }; + return { id, name, note, kind: 'light', v }; +} + +const themes = [...DARK.map(dark), ...LIGHT.map(light)]; + +const css = '/* Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand. */\n\n' + themes.map(t => + `:root[data-theme="${t.id}"], [data-theme-preview="${t.id}"] {\n --rail: var(--bg-0);\n${Object.entries(t.v).map(([k, c]) => ` ${k}: ${c};`).join('\n')}\n color-scheme: ${t.kind};\n}\n`).join('\n'); +writeFileSync(path.join(root, 'src/themes-generated.css'), css); + +const ts = '// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand.\n' + + 'export const GENERATED_THEMES = [\n' + themes.map(t => ` { id: '${t.id}', name: '${t.name}', note: '${t.note}', light: ${t.kind === 'light'} }`).join(',\n') + '\n] as const;\n'; +writeFileSync(path.join(root, 'src/lib/themes-generated.ts'), ts); +console.log(`${themes.length} schemes written`); diff --git a/src/lib/settings.svelte.ts b/src/lib/settings.svelte.ts index 19c5918..78b8689 100644 --- a/src/lib/settings.svelte.ts +++ b/src/lib/settings.svelte.ts @@ -1,4 +1,5 @@ import { load, loadList, save } from './storage.ts'; +import { GENERATED_THEMES } from './themes-generated.ts'; export interface SavedServer { id: string; @@ -38,14 +39,22 @@ export interface Settings { // Window layouts; classic and stacked follow the Mumble desktop client's layouts of the same name export type Arrangement = 'side' | 'classic' | 'stacked'; -export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'win95'; +export type ThemeId = 'dark' | 'light' | 'midnight' | 'frost' | 'forest' | 'ember' | 'ocean' | 'plum' | 'paper' | 'contrast' | 'win95' | typeof GENERATED_THEMES[number]['id']; -export const THEMES: { id: ThemeId; name: string; note: string }[] = [ - { id: 'dark', name: 'Dark', note: 'The default' }, - { id: 'light', name: 'Light', note: 'For bright rooms' }, - { id: 'midnight', name: 'Midnight', note: 'True black, red accents' }, - { id: 'frost', name: 'Frost', note: 'Cool blue-grey' }, - { id: 'win95', name: 'Windows 95', note: 'Start me up' } +// Hand-made schemes first, then the generated ones (scripts/gen-themes.mjs), dark before light +export const THEMES: { id: ThemeId; name: string; note: string; light: boolean }[] = [ + { id: 'dark', name: 'Dark', note: 'The default', light: false }, + { id: 'light', name: 'Light', note: 'For bright rooms', light: true }, + { id: 'midnight', name: 'Midnight', note: 'True black, red accents', light: false }, + { id: 'frost', name: 'Frost', note: 'Cool blue-grey', light: false }, + { id: 'forest', name: 'Forest', note: 'Deep green, warm light', light: false }, + { id: 'ember', name: 'Ember', note: 'Warm dark, orange accents', light: false }, + { id: 'ocean', name: 'Ocean', note: 'Deep navy, cyan accents', light: false }, + { id: 'plum', name: 'Plum', note: 'Dark purple, pink accents', light: false }, + { id: 'paper', name: 'Paper', note: 'Warm light, easy on the eyes', light: true }, + { id: 'contrast', name: 'High contrast', note: 'Black and white, strong lines', light: false }, + { id: 'win95', name: 'Windows 95', note: 'Start me up', light: true }, + ...GENERATED_THEMES ]; const defaults: Settings = { diff --git a/src/lib/themes-generated.ts b/src/lib/themes-generated.ts new file mode 100644 index 0000000..83bc7eb --- /dev/null +++ b/src/lib/themes-generated.ts @@ -0,0 +1,92 @@ +// Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand. +export const GENERATED_THEMES = [ + { id: 'slate', name: 'Slate', note: 'Blue-grey, calm', light: false }, + { id: 'graphite', name: 'Graphite', note: 'Neutral grey, no color', light: false }, + { id: 'charcoal', name: 'Charcoal', note: 'Warm grey, amber accents', light: false }, + { id: 'obsidian', name: 'Obsidian', note: 'Near black, violet accents', light: false }, + { id: 'navy', name: 'Navy', note: 'Dark blue', light: false }, + { id: 'cobalt', name: 'Cobalt', note: 'Blue, yellow accents', light: false }, + { id: 'indigo', name: 'Indigo', note: 'Blue-violet', light: false }, + { id: 'violet', name: 'Violet', note: 'Purple', light: false }, + { id: 'orchid', name: 'Orchid', note: 'Soft purple-pink', light: false }, + { id: 'magenta', name: 'Magenta', note: 'Dark pink', light: false }, + { id: 'crimson', name: 'Crimson', note: 'Dark red', light: false }, + { id: 'ruby', name: 'Ruby', note: 'Deep red, orange accents', light: false }, + { id: 'rust', name: 'Rust', note: 'Red-brown', light: false }, + { id: 'copper', name: 'Copper', note: 'Brown, muted orange', light: false }, + { id: 'amber', name: 'Amber', note: 'Dark yellow-brown', light: false }, + { id: 'olive', name: 'Olive', note: 'Yellow-green', light: false }, + { id: 'moss', name: 'Moss', note: 'Muted green', light: false }, + { id: 'emerald', name: 'Emerald', note: 'Rich green', light: false }, + { id: 'jade', name: 'Jade', note: 'Blue-green', light: false }, + { id: 'teal', name: 'Teal', note: 'Green-blue', light: false }, + { id: 'lagoon', name: 'Lagoon', note: 'Bright turquoise accents', light: false }, + { id: 'arctic', name: 'Arctic', note: 'Lighter cool grey-blue', light: false }, + { id: 'storm', name: 'Storm', note: 'Lighter blue-grey', light: false }, + { id: 'dusk', name: 'Dusk', note: 'Purple evening, orange accents', light: false }, + { id: 'wine', name: 'Wine', note: 'Dark red-purple', light: false }, + { id: 'cocoa', name: 'Cocoa', note: 'Brown', light: false }, + { id: 'espresso', name: 'Espresso', note: 'Very dark brown', light: false }, + { id: 'terminal', name: 'Terminal', note: 'Green on black', light: false }, + { id: 'phosphor', name: 'Phosphor', note: 'Amber on black', light: false }, + { id: 'neon', name: 'Neon', note: 'Black, pink and cyan', light: false }, + { id: 'synthwave', name: 'Synthwave', note: 'Purple, pink and cyan', light: false }, + { id: 'aurora', name: 'Aurora', note: 'Dark blue, green accents', light: false }, + { id: 'abyss', name: 'Abyss', note: 'Blue-black, turquoise accents', light: false }, + { id: 'carbon', name: 'Carbon', note: 'Black, red accents', light: false }, + { id: 'ash', name: 'Ash', note: 'Lighter grey, blue accents', light: false }, + { id: 'steel', name: 'Steel', note: 'Grey-blue, orange accents', light: false }, + { id: 'ink', name: 'Ink', note: 'Blue-black, yellow accents', light: false }, + { id: 'sapphire', name: 'Sapphire', note: 'Strong blue', light: false }, + { id: 'azure', name: 'Azure', note: 'Bright blue accents', light: false }, + { id: 'peacock', name: 'Peacock', note: 'Turquoise, purple accents', light: false }, + { id: 'pine', name: 'Pine', note: 'Dark green, orange accents', light: false }, + { id: 'fern', name: 'Fern', note: 'Soft green', light: false }, + { id: 'lime', name: 'Lime', note: 'Yellow-green accents', light: false }, + { id: 'citrus', name: 'Citrus', note: 'Dark yellow, orange accents', light: false }, + { id: 'sunset', name: 'Sunset', note: 'Purple, orange accents', light: false }, + { id: 'flamingo', name: 'Flamingo', note: 'Dark rose, bright pink', light: false }, + { id: 'cherry', name: 'Cherry', note: 'Deep red', light: false }, + { id: 'brick', name: 'Brick', note: 'Red-brown, gold accents', light: false }, + { id: 'terracotta', name: 'Terracotta', note: 'Clay, teal accents', light: false }, + { id: 'sandstone', name: 'Sandstone', note: 'Lighter warm grey', light: false }, + { id: 'mocha', name: 'Mocha', note: 'Brown, lilac accents', light: false }, + { id: 'walnut', name: 'Walnut', note: 'Dark brown, gold accents', light: false }, + { id: 'bronze', name: 'Bronze', note: 'Yellow-brown', light: false }, + { id: 'brass', name: 'Brass', note: 'Dark gold', light: false }, + { id: 'mulberry', name: 'Mulberry', note: 'Dark purple-pink', light: false }, + { id: 'grape', name: 'Grape', note: 'Strong purple', light: false }, + { id: 'iris', name: 'Iris', note: 'Lighter blue-violet', light: false }, + { id: 'twilight', name: 'Twilight', note: 'Night blue, pink accents', light: false }, + { id: 'nebula', name: 'Nebula', note: 'Purple, turquoise accents', light: false }, + { id: 'cathode', name: 'Cathode', note: 'Cyan on black', light: false }, + { id: 'bubblegum', name: 'Bubblegum', note: 'Purple-pink, cyan links', light: false }, + { id: 'volcano', name: 'Volcano', note: 'Black, lava orange', light: false }, + { id: 'glacier', name: 'Glacier', note: 'The lightest dark, cool', light: false }, + { id: 'seaweed', name: 'Seaweed', note: 'Dark teal, green accents', light: false }, + { id: 'snow', name: 'Snow', note: 'Cool white', light: true }, + { id: 'linen', name: 'Linen', note: 'Warm white', light: true }, + { id: 'sky', name: 'Sky', note: 'Light blue', light: true }, + { id: 'mint', name: 'Mint', note: 'Light green', light: true }, + { id: 'lavender', name: 'Lavender', note: 'Light purple', light: true }, + { id: 'blush', name: 'Blush', note: 'Light pink', light: true }, + { id: 'peach', name: 'Peach', note: 'Light orange', light: true }, + { id: 'lemon', name: 'Lemon', note: 'Light yellow', light: true }, + { id: 'sage', name: 'Sage', note: 'Grey-green', light: true }, + { id: 'latte', name: 'Latte', note: 'Milky brown', light: true }, + { id: 'ice', name: 'Ice', note: 'Pale turquoise', light: true }, + { id: 'cloud', name: 'Cloud', note: 'Soft blue-grey', light: true }, + { id: 'pearl', name: 'Pearl', note: 'Near white, rose accents', light: true }, + { id: 'cream', name: 'Cream', note: 'Soft yellow', light: true }, + { id: 'sand', name: 'Sand', note: 'Beige', light: true }, + { id: 'rose', name: 'Rose', note: 'Pink-red', light: true }, + { id: 'coral', name: 'Coral', note: 'Light red-orange', light: true }, + { id: 'apricot', name: 'Apricot', note: 'Warm orange', light: true }, + { id: 'pistachio', name: 'Pistachio', note: 'Yellow-green', light: true }, + { id: 'seafoam', name: 'Seafoam', note: 'Green-turquoise', light: true }, + { id: 'aqua', name: 'Aqua', note: 'Turquoise', light: true }, + { id: 'periwinkle', name: 'Periwinkle', note: 'Blue-violet', light: true }, + { id: 'lilac', name: 'Lilac', note: 'Soft purple', light: true }, + { id: 'petal', name: 'Petal', note: 'Purple-pink', light: true }, + { id: 'fog', name: 'Fog', note: 'Grey with a little blue', light: true } +] as const; diff --git a/src/main.ts b/src/main.ts index 4d0ac2f..fee4806 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import './app.css'; import App from './App.svelte'; // After the components' styles, so themes override them import './themes.css'; +import './themes-generated.css'; import { startTray } from './lib/tray.svelte.ts'; import { startTextMenu } from './lib/text-menu.ts'; import { startHints } from './lib/hints.ts'; diff --git a/src/themes-generated.css b/src/themes-generated.css new file mode 100644 index 0000000..1c16d90 --- /dev/null +++ b/src/themes-generated.css @@ -0,0 +1,1959 @@ +/* Generated by scripts/gen-themes.mjs (npm run themes). Do not edit by hand. */ + +:root[data-theme="slate"], [data-theme-preview="slate"] { + --rail: var(--bg-0); + --bg-0: #0d0f11; + --bg-1: #14161a; + --bg-2: #191d21; + --bg-3: #23282f; + --bg-hover: #2a2f37; + --bg-active: #353c46; + --line: #252a31; + --text: #e5e8eb; + --text-dim: #9ca6b4; + --text-faint: #657081; + --accent: #5a93e2; + --accent-hover: #79a7e7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f16055; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="graphite"], [data-theme-preview="graphite"] { + --rail: var(--bg-0); + --bg-0: #0f0f0f; + --bg-1: #171717; + --bg-2: #1d1d1d; + --bg-3: #292929; + --bg-hover: #303030; + --bg-active: #3d3d3d; + --line: #2b2b2b; + --text: #e8e8e8; + --text-dim: #a8a8a8; + --text-faint: #737373; + --accent: #cccccc; + --accent-hover: #e0e0e0; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="charcoal"], [data-theme-preview="charcoal"] { + --rail: var(--bg-0); + --bg-0: #100f0f; + --bg-1: #181716; + --bg-2: #1f1d1c; + --bg-3: #2b2927; + --bg-hover: #33302e; + --bg-active: #403d3a; + --line: #2e2b29; + --text: #e9e8e7; + --text-dim: #ada8a4; + --text-faint: #78736d; + --accent: #f0af4c; + --accent-hover: #f3bd6d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="obsidian"], [data-theme-preview="obsidian"] { + --rail: var(--bg-0); + --bg-0: #070708; + --bg-1: #0f0e11; + --bg-2: #151418; + --bg-3: #201e24; + --bg-hover: #27252d; + --bg-active: #34303b; + --line: #232027; + --text: #e7e6ea; + --text-dim: #a5a0b1; + --text-faint: #6f677e; + --accent: #904cf0; + --accent-hover: #a56df3; + --accent-text: #ffffff; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="navy"], [data-theme-preview="navy"] { + --rail: var(--bg-0); + --bg-0: #080d16; + --bg-1: #0d1321; + --bg-2: #10182b; + --bg-3: #16213b; + --bg-hover: #1b2846; + --bg-active: #223259; + --line: #18243f; + --text: #e3e6ed; + --text-dim: #9ba3b5; + --text-faint: #656d81; + --accent: #4790f5; + --accent-hover: #69a4f7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="cobalt"], [data-theme-preview="cobalt"] { + --rail: var(--bg-0); + --bg-0: #081121; + --bg-1: #0b182d; + --bg-2: #0e1d37; + --bg-3: #122749; + --bg-hover: #152d56; + --bg-active: #1b386a; + --line: #13294e; + --text: #e3e7ed; + --text-dim: #9ba5b5; + --text-faint: #656f81; + --accent: #facf42; + --accent-hover: #fbd865; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f16055; + --warn: #e8b23c; + --link: #71def4; + color-scheme: dark; +} + +:root[data-theme="indigo"], [data-theme-preview="indigo"] { + --rail: var(--bg-0); + --bg-0: #0a0a15; + --bg-1: #0f0f1f; + --bg-2: #131328; + --bg-3: #1b1b37; + --bg-hover: #1f1f41; + --bg-active: #282853; + --line: #1c1c3b; + --text: #e3e3ed; + --text-dim: #9b9bb5; + --text-faint: #656581; + --accent: #5547f5; + --accent-hover: #7569f7; + --accent-text: #ffffff; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="violet"], [data-theme-preview="violet"] { + --rail: var(--bg-0); + --bg-0: #0f0a15; + --bg-1: #170f1f; + --bg-2: #1d1328; + --bg-3: #291b37; + --bg-hover: #301f41; + --bg-active: #3d2853; + --line: #2b1c3b; + --text: #e8e3ed; + --text-dim: #a89bb5; + --text-faint: #736581; + --accent: #ac4cf0; + --accent-hover: #bb6df3; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="orchid"], [data-theme-preview="orchid"] { + --rail: var(--bg-0); + --bg-0: #130b14; + --bg-1: #1c111d; + --bg-2: #241526; + --bg-3: #321d34; + --bg-hover: #3c233e; + --bg-active: #4b2c4e; + --line: #351f37; + --text: #ece3ed; + --text-dim: #b39bb5; + --text-faint: #7e6581; + --accent: #e25ae2; + --accent-hover: #e779e7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="magenta"], [data-theme-preview="magenta"] { + --rail: var(--bg-0); + --bg-0: #140b11; + --bg-1: #1e1019; + --bg-2: #261520; + --bg-3: #351d2d; + --bg-hover: #3f2235; + --bg-active: #502b43; + --line: #381e30; + --text: #ede3ea; + --text-dim: #b59bad; + --text-faint: #816577; + --accent: #f04cac; + --accent-hover: #f36dbb; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="crimson"], [data-theme-preview="crimson"] { + --rail: var(--bg-0); + --bg-0: #140b0c; + --bg-1: #1e1012; + --bg-2: #261517; + --bg-3: #351d21; + --bg-hover: #3f2227; + --bg-active: #502b31; + --line: #381e23; + --text: #ede3e5; + --text-dim: #b59ba0; + --text-faint: #81656a; + --accent: #ec5165; + --accent-hover: #ef7182; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="ruby"], [data-theme-preview="ruby"] { + --rail: var(--bg-0); + --bg-0: #12080b; + --bg-1: #1d0c12; + --bg-2: #251017; + --bg-3: #361721; + --bg-hover: #401c28; + --bg-active: #522333; + --line: #391823; + --text: #ede3e7; + --text-dim: #b59ba4; + --text-faint: #81656e; + --accent: #f06d4c; + --accent-hover: #f3886d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="rust"], [data-theme-preview="rust"] { + --rail: var(--bg-0); + --bg-0: #150d0a; + --bg-1: #1f130f; + --bg-2: #281813; + --bg-3: #37221b; + --bg-hover: #41281f; + --bg-active: #533228; + --line: #3b241c; + --text: #ede6e3; + --text-dim: #b5a29b; + --text-faint: #816c65; + --accent: #ec7f51; + --accent-hover: #ef9771; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f16055; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="copper"], [data-theme-preview="copper"] { + --rail: var(--bg-0); + --bg-0: #140f0b; + --bg-1: #1e1610; + --bg-2: #261c15; + --bg-3: #35271d; + --bg-hover: #3f2e22; + --bg-active: #503a2b; + --line: #38291e; + --text: #ede7e3; + --text-dim: #b5a69b; + --text-faint: #817065; + --accent: #dd9a5f; + --accent-hover: #e3ad7d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="amber"], [data-theme-preview="amber"] { + --rail: var(--bg-0); + --bg-0: #14110b; + --bg-1: #1e1910; + --bg-2: #262015; + --bg-3: #352c1d; + --bg-hover: #3f3422; + --bg-active: #50422b; + --line: #382f1e; + --text: #ede9e3; + --text-dim: #b5ac9b; + --text-faint: #817665; + --accent: #fac342; + --accent-hover: #fbce65; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f26d63; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="olive"], [data-theme-preview="olive"] { + --rail: var(--bg-0); + --bg-0: #12130c; + --bg-1: #1a1c12; + --bg-2: #222417; + --bg-3: #2f3220; + --bg-hover: #383b26; + --bg-active: #464b30; + --line: #323522; + --text: #ebede3; + --text-dim: #b1b59b; + --text-faint: #7c8165; + --accent: #bbd864; + --accent-hover: #c8df81; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="moss"], [data-theme-preview="moss"] { + --rail: var(--bg-0); + --bg-0: #0f130c; + --bg-1: #161c12; + --bg-2: #1c2417; + --bg-3: #273220; + --bg-hover: #2f3b26; + --bg-active: #3b4b30; + --line: #2a3522; + --text: #e7ede3; + --text-dim: #a6b59b; + --text-faint: #708165; + --accent: #8ecf6e; + --accent-hover: #a3d788; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="emerald"], [data-theme-preview="emerald"] { + --rail: var(--bg-0); + --bg-0: #0a150f; + --bg-1: #0f1f17; + --bg-2: #13281d; + --bg-3: #1b3729; + --bg-hover: #1f4130; + --bg-active: #28533d; + --line: #1c3b2b; + --text: #e3ede8; + --text-dim: #9bb5a8; + --text-faint: #658173; + --accent: #5ae2a3; + --accent-hover: #79e7b4; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f3766d; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="jade"], [data-theme-preview="jade"] { + --rail: var(--bg-0); + --bg-0: #0b1412; + --bg-1: #101e1a; + --bg-2: #152622; + --bg-3: #1d352f; + --bg-hover: #223f38; + --bg-active: #2b5046; + --line: #1e3832; + --text: #e3edea; + --text-dim: #9bb5af; + --text-faint: #65817a; + --accent: #5fddc4; + --accent-hover: #7de3cf; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="teal"], [data-theme-preview="teal"] { + --rail: var(--bg-0); + --bg-0: #0a1515; + --bg-1: #0f1f1f; + --bg-2: #132828; + --bg-3: #1b3737; + --bg-hover: #1f4141; + --bg-active: #285353; + --line: #1c3b3b; + --text: #e3eded; + --text-dim: #9bb5b5; + --text-faint: #658181; + --accent: #5ae2e2; + --accent-hover: #79e7e7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f3766d; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="lagoon"], [data-theme-preview="lagoon"] { + --rail: var(--bg-0); + --bg-0: #081316; + --bg-1: #0d1d21; + --bg-2: #10252b; + --bg-3: #16343b; + --bg-hover: #1b3e46; + --bg-active: #224e59; + --line: #18373f; + --text: #e3ebed; + --text-dim: #9bb0b5; + --text-faint: #657b81; + --accent: #4cd5f0; + --accent-hover: #6dddf3; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="arctic"], [data-theme-preview="arctic"] { + --rail: var(--bg-0); + --bg-0: #11191d; + --bg-1: #172126; + --bg-2: #1c282e; + --bg-3: #24343d; + --bg-hover: #2a3d46; + --bg-active: #344b56; + --line: #263740; + --text: #e3eaed; + --text-dim: #9badb5; + --text-faint: #657781; + --accent: #55c2e7; + --accent-hover: #75ceeb; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f3766d; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="storm"], [data-theme-preview="storm"] { + --rail: var(--bg-0); + --bg-0: #15171e; + --bg-1: #1b1e27; + --bg-2: #20242f; + --bg-3: #2a2e3c; + --bg-hover: #303545; + --bg-active: #3b4154; + --line: #2c313f; + --text: #e4e6ec; + --text-dim: #9ba2b5; + --text-faint: #656c81; + --accent: #64b1d8; + --accent-hover: #81c0df; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="dusk"], [data-theme-preview="dusk"] { + --rail: var(--bg-0); + --bg-0: #14121c; + --bg-1: #1b1825; + --bg-2: #211d2d; + --bg-3: #2b263b; + --bg-hover: #322c44; + --bg-active: #3d3654; + --line: #2d283e; + --text: #e6e3ed; + --text-dim: #a29bb5; + --text-faint: #6c6581; + --accent: #f0834c; + --accent-hover: #f39a6d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="wine"], [data-theme-preview="wine"] { + --rail: var(--bg-0); + --bg-0: #11080c; + --bg-1: #1c0d13; + --bg-2: #241119; + --bg-3: #341924; + --bg-hover: #3e1e2b; + --bg-active: #4f2637; + --line: #371b26; + --text: #ede3e7; + --text-dim: #b59ba6; + --text-faint: #816570; + --accent: #d8648b; + --accent-hover: #df81a0; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="cocoa"], [data-theme-preview="cocoa"] { + --rail: var(--bg-0); + --bg-0: #16110d; + --bg-1: #201813; + --bg-2: #281e18; + --bg-3: #362821; + --bg-hover: #403026; + --bg-active: #503b30; + --line: #392b22; + --text: #ede7e3; + --text-dim: #b5a59b; + --text-faint: #816f65; + --accent: #d39e69; + --accent-hover: #dbb084; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2685f; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="espresso"], [data-theme-preview="espresso"] { + --rail: var(--bg-0); + --bg-0: #0d0a07; + --bg-1: #17110c; + --bg-2: #1f1811; + --bg-3: #2e2319; + --bg-hover: #382a1e; + --bg-active: #493727; + --line: #32251b; + --text: #ede8e3; + --text-dim: #b5a79b; + --text-faint: #817265; + --accent: #e2a95a; + --accent-hover: #e7b979; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="terminal"], [data-theme-preview="terminal"] { + --rail: var(--bg-0); + --bg-0: #040704; + --bg-1: #091109; + --bg-2: #0d190d; + --bg-3: #152815; + --bg-hover: #1b321b; + --bg-active: #244224; + --line: #172b17; + --text: #71f471; + --text-dim: #34cb34; + --text-faint: #42a442; + --accent: #47f547; + --accent-hover: #69f769; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71f4b3; + color-scheme: dark; +} + +:root[data-theme="phosphor"], [data-theme-preview="phosphor"] { + --rail: var(--bg-0); + --bg-0: #070604; + --bg-1: #110e09; + --bg-2: #19150d; + --bg-3: #282215; + --bg-hover: #322a1b; + --bg-active: #423824; + --line: #2b2417; + --text: #f7c96e; + --text-dim: #d09a2f; + --text-faint: #a6843f; + --accent: #fabd42; + --accent-hover: #fbc965; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #f4a871; + color-scheme: dark; +} + +:root[data-theme="neon"], [data-theme-preview="neon"] { + --rail: var(--bg-0); + --bg-0: #080609; + --bg-1: #100c12; + --bg-2: #17111a; + --bg-3: #231b28; + --bg-hover: #2c2131; + --bg-active: #392b40; + --line: #261d2b; + --text: #eae3ed; + --text-dim: #ad9bb5; + --text-faint: #776581; + --accent: #ff3dbe; + --accent-hover: #ff61ca; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="synthwave"], [data-theme-preview="synthwave"] { + --rail: var(--bg-0); + --bg-0: #130b1e; + --bg-1: #1a0f29; + --bg-2: #201332; + --bg-3: #2a1943; + --bg-hover: #321d4e; + --bg-active: #3d2460; + --line: #2d1b46; + --text: #e7e3ed; + --text-dim: #a69bb5; + --text-faint: #706581; + --accent: #f547bb; + --accent-hover: #f769c8; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="aurora"], [data-theme-preview="aurora"] { + --rail: var(--bg-0); + --bg-0: #0b0f14; + --bg-1: #10171e; + --bg-2: #151d26; + --bg-3: #1d2935; + --bg-hover: #22303f; + --bg-active: #2b3d50; + --line: #1e2b38; + --text: #e3e8ed; + --text-dim: #9ba8b5; + --text-faint: #657381; + --accent: #55e7b6; + --accent-hover: #75ebc4; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f16055; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="abyss"], [data-theme-preview="abyss"] { + --rail: var(--bg-0); + --bg-0: #04050b; + --bg-1: #080a17; + --bg-2: #0b0e21; + --bg-3: #111632; + --bg-hover: #141b3d; + --bg-active: #1b2450; + --line: #121836; + --text: #e3e5ed; + --text-dim: #9ba0b5; + --text-faint: #656a81; + --accent: #47caf5; + --accent-hover: #69d4f7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="carbon"], [data-theme-preview="carbon"] { + --rail: var(--bg-0); + --bg-0: #080808; + --bg-1: #0f0f0f; + --bg-2: #161616; + --bg-3: #212121; + --bg-hover: #292929; + --bg-active: #363636; + --line: #242424; + --text: #e8e8e8; + --text-dim: #a8a8a8; + --text-faint: #737373; + --accent: #f04c4c; + --accent-hover: #f36d6d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="ash"], [data-theme-preview="ash"] { + --rail: var(--bg-0); + --bg-0: #1f1f1f; + --bg-1: #262626; + --bg-2: #2d2d2d; + --bg-3: #383838; + --bg-hover: #404040; + --bg-active: #4d4d4d; + --line: #3b3b3b; + --text: #e8e8e8; + --text-dim: #a8a8a8; + --text-faint: #737373; + --accent: #649ed8; + --accent-hover: #81b0df; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f4837b; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="steel"], [data-theme-preview="steel"] { + --rail: var(--bg-0); + --bg-0: #191c1f; + --bg-1: #202427; + --bg-2: #262a2e; + --bg-3: #30363b; + --bg-hover: #373d43; + --bg-active: #434a51; + --line: #32383e; + --text: #e6e8ea; + --text-dim: #a0a8b1; + --text-faint: #67737e; + --accent: #ec9e51; + --accent-hover: #efb071; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f47f76; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="ink"], [data-theme-preview="ink"] { + --rail: var(--bg-0); + --bg-0: #05060a; + --bg-1: #0b0b14; + --bg-2: #0f101c; + --bg-3: #17192b; + --bg-hover: #1d1f35; + --bg-active: #252846; + --line: #191b2e; + --text: #e3e4ed; + --text-dim: #9b9db5; + --text-faint: #656781; + --accent: #f5d847; + --accent-hover: #f7df69; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="sapphire"], [data-theme-preview="sapphire"] { + --rail: var(--bg-0); + --bg-0: #070a18; + --bg-1: #0a0f24; + --bg-2: #0d142d; + --bg-3: #121b3f; + --bg-hover: #16204b; + --bg-active: #1c295f; + --line: #141d43; + --text: #e3e5ed; + --text-dim: #9ba0b5; + --text-faint: #656a81; + --accent: #4267fa; + --accent-hover: #6583fb; + --accent-text: #ffffff; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="azure"], [data-theme-preview="azure"] { + --rail: var(--bg-0); + --bg-0: #081017; + --bg-1: #0b1822; + --bg-2: #0f1e2c; + --bg-3: #142a3d; + --bg-hover: #183249; + --bg-active: #1f3f5c; + --line: #162d41; + --text: #e3e8ed; + --text-dim: #9ba9b5; + --text-faint: #657481; + --accent: #42adfa; + --accent-hover: #65bcfb; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="peacock"], [data-theme-preview="peacock"] { + --rail: var(--bg-0); + --bg-0: #081517; + --bg-1: #0b1f22; + --bg-2: #0f282c; + --bg-3: #14383d; + --bg-hover: #184249; + --bg-active: #1f545c; + --line: #163b41; + --text: #e3ebed; + --text-dim: #9bb2b5; + --text-faint: #657d81; + --accent: #e25ae2; + --accent-hover: #e779e7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f47a71; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="pine"], [data-theme-preview="pine"] { + --rail: var(--bg-0); + --bg-0: #060e0c; + --bg-1: #0b1914; + --bg-2: #0f221b; + --bg-3: #153228; + --bg-hover: #1a3d31; + --bg-active: #224f40; + --line: #17362b; + --text: #e3edea; + --text-dim: #9bb5ad; + --text-faint: #658177; + --accent: #f0ac4c; + --accent-hover: #f3bb6d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f26d63; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="fern"], [data-theme-preview="fern"] { + --rail: var(--bg-0); + --bg-0: #0b130c; + --bg-1: #111d12; + --bg-2: #162517; + --bg-3: #1f3320; + --bg-hover: #243d26; + --bg-active: #2e4d30; + --line: #213622; + --text: #e3ede4; + --text-dim: #9bb59d; + --text-faint: #658167; + --accent: #69d37b; + --accent-hover: #84db93; + --accent-text: #0b0b0b; + --speaking: #f2c14e; + --danger: #f26d63; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="lime"], [data-theme-preview="lime"] { + --rail: var(--bg-0); + --bg-0: #10140b; + --bg-1: #181e10; + --bg-2: #1f2615; + --bg-3: #2b351d; + --bg-hover: #333f22; + --bg-active: #40502b; + --line: #2e381e; + --text: #e9ede3; + --text-dim: #aab59b; + --text-faint: #758165; + --accent: #b4f04c; + --accent-hover: #c2f36d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f3766d; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="citrus"], [data-theme-preview="citrus"] { + --rail: var(--bg-0); + --bg-0: #13130b; + --bg-1: #1d1d11; + --bg-2: #252516; + --bg-3: #33331f; + --bg-hover: #3d3d24; + --bg-active: #4d4d2e; + --line: #363621; + --text: #edede3; + --text-dim: #b5b59b; + --text-faint: #818165; + --accent: #fa9842; + --accent-hover: #fbab65; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f3766d; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="sunset"], [data-theme-preview="sunset"] { + --rail: var(--bg-0); + --bg-0: #110b14; + --bg-1: #19101e; + --bg-2: #201526; + --bg-3: #2d1d35; + --bg-hover: #35223f; + --bg-active: #432b50; + --line: #301e38; + --text: #eae3ed; + --text-dim: #ad9bb5; + --text-faint: #776581; + --accent: #fa8f42; + --accent-hover: #fba365; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="flamingo"], [data-theme-preview="flamingo"] { + --rail: var(--bg-0); + --bg-0: #130b0f; + --bg-1: #1d1117; + --bg-2: #25161d; + --bg-3: #331f29; + --bg-hover: #3d2430; + --bg-active: #4d2e3d; + --line: #36212b; + --text: #ede3e8; + --text-dim: #b59ba8; + --text-faint: #816573; + --accent: #f54772; + --accent-hover: #f7698c; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="cherry"], [data-theme-preview="cherry"] { + --rail: var(--bg-0); + --bg-0: #120808; + --bg-1: #1d0c0e; + --bg-2: #251012; + --bg-3: #361719; + --bg-hover: #401c1f; + --bg-active: #522327; + --line: #39181b; + --text: #ede3e4; + --text-dim: #b59b9d; + --text-faint: #816567; + --accent: #f54755; + --accent-hover: #f76975; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="brick"], [data-theme-preview="brick"] { + --rail: var(--bg-0); + --bg-0: #150b0a; + --bg-1: #1f110f; + --bg-2: #281613; + --bg-3: #371e1b; + --bg-hover: #41241f; + --bg-active: #532d28; + --line: #3b201c; + --text: #ede5e3; + --text-dim: #b59f9b; + --text-faint: #816965; + --accent: #ecb851; + --accent-hover: #efc571; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f15b50; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="terracotta"], [data-theme-preview="terracotta"] { + --rail: var(--bg-0); + --bg-0: #1b120e; + --bg-1: #241914; + --bg-2: #2d1e18; + --bg-3: #3c2820; + --bg-hover: #462f25; + --bg-active: #563a2e; + --line: #3f2b22; + --text: #ede6e3; + --text-dim: #b5a39b; + --text-faint: #816d65; + --accent: #69d3cb; + --accent-hover: #84dbd4; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f26d63; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="sandstone"], [data-theme-preview="sandstone"] { + --rail: var(--bg-0); + --bg-0: #1e1a15; + --bg-1: #27221b; + --bg-2: #2f2920; + --bg-3: #3c352a; + --bg-hover: #453c30; + --bg-active: #544a3b; + --line: #3f372c; + --text: #ece9e4; + --text-dim: #b5aa9b; + --text-faint: #817565; + --accent: #e2875a; + --accent-hover: #e79e79; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f47f76; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="mocha"], [data-theme-preview="mocha"] { + --rail: var(--bg-0); + --bg-0: #1b1613; + --bg-1: #241d19; + --bg-2: #2c231e; + --bg-3: #392e28; + --bg-hover: #42352e; + --bg-active: #514138; + --line: #3c302a; + --text: #ece7e4; + --text-dim: #b5a49b; + --text-faint: #816e65; + --accent: #ae6ecf; + --accent-hover: #bd88d7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="walnut"], [data-theme-preview="walnut"] { + --rail: var(--bg-0); + --bg-0: #110c08; + --bg-1: #1c130d; + --bg-2: #241911; + --bg-3: #342419; + --bg-hover: #3e2b1e; + --bg-active: #4f3726; + --line: #37261b; + --text: #ede7e3; + --text-dim: #b5a69b; + --text-faint: #817065; + --accent: #ecc551; + --accent-hover: #efd071; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f16055; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="bronze"], [data-theme-preview="bronze"] { + --rail: var(--bg-0); + --bg-0: #15110a; + --bg-1: #1f1a0f; + --bg-2: #282113; + --bg-3: #372e1b; + --bg-hover: #41361f; + --bg-active: #534428; + --line: #3b301c; + --text: #edeae3; + --text-dim: #b5ad9b; + --text-faint: #817765; + --accent: #e2b05a; + --accent-hover: #e7bf79; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="brass"], [data-theme-preview="brass"] { + --rail: var(--bg-0); + --bg-0: #14120b; + --bg-1: #1e1b10; + --bg-2: #262315; + --bg-3: #35301d; + --bg-hover: #3f3922; + --bg-active: #50482b; + --line: #38331e; + --text: #edebe3; + --text-dim: #b5b09b; + --text-faint: #817b65; + --accent: #f0d54c; + --accent-hover: #f3dd6d; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f37168; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="mulberry"], [data-theme-preview="mulberry"] { + --rail: var(--bg-0); + --bg-0: #110810; + --bg-1: #1c0d19; + --bg-2: #241121; + --bg-3: #34192f; + --bg-hover: #3e1e39; + --bg-active: #4f2648; + --line: #371b32; + --text: #ede3eb; + --text-dim: #b59bb1; + --text-faint: #81657c; + --accent: #dd5fc8; + --accent-hover: #e37dd2; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="grape"], [data-theme-preview="grape"] { + --rail: var(--bg-0); + --bg-0: #110915; + --bg-1: #1a0e20; + --bg-2: #211229; + --bg-3: #2e1839; + --bg-hover: #371d44; + --bg-active: #452556; + --line: #311a3d; + --text: #eae3ed; + --text-dim: #ad9bb5; + --text-faint: #776581; + --accent: #bb47f5; + --accent-hover: #c869f7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="iris"], [data-theme-preview="iris"] { + --rail: var(--bg-0); + --bg-0: #12101e; + --bg-1: #181528; + --bg-2: #1e1a30; + --bg-3: #27223f; + --bg-hover: #2d2749; + --bg-active: #37305a; + --line: #292442; + --text: #e5e3ed; + --text-dim: #a09bb5; + --text-faint: #6a6581; + --accent: #7247f5; + --accent-hover: #8c69f7; + --accent-text: #ffffff; + --speaking: #3bd16f; + --danger: #f16055; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="twilight"], [data-theme-preview="twilight"] { + --rail: var(--bg-0); + --bg-0: #090811; + --bg-1: #0e0d1c; + --bg-2: #131124; + --bg-3: #1b1934; + --bg-hover: #211e3e; + --bg-active: #2a264f; + --line: #1d1b37; + --text: #e4e3ed; + --text-dim: #9d9bb5; + --text-faint: #676581; + --accent: #e7559e; + --accent-hover: #eb75b0; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="nebula"], [data-theme-preview="nebula"] { + --rail: var(--bg-0); + --bg-0: #0a060f; + --bg-1: #120a1a; + --bg-2: #180d23; + --bg-3: #241434; + --bg-hover: #2b183f; + --bg-active: #381f51; + --line: #261537; + --text: #e8e3ed; + --text-dim: #a89bb5; + --text-faint: #736581; + --accent: #4cd5f0; + --accent-hover: #6dddf3; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="cathode"], [data-theme-preview="cathode"] { + --rail: var(--bg-0); + --bg-0: #040607; + --bg-1: #091011; + --bg-2: #0d1819; + --bg-3: #152628; + --bg-hover: #1b3032; + --bg-active: #244042; + --line: #17292b; + --text: #75e5f0; + --text-dim: #38bbc7; + --text-faint: #4599a1; + --accent: #47e7f5; + --accent-hover: #69ebf7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71f4c8; + color-scheme: dark; +} + +:root[data-theme="bubblegum"], [data-theme-preview="bubblegum"] { + --rail: var(--bg-0); + --bg-0: #1a0f1a; + --bg-1: #231523; + --bg-2: #2b1a2b; + --bg-3: #392239; + --bg-hover: #432843; + --bg-active: #533253; + --line: #3d243d; + --text: #ede3ed; + --text-dim: #b59bb5; + --text-faint: #816581; + --accent: #fa429e; + --accent-hover: #fb65b0; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2645a; + --warn: #e8b23c; + --link: #71e9f4; + color-scheme: dark; +} + +:root[data-theme="volcano"], [data-theme-preview="volcano"] { + --rail: var(--bg-0); + --bg-0: #0a0605; + --bg-1: #140c0b; + --bg-2: #1c110f; + --bg-3: #2b1b17; + --bg-hover: #35211d; + --bg-active: #462b25; + --line: #2e1d19; + --text: #ede5e3; + --text-dim: #b5a09b; + --text-faint: #816a65; + --accent: #ff7e3d; + --accent-hover: #ff9661; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f0574c; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="glacier"], [data-theme-preview="glacier"] { + --rail: var(--bg-0); + --bg-0: #152028; + --bg-1: #1b2832; + --bg-2: #1f2f3a; + --bg-3: #273b49; + --bg-hover: #2d4353; + --bg-active: #365063; + --line: #293e4c; + --text: #e3e9ed; + --text-dim: #9baab5; + --text-faint: #657581; + --accent: #5acbe2; + --accent-hover: #79d5e7; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f4837b; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="seaweed"], [data-theme-preview="seaweed"] { + --rail: var(--bg-0); + --bg-0: #070e0d; + --bg-1: #0c1816; + --bg-2: #10211e; + --bg-3: #17302c; + --bg-hover: #1c3b35; + --bg-active: #244c45; + --line: #19342f; + --text: #e3edeb; + --text-dim: #9bb5b1; + --text-faint: #65817c; + --accent: #9ed864; + --accent-hover: #b0df81; + --accent-text: #0b0b0b; + --speaking: #3bd16f; + --danger: #f2685f; + --warn: #e8b23c; + --link: #71b3f4; + color-scheme: dark; +} + +:root[data-theme="snow"], [data-theme-preview="snow"] { + --rail: var(--bg-0); + --bg-0: #e0e2e6; + --bg-1: #f0f1f2; + --bg-2: #f9fafa; + --bg-3: #e6e7ea; + --bg-hover: #dbdde1; + --bg-active: #cdd0d6; + --line: #cdd0d6; + --text: #1b1f28; + --text-dim: #4c5361; + --text-faint: #788191; + --accent: #1b43bb; + --accent-hover: #163698; + --accent-text: #ffffff; + --speaking: #1d8b42; + --danger: #ba2b21; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="linen"], [data-theme-preview="linen"] { + --rail: var(--bg-0); + --bg-0: #ebe6db; + --bg-1: #f5f2ed; + --bg-2: #fbfaf8; + --bg-3: #efeae1; + --bg-hover: #e8e1d4; + --bg-active: #dfd6c3; + --line: #dfd6c3; + --text: #28231b; + --text-dim: #615a4c; + --text-faint: #918978; + --accent: #ab602b; + --accent-hover: #8b4e23; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="sky"], [data-theme-preview="sky"] { + --rail: var(--bg-0); + --bg-0: #d2e6f4; + --bg-1: #e9f2f9; + --bg-2: #f7fafd; + --bg-3: #daeaf6; + --bg-hover: #cae1f2; + --bg-active: #b6d6ed; + --line: #c3d3df; + --text: #1b2228; + --text-dim: #4c5861; + --text-faint: #788791; + --accent: #106bc6; + --accent-hover: #0d57a0; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #ba2b21; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="mint"], [data-theme-preview="mint"] { + --rail: var(--bg-0); + --bg-0: #d8eee3; + --bg-1: #ebf7f1; + --bg-2: #f8fcfa; + --bg-3: #dff1e8; + --bg-hover: #d1ebde; + --bg-active: #bfe3d1; + --line: #c3dfd1; + --text: #1b2821; + --text-dim: #4c6157; + --text-faint: #789185; + --accent: #17825e; + --accent-hover: #136c4f; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="lavender"], [data-theme-preview="lavender"] { + --rail: var(--bg-0); + --bg-0: #e1d6f0; + --bg-1: #f0ebf7; + --bg-2: #faf8fc; + --bg-3: #e6def2; + --bg-hover: #dbcfed; + --bg-active: #cebce6; + --line: #cfc3df; + --text: #201b28; + --text-dim: #554c61; + --text-faint: #837891; + --accent: #602bab; + --accent-hover: #4e238b; + --accent-text: #ffffff; + --speaking: #1d8740; + --danger: #b2291f; + --warn: #a06a0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="blush"], [data-theme-preview="blush"] { + --rail: var(--bg-0); + --bg-0: #f1d5dc; + --bg-1: #f8eaed; + --bg-2: #fcf7f9; + --bg-3: #f4dde2; + --bg-hover: #eecdd6; + --bg-active: #e8bac6; + --line: #dfc3ca; + --text: #281b1e; + --text-dim: #614c51; + --text-faint: #91787e; + --accent: #b12548; + --accent-hover: #8f1e3b; + --accent-text: #ffffff; + --speaking: #1d8740; + --danger: #b2291f; + --warn: #a06a0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="peach"], [data-theme-preview="peach"] { + --rail: var(--bg-0); + --bg-0: #f5e0d1; + --bg-1: #faefe8; + --bg-2: #fdf9f7; + --bg-3: #f7e6d9; + --bg-hover: #f3dac8; + --bg-active: #efccb3; + --line: #dfcfc3; + --text: #28201b; + --text-dim: #61554c; + --text-faint: #918378; + --accent: #bb4b1b; + --accent-hover: #983d16; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #ba2b21; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="lemon"], [data-theme-preview="lemon"] { + --rail: var(--bg-0); + --bg-0: #f4f1d2; + --bg-1: #f9f8e9; + --bg-2: #fdfcf7; + --bg-3: #f6f4da; + --bg-hover: #f2eeca; + --bg-active: #ede8b6; + --line: #dfddc3; + --text: #28271b; + --text-dim: #615f4c; + --text-faint: #918f78; + --accent: #916f08; + --accent-hover: #795c06; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="sage"], [data-theme-preview="sage"] { + --rail: var(--bg-0); + --bg-0: #e1e9dd; + --bg-1: #f0f4ee; + --bg-2: #fafbf9; + --bg-3: #e7ede3; + --bg-hover: #dce4d7; + --bg-active: #cedac8; + --line: #cedac8; + --text: #1f281b; + --text-dim: #53614c; + --text-faint: #819178; + --accent: #487f3d; + --accent-hover: #3f6e35; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="latte"], [data-theme-preview="latte"] { + --rail: var(--bg-0); + --bg-0: #ede3d9; + --bg-1: #f6f1ec; + --bg-2: #fcfaf8; + --bg-3: #f0e8e0; + --bg-hover: #e9ded2; + --bg-active: #e1d1c1; + --line: #dfd1c3; + --text: #28211b; + --text-dim: #61574c; + --text-faint: #918578; + --accent: #9b633b; + --accent-hover: #7e5030; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="ice"], [data-theme-preview="ice"] { + --rail: var(--bg-0); + --bg-0: #d6ebf0; + --bg-1: #ebf5f7; + --bg-2: #f8fbfc; + --bg-3: #deeff2; + --bg-hover: #cfe8ed; + --bg-active: #bcdfe6; + --line: #c3dadf; + --text: #1b2628; + --text-dim: #4c5e61; + --text-faint: #788d91; + --accent: #127da1; + --accent-hover: #0f6b8a; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="cloud"], [data-theme-preview="cloud"] { + --rail: var(--bg-0); + --bg-0: #dce3ea; + --bg-1: #edf1f4; + --bg-2: #f9fafb; + --bg-3: #e2e8ee; + --bg-hover: #d6dee6; + --bg-active: #c6d1dd; + --line: #c6d1dd; + --text: #1b2128; + --text-dim: #4c5761; + --text-faint: #788591; + --accent: #2b6bab; + --accent-hover: #23578b; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #ba2b21; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="pearl"], [data-theme-preview="pearl"] { + --rail: var(--bg-0); + --bg-0: #e5e1e5; + --bg-1: #f2f0f2; + --bg-2: #faf9fa; + --bg-3: #eae6ea; + --bg-hover: #e1dbe1; + --bg-active: #d5cdd5; + --line: #d5cdd5; + --text: #281b28; + --text-dim: #614c61; + --text-faint: #917891; + --accent: #9b3b7b; + --accent-hover: #7e3064; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #ba2b21; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="cream"], [data-theme-preview="cream"] { + --rail: var(--bg-0); + --bg-0: #f2ecd4; + --bg-1: #f9f6e9; + --bg-2: #fdfcf7; + --bg-3: #f5f0db; + --bg-hover: #f0e9cc; + --bg-active: #eae0b8; + --line: #dfd9c3; + --text: #28251b; + --text-dim: #615d4c; + --text-faint: #918c78; + --accent: #a9631e; + --accent-hover: #8f5419; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="sand"], [data-theme-preview="sand"] { + --rail: var(--bg-0); + --bg-0: #eee6d8; + --bg-1: #f7f2eb; + --bg-2: #fcfaf8; + --bg-3: #f1eadf; + --bg-hover: #ebe1d1; + --bg-active: #e3d6bf; + --line: #dfd5c3; + --text: #28231b; + --text-dim: #61594c; + --text-faint: #918878; + --accent: #a65730; + --accent-hover: #864727; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="rose"], [data-theme-preview="rose"] { + --rail: var(--bg-0); + --bg-0: #f2d4d6; + --bg-1: #f9e9eb; + --bg-2: #fdf7f8; + --bg-3: #f5dbde; + --bg-hover: #f0cccf; + --bg-active: #eab8bc; + --line: #dfc3c6; + --text: #281b1c; + --text-dim: #614c4e; + --text-faint: #91787a; + --accent: #b62039; + --accent-hover: #931a2e; + --accent-text: #ffffff; + --speaking: #1d8740; + --danger: #b2291f; + --warn: #a06a0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="coral"], [data-theme-preview="coral"] { + --rail: var(--bg-0); + --bg-0: #f5d8d1; + --bg-1: #faece8; + --bg-2: #fdf8f7; + --bg-3: #f7dfd9; + --bg-hover: #f3d1c8; + --bg-active: #efbfb3; + --line: #dfc9c3; + --text: #281d1b; + --text-dim: #61504c; + --text-faint: #917d78; + --accent: #bb301b; + --accent-hover: #982716; + --accent-text: #ffffff; + --speaking: #1d8b42; + --danger: #b62a20; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="apricot"], [data-theme-preview="apricot"] { + --rail: var(--bg-0); + --bg-0: #f7e4cf; + --bg-1: #fbf2e7; + --bg-2: #fdfaf6; + --bg-3: #f8e9d8; + --bg-hover: #f5dfc7; + --bg-active: #f1d3b1; + --line: #dfd2c3; + --text: #28221b; + --text-dim: #61574c; + --text-faint: #918578; + --accent: #b35b0f; + --accent-hover: #974d0c; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="pistachio"], [data-theme-preview="pistachio"] { + --rail: var(--bg-0); + --bg-0: #e1edd9; + --bg-1: #f0f6ec; + --bg-2: #fafcf8; + --bg-3: #e7f0e0; + --bg-hover: #dce9d2; + --bg-active: #cee1c1; + --line: #cfdfc3; + --text: #20281b; + --text-dim: #55614c; + --text-faint: #839178; + --accent: #3a822b; + --accent-hover: #316f25; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="seafoam"], [data-theme-preview="seafoam"] { + --rail: var(--bg-0); + --bg-0: #d8eee9; + --bg-1: #ebf7f4; + --bg-2: #f8fcfb; + --bg-3: #dff1ed; + --bg-hover: #d1ebe4; + --bg-active: #bfe3da; + --line: #c3dfd8; + --text: #1b2824; + --text-dim: #4c615c; + --text-faint: #78918b; + --accent: #128173; + --accent-hover: #0f6b5f; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="aqua"], [data-theme-preview="aqua"] { + --rail: var(--bg-0); + --bg-0: #d5f0f1; + --bg-1: #eaf8f8; + --bg-2: #f7fcfc; + --bg-3: #ddf3f4; + --bg-hover: #cdedee; + --bg-active: #bae7e8; + --line: #c3dedf; + --text: #1b2728; + --text-dim: #4c6061; + --text-faint: #789091; + --accent: #0b818e; + --accent-hover: #0a6b76; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #bf2c22; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="periwinkle"], [data-theme-preview="periwinkle"] { + --rail: var(--bg-0); + --bg-0: #d4d8f2; + --bg-1: #e9ebf9; + --bg-2: #f7f8fd; + --bg-3: #dbdff5; + --bg-hover: #ccd0f0; + --bg-active: #b8bfea; + --line: #c3c7df; + --text: #1b1c28; + --text-dim: #4c4f61; + --text-faint: #787c91; + --accent: #202db6; + --accent-hover: #1a2493; + --accent-text: #ffffff; + --speaking: #1d8740; + --danger: #b2291f; + --warn: #a06a0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="lilac"], [data-theme-preview="lilac"] { + --rail: var(--bg-0); + --bg-0: #e9d8ee; + --bg-1: #f4ebf7; + --bg-2: #fbf8fc; + --bg-3: #eddff1; + --bg-hover: #e4d1eb; + --bg-active: #dabfe3; + --line: #d8c3df; + --text: #241b28; + --text-dim: #5c4c61; + --text-faint: #8b7891; + --accent: #8930a6; + --accent-hover: #6f2786; + --accent-text: #ffffff; + --speaking: #1d8b42; + --danger: #b62a20; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="petal"], [data-theme-preview="petal"] { + --rail: var(--bg-0); + --bg-0: #f0d6e7; + --bg-1: #f7ebf3; + --bg-2: #fcf8fb; + --bg-3: #f2deeb; + --bg-hover: #edcfe3; + --bg-active: #e6bcd8; + --line: #dfc3d6; + --text: #281b23; + --text-dim: #614c5a; + --text-faint: #917889; + --accent: #ab2b81; + --accent-hover: #8b2368; + --accent-text: #ffffff; + --speaking: #1d8b42; + --danger: #b62a20; + --warn: #a56d0d; + --link: #1959b3; + color-scheme: light; +} + +:root[data-theme="fog"], [data-theme-preview="fog"] { + --rail: var(--bg-0); + --bg-0: #e1e4e5; + --bg-1: #f0f1f2; + --bg-2: #f9fafa; + --bg-3: #e6e9ea; + --bg-hover: #dbdfe1; + --bg-active: #cdd2d5; + --line: #cdd2d5; + --text: #1b2328; + --text-dim: #4c5a61; + --text-faint: #788991; + --accent: #507486; + --accent-hover: #415e6c; + --accent-text: #ffffff; + --speaking: #1e8f44; + --danger: #ba2b21; + --warn: #aa710e; + --link: #1959b3; + color-scheme: light; +} diff --git a/src/themes.css b/src/themes.css index 2e31f9a..49ebdfa 100644 --- a/src/themes.css +++ b/src/themes.css @@ -89,6 +89,138 @@ color-scheme: dark; } +:root[data-theme="forest"], [data-theme-preview="forest"] { + --rail: var(--bg-0); + --bg-0: #0c1410; + --bg-1: #111c16; + --bg-2: #16231c; + --bg-3: #1f3027; + --bg-hover: #263a2f; + --bg-active: #2f4739; + --line: #243529; + --text: #e2ebe4; + --text-dim: #9db3a4; + --text-faint: #67806f; + --accent: #4fbf7b; + --accent-hover: #66d08f; + --accent-text: #07140c; + --speaking: #f2c14e; + --danger: #f0605a; + --warn: #e8b23c; + --link: #7fd4c1; + color-scheme: dark; +} + +:root[data-theme="ember"], [data-theme-preview="ember"] { + --rail: var(--bg-0); + --bg-0: #120d0a; + --bg-1: #1a130f; + --bg-2: #211914; + --bg-3: #2d221b; + --bg-hover: #372a21; + --bg-active: #443428; + --line: #33271f; + --text: #efe6dd; + --text-dim: #b5a596; + --text-faint: #7d6f62; + --accent: #f08a3c; + --accent-hover: #ff9d52; + --accent-text: #1a0d03; + --speaking: #5fd08a; + --danger: #f0554e; + --warn: #f0c04a; + --link: #f4b27a; + color-scheme: dark; +} + +:root[data-theme="ocean"], [data-theme-preview="ocean"] { + --rail: var(--bg-0); + --bg-0: #071018; + --bg-1: #0b1722; + --bg-2: #0f1e2c; + --bg-3: #172a3c; + --bg-hover: #1d3348; + --bg-active: #254058; + --line: #1b2f42; + --text: #e0ebf5; + --text-dim: #93a9bd; + --text-faint: #5f7689; + --accent: #2fc4e0; + --accent-hover: #56d6ee; + --accent-text: #041018; + --speaking: #4be0a0; + --danger: #ff6259; + --warn: #f0c04a; + --link: #7cc4ff; + color-scheme: dark; +} + +:root[data-theme="plum"], [data-theme-preview="plum"] { + --rail: var(--bg-0); + --bg-0: #120c16; + --bg-1: #19111f; + --bg-2: #201628; + --bg-3: #2c1f37; + --bg-hover: #372743; + --bg-active: #443152; + --line: #32243e; + --text: #eee4f3; + --text-dim: #b3a2bf; + --text-faint: #7a6a86; + --accent: #e667a8; + --accent-hover: #f07fba; + --accent-text: #1c0612; + --speaking: #5fd08a; + --danger: #ff6259; + --warn: #f0c04a; + --link: #c9a2ff; + color-scheme: dark; +} + +:root[data-theme="paper"], [data-theme-preview="paper"] { + --rail: var(--bg-0); + --bg-0: #e4dccb; + --bg-1: #efe8d9; + --bg-2: #f8f3e7; + --bg-3: #e6dfcf; + --bg-hover: #ded6c3; + --bg-active: #d2c9b3; + --line: #d3cab6; + --text: #2b2620; + --text-dim: #5f574b; + --text-faint: #8a8172; + --accent: #a2542a; + --accent-hover: #8a441f; + --accent-text: #ffffff; + --speaking: #2e7d46; + --danger: #b93a2f; + --warn: #96690f; + --link: #2a5f9e; + color-scheme: light; +} + +:root[data-theme="contrast"], [data-theme-preview="contrast"] { + --rail: var(--bg-0); + --bg-0: #000000; + --bg-1: #000000; + --bg-2: #000000; + --bg-3: #1a1a1a; + --bg-hover: #262626; + --bg-active: #3a3a3a; + --line: #8c8c8c; + --text: #ffffff; + --text-dim: #e0e0e0; + --text-faint: #b0b0b0; + --accent: #ffd400; + --accent-hover: #ffe14d; + --accent-text: #000000; + --speaking: #00ff7f; + --danger: #ff6b6b; + --warn: #ffd400; + --link: #7fd4ff; + color-scheme: dark; +} + /* ─── Windows 95 ───────────────────────────────────────────────────────────── */ :root[data-theme="win95"], [data-theme-preview="win95"] { diff --git a/src/ui/AppearanceSettings.svelte b/src/ui/AppearanceSettings.svelte index fac2c7e..b22600a 100644 --- a/src/ui/AppearanceSettings.svelte +++ b/src/ui/AppearanceSettings.svelte @@ -21,6 +21,12 @@ store.saveSettings(); } + // With this many schemes the list can be narrowed by name and by dark or light + let find = $state(''); + let shade = $state<'all' | 'dark' | 'light'>('all'); + const shown = $derived(THEMES.filter(t => (shade === 'all' || (shade === 'light') === t.light) + && (!find.trim() || `${t.name} ${t.note}`.toLowerCase().includes(find.trim().toLowerCase())))); + function pick(id: ThemeId) { store.settings.theme = id; store.saveSettings(); @@ -28,8 +34,16 @@

    Color scheme

    +
    + +
    + {#each ['all', 'dark', 'light'] as const as k (k)} + + {/each} +
    +
    - {#each THEMES as t (t.id)} + {#each shown as t (t.id)} {/each}
    +{#if !shown.length}

    No color scheme matches.

    {/if}

    Interface

    @@ -101,6 +116,12 @@