@ifc-lite/viewer 1.17.4 → 1.17.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +16 -16
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +117 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-86rgogji.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-CcPS9MK5.js} +2274 -2227
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-BFUYA08u.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-Bfms9I4A.js} +35160 -33084
  12. package/dist/assets/index-_bfZsDCC.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-DUyLCMZS.js} +104 -104
  14. package/dist/assets/{sandbox-DZiNLNMk.js → sandbox-C8575tul.js} +4340 -4322
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-BuZK7OST.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-JsqEGDV8.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +7 -7
  20. package/src/App.tsx +16 -2
  21. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  22. package/src/components/viewer/ChatPanel.tsx +195 -91
  23. package/src/components/viewer/MainToolbar.tsx +4 -3
  24. package/src/components/viewer/PropertiesPanel.tsx +16 -2
  25. package/src/components/viewer/SettingsPage.tsx +252 -101
  26. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  27. package/src/components/viewer/ViewerLayout.tsx +1 -0
  28. package/src/components/viewer/Viewport.tsx +14 -2
  29. package/src/components/viewer/ViewportContainer.tsx +49 -64
  30. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  31. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  32. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  33. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  34. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  35. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  36. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  37. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  38. package/src/components/viewer/useAnimationLoop.ts +9 -1
  39. package/src/components/viewer/useRenderUpdates.ts +1 -1
  40. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  41. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  42. package/src/hooks/useIfcFederation.ts +326 -71
  43. package/src/hooks/useIfcLoader.ts +1 -0
  44. package/src/hooks/useViewControls.ts +13 -5
  45. package/src/index.css +484 -10
  46. package/src/lib/desktop-entitlement.ts +2 -4
  47. package/src/lib/geo/cesium-bridge.ts +15 -7
  48. package/src/lib/geo/effective-georef.test.ts +73 -0
  49. package/src/lib/geo/effective-georef.ts +111 -0
  50. package/src/lib/geo/reproject.ts +105 -19
  51. package/src/lib/llm/byok-guard.test.ts +77 -0
  52. package/src/lib/llm/byok-guard.ts +39 -0
  53. package/src/lib/llm/free-models.test.ts +0 -6
  54. package/src/lib/llm/models.ts +104 -42
  55. package/src/lib/llm/stream-client.ts +74 -110
  56. package/src/lib/llm/stream-direct.test.ts +130 -0
  57. package/src/lib/llm/stream-direct.ts +316 -0
  58. package/src/lib/llm/types.ts +14 -2
  59. package/src/main.tsx +1 -10
  60. package/src/services/api-keys.ts +73 -0
  61. package/src/store/constants.ts +20 -2
  62. package/src/store/index.ts +12 -5
  63. package/src/store/slices/cesiumSlice.ts +5 -0
  64. package/src/store/slices/chatSlice.test.ts +6 -76
  65. package/src/store/slices/chatSlice.ts +17 -58
  66. package/src/store/slices/sectionSlice.test.ts +87 -7
  67. package/src/store/slices/sectionSlice.ts +151 -5
  68. package/src/store/slices/uiSlice.ts +28 -5
  69. package/src/store/types.ts +26 -0
  70. package/src/utils/nativeSpatialDataStore.ts +4 -1
  71. package/src/utils/viewportUtils.ts +7 -2
  72. package/src/vite-env.d.ts +0 -4
  73. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  74. package/dist/assets/ids-B4jTqB1O.js +0 -1
  75. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  76. package/dist/assets/index-DckuDqlv.css +0 -1
  77. package/src/components/viewer/UpgradePage.tsx +0 -71
  78. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  79. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  80. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -2,23 +2,28 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { SignInButton, SignedIn, SignedOut, UserButton, useAuth, useUser } from '@clerk/clerk-react';
6
5
  import {
7
6
  ArrowLeft,
8
7
  Bot,
9
8
  Check,
9
+ ChevronDown,
10
+ ChevronUp,
10
11
  Clock3,
11
12
  Cloud,
12
13
  CreditCard,
14
+ ExternalLink,
15
+ Eye,
16
+ EyeOff,
13
17
  FolderOpen,
18
+ Key,
14
19
  LayoutPanelTop,
15
20
  Lock,
16
- RefreshCw,
17
21
  Settings2,
18
22
  ShieldCheck,
23
+ Trash2,
19
24
  WifiOff,
20
25
  } from 'lucide-react';
