Skip to content

Commit 94d21fa

Browse files
committed
feat: mute toggle, persisted preferences, onboarding polish
- Sound mute toggle in the system tray, persisted; muted floppy-seek also skips its pacing delay - CRT preference persists across visits - Settings: 'Verify GitHub Account' button (cheap 1-page fetch) closes the validation gap for the most error-prone input; explicit copy that the AI key is optional (language-only map is a supported mode) - D7: 'Remember API key on this device' checkbox — unchecked keeps the key in memory only, persistence strips it - Help: Ollama/OLLAMA_ORIGINS guidance, keyboard shortcuts, 500-star cap and layout-toggle documentation https://claude.ai/code/session_01K7SFMH22xz6AJwkMgtqH7E
1 parent 92f242f commit 94d21fa

5 files changed

Lines changed: 133 additions & 13 deletions

File tree

src/App.jsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ const DEFAULT_SETTINGS = {
3030
provider: 'gemini',
3131
apiKey: '',
3232
model: 'gemini-2.5-flash',
33-
customUrl: ''
33+
customUrl: '',
34+
persistApiKey: true
3435
};
3536

3637
export default function App() {
37-
// 1. Desktop & CRT State
38-
const [crtEnabled, setCrtEnabled] = useState(true);
38+
// 1. Desktop & CRT State (CRT and mute preferences persist across visits)
39+
const [crtEnabled, setCrtEnabled] = useState(() =>
40+
storage.read('crt', true, (v) => typeof v === 'boolean')
41+
);
42+
const [muted, setMuted] = useState(() => audio.isMuted());
3943
const [isShutdown, setIsShutdown] = useState(false);
4044
const [selectedIcon, setSelectedIcon] = useState(null);
4145

@@ -86,7 +90,11 @@ export default function App() {
8690

8791
const handleSaveSettings = (newSettings) => {
8892
setSettings(newSettings);
89-
storage.write('settings', newSettings);
93+
// Session-only key option (D7): the key lives in React state either way,
94+
// but is stripped from persistence when the user opts out
95+
storage.write('settings', newSettings.persistApiKey
96+
? newSettings
97+
: { ...newSettings, apiKey: '' });
9098

9199
// Clear data cache if username changed to force re-fetch
92100
if (newSettings.username !== settings.username) {
@@ -316,6 +324,14 @@ export default function App() {
316324
onToggleCrt={() => {
317325
audio.playClick();
318326
setCrtEnabled(!crtEnabled);
327+
storage.write('crt', !crtEnabled);
328+
}}
329+
muted={muted}
330+
onToggleMute={() => {
331+
const next = !muted;
332+
audio.setMuted(next);
333+
setMuted(next);
334+
if (!next) audio.playClick(); // audible confirmation on unmute
319335
}}
320336
onShutdown={() => {
321337
audio.playError();

src/components/HelpWindow.jsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ export default function HelpWindow({ onClose }) {
5353
<li>
5454
<strong>Local Guardrails:</strong> Our built-in rate limiter monitors your requests per-minute and per-day in local storage, blocking requests client-side before they can reach the limits.
5555
</li>
56+
<li>
57+
<strong>Local AI (Ollama):</strong> Pick the Custom provider and point it at your local endpoint (e.g. <code style={{ fontFamily: 'var(--font-mono)' }}>http://localhost:11434/v1/chat/completions</code>). Start Ollama with <code style={{ fontFamily: 'var(--font-mono)' }}>OLLAMA_ORIGINS</code> set so the browser may call it. No key needed.
58+
</li>
59+
</ul>
60+
</fieldset>
61+
62+
{/* Keyboard & windows */}
63+
<fieldset className="win95-raised" style={{ padding: '10px', marginBottom: '12px' }}>
64+
<legend style={{ padding: '0 4px', fontWeight: 'bold' }}>⌨️ Windows & Shortcuts</legend>
65+
<ul style={{ paddingLeft: '18px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
66+
<li>Drag windows by the title bar; resize from the bottom-right grip; double-click the title bar to maximize.</li>
67+
<li><strong>Esc</strong> closes the active window · <strong>F6</strong> cycles window focus.</li>
68+
<li>The map shows your <strong>500 most recent stars</strong> at most; toggle 🕸️ Web / 🌀 Force layouts inside the map; press <strong>Enter</strong> in the search box to fly to a match.</li>
5669
</ul>
5770
</fieldset>
5871

src/components/SettingsWindow.jsx

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from 'react';
22
import { audio } from '../utils/audio';
33
import { aiRouter } from '../services/aiRouter';
4+
import { fetchStarredRepos } from '../services/github';
45

56
export default function SettingsWindow({ settings, onSave, onClose }) {
67
const [username, setUsername] = useState(settings.username || '');
@@ -10,9 +11,11 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
1011
const [apiKey, setApiKey] = useState(settings.apiKey || '');
1112
const [model, setModel] = useState(settings.model || 'gemini-2.5-flash');
1213
const [customUrl, setCustomUrl] = useState(settings.customUrl || '');
14+
const [persistApiKey, setPersistApiKey] = useState(settings.persistApiKey !== false);
1315

1416
// Testing connection state
1517
const [testStatus, setTestStatus] = useState({ state: 'idle', message: '' });
18+
const [ghStatus, setGhStatus] = useState({ state: 'idle', message: '' });
1619

1720
// Re-sync the form when the settings object changes from outside (e.g.
1821
// Reset All). Render-time adjustment per React docs — avoids the
@@ -27,6 +30,7 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
2730
setApiKey(settings.apiKey || '');
2831
setModel(settings.model || 'gemini-2.5-flash');
2932
setCustomUrl(settings.customUrl || '');
33+
setPersistApiKey(settings.persistApiKey !== false);
3034
}
3135

3236
const handleSave = (e) => {
@@ -39,10 +43,34 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
3943
provider,
4044
apiKey,
4145
model,
42-
customUrl
46+
customUrl,
47+
persistApiKey
4348
});
4449
};
4550

51+
const handleVerifyGitHub = async () => {
52+
if (!username) {
53+
setGhStatus({ state: 'error', message: 'Enter a GitHub username first!' });
54+
audio.playError();
55+
return;
56+
}
57+
setGhStatus({ state: 'loading', message: 'Checking GitHub account...' });
58+
audio.playClick();
59+
try {
60+
const repos = await fetchStarredRepos(username, githubToken, 1);
61+
setGhStatus({
62+
state: 'success',
63+
message: repos.length > 0
64+
? `Account found — stars are accessible.`
65+
: 'Account found, but it has no public stars yet.'
66+
});
67+
audio.playSuccess();
68+
} catch (err) {
69+
setGhStatus({ state: 'error', message: err.message });
70+
audio.playError();
71+
}
72+
};
73+
4674
const handleTestConnection = async () => {
4775
if (!apiKey) {
4876
setTestStatus({ state: 'error', message: 'Enter an API key first!' });
@@ -104,6 +132,26 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
104132
Increases rate limit from 60 to 5000 requests/hr. Required for private stars.
105133
</span>
106134
</div>
135+
<div className="field-group">
136+
<button
137+
type="button"
138+
className="win95-btn"
139+
onClick={handleVerifyGitHub}
140+
disabled={ghStatus.state === 'loading'}
141+
style={{ padding: '4px 10px' }}
142+
>
143+
{ghStatus.state === 'loading' ? 'Verifying...' : '✓ Verify GitHub Account'}
144+
</button>
145+
{ghStatus.state !== 'idle' && ghStatus.state !== 'loading' && (
146+
<span style={{
147+
fontSize: '10px',
148+
marginLeft: '6px',
149+
color: ghStatus.state === 'success' ? '#006600' : '#cc0000'
150+
}}>
151+
{ghStatus.state === 'success' ? '✓ ' : '❌ '}{ghStatus.message}
152+
</span>
153+
)}
154+
</div>
107155
<div className="field-group">
108156
<label htmlFor="gh-limit">Max Stars to Visualize:</label>
109157
<select
@@ -128,7 +176,10 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
128176
{/* Universal AI Router Config */}
129177
<fieldset className="win95-raised" style={{ padding: '10px', margin: '5px 0' }}>
130178
<legend style={{ padding: '0 5px', fontSize: '12px', fontWeight: 'bold' }}>Universal AI Settings</legend>
131-
179+
<span style={{ fontSize: '10px', color: '#666', display: 'block', marginBottom: '6px' }}>
180+
Optional — without an AI key you still get a working map, grouped by programming language (no AI categories or semantic links).
181+
</span>
182+
132183
<div className="field-group">
133184
<label htmlFor="ai-provider">AI Provider:</label>
134185
<select
@@ -163,6 +214,14 @@ export default function SettingsWindow({ settings, onSave, onClose }) {
163214
value={apiKey}
164215
onChange={(e) => setApiKey(e.target.value)}
165216
/>
217+
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '10px', color: '#444', marginTop: '4px', cursor: 'pointer' }}>
218+
<input
219+
type="checkbox"
220+
checked={persistApiKey}
221+
onChange={(e) => setPersistApiKey(e.target.checked)}
222+
/>
223+
Remember API key on this device (uncheck to keep it for this session only)
224+
</label>
166225
</div>
167226

168227
<div className="field-group">

src/components/Taskbar.jsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { useState, useEffect } from 'react';
22
import { audio } from '../utils/audio';
33

4-
export default function Taskbar({
5-
windows,
6-
onToggleWindow,
7-
onFocusWindow,
8-
crtEnabled,
9-
onToggleCrt,
4+
export default function Taskbar({
5+
windows,
6+
onToggleWindow,
7+
onFocusWindow,
8+
crtEnabled,
9+
onToggleCrt,
10+
muted,
11+
onToggleMute,
1012
onShutdown,
11-
onResetAll
13+
onResetAll
1214
}) {
1315
const [startMenuOpen, setStartMenuOpen] = useState(false);
1416
const [timeStr, setTimeStr] = useState('');
@@ -181,6 +183,20 @@ export default function Taskbar({
181183
>
182184
📺 CRT: {crtEnabled ? 'ON' : 'OFF'}
183185
</button>
186+
{/* Sound mute toggle */}
187+
<button
188+
className="win95-btn"
189+
aria-label={muted ? 'Unmute sounds' : 'Mute sounds'}
190+
style={{
191+
fontSize: '10px',
192+
padding: '1px 4px',
193+
height: '20px',
194+
borderStyle: muted ? 'inset' : 'outset'
195+
}}
196+
onClick={onToggleMute}
197+
>
198+
{muted ? '🔇' : '🔊'}
199+
</button>
184200
<span style={{ fontFamily: 'var(--font-mono)' }}>{timeStr}</span>
185201
</div>
186202

src/utils/audio.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { storage } from '../services/storage';
2+
13
let audioCtx = null;
4+
let muted = storage.read('muted', false, (v) => typeof v === 'boolean');
25

36
function getAudioContext() {
47
if (!audioCtx) {
@@ -14,7 +17,17 @@ function getAudioContext() {
1417
}
1518

1619
export const audio = {
20+
isMuted() {
21+
return muted;
22+
},
23+
24+
setMuted(value) {
25+
muted = !!value;
26+
storage.write('muted', muted);
27+
},
28+
1729
playClick() {
30+
if (muted) return;
1831
try {
1932
const ctx = getAudioContext();
2033
const osc = ctx.createOscillator();
@@ -37,6 +50,7 @@ export const audio = {
3750
},
3851

3952
playSuccess() {
53+
if (muted) return;
4054
try {
4155
const ctx = getAudioContext();
4256
const now = ctx.currentTime;
@@ -61,6 +75,7 @@ export const audio = {
6175
},
6276

6377
playError() {
78+
if (muted) return;
6479
try {
6580
const ctx = getAudioContext();
6681
const now = ctx.currentTime;
@@ -83,6 +98,7 @@ export const audio = {
8398
},
8499

85100
async playFloppySeek(durationMs = 1500) {
101+
if (muted) return; // skip the pacing delay too — silence shouldn't slow indexing
86102
try {
87103
const ctx = getAudioContext();
88104
const steps = Math.floor(durationMs / 120);

0 commit comments

Comments
 (0)