diff --git a/apps/client/package.json b/apps/client/package.json index de8ec26..1d91008 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,7 +16,7 @@ "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", - "test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts", + "test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts", "typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json", "prepare": "husky install", "commit": "git-cz", diff --git a/apps/client/src/botsu/presence/BotsuPresence.tsx b/apps/client/src/botsu/presence/BotsuPresence.tsx index 18f3e5c..6e46028 100644 --- a/apps/client/src/botsu/presence/BotsuPresence.tsx +++ b/apps/client/src/botsu/presence/BotsuPresence.tsx @@ -19,6 +19,7 @@ import { type RemoteCursorState, } from './cursor-state'; import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from './socket-message'; +import { createCompactPresence } from './compact-presence'; const getInitials = (displayName: string): string => displayName @@ -178,6 +179,7 @@ export function BotsuPresence() { () => projectVisibleRemoteCursors(remoteCursors, state.participants, location.pathname), [location.pathname, remoteCursors, state.participants] ); + const compactPresence = useMemo(() => createCompactPresence(participants, 4), [participants]); const statusLabel = { connecting: 'Connexion…', @@ -196,13 +198,19 @@ export function BotsuPresence() { {participants.length > 0 && ( )} diff --git a/apps/client/src/botsu/presence/compact-presence.test.ts b/apps/client/src/botsu/presence/compact-presence.test.ts new file mode 100644 index 0000000..ed188a0 --- /dev/null +++ b/apps/client/src/botsu/presence/compact-presence.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCompactPresence } from './compact-presence.ts'; + +const participants = ['Ada', 'Bob', 'Chloé', 'Dina', 'Eli'].map((displayName, index) => ({ + userId: `@member${index}:botsu.net`, + displayName, +})); + +test('compact presence keeps four members visible and counts the remainder', () => { + const compact = createCompactPresence(participants, 4); + + assert.deepEqual( + compact.visible.map((participant) => participant.displayName), + ['Ada', 'Bob', 'Chloé', 'Dina'] + ); + assert.equal(compact.hiddenCount, 1); + assert.equal(compact.totalCount, 5); +}); + +test('compact presence rejects an invalid visible member limit', () => { + assert.throws(() => createCompactPresence(participants, 0), /positive integer/); +}); diff --git a/apps/client/src/botsu/presence/compact-presence.ts b/apps/client/src/botsu/presence/compact-presence.ts new file mode 100644 index 0000000..b0e163b --- /dev/null +++ b/apps/client/src/botsu/presence/compact-presence.ts @@ -0,0 +1,20 @@ +export type CompactPresence = { + visible: readonly T[]; + hiddenCount: number; + totalCount: number; +}; + +export const createCompactPresence = ( + participants: readonly T[], + visibleLimit: number +): CompactPresence => { + if (!Number.isInteger(visibleLimit) || visibleLimit < 1) { + throw new RangeError('visibleLimit must be a positive integer'); + } + + return { + visible: participants.slice(0, visibleLimit), + hiddenCount: Math.max(0, participants.length - visibleLimit), + totalCount: participants.length, + }; +}; diff --git a/apps/client/src/botsu/shell/layout.test.ts b/apps/client/src/botsu/shell/layout.test.ts new file mode 100644 index 0000000..805bb7b --- /dev/null +++ b/apps/client/src/botsu/shell/layout.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const shellCssUrl = new URL('./shell.css', import.meta.url); + +const getRuleBody = (css: string, selector: string): string => { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); + assert.ok(match, `Missing CSS rule for ${selector}`); + return match[1]; +}; + +test('BOTSU shell leaves room for the active call footer', async () => { + const css = await readFile(shellCssUrl, 'utf8'); + const shellRule = getRuleBody(css, '.botsu-shell'); + + assert.match(shellRule, /height:\s*100%;/); + assert.doesNotMatch(shellRule, /height:\s*100dvh;/); +}); diff --git a/apps/client/src/botsu/shell/shell.css b/apps/client/src/botsu/shell/shell.css index 5bb386d..1ec39df 100644 --- a/apps/client/src/botsu/shell/shell.css +++ b/apps/client/src/botsu/shell/shell.css @@ -1,7 +1,7 @@ .botsu-shell { width: 100%; min-width: 0; - height: 100dvh; + height: 100%; overflow: auto; color: var(--botsu-color-text); background-color: var(--botsu-color-canvas); @@ -183,7 +183,7 @@ .botsu-presence ul { display: flex; justify-content: flex-end; - gap: 0.3rem; + gap: 0; margin: 0; padding: 0; list-style: none; @@ -192,10 +192,13 @@ .botsu-presence li { display: flex; align-items: center; - gap: 0.3rem; font-size: 0.68rem; } +.botsu-presence li + li { + margin-left: -0.35rem; +} + .botsu-presence-avatar { width: 1.5rem; height: 1.5rem; @@ -215,6 +218,31 @@ object-fit: cover; } +.botsu-presence-overflow { + min-width: 1.5rem; + height: 1.5rem; + justify-content: center; + color: var(--botsu-color-text); + background: var(--botsu-color-surface); + border: var(--botsu-border-width) solid var(--botsu-color-border); + border-radius: 999px; + font-family: ui-monospace, monospace; + font-size: 0.58rem; + font-weight: 800; +} + +.botsu-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + .botsu-remote-cursors { position: fixed; inset: 0;