detached cb6b0af

fix(desktop): XP inputs, inline away presets, docked buddy list, compact send button

- new-room and buddy-list inputs use type=text so xp.css renders them white
- away presets become an inline list box that fits the window
- buddy list opens top-right with margins and stays under the sign-on window
- chat room drops the redundant online count and the Envoyer button
  matches the 21px input height
- sounds: playback always waits for the sample probe so the first play no
  longer falls back to the synthesized sketch
commit
cb6b0afdd5621fb038c54d83e36c5aed972d2cf1
author
Kit · · 2026-09-27 13:41 UTC
parent
824e236
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
6 files · +109 -49
M src/lib/aim/sounds.ts +39 -34
@@ -50,7 +50,7 @@ function getContext(): AudioContext | null {
5050
5151 /** Call once from a user gesture so the browser allows playback. */
5252 export function unlockAudio() {
53- getContext();
53+ if (getContext()) preloadSounds();
5454 }
5555
5656 function noiseBuffer(ac: AudioContext, seconds: number) {
@@ -234,25 +234,28 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
234234 modem: 4500
235235 };
236236
237-// Sample cache: undefined = not probed yet, null = no file available
238-const samples = new Map<AimSound, AudioBuffer | null>();
237+// Sample cache: one in-flight/resolved promise per sound (null = no file available)
238+const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
239239 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];
240240
241-async function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
242- if (samples.has(name)) return samples.get(name) ?? null;
243- samples.set(name, null);
244- for (const ext of SAMPLE_EXTENSIONS) {
245- try {
246- const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
247- if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue;
248- const buffer = await ac.decodeAudioData(await res.arrayBuffer());
249- samples.set(name, buffer);
250- return buffer;
251- } catch {
252- // try next extension
253- }
241+function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
242+ let pending = samples.get(name);
243+ if (!pending) {
244+ pending = (async () => {
245+ for (const ext of SAMPLE_EXTENSIONS) {
246+ try {
247+ const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
248+ if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue;
249+ return await ac.decodeAudioData(await res.arrayBuffer());
250+ } catch {
251+ // try next extension
252+ }
253+ }
254+ return null;
255+ })();
256+ samples.set(name, pending);
254257 }
255- return null;
258+ return pending;
256259 }
257260
258261 /** Warm the sample cache so the first playback is not delayed by a fetch. */
@@ -266,21 +269,23 @@ export function playSound(name: AimSound) {
266269 if (!enabled) return;
267270 const ac = getContext();
268271 if (!ac) return;
269- const cached = samples.get(name);
270- if (cached) {
271- const src = ac.createBufferSource();
272- src.buffer = cached;
273- src.connect(ac.destination);
274- src.start();
275- return;
276- }
277- if (cached === undefined) void loadSample(ac, name);
278- const master = ac.createGain();
279- master.gain.value = 0.9;
280- master.connect(ac.destination);
281- try {
282- players[name](ac, master, ac.currentTime);
283- } catch (err) {
284- console.warn('Sound playback failed', err);
285- }
272+ // Always wait for the sample probe so the first play never falls back to the
273+ // synthesized sketch while the real file is still downloading.
274+ void loadSample(ac, name).then((buffer) => {
275+ if (buffer) {
276+ const src = ac.createBufferSource();
277+ src.buffer = buffer;
278+ src.connect(ac.destination);
279+ src.start();
280+ return;
281+ }
282+ const master = ac.createGain();
283+ master.gain.value = 0.9;
284+ master.connect(ac.destination);
285+ try {
286+ players[name](ac, master, ac.currentTime);
287+ } catch (err) {
288+ console.warn('Sound playback failed', err);
289+ }
290+ });
286291 }
@@ -50,7 +50,7 @@ function getContext(): AudioContext | null {
50 50
51 /** Call once from a user gesture so the browser allows playback. */51 /** Call once from a user gesture so the browser allows playback. */
52 export function unlockAudio() {52 export function unlockAudio() {
53- getContext();53+ if (getContext()) preloadSounds();
54 }54 }
55 55
56 function noiseBuffer(ac: AudioContext, seconds: number) {56 function noiseBuffer(ac: AudioContext, seconds: number) {
@@ -234,25 +234,28 @@ export const SOUND_DURATIONS_MS: Record<AimSound, number> = {
234 modem: 4500234 modem: 4500
235 };235 };
236 236
237-// Sample cache: undefined = not probed yet, null = no file available237+// Sample cache: one in-flight/resolved promise per sound (null = no file available)
238-const samples = new Map<AimSound, AudioBuffer | null>();238+const samples = new Map<AimSound, Promise<AudioBuffer | null>>();
239 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];239 const SAMPLE_EXTENSIONS = ['mp3', 'wav', 'ogg'];
240 240
241-async function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {241+function loadSample(ac: AudioContext, name: AimSound): Promise<AudioBuffer | null> {
242- if (samples.has(name)) return samples.get(name) ?? null;242+ let pending = samples.get(name);
243- samples.set(name, null);243+ if (!pending) {
244- for (const ext of SAMPLE_EXTENSIONS) {244+ pending = (async () => {
245- try {245+ for (const ext of SAMPLE_EXTENSIONS) {
246- const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });246+ try {
247- if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue;247+ const res = await fetch(`/sounds/${name}.${ext}`, { cache: 'force-cache' });
248- const buffer = await ac.decodeAudioData(await res.arrayBuffer());248+ if (!res.ok || !(res.headers.get('content-type') ?? '').startsWith('audio/')) continue;
249- samples.set(name, buffer);249+ return await ac.decodeAudioData(await res.arrayBuffer());
250- return buffer;250+ } catch {
251- } catch {251+ // try next extension
252- // try next extension252+ }
253- }253+ }
254+ return null;
255+ })();
256+ samples.set(name, pending);
254 }257 }
255- return null;258+ return pending;
256 }259 }
257 260
258 /** Warm the sample cache so the first playback is not delayed by a fetch. */261 /** Warm the sample cache so the first playback is not delayed by a fetch. */
@@ -266,21 +269,23 @@ export function playSound(name: AimSound) {
266 if (!enabled) return;269 if (!enabled) return;
267 const ac = getContext();270 const ac = getContext();
268 if (!ac) return;271 if (!ac) return;
269- const cached = samples.get(name);272+ // Always wait for the sample probe so the first play never falls back to the
270- if (cached) {273+ // synthesized sketch while the real file is still downloading.
271- const src = ac.createBufferSource();274+ void loadSample(ac, name).then((buffer) => {
272- src.buffer = cached;275+ if (buffer) {
273- src.connect(ac.destination);276+ const src = ac.createBufferSource();
274- src.start();277+ src.buffer = buffer;
275- return;278+ src.connect(ac.destination);
276- }279+ src.start();
277- if (cached === undefined) void loadSample(ac, name);280+ return;
278- const master = ac.createGain();281+ }
279- master.gain.value = 0.9;282+ const master = ac.createGain();
280- master.connect(ac.destination);283+ master.gain.value = 0.9;
281- try {284+ master.connect(ac.destination);
282- players[name](ac, master, ac.currentTime);285+ try {
283- } catch (err) {286+ players[name](ac, master, ac.currentTime);
284- console.warn('Sound playback failed', err);287+ } catch (err) {
285- }288+ console.warn('Sound playback failed', err);
289+ }
290+ });
286 }291 }
M src/lib/components/chat-room.svelte +9 -9
@@ -1078,7 +1078,6 @@
10781078 {#if roomTopic}
10791079 <span class="room-topic">— {roomTopic}</span>
10801080 {/if}
1081- <span class="room-count">{usersOnline.length} en ligne</span>
10821081 {#if mentionUnreadCount > 0}
10831082 <span class="room-mentions"
10841083 >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
@@ -1492,13 +1491,8 @@
14921491 flex: 1;
14931492 }
14941493
1495- .room-count {
1496- margin-left: auto;
1497- color: #666;
1498- white-space: nowrap;
1499- }
1500-
15011494 .room-mentions {
1495+ margin-left: auto;
15021496 font-weight: bold;
15031497 color: #c00000;
15041498 white-space: nowrap;
@@ -1729,8 +1723,14 @@
17291723 font-style: inherit;
17301724 }
17311725
1732- button {
1733- font-size: 1rem;
1726+ .input-container :global(> button) {
1727+ height: 21px;
1728+ min-height: 21px;
1729+ min-width: 0;
1730+ padding: 0 10px;
1731+ margin-left: 4px;
1732+ font-size: 11px;
1733+ white-space: nowrap;
17341734 }
17351735
17361736 .chat-container {
@@ -1078,7 +1078,6 @@
1078 {#if roomTopic}1078 {#if roomTopic}
1079 <span class="room-topic">— {roomTopic}</span>1079 <span class="room-topic">— {roomTopic}</span>
1080 {/if}1080 {/if}
1081- <span class="room-count">{usersOnline.length} en ligne</span>
1082 {#if mentionUnreadCount > 0}1081 {#if mentionUnreadCount > 0}
1083 <span class="room-mentions"1082 <span class="room-mentions"
1084 >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span1083 >({mentionUnreadCount} mention{mentionUnreadCount > 1 ? 's' : ''})</span
@@ -1492,13 +1491,8 @@
1492 flex: 1;1491 flex: 1;
1493 }1492 }
1494 1493
1495- .room-count {
1496- margin-left: auto;
1497- color: #666;
1498- white-space: nowrap;
1499- }
1500-
1501 .room-mentions {1494 .room-mentions {
1495+ margin-left: auto;
1502 font-weight: bold;1496 font-weight: bold;
1503 color: #c00000;1497 color: #c00000;
1504 white-space: nowrap;1498 white-space: nowrap;
@@ -1729,8 +1723,14 @@
1729 font-style: inherit;1723 font-style: inherit;
1730 }1724 }
1731 1725
1732- button {1726+ .input-container :global(> button) {
1733- font-size: 1rem;1727+ height: 21px;
1728+ min-height: 21px;
1729+ min-width: 0;
1730+ padding: 0 10px;
1731+ margin-left: 4px;
1732+ font-size: 11px;
1733+ white-space: nowrap;
1734 }1734 }
1735 1735
1736 .chat-container {1736 .chat-container {
M src/lib/components/desktop/away-window.svelte +33 -4
@@ -58,10 +58,17 @@
5858 }
5959 </script>
6060
61-<XpWindow id="away" width={360} height={300} resizableWindow={false}>
61+<XpWindow id="away" width={360} height={340} resizableWindow={false}>
6262 <div class="content">
63- <label for="away-preset">Message d’absence</label>
64- <select id="away-preset" value={selected} onchange={selectPreset}>
63+ <label for="away-preset">Messages d’absence enregistrés</label>
64+ <!-- Inline list box (like AIM) so the choices always stay inside the window -->
65+ <select
66+ id="away-preset"
67+ class="preset-list"
68+ size={presets.length + 1}
69+ value={selected}
70+ onchange={selectPreset}
71+ >
6572 {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each}
6673 <option value="custom">Personnalisé…</option>
6774 </select>
@@ -96,10 +103,32 @@
96103 box-sizing: border-box;
97104 font: inherit;
98105 }
106+ .preset-list {
107+ height: auto;
108+ padding: 1px;
109+ background-image: none;
110+ border: 1px solid #7f9db9;
111+ overflow: hidden;
112+ }
113+ .preset-list:focus {
114+ background-color: #fff;
115+ color: inherit;
116+ }
117+ .preset-list option {
118+ padding: 1px 3px;
119+ overflow: hidden;
120+ text-overflow: ellipsis;
121+ white-space: nowrap;
122+ }
123+ .preset-list option:checked {
124+ background: #316ac5;
125+ color: #fff;
126+ }
99127 textarea {
100128 flex: 1;
101- min-height: 65px;
129+ min-height: 60px;
102130 resize: none;
131+ border: 1px solid #7f9db9;
103132 }
104133 small {
105134 align-self: flex-end;
@@ -58,10 +58,17 @@
58 }58 }
59 </script>59 </script>
60 60
61-<XpWindow id="away" width={360} height={300} resizableWindow={false}>61+<XpWindow id="away" width={360} height={340} resizableWindow={false}>
62 <div class="content">62 <div class="content">
63- <label for="away-preset">Message d’absence</label>63+ <label for="away-preset">Messages d’absence enregistrés</label>
64- <select id="away-preset" value={selected} onchange={selectPreset}>64+ <!-- Inline list box (like AIM) so the choices always stay inside the window -->
65+ <select
66+ id="away-preset"
67+ class="preset-list"
68+ size={presets.length + 1}
69+ value={selected}
70+ onchange={selectPreset}
71+ >
65 {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each}72 {#each presets as preset (preset)}<option value={preset}>{preset}</option>{/each}
66 <option value="custom">Personnalisé…</option>73 <option value="custom">Personnalisé…</option>
67 </select>74 </select>
@@ -96,10 +103,32 @@
96 box-sizing: border-box;103 box-sizing: border-box;
97 font: inherit;104 font: inherit;
98 }105 }
106+ .preset-list {
107+ height: auto;
108+ padding: 1px;
109+ background-image: none;
110+ border: 1px solid #7f9db9;
111+ overflow: hidden;
112+ }
113+ .preset-list:focus {
114+ background-color: #fff;
115+ color: inherit;
116+ }
117+ .preset-list option {
118+ padding: 1px 3px;
119+ overflow: hidden;
120+ text-overflow: ellipsis;
121+ white-space: nowrap;
122+ }
123+ .preset-list option:checked {
124+ background: #316ac5;
125+ color: #fff;
126+ }
99 textarea {127 textarea {
100 flex: 1;128 flex: 1;
101- min-height: 65px;129+ min-height: 60px;
102 resize: none;130 resize: none;
131+ border: 1px solid #7f9db9;
103 }132 }
104 small {133 small {
105 align-self: flex-end;134 align-self: flex-end;
M src/lib/components/desktop/buddy-list.svelte +19 -1
@@ -12,6 +12,14 @@
1212 import { chatState } from '$lib/states/chat.svelte';
1313 import { desktop } from '$lib/states/desktop.svelte';
1414 import XpWindow from './xp-window.svelte';
15+ import { browser } from '$app/environment';
16+
17+ // Dock top-right like the real AIM buddy list, with breathing room above and
18+ // above the taskbar (30px) below.
19+ const BUDDY_LIST_WIDTH = 230;
20+ const buddyListHeight = browser
21+ ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40))
22+ : 520;
1523
1624 const aim = useAimClient();
1725 const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
@@ -244,7 +252,15 @@
244252 </div>
245253 {/snippet}
246254
247-<XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}>
255+<XpWindow
256+ id="buddy-list"
257+ width={BUDDY_LIST_WIDTH}
258+ height={buddyListHeight}
259+ minWidth={BUDDY_LIST_WIDTH}
260+ showMaximize={false}
261+ x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined}
262+ y={browser ? 40 : undefined}
263+>
248264 {#snippet menu()}
249265 <div class="menus" bind:this={menuBar}>
250266 <div class="menu-wrap">
@@ -407,6 +423,7 @@
407423 >
408424 <label for="im-nick">Pseudo du contact</label><input
409425 id="im-nick"
426+ type="text"
410427 bind:value={nickname}
411428 autocomplete="off"
412429 /><button type="submit">Ouvrir IM</button>
@@ -426,6 +443,7 @@
426443 >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
427444 >{/each}<option value="Autre…">Autre…</option></select
428445 >{#if addGroup === 'Autre…'}<input
446+ type="text"
429447 aria-label="Nom du groupe"
430448 bind:value={customGroup}
431449 />{/if}<button type="submit">Ajouter</button>
@@ -12,6 +12,14 @@
12 import { chatState } from '$lib/states/chat.svelte';12 import { chatState } from '$lib/states/chat.svelte';
13 import { desktop } from '$lib/states/desktop.svelte';13 import { desktop } from '$lib/states/desktop.svelte';
14 import XpWindow from './xp-window.svelte';14 import XpWindow from './xp-window.svelte';
15+ import { browser } from '$app/environment';
16+
17+ // Dock top-right like the real AIM buddy list, with breathing room above and
18+ // above the taskbar (30px) below.
19+ const BUDDY_LIST_WIDTH = 230;
20+ const buddyListHeight = browser
21+ ? Math.max(360, Math.min(520, window.innerHeight - 30 - 2 * 40))
22+ : 520;
15 23
16 const aim = useAimClient();24 const aim = useAimClient();
17 const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));25 const buddyQuery = authQuery(api.aim.getMyBuddies, () => ({}));
@@ -244,7 +252,15 @@
244 </div>252 </div>
245 {/snippet}253 {/snippet}
246 254
247-<XpWindow id="buddy-list" width={230} height={520} minWidth={230} showMaximize={false}>255+<XpWindow
256+ id="buddy-list"
257+ width={BUDDY_LIST_WIDTH}
258+ height={buddyListHeight}
259+ minWidth={BUDDY_LIST_WIDTH}
260+ showMaximize={false}
261+ x={browser ? Math.max(8, window.innerWidth - BUDDY_LIST_WIDTH - 24) : undefined}
262+ y={browser ? 40 : undefined}
263+>
248 {#snippet menu()}264 {#snippet menu()}
249 <div class="menus" bind:this={menuBar}>265 <div class="menus" bind:this={menuBar}>
250 <div class="menu-wrap">266 <div class="menu-wrap">
@@ -407,6 +423,7 @@
407 >423 >
408 <label for="im-nick">Pseudo du contact</label><input424 <label for="im-nick">Pseudo du contact</label><input
409 id="im-nick"425 id="im-nick"
426+ type="text"
410 bind:value={nickname}427 bind:value={nickname}
411 autocomplete="off"428 autocomplete="off"
412 /><button type="submit">Ouvrir IM</button>429 /><button type="submit">Ouvrir IM</button>
@@ -426,6 +443,7 @@
426 >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option443 >{#each DEFAULT_BUDDY_GROUPS as group (group)}<option value={group}>{group}</option
427 >{/each}<option value="Autre…">Autre…</option></select444 >{/each}<option value="Autre…">Autre…</option></select
428 >{#if addGroup === 'Autre…'}<input445 >{#if addGroup === 'Autre…'}<input
446+ type="text"
429 aria-label="Nom du groupe"447 aria-label="Nom du groupe"
430 bind:value={customGroup}448 bind:value={customGroup}
431 />{/if}<button type="submit">Ajouter</button>449 />{/if}<button type="submit">Ajouter</button>
M src/lib/components/desktop/new-room-window.svelte +8 -1
@@ -42,13 +42,20 @@
4242 <label for="room-name">Nom du salon</label>
4343 <input
4444 id="room-name"
45+ type="text"
4546 maxlength="40"
4647 minlength="2"
4748 bind:value={name}
4849 oninput={() => (error = '')}
4950 />
5051 <label for="room-topic">Sujet (facultatif)</label>
51- <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} />
52+ <input
53+ id="room-topic"
54+ type="text"
55+ maxlength="120"
56+ bind:value={topic}
57+ oninput={() => (error = '')}
58+ />
5259 {#if error}<span role="alert">{error}</span>{/if}
5360 {:else}
5461 <p>Connectez-vous pour créer un salon.</p>
@@ -42,13 +42,20 @@
42 <label for="room-name">Nom du salon</label>42 <label for="room-name">Nom du salon</label>
43 <input43 <input
44 id="room-name"44 id="room-name"
45+ type="text"
45 maxlength="40"46 maxlength="40"
46 minlength="2"47 minlength="2"
47 bind:value={name}48 bind:value={name}
48 oninput={() => (error = '')}49 oninput={() => (error = '')}
49 />50 />
50 <label for="room-topic">Sujet (facultatif)</label>51 <label for="room-topic">Sujet (facultatif)</label>
51- <input id="room-topic" maxlength="120" bind:value={topic} oninput={() => (error = '')} />52+ <input
53+ id="room-topic"
54+ type="text"
55+ maxlength="120"
56+ bind:value={topic}
57+ oninput={() => (error = '')}
58+ />
52 {#if error}<span role="alert">{error}</span>{/if}59 {#if error}<span role="alert">{error}</span>{/if}
53 {:else}60 {:else}
54 <p>Connectez-vous pour créer un salon.</p>61 <p>Connectez-vous pour créer un salon.</p>
M src/lib/components/desktop/sign-on-window.svelte +1 -0
@@ -101,6 +101,7 @@
101101 playSound('welcome');
102102 signedOnAs = name;
103103 desktop.openBuddyList();
104+ desktop.focus(WINDOW_ID);
104105 await wait(1200);
105106 desktop.close(WINDOW_ID);
106107 } finally {
@@ -101,6 +101,7 @@
101 playSound('welcome');101 playSound('welcome');
102 signedOnAs = name;102 signedOnAs = name;
103 desktop.openBuddyList();103 desktop.openBuddyList();
104+ desktop.focus(WINDOW_ID);
104 await wait(1200);105 await wait(1200);
105 desktop.close(WINDOW_ID);106 desktop.close(WINDOW_ID);
106 } finally {107 } finally {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close