From f179590990d175447bc76e227ecc8d5021ee27a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 14:56:35 +0000 Subject: [PATCH 1/3] i18n: shared room labels prefer the room's own entry; layer-tree rename edits the shown name - roomNameOptions takes the room's stored name: a label two catalog names share is the exact match for that room's own entry, so Enter on a Lounge keeps Lounge. - The layer tree's room rename shows displayRoomName while editing and commits through commitRoomNameEdit (updateNode, trimmed, cleared as before). - The rename input and the snap-target badge get accessible names. - New pseudo-audit surface layer-room-rename. --- i18n/pseudo-surfaces.ts | 33 ++++++++++-- .../ui/panels/room-name-options.test.ts | 31 +++++++++++ .../components/ui/panels/room-name-options.ts | 52 +++++++++++++++---- .../src/components/ui/panels/room-panel.tsx | 4 +- .../ui/sidebar/panels/site-panel/index.tsx | 6 +-- .../panels/site-panel/inline-rename-input.tsx | 27 ++++++++++ .../panels/site-panel/zone-tree-node.tsx | 7 +-- .../src/components/ui/snap-target-badge.tsx | 13 ++--- .../editor/src/lib/room-name-storage.test.ts | 46 ++++++++++++++++ 9 files changed, 187 insertions(+), 32 deletions(-) diff --git a/i18n/pseudo-surfaces.ts b/i18n/pseudo-surfaces.ts index 4d36484968..dcb392a7f1 100644 --- a/i18n/pseudo-surfaces.ts +++ b/i18n/pseudo-surfaces.ts @@ -90,6 +90,16 @@ async function openLayer( english: string | RegExp, options: { exact?: boolean } = {}, ) { + await expandLayers(page) + const name = + typeof english !== 'string' || options.exact === false ? english : englishOrPseudo(english) + await page.getByText(name, { exact: true }).first().click() + await page.waitForTimeout(1200) + await expandInspector(page) +} + +/** Opens the Scene panel and expands every row. */ +async function expandLayers(page: Page) { await openScenePanel(page) // Expand every row: the tree's chevrons are the small square buttons beside each name. for (let pass = 0; pass < 3; pass++) { @@ -102,11 +112,6 @@ async function openLayer( } await page.waitForTimeout(700) } - const name = - typeof english !== 'string' || options.exact === false ? english : englishOrPseudo(english) - await page.getByText(name, { exact: true }).first().click() - await page.waitForTimeout(1200) - await expandInspector(page) } /** The inspector opens collapsed to its header on small viewports. */ @@ -336,6 +341,24 @@ export const surfaces: PseudoSurface[] = [ scope: '[data-surface="site-panel"]', data: apartmentData, }, + { + name: 'layer-room-rename', + path: apartmentPath, + steps: async (page) => { + await expandLayers(page) + // The template's "Bedroom 1" shows, and edits, as the catalog name in the UI language. + const row = page.getByText(`${pseudo('Bedroom')} 1`, { exact: true }).first() + await row.hover() + await row + .locator('xpath=..') + .getByRole('button', { name: pseudo('Rename'), exact: true }) + .click() + await page.locator('[data-surface="site-panel"] input[type="text"]').first().waitFor() + await page.waitForTimeout(500) + }, + scope: '[data-surface="site-panel"]', + data: apartmentData, + }, { name: 'level-selector', path: apartmentPath, diff --git a/packages/editor/src/components/ui/panels/room-name-options.test.ts b/packages/editor/src/components/ui/panels/room-name-options.test.ts index 7d02ed6863..f7a850f62e 100644 --- a/packages/editor/src/components/ui/panels/room-name-options.test.ts +++ b/packages/editor/src/components/ui/panels/room-name-options.test.ts @@ -49,6 +49,37 @@ describe('roomNameOptions', () => { expect(roomNameOptions('kitchen', SECTIONS).typed?.value).toBe('kitchen') }) + test('an exact English name wins over names that contain it', () => { + const storage: RoomNameSection[] = [ + { id: 'utility', title: 'Utility', names: ['Walk-in closet', 'Closet'] }, + ] + for (const typed of ['Closet', 'closet']) { + const { options, active } = roomNameOptions(typed, storage) + expect(options[active]?.value).toBe('Closet') + } + }) + + test('a shared label is exact only for the room’s own entry', () => { + const shared: RoomNameSection[] = [ + { + id: 'living', + title: 'Séjour', + names: ['Living room', 'Kitchen', 'Lounge'], + labels: ['Salon', 'Cuisine', 'Salon'], + }, + ] + const activeValue = (current: string) => { + const { options, active } = roomNameOptions('Salon', shared, true, current) + return options[active]?.value + } + expect(activeValue('Lounge')).toBe('Lounge') + expect(activeValue('Lounge 2')).toBe('Lounge') + expect(activeValue('Living room')).toBe('Living room') + // Any other room falls back to the first suggestion. + expect(activeValue('Kitchen')).toBe('Living room') + expect(activeValue('')).toBe('Living room') + }) + test('the keyboard order is the typed row, then the groups in order', () => { const { groups, options } = roomNameOptions('e', SECTIONS) expect(options.slice(1)).toEqual(groups.flatMap((g) => g.options)) diff --git a/packages/editor/src/components/ui/panels/room-name-options.ts b/packages/editor/src/components/ui/panels/room-name-options.ts index 51a48d6e7a..a6d001b2e6 100644 --- a/packages/editor/src/components/ui/panels/room-name-options.ts +++ b/packages/editor/src/components/ui/panels/room-name-options.ts @@ -1,5 +1,9 @@ -import { useScene } from '@pascal-app/core' -import { numberedRoomName, type RoomNameSection } from '../../../lib/room-name-catalog' +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { + baseRoomName, + numberedRoomName, + type RoomNameSection, +} from '../../../lib/room-name-catalog' import { type DescriptorTranslator, displayRoomName, @@ -25,12 +29,13 @@ export type RoomNameGroup = { id: string; title: string; options: RoomNameOption * case and surrounding spaces), drops the sections left empty, and puts the * typed text first as its own row unless it is exactly an English catalog * name. `options` is the flat order the keyboard walks: the typed row, then - * each group's rows. + * each group's rows. `current` is the room's stored name. */ export function roomNameOptions( typed: string, sections: readonly RoomNameSection[], filtering = true, + current = '', ): { typed: RoomNameOption | null groups: RoomNameGroup[] @@ -62,12 +67,15 @@ export function roomNameOptions( query && !known ? { kind: 'typed', value: typed, label: typed, match: null } : null const options = [...(typedRow ? [typedRow] : []), ...groups.flatMap((group) => group.options)] const suggestions = options.filter((option) => option.kind === 'suggestion') - // Labels can be shared ("Salon" for both Living room and Lounge): a label is - // only an exact match when no other name has it. + // Labels can be shared ("Salon" for both Living room and Lounge): a shared + // label is only an exact match for the room's own name. + const own = baseRoomName(current).toLowerCase() const byLabel = suggestions.filter((option) => option.label.toLowerCase() === query) const exactRow = suggestions.find((option) => option.value.toLowerCase() === query) ?? - (byLabel.length === 1 ? byLabel[0] : undefined) + (byLabel.length === 1 + ? byLabel[0] + : byLabel.find((option) => option.value.toLowerCase() === own)) const exact = exactRow ? options.indexOf(exactRow) : -1 return { typed: typedRow, @@ -78,9 +86,10 @@ export function roomNameOptions( } /** - * The room panel's commit of its edited field, on blur or Enter (unless Enter - * picks a suggestion). `committed` is what the field last committed or showed; - * text still equal to it writes nothing. + * The commit of an edited room-name field, on blur or Enter (unless Enter + * picks a suggestion): the room panel's, and the layer tree's with its own + * `write`. `committed` is what the field last committed or showed; text still + * equal to it writes nothing. */ export function commitRoomNameEdit( zoneId: string, @@ -88,10 +97,33 @@ export function commitRoomNameEdit( committed: string, text: string, i18n: DescriptorTranslator, + write: (zoneId: string, name: string) => void = renameRoom, ) { if (text === committed) return const stored = editedRoomName(name, text, i18n) - if (stored !== name) renameRoom(zoneId, stored) + if (stored !== name) write(zoneId, stored) +} + +/** + * The layer tree's rename of a room or zone. Its field shows the name as + * `displayRoomName` does and passes the trimmed text, or `undefined` when + * cleared; it writes through `updateNode`, as main's tree does. + */ +export function commitTreeRoomRename( + zoneId: string, + text: string | undefined, + i18n: DescriptorTranslator, +) { + const { nodes, updateNode } = useScene.getState() + const id = zoneId as AnyNodeId + const name = nodes[id]?.name ?? '' + if (text === undefined) { + updateNode(id, { name: undefined }) + return + } + commitRoomNameEdit(zoneId, name, displayRoomName(name, i18n), text, i18n, (_, stored) => + updateNode(id, { name: stored }), + ) } /** diff --git a/packages/editor/src/components/ui/panels/room-panel.tsx b/packages/editor/src/components/ui/panels/room-panel.tsx index 411be674f3..029f7c7f9c 100644 --- a/packages/editor/src/components/ui/panels/room-panel.tsx +++ b/packages/editor/src/components/ui/panels/room-panel.tsx @@ -577,7 +577,7 @@ function RoomName({ const originalRef = useRef(display) const inputRef = useRef(null) const listId = useId() - const { typed: typedRow, groups, options } = roomNameOptions(value, sections, typing) + const { typed: typedRow, groups, options } = roomNameOptions(value, sections, typing, name) const expanded = open && options.length > 0 const activeId = expanded && options[active] ? `${listId}-${active}` : undefined const current = baseRoomName(name).toLowerCase() @@ -755,7 +755,7 @@ function RoomName({ setTyping(true) setOpen(true) scrollToActive.current = true - setActive(roomNameOptions(event.target.value, sections, true).active) + setActive(roomNameOptions(event.target.value, sections, true, name).active) }} onFocus={() => { originalRef.current = valueRef.current diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 4ffe37d3f8..0626fb399e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -81,14 +81,13 @@ import { useUploadStore } from '../../../../../store/use-upload' import { MetricControl } from '../../../controls/metric-control' import { LevelDuplicateDialog } from '../../../level-duplicate-dialog' import { CollectionsSection } from './collection-tree-node' -import { InlineRenameInput } from './inline-rename-input' +import { InlineRenameInput, RoomRenameInput } from './inline-rename-input' import { ZoneMembershipCheckbox } from './zone-membership-checkbox' import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeDragProvider } from './tree-node-drag' import { zoneDefaultName } from './default-names' import { nodeLabelDescriptor } from '../../../../../i18n/node-messages' import { authoredLevelName, localizedLevelName } from '../../../../../i18n/level-name' -import { displayRoomName } from '../../../../../lib/room-name-messages' // ============================================================================ // PROPERTY LINE SECTION @@ -1298,9 +1297,8 @@ const ZoneItem = memo(function ZoneItem({ />
- setIsEditing(true)} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx index e90a7ba6b0..2b9ca1c07b 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx @@ -1,7 +1,9 @@ import { type AnyNodeId, usePascalI18n, useScene } from '@pascal-app/core' import { Pencil } from 'lucide-react' import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { displayRoomName } from './../../../../../lib/room-name-messages' import { cn } from './../../../../../lib/utils' +import { commitTreeRoomRename } from './../../../panels/room-name-options' interface InlineRenameFieldProps { name: string | undefined @@ -38,6 +40,30 @@ export const InlineRenameInput = memo(function InlineRenameInput({ return }) +/** + * Renames a room or zone from the layer tree. The field edits the name in the + * UI language, as the room panel shows it, and stores what an edit there + * stores: a catalog name stays English ("Salon 3" on a Lounge stores "Lounge 3"). + */ +export const RoomRenameInput = memo(function RoomRenameInput({ + nodeId, + ...props +}: Omit) { + const i18n = usePascalI18n() + const stored = useScene((s) => s.nodes[nodeId]?.name) + const handleRename = useCallback( + (next: string | undefined) => commitTreeRoomRename(nodeId, next, i18n), + [nodeId, i18n], + ) + return ( + + ) +}) + export const InlineRenameField = memo(function InlineRenameField({ name, onRename, @@ -108,6 +134,7 @@ export const InlineRenameField = memo(function InlineRenameField({ return ( state.updateNode) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) - const storedName = useScene((s) => s.nodes[nodeId]?.name) const color = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.color) const polygon = useScene((s) => (s.nodes[nodeId] as ZoneNode | undefined)?.polygon ?? []) const isSelected = useZoneSelected(nodeId) @@ -85,9 +83,8 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ unitName={focusedUnit.name || i18n._(nodeLabelDescriptor('unit', 'Unit'))} /> )} - = { roof: '/icons/roof.webp', } -const SNAP_TARGET_LABELS: Record = { - wall: 'Wall attachment', - ceiling: 'Ceiling attachment', - roof: 'Roof attachment', +const SNAP_TARGET_LABELS: Record = { + wall: /* i18n */ { id: 'editor.snapTarget.wall', message: 'Wall attachment' }, + ceiling: /* i18n */ { id: 'editor.snapTarget.ceiling', message: 'Ceiling attachment' }, + roof: /* i18n */ { id: 'editor.snapTarget.roof', message: 'Roof attachment' }, } const SNAP_TARGET_BADGE_SIZE_CLASSES: Record = { @@ -53,6 +53,7 @@ export function SnapTargetBadge({ size?: SnapTargetBadgeSize target: SnapTarget }) { + const i18n = usePascalI18n() return ( {SNAP_TARGET_LABELS[target]} diff --git a/packages/editor/src/lib/room-name-storage.test.ts b/packages/editor/src/lib/room-name-storage.test.ts index 8113648f49..d41120432c 100644 --- a/packages/editor/src/lib/room-name-storage.test.ts +++ b/packages/editor/src/lib/room-name-storage.test.ts @@ -18,6 +18,7 @@ import { createAgentHost, liveSceneRuntime } from '@pascal-app/core/runtime' import { commitRoomNameEdit, commitRoomNamePick, + commitTreeRoomRename, roomNameOptions, } from '../components/ui/panels/room-name-options' import { roomNameSections } from './room-name-catalog' @@ -136,6 +137,14 @@ const edit = (i18n: PascalI18n, zoneId: string, draft: (shown: string) => string const shown = displayRoomName(stored, i18n) commitRoomNameEdit(zoneId, stored, shown, draft(shown), i18n) } +/** Types `text` in the panel field and presses Enter: the active row, as the field does. */ +const enter = (i18n: PascalI18n, zoneId: string, text: string) => { + const stored = nameOf(zoneId) ?? '' + const { options, active } = roomNameOptions(text, sectionsFor(i18n, zoneId), true, stored) + const chosen = options[active] + if (chosen?.kind === 'suggestion') commitRoomNamePick(zoneId, stored, chosen, i18n) + else commitRoomNameEdit(zoneId, stored, displayRoomName(stored, i18n), text, i18n) +} const named = (zoneId: string, name: string) => renameRoom(zoneId, name) describe('room names are stored in English whatever the UI language', () => { @@ -175,6 +184,20 @@ describe('room names are stored in English whatever the UI language', () => { expect(expectSameEnglish(study).zone_new).toBe('Study 2') }) + test('Enter on a shared label keeps the room its own entry', () => { + // "Salon" is shown for both Living room and Lounge; Enter on a Lounge keeps Lounge. + const lounge = (i18n: PascalI18n) => { + named('zone_new', 'Lounge') + enter(i18n, 'zone_new', displayRoomName('Lounge', i18n)) + } + expect(expectSameEnglish(lounge).zone_new).toBe('Lounge') + const living = (i18n: PascalI18n) => { + named('zone_new', 'Living room') + enter(i18n, 'zone_new', displayRoomName('Living room', i18n)) + } + expect(expectSameEnglish(living).zone_new).toBe('Living room') + }) + test('free text, including an exact translation, is stored as typed', () => { const atelier = (i18n: PascalI18n) => edit(i18n, 'zone_new', () => 'Atelier de Marie') expect(expectSameEnglish(atelier).zone_new).toBe('Atelier de Marie') @@ -207,6 +230,29 @@ describe('room names are stored in English whatever the UI language', () => { expect(expectSameEnglish(padded).zone_new).toBe(' Bedroom 3 ') }) + test('the layer tree rename edits the shown name and stores the English', () => { + /** The tree field shows `displayRoomName`, and passes the trimmed text or undefined. */ + const treeEdit = (i18n: PascalI18n, zoneId: string, draft: (shown: string) => string) => { + const shown = displayRoomName(nameOf(zoneId) ?? '', i18n) + const text = draft(shown).trim() + if (text !== shown) commitTreeRoomRename(zoneId, text || undefined, i18n) + } + const renumber = (i18n: PascalI18n) => { + named('zone_new', 'Lounge 2') + treeEdit(i18n, 'zone_new', (shown) => shown.replace(/2$/, '3')) + } + expect(expectSameEnglish(renumber).zone_new).toBe('Lounge 3') + const padded = (i18n: PascalI18n) => { + named('zone_new', ' Kitchen ') + treeEdit(i18n, 'zone_new', (shown) => shown) + } + expect(expectSameEnglish(padded).zone_new).toBe('Kitchen') + const free = (i18n: PascalI18n) => treeEdit(i18n, 'zone_existing', () => ' Atelier ') + expect(expectSameEnglish(free).zone_existing).toBe('Atelier') + const cleared = (i18n: PascalI18n) => treeEdit(i18n, 'zone_existing', () => '') + expect(expectSameEnglish(cleared).zone_existing).toBeUndefined() + }) + test('an unchanged field writes nothing', () => { for (const name of ['Bedroom', ' Bedroom 2 ', 'Lounge 2', 'Atelier']) { seed() From f21478848df04ca4507f40cf7b10407fdd948a95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 14:56:36 +0000 Subject: [PATCH 2/3] i18n: write the new plural counts ungrouped, as main does --- .../components/editor/floating-action-menu.tsx | 4 ++-- .../ui/panels/floor-foundation-panel.tsx | 6 +++--- .../ui/panels/room-construction-rows.tsx | 18 ++++++++++++------ packages/nodes/src/ceiling/panel.tsx | 4 +++- .../nodes/src/roof/glazing-panel-editor.tsx | 4 ++-- .../nodes/src/shared/system-check-panel.tsx | 4 +++- packages/nodes/src/slab/panel.tsx | 4 +++- packages/nodes/src/wall/curtain-wall-panel.tsx | 9 +++++---- 8 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index d305412157..eb0fc791d4 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -815,8 +815,8 @@ function SystemSummaryPill({ {formatMeasurement(summary.runLengthM, unit, metricNotation)} ·{' '} {i18n._({ id: 'editor.floatingActionMenu.runCount', - message: '{count, plural, one {# run} other {# runs}}', - values: { count: summary.runCount }, + message: '{count, plural, one {{number} run} other {{number} runs}}', + values: { count: summary.runCount, number: String(summary.runCount) }, })} diff --git a/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx b/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx index b4e9d65ced..98c45d2731 100644 --- a/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx +++ b/packages/editor/src/components/ui/panels/floor-foundation-panel.tsx @@ -311,16 +311,16 @@ type Notice = { } export function moveTogetherLabel(count: number, lower: boolean, i18n: PascalI18n) { - const values = { count } + const values = { count, number: String(count) } return lower ? i18n._({ id: 'editor.panels.floorFoundationPanel.lowerTogether', - message: '{count, plural, =2 {Lower both} other {Lower all #}}', + message: '{count, plural, =2 {Lower both} other {Lower all {number}}}', values, }) : i18n._({ id: 'editor.panels.floorFoundationPanel.raiseTogether', - message: '{count, plural, =2 {Raise both} other {Raise all #}}', + message: '{count, plural, =2 {Raise both} other {Raise all {number}}}', values, }) } diff --git a/packages/editor/src/components/ui/panels/room-construction-rows.tsx b/packages/editor/src/components/ui/panels/room-construction-rows.tsx index 18720a72a3..197662605a 100644 --- a/packages/editor/src/components/ui/panels/room-construction-rows.tsx +++ b/packages/editor/src/components/ui/panels/room-construction-rows.tsx @@ -700,13 +700,18 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) { ? i18n._({ id: 'editor.panels.roomConstructionRows.partialWalls', message: - 'Partial · {walls, plural, one {# wall} other {# walls}}, {openSides, plural, one {# open side} other {# open sides}}', - values: { walls: wallIds.length, openSides }, + 'Partial · {walls, plural, one {{wallCount} wall} other {{wallCount} walls}}, {openSides, plural, one {{openSideCount} open side} other {{openSideCount} open sides}}', + values: { + walls: wallIds.length, + openSides, + wallCount: String(wallIds.length), + openSideCount: String(openSides), + }, }) : i18n._({ id: 'editor.panels.roomConstructionRows.wallCount', - message: '{walls, plural, one {# wall} other {# walls}}', - values: { walls: wallIds.length }, + message: '{walls, plural, one {{wallCount} wall} other {{wallCount} walls}}', + values: { walls: wallIds.length, wallCount: String(wallIds.length) }, }) const locked = roomOutsideFacesLocked(nodes, wallIds) @@ -763,8 +768,9 @@ export function RoomConstructionRows({ room }: { room: RoomSelectionRecord }) {