Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a3a1408
i18n node messages: skip a missing nodes entry only in temporary cata…
cursoragent Oct 27, 2026
9e38fe4
core: floorFootprintNaming parts behind floorFootprintName, and forma…
cursoragent Oct 27, 2026
b230f97
i18n: translate the auto inspector's definition copy at display time
cursoragent Oct 27, 2026
1ee189e
i18n: migrate the properties and inspector panels
cursoragent Oct 27, 2026
f85f958
i18n inspector: rejoin sentences the codemod split around values
cursoragent Oct 27, 2026
3545599
i18n: extract the inspector messages with fuzzy AI drafts and shrink …
cursoragent Oct 27, 2026
703fd9e
i18n inspector: translate the controls, presets and diagnostics the p…
cursoragent Oct 27, 2026
70d04ac
i18n audit: add inspector surfaces on a furnished scene with a roof a…
cursoragent Oct 27, 2026
c43f071
i18n: extract the remaining inspector messages with fuzzy AI drafts
cursoragent Oct 27, 2026
82e6616
Room names: a suggestion stores its English catalog name in every UI …
cursoragent Oct 27, 2026
f012eeb
Merge origin/main into cursor/i18n-inspector-6d7c
cursoragent Oct 27, 2026
8cf978e
Room names: show catalog names in the UI language wherever a room is …
cursoragent Oct 27, 2026
26a378d
i18n inspector: whole refusal sentences, locale numbers and ICU count…
cursoragent Oct 27, 2026
60ce6cd
core: formatList keeps the English comma list
cursoragent Oct 27, 2026
66518e9
i18n audit: pin the browser to UTC and en-GB so units and data are me…
cursoragent Oct 27, 2026
5e51941
i18n: extract the review-round messages with fuzzy AI drafts
cursoragent Oct 27, 2026
6e727bf
i18n audit: open the Scene panel without collapsing it, and match plu…
cursoragent Oct 27, 2026
7b0eac8
i18n: room-name panel edits store English; match English names; own-k…
cursoragent Oct 27, 2026
99ffa55
i18n: viewer room labels use the editor's i18n and re-translate on lo…
cursoragent Oct 27, 2026
fd8d820
i18n: fixed-digit numbers round like toFixed, normalize -0, ungrouped…
cursoragent Oct 27, 2026
8ccde17
i18n: tighten pseudo-audit roof and stair row matches
cursoragent Oct 27, 2026
585db60
i18n: extract catalogs and add fuzzy drafts for the count messages
cursoragent Oct 27, 2026
966702a
i18n: a room-name edit converts only the room's own catalog name
cursoragent Oct 27, 2026
b563163
Merge remote-tracking branch 'origin/main' into cursor/i18n-inspector…
cursoragent Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,120 changes: 0 additions & 1,120 deletions i18n/literal-baseline.json

Large diffs are not rendered by default.

148 changes: 147 additions & 1 deletion i18n/pseudo-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { Page } from 'playwright-core'
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
import { template as twoBedroom } from '../packages/mcp/src/templates/two-bedroom'
import { pseudoLocalize } from '../scripts/i18n'
import {
RoofNode,
RoofSegmentNode,
StairNode,
StairSegmentNode,
} from '../packages/core/src/schema'

/**
* The surface manifest for `bun run i18n:pseudo-audit`. Each migration PR adds
Expand Down Expand Up @@ -44,6 +50,74 @@ export const auditProject = (base: string) =>

export const editorPath = async (base: string) => `/project/${await auditProject(base)}`

/** The two-bedroom template with a gable roof and a straight stair, so every inspector has a row. */
function furnishedScene() {
const graph = structuredClone(twoBedroom) as unknown as {
nodes: Record<string, { children?: string[] }>
}
const nodes = [
RoofSegmentNode.parse({ id: 'rseg_audit', parentId: 'roof_audit', roofType: 'gable', width: 4, depth: 6 }),
RoofNode.parse({ id: 'roof_audit', parentId: 'level_0', children: ['rseg_audit'] }),
StairSegmentNode.parse({ id: 'sseg_audit', parentId: 'stair_audit', segmentType: 'stair', width: 1, length: 3 }),
StairNode.parse({ id: 'stair_audit', parentId: 'level_0', children: ['sseg_audit'] }),
]
for (const node of nodes) graph.nodes[node.id] = node
graph.nodes.level_0?.children?.push('roof_audit', 'stair_audit')
return graph as unknown as SceneGraph
}

