The palette lived as ~620 hex literals in inline styles plus a few hundred more in
the stylesheets, so there was no single thing to change. It is now 113 custom
properties in src/styles/theme.css, in two blocks.
src/scripts/theme-boot.js decides the theme before first paint — blocking, in <head>,
above the stylesheet, because a deferred script paints dark first and corrects itself.
It stores the mode ('system' | 'light' | 'dark'), never the colour it resolved to, and
stamps data-theme so an explicit choice can beat the media query. The switcher in the
header is a view onto it.
A filled accent stays the brand colour in both themes — the ink on it is near-black
either way — while an accent used as text darkens to clear 4.5:1 on white. A colour
reaches a fill by four routes (a style property, a constant, a helper argument, an SVG
source string), and tests/theme-switching.test.mjs covers all four.
The dark theme is unchanged: every colour declaration the previous build produced comes
out of this one identically once the properties are resolved.
Also: the roadmap drops its status chips on mobile, and Roadmap.jsx no longer splits a
colour with parseInt at runtime, which a var() reference cannot survive.
212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
// Verifies the multi-session reducer keeps sessions fully isolated: a change to one
|
|
// session never mutates another, unread only grows for non-active received traffic, and
|
|
// removing a session re-points the active pointer without disturbing siblings.
|
|
import assert from 'node:assert/strict';
|
|
|
|
const {
|
|
sessionsReducer,
|
|
createInitialState,
|
|
createSessionEntry,
|
|
SESSION_ACTIONS: A,
|
|
decorateSession,
|
|
monoInitials,
|
|
statusDot
|
|
} = await import('../src/state/sessionsStore.js');
|
|
|
|
function withTwoSessions() {
|
|
let state = createInitialState();
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'atlas repo' }) });
|
|
return state;
|
|
}
|
|
|
|
// CREATE_SESSION activates the new session and preserves order.
|
|
{
|
|
const state = withTwoSessions();
|
|
assert.deepEqual(state.order, ['a', 'b']);
|
|
assert.equal(state.activeSessionId, 'b', 'newest session becomes active');
|
|
assert.equal(Object.keys(state.sessions).length, 2);
|
|
}
|
|
|
|
// Isolation: mutating session B leaves session A's object referentially untouched.
|
|
{
|
|
const before = withTwoSessions();
|
|
const aRef = before.sessions.a;
|
|
const after = sessionsReducer(before, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'hi', type: 'sent' } });
|
|
assert.equal(after.sessions.a, aRef, 'session A object must be the same reference after editing B');
|
|
assert.equal(after.sessions.b.messages.length, 1);
|
|
assert.equal(after.sessions.a.messages.length, 0, 'A transcript untouched');
|
|
// And the original state object was not mutated in place.
|
|
assert.equal(before.sessions.b.messages.length, 0, 'reducer is immutable');
|
|
}
|
|
|
|
// SET_STATUS / SET_FINGERPRINT / SET_SAS are scoped to one session.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
|
|
state = sessionsReducer(state, { type: A.SET_SAS, id: 'a', sas: { isVerified: true, bothConfirmed: true } });
|
|
state = sessionsReducer(state, { type: A.SET_FINGERPRINT, id: 'a', fingerprint: 'AB:CD' });
|
|
assert.equal(state.sessions.a.status, 'verified');
|
|
assert.equal(state.sessions.a.sas.isVerified, true);
|
|
assert.equal(state.sessions.a.keyFingerprint, 'AB:CD');
|
|
assert.equal(state.sessions.b.status, 'new', 'sibling status untouched');
|
|
assert.equal(state.sessions.b.sas.isVerified, false, 'sibling SAS untouched');
|
|
assert.equal(state.sessions.b.keyFingerprint, '', 'sibling fingerprint untouched');
|
|
}
|
|
|
|
// Peer presence is cleared when the session leaves the connected state, so a reconnect
|
|
// never re-shows the peer's stale status before they re-broadcast it.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
|
|
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'busy' });
|
|
assert.equal(state.sessions.a.peerPresence, 'busy');
|
|
|
|
// connected -> verified keeps presence (still connected).
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'verified' });
|
|
assert.equal(state.sessions.a.peerPresence, 'busy', 'presence kept while still connected');
|
|
|
|
// verified -> peer_disconnected clears it.
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'peer_disconnected' });
|
|
assert.equal(state.sessions.a.peerPresence, null, 'presence cleared on disconnect');
|
|
|
|
// Reconnecting does not resurrect the old presence; it stays null until re-broadcast.
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
|
|
assert.equal(state.sessions.a.peerPresence, null, 'no stale presence after reconnect');
|
|
state = sessionsReducer(state, { type: A.SET_PEER_PRESENCE, id: 'a', presence: 'available' });
|
|
assert.equal(state.sessions.a.peerPresence, 'available', 'fresh presence applies after reconnect');
|
|
|
|
// A session repairing its network path is still a live session: blanking the
|
|
// peer's presence would make a two-second glitch look like a disconnect.
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'reconnecting' });
|
|
assert.equal(state.sessions.a.peerPresence, 'available', 'presence survives a path repair');
|
|
}
|
|
|
|
// A reconnecting session reads as in-progress (amber), not as dropped (red).
|
|
{
|
|
const entry = createSessionEntry({ id: 'a', peerLabel: 'phone' });
|
|
entry.status = 'reconnecting';
|
|
const d = decorateSession(entry, 'a');
|
|
assert.equal(d.headerSub, 'Reconnecting…');
|
|
|
|
const dropped = createSessionEntry({ id: 'b', peerLabel: 'phone' });
|
|
dropped.status = 'disconnected';
|
|
assert.notEqual(d.dot, decorateSession(dropped, 'b').dot, 'reconnecting must not look dropped');
|
|
}
|
|
|
|
// UPDATE_MESSAGE_STATUS and DELETE_MESSAGE only touch the named session/message.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, mid: 'm1', message: 'x', type: 'sent', status: 'sending' } });
|
|
state = sessionsReducer(state, { type: A.UPDATE_MESSAGE_STATUS, id: 'a', mid: 'm1', status: 'delivered' });
|
|
assert.equal(state.sessions.a.messages[0].status, 'delivered');
|
|
state = sessionsReducer(state, { type: A.DELETE_MESSAGE, id: 'a', mid: 'm1' });
|
|
assert.equal(state.sessions.a.messages.length, 0);
|
|
assert.equal(state.sessions.b.messages.length, 0);
|
|
}
|
|
|
|
// Unread bookkeeping.
|
|
{
|
|
let state = withTwoSessions(); // active = b
|
|
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.INCREMENT_UNREAD, id: 'a' });
|
|
assert.equal(state.sessions.a.unreadCount, 2);
|
|
assert.equal(state.sessions.b.unreadCount, 0);
|
|
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.CLEAR_UNREAD, id: 'a' });
|
|
assert.equal(state.sessions.a.unreadCount, 0);
|
|
assert.equal(state.activeSessionId, 'a');
|
|
}
|
|
|
|
// PATCH_SETUP merges, scoped per session.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.PATCH_SETUP, id: 'a', patch: { offerData: 'OFFER', showOfferStep: true } });
|
|
assert.equal(state.sessions.a.setup.offerData, 'OFFER');
|
|
assert.equal(state.sessions.a.setup.showOfferStep, true);
|
|
assert.equal(state.sessions.a.setup.answerData, '', 'untouched setup field keeps default');
|
|
assert.equal(state.sessions.b.setup.offerData, '', 'sibling setup untouched');
|
|
}
|
|
|
|
// RENAME marks the label custom.
|
|
{
|
|
let state = withTwoSessions();
|
|
state = sessionsReducer(state, { type: A.RENAME, id: 'a', label: 'Alice' });
|
|
assert.equal(state.sessions.a.peerLabel, 'Alice');
|
|
assert.equal(state.sessions.a.labelIsCustom, true);
|
|
assert.equal(state.sessions.b.labelIsCustom, false);
|
|
}
|
|
|
|
// REMOVE_SESSION re-points active to the previous sibling and leaves the rest intact.
|
|
{
|
|
let state = withTwoSessions(); // order [a,b], active b
|
|
const bRef = state.sessions.b;
|
|
state = sessionsReducer(state, { type: A.SET_ACTIVE, id: 'a' });
|
|
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'a' });
|
|
assert.equal(state.sessions.a, undefined, 'a removed');
|
|
assert.equal(state.sessions.b, bRef, 'sibling b object untouched');
|
|
assert.deepEqual(state.order, ['b']);
|
|
assert.equal(state.activeSessionId, 'b', 'active re-pointed to remaining session');
|
|
}
|
|
|
|
// REMOVE_SESSION on the last session leaves no active.
|
|
{
|
|
let state = createInitialState();
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'solo' }) });
|
|
state = sessionsReducer(state, { type: A.REMOVE_SESSION, id: 'solo' });
|
|
assert.equal(state.activeSessionId, null);
|
|
assert.deepEqual(state.order, []);
|
|
}
|
|
|
|
// Decorators mirror the design helpers.
|
|
{
|
|
assert.equal(monoInitials('work laptop'), 'WL');
|
|
assert.equal(monoInitials('atlas'), 'AT');
|
|
// Custom properties rather than literals since the app gained a light theme: a dot
|
|
// is a mark, so it takes the -solid form of the accent, which is the brand colour in
|
|
// both themes. What is being asserted is unchanged — which state gets which colour.
|
|
assert.equal(statusDot('verified'), 'var(--sb-green-solid)');
|
|
assert.equal(statusDot('connecting'), 'var(--sb-yellow-2-solid)');
|
|
assert.equal(statusDot('disconnected'), 'var(--sb-red-solid)');
|
|
|
|
const entry = createSessionEntry({ id: 'a', peerLabel: 'work laptop' });
|
|
entry.unreadCount = 3;
|
|
entry.status = 'connecting';
|
|
const d = decorateSession(entry, 'b');
|
|
assert.equal(d.mono, 'WL');
|
|
assert.equal(d.unread, '3');
|
|
assert.equal(d.active, false);
|
|
assert.equal(d.inactive, true);
|
|
}
|
|
|
|
// The rail preview shows conversation, not system notices.
|
|
{
|
|
let state = createInitialState();
|
|
state = sessionsReducer(state, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'a', peerLabel: 'work laptop' }) });
|
|
state = sessionsReducer(state, { type: A.SET_STATUS, id: 'a', status: 'connected' });
|
|
state = sessionsReducer(state, { type: A.ADD_MESSAGE, id: 'a', message: { id: 1, message: 'see you at six', type: 'received' } });
|
|
|
|
assert.equal(decorateSession(state.sessions.a, 'a').preview, 'see you at six');
|
|
|
|
// A closing notice arriving after it must not take the preview over: the
|
|
// status line next to it already says the connection is gone, and the last
|
|
// thing the peer actually said is what belongs there.
|
|
state = sessionsReducer(state, {
|
|
type: A.ADD_MESSAGE, id: 'a',
|
|
message: { id: 2, message: '🔌 Enhanced secure connection closed. Check connection status.', type: 'system' },
|
|
});
|
|
assert.equal(
|
|
decorateSession(state.sessions.a, 'a').preview, 'see you at six',
|
|
'a system notice must not become the chat preview',
|
|
);
|
|
|
|
// With nothing but system messages the preview falls back to the status text.
|
|
let bare = createInitialState();
|
|
bare = sessionsReducer(bare, { type: A.CREATE_SESSION, entry: createSessionEntry({ id: 'b', peerLabel: 'x' }) });
|
|
bare = sessionsReducer(bare, { type: A.SET_STATUS, id: 'b', status: 'disconnected' });
|
|
bare = sessionsReducer(bare, { type: A.ADD_MESSAGE, id: 'b', message: { id: 1, message: 'Peer manually disconnected.', type: 'system' } });
|
|
assert.equal(decorateSession(bare.sessions.b, 'b').preview, 'Disconnected');
|
|
}
|
|
|
|
console.log('sessions-reducer.test.mjs: all assertions passed');
|