21
- import { useEffect, useMemo, useState } from 'react';
26
+ import { useCallback, useEffect, useMemo, useState } from 'react';
22
27
  import { Badge } from '@/components/ui/badge';
23
28
  import { Button } from '@/components/ui/button';
24
29
  import { Switch } from '@/components/ui/switch';
@@ -33,21 +38,25 @@ import {
33
38
  isDesktopBillingEnforced,
34
39
  type DesktopEntitlement,
35
40
  } from '@/lib/desktop-product';
36
- import { isClerkConfigured } from '@/lib/llm/clerk-auth';
37
41
  import { navigateToPath } from '@/services/app-navigation';
38
- import { requestDesktopEntitlementRefresh } from '@/lib/desktop/desktopEntitlementEvents';
39
42
  import {
40
43
  getDesktopPreferences,
41
44
  subscribeDesktopPreferences,
42
45
  updateDesktopPreferences,
43
46
  } from '@/services/desktop-preferences';
47
+ import {
48
+ getApiKeys,
49
+ updateApiKeys,
50
+ clearApiKeys,
51
+ subscribeApiKeys,
52
+ type ApiKeyConfig,
53
+ } from '@/services/api-keys';
44
54
 
45
55
  export function SettingsPage() {
46
- const clerkEnabled = isClerkConfigured();
47
56
  const desktopEntitlement = useViewerStore((s) => s.desktopEntitlement);
48
57
  const chatUsage = useViewerStore((s) => s.chatUsage);
49
58
  const [preferences, setPreferences] = useState(() => getDesktopPreferences());
50
- const [isRefreshingAccount, setIsRefreshingAccount] = useState(false);
59
+ const [apiKeys, setApiKeys] = useState(() => getApiKeys());
51
60
  const returnTo = (() => {
52
61
  const params = new URLSearchParams(window.location.search);
53
62
  const candidate = params.get('returnTo');
@@ -56,6 +65,9 @@ export function SettingsPage() {
56
65
  useEffect(() => subscribeDesktopPreferences(() => {
57
66
  setPreferences(getDesktopPreferences());
58
67
  }), []);
68
+ useEffect(() => subscribeApiKeys(() => {
69
+ setApiKeys(getApiKeys());
70
+ }), []);
59
71
 
60
72
  const updatePreference = (updates: Partial<typeof preferences>) => {
61
73
  setPreferences(updateDesktopPreferences(updates));
@@ -75,19 +87,6 @@ export function SettingsPage() {
75
87
  return `${chatUsage.used}/${chatUsage.limit} ${unit} used. Resets ${resetLabel}.`;
76
88
  }, [chatUsage]);
77
89
 
78
- const handleRefreshAccount = async () => {
79
- setIsRefreshingAccount(true);
80
- try {
81
- await requestDesktopEntitlementRefresh();
82
- toast.success('Account status refreshed');
83
- } catch (error) {
84
- const message = error instanceof Error ? error.message : 'Could not refresh account status';
85
- toast.error(message);
86
- } finally {
87
- setIsRefreshingAccount(false);
88
- }
89
- };
90
-
91
90
  return (
92
91
  <div className="min-h-screen bg-background text-foreground">
93
92
  <div className="mx-auto w-full max-w-4xl px-6 py-8">
@@ -99,6 +98,9 @@ export function SettingsPage() {
99
98
  </div>
100
99
 
101
100
  <div className="space-y-6">
101
+ {/* API Keys Section */}
102
+ <ApiKeysSection apiKeys={apiKeys} />
103
+
102
104
  <section className="rounded-lg border bg-card p-6 shadow-sm">
103
105
  <div className="mb-5 flex items-center gap-3">
104
106
  <ShieldCheck className="h-5 w-5" />
@@ -127,8 +129,8 @@ export function SettingsPage() {
127
129
  icon={<CreditCard className="h-4 w-4" />}
128
130
  />
129
131
  <InfoCard
130
- title="AI Usage"
131
- body={usageSummary ?? 'No AI usage data yet. Pro usage appears after the first synced usage snapshot.'}
132
+ title="AI Usage (Free Tier)"
133
+ body={usageSummary ?? 'No AI usage data yet. Usage appears after the first chat message through the proxy.'}
132
134
  icon={<Bot className="h-4 w-4" />}
133
135
  />
134
136
  <InfoCard
@@ -142,24 +144,6 @@ export function SettingsPage() {
142
144
  icon={<WifiOff className="h-4 w-4" />}
143
145
  />
144
146
  </div>
145
-
146
- {clerkEnabled ? (
147
- <div className="flex flex-wrap gap-3">
148
- <Button
149
- variant="outline"
150
- onClick={() => void handleRefreshAccount()}
151
- disabled={isRefreshingAccount}
152
- >
153
- <RefreshCw className={`mr-2 h-4 w-4 ${isRefreshingAccount ? 'animate-spin' : ''}`} />
154
- Refresh Account Status
155
- </Button>
156
- {!hasDesktopPro(desktopEntitlement) ? (
157
- <Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
158
- Upgrade to Pro
159
- </Button>
160
- ) : null}
161
- </div>
162
- ) : null}
163
147
  </section>
164
148
 
165
149
  <section className="rounded-lg border bg-card p-6 shadow-sm">
@@ -225,7 +209,7 @@ export function SettingsPage() {
225
209
  <div>
226
210
  <h2 className="text-xl font-semibold">Billing & Features</h2>
227
211
  <p className="text-sm text-muted-foreground">
228
- Desktop billing is app-wide. The viewer stays available on Free, while Pro unlocks advanced desktop features and full AI access.
212
+ Desktop billing is app-wide. The viewer stays available on Free, while Pro unlocks advanced desktop features.
229
213
  </p>
230
214
  </div>
231
215
  </div>
@@ -235,9 +219,6 @@ export function SettingsPage() {
235
219
  <div>
236
220
  <div className="font-medium capitalize">{planTier} plan</div>
237
221
  <p className="text-sm text-muted-foreground">{planSummary}</p>
238
- <p className="text-sm text-muted-foreground">
239
- Desktop caches the latest validated plan locally and stores the auth bearer in native secure storage when available.
240
- </p>
241
222
  </div>
242
223
  {isDesktopBillingEnforced() && !hasDesktopPro(desktopEntitlement) && (
243
224
  <Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
@@ -270,14 +251,6 @@ export function SettingsPage() {
270
251
  </div>
271
252
  ))}
272
253
  </div>
273
-
274
- {!clerkEnabled ? (
275
- <div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
276
- Auth and billing are not configured in this build. Set `VITE_CLERK_PUBLISHABLE_KEY` to enable sign-in and subscription flows.
277
- </div>
278
- ) : (
279
- <SettingsAccountSection desktopEntitlement={desktopEntitlement} />
280
- )}
281
254
  </section>
282
255
 
283
256
  <section className="rounded-lg border bg-card p-6 shadow-sm">
@@ -299,7 +272,7 @@ export function SettingsPage() {
299
272
  />
300
273
  <InfoCard
301
274
  title="Needs Network"
302
- body="Cloud AI assistant, billing sync, and live bSDD lookups require network access. Cached Pro can continue during offline grace."
275
+ body="AI assistant (free via proxy, or direct with your API key), live bSDD lookups, and billing sync require network access."
303
276
  icon={<Cloud className="h-4 w-4" />}
304
277
  />
305
278
  </div>
@@ -310,6 +283,231 @@ export function SettingsPage() {
310
283
  );
311
284
  }
312
285
 
286
+ // ── API Keys Section ──────────────────────────────────────────────────────
287
+
288
+ function ApiKeyInput({
289
+ id,
290
+ value,
291
+ onChange,
292
+ onSave,
293
+ placeholder,
294
+ isSaved,
295
+ }: {
296
+ id: string;
297
+ value: string;
298
+ onChange: (v: string) => void;
299
+ onSave: () => void;
300
+ placeholder: string;
301
+ isSaved: boolean;
302
+ }) {
303
+ const [show, setShow] = useState(false);
304
+ return (
305
+ <div className="flex gap-2">
306
+ <div className="relative flex-1">
307
+ <input
308
+ id={id}
309
+ type={show ? 'text' : 'password'}
310
+ value={value}
311
+ onChange={(e) => onChange(e.target.value)}
312
+ onKeyDown={(e) => { if (e.key === 'Enter' && !isSaved) onSave(); }}
313
+ placeholder={placeholder}
314
+ autoComplete="off"
315
+ spellCheck={false}
316
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-8"
317
+ />
318
+ <button
319
+ type="button"
320
+ onClick={() => setShow(!show)}
321
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
322
+ aria-label={show ? 'Hide key' : 'Show key'}
323
+ >
324
+ {show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
325
+ </button>
326
+ </div>
327
+ <Button size="sm" onClick={onSave} disabled={isSaved}>
328
+ Save
329
+ </Button>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ function ApiKeysSection({ apiKeys }: { apiKeys: ApiKeyConfig }) {
335
+ const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropicKey);
336
+ const [openaiKey, setOpenaiKey] = useState(apiKeys.openaiKey);
337
+ const [showHowTo, setShowHowTo] = useState<'anthropic' | 'openai' | null>(null);
338
+
339
+ useEffect(() => {
340
+ setAnthropicKey(apiKeys.anthropicKey);
341
+ setOpenaiKey(apiKeys.openaiKey);
342
+ }, [apiKeys.anthropicKey, apiKeys.openaiKey]);
343
+
344
+ const saveAnthropicKey = useCallback(() => {
345
+ updateApiKeys({ anthropicKey: anthropicKey.trim() });
346
+ toast.success(anthropicKey.trim() ? 'Anthropic API key saved' : 'Anthropic API key removed');
347
+ }, [anthropicKey]);
348
+
349
+ const saveOpenaiKey = useCallback(() => {
350
+ updateApiKeys({ openaiKey: openaiKey.trim() });
351
+ toast.success(openaiKey.trim() ? 'OpenAI API key saved' : 'OpenAI API key removed');
352
+ }, [openaiKey]);
353
+
354
+ const handleClearAll = useCallback(() => {
355
+ clearApiKeys();
356
+ setAnthropicKey('');
357
+ setOpenaiKey('');
358
+ toast.success('All API keys removed');
359
+ }, []);
360
+
361
+ const hasAnyKey = apiKeys.anthropicKey.length > 0 || apiKeys.openaiKey.length > 0;
362
+
363
+ return (
364
+ <section className="rounded-lg border bg-card p-6 shadow-sm">
365
+ <div className="mb-5 flex items-center gap-3">
366
+ <Key className="h-5 w-5" />
367
+ <div>
368
+ <h1 className="text-2xl font-semibold">API Keys</h1>
369
+ <p className="text-sm text-muted-foreground">
370
+ Bring your own Anthropic or OpenAI API key to unlock additional models.
371
+ You can configure one or both providers independently.
372
+ </p>
373
+ </div>
374
+ </div>
375
+
376
+ <div className="space-y-4">
377
+ {/* Anthropic */}
378
+ <div className="rounded-md border p-4">
379
+ <div className="mb-2 flex items-center justify-between">
380
+ <label className="text-sm font-medium" htmlFor="anthropic-key">Anthropic API Key</label>
381
+ {apiKeys.anthropicKey ? (
382
+ <Badge variant="default" className="text-[10px]">
383
+ <Check className="mr-1 h-3 w-3" />
384
+ Configured
385
+ </Badge>
386
+ ) : (
387
+ <Badge variant="outline" className="text-[10px]">Not set</Badge>
388
+ )}
389
+ </div>
390
+ <p className="mb-2 text-xs text-muted-foreground">
391
+ Unlocks <strong>Claude Opus 4.6</strong>, <strong>Claude Sonnet 4.6</strong>, and <strong>Claude Haiku 4.5</strong>.
392
+ </p>
393
+ <button
394
+ type="button"
395
+ onClick={() => setShowHowTo(showHowTo === 'anthropic' ? null : 'anthropic')}
396
+ className="mb-3 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
397
+ >
398
+ {showHowTo === 'anthropic' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
399
+ How to get an Anthropic API key
400
+ </button>
401
+ {showHowTo === 'anthropic' && (
402
+ <div className="mb-3 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1.5">
403
+ <p>1. Go to{' '}
404
+ <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
405
+ console.anthropic.com/settings/keys <ExternalLink className="h-2.5 w-2.5" />
406
+ </a>
407
+ </p>
408
+ <p>2. Sign in or create an Anthropic account</p>
409
+ <p>3. Click <strong>Create Key</strong>, name it (e.g. &quot;ifc-lite&quot;)</p>
410
+ <p>4. Copy the key (starts with <code className="bg-muted px-1 rounded">sk-ant-api03-...</code>)</p>
411
+ <p>5. Paste it below and click Save</p>
412
+ <p className="pt-1 text-muted-foreground/70">Anthropic offers $5 free credit on new accounts. After that, usage is pay-as-you-go on your Anthropic billing.</p>
413
+ </div>
414
+ )}
415
+ <ApiKeyInput
416
+ id="anthropic-key"
417
+ value={anthropicKey}
418
+ onChange={setAnthropicKey}
419
+ onSave={saveAnthropicKey}
420
+ placeholder="sk-ant-api03-..."
421
+ isSaved={anthropicKey.trim() === apiKeys.anthropicKey}
422
+ />
423
+ </div>
424
+
425
+ {/* OpenAI */}
426
+ <div className="rounded-md border p-4">
427
+ <div className="mb-2 flex items-center justify-between">
428
+ <label className="text-sm font-medium" htmlFor="openai-key">OpenAI API Key</label>
429
+ {apiKeys.openaiKey ? (
430
+ <Badge variant="default" className="text-[10px]">
431
+ <Check className="mr-1 h-3 w-3" />
432
+ Configured
433
+ </Badge>
434
+ ) : (
435
+ <Badge variant="outline" className="text-[10px]">Not set</Badge>
436
+ )}
437
+ </div>
438
+ <p className="mb-2 text-xs text-muted-foreground">
439
+ Unlocks <strong>GPT-5.4</strong>, <strong>GPT-5.3 Codex</strong>, and <strong>GPT-5.4 Mini</strong>.
440
+ </p>
441
+ <button
442
+ type="button"
443
+ onClick={() => setShowHowTo(showHowTo === 'openai' ? null : 'openai')}
444
+ className="mb-3 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
445
+ >
446
+ {showHowTo === 'openai' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
447
+ How to get an OpenAI API key
448
+ </button>
449
+ {showHowTo === 'openai' && (
450
+ <div className="mb-3 rounded-md bg-muted/50 p-3 text-xs text-muted-foreground space-y-1.5">
451
+ <p>1. Go to{' '}
452
+ <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
453
+ platform.openai.com/api-keys <ExternalLink className="h-2.5 w-2.5" />
454
+ </a>
455
+ </p>
456
+ <p>2. Sign in or create an OpenAI account</p>
457
+ <p>3. Click <strong>Create new secret key</strong>, name it (e.g. &quot;ifc-lite&quot;)</p>
458
+ <p>4. Copy the key (starts with <code className="bg-muted px-1 rounded">sk-...</code>)</p>
459
+ <p>5. Paste it below and click Save</p>
460
+ <p className="pt-1 text-muted-foreground/70">OpenAI requires prepaid credits or a payment method. Usage is billed to your OpenAI account.</p>
461
+ </div>
462
+ )}
463
+ <ApiKeyInput
464
+ id="openai-key"
465
+ value={openaiKey}
466
+ onChange={setOpenaiKey}
467
+ onSave={saveOpenaiKey}
468
+ placeholder="sk-..."
469
+ isSaved={openaiKey.trim() === apiKeys.openaiKey}
470
+ />
471
+ </div>
472
+
473
+ {hasAnyKey && (
474
+ <div className="flex justify-end">
475
+ <Button variant="outline" size="sm" onClick={handleClearAll}>
476
+ <Trash2 className="mr-2 h-3.5 w-3.5" />
477
+ Remove all keys
478
+ </Button>
479
+ </div>
480
+ )}
481
+
482
+ {/* Security & Privacy notice */}
483
+ <div className="rounded-md border border-dashed p-4 text-xs space-y-2">
484
+ <p className="font-medium text-foreground">Your API keys never leave your machine.</p>
485
+ <ul className="list-disc pl-4 space-y-1 text-muted-foreground">
486
+ <li>Keys are stored in your browser&apos;s <code className="bg-muted px-1 rounded">localStorage</code> and persist across page reloads.</li>
487
+ <li>When you use a BYOK model, requests go <strong>directly from your browser to the provider</strong> (Anthropic or OpenAI). They never pass through our servers.</li>
488
+ <li>Free models use our server proxy and do not require any API key.</li>
489
+ <li>Clearing your browser data or clicking &quot;Remove all keys&quot; above permanently deletes them.</li>
490
+ </ul>
491
+ <p className="text-muted-foreground pt-1">
492
+ <strong>Verify in your browser console:</strong> open DevTools (F12), go to the Console tab, and run:
493
+ </p>
494
+ <pre className="bg-muted rounded px-2 py-1.5 font-mono text-[11px] overflow-x-auto text-foreground">
495
+ {`JSON.parse(localStorage.getItem('ifc-lite:api-keys:v1') ?? '{}')`}
496
+ </pre>
497
+ <p className="text-muted-foreground">
498
+ This shows exactly what is stored. Only you can see it. To delete everything:
499
+ </p>
500
+ <pre className="bg-muted rounded px-2 py-1.5 font-mono text-[11px] overflow-x-auto text-foreground">
501
+ {`localStorage.removeItem('ifc-lite:api-keys:v1')`}
502
+ </pre>
503
+ </div>
504
+ </div>
505
+ </section>
506
+ );
507
+ }
508
+
509
+ // ── Helpers ──────────────────────────────────────────────────────────────
510
+
313
511
  function formatTimestamp(value: number | null): string {
314
512
  if (!value) {
315
513
  return 'Not validated yet';
@@ -342,7 +540,7 @@ function describeDesktopStatus(entitlement: DesktopEntitlement): string {
342
540
  case 'signed_out':
343
541
  return 'Signed out';
344
542
  case 'anonymous':
345
- return 'Auth unavailable in this build';
543
+ return 'No account configured';
346
544
  default:
347
545
  return entitlement.status;
348
546
  }
@@ -381,50 +579,3 @@ function InfoCard({ title, body, icon }: { title: string; body: string; icon: Re
381
579
  </div>
382
580
  );
383
581
  }
384
-
385
- function SettingsAccountSection({ desktopEntitlement }: { desktopEntitlement: DesktopEntitlement }) {
386
- const { isSignedIn } = useAuth();
387
- const { user } = useUser();
388
- const hasPro = hasDesktopPro(desktopEntitlement);
389
- const statusLabel = describeDesktopStatus(desktopEntitlement);
390
-
391
- return (
392
- <div className="space-y-4">
393
- <SignedOut>
394
- <div className="rounded-md border p-4">
395
- <p className="mb-3 text-sm text-muted-foreground">
396
- Sign in to sync your desktop plan, subscription status, and AI usage limits across web and desktop.
397
- </p>
398
- <SignInButton mode="modal" forceRedirectUrl="/settings" fallbackRedirectUrl="/settings">
399
- <Button>Sign in</Button>
400
- </SignInButton>
401
- </div>
402
- </SignedOut>
403
-
404
- <SignedIn>
405
- <div className="flex items-center justify-between gap-4 rounded-md border p-4">
406
- <div>
407
- <div className="font-medium">
408
- {user?.primaryEmailAddress?.emailAddress ?? user?.username ?? 'Signed in'}
409
- </div>
410
- <p className="text-sm text-muted-foreground">
411
- Plan: {hasPro ? 'Pro' : 'Free'}
412
- </p>
413
- <p className="text-sm text-muted-foreground">
414
- Status: {statusLabel}
415
- </p>
416
- <p className="text-sm text-muted-foreground">
417
- Last validated: {formatTimestamp(desktopEntitlement.validatedAt)}
418
- </p>
419
- </div>
420
- <div className="flex items-center gap-3">
421
- <UserButton afterSignOutUrl="/" />
422
- <Button onClick={() => navigateToPath(buildDesktopUpgradeUrl('/settings'))}>
423
- {isSignedIn && hasPro ? 'Manage Plan' : 'Upgrade to Pro'}
424
- </Button>
425
- </div>
426
- </div>
427
- </SignedIn>
428
- </div>
429
- );
430
- }
@@ -2,7 +2,7 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { useEffect, useRef } from 'react';
5
+ import { useEffect, useRef, useCallback } from 'react';
6
6
  import { ThemeToggle } from 'beautiful-theme-toggle';
7
7
  import { useViewerStore } from '@/store';
8
8
 
@@ -12,10 +12,22 @@ import { useViewerStore } from '@/store';
12
12
  * Bidirectional sync:
13
13
  * - User clicks the widget → onChange → store.setTheme
14
14
  * - External change (keyboard shortcut / command palette) → store updates → widget.setTheme
15
+ *
16
+ * Secret colorful mode:
17
+ * - Hold Shift while clicking → toggles the hidden "colorful" theme
18
+ * - The sun/moon widget can't represent a third state, so it shows "sun" (day vibes)
19
+ * while the colorful gradient takes over the world.
15
20
  */
16
21
  export function ThemeSwitch() {
17
22
  const containerRef = useRef<HTMLDivElement>(null);
18
23
  const toggleRef = useRef<ThemeToggle | null>(null);
24
+ // Track whether Shift was held during the click so we can intercept in onChange
25
+ const shiftHeldRef = useRef(false);
26
+
27
+ // Capture shift key state on pointerdown (fires before the widget's internal click handler)
28
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
29
+ shiftHeldRef.current = e.shiftKey;
30
+ }, []);
19
31
 
20
32
  useEffect(() => {
21
33
  if (!containerRef.current) return;
@@ -25,9 +37,36 @@ export function ThemeSwitch() {
25
37
  const toggle = new ThemeToggle({
26
38
  element: containerRef.current,
27
39
  size: 80,
28
- initialState: currentTheme,
29
- onChange: (state) => {
30
- useViewerStore.getState().setTheme(state);
40
+ // Colorful → show sun (it's a bright/day-ish theme)
41
+ initialState: currentTheme === 'dark' ? 'dark' : 'light',
42
+ onChange: (widgetState) => {
43
+ const store = useViewerStore.getState();
44
+
45
+ if (shiftHeldRef.current) {
46
+ // Secret shift-click: toggle colorful mode
47
+ shiftHeldRef.current = false;
48
+ store.toggleColorful();
49
+
50
+ // Sync widget visual: colorful → sun, otherwise follow the new theme
51
+ const newTheme = useViewerStore.getState().theme;
52
+ const widgetTarget = newTheme === 'dark' ? 'dark' : 'light';
53
+ if (toggleRef.current && toggleRef.current.getTheme() !== widgetTarget) {
54
+ toggleRef.current.setTheme(widgetTarget, false);
55
+ }
56
+ return;
57
+ }
58
+
59
+ // Normal click: dark ↔ light (if colorful, drops to dark)
60
+ shiftHeldRef.current = false;
61
+ store.toggleTheme();
62
+
63
+ // The widget already animated to widgetState, but toggleTheme may have
64
+ // produced a different result (e.g. colorful → dark). Reconcile:
65
+ const newTheme = useViewerStore.getState().theme;
66
+ const expectedWidget = newTheme === 'dark' ? 'dark' : 'light';
67
+ if (toggleRef.current && widgetState !== expectedWidget) {
68
+ toggleRef.current.setTheme(expectedWidget, false);
69
+ }
31
70
  },
32
71
  });
33
72
 
@@ -38,8 +77,9 @@ export function ThemeSwitch() {
38
77
  const unsub = useViewerStore.subscribe((state) => {
39
78
  if (state.theme !== prevTheme) {
40
79
  prevTheme = state.theme;
41
- if (toggleRef.current && toggleRef.current.getTheme() !== state.theme) {
42
- toggleRef.current.setTheme(state.theme, false);
80
+ const widgetTarget = state.theme === 'dark' ? 'dark' : 'light';
81
+ if (toggleRef.current && toggleRef.current.getTheme() !== widgetTarget) {
82
+ toggleRef.current.setTheme(widgetTarget, false);
43
83
  }
44
84
  }
45
85
  });
@@ -51,5 +91,21 @@ export function ThemeSwitch() {
51
91
  };
52
92
  }, []);
53
93
 
54
- return <div ref={containerRef} className="flex items-center cursor-pointer opacity-80 hover:opacity-100 transition-opacity" />;
94
+ const theme = useViewerStore((s) => s.theme);
95
+ const isColorful = theme === 'colorful';
96
+
97
+ return (
98
+ <div
99
+ ref={containerRef}
100
+ onPointerDown={handlePointerDown}
101
+ className={`flex items-center cursor-pointer transition-all duration-300 ${
102
+ isColorful
103
+ ? 'opacity-100 scale-110'
104
+ : 'opacity-80 hover:opacity-100'
105
+ }`}
106
+ style={isColorful ? {
107
+ filter: 'drop-shadow(0 0 6px rgba(157,124,216,0.5)) drop-shadow(0 0 12px rgba(255,158,100,0.25))',
108
+ } : undefined}
109
+ />
110
+ );
55
111
  }
@@ -186,6 +186,7 @@ export function ViewerLayout() {
186
186
  // Keep DOM class in sync when theme changes (initial class is set by inline script in index.html)
187
187
  useEffect(() => {
188
188
  document.documentElement.classList.toggle('dark', theme === 'dark');
189
+ document.documentElement.classList.toggle('colorful', theme === 'colorful');
189
190
  }, [theme]);
190
191
 
191
192
 
@@ -312,7 +312,7 @@ export function Viewport({
312
312
  if (cesiumActive) {
313
313
  clearColorRef.current = [0, 0, 0, 0]; // fully transparent
314
314
  } else {
315
- clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
315
+ clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark' | 'colorful');
316
316
  }
317
317
  rendererRef.current?.requestRender();
318
318
  }, [cesiumActive, theme]);
@@ -878,13 +878,25 @@ export function Viewport({
878
878
  // The model will be rendered by Cesium (as GLB) for correct positioning.
879
879
  // Canvas stays in the DOM for picking/interaction.
880
880
 
881
+ // Colorful mode: transparent WebGPU clear colour + CSS gradient on the
882
+ // canvas element. The gradient is the *CSS background* of the <canvas>;
883
+ // premultiplied-alpha compositing shows it through transparent clear-colour
884
+ // regions while opaque model fragments (alpha=1) stay fully visible.
885
+ const canvasStyle = cesiumActive
886
+ ? { opacity: 0 }
887
+ : theme === 'colorful'
888
+ ? {
889
+ background: 'linear-gradient(180deg, #4a5a8a 0%, #6272a8 10%, #7e8dba 20%, #9aa3c8 32%, #b5b8d1 44%, #cdc3d4 56%, #dcccc8 68%, #e8d5be 80%, #f0ddb8 92%, #f5e2b6 100%)',
890
+ }
891
+ : undefined;
892
+
881
893
  return (
882
894
  <canvas
883
895
  ref={canvasRef}
884
896
  data-viewport="main"
885
897
  tabIndex={-1}
886
898
  className={`w-full h-full block ${cesiumActive ? 'relative z-[1]' : ''}`}
887
- style={cesiumActive ? { opacity: 0 } : undefined}
899
+ style={canvasStyle}
888
900
  onPointerDown={focusViewportForKeyboardShortcuts}
889
901
  />
890
902
  );