Text channels #18

Merged
kibi merged 4 commits from text-channels into main 2026-10-02 18:58:28 +00:00
13 changed files with 2556 additions and 85 deletions
Showing only changes of commit 2883c61965 - Show all commits
+2
View File
@@ -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.
+1
View File
@@ -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/
```
+64 -76
View File
@@ -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
<p align="center"><img src="docs/screenshots/mobile.png" alt="mumh5 in a narrow window" width="300"></p>
@@ -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
+1
View File
@@ -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"
},
+170
View File
@@ -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`);
+16 -7
View File
@@ -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 = {
+92
View File
@@ -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;
+1
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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"] {
+22 -1
View File
@@ -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 @@
</script>
<h3>Color scheme</h3>
<div class="narrow">
<input type="search" placeholder="Find a color scheme" aria-label="Find a color scheme" bind:value={find} />
<div class="shades" role="group" aria-label="Show">
{#each ['all', 'dark', 'light'] as const as k (k)}
<button class:on={shade === k} aria-pressed={shade === k} onclick={() => (shade = k)}>{k === 'all' ? `All ${THEMES.length}` : k === 'dark' ? 'Dark' : 'Light'}</button>
{/each}
</div>
</div>
<div class="themes" role="radiogroup" aria-label="Color scheme">
{#each THEMES as t (t.id)}
{#each shown as t (t.id)}
<button role="radio" aria-checked={store.settings.theme === t.id} class:active={store.settings.theme === t.id}
onclick={() => pick(t.id)} aria-label={t.name}>
<!-- A miniature of the app in that theme -->
@@ -47,6 +61,7 @@
</button>
{/each}
</div>
{#if !shown.length}<p class="nothing">No color scheme matches.</p>{/if}
<h3 class="gap">Interface</h3>
<div class="themes" role="radiogroup" aria-label="Interface">
@@ -101,6 +116,12 @@
<style>
.gap { margin-top: 22px; }
.narrow { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.narrow input { flex: 1 1 180px; width: auto; }
.shades { display: flex; border: 1px solid var(--line); }
.shades button { padding: 0 14px; font-size: 14px; color: var(--text-dim); }
.shades button.on { background: var(--accent); color: var(--accent-text); }
.nothing { margin: 10px 0 0; font-size: 14px; color: var(--text-dim); }
.help { margin: 6px 0 0; font-size: 13px; color: var(--text-dim); }
.m-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.m-col .m-chat { flex: 1; }
+52 -1
View File
@@ -359,7 +359,28 @@ try {
await page.getByRole('radio', { name: 'Windows 95' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
await page.getByRole('radio', { name: 'Dark' }).click();
for (const [name, id, bg] of [['Forest', 'forest', 'rgb(22, 35, 28)'], ['Ember', 'ember', 'rgb(33, 25, 20)'], ['Ocean', 'ocean', 'rgb(15, 30, 44)'],
['Plum', 'plum', 'rgb(32, 22, 40)'], ['Paper', 'paper', 'rgb(248, 243, 231)'], ['High contrast', 'contrast', 'rgb(0, 0, 0)']]) {
await page.getByRole('radio', { name }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), id);
assert.equal(await page.locator('.chat').evaluate(el => getComputedStyle(el).backgroundColor), bg, `${name} colors the chat`);
if (shots) await page.screenshot({ path: path.join(shots, `theme-pick-${id}.png`) });
}
// Generated schemes, and narrowing the list
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 100);
await page.getByRole('button', { name: 'Light', exact: true }).click();
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 28);
await page.getByRole('radio', { name: 'Lavender' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'lavender');
if (shots) await page.screenshot({ path: path.join(shots, 'theme-pick-lavender.png') });
await page.getByRole('button', { name: 'All 100' }).click();
await page.getByLabel('Find a color scheme').fill('synth');
assert.equal(await page.getByRole('radiogroup', { name: 'Color scheme' }).getByRole('radio').count(), 1);
await page.getByRole('radio', { name: 'Synthwave' }).click();
assert.equal(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('color-scheme').trim()), 'dark');
if (shots) { await page.getByRole('button', { name: 'Done', exact: true }).click(); await page.screenshot({ path: path.join(shots, 'theme-synthwave.png') }); await page.getByTitle('Settings').click(); await page.getByRole('tab', { name: 'Appearance' }).click(); }
await page.getByLabel('Find a color scheme').fill('');
await page.getByRole('radio', { name: 'Dark', exact: true }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
// Hints are off by default: the title under the pointer is set aside while hovered, and back afterwards
const hintTarget = page.locator('.rail').getByRole('button', { name: 'Add a server' });
@@ -1109,6 +1130,36 @@ try {
await page.keyboard.press('Escape');
console.log('ok: registered on the server');
// Giving the microphone back while muted (for Bluetooth headsets), and taking it again
{
const muteBtn = (name: 'Mute' | 'Unmute') => page.locator('.me').getByRole('button', { name, exact: true });
if (!userState(newName)!.selfMute) {
await muteBtn('Mute').click();
await until2('muted', () => !!userState(newName)!.selfMute);
}
await page.getByTitle('Settings').click();
assert.equal(await page.getByLabel('Give the microphone back while muted').isChecked(), false, 'off by default on a desktop');
await page.getByLabel('Give the microphone back while muted').check();
await page.getByText('The microphone is given back now.').waitFor();
await page.getByRole('button', { name: 'Done', exact: true }).click();
// Unmuting takes it again
const heard = new Promise<void>(res => { const off = bob.on('voice', raw => { if (decodeVoice(raw)?.session === userState(newName)!.session) { off(); res(); } }); });
await muteBtn('Unmute').click();
await until2('unmuted', () => !userState(newName)!.selfMute);
await page.getByTitle('Settings').click();
await page.getByText('The microphone is open now.').waitFor();
await page.getByLabel('Give the microphone back while muted').uncheck();
await page.getByRole('button', { name: 'Done', exact: true }).click();
// Push to talk is the mode at this point: hold the key until bob hears the fake microphone
await page.locator('.chat header h1').click();
await page.keyboard.down('Backquote');
await within(heard, 'voice after taking the microphone again', 15000);
await page.keyboard.up('Backquote');
await muteBtn('Mute').click();
await until2('muted again', () => !!userState(newName)!.selfMute);
console.log('ok: microphone given back while muted and taken again on unmute');
}
// ─── The Rooms interface: the same app arranged for phones ─────────────────
{
const before = page.viewportSize()!;
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
// Every color scheme must stay readable: text on the surfaces it sits on, and the label on an accent button
const css = ['themes.css', 'themes-generated.css'].map(f => readFileSync(path.resolve(import.meta.dirname, '../src', f), 'utf8')).join('\n');
const settings = ['settings.svelte.ts', 'themes-generated.ts'].map(f => readFileSync(path.resolve(import.meta.dirname, '../src/lib', f), 'utf8')).join('\n');
function luminance(hex: string): number {
const n = parseInt(hex.slice(1), 16);
const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map(v => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const themes = [...css.matchAll(/\[data-theme-preview="([\w-]+)"\] \{([^}]*)\}/g)].map(m => ({
id: m[1],
vars: Object.fromEntries([...m[2].matchAll(/(--[\w-]+):\s*(#[0-9a-f]{6});/gi)].map(v => [v[1], v[2]]))
}));
test('every scheme in the settings list has its colors, and the other way round', () => {
const listed = [...settings.matchAll(/\{ id: '([\w-]+)', name:/g)].map(m => m[1]).sort();
assert.deepEqual(themes.map(t => t.id).sort(), listed);
assert.equal(listed.length, 100);
});
for (const t of themes) {
test(`${t.id}: text is readable`, () => {
const v = t.vars;
for (const bg of ['--bg-0', '--bg-1', '--bg-2']) {
assert.ok(contrast(v['--text'], v[bg]) >= 7, `text on ${bg}: ${contrast(v['--text'], v[bg]).toFixed(1)}`);
assert.ok(contrast(v['--text-dim'], v[bg]) >= 4.5, `dim text on ${bg}: ${contrast(v['--text-dim'], v[bg]).toFixed(1)}`);
}
assert.ok(contrast(v['--accent-text'], v['--accent']) >= 3.5, `accent label: ${contrast(v['--accent-text'], v['--accent']).toFixed(1)}`);
assert.ok(contrast(v['--link'], v['--bg-2']) >= 4, `link: ${contrast(v['--link'], v['--bg-2']).toFixed(1)}`);
});
}