@selvajs/ui 4.7.1 → 4.8.0

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.
@@ -1,5 +1,9 @@
1
1
  <script lang="ts">
2
2
  import * as Dialog from '../primitives/dialog/index.js';
3
+ import { getLocaleContext } from '../../i18n/localeContext.svelte';
4
+
5
+ const locale = getLocaleContext();
6
+ const t = $derived(locale.messages);
3
7
 
4
8
  interface Props {
5
9
  open: boolean;
@@ -50,12 +54,12 @@
50
54
  <Dialog.Title class="text-sm font-semibold">{meshName}</Dialog.Title>
51
55
  </div>
52
56
  {:else}
53
- <Dialog.Title class="sr-only">Object</Dialog.Title>
57
+ <Dialog.Title class="sr-only">{t.objectFallbackName}</Dialog.Title>
54
58
  {/if}
55
59
 
56
60
  <div class="max-h-80 overflow-y-auto">
57
61
  {#if !metadata || Object.keys(getFilteredMetadata()).length === 0}
58
- <p class="px-4 py-3 text-xs text-muted-foreground">No metadata</p>
62
+ <p class="px-4 py-3 text-xs text-muted-foreground">{t.noMetadata}</p>
59
63
  {:else}
60
64
  <table class="text-xs w-full border-collapse">
61
65
  <tbody>
@@ -2,6 +2,10 @@
2
2
  import * as THREE from 'three';
3
3
  import { Eye, EyeOff, ChevronRight, Search, X } from '@lucide/svelte';
4
4
  import { SvelteSet, SvelteMap } from 'svelte/reactivity';
5
+ import { getLocaleContext } from '../../i18n/localeContext.svelte';
6
+
7
+ const locale = getLocaleContext();
8
+ const t = $derived(locale.messages);
5
9
 
6
10
  interface Props {
7
11
  scene: THREE.Scene;
@@ -165,11 +169,11 @@
165
169
  <Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
166
170
  <input
167
171
  class="min-w-0 text-xs flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground/50"
168
- placeholder="Search objects..."
172
+ placeholder={t.searchObjects}
169
173
  bind:value={searchQuery}
170
174
  />
171
175
  {#if searchQuery}
172
- <button onclick={() => (searchQuery = '')} aria-label="Clear search">
176
+ <button onclick={() => (searchQuery = '')} aria-label={t.clearSearch}>
173
177
  <X
174
178
  class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
175
179
  />
@@ -187,7 +191,7 @@
187
191
  <button
188
192
  class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
189
193
  onclick={() => toggleLayerCollapsed(layerName)}
190
- aria-label={collapsed ? 'Expand layer' : 'Collapse layer'}
194
+ aria-label={collapsed ? t.expandLayer : t.collapseLayer}
191
195
  >
192
196
  <ChevronRight
193
197
  class="h-3.5 w-3.5 transition-transform duration-150 {collapsed ? '' : 'rotate-90'}"
@@ -197,8 +201,8 @@
197
201
  <button
198
202
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
199
203
  onclick={() => toggleLayer(layerName, objects)}
200
- title={layerHidden ? 'Show layer' : 'Hide layer'}
201
- aria-label={layerHidden ? 'Show layer' : 'Hide layer'}
204
+ title={layerHidden ? t.showLayer : t.hideLayer}
205
+ aria-label={layerHidden ? t.showLayer : t.hideLayer}
202
206
  >
203
207
  {#if layerHidden}
204
208
  <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
@@ -249,8 +253,8 @@
249
253
  e.stopPropagation();
250
254
  toggleObject(object);
251
255
  }}
252
- title={hidden ? 'Show' : 'Hide'}
253
- aria-label={hidden ? 'Show object' : 'Hide object'}
256
+ title={hidden ? t.showObject : t.hideObject}
257
+ aria-label={hidden ? t.showObject : t.hideObject}
254
258
  >
255
259
  {#if hidden}
256
260
  <EyeOff class="h-3 w-3 text-muted-foreground/60" />
@@ -284,12 +288,14 @@
284
288
  {#if getSceneObjects().length === 0}
285
289
  <div class="py-12 flex flex-col items-center justify-center text-center">
286
290
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
287
- <p class="text-xs text-muted-foreground">No objects</p>
291
+ <p class="text-xs text-muted-foreground">{t.noObjects}</p>
288
292
  </div>
289
293
  {:else if getFilteredLayerGroups().size === 0}
290
294
  <div class="py-12 flex flex-col items-center justify-center text-center">
291
295
  <Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
292
- <p class="text-xs text-muted-foreground">No results for "{searchQuery}"</p>
296
+ <p class="text-xs text-muted-foreground">
297
+ {t.noResultsFor.replace('{query}', searchQuery)}
298
+ </p>
293
299
  </div>
294
300
  {/if}
295
301
  </div>
@@ -31,6 +31,8 @@
31
31
  import SceneManager from './SceneManager.svelte';
32
32
  import MeshMetadataDialog from './MeshMetadataDialog.svelte';
33
33
  import * as Resizable from '../primitives/resizable/index.js';
34
+ import type { Locale } from '../../i18n/messages';
35
+ import { setLocaleContext, getLocaleContext } from '../../i18n/localeContext.svelte';
34
36
 
35
37
  export interface ViewerConfig {
36
38
  showScreenshotButton?: boolean;
@@ -50,6 +52,13 @@
50
52
  isBlurred?: boolean;
51
53
  drawerOpen?: boolean;
52
54
  viewerConfig?: ViewerConfig;
55
+ /**
56
+ * UI language for the viewer's own chrome (tools menu, panels, dialogs).
57
+ * When set, the viewer provides it to its subtree. When omitted, the viewer
58
+ * reads the nearest locale context (set by the host app), defaulting to
59
+ * English. Does not translate Grasshopper-sourced names/metadata.
60
+ */
61
+ lang?: Locale;
53
62
  }
54
63
 
55
64
  const defaultViewerConfig: Required<ViewerConfig> = {
@@ -68,11 +77,23 @@
68
77
  isSolving = false,
69
78
  isBlurred = false,
70
79
  drawerOpen = false,
71
- viewerConfig = {}
80
+ viewerConfig = {},
81
+ lang
72
82
  }: Props = $props();
73
83
 
74
84
  const config = $derived({ ...defaultViewerConfig, ...viewerConfig });
75
85
 
86
+ // Read any host-provided locale before we (maybe) override it for our subtree.
87
+ const hostLocale = getLocaleContext();
88
+
89
+ // Resolution order: explicit `lang` prop → host locale context → default.
90
+ // Provide the resolved value to our subtree so the scene manager and metadata
91
+ // dialog read the same locale. The getter is re-read reactively, so switching
92
+ // the `lang` prop (or the host's locale) updates the chrome live.
93
+ setLocaleContext(() => lang ?? hostLocale.locale);
94
+ const locale = getLocaleContext();
95
+ const t = $derived(locale.messages);
96
+
76
97
  let canvas: HTMLCanvasElement;
77
98
  let scene: THREE.Scene | null = $state(null);
78
99
  let camera: THREE.PerspectiveCamera | null = null;
@@ -91,14 +112,14 @@
91
112
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
92
113
  let selectedMeshName: string | null = $state(null);
93
114
 
94
- const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
95
- { preset: 'top', label: 'Top' },
96
- { preset: 'front', label: 'Front' },
97
- { preset: 'right', label: 'Right' },
98
- { preset: 'back', label: 'Back' },
99
- { preset: 'left', label: 'Left' },
100
- { preset: 'bottom', label: 'Bottom' },
101
- { preset: 'iso', label: 'Isometric' }
115
+ const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
116
+ { preset: 'top', label: () => t.viewTop },
117
+ { preset: 'front', label: () => t.viewFront },
118
+ { preset: 'right', label: () => t.viewRight },
119
+ { preset: 'back', label: () => t.viewBack },
120
+ { preset: 'left', label: () => t.viewLeft },
121
+ { preset: 'bottom', label: () => t.viewBottom },
122
+ { preset: 'iso', label: () => t.viewIso }
102
123
  ];
103
124
 
104
125
  // Hide button instantly when expanding, show after animation when collapsing
@@ -128,7 +149,7 @@
128
149
  ? (metadata: Record<string, string>) => {
129
150
  if (hasUsefulMetadata(metadata)) {
130
151
  selectedMeshMetadata = metadata;
131
- selectedMeshName = metadata?.name || 'Object';
152
+ selectedMeshName = metadata?.name || t.objectFallbackName;
132
153
  }
133
154
  }
134
155
  : undefined
@@ -265,8 +286,8 @@
265
286
  <DropdownMenu.Root>
266
287
  <DropdownMenu.Trigger
267
288
  class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95 data-[state=open]:bg-secondary/60"
268
- title="Viewer tools"
269
- aria-label="Viewer tools"
289
+ title={t.toolsMenu}
290
+ aria-label={t.toolsMenu}
270
291
  >
271
292
  <Settings2 class="h-5 w-5 text-card-foreground" />
272
293
  </DropdownMenu.Trigger>
@@ -280,22 +301,22 @@
280
301
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
281
302
  {#if projection === 'perspective'}
282
303
  <Square class="h-4 w-4" />
283
- Switch to 2D
304
+ {t.switchTo2D}
284
305
  {:else}
285
306
  <Box class="h-4 w-4" />
286
- Switch to 3D
307
+ {t.switchTo3D}
287
308
  {/if}
288
309
  </DropdownMenu.Item>
289
310
 
290
311
  <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
291
312
  <Frame class="h-4 w-4" />
292
- Fit to view
313
+ {t.fitToView}
293
314
  </DropdownMenu.Item>
294
315
 
295
316
  <DropdownMenu.Sub>
296
317
  <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
297
318
  <Box class="h-4 w-4" />
298
- <span class="flex-1">Views</span>
319
+ <span class="flex-1">{t.views}</span>
299
320
  <ChevronRight class="h-4 w-4 text-muted-foreground" />
300
321
  </DropdownMenu.SubTrigger>
301
322
  <DropdownMenu.SubContent
@@ -307,7 +328,7 @@
307
328
  class="px-2 py-1.5 text-sm flex cursor-pointer items-center rounded-sm transition-colors outline-none select-none hover:bg-muted focus:bg-muted"
308
329
  onSelect={() => setView(preset)}
309
330
  >
310
- {label}
331
+ {label()}
311
332
  </DropdownMenu.Item>
312
333
  {/each}
313
334
  </DropdownMenu.SubContent>
@@ -319,7 +340,7 @@
319
340
  onSelect={toggleMeasure}
320
341
  >
321
342
  <Ruler class="h-4 w-4" />
322
- <span class="flex-1">Measure</span>
343
+ <span class="flex-1">{t.measure}</span>
323
344
  {#if measureActive}
324
345
  <Check class="h-4 w-4" />
325
346
  {/if}
@@ -332,7 +353,7 @@
332
353
  onSelect={toggleGrid}
333
354
  >
334
355
  <Grid3x3 class="h-4 w-4" />
335
- <span class="flex-1">Grid</span>
356
+ <span class="flex-1">{t.grid}</span>
336
357
  {#if gridVisible}
337
358
  <Check class="h-4 w-4" />
338
359
  {/if}
@@ -351,7 +372,7 @@
351
372
  onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
352
373
  >
353
374
  <Layers class="h-4 w-4" />
354
- <span class="flex-1">Scene manager</span>
375
+ <span class="flex-1">{t.sceneManager}</span>
355
376
  {#if sceneManagerOpen}
356
377
  <Check class="h-4 w-4" />
357
378
  {/if}
@@ -361,7 +382,7 @@
361
382
  {#if config.showScreenshotButton}
362
383
  <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
363
384
  <Camera class="h-4 w-4" />
364
- Screenshot
385
+ {t.screenshot}
365
386
  </DropdownMenu.Item>
366
387
  {/if}
367
388
 
@@ -369,10 +390,10 @@
369
390
  <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
370
391
  {#if isFullscreen}
371
392
  <Minimize class="h-4 w-4" />
372
- Exit fullscreen
393
+ {t.exitFullscreen}
373
394
  {:else}
374
395
  <Maximize class="h-4 w-4" />
375
- Fullscreen
396
+ {t.fullscreen}
376
397
  {/if}
377
398
  </DropdownMenu.Item>
378
399
  {/if}
@@ -1,3 +1,4 @@
1
+ import type { Locale } from '../../i18n/messages';
1
2
  export interface ViewerConfig {
2
3
  showScreenshotButton?: boolean;
3
4
  showFullscreenButton?: boolean;
@@ -15,6 +16,13 @@ interface Props {
15
16
  isBlurred?: boolean;
16
17
  drawerOpen?: boolean;
17
18
  viewerConfig?: ViewerConfig;
19
+ /**
20
+ * UI language for the viewer's own chrome (tools menu, panels, dialogs).
21
+ * When set, the viewer provides it to its subtree. When omitted, the viewer
22
+ * reads the nearest locale context (set by the host app), defaulting to
23
+ * English. Does not translate Grasshopper-sourced names/metadata.
24
+ */
25
+ lang?: Locale;
18
26
  }
19
27
  declare const Viewer: import("svelte").Component<Props, {}, "isFullscreen">;
20
28
  type Viewer = ReturnType<typeof Viewer>;
@@ -0,0 +1,18 @@
1
+ import { type Locale, type ViewerMessages } from './messages';
2
+ export interface LocaleContext {
3
+ /** Current locale. Called reactively — return reactive state to enable live switching. */
4
+ readonly locale: Locale;
5
+ /** Resolved message catalog for the current locale. */
6
+ readonly messages: ViewerMessages;
7
+ }
8
+ /**
9
+ * Provide the locale to descendants. Pass a getter so a reactive source (a
10
+ * `$state`, a store, the app's Paraglide locale) keeps consumers in sync.
11
+ */
12
+ export declare function setLocaleContext(getLocale: () => Locale | undefined): void;
13
+ /**
14
+ * Read the locale context. Falls back to an English-only context when no
15
+ * provider exists (e.g. a primitive used in isolation), so consumers never
16
+ * need a null check.
17
+ */
18
+ export declare function getLocaleContext(): LocaleContext;
@@ -0,0 +1,50 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { messagesFor, DEFAULT_LOCALE } from './messages';
3
+ // ============================================================================
4
+ // Viewer locale context
5
+ // ============================================================================
6
+ //
7
+ // Carries the current UI locale down to the viewer and its panels without
8
+ // threading a `lang` prop through every layer. The value is a getter so the
9
+ // host can back it with reactive state — flip the language and the viewer
10
+ // re-renders live.
11
+ //
12
+ // Resolution order for any consuming component:
13
+ // explicit `lang` prop → nearest locale context → English default
14
+ //
15
+ // Two ways to provide it:
16
+ // - Standalone: <Viewer lang="de" /> — Viewer provides the context itself.
17
+ // - In an app: call setLocaleContext(() => app.locale) once at the root; the
18
+ // viewer (and anything else) reads it. selva later wires its Paraglide
19
+ // locale in here.
20
+ const LOCALE_CONTEXT_KEY = Symbol('viewer-locale-context');
21
+ /**
22
+ * Provide the locale to descendants. Pass a getter so a reactive source (a
23
+ * `$state`, a store, the app's Paraglide locale) keeps consumers in sync.
24
+ */
25
+ export function setLocaleContext(getLocale) {
26
+ const ctx = {
27
+ get locale() {
28
+ return getLocale() ?? DEFAULT_LOCALE;
29
+ },
30
+ get messages() {
31
+ return messagesFor(getLocale());
32
+ }
33
+ };
34
+ setContext(LOCALE_CONTEXT_KEY, ctx);
35
+ }
36
+ /**
37
+ * Read the locale context. Falls back to an English-only context when no
38
+ * provider exists (e.g. a primitive used in isolation), so consumers never
39
+ * need a null check.
40
+ */
41
+ export function getLocaleContext() {
42
+ return (getContext(LOCALE_CONTEXT_KEY) ?? {
43
+ get locale() {
44
+ return DEFAULT_LOCALE;
45
+ },
46
+ get messages() {
47
+ return messagesFor(DEFAULT_LOCALE);
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,38 @@
1
+ export type Locale = 'en' | 'de';
2
+ export interface ViewerMessages {
3
+ toolsMenu: string;
4
+ switchTo2D: string;
5
+ switchTo3D: string;
6
+ fitToView: string;
7
+ views: string;
8
+ measure: string;
9
+ grid: string;
10
+ sceneManager: string;
11
+ screenshot: string;
12
+ fullscreen: string;
13
+ exitFullscreen: string;
14
+ viewTop: string;
15
+ viewFront: string;
16
+ viewRight: string;
17
+ viewBack: string;
18
+ viewLeft: string;
19
+ viewBottom: string;
20
+ viewIso: string;
21
+ searchObjects: string;
22
+ clearSearch: string;
23
+ expandLayer: string;
24
+ collapseLayer: string;
25
+ showLayer: string;
26
+ hideLayer: string;
27
+ showObject: string;
28
+ hideObject: string;
29
+ noObjects: string;
30
+ /** `{query}` is replaced with the current search text. */
31
+ noResultsFor: string;
32
+ objectFallbackName: string;
33
+ noMetadata: string;
34
+ }
35
+ export declare const VIEWER_MESSAGES: Record<Locale, ViewerMessages>;
36
+ export declare const DEFAULT_LOCALE: Locale;
37
+ /** Resolve a catalog for a locale, falling back to English for unknown locales. */
38
+ export declare function messagesFor(locale: Locale | undefined): ViewerMessages;
@@ -0,0 +1,82 @@
1
+ // ============================================================================
2
+ // Viewer message catalog (library-owned strings)
3
+ // ============================================================================
4
+ //
5
+ // These are the strings @selvajs/ui renders itself in the 3D viewer and its
6
+ // panels — tool menu, view presets, scene manager, metadata dialog. They are
7
+ // NOT the strings that come from a Grasshopper definition (mesh/layer/metadata
8
+ // names): those live in the user's .gh file and can't be translated here.
9
+ //
10
+ // The library ships English + German. A host app can switch locale at runtime
11
+ // via the locale context (see ./localeContext.svelte.ts) — e.g. selva feeds its
12
+ // own Paraglide locale in. With no provider, components fall back to English.
13
+ const en = {
14
+ toolsMenu: 'Viewer tools',
15
+ switchTo2D: 'Switch to 2D',
16
+ switchTo3D: 'Switch to 3D',
17
+ fitToView: 'Fit to view',
18
+ views: 'Views',
19
+ measure: 'Measure',
20
+ grid: 'Grid',
21
+ sceneManager: 'Scene manager',
22
+ screenshot: 'Screenshot',
23
+ fullscreen: 'Fullscreen',
24
+ exitFullscreen: 'Exit fullscreen',
25
+ viewTop: 'Top',
26
+ viewFront: 'Front',
27
+ viewRight: 'Right',
28
+ viewBack: 'Back',
29
+ viewLeft: 'Left',
30
+ viewBottom: 'Bottom',
31
+ viewIso: 'Isometric',
32
+ searchObjects: 'Search objects...',
33
+ clearSearch: 'Clear search',
34
+ expandLayer: 'Expand layer',
35
+ collapseLayer: 'Collapse layer',
36
+ showLayer: 'Show layer',
37
+ hideLayer: 'Hide layer',
38
+ showObject: 'Show object',
39
+ hideObject: 'Hide object',
40
+ noObjects: 'No objects',
41
+ noResultsFor: 'No results for "{query}"',
42
+ objectFallbackName: 'Object',
43
+ noMetadata: 'No metadata'
44
+ };
45
+ const de = {
46
+ toolsMenu: 'Viewer-Werkzeuge',
47
+ switchTo2D: 'Zu 2D wechseln',
48
+ switchTo3D: 'Zu 3D wechseln',
49
+ fitToView: 'Ansicht anpassen',
50
+ views: 'Ansichten',
51
+ measure: 'Messen',
52
+ grid: 'Raster',
53
+ sceneManager: 'Szenen-Manager',
54
+ screenshot: 'Screenshot',
55
+ fullscreen: 'Vollbild',
56
+ exitFullscreen: 'Vollbild beenden',
57
+ viewTop: 'Oben',
58
+ viewFront: 'Vorne',
59
+ viewRight: 'Rechts',
60
+ viewBack: 'Hinten',
61
+ viewLeft: 'Links',
62
+ viewBottom: 'Unten',
63
+ viewIso: 'Isometrisch',
64
+ searchObjects: 'Objekte suchen...',
65
+ clearSearch: 'Suche löschen',
66
+ expandLayer: 'Ebene aufklappen',
67
+ collapseLayer: 'Ebene zuklappen',
68
+ showLayer: 'Ebene einblenden',
69
+ hideLayer: 'Ebene ausblenden',
70
+ showObject: 'Objekt einblenden',
71
+ hideObject: 'Objekt ausblenden',
72
+ noObjects: 'Keine Objekte',
73
+ noResultsFor: 'Keine Ergebnisse für „{query}“',
74
+ objectFallbackName: 'Objekt',
75
+ noMetadata: 'Keine Metadaten'
76
+ };
77
+ export const VIEWER_MESSAGES = { en, de };
78
+ export const DEFAULT_LOCALE = 'en';
79
+ /** Resolve a catalog for a locale, falling back to English for unknown locales. */
80
+ export function messagesFor(locale) {
81
+ return VIEWER_MESSAGES[locale ?? DEFAULT_LOCALE] ?? VIEWER_MESSAGES[DEFAULT_LOCALE];
82
+ }
package/dist/public.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { default as AppLayout } from './components/compute/AppLayout.svelte';
2
2
  export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
3
3
  export { default as Viewer, type ViewerConfig } from './components/viewer/Viewer.svelte';
4
+ export type { Locale, ViewerMessages } from './i18n/messages';
5
+ export { VIEWER_MESSAGES, DEFAULT_LOCALE, messagesFor } from './i18n/messages';
6
+ export { setLocaleContext, getLocaleContext, type LocaleContext } from './i18n/localeContext.svelte';
4
7
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
5
8
  export { createSolveSession, createRequestResponseDriver, type SolveSession, type SolveSessionArgs, type SolveDriver, type SolveReporter } from './compute/createSolveSession.svelte';
6
9
  export type { ClientSlotArgs, ClientSlot } from './contexts/clientSlotContext.svelte';
package/dist/public.js CHANGED
@@ -22,8 +22,11 @@ export { default as AppLayout } from './components/compute/AppLayout.svelte';
22
22
  export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
23
23
  // Standalone 3D viewer. Render Grasshopper meshes on their own, outside a
24
24
  // ComputeApp host — external apps drive it directly with a `meshes` array and
25
- // an optional `viewerConfig`.
25
+ // an optional `viewerConfig`. Pass `lang` to localize its chrome, or provide a
26
+ // locale context (setLocaleContext) at the host root to drive it app-wide.
26
27
  export { default as Viewer } from './components/viewer/Viewer.svelte';
28
+ export { VIEWER_MESSAGES, DEFAULT_LOCALE, messagesFor } from './i18n/messages';
29
+ export { setLocaleContext, getLocaleContext } from './i18n/localeContext.svelte';
27
30
  // Full-screen states a host app renders
28
31
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
29
32
  // Solve Session seam (transport-agnostic value/lifecycle state machine + its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.7.1",
3
+ "version": "4.8.0",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -72,8 +72,8 @@
72
72
  "svelte": "5.55.5",
73
73
  "tailwind-variants": "^3.2.2",
74
74
  "vitest": "^3.2.6",
75
- "@selvajs/config": "0.0.1",
76
- "@selvajs/schemas": "4.5.0"
75
+ "@selvajs/schemas": "4.5.0",
76
+ "@selvajs/config": "0.0.1"
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "vite dev",
@@ -1,5 +1,9 @@
1
1
  <script lang="ts">
2
2
  import * as Dialog from '../primitives/dialog/index.js';
3
+ import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
4
+
5
+ const locale = getLocaleContext();
6
+ const t = $derived(locale.messages);
3
7
 
4
8
  interface Props {
5
9
  open: boolean;
@@ -50,12 +54,12 @@
50
54
  <Dialog.Title class="text-sm font-semibold">{meshName}</Dialog.Title>
51
55
  </div>
52
56
  {:else}
53
- <Dialog.Title class="sr-only">Object</Dialog.Title>
57
+ <Dialog.Title class="sr-only">{t.objectFallbackName}</Dialog.Title>
54
58
  {/if}
55
59
 
56
60
  <div class="max-h-80 overflow-y-auto">
57
61
  {#if !metadata || Object.keys(getFilteredMetadata()).length === 0}
58
- <p class="px-4 py-3 text-xs text-muted-foreground">No metadata</p>
62
+ <p class="px-4 py-3 text-xs text-muted-foreground">{t.noMetadata}</p>
59
63
  {:else}
60
64
  <table class="text-xs w-full border-collapse">
61
65
  <tbody>
@@ -2,6 +2,10 @@
2
2
  import * as THREE from 'three';
3
3
  import { Eye, EyeOff, ChevronRight, Search, X } from '@lucide/svelte';
4
4
  import { SvelteSet, SvelteMap } from 'svelte/reactivity';
5
+ import { getLocaleContext } from '$lib/i18n/localeContext.svelte';
6
+
7
+ const locale = getLocaleContext();
8
+ const t = $derived(locale.messages);
5
9
 
6
10
  interface Props {
7
11
  scene: THREE.Scene;
@@ -165,11 +169,11 @@
165
169
  <Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
166
170
  <input
167
171
  class="min-w-0 text-xs flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground/50"
168
- placeholder="Search objects..."
172
+ placeholder={t.searchObjects}
169
173
  bind:value={searchQuery}
170
174
  />
171
175
  {#if searchQuery}
172
- <button onclick={() => (searchQuery = '')} aria-label="Clear search">
176
+ <button onclick={() => (searchQuery = '')} aria-label={t.clearSearch}>
173
177
  <X
174
178
  class="h-3.5 w-3.5 text-muted-foreground/50 transition-colors hover:text-muted-foreground"
175
179
  />
@@ -187,7 +191,7 @@
187
191
  <button
188
192
  class="rounded p-0.5 shrink-0 text-muted-foreground transition-colors hover:text-muted-foreground"
189
193
  onclick={() => toggleLayerCollapsed(layerName)}
190
- aria-label={collapsed ? 'Expand layer' : 'Collapse layer'}
194
+ aria-label={collapsed ? t.expandLayer : t.collapseLayer}
191
195
  >
192
196
  <ChevronRight
193
197
  class="h-3.5 w-3.5 transition-transform duration-150 {collapsed ? '' : 'rotate-90'}"
@@ -197,8 +201,8 @@
197
201
  <button
198
202
  class="rounded p-1 shrink-0 transition-colors hover:bg-muted"
199
203
  onclick={() => toggleLayer(layerName, objects)}
200
- title={layerHidden ? 'Show layer' : 'Hide layer'}
201
- aria-label={layerHidden ? 'Show layer' : 'Hide layer'}
204
+ title={layerHidden ? t.showLayer : t.hideLayer}
205
+ aria-label={layerHidden ? t.showLayer : t.hideLayer}
202
206
  >
203
207
  {#if layerHidden}
204
208
  <EyeOff class="h-3.5 w-3.5 text-muted-foreground/40" />
@@ -249,8 +253,8 @@
249
253
  e.stopPropagation();
250
254
  toggleObject(object);
251
255
  }}
252
- title={hidden ? 'Show' : 'Hide'}
253
- aria-label={hidden ? 'Show object' : 'Hide object'}
256
+ title={hidden ? t.showObject : t.hideObject}
257
+ aria-label={hidden ? t.showObject : t.hideObject}
254
258
  >
255
259
  {#if hidden}
256
260
  <EyeOff class="h-3 w-3 text-muted-foreground/60" />
@@ -284,12 +288,14 @@
284
288
  {#if getSceneObjects().length === 0}
285
289
  <div class="py-12 flex flex-col items-center justify-center text-center">
286
290
  <EyeOff class="mb-2 h-5 w-5 text-muted-foreground/30" />
287
- <p class="text-xs text-muted-foreground">No objects</p>
291
+ <p class="text-xs text-muted-foreground">{t.noObjects}</p>
288
292
  </div>
289
293
  {:else if getFilteredLayerGroups().size === 0}
290
294
  <div class="py-12 flex flex-col items-center justify-center text-center">
291
295
  <Search class="mb-2 h-5 w-5 text-muted-foreground/30" />
292
- <p class="text-xs text-muted-foreground">No results for "{searchQuery}"</p>
296
+ <p class="text-xs text-muted-foreground">
297
+ {t.noResultsFor.replace('{query}', searchQuery)}
298
+ </p>
293
299
  </div>
294
300
  {/if}
295
301
  </div>
@@ -31,6 +31,8 @@
31
31
  import SceneManager from './SceneManager.svelte';
32
32
  import MeshMetadataDialog from './MeshMetadataDialog.svelte';
33
33
  import * as Resizable from '$lib/components/primitives/resizable/index.js';
34
+ import type { Locale } from '$lib/i18n/messages';
35
+ import { setLocaleContext, getLocaleContext } from '$lib/i18n/localeContext.svelte';
34
36
 
35
37
  export interface ViewerConfig {
36
38
  showScreenshotButton?: boolean;
@@ -50,6 +52,13 @@
50
52
  isBlurred?: boolean;
51
53
  drawerOpen?: boolean;
52
54
  viewerConfig?: ViewerConfig;
55
+ /**
56
+ * UI language for the viewer's own chrome (tools menu, panels, dialogs).
57
+ * When set, the viewer provides it to its subtree. When omitted, the viewer
58
+ * reads the nearest locale context (set by the host app), defaulting to
59
+ * English. Does not translate Grasshopper-sourced names/metadata.
60
+ */
61
+ lang?: Locale;
53
62
  }
54
63
 
55
64
  const defaultViewerConfig: Required<ViewerConfig> = {
@@ -68,11 +77,23 @@
68
77
  isSolving = false,
69
78
  isBlurred = false,
70
79
  drawerOpen = false,
71
- viewerConfig = {}
80
+ viewerConfig = {},
81
+ lang
72
82
  }: Props = $props();
73
83
 
74
84
  const config = $derived({ ...defaultViewerConfig, ...viewerConfig });
75
85
 
86
+ // Read any host-provided locale before we (maybe) override it for our subtree.
87
+ const hostLocale = getLocaleContext();
88
+
89
+ // Resolution order: explicit `lang` prop → host locale context → default.
90
+ // Provide the resolved value to our subtree so the scene manager and metadata
91
+ // dialog read the same locale. The getter is re-read reactively, so switching
92
+ // the `lang` prop (or the host's locale) updates the chrome live.
93
+ setLocaleContext(() => lang ?? hostLocale.locale);
94
+ const locale = getLocaleContext();
95
+ const t = $derived(locale.messages);
96
+
76
97
  let canvas: HTMLCanvasElement;
77
98
  let scene: THREE.Scene | null = $state(null);
78
99
  let camera: THREE.PerspectiveCamera | null = null;
@@ -91,14 +112,14 @@
91
112
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
92
113
  let selectedMeshName: string | null = $state(null);
93
114
 
94
- const VIEW_PRESETS: { preset: ViewPreset; label: string }[] = [
95
- { preset: 'top', label: 'Top' },
96
- { preset: 'front', label: 'Front' },
97
- { preset: 'right', label: 'Right' },
98
- { preset: 'back', label: 'Back' },
99
- { preset: 'left', label: 'Left' },
100
- { preset: 'bottom', label: 'Bottom' },
101
- { preset: 'iso', label: 'Isometric' }
115
+ const VIEW_PRESETS: { preset: ViewPreset; label: () => string }[] = [
116
+ { preset: 'top', label: () => t.viewTop },
117
+ { preset: 'front', label: () => t.viewFront },
118
+ { preset: 'right', label: () => t.viewRight },
119
+ { preset: 'back', label: () => t.viewBack },
120
+ { preset: 'left', label: () => t.viewLeft },
121
+ { preset: 'bottom', label: () => t.viewBottom },
122
+ { preset: 'iso', label: () => t.viewIso }
102
123
  ];
103
124
 
104
125
  // Hide button instantly when expanding, show after animation when collapsing
@@ -128,7 +149,7 @@
128
149
  ? (metadata: Record<string, string>) => {
129
150
  if (hasUsefulMetadata(metadata)) {
130
151
  selectedMeshMetadata = metadata;
131
- selectedMeshName = metadata?.name || 'Object';
152
+ selectedMeshName = metadata?.name || t.objectFallbackName;
132
153
  }
133
154
  }
134
155
  : undefined
@@ -265,8 +286,8 @@
265
286
  <DropdownMenu.Root>
266
287
  <DropdownMenu.Trigger
267
288
  class="h-10 w-10 shadow-lg hover:shadow-xl flex items-center justify-center rounded-lg border border-border bg-card/90 transition-all hover:bg-card active:scale-95 data-[state=open]:bg-secondary/60"
268
- title="Viewer tools"
269
- aria-label="Viewer tools"
289
+ title={t.toolsMenu}
290
+ aria-label={t.toolsMenu}
270
291
  >
271
292
  <Settings2 class="h-5 w-5 text-card-foreground" />
272
293
  </DropdownMenu.Trigger>
@@ -280,22 +301,22 @@
280
301
  <DropdownMenu.Item class={itemClass} onSelect={toggleProjection}>
281
302
  {#if projection === 'perspective'}
282
303
  <Square class="h-4 w-4" />
283
- Switch to 2D
304
+ {t.switchTo2D}
284
305
  {:else}
285
306
  <Box class="h-4 w-4" />
286
- Switch to 3D
307
+ {t.switchTo3D}
287
308
  {/if}
288
309
  </DropdownMenu.Item>
289
310
 
290
311
  <DropdownMenu.Item class={itemClass} onSelect={() => fitToView?.()}>
291
312
  <Frame class="h-4 w-4" />
292
- Fit to view
313
+ {t.fitToView}
293
314
  </DropdownMenu.Item>
294
315
 
295
316
  <DropdownMenu.Sub>
296
317
  <DropdownMenu.SubTrigger class="{itemClass} data-[state=open]:bg-muted">
297
318
  <Box class="h-4 w-4" />
298
- <span class="flex-1">Views</span>
319
+ <span class="flex-1">{t.views}</span>
299
320
  <ChevronRight class="h-4 w-4 text-muted-foreground" />
300
321
  </DropdownMenu.SubTrigger>
301
322
  <DropdownMenu.SubContent
@@ -307,7 +328,7 @@
307
328
  class="px-2 py-1.5 text-sm flex cursor-pointer items-center rounded-sm transition-colors outline-none select-none hover:bg-muted focus:bg-muted"
308
329
  onSelect={() => setView(preset)}
309
330
  >
310
- {label}
331
+ {label()}
311
332
  </DropdownMenu.Item>
312
333
  {/each}
313
334
  </DropdownMenu.SubContent>
@@ -319,7 +340,7 @@
319
340
  onSelect={toggleMeasure}
320
341
  >
321
342
  <Ruler class="h-4 w-4" />
322
- <span class="flex-1">Measure</span>
343
+ <span class="flex-1">{t.measure}</span>
323
344
  {#if measureActive}
324
345
  <Check class="h-4 w-4" />
325
346
  {/if}
@@ -332,7 +353,7 @@
332
353
  onSelect={toggleGrid}
333
354
  >
334
355
  <Grid3x3 class="h-4 w-4" />
335
- <span class="flex-1">Grid</span>
356
+ <span class="flex-1">{t.grid}</span>
336
357
  {#if gridVisible}
337
358
  <Check class="h-4 w-4" />
338
359
  {/if}
@@ -351,7 +372,7 @@
351
372
  onSelect={() => (sceneManagerOpen = !sceneManagerOpen)}
352
373
  >
353
374
  <Layers class="h-4 w-4" />
354
- <span class="flex-1">Scene manager</span>
375
+ <span class="flex-1">{t.sceneManager}</span>
355
376
  {#if sceneManagerOpen}
356
377
  <Check class="h-4 w-4" />
357
378
  {/if}
@@ -361,7 +382,7 @@
361
382
  {#if config.showScreenshotButton}
362
383
  <DropdownMenu.Item class={itemClass} onSelect={downloadScreenshot}>
363
384
  <Camera class="h-4 w-4" />
364
- Screenshot
385
+ {t.screenshot}
365
386
  </DropdownMenu.Item>
366
387
  {/if}
367
388
 
@@ -369,10 +390,10 @@
369
390
  <DropdownMenu.Item class={itemClass} onSelect={toggleFullscreen}>
370
391
  {#if isFullscreen}
371
392
  <Minimize class="h-4 w-4" />
372
- Exit fullscreen
393
+ {t.exitFullscreen}
373
394
  {:else}
374
395
  <Maximize class="h-4 w-4" />
375
- Fullscreen
396
+ {t.fullscreen}
376
397
  {/if}
377
398
  </DropdownMenu.Item>
378
399
  {/if}
@@ -0,0 +1,63 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { type Locale, type ViewerMessages, messagesFor, DEFAULT_LOCALE } from './messages';
3
+
4
+ // ============================================================================
5
+ // Viewer locale context
6
+ // ============================================================================
7
+ //
8
+ // Carries the current UI locale down to the viewer and its panels without
9
+ // threading a `lang` prop through every layer. The value is a getter so the
10
+ // host can back it with reactive state — flip the language and the viewer
11
+ // re-renders live.
12
+ //
13
+ // Resolution order for any consuming component:
14
+ // explicit `lang` prop → nearest locale context → English default
15
+ //
16
+ // Two ways to provide it:
17
+ // - Standalone: <Viewer lang="de" /> — Viewer provides the context itself.
18
+ // - In an app: call setLocaleContext(() => app.locale) once at the root; the
19
+ // viewer (and anything else) reads it. selva later wires its Paraglide
20
+ // locale in here.
21
+
22
+ const LOCALE_CONTEXT_KEY = Symbol('viewer-locale-context');
23
+
24
+ export interface LocaleContext {
25
+ /** Current locale. Called reactively — return reactive state to enable live switching. */
26
+ readonly locale: Locale;
27
+ /** Resolved message catalog for the current locale. */
28
+ readonly messages: ViewerMessages;
29
+ }
30
+
31
+ /**
32
+ * Provide the locale to descendants. Pass a getter so a reactive source (a
33
+ * `$state`, a store, the app's Paraglide locale) keeps consumers in sync.
34
+ */
35
+ export function setLocaleContext(getLocale: () => Locale | undefined): void {
36
+ const ctx: LocaleContext = {
37
+ get locale() {
38
+ return getLocale() ?? DEFAULT_LOCALE;
39
+ },
40
+ get messages() {
41
+ return messagesFor(getLocale());
42
+ }
43
+ };
44
+ setContext(LOCALE_CONTEXT_KEY, ctx);
45
+ }
46
+
47
+ /**
48
+ * Read the locale context. Falls back to an English-only context when no
49
+ * provider exists (e.g. a primitive used in isolation), so consumers never
50
+ * need a null check.
51
+ */
52
+ export function getLocaleContext(): LocaleContext {
53
+ return (
54
+ getContext<LocaleContext | undefined>(LOCALE_CONTEXT_KEY) ?? {
55
+ get locale() {
56
+ return DEFAULT_LOCALE;
57
+ },
58
+ get messages() {
59
+ return messagesFor(DEFAULT_LOCALE);
60
+ }
61
+ }
62
+ );
63
+ }
@@ -0,0 +1,136 @@
1
+ // ============================================================================
2
+ // Viewer message catalog (library-owned strings)
3
+ // ============================================================================
4
+ //
5
+ // These are the strings @selvajs/ui renders itself in the 3D viewer and its
6
+ // panels — tool menu, view presets, scene manager, metadata dialog. They are
7
+ // NOT the strings that come from a Grasshopper definition (mesh/layer/metadata
8
+ // names): those live in the user's .gh file and can't be translated here.
9
+ //
10
+ // The library ships English + German. A host app can switch locale at runtime
11
+ // via the locale context (see ./localeContext.svelte.ts) — e.g. selva feeds its
12
+ // own Paraglide locale in. With no provider, components fall back to English.
13
+
14
+ export type Locale = 'en' | 'de';
15
+
16
+ export interface ViewerMessages {
17
+ // Tools menu
18
+ toolsMenu: string;
19
+ switchTo2D: string;
20
+ switchTo3D: string;
21
+ fitToView: string;
22
+ views: string;
23
+ measure: string;
24
+ grid: string;
25
+ sceneManager: string;
26
+ screenshot: string;
27
+ fullscreen: string;
28
+ exitFullscreen: string;
29
+
30
+ // View presets
31
+ viewTop: string;
32
+ viewFront: string;
33
+ viewRight: string;
34
+ viewBack: string;
35
+ viewLeft: string;
36
+ viewBottom: string;
37
+ viewIso: string;
38
+
39
+ // Scene manager
40
+ searchObjects: string;
41
+ clearSearch: string;
42
+ expandLayer: string;
43
+ collapseLayer: string;
44
+ showLayer: string;
45
+ hideLayer: string;
46
+ showObject: string;
47
+ hideObject: string;
48
+ noObjects: string;
49
+ /** `{query}` is replaced with the current search text. */
50
+ noResultsFor: string;
51
+
52
+ // Metadata dialog
53
+ objectFallbackName: string;
54
+ noMetadata: string;
55
+ }
56
+
57
+ const en: ViewerMessages = {
58
+ toolsMenu: 'Viewer tools',
59
+ switchTo2D: 'Switch to 2D',
60
+ switchTo3D: 'Switch to 3D',
61
+ fitToView: 'Fit to view',
62
+ views: 'Views',
63
+ measure: 'Measure',
64
+ grid: 'Grid',
65
+ sceneManager: 'Scene manager',
66
+ screenshot: 'Screenshot',
67
+ fullscreen: 'Fullscreen',
68
+ exitFullscreen: 'Exit fullscreen',
69
+
70
+ viewTop: 'Top',
71
+ viewFront: 'Front',
72
+ viewRight: 'Right',
73
+ viewBack: 'Back',
74
+ viewLeft: 'Left',
75
+ viewBottom: 'Bottom',
76
+ viewIso: 'Isometric',
77
+
78
+ searchObjects: 'Search objects...',
79
+ clearSearch: 'Clear search',
80
+ expandLayer: 'Expand layer',
81
+ collapseLayer: 'Collapse layer',
82
+ showLayer: 'Show layer',
83
+ hideLayer: 'Hide layer',
84
+ showObject: 'Show object',
85
+ hideObject: 'Hide object',
86
+ noObjects: 'No objects',
87
+ noResultsFor: 'No results for "{query}"',
88
+
89
+ objectFallbackName: 'Object',
90
+ noMetadata: 'No metadata'
91
+ };
92
+
93
+ const de: ViewerMessages = {
94
+ toolsMenu: 'Viewer-Werkzeuge',
95
+ switchTo2D: 'Zu 2D wechseln',
96
+ switchTo3D: 'Zu 3D wechseln',
97
+ fitToView: 'Ansicht anpassen',
98
+ views: 'Ansichten',
99
+ measure: 'Messen',
100
+ grid: 'Raster',
101
+ sceneManager: 'Szenen-Manager',
102
+ screenshot: 'Screenshot',
103
+ fullscreen: 'Vollbild',
104
+ exitFullscreen: 'Vollbild beenden',
105
+
106
+ viewTop: 'Oben',
107
+ viewFront: 'Vorne',
108
+ viewRight: 'Rechts',
109
+ viewBack: 'Hinten',
110
+ viewLeft: 'Links',
111
+ viewBottom: 'Unten',
112
+ viewIso: 'Isometrisch',
113
+
114
+ searchObjects: 'Objekte suchen...',
115
+ clearSearch: 'Suche löschen',
116
+ expandLayer: 'Ebene aufklappen',
117
+ collapseLayer: 'Ebene zuklappen',
118
+ showLayer: 'Ebene einblenden',
119
+ hideLayer: 'Ebene ausblenden',
120
+ showObject: 'Objekt einblenden',
121
+ hideObject: 'Objekt ausblenden',
122
+ noObjects: 'Keine Objekte',
123
+ noResultsFor: 'Keine Ergebnisse für „{query}“',
124
+
125
+ objectFallbackName: 'Objekt',
126
+ noMetadata: 'Keine Metadaten'
127
+ };
128
+
129
+ export const VIEWER_MESSAGES: Record<Locale, ViewerMessages> = { en, de };
130
+
131
+ export const DEFAULT_LOCALE: Locale = 'en';
132
+
133
+ /** Resolve a catalog for a locale, falling back to English for unknown locales. */
134
+ export function messagesFor(locale: Locale | undefined): ViewerMessages {
135
+ return VIEWER_MESSAGES[locale ?? DEFAULT_LOCALE] ?? VIEWER_MESSAGES[DEFAULT_LOCALE];
136
+ }
package/src/lib/public.ts CHANGED
@@ -24,9 +24,22 @@ export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
24
24
 
25
25
  // Standalone 3D viewer. Render Grasshopper meshes on their own, outside a
26
26
  // ComputeApp host — external apps drive it directly with a `meshes` array and
27
- // an optional `viewerConfig`.
27
+ // an optional `viewerConfig`. Pass `lang` to localize its chrome, or provide a
28
+ // locale context (setLocaleContext) at the host root to drive it app-wide.
28
29
  export { default as Viewer, type ViewerConfig } from './components/viewer/Viewer.svelte';
29
30
 
31
+ // Viewer localization. The library renders English + German chrome; switch at
32
+ // runtime by passing `lang` to <Viewer> or by setting a reactive locale context
33
+ // once at the host root (e.g. feed in an app-wide Paraglide locale). Does not
34
+ // translate Grasshopper-sourced names/metadata.
35
+ export type { Locale, ViewerMessages } from './i18n/messages';
36
+ export { VIEWER_MESSAGES, DEFAULT_LOCALE, messagesFor } from './i18n/messages';
37
+ export {
38
+ setLocaleContext,
39
+ getLocaleContext,
40
+ type LocaleContext
41
+ } from './i18n/localeContext.svelte';
42
+
30
43
  // Full-screen states a host app renders
31
44
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
32
45