mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-28 09:36:49 +00:00
Zoom moves into a new Accessibility section (the section keeps its section-zoom id, which layout plugins anchor to) next to: - Message text size, 80 to 200 %, separate from Zoom: messages, threads, forum posts, code blocks and the message boxes. - Line spacing (normal, relaxed, loose) and letter spacing (normal, wide). - Font: the theme's, the system's, Atkinson Hyperlegible or OpenDyslexic, self-hosted under SIL OFL 1.1 with their licenses in /fonts. - High contrast: secondary text and borders pulled toward each theme's own text color, and a thick focus ring. - Reduce motion: follow system, on or off. One html.reduce-motion class now covers every animation, the theme effects, the shimmering role colors, smooth scrolling, and GIFs, animated emoji and profile pictures, which wait for hover or tap. The old prefers-reduced-motion rules use it too. - Underline links, and larger click targets. Everything is per device and applied by theme-init.js before first paint, which hands the same read/apply functions to the settings page. Also: keyboard focus rings, keyboard-reachable settings nav rows, aria-labels on the icon buttons in the channel header and message box, and aria-pressed on the picker buttons. Co-authored-by: ancsemi <ancsemi@gmail.com>
349 lines
17 KiB
JavaScript
349 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Gradient role colors: a role can draw names as a gradient from its color
|
|
* to a second color, optionally shimmering. Covers saving the new fields,
|
|
* rejecting bad colors, carrying them to every place a role color reaches a
|
|
* client, and the client helpers that paint names with them.
|
|
*
|
|
* Boots a server on a scratch port and data dir:
|
|
* node --test test/gradientRoles.test.js
|
|
*/
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
const http = require('node:http');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const vm = require('node:vm');
|
|
const { spawn } = require('node:child_process');
|
|
const { io } = require('socket.io-client');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const PORT = 3417;
|
|
const BASE = `http://localhost:${PORT}`;
|
|
const DATA = path.join(os.tmpdir(), `haven-gradient-roles-${Date.now()}`);
|
|
|
|
let server;
|
|
|
|
const post = (p, body) => new Promise((res, rej) => {
|
|
const d = JSON.stringify(body);
|
|
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
|
|
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
|
|
r.on('error', rej); r.write(d); r.end();
|
|
});
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const register = (username) => post('/api/auth/register', { username, password: 'gradient-pass-321', eulaVersion: '2.0', ageVerified: true });
|
|
const ask = (sock, event, payload) => new Promise((res) => sock.emit(event, payload, res));
|
|
|
|
// The same fill-in the web client does for slimmed member entries (app-socket.js).
|
|
const fillMember = (u) => ({
|
|
highScore: 0, statusText: '', avatar: null, avatarShape: 'circle', border: null,
|
|
borderTransform: null, animateProfile: 'trigger', isGuest: false, role: null, activity: null,
|
|
...u,
|
|
});
|
|
|
|
function client(token, deltas) {
|
|
const s = io(BASE, { auth: deltas ? { token, presenceDeltas: 1 } : { token }, transports: ['websocket'], forceNew: true });
|
|
s.lists = new Map();
|
|
s.on('online-users', (d) => s.lists.set(d.channelCode, d.slim ? d.users.map(fillMember) : d.users));
|
|
s.on('online-users-delta', (d) => {
|
|
const list = s.lists.get(d.channelCode);
|
|
if (!list) return;
|
|
const upsert = d.upsert.map(fillMember);
|
|
const drop = new Set([...d.remove, ...upsert.map(u => u.id)]);
|
|
s.lists.set(d.channelCode, list.filter(u => !drop.has(u.id)).concat(upsert));
|
|
});
|
|
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
|
|
}
|
|
|
|
function next(sock, event, filter = () => true, ms = 4000) {
|
|
return new Promise((res) => {
|
|
const t = setTimeout(() => { sock.off(event, h); res(null); }, ms);
|
|
const h = (data) => { if (!filter(data)) return; clearTimeout(t); sock.off(event, h); res(data); };
|
|
sock.on(event, h);
|
|
});
|
|
}
|
|
|
|
const roleById = async (sock, id) => (await ask(sock, 'get-roles', {})).roles.find(r => r.id === id);
|
|
const member = (sock, code, username) => (sock.lists.get(code) || []).find(u => u.username === username);
|
|
|
|
test.before(async () => {
|
|
fs.mkdirSync(DATA, { recursive: true });
|
|
server = spawn(process.execPath, ['server.js'], {
|
|
cwd: ROOT,
|
|
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'admin', FORCE_HTTP: 'true' },
|
|
stdio: 'ignore',
|
|
});
|
|
for (let i = 0; i < 60; i++) {
|
|
try {
|
|
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
|
|
return;
|
|
} catch { await wait(500); }
|
|
}
|
|
throw new Error('server did not start');
|
|
});
|
|
|
|
test.after(() => { server?.kill(); try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {} });
|
|
|
|
test('gradient roles: saved, validated and carried to clients', async (t) => {
|
|
const admin = await register('admin');
|
|
const bob = await register('bob');
|
|
const dave = await register('dave');
|
|
assert.ok(admin.token && bob.token && dave.token, 'all registered');
|
|
|
|
const A = await client(admin.token, true);
|
|
const made = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'lounge'));
|
|
A.emit('create-channel', { name: 'lounge' });
|
|
const code = (await made).find((c) => c.name === 'lounge').code;
|
|
A.emit('enter-channel', { code });
|
|
const B = await client(bob.token, true);
|
|
const L = await client(dave.token, false); // an older app, full lists only
|
|
for (const s of [B, L]) { s.emit('join-channel', { code }); await wait(100); s.emit('enter-channel', { code }); }
|
|
await wait(800);
|
|
|
|
const rare = await ask(A, 'create-role', { name: 'Rare', level: 5, color: '#ff0000', color2: '#0000ff', shimmer: true });
|
|
assert.ok(rare.roleId, 'gradient role created');
|
|
|
|
await t.test('create stores the end color and shimmer', async () => {
|
|
const r = await roleById(A, rare.roleId);
|
|
assert.strictEqual(r.color, '#ff0000');
|
|
assert.strictEqual(r.color2, '#0000ff');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
});
|
|
|
|
await t.test('a role without an end color is a plain color, as before', async () => {
|
|
const plain = await ask(A, 'create-role', { name: 'Plain', level: 3, color: '#00aa00', shimmer: true });
|
|
const r = await roleById(A, plain.roleId);
|
|
assert.strictEqual(r.color2, null);
|
|
assert.strictEqual(r.color_shimmer, 0, 'shimmer means nothing without a gradient');
|
|
});
|
|
|
|
await t.test('bad end colors are refused like bad colors', async () => {
|
|
for (const bad of ['red', 'url(x)', '#12345g', '#1234567', 'javascript:alert(1)', 42, { a: 1 }]) {
|
|
const res = await ask(A, 'create-role', { name: 'Bad', level: 2, color: '#123456', color2: bad, shimmer: true });
|
|
const r = await roleById(A, res.roleId);
|
|
assert.strictEqual(r.color2, null, `color2 ${JSON.stringify(bad)} stored as no color`);
|
|
assert.strictEqual(r.color_shimmer, 0);
|
|
await ask(A, 'delete-role', { roleId: res.roleId });
|
|
}
|
|
});
|
|
|
|
await t.test('update changes, keeps and clears the gradient', async () => {
|
|
const tmp = await ask(A, 'create-role', { name: 'Tmp', level: 4, color: '#111111', color2: '#222222' });
|
|
let res = await ask(A, 'update-role', { roleId: tmp.roleId, color2: '#abc', shimmer: true });
|
|
assert.ok(res.success);
|
|
let r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, '#abc');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, name: 'Tmp2' });
|
|
r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, '#abc', 'an edit that does not send color2 leaves the gradient alone');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, shimmer: false });
|
|
assert.strictEqual((await roleById(A, tmp.roleId)).color_shimmer, 0);
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, color2: 'not-a-color', shimmer: true });
|
|
r = await roleById(A, tmp.roleId);
|
|
assert.strictEqual(r.color2, null, 'a bad end color clears the gradient');
|
|
assert.strictEqual(r.color_shimmer, 0, 'and its shimmer');
|
|
|
|
await ask(A, 'update-role', { roleId: tmp.roleId, color2: null });
|
|
assert.strictEqual((await roleById(A, tmp.roleId)).color2, null);
|
|
await ask(A, 'delete-role', { roleId: tmp.roleId });
|
|
});
|
|
|
|
await t.test('duplicate carries the gradient', async () => {
|
|
// Duplicate in the role editor re-creates the role from its row; this is
|
|
// the payload it sends (app-roles.js).
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-roles.js'), 'utf8');
|
|
const dup = src.slice(src.indexOf("getElementById('duplicate-role-btn')?.addEventListener"));
|
|
assert.match(dup.slice(0, 1500), /color2: role\.color2 \|\| null,\s*shimmer: !!role\.color_shimmer,/);
|
|
|
|
const orig = await roleById(A, rare.roleId);
|
|
const copy = await ask(A, 'create-role', {
|
|
name: 'Rare (copy)', level: orig.level, color: orig.color, color2: orig.color2 || null,
|
|
shimmer: !!orig.color_shimmer, transparent: !!orig.transparent, permissions: orig.permissions,
|
|
});
|
|
const r = await roleById(A, copy.roleId);
|
|
assert.strictEqual(r.color2, '#0000ff');
|
|
assert.strictEqual(r.color_shimmer, 1);
|
|
await ask(A, 'delete-role', { roleId: copy.roleId });
|
|
});
|
|
|
|
await ask(A, 'assign-role', { userId: bob.user.id, roleId: rare.roleId });
|
|
await wait(1500);
|
|
|
|
await t.test('member list entries carry the gradient, slim or whole', async () => {
|
|
for (const [who, sock] of [['deltas client', A], ['older client', L]]) {
|
|
const entry = member(sock, code, 'bob');
|
|
assert.ok(entry && entry.role, `${who} sees bob with a role`);
|
|
assert.strictEqual(entry.role.color, '#ff0000');
|
|
assert.strictEqual(entry.role.color2, '#0000ff', `${who} gets the end color`);
|
|
assert.strictEqual(entry.role.color_shimmer, 1, `${who} gets the shimmer`);
|
|
}
|
|
const adminEntry = member(A, code, 'admin');
|
|
assert.ok(adminEntry && adminEntry.role, 'the admin has their Admin role');
|
|
assert.ok(!('color2' in adminEntry.role), 'a plain role sends no gradient fields');
|
|
assert.deepStrictEqual(member(A, code, 'bob'), member(L, code, 'bob'), 'same entry either way');
|
|
});
|
|
|
|
await t.test('a transparent role on top leaves the gradient role styling the name', async () => {
|
|
const ghost = await ask(A, 'create-role', { name: 'Ghost', level: 40, color: '#999999', transparent: true });
|
|
await ask(A, 'assign-role', { userId: bob.user.id, roleId: ghost.roleId });
|
|
await wait(1500);
|
|
const entry = member(A, code, 'bob');
|
|
assert.strictEqual(entry.role.name, 'Rare');
|
|
assert.strictEqual(entry.role.color2, '#0000ff');
|
|
});
|
|
|
|
await t.test('a message author is styled by their member entry, which has the gradient', async () => {
|
|
const got = next(A, 'new-message', (d) => d && d.channelCode === code && d.message && d.message.content === 'shiny');
|
|
B.emit('send-message', { code, content: 'shiny' });
|
|
const msg = await got;
|
|
assert.ok(msg, 'message arrived');
|
|
const author = member(A, code, 'bob');
|
|
assert.strictEqual(author.id, msg.message.user_id);
|
|
assert.strictEqual(author.role.color2, '#0000ff');
|
|
// The chat renders that author through the shared helper.
|
|
const src = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-messages.js'), 'utf8');
|
|
assert.match(src, /this\._roleNameHtml\(onlineUser\.role, authorText\)/);
|
|
assert.match(src, /<span class="message-author" style="color:\$\{authorColor\}"[^\n]*>\$\{authorHtml\}<\/span>/);
|
|
});
|
|
|
|
await t.test('profile cards, role lookups and voice rosters carry it', async () => {
|
|
const profile = next(A, 'user-profile', (p) => p && p.id === bob.user.id);
|
|
A.emit('get-user-profile', { userId: bob.user.id });
|
|
const p = await profile;
|
|
const shown = p.roles.find(r => r.name === 'Rare');
|
|
assert.strictEqual(shown.color2, '#0000ff');
|
|
assert.strictEqual(shown.color_shimmer, 1);
|
|
|
|
const roles = next(A, 'user-roles', (d) => d && d.userId === bob.user.id);
|
|
A.emit('get-user-roles', { userId: bob.user.id });
|
|
const ur = await roles;
|
|
assert.strictEqual(ur.highestRole.name, 'Rare');
|
|
assert.strictEqual(ur.highestRole.color2, '#0000ff');
|
|
assert.strictEqual(ur.roles.find(r => r.name === 'Rare').color2, '#0000ff');
|
|
|
|
const voice = next(A, 'voice-users-update', (d) => d && d.channelCode === code && d.users.some(u => u.id === bob.user.id));
|
|
B.emit('voice-join', { code });
|
|
const v = await voice;
|
|
assert.ok(v, 'bob joined voice');
|
|
const vb = v.users.find(u => u.id === bob.user.id);
|
|
assert.strictEqual(vb.roleColor, '#ff0000', 'the old field is unchanged');
|
|
assert.strictEqual(vb.roleColor2, '#0000ff');
|
|
assert.strictEqual(vb.roleShimmer, true);
|
|
B.emit('voice-leave', { code });
|
|
});
|
|
|
|
await t.test('Reset to Default leaves stock roles without gradients', async () => {
|
|
const res = await ask(A, 'reset-roles-to-default', {});
|
|
assert.ok(res.success);
|
|
const { roles } = await ask(A, 'get-roles', {});
|
|
assert.ok(roles.length > 0);
|
|
for (const r of roles) {
|
|
assert.strictEqual(r.color2, null, `${r.name} has no end color`);
|
|
assert.strictEqual(r.color_shimmer, 0);
|
|
}
|
|
});
|
|
|
|
for (const s of [A, B, L]) s.close();
|
|
});
|
|
|
|
// ── client helpers ────────────────────────────────────────────────────────
|
|
function loadApp() {
|
|
const source = fs.readFileSync(path.join(ROOT, 'public/js/modules/app-utilities.js'), 'utf8');
|
|
const store = new Map();
|
|
const context = vm.createContext({
|
|
module: { exports: {} }, exports: {},
|
|
document: {
|
|
documentElement: { lang: 'en-US' }, querySelectorAll: () => [],
|
|
createElement: () => {
|
|
let text = '';
|
|
return {
|
|
set textContent(v) { text = String(v); },
|
|
get innerHTML() { return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); },
|
|
};
|
|
},
|
|
},
|
|
localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
navigator: { languages: ['en-US'] },
|
|
setInterval: () => 0, clearInterval: () => {},
|
|
Intl, Date, Number, Math, JSON, String, Array, Object, RegExp, Error, Map, Set,
|
|
t: (key) => key, console,
|
|
});
|
|
vm.runInContext(source.replace(/^export default/m, 'module.exports ='), context, { filename: 'app-utilities.js' });
|
|
const app = Object.create(context.module.exports);
|
|
app._escapeHtml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
app._store = store;
|
|
return app;
|
|
}
|
|
|
|
test('a plain role leaves the name text alone', () => {
|
|
const app = loadApp();
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000' }, 'Bob <3'), 'Bob <3');
|
|
assert.strictEqual(app._roleNameHtml(null, 'Bob'), 'Bob');
|
|
assert.strictEqual(app._roleFill({ color: '#ff0000' }, '#aaa'), '#ff0000');
|
|
assert.strictEqual(app._roleFill(null, '#aaa'), '#aaa');
|
|
});
|
|
|
|
test('a gradient role wraps the name in a gradient span, shimmering when asked', () => {
|
|
const app = loadApp();
|
|
const still = app._roleNameHtml({ color: '#ff0000', color2: '#0000ff', color_shimmer: 0 }, 'Bob');
|
|
assert.strictEqual(still, '<span class="role-gradient" style="--role-c1:#ff0000;--role-c2:#0000ff">Bob</span>');
|
|
const moving = app._roleNameHtml({ color: '#ff0000', color2: '#0000ff', color_shimmer: 1 }, 'Bob');
|
|
assert.match(moving, /class="role-gradient role-shimmer"/);
|
|
assert.strictEqual(app._roleFill({ color: '#ff0000', color2: '#0000ff' }), 'linear-gradient(90deg, #ff0000, #0000ff)');
|
|
// The flat voice shape works the same way.
|
|
assert.match(app._roleNameHtml({ roleColor: '#ff0000', roleColor2: '#0000ff', roleShimmer: true }, 'Bob'), /role-shimmer/);
|
|
});
|
|
|
|
test('emoji in a gradient name get their own span so they keep their colors (#5720)', () => {
|
|
const app = loadApp();
|
|
const role = { color: '#ff0000', color2: '#0000ff' };
|
|
const wrap = (name) => app._roleNameHtml(role, name).replace(/^<span[^>]*>|<\/span>$/g, '');
|
|
assert.strictEqual(wrap('Bob 🎉'), 'Bob <span class="role-emoji">🎉</span>');
|
|
// A ZWJ family, a skin tone, a flag and a keycap each stay one emoji, and a
|
|
// run of emoji shares one span.
|
|
assert.strictEqual(wrap('👨👩👧 a'), '<span class="role-emoji">👨👩👧</span> a');
|
|
assert.strictEqual(wrap('👍🏽'), '<span class="role-emoji">👍🏽</span>');
|
|
assert.strictEqual(wrap('🇧🇷'), '<span class="role-emoji">🇧🇷</span>');
|
|
assert.strictEqual(wrap('#️⃣1'), '<span class="role-emoji">#️⃣</span>1');
|
|
assert.strictEqual(wrap('🔥🔥 hot'), '<span class="role-emoji">🔥🔥</span> hot');
|
|
// Escaping is untouched, and entities never count as emoji.
|
|
assert.strictEqual(wrap('Bob <3 & #1'), 'Bob <3 & #1');
|
|
// A plain color name has no spans added.
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000' }, 'Bob 🎉'), 'Bob 🎉');
|
|
const css = require('./coreCss').readCoreCss();
|
|
// Colored only when Interface Icons is not Monochrome.
|
|
assert.match(css, /:root:not\(\[data-toolbaricons="mono"\]\) \.role-gradient \.role-emoji \{[^}]*-webkit-text-fill-color: currentColor/);
|
|
});
|
|
|
|
test('unsafe colors never reach the page', () => {
|
|
const app = loadApp();
|
|
assert.strictEqual(app._roleNameHtml({ color: '#ff0000', color2: 'red;background:url(x)' }, 'Bob'), 'Bob');
|
|
assert.strictEqual(app._roleNameHtml({ color: 'expression(x)', color2: '#0000ff' }, 'Bob'), 'Bob', 'no start color, no gradient');
|
|
assert.strictEqual(app._roleLook({ color: '#ff0000', color2: null, color_shimmer: 1 }).shimmer, false);
|
|
});
|
|
|
|
test('chat names follow the role display setting', () => {
|
|
const app = loadApp();
|
|
const role = { color: '#ff0000', color2: '#0000ff' };
|
|
app._lastOnlineUsers = [{ id: 5, username: 'bob', role }];
|
|
assert.strictEqual(app._chatNameRole(5), role);
|
|
assert.strictEqual(app._chatNameRole(6), null, 'unknown user');
|
|
app._store.set('haven-role-display', 'dot');
|
|
assert.strictEqual(app._chatNameRole(5), null, 'names are not role colored in dot mode');
|
|
});
|
|
|
|
test('shimmer is CSS only and stops for reduced motion', () => {
|
|
const css = require('./coreCss').readCoreCss();
|
|
assert.match(css, /\.role-gradient\.role-shimmer \{[^}]*animation: role-shimmer/);
|
|
// html.reduce-motion is the OS setting or the Accessibility override (theme-init.js).
|
|
assert.match(css, /html\.reduce-motion \.role-gradient\.role-shimmer \{ animation: none; \}/);
|
|
});
|