export const furnishedPath = async (base: string) =>
`/project/${await auditScene(base, 'furnished', furnishedScene())}`
const furnishedData = ['Project #1', 'Living / Kitchen', 'Bedroom 1', 'Bedroom 2', 'Bath']

const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

/** The rail and layer names stay English until the left-panel migration lands. */
const englishOrPseudo = (english: string) =>
new RegExp(`^(${escapeRegExp(english)}|${escapeRegExp(pseudo(english))})$`)

/** Opens the Scene panel; the tab state persists across audit pages, and clicking the active tab collapses it. */
async function openScenePanel(page: Page): Promise<void> {
await openEditor(page)
const sceneTab = page.getByRole('button', { name: englishOrPseudo('Scene') }).first()
if ((await sceneTab.getAttribute('aria-pressed')) !== 'true') await sceneTab.click()
await page.waitForTimeout(500)
}

/** Opens the Scene panel, expands every row and selects the first row named `english`. */
async function openLayer(
page: Page,
english: string | RegExp,
options: { exact?: boolean } = {},
) {
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++) {
for (const toggle of await page.$$('button.z-10.h-4.w-4:has(svg.lucide-chevron-right)')) {
const open = await toggle.evaluate((b) => {
const transform = b.querySelector('div')?.style.transform ?? 'none'
return transform !== 'none' && !/rotate\(0/.test(transform)
})
if (!open) await toggle.click().catch(() => undefined)
}
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. */
async function expandInspector(page: Page): Promise<void> {
const expand = page.getByRole('button', { name: pseudo('Expand panel'), exact: true })
if (await expand.count()) {
await expand.first().click()
await page.waitForTimeout(800)
}
}

/** A furnished project, so trees and level lists have rows to show. */
export const apartmentPath = async (base: string) =>
`/project/${await auditScene(base, 'two-bedroom', twoBedroom)}`
Expand Down Expand Up @@ -171,6 +245,78 @@ export const surfaces: PseudoSurface[] = [
// A kind registered by the external @pascal-app/plugin-streetscape, which owns its copy.
data: ['Road'],
},
{
name: 'room-panel',
path: furnishedPath,
steps: (page) => openLayer(page, 'Living / Kitchen', { exact: false }),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'floor-foundation-panel',
path: furnishedPath,
steps: async (page) => {
await openLayer(page, 'Living / Kitchen', { exact: false })
// The template's rooms have no floor yet: build one, then open what it sits on.
await page.locator('[data-room-construction] [role="switch"]').first().click()
await page.locator('[data-sits-on]').first().waitFor()
await page.locator('[data-sits-on]').first().click()
await page.waitForTimeout(1200)
await expandInspector(page)
},
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'wall-panel',
path: furnishedPath,
steps: (page) => openLayer(page, 'Wall'),
scope: '[data-editor-inspector]',
// An inspector extension registered by the external @pascal-app/plugin-landscape.
data: [...furnishedData, 'Retaining wall'],
},
{
name: 'door-panel',
path: furnishedPath,
steps: (page) => openLayer(page, 'Door'),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'window-panel',
path: furnishedPath,
steps: (page) => openLayer(page, 'Window'),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'roof-panel',
path: furnishedPath,
steps: (page) => openLayer(page, /^(Roof \(1 segment\)|\[Ŕööƒ \(1 šéĝɱéñţ\) ~+\])$/),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'roof-segment-panel',
path: furnishedPath,
steps: (page) => openLayer(page, /\(4\.0[x×]6\.0/),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'stair-panel',
path: furnishedPath,
steps: (page) => openLayer(page, /^(Staircase \(1 segment\)|\[Šţáîŕçášé \(1 šéĝɱéñţ\) ~+\])$/),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'stair-segment-panel',
path: furnishedPath,
steps: (page) => openLayer(page, /\(1\.0[x×]3\.0/),
scope: '[data-editor-inspector]',
data: furnishedData,
},
{
name: 'icon-rail',
path: apartmentPath,
Expand All @@ -186,7 +332,7 @@ export const surfaces: PseudoSurface[] = [
{
name: 'layers-panel',
path: apartmentPath,
steps: (page) => openInEditor(page, 'Scene'),
steps: openScenePanel,
scope: '[data-surface="site-panel"]',
data: apartmentData,
},
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/i18n/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect, test } from 'bun:test'
import { formatFixed, formatList } from './format'

test('English lists keep the plain comma list; other locales use their own separators', () => {
expect(formatList(['Kitchen', 'Hall'], 'en')).toBe('Kitchen, Hall')
expect(formatList(['Kitchen', 'Hall', 'Bath'])).toBe('Kitchen, Hall, Bath')
expect(formatList(['Kitchen', 'Hall', 'Bath'], 'en-GB')).toBe('Kitchen, Hall, Bath')
expect(formatList(['Kitchen', 'Hall', 'Bath'], 'fr')).toBe('Kitchen, Hall et Bath')
expect(formatList(['Kitchen', 'Hall', 'Bath'], 'ja')).toBe('Kitchen、Hall、Bath')
})

test('formatFixed rounds like toFixed, then writes the locale notation without -0', () => {
for (const [value, digits] of [
[2.55, 1],
[1.005, 2],
[0.125, 2],
[12.345, 1],
[1234.5, 1],
] as const) {
expect(formatFixed(value, digits, 'en')).toBe(value.toFixed(digits))
}
expect(formatFixed(2.55, 1, 'fr')).toBe('2,5')
expect(formatFixed(1234.5, 1, 'fr')).toBe('1234,5')
expect(formatFixed(-0.001, 2, 'en')).toBe('0.00')
expect(formatFixed(-0, 1, 'fr')).toBe('0,0')
})
26 changes: 26 additions & 0 deletions packages/core/src/i18n/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,32 @@ export function formatNumber(value: number, options: NumberFormatOptions = {}):
}
}

/**
* `value.toFixed(digits)` in the locale's notation: the same rounding as
* `toFixed` (so "2.55" stays "2.5"), then the locale's decimal separator, no
* grouping, and "-0.00" as "0.00".
*/
export function formatFixed(value: number, digits: number, locale = 'en'): string {
if (!Number.isFinite(value)) return '--'
const rounded = Number(value.toFixed(digits))
return formatNumber(Object.is(rounded, -0) ? 0 : rounded, { min: digits, max: digits, locale })
}

/**
* Names listed in a sentence. English keeps the plain comma list the editor has
* always written ("Kitchen, Hall, Bath"); other locales use their own list
* separators and conjunction (Japanese "Kitchen、Hall、Bath", French
* "Kitchen, Hall et Bath"), which a comma join gets wrong.
*/
export function formatList(items: readonly string[], locale = 'en'): string {
if (/^en\b/i.test(locale)) return items.join(', ')
try {
return new Intl.ListFormat([locale, 'en'], { type: 'conjunction' }).format(items)
} catch {
return items.join(', ')
}
}

export function formatLength(
meters: number,
unit: LengthUnit,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export type { MessageDescriptor } from '@lingui/core'
export {
formatFixed,
formatLength,
formatList,
formatNumber,
type LengthUnit,
levelDisplayNumber,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ export {
staleFinishScripts,
} from './lib/finish-script'
export { floorConstructionLift, liftedManualSlab } from './lib/floor-construction-lift'
export { floorFootprintName } from './lib/floor-footprint-name'
export {
type FloorFootprintNaming,
floorFootprintName,
floorFootprintNaming,
} from './lib/floor-footprint-name'
export {
automaticFloorHeight,
floorFootprintSupportClass,
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/lib/floor-footprint-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from 'bun:test'
import { type AnyNode, SlabNode, ZoneNode } from '../schema'
import { floorFootprintName } from './floor-footprint-name'
import { floorFootprintName, floorFootprintNaming } from './floor-footprint-name'

const square = (x: number, size: number): [number, number][] => [
[x, 0],
Expand Down Expand Up @@ -47,3 +47,21 @@ test('footprints never read as levels: Shared floor, then the largest room, then
const unnamed = { ...annex, zoneIds: [blank.id] }
expect(floorFootprintName({ ...nodes, [blank.id]: blank }, unnamed)).toBe('Floor area')
})

test('the naming parts let a UI word the footprint names in its own language', () => {
const kitchen = room('zone_kitchen', square(0, 6), 'Kitchen')
const hall = room('zone_hall', square(6, 2), 'Hall')
const nine = room('zone_nine', square(20, 3), 'Room 9')
const upper = room('zone_upper', square(30, 3), 'Upper floor')
const house = plate('slab_house', square(0, 8), [kitchen.id, hall.id])
const annex = plate('slab_annex', square(20, 4), [nine.id])
const deck = plate('slab_deck', square(30, 4), [upper.id])
const pad = plate('slab_pad', square(40, 2), [], 'Garden foundation')
const bare = plate('slab_bare', square(50, 2))
const nodes = scene(kitchen, hall, nine, upper, house, annex, deck, pad, bare)
expect(floorFootprintNaming(nodes, house)).toEqual({ kind: 'shared' })
expect(floorFootprintNaming(nodes, annex)).toEqual({ kind: 'room', room: 'Room 9' })
expect(floorFootprintNaming(nodes, deck)).toEqual({ kind: 'named', name: 'Upper floor' })
expect(floorFootprintNaming(nodes, pad)).toEqual({ kind: 'named', name: 'Garden foundation' })
expect(floorFootprintNaming(nodes, bare)).toEqual({ kind: 'area' })
})
37 changes: 33 additions & 4 deletions packages/core/src/lib/floor-footprint-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,36 @@ export function floorFootprintName(
nodes: Readonly<Record<string, AnyNode>>,
plate: SlabNode,
): string {
const naming = floorFootprintNaming(nodes, plate)
switch (naming.kind) {
case 'named':
return naming.name
case 'room':
return `${naming.room} floor`
case 'shared':
return 'Shared floor'
case 'area':
return 'Floor area'
}
}

/**
* How `floorFootprintName` arrives at its name, so a UI can word it in its
* language: `named` is the plate's own name (or a room name that already
* ends in "floor") and shows as is; `room` is "<room> floor".
*/
export type FloorFootprintNaming =
| { kind: 'named'; name: string }
| { kind: 'room'; room: string }
| { kind: 'shared' }
| { kind: 'area' }

export function floorFootprintNaming(
nodes: Readonly<Record<string, AnyNode>>,
plate: SlabNode,
): FloorFootprintNaming {
const own = plate.name?.trim()
if (own && !GENERATED_NAME.test(own)) return own
if (own && !GENERATED_NAME.test(own)) return { kind: 'named', name: own }
const rooms = (plate.zoneIds ?? [])
.flatMap((id) => {
const zone = nodes[id]
Expand All @@ -34,7 +62,7 @@ export function floorFootprintName(
!rooms.some((room) => room.floor?.footprint) &&
isLargestFootprint(nodes, plate)
)
return 'Shared floor'
return { kind: 'shared' }
const key = rooms.find((room) => room.floor?.footprint)?.floor?.footprint
const creatorId = key && floorFootprintCreatorId(key)
const creator = creatorId ? nodes[creatorId] : undefined
Expand All @@ -49,8 +77,9 @@ export function floorFootprintName(
? creator.name?.trim()
: undefined
const named = creatorName || rooms.map((room) => room.name?.trim()).find(Boolean)
if (!named) return rooms.some((room) => room.floor?.footprint) ? 'Shared floor' : 'Floor area'
return /\bfloor$/i.test(named) ? named : `${named} floor`
if (!named)
return rooms.some((room) => room.floor?.footprint) ? { kind: 'shared' } : { kind: 'area' }
return /\bfloor$/i.test(named) ? { kind: 'named', name: named } : { kind: 'room', room: named }
}

function isLargestFootprint(nodes: Readonly<Record<string, AnyNode>>, plate: SlabNode) {
Expand Down
Loading
Loading