void {
}
}
export interface EditorProps {
+ /** UI locale. Hosts may also call `setLocale` before mounting. */
+ locale?: string
// Layout version — 'v1' (default) or 'v2' (navbar + two-column)
layoutVersion?: 'v1' | 'v2'
// Viewport edge the tool menu docks to (desktop only; mobile stays at the bottom)
@@ -1960,10 +1965,28 @@ function EditorContent({
)
}
-export default function Editor(props: EditorProps) {
+function EditorFrame(props: EditorProps) {
return (
)
}
+
+function LocalizedEditor(props: EditorProps & { locale: string }) {
+ const i18n = useMemo(() => createI18n({ locale: props.locale }), [props.locale])
+
+ return (
+
+
+
+ )
+}
+
+export default function Editor(props: EditorProps) {
+ return props.locale ? (
+
+ ) : (
+
+ )
+}
diff --git a/packages/editor/src/components/editor/room-labels.tsx b/packages/editor/src/components/editor/room-labels.tsx
index 73e1052d48..489a4eb6c1 100644
--- a/packages/editor/src/components/editor/room-labels.tsx
+++ b/packages/editor/src/components/editor/room-labels.tsx
@@ -7,10 +7,12 @@ import {
containsPoint,
DEFAULT_LEVEL_HEIGHT,
emitter,
+ formatNumber,
type Polygon,
polygonInteriorPoint,
type Ring,
sceneRegistry,
+ useLocale,
useScene,
type ZoneNode,
} from '@pascal-app/core'
@@ -92,8 +94,12 @@ export function roomLabelsVisible(state: RoomLabelVisibilityState): boolean {
)
}
-export function formatRoomArea(squareMeters: number, unit: LinearUnit): string {
- return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(1)} ${getAreaUnitLabel(unit)}`
+export function formatRoomArea(squareMeters: number, unit: LinearUnit, locale?: string): string {
+ return `${formatNumber(squareMetersToAreaUnit(squareMeters, unit), {
+ min: 1,
+ max: 1,
+ locale,
+ })} ${getAreaUnitLabel(unit)}`
}
function useRoomLabelsVisible() {
@@ -1169,11 +1175,12 @@ export function RoomPillChip({
onLeave: () => void
}) {
const unit = useViewer((s) => s.unit)
- const areaLabel = formatRoomArea(areaM2, unit)
+ const locale = useLocale()
+ const areaLabel = formatRoomArea(areaM2, unit, locale)
// biome-ignore lint/correctness/useExhaustiveDependencies: A dot's border box does not resize when its hidden text changes.
useLayoutEffect(() => {
if (elements instanceof PillElements) elements.measure(zoneId)
- }, [areaM2, elements, name, unit, zoneId])
+ }, [areaM2, elements, locale, name, unit, zoneId])
const register = useCallback(
(element: HTMLElement | null) => {
if (element) elements.set(zoneId, element)
@@ -1227,6 +1234,7 @@ export function RoomPillDetail({
areaM2: number
}) {
const unit = useViewer((s) => s.unit)
+ const locale = useLocale()
return (
-
+
)
}
diff --git a/packages/editor/src/components/ui/build-panel/build-panel.tsx b/packages/editor/src/components/ui/build-panel/build-panel.tsx
index beba274971..4a6c3f77ed 100644
--- a/packages/editor/src/components/ui/build-panel/build-panel.tsx
+++ b/packages/editor/src/components/ui/build-panel/build-panel.tsx
@@ -1,10 +1,10 @@
'use client'
+import { type PascalI18n, usePascalI18n } from '@pascal-app/core'
import { ChevronRight } from 'lucide-react'
import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from 'react'
import { cn } from '../../../lib/utils'
import {
- WALL_DRAW_IDLE_CAPTION,
WALL_DRAW_VARIANTS,
type WallDrawVariant,
} from '../../../lib/wall-draw-variant'
@@ -70,6 +70,7 @@ export function BuildPanelAdvancedSection({
description?: string
hint?: string
}) {
+ const i18n = usePascalI18n()
const [open, setOpen] = useState(false)
useEffect(() => setOpen(readAdvancedOpen()), [])
useEffect(() => {
@@ -94,7 +95,9 @@ export function BuildPanelAdvancedSection({
open && 'rotate-90',
)}
/>
-
Advanced
+
+ {i18n._({ id: 'editor.buildPanel.advanced', message: 'Advanced' })}
+
{hint ? (
{hint}
) : null}
@@ -188,12 +191,23 @@ export function BuildToolTile({
)
}
+const CAPTION_LEAD_MARKER = '__PASCAL_CAPTION_LEAD__'
+
/** The line under the Rooms tiles: what the armed shape does, lead in bold. */
-export function BuildPanelCaption({ lead, rest }: { lead?: string; rest: string }) {
+export function BuildPanelCaption({ caption, lead }: { caption: string; lead?: string }) {
+ const markerIndex = caption.indexOf(CAPTION_LEAD_MARKER)
+ const hasMarkedLead = lead && markerIndex >= 0
return (
- {lead ? {lead} : null}
- {rest}
+ {hasMarkedLead ? (
+ <>
+ {caption.slice(0, markerIndex)}
+ {lead}
+ {caption.slice(markerIndex + CAPTION_LEAD_MARKER.length)}
+ >
+ ) : (
+ caption
+ )}
)
}
@@ -214,29 +228,103 @@ export function BuildPanelRoomsSection({
/** Extra attributes per tile (e.g. a host's guide-target hook). */
tileProps?: (variant: WallDrawVariant) => Record
}) {
+ const i18n = usePascalI18n()
const active = WALL_DRAW_VARIANTS.find((variant) => variant.id === activeVariant)
+ const activeMessages = active ? roomVariantMessages(active.id, i18n) : null
return (
-
+
- {WALL_DRAW_VARIANTS.map((variant) => (
- onSelect(variant.id)}
- onMouseEnter={() => onHover?.(variant.id)}
- size="lg"
- title={variant.title}
- {...tileProps?.(variant.id)}
- />
- ))}
+ {WALL_DRAW_VARIANTS.map((variant) => {
+ const messages = roomVariantMessages(variant.id, i18n)
+ return (
+ onSelect(variant.id)}
+ onMouseEnter={() => onHover?.(variant.id)}
+ size="lg"
+ title={messages.title}
+ {...tileProps?.(variant.id)}
+ />
+ )
+ })}
)
}
+
+function roomVariantMessages(variant: WallDrawVariant, i18n: PascalI18n) {
+ if (variant === 'rectangle') {
+ return {
+ label: i18n._({
+ id: 'editor.buildPanel.rooms.rectangle.label',
+ message: 'Rectangle',
+ }),
+ title: i18n._({
+ id: 'editor.buildPanel.rooms.rectangle.title',
+ message: 'Rectangle room',
+ }),
+ lead: i18n._({
+ id: 'editor.buildPanel.rooms.rectangle.lead',
+ message: 'Click two corners',
+ }),
+ caption: i18n._({
+ id: 'editor.buildPanel.rooms.rectangle.caption',
+ message: '{lead} on the grid. Walls, floor and ceiling are created together.',
+ values: { lead: CAPTION_LEAD_MARKER },
+ }),
+ }
+ }
+ if (variant === 'walls') {
+ return {
+ label: i18n._({ id: 'editor.buildPanel.rooms.walls.label', message: 'Walls' }),
+ title: i18n._({ id: 'editor.buildPanel.rooms.walls.title', message: 'Walls' }),
+ lead: i18n._({
+ id: 'editor.buildPanel.rooms.walls.lead',
+ message: 'Draw one wall at a time.',
+ }),
+ caption: i18n._({
+ id: 'editor.buildPanel.rooms.walls.caption',
+ message: '{lead} Close a loop and it becomes a room; leave it open for partitions.',
+ values: { lead: CAPTION_LEAD_MARKER },
+ }),
+ }
+ }
+ return {
+ label: i18n._({ id: 'editor.buildPanel.rooms.polygon.label', message: 'Polygon' }),
+ title: i18n._({
+ id: 'editor.buildPanel.rooms.polygon.title',
+ message: 'Polygon room',
+ }),
+ lead: i18n._({
+ id: 'editor.buildPanel.rooms.polygon.lead',
+ message: 'Click each corner,',
+ }),
+ caption: i18n._({
+ id: 'editor.buildPanel.rooms.polygon.caption',
+ message: '{lead} then the first one again to close. For L-shapes and angled rooms.',
+ values: { lead: CAPTION_LEAD_MARKER },
+ }),
+ }
+}
diff --git a/packages/editor/src/components/ui/controls/metric-control.test.ts b/packages/editor/src/components/ui/controls/metric-control.test.ts
new file mode 100644
index 0000000000..14ee72eee2
--- /dev/null
+++ b/packages/editor/src/components/ui/controls/metric-control.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from 'bun:test'
+import { parseMetricInput } from './metric-control'
+
+test('metric controls parse stair dimensions with the scoped locale', () => {
+ expect(
+ parseMetricInput('1,000', 'm', {
+ isImperial: false,
+ locale: 'fr',
+ parseUnit: 'm',
+ }),
+ ).toBe(1)
+ expect(
+ parseMetricInput('1,000', 'm', {
+ isImperial: false,
+ locale: 'en',
+ parseUnit: 'm',
+ }),
+ ).toBe(1000)
+})
diff --git a/packages/editor/src/components/ui/controls/metric-control.tsx b/packages/editor/src/components/ui/controls/metric-control.tsx
index 749ad84378..b810e96560 100644
--- a/packages/editor/src/components/ui/controls/metric-control.tsx
+++ b/packages/editor/src/components/ui/controls/metric-control.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useScene } from '@pascal-app/core'
+import { parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
import { useLinearDisplay } from '../../../lib/use-linear-display'
@@ -20,6 +20,21 @@ interface MetricControlProps {
restoreOnCommit?: boolean
}
+export function parseMetricInput(
+ text: string,
+ unit: string,
+ options: { isImperial: boolean; locale: string; parseUnit?: string },
+): number | null {
+ const spec = lingoUnitSpec(unit)
+ return spec
+ ? parseMeasurement(text, spec, {
+ bareUnit: options.parseUnit ?? spec.unitId,
+ locale: options.locale,
+ system: options.isImperial ? 'us' : 'metric',
+ })
+ : parseLocalizedNumber(text, options.locale)
+}
+
export function MetricControl({
label,
value,
@@ -33,6 +48,7 @@ export function MetricControl({
unit = '',
restoreOnCommit = true,
}: MetricControlProps) {
+ const locale = useLocale()
const {
isImperial,
displayUnit,
@@ -230,16 +246,8 @@ export function MetricControl({
const submitValue = useCallback(() => {
const spec = lingoUnitSpec(unit)
- let stored = spec
- ? parseMeasurement(inputValue, spec, {
- bareUnit: parseUnit ?? spec.unitId,
- system: isImperial ? 'us' : 'metric',
- })
- : null
- if (stored === null) {
- const numValue = Number.parseFloat(inputValue)
- stored = Number.isFinite(numValue) ? toStoredValue(numValue) : null
- }
+ const parsed = parseMetricInput(inputValue, unit, { isImperial, locale, parseUnit })
+ const stored = parsed === null ? null : spec ? parsed : toStoredValue(parsed)
if (stored === null) {
setInputValue(toDisplayValue(value).toFixed(precision))
} else {
@@ -249,6 +257,7 @@ export function MetricControl({
}, [
inputValue,
unit,
+ locale,
isImperial,
parseUnit,
applyCommittedValue,
@@ -265,6 +274,7 @@ export function MetricControl({
? measurementHint(inputValue, spec, {
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
+ locale,
displayUnit: parseUnit ?? spec.unitId,
precision,
clamp,
diff --git a/packages/editor/src/components/ui/controls/slider-control.test.tsx b/packages/editor/src/components/ui/controls/slider-control.test.tsx
new file mode 100644
index 0000000000..bfb964d811
--- /dev/null
+++ b/packages/editor/src/components/ui/controls/slider-control.test.tsx
@@ -0,0 +1,21 @@
+import { expect, test } from 'bun:test'
+import { createI18n, PascalI18nProvider } from '@pascal-app/core'
+import { renderToString } from 'react-dom/server'
+import { parseSliderBareValue, SliderControl } from './slider-control'
+
+test('slider numbers use the scoped locale for display and parsing', () => {
+ const i18n = createI18n({ locale: 'fr' })
+ try {
+ const html = renderToString(
+
+ {}} precision={2} value={2.75} />
+ ,
+ )
+
+ expect(html).toContain('2,75')
+ expect(parseSliderBareValue('2,75', i18n.getLocale())).toBe(2.75)
+ expect(parseSliderBareValue('1e2', i18n.getLocale())).toBe(100)
+ } finally {
+ i18n.destroy()
+ }
+})
diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx
index 3bf74c6b75..995c02a494 100644
--- a/packages/editor/src/components/ui/controls/slider-control.tsx
+++ b/packages/editor/src/components/ui/controls/slider-control.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useScene } from '@pascal-app/core'
+import { formatNumber, parseLocalizedNumber, useLocale, useScene } from '@pascal-app/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
import { useLinearDisplay } from '../../../lib/use-linear-display'
@@ -23,6 +23,10 @@ interface SliderControlProps {
mixed?: boolean
}
+export function parseSliderBareValue(text: string, locale: string): number | null {
+ return parseLocalizedNumber(text, locale)
+}
+
function stepPrecision(s: number): number {
if (s <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(s)))
@@ -67,14 +71,24 @@ export function SliderControl({
restoreOnCommit = true,
mixed = false,
}: SliderControlProps) {
+ const locale = useLocale()
// Values and bounds stay in meters; gestures and input use the displayed unit.
const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
useLinearDisplay(unit, storedPrecision, storedStep)
+ const formatDisplay = useCallback(
+ (displayValue: number) =>
+ formatNumber(displayValue, {
+ locale,
+ min: precision,
+ max: precision,
+ }),
+ [locale, precision],
+ )
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const [isHovered, setIsHovered] = useState(false)
- const [inputValue, setInputValue] = useState(toDisplay(value).toFixed(precision))
+ const [inputValue, setInputValue] = useState(() => formatDisplay(toDisplay(value)))
// Live readout while dragging. Multi-edit previews write live overrides
// instead of the scene, so `value` would otherwise stay frozen even though
// the meshes are moving.
@@ -116,9 +130,9 @@ export function SliderControl({
useEffect(() => {
if (!isEditing) {
- setInputValue(toDisplay(value).toFixed(precision))
+ setInputValue(formatDisplay(toDisplay(value)))
}
- }, [value, precision, isEditing, toDisplay])
+ }, [value, isEditing, toDisplay, formatDisplay])
// Wheel support on the label
useEffect(() => {
@@ -258,8 +272,8 @@ export function SliderControl({
const handleValueClick = useCallback(() => {
editingRef.current = true
setIsEditing(true)
- setInputValue(toDisplay(value).toFixed(precision))
- }, [value, precision, toDisplay])
+ setInputValue(formatDisplay(toDisplay(value)))
+ }, [value, toDisplay, formatDisplay])
const parseInputValue = useCallback(
(text: string) => {
@@ -267,16 +281,17 @@ export function SliderControl({
const parsed = spec
? parseMeasurement(text, spec, {
bareUnit: parseUnit ?? spec.unitId,
+ locale,
system: isImperial ? 'us' : 'metric',
})
: null
- const bare = Number(text)
- const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null)
+ const bare = parseSliderBareValue(text, locale)
+ const stored = parsed ?? (bare === null ? null : toStored(bare))
return stored === null
? null
: clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
},
- [unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision],
+ [unit, parseUnit, locale, isImperial, toStored, clamp, toDisplay, precision],
)
const submitValue = useCallback(() => {
@@ -285,13 +300,13 @@ export function SliderControl({
const nextValue = parseInputValue(inputValue)
if (nextValue === null) {
onCancel?.()
- setInputValue(toDisplay(value).toFixed(precision))
+ setInputValue(formatDisplay(toDisplay(value)))
} else {
onChange(nextValue)
onCommit?.(nextValue)
}
setIsEditing(false)
- }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
+ }, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, formatDisplay])
const spec = lingoUnitSpec(unit)
const hint =
@@ -299,6 +314,7 @@ export function SliderControl({
? measurementHint(inputValue, spec, {
bareUnit: parseUnit ?? spec.unitId,
system: isImperial ? 'us' : 'metric',
+ locale,
displayUnit: parseUnit ?? spec.unitId,
precision,
clamp,
@@ -317,10 +333,10 @@ export function SliderControl({
const adjustedStep = getAdjustedStep(step, e)
const newV = applyDisplayDelta(value, direction * adjustedStep, adjustedStep)
onChange(newV)
- setInputValue(toDisplay(newV).toFixed(precision))
+ setInputValue(formatDisplay(toDisplay(newV)))
}
},
- [submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
+ [submitValue, cancelEdit, value, step, applyDisplayDelta, onChange, toDisplay, formatDisplay],
)
const displayValue = toDisplay(shown)
@@ -414,7 +430,7 @@ export function SliderControl({
onClick={handleValueClick}
>
- {Number(displayValue.toFixed(precision)).toFixed(precision)}
+ {formatDisplay(displayValue)}
{displayUnit && {displayUnit}}
diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx
index 47e34bab33..40d6b176d3 100644
--- a/packages/editor/src/components/ui/floating-level-selector.tsx
+++ b/packages/editor/src/components/ui/floating-level-selector.tsx
@@ -55,11 +55,15 @@ import {
type LevelDuplicatePreset,
} from '@pascal-app/core/building'
import {
+ formatNumber,
getDefaultLevelName,
getLevelDisplayName,
roofLevelOrderBreach,
servesAsRoofLevel,
+ useLocale,
+ usePascalI18n,
} from '@pascal-app/core'
+import { localizedLevelName } from '../../i18n/level-name'
import { addStoreyBelow, addStoreyOnTop, setGroundFloor } from '../../lib/add-storey'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { unitMemberLevels, leaveUnitFocus } from '../../lib/units'
@@ -173,14 +177,19 @@ function LevelRow({
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const updateNode = useScene((s) => s.updateNode)
+ const i18n = usePascalI18n()
+ const locale = useLocale()
const { isImperial, toDisplay, displayUnit, precision: displayPrecision } = useLinearDisplay('m', 2)
const storeyHeight = getStoredLevelHeight(level)
// Decimal units keep the compact readout; integer millimeters must retain trailing zeroes.
- const formattedStoreyHeight = toDisplay(storeyHeight).toFixed(displayPrecision)
- const storeyHeightLabel = `${
- displayPrecision > 0 ? formattedStoreyHeight.replace(/0$/, '') : formattedStoreyHeight
- } ${displayUnit}`
+ const formattedStoreyHeight = formatNumber(toDisplay(storeyHeight), {
+ locale,
+ min: Math.max(0, displayPrecision - 1),
+ max: displayPrecision,
+ })
+ const storeyHeightLabel = `${formattedStoreyHeight} ${displayUnit}`
+ const levelName = localizedLevelName(level, i18n, locale)
// Same rule as the site panel and command palette: the ordinal-0 ground
// floor is the vertical model's zero anchor and must never be deletable.
const canDeleteLevel = level.level !== 0
@@ -219,7 +228,7 @@ function LevelRow({
>